diff --git a/stronglift_replacement/workout_app/lib/sandbox/sandbox_run_replay.dart b/stronglift_replacement/workout_app/lib/sandbox/sandbox_run_replay.dart index 70b42d8..535f035 100644 --- a/stronglift_replacement/workout_app/lib/sandbox/sandbox_run_replay.dart +++ b/stronglift_replacement/workout_app/lib/sandbox/sandbox_run_replay.dart @@ -34,7 +34,7 @@ double replaySpeedMps(RunSport sport) => switch (sport) { /// Fixes for one lap of a [kLoopLengthM] circle starting at [start], one every /// [kFixStepSeconds], 5 m accuracy. List sandboxLoopFixes(DateTime start, RunSport sport) { - final radius = kLoopLengthM / (2 * math.pi); + const radius = kLoopLengthM / (2 * math.pi); final steps = (kLoopLengthM / replaySpeedMps(sport) / kFixStepSeconds) .round(); const metresPerDegree = 111194.9; diff --git a/stronglift_replacement/workout_app/lib/services/feedback_cues.dart b/stronglift_replacement/workout_app/lib/services/feedback_cues.dart index 4d7cd0a..204e34a 100644 --- a/stronglift_replacement/workout_app/lib/services/feedback_cues.dart +++ b/stronglift_replacement/workout_app/lib/services/feedback_cues.dart @@ -6,6 +6,7 @@ import 'dart:async'; import 'package:audioplayers/audioplayers.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; +import 'package:vibration/vibration.dart'; import 'package:workout_app/sandbox/sandbox_log.dart'; import 'package:workout_app/services/storage_service.dart'; @@ -54,4 +55,25 @@ class FeedbackCues { debugPrint('WorkoutApp: finish chime failed to play ($error).'); }); } + + /// A whole kilometre of a run was just credited: a double buzz and, if + /// allowed, the chime. + /// + /// The vibration plugin rather than [HapticFeedback]: a run happens with the + /// screen off in a pocket, where view haptics may not fire. + Future kilometreReached(int km) async { + SandboxLog.event('cue kilometre', {'km': km, 'sound': soundOn}); + if (await Vibration.hasVibrator()) { + // Android/iOS-only: hasVibrator() is false on the Linux test host. + // coverage:ignore-start + unawaited(Vibration.vibrate(pattern: [0, 150, 100, 150])); + // coverage:ignore-end + } + if (!soundOn) return; + await _player.play(AssetSource('sounds/workout_done.wav')).catchError(( + Object error, + ) { + debugPrint('WorkoutApp: kilometre chime failed to play ($error).'); + }); + } } diff --git a/stronglift_replacement/workout_app/lib/services/foreground_run_client.dart b/stronglift_replacement/workout_app/lib/services/foreground_run_client.dart new file mode 100644 index 0000000..2fc2e33 --- /dev/null +++ b/stronglift_replacement/workout_app/lib/services/foreground_run_client.dart @@ -0,0 +1,54 @@ +/// The run screen's side of the foreground-service conversation. +library; + +/// What the foreground service did when a recording asked for it. +enum RunServiceState { + /// Not started yet (or stopped). + none, + + /// Running with its notification visible. + running, + + /// Running, but the user denied notifications so nothing is on screen. + runningHidden, + + /// The platform refused to start it: GPS may stop with the screen off. + failed, + + /// This platform has no foreground services (Linux desktop, tests). + unsupported, +} + +/// Starting, feeding and stopping the recording's foreground service. +/// +/// The GPS stream itself stays in the UI isolate (the recorder writes to the +/// database, which only that isolate may do); the service holds the process, +/// the wake lock and the notification so the stream keeps flowing with the +/// screen off. An interface so the controller is testable without Android, +/// and so the one file that touches `flutter_foreground_task` stays small. +abstract class ForegroundRunClient { + /// False on platforms with no foreground service. + bool get isSupported; + + /// True when the app may post notifications. + Future hasNotificationPermission(); + + /// Asks for notification permission, returning whether it was granted. + Future requestNotificationPermission(); + + /// Starts the service showing [title] and [text]. [withLocation] adds the + /// `location` service type, which Android 14 only allows once the location + /// permission is held (the sandbox's replayed GPS has none, so it asks for + /// the data-sync type alone). + Future start({ + required bool withLocation, + required String title, + required String text, + }); + + /// Redraws the notification. + Future update({required String title, required String text}); + + /// Stops the service and removes its notification. + Future stop(); +} diff --git a/stronglift_replacement/workout_app/lib/services/foreground_run_client_flutter.dart b/stronglift_replacement/workout_app/lib/services/foreground_run_client_flutter.dart new file mode 100644 index 0000000..056be68 --- /dev/null +++ b/stronglift_replacement/workout_app/lib/services/foreground_run_client_flutter.dart @@ -0,0 +1,116 @@ +/// The real run foreground-service client, and the service's entry point. +library; + +import 'dart:io' show Platform; + +import 'package:flutter_foreground_task/flutter_foreground_task.dart'; +import 'package:workout_app/services/foreground_run_client.dart'; + +/// Channel the ongoing run notification lives on. +/// +/// Low importance (it is on screen for the whole run and must never buzz) and +/// versioned for the same reason as the workout one: channel settings freeze +/// on first creation. The name is a symbol, not a word (textgate): Android +/// shows it in system settings. +const kRunOngoingChannelId = 'run_ongoing_v1'; + +// coverage:ignore-start +/// Entry point the plugin calls in the background isolate. +/// +/// The handler does nothing: the recorder lives in the UI isolate. The +/// service exists to keep that isolate's process alive, to hold the wake lock +/// and to own the notification. +@pragma('vm:entry-point') +void runServiceStartCallback() { + FlutterForegroundTask.setTaskHandler(_RunServiceTaskHandler()); +} + +class _RunServiceTaskHandler extends TaskHandler { + @override + Future onStart(DateTime timestamp, TaskStarter starter) async {} + + @override + void onRepeatEvent(DateTime timestamp) {} + + @override + void onNotificationPressed() => FlutterForegroundTask.launchApp(); + + @override + Future onDestroy(DateTime timestamp, bool isTimeout) async {} +} +// coverage:ignore-end + +/// The real [ForegroundRunClient], driving `flutter_foreground_task`. +class FlutterForegroundRunClient implements ForegroundRunClient { + /// Creates a client. Cheap: nothing happens until [start]. + FlutterForegroundRunClient(); + + @override + bool get isSupported => Platform.isAndroid; + + // Platform-channel passthroughs. `flutter test` runs on Linux, where + // isSupported is false and none of this is reachable; the decisions that + // lead here live in run_controller.dart, covered against a fake. + // coverage:ignore-start + + @override + Future hasNotificationPermission() async => + await FlutterForegroundTask.checkNotificationPermission() == + NotificationPermission.granted; + + @override + Future requestNotificationPermission() async => + await FlutterForegroundTask.requestNotificationPermission() == + NotificationPermission.granted; + + @override + Future start({ + required bool withLocation, + required String title, + required String text, + }) async { + FlutterForegroundTask.init( + androidNotificationOptions: AndroidNotificationOptions( + channelId: kRunOngoingChannelId, + channelName: '\u{1F3C3}', + onlyAlertOnce: true, + ), + iosNotificationOptions: const IOSNotificationOptions(), + foregroundTaskOptions: ForegroundTaskOptions( + eventAction: ForegroundTaskEventAction.nothing(), + // allowWakeLock stays at its default (true): it is why GPS fixes keep + // arriving with the screen off. + ), + ); + if (await FlutterForegroundTask.isRunningService) { + await FlutterForegroundTask.stopService(); + } + final result = await FlutterForegroundTask.startService( + serviceTypes: [ + ForegroundServiceTypes.dataSync, + if (withLocation) ForegroundServiceTypes.location, + ], + notificationTitle: title, + notificationText: text, + callback: runServiceStartCallback, + ); + return result is ServiceRequestSuccess; + } + + @override + Future update({required String title, required String text}) async { + await FlutterForegroundTask.updateService( + notificationTitle: title, + notificationText: text, + ); + } + + @override + Future stop() async { + if (await FlutterForegroundTask.isRunningService) { + await FlutterForegroundTask.stopService(); + } + } + + // coverage:ignore-end +} diff --git a/stronglift_replacement/workout_app/lib/services/run_controller.dart b/stronglift_replacement/workout_app/lib/services/run_controller.dart new file mode 100644 index 0000000..bed2a45 --- /dev/null +++ b/stronglift_replacement/workout_app/lib/services/run_controller.dart @@ -0,0 +1,167 @@ +/// Wires one recording to the phone: permission, foreground service, cues, +/// notification and the save at the end. +library; + +import 'dart:async'; +import 'dart:developer'; + +import 'package:flutter/foundation.dart'; +import 'package:workout_app/models/run_track.dart'; +import 'package:workout_app/sandbox/sandbox.dart'; +import 'package:workout_app/services/feedback_cues.dart'; +import 'package:workout_app/services/foreground_run_client.dart'; +import 'package:workout_app/services/position_source.dart'; +import 'package:workout_app/services/run_format.dart'; +import 'package:workout_app/services/run_recorder.dart'; +import 'package:workout_app/services/run_saver.dart'; + +/// Owns a [RunRecorder] and everything that must happen around it. +/// +/// Order matters at the start: location access first (the recorder asks), then +/// the foreground service (Android 14 refuses a `location` service without +/// the permission), then the first fix. +class RunController extends ChangeNotifier { + /// Creates a controller; it builds its own [recorder] over [sourceFor]. + RunController({ + required PositionSource Function(RunSport sport) sourceFor, + required this._service, + required this._cues, + required this._save, + }) { + recorder = RunRecorder(sourceFor: sourceFor, onKilometre: _onKilometre) + ..addListener(_onRecorderChanged); + } + + /// The state machine; the live map reads its points. + late final RunRecorder recorder; + + final ForegroundRunClient _service; + final FeedbackCues _cues; + final Future Function(RunTrack track) _save; + + RunServiceState _serviceState = RunServiceState.none; + RunSaveResult? _saved; + bool _saving = false; + String _lastCopy = ''; + + /// Whether the foreground service is up (and with a notification). + RunServiceState get serviceState => _serviceState; + + /// What saving the finished run did, once it ran. + RunSaveResult? get saved => _saved; + + /// True while the finished run is being written. + bool get saving => _saving; + + /// True from start to finish: the screen must not be left meanwhile. + bool get active => + recorder.phase == RunPhase.recording || recorder.phase == RunPhase.paused; + + /// Asks for access, starts the service, begins recording. + Future start() async { + await _cues.load(); + await recorder.start(afterAccess: _startService); + } + + /// Pauses crediting. + void pause() => recorder.pause(); + + /// Resumes crediting. + void resume() => recorder.resume(); + + /// Ends the run, stops the service and saves the track. + Future finish() async { + final track = await recorder.finish(); + await _stopService(); + if (track == null) return; + unawaited(_cues.workoutFinished()); + _saving = true; + notifyListeners(); + try { + _saved = await _save(track); + } on Exception catch (error, stack) { + log( + 'RunController: saving ${track.id} failed ($error) -- the run is ' + 'lost unless it is recorded again.', + level: 1000, + error: error, + stackTrace: stack, + ); + _saved = RunSaveResult(stored: false, reason: 'save failed: $error'); + } + _saving = false; + notifyListeners(); + } + + Future _startService() async { + if (!_service.isSupported) { + _serviceState = RunServiceState.unsupported; + return; + } + var shown = await _service.hasNotificationPermission(); + if (!shown) shown = await _service.requestNotificationPermission(); + if (!shown) { + log( + 'RunController: notification permission denied. The run still ' + 'records, but the status-bar distance and time cannot be shown.', + level: 1000, + ); + } + final copy = runNotificationCopy(metres: 0, movingSeconds: 0); + _lastCopy = '${copy.title}|${copy.body}'; + final started = await _service.start( + withLocation: !Sandbox.enabled, + title: copy.title, + text: copy.body, + ); + if (!started) { + _serviceState = RunServiceState.failed; + log( + 'RunController: the foreground service refused to start. GPS fixes ' + 'may stop when the screen turns off -- keep the app open.', + level: 1000, + ); + return; + } + _serviceState = shown + ? RunServiceState.running + : RunServiceState.runningHidden; + } + + Future _stopService() async { + if (_serviceState != RunServiceState.running && + _serviceState != RunServiceState.runningHidden) { + return; + } + await _service.stop(); + _serviceState = RunServiceState.none; + notifyListeners(); + } + + void _onKilometre(int km) => unawaited(_cues.kilometreReached(km)); + + void _onRecorderChanged() { + if (_serviceState == RunServiceState.running) { + final copy = runNotificationCopy( + metres: recorder.distanceM, + movingSeconds: recorder.seconds, + ); + // Only when the words changed: an update per fix would redraw twice a + // second for nothing. + if ('${copy.title}|${copy.body}' != _lastCopy) { + _lastCopy = '${copy.title}|${copy.body}'; + unawaited(_service.update(title: copy.title, text: copy.body)); + } + } + notifyListeners(); + } + + @override + void dispose() { + recorder + ..removeListener(_onRecorderChanged) + ..dispose(); + if (active) unawaited(_service.stop()); + super.dispose(); + } +} diff --git a/stronglift_replacement/workout_app/test/fake_run_service.dart b/stronglift_replacement/workout_app/test/fake_run_service.dart new file mode 100644 index 0000000..df68651 --- /dev/null +++ b/stronglift_replacement/workout_app/test/fake_run_service.dart @@ -0,0 +1,73 @@ +/// A recording fake for the run foreground service. +library; + +import 'package:workout_app/services/foreground_run_client.dart'; + +/// Stands in for `flutter_foreground_task` on the run screen's side. +class FakeForegroundRunClient implements ForegroundRunClient { + /// Creates a fake describing a healthy Android device. + FakeForegroundRunClient({ + this.isSupported = true, + this.permissionGranted = true, + this.grantOnRequest = true, + this.startSucceeds = true, + this.onStart, + }); + + @override + bool isSupported; + + /// Whether notifications are already allowed. + bool permissionGranted; + + /// Whether asking for them succeeds. + bool grantOnRequest; + + /// Whether the service starts. + bool startSucceeds; + + /// Runs inside [start], to record what had happened by then. + void Function()? onStart; + + /// `withLocation` of each start. + final starts = []; + + /// Notification redraws as `title|text`, the first being the start. + final drawn = []; + + /// Calls recorded in order. + final calls = []; + + @override + Future hasNotificationPermission() async { + calls.add('has'); + return permissionGranted; + } + + @override + Future requestNotificationPermission() async { + calls.add('request'); + return permissionGranted = grantOnRequest; + } + + @override + Future start({ + required bool withLocation, + required String title, + required String text, + }) async { + calls.add('start'); + onStart?.call(); + starts.add(withLocation); + drawn.add('$title|$text'); + return startSucceeds; + } + + @override + Future update({required String title, required String text}) async { + drawn.add('$title|$text'); + } + + @override + Future stop() async => calls.add('stop'); +} diff --git a/stronglift_replacement/workout_app/test/services/feedback_cues_km_test.dart b/stronglift_replacement/workout_app/test/services/feedback_cues_km_test.dart new file mode 100644 index 0000000..4e6cba4 --- /dev/null +++ b/stronglift_replacement/workout_app/test/services/feedback_cues_km_test.dart @@ -0,0 +1,29 @@ +// The kilometre cue of a run: a buzz, and the chime unless sound is off. +import 'package:audioplayers/audioplayers.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:workout_app/services/feedback_cues.dart'; + +import '../fake_audio_platform.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + late FakeAudioRecorder audio; + + setUp(() => audio = installFakeAudioPlatform()); + + test('chimes with the finish sound', () async { + await FeedbackCues(AudioPlayer()).kilometreReached(1); + expect(audio.resumedSources.single, contains('workout_done')); + }); + + test('stays silent when sound is off', () async { + await (FeedbackCues(AudioPlayer())..soundOn = false).kilometreReached(2); + expect(audio.resumedSources, isEmpty); + }); + + test('a chime that cannot play is logged, not thrown', () async { + audio.failNextPlay = true; + await FeedbackCues(AudioPlayer()).kilometreReached(3); + expect(audio.resumedSources, isEmpty); + }); +} diff --git a/stronglift_replacement/workout_app/test/services/run_controller_test.dart b/stronglift_replacement/workout_app/test/services/run_controller_test.dart new file mode 100644 index 0000000..919d6db --- /dev/null +++ b/stronglift_replacement/workout_app/test/services/run_controller_test.dart @@ -0,0 +1,244 @@ +import 'package:audioplayers/audioplayers.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:workout_app/models/run_track.dart'; +import 'package:workout_app/sandbox/sandbox.dart'; +import 'package:workout_app/services/feedback_cues.dart'; +import 'package:workout_app/services/foreground_run_client.dart'; +import 'package:workout_app/services/foreground_run_client_flutter.dart'; +import 'package:workout_app/services/position_source.dart'; +import 'package:workout_app/services/run_controller.dart'; +import 'package:workout_app/services/run_recorder.dart'; +import 'package:workout_app/services/run_saver.dart'; +import 'package:workout_app/services/storage_service.dart'; + +import '../fake_audio_platform.dart'; +import '../fake_run_service.dart'; +import '../run_test_fixtures.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + late FakeAudioRecorder audio; + late List saved; + + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + setUp(() async { + StorageService.resetForTesting(); + await StorageService.init(); + audio = installFakeAudioPlatform(); + saved = []; + }); + + tearDown(() => Sandbox.enabled = false); + + RunController build( + ManualPositionSource source, + FakeForegroundRunClient service, { + Future Function(RunTrack)? save, + }) => RunController( + sourceFor: (_) => source, + service: service, + cues: FeedbackCues(AudioPlayer()), + save: + save ?? + (track) async { + saved.add(track); + return const RunSaveResult(stored: true, reason: 'saved'); + }, + ); + + Future feed(ManualPositionSource source, List northM) async { + for (final (i, m) in northM.indexed) { + source.controller.add(fixAt(i * 2, m.toDouble())); + } + await pumpEventQueue(); + } + + test('starts the service after access and before the first fix', () async { + final source = ManualPositionSource(); + var subscribedAtStart = -1; + final service = FakeForegroundRunClient( + onStart: () => subscribedAtStart = source.subscriptions, + ); + final controller = build(source, service); + + await controller.start(); + + expect(subscribedAtStart, 0); + expect(source.subscriptions, 1); + expect(service.starts, [true]); + expect(controller.serviceState, RunServiceState.running); + expect(service.calls, ['has', 'start']); + expect(service.drawn.single, '0.00|0:00 --:--'); + expect(controller.active, isTrue); + controller.dispose(); + }); + + test('the notification follows the run, redrawing only on change', () async { + final source = ManualPositionSource(); + final service = FakeForegroundRunClient(); + final controller = build(source, service); + await controller.start(); + + await feed(source, [0, 6, 12, 18, 24, 30, 36]); + + expect(service.drawn.last, '0.04|0:12 5:33'); + expect(service.drawn.toSet(), hasLength(service.drawn.length)); + controller.dispose(); + }); + + test('a refused permission never reaches the service', () async { + final service = FakeForegroundRunClient(); + final controller = build( + ManualPositionSource(access: LocationAccess.deniedForever), + service, + ); + await controller.start(); + expect(service.calls, isEmpty); + expect(controller.serviceState, RunServiceState.none); + expect(controller.recorder.issue, RunIssue.deniedForever); + expect(controller.active, isFalse); + controller.dispose(); + }); + + test('denied notifications still record but draw nothing', () async { + final source = ManualPositionSource(); + final service = FakeForegroundRunClient( + permissionGranted: false, + grantOnRequest: false, + ); + final controller = build(source, service); + await controller.start(); + await feed(source, [0, 6, 12]); + + expect(service.calls, ['has', 'request', 'start']); + expect(controller.serviceState, RunServiceState.runningHidden); + expect(service.drawn, hasLength(1)); + expect(controller.recorder.livePoints.value, hasLength(3)); + controller.dispose(); + }); + + test('a service that will not start is flagged, the run goes on', () async { + final source = ManualPositionSource(); + final controller = build( + source, + FakeForegroundRunClient(startSucceeds: false), + ); + await controller.start(); + await feed(source, [0, 6]); + expect(controller.serviceState, RunServiceState.failed); + expect(controller.recorder.phase, RunPhase.recording); + expect(controller.recorder.livePoints.value, hasLength(2)); + await controller.finish(); + expect(controller.serviceState, RunServiceState.failed); + controller.dispose(); + }); + + test('a platform without services records without one', () async { + final source = ManualPositionSource(); + final service = FakeForegroundRunClient(isSupported: false); + final controller = build(source, service); + await controller.start(); + await feed(source, [0, 6]); + await controller.finish(); + expect(controller.serviceState, RunServiceState.unsupported); + expect(service.calls, isEmpty); + expect(saved, hasLength(1)); + controller.dispose(); + }); + + test('the sandbox asks for the data-sync service type alone', () async { + Sandbox.enabled = true; + final service = FakeForegroundRunClient(); + final controller = build(ManualPositionSource(), service); + await controller.start(); + expect(service.starts, [false]); + controller.dispose(); + }); + + test('a kilometre buzzes and chimes once', () async { + final source = ManualPositionSource(); + final controller = build(source, FakeForegroundRunClient()); + await controller.start(); + + await feed(source, [for (var i = 0; i <= 170; i++) i * 6]); + await Future.delayed(const Duration(milliseconds: 50)); + + expect(controller.recorder.distanceM, greaterThan(1000)); + expect( + audio.resumedSources.where((s) => s.contains('workout_done')), + hasLength(1), + ); + controller.dispose(); + }); + + test('finish stops the service, saves the track and reports it', () async { + final source = ManualPositionSource(); + final service = FakeForegroundRunClient(); + final controller = build(source, service); + await controller.start(); + await feed(source, [0, 6, 12, 18]); + controller + ..pause() + ..resume(); + + final notified = []; + controller.addListener(() => notified.add(controller.saving)); + await controller.finish(); + + expect(service.calls.last, 'stop'); + expect(controller.serviceState, RunServiceState.none); + expect(saved.single.points, hasLength(4)); + expect(controller.saved!.stored, isTrue); + expect(controller.saving, isFalse); + expect(notified, contains(true)); + expect(controller.active, isFalse); + controller.dispose(); + }); + + test('a run too short to save is not saved', () async { + final source = ManualPositionSource(); + final service = FakeForegroundRunClient(); + final controller = build(source, service); + await controller.start(); + await controller.finish(); + expect(saved, isEmpty); + expect(controller.saved, isNull); + expect(controller.recorder.issue, RunIssue.tooShort); + expect(service.calls.last, 'stop'); + controller.dispose(); + }); + + test('a failing save is reported, not thrown', () async { + final source = ManualPositionSource(); + final controller = build( + source, + FakeForegroundRunClient(), + save: (_) async => throw Exception('disk full'), + ); + await controller.start(); + await feed(source, [0, 6]); + await controller.finish(); + expect(controller.saved!.stored, isFalse); + expect(controller.saved!.reason, contains('disk full')); + controller.dispose(); + }); + + test('leaving mid-run stops the service', () async { + final source = ManualPositionSource(); + final service = FakeForegroundRunClient(); + final controller = build(source, service); + await controller.start(); + controller.dispose(); + expect(service.calls.last, 'stop'); + }); + + test('the real client is unsupported off Android', () { + expect(FlutterForegroundRunClient().isSupported, isFalse); + expect(kRunOngoingChannelId, endsWith('_v1')); + }); +}