mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 19:07:04 +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
220 lines
8.1 KiB
Dart
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;
|
|
}
|