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

220 lines
8.1 KiB
Dart

import 'dart:convert';
/// Why a package is hidden, mirroring Kotlin's `HideReason`.
enum HideReason {
alwaysBlocked,
notInAllowlist,
notInNightAllowlist,
unknown;
static HideReason parse(String? raw) => switch (raw) {
'ALWAYS_BLOCKED' => HideReason.alwaysBlocked,
'NOT_IN_ALLOWLIST' => HideReason.notInAllowlist,
'NOT_IN_NIGHT_ALLOWLIST' => HideReason.notInNightAllowlist,
_ => HideReason.unknown,
};
/// Plain-English explanation shown next to a hidden app.
String get explanation => switch (this) {
HideReason.alwaysBlocked => 'always blocked, everywhere',
HideReason.notInAllowlist => 'not on the day allowlist',
HideReason.notInNightAllowlist => 'not on the curfew allowlist',
HideReason.unknown => 'reason not recorded',
};
}
/// One hidden package and why.
class HiddenPackage {
const HiddenPackage(this.package, this.reason);
final String package;
final HideReason reason;
}
/// One enforcement pass, as written by the Kotlin runner.
///
/// Deliberately a *parser*, not a second decision layer. The Kotlin side is
/// what actually enforces; re-deriving the outcome in Dart is how the two
/// drifted before, and a status screen that computes its own answer can
/// confidently report "nothing blocked" while YouTube is in fact hidden.
/// Everything here comes from the record.
///
/// Parsing is tolerant: a field this build does not recognise, or one written
/// by an older build, renders as unknown rather than throwing. A debug screen
/// that crashes on the malformed record is useless precisely when it matters.
class EnforcementRecord {
const EnforcementRecord({
required this.timestamp,
required this.reason,
required this.distanceM,
required this.thresholdM,
required this.insideFence,
required this.homeConfigured,
required this.fixAgeMs,
required this.fixProvider,
required this.fixAccuracyM,
required this.fixOutcome,
required this.curfewActive,
required this.curfewWindow,
required this.hideCount,
required this.showCount,
required this.hidNow,
required this.restoredNow,
required this.hidden,
required this.failure,
});
/// Parses one JSON-lines record.
factory EnforcementRecord.fromJson(Map<String, Object?> json) {
final fix = json['fix'];
final fixMap = fix is Map<String, Object?> ? fix : const <String, Object?>{};
final counts = json['counts'];
final countMap =
counts is Map<String, Object?> ? counts : const <String, Object?>{};
return EnforcementRecord(
timestamp: DateTime.fromMillisecondsSinceEpoch(
(json['ts'] as num?)?.toInt() ?? 0,
),
reason: json['reason'] as String? ?? 'UNKNOWN',
distanceM: (json['distance_m'] as num?)?.toDouble(),
thresholdM: (json['threshold_m'] as num?)?.toDouble(),
insideFence: json['inside_fence'] as bool?,
homeConfigured: json['home_configured'] as bool?,
fixAgeMs: (fixMap['age_ms'] as num?)?.toInt(),
fixProvider: fixMap['provider'] as String?,
fixAccuracyM: (fixMap['accuracy_m'] as num?)?.toDouble(),
fixOutcome: fixMap['outcome'] as String?,
curfewActive: json['curfew_active'] as bool?,
curfewWindow: json['curfew_window'] as String?,
hideCount: (countMap['to_hide'] as num?)?.toInt() ?? 0,
showCount: (countMap['to_show'] as num?)?.toInt() ?? 0,
hidNow: (countMap['hid_delta'] as num?)?.toInt() ?? 0,
restoredNow: (countMap['restored_delta'] as num?)?.toInt() ?? 0,
hidden: _parseHidden(json['hidden']),
failure: json['failure'] as String?,
);
}
/// Parses the raw lines returned by the platform channel, newest first.
///
/// A line that will not parse is skipped rather than failing the batch: one
/// truncated record must not cost the whole history.
static List<EnforcementRecord> parseLines(List<String> lines) {
final records = <EnforcementRecord>[];
for (final line in lines) {
try {
final decoded = jsonDecode(line);
if (decoded is Map<String, Object?>) {
records.add(EnforcementRecord.fromJson(decoded));
}
// Deliberately broad. Catching only FormatException let a
// syntactically valid line with a wrong-typed field (say
// `"reason": 42` from a future schema, or a half-overwritten record)
// throw TypeError instead, and that escaped all the way out of
// _refresh(), leaving the status page stuck busy -- which disables
// the release-device-owner button, whose entire purpose is to be
// reachable when everything else has gone wrong. One unreadable
// record must never cost the escape hatch.
} catch (_) {
continue;
}
}
return records;
}
static List<HiddenPackage> _parseHidden(Object? raw) {
if (raw is! List) return const [];
return raw
.whereType<Map<String, Object?>>()
.map(
(e) => HiddenPackage(
e['pkg'] as String? ?? '?',
HideReason.parse(e['why'] as String?),
),
)
.toList();
}
final DateTime timestamp;
final String reason;
final double? distanceM;
final double? thresholdM;
final bool? insideFence;
final bool? homeConfigured;
final int? fixAgeMs;
final String? fixProvider;
final double? fixAccuracyM;
final String? fixOutcome;
final bool? curfewActive;
final String? curfewWindow;
final int hideCount;
final int showCount;
final int hidNow;
final int restoredNow;
final List<HiddenPackage> hidden;
final String? failure;
/// Whether this pass could not place the phone.
bool get locationUnknown => reason == 'LOCATION_UNKNOWN';
/// Whether home was never provisioned, which enforces everywhere.
///
/// Kept distinct from every other state: with no coordinates the fence
/// reports "inside" and the reason reads AT_HOME, so without this the screen
/// would claim the phone is at home wherever it actually is.
bool get homeMissing => homeConfigured == false;
/// A one-line explanation of [reason], in the user's terms.
String get explanation {
if (failure != null) return 'No decision was made: $failure';
if (homeMissing) {
return 'No home location is set, so everything is enforced everywhere. '
'Use "Set home to current location" while you are at home.';
}
return switch (reason) {
'AWAY' => 'Away from home. Everything is available except the '
'always-blocked apps, which stay hidden wherever you are.',
'AT_HOME' => 'At home. Only allowlisted apps are available.',
'CURFEW' => 'Night curfew. The shorter night allowlist applies.',
'WORKOUT' => 'Workout in progress, so the workout exceptions apply.',
'LOCATION_UNKNOWN' =>
'No usable location fix, so the phone is blocking as if you were at '
'home. Losing GPS must not become a way to switch enforcement off.',
_ => 'Unrecognised state: $reason',
};
}
/// Distance rendered for humans, or why there is none.
String get distanceLabel {
if (homeMissing) return 'no home set';
final metres = distanceM;
if (metres == null) return 'unknown - no location fix';
final threshold = thresholdM;
final distance =
metres >= 1000 ? '${(metres / 1000).toStringAsFixed(1)} km' : '${metres.round()} m';
if (threshold == null) return distance;
return '$distance (fence ${threshold.round()} m)';
}
/// Fix age, provider and accuracy on one line.
String get fixLabel {
final age = fixAgeMs;
if (age == null) return fixOutcome?.toLowerCase() ?? 'no fix';
final seconds = age ~/ 1000;
final agePart = seconds >= 60 ? '${seconds ~/ 60} min old' : '$seconds s old';
final parts = <String>[
agePart,
?fixProvider,
if (fixAccuracyM != null) '±${fixAccuracyM!.round()} m',
if (fixOutcome != null) fixOutcome!.toLowerCase(),
];
return parts.join(' · ');
}
/// Whether the fix was too vague to trust against a 150 m fence.
///
/// Surfaced because it is the tell that FINE location is not actually in
/// effect: coarse location is fuzzed to a ~1-2 km grid.
bool get fixLooksFuzzed => (fixAccuracyM ?? 0) > 500;
}