testsAndMisc/focus_owner/lib/debug_log_page.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

192 lines
6.7 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:focus_owner/enforcement_record.dart';
import 'package:focus_owner/theme.dart';
/// The enforcement history, newest first.
///
/// This screen is the deliverable for "extensive debug log to see when it does
/// not work". It has to live in the app: logcat rotates away within minutes
/// (measured empty while `dumpsys alarm` reported 82 alarms had fired) and
/// `run-as` is refused on the release build device owner requires, so neither
/// adb route can read the history.
class DebugLogPage extends StatelessWidget {
const DebugLogPage({required this.records, super.key});
final List<EnforcementRecord> records;
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: kField,
appBar: AppBar(
backgroundColor: kField,
foregroundColor: kText,
title: const Text('Enforcement log'),
),
body: records.isEmpty
? const Center(
child: Padding(
padding: EdgeInsets.all(kGap * 2),
child: Text(
'No passes recorded yet.\n\n'
'Each enforcement pass appends one entry here, so this fills '
'up on its own every 15 minutes.',
textAlign: TextAlign.center,
style: TextStyle(color: kMuted, height: 1.4),
),
),
)
: ListView.separated(
padding: const EdgeInsets.all(kGap),
itemCount: records.length,
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (context, index) => _Entry(record: records[index]),
),
);
}
}
class _Entry extends StatelessWidget {
const _Entry({required this.record});
final EnforcementRecord record;
@override
Widget build(BuildContext context) {
// Material, not a decorated box: ExpansionTile paints its ink on the
// nearest Material ancestor and a DecoratedBox in between hides it.
return Material(
color: kSurface,
borderRadius: BorderRadius.circular(6),
child: Theme(
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
child: ExpansionTile(
title: Row(
children: [
Text(
_clock(record.timestamp),
style: const TextStyle(color: kMuted, fontSize: 12),
),
const SizedBox(width: kGap / 2),
Expanded(
child: Text(
record.failure != null
? 'NO DECISION'
: record.reason.replaceAll('_', ' '),
style: TextStyle(
color: record.failure != null || record.locationUnknown
? kDanger
: kText,
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
),
Text(
record.distanceLabel,
style: const TextStyle(color: kMuted, fontSize: 12),
),
],
),
children: [
Padding(
padding: const EdgeInsets.fromLTRB(kGap, 0, kGap, kGap),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_line('When', record.timestamp.toString()),
_line('Fix', record.fixLabel),
_line(
'Curfew',
switch (record.curfewActive) {
true => 'active',
false => 'inactive',
null => 'unknown',
},
),
_line('Home set', '${record.homeConfigured ?? "unknown"}'),
_line('Inside fence', '${record.insideFence ?? "unanswerable"}'),
_line(
'Counts',
'${record.hideCount} hidden / ${record.showCount} shown · '
'hid ${record.hidNow}, restored ${record.restoredNow}',
),
if (record.failure != null)
_line('Failure', record.failure!, danger: true),
if (record.hidden.isNotEmpty) ...[
const SizedBox(height: 6),
const Text(
'Hidden',
style: TextStyle(color: kMuted, fontSize: 12),
),
for (final h in record.hidden)
Text(
' ${h.package} — ${h.reason.explanation}',
style: const TextStyle(color: kText, fontSize: 11),
),
],
const SizedBox(height: 6),
// Lets a record leave the phone without adb, which is the
// only other way it could be shared.
TextButton(
onPressed: () => Clipboard.setData(
ClipboardData(text: _asText(record)),
),
child: const Text('Copy'),
),
],
),
),
],
),
),
);
}
static Widget _line(String label, String value, {bool danger = false}) =>
Padding(
padding: const EdgeInsets.only(bottom: 3),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 110,
child: Text(
label,
style: const TextStyle(color: kMuted, fontSize: 12),
),
),
Expanded(
child: Text(
value,
style: TextStyle(
color: danger ? kDanger : kText,
fontSize: 12,
),
),
),
],
),
);
static String _clock(DateTime at) =>
'${at.hour.toString().padLeft(2, '0')}:'
'${at.minute.toString().padLeft(2, '0')}';
static String _asText(EnforcementRecord r) => [
'when: ${r.timestamp}',
'reason: ${r.reason}',
'distance: ${r.distanceLabel}',
'fix: ${r.fixLabel}',
'curfew active: ${r.curfewActive}',
'home configured: ${r.homeConfigured}',
'inside fence: ${r.insideFence}',
'hidden: ${r.hideCount}, shown: ${r.showCount}',
'hid now: ${r.hidNow}, restored now: ${r.restoredNow}',
if (r.failure != null) 'failure: ${r.failure}',
for (final h in r.hidden) ' ${h.package} (${h.reason.explanation})',
].join('\n');
}