mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
Merge remote-tracking branch 'upstream/master'
This commit is contained in:
commit
8aa7585fdd
22 changed files with 376 additions and 275 deletions
|
|
@ -37,6 +37,7 @@ import androidx.fragment.app.FragmentManager
|
|||
import androidx.fragment.app.FragmentTransaction
|
||||
import androidx.lifecycle.Observer
|
||||
import androidx.viewpager2.adapter.FragmentStateAdapter
|
||||
import com.geeksville.android.BindFailedException
|
||||
import com.geeksville.android.GeeksvilleApplication
|
||||
import com.geeksville.android.Logging
|
||||
import com.geeksville.android.ServiceClient
|
||||
|
|
@ -306,11 +307,7 @@ class MainActivity : AppCompatActivity(), Logging,
|
|||
|
||||
if (deniedPermissions.isNotEmpty()) {
|
||||
errormsg("Denied permissions: ${deniedPermissions.joinToString(",")}")
|
||||
Toast.makeText(
|
||||
this,
|
||||
getString(R.string.permission_missing),
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
showToast(R.string.permission_missing)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -675,8 +672,7 @@ class MainActivity : AppCompatActivity(), Logging,
|
|||
else {
|
||||
|
||||
val curVer = DeviceVersion(info.firmwareVersion ?: "0.0.0")
|
||||
val minVer = DeviceVersion("1.2.0")
|
||||
if (curVer < minVer)
|
||||
if (curVer < MeshService.minFirmwareVersion)
|
||||
showAlert(R.string.firmware_too_old, R.string.firmware_old)
|
||||
else {
|
||||
// If our app is too old/new, we probably don't understand the new radioconfig messages, so we don't read them until here
|
||||
|
|
@ -704,40 +700,52 @@ class MainActivity : AppCompatActivity(), Logging,
|
|||
}
|
||||
}
|
||||
|
||||
private fun showToast(msgId: Int) {
|
||||
Toast.makeText(
|
||||
this,
|
||||
msgId,
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
|
||||
private fun showToast(msg: String) {
|
||||
Toast.makeText(
|
||||
this,
|
||||
msg,
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
|
||||
private fun perhapsChangeChannel() {
|
||||
// If the is opening a channel URL, handle it now
|
||||
requestedChannelUrl?.let { url ->
|
||||
try {
|
||||
val channels = ChannelSet(url)
|
||||
val primary = channels.primaryChannel
|
||||
requestedChannelUrl = null
|
||||
if (primary == null)
|
||||
showToast(R.string.channel_invalid)
|
||||
else {
|
||||
requestedChannelUrl = null
|
||||
|
||||
MaterialAlertDialogBuilder(this)
|
||||
.setTitle(R.string.new_channel_rcvd)
|
||||
.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.setChannels(channels)
|
||||
} catch (ex: RemoteException) {
|
||||
errormsg("Couldn't change channel ${ex.message}")
|
||||
Toast.makeText(
|
||||
this,
|
||||
"Couldn't change channel, because radio is not yet connected. Please try again.",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
MaterialAlertDialogBuilder(this)
|
||||
.setTitle(R.string.new_channel_rcvd)
|
||||
.setMessage(getString(R.string.do_you_want_switch).format(primary.name))
|
||||
.setNeutralButton(R.string.cancel) { _, _ ->
|
||||
// Do nothing
|
||||
}
|
||||
}
|
||||
.show()
|
||||
.setPositiveButton(R.string.accept) { _, _ ->
|
||||
debug("Setting channel from URL")
|
||||
try {
|
||||
model.setChannels(channels)
|
||||
} catch (ex: RemoteException) {
|
||||
errormsg("Couldn't change channel ${ex.message}")
|
||||
showToast(R.string.cant_change_no_radio)
|
||||
}
|
||||
}
|
||||
.show()
|
||||
}
|
||||
} catch (ex: InvalidProtocolBufferException) {
|
||||
Toast.makeText(
|
||||
this,
|
||||
R.string.channel_invalid,
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
showToast(R.string.channel_invalid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -962,8 +970,14 @@ class MainActivity : AppCompatActivity(), Logging,
|
|||
}
|
||||
}
|
||||
|
||||
bindMeshService()
|
||||
|
||||
try {
|
||||
bindMeshService()
|
||||
}
|
||||
catch(ex: BindFailedException) {
|
||||
// App is probably shutting down, ignore
|
||||
errormsg("Bind of MeshService failed")
|
||||
}
|
||||
|
||||
val bonded = RadioInterfaceService.getBondedDeviceAddress(this) != null
|
||||
if (!bonded && usbDevice == null) // we will handle USB later
|
||||
showSettingsPage()
|
||||
|
|
@ -1062,7 +1076,7 @@ class MainActivity : AppCompatActivity(), Logging,
|
|||
try {
|
||||
val packageInfo: PackageInfo = packageManager.getPackageInfo(packageName, 0)
|
||||
val versionName = packageInfo.versionName
|
||||
Toast.makeText(applicationContext, versionName, Toast.LENGTH_LONG).show()
|
||||
showToast(versionName)
|
||||
} catch (e: PackageManager.NameNotFoundException) {
|
||||
errormsg("Can not find the version: ${e.message}")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,12 +14,27 @@ import kotlinx.serialization.Serializable
|
|||
|
||||
@Serializable
|
||||
@Parcelize
|
||||
data class MeshUser(val id: String, val longName: String, val shortName: String) :
|
||||
data class MeshUser(
|
||||
val id: String,
|
||||
val longName: String,
|
||||
val shortName: String,
|
||||
val hwModel: MeshProtos.HardwareModel
|
||||
) :
|
||||
Parcelable {
|
||||
|
||||
override fun toString(): String {
|
||||
return "MeshUser(id=${id.anonymize}, longName=${longName.anonymize}, shortName=${shortName.anonymize})"
|
||||
return "MeshUser(id=${id.anonymize}, longName=${longName.anonymize}, shortName=${shortName.anonymize}, hwModel=${hwModelString})"
|
||||
}
|
||||
|
||||
/** a string version of the hardware model, converted into pretty lowercase and changing _ to -, and p to dot
|
||||
* or null if unset
|
||||
* */
|
||||
val hwModelString: String?
|
||||
get() =
|
||||
if (hwModel == MeshProtos.HardwareModel.UNSET)
|
||||
null
|
||||
else
|
||||
hwModel.name.replace('_', '-').replace('p', '.').toLowerCase()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
|
|
@ -96,8 +111,8 @@ data class NodeInfo(
|
|||
get() {
|
||||
return position?.takeIf {
|
||||
(it.latitude <= 90.0 && it.latitude >= -90) && // If GPS gives a crap position don't crash our app
|
||||
it.latitude != 0.0 &&
|
||||
it.longitude != 0.0
|
||||
it.latitude != 0.0 &&
|
||||
it.longitude != 0.0
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,15 +1,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
|
||||
import com.google.zxing.MultiFormatWriter
|
||||
import com.journeyapps.barcodescanner.BarcodeEncoder
|
||||
import java.net.MalformedURLException
|
||||
|
||||
/** Utility function to make it easy to declare byte arrays - FIXME move someplace better */
|
||||
fun byteArrayOfInts(vararg ints: Int) = ByteArray(ints.size) { pos -> ints[pos].toByte() }
|
||||
|
|
@ -19,19 +11,15 @@ data class Channel(
|
|||
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
|
||||
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(
|
||||
0xd4, 0xf1, 0xbb, 0x3a, 0x20, 0x29, 0x07, 0x59,
|
||||
0xf0, 0xbc, 0xff, 0xab, 0xcf, 0x4e, 0x69, 0xbf
|
||||
)
|
||||
|
||||
// Placeholder when emulating
|
||||
val emulated = Channel(
|
||||
ChannelProtos.ChannelSettings.newBuilder().setName(defaultChannelName)
|
||||
// TH=he unsecured channel that devices ship with
|
||||
val defaultChannel = Channel(
|
||||
ChannelProtos.ChannelSettings.newBuilder()
|
||||
.setModemConfig(ChannelProtos.ChannelSettings.ModemConfig.Bw125Cr45Sf128).build()
|
||||
)
|
||||
}
|
||||
|
|
@ -78,14 +66,13 @@ data class Channel(
|
|||
*/
|
||||
val humanName: String
|
||||
get() {
|
||||
val suffix: Char = if (settings.psk.size() != 1) {
|
||||
// we have a full PSK, so hash it to generate the suffix
|
||||
val code = settings.psk.fold(0, { acc, x -> acc xor (x.toInt() and 0xff) })
|
||||
|
||||
'A' + (code % 26)
|
||||
} else
|
||||
'0' + settings.psk.byteAt(0).toInt()
|
||||
// start with the PSK then xor in the name
|
||||
val pskCode = xorHash(psk.toByteArray())
|
||||
val nameCode = xorHash(name.toByteArray())
|
||||
val suffix = 'A' + ((pskCode xor nameCode) % 26)
|
||||
|
||||
return "#${name}-${suffix}"
|
||||
}
|
||||
}
|
||||
|
||||
fun xorHash(b: ByteArray) = b.fold(0, { acc, x -> acc xor (x.toInt() and 0xff) })
|
||||
|
|
@ -16,11 +16,6 @@ data class ChannelSet(
|
|||
) {
|
||||
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
|
||||
|
|
@ -47,9 +42,11 @@ data class ChannelSet(
|
|||
/**
|
||||
* Return the primary channel info
|
||||
*/
|
||||
val primaryChannel: Channel get() {
|
||||
return Channel(protobuf.getSettings(0))
|
||||
}
|
||||
val primaryChannel: Channel? get() =
|
||||
if(protobuf.settingsCount > 0)
|
||||
Channel(protobuf.getSettings(0))
|
||||
else
|
||||
null
|
||||
|
||||
/// Return an URL that represents the current channel values
|
||||
/// @param upperCasePrefix - portions of the URL can be upper case to make for more efficient QR codes
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.geeksville.mesh.model
|
||||
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import com.geeksville.mesh.MeshProtos
|
||||
import com.geeksville.mesh.MeshUser
|
||||
import com.geeksville.mesh.NodeInfo
|
||||
import com.geeksville.mesh.Position
|
||||
|
|
@ -25,7 +26,8 @@ class NodeDB(private val ui: UIViewModel) {
|
|||
MeshUser(
|
||||
"+16508765308".format(8),
|
||||
"Kevin MesterNoLoc",
|
||||
"KLO"
|
||||
"KLO",
|
||||
MeshProtos.HardwareModel.ANDROID_SIM
|
||||
),
|
||||
null
|
||||
)
|
||||
|
|
@ -36,7 +38,8 @@ class NodeDB(private val ui: UIViewModel) {
|
|||
MeshUser(
|
||||
"+165087653%02d".format(9 + index),
|
||||
"Kevin Mester$index",
|
||||
"KM$index"
|
||||
"KM$index",
|
||||
MeshProtos.HardwareModel.ANDROID_SIM
|
||||
),
|
||||
it
|
||||
)
|
||||
|
|
|
|||
|
|
@ -94,6 +94,10 @@ class MeshService : Service(), Logging {
|
|||
"com.geeksville.mesh",
|
||||
"com.geeksville.mesh.service.MeshService"
|
||||
)
|
||||
|
||||
/** The minimmum firmware version we know how to talk to. We'll still be able to talk to 1.0 firmwares but only well enough to ask them to firmware update
|
||||
*/
|
||||
val minFirmwareVersion = DeviceVersion("1.2.0")
|
||||
}
|
||||
|
||||
enum class ConnectionState {
|
||||
|
|
@ -373,10 +377,10 @@ class MeshService : Service(), Logging {
|
|||
}
|
||||
}
|
||||
|
||||
private fun installNewNodeDB(newMyNodeInfo: MyNodeInfo, nodes: Array<NodeInfo>) {
|
||||
private fun installNewNodeDB(ni: MyNodeInfo, nodes: Array<NodeInfo>) {
|
||||
discardNodeDB() // Get rid of any old state
|
||||
|
||||
myNodeInfo = newMyNodeInfo
|
||||
myNodeInfo = ni
|
||||
|
||||
// put our node array into our two different map representations
|
||||
nodeDBbyNodeNum.putAll(nodes.map { Pair(it.num, it) })
|
||||
|
|
@ -542,7 +546,7 @@ class MeshService : Service(), Logging {
|
|||
|
||||
debug("Sending channels to device")
|
||||
asChannels.forEach {
|
||||
setChannel(it)
|
||||
setChannel(it)
|
||||
}
|
||||
|
||||
channels = asChannels.toTypedArray()
|
||||
|
|
@ -726,7 +730,8 @@ class MeshService : Service(), Logging {
|
|||
|
||||
// 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
|
||||
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)
|
||||
|
|
@ -779,12 +784,13 @@ class MeshService : Service(), Logging {
|
|||
channels[ch.index] = ch
|
||||
debug("Admin: Received channel ${ch.index}")
|
||||
if (ch.index + 1 < mi.maxChannels) {
|
||||
if(ch.hasSettings()) {
|
||||
|
||||
// Stop once we get to the first disabled entry
|
||||
if (/* ch.hasSettings() || */ ch.role != ChannelProtos.Channel.Role.DISABLED) {
|
||||
// Not done yet, request next channel
|
||||
requestChannel(ch.index + 1)
|
||||
}
|
||||
else {
|
||||
debug("We've received the primary channel, allowing rest of app to start...")
|
||||
} else {
|
||||
debug("We've received the last channel, allowing rest of app to start...")
|
||||
onHasSettings()
|
||||
}
|
||||
} else {
|
||||
|
|
@ -807,7 +813,8 @@ class MeshService : Service(), Logging {
|
|||
it.user = MeshUser(
|
||||
if (p.id.isNotEmpty()) p.id else oldId, // If the new update doesn't contain an ID keep our old value
|
||||
p.longName,
|
||||
p.shortName
|
||||
p.shortName,
|
||||
p.hwModel
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -945,13 +952,13 @@ class MeshService : Service(), Logging {
|
|||
}
|
||||
|
||||
private var locationRequestInterval: Long = 0;
|
||||
private fun setupLocationRequest () {
|
||||
private fun setupLocationRequest() {
|
||||
val desiredInterval: Long = if (myNodeInfo?.hasGPS == true) {
|
||||
0L // no requests when device has GPS
|
||||
} else if (numOnlineNodes < 2) {
|
||||
} else if (numOnlineNodes < 2) {
|
||||
5 * 60 * 1000L // send infrequently, device needs these requests to set its clock
|
||||
} else {
|
||||
radioConfig?.preferences?.positionBroadcastSecs?.times( 1000L) ?: 5 * 60 * 1000L
|
||||
radioConfig?.preferences?.positionBroadcastSecs?.times(1000L) ?: 5 * 60 * 1000L
|
||||
}
|
||||
|
||||
debug("desired location request $desiredInterval, current $locationRequestInterval")
|
||||
|
|
@ -1174,18 +1181,6 @@ class MeshService : Service(), Logging {
|
|||
/// Used to make sure we never get foold by old BLE packets
|
||||
private var configNonce = 1
|
||||
|
||||
|
||||
private fun handleRadioConfig(radio: RadioConfigProtos.RadioConfig) {
|
||||
val packetToSave = Packet(
|
||||
UUID.randomUUID().toString(),
|
||||
"RadioConfig",
|
||||
System.currentTimeMillis(),
|
||||
radio.toString()
|
||||
)
|
||||
insertPacket(packetToSave)
|
||||
radioConfig = radio
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a protobuf NodeInfo into our model objects and update our node DB
|
||||
*/
|
||||
|
|
@ -1197,7 +1192,8 @@ class MeshService : Service(), Logging {
|
|||
MeshUser(
|
||||
info.user.id,
|
||||
info.user.longName,
|
||||
info.user.shortName
|
||||
info.user.shortName,
|
||||
info.user.hwModel
|
||||
)
|
||||
|
||||
if (info.hasPosition()) {
|
||||
|
|
@ -1224,6 +1220,80 @@ class MeshService : Service(), Logging {
|
|||
}
|
||||
|
||||
|
||||
private var rawMyNodeInfo: MeshProtos.MyNodeInfo? = null
|
||||
|
||||
/** Regenerate the myNodeInfo model. We call this twice. Once after we receive myNodeInfo from the device
|
||||
* and again after we have the node DB (which might allow us a better notion of our HwModel.
|
||||
*/
|
||||
private fun regenMyNodeInfo() {
|
||||
val myInfo = rawMyNodeInfo
|
||||
if (myInfo != null) {
|
||||
val a = RadioInterfaceService.getBondedDeviceAddress(this)
|
||||
val isBluetoothInterface = a != null && a.startsWith("x")
|
||||
|
||||
var hwModelStr = myInfo.hwModelDeprecated
|
||||
if (hwModelStr.isEmpty()) {
|
||||
val nodeNum =
|
||||
myInfo.myNodeNum // Note: can't use the normal property because myNodeInfo not yet setup
|
||||
val ni = nodeDBbyNodeNum[nodeNum] // can't use toNodeInfo because too early
|
||||
val asStr = ni?.user?.hwModelString
|
||||
if (asStr != null)
|
||||
hwModelStr = asStr
|
||||
}
|
||||
val mi = with(myInfo) {
|
||||
MyNodeInfo(
|
||||
myNodeNum,
|
||||
hasGps,
|
||||
hwModelStr,
|
||||
firmwareVersion,
|
||||
firmwareUpdateFilename != null,
|
||||
isBluetoothInterface && com.geeksville.mesh.service.SoftwareUpdateService.shouldUpdate(
|
||||
this@MeshService,
|
||||
DeviceVersion(firmwareVersion)
|
||||
),
|
||||
currentPacketId.toLong() and 0xffffffffL,
|
||||
if (messageTimeoutMsec == 0) 5 * 60 * 1000 else messageTimeoutMsec, // constants from current device code
|
||||
minAppVersion,
|
||||
maxChannels
|
||||
)
|
||||
}
|
||||
|
||||
newMyNodeInfo = mi
|
||||
setFirmwareUpdateFilename(mi)
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendAnalytics() {
|
||||
val myInfo = rawMyNodeInfo
|
||||
val mi = myNodeInfo
|
||||
if (myInfo != null && mi != null) {
|
||||
/// Track types of devices and firmware versions in use
|
||||
GeeksvilleApplication.analytics.setUserInfo(
|
||||
// DataPair("region", mi.region),
|
||||
DataPair("firmware", mi.firmwareVersion),
|
||||
DataPair("has_gps", mi.hasGPS),
|
||||
DataPair("hw_model", mi.model),
|
||||
DataPair("dev_error_count", myInfo.errorCount)
|
||||
)
|
||||
|
||||
if (myInfo.errorCode.number != 0) {
|
||||
GeeksvilleApplication.analytics.track(
|
||||
"dev_error",
|
||||
DataPair("code", myInfo.errorCode.number),
|
||||
DataPair("address", myInfo.errorAddress),
|
||||
|
||||
// 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// If found, the old region string of the form 1.0-EU865 etc...
|
||||
private var legacyRegion: String? = null
|
||||
|
||||
/**
|
||||
* Update the nodeinfo (called from either new API version or the old one)
|
||||
*/
|
||||
|
|
@ -1236,62 +1306,19 @@ class MeshService : Service(), Logging {
|
|||
)
|
||||
insertPacket(packetToSave)
|
||||
|
||||
setFirmwareUpdateFilename(myInfo)
|
||||
|
||||
val a = RadioInterfaceService.getBondedDeviceAddress(this)
|
||||
val isBluetoothInterface = a != null && a.startsWith("x")
|
||||
|
||||
val mi = with(myInfo) {
|
||||
MyNodeInfo(
|
||||
myNodeNum,
|
||||
hasGps,
|
||||
hwModel,
|
||||
firmwareVersion,
|
||||
firmwareUpdateFilename != null,
|
||||
isBluetoothInterface && SoftwareUpdateService.shouldUpdate(
|
||||
this@MeshService,
|
||||
DeviceVersion(firmwareVersion)
|
||||
),
|
||||
currentPacketId.toLong() and 0xffffffffL,
|
||||
if (messageTimeoutMsec == 0) 5 * 60 * 1000 else messageTimeoutMsec, // constants from current device code
|
||||
minAppVersion,
|
||||
maxChannels
|
||||
)
|
||||
}
|
||||
|
||||
newMyNodeInfo = mi
|
||||
rawMyNodeInfo = myInfo
|
||||
legacyRegion = myInfo.region
|
||||
regenMyNodeInfo()
|
||||
|
||||
// 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) {
|
||||
channels = Array(myInfo.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("firmware", mi.firmwareVersion),
|
||||
DataPair("has_gps", mi.hasGPS),
|
||||
DataPair("hw_model", mi.model),
|
||||
DataPair("dev_error_count", myInfo.errorCount)
|
||||
)
|
||||
|
||||
if (myInfo.errorCode.number != 0) {
|
||||
GeeksvilleApplication.analytics.track(
|
||||
"dev_error",
|
||||
DataPair("code", myInfo.errorCode.number),
|
||||
DataPair("address", myInfo.errorAddress),
|
||||
|
||||
// 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -1314,34 +1341,37 @@ class MeshService : Service(), Logging {
|
|||
}
|
||||
|
||||
if (curRegionValue == RadioConfigProtos.RegionCode.Unset_VALUE) {
|
||||
TODO("Need gui for setting region")
|
||||
/* // look for a legacy region
|
||||
// look for a legacy region
|
||||
val legacyRegex = Regex(".+-(.+)")
|
||||
myNodeInfo?.region?.let { legacyRegion ->
|
||||
val matches = legacyRegex.find(legacyRegion)
|
||||
legacyRegion?.let { lr ->
|
||||
val matches = legacyRegex.find(lr)
|
||||
if (matches != null) {
|
||||
val (region) = matches.destructured
|
||||
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 == RadioConfigProtos.RegionCode.Unset && curRegionValue != RadioConfigProtos.RegionCode.Unset_VALUE) {
|
||||
info("Telling device to upgrade region")
|
||||
if (deviceVersion >= minFirmwareVersion) {
|
||||
info("Telling device to upgrade region")
|
||||
|
||||
// Tell the device to set the new region field (old devices will simply ignore this)
|
||||
radioConfig?.let { currentConfig ->
|
||||
val newConfig = currentConfig.toBuilder()
|
||||
// Tell the device to set the new region field (old devices will simply ignore this)
|
||||
radioConfig?.let { currentConfig ->
|
||||
val newConfig = currentConfig.toBuilder()
|
||||
|
||||
val newPrefs = currentConfig.preferences.toBuilder()
|
||||
newPrefs.regionValue = curRegionValue
|
||||
newConfig.preferences = newPrefs.build()
|
||||
val newPrefs = currentConfig.preferences.toBuilder()
|
||||
newPrefs.regionValue = curRegionValue
|
||||
newConfig.preferences = newPrefs.build()
|
||||
|
||||
sendRadioConfig(newConfig.build())
|
||||
sendRadioConfig(newConfig.build())
|
||||
}
|
||||
}
|
||||
else
|
||||
warn("Device is too old to understand region changes")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1376,13 +1406,23 @@ class MeshService : Service(), Logging {
|
|||
else {
|
||||
discardNodeDB()
|
||||
debug("Installing new node DB")
|
||||
myNodeInfo = newMyNodeInfo
|
||||
myNodeInfo = newMyNodeInfo// Install myNodeInfo as current
|
||||
|
||||
newNodes.forEach(::installNodeInfo)
|
||||
newNodes.clear() // Just to save RAM ;-)
|
||||
|
||||
haveNodeDB = true // we now have nodes from real hardware
|
||||
requestRadioConfig()
|
||||
|
||||
regenMyNodeInfo() // we have a node db now, so can possibly find a better hwmodel
|
||||
myNodeInfo = newMyNodeInfo // we might have just updated myNodeInfo
|
||||
|
||||
sendAnalytics()
|
||||
|
||||
if (deviceVersion < minFirmwareVersion) {
|
||||
info("Device firmware is too old, faking config so firmware update can occur")
|
||||
onHasSettings()
|
||||
} else
|
||||
requestRadioConfig()
|
||||
}
|
||||
} else
|
||||
warn("Ignoring stale config complete")
|
||||
|
|
@ -1553,12 +1593,12 @@ class MeshService : Service(), Logging {
|
|||
/***
|
||||
* Return the filename we will install on the device
|
||||
*/
|
||||
private fun setFirmwareUpdateFilename(info: MeshProtos.MyNodeInfo) {
|
||||
private fun setFirmwareUpdateFilename(info: MyNodeInfo) {
|
||||
firmwareUpdateFilename = try {
|
||||
if (info.region != null && info.firmwareVersion != null && info.hwModel != null)
|
||||
if (info.firmwareVersion != null && info.model != null)
|
||||
SoftwareUpdateService.getUpdateFilename(
|
||||
this,
|
||||
info.hwModel
|
||||
info.model
|
||||
)
|
||||
else
|
||||
null
|
||||
|
|
@ -1670,7 +1710,7 @@ class MeshService : Service(), Logging {
|
|||
|
||||
info("sendData dest=${p.to}, id=${p.id} <- ${p.bytes!!.size} bytes (connectionState=$connectionState)")
|
||||
|
||||
if(p.dataType == 0)
|
||||
if (p.dataType == 0)
|
||||
throw Exception("Port numbers must be non-zero!") // we are now more strict
|
||||
|
||||
// Keep a record of datapackets, so GUIs can show proper chat history
|
||||
|
|
@ -1726,7 +1766,7 @@ class MeshService : Service(), Logging {
|
|||
channelSet.toByteArray()
|
||||
}
|
||||
|
||||
override fun setChannels(payload: ByteArray?) {
|
||||
override fun setChannels(payload: ByteArray?) = toRemoteExceptions {
|
||||
val parsed = AppOnlyProtos.ChannelSet.parseFrom(payload)
|
||||
channelSet = parsed
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,12 +38,22 @@ class MeshServiceLocationCallback(
|
|||
MeshService.info("got phone location")
|
||||
if (location.isAccurateForMesh) { // if within 200 meters, or accuracy is unknown
|
||||
|
||||
// Do we want to broadcast this position globally, or are we just telling the local node what its current position is (
|
||||
val shouldBroadcast = isAllowedToSend()
|
||||
val destinationNumber = if (shouldBroadcast) DataPacket.NODENUM_BROADCAST else getNodeNum()
|
||||
try {
|
||||
// Do we want to broadcast this position globally, or are we just telling the local node what its current position is (
|
||||
val shouldBroadcast = isAllowedToSend()
|
||||
val destinationNumber =
|
||||
if (shouldBroadcast) DataPacket.NODENUM_BROADCAST else getNodeNum()
|
||||
|
||||
// Note: we never want this message sent as a reliable message, because it is low value and we'll be sending one again later anyways
|
||||
sendPosition(location, destinationNumber, wantResponse = false)
|
||||
// Note: we never want this message sent as a reliable message, because it is low value and we'll be sending one again later anyways
|
||||
sendPosition(location, destinationNumber, wantResponse = false)
|
||||
|
||||
} catch (ex: RemoteException) { // Really a RadioNotConnected exception, but it has changed into this type via remoting
|
||||
MeshService.warn("Lost connection to radio, stopping location requests")
|
||||
onSendPositionFailed()
|
||||
} catch (ex: BLEException) { // Really a RadioNotConnected exception, but it has changed into this type via remoting
|
||||
MeshService.warn("BLE exception, stopping location requests $ex")
|
||||
onSendPositionFailed()
|
||||
}
|
||||
} else {
|
||||
MeshService.warn("accuracy ${location.accuracy} is too poor to use")
|
||||
}
|
||||
|
|
@ -51,21 +61,13 @@ class MeshServiceLocationCallback(
|
|||
}
|
||||
|
||||
private fun sendPosition(location: Location, destinationNumber: Int, wantResponse: Boolean) {
|
||||
try {
|
||||
onSendPosition(
|
||||
location.latitude,
|
||||
location.longitude,
|
||||
location.altitude.toInt(),
|
||||
destinationNumber,
|
||||
wantResponse // wantResponse?
|
||||
)
|
||||
} catch (ex: RemoteException) { // Really a RadioNotConnected exception, but it has changed into this type via remoting
|
||||
MeshService.warn("Lost connection to radio, stopping location requests")
|
||||
onSendPositionFailed()
|
||||
} catch (ex: BLEException) { // Really a RadioNotConnected exception, but it has changed into this type via remoting
|
||||
MeshService.warn("BLE exception, stopping location requests $ex")
|
||||
onSendPositionFailed()
|
||||
}
|
||||
onSendPosition(
|
||||
location.latitude,
|
||||
location.longitude,
|
||||
location.altitude.toInt(),
|
||||
destinationNumber,
|
||||
wantResponse // wantResponse?
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -142,6 +142,7 @@ class MockInterface(private val service: RadioInterfaceService) : Logging, IRadi
|
|||
id = DataPacket.nodeNumToDefaultId(numIn)
|
||||
longName = "Sim " + num.toHexString()
|
||||
shortName = getInitials(longName)
|
||||
hwModel = MeshProtos.HardwareModel.ANDROID_SIM
|
||||
}.build()
|
||||
position = MeshProtos.Position.newBuilder().apply {
|
||||
latitudeI = Position.degI(lat)
|
||||
|
|
@ -160,9 +161,8 @@ class MockInterface(private val service: RadioInterfaceService) : Logging, IRadi
|
|||
MeshProtos.FromRadio.newBuilder().apply {
|
||||
myInfo = MeshProtos.MyNodeInfo.newBuilder().apply {
|
||||
myNodeNum = MY_NODE
|
||||
hwModel = "Sim"
|
||||
messageTimeoutMsec = 5 * 60 * 1000
|
||||
firmwareVersion = service.getString(R.string.cur_firmware_version)
|
||||
firmwareVersion = "1.2.8" // Pretend to be running an older 1.2 version
|
||||
numBands = 13
|
||||
maxChannels = 8
|
||||
}.build()
|
||||
|
|
|
|||
|
|
@ -79,11 +79,10 @@ class ChannelFragment : ScreenFragment("Channel"), Logging {
|
|||
/// Pull the latest data from the model (discarding any user edits)
|
||||
private fun setGUIfromModel() {
|
||||
val channels = model.channels.value
|
||||
val channel = channels?.primaryChannel
|
||||
|
||||
binding.editableCheckbox.isChecked = false // start locked
|
||||
if (channels != null) {
|
||||
val channel = channels.primaryChannel
|
||||
|
||||
if (channel != null) {
|
||||
binding.qrView.visibility = View.VISIBLE
|
||||
binding.channelNameEdit.visibility = View.VISIBLE
|
||||
binding.channelNameEdit.setText(channel.humanName)
|
||||
|
|
@ -156,8 +155,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
|
||||
model.channels.value?.let { channels ->
|
||||
binding.channelNameEdit.setText(channels.primaryChannel.name)
|
||||
model.channels.value?.primaryChannel?.let { ch ->
|
||||
binding.channelNameEdit.setText(ch.name)
|
||||
}
|
||||
} else {
|
||||
// User just locked it, we should warn and then apply changes to radio
|
||||
|
|
@ -169,14 +168,13 @@ 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
|
||||
model.channels.value?.let { old ->
|
||||
val oldPrimary = old.primaryChannel
|
||||
val newSettings = oldPrimary.settings.toBuilder()
|
||||
model.channels.value?.primaryChannel?.let { oldPrimary ->
|
||||
var newSettings = oldPrimary.settings.toBuilder()
|
||||
newSettings.name = binding.channelNameEdit.text.toString().trim()
|
||||
|
||||
// Generate a new AES256 key (for any channel not named Default)
|
||||
// Generate a new AES256 key unleess the user is trying to go back to stock
|
||||
if (!newSettings.name.equals(
|
||||
Channel.defaultChannelName,
|
||||
Channel.defaultChannel.name,
|
||||
ignoreCase = true
|
||||
)
|
||||
) {
|
||||
|
|
@ -186,10 +184,8 @@ class ChannelFragment : ScreenFragment("Channel"), Logging {
|
|||
random.nextBytes(bytes)
|
||||
newSettings.psk = ByteString.copyFrom(bytes)
|
||||
} else {
|
||||
debug("ASSIGNING NEW default AES128 KEY")
|
||||
newSettings.name =
|
||||
Channel.defaultChannelName // Fix any case errors
|
||||
newSettings.psk = ByteString.copyFrom(Channel.channelDefaultKey)
|
||||
debug("Switching back to default channel")
|
||||
newSettings = Channel.defaultChannel.settings.toBuilder()
|
||||
}
|
||||
|
||||
val selectedChannelOptionString =
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import com.geeksville.android.Logging
|
|||
import com.geeksville.mesh.NodeInfo
|
||||
import com.geeksville.mesh.R
|
||||
import com.geeksville.mesh.model.UIViewModel
|
||||
import com.geeksville.util.formatAgo
|
||||
import com.mapbox.geojson.Feature
|
||||
import com.mapbox.geojson.FeatureCollection
|
||||
import com.mapbox.geojson.Point
|
||||
|
|
@ -79,7 +80,7 @@ class MapFragment : ScreenFragment("Map"), Logging {
|
|||
)
|
||||
)
|
||||
node.user?.let {
|
||||
f.addStringProperty("name", it.longName)
|
||||
f.addStringProperty("name", it.longName + " " + formatAgo(p.time))
|
||||
}
|
||||
f
|
||||
}
|
||||
|
|
@ -93,7 +94,8 @@ class MapFragment : ScreenFragment("Map"), Logging {
|
|||
}
|
||||
|
||||
fun zoomToNodes(map: MapboxMap) {
|
||||
val nodesWithPosition = model.nodeDB.nodes.value?.values?.filter { it.validPosition != null }
|
||||
val nodesWithPosition =
|
||||
model.nodeDB.nodes.value?.values?.filter { it.validPosition != null }
|
||||
if (nodesWithPosition != null && nodesWithPosition.isNotEmpty()) {
|
||||
val update = if (nodesWithPosition.size >= 2) {
|
||||
// Multiple nodes, make them all fit on the map view
|
||||
|
|
@ -158,7 +160,10 @@ class MapFragment : ScreenFragment("Map"), Logging {
|
|||
if (view != null) { // it might have gone away by now
|
||||
// val markerIcon = BitmapFactory.decodeResource(context.resources, R.drawable.ic_twotone_person_pin_24)
|
||||
val markerIcon =
|
||||
ContextCompat.getDrawable(requireActivity(), R.drawable.ic_twotone_person_pin_24)!!
|
||||
ContextCompat.getDrawable(
|
||||
requireActivity(),
|
||||
R.drawable.ic_twotone_person_pin_24
|
||||
)!!
|
||||
|
||||
map.setStyle(Style.OUTDOORS) { style ->
|
||||
style.addSource(nodePositions)
|
||||
|
|
@ -176,7 +181,7 @@ class MapFragment : ScreenFragment("Map"), Logging {
|
|||
|
||||
// Any times nodes change update our map
|
||||
model.nodeDB.nodes.observe(viewLifecycleOwner, Observer { nodes ->
|
||||
if(isViewVisible)
|
||||
if (isViewVisible)
|
||||
onNodesChanged(map, nodes.values)
|
||||
})
|
||||
zoomToNodes(map)
|
||||
|
|
|
|||
|
|
@ -610,7 +610,7 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
|
|||
statusText.text = getString(R.string.must_set_region)
|
||||
|
||||
connected == MeshService.ConnectionState.CONNECTED -> {
|
||||
val fwStr = info?.firmwareString ?: ""
|
||||
val fwStr = info?.firmwareString ?: "unknown"
|
||||
statusText.text = getString(R.string.connected_to).format(fwStr)
|
||||
}
|
||||
connected == MeshService.ConnectionState.DISCONNECTED ->
|
||||
|
|
|
|||
|
|
@ -2,11 +2,13 @@ package com.geeksville.mesh.ui
|
|||
|
||||
|
||||
import android.os.Bundle
|
||||
import android.text.format.DateFormat
|
||||
import android.text.Html
|
||||
import android.text.method.LinkMovementMethod
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.text.HtmlCompat
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.lifecycle.Observer
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
|
|
@ -17,13 +19,14 @@ import com.geeksville.mesh.R
|
|||
import com.geeksville.mesh.databinding.AdapterNodeLayoutBinding
|
||||
import com.geeksville.mesh.databinding.NodelistFragmentBinding
|
||||
import com.geeksville.mesh.model.UIViewModel
|
||||
import java.text.ParseException
|
||||
import java.util.*
|
||||
import com.geeksville.util.formatAgo
|
||||
import java.net.URLEncoder
|
||||
|
||||
|
||||
class UsersFragment : ScreenFragment("Users"), Logging {
|
||||
|
||||
private var _binding: NodelistFragmentBinding? = null
|
||||
|
||||
// This property is only valid between onCreateView and onDestroyView.
|
||||
private val binding get() = _binding!!
|
||||
|
||||
|
|
@ -34,6 +37,7 @@ class UsersFragment : ScreenFragment("Users"), Logging {
|
|||
class ViewHolder(itemView: AdapterNodeLayoutBinding) : RecyclerView.ViewHolder(itemView.root) {
|
||||
val nodeNameView = itemView.nodeNameView
|
||||
val distanceView = itemView.distanceView
|
||||
val coordsView = itemView.coordsView
|
||||
val batteryPctView = itemView.batteryPercentageView
|
||||
val lastTime = itemView.lastConnectionView
|
||||
val powerIcon = itemView.batteryIcon
|
||||
|
|
@ -105,8 +109,26 @@ class UsersFragment : ScreenFragment("Users"), Logging {
|
|||
*/
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
val n = nodes[position]
|
||||
val name = n.user?.longName ?: n.user?.id ?: "Unknown node"
|
||||
holder.nodeNameView.text = name
|
||||
|
||||
holder.nodeNameView.text = n.user?.longName ?: n.user?.id ?: "Unknown node"
|
||||
val pos = n.validPosition;
|
||||
if (pos != null) {
|
||||
val coords =
|
||||
String.format("%.5f %.5f", pos.latitude, pos.longitude).replace(",", ".")
|
||||
val html =
|
||||
"<a href='geo:${pos.latitude},${pos.longitude}?z=17&label=${
|
||||
URLEncoder.encode(
|
||||
name,
|
||||
"utf-8"
|
||||
)
|
||||
}'>${coords}</a>"
|
||||
holder.coordsView.text = HtmlCompat.fromHtml(html, Html.FROM_HTML_MODE_LEGACY)
|
||||
holder.coordsView.movementMethod = LinkMovementMethod.getInstance()
|
||||
holder.coordsView.visibility = View.VISIBLE
|
||||
} else {
|
||||
holder.coordsView.visibility = View.INVISIBLE
|
||||
}
|
||||
|
||||
val ourNodeInfo = model.nodeDB.ourNodeInfo
|
||||
val distance = ourNodeInfo?.distanceStr(n)
|
||||
|
|
@ -116,10 +138,10 @@ class UsersFragment : ScreenFragment("Users"), Logging {
|
|||
} else {
|
||||
holder.distanceView.visibility = View.INVISIBLE
|
||||
}
|
||||
debug("node=${n.user?.longName} bat=${n.batteryPctLevel}")
|
||||
renderBattery(n.batteryPctLevel, holder)
|
||||
|
||||
holder.lastTime.text = getLastTimeValue(n)
|
||||
holder.lastTime.text = formatAgo(n.lastSeen);
|
||||
|
||||
if ((n.num == ourNodeInfo?.num) || (n.snr > 100f)) {
|
||||
holder.snrView.visibility = View.INVISIBLE
|
||||
} else {
|
||||
|
|
@ -157,30 +179,6 @@ class UsersFragment : ScreenFragment("Users"), Logging {
|
|||
})
|
||||
}
|
||||
|
||||
private fun getLastTimeValue(n: NodeInfo): String {
|
||||
var lastTimeText = "?"
|
||||
val currentTime = (System.currentTimeMillis()/1000).toInt()
|
||||
val threeDaysLong = 3 * 60*60*24
|
||||
|
||||
//if the lastSeen is too old
|
||||
if (n.lastSeen < (currentTime - threeDaysLong))
|
||||
return lastTimeText
|
||||
|
||||
try {
|
||||
val toLong: Long = n.lastSeen.toLong()
|
||||
val long1000 = toLong * 1000L
|
||||
val date = Date(long1000)
|
||||
val timeFormat = DateFormat.getTimeFormat(context)
|
||||
|
||||
lastTimeText = timeFormat.format(date)
|
||||
|
||||
} catch (e: ParseException) {
|
||||
//
|
||||
}
|
||||
return lastTimeText
|
||||
}
|
||||
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater, container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue