diff --git a/.idea/vcs.xml b/.idea/vcs.xml index bc2dfb5ae..8756b8ff2 100644 --- a/.idea/vcs.xml +++ b/.idea/vcs.xml @@ -5,6 +5,5 @@ - \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle index 5cac80212..e3cb2ebd5 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -30,15 +30,15 @@ android { keyPassword "$meshtasticKeyPassword" } } */ - compileSdkVersion 29 + compileSdkVersion 30 // leave undefined to use version plugin wants // buildToolsVersion "30.0.2" // Note: 30.0.2 doesn't yet work on Github actions CI defaultConfig { applicationId "com.geeksville.mesh" minSdkVersion 21 // The oldest emulator image I have tried is 22 (though 21 probably works) - targetSdkVersion 29 - versionCode 20211 // format is Mmmss (where M is 1+the numeric major number - versionName "1.2.11" + targetSdkVersion 29 // 30 can't work until an explicit location permissions dialog is added + versionCode 20222 // format is Mmmss (where M is 1+the numeric major number + versionName "1.2.22" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" // per https://developer.android.com/studio/write/vector-asset-studio @@ -119,14 +119,14 @@ dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" implementation 'androidx.appcompat:appcompat:1.2.0' implementation 'androidx.core:core-ktx:1.3.2' - implementation 'androidx.fragment:fragment-ktx:1.3.1' + implementation 'androidx.fragment:fragment-ktx:1.3.2' implementation 'androidx.cardview:cardview:1.0.0' implementation 'androidx.recyclerview:recyclerview:1.1.0' implementation 'androidx.constraintlayout:constraintlayout:2.0.4' implementation 'com.google.android.material:material:1.3.0' implementation 'androidx.viewpager2:viewpager2:1.0.0' implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0' - implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.3.0' + implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.3.1' implementation "androidx.room:room-runtime:$room_version" kapt "androidx.room:room-compiler:$room_version" @@ -151,7 +151,7 @@ dependencies { implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version" // For now I'm not using javalite, because I want JSON printing - implementation ('com.google.protobuf:protobuf-java:3.15.5') + implementation ('com.google.protobuf:protobuf-java:3.15.6') // For UART access // implementation 'com.google.android.things:androidthings:1.0' @@ -170,7 +170,7 @@ dependencies { implementation 'com.google.android.gms:play-services-auth:19.0.0' // Add the Firebase SDK for Crashlytics. - implementation 'com.google.firebase:firebase-crashlytics:17.3.1' + implementation 'com.google.firebase:firebase-crashlytics:17.4.1' // alas implementation bug deep in the bowels when I tried it for my SyncBluetoothDevice class // implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.3" diff --git a/app/src/main/aidl/com/geeksville/mesh/IMeshService.aidl b/app/src/main/aidl/com/geeksville/mesh/IMeshService.aidl index a9ee757d0..17382d016 100644 --- a/app/src/main/aidl/com/geeksville/mesh/IMeshService.aidl +++ b/app/src/main/aidl/com/geeksville/mesh/IMeshService.aidl @@ -113,4 +113,7 @@ interface IMeshService { Return a number 0-100 for progress. -1 for completed and success, -2 for failure */ int getUpdateStatus(); + + int getRegion(); + void setRegion(int regionCode); } diff --git a/app/src/main/java/com/geeksville/mesh/DataPacket.kt b/app/src/main/java/com/geeksville/mesh/DataPacket.kt index ee5df4940..7f5f20b76 100644 --- a/app/src/main/java/com/geeksville/mesh/DataPacket.kt +++ b/app/src/main/java/com/geeksville/mesh/DataPacket.kt @@ -65,8 +65,7 @@ data class DataPacket( parcel.readInt(), parcel.readParcelable(MessageStatus::class.java.classLoader), parcel.readInt() - ) { - } + ) override fun equals(other: Any?): Boolean { if (this === other) return true diff --git a/app/src/main/java/com/geeksville/mesh/MainActivity.kt b/app/src/main/java/com/geeksville/mesh/MainActivity.kt index c7879cf1d..f312bb667 100644 --- a/app/src/main/java/com/geeksville/mesh/MainActivity.kt +++ b/app/src/main/java/com/geeksville/mesh/MainActivity.kt @@ -363,7 +363,7 @@ class MainActivity : AppCompatActivity(), Logging, rater.monitor() // Monitors the app launch times // Only ask to rate if the user has a suitable store - AppRate.showRateDialogIfMeetsConditions(this); // Shows the Rate Dialog when conditions are met + AppRate.showRateDialogIfMeetsConditions(this) // Shows the Rate Dialog when conditions are met } } @@ -489,9 +489,11 @@ class MainActivity : AppCompatActivity(), Logging, } UsbManager.ACTION_USB_DEVICE_ATTACHED -> { - val device: UsbDevice = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE)!! - debug("Handle USB device attached! $device") - usbDevice = device + val device: UsbDevice? = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE) + if (device != null) { + debug("Handle USB device attached! $device") + usbDevice = device + } } Intent.ACTION_MAIN -> { @@ -596,7 +598,7 @@ class MainActivity : AppCompatActivity(), Logging, filter.addAction(MeshService.actionReceived(Portnums.PortNum.TEXT_MESSAGE_APP_VALUE)) filter.addAction((MeshService.ACTION_MESSAGE_STATUS)) registerReceiver(meshServiceReceiver, filter) - receiverRegistered = true; + receiverRegistered = true } private fun unregisterMeshReceiver() { @@ -663,30 +665,32 @@ class MainActivity : AppCompatActivity(), Logging, debug("Getting latest radioconfig from service") try { - val info = service.myNodeInfo + val info: MyNodeInfo? = service.myNodeInfo // this can be null model.myNodeInfo.value = info - val isOld = info.minAppVersion > BuildConfig.VERSION_CODE - if (isOld) - showAlert(R.string.app_too_old, R.string.must_update) - else { - - val curVer = DeviceVersion(info.firmwareVersion ?: "0.0.0") - if (curVer < MeshService.minFirmwareVersion) - showAlert(R.string.firmware_too_old, R.string.firmware_old) + if (info != null) { + val isOld = info.minAppVersion > BuildConfig.VERSION_CODE + if (isOld) + showAlert(R.string.app_too_old, R.string.must_update) else { - // If our app is too old/new, we probably don't understand the new radioconfig messages, so we don't read them until here - model.radioConfig.value = - RadioConfigProtos.RadioConfig.parseFrom(service.radioConfig) + val curVer = DeviceVersion(info.firmwareVersion ?: "0.0.0") + if (curVer < MeshService.minFirmwareVersion) + showAlert(R.string.firmware_too_old, R.string.firmware_old) + else { + // If our app is too old/new, we probably don't understand the new radioconfig messages, so we don't read them until here - model.channels.value = - ChannelSet(AppOnlyProtos.ChannelSet.parseFrom(service.channels)) + model.radioConfig.value = + RadioConfigProtos.RadioConfig.parseFrom(service.radioConfig) - updateNodesFromDevice() + model.channels.value = + ChannelSet(AppOnlyProtos.ChannelSet.parseFrom(service.channels)) - // we have a connection to our device now, do the channel change - perhapsChangeChannel() + updateNodesFromDevice() + + // we have a connection to our device now, do the channel change + perhapsChangeChannel() + } } } } catch (ex: RemoteException) { @@ -799,13 +803,10 @@ class MainActivity : AppCompatActivity(), Logging, } MeshService.ACTION_MESH_CONNECTED -> { - val connected = - MeshService.ConnectionState.valueOf( - intent.getStringExtra( - EXTRA_CONNECTED - )!! - ) - onMeshConnectionChanged(connected) + val extra = intent.getStringExtra(EXTRA_CONNECTED) + if (extra != null) { + onMeshConnectionChanged(MeshService.ConnectionState.valueOf(extra)) + } } else -> TODO() } @@ -972,12 +973,11 @@ class MainActivity : AppCompatActivity(), Logging, try { bindMeshService() - } - catch(ex: BindFailedException) { + } catch (ex: BindFailedException) { // App is probably shutting down, ignore errormsg("Bind of MeshService failed") } - + val bonded = RadioInterfaceService.getBondedDeviceAddress(this) != null if (!bonded && usbDevice == null) // we will handle USB later showSettingsPage() @@ -1090,7 +1090,7 @@ class MainActivity : AppCompatActivity(), Logging, applicationContext.contentResolver.openFileDescriptor(file_uri, "w")?.use { FileOutputStream(it.fileDescriptor).use { fs -> // Write header - fs.write(("from,rssi,snr,time,dist\n").toByteArray()); + fs.write(("from,rssi,snr,time,dist\n").toByteArray()) // Packets are ordered by time, we keep most recent position of // our device in my_position. var my_position: MeshProtos.Position? = null @@ -1101,8 +1101,12 @@ class MainActivity : AppCompatActivity(), Logging, my_position = position } else if (my_position != null) { val dist = positionToMeter(my_position!!, position).roundToInt() - fs.write("%x,%d,%f,%d,%d\n".format(packet_proto.from,packet_proto.rxRssi, - packet_proto.rxSnr, packet_proto.rxTime, dist).toByteArray()) + fs.write( + "%x,%d,%f,%d,%d\n".format( + packet_proto.from, packet_proto.rxRssi, + packet_proto.rxSnr, packet_proto.rxTime, dist + ).toByteArray() + ) } } } diff --git a/app/src/main/java/com/geeksville/mesh/MeshUtilApplication.kt b/app/src/main/java/com/geeksville/mesh/MeshUtilApplication.kt index f2fa418e6..e0a9bd87b 100644 --- a/app/src/main/java/com/geeksville/mesh/MeshUtilApplication.kt +++ b/app/src/main/java/com/geeksville/mesh/MeshUtilApplication.kt @@ -37,13 +37,13 @@ class MeshUtilApplication : GeeksvilleApplication() { } fun sendCrashReports() { - if(isAnalyticsAllowed) + if (isAnalyticsAllowed) crashlytics.sendUnsentReports() } // Send any old reports if user approves sendCrashReports() - + // Attach to our exception wrapper Exceptions.reporter = { exception, _, _ -> crashlytics.recordException(exception) diff --git a/app/src/main/java/com/geeksville/mesh/MyNodeInfo.kt b/app/src/main/java/com/geeksville/mesh/MyNodeInfo.kt index da76ccfaa..a650dfe06 100644 --- a/app/src/main/java/com/geeksville/mesh/MyNodeInfo.kt +++ b/app/src/main/java/com/geeksville/mesh/MyNodeInfo.kt @@ -32,8 +32,7 @@ data class MyNodeInfo( parcel.readInt(), parcel.readInt(), parcel.readInt() - ) { - } + ) override fun writeToParcel(parcel: Parcel, flags: Int) { parcel.writeInt(myNodeNum) diff --git a/app/src/main/java/com/geeksville/mesh/NodeInfo.kt b/app/src/main/java/com/geeksville/mesh/NodeInfo.kt index f4864dd91..ef72d478e 100644 --- a/app/src/main/java/com/geeksville/mesh/NodeInfo.kt +++ b/app/src/main/java/com/geeksville/mesh/NodeInfo.kt @@ -43,7 +43,7 @@ data class Position( val latitude: Double, val longitude: Double, val altitude: Int, - val time: Int = currentTime(), // default to current time in secs + val time: Int = currentTime(), // default to current time in secs (NOT MILLISECONDS!) val batteryPctLevel: Int = 0 ) : Parcelable { companion object { @@ -84,11 +84,13 @@ data class NodeInfo( var user: MeshUser? = null, var position: Position? = null, var snr: Float = Float.MAX_VALUE, - var rssi: Int = Int.MAX_VALUE + var rssi: Int = Int.MAX_VALUE, + var lastHeard: Int = 0 // the last time we've seen this node in secs since 1970 ) : Parcelable { - /// Return the last time we've seen this node in secs since 1970 - val lastSeen get() = position?.time ?: 0 + /** + * Return the last time we've seen this node in secs since 1970 + */ val batteryPctLevel get() = position?.batteryPctLevel @@ -104,7 +106,7 @@ data class NodeInfo( // FIXME - use correct timeout from the device settings val timeout = 15 * 60 // Don't set this timeout too tight, or otherwise we will stop sending GPS helper positions to our device - return (now - lastSeen <= timeout) || lastSeen == 0 + return (now - lastHeard <= timeout) || lastHeard == 0 } /// return the position if it is valid, else null diff --git a/app/src/main/java/com/geeksville/mesh/model/Channel.kt b/app/src/main/java/com/geeksville/mesh/model/Channel.kt index 665a8de25..fe66997cd 100644 --- a/app/src/main/java/com/geeksville/mesh/model/Channel.kt +++ b/app/src/main/java/com/geeksville/mesh/model/Channel.kt @@ -7,9 +7,7 @@ import com.google.protobuf.ByteString fun byteArrayOfInts(vararg ints: Int) = ByteArray(ints.size) { pos -> ints[pos].toByte() } -data class Channel( - val settings: ChannelProtos.ChannelSettings = ChannelProtos.ChannelSettings.getDefaultInstance() -) { +data class Channel(val settings: ChannelProtos.ChannelSettings) { companion object { // These bytes must match the well known and not secret bytes used the default channel AES128 key device code val channelDefaultKey = byteArrayOfInts( @@ -17,10 +15,16 @@ data class Channel( 0xf0, 0xbc, 0xff, 0xab, 0xcf, 0x4e, 0x69, 0xbf ) + private val cleartextPSK = ByteString.EMPTY + private val defaultPSK = + byteArrayOfInts(1) // a shortstring code to indicate we need our default PSK + // TH=he unsecured channel that devices ship with - val defaultChannel = Channel( + val default = Channel( ChannelProtos.ChannelSettings.newBuilder() - .setModemConfig(ChannelProtos.ChannelSettings.ModemConfig.Bw125Cr45Sf128).build() + .setModemConfig(ChannelProtos.ChannelSettings.ModemConfig.Bw125Cr48Sf4096) + .setPsk(ByteString.copyFrom(defaultPSK)) + .build() ) } @@ -50,7 +54,7 @@ data class Channel( val pskIndex = settings.psk.byteAt(0).toInt() if (pskIndex == 0) - ByteString.EMPTY // Treat as an empty PSK (no encryption) + cleartextPSK else { // Treat an index of 1 as the old channelDefaultKey and work up from there val bytes = channelDefaultKey.clone() @@ -73,6 +77,10 @@ data class Channel( return "#${name}-${suffix}" } + + override fun equals(o: Any?): Boolean = (o is Channel) + && psk.toByteArray() contentEquals o.psk.toByteArray() + && name == o.name } fun xorHash(b: ByteArray) = b.fold(0, { acc, x -> acc xor (x.toInt() and 0xff) }) \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/model/ChannelOption.kt b/app/src/main/java/com/geeksville/mesh/model/ChannelOption.kt index 62409f4ff..cae6558bb 100644 --- a/app/src/main/java/com/geeksville/mesh/model/ChannelOption.kt +++ b/app/src/main/java/com/geeksville/mesh/model/ChannelOption.kt @@ -1,14 +1,29 @@ package com.geeksville.mesh.model import com.geeksville.mesh.ChannelProtos -import com.geeksville.mesh.MeshProtos import com.geeksville.mesh.R -enum class ChannelOption(val modemConfig: ChannelProtos.ChannelSettings.ModemConfig, val configRes: Int, val minBroadcastPeriodSecs: Int) { +enum class ChannelOption( + val modemConfig: ChannelProtos.ChannelSettings.ModemConfig, + val configRes: Int, + val minBroadcastPeriodSecs: Int +) { SHORT(ChannelProtos.ChannelSettings.ModemConfig.Bw500Cr45Sf128, R.string.modem_config_short, 3), - MEDIUM(ChannelProtos.ChannelSettings.ModemConfig.Bw125Cr45Sf128, R.string.modem_config_medium, 12), - LONG(ChannelProtos.ChannelSettings.ModemConfig.Bw31_25Cr48Sf512, R.string.modem_config_long, 240), - VERY_LONG(ChannelProtos.ChannelSettings.ModemConfig.Bw125Cr48Sf4096, R.string.modem_config_very_long, 375); + MEDIUM( + ChannelProtos.ChannelSettings.ModemConfig.Bw125Cr45Sf128, + R.string.modem_config_medium, + 12 + ), + LONG( + ChannelProtos.ChannelSettings.ModemConfig.Bw31_25Cr48Sf512, + R.string.modem_config_long, + 240 + ), + VERY_LONG( + ChannelProtos.ChannelSettings.ModemConfig.Bw125Cr48Sf4096, + R.string.modem_config_very_long, + 375 + ); companion object { fun fromConfig(modemConfig: ChannelProtos.ChannelSettings.ModemConfig?): ChannelOption? { @@ -18,6 +33,7 @@ enum class ChannelOption(val modemConfig: ChannelProtos.ChannelSettings.ModemCon } return null } + val defaultMinBroadcastPeriod = VERY_LONG.minBroadcastPeriodSecs } } \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/model/ChannelSet.kt b/app/src/main/java/com/geeksville/mesh/model/ChannelSet.kt index 3fa0b4697..31a26aec5 100644 --- a/app/src/main/java/com/geeksville/mesh/model/ChannelSet.kt +++ b/app/src/main/java/com/geeksville/mesh/model/ChannelSet.kt @@ -4,8 +4,6 @@ import android.graphics.Bitmap import android.net.Uri import android.util.Base64 import com.geeksville.mesh.AppOnlyProtos -import com.geeksville.mesh.MeshProtos -import com.google.protobuf.ByteString import com.google.zxing.BarcodeFormat import com.google.zxing.MultiFormatWriter import com.journeyapps.barcodescanner.BarcodeEncoder @@ -42,11 +40,12 @@ data class ChannelSet( /** * Return the primary channel info */ - val primaryChannel: Channel? get() = - if(protobuf.settingsCount > 0) - Channel(protobuf.getSettings(0)) - else - null + val primaryChannel: Channel? + get() = + if (protobuf.settingsCount > 0) + Channel(protobuf.getSettings(0)) + else + null /// Return an URL that represents the current channel values /// @param upperCasePrefix - portions of the URL can be upper case to make for more efficient QR codes @@ -56,7 +55,7 @@ data class ChannelSet( val channelBytes = protobuf.toByteArray() ?: ByteArray(0) // if unset just use empty val enc = Base64.encodeToString(channelBytes, base64Flags) - val p = if(upperCasePrefix) + val p = if (upperCasePrefix) prefix.toUpperCase() else prefix @@ -68,7 +67,12 @@ data class ChannelSet( // We encode as UPPER case for the QR code URL because QR codes are more efficient for that special case val bitMatrix = - multiFormatWriter.encode(getChannelUrl(true).toString(), BarcodeFormat.QR_CODE, 192, 192); + multiFormatWriter.encode( + getChannelUrl(true).toString(), + BarcodeFormat.QR_CODE, + 192, + 192 + ) val barcodeEncoder = BarcodeEncoder() return barcodeEncoder.createBitmap(bitMatrix) } diff --git a/app/src/main/java/com/geeksville/mesh/model/DeviceVersion.kt b/app/src/main/java/com/geeksville/mesh/model/DeviceVersion.kt index 961524b66..e2096f2bf 100644 --- a/app/src/main/java/com/geeksville/mesh/model/DeviceVersion.kt +++ b/app/src/main/java/com/geeksville/mesh/model/DeviceVersion.kt @@ -5,14 +5,15 @@ import com.geeksville.android.Logging /** * Provide structured access to parse and compare device version strings */ -data class DeviceVersion(val asString: String): Comparable, Logging { +data class DeviceVersion(val asString: String) : Comparable, Logging { - val asInt get() = try { - verStringToInt(asString) - } catch(e: Exception) { - warn("Exception while parsing version '$asString', assuming version 0") - 0 - } + val asInt + get() = try { + verStringToInt(asString) + } catch (e: Exception) { + warn("Exception while parsing version '$asString', assuming version 0") + 0 + } /** * Convert a version string of the form 1.23.57 to a comparable integer of diff --git a/app/src/main/java/com/geeksville/mesh/model/UIState.kt b/app/src/main/java/com/geeksville/mesh/model/UIState.kt index fe4175e9b..2125b5978 100644 --- a/app/src/main/java/com/geeksville/mesh/model/UIState.kt +++ b/app/src/main/java/com/geeksville/mesh/model/UIState.kt @@ -12,7 +12,9 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import com.geeksville.android.Logging -import com.geeksville.mesh.* +import com.geeksville.mesh.IMeshService +import com.geeksville.mesh.MyNodeInfo +import com.geeksville.mesh.RadioConfigProtos import com.geeksville.mesh.database.MeshtasticDatabase import com.geeksville.mesh.database.PacketRepository import com.geeksville.mesh.database.entity.Packet @@ -134,15 +136,11 @@ class UIViewModel(private val app: Application) : AndroidViewModel(app), Logging } } - var region: RadioConfigProtos.RegionCode? - get() = radioConfig.value?.preferences?.region + var region: RadioConfigProtos.RegionCode + get() = meshService?.region?.let { RadioConfigProtos.RegionCode.forNumber(it) } + ?: RadioConfigProtos.RegionCode.Unset set(value) { - val config = radioConfig.value - if (value != null && config != null) { - val builder = config.toBuilder() - builder.preferencesBuilder.region = value - setRadioConfig(builder.build()) - } + meshService?.region = value.number } /// hardware info about our local device (can be null) diff --git a/app/src/main/java/com/geeksville/mesh/service/BLEException.kt b/app/src/main/java/com/geeksville/mesh/service/BLEException.kt index 4ee38cd1b..68f7c6c4d 100644 --- a/app/src/main/java/com/geeksville/mesh/service/BLEException.kt +++ b/app/src/main/java/com/geeksville/mesh/service/BLEException.kt @@ -5,4 +5,8 @@ import java.util.* open class BLEException(msg: String) : IOException(msg) -open class BLECharacteristicNotFoundException(uuid: UUID) : BLEException("Can't get characteristic $uuid") \ No newline at end of file +open class BLECharacteristicNotFoundException(uuid: UUID) : + BLEException("Can't get characteristic $uuid") + +/// Our interface is being shut down +open class BLEConnectionClosing : BLEException("Connection closing ") \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/service/BluetoothInterface.kt b/app/src/main/java/com/geeksville/mesh/service/BluetoothInterface.kt index a424f8640..85e4fc091 100644 --- a/app/src/main/java/com/geeksville/mesh/service/BluetoothInterface.kt +++ b/app/src/main/java/com/geeksville/mesh/service/BluetoothInterface.kt @@ -78,7 +78,15 @@ A variable keepAllPackets, if set to true will suppress this behavior and instea class BluetoothInterface(val service: RadioInterfaceService, val address: String) : IRadioInterface, Logging { - companion object : Logging { + companion object : Logging, InterfaceFactory('x') { + override fun createInterface( + service: RadioInterfaceService, + rest: String + ): IRadioInterface = BluetoothInterface(service, rest) + + init { + registerFactory() + } /// this service UUID is publically visible for scanning val BTM_SERVICE_UUID = UUID.fromString("6ba1b218-15a8-461f-9fa8-5dcae273eafd") @@ -97,15 +105,13 @@ class BluetoothInterface(val service: RadioInterfaceService, val address: String return bluetoothManager.adapter } - fun toInterfaceName(deviceName: String) = "x$deviceName" - /** Return true if this address is still acceptable. For BLE that means, still bonded */ - fun addressValid(context: Context, address: String): Boolean { + override fun addressValid(context: Context, rest: String): Boolean { val allPaired = getBluetoothAdapter(context)?.bondedDevices.orEmpty().map { it.address }.toSet() - return if (!allPaired.contains(address)) { - warn("Ignoring stale bond to ${address.anonymize}") + return if (!allPaired.contains(rest)) { + warn("Ignoring stale bond to ${rest.anonymize}") false } else true @@ -313,7 +319,7 @@ class BluetoothInterface(val service: RadioInterfaceService, val address: String var fromNumChanged = false private fun startWatchingFromNum() { - safe!!.setNotify(fromNum, true) { + safe?.setNotify(fromNum, true) { // We might get multiple notifies before we get around to reading from the radio - so just set one flag fromNumChanged = true debug("fromNum changed") @@ -469,7 +475,11 @@ class BluetoothInterface(val service: RadioInterfaceService, val address: String safe = null // We do this first, because if we throw we still want to mark that we no longer have a valid connection - s?.close() + try { + s?.close() + } catch (_: BLEConnectionClosing) { + warn("Ignoring BLE errors while closing") + } } else { debug("Radio was not connected, skipping disable") } diff --git a/app/src/main/java/com/geeksville/mesh/service/BluetoothStateReceiver.kt b/app/src/main/java/com/geeksville/mesh/service/BluetoothStateReceiver.kt index 770341e7c..a4edefb51 100644 --- a/app/src/main/java/com/geeksville/mesh/service/BluetoothStateReceiver.kt +++ b/app/src/main/java/com/geeksville/mesh/service/BluetoothStateReceiver.kt @@ -26,5 +26,9 @@ class BluetoothStateReceiver( } } - private val Intent.bluetoothAdapterState: Int get() = getIntExtra(BluetoothAdapter.EXTRA_STATE, -1) + private val Intent.bluetoothAdapterState: Int + get() = getIntExtra( + BluetoothAdapter.EXTRA_STATE, + -1 + ) } \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/service/InterfaceFactory.kt b/app/src/main/java/com/geeksville/mesh/service/InterfaceFactory.kt new file mode 100644 index 000000000..ec178df5e --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/service/InterfaceFactory.kt @@ -0,0 +1,23 @@ +package com.geeksville.mesh.service + +import android.content.Context + +/** + * A base class for the singleton factories that make interfaces. One instance per interface type + */ +abstract class InterfaceFactory(val prefix: Char) { + companion object { + private val factories = mutableMapOf() + + fun getFactory(l: Char) = factories.get(l) + } + + protected fun registerFactory() { + factories[prefix] = this + } + + abstract fun createInterface(service: RadioInterfaceService, rest: String): IRadioInterface + + /** Return true if this address is still acceptable. For BLE that means, still bonded */ + open fun addressValid(context: Context, rest: String): Boolean = true +} \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt index 68e9e1652..63685c2f6 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt @@ -38,6 +38,7 @@ import kotlinx.coroutines.* import kotlinx.serialization.json.Json import java.util.* import kotlin.math.absoluteValue +import kotlin.math.max /** * Handles all the communication with android apps. Also keeps an internal model @@ -55,7 +56,7 @@ class MeshService : Service(), Logging { /* @Deprecated(message = "Does not filter by port number. For legacy reasons only broadcast for UNKNOWN_APP, switch to ACTION_RECEIVED") const val ACTION_RECEIVED_DATA = "$prefix.RECEIVED_DATA" */ - fun actionReceived(portNum: String) = "$prefix.RECEIVED.$portNum" + private fun actionReceived(portNum: String) = "$prefix.RECEIVED.$portNum" /// generate a RECEIVED action filter string that includes either the portnumber as an int, or preferably a symbolic name from portnums.proto fun actionReceived(portNum: Int): String { @@ -70,7 +71,7 @@ class MeshService : Service(), Logging { const val ACTION_MESSAGE_STATUS = "$prefix.MESSAGE_STATUS" open class NodeNotFoundException(reason: String) : Exception(reason) - class InvalidNodeIdException() : NodeNotFoundException("Invalid NodeId") + class InvalidNodeIdException : NodeNotFoundException("Invalid NodeId") class NodeNumNotFoundException(id: Int) : NodeNotFoundException("NodeNum not found $id") class IdNotFoundException(id: String) : NodeNotFoundException("ID not found $id") @@ -78,7 +79,7 @@ class MeshService : Service(), Logging { RadioNotConnectedException(message) /** We treat software update as similar to loss of comms to the regular bluetooth service (so things like sendPosition for background GPS ignores the problem */ - class IsUpdatingException() : + class IsUpdatingException : RadioNotConnectedException("Operation prohibited during firmware update") /** @@ -132,7 +133,7 @@ class MeshService : Service(), Logging { } private val locationCallback = MeshServiceLocationCallback( - ::sendPositionScoped, + ::perhapsSendPosition, onSendPositionFailed = { onConnectionChanged(ConnectionState.DEVICE_SLEEP) }, getNodeNum = { myNodeNum } ) @@ -160,6 +161,36 @@ class MeshService : Service(), Logging { ).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 perhapsSendPosition( + 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 + + val myInfo = localNodeInfo + val lastSendMsec = (myInfo?.position?.time ?: 0) * 1000L + val now = System.currentTimeMillis() + if (now - lastSendMsec < locationIntervalMsec) + debug("Not sending position - the local node has sent one recently...") + else { + sendPosition(lat, lon, alt, destNum, wantResponse) + } + } + } + /** * start our location requests (if they weren't already running) * @@ -172,6 +203,7 @@ class MeshService : Service(), Logging { if (fusedLocationClient == null && isGooglePlayAvailable(this)) { GeeksvilleApplication.analytics.track("location_start") // Figure out how many users needed to use the phone GPS + locationIntervalMsec = requestInterval val request = LocationRequest.create().apply { interval = requestInterval priority = LocationRequest.PRIORITY_HIGH_ACCURACY @@ -242,24 +274,31 @@ class MeshService : Service(), Logging { 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 - private fun sendToRadio(p: ToRadio.Builder) { + /** 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 + @param requireConnected set to false if you are okay with using a partially connected device (i.e. during startup) + */ + private fun sendToRadio(p: ToRadio.Builder, requireConnected: Boolean = true) { val b = p.build().toByteArray() if (SoftwareUpdateService.isUpdating) throw IsUpdatingException() - connectedRadio.sendToRadio(b) + if (requireConnected) + connectedRadio.sendToRadio(b) + else { + val s = radio.serviceP ?: throw RadioNotConnectedException() + s.sendToRadio(b) + } } /** * Send a mesh packet to the radio, if the radio is not currently connected this function will throw NotConnectedException */ - private fun sendToRadio(packet: MeshPacket) { + private fun sendToRadio(packet: MeshPacket, requireConnected: Boolean = true) { sendToRadio(ToRadio.newBuilder().apply { this.packet = packet - }) + }, requireConnected) } private fun updateMessageNotification(message: DataPacket) = @@ -428,7 +467,7 @@ class MeshService : Service(), Logging { private var radioConfig: RadioConfigProtos.RadioConfig? = null - private var channels = arrayOf() + private var channels = fixupChannelList(listOf()) /// True after we've done our initial node db init @Volatile @@ -454,6 +493,17 @@ class MeshService : Service(), Logging { n ) + /** + * Return the nodeinfo for the local node, or null if not found + */ + private val localNodeInfo + get(): NodeInfo? = + try { + toNodeInfo(myNodeNum) + } catch (ex: Exception) { + null + } + /** Map a nodenum to the nodeid string, or return null if not present If we have a NodeInfo for this ID we prefer to return the string ID inside the user record. but some nodes might not have a user record at all (because not yet received), in that case, we return @@ -500,9 +550,13 @@ class MeshService : Service(), Logging { } /// A helper function that makes it easy to update node info objects - private fun updateNodeInfo(nodeNum: Int, updatefn: (NodeInfo) -> Unit) { + private fun updateNodeInfo( + nodeNum: Int, + withBroadcast: Boolean = true, + updateFn: (NodeInfo) -> Unit + ) { val info = getOrCreateNodeInfo(nodeNum) - updatefn(info) + 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.orEmpty() @@ -510,7 +564,8 @@ class MeshService : Service(), Logging { nodeDBbyID[userId] = info // parcelable is busted - serviceBroadcasts.broadcastNodeChange(info) + if (withBroadcast) + serviceBroadcasts.broadcastNodeChange(info) } /// My node num @@ -549,7 +604,7 @@ class MeshService : Service(), Logging { setChannel(it) } - channels = asChannels.toTypedArray() + channels = fixupChannelList(asChannels) } /// Generate a new mesh packet builder with our node as the sender, and the specified node num @@ -716,7 +771,10 @@ class MeshService : Service(), Logging { // Handle new style position info Portnums.PortNum.POSITION_APP_VALUE -> { - val u = MeshProtos.Position.parseFrom(data.payload) + var u = MeshProtos.Position.parseFrom(data.payload) + // position updates from mesh usually don't include times. So promote rx time + if (u.time == 0 && packet.rxTime != 0) + u = u.toBuilder().setTime(packet.rxTime).build() debug("position_app ${packet.from} ${u.toOneLineString()}") handleReceivedPosition(packet.from, u, dataPacket.time) } @@ -781,6 +839,7 @@ class MeshService : Service(), Logging { val mi = myNodeInfo if (mi != null) { val ch = a.getChannelResponse + // add new entries if needed channels[ch.index] = ch debug("Admin: Received channel ${ch.index}") if (ch.index + 1 < mi.maxChannels) { @@ -827,11 +886,16 @@ class MeshService : Service(), Logging { p: MeshProtos.Position, defaultTime: Long = System.currentTimeMillis() ) { - updateNodeInfo(fromNum) { - debug("update ${it.user?.longName} with ${p.toOneLineString()}") - it.position = Position(p) - updateNodeInfoTime(it, (defaultTime / 1000).toInt()) - } + // Nodes periodically send out position updates, but those updates might not contain a lat & lon (because no GPS lock) + // We like to look at the local node to see if it has been sending out valid lat/lon, so for the LOCAL node (only) + // we don't record these nop position updates + if (myNodeNum == fromNum && p.latitudeI == 0 && p.longitudeI == 0) + debug("Ignoring nop position update for the local node") + else + updateNodeInfo(fromNum) { + debug("update position: ${it.user?.longName} with ${p.toOneLineString()}") + it.position = Position(p, (defaultTime / 1000L).toInt()) + } } /// If packets arrive before we have our node DB, we delay parsing them until the DB is ready @@ -914,15 +978,17 @@ class MeshService : Service(), Logging { // Update last seen for the node that sent the packet, but also for _our node_ because anytime a packet passes // through our node on the way to the phone that means that local node is also alive in the mesh - updateNodeInfo(myNodeNum) { - it.position = it.position?.copy(time = currentSecond()) + val isOtherNode = myNodeNum != fromNum + updateNodeInfo(myNodeNum, withBroadcast = isOtherNode) { + it.lastHeard = currentSecond() } - // if (p.hasPosition()) handleReceivedPosition(fromNum, p.position, rxTime) + // Do not generate redundant broadcasts of node change for this bookkeeping updateNodeInfo call + // because apps really only care about important updates of node state - which handledReceivedData will give them + updateNodeInfo(fromNum, withBroadcast = false) { + // If the rxTime was not set by the device (because device software was old), guess at a time + val rxTime = if (packet.rxTime != 0) packet.rxTime else currentSecond() - // If the rxTime was not set by the device (because device software was old), guess at a time - val rxTime = if (packet.rxTime != 0) packet.rxTime else currentSecond() - updateNodeInfo(fromNum) { // Update our last seen based on any valid timestamps. If the device didn't provide a timestamp make one updateNodeInfoTime(it, rxTime) it.snr = packet.rxSnr @@ -946,28 +1012,32 @@ class MeshService : Service(), Logging { /// If we just changed our nodedb, we might want to do somethings private fun onNodeDBChanged() { maybeUpdateServiceStatusNotification() - - serviceScope.handledLaunch(Dispatchers.Main) { - setupLocationRequest() - } } - private var locationRequestInterval: Long = 0; private fun setupLocationRequest() { - val desiredInterval: Long = if (myNodeInfo?.hasGPS == true) { - 0L // no requests when device has GPS - } else if (numOnlineNodes < 2) { - 5 * 60 * 1000L // send infrequently, device needs these requests to set its clock - } else { - radioConfig?.preferences?.positionBroadcastSecs?.times(1000L) ?: 5 * 60 * 1000L - } + stopLocationRequests() + val mi = myNodeInfo + val prefs = radioConfig?.preferences + if (mi != null && prefs != null) { + var broadcastSecs = prefs.positionBroadcastSecs - debug("desired location request $desiredInterval, current $locationRequestInterval") + var desiredInterval = if (broadcastSecs == 0) // unset by device, use default + 15 * 60 * 1000L + else + broadcastSecs * 1000L - if (desiredInterval != locationRequestInterval) { - if (locationRequestInterval > 0) stopLocationRequests() - if (desiredInterval > 0) startLocationRequests(desiredInterval) - locationRequestInterval = desiredInterval + if (prefs.locationShare == RadioConfigProtos.LocationSharing.LocDisabled) { + info("GPS location sharing is disabled") + 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") + sendPosition() + } } } @@ -1079,7 +1149,7 @@ class MeshService : Service(), Logging { // claim we have a valid connection still connectionState = ConnectionState.DEVICE_SLEEP startDeviceSleep() - throw ex; // Important to rethrow so that we don't tell the app all is well + throw ex // Important to rethrow so that we don't tell the app all is well } } @@ -1315,13 +1385,45 @@ class MeshService : Service(), Logging { radioConfig = null // prefill the channel array with null channels - channels = Array(myInfo.maxChannels) { - val b = ChannelProtos.Channel.newBuilder() - b.index = it - b.build() - } + channels = fixupChannelList(listOf()) } + /// scan the channel list and make sure it has one PRIMARY channel and is maxChannels long + private fun fixupChannelList(lIn: List): Array { + // When updating old firmware, we will briefly be told that there is zero channels + val maxChannels = + max(myNodeInfo?.maxChannels ?: 8, 8) // If we don't have my node info, assume 8 channels + var l = lIn + while (l.size < maxChannels) { + val b = ChannelProtos.Channel.newBuilder() + b.index = l.size + l += b.build() + } + return l.toTypedArray() + } + + + private fun setRegionOnDevice() { + val curConfigRegion = + radioConfig?.preferences?.region ?: RadioConfigProtos.RegionCode.Unset + + if (curConfigRegion.number != curRegionValue && curRegionValue != RadioConfigProtos.RegionCode.Unset_VALUE) + if (deviceVersion >= minFirmwareVersion) { + info("Telling device to upgrade region") + + // Tell the device to set the new region field (old devices will simply ignore this) + radioConfig?.let { currentConfig -> + val newConfig = currentConfig.toBuilder() + + val newPrefs = currentConfig.preferences.toBuilder() + newPrefs.regionValue = curRegionValue + newConfig.preferences = newPrefs.build() + + sendRadioConfig(newConfig.build()) + } + } else + warn("Device is too old to understand region changes") + } /** * If we are updating nodes we might need to use old (fixed by firmware build) @@ -1356,31 +1458,14 @@ class MeshService : Service(), Logging { } // If nothing was set in our (new style radio preferences, but we now have a valid setting - slam it in) - if (curConfigRegion == RadioConfigProtos.RegionCode.Unset && curRegionValue != RadioConfigProtos.RegionCode.Unset_VALUE) { - if (deviceVersion >= minFirmwareVersion) { - info("Telling device to upgrade region") - - // Tell the device to set the new region field (old devices will simply ignore this) - radioConfig?.let { currentConfig -> - val newConfig = currentConfig.toBuilder() - - val newPrefs = currentConfig.preferences.toBuilder() - newPrefs.regionValue = curRegionValue - newConfig.preferences = newPrefs.build() - - sendRadioConfig(newConfig.build()) - } - } - else - warn("Device is too old to understand region changes") - } + setRegionOnDevice() } } /// If we've received our initial config, our radio settings and all of our channels, send any queueed packets and broadcast connected to clients private fun onHasSettings() { - processEarlyPackets() // send receive any packets that were queued up + processEarlyPackets() // send any packets that were queued up // broadcast an intent with our new connection state serviceBroadcasts.broadcastConnection() @@ -1388,6 +1473,8 @@ class MeshService : Service(), Logging { reportConnection() updateRegion() + + setupLocationRequest() // start sending location packets if needed } private fun handleConfigComplete(configCompleteId: Int) { @@ -1432,13 +1519,13 @@ class MeshService : Service(), Logging { private fun requestRadioConfig() { sendToRadio(newMeshPacketTo(myNodeNum).buildAdminPacket(wantResponse = true) { getRadioRequest = true - }) + }, requireConnected = false) } private fun requestChannel(channelIndex: Int) { sendToRadio(newMeshPacketTo(myNodeNum).buildAdminPacket(wantResponse = true) { getChannelRequest = channelIndex + 1 - }) + }, requireConnected = false) } private fun setChannel(channel: ChannelProtos.Channel) { @@ -1466,48 +1553,41 @@ class MeshService : Service(), Logging { * Must be called from serviceScope. Use sendPositionScoped() for direct calls. */ private fun sendPosition( - lat: Double, - lon: Double, - alt: Int, + lat: Double = 0.0, + lon: Double = 0.0, + alt: Int = 0, destNum: Int = DataPacket.NODENUM_BROADCAST, wantResponse: Boolean = false ) { - debug("Sending our position to=$destNum lat=$lat, lon=$lon, alt=$alt") - - val position = MeshProtos.Position.newBuilder().also { - it.longitudeI = Position.degI(lon) - it.latitudeI = Position.degI(lat) - - it.altitude = alt - it.time = currentSecond() // Include our current timestamp - }.build() - - // Also update our own map for our nodenum, by handling the packet just like packets from other users - handleReceivedPosition(myNodeInfo!!.myNodeNum, position) - - val fullPacket = - newMeshPacketTo(destNum).buildMeshPacket(priority = MeshProtos.MeshPacket.Priority.BACKGROUND) { - // Use the new position as data format - portnumValue = Portnums.PortNum.POSITION_APP_VALUE - payload = position.toByteString() - - this.wantResponse = wantResponse - } - - // send the packet into the mesh - sendToRadio(fullPacket) - } - - private fun sendPositionScoped( - lat: Double, - lon: Double, - alt: Int, - destNum: Int = DataPacket.NODENUM_BROADCAST, - wantResponse: Boolean = false - ) = serviceScope.handledLaunch { try { - sendPosition(lat, lon, alt, destNum, wantResponse) - } catch (ex: RadioNotConnectedException) { + val mi = myNodeInfo + if (mi != null) { + debug("Sending our position/time to=$destNum lat=$lat, lon=$lon, alt=$alt") + + val position = MeshProtos.Position.newBuilder().also { + it.longitudeI = Position.degI(lon) + it.latitudeI = Position.degI(lat) + + it.altitude = alt + it.time = currentSecond() // Include our current timestamp + }.build() + + // Also update our own map for our nodenum, by handling the packet just like packets from other users + handleReceivedPosition(mi.myNodeNum, position) + + val fullPacket = + newMeshPacketTo(destNum).buildMeshPacket(priority = MeshProtos.MeshPacket.Priority.BACKGROUND) { + // Use the new position as data format + portnumValue = Portnums.PortNum.POSITION_APP_VALUE + payload = position.toByteString() + + this.wantResponse = wantResponse + } + + // send the packet into the mesh + sendToRadio(fullPacket) + } + } catch (ex: BLEException) { warn("Ignoring disconnected radio during gps location update") } } @@ -1539,9 +1619,7 @@ class MeshService : Service(), Logging { val myNode = myNodeInfo if (myNode != null) { - - val myInfo = toNodeInfo(myNode.myNodeNum) - if (longName == myInfo.user?.longName && shortName == myInfo.user?.shortName) + if (longName == localNodeInfo?.user?.longName && shortName == localNodeInfo?.user?.shortName) debug("Ignoring nop owner change") else { debug("SetOwner $myId : ${longName.anonymize} : $shortName") @@ -1627,8 +1705,10 @@ class MeshService : Service(), Logging { } else { debug("Creating firmware update coroutine") updateJob = serviceScope.handledLaunch { - debug("Starting firmware update coroutine") - SoftwareUpdateService.doUpdate(this@MeshService, safe, filename) + exceptionReporter { + debug("Starting firmware update coroutine") + SoftwareUpdateService.doUpdate(this@MeshService, safe, filename) + } } } } @@ -1660,7 +1740,7 @@ class MeshService : Service(), Logging { offlineSentPackets.add(p) } - val binder = object : IMeshService.Stub() { + private val binder = object : IMeshService.Stub() { override fun setDeviceAddress(deviceAddr: String?) = toRemoteExceptions { debug("Passing through device change to radio service: ${deviceAddr.anonymize}") @@ -1684,6 +1764,12 @@ class MeshService : Service(), Logging { } override fun getUpdateStatus(): Int = SoftwareUpdateService.progress + override fun getRegion(): Int = curRegionValue + + override fun setRegion(regionCode: Int) = toRemoteExceptions { + curRegionValue = regionCode + setRegionOnDevice() + } override fun startFirmwareUpdate() = toRemoteExceptions { doFirmwareUpdate() @@ -1788,6 +1874,5 @@ class MeshService : Service(), Logging { } fun updateNodeInfoTime(it: NodeInfo, rxTime: Int) { - if (it.position?.time == null || it.position?.time!! < rxTime) - it.position = it.position?.copy(time = rxTime) + it.lastHeard = rxTime } diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshServiceBroadcasts.kt b/app/src/main/java/com/geeksville/mesh/service/MeshServiceBroadcasts.kt index 6d782269e..dd10134b5 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshServiceBroadcasts.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshServiceBroadcasts.kt @@ -5,7 +5,6 @@ import android.content.Intent import android.os.Parcelable import com.geeksville.mesh.DataPacket import com.geeksville.mesh.NodeInfo -import com.geeksville.mesh.Portnums class MeshServiceBroadcasts( private val context: Context, @@ -18,7 +17,12 @@ class MeshServiceBroadcasts( */ fun broadcastReceivedData(payload: DataPacket) { - explicitBroadcast(Intent(MeshService.actionReceived(payload.dataType)).putExtra(EXTRA_PAYLOAD, payload)) + explicitBroadcast( + Intent(MeshService.actionReceived(payload.dataType)).putExtra( + EXTRA_PAYLOAD, + payload + ) + ) /* // For the time being we ALSO broadcast using old ACTION_RECEIVED_DATA field for any oldschool opaque packets diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshServiceLocationCallback.kt b/app/src/main/java/com/geeksville/mesh/service/MeshServiceLocationCallback.kt index ac04a0f24..bb7e4bc24 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshServiceLocationCallback.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshServiceLocationCallback.kt @@ -21,8 +21,7 @@ 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 + private val getNodeNum: GetNodeNum ) : LocationCallback() { companion object { @@ -40,7 +39,8 @@ class MeshServiceLocationCallback( try { // Do we want to broadcast this position globally, or are we just telling the local node what its current position is ( - val shouldBroadcast = isAllowedToSend() + val shouldBroadcast = + true // no need to rate limit, because we are just sending at the interval requested by the preferences val destinationNumber = if (shouldBroadcast) DataPacket.NODENUM_BROADCAST else getNodeNum() @@ -69,17 +69,4 @@ class MeshServiceLocationCallback( wantResponse // wantResponse? ) } - - /** - * 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 - } } diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotifications.kt b/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotifications.kt index 5e02067a4..d56ad88be 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotifications.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotifications.kt @@ -16,12 +16,9 @@ import androidx.annotation.RequiresApi import androidx.core.app.NotificationCompat import androidx.core.content.ContextCompat import androidx.core.graphics.drawable.DrawableCompat -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.ui.SLogging -import com.geeksville.mesh.utf8 import java.io.Closeable @@ -29,6 +26,7 @@ class MeshServiceNotifications( private val context: Context ) : Closeable { private val notificationManager: NotificationManager get() = context.notificationManager + // We have two notification channels: one for general service status and another one for messages val notifyId = 101 private val messageNotifyId = 102 @@ -96,12 +94,16 @@ class MeshServiceNotifications( } fun updateServiceStateNotification(summaryString: String) = - notificationManager.notify(notifyId, - createServiceStateNotification(summaryString)) + notificationManager.notify( + notifyId, + createServiceStateNotification(summaryString) + ) fun updateMessageNotification(name: String, message: String) = - notificationManager.notify(messageNotifyId, - createMessageNotifcation(name, message)) + notificationManager.notify( + messageNotifyId, + createMessageNotifcation(name, message) + ) private val openAppIntent: PendingIntent by lazy { PendingIntent.getActivity(context, 0, Intent(context, MainActivity::class.java), 0) @@ -126,7 +128,7 @@ class MeshServiceNotifications( return bitmap } - fun commonBuilder(channel: String) : NotificationCompat.Builder { + fun commonBuilder(channel: String): NotificationCompat.Builder { val builder = NotificationCompat.Builder(context, channel) .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) .setContentIntent(openAppIntent) diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshServiceSettingsData.kt b/app/src/main/java/com/geeksville/mesh/service/MeshServiceSettingsData.kt index c1477b718..b7776acd8 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshServiceSettingsData.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshServiceSettingsData.kt @@ -1,6 +1,9 @@ package com.geeksville.mesh.service -import com.geeksville.mesh.* +import com.geeksville.mesh.DataPacket +import com.geeksville.mesh.MyNodeInfo +import com.geeksville.mesh.NodeInfo +import com.geeksville.mesh.RadioConfigProtos import kotlinx.serialization.Serializable /// Our saved preferences as stored on disk diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshServiceStarter.kt b/app/src/main/java/com/geeksville/mesh/service/MeshServiceStarter.kt index d765c21aa..f63b28ece 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshServiceStarter.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshServiceStarter.kt @@ -3,18 +3,14 @@ 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 androidx.work.* import com.geeksville.andlib.BuildConfig import java.util.concurrent.TimeUnit /** * Helper that calls MeshService.startService() */ -public class ServiceStarter( +class ServiceStarter( appContext: Context, workerParams: WorkerParameters ) : Worker(appContext, workerParams) { diff --git a/app/src/main/java/com/geeksville/mesh/service/MockInterface.kt b/app/src/main/java/com/geeksville/mesh/service/MockInterface.kt index 80d4495be..f7bdef9df 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MockInterface.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MockInterface.kt @@ -1,5 +1,8 @@ package com.geeksville.mesh.service +import android.content.Context +import com.geeksville.android.BuildUtils +import com.geeksville.android.GeeksvilleApplication import com.geeksville.android.Logging import com.geeksville.mesh.* import com.geeksville.mesh.model.getInitials @@ -8,9 +11,18 @@ import okhttp3.internal.toHexString /** A simulated interface that is used for testing in the simulator */ class MockInterface(private val service: RadioInterfaceService) : Logging, IRadioInterface { - companion object : Logging { + companion object : Logging, InterfaceFactory('m') { + override fun createInterface( + service: RadioInterfaceService, + rest: String + ): IRadioInterface = MockInterface(service) - const val interfaceName = "m" + override fun addressValid(context: Context, rest: String): Boolean = + BuildUtils.isEmulator || ((context.applicationContext as GeeksvilleApplication).isInTestLab) + + init { + registerFactory() + } } private var messageCount = 50 diff --git a/app/src/main/java/com/geeksville/mesh/service/NopInterface.kt b/app/src/main/java/com/geeksville/mesh/service/NopInterface.kt index f4e462349..3b24ad26d 100644 --- a/app/src/main/java/com/geeksville/mesh/service/NopInterface.kt +++ b/app/src/main/java/com/geeksville/mesh/service/NopInterface.kt @@ -1,6 +1,19 @@ package com.geeksville.mesh.service +import com.geeksville.android.Logging + class NopInterface : IRadioInterface { + companion object : Logging, InterfaceFactory('n') { + override fun createInterface( + service: RadioInterfaceService, + rest: String + ): IRadioInterface = NopInterface() + + init { + registerFactory() + } + } + override fun handleSendToRadio(p: ByteArray) { } diff --git a/app/src/main/java/com/geeksville/mesh/service/RadioInterfaceService.kt b/app/src/main/java/com/geeksville/mesh/service/RadioInterfaceService.kt index 112f7010f..9467187b3 100644 --- a/app/src/main/java/com/geeksville/mesh/service/RadioInterfaceService.kt +++ b/app/src/main/java/com/geeksville/mesh/service/RadioInterfaceService.kt @@ -26,7 +26,6 @@ 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... @@ -50,9 +49,11 @@ class RadioInterfaceService : Service(), Logging { */ const val RADIO_CONNECTED_ACTION = "$prefix.CONNECT_CHANGED" - const val DEVADDR_KEY_OLD = "devAddr" const val DEVADDR_KEY = "devAddr2" // the new name for devaddr + /// We keep this var alive so that the following factory objects get created and not stripped during the android build + private val factories = arrayOf(BluetoothInterface, SerialInterface, TCPInterface, MockInterface, NopInterface) + /// This is public only so that SimRadio can bootstrap our message flow fun broadcastReceivedFromRadio(context: Context, payload: ByteArray) { val intent = Intent(RECEIVE_FROMRADIO_ACTION) @@ -77,27 +78,13 @@ class RadioInterfaceService : Service(), Logging { val prefs = getPrefs(context) var address = prefs.getString(DEVADDR_KEY, null) - if (address == null) { /// Check for the old preferences name we used to use - var rest = prefs.getString(DEVADDR_KEY_OLD, null) - if(rest == "null") - rest = null - - if (rest != null) - address = BluetoothInterface.toInterfaceName(rest) // Add the bluetooth prefix - } - // 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 && isMockInterfaceAvailable(context)) - address = MockInterface.interfaceName + if (address == null && MockInterface.addressValid(context, "")) + address = MockInterface.prefix.toString() return address } - /** return true if we should show the mock interface on this device - * (ie are we in an emulator or in testlab - */ - fun isMockInterfaceAvailable(context: Context) = isEmulator || ((context.applicationContext as GeeksvilleApplication).isInTestLab) - /** Like getDeviceAddress, but filtered to return only devices we are currently bonded with * * at @@ -114,13 +101,7 @@ class RadioInterfaceService : Service(), Logging { if (address != null) { val c = address[0] val rest = address.substring(1) - val isValid = when (c) { - 'x' -> BluetoothInterface.addressValid(context, rest) - 's' -> SerialInterface.addressValid(context, rest) - 'n' -> true - 'm' -> true - else -> TODO("Unexpected interface type $c") - } + val isValid = InterfaceFactory.getFactory(c)?.addressValid(context, rest) ?: false if (!isValid) return null } @@ -141,8 +122,7 @@ class RadioInterfaceService : Service(), Logging { */ var serviceScope = CoroutineScope(Dispatchers.IO + Job()) - private val nopIf = NopInterface() - private var radioIf: IRadioInterface = nopIf + private var radioIf: IRadioInterface = NopInterface() /** true if we have started our interface * @@ -221,13 +201,13 @@ class RadioInterfaceService : Service(), Logging { } override fun onBind(intent: Intent?): IBinder? { - return binder; + return binder } /** Start our configured interface (if it isn't already running) */ private fun startInterface() { - if (radioIf != nopIf) + if (radioIf !is NopInterface) warn("Can't start interface - $radioIf is already running") else { val address = getBondedDeviceAddress(this) @@ -244,26 +224,17 @@ class RadioInterfaceService : Service(), Logging { val c = address[0] val rest = address.substring(1) - radioIf = when (c) { - 'x' -> BluetoothInterface(this, rest) - 's' -> SerialInterface(this, rest) - 'm' -> MockInterface(this) - 'n' -> nopIf - else -> { - errormsg("Unexpected radio interface type") - nopIf - } - } + radioIf = + InterfaceFactory.getFactory(c)?.createInterface(this, rest) ?: NopInterface() } } } - private fun stopInterface() { val r = radioIf info("stopping interface $r") isStarted = false - radioIf = nopIf + radioIf = NopInterface() r.close() // cancel any old jobs and get ready for the new ones @@ -276,7 +247,7 @@ class RadioInterfaceService : Service(), Logging { receivedPacketsLog.close() // Don't broadcast disconnects if we were just using the nop device - if (r != nopIf) + if (r !is NopInterface) onDisconnect(isPermanent = true) // Tell any clients we are now offline } @@ -307,8 +278,6 @@ class RadioInterfaceService : Service(), Logging { debug("Setting bonded device to ${address.anonymize}") getPrefs(this).edit(commit = true) { - this.remove(DEVADDR_KEY_OLD) // remove any old version of the key - if (address == null) this.remove(DEVADDR_KEY) else diff --git a/app/src/main/java/com/geeksville/mesh/service/SafeBluetooth.kt b/app/src/main/java/com/geeksville/mesh/service/SafeBluetooth.kt index ee460c9ca..65ae1ddfa 100644 --- a/app/src/main/java/com/geeksville/mesh/service/SafeBluetooth.kt +++ b/app/src/main/java/com/geeksville/mesh/service/SafeBluetooth.kt @@ -229,7 +229,7 @@ class SafeBluetooth(private val context: Context, private val device: BluetoothD if (reliable != null) if (!characteristic.value.contentEquals(reliable)) { errormsg("A reliable write failed!") - gatt.abortReliableWrite(); + gatt.abortReliableWrite() completeWork( STATUS_RELIABLE_WRITE_FAILED, characteristic @@ -325,7 +325,7 @@ class SafeBluetooth(private val context: Context, private val device: BluetoothD if (newWork.timeoutMillis != 0L) { activeTimeout = serviceScope.launch { - debug("Starting failsafe timer ${newWork.timeoutMillis}") + // debug("Starting failsafe timer ${newWork.timeoutMillis}") delay(newWork.timeoutMillis) errormsg("Failsafe BLE timer expired!") completeWork( @@ -415,7 +415,7 @@ class SafeBluetooth(private val context: Context, private val device: BluetoothD if (work == null) warn("wor completed, but we already killed it via failsafetimer? status=$status, res=$res") else { - debug("work ${work.tag} is completed, resuming status=$status, res=$res") + // debug("work ${work.tag} is completed, resuming status=$status, res=$res") if (status != 0) work.completion.resumeWithException( BLEStatusException( @@ -773,7 +773,7 @@ class SafeBluetooth(private val context: Context, private val device: BluetoothD closeGatt() - failAllWork(BLEException("Connection closing")) + failAllWork(BLEConnectionClosing()) } /** diff --git a/app/src/main/java/com/geeksville/mesh/service/SerialInterface.kt b/app/src/main/java/com/geeksville/mesh/service/SerialInterface.kt index 00b802527..5710f596c 100644 --- a/app/src/main/java/com/geeksville/mesh/service/SerialInterface.kt +++ b/app/src/main/java/com/geeksville/mesh/service/SerialInterface.kt @@ -16,19 +16,27 @@ import com.hoho.android.usbserial.driver.UsbSerialProber import com.hoho.android.usbserial.util.SerialInputOutputManager -class SerialInterface(private val service: RadioInterfaceService, val address: String) : Logging, - IRadioInterface, SerialInputOutputManager.Listener { - companion object : Logging { - private const val START1 = 0x94.toByte() - private const val START2 = 0xc3.toByte() - private const val MAX_TO_FROM_RADIO_SIZE = 512 +/** + * An interface that assumes we are talking to a meshtastic device via USB serial + */ +class SerialInterface(service: RadioInterfaceService, private val address: String) : + StreamInterface(service), Logging, SerialInputOutputManager.Listener { + companion object : Logging, InterfaceFactory('s') { + override fun createInterface( + service: RadioInterfaceService, + rest: String + ): IRadioInterface = SerialInterface(service, rest) + + init { + registerFactory() + } /** * according to https://stackoverflow.com/questions/12388914/usb-device-access-pop-up-suppression/15151075#15151075 * we should never ask for USB permissions ourselves, instead we should rely on the external dialog printed by the system. If * we do that the system will remember we have accesss */ - val assumePermission = true + const val assumePermission = true fun toInterfaceName(deviceName: String) = "s$deviceName" @@ -41,14 +49,14 @@ class SerialInterface(private val service: RadioInterfaceService, val address: S return drivers } - fun addressValid(context: Context, rest: String): Boolean { + override fun addressValid(context: Context, rest: String): Boolean { findSerial(context, rest)?.let { d -> return assumePermission || context.usbManager.hasPermission(d.device) } return false } - fun findSerial(context: Context, rest: String): UsbSerialDriver? { + private fun findSerial(context: Context, rest: String): UsbSerialDriver? { val drivers = findDrivers(context) return if (drivers.isEmpty()) @@ -61,7 +69,7 @@ class SerialInterface(private val service: RadioInterfaceService, val address: S private var uart: UsbSerialDriver? = null private var ioManager: SerialInputOutputManager? = null - var usbReceiver = object : BroadcastReceiver() { + private var usbReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) = exceptionReporter { if (UsbManager.ACTION_USB_DEVICE_DETACHED == intent.action) { @@ -85,16 +93,6 @@ class SerialInterface(private val service: RadioInterfaceService, val address: S } } - private val debugLineBuf = kotlin.text.StringBuilder() - - /** The index of the next byte we are hoping to receive */ - private var ptr = 0 - - /** The two halves of our length */ - private var msb = 0 - private var lsb = 0 - private var packetLen = 0 - init { val filter = IntentFilter() filter.addAction(UsbManager.ACTION_USB_DEVICE_DETACHED) @@ -105,16 +103,15 @@ class SerialInterface(private val service: RadioInterfaceService, val address: S } override fun close() { - debug("Closing serial port for good") service.unregisterReceiver(usbReceiver) - onDeviceDisconnect(true) + super.close() } /** Tell MeshService our device has gone away, but wait for it to come back * * @param waitForStopped if true we should wait for the manager to finish - must be false if called from inside the manager callbacks * */ - fun onDeviceDisconnect(waitForStopped: Boolean) { + override fun onDeviceDisconnect(waitForStopped: Boolean) { ignoreException { ioManager?.let { debug("USB device disconnected, but it might come back") @@ -143,10 +140,10 @@ class SerialInterface(private val service: RadioInterfaceService, val address: S } } - service.onDisconnect(isPermanent = true) // if USB device disconnects it is definitely permantently gone, not sleeping) + super.onDeviceDisconnect(waitForStopped) } - private fun connect() { + override fun connect() { val manager = service.getSystemService(Context.USB_SERVICE) as UsbManager val device = findSerial(service, address) @@ -175,101 +172,20 @@ class SerialInterface(private val service: RadioInterfaceService, val address: S thread.name = "serial reader" thread.start() // No need to keep reference to thread around, we quit by asking the ioManager to quit - // Before telling mesh service, send a few START1s to wake a sleeping device - val wakeBytes = byteArrayOf(START1, START1, START1, START1) - io.writeAsync(wakeBytes) - // Now tell clients they can (finally use the api) - service.onConnect() + super.connect() } } else { errormsg("Can't find device") } } - override fun handleSendToRadio(p: ByteArray) { - // This method is called from a continuation and it might show up late, so check for uart being null - - val header = ByteArray(4) - header[0] = START1 - header[1] = START2 - header[2] = (p.size shr 8).toByte() - header[3] = (p.size and 0xff).toByte() + override fun sendBytes(p: ByteArray) { ioManager?.apply { - writeAsync(header) writeAsync(p) } } - - /** Print device serial debug output somewhere */ - private fun debugOut(b: Byte) { - when (val c = b.toChar()) { - '\r' -> { - } // ignore - '\n' -> { - debug("DeviceLog: $debugLineBuf") - debugLineBuf.clear() - } - else -> - debugLineBuf.append(c) - } - } - - private val rxPacket = ByteArray(MAX_TO_FROM_RADIO_SIZE) - - private fun readChar(c: Byte) { - // Assume we will be advancing our pointer - var nextPtr = ptr + 1 - - fun lostSync() { - errormsg("Lost protocol sync") - nextPtr = 0 - } - - /// Deliver our current packet and restart our reader - fun deliverPacket() { - val buf = rxPacket.copyOf(packetLen) - service.handleFromRadio(buf) - - nextPtr = 0 // Start parsing the next packet - } - - when (ptr) { - 0 -> // looking for START1 - if (c != START1) { - debugOut(c) - nextPtr = 0 // Restart from scratch - } - 1 -> // Looking for START2 - if (c != START2) - lostSync() // Restart from scratch - 2 -> // Looking for MSB of our 16 bit length - msb = c.toInt() and 0xff - 3 -> { // Looking for LSB of our 16 bit length - lsb = c.toInt() and 0xff - - // We've read our header, do one big read for the packet itself - packetLen = (msb shl 8) or lsb - if (packetLen > MAX_TO_FROM_RADIO_SIZE) - lostSync() // If packet len is too long, the bytes must have been corrupted, start looking for START1 again - else if (packetLen == 0) - deliverPacket() // zero length packets are valid and should be delivered immediately (because there won't be a next byte of payload) - } - else -> { - // We are looking at the packet bytes now - rxPacket[ptr - 4] = c - - // Note: we have to check if ptr +1 is equal to packet length (for example, for a 1 byte packetlen, this code will be run with ptr of4 - if (ptr - 4 + 1 == packetLen) { - deliverPacket() - } - } - } - ptr = nextPtr - } - - /** * Called when [SerialInputOutputManager.run] aborts due to an error. */ diff --git a/app/src/main/java/com/geeksville/mesh/service/SoftwareUpdateService.kt b/app/src/main/java/com/geeksville/mesh/service/SoftwareUpdateService.kt index b9050105e..8326a5168 100644 --- a/app/src/main/java/com/geeksville/mesh/service/SoftwareUpdateService.kt +++ b/app/src/main/java/com/geeksville/mesh/service/SoftwareUpdateService.kt @@ -17,7 +17,7 @@ import java.util.zip.CRC32 /** * Some misformatted ESP32s have problems */ -class DeviceRejectedException() : BLEException("Device rejected filesize") +class DeviceRejectedException : BLEException("Device rejected filesize") /** * Move this somewhere as a generic network byte order function @@ -211,18 +211,18 @@ class SoftwareUpdateService : JobIntentService(), Logging { * @param isAppload if false, we don't report failure indications (because we consider spiffs non critical for now). But do report to analytics */ fun sendProgress(context: Context, p: Int, isAppload: Boolean) { - if(!isAppload && p < 0) - reportError("Error while writing spiffs $progress") // See if this is happening in the wild + if (!isAppload && p < 0) + errormsg("Error while writing spiffs $p") // treat errors writing spiffs as non fatal for now (user partition probably missized and most people don't need it) + else + if (progress != p) { + progress = p - if(progress != p && (p >= 0 || isAppload)) { - progress = p - - val intent = Intent(ACTION_UPDATE_PROGRESS).putExtra( - EXTRA_PROGRESS, - p - ) - context.sendBroadcast(intent) - } + val intent = Intent(ACTION_UPDATE_PROGRESS).putExtra( + EXTRA_PROGRESS, + p + ) + context.sendBroadcast(intent) + } } /** Return true if we thing the firmwarte shoulde be updated @@ -293,14 +293,12 @@ class SoftwareUpdateService : JobIntentService(), Logging { // we must attempt spiffs first, because if we update the appload the device will reboot afterwards try { assets.spiffs?.let { doUpdate(context, sync, it, FLASH_REGION_SPIFFS) } - } - catch(_: BLECharacteristicNotFoundException) { + } catch (_: BLECharacteristicNotFoundException) { // If we can't update spiffs (because not supported by target), do not fail errormsg("Ignoring failure to update spiffs on old appload") - } - catch(_: DeviceRejectedException) { - // the spi filesystem of this device is malformatted - reportError("Device rejected invalid spiffs partition") + } catch (_: DeviceRejectedException) { + // the spi filesystem of this device is malformatted, fail silently because most users don't need the web server + errormsg("Device rejected invalid spiffs partition") } assets.appLoad?.let { doUpdate(context, sync, it, FLASH_REGION_APPLOAD) } @@ -315,9 +313,14 @@ class SoftwareUpdateService : JobIntentService(), Logging { * A public function so that if you have your own SafeBluetooth connection already open * you can use it for the software update. */ - private fun doUpdate(context: Context, sync: SafeBluetooth, assetName: String, flashRegion: Int = FLASH_REGION_APPLOAD) { + private fun doUpdate( + context: Context, + sync: SafeBluetooth, + assetName: String, + flashRegion: Int = FLASH_REGION_APPLOAD + ) { val isAppload = flashRegion == FLASH_REGION_APPLOAD - + try { val g = sync.gatt!! val service = g.services.find { it.uuid == SW_UPDATE_UUID } @@ -332,7 +335,7 @@ class SoftwareUpdateService : JobIntentService(), Logging { info("Starting firmware update for $assetName, flash region $flashRegion") - sendProgress(context,0, isAppload) + sendProgress(context, 0, isAppload) val totalSizeDesc = getCharacteristic(SW_UPDATE_TOTALSIZE_CHARACTER) val dataDesc = getCharacteristic(SW_UPDATE_DATA_CHARACTER) val crc32Desc = getCharacteristic(SW_UPDATE_CRC32_CHARACTER) @@ -347,10 +350,9 @@ class SoftwareUpdateService : JobIntentService(), Logging { updateRegionDesc, toNetworkByteArray(flashRegion, BluetoothGattCharacteristic.FORMAT_UINT8) ) - } - catch(ex: BLECharacteristicNotFoundException) { + } catch (ex: BLECharacteristicNotFoundException) { errormsg("Can't set flash programming region (old appload?") - if(flashRegion != FLASH_REGION_APPLOAD) { + if (flashRegion != FLASH_REGION_APPLOAD) { throw ex } warn("Ignoring setting appload flashRegion") @@ -374,13 +376,21 @@ class SoftwareUpdateService : JobIntentService(), Logging { throw DeviceRejectedException() // Send all the blocks + var oldProgress = -1 // used to limit # of log spam while (firmwareNumSent < firmwareSize) { // If we are doing the spiffs partition, we limit progress to a max of 50%, so that the user doesn't think we are done // yet - val maxProgress = if(flashRegion != FLASH_REGION_APPLOAD) + val maxProgress = if (flashRegion != FLASH_REGION_APPLOAD) 50 else 100 - sendProgress(context, firmwareNumSent * maxProgress / firmwareSize, isAppload) - debug("sending block ${progress}%") + sendProgress( + context, + firmwareNumSent * maxProgress / firmwareSize, + isAppload + ) + if (progress != oldProgress) { + debug("sending block ${progress}%") + oldProgress = progress + } var blockSize = 512 - 3 // Max size MTU excluding framing if (blockSize > firmwareStream.available()) diff --git a/app/src/main/java/com/geeksville/mesh/service/StreamInterface.kt b/app/src/main/java/com/geeksville/mesh/service/StreamInterface.kt new file mode 100644 index 000000000..717805ef6 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/service/StreamInterface.kt @@ -0,0 +1,136 @@ +package com.geeksville.mesh.service + +import com.geeksville.android.Logging + + +/** + * An interface that assumes we are talking to a meshtastic device over some sort of stream connection (serial or TCP probably) + */ +abstract class StreamInterface(protected val service: RadioInterfaceService) : + Logging, + IRadioInterface { + companion object : Logging { + private const val START1 = 0x94.toByte() + private const val START2 = 0xc3.toByte() + private const val MAX_TO_FROM_RADIO_SIZE = 512 + } + + private val debugLineBuf = kotlin.text.StringBuilder() + + /** The index of the next byte we are hoping to receive */ + private var ptr = 0 + + /** The two halves of our length */ + private var msb = 0 + private var lsb = 0 + private var packetLen = 0 + + override fun close() { + debug("Closing stream for good") + onDeviceDisconnect(true) + } + + /** Tell MeshService our device has gone away, but wait for it to come back + * + * @param waitForStopped if true we should wait for the manager to finish - must be false if called from inside the manager callbacks + * */ + protected open fun onDeviceDisconnect(waitForStopped: Boolean) { + service.onDisconnect(isPermanent = true) // if USB device disconnects it is definitely permantently gone, not sleeping) + } + + protected open fun connect() { + // Before telling mesh service, send a few START1s to wake a sleeping device + val wakeBytes = byteArrayOf(START1, START1, START1, START1) + sendBytes(wakeBytes) + + // Now tell clients they can (finally use the api) + service.onConnect() + } + + abstract fun sendBytes(p: ByteArray) + + /// If subclasses need to flash at the end of a packet they can implement + open fun flushBytes() {} + + override fun handleSendToRadio(p: ByteArray) { + // This method is called from a continuation and it might show up late, so check for uart being null + + val header = ByteArray(4) + header[0] = START1 + header[1] = START2 + header[2] = (p.size shr 8).toByte() + header[3] = (p.size and 0xff).toByte() + + sendBytes(header) + sendBytes(p) + flushBytes() + } + + + /** Print device serial debug output somewhere */ + private fun debugOut(b: Byte) { + when (val c = b.toChar()) { + '\r' -> { + } // ignore + '\n' -> { + debug("DeviceLog: $debugLineBuf") + debugLineBuf.clear() + } + else -> + debugLineBuf.append(c) + } + } + + private val rxPacket = ByteArray(MAX_TO_FROM_RADIO_SIZE) + + protected fun readChar(c: Byte) { + // Assume we will be advancing our pointer + var nextPtr = ptr + 1 + + fun lostSync() { + errormsg("Lost protocol sync") + nextPtr = 0 + } + + /// Deliver our current packet and restart our reader + fun deliverPacket() { + val buf = rxPacket.copyOf(packetLen) + service.handleFromRadio(buf) + + nextPtr = 0 // Start parsing the next packet + } + + when (ptr) { + 0 -> // looking for START1 + if (c != START1) { + debugOut(c) + nextPtr = 0 // Restart from scratch + } + 1 -> // Looking for START2 + if (c != START2) + lostSync() // Restart from scratch + 2 -> // Looking for MSB of our 16 bit length + msb = c.toInt() and 0xff + 3 -> { // Looking for LSB of our 16 bit length + lsb = c.toInt() and 0xff + + // We've read our header, do one big read for the packet itself + packetLen = (msb shl 8) or lsb + if (packetLen > MAX_TO_FROM_RADIO_SIZE) + lostSync() // If packet len is too long, the bytes must have been corrupted, start looking for START1 again + else if (packetLen == 0) + deliverPacket() // zero length packets are valid and should be delivered immediately (because there won't be a next byte of payload) + } + else -> { + // We are looking at the packet bytes now + rxPacket[ptr - 4] = c + + // Note: we have to check if ptr +1 is equal to packet length (for example, for a 1 byte packetlen, this code will be run with ptr of4 + if (ptr - 4 + 1 == packetLen) { + deliverPacket() + } + } + } + ptr = nextPtr + } +} \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/service/TCPInterface.kt b/app/src/main/java/com/geeksville/mesh/service/TCPInterface.kt new file mode 100644 index 000000000..33760eeea --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/service/TCPInterface.kt @@ -0,0 +1,102 @@ +package com.geeksville.mesh.service + +import com.geeksville.android.Logging +import com.geeksville.util.Exceptions +import java.io.BufferedOutputStream +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.net.InetAddress +import java.net.Socket +import java.net.SocketTimeoutException +import kotlin.concurrent.thread + + +class TCPInterface(service: RadioInterfaceService, private val address: String) : + StreamInterface(service) { + + companion object : Logging, InterfaceFactory('t') { + override fun createInterface( + service: RadioInterfaceService, + rest: String + ): IRadioInterface = TCPInterface(service, rest) + + init { + registerFactory() + } + } + + var socket: Socket? = null + lateinit var outStream: OutputStream + lateinit var inStream: InputStream + + init { + connect() + } + + override fun sendBytes(p: ByteArray) { + outStream.write(p) + } + + override fun flushBytes() { + outStream.flush() + } + + override fun onDeviceDisconnect(waitForStopped: Boolean) { + val s = socket + if (s != null) { + debug("Closing TCP socket") + socket = null + outStream.close() + inStream.close() + s.close() + } + super.onDeviceDisconnect(waitForStopped) + } + + override fun connect() { + //here you must put your computer's IP address. + //here you must put your computer's IP address. + + // No need to keep a reference to this thread - it will exit when we close inStream + thread(start = true, isDaemon = true, name = "TCP reader") { + try { + val a = InetAddress.getByName(address) + debug("TCP connecting to $address") + + //create a socket to make the connection with the server + val port = 4403 + val s = Socket(a, port) + s.tcpNoDelay = true + s.soTimeout = 500 + socket = s + outStream = BufferedOutputStream(s.getOutputStream()) + inStream = s.getInputStream() + + // Note: we call the super method FROM OUR NEW THREAD + super.connect() + + while (true) { + try { + val c = inStream.read() + if (c == -1) { + warn("Got EOF on TCP stream") + onDeviceDisconnect(false) + break + } else + readChar(c.toByte()) + } catch (ex: SocketTimeoutException) { + // Ignore and start another read + } + } + } catch (ex: IOException) { + errormsg("IOException in TCP reader: $ex") // FIXME, show message to user + onDeviceDisconnect(false) + } catch (ex: Throwable) { + Exceptions.report(ex, "Exception in TCP reader") + onDeviceDisconnect(false) + } + debug("Exiting TCP reader") + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/ui/ChannelFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/ChannelFragment.kt index 9ca623088..e006ef038 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/ChannelFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/ChannelFragment.kt @@ -1,5 +1,6 @@ package com.geeksville.mesh.ui +import android.content.ActivityNotFoundException import android.content.Intent import android.graphics.ColorMatrix import android.graphics.ColorMatrixColorFilter @@ -18,7 +19,6 @@ import com.geeksville.android.Logging import com.geeksville.android.hideKeyboard import com.geeksville.mesh.AppOnlyProtos import com.geeksville.mesh.ChannelProtos -import com.geeksville.mesh.MeshProtos import com.geeksville.mesh.R import com.geeksville.mesh.databinding.ChannelFragmentBinding import com.geeksville.mesh.model.Channel @@ -50,6 +50,7 @@ fun ImageView.setOpaque() { class ChannelFragment : ScreenFragment("Channel"), Logging { private var _binding: ChannelFragmentBinding? = null + // This property is only valid between onCreateView and onDestroyView. private val binding get() = _binding!! @@ -81,6 +82,12 @@ class ChannelFragment : ScreenFragment("Channel"), Logging { val channels = model.channels.value val channel = channels?.primaryChannel + val connected = model.isConnected.value == MeshService.ConnectionState.CONNECTED + + // Only let buttons work if we are connected to the radio + binding.shareButton.isEnabled = connected + binding.resetButton.isEnabled = connected && Channel.default != channel + binding.editableCheckbox.isChecked = false // start locked if (channel != null) { binding.qrView.visibility = View.VISIBLE @@ -89,7 +96,6 @@ class ChannelFragment : ScreenFragment("Channel"), Logging { // For now, we only let the user edit/save channels while the radio is awake - because the service // doesn't cache radioconfig writes. - val connected = model.isConnected.value == MeshService.ConnectionState.CONNECTED binding.editableCheckbox.isEnabled = connected binding.qrView.setImageBitmap(channels.getChannelQR()) @@ -138,8 +144,38 @@ class ChannelFragment : ScreenFragment("Channel"), Logging { type = "text/plain" } - val shareIntent = Intent.createChooser(sendIntent, null) - requireActivity().startActivity(shareIntent) + try { + val shareIntent = Intent.createChooser(sendIntent, null) + requireActivity().startActivity(shareIntent) + } catch (ex: ActivityNotFoundException) { + Snackbar.make( + binding.shareButton, + R.string.no_app_found, + Snackbar.LENGTH_SHORT + ).show() + } + } + } + + /// Send new channel settings to the device + private fun installSettings(newChannel: ChannelProtos.ChannelSettings) { + val newSet = + ChannelSet(AppOnlyProtos.ChannelSet.newBuilder().addSettings(newChannel).build()) + // Try to change the radio, if it fails, tell the user why and throw away their redits + try { + model.setChannels(newSet) + // Since we are writing to radioconfig, that will trigger the rest of the GUI update (QR code etc) + } catch (ex: RemoteException) { + errormsg("ignoring channel problem", ex) + + setGUIfromModel() // Throw away user edits + + // Tell the user to try again + Snackbar.make( + binding.editableCheckbox, + R.string.radio_sleeping, + Snackbar.LENGTH_SHORT + ).show() } } @@ -150,13 +186,34 @@ class ChannelFragment : ScreenFragment("Channel"), Logging { requireActivity().hideKeyboard() } + binding.resetButton.setOnClickListener { _ -> + // User just locked it, we should warn and then apply changes to radio + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.reset_to_defaults) + .setMessage(R.string.are_you_shure_change_default) + .setNeutralButton(R.string.cancel) { _, _ -> + setGUIfromModel() // throw away any edits + } + .setPositiveButton(R.string.apply) { _, _ -> + debug("Switching back to default channel") + installSettings(Channel.default.settings) + } + .show() + } + // Note: Do not use setOnCheckedChanged here because we don't want to be called when we programmatically disable editing binding.editableCheckbox.setOnClickListener { _ -> + + /// We use this to determine if the user tried to install a custom name + var originalName = "" + val checked = binding.editableCheckbox.isChecked if (checked) { // User just unlocked for editing - remove the # goo around the channel name model.channels.value?.primaryChannel?.let { ch -> - binding.channelNameEdit.setText(ch.name) + // Note: We are careful to show the emptystring here if the user was on a default channel, so the user knows they should it for any changes + originalName = ch.settings.name + binding.channelNameEdit.setText(originalName) } } else { // User just locked it, we should warn and then apply changes to radio @@ -170,49 +227,33 @@ class ChannelFragment : ScreenFragment("Channel"), Logging { // Generate a new channel with only the changes the user can change in the GUI model.channels.value?.primaryChannel?.let { oldPrimary -> var newSettings = oldPrimary.settings.toBuilder() - newSettings.name = binding.channelNameEdit.text.toString().trim() + val newName = binding.channelNameEdit.text.toString().trim() - // Generate a new AES256 key unleess the user is trying to go back to stock - if (!newSettings.name.equals( - Channel.defaultChannel.name, - ignoreCase = true - ) - ) { + // Find the new modem config + val selectedChannelOptionString = + binding.filledExposedDropdown.editableText.toString() + var modemConfig = getModemConfig(selectedChannelOptionString) + if (modemConfig == ChannelProtos.ChannelSettings.ModemConfig.UNRECOGNIZED) // Huh? didn't find it - keep same + modemConfig = oldPrimary.settings.modemConfig + + // Generate a new AES256 key if the user changes channel name or the name is non-default and the settings changed + if (newName != originalName || (newName.isNotEmpty() && modemConfig != oldPrimary.settings.modemConfig)) { + // Install a new customized channel debug("ASSIGNING NEW AES256 KEY") val random = SecureRandom() val bytes = ByteArray(32) random.nextBytes(bytes) + newSettings.name = newName newSettings.psk = ByteString.copyFrom(bytes) } else { debug("Switching back to default channel") - newSettings = Channel.defaultChannel.settings.toBuilder() + newSettings = Channel.default.settings.toBuilder() } - val selectedChannelOptionString = - binding.filledExposedDropdown.editableText.toString() - val modemConfig = getModemConfig(selectedChannelOptionString) + // No matter what apply the speed selection from the user + newSettings.modemConfig = modemConfig - if (modemConfig != ChannelProtos.ChannelSettings.ModemConfig.UNRECOGNIZED) - newSettings.modemConfig = modemConfig - - val newChannel = newSettings.build() - val newSet = ChannelSet(AppOnlyProtos.ChannelSet.newBuilder().addSettings(newChannel).build()) - // Try to change the radio, if it fails, tell the user why and throw away their redits - try { - model.setChannels(newSet) - // Since we are writing to radioconfig, that will trigger the rest of the GUI update (QR code etc) - } catch (ex: RemoteException) { - errormsg("ignoring channel problem", ex) - - setGUIfromModel() // Throw away user edits - - // Tell the user to try again - Snackbar.make( - binding.editableCheckbox, - R.string.radio_sleeping, - Snackbar.LENGTH_SHORT - ).show() - } + installSettings(newSettings.build()) } } .show() diff --git a/app/src/main/java/com/geeksville/mesh/ui/DebugFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/DebugFragment.kt index 75822cdb2..44f79eda0 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/DebugFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/DebugFragment.kt @@ -16,6 +16,7 @@ import com.geeksville.mesh.model.UIViewModel class DebugFragment : Fragment() { private var _binding: DebugFragmentBinding? = null + // This property is only valid between onCreateView and onDestroyView. private val binding get() = _binding!! @@ -44,11 +45,11 @@ class DebugFragment : Fragment() { model.deleteAllPacket() } - binding.closeButton.setOnClickListener{ - parentFragmentManager.popBackStack(); + binding.closeButton.setOnClickListener { + parentFragmentManager.popBackStack() } - model.allPackets.observe(viewLifecycleOwner, Observer { - packets -> packets?.let { adapter.setPackets(it) } + model.allPackets.observe(viewLifecycleOwner, Observer { packets -> + packets?.let { adapter.setPackets(it) } }) } } \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/ui/LocationUtils.kt b/app/src/main/java/com/geeksville/mesh/ui/LocationUtils.kt index eed32c702..3a23b75b0 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/LocationUtils.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/LocationUtils.kt @@ -131,8 +131,10 @@ fun positionToMeter(a: MeshProtos.Position, b: MeshProtos.Position): Double { a.latitudeI * 1e-7, a.longitudeI * 1e-7, b.latitudeI * 1e-7, - b.longitudeI * 1e-7) + b.longitudeI * 1e-7 + ) } + /** * Convert degrees/mins/secs to a single double * @@ -186,7 +188,7 @@ fun bearing( val y = sin(deltaLonRad) * cos(lat2Rad) val x = cos(lat1Rad) * sin(lat2Rad) - (sin(lat1Rad) * cos(lat2Rad) - * Math.cos(deltaLonRad)) + * Math.cos(deltaLonRad)) return radToBearing(Math.atan2(y, x)) } diff --git a/app/src/main/java/com/geeksville/mesh/ui/MessagesFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/MessagesFragment.kt index 43553a20d..d552e4b67 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/MessagesFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/MessagesFragment.kt @@ -17,7 +17,6 @@ import androidx.recyclerview.widget.RecyclerView import com.geeksville.android.Logging import com.geeksville.mesh.DataPacket import com.geeksville.mesh.MessageStatus -import com.geeksville.mesh.NodeInfo import com.geeksville.mesh.R import com.geeksville.mesh.databinding.AdapterMessageLayoutBinding import com.geeksville.mesh.databinding.MessagesFragmentBinding @@ -25,7 +24,6 @@ import com.geeksville.mesh.model.UIViewModel import com.geeksville.mesh.service.MeshService import com.google.android.material.chip.Chip import java.text.DateFormat -import java.text.ParseException import java.util.* // Allows usage like email.on(EditorInfo.IME_ACTION_NEXT, { confirm() }) @@ -54,9 +52,9 @@ class MessagesFragment : ScreenFragment("Messages"), Logging { private val timeFormat: DateFormat = DateFormat.getTimeInstance(DateFormat.SHORT) - private fun getShortDateTime(time : Date): String { + private fun getShortDateTime(time: Date): String { // return time if within 24 hours, otherwise date/time - val one_day = 60*60*24*100L + val one_day = 60 * 60 * 24 * 100L if (System.currentTimeMillis() - time.time > one_day) { return dateTimeFormat.format(time) } else return timeFormat.format(time) @@ -152,11 +150,25 @@ class MessagesFragment : ScreenFragment("Messages"), Logging { if (isMe) { marginParams.leftMargin = messageOffset marginParams.rightMargin = 0 - context?.let{ holder.card.setCardBackgroundColor(ContextCompat.getColor(it, R.color.colorMyMsg)) } + context?.let { + holder.card.setCardBackgroundColor( + ContextCompat.getColor( + it, + R.color.colorMyMsg + ) + ) + } } else { marginParams.rightMargin = messageOffset marginParams.leftMargin = 0 - context?.let{ holder.card.setCardBackgroundColor(ContextCompat.getColor(it, R.color.colorMsg)) } + context?.let { + holder.card.setCardBackgroundColor( + ContextCompat.getColor( + it, + R.color.colorMsg + ) + ) + } } // Hide the username chip for my messages if (isMe) { @@ -240,20 +252,26 @@ class MessagesFragment : ScreenFragment("Messages"), Logging { // If connection state _OR_ myID changes we have to fix our ability to edit outgoing messages fun updateTextEnabled() { binding.textInputLayout.isEnabled = - model.isConnected.value != MeshService.ConnectionState.DISCONNECTED && model.nodeDB.myId.value != null && model.radioConfig.value != null + model.isConnected.value != MeshService.ConnectionState.DISCONNECTED + + // Just being connected is enough to allow sending texts I think + // && model.nodeDB.myId.value != null && model.radioConfig.value != null } model.isConnected.observe(viewLifecycleOwner, Observer { _ -> // If we don't know our node ID and we are offline don't let user try to send - updateTextEnabled() }) + updateTextEnabled() + }) - model.nodeDB.myId.observe(viewLifecycleOwner, Observer { _ -> + /* model.nodeDB.myId.observe(viewLifecycleOwner, Observer { _ -> // If we don't know our node ID and we are offline don't let user try to send - updateTextEnabled() }) + updateTextEnabled() + }) model.radioConfig.observe(viewLifecycleOwner, Observer { _ -> // If we don't know our node ID and we are offline don't let user try to send - updateTextEnabled() }) + updateTextEnabled() + }) */ } } diff --git a/app/src/main/java/com/geeksville/mesh/ui/PacketListAdapter.kt b/app/src/main/java/com/geeksville/mesh/ui/PacketListAdapter.kt index 52ec35892..726f56802 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/PacketListAdapter.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/PacketListAdapter.kt @@ -1,49 +1,50 @@ -package com.geeksville.mesh.ui - -import android.content.Context -import java.text.DateFormat -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.TextView -import androidx.recyclerview.widget.RecyclerView -import com.geeksville.mesh.R -import com.geeksville.mesh.database.entity.Packet -import java.util.* - -class PacketListAdapter internal constructor( - context: Context -) : RecyclerView.Adapter() { - - private val inflater: LayoutInflater = LayoutInflater.from(context) - private var packets = emptyList() - - private val timeFormat: DateFormat = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM) - - inner class PacketViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { - val packetTypeView: TextView = itemView.findViewById(R.id.type) - val packetDateReceivedView: TextView = itemView.findViewById(R.id.dateReceived) - val packetRawMessage : TextView = itemView.findViewById(R.id.rawMessage) - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PacketViewHolder { - val itemView = inflater.inflate(R.layout.adapter_packet_layout, parent, false) - return PacketViewHolder(itemView) - } - - override fun onBindViewHolder(holder: PacketViewHolder, position: Int) { - val current = packets[position] - holder.packetTypeView.text = current.message_type - holder.packetRawMessage.text = current.raw_message - val date = Date(current.received_date) - holder.packetDateReceivedView.text = timeFormat.format(date) - } - - internal fun setPackets(packets: List) { - this.packets = packets - notifyDataSetChanged() - } - - override fun getItemCount() = packets.size - +package com.geeksville.mesh.ui + +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView +import com.geeksville.mesh.R +import com.geeksville.mesh.database.entity.Packet +import java.text.DateFormat +import java.util.* + +class PacketListAdapter internal constructor( + context: Context +) : RecyclerView.Adapter() { + + private val inflater: LayoutInflater = LayoutInflater.from(context) + private var packets = emptyList() + + private val timeFormat: DateFormat = + DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM) + + inner class PacketViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + val packetTypeView: TextView = itemView.findViewById(R.id.type) + val packetDateReceivedView: TextView = itemView.findViewById(R.id.dateReceived) + val packetRawMessage: TextView = itemView.findViewById(R.id.rawMessage) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PacketViewHolder { + val itemView = inflater.inflate(R.layout.adapter_packet_layout, parent, false) + return PacketViewHolder(itemView) + } + + override fun onBindViewHolder(holder: PacketViewHolder, position: Int) { + val current = packets[position] + holder.packetTypeView.text = current.message_type + holder.packetRawMessage.text = current.raw_message + val date = Date(current.received_date) + holder.packetDateReceivedView.text = timeFormat.format(date) + } + + internal fun setPackets(packets: List) { + this.packets = packets + notifyDataSetChanged() + } + + override fun getItemCount() = packets.size + } \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt index d6fd03cfd..0a61cd403 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt @@ -38,10 +38,7 @@ import com.geeksville.mesh.android.bluetoothManager import com.geeksville.mesh.android.usbManager import com.geeksville.mesh.databinding.SettingsFragmentBinding import com.geeksville.mesh.model.UIViewModel -import com.geeksville.mesh.service.BluetoothInterface -import com.geeksville.mesh.service.MeshService -import com.geeksville.mesh.service.RadioInterfaceService -import com.geeksville.mesh.service.SerialInterface +import com.geeksville.mesh.service.* import com.geeksville.mesh.service.SoftwareUpdateService.Companion.ACTION_UPDATE_PROGRESS import com.geeksville.mesh.service.SoftwareUpdateService.Companion.ProgressNotStarted import com.geeksville.mesh.service.SoftwareUpdateService.Companion.ProgressSuccess @@ -59,7 +56,7 @@ import kotlinx.coroutines.Job import java.util.regex.Pattern -object SLogging : Logging {} +object SLogging : Logging /// Change to a new macaddr selection, updating GUI and radio fun changeDeviceSelection(context: MainActivity, newAddr: String?) { @@ -186,7 +183,7 @@ class BTScanModel(app: Application) : AndroidViewModel(app), Logging { // if that device later disconnects remove it as a candidate override fun onScanResult(callbackType: Int, result: ScanResult) { - if ((result.device.name?.startsWith("Mesh") ?: false)) { + if ((result.device.name?.startsWith("Mesh") == true)) { val addr = result.device.address val fullAddr = "x$addr" // full address with the bluetooh prefix // prevent logspam because weill get get lots of redundant scan results @@ -245,14 +242,12 @@ class BTScanModel(app: Application) : AndroidViewModel(app), Logging { debug("BTScan component active") selectedAddress = RadioInterfaceService.getDeviceAddress(context) - return if (bluetoothAdapter == null || RadioInterfaceService.isMockInterfaceAvailable( - context - ) - ) { + return if (bluetoothAdapter == null || MockInterface.addressValid(context, "")) { warn("No bluetooth adapter. Running under emulation?") val testnodes = listOf( - DeviceListEntry("Simulated interface", "m", true), + DeviceListEntry("Included simulator", "m", true), + DeviceListEntry("Complete simulator", "t10.0.2.2", true), DeviceListEntry(context.getString(R.string.none), "n", true) /* Don't populate fake bluetooth devices, because we don't want testlab inside of google to try and use them. @@ -494,7 +489,7 @@ class SettingsFragment : ScreenFragment("Settings"), Logging { } /// Set the correct update button configuration based on current progress - private fun refreshUpdateButton() { + private fun refreshUpdateButton(enable: Boolean) { debug("Reiniting the udpate button") val info = model.myNodeInfo.value val service = model.meshService @@ -505,7 +500,7 @@ class SettingsFragment : ScreenFragment("Settings"), Logging { val progress = service.updateStatus - binding.updateFirmwareButton.isEnabled = + binding.updateFirmwareButton.isEnabled = enable && (progress < 0) // if currently doing an upgrade disable button if (progress >= 0) { @@ -542,7 +537,7 @@ class SettingsFragment : ScreenFragment("Settings"), Logging { val connected = model.isConnected.value val isConnected = connected == MeshService.ConnectionState.CONNECTED - binding.nodeSettings.visibility = if(isConnected) View.VISIBLE else View.GONE + binding.nodeSettings.visibility = if (isConnected) View.VISIBLE else View.GONE if (connected == MeshService.ConnectionState.DISCONNECTED) model.ownerName.value = "" @@ -552,25 +547,19 @@ class SettingsFragment : ScreenFragment("Settings"), Logging { val spinner = binding.regionSpinner val unsetIndex = regions.indexOf(RadioConfigProtos.RegionCode.Unset.name) spinner.onItemSelectedListener = null - if(region != null) { - debug("current region is $region") - var regionIndex = regions.indexOf(region.name) - if(regionIndex == -1) // Not found, probably because the device has a region our app doesn't yet understand. Punt and say Unset - regionIndex = unsetIndex - // We don't want to be notified of our own changes, so turn off listener while making them - spinner.setSelection(regionIndex, false) - spinner.onItemSelectedListener = regionSpinnerListener - spinner.isEnabled = true - } - else { - warn("region is unset!") - spinner.setSelection(unsetIndex, false) - spinner.isEnabled = false // leave disabled, because we can't get our region - } + debug("current region is $region") + var regionIndex = regions.indexOf(region.name) + if (regionIndex == -1) // Not found, probably because the device has a region our app doesn't yet understand. Punt and say Unset + regionIndex = unsetIndex + + // We don't want to be notified of our own changes, so turn off listener while making them + spinner.setSelection(regionIndex, false) + spinner.onItemSelectedListener = regionSpinnerListener + spinner.isEnabled = true // If actively connected possibly let the user update firmware - refreshUpdateButton() + refreshUpdateButton(region != RadioConfigProtos.RegionCode.Unset) // Update the status string (highest priority messages first) val info = model.myNodeInfo.value @@ -590,14 +579,14 @@ class SettingsFragment : ScreenFragment("Settings"), Logging { } } - private val regionSpinnerListener = object : AdapterView.OnItemSelectedListener{ + private val regionSpinnerListener = object : AdapterView.OnItemSelectedListener { override fun onItemSelected( parent: AdapterView<*>, view: View, position: Int, id: Long ) { - val item = parent.getItemAtPosition(position) as String + val item = parent.getItemAtPosition(position) as String? val asProto = item!!.let { RadioConfigProtos.RegionCode.valueOf(it) } exceptionToSnackbar(requireView()) { model.region = asProto @@ -622,7 +611,8 @@ class SettingsFragment : ScreenFragment("Settings"), Logging { // init our region spinner val spinner = binding.regionSpinner - val regionAdapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_item, regions) + val regionAdapter = + ArrayAdapter(requireContext(), android.R.layout.simple_spinner_item, regions) regionAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) spinner.adapter = regionAdapter @@ -632,7 +622,7 @@ class SettingsFragment : ScreenFragment("Settings"), Logging { // Only let user edit their name or set software update while connected to a radio - model.isConnected.observe(viewLifecycleOwner, Observer { connectionState -> + model.isConnected.observe(viewLifecycleOwner, Observer { _ -> updateNodeInfo() }) @@ -702,7 +692,7 @@ class SettingsFragment : ScreenFragment("Settings"), Logging { scanModel.onSelected(requireActivity() as MainActivity, device) if (!b.isSelected) - binding.scanStatusText.setText(getString(R.string.please_pair)) + binding.scanStatusText.text = getString(R.string.please_pair) } } @@ -765,7 +755,10 @@ class SettingsFragment : ScreenFragment("Settings"), Logging { // get rid of the warning text once at least one device is paired. // If we are running on an emulator, always leave this message showing so we can test the worst case layout binding.warningNotPaired.visibility = - if (hasBonded && !RadioInterfaceService.isMockInterfaceAvailable(requireContext())) View.GONE else View.VISIBLE + if (hasBonded && !MockInterface.addressValid(requireContext(), "")) + View.GONE + else + View.VISIBLE } /// Setup the GUI to do a classic (pre SDK 26 BLE scan) @@ -935,7 +928,7 @@ class SettingsFragment : ScreenFragment("Settings"), Logging { private val updateProgressReceiver: BroadcastReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { - refreshUpdateButton() + refreshUpdateButton(true) } } diff --git a/app/src/main/java/com/geeksville/mesh/ui/UsersFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/UsersFragment.kt index 0649aa429..3873fd27b 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/UsersFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/UsersFragment.kt @@ -113,7 +113,7 @@ class UsersFragment : ScreenFragment("Users"), Logging { val name = n.user?.longName ?: n.user?.id ?: "Unknown node" holder.nodeNameView.text = name - val pos = n.validPosition; + val pos = n.validPosition if (pos != null) { val coords = String.format("%.5f %.5f", pos.latitude, pos.longitude).replace(",", ".") @@ -141,7 +141,7 @@ class UsersFragment : ScreenFragment("Users"), Logging { } renderBattery(n.batteryPctLevel, holder) - holder.lastTime.text = formatAgo(n.lastSeen); + holder.lastTime.text = formatAgo(n.lastHeard) if ((n.num == ourNodeInfo?.num) || (n.snr > 100f)) { holder.signalView.visibility = View.INVISIBLE diff --git a/app/src/main/proto b/app/src/main/proto index 820fa497d..f9c4f8758 160000 --- a/app/src/main/proto +++ b/app/src/main/proto @@ -1 +1 @@ -Subproject commit 820fa497dfde07e129cad6955bf2f4b2b9cecebc +Subproject commit f9c4f875818c9aa6995e6e25803d52557a1779c7 diff --git a/app/src/main/res/drawable/ic_twotone_public_24.xml b/app/src/main/res/drawable/ic_twotone_public_24.xml new file mode 100644 index 000000000..403051e61 --- /dev/null +++ b/app/src/main/res/drawable/ic_twotone_public_24.xml @@ -0,0 +1,15 @@ + + + + diff --git a/app/src/main/res/layout/channel_fragment.xml b/app/src/main/res/layout/channel_fragment.xml index 461eafab0..b834136f3 100644 --- a/app/src/main/res/layout/channel_fragment.xml +++ b/app/src/main/res/layout/channel_fragment.xml @@ -1,6 +1,7 @@ @@ -23,7 +24,7 @@ android:digits="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz1234567890- " android:hint="@string/channel_name" android:imeOptions="actionDone" - android:maxLength="11" + android:maxLength="15" android:singleLine="true" android:text="@string/unset" /> @@ -91,30 +92,46 @@ + android:layout_height="wrap_content" + android:hint="@string/set_channel_options" /> +