From 209987195b27a8449726b5b256c4413ed9997100 Mon Sep 17 00:00:00 2001 From: Developer Date: Fri, 4 Sep 2026 00:54:46 -0400 Subject: [PATCH] fix: harden live group ride runtime lifecycle --- android/app/src/main/AndroidManifest.xml | 1 - .../emulator-tests/firestore.rules.test.js | 168 ++++++ firestore.rules | 17 +- .../group_rides/live_group_ride_screen.dart | 39 +- .../live_broadcast_controller.dart | 117 +++- lib/services/location_service.dart | 3 - test/live_broadcast_controller_test.dart | 569 +++++++++++++++--- 7 files changed, 803 insertions(+), 111 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index f844493..58a0b57 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -2,7 +2,6 @@ - diff --git a/firebase/emulator-tests/firestore.rules.test.js b/firebase/emulator-tests/firestore.rules.test.js index d2ea2d7..c1c2eb1 100644 --- a/firebase/emulator-tests/firestore.rules.test.js +++ b/firebase/emulator-tests/firestore.rules.test.js @@ -793,6 +793,174 @@ describe('Social Architecture Security Rules', () => { // Bob CANNOT read Alice's presence await assertFails(getDoc(doc(bob, 'group_rides/ride_lifecycle/live_presence/alice'))); }); + + test('quick status security: organizer cannot impersonate or modify another rider status', async () => { + const alice = testEnv.authenticatedContext('alice').firestore(); + const bob = testEnv.authenticatedContext('bob').firestore(); + + await testEnv.withSecurityRulesDisabled(async (context) => { + const db = context.firestore(); + await setDoc(doc(db, 'group_rides/ride_status_sec'), { + id: 'ride_status_sec', + organizerUid: 'alice', + title: 'Status Security Ride', + status: 'underway', + scheduledStartAt: '2026-09-10T09:00:00Z', + departureAt: '2026-09-10T09:30:00Z', + participantCount: 2, + visibility: 'public', + meetingPoint: { latitude: 37.77, longitude: -122.41, displayName: 'SF' }, + }); + + await setDoc(doc(db, 'group_rides/ride_status_sec/participants/alice'), { + userId: 'alice', + role: 'organizer', + status: 'accepted', + riderHandle: 'alice', + riderDisplayName: 'Alice', + }); + + await setDoc(doc(db, 'group_rides/ride_status_sec/participants/bob'), { + userId: 'bob', + role: 'participant', + status: 'accepted', + riderHandle: 'bob', + riderDisplayName: 'Bob', + }); + + await setDoc(doc(db, 'group_rides/ride_status_sec/live_presence/bob'), { + userId: 'bob', + groupRideId: 'ride_status_sec', + latitude: 37.78, + longitude: -122.41, + heading: 0.0, + speedMph: 25.0, + accuracyMeters: 5.0, + updatedAt: '2026-09-10T09:40:00Z', + expiresAt: '2026-09-10T09:42:00Z', + sharingEnabled: true, + status: 'riding', + }); + }); + + // 1. Bob can update his own quick status to 'needFuel' + await assertSucceeds(updateDoc(doc(bob, 'group_rides/ride_status_sec/live_presence/bob'), { + status: 'needFuel', + updatedAt: '2026-09-10T09:41:00Z', + })); + + // 2. Organizer Alice CANNOT update Bob's presence document or status + await assertFails(updateDoc(doc(alice, 'group_rides/ride_status_sec/live_presence/bob'), { + status: 'riding', + })); + + // 3. Organizer Alice CANNOT create a presence document under Bob's UID + await assertFails(setDoc(doc(alice, 'group_rides/ride_status_sec/live_presence/bob'), { + userId: 'bob', + groupRideId: 'ride_status_sec', + latitude: 37.78, + longitude: -122.41, + heading: 0.0, + speedMph: 25.0, + accuracyMeters: 5.0, + updatedAt: '2026-09-10T09:42:00Z', + expiresAt: '2026-09-10T09:44:00Z', + sharingEnabled: true, + status: 'arrived', + })); + }); + + test('session authority: organizer transitions, participant denials, and terminal states', async () => { + const alice = testEnv.authenticatedContext('alice').firestore(); + const bob = testEnv.authenticatedContext('bob').firestore(); + + await testEnv.withSecurityRulesDisabled(async (context) => { + const db = context.firestore(); + await setDoc(doc(db, 'group_rides/ride_transition_test'), { + id: 'ride_transition_test', + organizerUid: 'alice', + title: 'Session Authority Ride', + status: 'scheduled', + scheduledStartAt: '2026-09-10T09:00:00Z', + departureAt: '2026-09-10T09:30:00Z', + participantCount: 2, + visibility: 'public', + meetingPoint: { latitude: 37.77, longitude: -122.41, displayName: 'SF' }, + }); + + await setDoc(doc(db, 'group_rides/ride_transition_test/participants/alice'), { + userId: 'alice', + role: 'organizer', + status: 'accepted', + riderHandle: 'alice', + riderDisplayName: 'Alice', + }); + + await setDoc(doc(db, 'group_rides/ride_transition_test/participants/bob'), { + userId: 'bob', + role: 'participant', + status: 'accepted', + riderHandle: 'bob', + riderDisplayName: 'Bob', + }); + }); + + // 1. Participant Bob CANNOT start the ride (scheduled -> underway) + await assertFails(updateDoc(doc(bob, 'group_rides/ride_transition_test'), { + status: 'underway', + })); + + // 2. Participant Bob CANNOT cancel the ride + await assertFails(updateDoc(doc(bob, 'group_rides/ride_transition_test'), { + status: 'canceled', + })); + + // 3. Organizer Alice transitions scheduled -> assembling: SUCCEEDS + await assertSucceeds(updateDoc(doc(alice, 'group_rides/ride_transition_test'), { + status: 'assembling', + })); + + // 4. Organizer Alice transitions assembling -> underway: SUCCEEDS + await assertSucceeds(updateDoc(doc(alice, 'group_rides/ride_transition_test'), { + status: 'underway', + })); + + // 5. Participant Bob CANNOT complete the ride + await assertFails(updateDoc(doc(bob, 'group_rides/ride_transition_test'), { + status: 'completed', + })); + + // 6. Organizer Alice transitions underway -> completed: SUCCEEDS + await assertSucceeds(updateDoc(doc(alice, 'group_rides/ride_transition_test'), { + status: 'completed', + })); + + // 7. INVALID TRANSITION: completed -> underway MUST BE DENIED for organizer Alice + await assertFails(updateDoc(doc(alice, 'group_rides/ride_transition_test'), { + status: 'underway', + })); + + // 8. Setup a canceled ride and verify canceled -> underway is denied + await testEnv.withSecurityRulesDisabled(async (context) => { + const db = context.firestore(); + await setDoc(doc(db, 'group_rides/ride_canceled_test'), { + id: 'ride_canceled_test', + organizerUid: 'alice', + title: 'Canceled Authority Ride', + status: 'canceled', + scheduledStartAt: '2026-09-10T09:00:00Z', + departureAt: '2026-09-10T09:30:00Z', + participantCount: 2, + visibility: 'public', + meetingPoint: { latitude: 37.77, longitude: -122.41, displayName: 'SF' }, + }); + }); + + // INVALID TRANSITION: canceled -> underway MUST BE DENIED + await assertFails(updateDoc(doc(alice, 'group_rides/ride_canceled_test'), { + status: 'underway', + })); + }); }); }); diff --git a/firestore.rules b/firestore.rules index e061d50..f78f416 100644 --- a/firestore.rules +++ b/firestore.rules @@ -221,13 +221,22 @@ service cloud.firestore { && request.resource.data.id == groupRideId && request.resource.data.keys().hasAll(['id', 'organizerUid', 'title', 'scheduledStartAt', 'departureAt', 'meetingPoint']) && request.resource.data.participantCount >= 1; + function validStatusTransition() { + let prev = resource.data.status; + let next = request.resource.data.status; + return prev == next || + (prev == 'scheduled' && next in ['assembling', 'underway', 'canceled']) || + (prev == 'assembling' && next in ['underway', 'canceled']) || + (prev == 'underway' && next in ['completed', 'canceled']); + } + allow update: if signedIn() && ( - // Organizer can update any ride authority fields - isOrganizer() || - // Participants/invitees can atomically adjust participantCount during join/leave transactions + // Organizer can update ride authority fields with valid status transitions + (isOrganizer() && validStatusTransition()) || + // Participants/invitees can only atomically adjust participantCount during join/leave transactions ( canReadRide() && - request.resource.data.diff(resource.data).affectedKeys().hasOnly(['participantCount', 'updatedAt', 'status', 'canceledReason', 'canceledAt']) + request.resource.data.diff(resource.data).affectedKeys().hasOnly(['participantCount', 'updatedAt']) ) ); allow delete: if isOrganizer(); diff --git a/lib/features/group_rides/live_group_ride_screen.dart b/lib/features/group_rides/live_group_ride_screen.dart index bb01469..2180980 100644 --- a/lib/features/group_rides/live_group_ride_screen.dart +++ b/lib/features/group_rides/live_group_ride_screen.dart @@ -19,10 +19,30 @@ class LiveGroupRideScreen extends ConsumerStatefulWidget { _LiveGroupRideScreenState(); } -class _LiveGroupRideScreenState extends ConsumerState { +class _LiveGroupRideScreenState extends ConsumerState + with WidgetsBindingObserver { AppLatLng? _customMapCenter; double _mapZoom = 15.0; + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + ref + .read(liveBroadcastControllerProvider(widget.rideId).notifier) + .updateAppLifecycle(state); + } + @override Widget build(BuildContext context) { final rideAsync = ref.watch(groupRideStreamProvider(widget.rideId)); @@ -616,12 +636,18 @@ class _LiveGroupRideScreenState extends ConsumerState { children: [ Text( broadcastState.isBroadcasting - ? 'SHARING LIVE' + ? (broadcastState.isPaused + ? 'SHARING PAUSED' + : (broadcastState.isBackground + ? 'BACKGROUND SHARING' + : 'SHARING LIVE')) : 'GHOST MODE', overflow: TextOverflow.ellipsis, style: TextStyle( color: broadcastState.isBroadcasting - ? const Color(0xFF00E5FF) + ? (broadcastState.isPaused + ? Colors.amber + : const Color(0xFF00E5FF)) : Colors.white60, fontWeight: FontWeight.bold, fontSize: 11, @@ -630,7 +656,11 @@ class _LiveGroupRideScreenState extends ConsumerState { ), Text( broadcastState.isBroadcasting - ? 'Broadcasting to riders' + ? (broadcastState.isPaused + ? 'Reconnecting (${broadcastState.retryCount})...' + : (broadcastState.isBackground + ? 'Active via foreground service' + : 'Broadcasting to riders')) : 'Location paused', overflow: TextOverflow.ellipsis, style: const TextStyle(color: Colors.white38, fontSize: 10), @@ -640,6 +670,7 @@ class _LiveGroupRideScreenState extends ConsumerState { ), Switch( value: broadcastState.isOptedIn, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, activeThumbColor: const Color(0xFF00E5FF), onChanged: (val) => broadcastController.setOptIn(val), ), diff --git a/lib/services/group_rides/live_broadcast_controller.dart b/lib/services/group_rides/live_broadcast_controller.dart index 50c27c7..def11b6 100644 --- a/lib/services/group_rides/live_broadcast_controller.dart +++ b/lib/services/group_rides/live_broadcast_controller.dart @@ -1,8 +1,9 @@ import 'dart:async'; -import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:geolocator/geolocator.dart'; import '../auth/auth_providers.dart'; +import '../location_service.dart'; import 'group_ride_models.dart'; import 'group_ride_providers.dart'; import 'group_ride_service.dart'; @@ -14,6 +15,9 @@ class LiveBroadcastState { final bool isMasterAllowed; final bool isEligible; final bool isBroadcasting; + final bool isPaused; + final int retryCount; + final bool isBackground; final LivePresenceStatus status; final Position? lastPosition; final DateTime? lastPublishedAt; @@ -25,6 +29,9 @@ class LiveBroadcastState { this.isMasterAllowed = false, this.isEligible = false, this.isBroadcasting = false, + this.isPaused = false, + this.retryCount = 0, + this.isBackground = false, this.status = LivePresenceStatus.riding, this.lastPosition, this.lastPublishedAt, @@ -37,6 +44,9 @@ class LiveBroadcastState { bool? isMasterAllowed, bool? isEligible, bool? isBroadcasting, + bool? isPaused, + int? retryCount, + bool? isBackground, LivePresenceStatus? status, Position? lastPosition, DateTime? lastPublishedAt, @@ -48,6 +58,9 @@ class LiveBroadcastState { isMasterAllowed: isMasterAllowed ?? this.isMasterAllowed, isEligible: isEligible ?? this.isEligible, isBroadcasting: isBroadcasting ?? this.isBroadcasting, + isPaused: isPaused ?? this.isPaused, + retryCount: retryCount ?? this.retryCount, + isBackground: isBackground ?? this.isBackground, status: status ?? this.status, lastPosition: lastPosition ?? this.lastPosition, lastPublishedAt: lastPublishedAt ?? this.lastPublishedAt, @@ -76,10 +89,15 @@ class LiveBroadcastController extends StateNotifier { StreamSubscription>? _presenceSub; Timer? _heartbeatTimer; + DateTime? _nextRetryTime; + int _consecutiveErrors = 0; + Position? _lastBroadcastPosition; DateTime? _lastBroadcastTime; bool _isDisposed = false; + final DateTime Function() _now; + LiveBroadcastController({ required this.rideId, required this.callerUid, @@ -89,7 +107,9 @@ class LiveBroadcastController extends StateNotifier { this.participantsStream, this.presenceStream, bool initialMasterAllowed = false, - }) : super(LiveBroadcastState(isMasterAllowed: initialMasterAllowed)) { + DateTime Function()? now, + }) : _now = now ?? DateTime.now, + super(LiveBroadcastState(isMasterAllowed: initialMasterAllowed)) { _initSubscriptions(); } @@ -181,6 +201,12 @@ class LiveBroadcastController extends StateNotifier { if (_isDisposed || state.isMasterAllowed == allowed) return; state = state.copyWith(isMasterAllowed: allowed); _evaluateEligibility(); + if (!allowed) { + service.clearLivePresence( + callerUid: callerUid, + groupRideId: rideId, + ); + } } /// Toggles per-ride opt-in for location broadcasting. @@ -188,7 +214,13 @@ class LiveBroadcastController extends StateNotifier { if (_isDisposed) return; if (state.isOptedIn == optIn) return; - state = state.copyWith(isOptedIn: optIn); + _consecutiveErrors = 0; + _nextRetryTime = null; + state = state.copyWith( + isOptedIn: optIn, + isPaused: false, + retryCount: 0, + ); _evaluateEligibility(); if (!optIn) { @@ -223,6 +255,16 @@ class LiveBroadcastController extends StateNotifier { await setStatus(LivePresenceStatus.riding); } + /// Updates app lifecycle state (e.g. background vs foreground). + void updateAppLifecycle(AppLifecycleState lifecycle) { + if (_isDisposed) return; + final isBg = lifecycle == AppLifecycleState.paused || + lifecycle == AppLifecycleState.hidden; + if (state.isBackground != isBg) { + state = state.copyWith(isBackground: isBg); + } + } + void _startBroadcasting() { _positionSub?.cancel(); final stream = positionStream ?? Geolocator.getPositionStream( @@ -255,6 +297,17 @@ class LiveBroadcastController extends StateNotifier { void _onPositionUpdate(Position position) { if (_isDisposed || !state.isBroadcasting) return; + // Reject invalid coordinates, out of bounds, or Null Island (0,0) + if (position.latitude.isNaN || + position.longitude.isNaN || + position.latitude < -90.0 || + position.latitude > 90.0 || + position.longitude < -180.0 || + position.longitude > 180.0 || + (position.latitude == 0.0 && position.longitude == 0.0)) { + return; + } + // Filter accuracy: reject fixes with accuracy > 30m if (position.accuracy > 30.0) { return; @@ -262,10 +315,16 @@ class LiveBroadcastController extends StateNotifier { state = state.copyWith(lastPosition: position); + final now = _now(); + + // Respect exponential backoff throttle during transient errors + if (_nextRetryTime != null && now.isBefore(_nextRetryTime!)) { + return; + } + final speedMph = position.speed * 2.23694; final isMoving = speedMph > 3.0; - final now = DateTime.now(); final lastTime = _lastBroadcastTime; if (lastTime == null) { @@ -276,7 +335,7 @@ class LiveBroadcastController extends StateNotifier { final elapsedSeconds = now.difference(lastTime).inSeconds; if (isMoving) { - // Moving cadence: 5 seconds with displacement > 15m or >= 5 seconds interval + // Moving cadence: 5 seconds with displacement > 15m or >= 10 seconds interval if (elapsedSeconds >= 5) { double displacement = 0; if (_lastBroadcastPosition != null) { @@ -304,7 +363,12 @@ class LiveBroadcastController extends StateNotifier { final lastTime = _lastBroadcastTime; if (lastTime == null) return; - final elapsed = DateTime.now().difference(lastTime).inSeconds; + final now = _now(); + if (_nextRetryTime != null && now.isBefore(_nextRetryTime!)) { + return; + } + + final elapsed = now.difference(lastTime).inSeconds; // If stopped or no fix received, keep heartbeat within 30s if (elapsed >= 30) { _publishPosition(state.lastPosition!, force: true); @@ -315,7 +379,7 @@ class LiveBroadcastController extends StateNotifier { if (!state.isBroadcasting && !force) return; final speedMph = position.speed * 2.23694; - final now = DateTime.now(); + final now = _now(); try { await service.publishLivePresence( @@ -332,19 +396,54 @@ class LiveBroadcastController extends StateNotifier { _lastBroadcastPosition = position; _lastBroadcastTime = now; + _consecutiveErrors = 0; + _nextRetryTime = null; state = state.copyWith( lastPublishedAt: now, errorMessage: null, + isPaused: false, + retryCount: 0, ); } catch (e) { - state = state.copyWith(errorMessage: 'Publish failed: $e'); + _handlePublishError(e); } } + void _handlePublishError(Object error) { + if (_isDisposed) return; + + final errStr = error.toString().toLowerCase(); + final isPermissionDenied = errStr.contains('permission-denied') || + errStr.contains('permission_denied') || + errStr.contains('unauthenticated'); + + if (isPermissionDenied) { + // Terminal: user removed, blocked, ride completed, or rules denied write. + // Immediately stop broadcasting, cancel timers, and do not retry. + _revokeBroadcasting('Authorization lost: permission denied'); + return; + } + + // Transient network/offline error: + _consecutiveErrors++; + // Exponential backoff capped at 30 seconds + final backoffSeconds = (5 * (1 << (_consecutiveErrors - 1))).clamp(5, 30); + _nextRetryTime = _now().add(Duration(seconds: backoffSeconds)); + + state = state.copyWith( + errorMessage: 'Publish failed: $error', + isPaused: true, + retryCount: _consecutiveErrors, + ); + } + void _revokeBroadcasting(String reason) { state = state.copyWith( isEligible: false, isBroadcasting: false, + isOptedIn: false, + isPaused: false, + retryCount: 0, errorMessage: reason, ); _stopBroadcasting(); @@ -380,12 +479,14 @@ final liveBroadcastControllerProvider = StateNotifierProvider.autoDispose final user = ref.watch(currentUserProvider); final service = ref.watch(groupRideServiceProvider); final masterAllowed = ref.watch(groupRideLiveSharingAllowedProvider); + final locationService = ref.watch(locationServiceProvider); final callerUid = user?.uid ?? ''; final controller = LiveBroadcastController( rideId: rideId, callerUid: callerUid, service: service, + positionStream: locationService.getFilteredPositionStream(), initialMasterAllowed: masterAllowed, ); diff --git a/lib/services/location_service.dart b/lib/services/location_service.dart index b8c497b..754daec 100644 --- a/lib/services/location_service.dart +++ b/lib/services/location_service.dart @@ -54,9 +54,6 @@ class LocationService { if (geoPermission == LocationPermission.whileInUse || geoPermission == LocationPermission.always || locStatus.isGranted) { - try { - await Permission.locationAlways.request(); - } catch (_) {} // Restart/refresh GPS stream now that permissions are granted _startListening(); return true; diff --git a/test/live_broadcast_controller_test.dart b/test/live_broadcast_controller_test.dart index 9d00f11..30caf14 100644 --- a/test/live_broadcast_controller_test.dart +++ b/test/live_broadcast_controller_test.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:geolocator/geolocator.dart'; import 'package:motorcycle_dash/services/group_rides/group_ride_models.dart'; @@ -8,6 +9,7 @@ import 'package:motorcycle_dash/services/group_rides/live_broadcast_controller.d class MockGroupRideService extends GroupRideService { final List> publishedPresences = []; final List clearedPresences = []; + Exception? errorToThrowOnPublish; @override Future publishLivePresence({ @@ -21,6 +23,9 @@ class MockGroupRideService extends GroupRideService { LivePresenceStatus status = LivePresenceStatus.riding, bool sharingEnabled = true, }) async { + if (errorToThrowOnPublish != null) { + throw errorToThrowOnPublish!; + } publishedPresences.add({ 'callerUid': callerUid, 'groupRideId': groupRideId, @@ -65,13 +70,15 @@ Position _createPosition({ } void main() { - group('LiveBroadcastController Unit Tests', () { + group('LiveBroadcastController 22-Scenario Unit Tests', () { late MockGroupRideService mockService; late StreamController rideController; late StreamController> participantsController; late StreamController> presenceController; late StreamController positionController; + DateTime simulatedNow = DateTime(2026, 9, 4, 12, 0, 0); + final now = DateTime.now(); final activeRide = GroupRide( id: 'ride_live_1', @@ -110,6 +117,7 @@ void main() { presenceController = StreamController>.broadcast(); positionController = StreamController.broadcast(); + simulatedNow = DateTime(2026, 9, 4, 12, 0, 0); }); tearDown(() { @@ -119,9 +127,10 @@ void main() { positionController.close(); }); - test('dual-gate privacy: requires both master allowed and per-ride opt-in', - () async { - final controller = LiveBroadcastController( + LiveBroadcastController createTestController({ + bool initialMasterAllowed = true, + }) { + return LiveBroadcastController( rideId: 'ride_live_1', callerUid: 'bob', service: mockService, @@ -129,15 +138,20 @@ void main() { participantsStream: participantsController.stream, presenceStream: presenceController.stream, positionStream: positionController.stream, - initialMasterAllowed: false, + initialMasterAllowed: initialMasterAllowed, + now: () => simulatedNow, ); + } + + // 1. Dual-gate privacy + test('1. dual-gate privacy: requires both master allowed and per-ride opt-in', + () async { + final controller = createTestController(initialMasterAllowed: false); - // Initially neither is enabled expect(controller.state.isMasterAllowed, isFalse); expect(controller.state.isOptedIn, isFalse); expect(controller.state.isBroadcasting, isFalse); - // Emit active ride and accepted membership rideController.add(activeRide); participantsController.add([acceptedParticipant]); await Future.delayed(const Duration(milliseconds: 10)); @@ -145,12 +159,10 @@ void main() { expect(controller.state.isEligible, isTrue); expect(controller.state.isBroadcasting, isFalse); - // Enable master allowed only controller.updateMasterAllowed(true); expect(controller.state.isMasterAllowed, isTrue); - expect(controller.state.isBroadcasting, isFalse); // Still false because optIn is false + expect(controller.state.isBroadcasting, isFalse); - // Enable optIn await controller.setOptIn(true); expect(controller.state.isOptedIn, isTrue); expect(controller.state.isBroadcasting, isTrue); @@ -158,18 +170,9 @@ void main() { controller.dispose(); }); - test('accuracy filtering: ignores fixes with accuracy > 30m', () async { - final controller = LiveBroadcastController( - rideId: 'ride_live_1', - callerUid: 'bob', - service: mockService, - rideStream: rideController.stream, - participantsStream: participantsController.stream, - presenceStream: presenceController.stream, - positionStream: positionController.stream, - initialMasterAllowed: true, - ); - + // 2. Accuracy filtering + test('2. accuracy filtering: ignores fixes with accuracy > 30m', () async { + final controller = createTestController(); rideController.add(activeRide); participantsController.add([acceptedParticipant]); await controller.setOptIn(true); @@ -177,7 +180,6 @@ void main() { expect(controller.state.isBroadcasting, isTrue); - // Emit fix with 50m accuracy (should be rejected) positionController.add(_createPosition( latitude: 34.501, longitude: -84.101, @@ -188,7 +190,6 @@ void main() { expect(controller.state.lastPosition, isNull); expect(mockService.publishedPresences, isEmpty); - // Emit fix with 10m accuracy (should be accepted) positionController.add(_createPosition( latitude: 34.502, longitude: -84.102, @@ -198,30 +199,19 @@ void main() { expect(controller.state.lastPosition, isNotNull); expect(mockService.publishedPresences.length, equals(1)); - expect(mockService.publishedPresences.first['latitude'], equals(34.502)); controller.dispose(); }); - test('quick statuses: setting needFuel or regroup updates and publishes', + // 3. Quick statuses + test('3. quick statuses: setting needFuel or regroup updates and publishes', () async { - final controller = LiveBroadcastController( - rideId: 'ride_live_1', - callerUid: 'bob', - service: mockService, - rideStream: rideController.stream, - participantsStream: participantsController.stream, - presenceStream: presenceController.stream, - positionStream: positionController.stream, - initialMasterAllowed: true, - ); - + final controller = createTestController(); rideController.add(activeRide); participantsController.add([acceptedParticipant]); await controller.setOptIn(true); await Future.delayed(const Duration(milliseconds: 10)); - // Emit valid initial position positionController.add(_createPosition( latitude: 34.505, longitude: -84.105, @@ -233,40 +223,18 @@ void main() { expect(mockService.publishedPresences.last['status'], equals(LivePresenceStatus.riding)); - // Select NEED FUEL await controller.setStatus(LivePresenceStatus.needFuel); expect(controller.state.status, equals(LivePresenceStatus.needFuel)); expect(mockService.publishedPresences.last['status'], equals(LivePresenceStatus.needFuel)); - // Trigger REGROUP - await controller.triggerRegroup(); - expect(controller.state.status, equals(LivePresenceStatus.regrouping)); - expect(mockService.publishedPresences.last['status'], - equals(LivePresenceStatus.regrouping)); - - // Clear REGROUP - await controller.clearRegroup(); - expect(controller.state.status, equals(LivePresenceStatus.riding)); - expect(mockService.publishedPresences.last['status'], - equals(LivePresenceStatus.riding)); - controller.dispose(); }); - test('authorization loss: ride completion immediately clears presence', + // 4. Authorization loss: ride completion + test('4. authorization loss: ride completion immediately clears presence', () async { - final controller = LiveBroadcastController( - rideId: 'ride_live_1', - callerUid: 'bob', - service: mockService, - rideStream: rideController.stream, - participantsStream: participantsController.stream, - presenceStream: presenceController.stream, - positionStream: positionController.stream, - initialMasterAllowed: true, - ); - + final controller = createTestController(); rideController.add(activeRide); participantsController.add([acceptedParticipant]); await controller.setOptIn(true); @@ -274,8 +242,8 @@ void main() { expect(controller.state.isBroadcasting, isTrue); - // Ride concludes - final completedRide = activeRide.copyWith(status: GroupRideStatus.completed); + final completedRide = + activeRide.copyWith(status: GroupRideStatus.completed); rideController.add(completedRide); await Future.delayed(const Duration(milliseconds: 10)); @@ -286,19 +254,10 @@ void main() { controller.dispose(); }); - test('authorization loss: participant removal immediately revokes and clears', + // 5. Authorization loss: participant removal + test('5. authorization loss: participant removal immediately revokes and clears', () async { - final controller = LiveBroadcastController( - rideId: 'ride_live_1', - callerUid: 'bob', - service: mockService, - rideStream: rideController.stream, - participantsStream: participantsController.stream, - presenceStream: presenceController.stream, - positionStream: positionController.stream, - initialMasterAllowed: true, - ); - + final controller = createTestController(); rideController.add(activeRide); participantsController.add([acceptedParticipant]); await controller.setOptIn(true); @@ -306,7 +265,6 @@ void main() { expect(controller.state.isBroadcasting, isTrue); - // Organizer removes Bob from participants participantsController.add([]); await Future.delayed(const Duration(milliseconds: 10)); @@ -317,19 +275,10 @@ void main() { controller.dispose(); }); - test('ghost mode: toggling opt-in to false clears presence and stops broadcast', + // 6. Ghost mode: per-ride opt-in toggle false + test('6. ghost mode: toggling opt-in to false clears presence and stops broadcast', () async { - final controller = LiveBroadcastController( - rideId: 'ride_live_1', - callerUid: 'bob', - service: mockService, - rideStream: rideController.stream, - participantsStream: participantsController.stream, - presenceStream: presenceController.stream, - positionStream: positionController.stream, - initialMasterAllowed: true, - ); - + final controller = createTestController(); rideController.add(activeRide); participantsController.add([acceptedParticipant]); await controller.setOptIn(true); @@ -337,7 +286,6 @@ void main() { expect(controller.state.isBroadcasting, isTrue); - // Toggle off opt-in await controller.setOptIn(false); expect(controller.state.isOptedIn, isFalse); expect(controller.state.isBroadcasting, isFalse); @@ -345,5 +293,444 @@ void main() { controller.dispose(); }); + + // 7. Master toggle disable + test('7. master toggle disable: clearing master setting stops broadcast and clears presence', + () async { + final controller = createTestController(); + rideController.add(activeRide); + participantsController.add([acceptedParticipant]); + await controller.setOptIn(true); + await Future.delayed(const Duration(milliseconds: 10)); + + expect(controller.state.isBroadcasting, isTrue); + + controller.updateMasterAllowed(false); + expect(controller.state.isMasterAllowed, isFalse); + expect(controller.state.isBroadcasting, isFalse); + expect(mockService.clearedPresences, contains('bob:ride_live_1')); + + controller.dispose(); + }); + + // 8. Stopped cadence: stationary fixes publish at 30s intervals + test('8. stopped cadence: stationary fixes publish at 30s intervals', + () async { + final controller = createTestController(); + rideController.add(activeRide); + participantsController.add([acceptedParticipant]); + await controller.setOptIn(true); + await Future.delayed(const Duration(milliseconds: 10)); + + // Initial stopped fix (speed = 0) + positionController.add(_createPosition( + latitude: 34.500, + longitude: -84.100, + speed: 0.0, + )); + await Future.delayed(const Duration(milliseconds: 10)); + expect(mockService.publishedPresences.length, equals(1)); + + // 10 seconds later: stationary -> should NOT publish + simulatedNow = simulatedNow.add(const Duration(seconds: 10)); + positionController.add(_createPosition( + latitude: 34.500, + longitude: -84.100, + speed: 0.0, + )); + await Future.delayed(const Duration(milliseconds: 10)); + expect(mockService.publishedPresences.length, equals(1)); + + // 29 seconds later -> should NOT publish + simulatedNow = DateTime(2026, 9, 4, 12, 0, 29); + positionController.add(_createPosition( + latitude: 34.500, + longitude: -84.100, + speed: 0.0, + )); + await Future.delayed(const Duration(milliseconds: 10)); + expect(mockService.publishedPresences.length, equals(1)); + + // 30 seconds later -> publishes 2nd fix + simulatedNow = DateTime(2026, 9, 4, 12, 0, 30); + positionController.add(_createPosition( + latitude: 34.500, + longitude: -84.100, + speed: 0.0, + )); + await Future.delayed(const Duration(milliseconds: 10)); + expect(mockService.publishedPresences.length, equals(2)); + + controller.dispose(); + }); + + // 9. Moving cadence: movement (>3 mph, displacement > 15m) publishes at 5s intervals + test('9. moving cadence: movement (>3 mph, displacement > 15m) publishes at 5s intervals', + () async { + final controller = createTestController(); + rideController.add(activeRide); + participantsController.add([acceptedParticipant]); + await controller.setOptIn(true); + await Future.delayed(const Duration(milliseconds: 10)); + + // Initial fix at 15 m/s (~33 mph) + positionController.add(_createPosition( + latitude: 34.5000, + longitude: -84.1000, + speed: 15.0, + )); + await Future.delayed(const Duration(milliseconds: 10)); + expect(mockService.publishedPresences.length, equals(1)); + + // 4 seconds later with 50m displacement -> elapsed < 5s -> suppressed + simulatedNow = simulatedNow.add(const Duration(seconds: 4)); + positionController.add(_createPosition( + latitude: 34.5005, + longitude: -84.1000, + speed: 15.0, + )); + await Future.delayed(const Duration(milliseconds: 10)); + expect(mockService.publishedPresences.length, equals(1)); + + // 5 seconds elapsed (total) with 50m displacement -> publishes 2nd fix + simulatedNow = DateTime(2026, 9, 4, 12, 0, 5); + positionController.add(_createPosition( + latitude: 34.5005, + longitude: -84.1000, + speed: 15.0, + )); + await Future.delayed(const Duration(milliseconds: 10)); + expect(mockService.publishedPresences.length, equals(2)); + + controller.dispose(); + }); + + // 10. Displacement threshold: stationary jitter (<15m) suppressed + test('10. displacement threshold: stationary jitter (<15m) suppressed', + () async { + final controller = createTestController(); + rideController.add(activeRide); + participantsController.add([acceptedParticipant]); + await controller.setOptIn(true); + await Future.delayed(const Duration(milliseconds: 10)); + + // Initial fix + positionController.add(_createPosition( + latitude: 34.500000, + longitude: -84.100000, + speed: 10.0, + )); + await Future.delayed(const Duration(milliseconds: 10)); + expect(mockService.publishedPresences.length, equals(1)); + + // 6 seconds elapsed, but position jittered only ~2 meters (<15m) + simulatedNow = simulatedNow.add(const Duration(seconds: 6)); + positionController.add(_createPosition( + latitude: 34.500020, + longitude: -84.100000, + speed: 10.0, + )); + await Future.delayed(const Duration(milliseconds: 10)); + // Suppressed because displacement < 15m and elapsed < 10s + expect(mockService.publishedPresences.length, equals(1)); + + controller.dispose(); + }); + + // 11. Invalid coordinates: NaN or out of range rejected + test('11. invalid coordinates: lat/long NaN or out-of-range rejected', + () async { + final controller = createTestController(); + rideController.add(activeRide); + participantsController.add([acceptedParticipant]); + await controller.setOptIn(true); + await Future.delayed(const Duration(milliseconds: 10)); + + positionController.add(_createPosition( + latitude: double.nan, + longitude: -84.100, + )); + positionController.add(_createPosition( + latitude: 95.0, + longitude: -84.100, + )); + positionController.add(_createPosition( + latitude: 34.500, + longitude: -190.0, + )); + await Future.delayed(const Duration(milliseconds: 10)); + + expect(controller.state.lastPosition, isNull); + expect(mockService.publishedPresences, isEmpty); + + controller.dispose(); + }); + + // 12. Null Island (0,0) rejected + test('12. (0,0) Null Island coordinates rejected', () async { + final controller = createTestController(); + rideController.add(activeRide); + participantsController.add([acceptedParticipant]); + await controller.setOptIn(true); + await Future.delayed(const Duration(milliseconds: 10)); + + positionController.add(_createPosition( + latitude: 0.0, + longitude: 0.0, + )); + await Future.delayed(const Duration(milliseconds: 10)); + + expect(controller.state.lastPosition, isNull); + expect(mockService.publishedPresences, isEmpty); + + controller.dispose(); + }); + + // 13. Transient network error + test('13. transient network error: enters paused/reconnecting, backs off, does not crash', + () async { + final controller = createTestController(); + rideController.add(activeRide); + participantsController.add([acceptedParticipant]); + await controller.setOptIn(true); + await Future.delayed(const Duration(milliseconds: 10)); + + mockService.errorToThrowOnPublish = + Exception('SocketException: Failed host lookup'); + + positionController.add(_createPosition( + latitude: 34.500, + longitude: -84.100, + )); + await Future.delayed(const Duration(milliseconds: 10)); + + expect(controller.state.isPaused, isTrue); + expect(controller.state.retryCount, equals(1)); + expect(controller.state.isBroadcasting, isTrue); // Still opted-in and eligible + + controller.dispose(); + }); + + // 14. Bounded exponential backoff + test('14. backoff exponential: retries with bounded backoff (5s to 30s)', + () async { + final controller = createTestController(); + rideController.add(activeRide); + participantsController.add([acceptedParticipant]); + await controller.setOptIn(true); + await Future.delayed(const Duration(milliseconds: 10)); + + mockService.errorToThrowOnPublish = Exception('503 Service Unavailable'); + + // Attempt 1: backoff is 5s + positionController.add(_createPosition( + latitude: 34.500, + longitude: -84.100, + )); + await Future.delayed(const Duration(milliseconds: 10)); + expect(controller.state.retryCount, equals(1)); + + // Attempt at +2s (should be ignored by backoff gate) + simulatedNow = simulatedNow.add(const Duration(seconds: 2)); + positionController.add(_createPosition( + latitude: 34.501, + longitude: -84.101, + )); + await Future.delayed(const Duration(milliseconds: 10)); + expect(controller.state.retryCount, equals(1)); + + // Attempt at +5s: backoff satisfied, attempt fires and fails -> retryCount = 2, next backoff 10s + simulatedNow = DateTime(2026, 9, 4, 12, 0, 5); + positionController.add(_createPosition( + latitude: 34.502, + longitude: -84.102, + )); + await Future.delayed(const Duration(milliseconds: 10)); + expect(controller.state.retryCount, equals(2)); + + controller.dispose(); + }); + + // 15. Terminal auth error (permission-denied) + test('15. terminal auth error (permission-denied): aborts, revokes broadcast, no retry storm', + () async { + final controller = createTestController(); + rideController.add(activeRide); + participantsController.add([acceptedParticipant]); + await controller.setOptIn(true); + await Future.delayed(const Duration(milliseconds: 10)); + + expect(controller.state.isBroadcasting, isTrue); + + mockService.errorToThrowOnPublish = + Exception('[cloud_firestore/permission-denied] Missing or insufficient permissions'); + + positionController.add(_createPosition( + latitude: 34.500, + longitude: -84.100, + )); + await Future.delayed(const Duration(milliseconds: 10)); + + expect(controller.state.isBroadcasting, isFalse); + expect(controller.state.isOptedIn, isFalse); + expect(controller.state.isEligible, isFalse); + expect(mockService.clearedPresences, contains('bob:ride_live_1')); + + controller.dispose(); + }); + + // 16. Terminal auth error (unauthenticated) + test('16. terminal auth error (unauthenticated): aborts, cancels timers', + () async { + final controller = createTestController(); + rideController.add(activeRide); + participantsController.add([acceptedParticipant]); + await controller.setOptIn(true); + await Future.delayed(const Duration(milliseconds: 10)); + + expect(controller.state.isBroadcasting, isTrue); + + mockService.errorToThrowOnPublish = + Exception('[cloud_firestore/unauthenticated] Request not authenticated'); + + positionController.add(_createPosition( + latitude: 34.500, + longitude: -84.100, + )); + await Future.delayed(const Duration(milliseconds: 10)); + + expect(controller.state.isBroadcasting, isFalse); + expect(controller.state.isOptedIn, isFalse); + expect(controller.state.isEligible, isFalse); + + controller.dispose(); + }); + + // 17. App lifecycle: paused + test('17. app lifecycle: paused state transitions properly', () { + final controller = createTestController(); + expect(controller.state.isBackground, isFalse); + + controller.updateAppLifecycle(AppLifecycleState.paused); + expect(controller.state.isBackground, isTrue); + + controller.dispose(); + }); + + // 18. App lifecycle: resumed + test('18. app lifecycle: resumed state reconnects / re-evaluates', () { + final controller = createTestController(); + controller.updateAppLifecycle(AppLifecycleState.paused); + expect(controller.state.isBackground, isTrue); + + controller.updateAppLifecycle(AppLifecycleState.resumed); + expect(controller.state.isBackground, isFalse); + + controller.dispose(); + }); + + // 19. Regroup activation + test('19. regroup activation sets status regrouping and broadcasts', + () async { + final controller = createTestController(); + rideController.add(activeRide); + participantsController.add([acceptedParticipant]); + await controller.setOptIn(true); + await Future.delayed(const Duration(milliseconds: 10)); + + positionController.add(_createPosition( + latitude: 34.500, + longitude: -84.100, + )); + await Future.delayed(const Duration(milliseconds: 10)); + + await controller.triggerRegroup(); + expect(controller.state.status, equals(LivePresenceStatus.regrouping)); + expect(mockService.publishedPresences.last['status'], + equals(LivePresenceStatus.regrouping)); + + controller.dispose(); + }); + + // 20. Regroup clearing + test('20. regroup clearing resets to riding', () async { + final controller = createTestController(); + rideController.add(activeRide); + participantsController.add([acceptedParticipant]); + await controller.setOptIn(true); + await Future.delayed(const Duration(milliseconds: 10)); + + positionController.add(_createPosition( + latitude: 34.500, + longitude: -84.100, + )); + await Future.delayed(const Duration(milliseconds: 10)); + + await controller.triggerRegroup(); + expect(controller.state.status, equals(LivePresenceStatus.regrouping)); + + await controller.clearRegroup(); + expect(controller.state.status, equals(LivePresenceStatus.riding)); + expect(mockService.publishedPresences.last['status'], + equals(LivePresenceStatus.riding)); + + controller.dispose(); + }); + + // 21. Stream cleanup: dispose cancels all subscriptions and clears presence + test('21. stream cleanup: dispose cancels all subscriptions and timers', + () async { + final controller = createTestController(); + rideController.add(activeRide); + participantsController.add([acceptedParticipant]); + await controller.setOptIn(true); + await Future.delayed(const Duration(milliseconds: 10)); + + controller.dispose(); + + expect(mockService.clearedPresences, contains('bob:ride_live_1')); + // Adding to streams after dispose should not affect controller or cause crash + rideController.add(null); + positionController.add(_createPosition( + latitude: 34.500, + longitude: -84.100, + )); + await Future.delayed(const Duration(milliseconds: 10)); + + expect(mockService.publishedPresences, isEmpty); + }); + + // 22. Duplicate suppression within cadence window + test('22. duplicate suppression: identical payload within cadence window not sent', + () async { + final controller = createTestController(); + rideController.add(activeRide); + participantsController.add([acceptedParticipant]); + await controller.setOptIn(true); + await Future.delayed(const Duration(milliseconds: 10)); + + // Publish initial fix + positionController.add(_createPosition( + latitude: 34.500, + longitude: -84.100, + speed: 15.0, + )); + await Future.delayed(const Duration(milliseconds: 10)); + expect(mockService.publishedPresences.length, equals(1)); + + // 1 second later: emit exact same coordinate + simulatedNow = simulatedNow.add(const Duration(seconds: 1)); + positionController.add(_createPosition( + latitude: 34.500, + longitude: -84.100, + speed: 15.0, + )); + await Future.delayed(const Duration(milliseconds: 10)); + + // Suppressed! Length remains 1 + expect(mockService.publishedPresences.length, equals(1)); + + controller.dispose(); + }); }); }