From 868d705b2d3ad9396e06077ec11e3d322285a3ca Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Tue, 3 Jun 2025 16:04:24 -0500 Subject: [PATCH] fix: per-device location sharing (#2010) --- .../java/com/geeksville/mesh/MainActivity.kt | 8 -- .../java/com/geeksville/mesh/model/UIState.kt | 32 ++++--- .../repository/datastore/DataStoreModule.kt | 11 --- .../repository/location/LocationRepository.kt | 30 ++----- .../mesh/ui/connections/Connections.kt | 88 +++++++++---------- 5 files changed, 69 insertions(+), 100 deletions(-) diff --git a/app/src/main/java/com/geeksville/mesh/MainActivity.kt b/app/src/main/java/com/geeksville/mesh/MainActivity.kt index 7cccfc640..7f18811e3 100644 --- a/app/src/main/java/com/geeksville/mesh/MainActivity.kt +++ b/app/src/main/java/com/geeksville/mesh/MainActivity.kt @@ -270,14 +270,6 @@ class MainActivity : AppCompatActivity(), Logging { // Called when we gain/lose a connection to our mesh radio private fun onMeshConnectionChanged(newConnection: MeshService.ConnectionState) { if (newConnection == MeshService.ConnectionState.CONNECTED) { - serviceRepository.meshService?.let { service -> - // if provideLocation enabled: Start providing location (from phone GPS) to mesh - if (model.provideLocation.value == true) { - service.startProvideLocation() - } else { - service.stopProvideLocation() - } - } checkNotificationPermissions() } } diff --git a/app/src/main/java/com/geeksville/mesh/model/UIState.kt b/app/src/main/java/com/geeksville/mesh/model/UIState.kt index 626f7f345..d97c3cd49 100644 --- a/app/src/main/java/com/geeksville/mesh/model/UIState.kt +++ b/app/src/main/java/com/geeksville/mesh/model/UIState.kt @@ -240,6 +240,7 @@ class UIViewModel @Inject constructor( _title.value = title } + val receivingLocationUpdates: StateFlow get() = locationRepository.receivingLocationUpdates val meshService: IMeshService? get() = radioConfigRepository.meshService val bondedAddress get() = radioInterfaceService.getBondedDeviceAddress() @@ -546,7 +547,8 @@ class UIViewModel @Inject constructor( // Connection state to our radio device val connectionState get() = radioConfigRepository.connectionState fun isConnected() = connectionState.value != MeshService.ConnectionState.DISCONNECTED - val isConnected = radioConfigRepository.connectionState.map { it == MeshService.ConnectionState.CONNECTED } + val isConnected = + radioConfigRepository.connectionState.map { it == MeshService.ConnectionState.CONNECTED } private val _requestChannelSet = MutableStateFlow(null) val requestChannelSet: StateFlow get() = _requestChannelSet @@ -651,16 +653,26 @@ class UIViewModel @Inject constructor( if (config.lora != newConfig.lora) setConfig(newConfig) } - private val _provideLocation = locationRepository.locationPreferencesFlow.stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5_000), - initialValue = false - ) - val provideLocation: StateFlow get() = _provideLocation - fun setProvideLocation(provideLocation: Boolean) { + fun refreshProvideLocation() { viewModelScope.launch { - locationRepository.updateLocationPreferences(provideLocation) - if (provideLocation) { + setProvideLocation(getProvidePref()) + } + } + + private fun getProvidePref(): Boolean { + val value = preferences.getBoolean("provide-location-$myNodeNum", false) + return value + } + + private val _provideLocation = + MutableStateFlow(getProvidePref()) + val provideLocation: StateFlow get() = _provideLocation.asStateFlow() + + fun setProvideLocation(value: Boolean) { + viewModelScope.launch { + preferences.edit { putBoolean("provide-location-$myNodeNum", value) } + _provideLocation.value = value + if (value) { meshService?.startProvideLocation() } else { meshService?.stopProvideLocation() diff --git a/app/src/main/java/com/geeksville/mesh/repository/datastore/DataStoreModule.kt b/app/src/main/java/com/geeksville/mesh/repository/datastore/DataStoreModule.kt index 999e3dcf0..705b38d0d 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/datastore/DataStoreModule.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/datastore/DataStoreModule.kt @@ -22,13 +22,9 @@ import androidx.datastore.core.DataStore import androidx.datastore.core.DataStoreFactory import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler import androidx.datastore.dataStoreFile -import androidx.datastore.preferences.core.PreferenceDataStoreFactory -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.preferencesDataStoreFile import com.geeksville.mesh.AppOnlyProtos.ChannelSet import com.geeksville.mesh.LocalOnlyProtos.LocalConfig import com.geeksville.mesh.LocalOnlyProtos.LocalModuleConfig -import com.geeksville.mesh.repository.location.LOCATION_PREFERNCES_NAME import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -81,11 +77,4 @@ object DataStoreModule { scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) ) } - - @Singleton - @Provides - fun provideLocationPreferencesDataStore(@ApplicationContext appContext: Context): DataStore = - PreferenceDataStoreFactory.create( - produceFile = { appContext.preferencesDataStoreFile(LOCATION_PREFERNCES_NAME) } - ) } diff --git a/app/src/main/java/com/geeksville/mesh/repository/location/LocationRepository.kt b/app/src/main/java/com/geeksville/mesh/repository/location/LocationRepository.kt index 736804e1e..4d9b791d6 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/location/LocationRepository.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/location/LocationRepository.kt @@ -27,16 +27,14 @@ import androidx.core.location.LocationListenerCompat import androidx.core.location.LocationManagerCompat import androidx.core.location.LocationRequestCompat import androidx.core.location.altitude.AltitudeConverterCompat -import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.core.booleanPreferencesKey import com.geeksville.mesh.android.GeeksvilleApplication import com.geeksville.mesh.android.Logging import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.asExecutor import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.callbackFlow -import kotlinx.coroutines.flow.map import javax.inject.Inject import javax.inject.Singleton @@ -44,22 +42,13 @@ import javax.inject.Singleton class LocationRepository @Inject constructor( private val context: Application, private val locationManager: dagger.Lazy, - private val locationPreferencesDataStore: DataStore, ) : Logging { /** * Status of whether the app is actively subscribed to location changes. */ - val locationPreferencesFlow = locationPreferencesDataStore.data.map { - it[PreferencesKeys.PROVIDE_LOCATION] == true - } - - suspend fun updateLocationPreferences(provideLocation: Boolean) = - locationPreferencesDataStore.updateData { preferences -> - preferences.toMutablePreferences().apply { - set(PreferencesKeys.PROVIDE_LOCATION, provideLocation) - } - } + private val _receivingLocationUpdates: MutableStateFlow = MutableStateFlow(false) + val receivingLocationUpdates: StateFlow get() = _receivingLocationUpdates @RequiresPermission(anyOf = [ACCESS_COARSE_LOCATION, ACCESS_FINE_LOCATION]) private fun LocationManager.requestLocationUpdates() = callbackFlow { @@ -95,8 +84,7 @@ class LocationRepository @Inject constructor( } info("Starting location updates with $providerList intervalMs=${intervalMs}ms and minDistanceM=${minDistanceM}m") -// _receivingLocationUpdates.value = true - updateLocationPreferences(true) + _receivingLocationUpdates.value = true GeeksvilleApplication.analytics.track("location_start") // Figure out how many users needed to use the phone GPS try { @@ -115,7 +103,7 @@ class LocationRepository @Inject constructor( awaitClose { info("Stopping location requests") -// _receivingLocationUpdates.value = false + _receivingLocationUpdates.value = false GeeksvilleApplication.analytics.track("location_stop") LocationManagerCompat.removeUpdates(this@requestLocationUpdates, locationListener) @@ -128,9 +116,3 @@ class LocationRepository @Inject constructor( @RequiresPermission(anyOf = [ACCESS_COARSE_LOCATION, ACCESS_FINE_LOCATION]) fun getLocations() = locationManager.get().requestLocationUpdates() } - -private object PreferencesKeys { - val PROVIDE_LOCATION = booleanPreferencesKey("provide_location") -} - -const val LOCATION_PREFERNCES_NAME = "location_preferences" diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/Connections.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/Connections.kt index b749b0354..468f98a66 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/connections/Connections.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/connections/Connections.kt @@ -25,6 +25,7 @@ import android.os.Build import android.util.Patterns import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -88,7 +89,6 @@ import com.geeksville.mesh.android.gpsDisabled import com.geeksville.mesh.android.hasLocationPermission import com.geeksville.mesh.android.isGooglePlayAvailable import com.geeksville.mesh.android.permissionMissing -import com.geeksville.mesh.database.entity.MyNodeEntity import com.geeksville.mesh.model.BTScanModel import com.geeksville.mesh.model.BluetoothViewModel import com.geeksville.mesh.model.NO_DEVICE_SELECTED @@ -135,10 +135,10 @@ fun ConnectionsScreen( val connectionState by uiViewModel.connectionState.collectAsState(MeshService.ConnectionState.DISCONNECTED) val devices by scanModel.devices.observeAsState(emptyMap()) val scanning by scanModel.spinner.observeAsState(false) + val receivingLocationUpdates by uiViewModel.receivingLocationUpdates.collectAsState(false) val context = LocalContext.current val app = (context.applicationContext as GeeksvilleApplication) val info by uiViewModel.myNodeInfo.collectAsState() - var lastConnection: MyNodeEntity? by remember { mutableStateOf(null) } val selectedDevice = scanModel.selectedNotNull val bluetoothEnabled by bluetoothViewModel.enabled.observeAsState() val regionUnset = currentRegion == ConfigProtos.Config.LoRaConfig.RegionCode.UNSET && @@ -205,9 +205,9 @@ fun ConnectionsScreen( onResult = { permissions -> if (permissions.entries.all { it.value }) { uiViewModel.setProvideLocation(true) + uiViewModel.meshService?.startProvideLocation() } else { debug("User denied location permission") - uiViewModel.setProvideLocation(false) uiViewModel.showSnackbar(context.getString(R.string.why_background_required)) } bluetoothViewModel.permissionsUpdated() @@ -239,7 +239,7 @@ fun ConnectionsScreen( showScanDialog = true } - LaunchedEffect(connectionState) { + LaunchedEffect(connectionState, regionUnset) { when (connectionState) { MeshService.ConnectionState.CONNECTED -> { if (regionUnset) R.string.must_set_region else R.string.connected_to @@ -252,16 +252,6 @@ fun ConnectionsScreen( info?.firmwareString ?: context.getString(R.string.unknown) scanModel.setErrorText(context.getString(it, firmwareString)) } - when (connectionState) { - MeshService.ConnectionState.CONNECTED -> { - if (lastConnection != null && lastConnection?.myNodeNum != info?.myNodeNum) { - uiViewModel.setProvideLocation(false) - } - lastConnection = info - } - - else -> {} - } } var showSharedContact by remember { mutableStateOf(null) } if (showSharedContact != null) { @@ -287,7 +277,7 @@ fun ConnectionsScreen( Spacer(modifier = Modifier.height(8.dp)) - val isConnected by uiViewModel.isConnected.collectAsStateWithLifecycle(false) + val isConnected by uiViewModel.isConnected.collectAsState(false) val ourNode by uiViewModel.ourNodeInfo.collectAsState() if (isConnected) { ourNode?.let { node -> @@ -440,35 +430,39 @@ fun ConnectionsScreen( Spacer(modifier = Modifier.height(16.dp)) - // Provide Location Checkbox - val checked by uiViewModel.provideLocation.collectAsState() - Row( - modifier = Modifier - .fillMaxWidth() - .toggleable( - value = checked, - onValueChange = { checked -> - uiViewModel.setProvideLocation(checked) - if (checked && !context.hasLocationPermission()) { - showLocationRationaleDialog = true // Show the Compose dialog - } - }, - enabled = !isGpsDisabled - ) - .padding(horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Checkbox( - checked = checked, - onCheckedChange = null, - enabled = !isGpsDisabled // Disable if GPS is disabled - ) - Text( - text = stringResource(R.string.provide_location_to_mesh), - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.padding(start = 16.dp) - ) + LaunchedEffect(ourNode) { + if (ourNode != null) { + uiViewModel.refreshProvideLocation() + } } + AnimatedVisibility(isConnected) { + val provideLocation by uiViewModel.provideLocation.collectAsState(false) + Row( + modifier = Modifier + .fillMaxWidth() + .toggleable( + value = provideLocation, + onValueChange = { checked -> + uiViewModel.setProvideLocation(checked) + }, + enabled = !isGpsDisabled + ) + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = receivingLocationUpdates, + onCheckedChange = null, + enabled = !isGpsDisabled // Disable if GPS is disabled + ) + Text( + text = stringResource(R.string.provide_location_to_mesh), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(start = 16.dp) + ) + } + } + // Provide Location Checkbox Spacer(modifier = Modifier.height(16.dp)) @@ -559,7 +553,7 @@ fun ConnectionsScreen( } } - // Compose Device Scan Dialog +// Compose Device Scan Dialog if (showScanDialog) { Dialog(onDismissRequest = { showScanDialog = false @@ -604,7 +598,7 @@ fun ConnectionsScreen( } } - // Compose Location Permission Rationale Dialog +// Compose Location Permission Rationale Dialog if (showLocationRationaleDialog) { AlertDialog( onDismissRequest = { showLocationRationaleDialog = false }, @@ -628,7 +622,7 @@ fun ConnectionsScreen( ) } - // Compose Bluetooth Permission Rationale Dialog +// Compose Bluetooth Permission Rationale Dialog if (showBluetoothRationaleDialog) { val bluetoothPermissions = context.getBluetoothPermissions() AlertDialog( @@ -656,7 +650,7 @@ fun ConnectionsScreen( ) } - // Compose Report Bug Dialog +// Compose Report Bug Dialog if (showReportBugDialog) { AlertDialog( onDismissRequest = { showReportBugDialog = false },