testsAndMisc/focus_owner/test/enforcement_status_card_test.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

212 lines
7.2 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:focus_owner/debug_log_page.dart';
import 'package:focus_owner/enforcement_record.dart';
import 'package:focus_owner/enforcement_status_card.dart';
EnforcementRecord _record({
String reason = 'AT_HOME',
Object? distanceM = 42.0,
Object? fixAccuracyM = 12.0,
Object? curfewActive = false,
Object? homeConfigured = true,
Object? failure,
int hidDelta = 0,
int tsOffsetMinutes = 0,
List<Object?> hidden = const [
{'pkg': 'com.google.android.youtube', 'why': 'ALWAYS_BLOCKED'},
],
}) =>
EnforcementRecord.fromJson({
'ts': DateTime.now()
.subtract(Duration(minutes: tsOffsetMinutes))
.millisecondsSinceEpoch,
'reason': reason,
'distance_m': distanceM,
'threshold_m': 180.0,
'inside_fence': true,
'home_configured': homeConfigured,
'fix': {
'age_ms': 45000,
'provider': 'gps',
'accuracy_m': fixAccuracyM,
'outcome': 'ACTIVE_OK',
},
'curfew_active': curfewActive,
'curfew_window': '23:00-05:00',
'counts': {
'to_hide': 3,
'to_show': 58,
'hid_delta': hidDelta,
'restored_delta': 0,
},
'hidden': hidden,
'failure': failure,
});
Future<void> _pump(WidgetTester tester, EnforcementRecord? record) =>
tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SingleChildScrollView(
child: EnforcementStatusCard(record: record, onOpenLog: () {}),
),
),
),
);
void main() {
group('EnforcementStatusCard', () {
testWidgets('says so when no pass has run yet', (tester) async {
await _pump(tester, null);
expect(find.textContaining('No enforcement pass recorded'), findsOneWidget);
});
testWidgets('shows the reason, distance and curfew state', (tester) async {
await _pump(tester, _record());
expect(find.text('AT HOME'), findsOneWidget);
expect(find.text('42 m (fence 180 m)'), findsOneWidget);
expect(find.text('inactive (23:00-05:00)'), findsOneWidget);
expect(find.text('3 hidden, 58 available'), findsOneWidget);
});
testWidgets('LOCATION_UNKNOWN is distinguishable from AT_HOME',
(tester) async {
// The core of the bug being fixed: these two apply the identical sweep,
// so without this the phone gives no way to tell them apart.
await _pump(tester, _record(reason: 'LOCATION_UNKNOWN', distanceM: null));
expect(find.text('LOCATION UNKNOWN'), findsOneWidget);
expect(find.textContaining('as if you were at home'), findsOneWidget);
expect(find.text('unknown - no location fix'), findsOneWidget);
});
testWidgets('a missing home is called out rather than shown as AT_HOME',
(tester) async {
await _pump(tester, _record(homeConfigured: false));
expect(find.text('NO HOME SET'), findsOneWidget);
expect(find.text('no home set'), findsOneWidget);
});
testWidgets('a failed pass reports the failure', (tester) async {
await _pump(tester, _record(failure: 'policy unreadable'));
expect(find.text('NO DECISION'), findsOneWidget);
expect(find.textContaining('policy unreadable'), findsOneWidget);
});
testWidgets('an implausibly vague fix is flagged', (tester) async {
await _pump(tester, _record(fixAccuracyM: 1400.0));
expect(find.textContaining('far wider than the fence'), findsOneWidget);
});
testWidgets('curfew renders as active when it is', (tester) async {
await _pump(tester, _record(reason: 'CURFEW', curfewActive: true));
expect(find.text('active (23:00-05:00)'), findsOneWidget);
});
testWidgets('a stalled schedule is called out', (tester) async {
await _pump(tester, _record(tsOffsetMinutes: 90));
expect(find.textContaining('schedule may have stalled'), findsOneWidget);
});
testWidgets('changes made this pass are shown when non-zero',
(tester) async {
await _pump(tester, _record(hidDelta: 2));
expect(find.text('hid 2, restored 0'), findsOneWidget);
});
testWidgets('each hidden app can be expanded to show why', (tester) async {
await _pump(tester, _record());
await tester.tap(find.textContaining('Why these 1 apps are hidden'));
await tester.pumpAndSettle();
expect(find.text('com.google.android.youtube'), findsOneWidget);
expect(find.text('always blocked, everywhere'), findsOneWidget);
});
testWidgets('no hidden apps means no expander', (tester) async {
await _pump(tester, _record(hidden: const []));
expect(find.textContaining('Why these'), findsNothing);
});
testWidgets('never renders anything that looks like a coordinate',
(tester) async {
// The record carries no latitude/longitude by design; this pins that the
// screen cannot start showing one.
await _pump(tester, _record());
final texts = tester
.widgetList<Text>(find.byType(Text))
.map((t) => t.data ?? '')
.join(' ');
expect(RegExp(r'\d{1,3}\.\d{4,}').hasMatch(texts), isFalse);
});
testWidgets('the log button is reachable', (tester) async {
var opened = false;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SingleChildScrollView(
child: EnforcementStatusCard(
record: _record(),
onOpenLog: () => opened = true,
),
),
),
),
);
await tester.tap(find.text('Debug log'));
expect(opened, isTrue);
});
});
group('DebugLogPage', () {
testWidgets('explains itself when empty', (tester) async {
await tester.pumpWidget(
const MaterialApp(home: DebugLogPage(records: [])),
);
expect(find.textContaining('No passes recorded yet'), findsOneWidget);
});
testWidgets('lists passes newest first and expands one', (tester) async {
await tester.pumpWidget(
MaterialApp(
home: DebugLogPage(
records: [
_record(reason: 'AWAY', distanceM: 10400.0),
_record(),
],
),
),
);
expect(find.text('AWAY'), findsOneWidget);
expect(find.text('10.4 km (fence 180 m)'), findsOneWidget);
await tester.tap(find.text('AWAY'));
await tester.pumpAndSettle();
expect(find.text('Fix'), findsOneWidget);
expect(find.text('Copy'), findsOneWidget);
});
testWidgets('a failed pass is marked in the list', (tester) async {
await tester.pumpWidget(
MaterialApp(
home: DebugLogPage(records: [_record(failure: 'policy unreadable')]),
),
);
expect(find.text('NO DECISION'), findsOneWidget);
await tester.tap(find.text('NO DECISION'));
await tester.pumpAndSettle();
expect(find.text('Failure'), findsOneWidget);
});
testWidgets('copying a record does not throw', (tester) async {
await tester.pumpWidget(
MaterialApp(home: DebugLogPage(records: [_record()])),
);
await tester.tap(find.text('AT HOME'));
await tester.pumpAndSettle();
await tester.tap(find.text('Copy'));
await tester.pumpAndSettle();
});
});
}