fix: harden live group ride runtime lifecycle

This commit is contained in:
Developer 2026-09-04 00:54:46 -04:00
parent 3d957fbf63
commit 209987195b
7 changed files with 803 additions and 111 deletions

View File

@ -2,7 +2,6 @@
<!-- Permissions for GPS, Wakelock, Network, and Android 13/14+ Lifecycle --> <!-- Permissions for GPS, Wakelock, Network, and Android 13/14+ Lifecycle -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<uses-permission android:name="android.permission.WAKE_LOCK" /> <uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />

View File

@ -793,6 +793,174 @@ describe('Social Architecture Security Rules', () => {
// Bob CANNOT read Alice's presence // Bob CANNOT read Alice's presence
await assertFails(getDoc(doc(bob, 'group_rides/ride_lifecycle/live_presence/alice'))); 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',
}));
});
}); });
}); });

View File

@ -221,13 +221,22 @@ service cloud.firestore {
&& request.resource.data.id == groupRideId && request.resource.data.id == groupRideId
&& request.resource.data.keys().hasAll(['id', 'organizerUid', 'title', 'scheduledStartAt', 'departureAt', 'meetingPoint']) && request.resource.data.keys().hasAll(['id', 'organizerUid', 'title', 'scheduledStartAt', 'departureAt', 'meetingPoint'])
&& request.resource.data.participantCount >= 1; && 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() && ( allow update: if signedIn() && (
// Organizer can update any ride authority fields // Organizer can update ride authority fields with valid status transitions
isOrganizer() || (isOrganizer() && validStatusTransition()) ||
// Participants/invitees can atomically adjust participantCount during join/leave transactions // Participants/invitees can only atomically adjust participantCount during join/leave transactions
( (
canReadRide() && 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(); allow delete: if isOrganizer();

View File

@ -19,10 +19,30 @@ class LiveGroupRideScreen extends ConsumerStatefulWidget {
_LiveGroupRideScreenState(); _LiveGroupRideScreenState();
} }
class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen> { class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
with WidgetsBindingObserver {
AppLatLng? _customMapCenter; AppLatLng? _customMapCenter;
double _mapZoom = 15.0; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final rideAsync = ref.watch(groupRideStreamProvider(widget.rideId)); final rideAsync = ref.watch(groupRideStreamProvider(widget.rideId));
@ -616,12 +636,18 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen> {
children: [ children: [
Text( Text(
broadcastState.isBroadcasting broadcastState.isBroadcasting
? 'SHARING LIVE' ? (broadcastState.isPaused
? 'SHARING PAUSED'
: (broadcastState.isBackground
? 'BACKGROUND SHARING'
: 'SHARING LIVE'))
: 'GHOST MODE', : 'GHOST MODE',
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: TextStyle( style: TextStyle(
color: broadcastState.isBroadcasting color: broadcastState.isBroadcasting
? const Color(0xFF00E5FF) ? (broadcastState.isPaused
? Colors.amber
: const Color(0xFF00E5FF))
: Colors.white60, : Colors.white60,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: 11, fontSize: 11,
@ -630,7 +656,11 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen> {
), ),
Text( Text(
broadcastState.isBroadcasting broadcastState.isBroadcasting
? 'Broadcasting to riders' ? (broadcastState.isPaused
? 'Reconnecting (${broadcastState.retryCount})...'
: (broadcastState.isBackground
? 'Active via foreground service'
: 'Broadcasting to riders'))
: 'Location paused', : 'Location paused',
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white38, fontSize: 10), style: const TextStyle(color: Colors.white38, fontSize: 10),
@ -640,6 +670,7 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen> {
), ),
Switch( Switch(
value: broadcastState.isOptedIn, value: broadcastState.isOptedIn,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
activeThumbColor: const Color(0xFF00E5FF), activeThumbColor: const Color(0xFF00E5FF),
onChanged: (val) => broadcastController.setOptIn(val), onChanged: (val) => broadcastController.setOptIn(val),
), ),

View File

@ -1,8 +1,9 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:geolocator/geolocator.dart'; import 'package:geolocator/geolocator.dart';
import '../auth/auth_providers.dart'; import '../auth/auth_providers.dart';
import '../location_service.dart';
import 'group_ride_models.dart'; import 'group_ride_models.dart';
import 'group_ride_providers.dart'; import 'group_ride_providers.dart';
import 'group_ride_service.dart'; import 'group_ride_service.dart';
@ -14,6 +15,9 @@ class LiveBroadcastState {
final bool isMasterAllowed; final bool isMasterAllowed;
final bool isEligible; final bool isEligible;
final bool isBroadcasting; final bool isBroadcasting;
final bool isPaused;
final int retryCount;
final bool isBackground;
final LivePresenceStatus status; final LivePresenceStatus status;
final Position? lastPosition; final Position? lastPosition;
final DateTime? lastPublishedAt; final DateTime? lastPublishedAt;
@ -25,6 +29,9 @@ class LiveBroadcastState {
this.isMasterAllowed = false, this.isMasterAllowed = false,
this.isEligible = false, this.isEligible = false,
this.isBroadcasting = false, this.isBroadcasting = false,
this.isPaused = false,
this.retryCount = 0,
this.isBackground = false,
this.status = LivePresenceStatus.riding, this.status = LivePresenceStatus.riding,
this.lastPosition, this.lastPosition,
this.lastPublishedAt, this.lastPublishedAt,
@ -37,6 +44,9 @@ class LiveBroadcastState {
bool? isMasterAllowed, bool? isMasterAllowed,
bool? isEligible, bool? isEligible,
bool? isBroadcasting, bool? isBroadcasting,
bool? isPaused,
int? retryCount,
bool? isBackground,
LivePresenceStatus? status, LivePresenceStatus? status,
Position? lastPosition, Position? lastPosition,
DateTime? lastPublishedAt, DateTime? lastPublishedAt,
@ -48,6 +58,9 @@ class LiveBroadcastState {
isMasterAllowed: isMasterAllowed ?? this.isMasterAllowed, isMasterAllowed: isMasterAllowed ?? this.isMasterAllowed,
isEligible: isEligible ?? this.isEligible, isEligible: isEligible ?? this.isEligible,
isBroadcasting: isBroadcasting ?? this.isBroadcasting, isBroadcasting: isBroadcasting ?? this.isBroadcasting,
isPaused: isPaused ?? this.isPaused,
retryCount: retryCount ?? this.retryCount,
isBackground: isBackground ?? this.isBackground,
status: status ?? this.status, status: status ?? this.status,
lastPosition: lastPosition ?? this.lastPosition, lastPosition: lastPosition ?? this.lastPosition,
lastPublishedAt: lastPublishedAt ?? this.lastPublishedAt, lastPublishedAt: lastPublishedAt ?? this.lastPublishedAt,
@ -76,10 +89,15 @@ class LiveBroadcastController extends StateNotifier<LiveBroadcastState> {
StreamSubscription<List<LiveGroupPresence>>? _presenceSub; StreamSubscription<List<LiveGroupPresence>>? _presenceSub;
Timer? _heartbeatTimer; Timer? _heartbeatTimer;
DateTime? _nextRetryTime;
int _consecutiveErrors = 0;
Position? _lastBroadcastPosition; Position? _lastBroadcastPosition;
DateTime? _lastBroadcastTime; DateTime? _lastBroadcastTime;
bool _isDisposed = false; bool _isDisposed = false;
final DateTime Function() _now;
LiveBroadcastController({ LiveBroadcastController({
required this.rideId, required this.rideId,
required this.callerUid, required this.callerUid,
@ -89,7 +107,9 @@ class LiveBroadcastController extends StateNotifier<LiveBroadcastState> {
this.participantsStream, this.participantsStream,
this.presenceStream, this.presenceStream,
bool initialMasterAllowed = false, bool initialMasterAllowed = false,
}) : super(LiveBroadcastState(isMasterAllowed: initialMasterAllowed)) { DateTime Function()? now,
}) : _now = now ?? DateTime.now,
super(LiveBroadcastState(isMasterAllowed: initialMasterAllowed)) {
_initSubscriptions(); _initSubscriptions();
} }
@ -181,6 +201,12 @@ class LiveBroadcastController extends StateNotifier<LiveBroadcastState> {
if (_isDisposed || state.isMasterAllowed == allowed) return; if (_isDisposed || state.isMasterAllowed == allowed) return;
state = state.copyWith(isMasterAllowed: allowed); state = state.copyWith(isMasterAllowed: allowed);
_evaluateEligibility(); _evaluateEligibility();
if (!allowed) {
service.clearLivePresence(
callerUid: callerUid,
groupRideId: rideId,
);
}
} }
/// Toggles per-ride opt-in for location broadcasting. /// Toggles per-ride opt-in for location broadcasting.
@ -188,7 +214,13 @@ class LiveBroadcastController extends StateNotifier<LiveBroadcastState> {
if (_isDisposed) return; if (_isDisposed) return;
if (state.isOptedIn == optIn) 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(); _evaluateEligibility();
if (!optIn) { if (!optIn) {
@ -223,6 +255,16 @@ class LiveBroadcastController extends StateNotifier<LiveBroadcastState> {
await setStatus(LivePresenceStatus.riding); 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() { void _startBroadcasting() {
_positionSub?.cancel(); _positionSub?.cancel();
final stream = positionStream ?? Geolocator.getPositionStream( final stream = positionStream ?? Geolocator.getPositionStream(
@ -255,6 +297,17 @@ class LiveBroadcastController extends StateNotifier<LiveBroadcastState> {
void _onPositionUpdate(Position position) { void _onPositionUpdate(Position position) {
if (_isDisposed || !state.isBroadcasting) return; 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 // Filter accuracy: reject fixes with accuracy > 30m
if (position.accuracy > 30.0) { if (position.accuracy > 30.0) {
return; return;
@ -262,10 +315,16 @@ class LiveBroadcastController extends StateNotifier<LiveBroadcastState> {
state = state.copyWith(lastPosition: position); 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 speedMph = position.speed * 2.23694;
final isMoving = speedMph > 3.0; final isMoving = speedMph > 3.0;
final now = DateTime.now();
final lastTime = _lastBroadcastTime; final lastTime = _lastBroadcastTime;
if (lastTime == null) { if (lastTime == null) {
@ -276,7 +335,7 @@ class LiveBroadcastController extends StateNotifier<LiveBroadcastState> {
final elapsedSeconds = now.difference(lastTime).inSeconds; final elapsedSeconds = now.difference(lastTime).inSeconds;
if (isMoving) { 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) { if (elapsedSeconds >= 5) {
double displacement = 0; double displacement = 0;
if (_lastBroadcastPosition != null) { if (_lastBroadcastPosition != null) {
@ -304,7 +363,12 @@ class LiveBroadcastController extends StateNotifier<LiveBroadcastState> {
final lastTime = _lastBroadcastTime; final lastTime = _lastBroadcastTime;
if (lastTime == null) return; 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 stopped or no fix received, keep heartbeat within 30s
if (elapsed >= 30) { if (elapsed >= 30) {
_publishPosition(state.lastPosition!, force: true); _publishPosition(state.lastPosition!, force: true);
@ -315,7 +379,7 @@ class LiveBroadcastController extends StateNotifier<LiveBroadcastState> {
if (!state.isBroadcasting && !force) return; if (!state.isBroadcasting && !force) return;
final speedMph = position.speed * 2.23694; final speedMph = position.speed * 2.23694;
final now = DateTime.now(); final now = _now();
try { try {
await service.publishLivePresence( await service.publishLivePresence(
@ -332,19 +396,54 @@ class LiveBroadcastController extends StateNotifier<LiveBroadcastState> {
_lastBroadcastPosition = position; _lastBroadcastPosition = position;
_lastBroadcastTime = now; _lastBroadcastTime = now;
_consecutiveErrors = 0;
_nextRetryTime = null;
state = state.copyWith( state = state.copyWith(
lastPublishedAt: now, lastPublishedAt: now,
errorMessage: null, errorMessage: null,
isPaused: false,
retryCount: 0,
); );
} catch (e) { } 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) { void _revokeBroadcasting(String reason) {
state = state.copyWith( state = state.copyWith(
isEligible: false, isEligible: false,
isBroadcasting: false, isBroadcasting: false,
isOptedIn: false,
isPaused: false,
retryCount: 0,
errorMessage: reason, errorMessage: reason,
); );
_stopBroadcasting(); _stopBroadcasting();
@ -380,12 +479,14 @@ final liveBroadcastControllerProvider = StateNotifierProvider.autoDispose
final user = ref.watch(currentUserProvider); final user = ref.watch(currentUserProvider);
final service = ref.watch(groupRideServiceProvider); final service = ref.watch(groupRideServiceProvider);
final masterAllowed = ref.watch(groupRideLiveSharingAllowedProvider); final masterAllowed = ref.watch(groupRideLiveSharingAllowedProvider);
final locationService = ref.watch(locationServiceProvider);
final callerUid = user?.uid ?? ''; final callerUid = user?.uid ?? '';
final controller = LiveBroadcastController( final controller = LiveBroadcastController(
rideId: rideId, rideId: rideId,
callerUid: callerUid, callerUid: callerUid,
service: service, service: service,
positionStream: locationService.getFilteredPositionStream(),
initialMasterAllowed: masterAllowed, initialMasterAllowed: masterAllowed,
); );

View File

@ -54,9 +54,6 @@ class LocationService {
if (geoPermission == LocationPermission.whileInUse || if (geoPermission == LocationPermission.whileInUse ||
geoPermission == LocationPermission.always || geoPermission == LocationPermission.always ||
locStatus.isGranted) { locStatus.isGranted) {
try {
await Permission.locationAlways.request();
} catch (_) {}
// Restart/refresh GPS stream now that permissions are granted // Restart/refresh GPS stream now that permissions are granted
_startListening(); _startListening();
return true; return true;

View File

@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:geolocator/geolocator.dart'; import 'package:geolocator/geolocator.dart';
import 'package:motorcycle_dash/services/group_rides/group_ride_models.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 { class MockGroupRideService extends GroupRideService {
final List<Map<String, dynamic>> publishedPresences = []; final List<Map<String, dynamic>> publishedPresences = [];
final List<String> clearedPresences = []; final List<String> clearedPresences = [];
Exception? errorToThrowOnPublish;
@override @override
Future<void> publishLivePresence({ Future<void> publishLivePresence({
@ -21,6 +23,9 @@ class MockGroupRideService extends GroupRideService {
LivePresenceStatus status = LivePresenceStatus.riding, LivePresenceStatus status = LivePresenceStatus.riding,
bool sharingEnabled = true, bool sharingEnabled = true,
}) async { }) async {
if (errorToThrowOnPublish != null) {
throw errorToThrowOnPublish!;
}
publishedPresences.add({ publishedPresences.add({
'callerUid': callerUid, 'callerUid': callerUid,
'groupRideId': groupRideId, 'groupRideId': groupRideId,
@ -65,13 +70,15 @@ Position _createPosition({
} }
void main() { void main() {
group('LiveBroadcastController Unit Tests', () { group('LiveBroadcastController 22-Scenario Unit Tests', () {
late MockGroupRideService mockService; late MockGroupRideService mockService;
late StreamController<GroupRide?> rideController; late StreamController<GroupRide?> rideController;
late StreamController<List<GroupRideParticipant>> participantsController; late StreamController<List<GroupRideParticipant>> participantsController;
late StreamController<List<LiveGroupPresence>> presenceController; late StreamController<List<LiveGroupPresence>> presenceController;
late StreamController<Position> positionController; late StreamController<Position> positionController;
DateTime simulatedNow = DateTime(2026, 9, 4, 12, 0, 0);
final now = DateTime.now(); final now = DateTime.now();
final activeRide = GroupRide( final activeRide = GroupRide(
id: 'ride_live_1', id: 'ride_live_1',
@ -110,6 +117,7 @@ void main() {
presenceController = presenceController =
StreamController<List<LiveGroupPresence>>.broadcast(); StreamController<List<LiveGroupPresence>>.broadcast();
positionController = StreamController<Position>.broadcast(); positionController = StreamController<Position>.broadcast();
simulatedNow = DateTime(2026, 9, 4, 12, 0, 0);
}); });
tearDown(() { tearDown(() {
@ -119,9 +127,10 @@ void main() {
positionController.close(); positionController.close();
}); });
test('dual-gate privacy: requires both master allowed and per-ride opt-in', LiveBroadcastController createTestController({
() async { bool initialMasterAllowed = true,
final controller = LiveBroadcastController( }) {
return LiveBroadcastController(
rideId: 'ride_live_1', rideId: 'ride_live_1',
callerUid: 'bob', callerUid: 'bob',
service: mockService, service: mockService,
@ -129,15 +138,20 @@ void main() {
participantsStream: participantsController.stream, participantsStream: participantsController.stream,
presenceStream: presenceController.stream, presenceStream: presenceController.stream,
positionStream: positionController.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.isMasterAllowed, isFalse);
expect(controller.state.isOptedIn, isFalse); expect(controller.state.isOptedIn, isFalse);
expect(controller.state.isBroadcasting, isFalse); expect(controller.state.isBroadcasting, isFalse);
// Emit active ride and accepted membership
rideController.add(activeRide); rideController.add(activeRide);
participantsController.add([acceptedParticipant]); participantsController.add([acceptedParticipant]);
await Future<void>.delayed(const Duration(milliseconds: 10)); await Future<void>.delayed(const Duration(milliseconds: 10));
@ -145,12 +159,10 @@ void main() {
expect(controller.state.isEligible, isTrue); expect(controller.state.isEligible, isTrue);
expect(controller.state.isBroadcasting, isFalse); expect(controller.state.isBroadcasting, isFalse);
// Enable master allowed only
controller.updateMasterAllowed(true); controller.updateMasterAllowed(true);
expect(controller.state.isMasterAllowed, isTrue); 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); await controller.setOptIn(true);
expect(controller.state.isOptedIn, isTrue); expect(controller.state.isOptedIn, isTrue);
expect(controller.state.isBroadcasting, isTrue); expect(controller.state.isBroadcasting, isTrue);
@ -158,18 +170,9 @@ void main() {
controller.dispose(); controller.dispose();
}); });
test('accuracy filtering: ignores fixes with accuracy > 30m', () async { // 2. Accuracy filtering
final controller = LiveBroadcastController( test('2. accuracy filtering: ignores fixes with accuracy > 30m', () async {
rideId: 'ride_live_1', final controller = createTestController();
callerUid: 'bob',
service: mockService,
rideStream: rideController.stream,
participantsStream: participantsController.stream,
presenceStream: presenceController.stream,
positionStream: positionController.stream,
initialMasterAllowed: true,
);
rideController.add(activeRide); rideController.add(activeRide);
participantsController.add([acceptedParticipant]); participantsController.add([acceptedParticipant]);
await controller.setOptIn(true); await controller.setOptIn(true);
@ -177,7 +180,6 @@ void main() {
expect(controller.state.isBroadcasting, isTrue); expect(controller.state.isBroadcasting, isTrue);
// Emit fix with 50m accuracy (should be rejected)
positionController.add(_createPosition( positionController.add(_createPosition(
latitude: 34.501, latitude: 34.501,
longitude: -84.101, longitude: -84.101,
@ -188,7 +190,6 @@ void main() {
expect(controller.state.lastPosition, isNull); expect(controller.state.lastPosition, isNull);
expect(mockService.publishedPresences, isEmpty); expect(mockService.publishedPresences, isEmpty);
// Emit fix with 10m accuracy (should be accepted)
positionController.add(_createPosition( positionController.add(_createPosition(
latitude: 34.502, latitude: 34.502,
longitude: -84.102, longitude: -84.102,
@ -198,30 +199,19 @@ void main() {
expect(controller.state.lastPosition, isNotNull); expect(controller.state.lastPosition, isNotNull);
expect(mockService.publishedPresences.length, equals(1)); expect(mockService.publishedPresences.length, equals(1));
expect(mockService.publishedPresences.first['latitude'], equals(34.502));
controller.dispose(); 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 { () async {
final controller = LiveBroadcastController( final controller = createTestController();
rideId: 'ride_live_1',
callerUid: 'bob',
service: mockService,
rideStream: rideController.stream,
participantsStream: participantsController.stream,
presenceStream: presenceController.stream,
positionStream: positionController.stream,
initialMasterAllowed: true,
);
rideController.add(activeRide); rideController.add(activeRide);
participantsController.add([acceptedParticipant]); participantsController.add([acceptedParticipant]);
await controller.setOptIn(true); await controller.setOptIn(true);
await Future<void>.delayed(const Duration(milliseconds: 10)); await Future<void>.delayed(const Duration(milliseconds: 10));
// Emit valid initial position
positionController.add(_createPosition( positionController.add(_createPosition(
latitude: 34.505, latitude: 34.505,
longitude: -84.105, longitude: -84.105,
@ -233,40 +223,18 @@ void main() {
expect(mockService.publishedPresences.last['status'], expect(mockService.publishedPresences.last['status'],
equals(LivePresenceStatus.riding)); equals(LivePresenceStatus.riding));
// Select NEED FUEL
await controller.setStatus(LivePresenceStatus.needFuel); await controller.setStatus(LivePresenceStatus.needFuel);
expect(controller.state.status, equals(LivePresenceStatus.needFuel)); expect(controller.state.status, equals(LivePresenceStatus.needFuel));
expect(mockService.publishedPresences.last['status'], expect(mockService.publishedPresences.last['status'],
equals(LivePresenceStatus.needFuel)); 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(); 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 { () async {
final controller = LiveBroadcastController( final controller = createTestController();
rideId: 'ride_live_1',
callerUid: 'bob',
service: mockService,
rideStream: rideController.stream,
participantsStream: participantsController.stream,
presenceStream: presenceController.stream,
positionStream: positionController.stream,
initialMasterAllowed: true,
);
rideController.add(activeRide); rideController.add(activeRide);
participantsController.add([acceptedParticipant]); participantsController.add([acceptedParticipant]);
await controller.setOptIn(true); await controller.setOptIn(true);
@ -274,8 +242,8 @@ void main() {
expect(controller.state.isBroadcasting, isTrue); expect(controller.state.isBroadcasting, isTrue);
// Ride concludes final completedRide =
final completedRide = activeRide.copyWith(status: GroupRideStatus.completed); activeRide.copyWith(status: GroupRideStatus.completed);
rideController.add(completedRide); rideController.add(completedRide);
await Future<void>.delayed(const Duration(milliseconds: 10)); await Future<void>.delayed(const Duration(milliseconds: 10));
@ -286,19 +254,10 @@ void main() {
controller.dispose(); 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 { () async {
final controller = LiveBroadcastController( final controller = createTestController();
rideId: 'ride_live_1',
callerUid: 'bob',
service: mockService,
rideStream: rideController.stream,
participantsStream: participantsController.stream,
presenceStream: presenceController.stream,
positionStream: positionController.stream,
initialMasterAllowed: true,
);
rideController.add(activeRide); rideController.add(activeRide);
participantsController.add([acceptedParticipant]); participantsController.add([acceptedParticipant]);
await controller.setOptIn(true); await controller.setOptIn(true);
@ -306,7 +265,6 @@ void main() {
expect(controller.state.isBroadcasting, isTrue); expect(controller.state.isBroadcasting, isTrue);
// Organizer removes Bob from participants
participantsController.add([]); participantsController.add([]);
await Future<void>.delayed(const Duration(milliseconds: 10)); await Future<void>.delayed(const Duration(milliseconds: 10));
@ -317,19 +275,10 @@ void main() {
controller.dispose(); 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 { () async {
final controller = LiveBroadcastController( final controller = createTestController();
rideId: 'ride_live_1',
callerUid: 'bob',
service: mockService,
rideStream: rideController.stream,
participantsStream: participantsController.stream,
presenceStream: presenceController.stream,
positionStream: positionController.stream,
initialMasterAllowed: true,
);
rideController.add(activeRide); rideController.add(activeRide);
participantsController.add([acceptedParticipant]); participantsController.add([acceptedParticipant]);
await controller.setOptIn(true); await controller.setOptIn(true);
@ -337,7 +286,6 @@ void main() {
expect(controller.state.isBroadcasting, isTrue); expect(controller.state.isBroadcasting, isTrue);
// Toggle off opt-in
await controller.setOptIn(false); await controller.setOptIn(false);
expect(controller.state.isOptedIn, isFalse); expect(controller.state.isOptedIn, isFalse);
expect(controller.state.isBroadcasting, isFalse); expect(controller.state.isBroadcasting, isFalse);
@ -345,5 +293,444 @@ void main() {
controller.dispose(); 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<void>.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<void>.delayed(const Duration(milliseconds: 10));
// Initial stopped fix (speed = 0)
positionController.add(_createPosition(
latitude: 34.500,
longitude: -84.100,
speed: 0.0,
));
await Future<void>.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<void>.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<void>.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<void>.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<void>.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<void>.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<void>.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<void>.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<void>.delayed(const Duration(milliseconds: 10));
// Initial fix
positionController.add(_createPosition(
latitude: 34.500000,
longitude: -84.100000,
speed: 10.0,
));
await Future<void>.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<void>.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<void>.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<void>.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<void>.delayed(const Duration(milliseconds: 10));
positionController.add(_createPosition(
latitude: 0.0,
longitude: 0.0,
));
await Future<void>.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<void>.delayed(const Duration(milliseconds: 10));
mockService.errorToThrowOnPublish =
Exception('SocketException: Failed host lookup');
positionController.add(_createPosition(
latitude: 34.500,
longitude: -84.100,
));
await Future<void>.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<void>.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<void>.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<void>.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<void>.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<void>.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<void>.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<void>.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<void>.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<void>.delayed(const Duration(milliseconds: 10));
positionController.add(_createPosition(
latitude: 34.500,
longitude: -84.100,
));
await Future<void>.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<void>.delayed(const Duration(milliseconds: 10));
positionController.add(_createPosition(
latitude: 34.500,
longitude: -84.100,
));
await Future<void>.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<void>.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<void>.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<void>.delayed(const Duration(milliseconds: 10));
// Publish initial fix
positionController.add(_createPosition(
latitude: 34.500,
longitude: -84.100,
speed: 15.0,
));
await Future<void>.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<void>.delayed(const Duration(milliseconds: 10));
// Suppressed! Length remains 1
expect(mockService.publishedPresences.length, equals(1));
controller.dispose();
});
}); });
} }