diff --git a/.idea/dictionaries/kevinh.xml b/.idea/dictionaries/kevinh.xml index 9de6060e7..56d3af4f2 100644 --- a/.idea/dictionaries/kevinh.xml +++ b/.idea/dictionaries/kevinh.xml @@ -5,6 +5,7 @@ errormsg geeksville meshtastic + protobuf \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle index ffb8e7599..557ecb57b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -31,8 +31,8 @@ android { applicationId "com.geeksville.mesh" minSdkVersion 21 // The oldest emulator image I have tried is 22 (though 21 probably works) targetSdkVersion 29 - versionCode 20150 // format is Mmmss (where M is 1+the numeric major number - versionName "1.1.50" + versionCode 20204 // format is Mmmss (where M is 1+the numeric major number + versionName "1.2.04" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" // per https://developer.android.com/studio/write/vector-asset-studio diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 97d4bf2f1..5bf37ee1f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -133,7 +133,7 @@ @@ -143,11 +143,11 @@ + android:pathPrefix="/d/" /> + android:pathPrefix="/D/" /> diff --git a/app/src/main/aidl/com/geeksville/mesh/IMeshService.aidl b/app/src/main/aidl/com/geeksville/mesh/IMeshService.aidl index fcb44874a..a9ee757d0 100644 --- a/app/src/main/aidl/com/geeksville/mesh/IMeshService.aidl +++ b/app/src/main/aidl/com/geeksville/mesh/IMeshService.aidl @@ -71,18 +71,26 @@ interface IMeshService { */ List getNodes(); - /// This method is only intended for use in our GUI, so the user can set radio options - /// It returns a RadioConfig protobuf. - byte []getRadioConfig(); - /// Return an list of MeshPacket protobuf (byte arrays) which were received while your client app was offline (recent messages only). /// Also includes any messages we have sent recently (useful for finding current message status) List getOldMessages(); + /// This method is only intended for use in our GUI, so the user can set radio options + /// It returns a RadioConfig protobuf. + byte []getRadioConfig(); + /// This method is only intended for use in our GUI, so the user can set radio options /// It sets a RadioConfig protobuf void setRadioConfig(in byte []payload); + /// This method is only intended for use in our GUI, so the user can set radio options + /// It returns a ChannelSet protobuf. + byte []getChannels(); + + /// This method is only intended for use in our GUI, so the user can set radio options + /// It sets a ChannelSet protobuf + void setChannels(in byte []payload); + /** Is the packet radio currently connected to the phone? Returns a ConnectionState string. */ @@ -94,8 +102,8 @@ interface IMeshService { /// Returns true if the device address actually changed, or false if no change was needed boolean setDeviceAddress(String deviceAddr); - /// Get basic device hardware info about our connected radio. Will never return NULL. Will throw - /// RemoteException if no my node info is available + /// Get basic device hardware info about our connected radio. Will never return NULL. Will return NULL + /// if no my node info is available (i.e. it will not throw an exception) MyNodeInfo getMyNodeInfo(); /// Start updating the radios firmware diff --git a/app/src/main/java/com/geeksville/mesh/MainActivity.kt b/app/src/main/java/com/geeksville/mesh/MainActivity.kt index 8e5a7230a..136169f67 100644 --- a/app/src/main/java/com/geeksville/mesh/MainActivity.kt +++ b/app/src/main/java/com/geeksville/mesh/MainActivity.kt @@ -45,6 +45,8 @@ import com.geeksville.android.ServiceClient import com.geeksville.concurrent.handledLaunch import com.geeksville.mesh.databinding.ActivityMainBinding import com.geeksville.mesh.model.Channel +import com.geeksville.mesh.model.ChannelSet +import com.geeksville.mesh.model.DeviceVersion import com.geeksville.mesh.model.UIViewModel import com.geeksville.mesh.service.* import com.geeksville.mesh.ui.* @@ -612,6 +614,29 @@ class MainActivity : AppCompatActivity(), Logging, } } + /** Show an alert that may contain HTML */ + private fun showAlert(titleText: Int, messageText: Int) { + // make links clickable per https://stackoverflow.com/a/62642807 + // val messageStr = getText(messageText) + + val builder = MaterialAlertDialogBuilder(this) + .setTitle(titleText) + .setMessage(messageText) + .setPositiveButton(R.string.okay) { _, _ -> + info("User acknowledged") + } + + val dialog = builder.show() + + // Make the textview clickable. Must be called after show() + val view = (dialog.findViewById(android.R.id.message) as TextView?)!! + // Linkify.addLinks(view, Linkify.ALL) // not needed with this method + view.movementMethod = LinkMovementMethod.getInstance() + + showSettingsPage() // Default to the settings page in this case + } + + /// Called when we gain/lose a connection to our mesh radio private fun onMeshConnectionChanged(connected: MeshService.ConnectionState) { debug("connchange ${model.isConnected.value} -> $connected") @@ -628,33 +653,28 @@ class MainActivity : AppCompatActivity(), Logging, model.myNodeInfo.value = info val isOld = info.minAppVersion > BuildConfig.VERSION_CODE - if (isOld) { - // make links clickable per https://stackoverflow.com/a/62642807 - val messageStr = getText(R.string.must_update) + if (isOld) + showAlert(R.string.app_too_old, R.string.must_update) + else { - val builder = MaterialAlertDialogBuilder(this) - .setTitle(getString(R.string.app_too_old)) - .setMessage(messageStr) - .setPositiveButton("Okay") { _, _ -> - info("User acknowledged app is old") - } + val curVer = DeviceVersion(info.firmwareVersion ?: "0.0.0") + val minVer = DeviceVersion("1.2.0") + if(curVer < minVer) + 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 - val dialog = builder.show() + model.radioConfig.value = + RadioConfigProtos.RadioConfig.parseFrom(service.radioConfig) - // Make the textview clickable. Must be called after show() - val view = (dialog.findViewById(android.R.id.message) as TextView?)!! - // Linkify.addLinks(view, Linkify.ALL) // not needed with this method - view.movementMethod = LinkMovementMethod.getInstance() - } else { - // If our app is too old, we probably don't understand the new radioconfig messages + model.channels.value = + ChannelSet(AppOnlyProtos.ChannelSet.parseFrom(service.channels)) - model.radioConfig.value = - MeshProtos.RadioConfig.parseFrom(service.radioConfig) + updateNodesFromDevice() - updateNodesFromDevice() - - // we have a connection to our device now, do the channel change - perhapsChangeChannel() + // we have a connection to our device now, do the channel change + perhapsChangeChannel() + } } } catch (ex: RemoteException) { warn("Abandoning connect $ex, because we probably just lost device connection") @@ -671,19 +691,20 @@ class MainActivity : AppCompatActivity(), Logging, // If the is opening a channel URL, handle it now requestedChannelUrl?.let { url -> try { - val channel = Channel(url) + val channels = ChannelSet(url) + val primary = channels.primaryChannel requestedChannelUrl = null MaterialAlertDialogBuilder(this) .setTitle(R.string.new_channel_rcvd) - .setMessage(getString(R.string.do_you_want_switch).format(channel.name)) + .setMessage(getString(R.string.do_you_want_switch).format(primary.name)) .setNeutralButton(R.string.cancel) { _, _ -> // Do nothing } .setPositiveButton(R.string.accept) { _, _ -> debug("Setting channel from URL") try { - model.setChannel(channel.settings) + model.setChannels(channels) } catch (ex: RemoteException) { errormsg("Couldn't change channel ${ex.message}") Toast.makeText( @@ -828,8 +849,10 @@ class MainActivity : AppCompatActivity(), Logging, val allMsgs = service.oldMessages val msgs = allMsgs.filter { p -> p.dataType == Portnums.PortNum.TEXT_MESSAGE_APP_VALUE } - debug("Service provided ${msgs.size} messages and myNodeNum ${service.myNodeInfo?.myNodeNum}") - model.myNodeInfo.value = service.myNodeInfo + + model.myNodeInfo.value = service.myNodeInfo // Note: this could be NULL! + debug("Service provided ${msgs.size} messages and myNodeNum ${model.myNodeInfo.value?.myNodeNum}") + model.messagesState.setMessages(msgs) val connectionState = MeshService.ConnectionState.valueOf(service.connectionState()) @@ -839,7 +862,6 @@ class MainActivity : AppCompatActivity(), Logging, if (connectionState != MeshService.ConnectionState.CONNECTED) updateNodesFromDevice() - // We won't receive a notify for the initial state of connection, so we force an update here onMeshConnectionChanged(connectionState) } catch (ex: RemoteException) { @@ -848,9 +870,11 @@ class MainActivity : AppCompatActivity(), Logging, model.isConnected.value = MeshService.ConnectionState.valueOf(service.connectionState()) } + finally { + connectionJob = null + } debug("connected to mesh service, isConnected=${model.isConnected.value}") - connectionJob = null } } diff --git a/app/src/main/java/com/geeksville/mesh/MyNodeInfo.kt b/app/src/main/java/com/geeksville/mesh/MyNodeInfo.kt index e5a447b1c..da76ccfaa 100644 --- a/app/src/main/java/com/geeksville/mesh/MyNodeInfo.kt +++ b/app/src/main/java/com/geeksville/mesh/MyNodeInfo.kt @@ -9,32 +9,28 @@ import kotlinx.serialization.Serializable data class MyNodeInfo( val myNodeNum: Int, val hasGPS: Boolean, - val region: String?, val model: String?, val firmwareVersion: String?, val couldUpdate: Boolean, // this application contains a software load we _could_ install if you want val shouldUpdate: Boolean, // this device has old firmware val currentPacketId: Long, - val nodeNumBits: Int, - val packetIdBits: Int, val messageTimeoutMsec: Int, - val minAppVersion: Int + val minAppVersion: Int, + val maxChannels: Int ) : Parcelable { /** A human readable description of the software/hardware version */ - val firmwareString: String get() = "$model $region/$firmwareVersion" + val firmwareString: String get() = "$model $firmwareVersion" constructor(parcel: Parcel) : this( parcel.readInt(), parcel.readByte() != 0.toByte(), parcel.readString(), parcel.readString(), - parcel.readString(), parcel.readByte() != 0.toByte(), parcel.readByte() != 0.toByte(), parcel.readLong(), parcel.readInt(), parcel.readInt(), - parcel.readInt(), parcel.readInt() ) { } @@ -42,16 +38,14 @@ data class MyNodeInfo( override fun writeToParcel(parcel: Parcel, flags: Int) { parcel.writeInt(myNodeNum) parcel.writeByte(if (hasGPS) 1 else 0) - parcel.writeString(region) parcel.writeString(model) parcel.writeString(firmwareVersion) parcel.writeByte(if (couldUpdate) 1 else 0) parcel.writeByte(if (shouldUpdate) 1 else 0) parcel.writeLong(currentPacketId) - parcel.writeInt(nodeNumBits) - parcel.writeInt(packetIdBits) parcel.writeInt(messageTimeoutMsec) parcel.writeInt(minAppVersion) + parcel.writeInt(maxChannels) } override fun describeContents(): Int { 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 54886c7e5..5686bb209 100644 --- a/app/src/main/java/com/geeksville/mesh/model/Channel.kt +++ b/app/src/main/java/com/geeksville/mesh/model/Channel.kt @@ -3,6 +3,7 @@ package com.geeksville.mesh.model import android.graphics.Bitmap import android.net.Uri import android.util.Base64 +import com.geeksville.mesh.ChannelProtos import com.geeksville.mesh.MeshProtos import com.google.protobuf.ByteString import com.google.zxing.BarcodeFormat @@ -15,12 +16,12 @@ fun byteArrayOfInts(vararg ints: Int) = ByteArray(ints.size) { pos -> ints[pos]. data class Channel( - val settings: MeshProtos.ChannelSettings = MeshProtos.ChannelSettings.getDefaultInstance() + val settings: ChannelProtos.ChannelSettings = ChannelProtos.ChannelSettings.getDefaultInstance() ) { companion object { // Note: this string _SHOULD NOT BE LOCALIZED_ because it directly hashes to values used on the device for the default channel name. // FIXME - make this work with new channel name system - val defaultChannelName = "Default" + const val defaultChannelName = "Default" // These bytes must match the well known and not secret bytes used the default channel AES128 key device code val channelDefaultKey = byteArrayOfInts( @@ -30,30 +31,11 @@ data class Channel( // Placeholder when emulating val emulated = Channel( - MeshProtos.ChannelSettings.newBuilder().setName(defaultChannelName) - .setModemConfig(MeshProtos.ChannelSettings.ModemConfig.Bw125Cr45Sf128).build() + ChannelProtos.ChannelSettings.newBuilder().setName(defaultChannelName) + .setModemConfig(ChannelProtos.ChannelSettings.ModemConfig.Bw125Cr45Sf128).build() ) - - const val prefix = "https://www.meshtastic.org/c/#" - - private const val base64Flags = Base64.URL_SAFE + Base64.NO_WRAP + Base64.NO_PADDING - - private fun urlToSettings(url: Uri): MeshProtos.ChannelSettings { - val urlStr = url.toString() - - // We no longer support the super old (about 0.8ish? verison of the URLs that don't use the # separator - I doubt - // anyone is still using that old format - val pathRegex = Regex("$prefix(.*)", RegexOption.IGNORE_CASE) - val (base64) = pathRegex.find(urlStr)?.destructured - ?: throw MalformedURLException("Not a meshtastic URL: ${urlStr.take(40)}") - val bytes = Base64.decode(base64, base64Flags) - - return MeshProtos.ChannelSettings.parseFrom(bytes) - } } - constructor(url: Uri) : this(urlToSettings(url)) - /// Return the name of our channel as a human readable string. If empty string, assume "Default" per mesh.proto spec val name: String get() = if (settings.name.isEmpty()) { @@ -61,16 +43,16 @@ data class Channel( if (settings.bandwidth != 0) "Unset" else when (settings.modemConfig) { - MeshProtos.ChannelSettings.ModemConfig.Bw125Cr45Sf128 -> "Medium" - MeshProtos.ChannelSettings.ModemConfig.Bw500Cr45Sf128 -> "ShortFast" - MeshProtos.ChannelSettings.ModemConfig.Bw31_25Cr48Sf512 -> "LongAlt" - MeshProtos.ChannelSettings.ModemConfig.Bw125Cr48Sf4096 -> "LongSlow" + ChannelProtos.ChannelSettings.ModemConfig.Bw125Cr45Sf128 -> "Medium" + ChannelProtos.ChannelSettings.ModemConfig.Bw500Cr45Sf128 -> "ShortFast" + ChannelProtos.ChannelSettings.ModemConfig.Bw31_25Cr48Sf512 -> "LongAlt" + ChannelProtos.ChannelSettings.ModemConfig.Bw125Cr48Sf4096 -> "LongSlow" else -> "Invalid" } } else settings.name - val modemConfig: MeshProtos.ChannelSettings.ModemConfig get() = settings.modemConfig + val modemConfig: ChannelProtos.ChannelSettings.ModemConfig get() = settings.modemConfig val psk get() = if (settings.psk.size() != 1) @@ -106,32 +88,4 @@ data class Channel( return "#${name}-${suffix}" } - - /// Can this channel be changed right now? - var editable = false - - /// 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 - fun getChannelUrl(upperCasePrefix: Boolean = false): Uri { - // If we have a valid radio config use it, othterwise use whatever we have saved in the prefs - - val channelBytes = settings.toByteArray() ?: ByteArray(0) // if unset just use empty - val enc = Base64.encodeToString(channelBytes, base64Flags) - - val p = if(upperCasePrefix) - prefix.toUpperCase() - else - prefix - return Uri.parse("$p$enc") - } - - fun getChannelQR(): Bitmap { - val multiFormatWriter = MultiFormatWriter() - - // 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); - val barcodeEncoder = BarcodeEncoder() - return barcodeEncoder.createBitmap(bitMatrix) - } } 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 827624cd5..62409f4ff 100644 --- a/app/src/main/java/com/geeksville/mesh/model/ChannelOption.kt +++ b/app/src/main/java/com/geeksville/mesh/model/ChannelOption.kt @@ -1,22 +1,23 @@ -package com.geeksville.mesh.model - -import com.geeksville.mesh.MeshProtos -import com.geeksville.mesh.R - -enum class ChannelOption(val modemConfig: MeshProtos.ChannelSettings.ModemConfig, val configRes: Int, val minBroadcastPeriodSecs: Int) { - SHORT(MeshProtos.ChannelSettings.ModemConfig.Bw500Cr45Sf128, R.string.modem_config_short, 3), - MEDIUM(MeshProtos.ChannelSettings.ModemConfig.Bw125Cr45Sf128, R.string.modem_config_medium, 12), - LONG(MeshProtos.ChannelSettings.ModemConfig.Bw31_25Cr48Sf512, R.string.modem_config_long, 240), - VERY_LONG(MeshProtos.ChannelSettings.ModemConfig.Bw125Cr48Sf4096, R.string.modem_config_very_long, 375); - - companion object { - fun fromConfig(modemConfig: MeshProtos.ChannelSettings.ModemConfig?): ChannelOption? { - for (option in values()) { - if (option.modemConfig == modemConfig) - return option - } - return null - } - val defaultMinBroadcastPeriod = VERY_LONG.minBroadcastPeriodSecs - } +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) { + 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); + + companion object { + fun fromConfig(modemConfig: ChannelProtos.ChannelSettings.ModemConfig?): ChannelOption? { + for (option in values()) { + if (option.modemConfig == modemConfig) + return option + } + 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 new file mode 100644 index 000000000..d6b0bcf5e --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/model/ChannelSet.kt @@ -0,0 +1,78 @@ +package com.geeksville.mesh.model + +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 +import java.net.MalformedURLException + +data class ChannelSet( + val protobuf: AppOnlyProtos.ChannelSet = AppOnlyProtos.ChannelSet.getDefaultInstance() +) { + companion object { + + // Placeholder when emulating + val emulated = ChannelSet( + AppOnlyProtos.ChannelSet.newBuilder().addSettings(Channel.emulated.settings).build() + ) + + const val prefix = "https://www.meshtastic.org/d/#" + + private const val base64Flags = Base64.URL_SAFE + Base64.NO_WRAP + Base64.NO_PADDING + + private fun urlToChannels(url: Uri): AppOnlyProtos.ChannelSet { + val urlStr = url.toString() + + // We no longer support the super old (about 0.8ish? verison of the URLs that don't use the # separator - I doubt + // anyone is still using that old format + val pathRegex = Regex("$prefix(.*)", RegexOption.IGNORE_CASE) + val (base64) = pathRegex.find(urlStr)?.destructured + ?: throw MalformedURLException("Not a meshtastic URL: ${urlStr.take(40)}") + val bytes = Base64.decode(base64, base64Flags) + + return AppOnlyProtos.ChannelSet.parseFrom(bytes) + } + } + + constructor(url: Uri) : this(urlToChannels(url)) + + /// Can this channel be changed right now? + var editable = false + + /** + * Return the primary channel info + */ + val primaryChannel: Channel get() { + return Channel(protobuf.getSettings(0)) + } + + /// 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 + fun getChannelUrl(upperCasePrefix: Boolean = false): Uri { + // If we have a valid radio config use it, othterwise use whatever we have saved in the prefs + + val channelBytes = protobuf.toByteArray() ?: ByteArray(0) // if unset just use empty + val enc = Base64.encodeToString(channelBytes, base64Flags) + + val p = if(upperCasePrefix) + prefix.toUpperCase() + else + prefix + return Uri.parse("$p$enc") + } + + fun getChannelQR(): Bitmap { + val multiFormatWriter = MultiFormatWriter() + + // 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); + val barcodeEncoder = BarcodeEncoder() + return barcodeEncoder.createBitmap(bitMatrix) + } +} diff --git a/app/src/main/java/com/geeksville/mesh/service/DeviceVersion.kt b/app/src/main/java/com/geeksville/mesh/model/DeviceVersion.kt similarity index 96% rename from app/src/main/java/com/geeksville/mesh/service/DeviceVersion.kt rename to app/src/main/java/com/geeksville/mesh/model/DeviceVersion.kt index 13ef461e0..961524b66 100644 --- a/app/src/main/java/com/geeksville/mesh/service/DeviceVersion.kt +++ b/app/src/main/java/com/geeksville/mesh/model/DeviceVersion.kt @@ -1,4 +1,4 @@ -package com.geeksville.mesh.service +package com.geeksville.mesh.model import com.geeksville.android.Logging 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 cdfb805bc..fe4175e9b 100644 --- a/app/src/main/java/com/geeksville/mesh/model/UIState.kt +++ b/app/src/main/java/com/geeksville/mesh/model/UIState.kt @@ -12,9 +12,7 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import com.geeksville.android.Logging -import com.geeksville.mesh.IMeshService -import com.geeksville.mesh.MeshProtos -import com.geeksville.mesh.MyNodeInfo +import com.geeksville.mesh.* import com.geeksville.mesh.database.MeshtasticDatabase import com.geeksville.mesh.database.PacketRepository import com.geeksville.mesh.database.entity.Packet @@ -69,17 +67,6 @@ class UIViewModel(private val app: Application) : AndroidViewModel(app), Logging } companion object { - /** - * Return the current channel info - * FIXME, we should sim channels at the MeshService level if we are running on an emulator, - * for now I just fake it by returning a canned channel. - */ - fun getChannel(c: MeshProtos.RadioConfig?): Channel? { - val channel = c?.channelSettings?.let { Channel(it) } - - return channel - } - fun getPreferences(context: Context): SharedPreferences = context.getSharedPreferences("ui-prefs", Context.MODE_PRIVATE) } @@ -100,13 +87,16 @@ class UIViewModel(private val app: Application) : AndroidViewModel(app), Logging } /// various radio settings (including the channel) - val radioConfig = object : MutableLiveData(null) { + val radioConfig = object : MutableLiveData(null) { + } + + val channels = object : MutableLiveData(null) { } var positionBroadcastSecs: Int? get() { radioConfig.value?.preferences?.let { - if (it.locationShare == MeshProtos.LocationSharing.LocDisabled) return 0 + if (it.locationShare == RadioConfigProtos.LocationSharing.LocDisabled) return 0 if (it.positionBroadcastSecs > 0) return it.positionBroadcastSecs // These default values are borrowed from the device code. if (it.isRouter) return 60 * 60 @@ -123,23 +113,18 @@ class UIViewModel(private val app: Application) : AndroidViewModel(app), Logging builder.preferencesBuilder.gpsUpdateInterval = value builder.preferencesBuilder.sendOwnerInterval = max(1, 3600 / value).toInt() builder.preferencesBuilder.locationShare = - MeshProtos.LocationSharing.LocEnabled + RadioConfigProtos.LocationSharing.LocEnabled } else { builder.preferencesBuilder.positionBroadcastSecs = Int.MAX_VALUE builder.preferencesBuilder.locationShare = - MeshProtos.LocationSharing.LocDisabled + RadioConfigProtos.LocationSharing.LocDisabled } setRadioConfig(builder.build()) } } var lsSleepSecs: Int? - get() { - radioConfig.value?.preferences?.let { - return it.lsSecs - } - return null - } + get() = radioConfig.value?.preferences?.lsSecs set(value) { val config = radioConfig.value if (value != null && config != null) { @@ -149,32 +134,48 @@ class UIViewModel(private val app: Application) : AndroidViewModel(app), Logging } } - /// hardware info about our local device - val myNodeInfo = object : MutableLiveData(null) {} + var region: RadioConfigProtos.RegionCode? + get() = radioConfig.value?.preferences?.region + set(value) { + val config = radioConfig.value + if (value != null && config != null) { + val builder = config.toBuilder() + builder.preferencesBuilder.region = value + setRadioConfig(builder.build()) + } + } + + /// hardware info about our local device (can be null) + val myNodeInfo = object : MutableLiveData(null) {} override fun onCleared() { super.onCleared() debug("ViewModel cleared") } - /// Set the radio config (also updates our saved copy in preferences) - fun setRadioConfig(c: MeshProtos.RadioConfig) { + /** + * Return the primary channel info + */ + val primaryChannel: Channel? get() = channels.value?.primaryChannel + + /// + // Set the radio config (also updates our saved copy in preferences) + private fun setRadioConfig(c: RadioConfigProtos.RadioConfig) { debug("Setting new radio config!") meshService?.radioConfig = c.toByteArray() radioConfig.value = c // Must be done after calling the service, so we will will properly throw if the service failed (and therefore not cache invalid new settings) - - getPreferences(context).edit(commit = true) { - this.putString("channel-url", getChannel(c)!!.getChannelUrl().toString()) - } } - /** Update just the channel settings portion of our config (both in the device and in saved preferences) */ - fun setChannel(c: MeshProtos.ChannelSettings) { - // When running on the emulator, radio config might not really be available, in that case, just ignore attempts to change the config - radioConfig.value?.toBuilder()?.let { config -> - config.channelSettings = c - setRadioConfig(config.build()) + /// Set the radio config (also updates our saved copy in preferences) + fun setChannels(c: ChannelSet) { + debug("Setting new channels!") + meshService?.channels = c.protobuf.toByteArray() + channels.value = + c // Must be done after calling the service, so we will will properly throw if the service failed (and therefore not cache invalid new settings) + + getPreferences(context).edit(commit = true) { + this.putString("channel-url", c.getChannelUrl().toString()) } } 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 13b314737..5b61c8b55 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt @@ -23,6 +23,7 @@ import com.geeksville.mesh.MeshProtos.ToRadio import com.geeksville.mesh.database.MeshtasticDatabase import com.geeksville.mesh.database.PacketRepository import com.geeksville.mesh.database.entity.Packet +import com.geeksville.mesh.model.DeviceVersion import com.geeksville.mesh.service.SoftwareUpdateService.Companion.ProgressNotStarted import com.geeksville.util.* import com.google.android.gms.common.api.ApiException @@ -51,8 +52,8 @@ class MeshService : Service(), Logging { /// Intents broadcast by MeshService - @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" + /* @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" @@ -110,11 +111,14 @@ class MeshService : Service(), Logging { private val serviceJob = Job() private val serviceScope = CoroutineScope(Dispatchers.IO + serviceJob) private var connectionState = ConnectionState.DISCONNECTED + + /// A database of received packets - used only for debug log private var packetRepo: PacketRepository? = null + private var fusedLocationClient: FusedLocationProviderClient? = null // If we've ever read a valid region code from our device it will be here - var curRegionValue = MeshProtos.RegionCode.Unset_VALUE + var curRegionValue = RadioConfigProtos.RegionCode.Unset_VALUE val radio = ServiceClient { IRadioInterfaceService.Stub.asInterface(it).apply { @@ -420,7 +424,9 @@ class MeshService : Service(), Logging { var myNodeInfo: MyNodeInfo? = null - private var radioConfig: MeshProtos.RadioConfig? = null + private var radioConfig: RadioConfigProtos.RadioConfig? = null + + private var channels = arrayOf() /// True after we've done our initial node db init @Volatile @@ -513,17 +519,45 @@ class MeshService : Service(), Logging { /// My node ID string private val myNodeID get() = toNodeID(myNodeNum) + /// Convert the channels array into a ChannelSet + private var channelSet: AppOnlyProtos.ChannelSet + get() { + val cs = channels.filter { + it.role != ChannelProtos.Channel.Role.DISABLED + }.map { + it.settings + } + + return AppOnlyProtos.ChannelSet.newBuilder().apply { + addAllSettings(cs) + }.build() + } + set(value) { + val asChannels = value.settingsList.mapIndexed { i, c -> + ChannelProtos.Channel.newBuilder().apply { + role = + if (i == 0) ChannelProtos.Channel.Role.PRIMARY else ChannelProtos.Channel.Role.SECONDARY + index = i + settings = c + }.build() + } + + debug("Sending channels to device") + asChannels.forEach { + setChannel(it) + } + + channels = asChannels.toTypedArray() + } + /// Generate a new mesh packet builder with our node as the sender, and the specified node num private fun newMeshPacketTo(idNum: Int) = MeshPacket.newBuilder().apply { - val useShortAddresses = (myNodeInfo?.nodeNumBits ?: 8) != 32 - if (myNodeInfo == null) throw RadioNotConnectedException() from = myNodeNum - // We might need to change broadcast addresses to work with old device loads - to = if (useShortAddresses && idNum == DataPacket.NODENUM_BROADCAST) 255 else idNum + to = idNum } /** @@ -536,23 +570,44 @@ class MeshService : Service(), Logging { /** * Helper to make it easy to build a subpacket in the proper protobufs - * - * If destId is null we assume a broadcast message */ - private fun buildMeshPacket( - destId: String, + private fun MeshProtos.MeshPacket.Builder.buildMeshPacket( wantAck: Boolean = false, - id: Int = 0, + id: Int = generatePacketId(), // always assign a packet ID if we didn't already have one hopLimit: Int = 0, - initFn: MeshProtos.SubPacket.Builder.() -> Unit - ): MeshPacket = newMeshPacketTo(destId).apply { + priority: MeshPacket.Priority = MeshPacket.Priority.UNSET, + initFn: MeshProtos.Data.Builder.() -> Unit + ): MeshPacket { this.wantAck = wantAck this.id = id this.hopLimit = hopLimit - decoded = MeshProtos.SubPacket.newBuilder().also { + this.priority = priority + decoded = MeshProtos.Data.newBuilder().also { initFn(it) }.build() - }.build() + + return build() + } + + + /** + * Helper to make it easy to build a subpacket in the proper protobufs + */ + private fun MeshProtos.MeshPacket.Builder.buildAdminPacket( + wantResponse: Boolean = false, + initFn: AdminProtos.AdminMessage.Builder.() -> Unit + ): MeshPacket = buildMeshPacket( + wantAck = true, + priority = MeshPacket.Priority.RELIABLE + ) + { + this.wantResponse = wantResponse + portnumValue = Portnums.PortNum.ADMIN_APP_VALUE + payload = AdminProtos.AdminMessage.newBuilder().also { + initFn(it) + }.build().toByteString() + } + // FIXME - possible kotlin bug in 1.3.72 - it seems that if we start with the (globally shared) emptyList, // then adding items are affecting that shared list rather than a copy. This was causing aliasing of @@ -561,11 +616,11 @@ class MeshService : Service(), Logging { /// Generate a DataPacket from a MeshPacket, or null if we didn't have enough data to do so private fun toDataPacket(packet: MeshPacket): DataPacket? { - return if (!packet.hasDecoded() || !packet.decoded.hasData()) { + return if (!packet.hasDecoded()) { // We never convert packets that are not DataPackets null } else { - val data = packet.decoded.data + val data = packet.decoded val bytes = data.payload.toByteArray() val fromId = toNodeID(packet.from) val toId = toNodeID(packet.to) @@ -598,15 +653,14 @@ class MeshService : Service(), Logging { } } - /// Syntactic sugar to create data subpackets - private fun makeData(portNum: Int, bytes: ByteString) = MeshProtos.Data.newBuilder().also { - it.portnumValue = portNum - it.payload = bytes - }.build() - private fun toMeshPacket(p: DataPacket): MeshPacket { - return buildMeshPacket(p.to!!, id = p.id, wantAck = true, hopLimit = p.hopLimit) { - data = makeData(p.dataType, ByteString.copyFrom(p.bytes)) + return newMeshPacketTo(p.to!!).buildMeshPacket( + id = p.id, + wantAck = true, + hopLimit = p.hopLimit + ) { + portnumValue = p.dataType + payload = ByteString.copyFrom(p.bytes) } } @@ -631,62 +685,118 @@ class MeshService : Service(), Logging { /// Update our model and resend as needed for a MeshPacket we just received from the radio private fun handleReceivedData(packet: MeshPacket) { myNodeInfo?.let { myInfo -> - val data = packet.decoded.data + val data = packet.decoded val bytes = data.payload.toByteArray() val fromId = toNodeID(packet.from) val dataPacket = toDataPacket(packet) if (dataPacket != null) { - if (myInfo.myNodeNum == packet.from) { - // Handle position updates from the device - if (data.portnumValue == Portnums.PortNum.POSITION_APP_VALUE) { - handleReceivedPosition( - packet.from, - MeshProtos.Position.parseFrom(data.payload), - dataPacket.time - ) - } else - debug("Ignoring packet sent from our node, portnum=${data.portnumValue} ${bytes.size} bytes") - } else { - debug("Received data from $fromId, portnum=${data.portnumValue} ${bytes.size} bytes") + // We ignore most messages that we sent + val fromUs = myInfo.myNodeNum == packet.from - dataPacket.status = MessageStatus.RECEIVED - rememberDataPacket(dataPacket) + debug("Received data from $fromId, portnum=${data.portnum} ${bytes.size} bytes") - when (data.portnumValue) { - Portnums.PortNum.TEXT_MESSAGE_APP_VALUE -> { + dataPacket.status = MessageStatus.RECEIVED + rememberDataPacket(dataPacket) + + // if (p.hasUser()) handleReceivedUser(fromNum, p.user) + + /// We tell other apps about most message types, but some may have sensitve data, so that is not shared' + var shouldBroadcast = !fromUs + + when (data.portnumValue) { + Portnums.PortNum.TEXT_MESSAGE_APP_VALUE -> + if (!fromUs) { debug("Received CLEAR_TEXT from $fromId") updateMessageNotification(dataPacket) } - // Handle new style position info - Portnums.PortNum.POSITION_APP_VALUE -> { - val u = MeshProtos.Position.parseFrom(data.payload) - handleReceivedPosition(packet.from, u, dataPacket.time) - } + // Handle new style position info + Portnums.PortNum.POSITION_APP_VALUE -> { + val u = MeshProtos.Position.parseFrom(data.payload) + handleReceivedPosition(packet.from, u, dataPacket.time) + } - // Handle new style user info - Portnums.PortNum.NODEINFO_APP_VALUE -> { + // Handle new style user info + Portnums.PortNum.NODEINFO_APP_VALUE -> + if (!fromUs) { val u = MeshProtos.User.parseFrom(data.payload) handleReceivedUser(packet.from, u) } + + // Handle new style routing info + Portnums.PortNum.ROUTING_APP_VALUE -> { + shouldBroadcast = true // We always send acks to other apps, because they might care about the messages they sent + val u = MeshProtos.Routing.parseFrom(data.payload) + if (u.errorReasonValue == MeshProtos.Routing.Error.NONE_VALUE) + handleAckNak(true, data.requestId) + else + handleAckNak(false, data.requestId) } - // We always tell other apps when new data packets arrive + Portnums.PortNum.ADMIN_APP_VALUE -> { + val u = AdminProtos.AdminMessage.parseFrom(data.payload) + handleReceivedAdmin(packet.from, u) + shouldBroadcast = false + } + + else -> + debug("No custom processing needed for ${data.portnumValue}") + } + + // We always tell other apps when new data packets arrive + if (shouldBroadcast) serviceBroadcasts.broadcastReceivedData(dataPacket) - GeeksvilleApplication.analytics.track( - "num_data_receive", - DataPair(1) - ) + GeeksvilleApplication.analytics.track( + "num_data_receive", + DataPair(1) + ) - GeeksvilleApplication.analytics.track( - "data_receive", - DataPair("num_bytes", bytes.size), - DataPair("type", data.portnumValue) - ) + GeeksvilleApplication.analytics.track( + "data_receive", + DataPair("num_bytes", bytes.size), + DataPair("type", data.portnumValue) + ) + } + } + } + + private fun handleReceivedAdmin(fromNodeNum: Int, a: AdminProtos.AdminMessage) { + // For the time being we only care about admin messages from our local node + if (fromNodeNum == myNodeNum) { + when (a.variantCase) { + AdminProtos.AdminMessage.VariantCase.GET_RADIO_RESPONSE -> { + debug("Admin: received radioConfig") + radioConfig = a.getRadioResponse + requestChannel(0) // Now start reading channels } + + AdminProtos.AdminMessage.VariantCase.GET_CHANNEL_RESPONSE -> { + val mi = myNodeInfo + if (mi != null) { + val ch = a.getChannelResponse + channels[ch.index] = ch + debug("Admin: Received channel ${ch.index}") + if (ch.index + 1 < mi.maxChannels) { + if(ch.hasSettings()) { + // Not done yet, request next channel + requestChannel(ch.index + 1) + } + else { + debug("We've received the primary channel, allowing rest of app to start...") + onHasSettings() + } + } else { + debug("Received max channels, starting app") + onHasSettings() + } + } + } + else -> + warn("No special processing needed for ${a.variantCase}") + } } } @@ -705,7 +815,7 @@ class MeshService : Service(), Logging { /** Update our DB of users based on someone sending out a Position subpacket * @param defaultTime in msecs since 1970 - */ + */ private fun handleReceivedPosition( fromNum: Int, p: MeshProtos.Position, @@ -785,45 +895,33 @@ class MeshService : Service(), Logging { //val toNum = packet.to // debug("Recieved: $packet") - val p = packet.decoded + if (packet.hasDecoded()) { + val packetToSave = Packet( + UUID.randomUUID().toString(), + "packet", + System.currentTimeMillis(), + packet.toString() + ) + insertPacket(packetToSave) - val packetToSave = Packet( - UUID.randomUUID().toString(), - "packet", - System.currentTimeMillis(), - packet.toString() - ) - insertPacket(packetToSave) + // 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 - // 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()) + } - updateNodeInfo(myNodeNum) { - it.position = it.position?.copy(time = currentSecond()) - } + // if (p.hasPosition()) handleReceivedPosition(fromNum, p.position, rxTime) - // 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 (p.hasPosition()) { - handleReceivedPosition(fromNum, p.position, rxTime.toLong() * 1000) - } - else + // 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) } - if (p.hasData()) handleReceivedData(packet) - - if (p.hasUser()) - handleReceivedUser(fromNum, p.user) - - if (p.successId != 0) - handleAckNak(true, p.successId) - - if (p.failId != 0) - handleAckNak(false, p.failId) + } } private fun insertPacket(packetToSave: Packet) { @@ -1043,7 +1141,7 @@ class MeshService : Service(), Logging { MeshProtos.FromRadio.NODE_INFO_FIELD_NUMBER -> handleNodeInfo(proto.nodeInfo) - MeshProtos.FromRadio.RADIO_FIELD_NUMBER -> handleRadioConfig(proto.radio) + // MeshProtos.FromRadio.RADIO_FIELD_NUMBER -> handleRadioConfig(proto.radio) else -> errormsg("Unexpected FromRadio variant") } @@ -1067,7 +1165,7 @@ class MeshService : Service(), Logging { private var configNonce = 1 - private fun handleRadioConfig(radio: MeshProtos.RadioConfig) { + private fun handleRadioConfig(radio: RadioConfigProtos.RadioConfig) { val packetToSave = Packet( UUID.randomUUID().toString(), "RadioConfig", @@ -1137,7 +1235,6 @@ class MeshService : Service(), Logging { MyNodeInfo( myNodeNum, hasGps, - region, hwModel, firmwareVersion, firmwareUpdateFilename != null, @@ -1146,18 +1243,27 @@ class MeshService : Service(), Logging { DeviceVersion(firmwareVersion) ), currentPacketId.toLong() and 0xffffffffL, - if (nodeNumBits == 0) 8 else nodeNumBits, - if (packetIdBits == 0) 8 else packetIdBits, if (messageTimeoutMsec == 0) 5 * 60 * 1000 else messageTimeoutMsec, // constants from current device code - minAppVersion + minAppVersion, + maxChannels ) } newMyNodeInfo = mi + // We'll need to get a new set of channels and settings now + radioConfig = null + + // prefill the channel array with null channels + channels = Array(mi.maxChannels) { + val b = ChannelProtos.Channel.newBuilder() + b.index = it + b.build() + } + /// Track types of devices and firmware versions in use GeeksvilleApplication.analytics.setUserInfo( - DataPair("region", mi.region), + // DataPair("region", mi.region), DataPair("firmware", mi.firmwareVersion), DataPair("has_gps", mi.hasGPS), DataPair("hw_model", mi.model), @@ -1172,8 +1278,8 @@ class MeshService : Service(), Logging { // We also include this info, because it is required to correctly decode address from the map file DataPair("firmware", mi.firmwareVersion), - DataPair("hw_model", mi.model), - DataPair("region", mi.region) + DataPair("hw_model", mi.model) + // DataPair("region", mi.region) ) } } @@ -1190,28 +1296,30 @@ class MeshService : Service(), Logging { ignoreException { // Try to pull our region code from the new preferences field // FIXME - do not check net - figuring out why board is rebooting - val curConfigRegion = radioConfig?.preferences?.region ?: MeshProtos.RegionCode.Unset - if (curConfigRegion != MeshProtos.RegionCode.Unset) { + val curConfigRegion = + radioConfig?.preferences?.region ?: RadioConfigProtos.RegionCode.Unset + if (curConfigRegion != RadioConfigProtos.RegionCode.Unset) { info("Using device region $curConfigRegion (code ${curConfigRegion.number})") curRegionValue = curConfigRegion.number } - if (curRegionValue == MeshProtos.RegionCode.Unset_VALUE) { - // look for a legacy region + if (curRegionValue == RadioConfigProtos.RegionCode.Unset_VALUE) { + TODO("Need gui for setting region") + /* // look for a legacy region val legacyRegex = Regex(".+-(.+)") myNodeInfo?.region?.let { legacyRegion -> val matches = legacyRegex.find(legacyRegion) if (matches != null) { val (region) = matches.destructured - val newRegion = MeshProtos.RegionCode.valueOf(region) + val newRegion = RadioConfigProtos.RegionCode.valueOf(region) info("Upgrading legacy region $newRegion (code ${newRegion.number})") curRegionValue = newRegion.number } - } + } */ } // If nothing was set in our (new style radio preferences, but we now have a valid setting - slam it in) - if (curConfigRegion == MeshProtos.RegionCode.Unset && curRegionValue != MeshProtos.RegionCode.Unset_VALUE) { + if (curConfigRegion == RadioConfigProtos.RegionCode.Unset && curRegionValue != RadioConfigProtos.RegionCode.Unset_VALUE) { info("Telling device to upgrade region") // Tell the device to set the new region field (old devices will simply ignore this) @@ -1228,6 +1336,19 @@ class MeshService : Service(), Logging { } } + /// 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 + + // broadcast an intent with our new connection state + serviceBroadcasts.broadcastConnection() + onNodeDBChanged() + reportConnection() + + updateRegion() + } + private fun handleConfigComplete(configCompleteId: Int) { if (configCompleteId == configNonce) { @@ -1251,19 +1372,30 @@ class MeshService : Service(), Logging { newNodes.clear() // Just to save RAM ;-) haveNodeDB = true // we now have nodes from real hardware - processEarlyPackets() // send receive any packets that were queued up - - // broadcast an intent with our new connection state - serviceBroadcasts.broadcastConnection() - onNodeDBChanged() - reportConnection() - - updateRegion() + requestRadioConfig() } } else warn("Ignoring stale config complete") } + private fun requestRadioConfig() { + sendToRadio(newMeshPacketTo(myNodeNum).buildAdminPacket(wantResponse = true) { + getRadioRequest = true + }) + } + + private fun requestChannel(channelIndex: Int) { + sendToRadio(newMeshPacketTo(myNodeNum).buildAdminPacket(wantResponse = true) { + getChannelRequest = channelIndex + 1 + }) + } + + private fun setChannel(channel: ChannelProtos.Channel) { + sendToRadio(newMeshPacketTo(myNodeNum).buildAdminPacket(wantResponse = true) { + setChannel = channel + }) + } + /** * Start the modern (REV2) API configuration flow */ @@ -1299,30 +1431,20 @@ class MeshService : Service(), Logging { it.time = currentSecond() // Include our current timestamp }.build() - // encapsulate our payload in the proper protobufs and fire it off - val packet = newMeshPacketTo(destNum) - - packet.decoded = MeshProtos.SubPacket.newBuilder().also { - val isNewPositionAPI = - deviceVersion >= DeviceVersion("1.20.0") // We changed position APIs with this version - if (isNewPositionAPI) { - // Use the new position as data format - it.data = makeData(Portnums.PortNum.POSITION_APP_VALUE, position.toByteString()) - } else { - // Send the old dedicated position subpacket - it.position = position - } - it.wantResponse = wantResponse - }.build() - - // Assume our position packets are not critical - packet.priority = MeshProtos.MeshPacket.Priority.BACKGROUND - // 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(packet.build()) + sendToRadio(fullPacket) } private fun sendPositionScoped( @@ -1341,10 +1463,10 @@ class MeshService : Service(), Logging { /** Send our current radio config to the device */ - private fun sendRadioConfig(c: MeshProtos.RadioConfig) { - // Update our device - sendToRadio(ToRadio.newBuilder().apply { - this.setRadio = c + private fun sendRadioConfig(c: RadioConfigProtos.RadioConfig) { + // send the packet into the mesh + sendToRadio(newMeshPacketTo(myNodeNum).buildAdminPacket { + setRadio = c }) // Update our cached copy @@ -1354,7 +1476,7 @@ class MeshService : Service(), Logging { /** Set our radio config */ private fun setRadioConfig(payload: ByteArray) { - val parsed = MeshProtos.RadioConfig.parseFrom(payload) + val parsed = RadioConfigProtos.RadioConfig.parseFrom(payload) sendRadioConfig(parsed) } @@ -1384,47 +1506,36 @@ class MeshService : Service(), Logging { handleReceivedUser(myNode.myNodeNum, user) - // set my owner info - sendToRadio(ToRadio.newBuilder().apply { - this.setOwner = user - }) + // encapsulate our payload in the proper protobufs and fire it off + val packet = newMeshPacketTo(myNodeNum).buildAdminPacket { + setOwner = user + } + + // send the packet into the mesh + sendToRadio(packet) } } else throw Exception("Can't set user without a node info") // this shouldn't happen } + /// Do not use directly, instead call generatePacketId() - private var currentPacketId = 0L + private var currentPacketId = Random(System.currentTimeMillis()).nextLong().absoluteValue /** * Generate a unique packet ID (if we know enough to do so - otherwise return 0 so the device will do it) */ + @Synchronized private fun generatePacketId(): Int { + val numPacketIds = + ((1L shl 32) - 1).toLong() // A mask for only the valid packet ID bits, either 255 or maxint - myNodeInfo?.let { - val numPacketIds = - ((1L shl it.packetIdBits) - 1).toLong() // A mask for only the valid packet ID bits, either 255 or maxint + currentPacketId++ - if (currentPacketId == 0L) { - logAssert(it.packetIdBits == 8 || it.packetIdBits == 32) // Only values I'm expecting (though we don't require this) + currentPacketId = currentPacketId and 0xffffffff // keep from exceeding 32 bits - // We now always pick a random initial packet id (odds of collision with the device is insanely low with 32 bit ids) - val random = Random(System.currentTimeMillis()) - val devicePacketId = random.nextLong().absoluteValue - - // Not inited - pick a number on the opposite side of what the device is using - currentPacketId = devicePacketId + numPacketIds / 2 - } else { - currentPacketId++ - } - - currentPacketId = currentPacketId and 0xffffffff // keep from exceeding 32 bits - - // Use modulus and +1 to ensure we skip 0 on any values we return - return ((currentPacketId % numPacketIds) + 1L).toInt() - } - - return 0 // We don't have mynodeinfo yet, so just let the radio eventually assign an ID + // Use modulus and +1 to ensure we skip 0 on any values we return + return ((currentPacketId % numPacketIds) + 1L).toInt() } var firmwareUpdateFilename: UpdateFilenames? = null @@ -1527,9 +1638,7 @@ class MeshService : Service(), Logging { doFirmwareUpdate() } - override fun getMyNodeInfo(): MyNodeInfo = toRemoteExceptions { - this@MeshService.myNodeInfo ?: throw RadioNotConnectedException("No MyNodeInfo") - } + override fun getMyNodeInfo(): MyNodeInfo? = this@MeshService.myNodeInfo override fun getMyId() = toRemoteExceptions { myNodeID } @@ -1600,6 +1709,15 @@ class MeshService : Service(), Logging { this@MeshService.setRadioConfig(payload) } + override fun getChannels(): ByteArray = toRemoteExceptions { + channelSet.toByteArray() + } + + override fun setChannels(payload: ByteArray?) { + val parsed = AppOnlyProtos.ChannelSet.parseFrom(payload) + channelSet = parsed + } + override fun getNodes(): MutableList = toRemoteExceptions { val r = nodeDBbyID.values.toMutableList() info("in getOnline, count=${r.size}") 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 831ab1bd2..6d782269e 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshServiceBroadcasts.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshServiceBroadcasts.kt @@ -20,10 +20,12 @@ class MeshServiceBroadcasts( 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 // newer packets (that have a non zero portnum) are only broadcast using the standard mechanism. if(payload.dataType == Portnums.PortNum.UNKNOWN_APP_VALUE) explicitBroadcast(Intent(MeshService.ACTION_RECEIVED_DATA).putExtra(EXTRA_PAYLOAD, payload)) + */ } fun broadcastNodeChange(info: NodeInfo) { 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 2b6e455a4..c1477b718 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshServiceSettingsData.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshServiceSettingsData.kt @@ -1,9 +1,6 @@ package com.geeksville.mesh.service -import com.geeksville.mesh.DataPacket -import com.geeksville.mesh.MeshProtos -import com.geeksville.mesh.MyNodeInfo -import com.geeksville.mesh.NodeInfo +import com.geeksville.mesh.* import kotlinx.serialization.Serializable /// Our saved preferences as stored on disk @@ -12,7 +9,7 @@ data class MeshServiceSettingsData( val nodeDB: Array, val myInfo: MyNodeInfo, val messages: Array, - val regionCode: Int = MeshProtos.RegionCode.Unset_VALUE + val regionCode: Int = RadioConfigProtos.RegionCode.Unset_VALUE ) { override fun equals(other: Any?): Boolean { if (this === other) return true 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 5eaf012c6..7c76458e5 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MockInterface.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MockInterface.kt @@ -26,13 +26,47 @@ class MockInterface(private val service: RadioInterfaceService) : Logging, IRadi override fun handleSendToRadio(p: ByteArray) { val pr = MeshProtos.ToRadio.parseFrom(p) + val data = if (pr.hasPacket()) pr.packet.decoded else null + when { pr.wantConfigId != 0 -> sendConfigResponse(pr.wantConfigId) + data != null && data.portnum == Portnums.PortNum.ADMIN_APP -> handleAdminPacket( + pr, + AdminProtos.AdminMessage.parseFrom(data.payload) + ) pr.hasPacket() && pr.packet.wantAck -> sendFakeAck(pr) else -> info("Ignoring data sent to mock interface $pr") } } + private fun handleAdminPacket(pr: MeshProtos.ToRadio, d: AdminProtos.AdminMessage) { + when { + d.getRadioRequest -> + sendAdmin(pr.packet.to, pr.packet.from, pr.packet.id) { + getRadioResponse = RadioConfigProtos.RadioConfig.newBuilder().apply { + + preferences = + RadioConfigProtos.RadioConfig.UserPreferences.newBuilder().apply { + region = RadioConfigProtos.RegionCode.TW + // FIXME set critical times? + }.build() + }.build() + } + + d.getChannelRequest != 0 -> + sendAdmin(pr.packet.to, pr.packet.from, pr.packet.id) { + getChannelResponse = ChannelProtos.Channel.newBuilder().apply { + index = d.getChannelRequest - 1 // 0 based on the response + role = + if (d.getChannelRequest == 1) ChannelProtos.Channel.Role.PRIMARY else ChannelProtos.Channel.Role.DISABLED + }.build() + } + + else -> + info("Ignoring admin sent to mock interface $d") + } + } + override fun close() { info("Closing the mock interface") } @@ -46,17 +80,14 @@ class MockInterface(private val service: RadioInterfaceService) : Logging, IRadi to = 0xffffffff.toInt() // ugly way of saying broadcast rxTime = (System.currentTimeMillis() / 1000).toInt() rxSnr = 1.5f - decoded = MeshProtos.SubPacket.newBuilder().apply { - data = MeshProtos.Data.newBuilder().apply { - portnum = Portnums.PortNum.TEXT_MESSAGE_APP - payload = ByteString.copyFromUtf8("This simulated node sends Hi!") - }.build() + decoded = MeshProtos.Data.newBuilder().apply { + portnum = Portnums.PortNum.TEXT_MESSAGE_APP + payload = ByteString.copyFromUtf8("This simulated node sends Hi!") }.build() }.build() } - - private fun makeAck(fromIn: Int, toIn: Int, msgId: Int) = + private fun makeDataPacket(fromIn: Int, toIn: Int, data: MeshProtos.Data.Builder) = MeshProtos.FromRadio.newBuilder().apply { packet = MeshProtos.MeshPacket.newBuilder().apply { id = messageNumSequence.next() @@ -64,17 +95,39 @@ class MockInterface(private val service: RadioInterfaceService) : Logging, IRadi to = toIn rxTime = (System.currentTimeMillis() / 1000).toInt() rxSnr = 1.5f - decoded = MeshProtos.SubPacket.newBuilder().apply { - data = MeshProtos.Data.newBuilder().apply { - successId = msgId - }.build() - }.build() + decoded = data.build() }.build() } + private fun makeAck(fromIn: Int, toIn: Int, msgId: Int) = + makeDataPacket(fromIn, toIn, MeshProtos.Data.newBuilder().apply { + portnum = Portnums.PortNum.ROUTING_APP + payload = MeshProtos.Routing.newBuilder().apply { + }.build().toByteString() + requestId = msgId + }) + + private fun sendAdmin( + fromIn: Int, + toIn: Int, + reqId: Int, + initFn: AdminProtos.AdminMessage.Builder.() -> Unit + ) { + val p = makeDataPacket(fromIn, toIn, MeshProtos.Data.newBuilder().apply { + portnum = Portnums.PortNum.ADMIN_APP + payload = AdminProtos.AdminMessage.newBuilder().also { + initFn(it) + }.build().toByteString() + requestId = reqId + }) + service.handleFromRadio(p.build().toByteArray()) + } + /// Send a fake ack packet back if the sender asked for want_ack private fun sendFakeAck(pr: MeshProtos.ToRadio) { - service.handleFromRadio(makeAck(pr.packet.to, pr.packet.from, pr.packet.id).build().toByteArray()) + service.handleFromRadio( + makeAck(pr.packet.to, pr.packet.from, pr.packet.id).build().toByteArray() + ) } private fun sendConfigResponse(configId: Int) { @@ -101,35 +154,17 @@ class MockInterface(private val service: RadioInterfaceService) : Logging, IRadi } // Simulated network data to feed to our app - val MY_NODE = 0x42424242 + val MY_NODE = 0x42424242 val packets = arrayOf( // MyNodeInfo MeshProtos.FromRadio.newBuilder().apply { myInfo = MeshProtos.MyNodeInfo.newBuilder().apply { myNodeNum = MY_NODE - region = "TW" - numChannels = 7 hwModel = "Sim" - packetIdBits = 32 - nodeNumBits = 32 - currentPacketId = 1 messageTimeoutMsec = 5 * 60 * 1000 firmwareVersion = service.getString(R.string.cur_firmware_version) - }.build() - }, - - // RadioConfig - MeshProtos.FromRadio.newBuilder().apply { - radio = MeshProtos.RadioConfig.newBuilder().apply { - - preferences = MeshProtos.RadioConfig.UserPreferences.newBuilder().apply { - region = MeshProtos.RegionCode.TW - // FIXME set critical times? - }.build() - - channel = MeshProtos.ChannelSettings.newBuilder().apply { - // we just have an empty listing so that the default channel works - }.build() + numBands = 13 + maxChannels = 8 }.build() }, diff --git a/app/src/main/java/com/geeksville/mesh/service/SimRadio.kt b/app/src/main/java/com/geeksville/mesh/service/SimRadio.kt deleted file mode 100644 index 36b87b40d..000000000 --- a/app/src/main/java/com/geeksville/mesh/service/SimRadio.kt +++ /dev/null @@ -1,51 +0,0 @@ -package com.geeksville.mesh - -import android.content.Context -import com.geeksville.mesh.service.RadioInterfaceService - -class SimRadio(private val context: Context) { - - /** - * When simulating we parse these MeshPackets as if they arrived at startup - * Send broadcast them after we receive a ToRadio.WantNodes message. - * - * Our fake net has three nodes - * - * +16508675309, nodenum 9 - our node - * +16508675310, nodenum 10 - some other node, name Bob One/BO - * (eventually) +16508675311, nodenum 11 - some other node - */ - private val simInitPackets = - arrayOf( - """ { "from": 10, "to": 9, "payload": { "user": { "id": "+16508675310", "longName": "Bob One", "shortName": "BO" }}} """, - """ { "from": 10, "to": 9, "payload": { "data": { "payload": "aGVsbG8gd29ybGQ=", "typ": 0 }}} """, // SIGNAL_OPAQUE - """ { "from": 10, "to": 9, "payload": { "data": { "payload": "aGVsbG8gd29ybGQ=", "typ": 1 }}} """, // CLEAR_TEXT - """ { "from": 10, "to": 9, "payload": { "data": { "payload": "", "typ": 2 }}} """ // CLEAR_READACK - ) - - fun start() { - // FIXME, do this sim startup elsewhere, because waiting for a packet from MeshService - // isn't right, because that service can't successfully send radio packets until it knows - // our node num - // Instead a separate sim radio thing can come in at startup and force these broadcasts to happen - // at the right time - // Send a fake my_node_num response - /* FIXME - change to use new radio info message - RadioInterfaceService.broadcastReceivedFromRadio( - context, - MeshProtos.FromRadio.newBuilder().apply { - myNodeNum = 9 - }.build().toByteArray() - ) */ - - simInitPackets.forEach { _ -> - val fromRadio = MeshProtos.FromRadio.newBuilder().apply { - packet = MeshProtos.MeshPacket.newBuilder().apply { - // jsonParser.merge(json, this) - }.build() - }.build() - - RadioInterfaceService.broadcastReceivedFromRadio(context, fromRadio.toByteArray()) - } - } -} \ No newline at end of file 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 d7b3cc2dd..b9050105e 100644 --- a/app/src/main/java/com/geeksville/mesh/service/SoftwareUpdateService.kt +++ b/app/src/main/java/com/geeksville/mesh/service/SoftwareUpdateService.kt @@ -9,6 +9,7 @@ import androidx.core.app.JobIntentService import com.geeksville.android.Logging import com.geeksville.mesh.MainActivity import com.geeksville.mesh.R +import com.geeksville.mesh.model.DeviceVersion import com.geeksville.util.exceptionReporter import java.util.* import java.util.zip.CRC32 diff --git a/app/src/main/java/com/geeksville/mesh/ui/AdvancedSettingsFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/AdvancedSettingsFragment.kt index 75175a14e..6b22b7e6a 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/AdvancedSettingsFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/AdvancedSettingsFragment.kt @@ -51,7 +51,7 @@ class AdvancedSettingsFragment : ScreenFragment("Advanced Settings"), Logging { val textEdit = binding.positionBroadcastPeriodEditText val n = textEdit.text.toString().toIntOrNull() val minBroadcastPeriodSecs = - ChannelOption.fromConfig(model.radioConfig.value?.channelSettings?.modemConfig)?.minBroadcastPeriodSecs + ChannelOption.fromConfig(model.primaryChannel?.modemConfig)?.minBroadcastPeriodSecs ?: ChannelOption.defaultMinBroadcastPeriod if (n != null && n < MAX_INT_DEVICE && (n == 0 || n >= minBroadcastPeriodSecs)) { 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 ecc3cfe76..d4c5e8303 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/ChannelFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/ChannelFragment.kt @@ -16,11 +16,14 @@ import com.geeksville.analytics.DataPair import com.geeksville.android.GeeksvilleApplication 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 import com.geeksville.mesh.model.ChannelOption +import com.geeksville.mesh.model.ChannelSet import com.geeksville.mesh.model.UIViewModel import com.geeksville.mesh.service.MeshService import com.google.android.material.dialog.MaterialAlertDialogBuilder @@ -75,11 +78,12 @@ class ChannelFragment : ScreenFragment("Channel"), Logging { /// Pull the latest data from the model (discarding any user edits) private fun setGUIfromModel() { - val radioConfig = model.radioConfig.value - val channel = UIViewModel.getChannel(radioConfig) + val channels = model.channels.value binding.editableCheckbox.isChecked = false // start locked - if (channel != null) { + if (channels != null) { + val channel = channels.primaryChannel + binding.qrView.visibility = View.VISIBLE binding.channelNameEdit.visibility = View.VISIBLE binding.channelNameEdit.setText(channel.humanName) @@ -89,9 +93,9 @@ class ChannelFragment : ScreenFragment("Channel"), Logging { val connected = model.isConnected.value == MeshService.ConnectionState.CONNECTED binding.editableCheckbox.isEnabled = connected - binding.qrView.setImageBitmap(channel.getChannelQR()) + binding.qrView.setImageBitmap(channels.getChannelQR()) - val modemConfig = radioConfig?.channelSettings?.modemConfig + val modemConfig = channel.modemConfig val channelOption = ChannelOption.fromConfig(modemConfig) binding.filledExposedDropdown.setText( getString( @@ -118,7 +122,7 @@ class ChannelFragment : ScreenFragment("Channel"), Logging { } private fun shareChannel() { - UIViewModel.getChannel(model.radioConfig.value)?.let { channel -> + model.channels.value?.let { channels -> GeeksvilleApplication.analytics.track( "share", @@ -127,7 +131,7 @@ class ChannelFragment : ScreenFragment("Channel"), Logging { val sendIntent: Intent = Intent().apply { action = Intent.ACTION_SEND - putExtra(Intent.EXTRA_TEXT, channel.getChannelUrl().toString()) + putExtra(Intent.EXTRA_TEXT, channels.getChannelUrl().toString()) putExtra( Intent.EXTRA_TITLE, getString(R.string.url_for_join) @@ -152,8 +156,8 @@ class ChannelFragment : ScreenFragment("Channel"), Logging { val checked = binding.editableCheckbox.isChecked if (checked) { // User just unlocked for editing - remove the # goo around the channel name - UIViewModel.getChannel(model.radioConfig.value)?.let { channel -> - binding.channelNameEdit.setText(channel.name) + model.channels.value?.let { channels -> + binding.channelNameEdit.setText(channels.primaryChannel.name) } } else { // User just locked it, we should warn and then apply changes to radio @@ -165,8 +169,9 @@ class ChannelFragment : ScreenFragment("Channel"), Logging { } .setPositiveButton(getString(R.string.accept)) { _, _ -> // Generate a new channel with only the changes the user can change in the GUI - UIViewModel.getChannel(model.radioConfig.value)?.let { old -> - val newSettings = old.settings.toBuilder() + model.channels.value?.let { old -> + val oldPrimary = old.primaryChannel + val newSettings = oldPrimary.settings.toBuilder() newSettings.name = binding.channelNameEdit.text.toString().trim() // Generate a new AES256 key (for any channel not named Default) @@ -191,11 +196,14 @@ class ChannelFragment : ScreenFragment("Channel"), Logging { binding.filledExposedDropdown.editableText.toString() val modemConfig = getModemConfig(selectedChannelOptionString) - if (modemConfig != MeshProtos.ChannelSettings.ModemConfig.UNRECOGNIZED) + 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.setChannel(newSettings.build()) + 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) @@ -222,7 +230,7 @@ class ChannelFragment : ScreenFragment("Channel"), Logging { shareChannel() } - model.radioConfig.observe(viewLifecycleOwner, { + model.channels.observe(viewLifecycleOwner, { setGUIfromModel() }) @@ -232,12 +240,12 @@ class ChannelFragment : ScreenFragment("Channel"), Logging { }) } - private fun getModemConfig(selectedChannelOptionString: String): MeshProtos.ChannelSettings.ModemConfig { + private fun getModemConfig(selectedChannelOptionString: String): ChannelProtos.ChannelSettings.ModemConfig { for (item in ChannelOption.values()) { if (getString(item.configRes) == selectedChannelOptionString) return item.modemConfig } - return MeshProtos.ChannelSettings.ModemConfig.UNRECOGNIZED + return ChannelProtos.ChannelSettings.ModemConfig.UNRECOGNIZED } } 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 92c1d0d73..43553a20d 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/MessagesFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/MessagesFragment.kt @@ -9,6 +9,7 @@ import android.widget.EditText import android.widget.ImageView import android.widget.TextView import androidx.cardview.widget.CardView +import androidx.core.content.ContextCompat import androidx.fragment.app.activityViewModels import androidx.lifecycle.Observer import androidx.recyclerview.widget.LinearLayoutManager @@ -151,11 +152,11 @@ class MessagesFragment : ScreenFragment("Messages"), Logging { if (isMe) { marginParams.leftMargin = messageOffset marginParams.rightMargin = 0 - holder.card.setCardBackgroundColor(resources.getColor(R.color.colorMyMsg)) + context?.let{ holder.card.setCardBackgroundColor(ContextCompat.getColor(it, R.color.colorMyMsg)) } } else { marginParams.rightMargin = messageOffset marginParams.leftMargin = 0 - holder.card.setCardBackgroundColor(resources.getColor(R.color.colorMsg)) + context?.let{ holder.card.setCardBackgroundColor(ContextCompat.getColor(it, R.color.colorMsg)) } } // Hide the username chip for my messages if (isMe) { @@ -237,17 +238,22 @@ class MessagesFragment : ScreenFragment("Messages"), Logging { }) // If connection state _OR_ myID changes we have to fix our ability to edit outgoing messages - model.isConnected.observe(viewLifecycleOwner, Observer { connected -> - // If we don't know our node ID and we are offline don't let user try to send + fun updateTextEnabled() { binding.textInputLayout.isEnabled = - connected != MeshService.ConnectionState.DISCONNECTED && model.nodeDB.myId.value != null - }) + model.isConnected.value != MeshService.ConnectionState.DISCONNECTED && model.nodeDB.myId.value != null && model.radioConfig.value != null + } - model.nodeDB.myId.observe(viewLifecycleOwner, Observer { myId -> + model.isConnected.observe(viewLifecycleOwner, Observer { _ -> // If we don't know our node ID and we are offline don't let user try to send - binding.textInputLayout.isEnabled = - model.isConnected.value != MeshService.ConnectionState.DISCONNECTED && myId != null - }) + updateTextEnabled() }) + + 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() }) + + 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() }) } } 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 06eb73658..ae01f6808 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt @@ -19,6 +19,8 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.view.inputmethod.EditorInfo +import android.widget.AdapterView +import android.widget.ArrayAdapter import android.widget.RadioButton import android.widget.Toast import androidx.fragment.app.activityViewModels @@ -31,6 +33,7 @@ import com.geeksville.android.hideKeyboard import com.geeksville.android.isGooglePlayAvailable import com.geeksville.mesh.MainActivity import com.geeksville.mesh.R +import com.geeksville.mesh.RadioConfigProtos import com.geeksville.mesh.android.bluetoothManager import com.geeksville.mesh.android.usbManager import com.geeksville.mesh.databinding.SettingsFragmentBinding @@ -562,29 +565,95 @@ class SettingsFragment : ScreenFragment("Settings"), Logging { } } - private fun initNodeInfo() { + /** + * Pull the latest device info from the model and into the GUI + */ + private fun updateNodeInfo() { val connected = model.isConnected.value - refreshUpdateButton() + val isConnected = connected == MeshService.ConnectionState.CONNECTED + binding.nodeSettings.visibility = if(isConnected) View.VISIBLE else View.GONE + + if (connected == MeshService.ConnectionState.DISCONNECTED) + model.ownerName.value = "" + + // update the region selection from the device + val region = model.region + val spinner = binding.regionSpinner + val unsetIndex = regions.indexOf(RadioConfigProtos.RegionCode.Unset.name) + spinner.onItemSelectedListener = null + if(region != null) { + 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 { + spinner.setSelection(unsetIndex, false) + spinner.isEnabled = false // leave disabled, because we can't get our region + } // If actively connected possibly let the user update firmware - val info = model.myNodeInfo.value + refreshUpdateButton() - when (connected) { - MeshService.ConnectionState.CONNECTED -> { + // Update the status string (highest priority messages first) + val info = model.myNodeInfo.value + val statusText = binding.scanStatusText + when { + region == RadioConfigProtos.RegionCode.Unset -> + statusText.text = getString(R.string.must_set_region) + + connected == MeshService.ConnectionState.CONNECTED -> { val fwStr = info?.firmwareString ?: "" - binding.scanStatusText.text = getString(R.string.connected_to).format(fwStr) + statusText.text = getString(R.string.connected_to).format(fwStr) } - MeshService.ConnectionState.DISCONNECTED -> - binding.scanStatusText.text = getString(R.string.not_connected) - MeshService.ConnectionState.DEVICE_SLEEP -> - binding.scanStatusText.text = getString(R.string.connected_sleeping) + connected == MeshService.ConnectionState.DISCONNECTED -> + statusText.text = getString(R.string.not_connected) + connected == MeshService.ConnectionState.DEVICE_SLEEP -> + statusText.text = getString(R.string.connected_sleeping) } } + 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 asProto = item!!.let { RadioConfigProtos.RegionCode.valueOf(it) } + exceptionToSnackbar(requireView()) { + model.region = asProto + } + updateNodeInfo() // We might have just changed Unset to set + } + + override fun onNothingSelected(parent: AdapterView<*>) { + //TODO("Not yet implemented") + } + } + + /// the sorted list of region names like arrayOf("US", "CN", "EU488") + private val regions = RadioConfigProtos.RegionCode.values().filter { + it != RadioConfigProtos.RegionCode.UNRECOGNIZED + }.map { + it.name + }.sorted() + /// Setup the ui widgets unrelated to BLE scanning private fun initCommonUI() { + // init our region spinner + val spinner = binding.regionSpinner + val regionAdapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_item, regions) + regionAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) + spinner.adapter = regionAdapter + model.ownerName.observe(viewLifecycleOwner, { name -> binding.usernameEditText.setText(name) }) @@ -592,18 +661,12 @@ 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 -> - val connected = connectionState == MeshService.ConnectionState.CONNECTED - binding.usernameView.isEnabled = connected - - if (connectionState == MeshService.ConnectionState.DISCONNECTED) - model.ownerName.value = "" - - initNodeInfo() + updateNodeInfo() }) // Also watch myNodeInfo because it might change later model.myNodeInfo.observe(viewLifecycleOwner, Observer { - initNodeInfo() + updateNodeInfo() }) binding.updateFirmwareButton.setOnClickListener { diff --git a/app/src/main/proto b/app/src/main/proto index 512d1aca0..7de496ffe 160000 --- a/app/src/main/proto +++ b/app/src/main/proto @@ -1 +1 @@ -Subproject commit 512d1aca0a066107de749c0c47397c7f9bf9cb99 +Subproject commit 7de496ffe941f88e9d99c2ef2c7bc01f79efe11e diff --git a/app/src/main/res/layout/settings_fragment.xml b/app/src/main/res/layout/settings_fragment.xml index d5f5103d9..352fab9a0 100644 --- a/app/src/main/res/layout/settings_fragment.xml +++ b/app/src/main/res/layout/settings_fragment.xml @@ -10,14 +10,14 @@ + android:layout_height="0dp" + android:layout_marginTop="16dp"> + + + + + + + app:layout_constraintTop_toBottomOf="@+id/nodeSettings" /> - - \ No newline at end of file diff --git a/app/src/main/res/values/protobufs.xml b/app/src/main/res/values/protobufs.xml new file mode 100644 index 000000000..190471fc4 --- /dev/null +++ b/app/src/main/res/values/protobufs.xml @@ -0,0 +1,15 @@ + + + + + Unset + US + EU433 + EU865 + CN + JP + ANZ + KR + TW + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 33c6f745f..b953889e2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -89,4 +89,9 @@ Minimum broadcast period for this channel is %d Protocol stress test Advanced settings + Firmware update required + The radio firmware is too old to talk to this application, please go to the settings pane and choose "Update Firmware". For more information on this see our wiki. + Okay + You must set a region! + Region diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 691680617..e36ac68c6 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -13,6 +13,20 @@ @style/menu_item_color + + + + +