mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-11 12:07:52 +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
254 lines
8.8 KiB
Dart
254 lines
8.8 KiB
Dart
import 'dart:convert';
|
|
import 'dart:io';
|
|
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:focus_owner/policy.dart';
|
|
|
|
Map<String, Object?> _doc({
|
|
Object? schemaVersion = kSupportedSchemaVersion,
|
|
Object? curfew = const {'start': '23:00', 'end': '05:00'},
|
|
double? latitude,
|
|
double? longitude,
|
|
}) =>
|
|
{
|
|
'schema_version': schemaVersion,
|
|
'home': {
|
|
'latitude': latitude,
|
|
'longitude': longitude,
|
|
'radius_m': 150.0,
|
|
'hysteresis_m': 30.0,
|
|
},
|
|
'curfew': curfew,
|
|
'launcher_package': 'com.qqlabs.minimalistlauncher',
|
|
'allowed_packages': ['com.good', 'pl.mbank'],
|
|
'night_allowed_packages': ['pl.mbank'],
|
|
'never_disable_prefixes': ['com.android.providers'],
|
|
'workout_unblock_domains': ['youtube.com'],
|
|
'browser_packages': <String>[],
|
|
};
|
|
|
|
void main() {
|
|
group('FocusPolicy.fromJson', () {
|
|
test('parses a rendered policy', () {
|
|
final policy = FocusPolicy.fromJson(_doc());
|
|
expect(policy.allowedPackages, {'com.good', 'pl.mbank'});
|
|
expect(policy.nightAllowedPackages, {'pl.mbank'});
|
|
expect(policy.launcherPackage, 'com.qqlabs.minimalistlauncher');
|
|
expect(policy.home.radiusM, 150.0);
|
|
});
|
|
|
|
test('rejects an unknown schema version', () {
|
|
// Accepting a newer schema would mean enforcing a misread policy.
|
|
expect(
|
|
() => FocusPolicy.fromJson(_doc(schemaVersion: 999)),
|
|
throwsA(isA<PolicyFormatException>()),
|
|
);
|
|
});
|
|
|
|
test('rejects a document with no home object', () {
|
|
final doc = _doc()..remove('home');
|
|
expect(
|
|
() => FocusPolicy.fromJson(doc),
|
|
throwsA(isA<PolicyFormatException>()),
|
|
);
|
|
});
|
|
|
|
test('rejects a malformed curfew time', () {
|
|
expect(
|
|
() => FocusPolicy.fromJson(_doc(curfew: {'start': '25:00', 'end': 'x'})),
|
|
throwsA(isA<PolicyFormatException>()),
|
|
);
|
|
});
|
|
|
|
test('accepts an absent curfew', () {
|
|
expect(FocusPolicy.fromJson(_doc(curfew: null)).curfew, isNull);
|
|
});
|
|
});
|
|
|
|
group('HomeLocation', () {
|
|
test('redacted coordinates are reported as absent', () {
|
|
// The committed asset is redacted; enforcement must not treat a missing
|
|
// coordinate as (0, 0), which would put "home" in the Atlantic.
|
|
final policy = FocusPolicy.fromJson(_doc());
|
|
expect(policy.home.hasCoordinates, isFalse);
|
|
expect(policy.home.radiusM, 150.0);
|
|
});
|
|
|
|
test('real coordinates are reported as present', () {
|
|
final policy =
|
|
FocusPolicy.fromJson(_doc(latitude: 52.2297, longitude: 21.0122));
|
|
expect(policy.home.hasCoordinates, isTrue);
|
|
});
|
|
});
|
|
|
|
group('CurfewWindow', () {
|
|
const window = CurfewWindow(startMinutes: 23 * 60, endMinutes: 5 * 60);
|
|
|
|
test('wraps midnight', () {
|
|
expect(window.contains(22 * 60 + 59), isFalse);
|
|
expect(window.contains(23 * 60), isTrue);
|
|
expect(window.contains(0), isTrue);
|
|
expect(window.contains(4 * 60 + 59), isTrue);
|
|
expect(window.contains(5 * 60), isFalse);
|
|
expect(window.contains(12 * 60), isFalse);
|
|
});
|
|
|
|
test('handles a same-day window', () {
|
|
const day = CurfewWindow(startMinutes: 9 * 60, endMinutes: 17 * 60);
|
|
expect(day.contains(8 * 60), isFalse);
|
|
expect(day.contains(9 * 60), isTrue);
|
|
expect(day.contains(17 * 60), isFalse);
|
|
});
|
|
});
|
|
|
|
group('allow decisions', () {
|
|
final policy = FocusPolicy.fromJson(_doc());
|
|
|
|
test('protects system prefixes on whole labels only', () {
|
|
expect(policy.isProtected('com.android.providers'), isTrue);
|
|
expect(policy.isProtected('com.android.providers.telephony'), isTrue);
|
|
expect(policy.isProtected('com.android.providersomething'), isFalse);
|
|
});
|
|
|
|
test('protected packages are allowed even during curfew', () {
|
|
expect(
|
|
policy.isAllowed('com.android.providers', duringCurfew: true),
|
|
isTrue,
|
|
);
|
|
});
|
|
|
|
test('curfew narrows the allowlist', () {
|
|
expect(policy.isAllowed('com.good'), isTrue);
|
|
expect(policy.isAllowed('com.good', duringCurfew: true), isFalse);
|
|
expect(policy.isAllowed('pl.mbank', duringCurfew: true), isTrue);
|
|
});
|
|
|
|
test('unknown packages are blocked', () {
|
|
expect(policy.isAllowed('com.evil'), isFalse);
|
|
});
|
|
|
|
test('the launcher is allowed even when absent from the lists', () {
|
|
// Hiding it leaves no home screen. FocusPolicy.kt has always had this
|
|
// check; Dart had drifted without it.
|
|
expect(policy.allowedPackages, isNot(contains(policy.launcherPackage)));
|
|
expect(policy.isAllowed('com.qqlabs.minimalistlauncher'), isTrue);
|
|
expect(
|
|
policy.isAllowed('com.qqlabs.minimalistlauncher', duringCurfew: true),
|
|
isTrue,
|
|
);
|
|
});
|
|
|
|
test('an asset without prefix keys parses as empty', () {
|
|
// Back-compat: a policy rendered before prefixes existed must still
|
|
// parse, degrading to exact matching rather than failing entirely.
|
|
expect(policy.allowedPrefixes, isEmpty);
|
|
expect(policy.nightAllowedPrefixes, isEmpty);
|
|
});
|
|
|
|
test('allowed prefixes cover a family of packages', () {
|
|
final prefixed = FocusPolicy.fromJson(
|
|
_doc()
|
|
..['allowed_prefixes'] = ['eu.kanade.tachiyomi']
|
|
..['night_allowed_prefixes'] = ['eu.kanade.tachiyomi'],
|
|
);
|
|
expect(prefixed.isAllowed('eu.kanade.tachiyomi.sy'), isTrue);
|
|
expect(
|
|
prefixed.isAllowed('eu.kanade.tachiyomi.extension.all.mangadex'),
|
|
isTrue,
|
|
);
|
|
// Whole labels only, so an unrelated package is not swept in.
|
|
expect(prefixed.isAllowed('eu.kanade.tachiyomisomething'), isFalse);
|
|
// Chosen 2026-08-14: manga stays available during the curfew.
|
|
expect(
|
|
prefixed.isAllowed('eu.kanade.tachiyomi.sy', duringCurfew: true),
|
|
isTrue,
|
|
);
|
|
});
|
|
|
|
test('a day-only prefix is blocked during curfew', () {
|
|
final dayOnly = FocusPolicy.fromJson(
|
|
_doc()..['allowed_prefixes'] = ['eu.kanade.tachiyomi'],
|
|
);
|
|
expect(dayOnly.isAllowed('eu.kanade.tachiyomi.sy'), isTrue);
|
|
expect(
|
|
dayOnly.isAllowed('eu.kanade.tachiyomi.sy', duringCurfew: true),
|
|
isFalse,
|
|
);
|
|
});
|
|
|
|
test('a prefix list of the wrong type is still an error', () {
|
|
// Absent is fine; present-but-wrong is a real mistake.
|
|
expect(
|
|
() => FocusPolicy.fromJson(_doc()..['allowed_prefixes'] = 'nope'),
|
|
throwsA(isA<PolicyFormatException>()),
|
|
);
|
|
});
|
|
|
|
test('isCurfewActive follows the window', () {
|
|
expect(policy.isCurfewActive(23 * 60 + 30), isTrue);
|
|
expect(policy.isCurfewActive(12 * 60), isFalse);
|
|
});
|
|
});
|
|
|
|
group('FocusPolicy.load', () {
|
|
test('reads and parses the bundled asset', () async {
|
|
final bundle = _StubBundle(jsonEncode(_doc()));
|
|
final policy = await FocusPolicy.load(bundle: bundle);
|
|
expect(policy.allowedPackages, contains('pl.mbank'));
|
|
});
|
|
|
|
test('rejects an asset that is not a JSON object', () async {
|
|
final bundle = _StubBundle('[]');
|
|
await expectLater(
|
|
FocusPolicy.load(bundle: bundle),
|
|
throwsA(isA<PolicyFormatException>()),
|
|
);
|
|
});
|
|
});
|
|
|
|
test('the real committed asset parses and is redacted', () async {
|
|
// Guards the generator: a policy.json that this app cannot read, or that
|
|
// leaks the home coordinates into git, is a build error.
|
|
final policy = await FocusPolicy.load(bundle: _RealAssetBundle());
|
|
// Guards against an empty or truncated render, not against a specific
|
|
// size. The threshold was 50 when the allowlist still described the rooted
|
|
// Blackview; the 2026-08-11 rewrite to an explicit list for this phone cut
|
|
// it to ~26, so a high bound would only pin the old policy in place.
|
|
expect(policy.allowedPackages.length, greaterThan(10));
|
|
// The launcher must survive enforcement or the device has no home screen.
|
|
expect(policy.launcherPackage, isNotNull);
|
|
expect(policy.allowedPackages, contains(policy.launcherPackage));
|
|
expect(policy.home.hasCoordinates, isFalse,
|
|
reason: 'committed policy.json must not carry home coordinates');
|
|
expect(policy.curfew, isNotNull);
|
|
});
|
|
}
|
|
|
|
class _StubBundle extends CachingAssetBundle {
|
|
_StubBundle(this.contents);
|
|
|
|
final String contents;
|
|
|
|
@override
|
|
Future<ByteData> load(String key) async =>
|
|
ByteData.sublistView(utf8.encode(contents));
|
|
|
|
@override
|
|
Future<String> loadString(String key, {bool cache = true}) async => contents;
|
|
}
|
|
|
|
/// Reads the asset straight off disk, bypassing the (empty) test rootBundle.
|
|
class _RealAssetBundle extends CachingAssetBundle {
|
|
@override
|
|
Future<ByteData> load(String key) async {
|
|
throw UnimplementedError(key);
|
|
}
|
|
|
|
@override
|
|
Future<String> loadString(String key, {bool cache = true}) async {
|
|
final file = File('assets/policy.json');
|
|
return file.readAsString();
|
|
}
|
|
}
|