diff --git a/app/src/main/aidl/com/geeksville/mesh/IMeshService.aidl b/app/src/main/aidl/com/geeksville/mesh/IMeshService.aidl index 62767952..fd4e297e 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 c1a1fe84..b4d0215b 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 00000000..4d20c703 --- /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 00000000..0ad5b617 --- /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 00000000..1679f36a --- /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 2341788e..b40c0cb3 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,23 +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.mesh.service.SoftwareUpdateService.Companion.ProgressNotStarted 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 @@ -66,6 +58,9 @@ class MeshService : Service(), Logging { @Inject lateinit var usbRepository: Lazy + @Inject + lateinit var locationRepository: LocationRepository + companion object : Logging { /// Intents broadcast by MeshService @@ -134,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" @@ -160,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() } } @@ -493,7 +405,7 @@ class MeshService : Service(), Logging { /// given a nodenum, return a db entry - creating if necessary private fun getOrCreateNodeInfo(n: Int) = - nodeDBbyNodeNum.getOrPut(n) { -> NodeInfo(n) } + nodeDBbyNodeNum.getOrPut(n) { NodeInfo(n) } private val hexIdRegex = """\!([0-9A-Fa-f]+)""".toRegex() @@ -605,7 +517,7 @@ class MeshService : Service(), Logging { /** * Helper to make it easy to build a subpacket in the proper protobufs */ - private fun MeshProtos.MeshPacket.Builder.buildMeshPacket( + private fun MeshPacket.Builder.buildMeshPacket( wantAck: Boolean = false, id: Int = generatePacketId(), // always assign a packet ID if we didn't already have one hopLimit: Int = 0, @@ -627,7 +539,7 @@ class MeshService : Service(), Logging { /** * Helper to make it easy to build a subpacket in the proper protobufs */ - private fun MeshProtos.MeshPacket.Builder.buildAdminPacket( + private fun MeshPacket.Builder.buildAdminPacket( wantResponse: Boolean = false, initFn: AdminProtos.AdminMessage.Builder.() -> Unit ): MeshPacket = buildMeshPacket( @@ -858,7 +770,7 @@ class MeshService : Service(), Logging { updateNodeInfo(fromNum) { val oldId = it.user?.id.orEmpty() it.user = MeshUser( - if (p.id.isNotEmpty()) p.id else oldId, // If the new update doesn't contain an ID keep our old value + p.id.ifEmpty { oldId }, // If the new update doesn't contain an ID keep our old value p.longName, p.shortName, p.hwModel @@ -1023,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 */ @@ -1127,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), @@ -1144,7 +1026,7 @@ class MeshService : Service(), Logging { connectTimeMsec = System.currentTimeMillis() SoftwareUpdateService.sendProgress( this, - ProgressNotStarted, + SoftwareUpdateService.ProgressNotStarted, true ) // Kinda crufty way of reiniting software update startConfig() @@ -1317,7 +1199,7 @@ class MeshService : Service(), Logging { this@MeshService, DeviceVersion(firmwareVersion) ), - currentPacketId.toLong() and 0xffffffffL, + currentPacketId and 0xffffffffL, if (messageTimeoutMsec == 0) 5 * 60 * 1000 else messageTimeoutMsec, // constants from current device code minAppVersion, maxChannels, @@ -1519,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, @@ -1545,7 +1426,7 @@ class MeshService : Service(), Logging { handleReceivedPosition(mi.myNodeNum, position) val fullPacket = - newMeshPacketTo(destNum).buildMeshPacket(priority = MeshProtos.MeshPacket.Priority.BACKGROUND) { + newMeshPacketTo(destNum).buildMeshPacket(priority = MeshPacket.Priority.BACKGROUND) { // Use the new position as data format portnumValue = Portnums.PortNum.POSITION_APP_VALUE payload = position.toByteString() @@ -1626,7 +1507,7 @@ class MeshService : Service(), Logging { @Synchronized private fun generatePacketId(): Int { val numPacketIds = - ((1L shl 32) - 1).toLong() // A mask for only the valid packet ID bits, either 255 or maxint + ((1L shl 32) - 1) // A mask for only the valid packet ID bits, either 255 or maxint currentPacketId++ @@ -1766,14 +1647,7 @@ class MeshService : Service(), Logging { override fun send(p: DataPacket) { toRemoteExceptions { // Init from and id - myNodeID?.let { myId -> - // we no longer set from, we let the device do it - //if (p.from == DataPacket.ID_LOCAL) - // p.from = myId - - if (p.id == 0) - p.id = generatePacketId() - } + myNodeID?.let { if (p.id == 0) p.id = generatePacketId() } info("sendData dest=${p.to}, id=${p.id} <- ${p.bytes!!.size} bytes (connectionState=$connectionState)") @@ -1851,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 2225756e..00000000 --- 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 c98c6ebe..31d0bcf1 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