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:
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