diff --git a/README.md b/README.md index e7355a6ed..bb4040be5 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ The app is also distributed for Amazon Fire devices via the Amazon appstore: [![ If you would like to develop this application we'd love your help! These build instructions are brief and should be improved, please send a PR if you can. -* Use Android Studio 4.0 RC 1 to build/debug (other versions might work but no promises) +* Use Android Studio 4.1.2 to build/debug (other versions might work but no promises) * Use "git submodule update --init --recursive" to pull in the various submodules we depend on * There are a few config files which you'll need to copy from templates included in the project. Run the following commands to do so: diff --git a/app/build.gradle b/app/build.gradle index 5f03dba4f..5cac80212 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -4,6 +4,7 @@ apply plugin: 'kotlin-parcelize' apply plugin: 'kotlinx-serialization' apply plugin: 'com.google.gms.google-services' apply plugin: 'com.github.triplet.play' +apply plugin: 'de.mobilej.unmock' // apply plugin: "app.brant.amazonappstorepublisher" // Apply the Crashlytics Gradle plugin @@ -14,6 +15,11 @@ apply plugin: 'com.google.protobuf' apply plugin: 'kotlin-kapt' +unMock { + keep "android.net.Uri" + keep "android.util.Base64" +} + android { /* signingConfigs { @@ -31,8 +37,8 @@ android { applicationId "com.geeksville.mesh" minSdkVersion 21 // The oldest emulator image I have tried is 22 (though 21 probably works) targetSdkVersion 29 - versionCode 20207 // format is Mmmss (where M is 1+the numeric major number - versionName "1.2.7" + versionCode 20211 // format is Mmmss (where M is 1+the numeric major number + versionName "1.2.11" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" // per https://developer.android.com/studio/write/vector-asset-studio @@ -113,7 +119,7 @@ dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" implementation 'androidx.appcompat:appcompat:1.2.0' implementation 'androidx.core:core-ktx:1.3.2' - implementation 'androidx.fragment:fragment-ktx:1.3.0' + implementation 'androidx.fragment:fragment-ktx:1.3.1' implementation 'androidx.cardview:cardview:1.0.0' implementation 'androidx.recyclerview:recyclerview:1.1.0' implementation 'androidx.constraintlayout:constraintlayout:2.0.4' diff --git a/app/src/main/java/com/geeksville/mesh/MainActivity.kt b/app/src/main/java/com/geeksville/mesh/MainActivity.kt index 49afe1710..94c12b7cf 100644 --- a/app/src/main/java/com/geeksville/mesh/MainActivity.kt +++ b/app/src/main/java/com/geeksville/mesh/MainActivity.kt @@ -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}") } diff --git a/app/src/main/java/com/geeksville/mesh/NodeInfo.kt b/app/src/main/java/com/geeksville/mesh/NodeInfo.kt index 2daeaeabd..86d32d78a 100644 --- a/app/src/main/java/com/geeksville/mesh/NodeInfo.kt +++ b/app/src/main/java/com/geeksville/mesh/NodeInfo.kt @@ -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 } } diff --git a/app/src/main/java/com/geeksville/mesh/model/Channel.kt b/app/src/main/java/com/geeksville/mesh/model/Channel.kt index 5686bb209..665a8de25 100644 --- a/app/src/main/java/com/geeksville/mesh/model/Channel.kt +++ b/app/src/main/java/com/geeksville/mesh/model/Channel.kt @@ -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) }) \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/model/ChannelSet.kt b/app/src/main/java/com/geeksville/mesh/model/ChannelSet.kt index d6b0bcf5e..3fa0b4697 100644 --- a/app/src/main/java/com/geeksville/mesh/model/ChannelSet.kt +++ b/app/src/main/java/com/geeksville/mesh/model/ChannelSet.kt @@ -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 diff --git a/app/src/main/java/com/geeksville/mesh/model/NodeDB.kt b/app/src/main/java/com/geeksville/mesh/model/NodeDB.kt index d2f35f5ff..01ac4e18c 100644 --- a/app/src/main/java/com/geeksville/mesh/model/NodeDB.kt +++ b/app/src/main/java/com/geeksville/mesh/model/NodeDB.kt @@ -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 ) diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt index 76fd3ca61..e0f38200f 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt @@ -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) { + private fun installNewNodeDB(ni: MyNodeInfo, nodes: Array) { 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 } diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshServiceLocationCallback.kt b/app/src/main/java/com/geeksville/mesh/service/MeshServiceLocationCallback.kt index b0bc2e857..ac04a0f24 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshServiceLocationCallback.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshServiceLocationCallback.kt @@ -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? + ) } /** diff --git a/app/src/main/java/com/geeksville/mesh/service/MockInterface.kt b/app/src/main/java/com/geeksville/mesh/service/MockInterface.kt index 7c76458e5..80d4495be 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MockInterface.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MockInterface.kt @@ -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() diff --git a/app/src/main/java/com/geeksville/mesh/ui/ChannelFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/ChannelFragment.kt index d4c5e8303..9ca623088 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/ChannelFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/ChannelFragment.kt @@ -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 = diff --git a/app/src/main/java/com/geeksville/mesh/ui/MapFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/MapFragment.kt index e4d6bd771..f40c78b3d 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/MapFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/MapFragment.kt @@ -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) diff --git a/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt index 46b780ecf..fde6466bd 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt @@ -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 -> diff --git a/app/src/main/java/com/geeksville/mesh/ui/UsersFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/UsersFragment.kt index db90c63f8..744a87106 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/UsersFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/UsersFragment.kt @@ -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 = + "${coords}" + 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? diff --git a/app/src/main/proto b/app/src/main/proto index 7c025b9a4..ac26ffdc7 160000 --- a/app/src/main/proto +++ b/app/src/main/proto @@ -1 +1 @@ -Subproject commit 7c025b9a4d54bb410ec17ee653122861b413f177 +Subproject commit ac26ffdc71dad5765124186df5ec38771a0e5240 diff --git a/app/src/main/res/layout/adapter_node_layout.xml b/app/src/main/res/layout/adapter_node_layout.xml index 610eb0d60..75905d5e9 100644 --- a/app/src/main/res/layout/adapter_node_layout.xml +++ b/app/src/main/res/layout/adapter_node_layout.xml @@ -51,6 +51,20 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/imageView" /> + + Okay You must set a region! Region + Couldn\'t change channel, because radio is not yet connected. Please try again. + 55.332244 34.442211 Save messages as csv... diff --git a/app/src/test/java/com/geeksville/mesh/NodeInfoTest.kt b/app/src/test/java/com/geeksville/mesh/NodeInfoTest.kt index aa9059e57..cf6713cc9 100644 --- a/app/src/test/java/com/geeksville/mesh/NodeInfoTest.kt +++ b/app/src/test/java/com/geeksville/mesh/NodeInfoTest.kt @@ -8,9 +8,10 @@ import org.junit.Test import java.util.* class NodeInfoTest { - val ni1 = NodeInfo(4, MeshUser("+one", "User One", "U1"), Position(37.1, 121.1, 35)) - val ni2 = NodeInfo(5, MeshUser("+two", "User Two", "U2"), Position(37.11, 121.1, 40)) - val ni3 = NodeInfo(6, MeshUser("+three", "User Three", "U3"), Position(37.101, 121.1, 40)) + val model = MeshProtos.HardwareModel.ANDROID_SIM + val ni1 = NodeInfo(4, MeshUser("+one", "User One", "U1", model), Position(37.1, 121.1, 35)) + val ni2 = NodeInfo(5, MeshUser("+two", "User Two", "U2", model), Position(37.11, 121.1, 40)) + val ni3 = NodeInfo(6, MeshUser("+three", "User Three", "U3", model), Position(37.101, 121.1, 40)) private val currentDefaultLocale = LocaleListCompat.getDefault().get(0) diff --git a/app/src/test/java/com/geeksville/mesh/model/ChannelSetTest.kt b/app/src/test/java/com/geeksville/mesh/model/ChannelSetTest.kt new file mode 100644 index 000000000..bf41360d8 --- /dev/null +++ b/app/src/test/java/com/geeksville/mesh/model/ChannelSetTest.kt @@ -0,0 +1,17 @@ +package com.geeksville.mesh.model + +import android.net.Uri +import org.junit.Assert +import org.junit.Test + +class ChannelSetTest { + /** make sure we match the python and device code behavior */ + @Test + fun matchPython() { + val url = Uri.parse("https://www.meshtastic.org/d/#CgUYAyIBAQ") + val cs = ChannelSet(url) + Assert.assertEquals("LongSlow", cs.primaryChannel!!.name, ) + Assert.assertEquals("#LongSlow-V", cs.primaryChannel!!.humanName, ) + Assert.assertEquals(url, cs.getChannelUrl(false)) + } +} \ No newline at end of file diff --git a/app/src/test/java/com/geeksville/mesh/service/MeshServiceTest.kt b/app/src/test/java/com/geeksville/mesh/service/MeshServiceTest.kt index f8db6328c..aabea7598 100644 --- a/app/src/test/java/com/geeksville/mesh/service/MeshServiceTest.kt +++ b/app/src/test/java/com/geeksville/mesh/service/MeshServiceTest.kt @@ -1,31 +1,32 @@ -package com.geeksville.mesh.service - -import com.geeksville.mesh.MeshUser -import com.geeksville.mesh.NodeInfo -import com.geeksville.mesh.Position -import org.junit.Assert -import org.junit.Test - - -class MeshServiceTest { - - val nodeInfo = NodeInfo(4, MeshUser("+one", "User One", "U1"), Position(37.1, 121.1, 35, 10)) - - @Test - fun givenNodeInfo_whenUpdatingWithNewTime_thenPositionTimeIsUpdated() { - - val newerTime = 20 - updateNodeInfoTime(nodeInfo, newerTime) - Assert.assertEquals(newerTime, nodeInfo.position?.time) - } - - @Test - fun givenNodeInfo_whenUpdatingWithOldTime_thenPositionTimeIsNotUpdated() { - val olderTime = 5 - val timeBeforeTryingToUpdate = nodeInfo.position?.time - updateNodeInfoTime(nodeInfo, olderTime) - Assert.assertEquals(timeBeforeTryingToUpdate, nodeInfo.position?.time) - } -} - - +package com.geeksville.mesh.service + +import com.geeksville.mesh.MeshProtos +import com.geeksville.mesh.MeshUser +import com.geeksville.mesh.NodeInfo +import com.geeksville.mesh.Position +import org.junit.Assert +import org.junit.Test + + +class MeshServiceTest { + val model = MeshProtos.HardwareModel.ANDROID_SIM + val nodeInfo = NodeInfo(4, MeshUser("+one", "User One", "U1", model), Position(37.1, 121.1, 35, 10)) + + @Test + fun givenNodeInfo_whenUpdatingWithNewTime_thenPositionTimeIsUpdated() { + + val newerTime = 20 + updateNodeInfoTime(nodeInfo, newerTime) + Assert.assertEquals(newerTime, nodeInfo.position?.time) + } + + @Test + fun givenNodeInfo_whenUpdatingWithOldTime_thenPositionTimeIsNotUpdated() { + val olderTime = 5 + val timeBeforeTryingToUpdate = nodeInfo.position?.time + updateNodeInfoTime(nodeInfo, olderTime) + Assert.assertEquals(timeBeforeTryingToUpdate, nodeInfo.position?.time) + } +} + + diff --git a/build.gradle b/build.gradle index 183d7bf8d..ba99badad 100644 --- a/build.gradle +++ b/build.gradle @@ -10,7 +10,7 @@ buildscript { mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:4.1.2' + classpath 'com.android.tools.build:gradle:4.1.3' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version" @@ -28,6 +28,9 @@ buildscript { //classpath "app.brant:amazonappstorepublisher:0.1.0" classpath 'com.github.triplet.gradle:play-publisher:2.8.0' + + // for unit testing https://github.com/bjoernQ/unmock-plugin + classpath 'com.github.bjoernq:unmockplugin:0.7.6' } } diff --git a/geeksville-androidlib b/geeksville-androidlib index 99cf0da30..158f6f2dd 160000 --- a/geeksville-androidlib +++ b/geeksville-androidlib @@ -1 +1 @@ -Subproject commit 99cf0da30fe41163a735ac291f3dd018a7d6295d +Subproject commit 158f6f2dd5dfe81833ed035d54045d7b34394e51