diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index f844493..58a0b57 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -2,7 +2,6 @@
-
diff --git a/firebase/emulator-tests/firestore.rules.test.js b/firebase/emulator-tests/firestore.rules.test.js
index d2ea2d7..c1c2eb1 100644
--- a/firebase/emulator-tests/firestore.rules.test.js
+++ b/firebase/emulator-tests/firestore.rules.test.js
@@ -793,6 +793,174 @@ describe('Social Architecture Security Rules', () => {
// Bob CANNOT read Alice's presence
await assertFails(getDoc(doc(bob, 'group_rides/ride_lifecycle/live_presence/alice')));
});
+
+ test('quick status security: organizer cannot impersonate or modify another rider status', async () => {
+ const alice = testEnv.authenticatedContext('alice').firestore();
+ const bob = testEnv.authenticatedContext('bob').firestore();
+
+ await testEnv.withSecurityRulesDisabled(async (context) => {
+ const db = context.firestore();
+ await setDoc(doc(db, 'group_rides/ride_status_sec'), {
+ id: 'ride_status_sec',
+ organizerUid: 'alice',
+ title: 'Status Security Ride',
+ status: 'underway',
+ scheduledStartAt: '2026-09-10T09:00:00Z',
+ departureAt: '2026-09-10T09:30:00Z',
+ participantCount: 2,
+ visibility: 'public',
+ meetingPoint: { latitude: 37.77, longitude: -122.41, displayName: 'SF' },
+ });
+
+ await setDoc(doc(db, 'group_rides/ride_status_sec/participants/alice'), {
+ userId: 'alice',
+ role: 'organizer',
+ status: 'accepted',
+ riderHandle: 'alice',
+ riderDisplayName: 'Alice',
+ });
+
+ await setDoc(doc(db, 'group_rides/ride_status_sec/participants/bob'), {
+ userId: 'bob',
+ role: 'participant',
+ status: 'accepted',
+ riderHandle: 'bob',
+ riderDisplayName: 'Bob',
+ });
+
+ await setDoc(doc(db, 'group_rides/ride_status_sec/live_presence/bob'), {
+ userId: 'bob',
+ groupRideId: 'ride_status_sec',
+ latitude: 37.78,
+ longitude: -122.41,
+ heading: 0.0,
+ speedMph: 25.0,
+ accuracyMeters: 5.0,
+ updatedAt: '2026-09-10T09:40:00Z',
+ expiresAt: '2026-09-10T09:42:00Z',
+ sharingEnabled: true,
+ status: 'riding',
+ });
+ });
+
+ // 1. Bob can update his own quick status to 'needFuel'
+ await assertSucceeds(updateDoc(doc(bob, 'group_rides/ride_status_sec/live_presence/bob'), {
+ status: 'needFuel',
+ updatedAt: '2026-09-10T09:41:00Z',
+ }));
+
+ // 2. Organizer Alice CANNOT update Bob's presence document or status
+ await assertFails(updateDoc(doc(alice, 'group_rides/ride_status_sec/live_presence/bob'), {
+ status: 'riding',
+ }));
+
+ // 3. Organizer Alice CANNOT create a presence document under Bob's UID
+ await assertFails(setDoc(doc(alice, 'group_rides/ride_status_sec/live_presence/bob'), {
+ userId: 'bob',
+ groupRideId: 'ride_status_sec',
+ latitude: 37.78,
+ longitude: -122.41,
+ heading: 0.0,
+ speedMph: 25.0,
+ accuracyMeters: 5.0,
+ updatedAt: '2026-09-10T09:42:00Z',
+ expiresAt: '2026-09-10T09:44:00Z',
+ sharingEnabled: true,
+ status: 'arrived',
+ }));
+ });
+
+ test('session authority: organizer transitions, participant denials, and terminal states', async () => {
+ const alice = testEnv.authenticatedContext('alice').firestore();
+ const bob = testEnv.authenticatedContext('bob').firestore();
+
+ await testEnv.withSecurityRulesDisabled(async (context) => {
+ const db = context.firestore();
+ await setDoc(doc(db, 'group_rides/ride_transition_test'), {
+ id: 'ride_transition_test',
+ organizerUid: 'alice',
+ title: 'Session Authority Ride',
+ status: 'scheduled',
+ scheduledStartAt: '2026-09-10T09:00:00Z',
+ departureAt: '2026-09-10T09:30:00Z',
+ participantCount: 2,
+ visibility: 'public',
+ meetingPoint: { latitude: 37.77, longitude: -122.41, displayName: 'SF' },
+ });
+
+ await setDoc(doc(db, 'group_rides/ride_transition_test/participants/alice'), {
+ userId: 'alice',
+ role: 'organizer',
+ status: 'accepted',
+ riderHandle: 'alice',
+ riderDisplayName: 'Alice',
+ });
+
+ await setDoc(doc(db, 'group_rides/ride_transition_test/participants/bob'), {
+ userId: 'bob',
+ role: 'participant',
+ status: 'accepted',
+ riderHandle: 'bob',
+ riderDisplayName: 'Bob',
+ });
+ });
+
+ // 1. Participant Bob CANNOT start the ride (scheduled -> underway)
+ await assertFails(updateDoc(doc(bob, 'group_rides/ride_transition_test'), {
+ status: 'underway',
+ }));
+
+ // 2. Participant Bob CANNOT cancel the ride
+ await assertFails(updateDoc(doc(bob, 'group_rides/ride_transition_test'), {
+ status: 'canceled',
+ }));
+
+ // 3. Organizer Alice transitions scheduled -> assembling: SUCCEEDS
+ await assertSucceeds(updateDoc(doc(alice, 'group_rides/ride_transition_test'), {
+ status: 'assembling',
+ }));
+
+ // 4. Organizer Alice transitions assembling -> underway: SUCCEEDS
+ await assertSucceeds(updateDoc(doc(alice, 'group_rides/ride_transition_test'), {
+ status: 'underway',
+ }));
+
+ // 5. Participant Bob CANNOT complete the ride
+ await assertFails(updateDoc(doc(bob, 'group_rides/ride_transition_test'), {
+ status: 'completed',
+ }));
+
+ // 6. Organizer Alice transitions underway -> completed: SUCCEEDS
+ await assertSucceeds(updateDoc(doc(alice, 'group_rides/ride_transition_test'), {
+ status: 'completed',
+ }));
+
+ // 7. INVALID TRANSITION: completed -> underway MUST BE DENIED for organizer Alice
+ await assertFails(updateDoc(doc(alice, 'group_rides/ride_transition_test'), {
+ status: 'underway',
+ }));
+
+ // 8. Setup a canceled ride and verify canceled -> underway is denied
+ await testEnv.withSecurityRulesDisabled(async (context) => {
+ const db = context.firestore();
+ await setDoc(doc(db, 'group_rides/ride_canceled_test'), {
+ id: 'ride_canceled_test',
+ organizerUid: 'alice',
+ title: 'Canceled Authority Ride',
+ status: 'canceled',
+ scheduledStartAt: '2026-09-10T09:00:00Z',
+ departureAt: '2026-09-10T09:30:00Z',
+ participantCount: 2,
+ visibility: 'public',
+ meetingPoint: { latitude: 37.77, longitude: -122.41, displayName: 'SF' },
+ });
+ });
+
+ // INVALID TRANSITION: canceled -> underway MUST BE DENIED
+ await assertFails(updateDoc(doc(alice, 'group_rides/ride_canceled_test'), {
+ status: 'underway',
+ }));
+ });
});
});
diff --git a/firestore.rules b/firestore.rules
index e061d50..f78f416 100644
--- a/firestore.rules
+++ b/firestore.rules
@@ -221,13 +221,22 @@ service cloud.firestore {
&& request.resource.data.id == groupRideId
&& request.resource.data.keys().hasAll(['id', 'organizerUid', 'title', 'scheduledStartAt', 'departureAt', 'meetingPoint'])
&& request.resource.data.participantCount >= 1;
+ function validStatusTransition() {
+ let prev = resource.data.status;
+ let next = request.resource.data.status;
+ return prev == next ||
+ (prev == 'scheduled' && next in ['assembling', 'underway', 'canceled']) ||
+ (prev == 'assembling' && next in ['underway', 'canceled']) ||
+ (prev == 'underway' && next in ['completed', 'canceled']);
+ }
+
allow update: if signedIn() && (
- // Organizer can update any ride authority fields
- isOrganizer() ||
- // Participants/invitees can atomically adjust participantCount during join/leave transactions
+ // Organizer can update ride authority fields with valid status transitions
+ (isOrganizer() && validStatusTransition()) ||
+ // Participants/invitees can only atomically adjust participantCount during join/leave transactions
(
canReadRide() &&
- request.resource.data.diff(resource.data).affectedKeys().hasOnly(['participantCount', 'updatedAt', 'status', 'canceledReason', 'canceledAt'])
+ request.resource.data.diff(resource.data).affectedKeys().hasOnly(['participantCount', 'updatedAt'])
)
);
allow delete: if isOrganizer();
diff --git a/lib/features/group_rides/live_group_ride_screen.dart b/lib/features/group_rides/live_group_ride_screen.dart
index bb01469..2180980 100644
--- a/lib/features/group_rides/live_group_ride_screen.dart
+++ b/lib/features/group_rides/live_group_ride_screen.dart
@@ -19,10 +19,30 @@ class LiveGroupRideScreen extends ConsumerStatefulWidget {
_LiveGroupRideScreenState();
}
-class _LiveGroupRideScreenState extends ConsumerState {
+class _LiveGroupRideScreenState extends ConsumerState
+ with WidgetsBindingObserver {
AppLatLng? _customMapCenter;
double _mapZoom = 15.0;
+ @override
+ void initState() {
+ super.initState();
+ WidgetsBinding.instance.addObserver(this);
+ }
+
+ @override
+ void dispose() {
+ WidgetsBinding.instance.removeObserver(this);
+ super.dispose();
+ }
+
+ @override
+ void didChangeAppLifecycleState(AppLifecycleState state) {
+ ref
+ .read(liveBroadcastControllerProvider(widget.rideId).notifier)
+ .updateAppLifecycle(state);
+ }
+
@override
Widget build(BuildContext context) {
final rideAsync = ref.watch(groupRideStreamProvider(widget.rideId));
@@ -616,12 +636,18 @@ class _LiveGroupRideScreenState extends ConsumerState {
children: [
Text(
broadcastState.isBroadcasting
- ? 'SHARING LIVE'
+ ? (broadcastState.isPaused
+ ? 'SHARING PAUSED'
+ : (broadcastState.isBackground
+ ? 'BACKGROUND SHARING'
+ : 'SHARING LIVE'))
: 'GHOST MODE',
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: broadcastState.isBroadcasting
- ? const Color(0xFF00E5FF)
+ ? (broadcastState.isPaused
+ ? Colors.amber
+ : const Color(0xFF00E5FF))
: Colors.white60,
fontWeight: FontWeight.bold,
fontSize: 11,
@@ -630,7 +656,11 @@ class _LiveGroupRideScreenState extends ConsumerState {
),
Text(
broadcastState.isBroadcasting
- ? 'Broadcasting to riders'
+ ? (broadcastState.isPaused
+ ? 'Reconnecting (${broadcastState.retryCount})...'
+ : (broadcastState.isBackground
+ ? 'Active via foreground service'
+ : 'Broadcasting to riders'))
: 'Location paused',
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white38, fontSize: 10),
@@ -640,6 +670,7 @@ class _LiveGroupRideScreenState extends ConsumerState {
),
Switch(
value: broadcastState.isOptedIn,
+ materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
activeThumbColor: const Color(0xFF00E5FF),
onChanged: (val) => broadcastController.setOptIn(val),
),
diff --git a/lib/services/group_rides/live_broadcast_controller.dart b/lib/services/group_rides/live_broadcast_controller.dart
index 50c27c7..def11b6 100644
--- a/lib/services/group_rides/live_broadcast_controller.dart
+++ b/lib/services/group_rides/live_broadcast_controller.dart
@@ -1,8 +1,9 @@
import 'dart:async';
-import 'package:flutter/foundation.dart';
+import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:geolocator/geolocator.dart';
import '../auth/auth_providers.dart';
+import '../location_service.dart';
import 'group_ride_models.dart';
import 'group_ride_providers.dart';
import 'group_ride_service.dart';
@@ -14,6 +15,9 @@ class LiveBroadcastState {
final bool isMasterAllowed;
final bool isEligible;
final bool isBroadcasting;
+ final bool isPaused;
+ final int retryCount;
+ final bool isBackground;
final LivePresenceStatus status;
final Position? lastPosition;
final DateTime? lastPublishedAt;
@@ -25,6 +29,9 @@ class LiveBroadcastState {
this.isMasterAllowed = false,
this.isEligible = false,
this.isBroadcasting = false,
+ this.isPaused = false,
+ this.retryCount = 0,
+ this.isBackground = false,
this.status = LivePresenceStatus.riding,
this.lastPosition,
this.lastPublishedAt,
@@ -37,6 +44,9 @@ class LiveBroadcastState {
bool? isMasterAllowed,
bool? isEligible,
bool? isBroadcasting,
+ bool? isPaused,
+ int? retryCount,
+ bool? isBackground,
LivePresenceStatus? status,
Position? lastPosition,
DateTime? lastPublishedAt,
@@ -48,6 +58,9 @@ class LiveBroadcastState {
isMasterAllowed: isMasterAllowed ?? this.isMasterAllowed,
isEligible: isEligible ?? this.isEligible,
isBroadcasting: isBroadcasting ?? this.isBroadcasting,
+ isPaused: isPaused ?? this.isPaused,
+ retryCount: retryCount ?? this.retryCount,
+ isBackground: isBackground ?? this.isBackground,
status: status ?? this.status,
lastPosition: lastPosition ?? this.lastPosition,
lastPublishedAt: lastPublishedAt ?? this.lastPublishedAt,
@@ -76,10 +89,15 @@ class LiveBroadcastController extends StateNotifier {
StreamSubscription>? _presenceSub;
Timer? _heartbeatTimer;
+ DateTime? _nextRetryTime;
+ int _consecutiveErrors = 0;
+
Position? _lastBroadcastPosition;
DateTime? _lastBroadcastTime;
bool _isDisposed = false;
+ final DateTime Function() _now;
+
LiveBroadcastController({
required this.rideId,
required this.callerUid,
@@ -89,7 +107,9 @@ class LiveBroadcastController extends StateNotifier {
this.participantsStream,
this.presenceStream,
bool initialMasterAllowed = false,
- }) : super(LiveBroadcastState(isMasterAllowed: initialMasterAllowed)) {
+ DateTime Function()? now,
+ }) : _now = now ?? DateTime.now,
+ super(LiveBroadcastState(isMasterAllowed: initialMasterAllowed)) {
_initSubscriptions();
}
@@ -181,6 +201,12 @@ class LiveBroadcastController extends StateNotifier {
if (_isDisposed || state.isMasterAllowed == allowed) return;
state = state.copyWith(isMasterAllowed: allowed);
_evaluateEligibility();
+ if (!allowed) {
+ service.clearLivePresence(
+ callerUid: callerUid,
+ groupRideId: rideId,
+ );
+ }
}
/// Toggles per-ride opt-in for location broadcasting.
@@ -188,7 +214,13 @@ class LiveBroadcastController extends StateNotifier {
if (_isDisposed) return;
if (state.isOptedIn == optIn) return;
- state = state.copyWith(isOptedIn: optIn);
+ _consecutiveErrors = 0;
+ _nextRetryTime = null;
+ state = state.copyWith(
+ isOptedIn: optIn,
+ isPaused: false,
+ retryCount: 0,
+ );
_evaluateEligibility();
if (!optIn) {
@@ -223,6 +255,16 @@ class LiveBroadcastController extends StateNotifier {
await setStatus(LivePresenceStatus.riding);
}
+ /// Updates app lifecycle state (e.g. background vs foreground).
+ void updateAppLifecycle(AppLifecycleState lifecycle) {
+ if (_isDisposed) return;
+ final isBg = lifecycle == AppLifecycleState.paused ||
+ lifecycle == AppLifecycleState.hidden;
+ if (state.isBackground != isBg) {
+ state = state.copyWith(isBackground: isBg);
+ }
+ }
+
void _startBroadcasting() {
_positionSub?.cancel();
final stream = positionStream ?? Geolocator.getPositionStream(
@@ -255,6 +297,17 @@ class LiveBroadcastController extends StateNotifier {
void _onPositionUpdate(Position position) {
if (_isDisposed || !state.isBroadcasting) return;
+ // Reject invalid coordinates, out of bounds, or Null Island (0,0)
+ if (position.latitude.isNaN ||
+ position.longitude.isNaN ||
+ position.latitude < -90.0 ||
+ position.latitude > 90.0 ||
+ position.longitude < -180.0 ||
+ position.longitude > 180.0 ||
+ (position.latitude == 0.0 && position.longitude == 0.0)) {
+ return;
+ }
+
// Filter accuracy: reject fixes with accuracy > 30m
if (position.accuracy > 30.0) {
return;
@@ -262,10 +315,16 @@ class LiveBroadcastController extends StateNotifier {
state = state.copyWith(lastPosition: position);
+ final now = _now();
+
+ // Respect exponential backoff throttle during transient errors
+ if (_nextRetryTime != null && now.isBefore(_nextRetryTime!)) {
+ return;
+ }
+
final speedMph = position.speed * 2.23694;
final isMoving = speedMph > 3.0;
- final now = DateTime.now();
final lastTime = _lastBroadcastTime;
if (lastTime == null) {
@@ -276,7 +335,7 @@ class LiveBroadcastController extends StateNotifier {
final elapsedSeconds = now.difference(lastTime).inSeconds;
if (isMoving) {
- // Moving cadence: 5 seconds with displacement > 15m or >= 5 seconds interval
+ // Moving cadence: 5 seconds with displacement > 15m or >= 10 seconds interval
if (elapsedSeconds >= 5) {
double displacement = 0;
if (_lastBroadcastPosition != null) {
@@ -304,7 +363,12 @@ class LiveBroadcastController extends StateNotifier {
final lastTime = _lastBroadcastTime;
if (lastTime == null) return;
- final elapsed = DateTime.now().difference(lastTime).inSeconds;
+ final now = _now();
+ if (_nextRetryTime != null && now.isBefore(_nextRetryTime!)) {
+ return;
+ }
+
+ final elapsed = now.difference(lastTime).inSeconds;
// If stopped or no fix received, keep heartbeat within 30s
if (elapsed >= 30) {
_publishPosition(state.lastPosition!, force: true);
@@ -315,7 +379,7 @@ class LiveBroadcastController extends StateNotifier {
if (!state.isBroadcasting && !force) return;
final speedMph = position.speed * 2.23694;
- final now = DateTime.now();
+ final now = _now();
try {
await service.publishLivePresence(
@@ -332,19 +396,54 @@ class LiveBroadcastController extends StateNotifier {
_lastBroadcastPosition = position;
_lastBroadcastTime = now;
+ _consecutiveErrors = 0;
+ _nextRetryTime = null;
state = state.copyWith(
lastPublishedAt: now,
errorMessage: null,
+ isPaused: false,
+ retryCount: 0,
);
} catch (e) {
- state = state.copyWith(errorMessage: 'Publish failed: $e');
+ _handlePublishError(e);
}
}
+ void _handlePublishError(Object error) {
+ if (_isDisposed) return;
+
+ final errStr = error.toString().toLowerCase();
+ final isPermissionDenied = errStr.contains('permission-denied') ||
+ errStr.contains('permission_denied') ||
+ errStr.contains('unauthenticated');
+
+ if (isPermissionDenied) {
+ // Terminal: user removed, blocked, ride completed, or rules denied write.
+ // Immediately stop broadcasting, cancel timers, and do not retry.
+ _revokeBroadcasting('Authorization lost: permission denied');
+ return;
+ }
+
+ // Transient network/offline error:
+ _consecutiveErrors++;
+ // Exponential backoff capped at 30 seconds
+ final backoffSeconds = (5 * (1 << (_consecutiveErrors - 1))).clamp(5, 30);
+ _nextRetryTime = _now().add(Duration(seconds: backoffSeconds));
+
+ state = state.copyWith(
+ errorMessage: 'Publish failed: $error',
+ isPaused: true,
+ retryCount: _consecutiveErrors,
+ );
+ }
+
void _revokeBroadcasting(String reason) {
state = state.copyWith(
isEligible: false,
isBroadcasting: false,
+ isOptedIn: false,
+ isPaused: false,
+ retryCount: 0,
errorMessage: reason,
);
_stopBroadcasting();
@@ -380,12 +479,14 @@ final liveBroadcastControllerProvider = StateNotifierProvider.autoDispose
final user = ref.watch(currentUserProvider);
final service = ref.watch(groupRideServiceProvider);
final masterAllowed = ref.watch(groupRideLiveSharingAllowedProvider);
+ final locationService = ref.watch(locationServiceProvider);
final callerUid = user?.uid ?? '';
final controller = LiveBroadcastController(
rideId: rideId,
callerUid: callerUid,
service: service,
+ positionStream: locationService.getFilteredPositionStream(),
initialMasterAllowed: masterAllowed,
);
diff --git a/lib/services/location_service.dart b/lib/services/location_service.dart
index b8c497b..754daec 100644
--- a/lib/services/location_service.dart
+++ b/lib/services/location_service.dart
@@ -54,9 +54,6 @@ class LocationService {
if (geoPermission == LocationPermission.whileInUse ||
geoPermission == LocationPermission.always ||
locStatus.isGranted) {
- try {
- await Permission.locationAlways.request();
- } catch (_) {}
// Restart/refresh GPS stream now that permissions are granted
_startListening();
return true;
diff --git a/test/live_broadcast_controller_test.dart b/test/live_broadcast_controller_test.dart
index 9d00f11..30caf14 100644
--- a/test/live_broadcast_controller_test.dart
+++ b/test/live_broadcast_controller_test.dart
@@ -1,4 +1,5 @@
import 'dart:async';
+import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:geolocator/geolocator.dart';
import 'package:motorcycle_dash/services/group_rides/group_ride_models.dart';
@@ -8,6 +9,7 @@ import 'package:motorcycle_dash/services/group_rides/live_broadcast_controller.d
class MockGroupRideService extends GroupRideService {
final List