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({
|
testEnv = await initializeTestEnvironment({
|
||||||
projectId,
|
projectId,
|
||||||
firestore: {
|
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.longitude == resource.data.longitude
|
||||||
&& request.resource.data.reportedAt == resource.data.reportedAt
|
&& request.resource.data.reportedAt == resource.data.reportedAt
|
||||||
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(['confirmations', 'dismissals', 'expiresAt'])
|
&& 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();
|
allow read: if signedIn();
|
||||||
|
|
|
||||||
|
|
@ -163,8 +163,10 @@ class LeanAngleIndicator extends ConsumerWidget {
|
||||||
width: 0,
|
width: 0,
|
||||||
height: 0,
|
height: 0,
|
||||||
child: OverflowBox(
|
child: OverflowBox(
|
||||||
maxWidth: 0,
|
minWidth: 0,
|
||||||
maxHeight: 0,
|
maxWidth: 300,
|
||||||
|
minHeight: 0,
|
||||||
|
maxHeight: 50,
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Text('L: ${currentLeft.toStringAsFixed(0)}°'),
|
Text('L: ${currentLeft.toStringAsFixed(0)}°'),
|
||||||
|
|
|
||||||
|
|
@ -411,22 +411,26 @@ class ResponsiveDashboardLayout extends ConsumerWidget {
|
||||||
children: [
|
children: [
|
||||||
const GForceIndicator(compact: true),
|
const GForceIndicator(compact: true),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Row(
|
FittedBox(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
fit: BoxFit.scaleDown,
|
||||||
children: [
|
child: Row(
|
||||||
MetricTile(
|
mainAxisSize: MainAxisSize.min,
|
||||||
label: 'TRIP',
|
children: [
|
||||||
value: session.tripDistanceMiles.toStringAsFixed(1),
|
MetricTile(
|
||||||
unit: 'MI',
|
label: 'TRIP',
|
||||||
compact: true,
|
value: session.tripDistanceMiles.toStringAsFixed(1),
|
||||||
),
|
unit: 'MI',
|
||||||
MetricTile(
|
compact: true,
|
||||||
label: 'TOP',
|
),
|
||||||
value: session.topSpeedMph.toStringAsFixed(0),
|
const SizedBox(width: 8),
|
||||||
unit: 'MPH',
|
MetricTile(
|
||||||
compact: true,
|
label: 'TOP',
|
||||||
),
|
value: session.topSpeedMph.toStringAsFixed(0),
|
||||||
],
|
unit: 'MPH',
|
||||||
|
compact: true,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -438,14 +442,20 @@ class ResponsiveDashboardLayout extends ConsumerWidget {
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
const Row(
|
const FittedBox(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
fit: BoxFit.scaleDown,
|
||||||
children: [
|
child: Row(
|
||||||
DashboardLayoutSwitcher(),
|
mainAxisSize: MainAxisSize.min,
|
||||||
GPSStatusIndicator(showLabel: false),
|
children: [
|
||||||
HeadingIndicator(compact: true),
|
DashboardLayoutSwitcher(),
|
||||||
RideStatusChip(),
|
SizedBox(width: 4),
|
||||||
],
|
GPSStatusIndicator(showLabel: false),
|
||||||
|
SizedBox(width: 4),
|
||||||
|
HeadingIndicator(compact: true),
|
||||||
|
SizedBox(width: 4),
|
||||||
|
RideStatusChip(),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const Center(child: SpeedReadout(size: SpeedReadoutSize.compact)),
|
const Center(child: SpeedReadout(size: SpeedReadoutSize.compact)),
|
||||||
const RideActionButton(compact: true),
|
const RideActionButton(compact: true),
|
||||||
|
|
@ -463,17 +473,21 @@ class ResponsiveDashboardLayout extends ConsumerWidget {
|
||||||
children: [
|
children: [
|
||||||
const LeanAngleIndicator(compact: true),
|
const LeanAngleIndicator(compact: true),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Row(
|
FittedBox(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
fit: BoxFit.scaleDown,
|
||||||
children: [
|
child: Row(
|
||||||
MetricTile(
|
mainAxisSize: MainAxisSize.min,
|
||||||
label: 'AVG',
|
children: [
|
||||||
value: session.averageSpeedMph.toStringAsFixed(0),
|
MetricTile(
|
||||||
unit: 'MPH',
|
label: 'AVG',
|
||||||
compact: true,
|
value: session.averageSpeedMph.toStringAsFixed(0),
|
||||||
),
|
unit: 'MPH',
|
||||||
const PerformanceSummary(compact: true),
|
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
|
// ignore_for_file: type=lint
|
||||||
import 'package:firebase_core/firebase_core.dart' show FirebaseOptions;
|
import 'package:firebase_core/firebase_core.dart' show FirebaseOptions;
|
||||||
import 'package:flutter/foundation.dart'
|
import 'package:flutter/foundation.dart'
|
||||||
show defaultTargetPlatform, kIsWeb, TargetPlatform;
|
show defaultTargetPlatform, kIsWeb, TargetPlatform;
|
||||||
|
|
||||||
|
import 'package:flutter/services.dart' show appFlavor;
|
||||||
|
|
||||||
/// Default [FirebaseOptions] for use with your Firebase apps.
|
/// Default [FirebaseOptions] for use with your Firebase apps.
|
||||||
class DefaultFirebaseOptions {
|
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 {
|
static FirebaseOptions get currentPlatform {
|
||||||
|
if (isQa) {
|
||||||
|
if (defaultTargetPlatform == TargetPlatform.android) {
|
||||||
|
return androidQa;
|
||||||
|
}
|
||||||
|
return qaOptions;
|
||||||
|
}
|
||||||
if (kIsWeb) {
|
if (kIsWeb) {
|
||||||
return web;
|
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(
|
static const FirebaseOptions android = FirebaseOptions(
|
||||||
apiKey: 'AIzaSyD-ie8zTaAWvFUlCG5bHqRhfHKOcu4jPtg',
|
apiKey: 'AIzaSyD-ie8zTaAWvFUlCG5bHqRhfHKOcu4jPtg',
|
||||||
appId: '1:809431485362:android:06fdcd9ca12ff9342472ab',
|
appId: '1:809431485362:android:06fdcd9ca12ff9342472ab',
|
||||||
|
|
|
||||||
|
|
@ -30,8 +30,10 @@ Future<void> main() async {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
final options = DefaultFirebaseOptions.currentPlatform;
|
||||||
|
DefaultFirebaseOptions.validateFirebaseOptions(options);
|
||||||
await Firebase.initializeApp(
|
await Firebase.initializeApp(
|
||||||
options: DefaultFirebaseOptions.currentPlatform,
|
options: options,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[Firebase] Standalone offline mode active: $e');
|
debugPrint('[Firebase] Standalone offline mode active: $e');
|
||||||
|
|
|
||||||
|
|
@ -338,7 +338,10 @@ final hereStatusProvider = StateProvider<RoadAlertProviderStatus>((ref) {
|
||||||
// Waze Police Alerts API Key (OpenWebNinja)
|
// Waze Police Alerts API Key (OpenWebNinja)
|
||||||
class WazeApiKeyNotifier extends StateNotifier<String> {
|
class WazeApiKeyNotifier extends StateNotifier<String> {
|
||||||
static const _key = 'waze_api_key';
|
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) {
|
WazeApiKeyNotifier() : super(defaultKey) {
|
||||||
_load();
|
_load();
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,20 @@ import 'road_alert_provider_interface.dart';
|
||||||
/// (`alert_types=POLICE`, `max_jams=0`) around the rider's coordinate.
|
/// (`alert_types=POLICE`, `max_jams=0`) around the rider's coordinate.
|
||||||
class WazeRoadAlertProvider implements RoadAlertProvider {
|
class WazeRoadAlertProvider implements RoadAlertProvider {
|
||||||
final http.Client _client;
|
final http.Client _client;
|
||||||
|
final String apiHost;
|
||||||
|
|
||||||
WazeRoadAlertProvider({http.Client? client})
|
WazeRoadAlertProvider({
|
||||||
: _client = client ?? http.Client();
|
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
|
@override
|
||||||
String get id => 'waze';
|
String get id => 'waze';
|
||||||
|
|
@ -35,7 +46,7 @@ class WazeRoadAlertProvider implements RoadAlertProvider {
|
||||||
try {
|
try {
|
||||||
// Diagnostic check: test point around downtown Los Angeles with max_alerts=1
|
// Diagnostic check: test point around downtown Los Angeles with max_alerts=1
|
||||||
final uri = Uri.https(
|
final uri = Uri.https(
|
||||||
'api.openwebninja.com',
|
apiHost,
|
||||||
'/waze/alerts-and-jams',
|
'/waze/alerts-and-jams',
|
||||||
{
|
{
|
||||||
'center': '34.0522,-118.2437',
|
'center': '34.0522,-118.2437',
|
||||||
|
|
@ -80,7 +91,7 @@ class WazeRoadAlertProvider implements RoadAlertProvider {
|
||||||
try {
|
try {
|
||||||
final radiusKm = (radiusMeters / 1000.0).clamp(1.0, 50.0).toStringAsFixed(1);
|
final radiusKm = (radiusMeters / 1000.0).clamp(1.0, 50.0).toStringAsFixed(1);
|
||||||
final uri = Uri.https(
|
final uri = Uri.https(
|
||||||
'api.openwebninja.com',
|
apiHost,
|
||||||
'/waze/alerts-and-jams',
|
'/waze/alerts-and-jams',
|
||||||
{
|
{
|
||||||
'center': '$latitude,$longitude',
|
'center': '$latitude,$longitude',
|
||||||
|
|
@ -158,7 +169,7 @@ class WazeRoadAlertProvider implements RoadAlertProvider {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final uri = Uri.https(
|
final uri = Uri.https(
|
||||||
'api.openwebninja.com',
|
apiHost,
|
||||||
'/waze/alerts-and-jams',
|
'/waze/alerts-and-jams',
|
||||||
{
|
{
|
||||||
'bottom_left': '$bottomLat,$bottomLng',
|
'bottom_left': '$bottomLat,$bottomLng',
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import 'dart:convert';
|
||||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
import 'package:drift/drift.dart';
|
import 'package:drift/drift.dart';
|
||||||
import 'package:firebase_core/firebase_core.dart';
|
import 'package:firebase_core/firebase_core.dart';
|
||||||
|
|
@ -12,12 +13,14 @@ class FirebaseAccountSyncService {
|
||||||
final AppDatabase db;
|
final AppDatabase db;
|
||||||
final AuthService auth;
|
final AuthService auth;
|
||||||
final FirebaseFirestore? _firestore;
|
final FirebaseFirestore? _firestore;
|
||||||
|
final bool _customFirestore;
|
||||||
|
|
||||||
FirebaseAccountSyncService({
|
FirebaseAccountSyncService({
|
||||||
required this.db,
|
required this.db,
|
||||||
required this.auth,
|
required this.auth,
|
||||||
FirebaseFirestore? firestore,
|
FirebaseFirestore? firestore,
|
||||||
}) : _firestore = firestore ?? _resolveFirestore();
|
}) : _customFirestore = firestore != null,
|
||||||
|
_firestore = firestore ?? _resolveFirestore();
|
||||||
|
|
||||||
static FirebaseFirestore? _resolveFirestore() {
|
static FirebaseFirestore? _resolveFirestore() {
|
||||||
try {
|
try {
|
||||||
|
|
@ -26,7 +29,50 @@ class FirebaseAccountSyncService {
|
||||||
return null;
|
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) {
|
DateTime? _parseDateTime(dynamic value) {
|
||||||
if (value == null) return null;
|
if (value == null) return null;
|
||||||
|
|
@ -52,12 +98,22 @@ class FirebaseAccountSyncService {
|
||||||
final userRef = _firestore!.collection('users').doc(user.uid);
|
final userRef = _firestore!.collection('users').doc(user.uid);
|
||||||
await userRef.set(profile.toJson(), SetOptions(merge: true));
|
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 {
|
try {
|
||||||
final remoteSnapshot = await userRef.collection('motorcycles').get();
|
final remoteSnapshot = await userRef.collection('motorcycles').get();
|
||||||
final localBikes = await db.select(db.garageMotorcycles).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) {
|
for (final doc in remoteSnapshot.docs) {
|
||||||
final data = doc.data();
|
final data = doc.data();
|
||||||
|
|
@ -65,33 +121,54 @@ class FirebaseAccountSyncService {
|
||||||
final year = (data['year'] as num?)?.toInt() ?? 2000;
|
final year = (data['year'] as num?)?.toInt() ?? 2000;
|
||||||
final makeName = (data['makeName'] ?? '').toString();
|
final makeName = (data['makeName'] ?? '').toString();
|
||||||
final modelName = (data['modelName'] ?? '').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) {
|
if (existing == null) {
|
||||||
final parsedId = int.tryParse(docId);
|
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(
|
await db.into(db.garageMotorcycles).insert(
|
||||||
GarageMotorcyclesCompanion(
|
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),
|
year: Value(year),
|
||||||
makeId: Value((data['makeId'] ?? parsedId?.toString() ?? '1').toString()),
|
makeId: Value((data['makeId'] ?? '1').toString()),
|
||||||
makeName: Value(makeName),
|
makeName: Value(makeName),
|
||||||
modelId: Value((data['modelId'] ?? parsedId?.toString() ?? '1').toString()),
|
modelId: Value((data['modelId'] ?? '1').toString()),
|
||||||
modelName: Value(modelName),
|
modelName: Value(modelName),
|
||||||
variantId: Value(data['variantId']?.toString()),
|
variantId: Value(data['variantId']?.toString()),
|
||||||
variantName: Value(data['variantName']?.toString()),
|
variantName: Value(data['variantName']?.toString()),
|
||||||
nickname: Value(data['nickname']?.toString()),
|
nickname: Value(data['nickname']?.toString()),
|
||||||
isDefault: Value(data['isDefault'] == true),
|
isDefault: Value(data['isDefault'] == true && localBikes.isEmpty),
|
||||||
dateAdded: Value(_parseDateTime(data['createdAt']) ?? timestamp),
|
dateAdded: Value(_parseDateTime(data['createdAt']) ?? timestamp),
|
||||||
lastUpdated: Value(_parseDateTime(data['updatedAt']) ?? 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) {
|
} else {
|
||||||
await (db.update(db.garageMotorcycles)..where((t) => t.id.equals(existing.id)))
|
// 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(
|
.write(GarageMotorcyclesCompanion(
|
||||||
specificationJson: Value(data['specificationJson']?.toString()),
|
specificationJson: Value(updatedSpec),
|
||||||
lastUpdated: Value(timestamp),
|
lastUpdated: Value(timestamp),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
@ -100,11 +177,28 @@ class FirebaseAccountSyncService {
|
||||||
// Remote fetch error should not block local operations
|
// 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();
|
final currentLocal = await db.select(db.garageMotorcycles).get();
|
||||||
for (final motorcycle in currentLocal) {
|
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(
|
final cloud = CloudMotorcycle(
|
||||||
id: motorcycle.id.toString(),
|
id: cloudId,
|
||||||
ownerId: user.uid,
|
ownerId: user.uid,
|
||||||
year: motorcycle.year,
|
year: motorcycle.year,
|
||||||
makeId: motorcycle.makeId,
|
makeId: motorcycle.makeId,
|
||||||
|
|
@ -151,6 +245,9 @@ class FirebaseAccountSyncService {
|
||||||
final model = r.motorcycleModel ?? 'Unknown';
|
final model = r.motorcycleModel ?? 'Unknown';
|
||||||
final variant = r.motorcycleVariant;
|
final variant = r.motorcycleVariant;
|
||||||
|
|
||||||
|
final cloudId = generateCloudMotorcycleId(uid, bikeId, year, make, model);
|
||||||
|
final specWithCloudId = attachCloudId(null, cloudId);
|
||||||
|
|
||||||
await db.into(db.garageMotorcycles).insert(
|
await db.into(db.garageMotorcycles).insert(
|
||||||
GarageMotorcyclesCompanion(
|
GarageMotorcyclesCompanion(
|
||||||
id: Value(bikeId),
|
id: Value(bikeId),
|
||||||
|
|
@ -163,12 +260,13 @@ class FirebaseAccountSyncService {
|
||||||
isDefault: const Value(true),
|
isDefault: const Value(true),
|
||||||
dateAdded: Value(timestamp),
|
dateAdded: Value(timestamp),
|
||||||
lastUpdated: Value(timestamp),
|
lastUpdated: Value(timestamp),
|
||||||
|
specificationJson: Value(specWithCloudId),
|
||||||
),
|
),
|
||||||
mode: InsertMode.insertOrReplace,
|
mode: InsertMode.insertOrReplace,
|
||||||
);
|
);
|
||||||
|
|
||||||
final cloud = CloudMotorcycle(
|
final cloud = CloudMotorcycle(
|
||||||
id: bikeId.toString(),
|
id: cloudId,
|
||||||
ownerId: uid,
|
ownerId: uid,
|
||||||
year: year,
|
year: year,
|
||||||
makeId: bikeId.toString(),
|
makeId: bikeId.toString(),
|
||||||
|
|
@ -179,6 +277,7 @@ class FirebaseAccountSyncService {
|
||||||
isDefault: true,
|
isDefault: true,
|
||||||
createdAt: timestamp,
|
createdAt: timestamp,
|
||||||
updatedAt: timestamp,
|
updatedAt: timestamp,
|
||||||
|
specificationJson: specWithCloudId,
|
||||||
);
|
);
|
||||||
await userRef.collection('motorcycles').doc(cloud.id).set(
|
await userRef.collection('motorcycles').doc(cloud.id).set(
|
||||||
cloud.toJson(),
|
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));
|
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();
|
final container = ProviderContainer();
|
||||||
addTearDown(container.dispose);
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
final initialKey = container.read(wazeApiKeyProvider);
|
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');
|
await container.read(wazeApiKeyProvider.notifier).setApiKey('new_ninja_key_999');
|
||||||
expect(container.read(wazeApiKeyProvider), equals('new_ninja_key_999'));
|
expect(container.read(wazeApiKeyProvider), equals('new_ninja_key_999'));
|
||||||
|
|
@ -367,12 +368,32 @@ void main() {
|
||||||
expect(container.read(wazeRoadAlertsEnabledProvider), isFalse);
|
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();
|
final container = ProviderContainer();
|
||||||
addTearDown(container.dispose);
|
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);
|
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,
|
generateRideGpx,
|
||||||
generateRideCsv,
|
generateRideCsv,
|
||||||
downloadFile,
|
downloadFile,
|
||||||
|
resolveRideTelemetryPoints,
|
||||||
TelemetryPoint,
|
TelemetryPoint,
|
||||||
} from '../lib/telemetry-export';
|
} 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();
|
e.stopPropagation();
|
||||||
const gpx = generateRideGpx(ride);
|
try {
|
||||||
const dateStr = ride.startedAt ? ride.startedAt.toISOString().split('T')[0] : 'session';
|
setExportNotice('Loading recorded telemetry for export…');
|
||||||
const filename = `motorsignal_${ride.id}_${dateStr}.gpx`;
|
const points = await resolveRideTelemetryPoints(ride);
|
||||||
downloadFile(gpx, filename, 'application/gpx+xml');
|
if (!points.length) {
|
||||||
setExportNotice(`Exported GPX: ${filename}`);
|
throw new Error('No recorded telemetry points available.');
|
||||||
setTimeout(() => setExportNotice(null), 3000);
|
}
|
||||||
|
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();
|
e.stopPropagation();
|
||||||
const csv = generateRideCsv(ride);
|
try {
|
||||||
const dateStr = ride.startedAt ? ride.startedAt.toISOString().split('T')[0] : 'session';
|
setExportNotice('Loading recorded telemetry for export…');
|
||||||
const filename = `motorsignal_20hz_${ride.id}_${dateStr}.csv`;
|
const points = await resolveRideTelemetryPoints(ride);
|
||||||
downloadFile(csv, filename, 'text/csv;charset=utf-8;');
|
if (!points.length) {
|
||||||
setExportNotice(`Exported 20 Hz CSV: ${filename}`);
|
throw new Error('No recorded telemetry points available.');
|
||||||
setTimeout(() => setExportNotice(null), 3000);
|
}
|
||||||
|
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
|
// Handle Delete Ride Session
|
||||||
|
|
|
||||||
|
|
@ -48,11 +48,17 @@ export function downloadFile(content: string, filename: string, mimeType: string
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import { firebaseAuth } from './firebase-client';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Authoritative GPX 1.1 Exporter matching MotorSignal's ExportService schema.
|
* 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 {
|
export function generateRideGpx(ride: Ride, points?: TelemetryPoint[]): string {
|
||||||
const telemetry = points && points.length > 0 ? points : synthesizeTelemetryTrack(ride);
|
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 rideDate = ride.startedAt ? ride.startedAt.toISOString().split('T')[0] : 'session';
|
||||||
const rideTitle = `${ride.motorcycle || 'Motorcycle'} Ride - ${rideDate}`;
|
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.
|
* 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 {
|
export function generateRideCsv(ride: Ride, points?: TelemetryPoint[]): string {
|
||||||
const telemetry = points && points.length > 0 ? points : synthesizeTelemetryTrack(ride);
|
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 header = 'timestamp,latitude,longitude,altitude,speed,heading,gpsAccuracy,leanAngle,pitchAngle,accelX,accelY,accelZ,longitudinalG,lateralG';
|
||||||
|
|
||||||
const rows = telemetry.map((pt) =>
|
const rows = telemetry.map((pt) =>
|
||||||
|
|
@ -123,6 +133,42 @@ export function generateRideCsv(ride: Ride, points?: TelemetryPoint[]): string {
|
||||||
return [header, ...rows].join('\n');
|
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.
|
* Escape XML special characters.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -1308,8 +1308,18 @@ service cloud.firestore {
|
||||||
&& request.resource.data.longitude == resource.data.longitude
|
&& request.resource.data.longitude == resource.data.longitude
|
||||||
&& request.resource.data.reportedAt == resource.data.reportedAt
|
&& request.resource.data.reportedAt == resource.data.reportedAt
|
||||||
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(['confirmations', 'dismissals', 'expiresAt'])
|
&& 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();
|
allow read: if signedIn();
|
||||||
|
|
|
||||||
|
|
@ -124,4 +124,20 @@ describe('Web Telemetry Replay & Export (WEB-23)', () => {
|
||||||
fireEvent.click(screen.getByTestId('close-replay-modal'));
|
fireEvent.click(screen.getByTestId('close-replay-modal'));
|
||||||
expect(onClose).toHaveBeenCalled();
|
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