From 173c99d4503aab02a0e7ffe637952b80e8b61a10 Mon Sep 17 00:00:00 2001 From: Developer Date: Thu, 3 Sep 2026 20:37:26 -0400 Subject: [PATCH] fix: harden live group ride privacy architecture --- .../emulator-tests/firestore.rules.test.js | 151 ++++++ firestore.rules | 58 +++ .../group_rides/group_ride_detail_screen.dart | 451 ++++++++++++++++++ lib/features/settings/account_screen.dart | 63 +++ .../group_rides/group_ride_models.dart | 182 +++++++ .../group_rides/group_ride_providers.dart | 18 + .../group_rides/group_ride_service.dart | 127 ++++- test/group_ride_models_test.dart | 129 +++++ test/group_ride_service_test.dart | 22 + 9 files changed, 1200 insertions(+), 1 deletion(-) diff --git a/firebase/emulator-tests/firestore.rules.test.js b/firebase/emulator-tests/firestore.rules.test.js index 68afa82..8e002f7 100644 --- a/firebase/emulator-tests/firestore.rules.test.js +++ b/firebase/emulator-tests/firestore.rules.test.js @@ -518,5 +518,156 @@ describe('Social Architecture Security Rules', () => { title: 'Hijacked Title', })); }); + + test('live group presence security rules', async () => { + const alice = testEnv.authenticatedContext('alice').firestore(); + const bob = testEnv.authenticatedContext('bob').firestore(); + const charlie = testEnv.authenticatedContext('charlie').firestore(); + const dave = testEnv.authenticatedContext('dave').firestore(); + + await testEnv.withSecurityRulesDisabled(async (context) => { + const db = context.firestore(); + // Ride setup: Alice is organizer, Bob is accepted participant, Charlie is invited only, Dave is stranger + await setDoc(doc(db, 'group_rides/ride_live'), { + id: 'ride_live', + organizerUid: 'alice', + title: 'Live Tracking Run', + 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: 'San Francisco' }, + }); + + await setDoc(doc(db, 'group_rides/ride_live/participants/alice'), { + userId: 'alice', + role: 'organizer', + status: 'accepted', + riderHandle: 'alice_rider', + riderDisplayName: 'Alice Rider', + }); + + await setDoc(doc(db, 'group_rides/ride_live/participants/bob'), { + userId: 'bob', + role: 'participant', + status: 'accepted', + riderHandle: 'bob_rider', + riderDisplayName: 'Bob Rider', + }); + + await setDoc(doc(db, 'group_rides/ride_live/participants/charlie'), { + userId: 'charlie', + role: 'participant', + status: 'invited', + riderHandle: 'charlie_rider', + riderDisplayName: 'Charlie Rider', + }); + + await setDoc(doc(db, 'users/alice'), { id: 'alice' }); + await setDoc(doc(db, 'users/bob'), { id: 'bob' }); + }); + + const validBobPresence = { + userId: 'bob', + groupRideId: 'ride_live', + latitude: 37.7833, + longitude: -122.4167, + heading: 90.0, + speedMph: 45.0, + accuracyMeters: 5.0, + updatedAt: '2026-09-10T09:40:00Z', + expiresAt: '2026-09-10T09:42:00Z', + sharingEnabled: true, + status: 'riding', + }; + + // 1. Accepted participant Bob publishes his own presence: SUCCEEDS + await assertSucceeds(setDoc(doc(bob, 'group_rides/ride_live/live_presence/bob'), validBobPresence)); + + // 2. Stranger Dave CANNOT publish presence + await assertFails(setDoc(doc(dave, 'group_rides/ride_live/live_presence/dave'), { + ...validBobPresence, + userId: 'dave', + })); + + // 3. Invited-only Charlie CANNOT publish presence until accepted + await assertFails(setDoc(doc(charlie, 'group_rides/ride_live/live_presence/charlie'), { + ...validBobPresence, + userId: 'charlie', + })); + + // 4. Bob CANNOT publish presence for Alice (spoofing) + await assertFails(setDoc(doc(bob, 'group_rides/ride_live/live_presence/alice'), { + ...validBobPresence, + userId: 'alice', + })); + + // 5. Organizer Alice CANNOT publish on Bob's presence document + await assertFails(setDoc(doc(alice, 'group_rides/ride_live/live_presence/bob'), { + ...validBobPresence, + userId: 'bob', + })); + + // 6. Non-whitelisted keys rejected + await assertFails(setDoc(doc(bob, 'group_rides/ride_live/live_presence/bob'), { + ...validBobPresence, + leanAngle: 42.0, + })); + + // 7. Invalid coordinates rejected + await assertFails(setDoc(doc(bob, 'group_rides/ride_live/live_presence/bob'), { + ...validBobPresence, + latitude: 95.0, + })); + + // 8. Accepted participant Alice CAN read Bob's presence + await assertSucceeds(getDoc(doc(alice, 'group_rides/ride_live/live_presence/bob'))); + + // 9. Stranger Dave CANNOT read Bob's presence even on public ride + await assertFails(getDoc(doc(dave, 'group_rides/ride_live/live_presence/bob'))); + + // 10. If Bob has sharingEnabled: false, Alice CANNOT read Bob's presence + await testEnv.withSecurityRulesDisabled(async (context) => { + const db = context.firestore(); + await setDoc(doc(db, 'group_rides/ride_live/live_presence/bob'), { + ...validBobPresence, + sharingEnabled: false, + }); + }); + await assertFails(getDoc(doc(alice, 'group_rides/ride_live/live_presence/bob'))); + // But Bob CAN still read his own presence + await assertSucceeds(getDoc(doc(bob, 'group_rides/ride_live/live_presence/bob'))); + + // 11. If Alice blocks Bob, Alice CANNOT read Bob's presence even if sharingEnabled: true + await testEnv.withSecurityRulesDisabled(async (context) => { + const db = context.firestore(); + await setDoc(doc(db, 'group_rides/ride_live/live_presence/bob'), validBobPresence); + await setDoc(doc(db, 'users/alice/blocks/bob'), { blockedUid: 'bob' }); + }); + await assertFails(getDoc(doc(alice, 'group_rides/ride_live/live_presence/bob'))); + + // 12. If ride is canceled, publishing is denied + await testEnv.withSecurityRulesDisabled(async (context) => { + const db = context.firestore(); + await deleteDoc(doc(db, 'users/alice/blocks/bob')); + await setDoc(doc(db, 'group_rides/ride_live'), { + id: 'ride_live', + organizerUid: 'alice', + title: 'Live Tracking Run', + 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: 'San Francisco' }, + }); + }); + await assertFails(setDoc(doc(bob, 'group_rides/ride_live/live_presence/bob'), validBobPresence)); + + // 13. Bob can delete his own presence record + await assertSucceeds(deleteDoc(doc(bob, 'group_rides/ride_live/live_presence/bob'))); + }); }); }); + diff --git a/firestore.rules b/firestore.rules index 18d4e71..a67de5f 100644 --- a/firestore.rules +++ b/firestore.rules @@ -290,6 +290,64 @@ service cloud.firestore { request.auth.uid == rideOrganizer() ); } + + // --- LIVE PRESENCE SUBCOLLECTION --- + // Ephemeral location and status sharing during an active or assembling ride. + match /live_presence/{presenceUid} { + function rideDoc() { + return get(/databases/$(database)/documents/group_rides/$(groupRideId)); + } + + function isAcceptedParticipant(uid) { + return exists(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(uid)) && + get(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(uid)).data.status == 'accepted'; + } + + function rideIsLiveEligible() { + return rideDoc().data.status in ['scheduled', 'assembling', 'underway']; + } + + function validCoordinates(data) { + return data.latitude >= -90.0 && data.latitude <= 90.0 && + data.longitude >= -180.0 && data.longitude <= 180.0; + } + + function validPresenceKeys(data) { + return data.keys().hasAll(['userId', 'groupRideId', 'latitude', 'longitude', 'updatedAt', 'expiresAt', 'sharingEnabled', 'status']) && + data.keys().hasOnly(['userId', 'groupRideId', 'latitude', 'longitude', 'heading', 'speedMph', 'accuracyMeters', 'updatedAt', 'expiresAt', 'sharingEnabled', 'status']); + } + + // READ: + // Caller must be signed in, be an accepted participant of this ride, ride must be live-eligible, + // and either caller is reading own presence or (sharingEnabled == true and caller not mutually blocked with target). + allow read: if signedIn() + && isAcceptedParticipant(request.auth.uid) + && rideIsLiveEligible() + && ( + request.auth.uid == presenceUid || + (resource.data.sharingEnabled == true && !areMutuallyBlocked(request.auth.uid, presenceUid)) + ); + + // WRITE (CREATE/UPDATE): + // Caller must write only their own document, must be an accepted participant, + // ride must be live-eligible, valid coordinates, valid schema keys, + // request.resource.data.userId == request.auth.uid and groupRideId == groupRideId. + allow create, update: if signedIn() + && request.auth.uid == presenceUid + && request.resource.data.userId == request.auth.uid + && request.resource.data.groupRideId == groupRideId + && isAcceptedParticipant(request.auth.uid) + && rideIsLiveEligible() + && validCoordinates(request.resource.data) + && validPresenceKeys(request.resource.data); + + // DELETE: + // Rider can delete their own live presence record, or ride organizer during cleanup. + allow delete: if signedIn() && ( + request.auth.uid == presenceUid || + request.auth.uid == rideDoc().data.organizerUid + ); + } } match /{document=**} { diff --git a/lib/features/group_rides/group_ride_detail_screen.dart b/lib/features/group_rides/group_ride_detail_screen.dart index 5ce29e8..af16dd8 100644 --- a/lib/features/group_rides/group_ride_detail_screen.dart +++ b/lib/features/group_rides/group_ride_detail_screen.dart @@ -201,6 +201,12 @@ class _GroupRideDetailScreenState extends ConsumerState { _buildHeaderCard(context, ride, isOrganizer), const SizedBox(height: 16), + // Organizer Status Controls + if (isOrganizer && !ride.isCanceled && !ride.isCompleted) ...[ + _buildOrganizerControlCard(context, ride), + const SizedBox(height: 16), + ], + // Dynamic action banner based on membership/organizer state _buildActionBanner( context, @@ -211,6 +217,19 @@ class _GroupRideDetailScreenState extends ConsumerState { ), const SizedBox(height: 16), + // Live Presence Ephemeral Section (for active members) + if (ride.isLiveEligible && + myParticipant != null && + myParticipant.status == ParticipantStatus.accepted) ...[ + _buildLivePresenceSection( + context, + ride, + myParticipant, + currentUid, + ), + const SizedBox(height: 16), + ], + // Schedule card _buildScheduleCard(ride), const SizedBox(height: 16), @@ -1160,4 +1179,436 @@ class _GroupRideDetailScreenState extends ConsumerState { final period = dt.hour >= 12 ? 'PM' : 'AM'; return '$hour:$minute $period'; } + + Widget _buildOrganizerControlCard(BuildContext context, GroupRide ride) { + final service = ref.read(groupRideServiceProvider); + final currentUid = ref.read(currentUserProvider)?.uid; + + return Card( + color: const Color(0xFF1B1E28), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: const BorderSide(color: Color(0xFF00E5FF), width: 0.8), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Row( + children: [ + Icon(Icons.admin_panel_settings, color: Color(0xFF00E5FF), size: 18), + SizedBox(width: 8), + Text( + 'ORGANIZER CONTROLS', + style: TextStyle( + color: Color(0xFF00E5FF), + fontWeight: FontWeight.bold, + letterSpacing: 1.0, + fontSize: 12, + ), + ), + ], + ), + const SizedBox(height: 10), + Text( + 'Current Status: ${ride.status.label.toUpperCase()}', + style: const TextStyle(color: Colors.white70, fontSize: 13), + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + if (ride.status == GroupRideStatus.scheduled) ...[ + ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF00E5FF).withValues(alpha: 0.2), + foregroundColor: const Color(0xFF00E5FF), + side: const BorderSide(color: Color(0xFF00E5FF)), + ), + icon: const Icon(Icons.group, size: 16), + label: const Text('START ASSEMBLING'), + onPressed: _isProcessing || currentUid == null + ? null + : () async { + setState(() => _isProcessing = true); + await service.updateRideStatus( + callerUid: currentUid, + groupRideId: ride.id, + newStatus: GroupRideStatus.assembling, + ); + if (mounted) setState(() => _isProcessing = false); + }, + ), + ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF00E5FF), + foregroundColor: Colors.black, + ), + icon: const Icon(Icons.two_wheeler, size: 16), + label: const Text('START RIDE (UNDERWAY)', style: TextStyle(fontWeight: FontWeight.bold)), + onPressed: _isProcessing || currentUid == null + ? null + : () async { + setState(() => _isProcessing = true); + await service.updateRideStatus( + callerUid: currentUid, + groupRideId: ride.id, + newStatus: GroupRideStatus.underway, + ); + if (mounted) setState(() => _isProcessing = false); + }, + ), + ], + if (ride.status == GroupRideStatus.assembling) ...[ + ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF00E5FF), + foregroundColor: Colors.black, + ), + icon: const Icon(Icons.two_wheeler, size: 16), + label: const Text('START RIDE (UNDERWAY)', style: TextStyle(fontWeight: FontWeight.bold)), + onPressed: _isProcessing || currentUid == null + ? null + : () async { + setState(() => _isProcessing = true); + await service.updateRideStatus( + callerUid: currentUid, + groupRideId: ride.id, + newStatus: GroupRideStatus.underway, + ); + if (mounted) setState(() => _isProcessing = false); + }, + ), + ], + if (ride.status == GroupRideStatus.underway) ...[ + ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.greenAccent.withValues(alpha: 0.2), + foregroundColor: Colors.greenAccent, + side: const BorderSide(color: Colors.greenAccent), + ), + icon: const Icon(Icons.flag, size: 16), + label: const Text('FINISH / COMPLETE RIDE', style: TextStyle(fontWeight: FontWeight.bold)), + onPressed: _isProcessing || currentUid == null + ? null + : () async { + setState(() => _isProcessing = true); + await service.updateRideStatus( + callerUid: currentUid, + groupRideId: ride.id, + newStatus: GroupRideStatus.completed, + ); + if (mounted) setState(() => _isProcessing = false); + }, + ), + ], + ], + ), + ], + ), + ), + ); + } + + Widget _buildLivePresenceSection( + BuildContext context, + GroupRide ride, + GroupRideParticipant myParticipant, + String? currentUid, + ) { + if (currentUid == null) return const SizedBox.shrink(); + + final masterAllowed = ref.watch(groupRideLiveSharingAllowedProvider); + final activeSharingRideId = ref.watch(activeLiveRideSharingIdProvider); + final isCurrentlySharing = activeSharingRideId == ride.id; + final livePresenceAsync = ref.watch(groupRideLivePresenceProvider(ride.id)); + final service = ref.read(groupRideServiceProvider); + + return Card( + color: const Color(0xFF161922), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide( + color: isCurrentlySharing + ? const Color(0xFF00E5FF) + : Colors.white12, + width: isCurrentlySharing ? 1.2 : 0.8, + ), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.radar, + color: isCurrentlySharing + ? const Color(0xFF00E5FF) + : Colors.white70, + size: 20, + ), + const SizedBox(width: 8), + const Text( + 'LIVE RIDE PRESENCE', + style: TextStyle( + color: Color(0xFF00E5FF), + fontWeight: FontWeight.bold, + letterSpacing: 1.0, + fontSize: 12, + ), + ), + const Spacer(), + if (isCurrentlySharing) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 3, + ), + decoration: BoxDecoration( + color: const Color(0xFF00E5FF).withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(4), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.circle, color: Color(0xFF00E5FF), size: 8), + SizedBox(width: 4), + Text( + 'BROADCASTING', + style: TextStyle( + color: Color(0xFF00E5FF), + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 12), + if (!masterAllowed) ...[ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.amber.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Colors.amber.withValues(alpha: 0.4), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Row( + children: [ + Icon(Icons.lock_outline, color: Colors.amber, size: 16), + SizedBox(width: 6), + Text( + 'Master Location Privacy is OFF', + style: TextStyle( + color: Colors.amber, + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + ], + ), + const SizedBox(height: 6), + const Text( + 'Live location sharing is currently blocked by your privacy preference. Enable it below to share coordinates in this ride.', + style: TextStyle(color: Colors.white70, fontSize: 11), + ), + const SizedBox(height: 8), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.amber, + foregroundColor: Colors.black, + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 6, + ), + minimumSize: Size.zero, + ), + onPressed: () { + ref.read(groupRideLiveSharingAllowedProvider.notifier).state = true; + }, + child: const Text('ALLOW LOCATION SHARING', style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold)), + ), + ], + ), + ), + ] else ...[ + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text( + 'Share My Live Location in this Ride', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + fontSize: 13, + ), + ), + subtitle: Text( + isCurrentlySharing + ? 'Broadcasting ephemeral coordinates to accepted riders' + : 'Disabled — other riders cannot see your location', + style: TextStyle( + color: isCurrentlySharing + ? const Color(0xFF00E5FF) + : Colors.white38, + fontSize: 11, + ), + ), + value: isCurrentlySharing, + activeThumbColor: const Color(0xFF00E5FF), + onChanged: (val) async { + if (val) { + ref.read(activeLiveRideSharingIdProvider.notifier).state = ride.id; + await service.publishLivePresence( + callerUid: currentUid, + groupRideId: ride.id, + latitude: ride.meetingPoint.latitude, + longitude: ride.meetingPoint.longitude, + status: LivePresenceStatus.riding, + sharingEnabled: true, + ); + } else { + ref.read(activeLiveRideSharingIdProvider.notifier).state = null; + await service.clearLivePresence( + callerUid: currentUid, + groupRideId: ride.id, + ); + } + }, + ), + if (isCurrentlySharing) ...[ + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + style: OutlinedButton.styleFrom( + foregroundColor: Colors.redAccent, + side: const BorderSide(color: Colors.redAccent), + padding: const EdgeInsets.symmetric(vertical: 10), + ), + icon: const Icon(Icons.location_off, size: 16), + label: const Text( + 'STOP SHARING IMMEDIATELY', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 12, + letterSpacing: 0.5, + ), + ), + onPressed: () async { + ref.read(activeLiveRideSharingIdProvider.notifier).state = null; + await service.clearLivePresence( + callerUid: currentUid, + groupRideId: ride.id, + ); + }, + ), + ), + ], + ], + const Divider(height: 24, color: Colors.white12), + livePresenceAsync.when( + data: (presences) { + if (presences.isEmpty) { + return const Text( + 'No riders currently broadcasting live location.', + style: TextStyle(color: Colors.white38, fontSize: 12), + ); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'ACTIVE RIDERS (${presences.length})', + style: const TextStyle( + color: Colors.white60, + fontSize: 11, + fontWeight: FontWeight.bold, + letterSpacing: 0.5, + ), + ), + const SizedBox(height: 8), + for (final p in presences) + Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0), + child: Row( + children: [ + Icon( + p.isFresh ? Icons.circle : Icons.circle_outlined, + color: p.isFresh ? Colors.greenAccent : Colors.amber, + size: 10, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + p.userId == currentUid + ? 'You (${p.status.label})' + : 'Rider (${p.status.label})', + style: TextStyle( + color: p.userId == currentUid + ? const Color(0xFF00E5FF) + : Colors.white, + fontSize: 12, + fontWeight: p.userId == currentUid + ? FontWeight.bold + : FontWeight.normal, + ), + ), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: (p.isFresh ? Colors.greenAccent : Colors.amber).withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + p.isFresh ? 'FRESH' : 'STALE', + style: TextStyle( + color: p.isFresh ? Colors.greenAccent : Colors.amber, + fontSize: 9, + fontWeight: FontWeight.w900, + ), + ), + ), + ], + ), + ), + ], + ); + }, + loading: () => const SizedBox( + height: 24, + child: Center( + child: SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2, color: Color(0xFF00E5FF)), + ), + ), + ), + error: (_, _) => const SizedBox.shrink(), + ), + const SizedBox(height: 12), + const Text( + 'Stage 6.1 Privacy Freeze: Ephemeral location is isolated to /group_rides/{id}/live_presence with 120s TTL and auto-purges on ride completion. Moving radar map and rider dots will be rendered in Stage 7.', + style: TextStyle(color: Colors.white38, fontSize: 10, height: 1.3), + ), + ], + ), + ), + ); + } } diff --git a/lib/features/settings/account_screen.dart b/lib/features/settings/account_screen.dart index 0a6d0fd..4b9a5e0 100644 --- a/lib/features/settings/account_screen.dart +++ b/lib/features/settings/account_screen.dart @@ -8,6 +8,7 @@ import '../../services/auth/auth_state.dart'; import '../../services/auth/auth_user.dart'; import '../../services/sync/cloud_restore_controller.dart'; import '../../services/sync/sync_providers.dart'; +import '../../services/group_rides/group_ride_providers.dart'; import 'widgets/current_plan_card.dart'; class AccountScreen extends ConsumerStatefulWidget { @@ -748,11 +749,73 @@ class _AccountScreenState extends ConsumerState { isAuthenticated: true, ), const SizedBox(height: 16), + _buildGroupRidePrivacyCard(isEvolution), + const SizedBox(height: 16), _buildLocalRidingActiveCard(isEvolution), ], ); } + Widget _buildGroupRidePrivacyCard(bool isEvolution) { + final isAllowed = ref.watch(groupRideLiveSharingAllowedProvider); + return _buildAuthCard( + isEvolution: isEvolution, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.share_location, color: Color(0xFF00E5FF), size: 20), + const SizedBox(width: 8), + Text( + 'GROUP RIDE LIVE LOCATION', + style: TextStyle( + fontFamily: isEvolution ? 'Sora' : null, + fontSize: 14, + fontWeight: FontWeight.w800, + color: Colors.white, + letterSpacing: 0.5, + ), + ), + ], + ), + const SizedBox(height: 8), + const Text( + 'Control whether MotorSignal can broadcast your live location to other accepted riders during an active Group Ride.', + style: TextStyle(color: Colors.white70, fontSize: 12, height: 1.35), + ), + const SizedBox(height: 12), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text( + 'Allow Live Location Sharing', + style: TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.w600), + ), + subtitle: Text( + isAllowed + ? 'Permitted when opted in inside individual rides' + : 'Master block: disabled for all group rides', + style: TextStyle( + color: isAllowed ? const Color(0xFF00E5FF) : Colors.white38, + fontSize: 11, + ), + ), + value: isAllowed, + activeThumbColor: const Color(0xFF00E5FF), + onChanged: (val) { + ref.read(groupRideLiveSharingAllowedProvider.notifier).state = val; + }, + ), + const SizedBox(height: 6), + const Text( + 'Privacy contract: Live location is ephemeral, shared only with accepted riders while a ride is active, and never saved to historical telemetry or rider profiles.', + style: TextStyle(color: Colors.white38, fontSize: 10, height: 1.3), + ), + ], + ), + ); + } + Widget _buildAnonymousView( BuildContext context, AuthUser user, diff --git a/lib/services/group_rides/group_ride_models.dart b/lib/services/group_rides/group_ride_models.dart index d0fe334..bce6b4d 100644 --- a/lib/services/group_rides/group_ride_models.dart +++ b/lib/services/group_rides/group_ride_models.dart @@ -165,6 +165,7 @@ enum GroupRideJoinPolicy { enum GroupRideStatus { scheduled, assembling, + underway, completed, canceled; @@ -174,6 +175,8 @@ enum GroupRideStatus { return 'Scheduled'; case GroupRideStatus.assembling: return 'Assembling'; + case GroupRideStatus.underway: + return 'Underway'; case GroupRideStatus.completed: return 'Completed'; case GroupRideStatus.canceled: @@ -386,6 +389,11 @@ class GroupRide { bool get isCanceled => status == GroupRideStatus.canceled; bool get isCompleted => status == GroupRideStatus.completed; + bool get isUnderway => status == GroupRideStatus.underway; + bool get isLiveEligible => + status == GroupRideStatus.scheduled || + status == GroupRideStatus.assembling || + status == GroupRideStatus.underway; bool get isFull => participantLimit != null && participantCount >= participantLimit!; @@ -548,3 +556,177 @@ class GroupRide { participantCount.hashCode ^ participantLimit.hashCode; } + +/// Status of rider in live group ride presence. +enum LivePresenceStatus { + riding, + stopped, + regrouping, + delayed, + arrived, + offline; + + String get label { + switch (this) { + case LivePresenceStatus.riding: + return 'Riding'; + case LivePresenceStatus.stopped: + return 'Stopped'; + case LivePresenceStatus.regrouping: + return 'Regrouping'; + case LivePresenceStatus.delayed: + return 'Delayed'; + case LivePresenceStatus.arrived: + return 'Arrived'; + case LivePresenceStatus.offline: + return 'Offline'; + } + } +} + +/// Ephemeral live group presence for an accepted group ride participant. +/// +/// Strictly isolated from public profiles, garage, telemetry bundles, and +/// persistent user records. Whitelisted coordinates and coarse indicators only. +@immutable +class LiveGroupPresence { + final String userId; + final String groupRideId; + final double latitude; + final double longitude; + final double? heading; + final double? speedMph; + final double? accuracyMeters; + final DateTime updatedAt; + final DateTime expiresAt; + final bool sharingEnabled; + final LivePresenceStatus status; + + const LiveGroupPresence({ + required this.userId, + required this.groupRideId, + required this.latitude, + required this.longitude, + this.heading, + this.speedMph, + this.accuracyMeters, + required this.updatedAt, + required this.expiresAt, + this.sharingEnabled = true, + this.status = LivePresenceStatus.riding, + }); + + /// Location is considered fresh if updated within the last 60 seconds and sharing is active. + bool get isFresh => + sharingEnabled && DateTime.now().difference(updatedAt).inSeconds < 60; + + /// Location is considered stale if over 120 seconds old or sharing was disabled. + bool get isStale => + !sharingEnabled || DateTime.now().difference(updatedAt).inSeconds >= 120; + + /// Coordinates within valid physical limits. + bool get isValidCoordinates => + latitude >= -90.0 && + latitude <= 90.0 && + longitude >= -180.0 && + longitude <= 180.0; + + /// Reasonable GPS accuracy (within 100m) if provided. + bool get isAccurate => accuracyMeters == null || accuracyMeters! <= 100.0; + + Map toJson() => { + 'userId': userId, + 'groupRideId': groupRideId, + 'latitude': latitude, + 'longitude': longitude, + if (heading != null) 'heading': heading, + if (speedMph != null) 'speedMph': speedMph, + if (accuracyMeters != null) 'accuracyMeters': accuracyMeters, + 'updatedAt': updatedAt.toIso8601String(), + 'expiresAt': expiresAt.toIso8601String(), + 'sharingEnabled': sharingEnabled, + 'status': status.name, + }; + + factory LiveGroupPresence.fromJson(Map json) { + final updated = json['updatedAt'] != null + ? DateTime.tryParse(json['updatedAt'] as String) ?? DateTime.now() + : DateTime.now(); + final expires = json['expiresAt'] != null + ? DateTime.tryParse(json['expiresAt'] as String) ?? + updated.add(const Duration(seconds: 120)) + : updated.add(const Duration(seconds: 120)); + + return LiveGroupPresence( + userId: json['userId'] as String? ?? '', + groupRideId: json['groupRideId'] as String? ?? '', + latitude: (json['latitude'] as num?)?.toDouble() ?? 0.0, + longitude: (json['longitude'] as num?)?.toDouble() ?? 0.0, + heading: (json['heading'] as num?)?.toDouble(), + speedMph: (json['speedMph'] as num?)?.toDouble(), + accuracyMeters: (json['accuracyMeters'] as num?)?.toDouble(), + updatedAt: updated, + expiresAt: expires, + sharingEnabled: json['sharingEnabled'] as bool? ?? false, + status: LivePresenceStatus.values.firstWhere( + (s) => s.name == (json['status'] as String?), + orElse: () => LivePresenceStatus.offline, + ), + ); + } + + LiveGroupPresence copyWith({ + String? userId, + String? groupRideId, + double? latitude, + double? longitude, + double? heading, + double? speedMph, + double? accuracyMeters, + DateTime? updatedAt, + DateTime? expiresAt, + bool? sharingEnabled, + LivePresenceStatus? status, + }) { + return LiveGroupPresence( + userId: userId ?? this.userId, + groupRideId: groupRideId ?? this.groupRideId, + latitude: latitude ?? this.latitude, + longitude: longitude ?? this.longitude, + heading: heading ?? this.heading, + speedMph: speedMph ?? this.speedMph, + accuracyMeters: accuracyMeters ?? this.accuracyMeters, + updatedAt: updatedAt ?? this.updatedAt, + expiresAt: expiresAt ?? this.expiresAt, + sharingEnabled: sharingEnabled ?? this.sharingEnabled, + status: status ?? this.status, + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is LiveGroupPresence && + runtimeType == other.runtimeType && + userId == other.userId && + groupRideId == other.groupRideId && + latitude == other.latitude && + longitude == other.longitude && + heading == other.heading && + speedMph == other.speedMph && + accuracyMeters == other.accuracyMeters && + sharingEnabled == other.sharingEnabled && + status == other.status; + + @override + int get hashCode => + userId.hashCode ^ + groupRideId.hashCode ^ + latitude.hashCode ^ + longitude.hashCode ^ + heading.hashCode ^ + speedMph.hashCode ^ + accuracyMeters.hashCode ^ + sharingEnabled.hashCode ^ + status.hashCode; +} diff --git a/lib/services/group_rides/group_ride_providers.dart b/lib/services/group_rides/group_ride_providers.dart index 6c94342..c9bb79d 100644 --- a/lib/services/group_rides/group_ride_providers.dart +++ b/lib/services/group_rides/group_ride_providers.dart @@ -31,3 +31,21 @@ final upcomingGroupRidesProvider = StreamProvider>((ref) { final service = ref.watch(groupRideServiceProvider); return service.upcomingRidesStream(callerUid: user.uid); }); + +/// Stream of live presence records for all participants in a specific Group Ride. +final groupRideLivePresenceProvider = + StreamProvider.family, String>((ref, rideId) { + final service = ref.watch(groupRideServiceProvider); + return service.livePresenceStream(rideId); +}); + +/// Master privacy setting: whether group ride live location sharing is permitted by user. +/// Defaults to false (explicit opt-in required). +final groupRideLiveSharingAllowedProvider = StateProvider((ref) { + return false; +}); + +/// Active Group Ride ID currently being broadcasted to by the local rider, if any. +final activeLiveRideSharingIdProvider = StateProvider((ref) { + return null; +}); diff --git a/lib/services/group_rides/group_ride_service.dart b/lib/services/group_rides/group_ride_service.dart index 944f077..fff4231 100644 --- a/lib/services/group_rides/group_ride_service.dart +++ b/lib/services/group_rides/group_ride_service.dart @@ -703,6 +703,127 @@ class GroupRideService { }); } + /// Updates the status of a Group Ride (e.g. scheduled -> assembling -> underway -> completed). + /// Only the ride organizer has authority to transition ride state. + Future updateRideStatus({ + required String callerUid, + required String groupRideId, + required GroupRideStatus newStatus, + }) async { + final firestore = _firestore; + if (firestore == null) return; + + final rideRef = firestore.collection('group_rides').doc(groupRideId); + final snap = await rideRef.get(); + if (!snap.exists || snap.data() == null) { + throw StateError('Group ride not found.'); + } + + final currentRide = GroupRide.fromJson(snap.data()!); + if (currentRide.organizerUid != callerUid) { + throw StateError('Only the organizer can change ride status.'); + } + if (currentRide.isCanceled || currentRide.isCompleted) { + throw StateError('Cannot change status of a closed ride.'); + } + + final now = DateTime.now(); + await rideRef.update({ + 'status': newStatus.name, + 'updatedAt': now.toIso8601String(), + }); + } + + /// Publishes ephemeral live presence for an accepted participant during an eligible ride. + /// Strictly isolates coordinates to the ride subcollection; never writes to user profiles. + Future publishLivePresence({ + required String callerUid, + required String groupRideId, + required double latitude, + required double longitude, + double? heading, + double? speedMph, + double? accuracyMeters, + LivePresenceStatus status = LivePresenceStatus.riding, + bool sharingEnabled = true, + }) async { + final now = DateTime.now(); + final presence = LiveGroupPresence( + userId: callerUid, + groupRideId: groupRideId, + latitude: latitude, + longitude: longitude, + heading: heading, + speedMph: speedMph, + accuracyMeters: accuracyMeters, + updatedAt: now, + expiresAt: now.add(const Duration(seconds: 120)), + sharingEnabled: sharingEnabled, + status: status, + ); + + if (!presence.isValidCoordinates) { + throw ArgumentError('Invalid physical coordinates for live presence.'); + } + + final firestore = _firestore; + if (firestore == null) return; + + final presenceRef = firestore + .collection('group_rides') + .doc(groupRideId) + .collection('live_presence') + .doc(callerUid); + + await presenceRef.set(presence.toJson()); + } + + /// Explicitly clears or disables live presence for a rider. + Future clearLivePresence({ + required String callerUid, + required String groupRideId, + }) async { + final firestore = _firestore; + if (firestore == null) return; + + final presenceRef = firestore + .collection('group_rides') + .doc(groupRideId) + .collection('live_presence') + .doc(callerUid); + + try { + await presenceRef.delete(); + } catch (e) { + debugPrint('[GroupRideService] clearLivePresence error: $e'); + } + } + + /// Realtime stream of live presence records for all participants in an eligible ride. + Stream> livePresenceStream(String groupRideId) { + final firestore = _firestore; + if (firestore == null) return Stream.value([]); + + return firestore + .collection('group_rides') + .doc(groupRideId) + .collection('live_presence') + .snapshots() + .map((snap) { + return snap.docs + .map((d) { + try { + return LiveGroupPresence.fromJson(d.data()); + } catch (_) { + return null; + } + }) + .whereType() + .where((p) => p.sharingEnabled && !p.isStale) + .toList(); + }); + } + /// Stream of upcoming Group Rides visible to the caller (excluding canceled/completed). Stream> upcomingRidesStream({required String callerUid}) { final firestore = _firestore; @@ -710,7 +831,11 @@ class GroupRideService { return firestore .collection('group_rides') - .where('status', isEqualTo: GroupRideStatus.scheduled.name) + .where('status', whereIn: [ + GroupRideStatus.scheduled.name, + GroupRideStatus.assembling.name, + GroupRideStatus.underway.name, + ]) .limit(30) .snapshots() .map((snap) { diff --git a/test/group_ride_models_test.dart b/test/group_ride_models_test.dart index 27ad275..921fe9d 100644 --- a/test/group_ride_models_test.dart +++ b/test/group_ride_models_test.dart @@ -149,4 +149,133 @@ void main() { expect(restored.displayHandle, equals('@bob_rider')); }); }); + + group('LiveGroupPresence and Ride Lifecycle Tests', () { + test('verifies GroupRideStatus underway and isLiveEligible semantics', () { + expect(GroupRideStatus.underway.label, equals('Underway')); + + final now = DateTime.now(); + final rideScheduled = GroupRide( + id: 'r1', + organizerUid: 'alice', + organizerHandle: 'alice', + organizerDisplayName: 'Alice', + title: 'Run', + status: GroupRideStatus.scheduled, + scheduledStartAt: now, + departureAt: now, + meetingPoint: const GroupRideLocation(latitude: 0, longitude: 0, displayName: 'Point'), + createdAt: now, + updatedAt: now, + ); + expect(rideScheduled.isLiveEligible, isTrue); + expect(rideScheduled.isUnderway, isFalse); + + final rideUnderway = rideScheduled.copyWith(status: GroupRideStatus.underway); + expect(rideUnderway.isUnderway, isTrue); + expect(rideUnderway.isLiveEligible, isTrue); + + final rideCompleted = rideScheduled.copyWith(status: GroupRideStatus.completed); + expect(rideCompleted.isLiveEligible, isFalse); + expect(rideCompleted.isUnderway, isFalse); + + final rideCanceled = rideScheduled.copyWith(status: GroupRideStatus.canceled); + expect(rideCanceled.isLiveEligible, isFalse); + }); + + test('serializes and deserializes LiveGroupPresence correctly', () { + final now = DateTime.now(); + final expires = now.add(const Duration(seconds: 120)); + + final presence = LiveGroupPresence( + userId: 'bob', + groupRideId: 'ride_101', + latitude: 34.5378, + longitude: -83.9849, + heading: 180.5, + speedMph: 48.2, + accuracyMeters: 4.5, + updatedAt: now, + expiresAt: expires, + sharingEnabled: true, + status: LivePresenceStatus.riding, + ); + + final json = presence.toJson(); + expect(json['userId'], equals('bob')); + expect(json['groupRideId'], equals('ride_101')); + expect(json['latitude'], equals(34.5378)); + expect(json['longitude'], equals(-83.9849)); + expect(json['heading'], equals(180.5)); + expect(json['speedMph'], equals(48.2)); + expect(json['accuracyMeters'], equals(4.5)); + expect(json['sharingEnabled'], isTrue); + expect(json['status'], equals('riding')); + + final restored = LiveGroupPresence.fromJson(json); + expect(restored.userId, equals('bob')); + expect(restored.groupRideId, equals('ride_101')); + expect(restored.latitude, equals(34.5378)); + expect(restored.longitude, equals(-83.9849)); + expect(restored.status, equals(LivePresenceStatus.riding)); + expect(restored.isValidCoordinates, isTrue); + expect(restored.isAccurate, isTrue); + expect(restored.isFresh, isTrue); + expect(restored.isStale, isFalse); + }); + + test('coordinate bounds validation rejects invalid coordinates', () { + final now = DateTime.now(); + final invalidLat = LiveGroupPresence( + userId: 'bob', + groupRideId: 'r1', + latitude: 91.0, + longitude: 0.0, + updatedAt: now, + expiresAt: now.add(const Duration(seconds: 120)), + ); + expect(invalidLat.isValidCoordinates, isFalse); + + final invalidLng = LiveGroupPresence( + userId: 'bob', + groupRideId: 'r1', + latitude: 0.0, + longitude: 185.0, + updatedAt: now, + expiresAt: now.add(const Duration(seconds: 120)), + ); + expect(invalidLng.isValidCoordinates, isFalse); + }); + + test('freshness and staleness calculation respects timestamp and sharing flag', () { + final now = DateTime.now(); + final fresh = LiveGroupPresence( + userId: 'bob', + groupRideId: 'r1', + latitude: 35.0, + longitude: -84.0, + updatedAt: now.subtract(const Duration(seconds: 10)), + expiresAt: now.add(const Duration(seconds: 110)), + sharingEnabled: true, + ); + expect(fresh.isFresh, isTrue); + expect(fresh.isStale, isFalse); + + final stale = LiveGroupPresence( + userId: 'bob', + groupRideId: 'r1', + latitude: 35.0, + longitude: -84.0, + updatedAt: now.subtract(const Duration(seconds: 130)), + expiresAt: now.subtract(const Duration(seconds: 10)), + sharingEnabled: true, + ); + expect(stale.isFresh, isFalse); + expect(stale.isStale, isTrue); + + final disabled = fresh.copyWith(sharingEnabled: false); + expect(disabled.isFresh, isFalse); + expect(disabled.isStale, isTrue); + }); + }); } diff --git a/test/group_ride_service_test.dart b/test/group_ride_service_test.dart index 32c3489..ed76578 100644 --- a/test/group_ride_service_test.dart +++ b/test/group_ride_service_test.dart @@ -114,5 +114,27 @@ void main() { throwsA(isA()), ); }); + + test('publishLivePresence validates physical coordinates', () async { + final service = GroupRideService(); + expect( + () => service.publishLivePresence( + callerUid: 'user_alice', + groupRideId: 'ride_1', + latitude: 105.0, // out of range + longitude: -84.0, + ), + throwsA(isA()), + ); + }); + + test('offline live presence and upcoming streams return safely', () async { + final service = GroupRideService(); + final stream = service.livePresenceStream('ride_offline'); + expect(await stream.first, isEmpty); + + final upcoming = service.upcomingRidesStream(callerUid: 'user_alice'); + expect(await upcoming.first, isEmpty); + }); }); }