mirror of
https://github.com/torlando-tech/columba.git
synced 2025-12-22 05:37:07 +00:00
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:
@@ -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 ->
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>>
|
||||
|
||||
/**
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user