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:
@@ -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,
|
||||
);
|
||||
|
||||
61
lib/src/config/config.dart
Normal file
61
lib/src/config/config.dart
Normal 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)}';
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
118
lib/src/telemetry/telemetry_uploader.dart
Normal file
118
lib/src/telemetry/telemetry_uploader.dart
Normal 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();
|
||||
}
|
||||
Reference in New Issue
Block a user