feat: implement configurable location precision with uncertainty circles

Add configurable location coarsening for privacy:
- Presets: Precise, Neighborhood (~100m), City (~1km), Region (~10km)
- Sender coarsens coordinates to grid before sending
- Sends approxRadius in telemetry so recipient knows precision
- Recipient renders semi-transparent uncertainty circle on map
- Fix settings persistence when navigating away from settings page

🤖 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 22:51:57 -05:00
parent 6092dfd0de
commit 8b94032290
10 changed files with 167 additions and 69 deletions

View File

@@ -14,6 +14,7 @@ import kotlinx.serialization.Serializable
* @property ts Timestamp when location was captured (millis since epoch)
* @property expires When sharing ends (millis since epoch), null for indefinite
* @property cease If true, recipient should delete sender's location (sharing stopped)
* @property approxRadius Coarsening radius in meters (0 = precise, >0 = approximate)
*/
@Serializable
data class LocationTelemetry(
@@ -24,6 +25,7 @@ data class LocationTelemetry(
val ts: Long,
val expires: Long? = null,
val cease: Boolean = false,
val approxRadius: Int = 0,
) {
companion object {
const val TYPE_LOCATION_SHARE = "location_share"

View File

@@ -86,7 +86,7 @@ class SettingsRepository
// Location sharing preferences
val LOCATION_SHARING_ENABLED = booleanPreferencesKey("location_sharing_enabled")
val DEFAULT_SHARING_DURATION = stringPreferencesKey("default_sharing_duration")
val LOCATION_PRECISION = stringPreferencesKey("location_precision")
val LOCATION_PRECISION_RADIUS = intPreferencesKey("location_precision_radius")
}
// Notification preferences
@@ -867,25 +867,25 @@ class SettingsRepository
}
/**
* Flow of the location precision setting.
* Values: "PRECISE" (GPS accuracy) or "APPROXIMATE" (reduced accuracy).
* Defaults to "PRECISE" if not set.
* Flow of the location precision radius in meters.
* 0 = Precise (no coarsening), >0 = coarsening radius in meters.
* Defaults to 0 (precise) if not set.
*/
val locationPrecisionFlow: Flow<String> =
val locationPrecisionRadiusFlow: Flow<Int> =
context.dataStore.data
.map { preferences ->
preferences[PreferencesKeys.LOCATION_PRECISION] ?: "PRECISE"
preferences[PreferencesKeys.LOCATION_PRECISION_RADIUS] ?: 0
}
.distinctUntilChanged()
/**
* Save the location precision setting.
* Save the location precision radius.
*
* @param precision "PRECISE" or "APPROXIMATE"
* @param radiusMeters 0 for precise, or coarsening radius in meters (100, 1000, 10000, etc.)
*/
suspend fun saveLocationPrecision(precision: String) {
suspend fun saveLocationPrecisionRadius(radiusMeters: Int) {
context.dataStore.edit { preferences ->
preferences[PreferencesKeys.LOCATION_PRECISION] = precision
preferences[PreferencesKeys.LOCATION_PRECISION_RADIUS] = radiusMeters
}
}

View File

@@ -14,6 +14,7 @@ import com.lxmf.messenger.data.db.dao.ReceivedLocationDao
import com.lxmf.messenger.data.db.entity.ReceivedLocationEntity
import com.lxmf.messenger.data.model.LocationTelemetry
import com.lxmf.messenger.di.ApplicationScope
import com.lxmf.messenger.repository.SettingsRepository
import com.lxmf.messenger.reticulum.protocol.ReticulumProtocol
import com.lxmf.messenger.reticulum.protocol.ServiceReticulumProtocol
import com.lxmf.messenger.ui.model.SharingDuration
@@ -27,6 +28,7 @@ import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
@@ -35,6 +37,7 @@ import org.json.JSONObject
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.math.roundToInt
/**
* Represents an active location sharing session.
@@ -67,6 +70,7 @@ class LocationSharingManager
@ApplicationContext private val context: Context,
private val reticulumProtocol: ReticulumProtocol,
private val receivedLocationDao: ReceivedLocationDao,
private val settingsRepository: SettingsRepository,
@ApplicationScope private val scope: CoroutineScope,
) {
companion object {
@@ -375,13 +379,20 @@ class LocationSharingManager
// Find the first expired session end time among all sessions
val earliestExpiry = sessions.mapNotNull { it.endTime }.minOrNull()
// Get precision radius setting (0 = precise, >0 = coarsen to that radius)
val precisionRadius = settingsRepository.locationPrecisionRadiusFlow.first()
// Coarsen location if needed
val (finalLat, finalLng) = coarsenLocation(location.latitude, location.longitude, precisionRadius)
val telemetry =
LocationTelemetry(
lat = location.latitude,
lng = location.longitude,
acc = location.accuracy,
lat = finalLat,
lng = finalLng,
acc = if (precisionRadius > 0) precisionRadius.toFloat() else location.accuracy,
ts = System.currentTimeMillis(),
expires = earliestExpiry,
approxRadius = precisionRadius,
)
val json = Json.encodeToString(telemetry)
@@ -399,7 +410,7 @@ class LocationSharingManager
)
result.onSuccess {
Log.d(TAG, "Location sent to ${session.displayName}")
Log.d(TAG, "Location sent to ${session.displayName} (approxRadius=$precisionRadius)")
}.onFailure { e ->
Log.e(TAG, "Failed to send location to ${session.displayName}", e)
}
@@ -410,6 +421,24 @@ class LocationSharingManager
}
}
/**
* Coarsen location coordinates to a grid based on the specified radius.
*
* @param lat Latitude in decimal degrees
* @param lng Longitude in decimal degrees
* @param radiusMeters Coarsening radius in meters (0 = no coarsening)
* @return Pair of coarsened (lat, lng)
*/
private fun coarsenLocation(lat: Double, lng: Double, radiusMeters: Int): Pair<Double, Double> {
if (radiusMeters <= 0) return Pair(lat, lng)
// Convert radius to degrees (approximate: 111km per degree at equator)
val gridSizeDegrees = radiusMeters / 111_000.0
val coarseLat = (lat / gridSizeDegrees).roundToInt() * gridSizeDegrees
val coarseLng = (lng / gridSizeDegrees).roundToInt() * gridSizeDegrees
return Pair(coarseLat, coarseLng)
}
private fun startListeningForLocationTelemetry() {
// Cast to ServiceReticulumProtocol to access the locationTelemetryFlow
val serviceProtocol = reticulumProtocol as? ServiceReticulumProtocol
@@ -450,6 +479,7 @@ class LocationSharingManager
val acc = json.getDouble("acc").toFloat()
val ts = json.getLong("ts")
val expires = if (json.has("expires") && !json.isNull("expires")) json.getLong("expires") else null
val approxRadius = json.optInt("approxRadius", 0)
val entity =
ReceivedLocationEntity(
@@ -461,10 +491,11 @@ class LocationSharingManager
timestamp = ts,
expiresAt = expires,
receivedAt = System.currentTimeMillis(),
approximateRadius = approxRadius,
)
receivedLocationDao.insert(entity)
Log.d(TAG, "Stored location from $senderHash: ($lat, $lng)")
Log.d(TAG, "Stored location from $senderHash: ($lat, $lng) approxRadius=$approxRadius")
} catch (e: Exception) {
Log.e(TAG, "Failed to parse/store received location: $locationJson", e)
}

View File

@@ -303,7 +303,7 @@ fun MapScreen(
val sourceId = "contact-markers-source"
val layerId = "contact-markers-layer"
// Create GeoJSON features from contact markers with state property
// Create GeoJSON features from contact markers with state and approximateRadius properties
val features = state.contactMarkers.map { marker ->
Feature.fromGeometry(
Point.fromLngLat(marker.longitude, marker.latitude)
@@ -311,6 +311,7 @@ fun MapScreen(
addStringProperty("name", marker.displayName)
addStringProperty("hash", marker.destinationHash)
addStringProperty("state", marker.state.name) // FRESH, STALE, or EXPIRED_GRACE_PERIOD
addNumberProperty("approximateRadius", marker.approximateRadius) // meters, 0 = precise
}
}
val featureCollection = FeatureCollection.fromFeatures(features)
@@ -323,6 +324,47 @@ fun MapScreen(
// Add new source and layers with data-driven styling based on marker state
style.addSource(GeoJsonSource(sourceId, featureCollection))
// Uncertainty circle layer for approximate locations (rendered behind main marker)
// Only visible when approximateRadius > 0
val uncertaintyLayerId = "contact-markers-uncertainty-layer"
style.addLayer(
CircleLayer(uncertaintyLayerId, sourceId).withProperties(
// Circle radius scales with zoom - converts meters to screen pixels
// At zoom 15, 1 pixel ≈ 1 meter, so we scale accordingly
PropertyFactory.circleRadius(
Expression.interpolate(
Expression.linear(),
Expression.zoom(),
// At lower zooms, show smaller radius (it's farther out)
Expression.stop(10, Expression.division(Expression.get("approximateRadius"), Expression.literal(30))),
Expression.stop(12, Expression.division(Expression.get("approximateRadius"), Expression.literal(10))),
Expression.stop(15, Expression.division(Expression.get("approximateRadius"), Expression.literal(3))),
Expression.stop(18, Expression.product(Expression.get("approximateRadius"), Expression.literal(0.8))),
)
),
// Semi-transparent fill
PropertyFactory.circleColor(
Expression.color(android.graphics.Color.parseColor("#FF5722")) // Orange
),
PropertyFactory.circleOpacity(
Expression.literal(0.15f)
),
// Dashed stroke for the uncertainty boundary
PropertyFactory.circleStrokeWidth(
Expression.literal(2f)
),
PropertyFactory.circleStrokeColor(
Expression.color(android.graphics.Color.parseColor("#FF5722")) // Orange
),
PropertyFactory.circleStrokeOpacity(
Expression.literal(0.4f)
),
).withFilter(
// Only show for locations with approximateRadius > 0
Expression.gt(Expression.get("approximateRadius"), Expression.literal(0))
)
)
// CircleLayer for the filled circle
style.addLayer(
CircleLayer(layerId, sourceId).withProperties(

View File

@@ -178,8 +178,8 @@ fun SettingsScreen(
onStopAllSharing = { viewModel.stopAllSharing() },
defaultDuration = state.defaultSharingDuration,
onDefaultDurationChange = { viewModel.setDefaultSharingDuration(it) },
locationPrecision = state.locationPrecision,
onLocationPrecisionChange = { viewModel.setLocationPrecision(it) },
locationPrecisionRadius = state.locationPrecisionRadius,
onLocationPrecisionRadiusChange = { viewModel.setLocationPrecisionRadius(it) },
)
MessageDeliveryRetrievalCard(

View File

@@ -60,8 +60,8 @@ fun LocationSharingCard(
onStopAllSharing: () -> Unit,
defaultDuration: String,
onDefaultDurationChange: (String) -> Unit,
locationPrecision: String,
onLocationPrecisionChange: (String) -> Unit,
locationPrecisionRadius: Int,
onLocationPrecisionRadiusChange: (Int) -> Unit,
) {
var showDurationPicker by remember { mutableStateOf(false) }
var showPrecisionPicker by remember { mutableStateOf(false) }
@@ -136,7 +136,7 @@ fun LocationSharingCard(
// Location precision picker
SettingsRow(
label = "Location precision",
value = getPrecisionDisplayText(locationPrecision),
value = getPrecisionRadiusDisplayText(locationPrecisionRadius),
onClick = { showPrecisionPicker = true },
)
}
@@ -156,10 +156,10 @@ fun LocationSharingCard(
// Precision picker dialog
if (showPrecisionPicker) {
PrecisionPickerDialog(
currentPrecision = locationPrecision,
onPrecisionSelected = {
onLocationPrecisionChange(it)
PrecisionRadiusPickerDialog(
currentRadius = locationPrecisionRadius,
onRadiusSelected = {
onLocationPrecisionRadiusChange(it)
showPrecisionPicker = false
},
onDismiss = { showPrecisionPicker = false },
@@ -311,10 +311,21 @@ private fun DurationPickerDialog(
)
}
/**
* Precision radius presets for the picker.
*/
private enum class PrecisionPreset(val radiusMeters: Int, val displayName: String, val description: String) {
PRECISE(0, "Precise", "Exact GPS location"),
NEIGHBORHOOD(100, "Neighborhood", "~100m radius"),
CITY(1000, "City", "~1km radius"),
REGION(10000, "Region", "~10km radius"),
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun PrecisionPickerDialog(
currentPrecision: String,
onPrecisionSelected: (String) -> Unit,
private fun PrecisionRadiusPickerDialog(
currentRadius: Int,
onRadiusSelected: (Int) -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
@@ -323,24 +334,19 @@ private fun PrecisionPickerDialog(
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
"Choose the accuracy of your shared location:",
"Choose how precisely your location is shared:",
style = MaterialTheme.typography.bodyMedium,
)
Spacer(modifier = Modifier.height(8.dp))
PrecisionOption(
title = "Precise",
description = "Full GPS accuracy for exact location",
isSelected = currentPrecision == "PRECISE",
onClick = { onPrecisionSelected("PRECISE") },
)
PrecisionOption(
title = "Approximate",
description = "Reduced accuracy (~100m radius)",
isSelected = currentPrecision == "APPROXIMATE",
onClick = { onPrecisionSelected("APPROXIMATE") },
)
PrecisionPreset.entries.forEach { preset ->
PrecisionRadiusOption(
title = preset.displayName,
description = preset.description,
isSelected = currentRadius == preset.radiusMeters,
onClick = { onRadiusSelected(preset.radiusMeters) },
)
}
}
},
confirmButton = {
@@ -352,7 +358,7 @@ private fun PrecisionPickerDialog(
}
@Composable
private fun PrecisionOption(
private fun PrecisionRadiusOption(
title: String,
description: String,
isSelected: Boolean,
@@ -412,12 +418,14 @@ private fun getDurationDisplayText(durationName: String): String {
}
/**
* Get display text for a precision setting.
* Get display text for a precision radius setting.
*/
private fun getPrecisionDisplayText(precision: String): String {
return when (precision) {
"PRECISE" -> "Precise"
"APPROXIMATE" -> "Approximate"
else -> "Precise"
private fun getPrecisionRadiusDisplayText(radiusMeters: Int): String {
return when (radiusMeters) {
0 -> "Precise"
100 -> "Neighborhood (~100m)"
1000 -> "City (~1km)"
10000 -> "Region (~10km)"
else -> if (radiusMeters >= 1000) "${radiusMeters / 1000}km" else "${radiusMeters}m"
}
}

View File

@@ -54,6 +54,7 @@ data class ContactMarker(
val timestamp: Long = 0L,
val expiresAt: Long? = null,
val state: MarkerState = MarkerState.FRESH,
val approximateRadius: Int = 0, // Coarsening radius in meters (0 = precise)
)
/**
@@ -165,6 +166,7 @@ class MapViewModel
timestamp = loc.timestamp,
expiresAt = loc.expiresAt,
state = markerState,
approximateRadius = loc.approximateRadius,
)
}
}.collect { markers ->

View File

@@ -70,7 +70,7 @@ data class SettingsState(
val locationSharingEnabled: Boolean = true,
val activeSharingSessions: List<com.lxmf.messenger.service.SharingSession> = emptyList(),
val defaultSharingDuration: String = "ONE_HOUR",
val locationPrecision: String = "PRECISE",
val locationPrecisionRadius: Int = 0,
)
@Suppress("TooManyFunctions", "LargeClass") // ViewModel with many user interaction methods is expected
@@ -117,6 +117,8 @@ class SettingsViewModel
init {
loadSettings()
// Always load location sharing settings (not dependent on monitors)
loadLocationSharingSettings()
if (enableMonitors) {
startSharedInstanceMonitor()
startSharedInstanceAvailabilityMonitor()
@@ -238,6 +240,11 @@ class SettingsViewModel
transportNodeEnabled = transportNodeEnabled,
// Message delivery state
defaultDeliveryMethod = defaultDeliveryMethod,
// Preserve location sharing state from loadLocationSharingSettings()
locationSharingEnabled = _state.value.locationSharingEnabled,
activeSharingSessions = _state.value.activeSharingSessions,
defaultSharingDuration = _state.value.defaultSharingDuration,
locationPrecisionRadius = _state.value.locationPrecisionRadius,
)
}.distinctUntilChanged().collect { newState ->
val previousState = _state.value
@@ -1109,18 +1116,10 @@ class SettingsViewModel
// Location sharing methods
/**
* Start monitoring location sharing state from the LocationSharingManager
* and settings repository.
* Load location sharing settings from the repository.
* Called unconditionally to ensure settings persist across navigation.
*/
private fun startLocationSharingMonitor() {
// Monitor active sharing sessions
viewModelScope.launch {
locationSharingManager.activeSessions.collect { sessions ->
_state.value = _state.value.copy(activeSharingSessions = sessions)
}
}
// Monitor location sharing settings
private fun loadLocationSharingSettings() {
viewModelScope.launch {
settingsRepository.locationSharingEnabledFlow.collect { enabled ->
_state.value = _state.value.copy(locationSharingEnabled = enabled)
@@ -1132,8 +1131,20 @@ class SettingsViewModel
}
}
viewModelScope.launch {
settingsRepository.locationPrecisionFlow.collect { precision ->
_state.value = _state.value.copy(locationPrecision = precision)
settingsRepository.locationPrecisionRadiusFlow.collect { radiusMeters ->
_state.value = _state.value.copy(locationPrecisionRadius = radiusMeters)
}
}
}
/**
* Start monitoring active location sharing sessions from the LocationSharingManager.
* Only called when monitors are enabled.
*/
private fun startLocationSharingMonitor() {
viewModelScope.launch {
locationSharingManager.activeSessions.collect { sessions ->
_state.value = _state.value.copy(activeSharingSessions = sessions)
}
}
}
@@ -1185,14 +1196,14 @@ class SettingsViewModel
}
/**
* Set the location precision.
* Set the location precision radius.
*
* @param precision "PRECISE" or "APPROXIMATE"
* @param radiusMeters 0 for precise, or coarsening radius in meters (100, 1000, 10000, etc.)
*/
fun setLocationPrecision(precision: String) {
fun setLocationPrecisionRadius(radiusMeters: Int) {
viewModelScope.launch {
settingsRepository.saveLocationPrecision(precision)
Log.d(TAG, "Location precision set to: $precision")
settingsRepository.saveLocationPrecisionRadius(radiusMeters)
Log.d(TAG, "Location precision radius set to: ${radiusMeters}m")
}
}
}

View File

@@ -30,4 +30,5 @@ data class ReceivedLocationEntity(
val timestamp: Long, // When the location was captured (from sender)
val expiresAt: Long?, // When sharing ends (null = indefinite)
val receivedAt: Long, // When we received this update
val approximateRadius: Int = 0, // Coarsening radius in meters (0 = precise)
)

View File

@@ -1068,7 +1068,8 @@ object DatabaseModule {
accuracy REAL NOT NULL,
timestamp INTEGER NOT NULL,
expiresAt INTEGER,
receivedAt INTEGER NOT NULL
receivedAt INTEGER NOT NULL,
approximateRadius INTEGER NOT NULL DEFAULT 0
)
""".trimIndent(),
)