Add the Rippr Flutter port: source, history bundle, and installable APK

The port runs on Android and iOS and is feature-complete; the native Android app
is superseded but kept, since it is still the only version that has recorded real
rides.

rippr-flutter-1.0-debug.apk is package com.rippr.port, deliberately different
from the native com.rippr so both install side by side. Recording the same ride
on both at once is the strongest available check that the port is faithful.

Added INSTALL.md covering both platforms. Android is a one-line adb install; iOS
has no APK equivalent and must be built and signed through Xcode with a free
Apple ID, which gives a 7-day profile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-15 22:37:45 -05:00
parent 64f5dff30b
commit 0bc42b2e5a
98 changed files with 8417 additions and 24 deletions

View File

@@ -0,0 +1,152 @@
import 'dart:convert';
import 'package:drift/drift.dart' show driftRuntimeOptions;
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:rippr/src/data/database.dart';
import 'package:rippr/src/domain/models.dart';
import 'package:rippr/src/telemetry/telemetry_uploader.dart';
/// Ported from `com.rippr.TelemetryUploaderTest`.
///
/// The Kotlin suite used MockWebServer plus an in-memory fake DAO. Here a `MockClient`
/// stands in for the network and a real in-memory Drift database stands in for the DAO —
/// closer to production, and still no device required.
void main() {
late AppDatabase db;
setUp(() {
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
db = AppDatabase(NativeDatabase.memory());
});
tearDown(() async => db.close());
Future<void> seed(int count) async {
final tripId =
await db.insertTrip(const Trip(startedAt: 1000, state: TripState.recording));
final segmentId = await db.insertSegment(tripId, 1000);
await db.insertPoints([
for (var i = 0; i < count; i++)
TrackPoint(
tripId: tripId,
segmentId: segmentId,
timestamp: 1000 + i,
latitude: 51.0,
longitude: -114.0,
speedKmh: 40,
altitudeM: 1000,
),
]);
}
TelemetryUploader uploader({
required http.Client client,
String endpoint = 'https://example.test/ingest',
}) =>
TelemetryUploader(
db: db,
endpoint: endpoint,
deviceId: 'device-abc',
client: client,
);
test('an empty endpoint disables upload entirely', () async {
var called = false;
final client = MockClient((_) async {
called = true;
return http.Response('', 200);
});
final result = await uploader(client: client, endpoint: '').uploadPending();
expect(result, isA<UploadDisabled>());
expect(called, isFalse, reason: 'recording must work with no server at all');
});
test('a successful run marks points synced', () async {
await seed(5);
final client = MockClient((_) async => http.Response('{}', 200));
final result = await uploader(client: client).uploadPending();
expect(result, isA<UploadSuccess>());
expect((result as UploadSuccess).uploaded, 5);
expect(await db.countUnsynced(), 0);
});
test('a rejected batch leaves the backlog intact for retry', () async {
await seed(5);
final client = MockClient((_) async => http.Response('nope', 500));
final result = await uploader(client: client).uploadPending();
expect(result, isA<UploadFailed>());
expect(await db.countUnsynced(), 5,
reason: 'nothing may be marked synced when the server refused it');
});
test('a network error is swallowed and retried later', () async {
await seed(3);
final client = MockClient((_) async => throw http.ClientException('offline'));
final result = await uploader(client: client).uploadPending();
expect(result, isA<UploadFailed>());
expect(await db.countUnsynced(), 3);
});
test('the payload carries trip and segment identity per point', () async {
await seed(2);
String? captured;
final client = MockClient((req) async {
captured = req.body;
return http.Response('{}', 200);
});
await uploader(client: client).uploadPending();
final json = jsonDecode(captured!) as Map<String, dynamic>;
expect(json['device_id'], 'device-abc');
final first = (json['points'] as List).first as Map<String, dynamic>;
// Batches are drawn by id and can straddle a boundary, so identity travels with the
// point rather than the batch.
expect(first.containsKey('trip_id'), isTrue);
expect(first.containsKey('segment_id'), isTrue);
});
test('a backlog larger than one batch is sent across several requests',
() async {
// Two full batches plus a remainder.
await seed(uploadBatchSize * 2 + 7);
var requests = 0;
final client = MockClient((_) async {
requests++;
return http.Response('{}', 200);
});
final result = await uploader(client: client).uploadPending();
expect(requests, 3);
expect((result as UploadSuccess).uploaded, uploadBatchSize * 2 + 7);
expect(await db.countUnsynced(), 0);
});
test('a failure partway through reports what did land', () async {
await seed(uploadBatchSize + 10);
var requests = 0;
final client = MockClient((_) async {
requests++;
return http.Response('{}', requests == 1 ? 200 : 503);
});
final result = await uploader(client: client).uploadPending();
expect(result, isA<UploadPartial>());
expect((result as UploadPartial).uploaded, uploadBatchSize);
expect(await db.countUnsynced(), 10,
reason: 'the first batch landed; the rest must survive for retry');
});
}