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:
parent
20da9ee27d
commit
3ba94777a1
|
|
@ -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));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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)}°'),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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')));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue