mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-02-06 22:02:37 -05:00
@@ -1,6 +1,6 @@
|
||||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'kotlin-android'
|
||||
apply plugin: 'kotlin-android-extensions'
|
||||
apply plugin: 'kotlin-parcelize'
|
||||
apply plugin: 'kotlinx-serialization'
|
||||
apply plugin: 'com.google.gms.google-services'
|
||||
apply plugin: 'com.github.triplet.play'
|
||||
@@ -25,13 +25,13 @@ android {
|
||||
}
|
||||
} */
|
||||
compileSdkVersion 29
|
||||
buildToolsVersion "30.0.1" // Note: 30.0.0.2 doesn't yet work on Github actions CI
|
||||
buildToolsVersion "30.0.2" // Note: 30.0.0.2 doesn't yet work on Github actions CI
|
||||
defaultConfig {
|
||||
applicationId "com.geeksville.mesh"
|
||||
minSdkVersion 21 // The oldest emulator image I have tried is 22 (though 21 probably works)
|
||||
targetSdkVersion 29
|
||||
versionCode 20108 // format is Mmmss (where M is 1+the numeric major number
|
||||
versionName "1.1.8"
|
||||
versionCode 20120 // format is Mmmss (where M is 1+the numeric major number
|
||||
versionName "1.1.20"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
buildTypes {
|
||||
@@ -59,6 +59,8 @@ android {
|
||||
buildFeatures {
|
||||
// Enables Jetpack Compose for this module
|
||||
// compose true // NOTE, if true main app crashes if you use regular view layout functions
|
||||
|
||||
viewBinding true
|
||||
}
|
||||
|
||||
// Set both the Java and Kotlin compilers to target Java 8.
|
||||
@@ -85,10 +87,6 @@ play {
|
||||
serviceAccountCredentials = file("../../play-credentials.json")
|
||||
}
|
||||
|
||||
androidExtensions {
|
||||
experimental = true
|
||||
}
|
||||
|
||||
// per protobuf-gradle-plugin docs, this is recommended for android
|
||||
protobuf {
|
||||
protoc {
|
||||
@@ -147,7 +145,7 @@ dependencies {
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version"
|
||||
|
||||
// For now I'm not using javalite, because I want JSON printing
|
||||
implementation ('com.google.protobuf:protobuf-java:3.13.0')
|
||||
implementation ('com.google.protobuf:protobuf-java:3.14.0')
|
||||
|
||||
// For UART access
|
||||
// implementation 'com.google.android.things:androidthings:1.0'
|
||||
@@ -166,7 +164,7 @@ dependencies {
|
||||
implementation 'com.google.android.gms:play-services-auth:19.0.0'
|
||||
|
||||
// Add the Firebase SDK for Crashlytics.
|
||||
implementation 'com.google.firebase:firebase-crashlytics:17.2.2'
|
||||
implementation 'com.google.firebase:firebase-crashlytics:17.3.0'
|
||||
|
||||
// alas implementation bug deep in the bowels when I tried it for my SyncBluetoothDevice class
|
||||
// implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.3"
|
||||
@@ -177,7 +175,7 @@ dependencies {
|
||||
// barcode support
|
||||
// per https://github.com/journeyapps/zxing-android-embedded for support of android version 22
|
||||
implementation('com.journeyapps:zxing-android-embedded:4.1.0') { transitive = false }
|
||||
implementation 'com.google.zxing:core:3.4.0'
|
||||
implementation 'com.google.zxing:core:3.4.1'
|
||||
|
||||
def work_version = '2.4.0'
|
||||
|
||||
|
||||
@@ -133,7 +133,7 @@
|
||||
<!-- The QR codes to share channel settings are shared as meshtastic URLS
|
||||
|
||||
an approximate example:
|
||||
http://www.meshtastic.org/s/YXNkZnF3ZXJhc2RmcXdlcmFzZGZxd2Vy
|
||||
http://www.meshtastic.org/c/YXNkZnF3ZXJhc2RmcXdlcmFzZGZxd2Vy
|
||||
-->
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ package com.geeksville.mesh
|
||||
|
||||
import android.os.Parcel
|
||||
import android.os.Parcelable
|
||||
import kotlinx.android.parcel.Parcelize
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Parcelize
|
||||
@@ -22,7 +22,7 @@ enum class MessageStatus : Parcelable {
|
||||
data class DataPacket(
|
||||
var to: String? = ID_BROADCAST, // a nodeID string, or ID_BROADCAST for broadcast
|
||||
val bytes: ByteArray?,
|
||||
val dataType: Int, // A value such as MeshProtos.Data.Type.OPAQUE_VALUE
|
||||
val dataType: Int, // A port number for this packet (formerly called DataType, see portnums.proto for new usage instructions)
|
||||
var from: String? = ID_LOCAL, // a nodeID string, or ID_LOCAL for localhost
|
||||
var time: Long = System.currentTimeMillis(), // msecs since 1970
|
||||
var id: Int = 0, // 0 means unassigned
|
||||
@@ -39,14 +39,14 @@ data class DataPacket(
|
||||
*/
|
||||
constructor(to: String? = ID_BROADCAST, text: String) : this(
|
||||
to, text.toByteArray(utf8),
|
||||
MeshProtos.Data.Type.CLEAR_TEXT_VALUE
|
||||
Portnums.PortNum.TEXT_MESSAGE_APP_VALUE
|
||||
)
|
||||
|
||||
/**
|
||||
* If this is a text message, return the string, otherwise null
|
||||
*/
|
||||
val text: String?
|
||||
get() = if (dataType == MeshProtos.Data.Type.CLEAR_TEXT_VALUE)
|
||||
get() = if (dataType == Portnums.PortNum.TEXT_MESSAGE_APP_VALUE)
|
||||
bytes?.toString(utf8)
|
||||
else
|
||||
null
|
||||
|
||||
@@ -38,6 +38,7 @@ import com.geeksville.android.GeeksvilleApplication
|
||||
import com.geeksville.android.Logging
|
||||
import com.geeksville.android.ServiceClient
|
||||
import com.geeksville.concurrent.handledLaunch
|
||||
import com.geeksville.mesh.databinding.ActivityMainBinding
|
||||
import com.geeksville.mesh.model.Channel
|
||||
import com.geeksville.mesh.model.UIViewModel
|
||||
import com.geeksville.mesh.service.*
|
||||
@@ -54,7 +55,6 @@ import com.google.android.material.tabs.TabLayoutMediator
|
||||
import com.google.protobuf.InvalidProtocolBufferException
|
||||
import com.vorlonsoft.android.rate.AppRate
|
||||
import com.vorlonsoft.android.rate.StoreType
|
||||
import kotlinx.android.synthetic.main.activity_main.*
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -124,6 +124,8 @@ class MainActivity : AppCompatActivity(), Logging,
|
||||
13 // seems to be hardwired in CompanionDeviceManager to add 65536
|
||||
}
|
||||
|
||||
private lateinit var binding: ActivityMainBinding
|
||||
|
||||
// Used to schedule a coroutine in the GUI thread
|
||||
private val mainScope = CoroutineScope(Dispatchers.Main + Job())
|
||||
|
||||
@@ -324,14 +326,14 @@ class MainActivity : AppCompatActivity(), Logging,
|
||||
DataPacket(
|
||||
"+16508675310",
|
||||
testPayload,
|
||||
MeshProtos.Data.Type.OPAQUE_VALUE
|
||||
Portnums.PortNum.PRIVATE_APP_VALUE
|
||||
)
|
||||
)
|
||||
m.send(
|
||||
DataPacket(
|
||||
"+16508675310",
|
||||
testPayload,
|
||||
MeshProtos.Data.Type.CLEAR_TEXT_VALUE
|
||||
Portnums.PortNum.TEXT_MESSAGE_APP_VALUE
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -366,6 +368,8 @@ class MainActivity : AppCompatActivity(), Logging,
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
binding = ActivityMainBinding.inflate(layoutInflater)
|
||||
|
||||
val prefs = UIViewModel.getPreferences(this)
|
||||
model.ownerName.value = prefs.getString("owner", "")!!
|
||||
|
||||
@@ -396,15 +400,15 @@ class MainActivity : AppCompatActivity(), Logging,
|
||||
/* setContent {
|
||||
MeshApp()
|
||||
} */
|
||||
setContentView(R.layout.activity_main)
|
||||
setContentView(binding.root)
|
||||
|
||||
initToolbar()
|
||||
|
||||
pager.adapter = tabsAdapter
|
||||
pager.isUserInputEnabled =
|
||||
binding.pager.adapter = tabsAdapter
|
||||
binding.pager.isUserInputEnabled =
|
||||
false // Gestures for screen switching doesn't work so good with the map view
|
||||
// pager.offscreenPageLimit = 0 // Don't keep any offscreen pages around, because we want to make sure our bluetooth scanning stops
|
||||
TabLayoutMediator(tab_layout, pager) { tab, position ->
|
||||
TabLayoutMediator(binding.tabLayout, binding.pager) { tab, position ->
|
||||
// tab.text = tabInfos[position].text // I think it looks better with icons only
|
||||
tab.icon = getDrawable(tabInfos[position].icon)
|
||||
}.attach()
|
||||
@@ -707,7 +711,7 @@ class MainActivity : AppCompatActivity(), Logging,
|
||||
intent.getParcelableExtra<DataPacket>(EXTRA_PAYLOAD)!!
|
||||
|
||||
when (payload.dataType) {
|
||||
MeshProtos.Data.Type.CLEAR_TEXT_VALUE -> {
|
||||
Portnums.PortNum.TEXT_MESSAGE_APP_VALUE -> {
|
||||
model.messagesState.addMessage(payload)
|
||||
}
|
||||
else ->
|
||||
@@ -893,7 +897,7 @@ class MainActivity : AppCompatActivity(), Logging,
|
||||
}
|
||||
|
||||
private fun showSettingsPage() {
|
||||
pager.currentItem = 5
|
||||
binding.pager.currentItem = 5
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
|
||||
@@ -4,7 +4,7 @@ import android.os.Parcelable
|
||||
import com.geeksville.mesh.ui.bearing
|
||||
import com.geeksville.mesh.ui.latLongToMeter
|
||||
import com.geeksville.util.anonymize
|
||||
import kotlinx.android.parcel.Parcelize
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
|
||||
|
||||
@@ -31,17 +31,17 @@ data class Channel(
|
||||
MeshProtos.ChannelSettings.newBuilder().setName(defaultChannelName)
|
||||
.setModemConfig(MeshProtos.ChannelSettings.ModemConfig.Bw125Cr45Sf128).build()
|
||||
)
|
||||
|
||||
private const val prefixRoot = "https://www.meshtastic.org/c/"
|
||||
const val prefix = "$prefixRoot#"
|
||||
|
||||
const val prefix = "https://www.meshtastic.org/c/#"
|
||||
|
||||
private const val base64Flags = Base64.URL_SAFE + Base64.NO_WRAP
|
||||
|
||||
private fun urlToSettings(url: Uri): MeshProtos.ChannelSettings {
|
||||
val urlStr = url.toString()
|
||||
|
||||
// Let the path optionally include the # character (or not) so we can work with old URLs generated by old versions of the app
|
||||
val pathRegex = Regex("$prefixRoot#?(.*)")
|
||||
// We no longer support the super old (about 0.8ish? verison of the URLs that don't use the # separator - I doubt
|
||||
// anyone is still using that old format
|
||||
val pathRegex = Regex("$prefix(.*)")
|
||||
val (base64) = pathRegex.find(urlStr)?.destructured
|
||||
?: throw MalformedURLException("Not a meshtastic URL: ${urlStr.take(40)}")
|
||||
val bytes = Base64.decode(base64, base64Flags)
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package com.geeksville.mesh.service
|
||||
|
||||
import java.io.IOException
|
||||
import java.util.*
|
||||
|
||||
open class BLEException(msg: String) : IOException(msg)
|
||||
open class BLEException(msg: String) : IOException(msg)
|
||||
|
||||
open class BLECharacteristicNotFoundException(uuid: UUID) : BLEException("Can't get characteristic $uuid")
|
||||
@@ -489,6 +489,6 @@ class BluetoothInterface(val service: RadioInterfaceService, val address: String
|
||||
* Get a chracteristic, but in a safe manner because some buggy BLE implementations might return null
|
||||
*/
|
||||
private fun getCharacteristic(uuid: UUID) =
|
||||
bservice.getCharacteristic(uuid) ?: throw BLEException("Can't get characteristic $uuid")
|
||||
bservice.getCharacteristic(uuid) ?: throw BLECharacteristicNotFoundException(uuid)
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.geeksville.mesh.service
|
||||
|
||||
import com.geeksville.android.Logging
|
||||
|
||||
/**
|
||||
* Provide structured access to parse and compare device version strings
|
||||
*/
|
||||
data class DeviceVersion(val asString: String): Comparable<DeviceVersion>, Logging {
|
||||
|
||||
val asInt get() = try {
|
||||
verStringToInt(asString)
|
||||
} catch(e: Exception) {
|
||||
warn("Exception while parsing version '$asString', assuming version 0")
|
||||
0
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a version string of the form 1.23.57 to a comparable integer of
|
||||
* the form 12357.
|
||||
*
|
||||
* Or throw an exception if the string can not be parsed
|
||||
*/
|
||||
private fun verStringToInt(s: String): Int {
|
||||
// Allow 1 to two digits per match
|
||||
val match =
|
||||
Regex("(\\d{1,2}).(\\d{1,2}).(\\d{1,2})").find(s)
|
||||
?: throw Exception("Can't parse version $s")
|
||||
val (major, minor, build) = match.destructured
|
||||
return major.toInt() * 1000 + minor.toInt() * 100 + build.toInt()
|
||||
}
|
||||
|
||||
override fun compareTo(other: DeviceVersion): Int = asInt - other.asInt
|
||||
}
|
||||
@@ -91,6 +91,9 @@ class MeshService : Service(), Logging {
|
||||
private var packetRepo: PacketRepository? = null
|
||||
private var fusedLocationClient: FusedLocationProviderClient? = null
|
||||
|
||||
// If we've ever read a valid region code from our device it will be here
|
||||
var curRegionValue = MeshProtos.RegionCode.Unset_VALUE
|
||||
|
||||
val radio = ServiceClient {
|
||||
IRadioInterfaceService.Stub.asInterface(it).apply {
|
||||
// Now that we are connected to the radio service, tell it to connect to the radio
|
||||
@@ -418,6 +421,8 @@ class MeshService : Service(), Logging {
|
||||
/// END OF MODEL
|
||||
///
|
||||
|
||||
val deviceVersion get() = DeviceVersion(myNodeInfo?.firmwareVersion ?: "")
|
||||
|
||||
/// Map a nodenum to a node, or throw an exception if not found
|
||||
private fun toNodeInfo(n: Int) = nodeDBbyNodeNum[n] ?: throw NodeNumNotFoundException(
|
||||
n
|
||||
@@ -547,7 +552,7 @@ class MeshService : Service(), Logging {
|
||||
to = toId,
|
||||
time = rxTime * 1000L,
|
||||
id = packet.id,
|
||||
dataType = data.typValue,
|
||||
dataType = data.portnumValue,
|
||||
bytes = bytes
|
||||
)
|
||||
}
|
||||
@@ -555,12 +560,15 @@ class MeshService : Service(), Logging {
|
||||
}
|
||||
}
|
||||
|
||||
/// Syntactic sugar to create data subpackets
|
||||
private fun makeData(portNum: Int, bytes: ByteString) = MeshProtos.Data.newBuilder().also {
|
||||
it.portnumValue = portNum
|
||||
it.payload = bytes
|
||||
}.build()
|
||||
|
||||
private fun toMeshPacket(p: DataPacket): MeshPacket {
|
||||
return buildMeshPacket(p.to!!, id = p.id, wantAck = true) {
|
||||
data = MeshProtos.Data.newBuilder().also {
|
||||
it.typ = MeshProtos.Data.Type.forNumber(p.dataType)
|
||||
it.payload = ByteString.copyFrom(p.bytes)
|
||||
}.build()
|
||||
data = makeData(p.dataType, ByteString.copyFrom(p.bytes))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -591,31 +599,36 @@ class MeshService : Service(), Logging {
|
||||
if (myInfo.myNodeNum == packet.from)
|
||||
debug("Ignoring retransmission of our packet ${bytes.size}")
|
||||
else {
|
||||
debug("Received data from $fromId ${bytes.size}")
|
||||
debug("Received data from $fromId, portnum=${data.portnumValue} ${bytes.size} bytes")
|
||||
|
||||
dataPacket.status = MessageStatus.RECEIVED
|
||||
rememberDataPacket(dataPacket)
|
||||
|
||||
when (data.typValue) {
|
||||
MeshProtos.Data.Type.CLEAR_TEXT_VALUE -> {
|
||||
when (data.portnumValue) {
|
||||
Portnums.PortNum.TEXT_MESSAGE_APP_VALUE -> {
|
||||
debug("Received CLEAR_TEXT from $fromId")
|
||||
|
||||
recentReceivedTextPacket = dataPacket
|
||||
updateNotification()
|
||||
serviceBroadcasts.broadcastReceivedData(dataPacket)
|
||||
}
|
||||
|
||||
MeshProtos.Data.Type.CLEAR_READACK_VALUE ->
|
||||
warn(
|
||||
"TODO ignoring CLEAR_READACK from $fromId"
|
||||
)
|
||||
// Handle new style position info
|
||||
Portnums.PortNum.POSITION_APP_VALUE -> {
|
||||
val rxTime = if (packet.rxTime != 0) packet.rxTime else currentSecond()
|
||||
val u = MeshProtos.Position.parseFrom(data.payload)
|
||||
handleReceivedPosition(packet.from, u, rxTime)
|
||||
}
|
||||
|
||||
MeshProtos.Data.Type.OPAQUE_VALUE ->
|
||||
serviceBroadcasts.broadcastReceivedData(dataPacket)
|
||||
|
||||
else -> TODO()
|
||||
// Handle new style user info
|
||||
Portnums.PortNum.NODEINFO_APP_VALUE -> {
|
||||
val u = MeshProtos.User.parseFrom(data.payload)
|
||||
handleReceivedUser(packet.from, u)
|
||||
}
|
||||
}
|
||||
|
||||
// We always tell other apps when new data packets arrive
|
||||
serviceBroadcasts.broadcastReceivedData(dataPacket)
|
||||
|
||||
GeeksvilleApplication.analytics.track(
|
||||
"num_data_receive",
|
||||
DataPair(1)
|
||||
@@ -624,7 +637,7 @@ class MeshService : Service(), Logging {
|
||||
GeeksvilleApplication.analytics.track(
|
||||
"data_receive",
|
||||
DataPair("num_bytes", bytes.size),
|
||||
DataPair("type", data.typValue)
|
||||
DataPair("type", data.portnumValue)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1061,7 +1074,10 @@ class MeshService : Service(), Logging {
|
||||
hwModel,
|
||||
firmwareVersion,
|
||||
firmwareUpdateFilename != null,
|
||||
SoftwareUpdateService.shouldUpdate(this@MeshService, firmwareVersion),
|
||||
SoftwareUpdateService.shouldUpdate(
|
||||
this@MeshService,
|
||||
DeviceVersion(firmwareVersion)
|
||||
),
|
||||
currentPacketId.toLong() and 0xffffffffL,
|
||||
if (nodeNumBits == 0) 8 else nodeNumBits,
|
||||
if (packetIdBits == 0) 8 else packetIdBits,
|
||||
@@ -1095,8 +1111,7 @@ class MeshService : Service(), Logging {
|
||||
}
|
||||
}
|
||||
|
||||
// If we've ever read a valid region code from our device it will be here
|
||||
var curRegionValue = MeshProtos.RegionCode.Unset_VALUE
|
||||
|
||||
|
||||
/**
|
||||
* If we are updating nodes we might need to use old (fixed by firmware build)
|
||||
@@ -1222,7 +1237,14 @@ class MeshService : Service(), Logging {
|
||||
val packet = newMeshPacketTo(destNum)
|
||||
|
||||
packet.decoded = MeshProtos.SubPacket.newBuilder().also {
|
||||
it.position = position
|
||||
val isNewPositionAPI = deviceVersion >= DeviceVersion("1.20.0") // We changed position APIs with this version
|
||||
if (isNewPositionAPI) {
|
||||
// Use the new position as data format
|
||||
it.data = makeData(Portnums.PortNum.POSITION_APP_VALUE, position.toByteString())
|
||||
} else {
|
||||
// Send the old dedicated position subpacket
|
||||
it.position = position
|
||||
}
|
||||
it.wantResponse = wantResponse
|
||||
}.build()
|
||||
|
||||
@@ -1334,7 +1356,7 @@ class MeshService : Service(), Logging {
|
||||
return 0 // We don't have mynodeinfo yet, so just let the radio eventually assign an ID
|
||||
}
|
||||
|
||||
var firmwareUpdateFilename: String? = null
|
||||
var firmwareUpdateFilename: UpdateFilenames? = null
|
||||
|
||||
/***
|
||||
* Return the filename we will install on the device
|
||||
@@ -1458,6 +1480,11 @@ class MeshService : Service(), Logging {
|
||||
// Keep a record of datapackets, so GUIs can show proper chat history
|
||||
rememberDataPacket(p)
|
||||
|
||||
if (p.bytes.size >= MeshProtos.Constants.DATA_PAYLOAD_LEN.number) {
|
||||
p.status = MessageStatus.ERROR
|
||||
throw RemoteException("Message too long")
|
||||
}
|
||||
|
||||
if (p.id != 0) { // If we have an ID we can wait for an ack or nak
|
||||
deleteOldPackets()
|
||||
sentPackets[p.id] = p
|
||||
|
||||
@@ -38,7 +38,7 @@ class SimRadio(private val context: Context) {
|
||||
}.build().toByteArray()
|
||||
) */
|
||||
|
||||
simInitPackets.forEach { json ->
|
||||
simInitPackets.forEach { _ ->
|
||||
val fromRadio = MeshProtos.FromRadio.newBuilder().apply {
|
||||
packet = MeshProtos.MeshPacket.newBuilder().apply {
|
||||
// jsonParser.merge(json, this)
|
||||
|
||||
@@ -18,7 +18,12 @@ import java.util.zip.CRC32
|
||||
*/
|
||||
fun toNetworkByteArray(value: Int, formatType: Int): ByteArray {
|
||||
|
||||
val len: Int = 4 // getTypeLen(formatType)
|
||||
val len = when (formatType) {
|
||||
BluetoothGattCharacteristic.FORMAT_UINT8 -> 1
|
||||
BluetoothGattCharacteristic.FORMAT_UINT32 -> 4
|
||||
else -> TODO()
|
||||
}
|
||||
|
||||
val mValue = ByteArray(len)
|
||||
|
||||
when (formatType) {
|
||||
@@ -44,6 +49,9 @@ fun toNetworkByteArray(value: Int, formatType: Int): ByteArray {
|
||||
mValue.get(offset++) = (value shr 16 and 0xFF).toByte()
|
||||
mValue.get(offset) = (value shr 24 and 0xFF).toByte()
|
||||
} */
|
||||
BluetoothGattCharacteristic.FORMAT_UINT8 ->
|
||||
mValue[0] = (value and 0xFF).toByte()
|
||||
|
||||
BluetoothGattCharacteristic.FORMAT_UINT32 -> {
|
||||
mValue[0] = (value and 0xFF).toByte()
|
||||
mValue[1] = (value shr 8 and 0xFF).toByte()
|
||||
@@ -55,6 +63,9 @@ fun toNetworkByteArray(value: Int, formatType: Int): ByteArray {
|
||||
return mValue
|
||||
}
|
||||
|
||||
|
||||
data class UpdateFilenames(val appLoad: String?, val spiffs: String?)
|
||||
|
||||
/**
|
||||
* typical flow
|
||||
*
|
||||
@@ -152,6 +163,8 @@ class SoftwareUpdateService : JobIntentService(), Logging {
|
||||
UUID.fromString("4826129c-c22a-43a3-b066-ce8f0d5bacc6") // write crc32, write last - writing this will complete the OTA operation, now you can read result
|
||||
private val SW_UPDATE_RESULT_CHARACTER =
|
||||
UUID.fromString("5e134862-7411-4424-ac4a-210937432c77") // read|notify result code, readable but will notify when the OTA operation completes
|
||||
private val SW_UPDATE_REGION_CHARACTER =
|
||||
UUID.fromString("5e134862-7411-4424-ac4a-210937432c67") // write - used to set the region we are setting (appload vs spiffs)
|
||||
|
||||
private val SW_VERSION_CHARACTER = longBLEUUID("2a28")
|
||||
private val MANUFACTURE_CHARACTER = longBLEUUID("2a29")
|
||||
@@ -173,20 +186,7 @@ class SoftwareUpdateService : JobIntentService(), Logging {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a version string of the form 1.23.57 to a comparable integer of
|
||||
* the form 12357.
|
||||
*
|
||||
* Or throw an exception if the string can not be parsed
|
||||
*/
|
||||
fun verStringToInt(s: String): Int {
|
||||
// Allow 1 to two digits per match
|
||||
val match =
|
||||
Regex("(\\d{1,2}).(\\d{1,2}).(\\d{1,2})").find(s)
|
||||
?: throw Exception("Can't parse version $s")
|
||||
val (major, minor, build) = match.destructured
|
||||
return major.toInt() * 1000 + minor.toInt() * 100 + build.toInt()
|
||||
}
|
||||
|
||||
|
||||
/** Return true if we thing the firmwarte shoulde be updated
|
||||
*
|
||||
@@ -194,15 +194,11 @@ class SoftwareUpdateService : JobIntentService(), Logging {
|
||||
*/
|
||||
fun shouldUpdate(
|
||||
context: Context,
|
||||
swVer: String
|
||||
deviceVersion: DeviceVersion
|
||||
): Boolean = try {
|
||||
val curVer = verStringToInt(context.getString(R.string.cur_firmware_version))
|
||||
val curVer = DeviceVersion(context.getString(R.string.cur_firmware_version))
|
||||
val minVer =
|
||||
verStringToInt("0.7.8") // The oldest device version with a working software update service
|
||||
|
||||
// If the user is running a development build we never do an automatic update
|
||||
val deviceVersion =
|
||||
verStringToInt(if (swVer.isEmpty() || swVer == "unset") "99.99.99" else swVer)
|
||||
DeviceVersion("0.7.8") // The oldest device version with a working software update service
|
||||
|
||||
(curVer > deviceVersion) && (deviceVersion >= minVer)
|
||||
} catch (ex: Exception) {
|
||||
@@ -210,28 +206,36 @@ class SoftwareUpdateService : JobIntentService(), Logging {
|
||||
false // If we fail parsing our update info
|
||||
}
|
||||
|
||||
/** Return the filename this device needs to use as an update (or null if no update needed)
|
||||
/** Return a Pair of apploadfilename, spiffs filename this device needs to use as an update (or null if no update needed)
|
||||
*/
|
||||
fun getUpdateFilename(
|
||||
context: Context,
|
||||
mfg: String
|
||||
): String? {
|
||||
val curver = context.getString(R.string.cur_firmware_version)
|
||||
|
||||
val base = "firmware-$mfg-$curver.bin"
|
||||
): UpdateFilenames {
|
||||
val curVer = context.getString(R.string.cur_firmware_version)
|
||||
|
||||
// Check to see if the file exists (some builds might not include update files for size reasons)
|
||||
val firmwareFiles = context.assets.list("firmware") ?: arrayOf()
|
||||
return if (firmwareFiles.contains(base))
|
||||
"firmware/$base"
|
||||
else
|
||||
null
|
||||
|
||||
val appLoad = "firmware-$mfg-$curVer.bin"
|
||||
val spiffs = "spiffs-$curVer.bin"
|
||||
|
||||
return UpdateFilenames(
|
||||
if (firmwareFiles.contains(appLoad))
|
||||
"firmware/$appLoad"
|
||||
else
|
||||
null,
|
||||
if (firmwareFiles.contains(spiffs))
|
||||
"firmware/$spiffs"
|
||||
else
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
/** Return the filename this device needs to use as an update (or null if no update needed)
|
||||
* No longer used, because we get update info inband from our radio API
|
||||
*/
|
||||
fun getUpdateFilename(context: Context, sync: SafeBluetooth): String? {
|
||||
fun getUpdateFilename(context: Context, sync: SafeBluetooth): UpdateFilenames? {
|
||||
val service = sync.gatt!!.services.find { it.uuid == SW_UPDATE_UUID }!!
|
||||
|
||||
//val hwVerDesc = service.getCharacteristic(HW_VERSION_CHARACTER)
|
||||
@@ -248,19 +252,66 @@ class SoftwareUpdateService : JobIntentService(), Logging {
|
||||
* A public function so that if you have your own SafeBluetooth connection already open
|
||||
* you can use it for the software update.
|
||||
*/
|
||||
fun doUpdate(context: Context, sync: SafeBluetooth, assetName: String) {
|
||||
fun doUpdate(context: Context, sync: SafeBluetooth, assets: UpdateFilenames) {
|
||||
// we must attempt spiffs first, because if we update the appload the device will reboot afterwards
|
||||
try {
|
||||
assets.spiffs?.let { doUpdate(context, sync, it, FLASH_REGION_SPIFFS) }
|
||||
}
|
||||
catch(_: BLECharacteristicNotFoundException) {
|
||||
// If we can't update spiffs (because not supported by target), do not fail
|
||||
errormsg("Ignoring failure to update spiffs on old appload")
|
||||
}
|
||||
assets.appLoad?.let { doUpdate(context, sync, it, FLASH_REGION_APPLOAD) }
|
||||
progress = -1 // success
|
||||
}
|
||||
|
||||
// writable region codes in the ESP32 update code
|
||||
private val FLASH_REGION_APPLOAD = 0
|
||||
private val FLASH_REGION_SPIFFS = 100
|
||||
|
||||
/**
|
||||
* A public function so that if you have your own SafeBluetooth connection already open
|
||||
* you can use it for the software update.
|
||||
*/
|
||||
private fun doUpdate(context: Context, sync: SafeBluetooth, assetName: String, flashRegion: Int = FLASH_REGION_APPLOAD) {
|
||||
try {
|
||||
val g = sync.gatt!!
|
||||
val service = g.services.find { it.uuid == SW_UPDATE_UUID }
|
||||
?: throw BLEException("Couldn't find update service")
|
||||
|
||||
info("Starting firmware update for $assetName")
|
||||
/**
|
||||
* Get a chracteristic, but in a safe manner because some buggy BLE implementations might return null
|
||||
*/
|
||||
fun getCharacteristic(uuid: UUID) =
|
||||
service.getCharacteristic(uuid)
|
||||
?: throw BLECharacteristicNotFoundException(uuid)
|
||||
|
||||
info("Starting firmware update for $assetName, flash region $flashRegion")
|
||||
|
||||
progress = 0
|
||||
val totalSizeDesc = service.getCharacteristic(SW_UPDATE_TOTALSIZE_CHARACTER)
|
||||
val dataDesc = service.getCharacteristic(SW_UPDATE_DATA_CHARACTER)
|
||||
val crc32Desc = service.getCharacteristic(SW_UPDATE_CRC32_CHARACTER)
|
||||
val updateResultDesc = service.getCharacteristic(SW_UPDATE_RESULT_CHARACTER)
|
||||
val totalSizeDesc = getCharacteristic(SW_UPDATE_TOTALSIZE_CHARACTER)
|
||||
val dataDesc = getCharacteristic(SW_UPDATE_DATA_CHARACTER)
|
||||
val crc32Desc = getCharacteristic(SW_UPDATE_CRC32_CHARACTER)
|
||||
val updateResultDesc = getCharacteristic(SW_UPDATE_RESULT_CHARACTER)
|
||||
|
||||
/// Try to set the destination region for programming (spiffs vs appload etc)
|
||||
/// Old apploads don't have this feature, but we only fail if the user was trying to set a
|
||||
/// spiffs - otherwise we assume appload.
|
||||
try {
|
||||
val updateRegionDesc = getCharacteristic(SW_UPDATE_REGION_CHARACTER)
|
||||
sync.writeCharacteristic(
|
||||
updateRegionDesc,
|
||||
toNetworkByteArray(flashRegion, BluetoothGattCharacteristic.FORMAT_UINT8)
|
||||
)
|
||||
}
|
||||
catch(ex: BLECharacteristicNotFoundException) {
|
||||
errormsg("Can't set flash programming region (old appload?")
|
||||
if(flashRegion != FLASH_REGION_APPLOAD) {
|
||||
errormsg("Can't set flash programming region (old appload?)")
|
||||
throw ex
|
||||
}
|
||||
warn("Ignoring setting appload flashRegion (old appload?")
|
||||
}
|
||||
|
||||
context.assets.open(assetName).use { firmwareStream ->
|
||||
val firmwareCrc = CRC32()
|
||||
@@ -281,7 +332,11 @@ class SoftwareUpdateService : JobIntentService(), Logging {
|
||||
|
||||
// Send all the blocks
|
||||
while (firmwareNumSent < firmwareSize) {
|
||||
progress = firmwareNumSent * 100 / firmwareSize
|
||||
// If we are doing the spiffs partition, we limit progress to a max of 50%, so that the user doesn't think we are done
|
||||
// yet
|
||||
val maxProgress = if(flashRegion != FLASH_REGION_APPLOAD)
|
||||
50 else 100
|
||||
progress = firmwareNumSent * maxProgress / firmwareSize
|
||||
debug("sending block ${progress}%")
|
||||
var blockSize = 512 - 3 // Max size MTU excluding framing
|
||||
|
||||
@@ -320,8 +375,6 @@ class SoftwareUpdateService : JobIntentService(), Logging {
|
||||
// We might get SyncContinuation timeout on the final write, assume the device simply rebooted to run the new load and we missed it
|
||||
errormsg("Assuming successful update", ex)
|
||||
}
|
||||
|
||||
progress = -1 // success
|
||||
}
|
||||
} catch (ex: BLEException) {
|
||||
progress = -3
|
||||
|
||||
@@ -18,6 +18,7 @@ import com.geeksville.android.Logging
|
||||
import com.geeksville.android.hideKeyboard
|
||||
import com.geeksville.mesh.MeshProtos
|
||||
import com.geeksville.mesh.R
|
||||
import com.geeksville.mesh.databinding.ChannelFragmentBinding
|
||||
import com.geeksville.mesh.model.Channel
|
||||
import com.geeksville.mesh.model.ChannelOption
|
||||
import com.geeksville.mesh.model.UIViewModel
|
||||
@@ -25,7 +26,6 @@ import com.geeksville.mesh.service.MeshService
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.google.protobuf.ByteString
|
||||
import kotlinx.android.synthetic.main.channel_fragment.*
|
||||
import java.security.SecureRandom
|
||||
|
||||
|
||||
@@ -46,26 +46,31 @@ fun ImageView.setOpaque() {
|
||||
|
||||
class ChannelFragment : ScreenFragment("Channel"), Logging {
|
||||
|
||||
private var _binding: ChannelFragmentBinding? = null
|
||||
// This property is only valid between onCreateView and onDestroyView.
|
||||
private val binding get() = _binding!!
|
||||
|
||||
private val model: UIViewModel by activityViewModels()
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater, container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
return inflater.inflate(R.layout.channel_fragment, container, false)
|
||||
_binding = ChannelFragmentBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
/// Called when the lock/unlock icon has changed
|
||||
private fun onEditingChanged() {
|
||||
val isEditing = editableCheckbox.isChecked
|
||||
val isEditing = binding.editableCheckbox.isChecked
|
||||
|
||||
channelOptions.isEnabled = isEditing
|
||||
shareButton.isEnabled = !isEditing
|
||||
channelNameView.isEnabled = isEditing
|
||||
binding.channelOptions.isEnabled = isEditing
|
||||
binding.shareButton.isEnabled = !isEditing
|
||||
binding.channelNameView.isEnabled = isEditing
|
||||
if (isEditing) // Dim the (stale) QR code while editing...
|
||||
qrView.setDim()
|
||||
binding.qrView.setDim()
|
||||
else
|
||||
qrView.setOpaque()
|
||||
binding.qrView.setOpaque()
|
||||
}
|
||||
|
||||
/// Pull the latest data from the model (discarding any user edits)
|
||||
@@ -73,31 +78,31 @@ class ChannelFragment : ScreenFragment("Channel"), Logging {
|
||||
val radioConfig = model.radioConfig.value
|
||||
val channel = UIViewModel.getChannel(radioConfig)
|
||||
|
||||
editableCheckbox.isChecked = false // start locked
|
||||
binding.editableCheckbox.isChecked = false // start locked
|
||||
if (channel != null) {
|
||||
qrView.visibility = View.VISIBLE
|
||||
channelNameEdit.visibility = View.VISIBLE
|
||||
channelNameEdit.setText(channel.humanName)
|
||||
binding.qrView.visibility = View.VISIBLE
|
||||
binding.channelNameEdit.visibility = View.VISIBLE
|
||||
binding.channelNameEdit.setText(channel.humanName)
|
||||
|
||||
// For now, we only let the user edit/save channels while the radio is awake - because the service
|
||||
// doesn't cache radioconfig writes.
|
||||
val connected = model.isConnected.value == MeshService.ConnectionState.CONNECTED
|
||||
editableCheckbox.isEnabled = connected
|
||||
binding.editableCheckbox.isEnabled = connected
|
||||
|
||||
qrView.setImageBitmap(channel.getChannelQR())
|
||||
binding.qrView.setImageBitmap(channel.getChannelQR())
|
||||
|
||||
val modemConfig = radioConfig?.channelSettings?.modemConfig
|
||||
val channelOption = ChannelOption.fromConfig(modemConfig)
|
||||
filled_exposed_dropdown.setText(
|
||||
binding.filledExposedDropdown.setText(
|
||||
getString(
|
||||
channelOption?.configRes ?: R.string.modem_config_unrecognized
|
||||
), false
|
||||
)
|
||||
|
||||
} else {
|
||||
qrView.visibility = View.INVISIBLE
|
||||
channelNameEdit.visibility = View.INVISIBLE
|
||||
editableCheckbox.isEnabled = false
|
||||
binding.qrView.visibility = View.INVISIBLE
|
||||
binding.channelNameEdit.visibility = View.INVISIBLE
|
||||
binding.editableCheckbox.isEnabled = false
|
||||
}
|
||||
|
||||
onEditingChanged() // we just locked the gui
|
||||
@@ -109,7 +114,7 @@ class ChannelFragment : ScreenFragment("Channel"), Logging {
|
||||
modemConfigList
|
||||
)
|
||||
|
||||
filled_exposed_dropdown.setAdapter(adapter)
|
||||
binding.filledExposedDropdown.setAdapter(adapter)
|
||||
}
|
||||
|
||||
private fun shareChannel() {
|
||||
@@ -138,17 +143,17 @@ class ChannelFragment : ScreenFragment("Channel"), Logging {
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
channelNameEdit.on(EditorInfo.IME_ACTION_DONE) {
|
||||
binding.channelNameEdit.on(EditorInfo.IME_ACTION_DONE) {
|
||||
requireActivity().hideKeyboard()
|
||||
}
|
||||
|
||||
// Note: Do not use setOnCheckedChanged here because we don't want to be called when we programmatically disable editing
|
||||
editableCheckbox.setOnClickListener { _ ->
|
||||
val checked = editableCheckbox.isChecked
|
||||
binding.editableCheckbox.setOnClickListener { _ ->
|
||||
val checked = binding.editableCheckbox.isChecked
|
||||
if (checked) {
|
||||
// User just unlocked for editing - remove the # goo around the channel name
|
||||
UIViewModel.getChannel(model.radioConfig.value)?.let { channel ->
|
||||
channelNameEdit.setText(channel.name)
|
||||
binding.channelNameEdit.setText(channel.name)
|
||||
}
|
||||
} else {
|
||||
// User just locked it, we should warn and then apply changes to radio
|
||||
@@ -162,7 +167,7 @@ class ChannelFragment : ScreenFragment("Channel"), Logging {
|
||||
// Generate a new channel with only the changes the user can change in the GUI
|
||||
UIViewModel.getChannel(model.radioConfig.value)?.let { old ->
|
||||
val newSettings = old.settings.toBuilder()
|
||||
newSettings.name = channelNameEdit.text.toString().trim()
|
||||
newSettings.name = binding.channelNameEdit.text.toString().trim()
|
||||
|
||||
// Generate a new AES256 key (for any channel not named Default)
|
||||
if (!newSettings.name.equals(
|
||||
@@ -183,7 +188,7 @@ class ChannelFragment : ScreenFragment("Channel"), Logging {
|
||||
}
|
||||
|
||||
val selectedChannelOptionString =
|
||||
filled_exposed_dropdown.editableText.toString()
|
||||
binding.filledExposedDropdown.editableText.toString()
|
||||
val modemConfig = getModemConfig(selectedChannelOptionString)
|
||||
|
||||
if (modemConfig != MeshProtos.ChannelSettings.ModemConfig.UNRECOGNIZED)
|
||||
@@ -199,7 +204,7 @@ class ChannelFragment : ScreenFragment("Channel"), Logging {
|
||||
|
||||
// Tell the user to try again
|
||||
Snackbar.make(
|
||||
editableCheckbox,
|
||||
binding.editableCheckbox,
|
||||
R.string.radio_sleeping,
|
||||
Snackbar.LENGTH_SHORT
|
||||
).show()
|
||||
@@ -213,7 +218,7 @@ class ChannelFragment : ScreenFragment("Channel"), Logging {
|
||||
}
|
||||
|
||||
// Share this particular channel if someone clicks share
|
||||
shareButton.setOnClickListener {
|
||||
binding.shareButton.setOnClickListener {
|
||||
shareChannel()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,50 +1,54 @@
|
||||
package com.geeksville.mesh.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.Observer
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.geeksville.mesh.R
|
||||
import com.geeksville.mesh.model.UIViewModel
|
||||
import kotlinx.android.synthetic.main.debug_fragment.*
|
||||
|
||||
class DebugFragment : Fragment() {
|
||||
|
||||
val model: UIViewModel by viewModels()
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater, container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
|
||||
return inflater.inflate(R.layout.debug_fragment, container, false)
|
||||
}
|
||||
//Button to clear All log
|
||||
|
||||
//List all log
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
val recyclerView = view.findViewById<RecyclerView>(R.id.packets_recyclerview)
|
||||
val adapter = PacketListAdapter(requireContext())
|
||||
|
||||
recyclerView.adapter = adapter
|
||||
recyclerView.layoutManager = LinearLayoutManager(requireContext())
|
||||
|
||||
clearButton.setOnClickListener {
|
||||
model.deleteAllPacket()
|
||||
}
|
||||
|
||||
closeButton.setOnClickListener{
|
||||
parentFragmentManager.popBackStack();
|
||||
}
|
||||
model.allPackets.observe(viewLifecycleOwner, Observer {
|
||||
packets -> packets?.let { adapter.setPackets(it) }
|
||||
})
|
||||
}
|
||||
package com.geeksville.mesh.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.Observer
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.geeksville.mesh.R
|
||||
import com.geeksville.mesh.databinding.DebugFragmentBinding
|
||||
import com.geeksville.mesh.model.UIViewModel
|
||||
|
||||
class DebugFragment : Fragment() {
|
||||
|
||||
private var _binding: DebugFragmentBinding? = null
|
||||
// This property is only valid between onCreateView and onDestroyView.
|
||||
private val binding get() = _binding!!
|
||||
|
||||
val model: UIViewModel by viewModels()
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater, container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
_binding = DebugFragmentBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
//Button to clear All log
|
||||
|
||||
//List all log
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
val recyclerView = view.findViewById<RecyclerView>(R.id.packets_recyclerview)
|
||||
val adapter = PacketListAdapter(requireContext())
|
||||
|
||||
recyclerView.adapter = adapter
|
||||
recyclerView.layoutManager = LinearLayoutManager(requireContext())
|
||||
|
||||
binding.clearButton.setOnClickListener {
|
||||
model.deleteAllPacket()
|
||||
}
|
||||
|
||||
binding.closeButton.setOnClickListener{
|
||||
parentFragmentManager.popBackStack();
|
||||
}
|
||||
model.allPackets.observe(viewLifecycleOwner, Observer {
|
||||
packets -> packets?.let { adapter.setPackets(it) }
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -16,11 +16,11 @@ import com.geeksville.android.Logging
|
||||
import com.geeksville.mesh.DataPacket
|
||||
import com.geeksville.mesh.MessageStatus
|
||||
import com.geeksville.mesh.R
|
||||
import com.geeksville.mesh.databinding.AdapterMessageLayoutBinding
|
||||
import com.geeksville.mesh.databinding.MessagesFragmentBinding
|
||||
import com.geeksville.mesh.model.UIViewModel
|
||||
import com.geeksville.mesh.service.MeshService
|
||||
import com.google.android.material.chip.Chip
|
||||
import kotlinx.android.synthetic.main.adapter_message_layout.view.*
|
||||
import kotlinx.android.synthetic.main.messages_fragment.*
|
||||
import java.text.DateFormat
|
||||
import java.util.*
|
||||
|
||||
@@ -38,13 +38,17 @@ fun EditText.on(actionId: Int, func: () -> Unit) {
|
||||
|
||||
class MessagesFragment : ScreenFragment("Messages"), Logging {
|
||||
|
||||
private var _binding: MessagesFragmentBinding? = null
|
||||
// This property is only valid between onCreateView and onDestroyView.
|
||||
private val binding get() = _binding!!
|
||||
|
||||
private val model: UIViewModel by activityViewModels()
|
||||
|
||||
private val dateTimeFormat: DateFormat = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM)
|
||||
|
||||
// Provide a direct reference to each of the views within a data item
|
||||
// Used to cache the views within the item layout for fast access
|
||||
class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
||||
class ViewHolder(itemView: AdapterMessageLayoutBinding) : RecyclerView.ViewHolder(itemView.root) {
|
||||
val username: Chip = itemView.username
|
||||
val messageText: TextView = itemView.messageText
|
||||
val messageTime: TextView = itemView.messageTime
|
||||
@@ -82,10 +86,10 @@ class MessagesFragment : ScreenFragment("Messages"), Logging {
|
||||
// Inflate the custom layout
|
||||
|
||||
// Inflate the custom layout
|
||||
val contactView: View = inflater.inflate(R.layout.adapter_message_layout, parent, false)
|
||||
val contactViewBinding = AdapterMessageLayoutBinding.inflate(inflater, parent, false)
|
||||
|
||||
// Return a new holder instance
|
||||
return ViewHolder(contactView)
|
||||
return ViewHolder(contactViewBinding)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -159,7 +163,7 @@ class MessagesFragment : ScreenFragment("Messages"), Logging {
|
||||
|
||||
// scroll to the last line
|
||||
if (itemCount != 0)
|
||||
messageListView.scrollToPosition(itemCount - 1)
|
||||
binding.messageListView.scrollToPosition(itemCount - 1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,27 +171,28 @@ class MessagesFragment : ScreenFragment("Messages"), Logging {
|
||||
inflater: LayoutInflater, container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
return inflater.inflate(R.layout.messages_fragment, container, false)
|
||||
_binding = MessagesFragmentBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
messageInputText.on(EditorInfo.IME_ACTION_DONE) {
|
||||
binding.messageInputText.on(EditorInfo.IME_ACTION_DONE) {
|
||||
debug("did IME action")
|
||||
|
||||
val str = messageInputText.text.toString().trim()
|
||||
val str = binding.messageInputText.text.toString().trim()
|
||||
if (str.isNotEmpty())
|
||||
model.messagesState.sendMessage(str)
|
||||
messageInputText.setText("") // blow away the string the user just entered
|
||||
binding.messageInputText.setText("") // blow away the string the user just entered
|
||||
|
||||
// requireActivity().hideKeyboard()
|
||||
}
|
||||
|
||||
messageListView.adapter = messagesAdapter
|
||||
binding.messageListView.adapter = messagesAdapter
|
||||
val layoutManager = LinearLayoutManager(requireContext())
|
||||
layoutManager.stackFromEnd = true // We want the last rows to always be shown
|
||||
messageListView.layoutManager = layoutManager
|
||||
binding.messageListView.layoutManager = layoutManager
|
||||
|
||||
model.messagesState.messages.observe(viewLifecycleOwner, Observer {
|
||||
debug("New messages received: ${it.size}")
|
||||
@@ -197,13 +202,13 @@ class MessagesFragment : ScreenFragment("Messages"), Logging {
|
||||
// If connection state _OR_ myID changes we have to fix our ability to edit outgoing messages
|
||||
model.isConnected.observe(viewLifecycleOwner, Observer { connected ->
|
||||
// If we don't know our node ID and we are offline don't let user try to send
|
||||
textInputLayout.isEnabled =
|
||||
binding.textInputLayout.isEnabled =
|
||||
connected != MeshService.ConnectionState.DISCONNECTED && model.nodeDB.myId.value != null
|
||||
})
|
||||
|
||||
model.nodeDB.myId.observe(viewLifecycleOwner, Observer { myId ->
|
||||
// If we don't know our node ID and we are offline don't let user try to send
|
||||
textInputLayout.isEnabled =
|
||||
binding.textInputLayout.isEnabled =
|
||||
model.isConnected.value != MeshService.ConnectionState.DISCONNECTED && myId != null
|
||||
})
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ import com.geeksville.mesh.MainActivity
|
||||
import com.geeksville.mesh.R
|
||||
import com.geeksville.mesh.android.bluetoothManager
|
||||
import com.geeksville.mesh.android.usbManager
|
||||
import com.geeksville.mesh.databinding.SettingsFragmentBinding
|
||||
import com.geeksville.mesh.model.UIViewModel
|
||||
import com.geeksville.mesh.service.BluetoothInterface
|
||||
import com.geeksville.mesh.service.MeshService
|
||||
@@ -46,7 +47,6 @@ import com.google.android.gms.location.LocationServices
|
||||
import com.google.android.gms.location.LocationSettingsRequest
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.hoho.android.usbserial.driver.UsbSerialDriver
|
||||
import kotlinx.android.synthetic.main.settings_fragment.*
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -452,6 +452,10 @@ class BTScanModel(app: Application) : AndroidViewModel(app), Logging {
|
||||
@SuppressLint("NewApi")
|
||||
class SettingsFragment : ScreenFragment("Settings"), Logging {
|
||||
|
||||
private var _binding: SettingsFragmentBinding? = null
|
||||
// This property is only valid between onCreateView and onDestroyView.
|
||||
private val binding get() = _binding!!
|
||||
|
||||
private val scanModel: BTScanModel by activityViewModels()
|
||||
private val model: UIViewModel by activityViewModels()
|
||||
|
||||
@@ -478,23 +482,23 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
|
||||
|
||||
mainScope.handledLaunch {
|
||||
debug("User started firmware update")
|
||||
updateFirmwareButton.isEnabled = false // Disable until things complete
|
||||
updateProgressBar.visibility = View.VISIBLE
|
||||
updateProgressBar.progress = 0 // start from scratch
|
||||
binding.updateFirmwareButton.isEnabled = false // Disable until things complete
|
||||
binding.updateProgressBar.visibility = View.VISIBLE
|
||||
binding.updateProgressBar.progress = 0 // start from scratch
|
||||
|
||||
scanStatusText.text = "Updating firmware, wait up to eight minutes..."
|
||||
binding.scanStatusText.text = "Updating firmware, wait up to eight minutes..."
|
||||
try {
|
||||
service.startFirmwareUpdate()
|
||||
while (service.updateStatus >= 0) {
|
||||
updateProgressBar.progress = service.updateStatus
|
||||
binding.updateProgressBar.progress = service.updateStatus
|
||||
delay(2000) // Only check occasionally
|
||||
}
|
||||
} finally {
|
||||
val isSuccess = (service.updateStatus == -1)
|
||||
scanStatusText.text =
|
||||
binding.scanStatusText.text =
|
||||
if (isSuccess) "Update successful" else "Update failed"
|
||||
updateProgressBar.isEnabled = false
|
||||
updateFirmwareButton.isEnabled = !isSuccess
|
||||
binding.updateProgressBar.isEnabled = false
|
||||
binding.updateFirmwareButton.isEnabled = !isSuccess
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -504,7 +508,8 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
|
||||
inflater: LayoutInflater, container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
return inflater.inflate(R.layout.settings_fragment, container, false)
|
||||
_binding = SettingsFragmentBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
private fun initNodeInfo() {
|
||||
@@ -513,36 +518,36 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
|
||||
// If actively connected possibly let the user update firmware
|
||||
val info = model.myNodeInfo.value
|
||||
if (connected == MeshService.ConnectionState.CONNECTED && info != null && info.shouldUpdate && info.couldUpdate) {
|
||||
updateFirmwareButton.visibility = View.VISIBLE
|
||||
updateFirmwareButton.isEnabled = true
|
||||
updateFirmwareButton.text =
|
||||
binding.updateFirmwareButton.visibility = View.VISIBLE
|
||||
binding.updateFirmwareButton.isEnabled = true
|
||||
binding.updateFirmwareButton.text =
|
||||
getString(R.string.update_to).format(getString(R.string.cur_firmware_version))
|
||||
} else {
|
||||
updateFirmwareButton.visibility = View.GONE
|
||||
updateProgressBar.visibility = View.GONE
|
||||
binding.updateFirmwareButton.visibility = View.GONE
|
||||
binding.updateProgressBar.visibility = View.GONE
|
||||
}
|
||||
|
||||
when (connected) {
|
||||
MeshService.ConnectionState.CONNECTED -> {
|
||||
val fwStr = info?.firmwareString ?: ""
|
||||
scanStatusText.text = getString(R.string.connected_to).format(fwStr)
|
||||
binding.scanStatusText.text = getString(R.string.connected_to).format(fwStr)
|
||||
}
|
||||
MeshService.ConnectionState.DISCONNECTED ->
|
||||
scanStatusText.text = getString(R.string.not_connected)
|
||||
binding.scanStatusText.text = getString(R.string.not_connected)
|
||||
MeshService.ConnectionState.DEVICE_SLEEP ->
|
||||
scanStatusText.text = getString(R.string.connected_sleeping)
|
||||
binding.scanStatusText.text = getString(R.string.connected_sleeping)
|
||||
}
|
||||
}
|
||||
|
||||
/// Setup the ui widgets unrelated to BLE scanning
|
||||
private fun initCommonUI() {
|
||||
model.ownerName.observe(viewLifecycleOwner, Observer { name ->
|
||||
usernameEditText.setText(name)
|
||||
binding.usernameEditText.setText(name)
|
||||
})
|
||||
|
||||
// Only let user edit their name or set software update while connected to a radio
|
||||
model.isConnected.observe(viewLifecycleOwner, Observer { connected ->
|
||||
usernameView.isEnabled = connected == MeshService.ConnectionState.CONNECTED
|
||||
binding.usernameView.isEnabled = connected == MeshService.ConnectionState.CONNECTED
|
||||
if (connected == MeshService.ConnectionState.DISCONNECTED)
|
||||
model.ownerName.value = ""
|
||||
initNodeInfo()
|
||||
@@ -553,13 +558,13 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
|
||||
initNodeInfo()
|
||||
})
|
||||
|
||||
updateFirmwareButton.setOnClickListener {
|
||||
binding.updateFirmwareButton.setOnClickListener {
|
||||
doFirmwareUpdate()
|
||||
}
|
||||
|
||||
usernameEditText.on(EditorInfo.IME_ACTION_DONE) {
|
||||
binding.usernameEditText.on(EditorInfo.IME_ACTION_DONE) {
|
||||
debug("did IME action")
|
||||
val n = usernameEditText.text.toString().trim()
|
||||
val n = binding.usernameEditText.text.toString().trim()
|
||||
if (n.isNotEmpty())
|
||||
model.setOwner(n)
|
||||
|
||||
@@ -569,17 +574,17 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
|
||||
val app = (requireContext().applicationContext as GeeksvilleApplication)
|
||||
|
||||
// Set analytics checkbox
|
||||
analyticsOkayCheckbox.isChecked = app.isAnalyticsAllowed
|
||||
binding.analyticsOkayCheckbox.isChecked = app.isAnalyticsAllowed
|
||||
|
||||
analyticsOkayCheckbox.setOnCheckedChangeListener { _, isChecked ->
|
||||
binding.analyticsOkayCheckbox.setOnCheckedChangeListener { _, isChecked ->
|
||||
debug("User changed analytics to $isChecked")
|
||||
app.isAnalyticsAllowed = isChecked
|
||||
reportBugButton.isEnabled = app.isAnalyticsAllowed
|
||||
binding.reportBugButton.isEnabled = app.isAnalyticsAllowed
|
||||
}
|
||||
|
||||
// report bug button only enabled if analytics is allowed
|
||||
reportBugButton.isEnabled = app.isAnalyticsAllowed
|
||||
reportBugButton.setOnClickListener {
|
||||
binding.reportBugButton.isEnabled = app.isAnalyticsAllowed
|
||||
binding.reportBugButton.setOnClickListener {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.report_a_bug)
|
||||
.setMessage(getString(R.string.report_bug_text))
|
||||
@@ -600,34 +605,34 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
|
||||
b.isEnabled = enabled
|
||||
b.isChecked =
|
||||
device.address == scanModel.selectedNotNull && device.bonded // Only show checkbox if device is still paired
|
||||
deviceRadioGroup.addView(b)
|
||||
binding.deviceRadioGroup.addView(b)
|
||||
|
||||
// Once we have at least one device, don't show the "looking for" animation - it makes uers think
|
||||
// something is busted
|
||||
scanProgressBar.visibility = View.INVISIBLE
|
||||
binding.scanProgressBar.visibility = View.INVISIBLE
|
||||
|
||||
b.setOnClickListener {
|
||||
if (!device.bonded) // If user just clicked on us, try to bond
|
||||
scanStatusText.setText(R.string.starting_pairing)
|
||||
binding.scanStatusText.setText(R.string.starting_pairing)
|
||||
|
||||
b.isChecked =
|
||||
scanModel.onSelected(requireActivity() as MainActivity, device)
|
||||
|
||||
if (!b.isSelected)
|
||||
scanStatusText.setText(getString(R.string.please_pair))
|
||||
binding.scanStatusText.setText(getString(R.string.please_pair))
|
||||
}
|
||||
}
|
||||
|
||||
/// Show the GUI for classic scanning
|
||||
private fun showClassicWidgets(visible: Int) {
|
||||
scanProgressBar.visibility = visible
|
||||
deviceRadioGroup.visibility = visible
|
||||
binding.scanProgressBar.visibility = visible
|
||||
binding.deviceRadioGroup.visibility = visible
|
||||
}
|
||||
|
||||
/// Setup the GUI to do a classic (pre SDK 26 BLE scan)
|
||||
private fun initClassicScan() {
|
||||
// Turn off the widgets for the new API (we turn on/off hte classic widgets when we start scanning
|
||||
changeRadioButton.visibility = View.GONE
|
||||
binding.changeRadioButton.visibility = View.GONE
|
||||
|
||||
showClassicWidgets(View.VISIBLE)
|
||||
|
||||
@@ -640,13 +645,13 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
|
||||
|
||||
scanModel.errorText.observe(viewLifecycleOwner, Observer { errMsg ->
|
||||
if (errMsg != null) {
|
||||
scanStatusText.text = errMsg
|
||||
binding.scanStatusText.text = errMsg
|
||||
}
|
||||
})
|
||||
|
||||
scanModel.devices.observe(viewLifecycleOwner, Observer { devices ->
|
||||
// Remove the old radio buttons and repopulate
|
||||
deviceRadioGroup.removeAllViews()
|
||||
binding.deviceRadioGroup.removeAllViews()
|
||||
|
||||
val adapter = scanModel.bluetoothAdapter
|
||||
|
||||
@@ -690,14 +695,14 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
|
||||
RadioInterfaceService.getBondedDeviceAddress(requireContext()) != null
|
||||
|
||||
// get rid of the warning text once at least one device is paired
|
||||
warningNotPaired.visibility = if (hasBonded) View.GONE else View.VISIBLE
|
||||
binding.warningNotPaired.visibility = if (hasBonded) View.GONE else View.VISIBLE
|
||||
})
|
||||
}
|
||||
|
||||
/// Start running the modern scan, once it has one result we enable the
|
||||
private fun startBackgroundScan() {
|
||||
// Disable the change button until our scan has some results
|
||||
changeRadioButton.isEnabled = false
|
||||
binding.changeRadioButton.isEnabled = false
|
||||
|
||||
// To skip filtering based on name and supported feature flags (UUIDs),
|
||||
// don't include calls to setNamePattern() and addServiceUuid(),
|
||||
@@ -726,8 +731,8 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
|
||||
|
||||
override fun onDeviceFound(chooserLauncher: IntentSender) {
|
||||
debug("Found one device - enabling button")
|
||||
changeRadioButton.isEnabled = true
|
||||
changeRadioButton.setOnClickListener {
|
||||
binding.changeRadioButton.isEnabled = true
|
||||
binding.changeRadioButton.setOnClickListener {
|
||||
debug("User clicked BLE change button")
|
||||
|
||||
// Request code seems to be ignored anyways
|
||||
@@ -748,18 +753,18 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
|
||||
|
||||
private fun initModernScan() {
|
||||
// Turn off the widgets for the classic API
|
||||
scanProgressBar.visibility = View.GONE
|
||||
deviceRadioGroup.visibility = View.GONE
|
||||
changeRadioButton.visibility = View.VISIBLE
|
||||
binding.scanProgressBar.visibility = View.GONE
|
||||
binding.deviceRadioGroup.visibility = View.GONE
|
||||
binding.changeRadioButton.visibility = View.VISIBLE
|
||||
|
||||
val curRadio = RadioInterfaceService.getBondedDeviceAddress(requireContext())
|
||||
|
||||
if (curRadio != null) {
|
||||
scanStatusText.text = getString(R.string.current_pair).format(curRadio)
|
||||
changeRadioButton.text = getString(R.string.change_radio)
|
||||
binding.scanStatusText.text = getString(R.string.current_pair).format(curRadio)
|
||||
binding.changeRadioButton.text = getString(R.string.change_radio)
|
||||
} else {
|
||||
scanStatusText.text = getString(R.string.not_paired_yet)
|
||||
changeRadioButton.setText(R.string.select_radio)
|
||||
binding.scanStatusText.text = getString(R.string.not_paired_yet)
|
||||
binding.changeRadioButton.setText(R.string.select_radio)
|
||||
}
|
||||
|
||||
startBackgroundScan()
|
||||
@@ -802,7 +807,7 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
|
||||
debug("We have location access")
|
||||
}
|
||||
|
||||
locationSettingsResponse.addOnFailureListener { exception ->
|
||||
locationSettingsResponse.addOnFailureListener { _ ->
|
||||
errormsg("Failed to get location access")
|
||||
// We always show the toast regardless of what type of exception we receive. Because even non
|
||||
// resolvable api exceptions mean user still needs to fix something.
|
||||
|
||||
@@ -14,22 +14,26 @@ import androidx.recyclerview.widget.RecyclerView
|
||||
import com.geeksville.android.Logging
|
||||
import com.geeksville.mesh.NodeInfo
|
||||
import com.geeksville.mesh.R
|
||||
import com.geeksville.mesh.databinding.AdapterNodeLayoutBinding
|
||||
import com.geeksville.mesh.databinding.NodelistFragmentBinding
|
||||
import com.geeksville.mesh.model.UIViewModel
|
||||
import kotlinx.android.synthetic.main.adapter_node_layout.view.*
|
||||
import kotlinx.android.synthetic.main.nodelist_fragment.*
|
||||
import java.text.ParseException
|
||||
import java.util.*
|
||||
|
||||
|
||||
class UsersFragment : ScreenFragment("Users"), Logging {
|
||||
|
||||
private var _binding: NodelistFragmentBinding? = null
|
||||
// This property is only valid between onCreateView and onDestroyView.
|
||||
private val binding get() = _binding!!
|
||||
|
||||
private val model: UIViewModel by activityViewModels()
|
||||
|
||||
// Provide a direct reference to each of the views within a data item
|
||||
// Used to cache the views within the item layout for fast access
|
||||
class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
||||
class ViewHolder(itemView: AdapterNodeLayoutBinding) : RecyclerView.ViewHolder(itemView.root) {
|
||||
val nodeNameView = itemView.nodeNameView
|
||||
val distance_view = itemView.distance_view
|
||||
val distanceView = itemView.distanceView
|
||||
val batteryPctView = itemView.batteryPercentageView
|
||||
val lastTime = itemView.lastConnectionView
|
||||
val powerIcon = itemView.batteryIcon
|
||||
@@ -64,9 +68,7 @@ class UsersFragment : ScreenFragment("Users"), Logging {
|
||||
val inflater = LayoutInflater.from(requireContext())
|
||||
|
||||
// Inflate the custom layout
|
||||
|
||||
// Inflate the custom layout
|
||||
val contactView: View = inflater.inflate(R.layout.adapter_node_layout, parent, false)
|
||||
val contactView = AdapterNodeLayoutBinding.inflate(inflater, parent, false)
|
||||
|
||||
// Return a new holder instance
|
||||
return ViewHolder(contactView)
|
||||
@@ -108,10 +110,10 @@ class UsersFragment : ScreenFragment("Users"), Logging {
|
||||
val ourNodeInfo = model.nodeDB.ourNodeInfo
|
||||
val distance = ourNodeInfo?.distanceStr(n)
|
||||
if (distance != null) {
|
||||
holder.distance_view.text = distance
|
||||
holder.distance_view.visibility = View.VISIBLE
|
||||
holder.distanceView.text = distance
|
||||
holder.distanceView.visibility = View.VISIBLE
|
||||
} else {
|
||||
holder.distance_view.visibility = View.INVISIBLE
|
||||
holder.distanceView.visibility = View.INVISIBLE
|
||||
}
|
||||
|
||||
renderBattery(n.batteryPctLevel, holder)
|
||||
@@ -176,14 +178,15 @@ class UsersFragment : ScreenFragment("Users"), Logging {
|
||||
inflater: LayoutInflater, container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
return inflater.inflate(R.layout.nodelist_fragment, container, false)
|
||||
_binding = NodelistFragmentBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
nodeListView.adapter = nodesAdapter
|
||||
nodeListView.layoutManager = LinearLayoutManager(requireContext())
|
||||
binding.nodeListView.adapter = nodesAdapter
|
||||
binding.nodeListView.layoutManager = LinearLayoutManager(requireContext())
|
||||
|
||||
model.nodeDB.nodes.observe(viewLifecycleOwner, Observer { it ->
|
||||
nodesAdapter.onNodesChanged(it.values)
|
||||
|
||||
Submodule app/src/main/proto updated: a0b8d88896...aac0044b2d
@@ -1,7 +1,7 @@
|
||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
|
||||
buildscript {
|
||||
ext.kotlin_version = '1.4.10'
|
||||
ext.kotlin_version = '1.4.20'
|
||||
ext.coroutines_version = "1.3.9"
|
||||
|
||||
repositories {
|
||||
@@ -21,10 +21,10 @@ buildscript {
|
||||
// Check that you have the Google Services Gradle plugin v4.3.2 or later
|
||||
// (if not, add it).
|
||||
classpath 'com.google.gms:google-services:4.3.4'
|
||||
classpath 'com.google.firebase:firebase-crashlytics-gradle:2.3.0'
|
||||
classpath 'com.google.firebase:firebase-crashlytics-gradle:2.4.1'
|
||||
|
||||
// protobuf plugin - docs here https://github.com/google/protobuf-gradle-plugin
|
||||
classpath 'com.google.protobuf:protobuf-gradle-plugin:0.8.12'
|
||||
classpath 'com.google.protobuf:protobuf-gradle-plugin:0.8.14'
|
||||
|
||||
//classpath "app.brant:amazonappstorepublisher:0.1.0"
|
||||
classpath 'com.github.triplet.gradle:play-publisher:2.8.0'
|
||||
|
||||
Submodule geeksville-androidlib updated: af1a758b0d...534f0e192b
Reference in New Issue
Block a user