mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-11 12:27:50 +01:00
The whole Device Owner design assumed setApplicationHidden persists across a reboot, and that had never been tested -- while both shell primitives were measured to be cleared by one. Settling it looked like it needed a factory reset. It does not: a managed profile is removable and takes its profile-owner grant with it, so the DPM API can be exercised and then erased. pm create-user --profileOf 0 --managed -> user 10 dpm set-profile-owner --user 10 ... ... hide, reboot, re-check ... pm remove-user 10 hidden=true before the reboot, hidden=true after, agreed by dumpsys and by the DPM API itself. Afterwards: Device Owner Type -1, no restrictions, and Chrome in user 0 untouched. Scope limit, stated because it is easy to overclaim: this proves the API's hidden state persists under PROFILE OWNER inside a work profile. It does not prove a DEVICE OWNER can hide a given app in user 0, which is what the design actually needs -- DO there additionally cannot touch the launcher, the default dialer/SMS/IME or packages the system deems critical, and the call can return false silently. That must be tested in the first minute after provisioning, while clearDeviceOwnerApp() is still a cheap exit. On top of that, EnforcementDecision turns policy plus conditions into a hide/show set. It is a pure function, so the whole decision layer is tested without a device, a clock or a location provider. packagesToShow is deliberately the complete allowed set rather than a delta. Failing to hide something is a mild disappointment; failing to UNHIDE strands the user without a dialer or a bank at 09:00 because the phone rebooted during curfew. Applying the full set on every evaluation makes a missed run self-repairing, which matters because BOOT_COMPLETED does not fire until first unlock -- observed directly, the phone sat at RUNNING_LOCKED for minutes after a reboot with no network at all. The workout exception is expressed as packages rather than domains. The rooted system unblocked youtube.com in a hosts file; hiding an app cannot target a domain, so the package set is derived from the configured domains and config.sh stays the source of truth for intent. Signal sources were checked on the device before being built: StrongLifts is granted health.WRITE_EXERCISE and Health Connect is installed, so that path is viable; com.kuhy.workout_app exposes only a MainActivity and Flutter boilerplate; org.runnerup is not installed here at all. Only the two that can actually produce a signal will be wired. HideTestReceiver is temporary and must be deleted before any provisioned build. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AwepyL5Lxs5QKbKkMpxxxL
229 lines
7.7 KiB
Dart
229 lines
7.7 KiB
Dart
import 'dart:convert';
|
|
|
|
import 'package:flutter/services.dart' show AssetBundle, rootBundle;
|
|
|
|
/// Schema version this build understands.
|
|
///
|
|
/// The asset is generated by `python3 -m python_pkg.focus_policy`, which is the
|
|
/// single source of truth. Refusing an unknown version is deliberate: silently
|
|
/// accepting a newer schema would mean enforcing a policy this code has
|
|
/// misread, and a misread allowlist is an allowlist that blocks the dialer.
|
|
const int kSupportedSchemaVersion = 1;
|
|
|
|
/// Raised when the bundled policy cannot be trusted.
|
|
class PolicyFormatException implements Exception {
|
|
const PolicyFormatException(this.message);
|
|
|
|
final String message;
|
|
|
|
@override
|
|
String toString() => 'PolicyFormatException: $message';
|
|
}
|
|
|
|
/// A nightly window that may wrap midnight (23:00 -> 05:00).
|
|
class CurfewWindow {
|
|
const CurfewWindow({required this.startMinutes, required this.endMinutes});
|
|
|
|
/// Parses an `{"start": "23:00", "end": "05:00"}` object.
|
|
factory CurfewWindow.fromJson(Map<String, Object?> json) {
|
|
return CurfewWindow(
|
|
startMinutes: _parseHhMm(json['start'], 'curfew.start'),
|
|
endMinutes: _parseHhMm(json['end'], 'curfew.end'),
|
|
);
|
|
}
|
|
|
|
/// Minutes since midnight.
|
|
final int startMinutes;
|
|
final int endMinutes;
|
|
|
|
/// Whether [minutesSinceMidnight] falls inside the window.
|
|
///
|
|
/// Wrapping windows are the normal case here, so "inside" means at or after
|
|
/// the start *or* before the end, not both.
|
|
bool contains(int minutesSinceMidnight) {
|
|
if (startMinutes <= endMinutes) {
|
|
return minutesSinceMidnight >= startMinutes &&
|
|
minutesSinceMidnight < endMinutes;
|
|
}
|
|
return minutesSinceMidnight >= startMinutes ||
|
|
minutesSinceMidnight < endMinutes;
|
|
}
|
|
}
|
|
|
|
/// The home anchor. Coordinates are null in a redacted policy.
|
|
class HomeLocation {
|
|
const HomeLocation({
|
|
required this.latitude,
|
|
required this.longitude,
|
|
required this.radiusM,
|
|
required this.hysteresisM,
|
|
});
|
|
|
|
/// Parses the `home` object.
|
|
factory HomeLocation.fromJson(Map<String, Object?> json) {
|
|
return HomeLocation(
|
|
latitude: (json['latitude'] as num?)?.toDouble(),
|
|
longitude: (json['longitude'] as num?)?.toDouble(),
|
|
radiusM: _requireNum(json['radius_m'], 'home.radius_m'),
|
|
hysteresisM: _requireNum(json['hysteresis_m'], 'home.hysteresis_m'),
|
|
);
|
|
}
|
|
|
|
final double? latitude;
|
|
final double? longitude;
|
|
final double radiusM;
|
|
final double hysteresisM;
|
|
|
|
/// Whether this policy carries real coordinates.
|
|
///
|
|
/// The committed asset is redacted, so the location gate cannot run against
|
|
/// it. Enforcement must check this rather than treating a missing coordinate
|
|
/// as (0, 0), which would put "home" in the Atlantic.
|
|
bool get hasCoordinates => latitude != null && longitude != null;
|
|
}
|
|
|
|
/// The device-independent focus policy, as generated from `config.sh`.
|
|
class FocusPolicy {
|
|
const FocusPolicy({
|
|
required this.home,
|
|
required this.allowedPackages,
|
|
required this.nightAllowedPackages,
|
|
required this.neverDisablePrefixes,
|
|
required this.workoutUnblockDomains,
|
|
required this.curfew,
|
|
required this.launcherPackage,
|
|
});
|
|
|
|
/// Parses a rendered policy document.
|
|
factory FocusPolicy.fromJson(Map<String, Object?> json) {
|
|
final version = json['schema_version'];
|
|
if (version != kSupportedSchemaVersion) {
|
|
throw PolicyFormatException(
|
|
'unsupported schema_version $version '
|
|
'(this build understands $kSupportedSchemaVersion)',
|
|
);
|
|
}
|
|
final home = json['home'];
|
|
if (home is! Map<String, Object?>) {
|
|
throw const PolicyFormatException('missing "home" object');
|
|
}
|
|
final curfew = json['curfew'];
|
|
return FocusPolicy(
|
|
home: HomeLocation.fromJson(home),
|
|
allowedPackages: _stringSet(json['allowed_packages'], 'allowed_packages'),
|
|
nightAllowedPackages:
|
|
_stringSet(json['night_allowed_packages'], 'night_allowed_packages'),
|
|
neverDisablePrefixes: _stringSet(
|
|
json['never_disable_prefixes'],
|
|
'never_disable_prefixes',
|
|
),
|
|
workoutUnblockDomains: _stringSet(
|
|
json['workout_unblock_domains'],
|
|
'workout_unblock_domains',
|
|
),
|
|
curfew: curfew == null
|
|
? null
|
|
: CurfewWindow.fromJson(curfew as Map<String, Object?>),
|
|
launcherPackage: json['launcher_package'] as String?,
|
|
);
|
|
}
|
|
|
|
/// Loads the policy bundled with the app.
|
|
static Future<FocusPolicy> load({
|
|
AssetBundle? bundle,
|
|
String assetKey = 'assets/policy.json',
|
|
}) async {
|
|
final raw = await (bundle ?? rootBundle).loadString(assetKey);
|
|
final decoded = jsonDecode(raw);
|
|
if (decoded is! Map<String, Object?>) {
|
|
throw const PolicyFormatException('policy asset is not a JSON object');
|
|
}
|
|
return FocusPolicy.fromJson(decoded);
|
|
}
|
|
|
|
final HomeLocation home;
|
|
final Set<String> allowedPackages;
|
|
final Set<String> nightAllowedPackages;
|
|
final Set<String> neverDisablePrefixes;
|
|
final Set<String> workoutUnblockDomains;
|
|
final CurfewWindow? curfew;
|
|
final String? launcherPackage;
|
|
|
|
/// Whether a package must never be disabled.
|
|
///
|
|
/// Prefix-matched on whole labels, so `com.android.providers` covers
|
|
/// `com.android.providers.telephony` but not `com.android.providersomething`.
|
|
bool isProtected(String package) => neverDisablePrefixes.any(
|
|
(prefix) => package == prefix || package.startsWith('$prefix.'),
|
|
);
|
|
|
|
/// Whether a package may run under the given conditions.
|
|
bool isAllowed(String package, {bool duringCurfew = false}) {
|
|
if (isProtected(package)) return true;
|
|
return duringCurfew
|
|
? nightAllowedPackages.contains(package)
|
|
: allowedPackages.contains(package);
|
|
}
|
|
|
|
/// Whether the curfew is in force at [minutesSinceMidnight].
|
|
bool isCurfewActive(int minutesSinceMidnight) =>
|
|
curfew?.contains(minutesSinceMidnight) ?? false;
|
|
|
|
/// Packages released while a workout is in progress.
|
|
///
|
|
/// The rooted system expressed this exception as *domains*, because it
|
|
/// enforced through a hosts file. Hiding apps is a coarser instrument: there
|
|
/// is no way to unblock youtube.com without unhiding the YouTube app, so the
|
|
/// exception is expressed as packages here.
|
|
///
|
|
/// Derived from [workoutUnblockDomains] rather than configured separately,
|
|
/// so that the shell config stays the single source of truth for *intent*
|
|
/// even though the mechanism differs.
|
|
Set<String> get workoutExemptPackages {
|
|
const domainToPackages = <String, List<String>>{
|
|
'youtube.com': [
|
|
'com.google.android.youtube',
|
|
'com.google.android.apps.youtube.music',
|
|
],
|
|
};
|
|
final packages = <String>{};
|
|
for (final entry in domainToPackages.entries) {
|
|
final covered = workoutUnblockDomains.any(
|
|
(domain) => domain == entry.key || domain.endsWith('.${entry.key}'),
|
|
);
|
|
if (covered) packages.addAll(entry.value);
|
|
}
|
|
return packages;
|
|
}
|
|
}
|
|
|
|
Set<String> _stringSet(Object? value, String field) {
|
|
if (value is! List) {
|
|
throw PolicyFormatException('"$field" must be a list');
|
|
}
|
|
return value.map((e) => e! as String).toSet();
|
|
}
|
|
|
|
double _requireNum(Object? value, String field) {
|
|
if (value is! num) {
|
|
throw PolicyFormatException('"$field" must be a number');
|
|
}
|
|
return value.toDouble();
|
|
}
|
|
|
|
int _parseHhMm(Object? value, String field) {
|
|
if (value is! String) {
|
|
throw PolicyFormatException('"$field" must be a "HH:MM" string');
|
|
}
|
|
final parts = value.split(':');
|
|
if (parts.length != 2) {
|
|
throw PolicyFormatException('"$field" must be "HH:MM", got "$value"');
|
|
}
|
|
final hour = int.tryParse(parts[0]);
|
|
final minute = int.tryParse(parts[1]);
|
|
if (hour == null || minute == null || hour > 23 || minute > 59) {
|
|
throw PolicyFormatException('"$field" is not a valid time: "$value"');
|
|
}
|
|
return hour * 60 + minute;
|
|
}
|