feat: add location sharing UX improvements

- Add maintenance loop to clean up expired locations every 5 minutes
- Add SharingStatusChip showing "Sharing with X people" on map
- Add location icon indicator on contacts sharing with you
- Add stale/expired marker styling (dashed circles)
- Add marker state tracking (FRESH/STALE/EXPIRED_GRACE_PERIOD)
- Enrich ContactDao query to join with received_locations

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
torlando-tech
2025-12-19 15:41:15 -05:00
parent 7f0e517ec0
commit 67524d7e61
11 changed files with 591 additions and 26 deletions

View File

@@ -74,6 +74,7 @@ class LocationSharingManager
private const val LOCATION_UPDATE_INTERVAL_MS = 60_000L // 60 seconds
private const val LOCATION_MIN_UPDATE_INTERVAL_MS = 30_000L // 30 seconds
private const val SESSION_CHECK_INTERVAL_MS = 30_000L // 30 seconds
private const val CLEANUP_INTERVAL_MS = 5 * 60 * 1000L // 5 minutes
}
private val fusedLocationClient: FusedLocationProviderClient =
@@ -94,6 +95,7 @@ class LocationSharingManager
// Location update job
private var locationUpdateJob: Job? = null
private var sessionCheckJob: Job? = null
private var maintenanceJob: Job? = null
private var lastLocation: Location? = null
// Location callback for updates
@@ -110,6 +112,9 @@ class LocationSharingManager
init {
// Start listening for incoming location telemetry
startListeningForLocationTelemetry()
// Start periodic cleanup of expired locations
startMaintenanceLoop()
}
/**
@@ -300,6 +305,21 @@ class LocationSharingManager
}
}
/**
* Start periodic cleanup of expired received locations.
* Runs every 5 minutes to remove locations that have expired past the grace period.
*/
private fun startMaintenanceLoop() {
maintenanceJob =
scope.launch {
while (isActive) {
delay(CLEANUP_INTERVAL_MS)
cleanupExpiredLocations()
}
}
Log.d(TAG, "Started maintenance loop for location cleanup")
}
private suspend fun checkExpiredSessions() {
val now = System.currentTimeMillis()
val (expired, active) = _activeSessions.value.partition { session ->

View File

@@ -5,6 +5,7 @@ import android.content.Intent
import android.location.Location
import android.net.Uri
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -15,6 +16,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Message
import androidx.compose.material.icons.filled.Directions
@@ -25,14 +27,17 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.SheetState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.lxmf.messenger.viewmodel.ContactMarker
import com.lxmf.messenger.viewmodel.MarkerState
/**
* Bottom sheet displayed when tapping a contact's location marker on the map.
@@ -62,6 +67,7 @@ fun ContactLocationBottomSheet(
val context = LocalContext.current
val distanceText = formatDistanceAndDirection(userLocation, marker.latitude, marker.longitude)
val updatedText = formatUpdatedTime(marker.timestamp)
val isStale = marker.state != MarkerState.FRESH
ModalBottomSheet(
onDismissRequest = onDismiss,
@@ -79,24 +85,38 @@ fun ContactLocationBottomSheet(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
// Identicon avatar
Identicon(
hash = hexStringToByteArray(marker.destinationHash),
size = 48.dp,
)
// Identicon avatar (dimmed for stale locations)
Box {
Identicon(
hash = hexStringToByteArray(marker.destinationHash),
size = 48.dp,
modifier = if (isStale) Modifier.alpha(0.6f) else Modifier,
)
}
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = marker.displayName,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
)
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = marker.displayName,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
)
// Stale badge
if (isStale) {
Spacer(modifier = Modifier.width(8.dp))
StaleLocationBadge(marker.state)
}
}
Text(
text = updatedText,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
color = if (isStale) {
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
}
@@ -247,6 +267,34 @@ internal fun openDirectionsInMaps(context: Context, lat: Double, lng: Double) {
}
}
/**
* Badge indicating the location is stale or expired.
*
* @param state The marker state (STALE or EXPIRED_GRACE_PERIOD)
*/
@Composable
private fun StaleLocationBadge(state: MarkerState) {
if (state == MarkerState.FRESH) return
val (text, color) = when (state) {
MarkerState.STALE -> "Stale" to MaterialTheme.colorScheme.outline
MarkerState.EXPIRED_GRACE_PERIOD -> "Last known" to MaterialTheme.colorScheme.error.copy(alpha = 0.7f)
else -> return
}
Surface(
color = color.copy(alpha = 0.15f),
shape = RoundedCornerShape(4.dp),
) {
Text(
text = text,
style = MaterialTheme.typography.labelSmall,
color = color,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
)
}
}
/**
* Convert a hex string to a byte array.
*

View File

@@ -0,0 +1,64 @@
package com.lxmf.messenger.ui.components
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.LocationOn
import androidx.compose.material3.AssistChip
import androidx.compose.material3.AssistChipDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
/**
* Status chip shown on the map when actively sharing location.
*
* Displays "Sharing with X people" with a location icon and close button.
*
* @param sharingWithCount Number of contacts currently sharing with
* @param onStopAllClick Callback when the stop (X) button is clicked
* @param modifier Modifier for the chip
*/
@Composable
fun SharingStatusChip(
sharingWithCount: Int,
onStopAllClick: () -> Unit,
modifier: Modifier = Modifier,
) {
AssistChip(
onClick = { /* Could open sharing management sheet in future */ },
label = {
Text(
text = "Sharing with $sharingWithCount ${if (sharingWithCount == 1) "person" else "people"}",
)
},
leadingIcon = {
Icon(
imageVector = Icons.Default.LocationOn,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.primary,
)
},
trailingIcon = {
IconButton(
onClick = onStopAllClick,
modifier = Modifier.size(24.dp),
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Stop sharing",
modifier = Modifier.size(16.dp),
)
}
},
colors = AssistChipDefaults.assistChipColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.95f),
),
modifier = modifier,
)
}

View File

@@ -40,6 +40,7 @@ import androidx.compose.material.icons.filled.FilterList
import androidx.compose.material.icons.filled.HourglassEmpty
import androidx.compose.material.icons.filled.Hub
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.LocationOn
import androidx.compose.material.icons.filled.People
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.QrCode
@@ -1038,12 +1039,22 @@ fun ContactListItem(
}
}
// Badge column (relay badge + source badge)
// Badge column (location badge + relay badge + source badge)
Column(
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.padding(horizontal = 4.dp),
) {
// Show location icon if contact is sharing their location with us
if (contact.isReceivingLocationFrom) {
Icon(
imageVector = Icons.Default.LocationOn,
contentDescription = "Sharing location with you",
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.primary,
)
}
// Show "RELAY" badge for relay contacts
if (contact.isMyRelay) {
Box(

View File

@@ -68,10 +68,12 @@ import com.google.android.gms.location.Priority
import com.lxmf.messenger.ui.components.ContactLocationBottomSheet
import com.lxmf.messenger.ui.components.LocationPermissionBottomSheet
import com.lxmf.messenger.ui.components.ShareLocationBottomSheet
import com.lxmf.messenger.ui.components.SharingStatusChip
import com.lxmf.messenger.util.LocationPermissionManager
import org.maplibre.android.geometry.LatLng as MapLibreLatLng
import com.lxmf.messenger.viewmodel.ContactMarker
import com.lxmf.messenger.viewmodel.MapViewModel
import com.lxmf.messenger.viewmodel.MarkerState
import org.maplibre.android.MapLibre
import org.maplibre.android.camera.CameraPosition
import org.maplibre.android.camera.CameraUpdateFactory
@@ -82,12 +84,15 @@ import org.maplibre.android.location.LocationComponentActivationOptions
import org.maplibre.android.location.modes.CameraMode
import org.maplibre.android.location.modes.RenderMode
import org.maplibre.android.maps.Style
import org.maplibre.android.style.expressions.Expression
import org.maplibre.android.style.layers.CircleLayer
import org.maplibre.android.style.layers.PropertyFactory
import org.maplibre.android.style.layers.SymbolLayer
import org.maplibre.android.style.sources.GeoJsonSource
import org.maplibre.geojson.Feature
import org.maplibre.geojson.FeatureCollection
import org.maplibre.geojson.Point
import com.lxmf.messenger.ui.util.MarkerBitmapFactory
/**
* Map screen displaying user location and contact markers.
@@ -219,6 +224,20 @@ fun MapScreen(
.fromUri("https://tiles.openfreemap.org/styles/liberty"),
) { style ->
Log.d("MapScreen", "Map style loaded")
// Add dashed circle bitmaps for stale markers
val density = ctx.resources.displayMetrics.density
val staleCircleBitmap = MarkerBitmapFactory.createDashedCircle(
sizeDp = 28f,
strokeWidthDp = 3f,
color = android.graphics.Color.parseColor("#E0E0E0"),
dashLengthDp = 4f,
gapLengthDp = 3f,
density = density,
)
style.addImage("stale-dashed-circle", staleCircleBitmap)
Log.d("MapScreen", "Added stale-dashed-circle image to style")
mapStyleLoaded = true
// Enable user location component (blue dot)
@@ -284,13 +303,14 @@ fun MapScreen(
val sourceId = "contact-markers-source"
val layerId = "contact-markers-layer"
// Create GeoJSON features from contact markers
// Create GeoJSON features from contact markers with state property
val features = state.contactMarkers.map { marker ->
Feature.fromGeometry(
Point.fromLngLat(marker.longitude, marker.latitude)
).apply {
addStringProperty("name", marker.displayName)
addStringProperty("hash", marker.destinationHash)
addStringProperty("state", marker.state.name) // FRESH, STALE, or EXPIRED_GRACE_PERIOD
}
}
val featureCollection = FeatureCollection.fromFeatures(features)
@@ -300,14 +320,74 @@ fun MapScreen(
if (existingSource != null) {
existingSource.setGeoJson(featureCollection)
} else {
// Add new source and layer
// Add new source and layers with data-driven styling based on marker state
style.addSource(GeoJsonSource(sourceId, featureCollection))
// CircleLayer for the filled circle
style.addLayer(
CircleLayer(layerId, sourceId).withProperties(
PropertyFactory.circleRadius(12f),
PropertyFactory.circleColor("#FF5722"), // Orange color
PropertyFactory.circleStrokeWidth(3f),
PropertyFactory.circleStrokeColor("#FFFFFF"),
// Data-driven color: Orange for fresh, Gray for stale/expired
PropertyFactory.circleColor(
Expression.match(
Expression.get("state"),
Expression.literal(MarkerState.FRESH.name),
Expression.color(android.graphics.Color.parseColor("#FF5722")), // Orange
Expression.literal(MarkerState.STALE.name),
Expression.color(android.graphics.Color.parseColor("#9E9E9E")), // Gray
Expression.literal(MarkerState.EXPIRED_GRACE_PERIOD.name),
Expression.color(android.graphics.Color.parseColor("#9E9E9E")), // Gray
Expression.color(android.graphics.Color.parseColor("#FF5722")), // Default: Orange
)
),
// Data-driven opacity: 100% for fresh, 60% for stale/expired
PropertyFactory.circleOpacity(
Expression.match(
Expression.get("state"),
Expression.literal(MarkerState.FRESH.name),
Expression.literal(1.0f),
Expression.literal(MarkerState.STALE.name),
Expression.literal(0.6f),
Expression.literal(MarkerState.EXPIRED_GRACE_PERIOD.name),
Expression.literal(0.6f),
Expression.literal(1.0f), // Default
)
),
// Solid stroke only for fresh markers (stale uses dashed overlay)
PropertyFactory.circleStrokeWidth(
Expression.match(
Expression.get("state"),
Expression.literal(MarkerState.FRESH.name),
Expression.literal(3f),
Expression.literal(0f), // No solid stroke for stale markers
)
),
PropertyFactory.circleStrokeColor(
Expression.color(android.graphics.Color.WHITE)
),
)
)
// SymbolLayer for dashed outline on stale/expired markers
val dashedLayerId = "contact-markers-dashed-layer"
style.addLayer(
SymbolLayer(dashedLayerId, sourceId).withProperties(
PropertyFactory.iconImage("stale-dashed-circle"),
PropertyFactory.iconAllowOverlap(true),
PropertyFactory.iconIgnorePlacement(true),
// Only show for stale/expired markers
PropertyFactory.iconOpacity(
Expression.match(
Expression.get("state"),
Expression.literal(MarkerState.FRESH.name),
Expression.literal(0f), // Hidden for fresh
Expression.literal(MarkerState.STALE.name),
Expression.literal(0.7f),
Expression.literal(MarkerState.EXPIRED_GRACE_PERIOD.name),
Expression.literal(0.7f),
Expression.literal(0f), // Default: hidden
)
),
)
)
}
@@ -351,6 +431,18 @@ fun MapScreen(
.align(Alignment.TopStart),
)
// Sharing status chip (shown when actively sharing)
if (state.isSharing && state.activeSessions.isNotEmpty()) {
SharingStatusChip(
sharingWithCount = state.activeSessions.size,
onStopAllClick = { viewModel.stopSharing() },
modifier = Modifier
.align(Alignment.TopCenter)
.statusBarsPadding()
.padding(top = 56.dp), // Below TopAppBar
)
}
// FABs positioned above navigation bar
Column(
horizontalAlignment = Alignment.End,

View File

@@ -0,0 +1,110 @@
package com.lxmf.messenger.ui.util
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.DashPathEffect
import android.graphics.Paint
/**
* Factory for creating marker bitmaps for the map.
*/
object MarkerBitmapFactory {
/**
* Creates a dashed circle ring bitmap for stale location markers.
*
* @param sizeDp The diameter of the circle in density-independent pixels
* @param strokeWidthDp The stroke width in dp
* @param color The stroke color
* @param dashLengthDp The length of each dash in dp
* @param gapLengthDp The length of each gap in dp
* @param density Screen density for dp to px conversion
* @return A bitmap with a dashed circle ring (transparent center)
*/
fun createDashedCircle(
sizeDp: Float = 28f,
strokeWidthDp: Float = 3f,
color: Int,
dashLengthDp: Float = 4f,
gapLengthDp: Float = 4f,
density: Float,
): Bitmap {
val sizePx = (sizeDp * density).toInt()
val strokeWidthPx = strokeWidthDp * density
val dashLengthPx = dashLengthDp * density
val gapLengthPx = gapLengthDp * density
val bitmap = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeWidth = strokeWidthPx
this.color = color
pathEffect = DashPathEffect(floatArrayOf(dashLengthPx, gapLengthPx), 0f)
}
// Draw circle with stroke inset by half stroke width to keep within bounds
val radius = (sizePx - strokeWidthPx) / 2f
val center = sizePx / 2f
canvas.drawCircle(center, center, radius, paint)
return bitmap
}
/**
* Creates a solid circle with dashed outline for stale markers.
* Combines a filled circle with a dashed stroke.
*
* @param sizeDp The diameter of the circle in dp
* @param fillColor The fill color of the circle
* @param strokeColor The stroke color (for dashed outline)
* @param fillOpacity Opacity for the fill (0-1)
* @param strokeWidthDp Stroke width in dp
* @param dashLengthDp Length of each dash in dp
* @param gapLengthDp Length of each gap in dp
* @param density Screen density
* @return A bitmap with filled circle and dashed outline
*/
fun createFilledCircleWithDashedOutline(
sizeDp: Float = 28f,
fillColor: Int,
strokeColor: Int,
fillOpacity: Float = 0.6f,
strokeWidthDp: Float = 3f,
dashLengthDp: Float = 4f,
gapLengthDp: Float = 4f,
density: Float,
): Bitmap {
val sizePx = (sizeDp * density).toInt()
val strokeWidthPx = strokeWidthDp * density
val dashLengthPx = dashLengthDp * density
val gapLengthPx = gapLengthDp * density
val bitmap = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
val center = sizePx / 2f
val radius = (sizePx - strokeWidthPx) / 2f
// Draw filled circle
val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.FILL
color = fillColor
alpha = (fillOpacity * 255).toInt()
}
canvas.drawCircle(center, center, radius, fillPaint)
// Draw dashed outline
val strokePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeWidth = strokeWidthPx
color = strokeColor
alpha = (fillOpacity * 255).toInt()
pathEffect = DashPathEffect(floatArrayOf(dashLengthPx, gapLengthPx), 0f)
}
canvas.drawCircle(center, center, radius, strokePaint)
return bitmap
}
}

View File

@@ -19,10 +19,26 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* Represents the freshness state of a contact's location marker.
*/
enum class MarkerState {
/** Location is fresh (received within stale threshold). */
FRESH,
/** Location is stale (older than threshold but sharing not yet expired). */
STALE,
/** Sharing has expired but within grace period (last known location). */
EXPIRED_GRACE_PERIOD,
}
/**
* Represents a contact's location marker on the map.
*
@@ -37,6 +53,7 @@ data class ContactMarker(
val accuracy: Float = 0f,
val timestamp: Long = 0L,
val expiresAt: Long? = null,
val state: MarkerState = MarkerState.FRESH,
)
/**
@@ -51,6 +68,7 @@ data class MapState(
val errorMessage: String? = null,
val isSharing: Boolean = false,
val activeSessions: List<SharingSession> = emptyList(),
val lastRefresh: Long = 0L,
)
/**
@@ -73,11 +91,17 @@ class MapViewModel
) : ViewModel() {
companion object {
private const val TAG = "MapViewModel"
private const val STALE_THRESHOLD_MS = 5 * 60 * 1000L // 5 minutes
private const val GRACE_PERIOD_MS = 60 * 60 * 1000L // 1 hour
private const val REFRESH_INTERVAL_MS = 30_000L // 30 seconds
}
private val _state = MutableStateFlow(MapState())
val state: StateFlow<MapState> = _state.asStateFlow()
// Refresh trigger for periodic staleness recalculation
private val _refreshTrigger = MutableStateFlow(0L)
// Contacts from repository (exposed for ShareLocationBottomSheet)
val contacts: StateFlow<List<EnrichedContact>> =
contactRepository
@@ -91,12 +115,17 @@ class MapViewModel
init {
// Collect received locations and convert to markers
// Combines with both contacts and announces for display name lookup
// Uses unfiltered query - filtering for stale/expired done in ViewModel
// Refresh trigger causes periodic recalculation of staleness
viewModelScope.launch {
combine(
receivedLocationDao.getLatestLocationsPerSender(),
receivedLocationDao.getLatestLocationsPerSenderUnfiltered(),
contacts,
announceDao.getAllAnnounces(),
) { locations, contactList, announceList ->
_refreshTrigger,
) { locations, contactList, announceList, _ ->
val currentTime = System.currentTimeMillis()
// Create lookup maps from contacts
val contactMap = contactList.associateBy { it.destinationHash }
val contactMapLower = contactList.associateBy { it.destinationHash.lowercase() }
@@ -107,7 +136,14 @@ class MapViewModel
Log.d(TAG, "Processing ${locations.size} locations, ${contactList.size} contacts, ${announceList.size} announces")
locations.map { loc ->
locations.mapNotNull { loc ->
// Calculate marker state - returns null if marker should be hidden
val markerState = calculateMarkerState(
timestamp = loc.timestamp,
expiresAt = loc.expiresAt,
currentTime = currentTime,
) ?: return@mapNotNull null
// Try contacts first (exact, then case-insensitive)
// Then try announces (exact, then case-insensitive)
val displayName = contactMap[loc.senderHash]?.displayName
@@ -128,6 +164,7 @@ class MapViewModel
accuracy = loc.accuracy,
timestamp = loc.timestamp,
expiresAt = loc.expiresAt,
state = markerState,
)
}
}.collect { markers ->
@@ -152,6 +189,15 @@ class MapViewModel
_state.update { it.copy(activeSessions = sessions) }
}
}
// Periodic refresh to update stale states (every 30 seconds)
// This ensures markers transition from FRESH -> STALE as time passes
viewModelScope.launch {
while (isActive) {
delay(REFRESH_INTERVAL_MS)
_refreshTrigger.value = System.currentTimeMillis()
}
}
}
/**
@@ -209,4 +255,36 @@ class MapViewModel
Log.d(TAG, "Stopping location sharing: ${destinationHash ?: "all"}")
locationSharingManager.stopSharing(destinationHash)
}
/**
* Calculate the marker state based on timestamp and expiry.
*
* @param timestamp When the location was captured
* @param expiresAt When sharing ends (null = indefinite)
* @param currentTime Current time for comparison (injectable for testing)
* @return MarkerState indicating freshness, or null if marker should be hidden
*/
internal fun calculateMarkerState(
timestamp: Long,
expiresAt: Long?,
currentTime: Long = System.currentTimeMillis(),
): MarkerState? {
val age = currentTime - timestamp
val isExpired = expiresAt != null && expiresAt < currentTime
val gracePeriodEnd = (expiresAt ?: Long.MAX_VALUE) + GRACE_PERIOD_MS
return when {
// Beyond grace period - should not be shown
isExpired && currentTime > gracePeriodEnd -> null
// Expired but within grace period (last known location)
isExpired -> MarkerState.EXPIRED_GRACE_PERIOD
// Not expired but stale (5+ minutes without update)
age > STALE_THRESHOLD_MS -> MarkerState.STALE
// Fresh location
else -> MarkerState.FRESH
}
}
}

View File

@@ -62,6 +62,7 @@ class MapViewModelTest {
every { contactRepository.getEnrichedContacts() } returns flowOf(emptyList())
every { receivedLocationDao.getLatestLocationsPerSender(any()) } returns flowOf(emptyList())
every { receivedLocationDao.getLatestLocationsPerSenderUnfiltered() } returns flowOf(emptyList())
every { announceDao.getAllAnnounces() } returns flowOf(emptyList())
every { locationSharingManager.isSharing } returns MutableStateFlow(false)
every { locationSharingManager.activeSessions } returns MutableStateFlow(emptyList())
@@ -432,6 +433,107 @@ class MapViewModelTest {
assertTrue(viewModel.state.value.hasLocationPermission)
}
// ===== calculateMarkerState Tests =====
@Test
fun `calculateMarkerState - fresh location returns FRESH`() = runTest {
viewModel = MapViewModel(contactRepository, receivedLocationDao, locationSharingManager, announceDao)
val now = System.currentTimeMillis()
val state = viewModel.calculateMarkerState(
timestamp = now - 1_000L, // 1 second ago
expiresAt = now + 3600_000L, // Expires in 1 hour
currentTime = now,
)
assertEquals(MarkerState.FRESH, state)
}
@Test
fun `calculateMarkerState - location older than 5 minutes returns STALE`() = runTest {
viewModel = MapViewModel(contactRepository, receivedLocationDao, locationSharingManager, announceDao)
val now = System.currentTimeMillis()
val state = viewModel.calculateMarkerState(
timestamp = now - (6 * 60_000L), // 6 minutes ago
expiresAt = now + 3600_000L, // Not expired
currentTime = now,
)
assertEquals(MarkerState.STALE, state)
}
@Test
fun `calculateMarkerState - expired within grace period returns EXPIRED_GRACE_PERIOD`() = runTest {
viewModel = MapViewModel(contactRepository, receivedLocationDao, locationSharingManager, announceDao)
val now = System.currentTimeMillis()
val state = viewModel.calculateMarkerState(
timestamp = now - (10 * 60_000L), // 10 minutes ago
expiresAt = now - (5 * 60_000L), // Expired 5 minutes ago (within 1 hour grace)
currentTime = now,
)
assertEquals(MarkerState.EXPIRED_GRACE_PERIOD, state)
}
@Test
fun `calculateMarkerState - expired past grace period returns null`() = runTest {
viewModel = MapViewModel(contactRepository, receivedLocationDao, locationSharingManager, announceDao)
val now = System.currentTimeMillis()
val state = viewModel.calculateMarkerState(
timestamp = now - (3 * 3600_000L), // 3 hours ago
expiresAt = now - (2 * 3600_000L), // Expired 2 hours ago (past 1 hour grace)
currentTime = now,
)
assertNull(state)
}
@Test
fun `calculateMarkerState - indefinite sharing never expires`() = runTest {
viewModel = MapViewModel(contactRepository, receivedLocationDao, locationSharingManager, announceDao)
val now = System.currentTimeMillis()
val state = viewModel.calculateMarkerState(
timestamp = now - (6 * 60_000L), // 6 minutes ago (stale)
expiresAt = null, // Indefinite
currentTime = now,
)
assertEquals(MarkerState.STALE, state) // Stale but never expired
}
@Test
fun `calculateMarkerState - exactly at stale threshold returns FRESH`() = runTest {
viewModel = MapViewModel(contactRepository, receivedLocationDao, locationSharingManager, announceDao)
val now = System.currentTimeMillis()
val state = viewModel.calculateMarkerState(
timestamp = now - (5 * 60_000L), // Exactly 5 minutes ago
expiresAt = null,
currentTime = now,
)
// At exactly 5 minutes, age > threshold is false (5 is not > 5)
assertEquals(MarkerState.FRESH, state)
}
@Test
fun `calculateMarkerState - just past stale threshold returns STALE`() = runTest {
viewModel = MapViewModel(contactRepository, receivedLocationDao, locationSharingManager, announceDao)
val now = System.currentTimeMillis()
val state = viewModel.calculateMarkerState(
timestamp = now - (5 * 60_000L + 1L), // 5 minutes + 1ms ago
expiresAt = null,
currentTime = now,
)
assertEquals(MarkerState.STALE, state)
}
// Helper function to create mock Location
private fun createMockLocation(lat: Double, lng: Double): Location {
val location = mockk<Location>(relaxed = true)

View File

@@ -29,8 +29,8 @@ interface ContactDao {
fun getAllContacts(identityHash: String): Flow<List<ContactEntity>>
/**
* Get enriched contacts with data from announces and conversations.
* Combines contact data with network status and conversation info.
* Get enriched contacts with data from announces, conversations, and location sharing.
* Combines contact data with network status, conversation info, and location sharing status.
* Filters by identity hash to ensure data isolation between identities.
*/
@Query(
@@ -54,10 +54,21 @@ interface ContactDao {
c.isPinned,
c.status,
c.isMyRelay,
a.nodeType
a.nodeType,
CASE WHEN loc.senderHash IS NOT NULL THEN 1 ELSE 0 END as isReceivingLocationFrom,
loc.expiresAt as locationSharingExpiresAt
FROM contacts c
LEFT JOIN announces a ON c.destinationHash = a.destinationHash
LEFT JOIN conversations conv ON c.destinationHash = conv.peerHash AND c.identityHash = conv.identityHash
LEFT JOIN (
SELECT rl.senderHash, rl.expiresAt
FROM received_locations rl
WHERE rl.timestamp = (
SELECT MAX(rl2.timestamp) FROM received_locations rl2
WHERE rl2.senderHash = rl.senderHash
)
AND (rl.expiresAt IS NULL OR rl.expiresAt > :currentTime)
) loc ON c.destinationHash = loc.senderHash
WHERE c.identityHash = :identityHash
ORDER BY c.isPinned DESC, displayName ASC
""",
@@ -65,6 +76,7 @@ interface ContactDao {
fun getEnrichedContacts(
identityHash: String,
onlineThreshold: Long,
currentTime: Long = System.currentTimeMillis(),
): Flow<List<EnrichedContact>>
/**

View File

@@ -34,6 +34,22 @@ interface ReceivedLocationDao {
)
fun getLatestLocationsPerSender(currentTime: Long = System.currentTimeMillis()): Flow<List<ReceivedLocationEntity>>
/**
* Get the latest location for each sender without expiry filtering.
* Used for stale/last-known location display where filtering is done in ViewModel.
*/
@Query(
"""
SELECT * FROM received_locations r1
WHERE timestamp = (
SELECT MAX(timestamp) FROM received_locations r2
WHERE r2.senderHash = r1.senderHash
)
ORDER BY timestamp DESC
""",
)
fun getLatestLocationsPerSenderUnfiltered(): Flow<List<ReceivedLocationEntity>>
/**
* Get all locations for a specific sender (for trail visualization).
*/
@@ -61,10 +77,13 @@ interface ReceivedLocationDao {
suspend fun getLatestLocationForSender(senderHash: String): ReceivedLocationEntity?
/**
* Delete expired locations (cleanup job).
* Delete expired locations past the grace period (cleanup job).
* Keeps expired locations for 1 hour to display as "last known" location.
*
* @param gracePeriodCutoff Locations expired before this time will be deleted
*/
@Query("DELETE FROM received_locations WHERE expiresAt IS NOT NULL AND expiresAt < :currentTime")
suspend fun deleteExpiredLocations(currentTime: Long = System.currentTimeMillis())
@Query("DELETE FROM received_locations WHERE expiresAt IS NOT NULL AND expiresAt < :gracePeriodCutoff")
suspend fun deleteExpiredLocations(gracePeriodCutoff: Long = System.currentTimeMillis() - 3600_000L)
/**
* Delete all locations for a sender (when contact is removed).

View File

@@ -37,6 +37,11 @@ data class EnrichedContact(
val isMyRelay: Boolean = false,
// Node type (from announces table) - "PEER", "NODE", "PROPAGATION_NODE"
val nodeType: String? = null,
// Location sharing status (from received_locations table)
// True if this contact is currently sharing their location with us
val isReceivingLocationFrom: Boolean = false,
// When their location share expires (null = indefinite)
val locationSharingExpiresAt: Long? = null,
) {
/**
* Parse tags from JSON string to list
@@ -86,6 +91,8 @@ data class EnrichedContact(
if (status != other.status) return false
if (isMyRelay != other.isMyRelay) return false
if (nodeType != other.nodeType) return false
if (isReceivingLocationFrom != other.isReceivingLocationFrom) return false
if (locationSharingExpiresAt != other.locationSharingExpiresAt) return false
return true
}
@@ -110,6 +117,8 @@ data class EnrichedContact(
result = 31 * result + status.hashCode()
result = 31 * result + isMyRelay.hashCode()
result = 31 * result + (nodeType?.hashCode() ?: 0)
result = 31 * result + isReceivingLocationFrom.hashCode()
result = 31 * result + (locationSharingExpiresAt?.hashCode() ?: 0)
return result
}
}