testsAndMisc/focus_owner/lib/enforcement_status_card.dart
Krzysztof kuhy Rudnicki 82e206116a
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
Python tests / test (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
Make the geofence tell the truth, and the phone explain itself
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
2026-08-14 13:43:05 +02:00

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,
),
),
);
}