Merge remote-tracking branch 'origin/master' into feature/mapbox-v10-migration

This commit is contained in:
Jackson Rosenthal
2022-02-05 13:22:09 -05:00
16 changed files with 202 additions and 121 deletions

View File

@@ -33,6 +33,7 @@ import androidx.appcompat.app.AppCompatDelegate
import androidx.appcompat.widget.Toolbar
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.FragmentTransaction
@@ -43,7 +44,6 @@ import com.geeksville.android.Logging
import com.geeksville.android.ServiceClient
import com.geeksville.concurrent.handledLaunch
import com.geeksville.mesh.android.*
import com.geeksville.mesh.database.entity.Packet
import com.geeksville.mesh.databinding.ActivityMainBinding
import com.geeksville.mesh.model.ChannelSet
import com.geeksville.mesh.model.DeviceVersion
@@ -60,16 +60,16 @@ import com.google.android.gms.tasks.Task
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
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.coroutines.*
import java.io.FileOutputStream
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import java.nio.charset.Charset
import java.text.DateFormat
import java.util.*
import java.util.regex.Pattern
import kotlin.math.roundToInt
/*
@@ -388,12 +388,7 @@ class MainActivity : AppCompatActivity(), Logging,
return if (message != null) {
errormsg("Denied permissions: $message")
Snackbar.make(findViewById(android.R.id.content), message, Snackbar.LENGTH_INDEFINITE)
.apply { view.findViewById<TextView>(R.id.snackbar_text).isSingleLine = false }
.setAction(R.string.okay) {
// dismiss
}
.show()
showSnackbar(message)
true
} else
false
@@ -483,6 +478,7 @@ class MainActivity : AppCompatActivity(), Logging,
}
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
@@ -661,20 +657,7 @@ class MainActivity : AppCompatActivity(), Logging,
}
CREATE_CSV_FILE -> {
if (resultCode == Activity.RESULT_OK) {
data?.data?.let { file_uri ->
// model.allPackets is a result of a query, so we need to use observer for
// the query to materialize
model.allPackets.observe(this, { packets ->
if (packets != null) {
// no need for observer once got non-null list
model.allPackets.removeObservers(this)
// execute on the default thread pool to not block the main thread
CoroutineScope(Dispatchers.Default + Job()).handledLaunch {
saveMessagesCSV(file_uri, packets)
}
}
})
}
data?.data?.let { file_uri -> model.saveMessagesCSV(file_uri) }
}
}
}
@@ -814,30 +797,35 @@ class MainActivity : AppCompatActivity(), Logging,
}
}
private fun showToast(msgId: Int) {
Toast.makeText(
this,
private fun showSnackbar(msgId: Int) {
Snackbar.make(
findViewById(android.R.id.content),
msgId,
Toast.LENGTH_LONG
Snackbar.LENGTH_LONG
).show()
}
private fun showToast(msg: String) {
Toast.makeText(
this,
private fun showSnackbar(msg: String) {
Snackbar.make(
findViewById(android.R.id.content),
msg,
Toast.LENGTH_LONG
).show()
Snackbar.LENGTH_INDEFINITE
)
.apply { view.findViewById<TextView>(R.id.snackbar_text).isSingleLine = false }
.setAction(R.string.okay) {
// dismiss
}
.show()
}
private fun perhapsChangeChannel() {
fun perhapsChangeChannel(url: Uri? = requestedChannelUrl) {
// If the is opening a channel URL, handle it now
requestedChannelUrl?.let { url ->
if (url != null) {
try {
val channels = ChannelSet(url)
val primary = channels.primaryChannel
if (primary == null)
showToast(R.string.channel_invalid)
showSnackbar(R.string.channel_invalid)
else {
requestedChannelUrl = null
@@ -853,13 +841,14 @@ class MainActivity : AppCompatActivity(), Logging,
model.setChannels(channels)
} catch (ex: RemoteException) {
errormsg("Couldn't change channel ${ex.message}")
showToast(R.string.cant_change_no_radio)
showSnackbar(R.string.cant_change_no_radio)
}
}
.show()
}
} catch (ex: InvalidProtocolBufferException) {
showToast(R.string.channel_invalid)
} catch (ex: Throwable) {
errormsg("Channel url error: ${ex.message}")
showSnackbar("${getString(R.string.channel_invalid)}: ${ex.message}")
}
}
}
@@ -1184,46 +1173,12 @@ class MainActivity : AppCompatActivity(), Logging,
try {
val packageInfo: PackageInfo = packageManager.getPackageInfo(packageName, 0)
val versionName = packageInfo.versionName
showToast(versionName)
Toast.makeText(this, versionName, Toast.LENGTH_LONG).show()
} catch (e: PackageManager.NameNotFoundException) {
errormsg("Can not find the version: ${e.message}")
}
}
private fun saveMessagesCSV(file_uri: Uri, packets: List<Packet>) {
// Extract distances to this device from position messages and put (node,SNR,distance) in
// the file_uri
val myNodeNum = model.myNodeInfo.value?.myNodeNum ?: return
applicationContext.contentResolver.openFileDescriptor(file_uri, "w")?.use {
FileOutputStream(it.fileDescriptor).use { fs ->
// Write header
fs.write(("from,rssi,snr,time,dist\n").toByteArray())
// Packets are ordered by time, we keep most recent position of
// our device in my_position.
var my_position: MeshProtos.Position? = null
packets.forEach {
it.proto?.let { packet_proto ->
it.position?.let { position ->
if (packet_proto.from == myNodeNum) {
my_position = position
} else if (my_position != null) {
val dist = positionToMeter(my_position!!, position).roundToInt()
fs.write(
"%x,%d,%f,%d,%d\n".format(
packet_proto.from, packet_proto.rxRssi,
packet_proto.rxSnr, packet_proto.rxTime, dist
).toByteArray()
)
}
}
}
}
}
}
}
/// Theme functions
private fun chooseThemeDialog() {

View File

@@ -71,6 +71,13 @@ data class Position(
/// @return bearing to the other position in degrees
fun bearing(o: Position) = bearing(latitude, longitude, o.latitude, o.longitude)
// If GPS gives a crap position don't crash our app
fun isValid(): Boolean {
return (latitude <= 90.0 && latitude >= -90) &&
latitude != 0.0 &&
longitude != 0.0
}
override fun toString(): String {
return "Position(lat=${latitude.anonymize}, lon=${longitude.anonymize}, alt=${altitude.anonymize}, time=${time}, batteryPctLevel=${batteryPctLevel})"
}
@@ -112,11 +119,7 @@ data class NodeInfo(
/// return the position if it is valid, else null
val validPosition: Position?
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
}
return position?.takeIf { it.isValid() }
}
/// @return distance in meters to some other node (or null if unknown)

View File

@@ -3,9 +3,11 @@ package com.geeksville.mesh.database
import androidx.lifecycle.LiveData
import com.geeksville.mesh.database.dao.PacketDao
import com.geeksville.mesh.database.entity.Packet
import kotlinx.coroutines.flow.Flow
class PacketRepository(private val packetDao : PacketDao) {
val allPackets : LiveData<List<Packet>> = packetDao.getAllPacket(500)
val allPackets : LiveData<List<Packet>> = packetDao.getAllPacket(MAX_ITEMS)
val allPacketsInReceiveOrder : Flow<List<Packet>> = packetDao.getAllPacketsInReceiveOrder(MAX_ITEMS)
suspend fun insert(packet: Packet) {
packetDao.insert(packet)
@@ -14,4 +16,9 @@ class PacketRepository(private val packetDao : PacketDao) {
suspend fun deleteAll() {
packetDao.deleteAll()
}
companion object {
private const val MAX_ITEMS = 500
}
}

View File

@@ -5,6 +5,7 @@ import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import com.geeksville.mesh.database.entity.Packet
import kotlinx.coroutines.flow.Flow
@Dao
interface PacketDao {
@@ -12,6 +13,9 @@ interface PacketDao {
@Query("Select * from packet order by received_date desc limit 0,:maxItem")
fun getAllPacket(maxItem: Int): LiveData<List<Packet>>
@Query("Select * from packet order by received_date asc limit 0,:maxItem")
fun getAllPacketsInReceiveOrder(maxItem: Int): Flow<List<Packet>>
@Insert
fun insert(packet: Packet)

View File

@@ -12,15 +12,21 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import com.geeksville.android.Logging
import com.geeksville.mesh.IMeshService
import com.geeksville.mesh.MyNodeInfo
import com.geeksville.mesh.RadioConfigProtos
import com.geeksville.mesh.*
import com.geeksville.mesh.database.MeshtasticDatabase
import com.geeksville.mesh.database.PacketRepository
import com.geeksville.mesh.database.entity.Packet
import com.geeksville.mesh.service.MeshService
import com.geeksville.mesh.ui.positionToMeter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.BufferedWriter
import java.io.FileWriter
import java.text.SimpleDateFormat
import java.util.*
import kotlin.math.roundToInt
/// Given a human name, strip out the first letter of the first three words and return that as the initials for
/// that user. If the original name is only one word, strip vowels from the original name and if the result is
@@ -257,5 +263,96 @@ class UIViewModel(private val app: Application) : AndroidViewModel(app), Logging
errormsg("Can't set username on device, is device offline? ${ex.message}")
}
}
/**
* Write the persisted packet data out to a CSV file in the specified location.
*/
fun saveMessagesCSV(file_uri: Uri) {
viewModelScope.launch(Dispatchers.Main) {
// Extract distances to this device from position messages and put (node,SNR,distance) in
// the file_uri
val myNodeNum = myNodeInfo.value?.myNodeNum ?: return@launch
// Capture the current node value while we're still on main thread
val nodes = nodeDB.nodes.value ?: emptyMap()
writeToUri(file_uri) { writer ->
// Create a map of nodes keyed by their ID
val nodesById = nodes.values.associateBy { it.num }
writer.appendLine("date,time,from,sender name,sender lat,sender long,rx lat,rx long,rx elevation,rx snr,distance,hop limit,payload")
// Packets are ordered by time, we keep most recent position of
// our device in localNodePosition.
var localNodePosition: MeshProtos.Position? = null
val dateFormat = SimpleDateFormat("yyyy-MM-dd,HH:mm:ss", Locale.getDefault())
repository.allPacketsInReceiveOrder.first().forEach { packet ->
packet.proto?.let { proto ->
packet.position?.let { position ->
if (proto.from == myNodeNum) {
localNodePosition = position
} else {
val rxDateTime = dateFormat.format(packet.received_date)
val rxFrom = proto.from.toUInt()
val senderName = nodesById[proto.from]?.user?.longName ?: ""
// sender lat & long
val senderPos = packet.position
?.let { p -> Position(p) }
?.takeIf { p -> p.isValid() }
val senderLat = senderPos?.latitude ?: ""
val senderLong = senderPos?.longitude ?: ""
// rx lat, long, and elevation
val rxPos = localNodePosition
?.let { p -> Position(p) }
?.takeIf { p -> p.isValid() }
val rxLat = rxPos?.latitude ?: ""
val rxLong = rxPos?.longitude ?: ""
val rxAlt = rxPos?.altitude ?: ""
val rxSnr = "%f".format(proto.rxSnr)
// Calculate the distance if both positions are valid
val dist = if (senderPos == null || rxPos == null) {
""
} else {
positionToMeter(
localNodePosition!!,
position
).roundToInt().toString()
}
val hopLimit = proto.hopLimit
val payload = when {
proto.decoded.portnumValue != Portnums.PortNum.TEXT_MESSAGE_APP_VALUE -> "<${proto.decoded.portnum}>"
proto.hasDecoded() -> "\"" + proto.decoded.payload.toStringUtf8()
.replace("\"", "\\\"") + "\""
proto.hasEncrypted() -> "${proto.encrypted.size()} encrypted bytes"
else -> ""
}
// date,time,from,sender name,sender lat,sender long,rx lat,rx long,rx elevation,rx snr,distance,hop limit,payload
writer.appendLine("$rxDateTime,$rxFrom,$senderName,$senderLat,$senderLong,$rxLat,$rxLong,$rxAlt,$rxSnr,$dist,$hopLimit,$payload")
}
}
}
}
}
}
}
private suspend inline fun writeToUri(uri: Uri, crossinline block: suspend (BufferedWriter) -> Unit) {
withContext(Dispatchers.IO) {
app.contentResolver.openFileDescriptor(uri, "wt")?.use { parcelFileDescriptor ->
FileWriter(parcelFileDescriptor.fileDescriptor).use { fileWriter ->
BufferedWriter(fileWriter).use { writer ->
block.invoke(writer)
}
}
}
}
}
}

View File

@@ -2,6 +2,7 @@ package com.geeksville.mesh.service
import android.annotation.SuppressLint
import android.app.Service
import android.companion.CompanionDeviceManager
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
@@ -285,6 +286,22 @@ class RadioInterfaceService : Service(), Logging {
debug("Setting bonded device to ${address.anonymize}")
// We only keep an association to one device at a time... (move to BluetoothInterface?)
if (BluetoothInterface.hasCompanionDeviceApi(this)) {
if (address != null) {
val deviceManager = getSystemService(CompanionDeviceManager::class.java)
val c = address[0]
val rest = address.substring(1)
deviceManager.associations.forEach { old ->
if (rest != old) {
debug("Forgetting old BLE association ${old.anonymize}")
deviceManager.disassociate(old)
}
}
}
}
getPrefs(this).edit(commit = true) {
if (address == null)
this.remove(DEVADDR_KEY)

View File

@@ -33,7 +33,6 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import com.google.protobuf.ByteString
import com.google.zxing.integration.android.IntentIntegrator
import java.net.MalformedURLException
import java.security.SecureRandom
@@ -320,18 +319,8 @@ class ChannelFragment : ScreenFragment("Channel"), Logging {
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
val result = IntentIntegrator.parseActivityResult(requestCode, resultCode, data)
if (result != null) {
if (result.contents == null) {
Snackbar.make(binding.scanButton, R.string.channel_invalid, Snackbar.LENGTH_LONG).show()
} else {
try {
val intent = Intent(Intent.ACTION_VIEW)
intent.data = ChannelSet(Uri.parse(result.contents)).getChannelUrl(false)
startActivity(intent)
} catch (ex: ActivityNotFoundException) {
Snackbar.make(binding.scanButton, R.string.channel_invalid, Snackbar.LENGTH_LONG).show()
} catch (ex: MalformedURLException) {
Snackbar.make(binding.scanButton, R.string.channel_invalid, Snackbar.LENGTH_LONG).show()
}
if (result.contents != null) {
((requireActivity() as MainActivity).perhapsChangeChannel(Uri.parse(result.contents)))
}
} else {
super.onActivityResult(requestCode, resultCode, data)