fix: harden live group ride runtime lifecycle
This commit is contained in:
parent
3d957fbf63
commit
209987195b
|
|
@ -2,7 +2,6 @@
|
|||
<!-- 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_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.INTERNET" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -19,10 +19,30 @@ class LiveGroupRideScreen extends ConsumerStatefulWidget {
|
|||
_LiveGroupRideScreenState();
|
||||
}
|
||||
|
||||
class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen> {
|
||||
class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
|
||||
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<LiveGroupRideScreen> {
|
|||
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<LiveGroupRideScreen> {
|
|||
),
|
||||
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<LiveGroupRideScreen> {
|
|||
),
|
||||
Switch(
|
||||
value: broadcastState.isOptedIn,
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
activeThumbColor: const Color(0xFF00E5FF),
|
||||
onChanged: (val) => broadcastController.setOptIn(val),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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<LiveBroadcastState> {
|
|||
StreamSubscription<List<LiveGroupPresence>>? _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<LiveBroadcastState> {
|
|||
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<LiveBroadcastState> {
|
|||
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<LiveBroadcastState> {
|
|||
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<LiveBroadcastState> {
|
|||
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<LiveBroadcastState> {
|
|||
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<LiveBroadcastState> {
|
|||
|
||||
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<LiveBroadcastState> {
|
|||
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<LiveBroadcastState> {
|
|||
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<LiveBroadcastState> {
|
|||
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<LiveBroadcastState> {
|
|||
|
||||
_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,
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<Map<String, dynamic>> publishedPresences = [];
|
||||
final List<String> clearedPresences = [];
|
||||
Exception? errorToThrowOnPublish;
|
||||
|
||||
@override
|
||||
Future<void> 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<GroupRide?> rideController;
|
||||
late StreamController<List<GroupRideParticipant>> participantsController;
|
||||
late StreamController<List<LiveGroupPresence>> presenceController;
|
||||
late StreamController<Position> 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<List<LiveGroupPresence>>.broadcast();
|
||||
positionController = StreamController<Position>.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<void>.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<void>.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<void>.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<void>.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<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();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue