Merge pull request #10 from geeksville/master

make app aware of device sleep states
This commit is contained in:
Kevin Hester
2020-04-04 17:02:44 -07:00
committed by GitHub
10 changed files with 260 additions and 109 deletions

12
TODO.md
View File

@@ -1,6 +1,16 @@
# High priority
Work items for soon alpha builds
Document the following in application behavior
*change ls_secs is 1 hr normally, which is fine because if there are other nodes in the mesh and they send us a packet we will wake any time during ls_secs and update app state
* use states for meshservice: disconnected -> connected-> devsleep -> disconnected (3 states)
* when device enters LS state radiointerfaceservice publishes "Broadcasting connection=false", meshservice should then enter devicesleepstate for ls_secs + 30s (to allow for some margin)
* use compose on each page, but not for the outer wrapper
* one view per page: https://developer.android.com/guide/navigation/navigation-swipe-view-2
* use viewgroup with a unique ID https://developer.android.com/reference/kotlin/androidx/ui/core/package-summary#(android.view.ViewGroup).setContent(kotlin.Function0)
* let channel be editited
* make link sharing work
* finish map view
@@ -19,6 +29,8 @@ Work items for soon alpha builds
# Medium priority
Features for future builds
* use coroutines in the services, to ensure low latency for both API calls and GUI operations https://developer.android.com/kotlin/coroutines &
https://medium.com/@kenkyee/android-kotlin-coroutine-best-practices-bc033fed62e7 & https://codelabs.developers.google.com/codelabs/kotlin-coroutines/#5
* fix notification setSmallIcon parameter - change it to use the meshtastic icon
* ditch compose and use https://github.com/zsmb13/MaterialDrawerKt + https://github.com/Kotlin/anko/wiki/Anko-Layouts?
* describe user experience: devices always point to each other and show distance, you can send texts between nodes

View File

@@ -16,8 +16,8 @@ android {
applicationId "com.geeksville.mesh"
minSdkVersion 22 // The oldest emulator image I have tried is 22 (though 21 probably works)
targetSdkVersion 29
versionCode 122
versionName "0.2.2"
versionCode 123
versionName "0.2.3"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
@@ -82,6 +82,10 @@ dependencies {
androidTestImplementation 'androidx.test.ext:junit:1.1.1'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
// Coroutines
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version"
// You need to depend on the lite runtime library, not protobuf-java
// For now I'm not using javalite, because I want JSON printing
//implementation 'com.google.protobuf:protobuf-java:3.11.1'

View File

@@ -19,3 +19,8 @@
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
# per https://medium.com/@kenkyee/android-kotlin-coroutine-best-practices-bc033fed62e7
-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
-keepclassmembernames class kotlinx.** { volatile <fields>; }

View File

@@ -30,8 +30,11 @@ interface IMeshService {
typ is defined in mesh.proto Data.Type. For now juse use 0 to mean opaque bytes.
destId can be null to indicate "broadcast message"
Returns true if the packet has been sent into the mesh, or false if it was merely queued
inside the service - and will be delivered to mesh the next time we hear from our radio.
*/
void sendData(String destId, in byte[] payload, int typ);
boolean sendData(String destId, in byte[] payload, int typ);
/**
Get the IDs of everyone on the mesh. You should also subscribe for NODE_CHANGE broadcasts.
@@ -47,9 +50,9 @@ interface IMeshService {
void setRadioConfig(in byte []payload);
/**
Is the packet radio currently connected to the phone?
Is the packet radio currently connected to the phone? Returns a ConnectionState string.
*/
boolean isConnected();
String connectionState();
// see com.geeksville.com.geeksville.mesh broadcast intents
// RECEIVED_OPAQUE for data received from other nodes

View File

@@ -311,10 +311,10 @@ class MainActivity : AppCompatActivity(), Logging,
}
/// Called when we gain/lose a connection to our mesh radio
private fun onMeshConnectionChanged(connected: Boolean) {
private fun onMeshConnectionChanged(connected: MeshService.ConnectionState) {
UIState.isConnected.value = connected
debug("connchange ${UIState.isConnected.value}")
if (connected) {
if (connected == MeshService.ConnectionState.CONNECTED) {
// always get the current radio config when we connect
readRadioConfig()
@@ -383,7 +383,8 @@ class MainActivity : AppCompatActivity(), Logging,
}
}
MeshService.ACTION_MESH_CONNECTED -> {
val connected = intent.getBooleanExtra(EXTRA_CONNECTED, false)
val connected =
MeshService.ConnectionState.valueOf(intent.getStringExtra(EXTRA_CONNECTED)!!)
onMeshConnectionChanged(connected)
}
else -> TODO()
@@ -402,7 +403,8 @@ class MainActivity : AppCompatActivity(), Logging,
registerMeshReceiver()
// We won't receive a notify for the initial state of connection, so we force an update here
onMeshConnectionChanged(service.isConnected)
val connectionState = MeshService.ConnectionState.valueOf(service.connectionState())
onMeshConnectionChanged(connectionState)
debug("connected to mesh service, isConnected=${UIState.isConnected.value}")
}

View File

@@ -11,6 +11,7 @@ import com.geeksville.android.BuildUtils.isEmulator
import com.geeksville.android.Logging
import com.geeksville.mesh.IMeshService
import com.geeksville.mesh.MeshProtos
import com.geeksville.mesh.service.MeshService
import com.geeksville.mesh.ui.getInitials
/// FIXME - figure out how to merge this staate with the AppStatus Model
@@ -22,7 +23,7 @@ object UIState : Logging {
var meshService: IMeshService? = null
/// Are we connected to our radio device
val isConnected = mutableStateOf(false)
val isConnected = mutableStateOf(MeshService.ConnectionState.DISCONNECTED)
/// various radio settings (including the channel)
private val radioConfig = mutableStateOf<MeshProtos.RadioConfig?>(null)

View File

@@ -11,6 +11,7 @@ import android.os.Build
import android.os.IBinder
import android.os.RemoteException
import androidx.annotation.RequiresApi
import androidx.annotation.UiThread
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationCompat.PRIORITY_MIN
import com.geeksville.analytics.DataPair
@@ -27,12 +28,26 @@ import com.geeksville.util.toRemoteExceptions
import com.google.android.gms.common.api.ResolvableApiException
import com.google.android.gms.location.*
import com.google.protobuf.ByteString
import kotlinx.coroutines.*
import java.nio.charset.Charset
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
class RadioNotConnectedException() : Exception("Not connected to radio")
private val errorHandler = CoroutineExceptionHandler { _, exception ->
Exceptions.report(exception, "MeshService-coroutine", "coroutine-exception")
}
/// Wrap launch with an exception handler, FIXME, move into a utility lib
fun CoroutineScope.handledLaunch(
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> Unit
) = this.launch(context = context + errorHandler, start = start, block = block)
/**
* Handles all the communication with android apps. Also keeps an internal model
* of the network state.
@@ -89,6 +104,12 @@ class MeshService : Service(), Logging {
data class TextMessage(val fromId: String, val text: String)
}
public enum class ConnectionState {
DISCONNECTED,
CONNECTED,
DEVICE_SLEEP // device is in LS sleep state, it will reconnected to us over bluetooth once it has data
}
/// A mapping of receiver class name to package name - used for explicit broadcasts
private val clientPackages = mutableMapOf<String, String>()
@@ -96,6 +117,12 @@ class MeshService : Service(), Logging {
IRadioInterfaceService.Stub.asInterface(it)
}
private val serviceJob = Job()
private val serviceScope = CoroutineScope(Dispatchers.IO + serviceJob)
/// The current state of our connection
private var connectionState = ConnectionState.DISCONNECTED
/*
see com.geeksville.mesh broadcast intents
// RECEIVED_OPAQUE for data received from other nodes
@@ -117,7 +144,7 @@ class MeshService : Service(), Logging {
private var lastSendMsec = 0L
override fun onLocationResult(locationResult: LocationResult) {
exceptionReporter {
serviceScope.handledLaunch {
super.onLocationResult(locationResult)
var l = locationResult.lastLocation
@@ -147,7 +174,7 @@ class MeshService : Service(), Logging {
)
} catch (ex: RadioNotConnectedException) {
warn("Lost connection to radio, stopping location requests")
onConnectionChanged(false)
onConnectionChanged(ConnectionState.DEVICE_SLEEP)
}
}
}
@@ -163,6 +190,7 @@ class MeshService : Service(), Logging {
* per https://developer.android.com/training/location/change-location-settings
*/
@SuppressLint("MissingPermission")
@UiThread
private fun startLocationRequests() {
if (fusedLocationClient == null) {
GeeksvilleApplication.analytics.track("location_start") // Figure out how many users needed to use the phone GPS
@@ -243,7 +271,8 @@ class MeshService : Service(), Logging {
/// Safely access the radio service, if not connected an exception will be thrown
private val connectedRadio: IRadioInterfaceService
get() = (if (isConnected) radio.serviceP else null) ?: throw RadioNotConnectedException()
get() = (if (connectionState == ConnectionState.CONNECTED) radio.serviceP else null)
?: throw RadioNotConnectedException()
/// 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
@@ -295,11 +324,13 @@ class MeshService : Service(), Logging {
/// A text message that has a arrived since the last notification update
private var recentReceivedText: TextMessage? = null
val summaryString
get() = if (!isConnected)
"No radio connected"
else
"Connected: $numOnlineNodes of $numNodes online"
private val summaryString
get() = when (connectionState) {
ConnectionState.CONNECTED -> "Connected: $numOnlineNodes of $numNodes online"
ConnectionState.DISCONNECTED -> "Disconnected"
ConnectionState.DEVICE_SLEEP -> "Device sleeping"
}
override fun toString() = summaryString
@@ -375,6 +406,7 @@ class MeshService : Service(), Logging {
radio.close()
super.onDestroy()
serviceJob.cancel()
}
@@ -396,8 +428,7 @@ class MeshService : Service(), Logging {
var myNodeInfo: MyNodeInfo? = null
/// Is our radio connected to the phone?
private var isConnected = false
private var radioConfig: MeshProtos.RadioConfig? = null
/// True after we've done our initial node db init
private var haveNodeDB = false
@@ -567,7 +598,10 @@ class MeshService : Service(), Logging {
}
/// If packets arrive before we have our node DB, we delay parsing them until the DB is ready
private val earlyPackets = mutableListOf<MeshPacket>()
private val earlyReceivedPackets = mutableListOf<MeshPacket>()
/// If apps try to send packets when our radio is sleeping, we queue them here instead
private val offlineSentPackets = mutableListOf<MeshPacket>()
/// Update our model and resend as needed for a MeshPacket we just received from the radio
private fun handleReceivedMeshPacket(packet: MeshPacket) {
@@ -575,17 +609,21 @@ class MeshService : Service(), Logging {
processReceivedMeshPacket(packet)
onNodeDBChanged()
} else {
earlyPackets.add(packet)
logAssert(earlyPackets.size < 128) // The max should normally be about 32, but if the device is messed up it might try to send forever
earlyReceivedPackets.add(packet)
logAssert(earlyReceivedPackets.size < 128) // The max should normally be about 32, but if the device is messed up it might try to send forever
}
}
/// Process any packets that showed up too early
private fun processEarlyPackets() {
earlyPackets.forEach { processReceivedMeshPacket(it) }
earlyPackets.clear()
earlyReceivedPackets.forEach { processReceivedMeshPacket(it) }
earlyReceivedPackets.clear()
offlineSentPackets.forEach { sendMeshPacket(it) }
offlineSentPackets.clear()
}
/// Update our model and resend as needed for a MeshPacket we just received from the radio
private fun processReceivedMeshPacket(packet: MeshPacket) {
val fromNum = packet.from
@@ -624,6 +662,7 @@ class MeshService : Service(), Logging {
private fun currentSecond() = (System.currentTimeMillis() / 1000).toInt()
/// We are reconnecting to a radio, redownload the full state. This operation might take hundreds of milliseconds
private fun reinitFromRadio() {
// Read the MyNodeInfo object
@@ -637,6 +676,8 @@ class MeshService : Service(), Logging {
myNodeInfo = mi
radioConfig = MeshProtos.RadioConfig.parseFrom(connectedRadio.readRadioConfig())
/// Track types of devices and firmware versions in use
GeeksvilleApplication.analytics.setUserInfo(
DataPair("region", mi.region),
@@ -712,19 +753,53 @@ class MeshService : Service(), Logging {
if (!myNodeInfo!!.hasGPS) {
// If we have at least one other person in the mesh, send our GPS position otherwise stop listening to GPS
if (numOnlineNodes >= 2)
startLocationRequests()
else
stopLocationRequests()
serviceScope.handledLaunch(Dispatchers.Main) {
if (numOnlineNodes >= 2)
startLocationRequests()
else
stopLocationRequests()
}
} else
debug("Our radio has a built in GPS, so not reading GPS in phone")
}
private var sleepTimeout: Job? = null
/// Called when we gain/lose connection to our radio
private fun onConnectionChanged(c: Boolean) {
debug("onConnectionChanged connected=$c")
isConnected = c
if (c) {
private fun onConnectionChanged(c: ConnectionState) {
debug("onConnectionChanged=$c")
/// Perform all the steps needed once we start waiting for device sleep to complete
fun startDeviceSleep() {
// lost radio connection, therefore no need to keep listening to GPS
stopLocationRequests()
// Have our timeout fire in the approprate number of seconds
sleepTimeout = serviceScope.handledLaunch {
try {
// If we have a valid timeout, wait that long (+30 seconds) otherwise, just wait 30 seconds
val timeout = (radioConfig?.preferences?.lsSecs ?: 0) + 30
debug("Waiting for sleeping device, timeout=$timeout secs")
delay(timeout * 1000L)
warn("Device timeout out, setting disconnected")
onConnectionChanged(ConnectionState.DISCONNECTED)
} catch (ex: CancellationException) {
debug("device sleep timeout cancelled")
}
}
}
fun startDisconnect() {
GeeksvilleApplication.analytics.track(
"mesh_disconnect",
DataPair("num_nodes", numNodes),
DataPair("num_online", numOnlineNodes)
)
}
fun startConnect() {
// Do our startup init
try {
reinitFromRadio()
@@ -748,20 +823,37 @@ class MeshService : Service(), Logging {
// It seems that when the ESP32 goes offline it can briefly come back for a 100ms ish which
// causes the phone to try and reconnect. If we fail downloading our initial radio state we don't want to
// claim we have a valid connection still
isConnected = false;
connectionState = ConnectionState.DEVICE_SLEEP
startDeviceSleep()
throw ex; // Important to rethrow so that we don't tell the app all is well
}
} else {
// lost radio connection, therefore no need to keep listening to GPS
stopLocationRequests()
GeeksvilleApplication.analytics.track(
"mesh_disconnect",
DataPair("num_nodes", numNodes),
DataPair("num_online", numOnlineNodes)
)
}
// Cancel any existing timeouts
sleepTimeout?.let {
it.cancel()
sleepTimeout = null
}
connectionState = c
when (c) {
ConnectionState.CONNECTED ->
startConnect()
ConnectionState.DEVICE_SLEEP ->
startDeviceSleep()
ConnectionState.DISCONNECTED ->
startDisconnect()
}
// broadcast an intent with our new connection state
val intent = Intent(ACTION_MESH_CONNECTED)
intent.putExtra(
EXTRA_CONNECTED,
connectionState.toString()
)
explicitBroadcast(intent)
// Update the android notification in the status bar
updateNotification()
}
@@ -773,41 +865,42 @@ class MeshService : Service(), Logging {
// 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.RADIO_CONNECTED_ACTION -> {
try {
onConnectionChanged(intent.getBooleanExtra(EXTRA_CONNECTED, false))
// forward the connection change message to anyone who is listening to us. but change the action
// to prevent an infinite loop from us receiving our own broadcast. ;-)
intent.action = ACTION_MESH_CONNECTED
explicitBroadcast(intent)
} catch (ex: RemoteException) {
// This can happen sometimes (especially if the device is slowly dying due to killing power, don't report to crashlytics
warn("Abandoning reconnect attempt, due to errors during init: ${ex.message}")
serviceScope.handledLaunch {
debug("Received broadcast ${intent.action}")
when (intent.action) {
RadioInterfaceService.RADIO_CONNECTED_ACTION -> {
try {
onConnectionChanged(
if (intent.getBooleanExtra(EXTRA_CONNECTED, false))
ConnectionState.CONNECTED
else
ConnectionState.DEVICE_SLEEP
)
} catch (ex: RemoteException) {
// This can happen sometimes (especially if the device is slowly dying due to killing power, don't report to crashlytics
warn("Abandoning reconnect attempt, due to errors during init: ${ex.message}")
}
}
}
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
)
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 FromRadio variant")
}
}
}
else -> TODO("Unexpected radio interface broadcast")
else -> TODO("Unexpected radio interface broadcast")
}
}
}
}
@@ -846,6 +939,15 @@ class MeshService : Service(), Logging {
})
}
/**
* Send a mesh packet to the radio, if the radio is not currently connected this function will throw NotConnectedException
*/
private fun sendMeshPacket(packet: MeshPacket) {
sendToRadio(ToRadio.newBuilder().apply {
this.packet = packet
})
}
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
@@ -876,9 +978,9 @@ class MeshService : Service(), Logging {
connectedRadio.writeOwner(user.toByteArray())
}
override fun sendData(destId: String?, payloadIn: ByteArray, typ: Int) =
override fun sendData(destId: String?, payloadIn: ByteArray, typ: Int): Boolean =
toRemoteExceptions {
info("sendData dest=$destId <- ${payloadIn.size} bytes")
info("sendData dest=$destId <- ${payloadIn.size} bytes (connectionState=$connectionState)")
// encapsulate our payload in the proper protobufs and fire it off
val packet = buildMeshPacket(destId) {
@@ -887,24 +989,33 @@ class MeshService : Service(), Logging {
it.payload = ByteString.copyFrom(payloadIn)
}.build()
}
sendToRadio(ToRadio.newBuilder().apply {
this.packet = packet
})
// If radio is sleeping, queue the packet
when (connectionState) {
ConnectionState.DEVICE_SLEEP ->
offlineSentPackets.add(packet)
else ->
sendMeshPacket(packet)
}
GeeksvilleApplication.analytics.track(
"data_send",
DataPair("num_bytes", payloadIn.size),
DataPair("type", typ)
)
connectionState == ConnectionState.CONNECTED
}
override fun getRadioConfig(): ByteArray = toRemoteExceptions {
connectedRadio.readRadioConfig()
this@MeshService.radioConfig?.toByteArray() ?: throw RadioNotConnectedException()
}
override fun setRadioConfig(payload: ByteArray) = toRemoteExceptions {
// Update our device
connectedRadio.writeRadioConfig(payload)
// Update our cached copy
this@MeshService.radioConfig = MeshProtos.RadioConfig.parseFrom(payload)
}
override fun getNodes(): Array<NodeInfo> = toRemoteExceptions {
@@ -914,10 +1025,10 @@ class MeshService : Service(), Logging {
r
}
override fun isConnected(): Boolean = toRemoteExceptions {
val r = this@MeshService.isConnected
info("in isConnected=$r")
r
override fun connectionState(): String = toRemoteExceptions {
val r = this@MeshService.connectionState
info("in connectionState=$r")
r.toString()
}
}
}

View File

@@ -291,13 +291,20 @@ class RadioInterfaceService : Service(), Logging {
}
}
/// We only force service refresh the _first_ time we connect to the device. Thereafter it is assumed the firmware didn't change
private var hasForcedRefresh = false
private fun onConnect(connRes: Result<Unit>) {
// This callback is invoked after we are connected
connRes.getOrThrow() // FIXME, instead just try to reconnect?
info("Connected to radio!")
forceServiceRefresh()
if (!hasForcedRefresh) {
// FIXME - for some reason we need to refresh _everytime_. It is almost as if we've cached wrong descriptor fieldnums forever
// hasForcedRefresh = true
forceServiceRefresh()
}
// FIXME - no need to discover services more than once - instead use lazy() to use them in future attempts
safe!!.asyncDiscoverServices { discRes ->

View File

@@ -4,10 +4,7 @@ import androidx.compose.Composable
import androidx.compose.state
import androidx.ui.core.ContextAmbient
import androidx.ui.core.Text
import androidx.ui.layout.Column
import androidx.ui.layout.Container
import androidx.ui.layout.LayoutSize
import androidx.ui.layout.Row
import androidx.ui.layout.*
import androidx.ui.material.*
import androidx.ui.tooling.preview.Preview
import androidx.ui.unit.dp
@@ -15,6 +12,7 @@ import com.geeksville.android.Logging
import com.geeksville.mesh.R
import com.geeksville.mesh.model.NodeDB
import com.geeksville.mesh.model.UIState
import com.geeksville.mesh.service.MeshService
import com.geeksville.mesh.service.RadioInterfaceService
import com.geeksville.mesh.service.SoftwareUpdateService
@@ -36,34 +34,40 @@ fun HomeContent() {
Column {
Row {
fun connected() = UIState.isConnected.value != MeshService.ConnectionState.DISCONNECTED
VectorImage(
id = if (UIState.isConnected.value) R.drawable.cloud_on else R.drawable.cloud_off,
tint = palette.onBackground // , modifier = LayoutSize(40.dp, 40.dp)
id = if (connected()) R.drawable.cloud_on else R.drawable.cloud_off,
tint = palette.onBackground,
modifier = LayoutPadding(start = 8.dp)
)
if (UIState.isConnected.value) {
Column {
Text("Connected")
Column {
if (false) { // hide the firmware update button for now, it is kinda ugly and users don't need it yet
/// Create a software update button
val context = ContextAmbient.current
RadioInterfaceService.getBondedDeviceAddress(context)?.let { macAddress ->
Button(
onClick = {
SoftwareUpdateService.enqueueWork(
context,
SoftwareUpdateService.startUpdateIntent(macAddress)
)
}
) {
Text(text = "Update firmware")
Text(
when (UIState.isConnected.value) {
MeshService.ConnectionState.CONNECTED -> "Connected"
MeshService.ConnectionState.DISCONNECTED -> "Disconnected"
MeshService.ConnectionState.DEVICE_SLEEP -> "Power Saving"
},
modifier = LayoutPadding(start = 8.dp)
)
if (false) { // hide the firmware update button for now, it is kinda ugly and users don't need it yet
/// Create a software update button
val context = ContextAmbient.current
RadioInterfaceService.getBondedDeviceAddress(context)?.let { macAddress ->
Button(
onClick = {
SoftwareUpdateService.enqueueWork(
context,
SoftwareUpdateService.startUpdateIntent(macAddress)
)
}
) {
Text(text = "Update firmware")
}
}
}
} else {
Text("Not Connected")
}
}

View File

@@ -3,6 +3,8 @@
buildscript {
ext.kotlin_version = '1.3.61'
ext.compose_version = '0.1.0-dev07'
ext.coroutines_version = "1.3.5"
repositories {
google()
jcenter()