move location service to repository

pull/433/head
andrekir 2022-05-20 09:13:59 -03:00
rodzic d50e9e1644
commit 6bda993851
8 zmienionych plików z 160 dodań i 222 usunięć

Wyświetl plik

@ -120,7 +120,7 @@ interface IMeshService {
void setRegion(int regionCode); void setRegion(int regionCode);
/// Start providing location (from phone GPS) to mesh /// Start providing location (from phone GPS) to mesh
void setupProvideLocation(); void startProvideLocation();
/// Stop providing location (from phone GPS) to mesh /// Stop providing location (from phone GPS) to mesh
void stopProvideLocation(); void stopProvideLocation();

Wyświetl plik

@ -683,7 +683,7 @@ class MainActivity : BaseActivity(), Logging,
} }
// if provideLocation enabled: Start providing location (from phone GPS) to mesh // if provideLocation enabled: Start providing location (from phone GPS) to mesh
if (model.provideLocation.value == true) if (model.provideLocation.value == true)
service.setupProvideLocation() service.startProvideLocation()
} }
} else { } else {
// For other connection states, just slam them in // For other connection states, just slam them in

Wyświetl plik

@ -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<Boolean> = sharedLocationManager.receivingLocationUpdates
/**
* Observable flow for location updates
*/
fun getLocations() = sharedLocationManager.locationFlow()
}

Wyświetl plik

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

Wyświetl plik

@ -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<Boolean> = MutableStateFlow(false)
val receivingLocationUpdates: StateFlow<Boolean> 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<Location> {
return _locationUpdates
}
}

Wyświetl plik

@ -1,14 +1,10 @@
package com.geeksville.mesh.service package com.geeksville.mesh.service
import android.annotation.SuppressLint
import android.app.Service import android.app.Service
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.IBinder import android.os.IBinder
import android.os.Looper
import android.os.RemoteException import android.os.RemoteException
import android.widget.Toast
import androidx.annotation.UiThread
import androidx.core.content.edit import androidx.core.content.edit
import com.geeksville.analytics.DataPair import com.geeksville.analytics.DataPair
import com.geeksville.android.GeeksvilleApplication 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.PacketRepository
import com.geeksville.mesh.database.entity.Packet import com.geeksville.mesh.database.entity.Packet
import com.geeksville.mesh.model.DeviceVersion 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.BluetoothInterface
import com.geeksville.mesh.repository.radio.RadioInterfaceService import com.geeksville.mesh.repository.radio.RadioInterfaceService
import com.geeksville.mesh.repository.radio.RadioServiceConnectionState import com.geeksville.mesh.repository.radio.RadioServiceConnectionState
import com.geeksville.mesh.repository.usb.UsbRepository import com.geeksville.mesh.repository.usb.UsbRepository
import com.geeksville.util.* 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.ByteString
import com.google.protobuf.InvalidProtocolBufferException import com.google.protobuf.InvalidProtocolBufferException
import dagger.Lazy import dagger.Lazy
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import java.util.* import java.util.*
import javax.inject.Inject import javax.inject.Inject
@ -65,6 +58,9 @@ class MeshService : Service(), Logging {
@Inject @Inject
lateinit var usbRepository: Lazy<UsbRepository> lateinit var usbRepository: Lazy<UsbRepository>
@Inject
lateinit var locationRepository: LocationRepository
companion object : Logging { companion object : Logging {
/// Intents broadcast by MeshService /// Intents broadcast by MeshService
@ -133,17 +129,11 @@ class MeshService : Service(), Logging {
private val serviceScope = CoroutineScope(Dispatchers.IO + serviceJob) private val serviceScope = CoroutineScope(Dispatchers.IO + serviceJob)
private var connectionState = ConnectionState.DISCONNECTED 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 // If we've ever read a valid region code from our device it will be here
var curRegionValue = RadioConfigProtos.RegionCode.Unset_VALUE var curRegionValue = RadioConfigProtos.RegionCode.Unset_VALUE
private val locationCallback = MeshServiceLocationCallback(
::sendPositionScoped,
onSendPositionFailed = { onConnectionChanged(ConnectionState.DEVICE_SLEEP) },
getNodeNum = { myNodeNum }
)
private fun getSenderName(packet: DataPacket?): String { private fun getSenderName(packet: DataPacket?): String {
val name = nodeDBbyID[packet?.from]?.user?.longName val name = nodeDBbyID[packet?.from]?.user?.longName
return name ?: "Unknown username" return name ?: "Unknown username"
@ -159,109 +149,32 @@ class MeshService : Service(), Logging {
ConnectionState.DEVICE_SLEEP -> getString(R.string.device_sleeping) 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) * 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") private fun startLocationRequests() {
@UiThread // If we're already observing updates, don't register again
private fun startLocationRequests(requestInterval: Long) { if (locationFlow?.isActive == true) return
// 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
locationIntervalMsec = requestInterval if (hasBackgroundPermission() && isGooglePlayAvailable(this)) {
val request = LocationRequest.create().apply { locationFlow = locationRepository.getLocations()
interval = requestInterval .onEach { location ->
priority = LocationRequest.PRIORITY_HIGH_ACCURACY sendPosition(
} location.latitude,
val builder = LocationSettingsRequest.Builder().addLocationRequest(request) location.longitude,
val locationClient = LocationServices.getSettingsClient(this) location.altitude.toInt(),
val locationSettingsResponse = locationClient.checkLocationSettings(builder.build()) myNodeNum, // we just send to the local node
false // and we never want ACKs
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)
} }
} .launchIn(CoroutineScope(Dispatchers.Default))
val client = LocationServices.getFusedLocationProviderClient(this)
client.requestLocationUpdates(request, locationCallback, Looper.getMainLooper())
fusedLocationClient = client
} }
} }
private fun stopLocationRequests() { private fun stopLocationRequests() {
if (fusedLocationClient != null) { if (locationFlow?.isActive == true) {
debug("Stopping location requests") debug("Stopping location requests")
GeeksvilleApplication.analytics.track("location_stop") locationFlow?.cancel()
fusedLocationClient?.removeLocationUpdates(locationCallback)
fusedLocationClient = null
} }
} }
@ -1022,39 +935,6 @@ class MeshService : Service(), Logging {
maybeUpdateServiceStatusNotification() 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 * 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) // Just in case the user uncleanly reboots the phone, save now (we normally save in onDestroy)
saveSettings() saveSettings()
// lost radio connection, therefore no need to keep listening to GPS
stopLocationRequests()
GeeksvilleApplication.analytics.track( GeeksvilleApplication.analytics.track(
"mesh_disconnect", "mesh_disconnect",
DataPair("num_nodes", numNodes), DataPair("num_nodes", numNodes),
@ -1518,7 +1401,6 @@ class MeshService : Service(), Logging {
/** /**
* Send a position (typically from our built in GPS) into the mesh. * 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( private fun sendPosition(
lat: Double = 0.0, lat: Double = 0.0,
@ -1843,14 +1725,13 @@ class MeshService : Service(), Logging {
r.toString() r.toString()
} }
override fun setupProvideLocation() = toRemoteExceptions { override fun startProvideLocation() = toRemoteExceptions {
setupLocationRequests() startLocationRequests()
} }
override fun stopProvideLocation() = toRemoteExceptions { override fun stopProvideLocation() = toRemoteExceptions {
stopLocationRequests() stopLocationRequests()
} }
} }
} }

Wyświetl plik

@ -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<Location>.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?
)
}
}

Wyświetl plik

@ -826,7 +826,7 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
debug("User changed location tracking to $isChecked") debug("User changed location tracking to $isChecked")
model.provideLocation.value = isChecked model.provideLocation.value = isChecked
checkLocationEnabled(getString(R.string.location_disabled)) checkLocationEnabled(getString(R.string.location_disabled))
model.meshService?.setupProvideLocation() model.meshService?.startProvideLocation()
} }
} else { } else {
model.provideLocation.value = isChecked model.provideLocation.value = isChecked