From 8b9403229057aed90524948afde4156ffe62b0b0 Mon Sep 17 00:00:00 2001 From: torlando-tech Date: Fri, 19 Dec 2025 22:51:57 -0500 Subject: [PATCH] feat: implement configurable location precision with uncertainty circles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../messenger/data/model/LocationTelemetry.kt | 2 + .../repository/SettingsRepository.kt | 20 +++--- .../service/LocationSharingManager.kt | 41 +++++++++-- .../lxmf/messenger/ui/screens/MapScreen.kt | 44 +++++++++++- .../messenger/ui/screens/SettingsScreen.kt | 4 +- .../settings/cards/LocationSharingCard.kt | 70 +++++++++++-------- .../lxmf/messenger/viewmodel/MapViewModel.kt | 2 + .../messenger/viewmodel/SettingsViewModel.kt | 49 ++++++++----- .../data/db/entity/ReceivedLocationEntity.kt | 1 + .../lxmf/messenger/data/di/DatabaseModule.kt | 3 +- 10 files changed, 167 insertions(+), 69 deletions(-) diff --git a/app/src/main/java/com/lxmf/messenger/data/model/LocationTelemetry.kt b/app/src/main/java/com/lxmf/messenger/data/model/LocationTelemetry.kt index a68bad7..b2a2c82 100644 --- a/app/src/main/java/com/lxmf/messenger/data/model/LocationTelemetry.kt +++ b/app/src/main/java/com/lxmf/messenger/data/model/LocationTelemetry.kt @@ -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" diff --git a/app/src/main/java/com/lxmf/messenger/repository/SettingsRepository.kt b/app/src/main/java/com/lxmf/messenger/repository/SettingsRepository.kt index e8121cf..03193cb 100644 --- a/app/src/main/java/com/lxmf/messenger/repository/SettingsRepository.kt +++ b/app/src/main/java/com/lxmf/messenger/repository/SettingsRepository.kt @@ -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 = + val locationPrecisionRadiusFlow: Flow = 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 } } diff --git a/app/src/main/java/com/lxmf/messenger/service/LocationSharingManager.kt b/app/src/main/java/com/lxmf/messenger/service/LocationSharingManager.kt index 380f2b2..b0fb837 100644 --- a/app/src/main/java/com/lxmf/messenger/service/LocationSharingManager.kt +++ b/app/src/main/java/com/lxmf/messenger/service/LocationSharingManager.kt @@ -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 { + 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) } diff --git a/app/src/main/java/com/lxmf/messenger/ui/screens/MapScreen.kt b/app/src/main/java/com/lxmf/messenger/ui/screens/MapScreen.kt index fa83df1..c30eff9 100644 --- a/app/src/main/java/com/lxmf/messenger/ui/screens/MapScreen.kt +++ b/app/src/main/java/com/lxmf/messenger/ui/screens/MapScreen.kt @@ -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( diff --git a/app/src/main/java/com/lxmf/messenger/ui/screens/SettingsScreen.kt b/app/src/main/java/com/lxmf/messenger/ui/screens/SettingsScreen.kt index efeebe6..b7c2a13 100644 --- a/app/src/main/java/com/lxmf/messenger/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/com/lxmf/messenger/ui/screens/SettingsScreen.kt @@ -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( diff --git a/app/src/main/java/com/lxmf/messenger/ui/screens/settings/cards/LocationSharingCard.kt b/app/src/main/java/com/lxmf/messenger/ui/screens/settings/cards/LocationSharingCard.kt index 38ea2ea..a8f22ea 100644 --- a/app/src/main/java/com/lxmf/messenger/ui/screens/settings/cards/LocationSharingCard.kt +++ b/app/src/main/java/com/lxmf/messenger/ui/screens/settings/cards/LocationSharingCard.kt @@ -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" } } diff --git a/app/src/main/java/com/lxmf/messenger/viewmodel/MapViewModel.kt b/app/src/main/java/com/lxmf/messenger/viewmodel/MapViewModel.kt index ac80f7f..c1eee3e 100644 --- a/app/src/main/java/com/lxmf/messenger/viewmodel/MapViewModel.kt +++ b/app/src/main/java/com/lxmf/messenger/viewmodel/MapViewModel.kt @@ -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 -> diff --git a/app/src/main/java/com/lxmf/messenger/viewmodel/SettingsViewModel.kt b/app/src/main/java/com/lxmf/messenger/viewmodel/SettingsViewModel.kt index 08b512c..81751da 100644 --- a/app/src/main/java/com/lxmf/messenger/viewmodel/SettingsViewModel.kt +++ b/app/src/main/java/com/lxmf/messenger/viewmodel/SettingsViewModel.kt @@ -70,7 +70,7 @@ data class SettingsState( val locationSharingEnabled: Boolean = true, val activeSharingSessions: List = 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") } } } diff --git a/data/src/main/java/com/lxmf/messenger/data/db/entity/ReceivedLocationEntity.kt b/data/src/main/java/com/lxmf/messenger/data/db/entity/ReceivedLocationEntity.kt index 4143f3e..c4dfd55 100644 --- a/data/src/main/java/com/lxmf/messenger/data/db/entity/ReceivedLocationEntity.kt +++ b/data/src/main/java/com/lxmf/messenger/data/db/entity/ReceivedLocationEntity.kt @@ -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) ) diff --git a/data/src/main/java/com/lxmf/messenger/data/di/DatabaseModule.kt b/data/src/main/java/com/lxmf/messenger/data/di/DatabaseModule.kt index fea9d7c..82b636b 100644 --- a/data/src/main/java/com/lxmf/messenger/data/di/DatabaseModule.kt +++ b/data/src/main/java/com/lxmf/messenger/data/di/DatabaseModule.kt @@ -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(), )