Skip to content
Open
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,23 @@ class DriverRoadflareRepository(context: Context) {
return _state.value?.followers?.any { it.pubkey == pubkey && it.mutedAt != null } ?: false
}

/**
* True if the rider is muted via either path (issue #82). Single helper so receive-side
* filters across the codebase use the same check and can't drift. Callers:
* - `DriverViewModel.processIncomingOffer` — Kind 3173 direct/RoadFlare ride offers
* - `DriverViewModel.subscribeToBroadcastRequests` — Kind 3173 broadcast offers
* - `RoadflareListenerService.subscribeToRoadflareRequests` — Kind 3173 RoadFlare offers
* reaching the foreground service
* - `RoadflareListenerService.processPingEvent` — Kind 3189 driver pings
* - `MainActivity` Kind 3188 ack handler — refuses key re-delivery to muted riders
*
* Kind 3187 (`RoadflareKeyManager.handleFollowNotification`) deliberately calls
* [isMuted] and [isFollowerMuted] separately because the two outcomes map to
* distinct return values (`AlreadyMuted` vs `AlreadyLightMuted`) — collapsing
* to `isAnyMuted` there would lose the semantic distinction the caller depends on.
*/
fun isAnyMuted(pubkey: String): Boolean = isMuted(pubkey) || isFollowerMuted(pubkey)

// In-memory flag (resets across process restarts) tracking whether the lightweight-mute
// reconciliation against Kind 30177 has run at least once this session.
//
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import com.ridestr.common.nostr.events.RoadflareKey
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import org.json.JSONArray
import org.json.JSONObject

Expand All @@ -25,6 +26,27 @@ data class CachedDriverLocation(
val keyVersion: Int = 0
)

/**
* Cached driver presence — read from the **public** `status` tag on Kind 30014 events
* (no decryption required). Issue #82: drives the rider's "this driver is available
* even though I can't see their location" UX so a stale or missing RoadFlare key never
* blocks ride-on-demand.
*
* Distinct from [CachedDriverLocation] which carries the decrypted lat/lon/timestamp
* from the event content. Presence is a strict subset that any follower can read
* regardless of key state — the protocol exposes `status` publicly via the event tags
* (see `RoadflareLocationEvent.create()` line 86 + `getStatus()` helper line 163).
*
* @param status `"online"` / `"on_ride"` / `"offline"` from the public tag
* @param timestamp `event.createdAt` of the latest 30014 event for this driver
* @param keyVersion Driver's current RoadFlare key version, also from a public tag
*/
data class CachedDriverPresence(
val status: String,
val timestamp: Long,
val keyVersion: Int = 0
)

/**
* Repository for managing rider's followed drivers list for RoadFlare.
* Stores drivers and their RoadFlare decryption keys in SharedPreferences.
Expand Down Expand Up @@ -76,6 +98,48 @@ class FollowedDriversRepository(context: Context) {
_driverLocations.value = emptyMap()
}

/**
* Cached driver presence — populated from the public `status` tag on Kind 30014
* regardless of whether the encrypted content can be decrypted. Issue #82.
*/
private val _driverPresence = MutableStateFlow<Map<String, CachedDriverPresence>>(emptyMap())
val driverPresence: StateFlow<Map<String, CachedDriverPresence>> = _driverPresence.asStateFlow()

/**
* Update a driver's cached presence from a Kind 30014 event's PUBLIC tags.
* Skips the update if an existing entry has a newer-or-equal timestamp (out-of-order
* delivery + same-event multi-relay dedup).
*
* Issue #82: uses [kotlinx.coroutines.flow.MutableStateFlow.update] so the read /
* timestamp-compare / write executes atomically under CAS. Direct
* `_driverPresence.value =` would race when multiple relay callbacks (`Dispatchers.IO`)
* update the same pubkey concurrently — both reading the same `existing` before either
* writes — and the lower-timestamp write could win, defeating the guard. The other
* in-memory caches in this file use the simpler unprotected pattern; this one is held
* to a stricter standard because the PR's KDoc explicitly advertises the out-of-order
* guard as a correctness property.
*/
fun updateDriverPresence(pubkey: String, status: String, timestamp: Long, keyVersion: Int = 0) {
_driverPresence.update { current ->
val existing = current[pubkey]
if (existing != null && existing.timestamp >= timestamp) {
current
} else {
current + (pubkey to CachedDriverPresence(status, timestamp, keyVersion))
}
}
}

/** Remove a driver's cached presence. */
fun removeDriverPresence(pubkey: String) {
_driverPresence.value = _driverPresence.value - pubkey
}

/** Clear all cached driver presence. */
fun clearDriverPresence() {
_driverPresence.value = emptyMap()
}

/**
* Load cached driver names from SharedPreferences.
*/
Expand Down Expand Up @@ -191,6 +255,11 @@ class FollowedDriversRepository(context: Context) {
}
// Remove from location cache (in-memory only, no persist needed)
_driverLocations.value = _driverLocations.value - pubkey
// Issue #82: also remove the presence entry so a re-add of the same driver
// in the same session doesn't see a stale `(timestamp >= timestamp)` guard
// hit and silently render the re-added driver as offline until their next
// broadcast tick.
_driverPresence.value = _driverPresence.value - pubkey
}

/**
Expand Down Expand Up @@ -268,7 +337,7 @@ class FollowedDriversRepository(context: Context) {
}

/**
* Clear all followed drivers and cached names/locations (for logout).
* Clear all followed drivers and cached names/locations/presence (for logout).
*/
fun clearAll() {
prefs.edit()
Expand All @@ -278,6 +347,10 @@ class FollowedDriversRepository(context: Context) {
_drivers.value = emptyList()
_driverNames.value = emptyMap()
_driverLocations.value = emptyMap()
// Issue #82: presence has the same in-memory-only semantic as locations and
// belongs in the logout reset path so the next user's session doesn't inherit
// stale presence data from the previous identity.
_driverPresence.value = emptyMap()
}

companion object {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,22 +48,62 @@ class RoadflareDriverPresenceCoordinator(
locationSubId?.let { nostrService.closeRoadflareSubscription(it) }
locationSubId = null

val withKeys = drivers.filter { it.roadflareKey != null }
if (withKeys.isEmpty()) {
Log.d(TAG, "No drivers with keys to subscribe to (total: ${drivers.size})")
// Issue #82: prune lastLocationCreatedAt for drivers no longer in the list. Without
// this, removing + re-adding the same driver in a single session leaves the prior
// session's `lastLocationCreatedAt[pubkey]` intact, which then gates the same Kind
// 30014 event (createdAt unchanged within the 5-min TTL window) at the
// `eventCreatedAt < lastSeen` boundary — driver shows as offline until the next
// broadcast tick, defeating the stale-key UX in a real scenario.
val currentPubkeys = drivers.map { it.pubkey }.toSet()
lastLocationCreatedAt.keys.retainAll(currentPubkeys)

// Issue #82: subscribe to ALL followed drivers, not just those with a current key.
// The PUBLIC `status` tag on Kind 30014 lets us track availability for stale-key /
// missing-key drivers without ever decrypting the encrypted lat/lon content. The
// decryption attempt below is best-effort — if it fails we still surface the driver
// as available via `updateDriverPresence`, so the rider can fall back to the
// rider-route fare and request the ride anyway.
if (drivers.isEmpty()) {
Log.d(TAG, "No followed drivers to subscribe to")
return
}

val driverPubkeys = withKeys.map { it.pubkey }
Log.d(TAG, "Subscribing to ${driverPubkeys.size} driver locations")
val driverPubkeys = drivers.map { it.pubkey }
val withKeysCount = drivers.count { it.roadflareKey != null }
Log.d(TAG, "Subscribing to ${driverPubkeys.size} driver locations ($withKeysCount with keys)")

locationSubId = nostrService.subscribeToRoadflareLocations(driverPubkeys) { event, relayUrl ->
val driverPubKey = event.pubKey
val driver = withKeys.find { it.pubkey == driverPubKey }
val roadflareKey = driver?.roadflareKey
val eventCreatedAt = event.createdAt
val isExpired = RoadflareLocationEvent.isExpired(event)
val lastSeen = lastLocationCreatedAt[driverPubKey] ?: 0L
val isOutOfOrder = eventCreatedAt < lastSeen

if (isExpired || isOutOfOrder) {
Log.d(TAG, "Rejected stale/out-of-order 30014 from ${driverPubKey.take(8)}: expired=$isExpired, outOfOrder=$isOutOfOrder")
return@subscribeToRoadflareLocations
}

// Mark this event as the latest seen for the driver, regardless of decryption
// outcome — the out-of-order guard at the top now applies to presence-only
// events too, so a stale Kind 30014 can't overwrite a fresher presence update.
lastLocationCreatedAt[driverPubKey] = eventCreatedAt

// Always update presence from the PUBLIC tags — works regardless of key state.
val publicStatus = RoadflareLocationEvent.getStatus(event)
val publicKeyVersion = RoadflareLocationEvent.getKeyVersion(event)
followedDriversRepository.updateDriverPresence(
pubkey = driverPubKey,
status = publicStatus,
timestamp = eventCreatedAt,
keyVersion = publicKeyVersion
)

// Best-effort location decryption — only succeeds if we have the current key.
val driver = drivers.find { it.pubkey == driverPubKey }
val roadflareKey = driver?.roadflareKey
if (roadflareKey == null) {
Log.w(TAG, "No RoadFlare key for driver ${driverPubKey.take(8)}")
Log.d(TAG, "No RoadFlare key for ${driverPubKey.take(8)} — presence-only update")
return@subscribeToRoadflareLocations
}

Expand All @@ -74,27 +114,19 @@ class RoadflareDriverPresenceCoordinator(
)

if (locationData != null) {
val eventCreatedAt = event.createdAt

val isExpired = RoadflareLocationEvent.isExpired(event)
val lastSeen = lastLocationCreatedAt[driverPubKey] ?: 0L
val isOutOfOrder = eventCreatedAt < lastSeen

if (!isExpired && !isOutOfOrder) {
lastLocationCreatedAt[driverPubKey] = eventCreatedAt
followedDriversRepository.updateDriverLocation(
pubkey = driverPubKey,
lat = locationData.location.lat,
lon = locationData.location.lon,
status = locationData.tagStatus,
timestamp = eventCreatedAt,
keyVersion = locationData.keyVersion
)
} else {
Log.d(TAG, "Rejected stale/out-of-order 30014 from ${driverPubKey.take(8)}: expired=$isExpired, outOfOrder=$isOutOfOrder")
}
followedDriversRepository.updateDriverLocation(
pubkey = driverPubKey,
lat = locationData.location.lat,
lon = locationData.location.lon,
status = locationData.tagStatus,
timestamp = eventCreatedAt,
keyVersion = locationData.keyVersion
)
} else {
Log.w(TAG, "Failed to decrypt location from ${driverPubKey.take(8)}")
// Decryption failed despite having a key — likely stale (key was rotated).
// Presence already updated above; rider will see the driver as available
// and fall through to the rider-route fare path on offer-send.
Log.w(TAG, "Failed to decrypt location from ${driverPubKey.take(8)} (presence-only, likely stale key)")
}
}
}
Expand All @@ -121,6 +153,9 @@ class RoadflareDriverPresenceCoordinator(
locationSubId?.let { nostrService.closeRoadflareSubscription(it) }
locationSubId = null
lastLocationCreatedAt.clear()
// Don't clear presence/locations from the repository here — other ViewModel
// observers may briefly read them across configuration changes. The repository
// owns its own clear lifecycle (e.g., on logout).
}

companion object {
Expand Down
Loading