meshtastic-android/app/src/main/java/com/geeksville/mesh/service/RadioInterfaceService.kt

341 wiersze
12 KiB
Kotlin

package com.geeksville.mesh.service
import android.annotation.SuppressLint
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.os.IBinder
import androidx.core.content.edit
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ServiceLifecycleDispatcher
import androidx.lifecycle.coroutineScope
import com.geeksville.android.BinaryLogFile
import com.geeksville.android.GeeksvilleApplication
import com.geeksville.android.Logging
import com.geeksville.concurrent.handledLaunch
import com.geeksville.mesh.IRadioInterfaceService
import com.geeksville.mesh.repository.bluetooth.BluetoothRepository
import com.geeksville.util.anonymize
import com.geeksville.util.ignoreException
import com.geeksville.util.toRemoteExceptions
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.collect
import javax.inject.Inject
open class RadioNotConnectedException(message: String = "Not connected to radio") :
BLEException(message)
/**
* Handles the bluetooth link with a mesh radio device. Does not cache any device state,
* just does bluetooth comms etc...
*
* This service is not exposed outside of this process.
*
* Note - this class intentionally dumb. It doesn't understand protobuf framing etc...
* It is designed to be simple so it can be stubbed out with a simulated version as needed.
*/
@AndroidEntryPoint
class RadioInterfaceService : Service(), Logging {
// The following is due to the fact that AIDL prevents us from extending from `LifecycleService`:
private val lifecycleOwner: LifecycleOwner = LifecycleOwner { lifecycleDispatcher.lifecycle }
private val lifecycleDispatcher: ServiceLifecycleDispatcher by lazy {
ServiceLifecycleDispatcher(lifecycleOwner)
}
@Inject
lateinit var bluetoothRepository: BluetoothRepository
companion object : Logging {
/**
* The RECEIVED_FROMRADIO
* Payload will be the raw bytes which were contained within a MeshProtos.FromRadio protobuf
*/
const val RECEIVE_FROMRADIO_ACTION = "$prefix.RECEIVE_FROMRADIO"
/**
* This is broadcast when connection state changed
*/
const val RADIO_CONNECTED_ACTION = "$prefix.CONNECT_CHANGED"
const val DEVADDR_KEY = "devAddr2" // the new name for devaddr
init {
/// We keep this var alive so that the following factory objects get created and not stripped during the android build
val factories = arrayOf<InterfaceFactory>(
BluetoothInterface,
SerialInterface,
TCPInterface,
MockInterface,
NopInterface
)
info("Using ${factories.size} interface factories")
}
/// This is public only so that SimRadio can bootstrap our message flow
fun broadcastReceivedFromRadio(context: Context, payload: ByteArray) {
val intent = Intent(RECEIVE_FROMRADIO_ACTION)
intent.putExtra(EXTRA_PAYLOAD, payload)
context.sendBroadcast(intent)
}
fun getPrefs(context: Context): SharedPreferences =
context.getSharedPreferences("radio-prefs", Context.MODE_PRIVATE)
/** Return the device we are configured to use, or null for none
* device address strings are of the form:
*
* at
*
* where a is either x for bluetooth or s for serial
* and t is an interface specific address (macaddr or a device path)
*/
@SuppressLint("NewApi")
fun getDeviceAddress(context: Context): String? {
// If the user has unpaired our device, treat things as if we don't have one
val prefs = getPrefs(context)
var address = prefs.getString(DEVADDR_KEY, null)
// If we are running on the emulator we default to the mock interface, so we can have some data to show to the user
if (address == null && MockInterface.addressValid(context, ""))
address = MockInterface.prefix.toString()
return address
}
/** Like getDeviceAddress, but filtered to return only devices we are currently bonded with
*
* at
*
* where a is either x for bluetooth or s for serial
* and t is an interface specific address (macaddr or a device path)
*/
@SuppressLint("NewApi")
fun getBondedDeviceAddress(context: Context): String? {
// If the user has unpaired our device, treat things as if we don't have one
val address = getDeviceAddress(context)
/// Interfaces can filter addresses to indicate that address is no longer acceptable
if (address != null) {
val c = address[0]
val rest = address.substring(1)
val isValid = InterfaceFactory.getFactory(c)?.addressValid(context, rest) ?: false
if (!isValid)
return null
}
return address
}
/// If our service is currently running, this pointer can be used to reach it (in case setBondedDeviceAddress is called)
private var runningService: RadioInterfaceService? = null
}
private val logSends = false
private val logReceives = false
private lateinit var sentPacketsLog: BinaryLogFile // inited in onCreate
private lateinit var receivedPacketsLog: BinaryLogFile
/**
* We recreate this scope each time we stop an interface
*/
var serviceScope = CoroutineScope(Dispatchers.IO + Job())
private var radioIf: IRadioInterface = NopInterface()
/** true if we have started our interface
*
* Note: an interface may be started without necessarily yet having a connection
*/
private var isStarted = false
/// true if our interface is currently connected to a device
private var isConnected = false
private fun broadcastConnectionChanged(isConnected: Boolean, isPermanent: Boolean) {
debug("Broadcasting connection=$isConnected")
val intent = Intent(RADIO_CONNECTED_ACTION)
intent.putExtra(EXTRA_CONNECTED, isConnected)
intent.putExtra(EXTRA_PERMANENT, isPermanent)
sendBroadcast(intent)
}
/// Send a packet/command out the radio link, this routine can block if it needs to
private fun handleSendToRadio(p: ByteArray) {
radioIf.handleSendToRadio(p)
}
// Handle an incoming packet from the radio, broadcasts it as an android intent
fun handleFromRadio(p: ByteArray) {
if (logReceives) {
receivedPacketsLog.write(p)
receivedPacketsLog.flush()
}
// ignoreException { debug("FromRadio: ${MeshProtos.FromRadio.parseFrom(p)}") }
broadcastReceivedFromRadio(
this,
p
)
}
fun onConnect() {
if (!isConnected) {
isConnected = true
broadcastConnectionChanged(true, false)
}
}
fun onDisconnect(isPermanent: Boolean) {
if (isConnected) {
isConnected = false
broadcastConnectionChanged(false, isPermanent)
}
}
override fun onCreate() {
runningService = this
lifecycleDispatcher.onServicePreSuperOnCreate()
super.onCreate()
lifecycleOwner.lifecycle.coroutineScope.launch {
bluetoothRepository.state.collect { state ->
if (state.enabled) {
startInterface()
} else {
stopInterface()
}
}
}
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
lifecycleDispatcher.onServicePreSuperOnStart()
return super.onStartCommand(intent, flags, startId)
}
override fun onDestroy() {
stopInterface()
serviceScope.cancel("Destroying RadioInterface")
runningService = null
lifecycleDispatcher.onServicePreSuperOnDestroy()
super.onDestroy()
}
override fun onBind(intent: Intent?): IBinder? {
lifecycleDispatcher.onServicePreSuperOnBind()
return binder
}
/** Start our configured interface (if it isn't already running) */
private fun startInterface() {
if (radioIf !is NopInterface)
warn("Can't start interface - $radioIf is already running")
else {
val address = getBondedDeviceAddress(this)
if (address == null)
warn("No bonded mesh radio, can't start interface")
else {
info("Starting radio ${address.anonymize}")
isStarted = true
if (logSends)
sentPacketsLog = BinaryLogFile(this, "sent_log.pb")
if (logReceives)
receivedPacketsLog = BinaryLogFile(this, "receive_log.pb")
val c = address[0]
val rest = address.substring(1)
radioIf =
InterfaceFactory.getFactory(c)?.createInterface(this, rest) ?: NopInterface()
}
}
}
private fun stopInterface() {
val r = radioIf
info("stopping interface $r")
isStarted = false
radioIf = NopInterface()
r.close()
// cancel any old jobs and get ready for the new ones
serviceScope.cancel("stopping interface")
serviceScope = CoroutineScope(Dispatchers.IO + Job())
if (logSends)
sentPacketsLog.close()
if (logReceives)
receivedPacketsLog.close()
// Don't broadcast disconnects if we were just using the nop device
if (r !is NopInterface)
onDisconnect(isPermanent = true) // Tell any clients we are now offline
}
/**
* Change to a new device
*
* @return true if the device changed, false if no change
*/
@SuppressLint("NewApi")
private fun setBondedDeviceAddress(address: String?): Boolean {
return if (getBondedDeviceAddress(this) == address && isStarted) {
warn("Ignoring setBondedDevice ${address.anonymize}, because we are already using that device")
false
} else {
// Record that this use has configured a new radio
GeeksvilleApplication.analytics.track(
"mesh_bond"
)
// Ignore any errors that happen while closing old device
ignoreException {
stopInterface()
}
// The device address "n" can be used to mean none
debug("Setting bonded device to ${address.anonymize}")
getPrefs(this).edit(commit = true) {
if (address == null)
this.remove(DEVADDR_KEY)
else
putString(DEVADDR_KEY, address)
}
// Force the service to reconnect
startInterface()
true
}
}
private val binder = object : IRadioInterfaceService.Stub() {
override fun setDeviceAddress(deviceAddr: String?): Boolean = toRemoteExceptions {
setBondedDeviceAddress(deviceAddr)
}
/** If the service is not currently connected to the radio, try to connect now. At boot the radio interface service will
* not connect to a radio until this call is received. */
override fun connect() = toRemoteExceptions {
// We don't start actually talking to our device until MeshService binds to us - this prevents
// broadcasting connection events before MeshService is ready to receive them
startInterface()
}
override fun sendToRadio(a: ByteArray) {
// Do this in the IO thread because it might take a while (and we don't care about the result code)
serviceScope.handledLaunch { handleSendToRadio(a) }
}
}
}