fix: harden live group ride privacy architecture

This commit is contained in:
Developer 2026-09-03 20:37:26 -04:00
parent f464d52462
commit 173c99d450
9 changed files with 1200 additions and 1 deletions

View File

@ -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')));
});
});
});

View File

@ -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=**} {

View File

@ -201,6 +201,12 @@ class _GroupRideDetailScreenState extends ConsumerState<GroupRideDetailScreen> {
_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<GroupRideDetailScreen> {
),
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<GroupRideDetailScreen> {
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),
),
],
),
),
);
}
}

View File

@ -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<AccountScreen> {
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,

View File

@ -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<String, dynamic> 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<String, dynamic> 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;
}

View File

@ -31,3 +31,21 @@ final upcomingGroupRidesProvider = StreamProvider<List<GroupRide>>((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<List<LiveGroupPresence>, 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<bool>((ref) {
return false;
});
/// Active Group Ride ID currently being broadcasted to by the local rider, if any.
final activeLiveRideSharingIdProvider = StateProvider<String?>((ref) {
return null;
});

View File

@ -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<void> 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<void> 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<void> 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<List<LiveGroupPresence>> 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<LiveGroupPresence>()
.where((p) => p.sharingEnabled && !p.isStale)
.toList();
});
}
/// Stream of upcoming Group Rides visible to the caller (excluding canceled/completed).
Stream<List<GroupRide>> 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) {

View File

@ -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);
});
});
}

View File

@ -114,5 +114,27 @@ void main() {
throwsA(isA<ArgumentError>()),
);
});
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<ArgumentError>()),
);
});
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);
});
});
}