mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 18:07:06 +01:00
The enforcer failed in both directions: nothing blocked at home, and everything blocked at the office. The comparison was never inverted -- `metres <= threshold` was always right. Three separate causes: Only ACCESS_COARSE_LOCATION was declared. Android 12+ fuzzes coarse to a ~1-2 km grid against a 150 m fence, so a fix taken at home routinely landed outside it, read as AWAY, and restored every app the geofence exists to hide. FINE is now declared and self-granted by the device owner, along with coarse and background, re-asserted every pass so a reinstall cannot silently drop it. There were three states but two behaviours. LOCATION_UNKNOWN applies the identical sweep to AT_HOME -- correct fail-closed design, indistinguishable from outside. And nothing ever requested a fix: the pass read whatever another app happened to cache, so at the office with no navigation running the cache was empty. Passes now acquire a fix inside a location-typed foreground service, bounded at 20 s, and treat anything older than 30 min as no fix. That window is deliberately longer than the 15-minute cadence; a shorter one marks the newest cache stale on every pass of an idle phone and blocks everything. None of it was diagnosable. `adb logcat -d -s FocusOwner:V` came back empty while dumpsys showed 82 alarms had fired, and `run-as` is refused on the release build device owner requires. So the pass now writes a ring-buffered JSONL record read back in-app: reason, distance vs the fence, fix age/provider/accuracy, curfew state, and every hidden app with the rule that hid it. Coordinates are never recorded -- a distance is enough to debug a geofence, and this is a home address. Also fixed along the way: - Hysteresis keyed off `isEnforcing`, which is true under AWAY too because YouTube stays hidden, so the threshold was permanently radius+hysteresis. It now keys off whether the previous pass was inside the fence. - The scheduler's curfew-boundary alarms were documented but never scheduled; both callers invoked `scheduleNext()` bare, so 05:00 could land 15 min late. - Installing over the app cancelled its pending alarms, so `adb install -r` silently stopped enforcement until someone opened the app. MY_PACKAGE_REPLACED now re-arms it, verified on device without launching the app. - A wrong-typed log record threw instead of being skipped, and the exception escaped far enough to leave the page stuck busy -- which disables the release-device-owner button, the one control that must work when everything else has gone wrong. - `lib/enforcement.dart` was a second decision layer with no always-blocked concept, cited by KDoc as pinned by a `PolicyParityTest` that never existed. It would have made the new screen report YouTube visible while it is hidden. Deleted; the UI renders the Kotlin record rather than re-deciding. - Anchoring home accepted a stale or unknown-precision fix. A wrong home offsets the fence everywhere and forever, which is what produced a 766 m reading while standing at home. Policy: Play Store is allowed by day (not during curfew) so infakt can be installed and updated; the sweep is default-deny for third-party packages, so anything installed from Play is hidden on the next at-home pass. Tachiyomi's per-source extension apks are covered by a new prefix allowlist rather than an exact list that goes stale whenever an extension is installed. `export.py` stopped subtracting the allowlist from `blockable_system_packages`. Sweepable is not hideable: that list decides which system packages are eligible for a decision, and `isAllowed` is what protects them. Removing an allowed system package from the sweep freezes it in its last state -- it would have stranded Play hidden permanently the moment it was allowlisted. Verified on the Pixel 6a: FINE granted POLICY_FIXED, `applied AT_HOME` with a 0 m distance from a +/-11 m GPS fix, YouTube/Chrome/Firefox absent from `pm list packages -e`, Play Store, infakt, RunnerUp and all five Tachiyomi packages present. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HjxL9kdAzZmGqQA5tMNrZ8
269 lines
8.1 KiB
Dart
269 lines
8.1 KiB
Dart
import 'package:flutter/material.dart';
|
|
|
|
import 'package:focus_owner/enforcement_record.dart';
|
|
import 'package:focus_owner/theme.dart';
|
|
|
|
/// Renders the most recent enforcement pass, and why it decided what it did.
|
|
///
|
|
/// Exists because the three states were indistinguishable from the phone:
|
|
/// LOCATION_UNKNOWN applies the identical sweep to AT_HOME, so "no GPS" and
|
|
/// "at home" looked the same, and nothing on screen showed the distance the
|
|
/// geofence had actually measured. Every field here answers a question that
|
|
/// previously required reading logcat, which does not survive long enough.
|
|
class EnforcementStatusCard extends StatelessWidget {
|
|
const EnforcementStatusCard({
|
|
required this.record,
|
|
required this.onOpenLog,
|
|
super.key,
|
|
});
|
|
|
|
/// The latest pass, or null when none has been recorded yet.
|
|
final EnforcementRecord? record;
|
|
final VoidCallback onOpenLog;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final latest = record;
|
|
if (latest == null) {
|
|
return const _Card(
|
|
child: Text(
|
|
'No enforcement pass recorded yet.\n'
|
|
'Tap "Run enforcement now" to make one.',
|
|
style: TextStyle(color: kMuted),
|
|
),
|
|
);
|
|
}
|
|
return _Card(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
_headline(latest),
|
|
style: TextStyle(
|
|
color: _reasonColour(latest),
|
|
fontSize: 22,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
Text(
|
|
_ago(latest.timestamp),
|
|
style: const TextStyle(color: kMuted, fontSize: 12),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: kGap / 2),
|
|
Text(
|
|
latest.explanation,
|
|
style: const TextStyle(color: kText, height: 1.35),
|
|
),
|
|
const SizedBox(height: kGap),
|
|
_Field(label: 'Distance from home', value: latest.distanceLabel),
|
|
_Field(
|
|
label: 'Location fix',
|
|
value: latest.fixLabel,
|
|
danger: latest.fixLooksFuzzed,
|
|
),
|
|
if (latest.fixLooksFuzzed)
|
|
const _Note(
|
|
text: 'That accuracy is far wider than the fence, so the '
|
|
'geofence cannot be trusted. Precise location is probably '
|
|
'not in effect.',
|
|
),
|
|
_Field(
|
|
label: 'Night curfew',
|
|
value: switch (latest.curfewActive) {
|
|
true => 'active${_window(latest)}',
|
|
false => 'inactive${_window(latest)}',
|
|
null => 'unknown',
|
|
},
|
|
),
|
|
_Field(
|
|
label: 'Apps',
|
|
value: '${latest.hideCount} hidden, ${latest.showCount} available',
|
|
),
|
|
if (latest.hidNow > 0 || latest.restoredNow > 0)
|
|
_Field(
|
|
label: 'Changed this pass',
|
|
value: 'hid ${latest.hidNow}, restored ${latest.restoredNow}',
|
|
),
|
|
if (_isStale(latest.timestamp))
|
|
const _Note(
|
|
text: 'The last pass was over 30 minutes ago. Enforcement runs '
|
|
'every 15 minutes, so the schedule may have stalled.',
|
|
danger: true,
|
|
),
|
|
if (latest.hidden.isNotEmpty) ...[
|
|
const SizedBox(height: kGap / 2),
|
|
_HiddenList(hidden: latest.hidden),
|
|
],
|
|
const SizedBox(height: kGap / 2),
|
|
Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: TextButton(
|
|
onPressed: onOpenLog,
|
|
child: const Text('Debug log'),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
String _headline(EnforcementRecord r) {
|
|
if (r.failure != null) return 'NO DECISION';
|
|
if (r.homeMissing) return 'NO HOME SET';
|
|
return r.reason.replaceAll('_', ' ');
|
|
}
|
|
|
|
/// Colour-coded so the state that needs attention stands out.
|
|
Color _reasonColour(EnforcementRecord r) {
|
|
if (r.failure != null || r.homeMissing) return kDanger;
|
|
return switch (r.reason) {
|
|
'LOCATION_UNKNOWN' => kDanger,
|
|
'AWAY' => kMuted,
|
|
'CURFEW' => kWarn,
|
|
_ => kAccent,
|
|
};
|
|
}
|
|
|
|
String _window(EnforcementRecord r) =>
|
|
r.curfewWindow == null ? '' : ' (${r.curfewWindow})';
|
|
|
|
static bool _isStale(DateTime at) =>
|
|
DateTime.now().difference(at) > const Duration(minutes: 30);
|
|
|
|
static String _ago(DateTime at) {
|
|
final delta = DateTime.now().difference(at);
|
|
if (delta.inMinutes < 1) return 'just now';
|
|
if (delta.inMinutes < 60) return '${delta.inMinutes} min ago';
|
|
if (delta.inHours < 24) return '${delta.inHours} h ago';
|
|
return '${delta.inDays} d ago';
|
|
}
|
|
}
|
|
|
|
/// The hidden apps, each with the rule that hid it.
|
|
class _HiddenList extends StatelessWidget {
|
|
const _HiddenList({required this.hidden});
|
|
|
|
final List<HiddenPackage> hidden;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Theme(
|
|
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
|
|
child: ExpansionTile(
|
|
tilePadding: EdgeInsets.zero,
|
|
title: Text(
|
|
'Why these ${hidden.length} apps are hidden',
|
|
style: const TextStyle(color: kAccent, fontSize: 14),
|
|
),
|
|
children: [
|
|
for (final entry in hidden)
|
|
Padding(
|
|
padding: const EdgeInsets.only(bottom: 6),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
entry.package,
|
|
style: const TextStyle(color: kText, fontSize: 12),
|
|
),
|
|
),
|
|
const SizedBox(width: kGap / 2),
|
|
Text(
|
|
entry.reason.explanation,
|
|
style: TextStyle(
|
|
color: entry.reason == HideReason.alwaysBlocked
|
|
? kDanger
|
|
: kMuted,
|
|
fontSize: 12,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _Card extends StatelessWidget {
|
|
const _Card({required this.child});
|
|
|
|
final Widget child;
|
|
|
|
// A Material rather than a plain decorated box: the hidden-apps
|
|
// ExpansionTile paints its ink on the nearest Material ancestor, and a
|
|
// DecoratedBox in between swallows the ripple.
|
|
@override
|
|
Widget build(BuildContext context) => Material(
|
|
color: kSurface,
|
|
borderRadius: BorderRadius.circular(8),
|
|
child: Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.all(kGap),
|
|
child: child,
|
|
),
|
|
);
|
|
}
|
|
|
|
class _Field extends StatelessWidget {
|
|
const _Field({required this.label, required this.value, this.danger = false});
|
|
|
|
final String label;
|
|
final String value;
|
|
final bool danger;
|
|
|
|
@override
|
|
Widget build(BuildContext context) => Padding(
|
|
padding: const EdgeInsets.only(bottom: 6),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
SizedBox(
|
|
width: 150,
|
|
child: Text(
|
|
label,
|
|
style: const TextStyle(color: kMuted, fontSize: 13),
|
|
),
|
|
),
|
|
Expanded(
|
|
child: Text(
|
|
value,
|
|
style: TextStyle(
|
|
color: danger ? kDanger : kText,
|
|
fontSize: 13,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
class _Note extends StatelessWidget {
|
|
const _Note({required this.text, this.danger = false});
|
|
|
|
final String text;
|
|
final bool danger;
|
|
|
|
@override
|
|
Widget build(BuildContext context) => Padding(
|
|
padding: const EdgeInsets.only(top: 4, bottom: 6),
|
|
child: Text(
|
|
text,
|
|
style: TextStyle(
|
|
color: danger ? kDanger : kWarn,
|
|
fontSize: 12,
|
|
height: 1.3,
|
|
),
|
|
),
|
|
);
|
|
}
|