From 6bda99385148f596a8a0e786b2b005328c1ecb44 Mon Sep 17 00:00:00 2001 From: andrekir Date: Fri, 20 May 2022 09:13:59 -0300 Subject: [PATCH] move location service to repository --- .../com/geeksville/mesh/IMeshService.aidl | 2 +- .../java/com/geeksville/mesh/MainActivity.kt | 2 +- .../repository/location/LocationRepository.kt | 18 ++ .../location/LocationRepositoryModule.kt | 22 +++ .../location/SharedLocationManager.kt | 89 +++++++++ .../geeksville/mesh/service/MeshService.kt | 175 +++--------------- .../service/MeshServiceLocationCallback.kt | 72 ------- .../geeksville/mesh/ui/SettingsFragment.kt | 2 +- 8 files changed, 160 insertions(+), 222 deletions(-) create mode 100644 app/src/main/java/com/geeksville/mesh/repository/location/LocationRepository.kt create mode 100644 app/src/main/java/com/geeksville/mesh/repository/location/LocationRepositoryModule.kt create mode 100644 app/src/main/java/com/geeksville/mesh/repository/location/SharedLocationManager.kt delete mode 100644 app/src/main/java/com/geeksville/mesh/service/MeshServiceLocationCallback.kt diff --git a/app/src/main/aidl/com/geeksville/mesh/IMeshService.aidl b/app/src/main/aidl/com/geeksville/mesh/IMeshService.aidl index 62767952b..fd4e297e4 100644 --- a/app/src/main/aidl/com/geeksville/mesh/IMeshService.aidl +++ b/app/src/main/aidl/com/geeksville/mesh/IMeshService.aidl @@ -120,7 +120,7 @@ interface IMeshService { void setRegion(int regionCode); /// Start providing location (from phone GPS) to mesh - void setupProvideLocation(); + void startProvideLocation(); /// Stop providing location (from phone GPS) to mesh void stopProvideLocation(); diff --git a/app/src/main/java/com/geeksville/mesh/MainActivity.kt b/app/src/main/java/com/geeksville/mesh/MainActivity.kt index c1a1fe840..b4d0215be 100644 --- a/app/src/main/java/com/geeksville/mesh/MainActivity.kt +++ b/app/src/main/java/com/geeksville/mesh/MainActivity.kt @@ -683,7 +683,7 @@ class MainActivity : BaseActivity(), Logging, } // if provideLocation enabled: Start providing location (from phone GPS) to mesh if (model.provideLocation.value == true) - service.setupProvideLocation() + service.startProvideLocation() } } else { // For other connection states, just slam them in 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 new file mode 100644 index 000000000..4d20c7036 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/repository/location/LocationRepository.kt @@ -0,0 +1,18 @@ +package com.geeksville.mesh.repository.location + +import kotlinx.coroutines.flow.StateFlow +import javax.inject.Inject + +class LocationRepository @Inject constructor( + private val sharedLocationManager: SharedLocationManager +) { + /** + * Status of whether the app is actively subscribed to location changes. + */ + val receivingLocationUpdates: StateFlow = sharedLocationManager.receivingLocationUpdates + + /** + * Observable flow for location updates + */ + fun getLocations() = sharedLocationManager.locationFlow() +} diff --git a/app/src/main/java/com/geeksville/mesh/repository/location/LocationRepositoryModule.kt b/app/src/main/java/com/geeksville/mesh/repository/location/LocationRepositoryModule.kt new file mode 100644 index 000000000..0ad5b6173 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/repository/location/LocationRepositoryModule.kt @@ -0,0 +1,22 @@ +package com.geeksville.mesh.repository.location + +import android.content.Context +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.GlobalScope +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object LocationRepositoryModule { + + @Provides + @Singleton + fun provideSharedLocationManager( + @ApplicationContext context: Context + ): SharedLocationManager = + SharedLocationManager(context, GlobalScope) +} \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/repository/location/SharedLocationManager.kt b/app/src/main/java/com/geeksville/mesh/repository/location/SharedLocationManager.kt new file mode 100644 index 000000000..1679f36a1 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/repository/location/SharedLocationManager.kt @@ -0,0 +1,89 @@ +package com.geeksville.mesh.repository.location + +import android.annotation.SuppressLint +import android.content.Context +import android.location.Location +import android.os.Looper +import com.geeksville.android.GeeksvilleApplication +import com.geeksville.android.Logging +import com.geeksville.mesh.android.hasBackgroundPermission +import com.google.android.gms.location.LocationCallback +import com.google.android.gms.location.LocationRequest +import com.google.android.gms.location.LocationResult +import com.google.android.gms.location.LocationServices +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.shareIn + +/** + * Wraps LocationCallback() in callbackFlow + * + * Derived in part from https://github.com/android/location-samples/blob/main/LocationUpdatesBackgroundKotlin/app/src/main/java/com/google/android/gms/location/sample/locationupdatesbackgroundkotlin/data/MyLocationManager.kt + * and https://github.com/googlecodelabs/kotlin-coroutines/blob/master/ktx-library-codelab/step-06/myktxlibrary/src/main/java/com/example/android/myktxlibrary/LocationUtils.kt + */ +class SharedLocationManager constructor( + private val context: Context, + externalScope: CoroutineScope +) : Logging { + + private val _receivingLocationUpdates: MutableStateFlow = MutableStateFlow(false) + val receivingLocationUpdates: StateFlow get() = _receivingLocationUpdates + + // TODO use positionBroadcastSecs / test locationRequest settings + private val desiredInterval = 1 * 60 * 1000L + // if unset, use positionBroadcastSecs default + // positionBroadcastSecs.takeIf { it != 0L }?.times(1000L) ?: (15 * 60 * 1000L) + + // Set up the Fused Location Provider and LocationRequest + private val fusedLocationClient = LocationServices.getFusedLocationProviderClient(context) + private val locationRequest = LocationRequest.create().apply { + interval = desiredInterval + fastestInterval = 30 * 1000L + // smallestDisplacement = 50F // 50 meters + priority = LocationRequest.PRIORITY_BALANCED_POWER_ACCURACY + } + + @SuppressLint("MissingPermission") + private val _locationUpdates = callbackFlow { + val callback = object : LocationCallback() { + override fun onLocationResult(result: LocationResult) { + // info("New location: ${result.lastLocation}") + trySend(result.lastLocation) + } + } + if (!context.hasBackgroundPermission()) close() + + info("Starting location requests with interval=${desiredInterval}ms") + _receivingLocationUpdates.value = true + GeeksvilleApplication.analytics.track("location_start") // Figure out how many users needed to use the phone GPS + + fusedLocationClient.requestLocationUpdates( + locationRequest, + callback, + Looper.getMainLooper() + ).addOnFailureListener { ex -> + errormsg("Failed to listen to GPS error: ${ex.message}") + close(ex) // in case of exception, close the Flow + } + + awaitClose { + info("Stopping location requests") + _receivingLocationUpdates.value = false + GeeksvilleApplication.analytics.track("location_stop") + fusedLocationClient.removeLocationUpdates(callback) // clean up when Flow collection ends + } + }.shareIn( + externalScope, + replay = 0, + started = SharingStarted.WhileSubscribed() + ) + + fun locationFlow(): Flow { + return _locationUpdates + } +} diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt index 3a654f05f..b40c0cb36 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt @@ -1,14 +1,10 @@ package com.geeksville.mesh.service -import android.annotation.SuppressLint import android.app.Service import android.content.Context import android.content.Intent import android.os.IBinder -import android.os.Looper import android.os.RemoteException -import android.widget.Toast -import androidx.annotation.UiThread import androidx.core.content.edit import com.geeksville.analytics.DataPair import com.geeksville.android.GeeksvilleApplication @@ -22,22 +18,19 @@ import com.geeksville.mesh.android.hasBackgroundPermission import com.geeksville.mesh.database.PacketRepository import com.geeksville.mesh.database.entity.Packet import com.geeksville.mesh.model.DeviceVersion +import com.geeksville.mesh.repository.location.LocationRepository import com.geeksville.mesh.repository.radio.BluetoothInterface import com.geeksville.mesh.repository.radio.RadioInterfaceService import com.geeksville.mesh.repository.radio.RadioServiceConnectionState import com.geeksville.mesh.repository.usb.UsbRepository import com.geeksville.util.* -import com.google.android.gms.common.api.ApiException -import com.google.android.gms.common.api.ResolvableApiException -import com.google.android.gms.location.FusedLocationProviderClient -import com.google.android.gms.location.LocationRequest -import com.google.android.gms.location.LocationServices -import com.google.android.gms.location.LocationSettingsRequest import com.google.protobuf.ByteString import com.google.protobuf.InvalidProtocolBufferException import dagger.Lazy import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.* +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.serialization.json.Json import java.util.* import javax.inject.Inject @@ -65,6 +58,9 @@ class MeshService : Service(), Logging { @Inject lateinit var usbRepository: Lazy + @Inject + lateinit var locationRepository: LocationRepository + companion object : Logging { /// Intents broadcast by MeshService @@ -133,17 +129,11 @@ class MeshService : Service(), Logging { private val serviceScope = CoroutineScope(Dispatchers.IO + serviceJob) private var connectionState = ConnectionState.DISCONNECTED - private var fusedLocationClient: FusedLocationProviderClient? = null + private var locationFlow: Job? = null // If we've ever read a valid region code from our device it will be here var curRegionValue = RadioConfigProtos.RegionCode.Unset_VALUE - private val locationCallback = MeshServiceLocationCallback( - ::sendPositionScoped, - onSendPositionFailed = { onConnectionChanged(ConnectionState.DEVICE_SLEEP) }, - getNodeNum = { myNodeNum } - ) - private fun getSenderName(packet: DataPacket?): String { val name = nodeDBbyID[packet?.from]?.user?.longName return name ?: "Unknown username" @@ -159,109 +149,32 @@ class MeshService : Service(), Logging { ConnectionState.DEVICE_SLEEP -> getString(R.string.device_sleeping) } - private fun warnUserAboutLocation() { - Toast.makeText( - this, - getString(R.string.location_disabled), - Toast.LENGTH_LONG - ).show() - } - - private var locationIntervalMsec = 0L - - /** - * a periodic callback that perhaps send our position to other nodes. - * We first check to see if our local device has already sent a position and if so, we punt until the next check. - * This allows us to only 'fill in' with GPS positions when the local device happens to have no good GPS sats. - */ - private fun sendPositionScoped( - lat: Double = 0.0, - lon: Double = 0.0, - alt: Int = 0, - destNum: Int = DataPacket.NODENUM_BROADCAST, - wantResponse: Boolean = false - ) { - // This operation can take a while, so instead of staying in the callback (location services) context - // do most of the work in my service thread - serviceScope.handledLaunch { - // if android called us too soon, just ignore - sendPosition(lat, lon, alt, destNum, wantResponse) - } - } - /** * start our location requests (if they weren't already running) - * - * per https://developer.android.com/training/location/change-location-settings - * & https://developer.android.com/training/location/request-updates */ - @SuppressLint("MissingPermission") - @UiThread - private fun startLocationRequests(requestInterval: Long) { - // FIXME - currently we don't support location reading without google play - if (fusedLocationClient == null && hasBackgroundPermission() && isGooglePlayAvailable(this)) { - GeeksvilleApplication.analytics.track("location_start") // Figure out how many users needed to use the phone GPS + private fun startLocationRequests() { + // If we're already observing updates, don't register again + if (locationFlow?.isActive == true) return - locationIntervalMsec = requestInterval - val request = LocationRequest.create().apply { - interval = requestInterval - priority = LocationRequest.PRIORITY_HIGH_ACCURACY - } - val builder = LocationSettingsRequest.Builder().addLocationRequest(request) - val locationClient = LocationServices.getSettingsClient(this) - val locationSettingsResponse = locationClient.checkLocationSettings(builder.build()) - - locationSettingsResponse.addOnSuccessListener { - debug("We are now successfully listening to the GPS") - } - - locationSettingsResponse.addOnFailureListener { exception -> - errormsg("Failed to listen to GPS") - - when (exception) { - is ResolvableApiException -> - exceptionReporter { - // Location settings are not satisfied, but this can be fixed - // by showing the user a dialog. - - // Show the dialog by calling startResolutionForResult(), - // and check the result in onActivityResult(). - // exception.startResolutionForResult(this@MainActivity, REQUEST_CHECK_SETTINGS) - - // For now just punt and show a dialog - warnUserAboutLocation() - } - is ApiException -> - when (exception.statusCode) { - 17 -> - // error: cancelled by user - errormsg("User cancelled location access", exception) - 8502 -> - // error: settings change unavailable - errormsg( - "Settings-change-unavailable, user disabled location access (globally?)", - exception - ) - else -> - Exceptions.report(exception) - } - else -> - Exceptions.report(exception) + if (hasBackgroundPermission() && isGooglePlayAvailable(this)) { + locationFlow = locationRepository.getLocations() + .onEach { location -> + sendPosition( + location.latitude, + location.longitude, + location.altitude.toInt(), + myNodeNum, // we just send to the local node + false // and we never want ACKs + ) } - } - - val client = LocationServices.getFusedLocationProviderClient(this) - client.requestLocationUpdates(request, locationCallback, Looper.getMainLooper()) - fusedLocationClient = client + .launchIn(CoroutineScope(Dispatchers.Default)) } } private fun stopLocationRequests() { - if (fusedLocationClient != null) { + if (locationFlow?.isActive == true) { debug("Stopping location requests") - GeeksvilleApplication.analytics.track("location_stop") - fusedLocationClient?.removeLocationUpdates(locationCallback) - fusedLocationClient = null + locationFlow?.cancel() } } @@ -1022,39 +935,6 @@ class MeshService : Service(), Logging { maybeUpdateServiceStatusNotification() } - private fun setupLocationRequests() { - stopLocationRequests() - val mi = myNodeInfo - val prefs = radioConfig?.preferences - if (mi != null && prefs != null) { - val broadcastSecs = prefs.positionBroadcastSecs - - var desiredInterval = if (broadcastSecs == 0) // unset by device, use default - 15 * 60 * 1000L - else - broadcastSecs * 1000L - - if (prefs.locationShareDisabled) { - info("GPS location sharing is disabled") - desiredInterval = 0 - } - - // if (prefs.fixedPosition) { - // info("Node has fixed position, therefore not overriding position") - // desiredInterval = 0 - // } - - if (desiredInterval != 0L) { - info("desired GPS assistance interval $desiredInterval") - startLocationRequests(desiredInterval) - } else { - info("No GPS assistance desired, but sending UTC time to mesh") - warnUserAboutLocation() - sendPosition() - } - } - } - /** * Send in analytics about mesh connection */ @@ -1126,6 +1006,9 @@ class MeshService : Service(), Logging { // Just in case the user uncleanly reboots the phone, save now (we normally save in onDestroy) saveSettings() + // lost radio connection, therefore no need to keep listening to GPS + stopLocationRequests() + GeeksvilleApplication.analytics.track( "mesh_disconnect", DataPair("num_nodes", numNodes), @@ -1518,7 +1401,6 @@ class MeshService : Service(), Logging { /** * Send a position (typically from our built in GPS) into the mesh. - * Must be called from serviceScope. Use sendPositionScoped() for direct calls. */ private fun sendPosition( lat: Double = 0.0, @@ -1843,14 +1725,13 @@ class MeshService : Service(), Logging { r.toString() } - override fun setupProvideLocation() = toRemoteExceptions { - setupLocationRequests() + override fun startProvideLocation() = toRemoteExceptions { + startLocationRequests() } override fun stopProvideLocation() = toRemoteExceptions { stopLocationRequests() } - } } diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshServiceLocationCallback.kt b/app/src/main/java/com/geeksville/mesh/service/MeshServiceLocationCallback.kt deleted file mode 100644 index 2225756ec..000000000 --- a/app/src/main/java/com/geeksville/mesh/service/MeshServiceLocationCallback.kt +++ /dev/null @@ -1,72 +0,0 @@ -package com.geeksville.mesh.service - -import android.location.Location -import android.os.RemoteException -import com.geeksville.mesh.DataPacket -import com.google.android.gms.location.LocationCallback -import com.google.android.gms.location.LocationResult - -val Location.isAccurateForMesh: Boolean get() = !this.hasAccuracy() || this.accuracy < 200 - -private fun List.filterAccurateForMesh() = filter { it.isAccurateForMesh } - -private fun LocationResult.lastLocationOrBestEffort(): Location? { - return lastLocation ?: locations.filterAccurateForMesh().lastOrNull() -} - -typealias SendPosition = (Double, Double, Int, Int, Boolean) -> Unit // Lat, Lon, alt, destNum, wantResponse -typealias OnSendFailure = () -> Unit -typealias GetNodeNum = () -> Int - -class MeshServiceLocationCallback( - private val onSendPosition: SendPosition, - private val onSendPositionFailed: OnSendFailure, - private val getNodeNum: GetNodeNum -) : LocationCallback() { - - companion object { - const val DEFAULT_SEND_RATE_LIMIT = 30 - } - - private var lastSendTimeMs: Long = 0L - - override fun onLocationResult(locationResult: LocationResult) { - super.onLocationResult(locationResult) - - locationResult.lastLocationOrBestEffort()?.let { location -> - MeshService.info("got phone location") - if (location.isAccurateForMesh) { // if within 200 meters, or accuracy is unknown - - try { - // Do we want to broadcast this position globally, or are we just telling the local node what its current position is - val shouldBroadcast = - false // no need to rate limit, because we are just sending to the local node - val destinationNumber = - if (shouldBroadcast) DataPacket.NODENUM_BROADCAST else getNodeNum() - - // Note: we never want this message sent as a reliable message, because it is low value and we'll be sending one again later anyways - sendPosition(location, destinationNumber, wantResponse = false) - - } catch (ex: RemoteException) { // Really a RadioNotConnected exception, but it has changed into this type via remoting - MeshService.warn("Lost connection to radio, stopping location requests") - onSendPositionFailed() - } catch (ex: BLEException) { // Really a RadioNotConnected exception, but it has changed into this type via remoting - MeshService.warn("BLE exception, stopping location requests $ex") - onSendPositionFailed() - } - } else { - MeshService.warn("accuracy ${location.accuracy} is too poor to use") - } - } - } - - private fun sendPosition(location: Location, destinationNumber: Int, wantResponse: Boolean) { - onSendPosition( - location.latitude, - location.longitude, - location.altitude.toInt(), - destinationNumber, - wantResponse // wantResponse? - ) - } -} diff --git a/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt index c98c6ebe6..31d0bcf12 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt @@ -826,7 +826,7 @@ class SettingsFragment : ScreenFragment("Settings"), Logging { debug("User changed location tracking to $isChecked") model.provideLocation.value = isChecked checkLocationEnabled(getString(R.string.location_disabled)) - model.meshService?.setupProvideLocation() + model.meshService?.startProvideLocation() } } else { model.provideLocation.value = isChecked