refactored MeshService

- moved MeshService start-related code to `MeshServiceStarter`
- moved `LocationCallback` to `MeshServiceLocationCallback`
  - coroutine scope is now handled in `MeshService` positio ncallback.
  - refactored `onLocationResult()` to be easier to read
- created `MeshServiceNotifications` for creating and updating of notifications
- moved `SavedSettings` to `MeshServiceSettingsData`
This commit is contained in:
Ken Van Hoeylandt 2020-10-01 22:20:19 +02:00
parent f9e39b66a4
commit 3610f0b53e
9 changed files with 434 additions and 347 deletions

View file

@ -845,7 +845,7 @@ class MainActivity : AppCompatActivity(), Logging,
}
// ALSO bind so we can use the api
mesh.connect(this, MeshService.intent, Context.BIND_AUTO_CREATE + Context.BIND_ABOVE_CLIENT)
mesh.connect(this, MeshService.createIntent(), Context.BIND_AUTO_CREATE + Context.BIND_ABOVE_CLIENT)
}
private fun unbindMeshService() {
@ -942,5 +942,3 @@ class MainActivity : AppCompatActivity(), Logging,
}
}
}

View file

@ -1,5 +1,6 @@
package com.geeksville.mesh.android
import android.app.NotificationManager
import android.bluetooth.BluetoothManager
import android.content.Context
import android.hardware.usb.UsbManager
@ -10,3 +11,5 @@ import android.hardware.usb.UsbManager
val Context.bluetoothManager: BluetoothManager? get() = getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager?
val Context.usbManager: UsbManager get() = requireNotNull(getSystemService(Context.USB_SERVICE) as? UsbManager?) { "USB_SERVICE is not available"}
val Context.notificationManager: NotificationManager get() = requireNotNull(getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager?)

View file

@ -9,6 +9,6 @@ import com.geeksville.android.Logging
class BootCompleteReceiver : BroadcastReceiver(), Logging {
override fun onReceive(mContext: Context, intent: Intent) {
// start listening for bluetooth messages from our device
MeshService.startLater(mContext)
MeshService.startServiceLater(mContext)
}
}

View file

@ -6,18 +6,11 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.graphics.Color
import android.os.Build
import android.os.IBinder
import android.os.Parcelable
import android.os.RemoteException
import android.widget.Toast
import androidx.annotation.RequiresApi
import androidx.annotation.UiThread
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationCompat.PRIORITY_MIN
import androidx.core.content.edit
import androidx.work.*
import com.geeksville.analytics.DataPair
import com.geeksville.android.GeeksvilleApplication
import com.geeksville.android.Logging
@ -38,18 +31,16 @@ import com.google.android.gms.location.*
import com.google.protobuf.ByteString
import com.google.protobuf.InvalidProtocolBufferException
import kotlinx.coroutines.*
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.util.*
import java.util.concurrent.TimeUnit
import kotlin.math.absoluteValue
/**
* Handles all the communication with android apps. Also keeps an internal model
* of the network state.
*
* Note: this service will go away once all clients are unbound from it.
* Warning: do not override toString, it causes infinite recursion on some androids (because contextWrapper.getResources calls to string
*/
class MeshService : Service(), Logging {
@ -66,23 +57,6 @@ class MeshService : Service(), Logging {
class IdNotFoundException(id: String) : Exception("ID not found $id")
class NodeNumNotFoundException(id: Int) : Exception("NodeNum not found $id")
// class NotInMeshException() : Exception("We are not yet in a mesh")
/** A little helper that just calls startService
*/
class ServiceStarter(appContext: Context, workerParams: WorkerParameters) :
Worker(appContext, workerParams) {
override fun doWork(): Result = try {
startService(this.applicationContext)
// Indicate whether the task finished successfully with the Result
Result.success()
} catch (ex: Exception) {
errormsg("failure starting service, will retry", ex)
Result.retry()
}
}
/**
* Talk to our running service and try to set a new device address. And then immediately
@ -93,53 +67,10 @@ class MeshService : Service(), Logging {
startService(context)
}
/**
* Just after boot the android OS is super busy, so if we call startForegroundService then, our
* thread might be stalled long enough to expose this google/samsung bug:
* https://issuetracker.google.com/issues/76112072#comment56
*/
fun startLater(context: Context) {
// No point in even starting the service if the user doesn't have a device bonded
info("Received boot complete announcement, starting mesh service in two minutes")
val delayRequest = OneTimeWorkRequestBuilder<ServiceStarter>()
.setInitialDelay(2, TimeUnit.MINUTES)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 2, TimeUnit.MINUTES)
.addTag("startLater")
.build()
WorkManager.getInstance(context).enqueue(delayRequest)
}
val intent = Intent().apply {
setClassName(
"com.geeksville.mesh",
"com.geeksville.mesh.service.MeshService"
)
}
/// Helper function to start running our service
fun startService(context: Context) {
// bind to our service using the same mechanism an external client would use (for testing coverage)
// The following would work for us, but not external users
//val intent = Intent(this, MeshService::class.java)
//intent.action = IMeshService::class.java.name
// Before binding we want to explicitly create - so the service stays alive forever (so it can keep
// listening for the bluetooth packets arriving from the radio. And when they arrive forward them
// to Signal or whatever.
info("Trying to start service")
val compName =
(if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
})
if (compName == null)
throw Exception("Failed to start service")
}
fun createIntent() = Intent().setClassName(
"com.geeksville.mesh",
"com.geeksville.mesh.service.MeshService"
)
}
enum class ConnectionState {
@ -150,88 +81,50 @@ class MeshService : Service(), Logging {
/// A mapping of receiver class name to package name - used for explicit broadcasts
private val clientPackages = mutableMapOf<String, String>()
val radio = ServiceClient {
val stub = IRadioInterfaceService.Stub.asInterface(it)
// Now that we are connected to the radio service, tell it to connect to the radio
stub.connect()
stub
}
private val serviceNotifications = MeshServiceNotifications(this)
private val serviceBroadcasts = MeshServiceBroadcasts(this, clientPackages) { connectionState }
private val serviceJob = Job()
private val serviceScope = CoroutineScope(Dispatchers.IO + serviceJob)
/// The current state of our connection
private var connectionState = ConnectionState.DISCONNECTED
private var packetRepo: PacketRepository? = null
/*
see com.geeksville.mesh broadcast intents
// RECEIVED_OPAQUE for data received from other nodes
// NODE_CHANGE for new IDs appearing or disappearing
// ACTION_MESH_CONNECTED for losing/gaining connection to the packet radio (note, this is not
the same as RadioInterfaceService.RADIO_CONNECTED_ACTION, because it implies we have assembled a valid
node db.
*/
private fun explicitBroadcast(intent: Intent) {
sendBroadcast(intent) // We also do a regular (not explicit broadcast) so any context-registered rceivers will work
clientPackages.forEach {
intent.setClassName(it.value, it.key)
sendBroadcast(intent)
}
}
private val locationCallback = object : LocationCallback() {
private var lastSendMsec = 0L
override fun onLocationResult(locationResult: LocationResult) {
serviceScope.handledLaunch {
super.onLocationResult(locationResult)
var l = locationResult.lastLocation
// Docs say lastLocation should always be !null if there are any locations, but that's not the case
if (l == null) {
// try to only look at the accurate locations
val locs =
locationResult.locations.filter { !it.hasAccuracy() || it.accuracy < 200 }
l = locs.lastOrNull()
}
if (l != null) {
info("got phone location")
if (l.hasAccuracy() && l.accuracy >= 200) // if more than 200 meters off we won't use it
warn("accuracy ${l.accuracy} is too poor to use")
else {
val now = System.currentTimeMillis()
// we limit our sends onto the lora net to a max one once every FIXME
val sendLora = (now - lastSendMsec >= 30 * 1000)
if (sendLora)
lastSendMsec = now
try {
sendPosition(
l.latitude, l.longitude, l.altitude.toInt(),
destNum = if (sendLora) NODENUM_BROADCAST else myNodeNum,
wantResponse = sendLora
)
} catch (ex: RemoteException) { // Really a RadioNotConnected exception, but it has changed into this type via remoting
warn("Lost connection to radio, stopping location requests")
onConnectionChanged(ConnectionState.DEVICE_SLEEP)
} catch (ex: BLEException) { // Really a RadioNotConnected exception, but it has changed into this type via remoting
warn("BLE exception, stopping location requests $ex")
onConnectionChanged(ConnectionState.DEVICE_SLEEP)
}
}
}
}
}
}
private var fusedLocationClient: FusedLocationProviderClient? = null
val radio = ServiceClient {
IRadioInterfaceService.Stub.asInterface(it).apply {
// Now that we are connected to the radio service, tell it to connect to the radio
connect()
}
}
private val locationCallback = MeshServiceLocationCallback(
::sendPositionScoped,
onSendPositionFailed = { onConnectionChanged(ConnectionState.DEVICE_SLEEP) },
getNodeNum = { myNodeNum }
)
private fun getSenderName(): String {
val recentFrom = recentReceivedTextPacket?.from // safe, immutable copy
return if (recentFrom != null) {
nodeDBbyID[recentFrom]?.user?.longName
?: recentFrom
} else {
getString(R.string.unknown_username)
}
}
/// A text message that has a arrived since the last notification update
private var recentReceivedTextPacket: DataPacket? = null
private val notificationSummary
get() = when (connectionState) {
ConnectionState.CONNECTED -> getString(R.string.connected_count).format(
numOnlineNodes,
numNodes
)
ConnectionState.DISCONNECTED -> getString(R.string.disconnected)
ConnectionState.DEVICE_SLEEP -> getString(R.string.device_sleeping)
}
private fun warnUserAboutLocation() {
Toast.makeText(
this,
@ -319,37 +212,6 @@ class MeshService : Service(), Logging {
}
}
/**
* The RECEIVED_OPAQUE:
* Payload will be a DataPacket
*/
private fun broadcastReceivedData(payload: DataPacket) {
val intent = Intent(ACTION_RECEIVED_DATA)
intent.putExtra(EXTRA_PAYLOAD, payload)
explicitBroadcast(intent)
}
private fun broadcastNodeChange(info: NodeInfo) {
debug("Broadcasting node change $info")
val intent = Intent(ACTION_NODE_CHANGE)
intent.putExtra(EXTRA_NODEINFO, info)
explicitBroadcast(intent)
}
private fun broadcastMessageStatus(p: DataPacket) {
if (p.id == 0) {
debug("Ignoring anonymous packet status")
} else {
debug("Broadcasting message status $p")
val intent = Intent(ACTION_MESSAGE_STATUS)
intent.putExtra(EXTRA_PACKET_ID, p.id)
intent.putExtra(EXTRA_STATUS, p.status as Parcelable)
explicitBroadcast(intent)
}
}
/// Safely access the radio service, if not connected an exception will be thrown
private val connectedRadio: IRadioInterfaceService
get() = (if (connectionState == ConnectionState.CONNECTED) radio.serviceP else null)
@ -372,98 +234,7 @@ class MeshService : Service(), Logging {
})
}
@RequiresApi(Build.VERSION_CODES.O)
private fun createNotificationChannel(): String {
val channelId = "my_service"
val channelName = getString(R.string.meshtastic_service_notifications)
val chan = NotificationChannel(
channelId,
channelName, NotificationManager.IMPORTANCE_HIGH
)
chan.lightColor = Color.BLUE
chan.importance = NotificationManager.IMPORTANCE_NONE
chan.lockscreenVisibility = Notification.VISIBILITY_PRIVATE
notificationManager.createNotificationChannel(chan)
return channelId
}
private val notifyId = 101
val notificationManager: NotificationManager by lazy() {
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
}
/// This must be lazy because we use Context
private val channelId: String by lazy() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
createNotificationChannel()
} else {
// If earlier version channel ID is not used
// https://developer.android.com/reference/android/support/v4/app/NotificationCompat.Builder.html#NotificationCompat.Builder(android.content.Context)
""
}
}
private val openAppIntent: PendingIntent by lazy() {
PendingIntent.getActivity(this, 0, Intent(this, MainActivity::class.java), 0)
}
/// A text message that has a arrived since the last notification update
private var recentReceivedText: DataPacket? = null
private val summaryString
get() = when (connectionState) {
ConnectionState.CONNECTED -> getString(R.string.connected_count).format(
numOnlineNodes,
numNodes
)
ConnectionState.DISCONNECTED -> getString(R.string.disconnected)
ConnectionState.DEVICE_SLEEP -> getString(R.string.device_sleeping)
}
// Note: do not override toString, it causes infinite recursion on some androids (because contextWrapper.getResources calls to string)
// override fun toString() = summaryString
/**
* Generate a new version of our notification - reflecting current app state
*/
private fun createNotification(): Notification {
val notificationBuilder = NotificationCompat.Builder(this, channelId)
val builder = notificationBuilder.setOngoing(true)
.setPriority(PRIORITY_MIN)
.setCategory(if (recentReceivedText != null) Notification.CATEGORY_SERVICE else Notification.CATEGORY_MESSAGE)
.setSmallIcon(android.R.drawable.stat_sys_data_bluetooth)
.setContentTitle(summaryString) // leave this off for now so our notification looks smaller
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setContentIntent(openAppIntent)
// FIXME, show information about the nearest node
// if(shortContent != null) builder.setContentText(shortContent)
// If a text message arrived include it with our notification
recentReceivedText?.let { packet ->
// Try to show the human name of the sender if possible
val sender = nodeDBbyID[packet.from]?.user?.longName ?: packet.from
builder.setContentText("Message from $sender")
builder.setStyle(
NotificationCompat.BigTextStyle()
.bigText(packet.bytes!!.toString(utf8))
)
}
return builder.build()
}
/**
* Update our notification with latest data
*/
private fun updateNotification() {
notificationManager.notify(notifyId, createNotification())
}
private fun updateNotification() = serviceNotifications.updateNotification(recentReceivedTextPacket, notificationSummary, getSenderName())
/**
* tell android not to kill us
@ -475,10 +246,12 @@ class MeshService : Service(), Logging {
info("Requesting foreground service=$wantForeground")
// We always start foreground because that's how our service is always started (if we didn't then android would kill us)
// but if we don't really need forground we immediately stop it.
startForeground(notifyId, createNotification())
if (!wantForeground)
// but if we don't really need foreground we immediately stop it.
val notification = serviceNotifications.createNotification(recentReceivedTextPacket, notificationSummary, getSenderName())
startForeground(serviceNotifications.notifyId, notification)
if (!wantForeground) {
stopForeground(true)
}
}
override fun onCreate() {
@ -494,9 +267,10 @@ class MeshService : Service(), Logging {
loadSettings() // Load our last known node DB
// we listen for messages from the radio receiver _before_ trying to create the service
val filter = IntentFilter()
filter.addAction(RadioInterfaceService.RECEIVE_FROMRADIO_ACTION)
filter.addAction(RadioInterfaceService.RADIO_CONNECTED_ACTION)
val filter = IntentFilter().apply {
addAction(RadioInterfaceService.RECEIVE_FROMRADIO_ACTION)
addAction(RadioInterfaceService.RADIO_CONNECTED_ACTION)
}
registerReceiver(radioInterfaceReceiver, filter)
// We in turn need to use the radiointerface service
@ -541,51 +315,22 @@ class MeshService : Service(), Logging {
serviceJob.cancel()
}
///
/// BEGINNING OF MODEL - FIXME, move elsewhere
///
/// Our saved preferences as stored on disk
@Serializable
private data class SavedSettings(
val nodeDB: Array<NodeInfo>,
val myInfo: MyNodeInfo,
val messages: Array<DataPacket>
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as SavedSettings
if (!nodeDB.contentEquals(other.nodeDB)) return false
if (myInfo != other.myInfo) return false
if (!messages.contentEquals(other.messages)) return false
return true
}
override fun hashCode(): Int {
var result = nodeDB.contentHashCode()
result = 31 * result + myInfo.hashCode()
result = 31 * result + messages.contentHashCode()
return result
}
}
private fun getPrefs() = getSharedPreferences("service-prefs", Context.MODE_PRIVATE)
/// Save information about our mesh to disk, so we will have it when we next start the service (even before we hear from our device)
private fun saveSettings() {
myNodeInfo?.let { myInfo ->
val settings = SavedSettings(
val settings = MeshServiceSettingsData(
myInfo = myInfo,
nodeDB = nodeDBbyNodeNum.values.toTypedArray(),
messages = recentDataPackets.toTypedArray()
)
val json = Json { isLenient = true }
val asString = json.encodeToString(SavedSettings.serializer(), settings)
val asString = json.encodeToString(MeshServiceSettingsData.serializer(), settings)
debug("Saving settings")
getPrefs().edit(commit = true) {
// FIXME, not really ideal to store this bigish blob in preferences
@ -594,9 +339,6 @@ class MeshService : Service(), Logging {
}
}
/**
* Install a new node DB
*/
private fun installNewNodeDB(newMyNodeInfo: MyNodeInfo, nodes: Array<NodeInfo>) {
discardNodeDB() // Get rid of any old state
@ -614,13 +356,12 @@ class MeshService : Service(), Logging {
})
}
/// Load our saved DB state
private fun loadSettings() {
try {
getPrefs().getString("json", null)?.let { asString ->
val json = Json { isLenient = true }
val settings = json.decodeFromString(SavedSettings.serializer(), asString)
val settings = json.decodeFromString(MeshServiceSettingsData.serializer(), asString)
installNewNodeDB(settings.myInfo, settings.nodeDB)
// Note: we do not haveNodeDB = true because that means we've got a valid db from a real device (rather than this possibly stale hint)
@ -685,7 +426,6 @@ class MeshService : Service(), Logging {
id
)
private val numNodes get() = nodeDBbyNodeNum.size
/**
@ -711,7 +451,7 @@ class MeshService : Service(), Logging {
nodeDBbyID[userId] = info
// parcelable is busted
broadcastNodeChange(info)
serviceBroadcasts.broadcastNodeChange(info)
}
/// My node num
@ -848,9 +588,9 @@ class MeshService : Service(), Logging {
MeshProtos.Data.Type.CLEAR_TEXT_VALUE -> {
debug("Received CLEAR_TEXT from $fromId")
recentReceivedText = dataPacket
recentReceivedTextPacket = dataPacket
updateNotification()
broadcastReceivedData(dataPacket)
serviceBroadcasts.broadcastReceivedData(dataPacket)
}
MeshProtos.Data.Type.CLEAR_READACK_VALUE ->
@ -859,7 +599,7 @@ class MeshService : Service(), Logging {
)
MeshProtos.Data.Type.OPAQUE_VALUE ->
broadcastReceivedData(dataPacket)
serviceBroadcasts.broadcastReceivedData(dataPacket)
else -> TODO()
}
@ -939,7 +679,7 @@ class MeshService : Service(), Logging {
offlineSentPackets.forEach { p ->
// encapsulate our payload in the proper protobufs and fire it off
sendNow(p)
broadcastMessageStatus(p)
serviceBroadcasts.broadcastMessageStatus(p)
}
offlineSentPackets.clear()
}
@ -949,7 +689,7 @@ class MeshService : Service(), Logging {
*/
private fun changeStatus(p: DataPacket, m: MessageStatus) {
p.status = m
broadcastMessageStatus(p)
serviceBroadcasts.broadcastMessageStatus(p)
}
/**
@ -1099,7 +839,7 @@ class MeshService : Service(), Logging {
}
// broadcast an intent with our new connection state
broadcastConnection()
serviceBroadcasts.broadcastConnection()
}
fun startDisconnect() {
@ -1114,7 +854,7 @@ class MeshService : Service(), Logging {
GeeksvilleApplication.analytics.track("num_nodes", DataPair(numNodes))
// broadcast an intent with our new connection state
broadcastConnection()
serviceBroadcasts.broadcastConnection()
}
fun startConnect() {
@ -1161,18 +901,6 @@ class MeshService : Service(), Logging {
updateNotification()
}
/**
* broadcast our current connection status
*/
private fun broadcastConnection() {
val intent = Intent(ACTION_MESH_CONNECTED)
intent.putExtra(
EXTRA_CONNECTED,
connectionState.toString()
)
explicitBroadcast(intent)
}
/**
* Receives messages from our BT radio service and processes them to update our model
* and send to clients as needed.
@ -1356,7 +1084,7 @@ class MeshService : Service(), Logging {
processEarlyPackets() // send receive any packets that were queued up
// broadcast an intent with our new connection state
broadcastConnection()
serviceBroadcasts.broadcastConnection()
onNodeDBChanged()
reportConnection()
}
@ -1378,7 +1106,10 @@ 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(
lat: Double,
lon: Double,
@ -1411,6 +1142,16 @@ class MeshService : Service(), Logging {
sendToRadio(packet.build())
}
private fun sendPositionScoped(
lat: Double,
lon: Double,
alt: Int,
destNum: Int = NODENUM_BROADCAST,
wantResponse: Boolean = false
) = serviceScope.handledLaunch {
sendPosition(lat, lon, alt, destNum, wantResponse)
}
/** Set our radio config either with the new or old API
*/
private fun setRadioConfig(payload: ByteArray) {
@ -1599,9 +1340,7 @@ class MeshService : Service(), Logging {
this@MeshService.setOwner(myId, longName, shortName)
}
override fun send(
p: DataPacket
) {
override fun send(p: DataPacket) {
toRemoteExceptions {
// Init from and id
myNodeID?.let { myId ->
@ -1673,7 +1412,7 @@ class MeshService : Service(), Logging {
}
}
public fun updateNodeInfoTime(it: NodeInfo, rxTime: Int) {
fun updateNodeInfoTime(it: NodeInfo, rxTime: Int) {
if (it.position?.time == null || it.position?.time!! < rxTime)
it.position = it.position?.copy(time = rxTime)
}
}

View file

@ -0,0 +1,69 @@
package com.geeksville.mesh.service
import android.content.Context
import android.content.Intent
import android.os.Parcelable
import com.geeksville.mesh.DataPacket
import com.geeksville.mesh.NodeInfo
class MeshServiceBroadcasts(
private val context: Context,
private val clientPackages: MutableMap<String, String>,
private val getConnectionState: () -> MeshService.ConnectionState
) {
/**
* The RECEIVED_OPAQUE:
* Payload will be a DataPacket
*/
fun broadcastReceivedData(payload: DataPacket) {
val intent = Intent(MeshService.ACTION_RECEIVED_DATA).putExtra(EXTRA_PAYLOAD, payload)
explicitBroadcast(intent)
}
fun broadcastNodeChange(info: NodeInfo) {
MeshService.debug("Broadcasting node change $info")
val intent = Intent(MeshService.ACTION_NODE_CHANGE).putExtra(EXTRA_NODEINFO, info)
explicitBroadcast(intent)
}
fun broadcastMessageStatus(p: DataPacket) {
if (p.id == 0) {
MeshService.debug("Ignoring anonymous packet status")
} else {
MeshService.debug("Broadcasting message status $p")
val intent = Intent(MeshService.ACTION_MESSAGE_STATUS).apply {
putExtra(EXTRA_PACKET_ID, p.id)
putExtra(EXTRA_STATUS, p.status as Parcelable)
}
explicitBroadcast(intent)
}
}
/**
* Broadcast our current connection status
*/
fun broadcastConnection() {
val intent = Intent(MeshService.ACTION_MESH_CONNECTED).putExtra(
EXTRA_CONNECTED,
getConnectionState().toString()
)
explicitBroadcast(intent)
}
/**
* See com.geeksville.mesh broadcast intents.
*
* RECEIVED_OPAQUE for data received from other nodes
* NODE_CHANGE for new IDs appearing or disappearing
* ACTION_MESH_CONNECTED for losing/gaining connection to the packet radio
* Note: this is not the same as RadioInterfaceService.RADIO_CONNECTED_ACTION,
* because it implies we have assembled a valid node db.
*/
private fun explicitBroadcast(intent: Intent) {
context.sendBroadcast(intent) // We also do a regular (not explicit broadcast) so any context-registered rceivers will work
clientPackages.forEach {
intent.setClassName(it.value, it.key)
context.sendBroadcast(intent)
}
}
}

View file

@ -0,0 +1,78 @@
package com.geeksville.mesh.service
import android.location.Location
import android.os.RemoteException
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,
private val sendRateLimitInSeconds: Int = DEFAULT_SEND_RATE_LIMIT
) : 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
val shouldSend = isAllowedToSend()
val destinationNumber = if (shouldSend) MeshService.NODENUM_BROADCAST else getNodeNum()
sendPosition(location, destinationNumber, wantResponse = shouldSend)
} else {
MeshService.warn("accuracy ${location.accuracy} is too poor to use")
}
}
}
private fun sendPosition(location: Location, destinationNumber: Int, wantResponse: Boolean) {
try {
onSendPosition(
location.latitude,
location.longitude,
location.altitude.toInt(),
destinationNumber,
wantResponse // wantResponse?
)
} 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()
}
}
/**
* Rate limiting function.
*/
private fun isAllowedToSend(): Boolean {
val now = System.currentTimeMillis()
// we limit our sends onto the lora net to a max one once every FIXME
val sendLora = (now - lastSendTimeMs >= sendRateLimitInSeconds * 1000)
if (sendLora) {
lastSendTimeMs = now
}
return sendLora
}
}

View file

@ -0,0 +1,96 @@
package com.geeksville.mesh.service
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.graphics.Color
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat
import com.geeksville.mesh.DataPacket
import com.geeksville.mesh.MainActivity
import com.geeksville.mesh.R
import com.geeksville.mesh.android.notificationManager
import com.geeksville.mesh.utf8
class MeshServiceNotifications(
private val context: Context
) {
private val notificationManager: NotificationManager get() = context.notificationManager
val notifyId = 101
@RequiresApi(Build.VERSION_CODES.O)
private fun createNotificationChannel(): String {
val channelId = "my_service"
val channelName = context.getString(R.string.meshtastic_service_notifications)
val channel = NotificationChannel(channelId, channelName, NotificationManager.IMPORTANCE_HIGH).apply {
lightColor = Color.BLUE
importance = NotificationManager.IMPORTANCE_NONE
lockscreenVisibility = Notification.VISIBILITY_PRIVATE
}
notificationManager.createNotificationChannel(channel)
return channelId
}
private val channelId: String by lazy {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
createNotificationChannel()
} else {
// If earlier version channel ID is not used
// https://developer.android.com/reference/android/support/v4/app/NotificationCompat.Builder.html#NotificationCompat.Builder(android.content.Context)
""
}
}
/**
* Update our notification with latest data
*/
fun updateNotification(
recentReceivedText: DataPacket?,
summaryString: String,
senderName: String
) {
val notification = createNotification(recentReceivedText, summaryString, senderName)
notificationManager.notify(notifyId, notification)
}
private val openAppIntent: PendingIntent by lazy {
PendingIntent.getActivity(context, 0, Intent(context, MainActivity::class.java), 0)
}
/**
* Generate a new version of our notification - reflecting current app state
*/
fun createNotification(
recentReceivedText: DataPacket?,
summaryString: String,
senderName: String
): Notification {
val category = if (recentReceivedText != null) Notification.CATEGORY_SERVICE else Notification.CATEGORY_MESSAGE
val builder = NotificationCompat.Builder(context, channelId).setOngoing(true)
.setPriority(NotificationCompat.PRIORITY_MIN)
.setCategory(category)
.setSmallIcon(android.R.drawable.stat_sys_data_bluetooth)
.setContentTitle(summaryString) // leave this off for now so our notification looks smaller
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setContentIntent(openAppIntent)
// FIXME, show information about the nearest node
// if(shortContent != null) builder.setContentText(shortContent)
// If a text message arrived include it with our notification
recentReceivedText?.let { packet ->
// Try to show the human name of the sender if possible
builder.setContentText("Message from $senderName")
builder.setStyle(
NotificationCompat.BigTextStyle()
.bigText(packet.bytes!!.toString(utf8))
)
}
return builder.build()
}
}

View file

@ -0,0 +1,34 @@
package com.geeksville.mesh.service
import com.geeksville.mesh.DataPacket
import com.geeksville.mesh.MyNodeInfo
import com.geeksville.mesh.NodeInfo
import kotlinx.serialization.Serializable
/// Our saved preferences as stored on disk
@Serializable
data class MeshServiceSettingsData(
val nodeDB: Array<NodeInfo>,
val myInfo: MyNodeInfo,
val messages: Array<DataPacket>
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as MeshServiceSettingsData
if (!nodeDB.contentEquals(other.nodeDB)) return false
if (myInfo != other.myInfo) return false
if (!messages.contentEquals(other.messages)) return false
return true
}
override fun hashCode(): Int {
var result = nodeDB.contentHashCode()
result = 31 * result + myInfo.hashCode()
result = 31 * result + messages.contentHashCode()
return result
}
}

View file

@ -0,0 +1,70 @@
package com.geeksville.mesh.service
import android.content.ComponentName
import android.content.Context
import android.os.Build
import androidx.work.BackoffPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.Worker
import androidx.work.WorkerParameters
import java.util.concurrent.TimeUnit
/**
* Helper that calls MeshService.startService()
*/
private class ServiceStarter(
appContext: Context,
workerParams: WorkerParameters
) : Worker(appContext, workerParams) {
override fun doWork(): Result = try {
MeshService.startService(this.applicationContext)
// Indicate whether the task finished successfully with the Result
Result.success()
} catch (ex: Exception) {
MeshService.errormsg("failure starting service, will retry", ex)
Result.retry()
}
}
private fun Context.startMeshService(): ComponentName? {
val intent = MeshService.createIntent()
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(intent)
} else {
startService(intent)
}
}
/**
* Just after boot the android OS is super busy, so if we call startForegroundService then, our
* thread might be stalled long enough to expose this Google/Samsung bug:
* https://issuetracker.google.com/issues/76112072#comment56
*/
fun MeshService.Companion.startServiceLater(context: Context) {
// No point in even starting the service if the user doesn't have a device bonded
info("Received boot complete announcement, starting mesh service in two minutes")
val delayRequest = OneTimeWorkRequestBuilder<ServiceStarter>()
.setInitialDelay(2, TimeUnit.MINUTES)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 2, TimeUnit.MINUTES)
.addTag("startLater")
.build()
WorkManager.getInstance(context).enqueue(delayRequest)
}
/// Helper function to start running our service
fun MeshService.Companion.startService(context: Context) {
// Bind to our service using the same mechanism an external client would use (for testing coverage)
// The following would work for us, but not external users:
// val intent = Intent(this, MeshService::class.java)
// intent.action = IMeshService::class.java.name
// Before binding we want to explicitly create - so the service stays alive forever (so it can keep
// listening for the bluetooth packets arriving from the radio. And when they arrive forward them
// to Signal or whatever.
info("Trying to start service")
requireNotNull(context.startMeshService()) { "Failed to start service" }
}