diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index eddebec..73914d5 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,25 @@ + + + + + + + + + + + + + **⚠ Parity gap for T27:** geolocator's notification cannot carry *actions*, so the +> native app's Pause/Resume buttons in the notification shade are not reproduced. Tapping +> the notification opens the app. This is the only known capability lost so far. + +### Android + +Permissions mirror the native manifest. **`ACCESS_BACKGROUND_LOCATION` is deliberately +not requested** — recording runs under a foreground service with a visible notification, +which is exactly what `foregroundServiceType="location"` is for. Asking for background +location would trigger the harder "Allow all the time" flow for no benefit. + +### iOS — two settings that matter more than they look + +```dart +pauseLocationUpdatesAutomatically: false // CoreLocation does not reliably restart +activityType: ActivityType.otherNavigation // NOT automotiveNavigation +``` + +`automotiveNavigation` makes CoreLocation **snap fixes to the road network**. For a +navigation app that is a feature; for a recorder whose entire purpose is the path actually +travelled, it silently falsifies the data. `otherNavigation` is the correct choice for a +motorcycle. + +`UIBackgroundModes: [location]` plus `allowBackgroundLocationUpdates` keeps the process — +and therefore the Dart isolate and the writer timer — alive while updates flow. Usage +strings are written specifically rather than generically, since vague text is the leading +cause of Guideline 5.1.1 rejection. + +**None of this is validated.** Whether iOS suspends a stationary app mid-ride is +answerable only by riding. It belongs to T25. + +--- + +## T14 — Process-death resume · **complete** + +Implemented in `RecordingEngine.restoreAfterProcessDeath` and called from a post-frame +callback at startup. Covered by four tests, including the 111 km guard documented under +T11. The remaining acceptance criterion — force-killing mid-ride on real hardware — +belongs with T25. + +--- + +## The app runs + +Built and launched on the iOS simulator. Drift opened against app-private storage, +Riverpod resolved the graph, `restoreAfterProcessDeath` correctly found no active trip, +and the controls were in the right state (START enabled, PAUSE/STOP disabled while idle). + +`lib/main.dart` is a deliberate **harness**, not the real UI — it exists so the pipeline +can be exercised on hardware before Phase 4 builds any screens, because the pipeline is +precisely the part unit tests and emulators cannot validate. T15 replaces it. + +--- + +## ⚠ Disk: the real environmental constraint + +Today's arc: **36 GiB free → 355 MiB** at the worst point, with roughly 7 GiB reclaimed +along the way and still ending at 6.6 GiB. + +A full dual-platform build cycle costs roughly **10 GiB** — Gradle caches, a 2.8 GB NDK, +CocoaPods, DerivedData, `build/`, and the installed simulator app. It is reclaimable, but +it is not optional space. + +Standing footprint: `/opt/homebrew` 17 GB, `~/Library/Android` 7.5 GB, Flutter SDK 3.9 GB, +iOS runtime ~8 GB. + +**Before T24 boots the Android emulator** (fixed 7.4 GiB free required): + +```bash +flutter clean && rm -rf ios/Pods ~/Library/Developer/Xcode/DerivedData/* +``` + +Realistically this machine needs headroom freed outside the dev tooling before Phase 5. + +--- + +## Phase 3 complete + +**145 tests passing, analyze clean, both platforms building and the app running on iOS.** + +Next: **Phase 4, the UI** — six screens, and the first widget tests this project has ever +had. diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 5512aec..91d3527 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,21 +1,15 @@ PODS: - Flutter (1.0.0) - - flutter_foreground_task (0.0.1): - - Flutter DEPENDENCIES: - Flutter (from `Flutter`) - - flutter_foreground_task (from `.symlinks/plugins/flutter_foreground_task/ios`) EXTERNAL SOURCES: Flutter: :path: Flutter - flutter_foreground_task: - :path: ".symlinks/plugins/flutter_foreground_task/ios" SPEC CHECKSUMS: Flutter: 71a624a5bc0c04062bf19101d501e466baf2fb47 - flutter_foreground_task: a159d2c2173b33699ddb3e6c2a067045d7cebb89 PODFILE CHECKSUM: 3ba3a73b1b12adcf321a227a745625fe0888cc82 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 473e8ed..7eccc46 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -205,7 +205,6 @@ 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, - 3501FC32F25039A72E487F54 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -283,23 +282,6 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ - 3501FC32F25039A72E487F54 /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Embed Pods Frameworks"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; - showEnvVarsInLog = 0; - }; 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 07cbfe9..077970f 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -66,5 +66,25 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight + + NSLocationWhenInUseUsageDescription + Rippr records the GPS track of your ride so you can see your route, speed and distance afterwards. Nothing is recorded until you press Start. + NSLocationAlwaysAndWhenInUseUsageDescription + Rippr keeps recording your route while your phone is in your pocket or the screen is off, so a ride you started is captured from beginning to end. Recording stops the moment you press Stop. + + + UIBackgroundModes + + location + diff --git a/lib/main.dart b/lib/main.dart index 244a702..03b5870 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,122 +1,126 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'src/app/providers.dart'; +import 'src/recording/recording_engine.dart'; + +/// Phase 3 entry point. +/// +/// The UI proper arrives in Phase 4 (T15–T21). This is deliberately a thin harness: it +/// exists so the recording pipeline can be exercised on a real device before any screens +/// are built, because the pipeline is the part that cannot be validated by unit tests or +/// by an emulator. void main() { - runApp(const MyApp()); + runApp(const ProviderScope(child: RipprApp())); } -class MyApp extends StatelessWidget { - const MyApp({super.key}); +class RipprApp extends StatelessWidget { + const RipprApp({super.key}); - // This widget is the root of your application. @override Widget build(BuildContext context) { return MaterialApp( - title: 'Flutter Demo', + title: 'Rippr', + debugShowCheckedModeBanner: false, theme: ThemeData( - // This is the theme of your application. - // - // TRY THIS: Try running your application with "flutter run". You'll see - // the application has a purple toolbar. Then, without quitting the app, - // try changing the seedColor in the colorScheme below to Colors.green - // and then invoke "hot reload" (save your changes or press the "hot - // reload" button in a Flutter-supported IDE, or press "r" if you used - // the command line to start the app). - // - // Notice that the counter didn't reset back to zero; the application - // state is not lost during the reload. To reset the state, use hot - // restart instead. - // - // This works for code too, not just values: Most code changes can be - // tested with just a hot reload. - colorScheme: .fromSeed(seedColor: Colors.deepPurple), + colorScheme: ColorScheme.fromSeed( + seedColor: const Color(0xFFFF6B1A), // safety orange, matching the icon + brightness: Brightness.dark, + ), + useMaterial3: true, ), - home: const MyHomePage(title: 'Flutter Demo Home Page'), + home: const _RecorderHarness(), ); } } -class MyHomePage extends StatefulWidget { - const MyHomePage({super.key, required this.title}); - - // This widget is the home page of your application. It is stateful, meaning - // that it has a State object (defined below) that contains fields that affect - // how it looks. - - // This class is the configuration for the state. It holds the values (in this - // case the title) provided by the parent (in this case the App widget) and - // used by the build method of the State. Fields in a Widget subclass are - // always marked "final". - - final String title; +class _RecorderHarness extends ConsumerStatefulWidget { + const _RecorderHarness(); @override - State createState() => _MyHomePageState(); + ConsumerState<_RecorderHarness> createState() => _RecorderHarnessState(); } -class _MyHomePageState extends State { - int _counter = 0; +class _RecorderHarnessState extends ConsumerState<_RecorderHarness> { + String? _error; - void _incrementCounter() { - setState(() { - // This call to setState tells the Flutter framework that something has - // changed in this State, which causes it to rerun the build method below - // so that the display can reflect the updated values. If we changed - // _counter without calling setState(), then the build method would not be - // called again, and so nothing would appear to happen. - _counter++; + @override + void initState() { + super.initState(); + // Re-attach to a ride that was in progress when the process died. Must happen before + // any user interaction, and the database is the only thing that knows. + WidgetsBinding.instance.addPostFrameCallback((_) async { + await ref.read(recordingEngineProvider).restoreAfterProcessDeath(); }); } + Future _run(Future Function() action) async { + try { + await action(); + if (mounted) setState(() => _error = null); + } catch (e) { + if (mounted) setState(() => _error = '$e'); + } + } + @override Widget build(BuildContext context) { - // This method is rerun every time setState is called, for instance as done - // by the _incrementCounter method above. - // - // The Flutter framework has been optimized to make rerunning build methods - // fast, so that you can just rebuild anything that needs updating rather - // than having to individually change instances of widgets. + final engine = ref.watch(recordingEngineProvider); + final state = ref.watch(recorderStateProvider).valueOrNull ?? engine.state; + final trip = ref.watch(activeTripProvider).valueOrNull; + return Scaffold( - appBar: AppBar( - // TRY THIS: Try changing the color here to a specific color (to - // Colors.amber, perhaps?) and trigger a hot reload to see the AppBar - // change color while the other colors stay the same. - backgroundColor: Theme.of(context).colorScheme.inversePrimary, - // Here we take the value from the MyHomePage object that was created by - // the App.build method, and use it to set our appbar title. - title: Text(widget.title), - ), - body: Center( - // Center is a layout widget. It takes a single child and positions it - // in the middle of the parent. + appBar: AppBar(title: const Text('Rippr — recorder harness')), + body: Padding( + padding: const EdgeInsets.all(24), child: Column( - // Column is also a layout widget. It takes a list of children and - // arranges them vertically. By default, it sizes itself to fit its - // children horizontally, and tries to be as tall as its parent. - // - // Column has various properties to control how it sizes itself and - // how it positions its children. Here we use mainAxisAlignment to - // center the children vertically; the main axis here is the vertical - // axis because Columns are vertical (the cross axis would be - // horizontal). - // - // TRY THIS: Invoke "debug painting" (choose the "Toggle Debug Paint" - // action in the IDE, or press "p" in the console), to see the - // wireframe for each widget. - mainAxisAlignment: .center, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - const Text('You have pushed the button this many times:'), - Text( - '$_counter', - style: Theme.of(context).textTheme.headlineMedium, + Text('State: ${state.name}', + style: Theme.of(context).textTheme.headlineSmall), + const SizedBox(height: 12), + if (trip != null) ...[ + Text('Trip ${trip.id} · ${trip.pointCount} points'), + Text('${trip.distanceM.toStringAsFixed(1)} m'), + Text('max ${trip.maxSpeedKmh.toStringAsFixed(1)} km/h'), + ] else + const Text('No active ride'), + if (_error != null) ...[ + const SizedBox(height: 12), + Text(_error!, style: const TextStyle(color: Colors.redAccent)), + ], + const Spacer(), + // 72dp targets, matching the native app's glove-sized controls. + SizedBox( + height: 72, + child: FilledButton( + onPressed: () => _run(engine.start), + child: Text(state == RecorderState.paused ? 'RESUME' : 'START'), + ), + ), + const SizedBox(height: 12), + SizedBox( + height: 72, + child: OutlinedButton( + onPressed: state == RecorderState.recording + ? () => _run(engine.pause) + : null, + child: const Text('PAUSE'), + ), + ), + const SizedBox(height: 12), + SizedBox( + height: 72, + child: OutlinedButton( + onPressed: state == RecorderState.idle + ? null + : () => _run(() async => engine.stop()), + child: const Text('STOP'), + ), ), ], ), ), - floatingActionButton: FloatingActionButton( - onPressed: _incrementCounter, - tooltip: 'Increment', - child: const Icon(Icons.add), - ), ); } } diff --git a/lib/src/app/providers.dart b/lib/src/app/providers.dart new file mode 100644 index 0000000..ca5b4b9 --- /dev/null +++ b/lib/src/app/providers.dart @@ -0,0 +1,67 @@ +/// Composition root. +/// +/// The one place that decides which concrete implementations the app runs with. Tests +/// override these rather than reaching for singletons — the mistake the native app made +/// with `AppDatabase.getDatabase()` and `TripRepository.get()`, which is how an +/// instrumented test once wiped a real device's rides. +library; + +import 'package:drift/drift.dart' show driftRuntimeOptions; +import 'package:drift_flutter/drift_flutter.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../data/database.dart'; +import '../data/trip_repository.dart'; +import '../domain/models.dart'; +import '../recording/geolocator_location_source.dart'; +import '../recording/location_source.dart'; +import '../recording/recording_engine.dart'; + +/// The Drift database, opened against app-private storage. +/// +/// Overridden in tests with an in-memory instance. +final databaseProvider = Provider((ref) { + driftRuntimeOptions.dontWarnAboutMultipleDatabases = true; + final db = AppDatabase(driftDatabase(name: 'rippr_db')); + ref.onDispose(db.close); + return db; +}); + +final tripRepositoryProvider = Provider( + (ref) => TripRepository(ref.watch(databaseProvider)), +); + +/// Overridden in tests with [FakeLocationSource]. +final locationSourceProvider = Provider((ref) { + final source = GeolocatorLocationSource(); + ref.onDispose(source.dispose); + return source; +}); + +final recordingEngineProvider = Provider((ref) { + final engine = RecordingEngine( + repository: ref.watch(tripRepositoryProvider), + locationSource: ref.watch(locationSourceProvider), + ); + ref.onDispose(engine.dispose); + return engine; +}); + +// --- Reactive state --------------------------------------------------------- + +/// The in-progress ride, straight from the database. +/// +/// Recording state is derived, never held as a flag — that is what makes it survive +/// process death. +final activeTripProvider = StreamProvider( + (ref) => ref.watch(tripRepositoryProvider).watchActiveTrip(), +); + +final completedTripsProvider = StreamProvider>( + (ref) => ref.watch(tripRepositoryProvider).watchCompletedTrips(), +); + +final recorderStateProvider = StreamProvider((ref) { + final engine = ref.watch(recordingEngineProvider); + return engine.stateStream; +}); diff --git a/lib/src/recording/geolocator_location_source.dart b/lib/src/recording/geolocator_location_source.dart new file mode 100644 index 0000000..4cbb30a --- /dev/null +++ b/lib/src/recording/geolocator_location_source.dart @@ -0,0 +1,164 @@ +/// The real [LocationSource], backed by `geolocator`. +/// +/// This is the one file in the app that knows Android and iOS keep a process alive by +/// different mechanisms. Everything above it — the engine, the repository, the UI — is +/// platform-agnostic. +/// +/// ## Android: geolocator raises the foreground service +/// +/// `ForegroundNotificationConfig` starts a service with +/// `foregroundServiceType="location"` for as long as the position stream is subscribed, +/// and holds a wake lock. That is everything the native `TrackingService` used a +/// foreground service and a `PARTIAL_WAKE_LOCK` for, which is why +/// `flutter_foreground_task` was dropped — along with its two deprecation warnings (no +/// Swift Package Manager support, and it applies KGP). +/// +/// **Known parity gap:** this notification cannot carry *actions*, so the native app's +/// Pause/Resume buttons in the shade are not reproduced. Tapping the notification opens +/// the app instead. Recorded in `docs/port/PROGRESS.md` for the T27 parity audit. +/// +/// ## iOS: the app stays awake only while location flows +/// +/// There is no foreground service. `allowBackgroundLocationUpdates` plus +/// `UIBackgroundModes: [location]` keeps the process — and therefore the Dart isolate and +/// the writer timer — running while updates are being delivered. +/// +/// The danger is the OS suspending a *stationary* app. Two settings push back: +/// +/// - `pauseLocationUpdatesAutomatically: false` — otherwise CoreLocation decides on its +/// own that the ride has ended, and it does not reliably restart. +/// - `activityType: otherNavigation` — **not** `automotiveNavigation`, which makes +/// CoreLocation snap fixes to the road network. This app records where the rider +/// actually went; a snapped path is a different (and wrong) artefact. +/// +/// Whether this is sufficient can only be settled by a real ride — see T13 in +/// `docs/port/PLAN.md` and the checklist in the native repo's `docs/TESTING.md`. +library; + +import 'dart:async'; +import 'dart:io' show Platform; + +import 'package:geolocator/geolocator.dart'; + +import 'location_source.dart'; + +/// Matches the native `LocationRequest`: high accuracy, 1 s nominal interval, no +/// distance filter (a stationary bike must still produce fixes so elapsed time and the +/// noise floor behave). +const Duration _interval = Duration(seconds: 1); + +class GeolocatorLocationSource implements LocationSource { + GeolocatorLocationSource({ + this.notificationTitle = 'Rippr is recording', + this.notificationText = 'Tracking your ride', + }); + + final String notificationTitle; + final String notificationText; + + final _controller = StreamController.broadcast(); + StreamSubscription? _subscription; + + @override + Stream get fixes => _controller.stream; + + @override + Future start() async { + if (_subscription != null) return; // idempotent + + await _ensurePermission(); + + _subscription = Geolocator.getPositionStream( + locationSettings: _settings(), + ).listen( + (p) => _controller.add(_toFix(p)), + // A transient platform error must not close the stream: the engine treats a closed + // stream as "recording stopped", which is a user-visible event. + onError: (Object _) {}, + cancelOnError: false, + ); + } + + @override + Future stop() async { + await _subscription?.cancel(); + _subscription = null; + } + + @override + Future dispose() async { + await stop(); + await _controller.close(); + } + + LocationSettings _settings() { + if (Platform.isAndroid) { + return AndroidSettings( + accuracy: LocationAccuracy.best, + distanceFilter: 0, + intervalDuration: _interval, + foregroundNotificationConfig: ForegroundNotificationConfig( + notificationTitle: notificationTitle, + notificationText: notificationText, + notificationChannelName: 'Ride recording', + enableWakeLock: true, + setOngoing: true, + ), + ); + } + if (Platform.isIOS) { + return AppleSettings( + accuracy: LocationAccuracy.best, + distanceFilter: 0, + allowBackgroundLocationUpdates: true, + // Never let CoreLocation decide the ride is over. It does not reliably restart. + pauseLocationUpdatesAutomatically: false, + // NOT automotiveNavigation: that snaps fixes to roads, and this app records + // where the rider actually went. + activityType: ActivityType.otherNavigation, + // The blue status bar while recording in the background. Honest, and reviewers + // look for it. + showBackgroundLocationIndicator: true, + ); + } + return const LocationSettings( + accuracy: LocationAccuracy.best, + distanceFilter: 0, + ); + } + + Future _ensurePermission() async { + if (!await Geolocator.isLocationServiceEnabled()) { + throw const LocationException( + LocationFailure.serviceDisabled, + 'Location services are switched off', + ); + } + + var permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied) { + permission = await Geolocator.requestPermission(); + } + + switch (permission) { + case LocationPermission.denied: + throw const LocationException(LocationFailure.permissionDenied); + case LocationPermission.deniedForever: + throw const LocationException(LocationFailure.permissionDeniedForever); + case LocationPermission.whileInUse: + case LocationPermission.always: + case LocationPermission.unableToDetermine: + return; + } + } + + LocationFix _toFix(Position p) => LocationFix( + timestamp: p.timestamp.millisecondsSinceEpoch, + latitude: p.latitude, + longitude: p.longitude, + speedMps: p.speed, + altitudeM: p.altitude, + accuracyM: p.accuracy, + bearingDeg: p.heading, + ); +} diff --git a/pubspec.lock b/pubspec.lock index 275a4d5..e05d8c9 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -291,14 +291,6 @@ packages: description: flutter source: sdk version: "0.0.0" - flutter_foreground_task: - dependency: "direct main" - description: - name: flutter_foreground_task - sha256: "0dad7e6a4ac57aeb0e680e35ec7ae997f1a2b8579fdb85b16dd16e299b634f4f" - url: "https://pub.dev" - source: hosted - version: "10.0.0" flutter_lints: dependency: "direct dev" description: diff --git a/pubspec.yaml b/pubspec.yaml index 7544c64..c480fcb 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -40,7 +40,6 @@ dependencies: drift_flutter: ^0.3.1 path_provider: ^2.1.6 geolocator: ^14.0.3 - flutter_foreground_task: ^10.0.0 permission_handler: ^13.0.1 flutter_map: ^8.3.1 latlong2: ^0.10.1