mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-11 15:08:28 +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
212 lines
7.2 KiB
Dart
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();
|
|
});
|
|
});
|
|
}
|