From 38b9515fca8da5bdbabcb76e8693a59348488785 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Mon, 19 May 2025 16:23:29 -0500 Subject: [PATCH] refactor: Move "provide location" preference to DataStore (#1877) --- .../java/com/geeksville/mesh/MainActivity.kt | 2 + .../java/com/geeksville/mesh/model/UIState.kt | 24 ++++++----- .../geeksville/mesh/navigation/NavGraph.kt | 4 +- .../repository/datastore/DataStoreModule.kt | 11 +++++ .../repository/location/LocationRepository.kt | 30 ++++++++++--- .../java/com/geeksville/mesh/ui/Settings.kt | 42 +++++++++++-------- gradle/libs.versions.toml | 7 ++-- 7 files changed, 83 insertions(+), 37 deletions(-) diff --git a/app/src/main/java/com/geeksville/mesh/MainActivity.kt b/app/src/main/java/com/geeksville/mesh/MainActivity.kt index 0599a32ce..fb4c09bdc 100644 --- a/app/src/main/java/com/geeksville/mesh/MainActivity.kt +++ b/app/src/main/java/com/geeksville/mesh/MainActivity.kt @@ -276,6 +276,8 @@ class MainActivity : AppCompatActivity(), Logging { // 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 549a58869..138c7adf7 100644 --- a/app/src/main/java/com/geeksville/mesh/model/UIState.kt +++ b/app/src/main/java/com/geeksville/mesh/model/UIState.kt @@ -26,7 +26,6 @@ import androidx.appcompat.app.AppCompatDelegate import androidx.compose.material3.SnackbarHostState import androidx.core.content.edit import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.asLiveData import androidx.lifecycle.viewModelScope @@ -236,7 +235,6 @@ class UIViewModel @Inject constructor( _title.value = title } - val receivingLocationUpdates: StateFlow get() = locationRepository.receivingLocationUpdates val meshService: IMeshService? get() = radioConfigRepository.meshService val bondedAddress get() = radioInterfaceService.getBondedDeviceAddress() @@ -608,16 +606,22 @@ class UIViewModel @Inject constructor( if (config.lora != newConfig.lora) setConfig(newConfig) } - val provideLocation = - object : MutableLiveData(preferences.getBoolean("provide-location", false)) { - override fun setValue(value: Boolean) { - super.setValue(value) - - preferences.edit { - this.putBoolean("provide-location", value) - } + private val _provideLocation = locationRepository.locationPreferencesFlow.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = false + ) + val provideLocation: StateFlow get() = _provideLocation + fun setProvideLocation(provideLocation: Boolean) { + viewModelScope.launch { + locationRepository.updateLocationPreferences(provideLocation) + if (provideLocation) { + meshService?.startProvideLocation() + } else { + meshService?.stopProvideLocation() } } + } fun setOwner(name: String) { val user = ourNodeInfo.value?.user?.copy { diff --git a/app/src/main/java/com/geeksville/mesh/navigation/NavGraph.kt b/app/src/main/java/com/geeksville/mesh/navigation/NavGraph.kt index 0af19c4e5..6cbc87764 100644 --- a/app/src/main/java/com/geeksville/mesh/navigation/NavGraph.kt +++ b/app/src/main/java/com/geeksville/mesh/navigation/NavGraph.kt @@ -266,7 +266,9 @@ fun NavGraph( } ) ) { backStackEntry -> - SettingsScreen { + SettingsScreen( + uIViewModel, + ) { navController.navigate(Route.RadioConfig()) { popUpTo(Route.Settings) { inclusive = false 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 705b38d0d..999e3dcf0 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,9 +22,13 @@ 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 @@ -77,4 +81,11 @@ 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 4d9b791d6..736804e1e 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,14 +27,16 @@ 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 @@ -42,13 +44,22 @@ 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. */ - private val _receivingLocationUpdates: MutableStateFlow = MutableStateFlow(false) - val receivingLocationUpdates: StateFlow get() = _receivingLocationUpdates + 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) + } + } @RequiresPermission(anyOf = [ACCESS_COARSE_LOCATION, ACCESS_FINE_LOCATION]) private fun LocationManager.requestLocationUpdates() = callbackFlow { @@ -84,7 +95,8 @@ class LocationRepository @Inject constructor( } info("Starting location updates with $providerList intervalMs=${intervalMs}ms and minDistanceM=${minDistanceM}m") - _receivingLocationUpdates.value = true +// _receivingLocationUpdates.value = true + updateLocationPreferences(true) GeeksvilleApplication.analytics.track("location_start") // Figure out how many users needed to use the phone GPS try { @@ -103,7 +115,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) @@ -116,3 +128,9 @@ 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/Settings.kt b/app/src/main/java/com/geeksville/mesh/ui/Settings.kt index 9ac233dc0..a46fe2f25 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/Settings.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/Settings.kt @@ -86,11 +86,12 @@ 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.UIViewModel -import com.geeksville.mesh.service.MeshService import com.geeksville.mesh.repository.network.NetworkRepository +import com.geeksville.mesh.service.MeshService import kotlinx.coroutines.delay fun String?.isIPAddress(): Boolean { @@ -110,19 +111,21 @@ fun SettingsScreen( bluetoothViewModel: BluetoothViewModel = hiltViewModel(), onSetRegion: () -> Unit, ) { - val currentRegion = uiViewModel.region - val regionUnset = currentRegion == ConfigProtos.Config.LoRaConfig.RegionCode.UNSET + val config by uiViewModel.localConfig.collectAsState() + val currentRegion = config.lora.region val scrollState = rememberScrollState() val scanStatusText by scanModel.errorText.observeAsState("") 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 && + connectionState == MeshService.ConnectionState.CONNECTED val isGpsDisabled = context.gpsDisabled() LaunchedEffect(isGpsDisabled) { @@ -165,10 +168,10 @@ fun SettingsScreen( contract = ActivityResultContracts.RequestMultiplePermissions(), onResult = { permissions -> if (permissions.entries.all { it.value }) { - uiViewModel.provideLocation.value = true - uiViewModel.meshService?.startProvideLocation() + uiViewModel.setProvideLocation(true) } else { debug("User denied location permission") + uiViewModel.setProvideLocation(false) uiViewModel.showSnackbar(context.getString(R.string.why_background_required)) } bluetoothViewModel.permissionsUpdated() @@ -200,11 +203,11 @@ fun SettingsScreen( showScanDialog = true } - LaunchedEffect(connectionState, regionUnset) { + LaunchedEffect(connectionState) { when (connectionState) { - MeshService.ConnectionState.CONNECTED -> - // Include region unset warning in status string if applicable + MeshService.ConnectionState.CONNECTED -> { if (regionUnset) R.string.must_set_region else R.string.connected_to + } MeshService.ConnectionState.DISCONNECTED -> R.string.not_connected MeshService.ConnectionState.DEVICE_SLEEP -> R.string.connected_sleeping @@ -216,6 +219,15 @@ fun SettingsScreen( scanModel.setErrorText(context.getString(it, firmwareString)) } } + when (connectionState) { + MeshService.ConnectionState.CONNECTED -> { + if (lastConnection != null && lastConnection?.myNodeNum != info?.myNodeNum) { + uiViewModel.setProvideLocation(false) + } + lastConnection = info + } + else -> {} + } } Box(modifier = Modifier.fillMaxSize()) { Column( @@ -349,21 +361,17 @@ fun SettingsScreen( Spacer(modifier = Modifier.height(16.dp)) // Provide Location Checkbox + val checked by uiViewModel.provideLocation.collectAsState() Row( modifier = Modifier .fillMaxWidth() .toggleable( - value = receivingLocationUpdates, + value = checked, onValueChange = { checked -> - uiViewModel.provideLocation.value = checked + uiViewModel.setProvideLocation(checked) if (checked && !context.hasLocationPermission()) { showLocationRationaleDialog = true // Show the Compose dialog } - if (checked) { - uiViewModel.meshService?.startProvideLocation() - } else { - uiViewModel.meshService?.stopProvideLocation() - } }, enabled = !isGpsDisabled ) @@ -371,7 +379,7 @@ fun SettingsScreen( verticalAlignment = Alignment.CenterVertically ) { Checkbox( - checked = receivingLocationUpdates, + checked = checked, onCheckedChange = null, enabled = !isGpsDisabled // Disable if GPS is disabled ) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 225f6bb06..0ec22d9a6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -30,7 +30,7 @@ kotlinx-coroutines-android = "1.10.2" kotlinx-serialization-json = "1.8.1" lifecycle = "2.9.0" material = "1.12.0" -material3 = "1.2.0" +material3 = "1.3.2" mgrs = "2.1.3" navigation = "2.9.0" org-eclipse-paho-client-mqttv3 = "1.2.5" @@ -58,7 +58,7 @@ awesome-app-rating = { group = "com.suddenh4x.ratingdialog", name = "awesome-app cardview = { group = "androidx.cardview", name = "cardview", version.ref = "cardview" } coil = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" } coil-svg = { group = "io.coil-kt.coil3", name = "coil-svg", version.ref = "coil" } -compose-material3 = { group = "androidx.compose.material3", name = "material3" } +compose-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" } compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" } compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } compose-runtime-livedata = { group = "androidx.compose.runtime", name = "runtime-livedata" } @@ -71,6 +71,7 @@ core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx core-location-altitude = { group = "androidx.core", name = "core-location-altitude", version.ref = "core-location-altitude" } core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "core-splashscreen" } datastore = { group = "androidx.datastore", name = "datastore", version.ref = "datastore" } +datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } detekt-formatting = { group = "io.gitlab.arturbosch.detekt", name = "detekt-formatting", version.ref = "detekt" } emoji2-emojipicker = { group = "androidx.emoji2", name = "emoji2-emojipicker", version.ref = "emoji2" } espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso-core" } @@ -142,7 +143,7 @@ navigation = ["navigation-compose"] coroutines = ["kotlinx-coroutines-android", "kotlinx-coroutines-guava"] # Data Storage -datastore = ["datastore"] +datastore = ["datastore", "datastore-preferences"] room = ["room-runtime", "room-ktx"] # Dependency Injection