diff --git a/README.md b/README.md index e7355a6e..bb4040be 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 5f03dba4..47f1f950 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -113,7 +113,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 2d232095..ecb07e1a 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 @@ -308,11 +309,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) } } @@ -700,40 +697,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) } } } @@ -958,8 +967,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() @@ -1058,7 +1073,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 77d26af7..bd8bacc8 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 @@ -95,8 +110,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/ChannelSet.kt b/app/src/main/java/com/geeksville/mesh/model/ChannelSet.kt index d6b0bcf5..59269184 100644 --- a/app/src/main/java/com/geeksville/mesh/model/ChannelSet.kt +++ b/app/src/main/java/com/geeksville/mesh/model/ChannelSet.kt @@ -47,9 +47,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 d2f35f5f..01ac4e18 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 bad2680d..2567c93b 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt @@ -373,10 +373,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 +542,7 @@ class MeshService : Service(), Logging { debug("Sending channels to device") asChannels.forEach { - setChannel(it) + setChannel(it) } channels = asChannels.toTypedArray() @@ -725,7 +725,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) @@ -778,12 +779,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 { @@ -806,7 +808,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 ) } } @@ -1194,7 +1197,8 @@ class MeshService : Service(), Logging { MeshUser( info.user.id, info.user.longName, - info.user.shortName + info.user.shortName, + info.user.hwModel ) if (info.hasPosition()) { @@ -1221,6 +1225,77 @@ 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) + ) + } + } + } + /** * Update the nodeinfo (called from either new API version or the old one) */ @@ -1233,62 +1308,18 @@ 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, - hwModelDeprecated, - 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 + 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) - ) - } } @@ -1373,12 +1404,18 @@ 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 + + regenMyNodeInfo() // we have a node db now, so can possibly find a better hwmodel + myNodeInfo = newMyNodeInfo // we might have just updated myNodeInfo + + sendAnalytics() + requestRadioConfig() } } else @@ -1550,12 +1587,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.hwModelDeprecated != null) + if (info.firmwareVersion != null && info.model != null) SoftwareUpdateService.getUpdateFilename( this, - info.hwModelDeprecated + info.model ) else null @@ -1667,7 +1704,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 @@ -1723,7 +1760,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 b0bc2e85..ac04a0f2 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 a6786c13..80d4495b 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 - hwModelDeprecated = "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 d4c5e830..bc32450c 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,8 +168,7 @@ 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 + model.channels.value?.primaryChannel?.let { oldPrimary -> val newSettings = oldPrimary.settings.toBuilder() newSettings.name = binding.channelNameEdit.text.toString().trim() 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 46b780ec..fde6466b 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/res/values/strings.xml b/app/src/main/res/values/strings.xml index 09992b3b..11f071ff 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -94,6 +94,7 @@ 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/design b/design index d0339f02..a8107415 160000 --- a/design +++ b/design @@ -1 +1 @@ -Subproject commit d0339f0297c629f1bd6873b4abccfecb98443538 +Subproject commit a81074152157fa54b0d02ccbbd6a6357cc3cedcf diff --git a/geeksville-androidlib b/geeksville-androidlib index 6da25035..158f6f2d 160000 --- a/geeksville-androidlib +++ b/geeksville-androidlib @@ -1 +1 @@ -Subproject commit 6da250358ed13e3c58fd4fa2a123b01b3826d4bf +Subproject commit 158f6f2dd5dfe81833ed035d54045d7b34394e51