Meshtastic-Android/app/src/main/java/com/geeksville/mesh/MeshService.kt
2020-02-09 10:18:26 -08:00

533 lines
No EOL
19 KiB
Kotlin

package com.geeksville.mesh
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.*
import android.graphics.Color
import android.os.Build
import android.os.IBinder
import android.os.Parcelable
import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationCompat.PRIORITY_MIN
import com.geeksville.android.Logging
import com.geeksville.mesh.MeshProtos.MeshPacket
import com.geeksville.mesh.MeshProtos.ToRadio
import com.geeksville.util.exceptionReporter
import com.geeksville.util.toOneLineString
import com.geeksville.util.toRemoteExceptions
import com.google.protobuf.ByteString
import kotlinx.android.parcel.Parcelize
import java.nio.charset.Charset
class RadioNotConnectedException() : Exception("Can't find radio")
// model objects that directly map to the corresponding protobufs
@Parcelize
data class MeshUser(val id: String, val longName: String, val shortName: String) :
Parcelable
@Parcelize
data class Position(val latitude: Double, val longitude: Double, val altitude: Int) :
Parcelable
@Parcelize
data class NodeInfo(
val num: Int, // This is immutable, and used as a key
var user: MeshUser? = null,
var position: Position? = null,
var lastSeen: Long? = null
) : Parcelable
/**
* 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.
*/
class MeshService : Service(), Logging {
companion object {
/// Intents broadcast by MeshService
const val ACTION_RECEIVED_DATA = "$prefix.RECEIVED_DATA"
const val ACTION_NODE_CHANGE = "$prefix.NODE_CHANGE"
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")
/// If we haven't yet received a node number from the radio
private const val NODE_NUM_UNKNOWN = -2
/// If the radio hasn't yet joined a mesh (i.e. no nodenum assigned)
private const val NODE_NUM_NO_MESH = -1
}
/// A mapping of receiver class name to package name - used for explicit broadcasts
private val clientPackages = mutableMapOf<String, String>()
private var radioService: IRadioInterfaceService? = null
/*
see com.geeksville.mesh broadcast intents
// RECEIVED_OPAQUE for data received from other nodes
// NODE_CHANGE for new IDs appearing or disappearing
// CONNECTION_CHANGED for losing/gaining connection to the packet radio
*/
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)
}
}
/**
* The RECEIVED_OPAQUE:
* Payload will be the raw bytes which were contained within a MeshPacket.Opaque field
* Sender will be a user ID string
* Type will be the Data.Type enum code for this payload
*/
private fun broadcastReceivedData(senderId: String, payload: ByteArray, typ: Int) {
val intent = Intent(ACTION_RECEIVED_DATA)
intent.putExtra(EXTRA_SENDER, senderId)
intent.putExtra(EXTRA_PAYLOAD, payload)
intent.putExtra(EXTRA_TYP, typ)
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)
}
/// Safely access the radio service, if not connected an exception will be thrown
private val connectedRadio: IRadioInterfaceService
get() {
val s = radioService
if (s == null || !isConnected)
throw RadioNotConnectedException()
return s
}
/// Send a command/packet to our radio. But cope with the possiblity that we might start up
/// before we are fully bound to the RadioInterfaceService
private fun sendToRadio(p: ToRadio.Builder) {
val b = p.build().toByteArray()
connectedRadio.sendToRadio(b)
}
override fun onBind(intent: Intent?): IBinder? {
return binder
}
private val radioConnection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
val m = IRadioInterfaceService.Stub.asInterface(service)
radioService = m
}
override fun onServiceDisconnected(name: ComponentName?) {
radioService = null
}
}
@RequiresApi(Build.VERSION_CODES.O)
private fun createNotificationChannel(): String {
val channelId = "my_service"
val channelName = "My Background Service"
val chan = NotificationChannel(
channelId,
channelName, NotificationManager.IMPORTANCE_HIGH
)
chan.lightColor = Color.BLUE
chan.importance = NotificationManager.IMPORTANCE_NONE
chan.lockscreenVisibility = Notification.VISIBILITY_PRIVATE
val service = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
service.createNotificationChannel(chan)
return channelId
}
private fun startForeground() {
// val service = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val channelId =
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)
""
}
val notificationBuilder = NotificationCompat.Builder(this, channelId)
val notification = notificationBuilder.setOngoing(true)
.setPriority(PRIORITY_MIN)
.setCategory(Notification.CATEGORY_SERVICE)
.setSmallIcon(android.R.drawable.stat_sys_data_bluetooth)
//.setContentTitle("Meshtastic") // leave this off for now so our notification looks smaller
//.setContentText("Listening for mesh...")
.build()
startForeground(101, notification)
}
override fun onCreate() {
super.onCreate()
info("Creating mesh service")
/*
// This intent will be used if the user clicks on the item in the status bar
val notificationIntent = Intent(this, MainActivity::class.java)
val pendingIntent = PendingIntent.getActivity(
this, 0,
notificationIntent, 0
)
val notification: Notification = NotificationCompat.Builder(this)
.setSmallIcon(android.R.drawable.stat_sys_data_bluetooth)
.setContentTitle("Meshtastic")
.setContentText("Listening for mesh...")
.setContentIntent(pendingIntent).build()
// We are required to call this within a few seconds of create
startForeground(1337, notification)
*/
startForeground()
// 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.CONNECTCHANGED_ACTION)
registerReceiver(radioInterfaceReceiver, filter)
// We in turn need to use the radiointerface service
val intent = Intent(this, RadioInterfaceService::class.java)
// intent.action = IMeshService::class.java.name
logAssert(bindService(intent, radioConnection, Context.BIND_AUTO_CREATE))
// the rest of our init will happen once we are in radioConnection.onServiceConnected
}
override fun onDestroy() {
info("Destroying mesh service")
unregisterReceiver(radioInterfaceReceiver)
unbindService(radioConnection)
radioService = null
super.onDestroy()
}
///
/// BEGINNING OF MODEL - FIXME, move elsewhere
///
/// Is our radio connected to the phone?
private var isConnected = false
/// We learn this from the node db sent by the device - it is stable for the entire session
private var ourNodeNum = NODE_NUM_UNKNOWN
// The database of active nodes, index is the node number
private val nodeDBbyNodeNum = mutableMapOf<Int, NodeInfo>()
/// The database of active nodes, index is the node user ID string
/// NOTE: some NodeInfos might be in only nodeDBbyNodeNum (because we don't yet know
/// an ID). But if a NodeInfo is in both maps, it must be one instance shared by
/// both datastructures.
private val nodeDBbyID = mutableMapOf<String, NodeInfo>()
///
/// END OF MODEL
///
/// Map a nodenum to a node, or throw an exception if not found
private fun toNodeInfo(n: Int) = nodeDBbyNodeNum[n] ?: throw NodeNumNotFoundException(n)
/// Map a nodenum to the nodeid string, or throw an exception if not present
private fun toNodeID(n: Int) = toNodeInfo(n).user?.id
/// given a nodenum, return a db entry - creating if necessary
private fun getOrCreateNodeInfo(n: Int) =
nodeDBbyNodeNum.getOrPut(n) { -> NodeInfo(n) }
/// Map a userid to a node/ node num, or throw an exception if not found
private fun toNodeInfo(id: String) =
nodeDBbyID[id]
?: throw IdNotFoundException(id)
// ?: getOrCreateNodeInfo(10) // FIXME hack for now - throw IdNotFoundException(id)
private fun toNodeNum(id: String) = toNodeInfo(id).num
/// A helper function that makes it easy to update node info objects
private fun updateNodeInfo(nodeNum: Int, updatefn: (NodeInfo) -> Unit) {
val info = getOrCreateNodeInfo(nodeNum)
updatefn(info)
// This might have been the first time we know an ID for this node, so also update the by ID map
val userId = info.user?.id
if (userId != null)
nodeDBbyID[userId] = info
// parcelable is busted
// broadcastNodeChange(info)
}
/// Generate a new mesh packet builder with our node as the sender, and the specified node num
private fun newMeshPacketTo(idNum: Int) = MeshPacket.newBuilder().apply {
from = ourNodeNum
if (from == NODE_NUM_NO_MESH)
throw NotInMeshException()
else if (from == NODE_NUM_UNKNOWN)
throw RadioNotConnectedException()
to = idNum
}
/// Generate a new mesh packet builder with our node as the sender, and the specified recipient
private fun newMeshPacketTo(id: String) = newMeshPacketTo(toNodeNum(id))
// Helper to make it easy to build a subpacket in the proper protobufs
private fun buildMeshPacket(
destId: String,
initFn: MeshProtos.SubPacket.Builder.() -> Unit
): MeshPacket = newMeshPacketTo(destId).apply {
payload = MeshProtos.SubPacket.newBuilder().also {
initFn(it)
}.build()
}.build()
/// Update our model and resend as needed for a MeshPacket we just received from the radio
private fun handleReceivedData(fromNum: Int, data: MeshProtos.Data) {
val bytes = data.payload.toByteArray()
val fromId = toNodeID(fromNum)
/// the sending node ID if possible, else just its number
val fromString = fromId ?: fromId.toString()
fun forwardData() {
if (fromId == null)
warn("Ignoring data from $fromNum because we don't yet know its ID")
else {
debug("Received data from $fromId ${bytes.size}")
broadcastReceivedData(fromId, bytes, data.typValue)
}
}
when (data.typValue) {
MeshProtos.Data.Type.CLEAR_TEXT_VALUE -> {
debug(
"FIXME - don't long this: Received CLEAR_TEXT from $fromString: ${bytes.toString(
Charset.forName("UTF-8")
)}"
)
forwardData()
}
MeshProtos.Data.Type.CLEAR_READACK_VALUE ->
warn(
"TODO ignoring CLEAR_READACK from $fromString"
)
MeshProtos.Data.Type.SIGNAL_OPAQUE_VALUE ->
forwardData()
else -> TODO()
}
}
/// Update our DB of users based on someone sending out a User subpacket
private fun handleReceivedUser(fromNum: Int, p: MeshProtos.User) {
updateNodeInfo(fromNum) {
it.user = MeshUser(p.id, p.longName, p.shortName)
}
}
/// Update our model and resend as needed for a MeshPacket we just received from the radio
private fun handleReceivedMeshPacket(packet: MeshPacket) {
val fromNum = packet.from
// FIXME, perhaps we could learn our node ID by looking at any to packets the radio
// decided to pass through to us (except for broadcast packets)
val toNum = packet.to
val p = packet.payload
// Update our last seen based on any valid timestamps
if (packet.rxTime != 0L) {
updateNodeInfo(fromNum) {
it.lastSeen = packet.rxTime
}
}
when (p.variantCase.number) {
MeshProtos.SubPacket.POSITION_FIELD_NUMBER ->
updateNodeInfo(fromNum) {
it.position = Position(
p.position.latitude,
p.position.longitude,
p.position.altitude
)
}
MeshProtos.SubPacket.DATA_FIELD_NUMBER ->
handleReceivedData(fromNum, p.data)
MeshProtos.SubPacket.USER_FIELD_NUMBER ->
handleReceivedUser(fromNum, p.user)
else -> TODO("Unexpected SubPacket variant")
}
}
/// Called when we gain/lose connection to our radio
private fun onConnectionChanged(c: Boolean) {
debug("onConnectionChanged connected=$c")
isConnected = c
if (c) {
// Do our startup init
// FIXME - don't do this until after we see that the radio is connected to the phone
//val sim = SimRadio(this@MeshService)
//sim.start() // Fake up our node id info and some past packets from other nodes
val myInfo = MeshProtos.MyNodeInfo.parseFrom(connectedRadio.readMyNode())
ourNodeNum = myInfo.myNodeNum
// Ask for the current node DB
connectedRadio.restartNodeInfo()
// read all the infos until we get back null
var infoBytes = connectedRadio.readNodeInfo()
while (infoBytes != null) {
val info = MeshProtos.NodeInfo.parseFrom(infoBytes)
debug("Received initial nodeinfo $info")
// Just replace/add any entry
updateNodeInfo(info.num) {
if (info.hasUser())
it.user =
MeshUser(info.user.id, info.user.longName, info.user.shortName)
if (info.hasPosition())
it.position = Position(
info.position.latitude,
info.position.longitude,
info.position.altitude
)
it.lastSeen = info.lastSeen
}
// advance to next
infoBytes = connectedRadio.readNodeInfo()
}
}
}
/**
* Receives messages from our BT radio service and processes them to update our model
* and send to clients as needed.
*/
private val radioInterfaceReceiver = object : BroadcastReceiver() {
// Important to never throw exceptions out of onReceive
override fun onReceive(context: Context, intent: Intent) = exceptionReporter {
debug("Received broadcast ${intent.action}")
when (intent.action) {
RadioInterfaceService.CONNECTCHANGED_ACTION -> {
onConnectionChanged(intent.getBooleanExtra(EXTRA_CONNECTED, false))
explicitBroadcast(intent) // forward the connection change message to anyone who is listening to us
}
RadioInterfaceService.RECEIVE_FROMRADIO_ACTION -> {
val proto =
MeshProtos.FromRadio.parseFrom(intent.getByteArrayExtra(EXTRA_PAYLOAD)!!)
info("Received from radio service: ${proto.toOneLineString()}")
when (proto.variantCase.number) {
MeshProtos.FromRadio.PACKET_FIELD_NUMBER -> handleReceivedMeshPacket(
proto.packet
)
else -> TODO("Unexpected FromRadio variant")
}
}
else -> TODO("Unexpected radio interface broadcast")
}
}
}
private val binder = object : IMeshService.Stub() {
// Note: bound methods don't get properly exception caught/logged, so do that with a wrapper
// per https://blog.classycode.com/dealing-with-exceptions-in-aidl-9ba904c6d63
override fun subscribeReceiver(packageName: String, receiverName: String) =
toRemoteExceptions {
clientPackages[receiverName] = packageName
}
override fun setOwner(myId: String, longName: String, shortName: String) =
toRemoteExceptions {
error("TODO setOwner $myId : $longName : $shortName")
val user = MeshProtos.User.newBuilder().also {
it.id = myId
it.longName = longName
it.shortName = shortName
}.build()
// Also update our own map for our nodenum, by handling the packet just like packets from other users
if (ourNodeNum >= 0) {
handleReceivedUser(ourNodeNum, user)
}
// set my owner info
connectedRadio.writeOwner(user.toByteArray())
}
override fun sendData(destId: String, payloadIn: ByteArray, typ: Int) =
toRemoteExceptions {
info("sendData $destId <- ${payloadIn.size} bytes")
// encapsulate our payload in the proper protobufs and fire it off
val packet = buildMeshPacket(destId) {
data = MeshProtos.Data.newBuilder().also {
it.typ = MeshProtos.Data.Type.SIGNAL_OPAQUE
it.payload = ByteString.copyFrom(payloadIn)
}.build()
}
sendToRadio(ToRadio.newBuilder().apply {
this.packet = packet
})
}
override fun getOnline(): Array<String> = toRemoteExceptions {
val r = nodeDBbyID.keys.toTypedArray()
info("in getOnline, count=${r.size}")
// return arrayOf("+16508675309")
r
}
override fun isConnected(): Boolean = toRemoteExceptions {
val r = this@MeshService.isConnected
info("in isConnected=$r")
r
}
}
}