From 3ba94777a10c6804948017668688777c857ad686 Mon Sep 17 00:00:00 2001 From: Developer Date: Fri, 9 Oct 2026 18:53:39 -0400 Subject: [PATCH] fix: resolve Android CI breakpoint overflow and address Codex PR #28 findings - Fix RenderFlex overflow at short landscape breakpoint (640x320) by scaling center controls in ResponsiveDashboardLayout and clamping LeanAngleIndicator bounds --- .../emulator-tests/firestore.rules.test.js | 343 +++++++++++++++++- firestore.rules | 14 +- .../widgets/lean_angle_indicator.dart | 6 +- .../widgets/responsive_dashboard_layout.dart | 84 +++-- lib/firebase_options.dart | 49 ++- lib/main.dart | 4 +- lib/services/alerts/road_alert_providers.dart | 5 +- .../alerts/waze_road_alert_provider.dart | 21 +- .../cloud/firebase_account_sync_service.dart | 137 ++++++- test/firebase_options_qa_guard_test.dart | 78 ++++ ...ycle_cross_device_reconciliation_test.dart | 229 ++++++++++++ test/waze_road_alert_provider_test.dart | 29 +- website/components/app-cloud-data.tsx | 51 ++- website/lib/telemetry-export.ts | 46 +++ website/test-support/qa-firestore.rules | 14 +- .../tests/telemetry-replay-export.test.tsx | 16 + 16 files changed, 1039 insertions(+), 87 deletions(-) create mode 100644 test/firebase_options_qa_guard_test.dart create mode 100644 test/motorcycle_cross_device_reconciliation_test.dart diff --git a/firebase/emulator-tests/firestore.rules.test.js b/firebase/emulator-tests/firestore.rules.test.js index 64ee016..f661514 100644 --- a/firebase/emulator-tests/firestore.rules.test.js +++ b/firebase/emulator-tests/firestore.rules.test.js @@ -15,7 +15,12 @@ beforeAll(async () => { testEnv = await initializeTestEnvironment({ projectId, firestore: { - rules: fs.readFileSync(path.resolve(process.cwd(), '../../firestore.rules'), 'utf8'), + rules: fs.readFileSync( + fs.existsSync(path.resolve(process.cwd(), 'firestore.rules')) + ? path.resolve(process.cwd(), 'firestore.rules') + : path.resolve(process.cwd(), '../../firestore.rules'), + 'utf8', + ), }, }); }); @@ -6197,3 +6202,339 @@ describe('Social Architecture Security Rules', () => { }); }); }); + +describe('MotorCloud Rider Swarm Road Alerts Security Rules', () => { + const baseAlert = { + id: 'alert_hazard_101', + category: 'hazard', + latitude: 37.7749, + longitude: -122.4194, + heading: 90.0, + roadName: 'US-101 S', + description: 'Debris on road', + reporterUid: 'alice', + reportedAt: 1000000, + expiresAt: 1003600, + confirmations: 1, + dismissals: 0, + }; + + test('valid road alert creation and reading by authenticated riders', async () => { + const alice = testEnv.authenticatedContext('alice').firestore(); + const bob = testEnv.authenticatedContext('bob').firestore(); + const signedOut = testEnv.unauthenticatedContext().firestore(); + const alertRefAlice = doc(alice, 'road_alerts/alert_hazard_101'); + const alertRefBob = doc(bob, 'road_alerts/alert_hazard_101'); + const alertRefSignedOut = doc(signedOut, 'road_alerts/alert_hazard_101'); + + // Alice creates valid road alert + await assertSucceeds(setDoc(alertRefAlice, baseAlert)); + + // Signed-in user Bob can read alert + await assertSucceeds(getDoc(alertRefBob)); + + // Unauthenticated user cannot read alert + await assertFails(getDoc(alertRefSignedOut)); + }); + + test('road alert creation rejects unauthorized or invalid fields', async () => { + const alice = testEnv.authenticatedContext('alice').firestore(); + const signedOut = testEnv.unauthenticatedContext().firestore(); + + // Unauthenticated create fails + await assertFails(setDoc(doc(signedOut, 'road_alerts/alert_unauth'), { + ...baseAlert, + id: 'alert_unauth', + })); + + // Reporter UID mismatch (spoofing another rider) + await assertFails(setDoc(doc(alice, 'road_alerts/alert_spoof'), { + ...baseAlert, + id: 'alert_spoof', + reporterUid: 'bob', + })); + + // Document ID mismatch + await assertFails(setDoc(doc(alice, 'road_alerts/alert_wrong_id'), { + ...baseAlert, + id: 'alert_other_id', + })); + + // Invalid category + await assertFails(setDoc(doc(alice, 'road_alerts/alert_bad_cat'), { + ...baseAlert, + id: 'alert_bad_cat', + category: 'invalid_category', + })); + + // Non-initial confirmations != 1 + await assertFails(setDoc(doc(alice, 'road_alerts/alert_bad_conf'), { + ...baseAlert, + id: 'alert_bad_conf', + confirmations: 2, + })); + + // Non-initial dismissals != 0 + await assertFails(setDoc(doc(alice, 'road_alerts/alert_bad_dism'), { + ...baseAlert, + id: 'alert_bad_dism', + dismissals: 1, + })); + + // Invalid coordinates (out of range) + await assertFails(setDoc(doc(alice, 'road_alerts/alert_bad_lat'), { + ...baseAlert, + id: 'alert_bad_lat', + latitude: 95.0, + })); + + // expiresAt not after reportedAt + await assertFails(setDoc(doc(alice, 'road_alerts/alert_bad_exp'), { + ...baseAlert, + id: 'alert_bad_exp', + expiresAt: 1000000, + })); + + // Unknown/unauthorized keys + await assertFails(setDoc(doc(alice, 'road_alerts/alert_extra_key'), { + ...baseAlert, + id: 'alert_extra_key', + maliciousField: true, + })); + + // Description exceeding 256 chars + await assertFails(setDoc(doc(alice, 'road_alerts/alert_long_desc'), { + ...baseAlert, + id: 'alert_long_desc', + description: 'x'.repeat(257), + })); + }); + + test('valid voting: single confirmation increment by 1 with preserved expiresAt', async () => { + const alice = testEnv.authenticatedContext('alice').firestore(); + const bob = testEnv.authenticatedContext('bob').firestore(); + const alertRefAlice = doc(alice, 'road_alerts/alert_hazard_101'); + const alertRefBob = doc(bob, 'road_alerts/alert_hazard_101'); + + await assertSucceeds(setDoc(alertRefAlice, baseAlert)); + + // Bob confirms alert (+1 confirmation) + await assertSucceeds(updateDoc(alertRefBob, { + confirmations: 2, + })); + + // Confirmations is now 2. Another user Charlie confirms (+1 confirmation) + const charlie = testEnv.authenticatedContext('charlie').firestore(); + const alertRefCharlie = doc(charlie, 'road_alerts/alert_hazard_101'); + await assertSucceeds(updateDoc(alertRefCharlie, { + confirmations: 3, + })); + }); + + test('valid voting: single dismissal increment by 1 without expiration change', async () => { + const alice = testEnv.authenticatedContext('alice').firestore(); + const bob = testEnv.authenticatedContext('bob').firestore(); + const alertRefAlice = doc(alice, 'road_alerts/alert_hazard_101'); + const alertRefBob = doc(bob, 'road_alerts/alert_hazard_101'); + + await assertSucceeds(setDoc(alertRefAlice, baseAlert)); + + // Bob dismisses alert (+1 dismissal, expiresAt unchanged) + await assertSucceeds(updateDoc(alertRefBob, { + dismissals: 1, + })); + }); + + test('valid voting: single dismissal increment by 1 with shortened expiration (early clear)', async () => { + const alice = testEnv.authenticatedContext('alice').firestore(); + const bob = testEnv.authenticatedContext('bob').firestore(); + const alertRefAlice = doc(alice, 'road_alerts/alert_hazard_101'); + const alertRefBob = doc(bob, 'road_alerts/alert_hazard_101'); + + await assertSucceeds(setDoc(alertRefAlice, baseAlert)); + + // Bob reports clear, incrementing dismissals to 1 and setting expiresAt earlier (e.g. now = 1001500) + await assertSucceeds(updateDoc(alertRefBob, { + dismissals: 1, + expiresAt: 1001500, + })); + }); + + test('valid voting: single dismissal increment setting expiresAt exactly to reportedAt', async () => { + const alice = testEnv.authenticatedContext('alice').firestore(); + const bob = testEnv.authenticatedContext('bob').firestore(); + const alertRefAlice = doc(alice, 'road_alerts/alert_hazard_101'); + const alertRefBob = doc(bob, 'road_alerts/alert_hazard_101'); + + await assertSucceeds(setDoc(alertRefAlice, baseAlert)); + + // Setting expiresAt to reportedAt exactly is bounded (reportedAt <= expiresAt <= previous expiresAt) + await assertSucceeds(updateDoc(alertRefBob, { + dismissals: 1, + expiresAt: 1000000, + })); + }); + + test('rejects vote inflation and multi-vote manipulation', async () => { + const alice = testEnv.authenticatedContext('alice').firestore(); + const bob = testEnv.authenticatedContext('bob').firestore(); + const signedOut = testEnv.unauthenticatedContext().firestore(); + const alertRefAlice = doc(alice, 'road_alerts/alert_hazard_101'); + const alertRefBob = doc(bob, 'road_alerts/alert_hazard_101'); + const alertRefSignedOut = doc(signedOut, 'road_alerts/alert_hazard_101'); + + await assertSucceeds(setDoc(alertRefAlice, baseAlert)); + + // Signed-out user cannot vote + await assertFails(updateDoc(alertRefSignedOut, { + confirmations: 2, + })); + await assertFails(updateDoc(alertRefSignedOut, { + dismissals: 1, + })); + + // Attempting to inflate confirmations from 1 to 5 (> 1 increment) + await assertFails(updateDoc(alertRefBob, { + confirmations: 5, + })); + + // Attempting to inflate confirmations to 9999 + await assertFails(updateDoc(alertRefBob, { + confirmations: 9999, + })); + + // Attempting to inflate dismissals from 0 to 5 (> 1 increment) + await assertFails(updateDoc(alertRefBob, { + dismissals: 5, + })); + + // Attempting to inflate dismissals to 9999 + await assertFails(updateDoc(alertRefBob, { + dismissals: 9999, + })); + + // Attempting to decrement confirmations + await assertFails(updateDoc(alertRefBob, { + confirmations: 0, + })); + + // Attempting to decrement dismissals + await assertFails(updateDoc(alertRefBob, { + dismissals: -1, + })); + + // Attempting to increment BOTH confirmations and dismissals simultaneously + await assertFails(updateDoc(alertRefBob, { + confirmations: 2, + dismissals: 1, + })); + }); + + test('rejects arbitrary expiresAt extension and unauthorized expiration manipulation', async () => { + const alice = testEnv.authenticatedContext('alice').firestore(); + const bob = testEnv.authenticatedContext('bob').firestore(); + const alertRefAlice = doc(alice, 'road_alerts/alert_hazard_101'); + const alertRefBob = doc(bob, 'road_alerts/alert_hazard_101'); + + await assertSucceeds(setDoc(alertRefAlice, baseAlert)); + + // Extending expiresAt into the far future during confirmation + await assertFails(updateDoc(alertRefBob, { + confirmations: 2, + expiresAt: 9999999, + })); + + // Shortening expiresAt during confirmation (confirmation must keep expiresAt unchanged) + await assertFails(updateDoc(alertRefBob, { + confirmations: 2, + expiresAt: 1001000, + })); + + // Extending expiresAt beyond current expiresAt during dismissal + await assertFails(updateDoc(alertRefBob, { + dismissals: 1, + expiresAt: 1005000, + })); + + // Setting expiresAt earlier than reportedAt during dismissal + await assertFails(updateDoc(alertRefBob, { + dismissals: 1, + expiresAt: 999999, + })); + + // Manipulating expiresAt without voting (neither confirmations nor dismissals incremented) + await assertFails(updateDoc(alertRefBob, { + expiresAt: 1001000, + })); + + // Manipulating expiresAt to far future with no vote + await assertFails(updateDoc(alertRefBob, { + expiresAt: 9999999, + })); + }); + + test('rejects tampering with immutable alert fields during voting', async () => { + const alice = testEnv.authenticatedContext('alice').firestore(); + const bob = testEnv.authenticatedContext('bob').firestore(); + const alertRefAlice = doc(alice, 'road_alerts/alert_hazard_101'); + const alertRefBob = doc(bob, 'road_alerts/alert_hazard_101'); + + await assertSucceeds(setDoc(alertRefAlice, baseAlert)); + + // Attempting to change reporterUid during vote + await assertFails(updateDoc(alertRefBob, { + confirmations: 2, + reporterUid: 'bob', + })); + + // Attempting to change category during vote + await assertFails(updateDoc(alertRefBob, { + confirmations: 2, + category: 'police', + })); + + // Attempting to change latitude during vote + await assertFails(updateDoc(alertRefBob, { + confirmations: 2, + latitude: 38.0, + })); + + // Attempting to change reportedAt during vote + await assertFails(updateDoc(alertRefBob, { + confirmations: 2, + reportedAt: 900000, + })); + + // Attempting to inject new fields during vote + await assertFails(updateDoc(alertRefBob, { + confirmations: 2, + newField: 'exploit', + })); + + // Attempting to modify description during vote + await assertFails(updateDoc(alertRefBob, { + confirmations: 2, + description: 'Modified description', + })); + }); + + test('delete authorization: only original reporter can delete', async () => { + const alice = testEnv.authenticatedContext('alice').firestore(); + const bob = testEnv.authenticatedContext('bob').firestore(); + const signedOut = testEnv.unauthenticatedContext().firestore(); + const alertRefAlice = doc(alice, 'road_alerts/alert_hazard_101'); + const alertRefBob = doc(bob, 'road_alerts/alert_hazard_101'); + const alertRefSignedOut = doc(signedOut, 'road_alerts/alert_hazard_101'); + + await assertSucceeds(setDoc(alertRefAlice, baseAlert)); + + // Bob cannot delete Alice's alert + await assertFails(deleteDoc(alertRefBob)); + + // Signed-out user cannot delete + await assertFails(deleteDoc(alertRefSignedOut)); + + // Alice (reporter) can delete her alert + await assertSucceeds(deleteDoc(alertRefAlice)); + }); +}); diff --git a/firestore.rules b/firestore.rules index 465aba5..dda1d9e 100644 --- a/firestore.rules +++ b/firestore.rules @@ -1543,8 +1543,18 @@ service cloud.firestore { && request.resource.data.longitude == resource.data.longitude && request.resource.data.reportedAt == resource.data.reportedAt && request.resource.data.diff(resource.data).affectedKeys().hasOnly(['confirmations', 'dismissals', 'expiresAt']) - && request.resource.data.confirmations >= resource.data.confirmations - && request.resource.data.dismissals >= resource.data.dismissals; + && ( + ( + request.resource.data.confirmations == resource.data.confirmations + 1 + && request.resource.data.dismissals == resource.data.dismissals + && request.resource.data.expiresAt == resource.data.expiresAt + ) || ( + request.resource.data.dismissals == resource.data.dismissals + 1 + && request.resource.data.confirmations == resource.data.confirmations + && request.resource.data.expiresAt <= resource.data.expiresAt + && request.resource.data.expiresAt >= resource.data.reportedAt + ) + ); } allow read: if signedIn(); diff --git a/lib/features/dashboard/widgets/lean_angle_indicator.dart b/lib/features/dashboard/widgets/lean_angle_indicator.dart index 8387a74..16f09dc 100644 --- a/lib/features/dashboard/widgets/lean_angle_indicator.dart +++ b/lib/features/dashboard/widgets/lean_angle_indicator.dart @@ -163,8 +163,10 @@ class LeanAngleIndicator extends ConsumerWidget { width: 0, height: 0, child: OverflowBox( - maxWidth: 0, - maxHeight: 0, + minWidth: 0, + maxWidth: 300, + minHeight: 0, + maxHeight: 50, child: Row( children: [ Text('L: ${currentLeft.toStringAsFixed(0)}°'), diff --git a/lib/features/dashboard/widgets/responsive_dashboard_layout.dart b/lib/features/dashboard/widgets/responsive_dashboard_layout.dart index 6921452..c5d7e9b 100644 --- a/lib/features/dashboard/widgets/responsive_dashboard_layout.dart +++ b/lib/features/dashboard/widgets/responsive_dashboard_layout.dart @@ -411,22 +411,26 @@ class ResponsiveDashboardLayout extends ConsumerWidget { children: [ const GForceIndicator(compact: true), const SizedBox(height: 4), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - MetricTile( - label: 'TRIP', - value: session.tripDistanceMiles.toStringAsFixed(1), - unit: 'MI', - compact: true, - ), - MetricTile( - label: 'TOP', - value: session.topSpeedMph.toStringAsFixed(0), - unit: 'MPH', - compact: true, - ), - ], + FittedBox( + fit: BoxFit.scaleDown, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + MetricTile( + label: 'TRIP', + value: session.tripDistanceMiles.toStringAsFixed(1), + unit: 'MI', + compact: true, + ), + const SizedBox(width: 8), + MetricTile( + label: 'TOP', + value: session.topSpeedMph.toStringAsFixed(0), + unit: 'MPH', + compact: true, + ), + ], + ), ), ], ), @@ -438,14 +442,20 @@ class ResponsiveDashboardLayout extends ConsumerWidget { child: Column( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - const Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - DashboardLayoutSwitcher(), - GPSStatusIndicator(showLabel: false), - HeadingIndicator(compact: true), - RideStatusChip(), - ], + const FittedBox( + fit: BoxFit.scaleDown, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + DashboardLayoutSwitcher(), + SizedBox(width: 4), + GPSStatusIndicator(showLabel: false), + SizedBox(width: 4), + HeadingIndicator(compact: true), + SizedBox(width: 4), + RideStatusChip(), + ], + ), ), const Center(child: SpeedReadout(size: SpeedReadoutSize.compact)), const RideActionButton(compact: true), @@ -463,17 +473,21 @@ class ResponsiveDashboardLayout extends ConsumerWidget { children: [ const LeanAngleIndicator(compact: true), const SizedBox(height: 4), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - MetricTile( - label: 'AVG', - value: session.averageSpeedMph.toStringAsFixed(0), - unit: 'MPH', - compact: true, - ), - const PerformanceSummary(compact: true), - ], + FittedBox( + fit: BoxFit.scaleDown, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + MetricTile( + label: 'AVG', + value: session.averageSpeedMph.toStringAsFixed(0), + unit: 'MPH', + compact: true, + ), + const SizedBox(width: 8), + const PerformanceSummary(compact: true), + ], + ), ), ], ), diff --git a/lib/firebase_options.dart b/lib/firebase_options.dart index 45faf2c..a1a6788 100644 --- a/lib/firebase_options.dart +++ b/lib/firebase_options.dart @@ -1,12 +1,43 @@ -// File generated for MotorSignal Firebase Configuration. +// File generated for MotorSignal Firebase Configuration. // ignore_for_file: type=lint import 'package:firebase_core/firebase_core.dart' show FirebaseOptions; import 'package:flutter/foundation.dart' show defaultTargetPlatform, kIsWeb, TargetPlatform; +import 'package:flutter/services.dart' show appFlavor; + /// Default [FirebaseOptions] for use with your Firebase apps. class DefaultFirebaseOptions { + /// Whether the app is running in QA flavor or QA test mode. + static bool get isQa { + final flavor = appFlavor?.toLowerCase() ?? ''; + if (flavor.contains('qa')) return true; + const envFlavor = String.fromEnvironment('FLAVOR'); + if (envFlavor.toLowerCase().contains('qa')) return true; + const appFlavorEnv = String.fromEnvironment('APP_FLAVOR'); + if (appFlavorEnv.toLowerCase().contains('qa')) return true; + return false; + } + + /// Production guard ensuring QA flavor never connects to production Firebase. + static void validateFirebaseOptions(FirebaseOptions options) { + if (isQa) { + if (options.projectId == 'motoapp-d22e0' || !options.projectId.startsWith('demo-')) { + throw StateError( + 'Android QA flavor cannot connect to production Firebase project (${options.projectId}). ' + 'QA requires a demo-* Firebase project.', + ); + } + } + } + static FirebaseOptions get currentPlatform { + if (isQa) { + if (defaultTargetPlatform == TargetPlatform.android) { + return androidQa; + } + return qaOptions; + } if (kIsWeb) { return web; } @@ -20,6 +51,22 @@ class DefaultFirebaseOptions { } } + static const FirebaseOptions androidQa = FirebaseOptions( + apiKey: 'demo-key', + appId: '1:123456789:android:qa', + messagingSenderId: '123456789', + projectId: 'demo-motorsignal-qa', + storageBucket: 'demo-motorsignal-qa.appspot.com', + ); + + static const FirebaseOptions qaOptions = FirebaseOptions( + apiKey: 'demo-key', + appId: '1:123456789:web:qa', + messagingSenderId: '123456789', + projectId: 'demo-motorsignal-qa', + storageBucket: 'demo-motorsignal-qa.appspot.com', + ); + static const FirebaseOptions android = FirebaseOptions( apiKey: 'AIzaSyD-ie8zTaAWvFUlCG5bHqRhfHKOcu4jPtg', appId: '1:809431485362:android:06fdcd9ca12ff9342472ab', diff --git a/lib/main.dart b/lib/main.dart index 457cc9a..387049c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -30,8 +30,10 @@ Future main() async { } try { + final options = DefaultFirebaseOptions.currentPlatform; + DefaultFirebaseOptions.validateFirebaseOptions(options); await Firebase.initializeApp( - options: DefaultFirebaseOptions.currentPlatform, + options: options, ); } catch (e) { debugPrint('[Firebase] Standalone offline mode active: $e'); diff --git a/lib/services/alerts/road_alert_providers.dart b/lib/services/alerts/road_alert_providers.dart index 080d618..4873ea0 100644 --- a/lib/services/alerts/road_alert_providers.dart +++ b/lib/services/alerts/road_alert_providers.dart @@ -338,7 +338,10 @@ final hereStatusProvider = StateProvider((ref) { // Waze Police Alerts API Key (OpenWebNinja) class WazeApiKeyNotifier extends StateNotifier { static const _key = 'waze_api_key'; - static const String defaultKey = 'ak_dud2s1987x5k7amfokrddptn41ov0iyf8kfdu3ljpv1fgvq'; + static const String defaultKey = String.fromEnvironment( + 'OPENWEBNINJA_API_KEY', + defaultValue: String.fromEnvironment('WAZE_API_KEY', defaultValue: ''), + ); WazeApiKeyNotifier() : super(defaultKey) { _load(); diff --git a/lib/services/alerts/waze_road_alert_provider.dart b/lib/services/alerts/waze_road_alert_provider.dart index 17b52ae..124f1a0 100644 --- a/lib/services/alerts/waze_road_alert_provider.dart +++ b/lib/services/alerts/waze_road_alert_provider.dart @@ -10,9 +10,20 @@ import 'road_alert_provider_interface.dart'; /// (`alert_types=POLICE`, `max_jams=0`) around the rider's coordinate. class WazeRoadAlertProvider implements RoadAlertProvider { final http.Client _client; + final String apiHost; - WazeRoadAlertProvider({http.Client? client}) - : _client = client ?? http.Client(); + WazeRoadAlertProvider({ + http.Client? client, + String? apiHost, + }) : _client = client ?? http.Client(), + apiHost = apiHost ?? + const String.fromEnvironment( + 'WAZE_API_HOST', + defaultValue: String.fromEnvironment( + 'OPENWEBNINJA_API_HOST', + defaultValue: 'api.openwebninja.com', + ), + ); @override String get id => 'waze'; @@ -35,7 +46,7 @@ class WazeRoadAlertProvider implements RoadAlertProvider { try { // Diagnostic check: test point around downtown Los Angeles with max_alerts=1 final uri = Uri.https( - 'api.openwebninja.com', + apiHost, '/waze/alerts-and-jams', { 'center': '34.0522,-118.2437', @@ -80,7 +91,7 @@ class WazeRoadAlertProvider implements RoadAlertProvider { try { final radiusKm = (radiusMeters / 1000.0).clamp(1.0, 50.0).toStringAsFixed(1); final uri = Uri.https( - 'api.openwebninja.com', + apiHost, '/waze/alerts-and-jams', { 'center': '$latitude,$longitude', @@ -158,7 +169,7 @@ class WazeRoadAlertProvider implements RoadAlertProvider { try { final uri = Uri.https( - 'api.openwebninja.com', + apiHost, '/waze/alerts-and-jams', { 'bottom_left': '$bottomLat,$bottomLng', diff --git a/lib/services/cloud/firebase_account_sync_service.dart b/lib/services/cloud/firebase_account_sync_service.dart index a27559b..eab1b1b 100644 --- a/lib/services/cloud/firebase_account_sync_service.dart +++ b/lib/services/cloud/firebase_account_sync_service.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:drift/drift.dart'; import 'package:firebase_core/firebase_core.dart'; @@ -12,12 +13,14 @@ class FirebaseAccountSyncService { final AppDatabase db; final AuthService auth; final FirebaseFirestore? _firestore; + final bool _customFirestore; FirebaseAccountSyncService({ required this.db, required this.auth, FirebaseFirestore? firestore, - }) : _firestore = firestore ?? _resolveFirestore(); + }) : _customFirestore = firestore != null, + _firestore = firestore ?? _resolveFirestore(); static FirebaseFirestore? _resolveFirestore() { try { @@ -26,7 +29,50 @@ class FirebaseAccountSyncService { return null; } - bool get isAvailable => _firestore != null && Firebase.apps.isNotEmpty; + bool get isAvailable => + _firestore != null && (_customFirestore || Firebase.apps.isNotEmpty); + + /// Extracts the stored cloudId from a motorcycle's specificationJson metadata. + static String? extractCloudId(String? specJson) { + if (specJson == null || specJson.isEmpty) return null; + try { + final decoded = json.decode(specJson); + if (decoded is Map) { + final id = decoded['cloudId']?.toString(); + if (id != null && id.isNotEmpty) return id; + } + } catch (_) {} + return null; + } + + /// Injects or updates cloudId inside specificationJson without altering other specs. + static String attachCloudId(String? specJson, String cloudId) { + Map map = {}; + if (specJson != null && specJson.isNotEmpty) { + try { + final decoded = json.decode(specJson); + if (decoded is Map) { + map = Map.from(decoded); + } + } catch (_) {} + } + map['cloudId'] = cloudId; + return json.encode(map); + } + + /// Generates a globally unique stable cloud document ID for a motorcycle. + static String generateCloudMotorcycleId( + String uid, + int localId, + int year, + String make, + String model, + ) { + final prefix = uid.length > 8 ? uid.substring(0, 8) : uid; + final slugMake = make.toLowerCase().replaceAll(RegExp(r'[^a-z0-9]'), '_'); + final slugModel = model.toLowerCase().replaceAll(RegExp(r'[^a-z0-9]'), '_'); + return 'moto_${prefix}_${localId}_${year}_${slugMake}_$slugModel'; + } DateTime? _parseDateTime(dynamic value) { if (value == null) return null; @@ -52,12 +98,22 @@ class FirebaseAccountSyncService { final userRef = _firestore!.collection('users').doc(user.uid); await userRef.set(profile.toJson(), SetOptions(merge: true)); - // 1. Fetch remote motorcycles from Firestore and reconcile into local Drift DB + // 1. Fetch remote motorcycles from Firestore and safely reconcile into local Drift DB try { final remoteSnapshot = await userRef.collection('motorcycles').get(); final localBikes = await db.select(db.garageMotorcycles).get(); - final localById = {for (final b in localBikes) b.id.toString(): b}; - final localByKey = {for (final b in localBikes) '${b.year}_${b.makeName}_${b.modelName}'.toLowerCase(): b}; + + // Index local motorcycles by stored cloudId and normalized vehicle attributes + final localByCloudId = {}; + final localByAttributes = {}; + for (final b in localBikes) { + final cid = extractCloudId(b.specificationJson); + if (cid != null && cid.isNotEmpty) { + localByCloudId[cid] = b; + } + final attrKey = '${b.year}_${b.makeName.trim()}_${b.modelName.trim()}'.toLowerCase(); + localByAttributes[attrKey] = b; + } for (final doc in remoteSnapshot.docs) { final data = doc.data(); @@ -65,33 +121,54 @@ class FirebaseAccountSyncService { final year = (data['year'] as num?)?.toInt() ?? 2000; final makeName = (data['makeName'] ?? '').toString(); final modelName = (data['modelName'] ?? '').toString(); - final key = '${year}_${makeName}_$modelName'.toLowerCase(); + final attrKey = '${year}_${makeName.trim()}_${modelName.trim()}'.toLowerCase(); - final existing = localById[docId] ?? localByKey[key]; + // Cross-device reconciliation: match by cloudId first, then by vehicle attributes. + // Never match different motorcycles that share an identical local integer ID! + GarageMotorcycle? existing = localByCloudId[docId] ?? localByAttributes[attrKey]; if (existing == null) { final parsedId = int.tryParse(docId); + if (parsedId != null) { + final cand = localBikes.where((b) => b.id == parsedId).firstOrNull; + if (cand != null) { + final candKey = '${cand.year}_${cand.makeName.trim()}_${cand.modelName.trim()}'.toLowerCase(); + if (candKey == attrKey) { + existing = cand; + } + } + } + } + + if (existing == null) { + // Brand new motorcycle from remote device or website: + // Insert with auto-increment local ID (never overwrite existing local bike!) + final specWithCloudId = attachCloudId(data['specificationJson']?.toString(), docId); await db.into(db.garageMotorcycles).insert( GarageMotorcyclesCompanion( - id: parsedId != null ? Value(parsedId) : const Value.absent(), + id: const Value.absent(), // Let SQLite assign fresh local ID to prevent collisions! year: Value(year), - makeId: Value((data['makeId'] ?? parsedId?.toString() ?? '1').toString()), + makeId: Value((data['makeId'] ?? '1').toString()), makeName: Value(makeName), - modelId: Value((data['modelId'] ?? parsedId?.toString() ?? '1').toString()), + modelId: Value((data['modelId'] ?? '1').toString()), modelName: Value(modelName), variantId: Value(data['variantId']?.toString()), variantName: Value(data['variantName']?.toString()), nickname: Value(data['nickname']?.toString()), - isDefault: Value(data['isDefault'] == true), + isDefault: Value(data['isDefault'] == true && localBikes.isEmpty), dateAdded: Value(_parseDateTime(data['createdAt']) ?? timestamp), lastUpdated: Value(_parseDateTime(data['updatedAt']) ?? timestamp), - specificationJson: Value(data['specificationJson']?.toString()), + specificationJson: Value(specWithCloudId), ), - mode: InsertMode.insertOrReplace, ); - } else if (existing.specificationJson == null && data['specificationJson'] != null) { - await (db.update(db.garageMotorcycles)..where((t) => t.id.equals(existing.id))) + } else { + // Existing motorcycle: link cloudId and preserve specifications + final updatedSpec = attachCloudId( + existing.specificationJson ?? data['specificationJson']?.toString(), + docId, + ); + await (db.update(db.garageMotorcycles)..where((t) => t.id.equals(existing!.id))) .write(GarageMotorcyclesCompanion( - specificationJson: Value(data['specificationJson']?.toString()), + specificationJson: Value(updatedSpec), lastUpdated: Value(timestamp), )); } @@ -100,11 +177,28 @@ class FirebaseAccountSyncService { // Remote fetch error should not block local operations } - // 2. Upload any local motorcycles to Firestore + // 2. Upload any local motorcycles to Firestore with stable unique cloud IDs final currentLocal = await db.select(db.garageMotorcycles).get(); for (final motorcycle in currentLocal) { + var cloudId = extractCloudId(motorcycle.specificationJson); + if (cloudId == null || cloudId.isEmpty) { + cloudId = generateCloudMotorcycleId( + user.uid, + motorcycle.id, + motorcycle.year, + motorcycle.makeName, + motorcycle.modelName, + ); + // Persist generated cloudId locally in specificationJson + final updatedSpec = attachCloudId(motorcycle.specificationJson, cloudId); + await (db.update(db.garageMotorcycles)..where((t) => t.id.equals(motorcycle.id))) + .write(GarageMotorcyclesCompanion( + specificationJson: Value(updatedSpec), + )); + } + final cloud = CloudMotorcycle( - id: motorcycle.id.toString(), + id: cloudId, ownerId: user.uid, year: motorcycle.year, makeId: motorcycle.makeId, @@ -151,6 +245,9 @@ class FirebaseAccountSyncService { final model = r.motorcycleModel ?? 'Unknown'; final variant = r.motorcycleVariant; + final cloudId = generateCloudMotorcycleId(uid, bikeId, year, make, model); + final specWithCloudId = attachCloudId(null, cloudId); + await db.into(db.garageMotorcycles).insert( GarageMotorcyclesCompanion( id: Value(bikeId), @@ -163,12 +260,13 @@ class FirebaseAccountSyncService { isDefault: const Value(true), dateAdded: Value(timestamp), lastUpdated: Value(timestamp), + specificationJson: Value(specWithCloudId), ), mode: InsertMode.insertOrReplace, ); final cloud = CloudMotorcycle( - id: bikeId.toString(), + id: cloudId, ownerId: uid, year: year, makeId: bikeId.toString(), @@ -179,6 +277,7 @@ class FirebaseAccountSyncService { isDefault: true, createdAt: timestamp, updatedAt: timestamp, + specificationJson: specWithCloudId, ); await userRef.collection('motorcycles').doc(cloud.id).set( cloud.toJson(), diff --git a/test/firebase_options_qa_guard_test.dart b/test/firebase_options_qa_guard_test.dart new file mode 100644 index 0000000..a41c348 --- /dev/null +++ b/test/firebase_options_qa_guard_test.dart @@ -0,0 +1,78 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:firebase_core/firebase_core.dart'; +import 'package:motorcycle_dash/firebase_options.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('DefaultFirebaseOptions QA Safety Guard Tests (Finding 2)', () { + test('production options target motoapp-d22e0', () { + expect(DefaultFirebaseOptions.android.projectId, equals('motoapp-d22e0')); + expect(DefaultFirebaseOptions.web.projectId, equals('motoapp-d22e0')); + expect(DefaultFirebaseOptions.ios.projectId, equals('motoapp-d22e0')); + }); + + test('androidQa options strictly target demo-motorsignal-qa and non-prod credentials', () { + expect(DefaultFirebaseOptions.androidQa.projectId, equals('demo-motorsignal-qa')); + expect(DefaultFirebaseOptions.androidQa.apiKey, equals('demo-key')); + expect(DefaultFirebaseOptions.androidQa.appId, equals('1:123456789:android:qa')); + expect(DefaultFirebaseOptions.androidQa.storageBucket, equals('demo-motorsignal-qa.appspot.com')); + }); + + test('validateFirebaseOptions throws StateError if QA mode attempts to use production Firebase', () { + // Create a simulated production options instance + const prodOptions = FirebaseOptions( + apiKey: 'fake-prod-key', + appId: '1:809431485362:android:06fdcd9ca12ff9342472ab', + messagingSenderId: '809431485362', + projectId: 'motoapp-d22e0', + ); + + const nonDemoOptions = FirebaseOptions( + apiKey: 'fake-prod-key', + appId: '1:809431485362:android:06fdcd9ca12ff9342472ab', + messagingSenderId: '809431485362', + projectId: 'custom-live-project', + ); + + const validQaOptions = DefaultFirebaseOptions.androidQa; + + // When isQa is false, production options are accepted + if (!DefaultFirebaseOptions.isQa) { + expect(() => DefaultFirebaseOptions.validateFirebaseOptions(prodOptions), returnsNormally); + } + + // If isQa is true or when testing validate logic directly: + // Verify that validateFirebaseOptions enforces demo-* project prefix in QA + if (DefaultFirebaseOptions.isQa) { + expect( + () => DefaultFirebaseOptions.validateFirebaseOptions(prodOptions), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Android QA flavor cannot connect to production Firebase'), + )), + ); + + expect( + () => DefaultFirebaseOptions.validateFirebaseOptions(nonDemoOptions), + throwsA(isA()), + ); + + expect( + () => DefaultFirebaseOptions.validateFirebaseOptions(validQaOptions), + returnsNormally, + ); + } + }); + + test('currentPlatform returns androidQa when isQa is true on Android', () { + if (DefaultFirebaseOptions.isQa && defaultTargetPlatform == TargetPlatform.android) { + final options = DefaultFirebaseOptions.currentPlatform; + expect(options.projectId, equals('demo-motorsignal-qa')); + expect(options.projectId, isNot(equals('motoapp-d22e0'))); + } + }); + }); +} diff --git a/test/motorcycle_cross_device_reconciliation_test.dart b/test/motorcycle_cross_device_reconciliation_test.dart new file mode 100644 index 0000000..77d5389 --- /dev/null +++ b/test/motorcycle_cross_device_reconciliation_test.dart @@ -0,0 +1,229 @@ +import 'dart:convert'; +import 'package:drift/drift.dart'; +import 'package:drift/native.dart'; +import 'package:fake_cloud_firestore/fake_cloud_firestore.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:motorcycle_dash/database/database.dart'; +import 'package:motorcycle_dash/services/auth/auth_service.dart'; +import 'package:motorcycle_dash/services/auth/auth_state.dart'; +import 'package:motorcycle_dash/services/auth/auth_user.dart'; +import 'package:motorcycle_dash/services/cloud/firebase_account_sync_service.dart'; + +class _FakeAuthService implements AuthService { + final AuthUser _user; + _FakeAuthService(this._user); + + @override + AuthUser? get currentUser => _user; + + @override + AuthState get currentState => AuthState.authenticated(_user); + + @override + Stream get authStateChanges => Stream.value(currentState); + + @override + bool get isConfigured => true; + + @override + bool get isEmailVerified => true; + + @override + Future createUserWithEmailAndPassword(String email, String password, {String? displayName}) async {} + + @override + Future deleteCurrentUser() async {} + + @override + void dispose() {} + + @override + Future ensureBackendIdentity() async {} + + @override + Future getIdToken([bool forceRefresh = false]) async => 'fake-token'; + + @override + Future reauthenticate({String? password}) async {} + + @override + Future reloadCurrentUser() async {} + + @override + Future sendEmailVerification() async {} + + @override + Future sendPasswordResetEmail(String email) async {} + + @override + Future signInAnonymously() async {} + + @override + Future signInWithEmailAndPassword(String email, String password) async {} + + @override + Future signInWithGoogle() async {} + + @override + Future signOut() async {} +} + +void main() { + const testUser = AuthUser( + uid: 'user_cross_dev_123', + email: 'rider@example.com', + displayName: 'Test Rider', + ); + + group('Motorcycle Cross-Device Reconciliation (Codex Finding 5)', () { + late FakeFirebaseFirestore fakeFirestore; + late _FakeAuthService auth; + + setUp(() { + fakeFirestore = FakeFirebaseFirestore(); + auth = _FakeAuthService(testUser); + }); + + test('reconciles different motorcycles on two devices with identical local ID 1 without clobbering', () async { + // Device A DB + final dbA = AppDatabase.forTesting(NativeDatabase.memory()); + addTearDown(dbA.close); + + // Device B DB + final dbB = AppDatabase.forTesting(NativeDatabase.memory()); + addTearDown(dbB.close); + + final now = DateTime.utc(2026, 10, 9, 12, 0, 0); + + // Device A has local bike with id=1: Yamaha MT-09 + await dbA.into(dbA.garageMotorcycles).insert( + GarageMotorcyclesCompanion( + id: const Value(1), + year: const Value(2024), + makeId: const Value('10'), + makeName: const Value('Yamaha'), + modelId: const Value('100'), + modelName: const Value('MT-09'), + dateAdded: Value(now), + lastUpdated: Value(now), + ), + ); + + // Device B has local bike with id=1: Ducati Monster (independent device, same local ID 1) + await dbB.into(dbB.garageMotorcycles).insert( + GarageMotorcyclesCompanion( + id: const Value(1), + year: const Value(2023), + makeId: const Value('20'), + makeName: const Value('Ducati'), + modelId: const Value('200'), + modelName: const Value('Monster'), + dateAdded: Value(now), + lastUpdated: Value(now), + ), + ); + + final syncA = FirebaseAccountSyncService( + db: dbA, + auth: auth, + firestore: fakeFirestore, + ); + + final syncB = FirebaseAccountSyncService( + db: dbB, + auth: auth, + firestore: fakeFirestore, + ); + + // --- Step 1: Device A syncs --- + await syncA.syncAccount(now: now); + + // Verify Firestore has Yamaha under unique cloud ID + final cloudBikesAfterA = await fakeFirestore + .collection('users') + .doc(testUser.uid) + .collection('motorcycles') + .get(); + expect(cloudBikesAfterA.docs.length, 1); + final bikeADoc = cloudBikesAfterA.docs.first; + expect(bikeADoc.data()['makeName'], 'Yamaha'); + expect(bikeADoc.data()['modelName'], 'MT-09'); + expect(bikeADoc.id, contains('yamaha')); + + // Verify Device A persisted cloudId in local specificationJson + final localBikesA = await dbA.select(dbA.garageMotorcycles).get(); + expect(localBikesA.length, 1); + expect(localBikesA.first.id, 1); + final cloudIdA = FirebaseAccountSyncService.extractCloudId(localBikesA.first.specificationJson); + expect(cloudIdA, equals(bikeADoc.id)); + + // --- Step 2: Device B syncs --- + await syncB.syncAccount(now: now); + + // Verify Device B local state: + // Local ID 1 must still be Ducati Monster! Yamaha MT-09 must NOT have overwritten ID 1! + final localBikesB = await dbB.select(dbB.garageMotorcycles).get(); + expect(localBikesB.length, 2, reason: 'Device B must now have both motorcycles locally'); + + final ducatiOnB = localBikesB.firstWhere((b) => b.modelName == 'Monster'); + final yamahaOnB = localBikesB.firstWhere((b) => b.modelName == 'MT-09'); + + expect(ducatiOnB.id, 1, reason: 'Original Ducati on Device B retains local ID 1'); + expect(ducatiOnB.makeName, 'Ducati'); + expect(yamahaOnB.id, isNot(1), reason: 'Synced Yamaha on Device B gets a fresh local auto-increment ID'); + + // Verify Firestore state after Device B sync: + // Both motorcycles must be present in Firestore. Ducati must NOT have overwritten Yamaha! + final cloudBikesAfterB = await fakeFirestore + .collection('users') + .doc(testUser.uid) + .collection('motorcycles') + .get(); + expect(cloudBikesAfterB.docs.length, 2, reason: 'Firestore must contain both motorcycles'); + + final cloudMakes = cloudBikesAfterB.docs.map((d) => d.data()['makeName']).toSet(); + expect(cloudMakes, containsAll(['Yamaha', 'Ducati'])); + + // --- Step 3: Device A syncs again --- + await syncA.syncAccount(now: now); + + // Device A should now also safely pull in Ducati without clobbering Yamaha ID 1 + final localBikesASecond = await dbA.select(dbA.garageMotorcycles).get(); + expect(localBikesASecond.length, 2, reason: 'Device A now also has both motorcycles'); + + final yamahaOnA = localBikesASecond.firstWhere((b) => b.modelName == 'MT-09'); + final ducatiOnA = localBikesASecond.firstWhere((b) => b.modelName == 'Monster'); + + expect(yamahaOnA.id, 1, reason: 'Original Yamaha on Device A retains local ID 1'); + expect(ducatiOnA.id, isNot(1), reason: 'Synced Ducati on Device A gets a fresh local auto-increment ID'); + + // --- Step 4: Idempotency / No Duplication --- + await syncA.syncAccount(now: now); + await syncB.syncAccount(now: now); + + expect((await dbA.select(dbA.garageMotorcycles).get()).length, 2); + expect((await dbB.select(dbB.garageMotorcycles).get()).length, 2); + expect( + (await fakeFirestore.collection('users').doc(testUser.uid).collection('motorcycles').get()).docs.length, + 2, + ); + }); + + test('extractCloudId and attachCloudId preserve existing specification metadata', () { + final initialSpec = json.encode({ + 'displacementCc': 890, + 'curbWeightKg': 193, + 'fuelCapacityL': 14.0, + }); + + final attached = FirebaseAccountSyncService.attachCloudId(initialSpec, 'moto_cloud_99'); + expect(FirebaseAccountSyncService.extractCloudId(attached), 'moto_cloud_99'); + + final decoded = json.decode(attached) as Map; + expect(decoded['displacementCc'], 890); + expect(decoded['curbWeightKg'], 193); + expect(decoded['fuelCapacityL'], 14.0); + expect(decoded['cloudId'], 'moto_cloud_99'); + }); + }); +} diff --git a/test/waze_road_alert_provider_test.dart b/test/waze_road_alert_provider_test.dart index f2c9e82..ae7a2b4 100644 --- a/test/waze_road_alert_provider_test.dart +++ b/test/waze_road_alert_provider_test.dart @@ -346,12 +346,13 @@ void main() { expect(alerts.first.severity, equals(RoadAlertSeverity.high)); }); - test('wazeApiKeyProvider initializes with default key and can update', () async { + test('wazeApiKeyProvider initializes with empty default and does not expose bundled key', () async { final container = ProviderContainer(); addTearDown(container.dispose); final initialKey = container.read(wazeApiKeyProvider); - expect(initialKey, equals('ak_dud2s1987x5k7amfokrddptn41ov0iyf8kfdu3ljpv1fgvq')); + expect(initialKey, isEmpty, reason: 'Bundled API key must not be present by default'); + expect(initialKey, isNot(contains('ak_dud2s1987x5k7amfokrddptn41ov0iyf8kfdu3ljpv1fgvq'))); await container.read(wazeApiKeyProvider.notifier).setApiKey('new_ninja_key_999'); expect(container.read(wazeApiKeyProvider), equals('new_ninja_key_999')); @@ -367,12 +368,32 @@ void main() { expect(container.read(wazeRoadAlertsEnabledProvider), isFalse); }); - test('activeRoadAlertProviders includes Waze provider when enabled and key present', () { + test('activeRoadAlertProviders excludes Waze when key is empty, includes when key is set', () async { final container = ProviderContainer(); addTearDown(container.dispose); - final activeList = container.read(activeRoadAlertProviders); + // Initially no key, so Waze should not be active + var activeList = container.read(activeRoadAlertProviders); + expect(activeList.any((p) => p.id == 'waze'), isFalse); + + // After user sets an API key, Waze should be active + await container.read(wazeApiKeyProvider.notifier).setApiKey('valid_test_key_123'); + activeList = container.read(activeRoadAlertProviders); expect(activeList.any((p) => p.id == 'waze'), isTrue); }); + + test('WazeRoadAlertProvider uses custom apiHost if provided', () async { + final mockClient = MockClient((request) async { + expect(request.url.host, equals('custom-proxy.motorsignal.app')); + return http.Response('{"status":"success","data":{"alerts":[]}}', 200); + }); + + final provider = WazeRoadAlertProvider( + client: mockClient, + apiHost: 'custom-proxy.motorsignal.app', + ); + final status = await provider.testConnection('test_key'); + expect(status, equals(RoadAlertProviderStatus.connected)); + }); }); } diff --git a/website/components/app-cloud-data.tsx b/website/components/app-cloud-data.tsx index 658b1f6..cbb32fe 100644 --- a/website/components/app-cloud-data.tsx +++ b/website/components/app-cloud-data.tsx @@ -18,6 +18,7 @@ import { generateRideGpx, generateRideCsv, downloadFile, + resolveRideTelemetryPoints, TelemetryPoint, } from '../lib/telemetry-export'; @@ -208,24 +209,46 @@ export function AppCloudData({ syncTrigger = 0, onSyncStateChange, onMetricsLoad } }, []); - function handleExportRideGpx(e: React.MouseEvent, ride: Ride) { + async function handleExportRideGpx(e: React.MouseEvent, ride: Ride) { e.stopPropagation(); - const gpx = generateRideGpx(ride); - const dateStr = ride.startedAt ? ride.startedAt.toISOString().split('T')[0] : 'session'; - const filename = `motorsignal_${ride.id}_${dateStr}.gpx`; - downloadFile(gpx, filename, 'application/gpx+xml'); - setExportNotice(`Exported GPX: ${filename}`); - setTimeout(() => setExportNotice(null), 3000); + try { + setExportNotice('Loading recorded telemetry for export…'); + const points = await resolveRideTelemetryPoints(ride); + if (!points.length) { + throw new Error('No recorded telemetry points available.'); + } + const gpx = generateRideGpx(ride, points); + const dateStr = ride.startedAt ? ride.startedAt.toISOString().split('T')[0] : 'session'; + const filename = `motorsignal_${ride.id}_${dateStr}.gpx`; + downloadFile(gpx, filename, 'application/gpx+xml'); + setExportNotice(`Exported GPX: ${filename}`); + setTimeout(() => setExportNotice(null), 3000); + } catch (err) { + const msg = err instanceof Error ? err.message : 'Telemetry export failed.'; + setExportNotice(`Export failed: ${msg}`); + setTimeout(() => setExportNotice(null), 4000); + } } - function handleExportRideCsv(e: React.MouseEvent, ride: Ride) { + async function handleExportRideCsv(e: React.MouseEvent, ride: Ride) { e.stopPropagation(); - const csv = generateRideCsv(ride); - const dateStr = ride.startedAt ? ride.startedAt.toISOString().split('T')[0] : 'session'; - const filename = `motorsignal_20hz_${ride.id}_${dateStr}.csv`; - downloadFile(csv, filename, 'text/csv;charset=utf-8;'); - setExportNotice(`Exported 20 Hz CSV: ${filename}`); - setTimeout(() => setExportNotice(null), 3000); + try { + setExportNotice('Loading recorded telemetry for export…'); + const points = await resolveRideTelemetryPoints(ride); + if (!points.length) { + throw new Error('No recorded telemetry points available.'); + } + const csv = generateRideCsv(ride, points); + const dateStr = ride.startedAt ? ride.startedAt.toISOString().split('T')[0] : 'session'; + const filename = `motorsignal_20hz_${ride.id}_${dateStr}.csv`; + downloadFile(csv, filename, 'text/csv;charset=utf-8;'); + setExportNotice(`Exported 20 Hz CSV: ${filename}`); + setTimeout(() => setExportNotice(null), 3000); + } catch (err) { + const msg = err instanceof Error ? err.message : 'Telemetry export failed.'; + setExportNotice(`Export failed: ${msg}`); + setTimeout(() => setExportNotice(null), 4000); + } } // Handle Delete Ride Session diff --git a/website/lib/telemetry-export.ts b/website/lib/telemetry-export.ts index d74a698..6ab8bf8 100644 --- a/website/lib/telemetry-export.ts +++ b/website/lib/telemetry-export.ts @@ -48,11 +48,17 @@ export function downloadFile(content: string, filename: string, mimeType: string URL.revokeObjectURL(url); } +import { firebaseAuth } from './firebase-client'; + /** * Authoritative GPX 1.1 Exporter matching MotorSignal's ExportService schema. + * Rejects export if no recorded telemetry points are present. */ export function generateRideGpx(ride: Ride, points?: TelemetryPoint[]): string { const telemetry = points && points.length > 0 ? points : synthesizeTelemetryTrack(ride); + if (!telemetry || telemetry.length === 0) { + throw new Error(`Cannot export ride ${ride.id}: no recorded telemetry points available.`); + } const rideDate = ride.startedAt ? ride.startedAt.toISOString().split('T')[0] : 'session'; const rideTitle = `${ride.motorcycle || 'Motorcycle'} Ride - ${rideDate}`; @@ -96,9 +102,13 @@ export function generateRideGpx(ride: Ride, points?: TelemetryPoint[]): string { /** * Authoritative 20 Hz Telemetry CSV Exporter matching MotorSignal's ExportService RFC-4180 format. + * Rejects export if no recorded telemetry points are present. */ export function generateRideCsv(ride: Ride, points?: TelemetryPoint[]): string { const telemetry = points && points.length > 0 ? points : synthesizeTelemetryTrack(ride); + if (!telemetry || telemetry.length === 0) { + throw new Error(`Cannot export ride ${ride.id}: no recorded telemetry points available.`); + } const header = 'timestamp,latitude,longitude,altitude,speed,heading,gpsAccuracy,leanAngle,pitchAngle,accelX,accelY,accelZ,longitudinalG,lateralG'; const rows = telemetry.map((pt) => @@ -123,6 +133,42 @@ export function generateRideCsv(ride: Ride, points?: TelemetryPoint[]): string { return [header, ...rows].join('\n'); } +/** + * Resolves real telemetry points for a ride: + * 1. Uses inline points/gpx/csv if present. + * 2. Fetches cloud telemetry bundle from /api/account/rides/:id/telemetry if not inline. + * 3. Throws if no recorded telemetry is available. + */ +export async function resolveRideTelemetryPoints(ride: Ride): Promise { + const inline = synthesizeTelemetryTrack(ride); + if (inline && inline.length > 0) { + return inline; + } + + if (typeof window !== 'undefined' && firebaseAuth) { + const user = firebaseAuth.currentUser; + if (!user) { + throw new Error('Please sign in to load recorded cloud telemetry.'); + } + const token = await user.getIdToken(); + const response = await fetch(`/api/account/rides/${encodeURIComponent(ride.id)}/telemetry`, { + headers: { Authorization: `Bearer ${token}` }, + cache: 'no-store', + }); + const data = await response.json(); + if (!response.ok || !data.success) { + throw new Error(data.error || 'Recorded cloud telemetry could not be loaded.'); + } + const points = normalizeTelemetryPoints(Array.isArray(data.points) ? data.points : []); + if (!points.length) { + throw new Error('No recorded telemetry points found in cloud bundle.'); + } + return points; + } + + throw new Error('No recorded telemetry points available for this ride.'); +} + /** * Escape XML special characters. */ diff --git a/website/test-support/qa-firestore.rules b/website/test-support/qa-firestore.rules index f51b12b..d758444 100644 --- a/website/test-support/qa-firestore.rules +++ b/website/test-support/qa-firestore.rules @@ -1308,8 +1308,18 @@ service cloud.firestore { && request.resource.data.longitude == resource.data.longitude && request.resource.data.reportedAt == resource.data.reportedAt && request.resource.data.diff(resource.data).affectedKeys().hasOnly(['confirmations', 'dismissals', 'expiresAt']) - && request.resource.data.confirmations >= resource.data.confirmations - && request.resource.data.dismissals >= resource.data.dismissals; + && ( + ( + request.resource.data.confirmations == resource.data.confirmations + 1 + && request.resource.data.dismissals == resource.data.dismissals + && request.resource.data.expiresAt == resource.data.expiresAt + ) || ( + request.resource.data.dismissals == resource.data.dismissals + 1 + && request.resource.data.confirmations == resource.data.confirmations + && request.resource.data.expiresAt <= resource.data.expiresAt + && request.resource.data.expiresAt >= resource.data.reportedAt + ) + ); } allow read: if signedIn(); diff --git a/website/tests/telemetry-replay-export.test.tsx b/website/tests/telemetry-replay-export.test.tsx index 965f383..fd1e89c 100644 --- a/website/tests/telemetry-replay-export.test.tsx +++ b/website/tests/telemetry-replay-export.test.tsx @@ -124,4 +124,20 @@ describe('Web Telemetry Replay & Export (WEB-23)', () => { fireEvent.click(screen.getByTestId('close-replay-modal')); expect(onClose).toHaveBeenCalled(); }); + + it('rejects generating GPX or CSV when ride telemetry is empty, preventing silent empty export (Finding 4)', () => { + const emptyRide: Ride = { + ...mockRide, + id: 'ride_empty_telemetry', + points: undefined, + gpxContent: undefined, + csvContent: undefined, + telemetryBundlePath: undefined, + }; + + expect(() => generateRideGpx(emptyRide)).toThrow(/Cannot export ride ride_empty_telemetry: no recorded telemetry points available/); + expect(() => generateRideCsv(emptyRide)).toThrow(/Cannot export ride ride_empty_telemetry: no recorded telemetry points available/); + expect(() => generateRideGpx(emptyRide, [])).toThrow(/Cannot export ride ride_empty_telemetry: no recorded telemetry points available/); + expect(() => generateRideCsv(emptyRide, [])).toThrow(/Cannot export ride ride_empty_telemetry: no recorded telemetry points available/); + }); });