Merge pull request #257 from geeksville/dev1.2

Dev1.2
This commit is contained in:
Kevin Hester
2021-03-05 14:30:37 +08:00
committed by GitHub
27 changed files with 785 additions and 481 deletions

View File

@@ -5,6 +5,7 @@
<w>errormsg</w>
<w>geeksville</w>
<w>meshtastic</w>
<w>protobuf</w>
</words>
</dictionary>
</component>

View File

@@ -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

View File

@@ -133,7 +133,7 @@
<!-- The QR codes to share channel settings are shared as meshtastic URLS
an approximate example:
http://www.meshtastic.org/c/YXNkZnF3ZXJhc2RmcXdlcmFzZGZxd2Vy
http://www.meshtastic.org/d/YXNkZnF3ZXJhc2RmcXdlcmFzZGZxd2Vy
-->
<action android:name="android.intent.action.VIEW" />
@@ -143,11 +143,11 @@
<data
android:scheme="https"
android:host="www.meshtastic.org"
android:pathPrefix="/c/" />
android:pathPrefix="/d/" />
<data
android:scheme="https"
android:host="www.meshtastic.org"
android:pathPrefix="/C/" />
android:pathPrefix="/D/" />
</intent-filter>
<intent-filter>

View File

@@ -71,18 +71,26 @@ interface IMeshService {
*/
List<NodeInfo> 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<DataPacket> 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

View File

@@ -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
}
}

View File

@@ -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 {

View File

@@ -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)
}
}

View File

@@ -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
}
}

View File

@@ -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)
}
}

View File

@@ -1,4 +1,4 @@
package com.geeksville.mesh.service
package com.geeksville.mesh.model
import com.geeksville.android.Logging

View File

@@ -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<MeshProtos.RadioConfig?>(null) {
val radioConfig = object : MutableLiveData<RadioConfigProtos.RadioConfig?>(null) {
}
val channels = object : MutableLiveData<ChannelSet?>(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<MyNodeInfo>(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<MyNodeInfo?>(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())
}
}

View File

@@ -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<ChannelProtos.Channel>()
/// 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<NodeInfo> = toRemoteExceptions {
val r = nodeDBbyID.values.toMutableList()
info("in getOnline, count=${r.size}")

View File

@@ -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) {

View File

@@ -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<NodeInfo>,
val myInfo: MyNodeInfo,
val messages: Array<DataPacket>,
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

View File

@@ -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()
},

View File

@@ -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())
}
}
}

View File

@@ -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

View File

@@ -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)) {

View File

@@ -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
}
}

View File

@@ -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() })
}
}

View File

@@ -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 {

View File

@@ -10,14 +10,14 @@
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
android:layout_height="0dp"
android:layout_marginTop="16dp">
<com.google.android.material.textview.MaterialTextView
android:id="@+id/warningNotPaired"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
android:autoLink="web"
android:ems="10"
@@ -27,17 +27,25 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/deviceRadioGroup" />
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/nodeSettings"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
android:layout_marginTop="16dp"
>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/usernameView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
android:hint="@string/your_name"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_weight="2"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toStartOf="@+id/regionSpinner"
app:layout_constraintTop_toTopOf="parent">
<com.google.android.material.textfield.TextInputEditText
@@ -48,6 +56,30 @@
android:singleLine="true" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textview.MaterialTextView
android:id="@+id/regionLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/region"
app:layout_constraintEnd_toEndOf="@+id/regionSpinner"
app:layout_constraintTop_toTopOf="parent"
tools:text="Region" />
<Spinner
android:id="@+id/regionSpinner"
android:layout_width="120dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginEnd="16dp"
android:entries="@array/regions"
android:theme="@style/AppTheme.Spinner"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/regionLabel"
app:layout_constraintStart_toEndOf="@+id/usernameView"
/>
</androidx.constraintlayout.widget.ConstraintLayout>
<com.google.android.material.textview.MaterialTextView
android:id="@+id/scanStatusText"
@@ -59,7 +91,7 @@
android:text="@string/looking_for_meshtastic_devices"
app:layout_constraintEnd_toStartOf="@+id/updateFirmwareButton"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/usernameView" />
app:layout_constraintTop_toBottomOf="@+id/nodeSettings" />
<ProgressBar
android:id="@+id/scanProgressBar"
@@ -150,7 +182,5 @@
app:layout_constraintStart_toStartOf="@+id/updateFirmwareButton"
app:layout_constraintTop_toBottomOf="@+id/updateFirmwareButton" />
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<array name="regions">
<!-- FIXME - better to get this through reflection -->
<item>Unset</item>
<item>US</item>
<item>EU433</item>
<item>EU865</item>
<item>CN</item>
<item>JP</item>
<item>ANZ</item>
<item>KR</item>
<item>TW</item>
</array>
</resources>

View File

@@ -89,4 +89,9 @@
<string name="broadcast_period_too_small">Minimum broadcast period for this channel is %d</string>
<string name="protocol_stress_test">Protocol stress test</string>
<string name="advanced_settings">Advanced settings</string>
<string name="firmware_too_old">Firmware update required</string>
<string name="firmware_old">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 <a href="https://www.meshtastic.org/software/firmware-too-old.html">our wiki</a>.</string>
<string name="okay">Okay</string>
<string name="must_set_region">You must set a region!</string>
<string name="region">Region</string>
</resources>

View File

@@ -13,6 +13,20 @@
<item name="android:itemTextAppearance">@style/menu_item_color</item>
</style>
<style name="AppTheme.Spinner">
<item name="android:spinnerItemStyle">@style/SpinnerItem</item>
<item name="android:spinnerDropDownItemStyle">@style/SpinnerDropDownItem</item>
</style>
<style name="SpinnerItem">
<item name="android:gravity">right|center_vertical</item>
<item name="android:paddingRight">16dp</item>
</style>
<style name="SpinnerDropDownItem">
<item name="android:gravity">right|center_vertical</item>
<item name="android:paddingRight">16dp</item>
</style>
<style name="AppTheme.AppBarOverlay" parent="ThemeOverlay.AppCompat.Dark.ActionBar" />