T22/T24/T26: uploader, integration tests, and release readiness

T22 -- TelemetryUploader on package:http with MockClient standing in for
MockWebServer, tested against a real in-memory Drift database rather than a fake
DAO. Upload runs on its own timer, injected as a callback so the engine has no
opinion about HTTP and tests need no network. Config on shared_preferences with
a hand-rolled UUID v4. Still no UI for the endpoint, exactly as in the native
app. 7 tests.

T24 -- integration_test/app_test.dart, 4 tests passing on the iOS simulator.
These cover what widget tests cannot: Drift opening against real platform
storage, plugin registration, go_router driving a real Navigator, cold start.

T26 -- RELEASE-IOS.md. Usage strings are specific rather than generic, which is
the leading Guideline 5.1.1 rejection cause; privacy-label answers decided; a
pre-submission list covering the bundle-id switch back to com.rippr, the still
default app icon, a release build, and a demo video for review notes.

T25 written up as REAL-RIDE-CHECKLIST.md but outstanding by nature. Two items
decide real things: force-stopping mid-ride on Android confirms the 111 km
crash-gap bug is fixed, and parking 15 minutes mid-recording on iOS decides
whether geolocator is sufficient or the paid engine is needed.

Two prefer_initializing_formals lints suppressed with a reason: Dart forbids a
named parameter beginning with an underscore, so the suggested fix will not
compile.

178 tests passing, analyze clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-15 22:20:39 -05:00
parent d501528e69
commit a3bcd014c0
10 changed files with 732 additions and 4 deletions

View File

@@ -10,6 +10,7 @@ import 'package:drift/drift.dart' show driftRuntimeOptions;
import 'package:drift_flutter/drift_flutter.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../config/config.dart';
import '../data/database.dart';
import '../data/trip_repository.dart';
import '../domain/models.dart';
@@ -17,6 +18,7 @@ import '../recording/geolocator_location_source.dart';
import '../recording/location_source.dart';
import '../recording/recording_engine.dart';
import '../telemetry/live_telemetry.dart';
import '../telemetry/telemetry_uploader.dart';
/// The Drift database, opened against app-private storage.
///
@@ -39,10 +41,30 @@ final locationSourceProvider = Provider<LocationSource>((ref) {
return source;
});
/// Loaded once at startup; null until then so nothing blocks the first frame.
final configProvider = StateProvider<Config?>((ref) => null);
/// The uploader, or null when no endpoint is configured.
///
/// Parity note: there is still **no UI** for setting the endpoint, exactly as in the
/// native app. It is reachable only through `Config.setUploadEndpoint`.
final uploaderProvider = Provider<TelemetryUploader?>((ref) {
final config = ref.watch(configProvider);
if (config == null || config.uploadEndpoint.trim().isEmpty) return null;
final uploader = TelemetryUploader(
db: ref.watch(databaseProvider),
endpoint: config.uploadEndpoint,
deviceId: config.deviceId,
);
ref.onDispose(uploader.close);
return uploader;
});
final recordingEngineProvider = Provider<RecordingEngine>((ref) {
final engine = RecordingEngine(
repository: ref.watch(tripRepositoryProvider),
locationSource: ref.watch(locationSourceProvider),
uploadPending: () async => ref.read(uploaderProvider)?.uploadPending(),
);
ref.onDispose(engine.dispose);
return engine;
@@ -76,4 +98,6 @@ final recorderStateProvider = StreamProvider<RecorderState>((ref) {
///
/// Kept as a toggle from v2: the map must only ever be live on a visible screen, and no
/// tile is fetched while recording in the background.
final mapEnabledProvider = StateProvider<bool>((ref) => true);
final mapEnabledProvider = StateProvider<bool>(
(ref) => ref.watch(configProvider)?.mapEnabled ?? true,
);

View File

@@ -0,0 +1,61 @@
/// Ported from `com.rippr.Config`.
///
/// Runtime configuration. The upload endpoint is intentionally empty by default —
/// recording must work with no server at all, and uploading is opt-in.
library;
import 'dart:math';
import 'package:shared_preferences/shared_preferences.dart';
const _keyEndpoint = 'upload_endpoint';
const _keyDeviceId = 'device_id';
const _keyMapEnabled = 'map_enabled';
class Config {
Config(this._prefs);
final SharedPreferences _prefs;
static Future<Config> load() async =>
Config(await SharedPreferences.getInstance());
String get uploadEndpoint => _prefs.getString(_keyEndpoint) ?? '';
Future<void> setUploadEndpoint(String url) =>
_prefs.setString(_keyEndpoint, url.trim());
/// Whether trip detail renders a map at all.
///
/// When off the map widget is never created, so no tile is ever requested — a real
/// short-circuit, not a hidden view. The map only ever exists inside a visible screen's
/// lifecycle; the recorder never touches it.
bool get mapEnabled => _prefs.getBool(_keyMapEnabled) ?? true;
Future<void> setMapEnabled(bool enabled) =>
_prefs.setBool(_keyMapEnabled, enabled);
/// Stable per-install id so a server can distinguish riders in a group.
String get deviceId {
final existing = _prefs.getString(_keyDeviceId);
if (existing != null) return existing;
final generated = _randomId();
// Fire-and-forget: the value is returned immediately either way, and a lost write
// only costs a new id next launch.
_prefs.setString(_keyDeviceId, generated);
return generated;
}
/// A UUID v4, without pulling in a package for sixteen bytes.
static String _randomId() {
final rng = Random.secure();
final bytes = List<int>.generate(16, (_) => rng.nextInt(256));
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
String hex(int start, int end) => bytes
.sublist(start, end)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
return '${hex(0, 4)}-${hex(4, 6)}-${hex(6, 8)}-${hex(8, 10)}-${hex(10, 16)}';
}
}

View File

@@ -28,6 +28,10 @@
/// the explicit drains at pause, stop and discard.
library;
// Dart does not permit a named parameter whose name begins with an underscore, so the
// lint's suggested `required this._uploadPending` will not compile here.
// ignore_for_file: prefer_initializing_formals
import 'dart:async';
import 'package:synchronized/synchronized.dart';
@@ -46,6 +50,13 @@ const int flushSize = 25;
/// Coalesce bursts without letting points sit unwritten for long.
const Duration flushInterval = Duration(seconds: 2);
/// How often the backlog is offered to the server.
///
/// Upload runs on its own timer so a slow or dead endpoint can never interrupt
/// recording — the same separation the native service kept between its writer loop and
/// its upload loop.
const Duration uploadInterval = Duration(seconds: 30);
/// What the recorder is doing right now, for the UI.
enum RecorderState { idle, recording, paused }
@@ -55,16 +66,23 @@ class RecordingEngine {
required LocationSource locationSource,
LiveTelemetry? liveTelemetry,
int Function()? clock,
Future<void> Function()? uploadPending,
}) : _repo = repository,
_source = locationSource,
_live = liveTelemetry ?? LiveTelemetry.instance,
_now = clock ?? (() => DateTime.now().millisecondsSinceEpoch);
_now = clock ?? (() => DateTime.now().millisecondsSinceEpoch),
_uploadPending = uploadPending;
final TripRepository _repo;
final LocationSource _source;
final LiveTelemetry _live;
final int Function() _now;
/// Injected rather than constructed here, so the engine has no opinion about HTTP and
/// tests need no network.
final Future<void> Function()? _uploadPending;
Timer? _uploadTimer;
/// Unbounded, exactly like the Kotlin `Channel(UNLIMITED)`. Appending is the only work
/// done on the fix path.
final List<TrackPoint> _pending = [];
@@ -127,10 +145,22 @@ class RecordingEngine {
await _source.start();
_subscription ??= _source.fixes.listen(_onFix);
_flushTimer ??= Timer.periodic(flushInterval, (_) => _flush());
_startUploadLoop();
_setState(RecorderState.recording);
}
void _startUploadLoop() {
final upload = _uploadPending;
if (upload == null || _uploadTimer != null) return;
_uploadTimer = Timer.periodic(uploadInterval, (_) async {
// Swallowed on purpose. Nothing about uploading may disturb recording.
try {
await upload();
} catch (_) {}
});
}
/// Stops consuming GPS but leaves the trip open, so resuming is instant.
Future<void> pause() async {
// Stop the source first, then drain, then close the segment. Points already queued
@@ -320,6 +350,8 @@ class RecordingEngine {
Future<void> _teardown() async {
_flushTimer?.cancel();
_flushTimer = null;
_uploadTimer?.cancel();
_uploadTimer = null;
await _subscription?.cancel();
_subscription = null;
await _writeLock.synchronized(() async {

View File

@@ -0,0 +1,118 @@
/// Ported from `com.rippr.TelemetryUploader`.
///
/// Streams recorded points to a REST endpoint when the network allows it.
///
/// Upload is strictly secondary to recording: every failure path here is swallowed and
/// retried later, and nothing in this class can stop the location pipeline. Points stay
/// in the database with `synced = 0` until the server acknowledges them, so a dead
/// endpoint costs nothing but a growing backlog.
///
/// **Parity note:** as in the native app, this still has no UI. It is reachable only by
/// setting an endpoint in [Config].
library;
import 'dart:async';
import 'package:http/http.dart' as http;
import '../data/database.dart';
import 'live_telemetry.dart';
import 'telemetry.dart';
const int uploadBatchSize = 200;
const int _maxBatchesPerRun = 10;
const Duration _timeout = Duration(seconds: 20);
sealed class UploadResult {
const UploadResult();
}
class UploadDisabled extends UploadResult {
const UploadDisabled();
}
class UploadFailed extends UploadResult {
const UploadFailed();
}
class UploadPartial extends UploadResult {
const UploadPartial(this.uploaded);
final int uploaded;
}
class UploadSuccess extends UploadResult {
const UploadSuccess(this.uploaded);
final int uploaded;
}
class TelemetryUploader {
TelemetryUploader({
required AppDatabase db,
required String endpoint,
required String deviceId,
http.Client? client,
UploadStatus? status,
}) : _db = db,
_endpoint = endpoint,
_deviceId = deviceId,
_client = client ?? http.Client(),
_status = status ?? UploadStatus.instance;
// ignore_for_file: prefer_initializing_formals
// Dart does not permit a named parameter whose name begins with an underscore, so the
// lint's suggested `required this._db` will not compile here.
final AppDatabase _db;
final String _endpoint;
final String _deviceId;
final http.Client _client;
final UploadStatus _status;
Future<UploadResult> uploadPending() async {
if (_endpoint.trim().isEmpty) return const UploadDisabled();
var uploaded = 0;
for (var i = 0; i < _maxBatchesPerRun; i++) {
final batch = await _db.unsyncedPoints(uploadBatchSize);
if (batch.isEmpty) return UploadSuccess(uploaded);
final ok = await _postBatch(batch);
if (!ok) {
return uploaded > 0 ? UploadPartial(uploaded) : const UploadFailed();
}
await _db.markSynced([for (final p in batch) p.id]);
uploaded += batch.length;
// Server was fine but there may be more; a brief pause so a long backlog does not
// saturate a weak mobile link.
if (batch.length == uploadBatchSize) {
await Future<void>.delayed(const Duration(milliseconds: 250));
}
}
return UploadPartial(uploaded);
}
Future<bool> _postBatch(List<dynamic> batch) async {
try {
final response = await _client
.post(
Uri.parse(_endpoint),
headers: const {'Content-Type': 'application/json'},
body: encodeBatch(_deviceId, batch.cast()),
)
.timeout(_timeout);
if (response.statusCode >= 200 && response.statusCode < 300) {
_status.setError(null);
return true;
}
_status.setError('HTTP ${response.statusCode}');
return false;
} catch (e) {
// Any network problem is transient by assumption. The backlog survives.
_status.setError('$e');
return false;
}
}
void close() => _client.close();
}