diff --git a/docs/superpowers/contracts/focus-owner-location-and-workout-2026-08-09.json b/docs/superpowers/contracts/focus-owner-location-and-workout-2026-08-09.json new file mode 100644 index 00000000..6db48332 --- /dev/null +++ b/docs/superpowers/contracts/focus-owner-location-and-workout-2026-08-09.json @@ -0,0 +1,22 @@ +{ + "title": "Home coordinates out of git, layered workout detection, and removal of the test hook", + "objective": "Give the geofence its anchor without recording where the user lives in the repository, and replace the root-only workout detector with sources an unrooted app can actually read. Success is coordinates living only in app-private storage, a workout signal that fails closed on every path, and the temporary test receiver gone before it can reach a provisioned build.", + "acceptance_criteria": [ + "Home coordinates are pushed from the untracked config_secrets.sh to app-private storage; nothing coordinate-shaped enters git", + "The shipped REDACTED_* placeholder is rejected with a message naming the file to fill in, not written as a nonsense coordinate", + "The provisioned file is mode 600 and unreadable by the shell user", + "The staging copy under /data/local/tmp is removed after the transfer", + "A missing, malformed, or out-of-range coordinate file reads as null rather than throwing, so the enforcement layer treats it as location-unknown and fails closed", + "Every workout source fails closed: a throwing or hanging probe reports no workout", + "The manual signal is time-boxed rather than a plain toggle", + "The combined signal can name which source opened the exception", + "HideTestReceiver is absent from the source, the manifest, and the installed APK" + ], + "out_of_scope": [ + "Device Owner provisioning and the factory reset", + "The Health Connect query implementation - the guarded wrapper exists, the platform call does not yet", + "The foreground service, BOOT_COMPLETED receiver and alarm scheduling", + "com.kuhy.workout_app and org.runnerup as sources: verified unusable and absent respectively" + ], + "verifier": "focus_owner/scripts/push_home_location.sh (both the placeholder-rejection and success paths) then --check; adb run-as ls -la to confirm mode 600; adb shell cat to confirm the shell cannot read it; cd focus_owner && flutter analyze && flutter test; rebuild, reinstall, and dumpsys package | grep -c HideTest" +} diff --git a/docs/superpowers/evidence/focus-owner-location-and-workout-2026-08-09.json b/docs/superpowers/evidence/focus-owner-location-and-workout-2026-08-09.json new file mode 100644 index 00000000..588df885 --- /dev/null +++ b/docs/superpowers/evidence/focus-owner-location-and-workout-2026-08-09.json @@ -0,0 +1,58 @@ +{ + "intent": "Let the geofence work without committing the user's home coordinates, and detect workouts through channels an unrooted app can read.", + "scope": [ + "focus_owner/lib/home_location.dart - new: read/write/clear app-private coordinates", + "focus_owner/scripts/push_home_location.sh - new: provisioning from config_secrets.sh", + "focus_owner/lib/workout_signal.dart - new: manual, guarded and combined sources", + "focus_owner/test/{home_location,workout_signal}_test.dart - new: 24 tests", + "focus_owner/android/.../HideTestReceiver.kt - DELETED, plus its manifest entry", + "Non-goal: no Device Owner provisioning; the app still applies no policy" + ], + "changes": [ + "Coordinates are pushed to app-private storage rather than committed. The asset stays redacted, and the provisioning script greps config_secrets.sh instead of sourcing it - that file holds other secrets and this script has no business evaluating them.", + "Private storage was chosen for its lifetime as much as its permissions: it does not survive an uninstall, so reinstalling forces re-provisioning rather than silently inheriting a stale home.", + "Every failure mode in HomeLocationStore.read returns null instead of throwing. Null flows into the enforcement layer as location-unknown, which already fails closed into enforcing - so a corrupt file cannot become an unlock, and cannot crash the service either.", + "The manual workout signal is time-boxed rather than a toggle: a workout mode left on by accident is indistinguishable from having no exception at all.", + "GuardedWorkoutSignal catches broadly and times out, because nothing about a workout probe failing should be able to stop enforcement running. CombinedWorkoutSignal can name the source that opened the exception, so the UI can explain an unlock rather than presenting it as magic.", + "HideTestReceiver deleted. It had served its purpose - proving setApplicationHidden persistence - and it applied policy from an exported receiver, which has no place in a build that will later hold device owner." + ], + "verification": [ + { + "command": "./focus_owner/scripts/push_home_location.sh (real config_secrets.sh)", + "result": "pass", + "evidence": "Exits non-zero with \"HOME_LAT is 'REDACTED_LAT', not a coordinate - fill in .../config_secrets.sh\". The shipped placeholder cannot silently become a geofence anchor." + }, + { + "command": "./focus_owner/scripts/push_home_location.sh --secrets && --check", + "result": "pass", + "evidence": "'Coordinates written to com.kuhy.focus_owner private storage' then 'OK coordinates provisioned (43 bytes)'. --check reports presence and size only, never the coordinates themselves, since its output lands in scrollback and transcripts." + }, + { + "command": "adb shell run-as ... ls -la; adb shell cat /data/data/.../home_location.json", + "result": "pass", + "evidence": "-rw------- after an explicit chmod 600 (the default umask had left it 666), and the shell user gets 'Permission denied'. The /data/local/tmp staging copy is gone." + }, + { + "command": "cd focus_owner && flutter analyze && flutter test", + "result": "pass", + "evidence": "'No issues found!' and 67 tests passing, 24 new. Covers malformed JSON, a non-object document, missing and non-numeric coordinates (the shape a leaked placeholder would take), out-of-range values, window expiry, and probes that throw or hang." + }, + { + "command": "rebuild + reinstall + dumpsys package | grep -c HideTest + am start", + "result": "pass", + "evidence": "0 references in the installed APK. App launches and stays alive with no FATAL entries. dumpsys device_policy still reports Device Owner Type: -1." + } + ], + "risks": [ + "push_home_location.sh depends on run-as, which only works against a debuggable build. A release APK will need a different channel for the coordinates.", + "The coordinates now exist on the device in plaintext inside app-private storage. That is the same exposure the rooted system already accepted for config_secrets.sh, but it is worth stating: root or a full device backup would reveal them.", + "GuardedWorkoutSignal catches Object deliberately. That is the right call inside an enforcement loop, but it means a genuinely broken Health Connect integration will look identical to 'no workout in progress' - it needs its own logging when that path is implemented.", + "workoutExemptPackages still hardcodes the youtube.com mapping, so a new unblock domain in config.sh will not map to a package by itself.", + "Untouched and still true: DO hiding in user 0 is unproven, and BOOT_COMPLETED does not fire until first unlock." + ], + "rollback": [ + "git revert. All the new files are additive and nothing outside focus_owner imports them.", + "On the device: adb shell run-as com.kuhy.focus_owner rm -f files/home_location.json removes the provisioned coordinates.", + "After rollback validate: flutter analyze && flutter test pass, and push_home_location.sh --check reports no coordinates provisioned." + ] +} diff --git a/focus_owner/android/app/src/main/AndroidManifest.xml b/focus_owner/android/app/src/main/AndroidManifest.xml index 598742f7..28459d3a 100644 --- a/focus_owner/android/app/src/main/AndroidManifest.xml +++ b/focus_owner/android/app/src/main/AndroidManifest.xml @@ -41,18 +41,6 @@ - - - - - - - - --ez hidden true --user - * - * It is exported so `am broadcast` can reach it at all — a non-exported - * receiver silently drops shell broadcasts — but gated behind - * MANAGE_DEVICE_ADMINS, a signature|privileged permission no third-party app - * can hold. In practice that means the shell and the system, and nothing else. - * - * It applies policy that this build otherwise never applies, which is why it is - * confined to a test affordance rather than wired into normal startup. Remove - * it once app-hiding persistence is settled. - */ -class HideTestReceiver : BroadcastReceiver() { - - override fun onReceive(context: Context, intent: Intent) { - val bridge = DevicePolicyBridge(context.applicationContext) - val pkg = intent.getStringExtra("package") - if (pkg == null) { - Log.w(TAG, "HIDE broadcast with no package extra") - return - } - when (intent.action) { - ACTION_HIDE -> { - val hidden = intent.getBooleanExtra("hidden", true) - val applied = bridge.setApplicationHidden(pkg, hidden) - Log.i(TAG, "RESULT hide $pkg -> requested=$hidden applied=$applied") - } - ACTION_QUERY -> { - Log.i(TAG, "RESULT query $pkg -> hidden=${bridge.isApplicationHidden(pkg)}") - } - else -> Log.w(TAG, "unexpected action ${intent.action}") - } - } - - companion object { - private const val TAG = FocusDeviceAdminReceiver.TAG - const val ACTION_HIDE = "com.kuhy.focus_owner.HIDE" - const val ACTION_QUERY = "com.kuhy.focus_owner.QUERY" - } -} diff --git a/focus_owner/lib/home_location.dart b/focus_owner/lib/home_location.dart new file mode 100644 index 00000000..aabcfa15 --- /dev/null +++ b/focus_owner/lib/home_location.dart @@ -0,0 +1,60 @@ +import 'dart:convert'; +import 'dart:io'; + +/// The home coordinates, kept out of the committed policy asset. +/// +/// `assets/policy.json` ships with `latitude`/`longitude` null so that the +/// repository never records where the user lives. The real values are pushed +/// to the app's private storage at provisioning time by +/// `scripts/push_home_location.sh`, which reads them from the untracked +/// `phone_focus_mode/config_secrets.sh`. +/// +/// Private app storage is not world-readable and does not survive an uninstall, +/// which is the right lifetime for this: reinstalling the app should require +/// re-provisioning rather than silently inheriting a stale home. +class HomeLocationStore { + const HomeLocationStore(this.file); + + /// Filename written by the provisioning script. + static const String fileName = 'home_location.json'; + + final File file; + + /// Reads the stored coordinates, or null when none have been provisioned. + /// + /// Returns null rather than throwing on a malformed or unreadable file. The + /// caller treats an absent home the same way it treats an absent GPS fix — + /// as "cannot determine location", which fails closed into enforcing rather + /// than releasing. + Future<({double latitude, double longitude})?> read() async { + if (!file.existsSync()) return null; + try { + final decoded = jsonDecode(await file.readAsString()); + if (decoded is! Map) return null; + final lat = decoded['latitude']; + final lon = decoded['longitude']; + if (lat is! num || lon is! num) return null; + if (lat.abs() > 90 || lon.abs() > 180) return null; + return (latitude: lat.toDouble(), longitude: lon.toDouble()); + } on FormatException { + return null; + } on FileSystemException { + return null; + } + } + + /// Writes coordinates, rejecting values that cannot describe a real place. + Future write(double latitude, double longitude) async { + if (latitude.abs() > 90 || longitude.abs() > 180) { + throw ArgumentError('coordinates out of range: $latitude, $longitude'); + } + await file.writeAsString( + jsonEncode({'latitude': latitude, 'longitude': longitude}), + ); + } + + /// Removes any stored coordinates. + Future clear() async { + if (file.existsSync()) await file.delete(); + } +} diff --git a/focus_owner/lib/workout_signal.dart b/focus_owner/lib/workout_signal.dart new file mode 100644 index 00000000..cb77a90b --- /dev/null +++ b/focus_owner/lib/workout_signal.dart @@ -0,0 +1,117 @@ +import 'dart:async'; + +/// Something that can report whether a workout is currently in progress. +/// +/// The rooted system read StrongLifts' SQLite database directly, which an +/// unrooted app cannot do — another app's private storage is unreachable. The +/// replacement is several weaker signals OR'd together. +/// +/// Every source must fail *closed*: an error or an unknown state reports "no +/// workout", so a broken source cannot silently unlock YouTube. A source that +/// always returns false is useless but harmless; one that always returns true +/// would quietly disable the exception it exists to gate. +abstract class WorkoutSignal { + /// Short name, for logging and for the UI to explain why YouTube is open. + String get name; + + /// Whether this source currently reports a workout in progress. + Future isActive(); +} + +/// A workout the user declared, valid for a fixed window. +/// +/// The only source that works unconditionally, so it is the fallback the +/// others are layered on top of. It is time-boxed rather than a plain toggle +/// because an untimed "workout mode" left on by accident is indistinguishable +/// from having no exception at all. +class ManualWorkoutSignal implements WorkoutSignal { + ManualWorkoutSignal({ + required this.maxDuration, + DateTime Function()? clock, + }) : _clock = clock ?? DateTime.now; + + /// How long a declared workout stays valid before it lapses. + final Duration maxDuration; + + final DateTime Function() _clock; + DateTime? _startedAt; + + @override + String get name => 'manual'; + + /// Declares a workout as starting now. + void start() => _startedAt = _clock(); + + /// Ends a declared workout immediately. + void stop() => _startedAt = null; + + /// How much of the window remains, or null when no workout is declared. + Duration? get remaining { + final started = _startedAt; + if (started == null) return null; + final left = maxDuration - _clock().difference(started); + return left.isNegative ? null : left; + } + + @override + Future isActive() async => remaining != null; +} + +/// A source backed by an asynchronous lookup that may fail. +/// +/// Wraps [probe] so that a thrown exception or a timeout reads as "no +/// workout" rather than propagating into the enforcement loop. Used for the +/// Health Connect path, where the query crosses a process boundary and can +/// fail for reasons that have nothing to do with whether the user is training. +class GuardedWorkoutSignal implements WorkoutSignal { + const GuardedWorkoutSignal({ + required this.name, + required this.probe, + this.timeout = const Duration(seconds: 5), + }); + + @override + final String name; + + /// The underlying lookup. + final Future Function() probe; + + /// How long to wait before treating the lookup as failed. + final Duration timeout; + + @override + Future isActive() async { + try { + return await probe().timeout(timeout); + } on Object { + // Deliberately broad: this sits in the enforcement loop, and no failure + // of a workout probe should be able to stop enforcement running. + return false; + } + } +} + +/// Combines several sources, reporting active if any of them does. +class CombinedWorkoutSignal implements WorkoutSignal { + const CombinedWorkoutSignal(this.sources); + + final List sources; + + @override + String get name => 'combined'; + + /// The name of the first source reporting a workout, or null if none is. + /// + /// Exposed so the UI can say *which* signal opened the exception; "YouTube + /// is available because Health Connect sees a session" is far easier to + /// trust, or to disbelieve, than an unexplained unlock. + Future activeSource() async { + for (final source in sources) { + if (await source.isActive()) return source.name; + } + return null; + } + + @override + Future isActive() async => (await activeSource()) != null; +} diff --git a/focus_owner/scripts/push_home_location.sh b/focus_owner/scripts/push_home_location.sh new file mode 100755 index 00000000..84b0ab9f --- /dev/null +++ b/focus_owner/scripts/push_home_location.sh @@ -0,0 +1,144 @@ +#!/bin/bash + +# ============================================================================ +# Push the home coordinates into focus_owner's private storage. +# +# Why this exists: +# The geofence needs HOME_LAT/HOME_LON, but those say where the user lives, +# so they are deliberately absent from the committed policy asset - it is +# generated with --redact-home. They live in phone_focus_mode/config_secrets.sh, +# which is gitignored, and are pushed to the device separately. +# +# The file lands in the app's private data directory, which is not +# world-readable and does not survive an uninstall. That is the right +# lifetime: reinstalling should require re-provisioning rather than silently +# inheriting a stale home. +# +# Usage: +# ./push_home_location.sh [--serial ] [--secrets ] [--check] +# ============================================================================ + +set -euo pipefail + +SCRIPT_NAME="$(basename "$0")" +readonly SCRIPT_NAME +readonly DEFAULT_SERIAL="23181JEGR08034" +readonly PACKAGE="com.kuhy.focus_owner" +readonly REMOTE_NAME="home_location.json" + +REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +readonly REPO_ROOT +SECRETS="$REPO_ROOT/phone_focus_mode/config_secrets.sh" +SERIAL="$DEFAULT_SERIAL" +CHECK_ONLY=0 + +log() { printf '[home-loc] %s\n' "$1"; } +die() { + printf '[home-loc] ERROR: %s\n' "$1" >&2 + exit 1 +} + +usage() { + cat < adb serial (default: $DEFAULT_SERIAL) + --secrets config_secrets.sh (default: $SECRETS) + --check Report whether coordinates are provisioned; change nothing + -h, --help Show this help +EOF + exit 0 +} + +adb_sh() { adb -s "$SERIAL" shell "$@" 2>/dev/null | tr -d '\r'; } + +require_device() { + command -v adb >/dev/null 2>&1 || die "adb not found; install android-tools" + adb -s "$SERIAL" get-state >/dev/null 2>&1 || + die "device $SERIAL not connected" + adb_sh "pm list packages $PACKAGE" | grep -q . || + die "$PACKAGE is not installed on $SERIAL" +} + +read_coordinate() { + # Echo one exported coordinate from the secrets file. Deliberately a grep + # rather than sourcing the file: it holds other secrets, and this script + # has no business evaluating them. + local name="$1" raw + raw="$(grep -oP "(?<=^export ${name}=)[^ #]*" "$SECRETS" | tail -1 | tr -d '"'"'"'')" + [[ -n "$raw" ]] || die "$name not found in $SECRETS" + # The shipped secrets file contains REDACTED_* placeholders. Catching that + # here gives a useful message instead of writing a nonsense coordinate that + # would silently put "home" wherever the parse happened to land. + [[ "$raw" =~ ^-?[0-9]+(\.[0-9]+)?$ ]] || + die "$name is '$raw', not a coordinate - fill in $SECRETS" + printf '%s' "$raw" +} + +run_check() { + local out + out="$(adb_sh "run-as $PACKAGE cat files/$REMOTE_NAME")" || true + if [[ -z "$out" ]]; then + log "FAIL no coordinates provisioned" + return 1 + fi + # Report presence and shape without echoing the coordinates themselves; + # this output ends up in terminal scrollback and session transcripts. + if grep -q '"latitude"' <<<"$out" && grep -q '"longitude"' <<<"$out"; then + log "OK coordinates provisioned (${#out} bytes)" + return 0 + fi + log "FAIL provisioned file is malformed" + return 1 +} + +main() { + require_device + if [[ $CHECK_ONLY -eq 1 ]]; then + run_check + exit $? + fi + + [[ -f "$SECRETS" ]] || die "secrets file not found: $SECRETS" + local lat lon tmp + lat="$(read_coordinate HOME_LAT)" + lon="$(read_coordinate HOME_LON)" + + tmp="$(mktemp)" + # shellcheck disable=SC2064 # expand $tmp now, not at trap time + trap "rm -f '$tmp'" EXIT + printf '{"latitude": %s, "longitude": %s}\n' "$lat" "$lon" >"$tmp" + + # Staged via /data/local/tmp because adb push cannot write into another + # app's private directory; run-as then moves it in as the app's own uid. + adb -s "$SERIAL" push "$tmp" "/data/local/tmp/$REMOTE_NAME" >/dev/null || + die "adb push failed" + # chmod 600 because the default umask leaves it 666. The directory is + # already private, so this is belt-and-braces rather than the only barrier, + # but a world-writable file holding the geofence anchor is worth avoiding. + adb_sh "run-as $PACKAGE sh -c 'mkdir -p files && cat /data/local/tmp/$REMOTE_NAME > files/$REMOTE_NAME && chmod 600 files/$REMOTE_NAME'" || + die "run-as failed - is this a debuggable build?" + adb_sh "rm -f /data/local/tmp/$REMOTE_NAME" + log "Coordinates written to $PACKAGE private storage" + run_check +} + +while [[ $# -gt 0 ]]; do + case $1 in + --serial) + SERIAL="$2" + shift 2 + ;; + --secrets) + SECRETS="$2" + shift 2 + ;; + --check) + CHECK_ONLY=1 + shift + ;; + -h | --help) usage ;; + *) die "Unknown option: $1" ;; + esac +done + +main "$@" diff --git a/focus_owner/test/home_location_test.dart b/focus_owner/test/home_location_test.dart new file mode 100644 index 00000000..5de48cfb --- /dev/null +++ b/focus_owner/test/home_location_test.dart @@ -0,0 +1,87 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:focus_owner/home_location.dart'; + +void main() { + late Directory dir; + late HomeLocationStore store; + + setUp(() { + dir = Directory.systemTemp.createTempSync('home_loc_test'); + store = HomeLocationStore(File('${dir.path}/${HomeLocationStore.fileName}')); + }); + + tearDown(() => dir.deleteSync(recursive: true)); + + group('read', () { + test('returns null when nothing is provisioned', () async { + expect(await store.read(), isNull); + }); + + test('round-trips written coordinates', () async { + await store.write(52.2297, 21.0122); + final read = await store.read(); + expect(read?.latitude, closeTo(52.2297, 1e-9)); + expect(read?.longitude, closeTo(21.0122, 1e-9)); + }); + + test('returns null on malformed JSON rather than throwing', () async { + // A corrupt file must read as "location unknown", which the enforcement + // layer treats as fail-closed. Throwing here would crash the service. + await store.file.writeAsString('not json at all'); + expect(await store.read(), isNull); + }); + + test('returns null when the document is not an object', () async { + await store.file.writeAsString('[1, 2, 3]'); + expect(await store.read(), isNull); + }); + + test('returns null when a coordinate is missing', () async { + await store.file.writeAsString('{"latitude": 52.2}'); + expect(await store.read(), isNull); + }); + + test('returns null when a coordinate is not a number', () async { + // This is the shape the shipped REDACTED_* placeholder would produce if + // it ever reached the device. + await store.file + .writeAsString('{"latitude": "REDACTED_LAT", "longitude": 21.0}'); + expect(await store.read(), isNull); + }); + + test('returns null for out-of-range coordinates', () async { + await store.file.writeAsString('{"latitude": 91.0, "longitude": 21.0}'); + expect(await store.read(), isNull); + }); + }); + + group('write', () { + test('rejects an impossible latitude', () { + expect(() => store.write(91, 0), throwsArgumentError); + }); + + test('rejects an impossible longitude', () { + expect(() => store.write(0, 181), throwsArgumentError); + }); + + test('accepts the poles and the antimeridian', () async { + await store.write(90, 180); + expect((await store.read())?.latitude, 90); + }); + }); + + group('clear', () { + test('removes provisioned coordinates', () async { + await store.write(52.2297, 21.0122); + await store.clear(); + expect(await store.read(), isNull); + }); + + test('is a no-op when nothing is stored', () async { + await store.clear(); + expect(await store.read(), isNull); + }); + }); +} diff --git a/focus_owner/test/workout_signal_test.dart b/focus_owner/test/workout_signal_test.dart new file mode 100644 index 00000000..06384e7b --- /dev/null +++ b/focus_owner/test/workout_signal_test.dart @@ -0,0 +1,125 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:focus_owner/workout_signal.dart'; + +/// A source with a fixed answer, for exercising the combinator. +class _Fixed implements WorkoutSignal { + const _Fixed(this.name, this.value); + + @override + final String name; + final bool value; + + @override + Future isActive() async => value; +} + +void main() { + group('ManualWorkoutSignal', () { + late DateTime now; + late ManualWorkoutSignal signal; + + setUp(() { + now = DateTime(2026, 8, 9, 12); + signal = ManualWorkoutSignal( + maxDuration: const Duration(minutes: 90), + clock: () => now, + ); + }); + + test('is inactive until a workout is declared', () async { + expect(await signal.isActive(), isFalse); + expect(signal.remaining, isNull); + }); + + test('is active once started', () async { + signal.start(); + expect(await signal.isActive(), isTrue); + expect(signal.remaining, const Duration(minutes: 90)); + }); + + test('lapses when the window expires', () async { + // Time-boxed rather than a plain toggle: a "workout mode" left on by + // accident is indistinguishable from having no exception at all. + signal.start(); + now = now.add(const Duration(minutes: 89)); + expect(await signal.isActive(), isTrue); + now = now.add(const Duration(minutes: 2)); + expect(await signal.isActive(), isFalse); + expect(signal.remaining, isNull); + }); + + test('stop ends it immediately', () async { + signal.start(); + signal.stop(); + expect(await signal.isActive(), isFalse); + }); + + test('restarting extends the window from the new start', () async { + signal.start(); + now = now.add(const Duration(minutes: 80)); + signal.start(); + expect(signal.remaining, const Duration(minutes: 90)); + }); + }); + + group('GuardedWorkoutSignal', () { + test('passes through a successful probe', () async { + const signal = GuardedWorkoutSignal(name: 'hc', probe: _alwaysTrue); + expect(await signal.isActive(), isTrue); + }); + + test('a throwing probe reads as no workout', () async { + // Fail closed: a broken probe must not unlock YouTube. + const signal = GuardedWorkoutSignal(name: 'hc', probe: _alwaysThrows); + expect(await signal.isActive(), isFalse); + }); + + test('a hanging probe times out to no workout', () async { + const signal = GuardedWorkoutSignal( + name: 'hc', + probe: _neverCompletes, + timeout: Duration(milliseconds: 50), + ); + expect(await signal.isActive(), isFalse); + }); + }); + + group('CombinedWorkoutSignal', () { + test('is inactive when every source is', () async { + const combined = CombinedWorkoutSignal([ + _Fixed('a', false), + _Fixed('b', false), + ]); + expect(await combined.isActive(), isFalse); + expect(await combined.activeSource(), isNull); + }); + + test('is active when any source is', () async { + const combined = CombinedWorkoutSignal([ + _Fixed('a', false), + _Fixed('b', true), + ]); + expect(await combined.isActive(), isTrue); + }); + + test('names the source that opened the exception', () async { + // So the UI can explain the unlock rather than presenting it as magic. + const combined = CombinedWorkoutSignal([ + _Fixed('manual', false), + _Fixed('health_connect', true), + ]); + expect(await combined.activeSource(), 'health_connect'); + }); + + test('with no sources it is inactive', () async { + const combined = CombinedWorkoutSignal([]); + expect(await combined.isActive(), isFalse); + }); + }); +} + +Future _alwaysTrue() async => true; +Future _alwaysThrows() async => throw StateError('probe failed'); +Future _neverCompletes() => Completer().future;