mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 20:27:02 +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
192 lines
6.7 KiB
Dart
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');
|
|
}
|