fix: per-device location sharing (#2010)

This commit is contained in:
James Rich
2025-06-03 16:04:24 -05:00
committed by GitHub
parent ff5cc55a60
commit 868d705b2d
5 changed files with 69 additions and 100 deletions

View File

@@ -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()
}
}

View File

@@ -240,6 +240,7 @@ class UIViewModel @Inject constructor(
_title.value = title
}
val receivingLocationUpdates: StateFlow<Boolean> 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<AppOnlyProtos.ChannelSet?>(null)
val requestChannelSet: StateFlow<AppOnlyProtos.ChannelSet?> 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<Boolean> 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<Boolean> 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()

View File

@@ -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<Preferences> =
PreferenceDataStoreFactory.create(
produceFile = { appContext.preferencesDataStoreFile(LOCATION_PREFERNCES_NAME) }
)
}

View File

@@ -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<LocationManager>,
private val locationPreferencesDataStore: DataStore<Preferences>,
) : 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<Boolean> = MutableStateFlow(false)
val receivingLocationUpdates: StateFlow<Boolean> 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"

View File

@@ -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<Node?>(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 },