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
This commit is contained in:
Developer 2026-10-09 18:53:39 -04:00
parent 20da9ee27d
commit 3ba94777a1
16 changed files with 1039 additions and 87 deletions

View File

@ -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));
});
});

View File

@ -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();

View File

@ -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)}°'),

View File

@ -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),
],
),
),
],
),

View File

@ -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',

View File

@ -30,8 +30,10 @@ Future<void> 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');

View File

@ -338,7 +338,10 @@ final hereStatusProvider = StateProvider<RoadAlertProviderStatus>((ref) {
// Waze Police Alerts API Key (OpenWebNinja)
class WazeApiKeyNotifier extends StateNotifier<String> {
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();

View File

@ -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',

View File

@ -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<String, dynamic>) {
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<String, dynamic> map = {};
if (specJson != null && specJson.isNotEmpty) {
try {
final decoded = json.decode(specJson);
if (decoded is Map<String, dynamic>) {
map = Map<String, dynamic>.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 = <String, GarageMotorcycle>{};
final localByAttributes = <String, GarageMotorcycle>{};
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(),

View File

@ -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<StateError>().having(
(e) => e.message,
'message',
contains('Android QA flavor cannot connect to production Firebase'),
)),
);
expect(
() => DefaultFirebaseOptions.validateFirebaseOptions(nonDemoOptions),
throwsA(isA<StateError>()),
);
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')));
}
});
});
}

View File

@ -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<AuthState> get authStateChanges => Stream.value(currentState);
@override
bool get isConfigured => true;
@override
bool get isEmailVerified => true;
@override
Future<void> createUserWithEmailAndPassword(String email, String password, {String? displayName}) async {}
@override
Future<void> deleteCurrentUser() async {}
@override
void dispose() {}
@override
Future<void> ensureBackendIdentity() async {}
@override
Future<String?> getIdToken([bool forceRefresh = false]) async => 'fake-token';
@override
Future<void> reauthenticate({String? password}) async {}
@override
Future<void> reloadCurrentUser() async {}
@override
Future<void> sendEmailVerification() async {}
@override
Future<void> sendPasswordResetEmail(String email) async {}
@override
Future<void> signInAnonymously() async {}
@override
Future<void> signInWithEmailAndPassword(String email, String password) async {}
@override
Future<void> signInWithGoogle() async {}
@override
Future<void> 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<String, dynamic>;
expect(decoded['displacementCc'], 890);
expect(decoded['curbWeightKg'], 193);
expect(decoded['fuelCapacityL'], 14.0);
expect(decoded['cloudId'], 'moto_cloud_99');
});
});
}

View File

@ -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));
});
});
}

View File

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

View File

@ -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<TelemetryPoint[]> {
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.
*/

View File

@ -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();

View File

@ -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/);
});
});