diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml
index 1638c5a2f..1991620cc 100644
--- a/.github/workflows/android.yml
+++ b/.github/workflows/android.yml
@@ -19,11 +19,12 @@ jobs:
- name: Load secrets
run: |
rm ./app/src/main/res/values/mapbox-token.xml
- echo -e "\n $MAPBOXTOKEN\n" > ./app/src/main/res/values/mapbox-token.xml
+ echo -e "\n $MAPBOX_ACCESS_TOKEN\n" > ./app/src/main/res/values/mapbox-token.xml
mkdir -p ~/.gradle
- echo "MAPBOX_DOWNLOADS_TOKEN=$MAPBOXTOKEN" >>~/.gradle/gradle.properties
+ echo "MAPBOX_DOWNLOADS_TOKEN=$MAPBOX_DOWNLOADS_TOKEN" >>~/.gradle/gradle.properties
env:
- MAPBOXTOKEN: ${{ secrets.MAPBOXTOKEN }}
+ MAPBOX_ACCESS_TOKEN: ${{ secrets.MAPBOX_ACCESS_TOKEN }}
+ MAPBOX_DOWNLOADS_TOKEN: ${{ secrets.MAPBOX_DOWNLOADS_TOKEN }}
- name: Mock files for CI
run: |
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 8c8d7336e..1d19fb552 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -22,14 +22,15 @@ jobs:
rm ./app/google-services.json
echo $GSERVICES > ./app/google-services.json
rm ./app/src/main/res/values/mapbox-token.xml
- echo -e "\n $MAPBOXTOKEN\n" > ./app/src/main/res/values/mapbox-token.xml
+ echo -e "\n $MAPBOX_ACCESS_TOKEN\n" > ./app/src/main/res/values/mapbox-token.xml
mkdir -p ~/.gradle
- echo "MAPBOX_DOWNLOADS_TOKEN=$MAPBOXTOKEN" >> ~/.gradle/gradle.properties
+ echo "MAPBOX_DOWNLOADS_TOKEN=$MAPBOX_DOWNLOADS_TOKEN" >> ~/.gradle/gradle.properties
echo $KEYSTORE | base64 -di > ./app/$KEYSTORE_FILENAME
echo "$KEYSTORE_PROPERTIES" > ./keystore.properties
env:
GSERVICES: ${{ secrets.GSERVICES }}
- MAPBOXTOKEN: ${{ secrets.MAPBOXTOKEN }}
+ MAPBOX_ACCESS_TOKEN: ${{ secrets.MAPBOX_ACCESS_TOKEN }}
+ MAPBOX_DOWNLOADS_TOKEN: ${{ secrets.MAPBOX_DOWNLOADS_TOKEN }}
KEYSTORE: ${{ secrets.KEYSTORE }}
KEYSTORE_FILENAME: ${{ secrets.KEYSTORE_FILENAME }}
KEYSTORE_PROPERTIES: ${{ secrets.KEYSTORE_PROPERTIES }}
@@ -47,6 +48,7 @@ jobs:
with:
repository: meshtastic/Meshtastic-device
releases-only: true
+ prefix: 'v1.2.'
token: ${{ secrets.GITHUB_TOKEN }}
- name: Create version strings
@@ -72,7 +74,7 @@ jobs:
run: |
rm -rf ./app/src/main/assets/firmware
mkdir -p ./app/src/main/assets/firmware
- unzip -qq ./firmware.zip 'spiffs-*.bin' 'firmware-heltec*.bin' 'firmware-tbeam*.bin' 'firmware-tlora*.bin' -d ./app/src/main/assets/firmware
+ unzip -qq ./firmware.zip 'spiffs-*.bin' 'firmware-heltec*.bin' 'firmware-tbeam*.bin' 'firmware-tlora*.bin' 'firmware-nano*.bin' -d ./app/src/main/assets/firmware
rm ./firmware.zip
- name: Validate Gradle wrapper
diff --git a/.gitmodules b/.gitmodules
index e225f97e7..b3af2a267 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -1,6 +1,7 @@
[submodule "app/src/main/proto"]
path = app/src/main/proto
url = https://github.com/meshtastic/Meshtastic-protobufs.git
+ branch = 1.2-legacy
[submodule "geeksville-androidlib"]
path = geeksville-androidlib
url = https://github.com/meshtastic/geeksville-androidlib.git
diff --git a/app/build.gradle b/app/build.gradle
index 9dcd3403e..739052e9e 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -43,8 +43,8 @@ android {
applicationId "com.geeksville.mesh"
minSdkVersion 21 // The oldest emulator image I have tried is 22 (though 21 probably works)
targetSdkVersion 30 // 30 can't work until an explicit location permissions dialog is added
- versionCode 20258 // format is Mmmss (where M is 1+the numeric major number
- versionName "1.2.58"
+ versionCode 20266 // format is Mmmss (where M is 1+the numeric major number
+ versionName "1.2.66"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
// per https://developer.android.com/studio/write/vector-asset-studio
@@ -122,7 +122,7 @@ protobuf {
dependencies {
- def room_version = '2.4.1'
+ def room_version = '2.4.2'
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation 'androidx.appcompat:appcompat:1.4.1'
@@ -134,7 +134,7 @@ dependencies {
implementation 'com.google.android.material:material:1.5.0'
implementation 'androidx.viewpager2:viewpager2:1.0.0'
implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
- implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.4.0'
+ implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.4.1'
implementation "androidx.room:room-runtime:$room_version"
implementation "com.google.dagger:hilt-android:$hilt_version"
kapt "androidx.room:room-compiler:$room_version"
@@ -175,9 +175,10 @@ dependencies {
// location services
implementation 'com.google.android.gms:play-services-location:19.0.1'
-
// For Google Sign-In (owner name accesss)
implementation 'com.google.android.gms:play-services-auth:20.1.0'
+ // ML Kit barcode scanning
+ implementation 'com.google.android.gms:play-services-code-scanner:16.0.0-beta1'
// Add the Firebase SDK for Crashlytics.
implementation 'com.google.firebase:firebase-crashlytics:18.2.6'
@@ -207,4 +208,4 @@ dependencies {
kapt {
correctErrorTypes true
-}
\ No newline at end of file
+}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 723da6332..c1392c906 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -97,6 +97,9 @@
+
{
@@ -145,7 +156,7 @@ data class DataPacket(
override fun newArray(size: Int): Array {
return arrayOfNulls(size)
}
- val utf8 = Charset.forName("UTF-8")
+ val utf8: Charset = Charset.forName("UTF-8")
}
diff --git a/app/src/main/java/com/geeksville/mesh/MainActivity.kt b/app/src/main/java/com/geeksville/mesh/MainActivity.kt
index 044dbf6f9..75e13187d 100644
--- a/app/src/main/java/com/geeksville/mesh/MainActivity.kt
+++ b/app/src/main/java/com/geeksville/mesh/MainActivity.kt
@@ -4,7 +4,6 @@ import android.Manifest
import android.annotation.SuppressLint
import android.app.Activity
import android.bluetooth.BluetoothAdapter
-import android.bluetooth.BluetoothManager
import android.content.*
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
@@ -14,6 +13,7 @@ import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Handler
+import android.os.Looper
import android.os.RemoteException
import android.text.method.LinkMovementMethod
import android.view.Menu
@@ -40,6 +40,7 @@ import com.geeksville.android.ServiceClient
import com.geeksville.concurrent.handledLaunch
import com.geeksville.mesh.android.*
import com.geeksville.mesh.databinding.ActivityMainBinding
+import com.geeksville.mesh.model.BluetoothViewModel
import com.geeksville.mesh.model.ChannelSet
import com.geeksville.mesh.model.DeviceVersion
import com.geeksville.mesh.model.UIViewModel
@@ -125,7 +126,7 @@ class MainActivity : AppCompatActivity(), Logging,
const val REQUEST_ENABLE_BT = 10
const val DID_REQUEST_PERM = 11
const val RC_SIGN_IN = 12 // google signin completed
- const val SELECT_DEVICE_REQUEST_CODE = 13
+ // const val SELECT_DEVICE_REQUEST_CODE = 13
const val CREATE_CSV_FILE = 14
}
@@ -134,11 +135,7 @@ class MainActivity : AppCompatActivity(), Logging,
// Used to schedule a coroutine in the GUI thread
private val mainScope = CoroutineScope(Dispatchers.Main + Job())
- private val bluetoothAdapter: BluetoothAdapter? by lazy(LazyThreadSafetyMode.NONE) {
- val bluetoothManager = getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
- bluetoothManager.adapter
- }
-
+ private val bluetoothViewModel: BluetoothViewModel by viewModels()
val model: UIViewModel by viewModels()
data class TabInfo(val text: String, val icon: Int, val content: Fragment)
@@ -148,7 +145,7 @@ class MainActivity : AppCompatActivity(), Logging,
TabInfo(
"Messages",
R.drawable.ic_twotone_message_24,
- MessagesFragment()
+ ContactsFragment()
),
TabInfo(
"Users",
@@ -187,28 +184,6 @@ class MainActivity : AppCompatActivity(), Logging,
}
}
- private val btStateReceiver = BluetoothStateReceiver {
- updateBluetoothEnabled()
- }
-
- /**
- * Don't tell our app we have bluetooth until we have bluetooth _and_ location access
- */
- private fun updateBluetoothEnabled() {
- var enabled = false // assume failure
-
- if (hasConnectPermission()) {
- /// ask the adapter if we have access
- bluetoothAdapter?.apply {
- enabled = isEnabled
- }
- } else
- errormsg("Still missing needed bluetooth permissions")
-
- debug("Detected our bluetooth access=$enabled")
- model.bluetoothEnabled.value = enabled
- }
-
/** Get the minimum permissions our app needs to run correctly
*/
private fun getMinimumPermissions(): List {
@@ -381,7 +356,7 @@ class MainActivity : AppCompatActivity(), Logging,
}
}
- updateBluetoothEnabled()
+ bluetoothViewModel.permissionsUpdated()
}
@@ -445,12 +420,6 @@ class MainActivity : AppCompatActivity(), Logging,
/// Set theme
setUITheme(prefs)
- /// Set initial bluetooth state
- updateBluetoothEnabled()
-
- /// We now want to be informed of bluetooth state
- registerReceiver(btStateReceiver, btStateReceiver.intentFilter)
-
/* not yet working
// Configure sign-in to request the user's ID, email address, and basic
// profile. ID and basic profile are included in DEFAULT_SIGN_IN.
@@ -545,8 +514,7 @@ class MainActivity : AppCompatActivity(), Logging,
requestedChannelUrl = appLinkData
// if the device is connected already, process it now
- if (model.isConnected.value == MeshService.ConnectionState.CONNECTED)
- perhapsChangeChannel()
+ perhapsChangeChannel()
// We now wait for the device to connect, once connected, we ask the user if they want to switch to the new channel
}
@@ -569,7 +537,6 @@ class MainActivity : AppCompatActivity(), Logging,
}
override fun onDestroy() {
- unregisterReceiver(btStateReceiver)
unregisterMeshReceiver()
mainScope.cancel("Activity going away")
super.onDestroy()
@@ -765,16 +732,16 @@ class MainActivity : AppCompatActivity(), Logging,
}
}
- fun perhapsChangeChannel(url: Uri? = requestedChannelUrl) {
- // If the is opening a channel URL, handle it now
- if (url != null) {
+ private fun perhapsChangeChannel(url: Uri? = requestedChannelUrl) {
+ // if the device is connected already, process it now
+ if (url != null && model.isConnected.value == MeshService.ConnectionState.CONNECTED) {
+ requestedChannelUrl = null
try {
val channels = ChannelSet(url)
val primary = channels.primaryChannel
if (primary == null)
showSnackbar(R.string.channel_invalid)
else {
- requestedChannelUrl = null
MaterialAlertDialogBuilder(this)
.setTitle(R.string.new_channel_rcvd)
@@ -1003,17 +970,26 @@ class MainActivity : AppCompatActivity(), Logging,
override fun onStart() {
super.onStart()
- // Ask to start bluetooth if no USB devices are visible
- val hasUSB = SerialInterface.findDrivers(this).isNotEmpty()
- if (!isInTestLab && !hasUSB) {
- if (hasConnectPermission()) {
- bluetoothAdapter?.let {
- if (!it.isEnabled) {
+ bluetoothViewModel.enabled.observe(this) { enabled ->
+ if (!enabled) {
+ // Ask to start bluetooth if no USB devices are visible
+ val hasUSB = SerialInterface.findDrivers(this).isNotEmpty()
+ if (!isInTestLab && !hasUSB) {
+ if (hasConnectPermission()) {
val enableBtIntent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)
startActivityForResult(enableBtIntent, REQUEST_ENABLE_BT)
- }
+ } else requestPermission()
}
- } else requestPermission()
+ }
+ }
+
+ // Call perhapsChangeChannel() whenever [changeChannelUrl] updates with a non-null value
+ model.requestChannelUrl.observe(this) { url ->
+ url?.let {
+ requestedChannelUrl = url
+ model.clearRequestChannelUrl()
+ perhapsChangeChannel()
+ }
}
try {
@@ -1043,7 +1019,7 @@ class MainActivity : AppCompatActivity(), Logging,
}
val handler: Handler by lazy {
- Handler(mainLooper)
+ Handler(Looper.getMainLooper())
}
override fun onPrepareOptionsMenu(menu: Menu): Boolean {
diff --git a/app/src/main/java/com/geeksville/mesh/android/ContextServices.kt b/app/src/main/java/com/geeksville/mesh/android/ContextServices.kt
index 6f2312bf8..61f4f1cc7 100644
--- a/app/src/main/java/com/geeksville/mesh/android/ContextServices.kt
+++ b/app/src/main/java/com/geeksville/mesh/android/ContextServices.kt
@@ -1,24 +1,43 @@
package com.geeksville.mesh.android
import android.Manifest
+import android.annotation.SuppressLint
import android.app.NotificationManager
import android.bluetooth.BluetoothManager
+import android.companion.CompanionDeviceManager
import android.content.Context
import android.content.pm.PackageManager
import android.hardware.usb.UsbManager
import android.os.Build
import androidx.core.content.ContextCompat
-import com.geeksville.mesh.service.BluetoothInterface
+import com.geeksville.android.GeeksvilleApplication
+import com.geeksville.mesh.MainActivity
/**
* @return null on platforms without a BlueTooth driver (i.e. the emulator)
*/
val Context.bluetoothManager: BluetoothManager? get() = getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager?
+val Context.deviceManager: CompanionDeviceManager?
+ @SuppressLint("InlinedApi")
+ get() {
+ val activity: MainActivity? = GeeksvilleApplication.currentActivity as MainActivity?
+ return if (hasCompanionDeviceApi()) activity?.getSystemService(Context.COMPANION_DEVICE_SERVICE) as? CompanionDeviceManager?
+ else null
+ }
+
val Context.usbManager: UsbManager get() = requireNotNull(getSystemService(Context.USB_SERVICE) as? UsbManager?) { "USB_SERVICE is not available"}
val Context.notificationManager: NotificationManager get() = requireNotNull(getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager?)
+/**
+ * @return true if CompanionDeviceManager API is present
+ */
+fun Context.hasCompanionDeviceApi(): Boolean =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
+ packageManager.hasSystemFeature(PackageManager.FEATURE_COMPANION_DEVICE_SETUP)
+ else false
+
/**
* return a list of the permissions we don't have
*/
@@ -62,7 +81,7 @@ fun Context.getScanPermissions(): List {
perms.add(Manifest.permission.BLUETOOTH_ADMIN)
}
*/
- if (!BluetoothInterface.hasCompanionDeviceApi(this)) {
+ if (!hasCompanionDeviceApi()) {
perms.add(Manifest.permission.ACCESS_FINE_LOCATION)
perms.add(Manifest.permission.BLUETOOTH_ADMIN)
}
diff --git a/app/src/main/java/com/geeksville/mesh/database/PacketRepository.kt b/app/src/main/java/com/geeksville/mesh/database/PacketRepository.kt
index 06a7ff841..e8a7077c9 100644
--- a/app/src/main/java/com/geeksville/mesh/database/PacketRepository.kt
+++ b/app/src/main/java/com/geeksville/mesh/database/PacketRepository.kt
@@ -16,8 +16,8 @@ class PacketRepository @Inject constructor(private val packetDaoLazy: dagger.Laz
packetDao.getAllPacket(MAX_ITEMS)
}
- suspend fun getAllPacketsInReceiveOrder(): Flow> = withContext(Dispatchers.IO) {
- packetDao.getAllPacketsInReceiveOrder(MAX_ITEMS)
+ suspend fun getAllPacketsInReceiveOrder(maxItems: Int = MAX_ITEMS): Flow> = withContext(Dispatchers.IO) {
+ packetDao.getAllPacketsInReceiveOrder(maxItems)
}
suspend fun insert(packet: Packet) = withContext(Dispatchers.IO) {
diff --git a/app/src/main/java/com/geeksville/mesh/database/entity/Packet.kt b/app/src/main/java/com/geeksville/mesh/database/entity/Packet.kt
index ad516e1ec..bc775f205 100644
--- a/app/src/main/java/com/geeksville/mesh/database/entity/Packet.kt
+++ b/app/src/main/java/com/geeksville/mesh/database/entity/Packet.kt
@@ -16,7 +16,7 @@ data class Packet(@PrimaryKey val uuid: String,
@ColumnInfo(name = "message") val raw_message: String
) {
- val proto: MeshProtos.MeshPacket?
+ val meshPacket: MeshProtos.MeshPacket?
get() {
if (message_type == "packet") {
val builder = MeshProtos.MeshPacket.newBuilder()
@@ -28,13 +28,27 @@ data class Packet(@PrimaryKey val uuid: String,
}
return null
}
+
+ val nodeInfo: MeshProtos.NodeInfo?
+ get() {
+ if (message_type == "NodeInfo") {
+ val builder = MeshProtos.NodeInfo.newBuilder()
+ try {
+ TextFormat.getParser().merge(raw_message, builder)
+ return builder.build()
+ } catch (e: IOException) {
+ }
+ }
+ return null
+ }
+
val position: MeshProtos.Position?
get() {
- return proto?.run {
+ return meshPacket?.run {
if (hasDecoded() && decoded.portnumValue == Portnums.PortNum.POSITION_APP_VALUE) {
return MeshProtos.Position.parseFrom(decoded.payload)
}
return null
- }
+ } ?: nodeInfo?.position
}
}
\ No newline at end of file
diff --git a/app/src/main/java/com/geeksville/mesh/model/BluetoothViewModel.kt b/app/src/main/java/com/geeksville/mesh/model/BluetoothViewModel.kt
new file mode 100644
index 000000000..6562f41be
--- /dev/null
+++ b/app/src/main/java/com/geeksville/mesh/model/BluetoothViewModel.kt
@@ -0,0 +1,24 @@
+package com.geeksville.mesh.model
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.asLiveData
+import com.geeksville.mesh.repository.bluetooth.BluetoothRepository
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.map
+import javax.inject.Inject
+
+/**
+ * Thin view model which adapts the view layer to the `BluetoothRepository`.
+ */
+@HiltViewModel
+class BluetoothViewModel @Inject constructor(
+ private val bluetoothRepository: BluetoothRepository,
+) : ViewModel() {
+ /**
+ * Called when permissions have been updated. This causes an explicit refresh of the
+ * bluetooth state.
+ */
+ fun permissionsUpdated() = bluetoothRepository.refreshState()
+
+ val enabled = bluetoothRepository.state.map { it.enabled }.asLiveData()
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/geeksville/mesh/model/MessagesState.kt b/app/src/main/java/com/geeksville/mesh/model/MessagesState.kt
index 6e98fb5d4..84a1d8764 100644
--- a/app/src/main/java/com/geeksville/mesh/model/MessagesState.kt
+++ b/app/src/main/java/com/geeksville/mesh/model/MessagesState.kt
@@ -30,10 +30,35 @@ class MessagesState(private val ui: UIViewModel) : Logging {
}
+ private var contactsList = emptyMap().toMutableMap()
+ val contacts = object : MutableLiveData>() {
+
+ }
+
+ private fun emptyDataPacket(to: String? = DataPacket.ID_BROADCAST): DataPacket {
+ return DataPacket(to, null, 1, DataPacket.ID_LOCAL, 0L)
+ }
+
+ // Map each contactId to last DataPacket message sent or received
+ // Broadcast: it.to == DataPacket.ID_BROADCAST; Direct Messages: it.to != DataPacket.ID_BROADCAST
+ private fun buildContacts() {
+ contactsList = messagesList.associateBy {
+ if (it.from == DataPacket.ID_LOCAL || it.to == DataPacket.ID_BROADCAST)
+ it.to else it.from
+ }.toMutableMap()
+
+ val all = DataPacket.ID_BROADCAST // always show contacts, even when empty
+ if (contactsList[all] == null)
+ contactsList[all] = emptyDataPacket()
+
+ contacts.value = contactsList
+ }
+
fun setMessages(m: List) {
messagesList.clear()
messagesList.addAll(m)
messages.value = messagesList
+ buildContacts()
}
/// add a message our GUI list of past msgs
@@ -44,6 +69,7 @@ class MessagesState(private val ui: UIViewModel) : Logging {
messagesList.add(m)
messages.value = messagesList
+ buildContacts()
}
fun removeMessage(m: DataPacket) {
@@ -51,6 +77,7 @@ class MessagesState(private val ui: UIViewModel) : Logging {
messagesList.remove(m)
messages.value = messagesList
+ buildContacts()
}
private fun removeAllMessages() {
@@ -58,6 +85,7 @@ class MessagesState(private val ui: UIViewModel) : Logging {
messagesList.clear()
messages.value = messagesList
+ buildContacts()
}
fun updateStatus(id: Int, status: MessageStatus) {
@@ -95,12 +123,12 @@ class MessagesState(private val ui: UIViewModel) : Logging {
addMessage(p)
}
- fun deleteMessage(packet: DataPacket, position: Int) {
+ fun deleteMessage(packet: DataPacket) {
val service = ui.meshService
if (service != null) {
try {
- service.delete(position)
+ service.deleteMessage(packet.id)
} catch (ex: RemoteException) {
packet.errorMessage = "Error: ${ex.message}"
}
@@ -116,7 +144,7 @@ class MessagesState(private val ui: UIViewModel) : Logging {
try {
service.deleteAllMessages()
} catch (ex: RemoteException) {
-
+ errormsg("Error: ${ex.message}")
}
removeAllMessages()
}
diff --git a/app/src/main/java/com/geeksville/mesh/model/UIState.kt b/app/src/main/java/com/geeksville/mesh/model/UIState.kt
index 6b0600e2e..18da52733 100644
--- a/app/src/main/java/com/geeksville/mesh/model/UIState.kt
+++ b/app/src/main/java/com/geeksville/mesh/model/UIState.kt
@@ -7,6 +7,7 @@ import android.net.Uri
import android.os.RemoteException
import android.view.Menu
import androidx.core.content.edit
+import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
@@ -20,7 +21,6 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -74,10 +74,6 @@ class UIViewModel @Inject constructor(
debug("ViewModel created")
}
- fun insertPacket(packet: Packet) = viewModelScope.launch(Dispatchers.IO) {
- repository.insert(packet)
- }
-
fun deleteAllPacket() = viewModelScope.launch(Dispatchers.IO) {
repository.deleteAll()
}
@@ -107,6 +103,20 @@ class UIViewModel @Inject constructor(
val channels = object : MutableLiveData(null) {
}
+ private val _requestChannelUrl = MutableLiveData(null)
+ val requestChannelUrl: LiveData get() = _requestChannelUrl
+
+ fun setRequestChannelUrl(channelUrl: Uri) {
+ _requestChannelUrl.value = channelUrl
+ }
+
+ /**
+ * Called immediately after activity observes requestChannelUrl
+ */
+ fun clearRequestChannelUrl() {
+ _requestChannelUrl.value = null
+ }
+
var positionBroadcastSecs: Int?
get() {
radioConfig.value?.preferences?.let {
@@ -229,10 +239,6 @@ class UIViewModel @Inject constructor(
val ownerName = object : MutableLiveData("MrIDE Test") {
}
-
- val bluetoothEnabled = object : MutableLiveData(false) {
- }
-
val provideLocation = object : MutableLiveData(preferences.getBoolean(MyPreferences.provideLocationKey, false)) {
override fun setValue(value: Boolean) {
super.setValue(value)
@@ -243,9 +249,6 @@ class UIViewModel @Inject constructor(
}
}
- /// If the app was launched because we received a new channel intent, the Url will be here
- var requestedChannelUrl: Uri? = null
-
// clean up all this nasty owner state management FIXME
fun setOwner(s: String? = null) {
@@ -283,66 +286,83 @@ class UIViewModel @Inject constructor(
// Capture the current node value while we're still on main thread
val nodes = nodeDB.nodes.value ?: emptyMap()
+ val positionToPos: (MeshProtos.Position?) -> Position? = { meshPosition ->
+ meshPosition?.let { Position(it) }.takeIf {
+ it?.isValid() == true
+ }
+ }
+
writeToUri(file_uri) { writer ->
// Create a map of nodes keyed by their ID
- val nodesById = nodes.values.associateBy { it.num }
+ val nodesById = nodes.values.associateBy { it.num }.toMutableMap()
+ val nodePositions = mutableMapOf()
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.getAllPacketsInReceiveOrder().first().forEach { packet ->
- packet.proto?.let { proto ->
+ repository.getAllPacketsInReceiveOrder(Int.MAX_VALUE).first().forEach { packet ->
+ // If we get a NodeInfo packet, use it to update our position data (if valid)
+ packet.nodeInfo?.let { nodeInfo ->
+ positionToPos.invoke(nodeInfo.position)?.let { _ ->
+ nodePositions[nodeInfo.num] = nodeInfo.position
+ }
+ }
+
+ packet.meshPacket?.let { proto ->
+ // If the packet contains position data then use it to update, if valid
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")
+ positionToPos.invoke(position)?.let { _ ->
+ nodePositions[proto.from] = position
}
}
+
+ // Filter out of our results any packet that doesn't report SNR. This
+ // is primarily ADMIN_APP.
+ if (proto.rxSnr > 0.0f) {
+ val rxDateTime = dateFormat.format(packet.received_date)
+ val rxFrom = proto.from.toUInt()
+ val senderName = nodesById[proto.from]?.user?.longName ?: ""
+
+ // sender lat & long
+ val senderPosition = nodePositions[proto.from]
+ val senderPos = positionToPos.invoke(senderPosition)
+ val senderLat = senderPos?.latitude ?: ""
+ val senderLong = senderPos?.longitude ?: ""
+
+ // rx lat, long, and elevation
+ val rxPosition = nodePositions[myNodeNum]
+ val rxPos = positionToPos.invoke(rxPosition)
+ 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(
+ rxPosition!!, // Use rxPosition but only if rxPos was valid
+ senderPosition!! // Use senderPosition but only if senderPos was valid
+ ).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")
+ }
}
}
}
diff --git a/app/src/main/java/com/geeksville/mesh/service/BluetoothStateReceiver.kt b/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothBroadcastReceiver.kt
similarity index 57%
rename from app/src/main/java/com/geeksville/mesh/service/BluetoothStateReceiver.kt
rename to app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothBroadcastReceiver.kt
index a4edefb51..3f4c2ee79 100644
--- a/app/src/main/java/com/geeksville/mesh/service/BluetoothStateReceiver.kt
+++ b/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothBroadcastReceiver.kt
@@ -1,4 +1,4 @@
-package com.geeksville.mesh.service
+package com.geeksville.mesh.repository.bluetooth
import android.bluetooth.BluetoothAdapter
import android.content.BroadcastReceiver
@@ -6,29 +6,26 @@ import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import com.geeksville.util.exceptionReporter
+import javax.inject.Inject
/**
* A helper class to call onChanged when bluetooth is enabled or disabled
*/
-class BluetoothStateReceiver(
- private val onChanged: (Boolean) -> Unit
+class BluetoothBroadcastReceiver @Inject constructor(
+ private val bluetoothRepository: BluetoothRepository
) : BroadcastReceiver() {
-
- val intentFilter get() = IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED) // Can be used for registering
+ internal val intentFilter get() = IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED) // Can be used for registering
override fun onReceive(context: Context, intent: Intent) = exceptionReporter {
if (intent.action == BluetoothAdapter.ACTION_STATE_CHANGED) {
when (intent.bluetoothAdapterState) {
// Simulate a disconnection if the user disables bluetooth entirely
- BluetoothAdapter.STATE_OFF -> onChanged(false)
- BluetoothAdapter.STATE_ON -> onChanged(true)
+ BluetoothAdapter.STATE_OFF -> bluetoothRepository.refreshState()
+ BluetoothAdapter.STATE_ON -> bluetoothRepository.refreshState()
}
}
}
private val Intent.bluetoothAdapterState: Int
- get() = getIntExtra(
- BluetoothAdapter.EXTRA_STATE,
- -1
- )
+ get() = getIntExtra(BluetoothAdapter.EXTRA_STATE,-1)
}
\ No newline at end of file
diff --git a/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothRepository.kt b/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothRepository.kt
new file mode 100644
index 000000000..4502783ae
--- /dev/null
+++ b/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothRepository.kt
@@ -0,0 +1,102 @@
+package com.geeksville.mesh.repository.bluetooth
+
+import android.annotation.SuppressLint
+import android.app.Application
+import android.bluetooth.BluetoothAdapter
+import android.bluetooth.BluetoothDevice
+import android.bluetooth.le.BluetoothLeScanner
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.coroutineScope
+import com.geeksville.android.Logging
+import com.geeksville.mesh.CoroutineDispatchers
+import com.geeksville.mesh.android.hasConnectPermission
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.*
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Repository responsible for maintaining and updating the state of Bluetooth availability.
+ */
+@Singleton
+class BluetoothRepository @Inject constructor(
+ private val application: Application,
+ private val bluetoothAdapterLazy: dagger.Lazy,
+ private val bluetoothBroadcastReceiverLazy: dagger.Lazy,
+ private val dispatchers: CoroutineDispatchers,
+ private val processLifecycle: Lifecycle,
+) : Logging {
+ private val _state = MutableStateFlow(BluetoothState(
+ // Assume we have permission until we get our initial state update to prevent premature
+ // notifications to the user.
+ hasPermissions = true
+ ))
+ val state: StateFlow = _state.asStateFlow()
+
+ init {
+ processLifecycle.coroutineScope.launch(dispatchers.default) {
+ updateBluetoothState()
+ bluetoothBroadcastReceiverLazy.get().let { receiver ->
+ application.registerReceiver(receiver, receiver.intentFilter)
+ }
+ }
+ }
+
+ fun refreshState() {
+ processLifecycle.coroutineScope.launch(dispatchers.default) {
+ updateBluetoothState()
+ }
+ }
+
+ fun getRemoteDevice(address: String): BluetoothDevice? {
+ return bluetoothAdapterLazy.get()?.getRemoteDevice(address)
+ }
+
+ fun getBluetoothLeScanner(): BluetoothLeScanner? {
+ return bluetoothAdapterLazy.get()?.bluetoothLeScanner
+ }
+
+ @SuppressLint("MissingPermission")
+ internal suspend fun updateBluetoothState() {
+ val newState: BluetoothState = bluetoothAdapterLazy.get()?.takeIf {
+ application.hasConnectPermission().also { hasPerms ->
+ if (!hasPerms) errormsg("Still missing needed bluetooth permissions")
+ }
+ }?.let { adapter ->
+ /// ask the adapter if we have access
+ BluetoothState(
+ hasPermissions = true,
+ enabled = adapter.isEnabled,
+ bondedDevices = createBondedDevicesFlow(adapter),
+ )
+ } ?: BluetoothState()
+
+ _state.emit(newState)
+ debug("Detected our bluetooth access=$newState")
+ }
+
+ /**
+ * Creates a cold Flow used to obtain the set of bonded devices.
+ */
+ @SuppressLint("MissingPermission") // Already checked prior to calling
+ private suspend fun createBondedDevicesFlow(adapter: BluetoothAdapter): Flow>? {
+ return if (adapter.isEnabled) {
+ flow> {
+ withContext(dispatchers.default) {
+ while (true) {
+ emit(adapter.bondedDevices)
+ delay(REFRESH_DELAY_MS)
+ }
+ }
+ }.flowOn(dispatchers.default)
+ } else {
+ null
+ }
+ }
+
+ companion object {
+ const val REFRESH_DELAY_MS = 1000L
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothRepositoryModule.kt b/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothRepositoryModule.kt
new file mode 100644
index 000000000..7974b9b31
--- /dev/null
+++ b/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothRepositoryModule.kt
@@ -0,0 +1,26 @@
+package com.geeksville.mesh.repository.bluetooth
+
+import android.app.Application
+import android.bluetooth.BluetoothAdapter
+import android.bluetooth.BluetoothManager
+import android.content.Context
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+
+@Module
+@InstallIn(SingletonComponent::class)
+interface BluetoothRepositoryModule {
+ companion object {
+ @Provides
+ fun provideBluetoothManager(application: Application): BluetoothManager? {
+ return application.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager?
+ }
+
+ @Provides
+ fun provideBluetoothAdapter(service: BluetoothManager?): BluetoothAdapter? {
+ return service?.adapter
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothState.kt b/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothState.kt
new file mode 100644
index 000000000..895afc9d9
--- /dev/null
+++ b/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothState.kt
@@ -0,0 +1,16 @@
+package com.geeksville.mesh.repository.bluetooth
+
+import android.bluetooth.BluetoothDevice
+import kotlinx.coroutines.flow.Flow
+
+/**
+ * A snapshot in time of the state of the bluetooth subsystem.
+ */
+data class BluetoothState(
+ /** Whether we have adequate permissions to query bluetooth state */
+ val hasPermissions: Boolean = false,
+ /** If we have adequate permissions and bluetooth is enabled */
+ val enabled: Boolean = false,
+ /** If enabled, a cold flow of the currently bonded devices */
+ val bondedDevices: Flow>? = null
+)
diff --git a/app/src/main/java/com/geeksville/mesh/service/BluetoothInterface.kt b/app/src/main/java/com/geeksville/mesh/service/BluetoothInterface.kt
index 5fdd12b3f..cb2b42011 100644
--- a/app/src/main/java/com/geeksville/mesh/service/BluetoothInterface.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/BluetoothInterface.kt
@@ -5,10 +5,7 @@ import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothGattCharacteristic
import android.bluetooth.BluetoothGattService
import android.bluetooth.BluetoothManager
-import android.companion.CompanionDeviceManager
import android.content.Context
-import android.content.pm.PackageManager
-import android.os.Build
import com.geeksville.android.Logging
import com.geeksville.concurrent.handledLaunch
import com.geeksville.util.anonymize
@@ -112,12 +109,6 @@ class BluetoothInterface(val service: RadioInterfaceService, val address: String
/** Return true if this address is still acceptable. For BLE that means, still bonded */
@SuppressLint("NewApi", "MissingPermission")
override fun addressValid(context: Context, rest: String): Boolean {
- /* val allPaired = if (hasCompanionDeviceApi(context)) {
- val deviceManager: CompanionDeviceManager by lazy {
- context.getSystemService(Context.COMPANION_DEVICE_SERVICE) as CompanionDeviceManager
- }
- deviceManager.associations.map { it }.toSet()
- } else { */
val allPaired = getBluetoothAdapter(context)?.bondedDevices.orEmpty()
.map { it.address }.toSet()
return if (!allPaired.contains(rest)) {
@@ -127,63 +118,6 @@ class BluetoothInterface(val service: RadioInterfaceService, val address: String
true
}
-
- /// Return the device we are configured to use, or null for none
- /*
- @SuppressLint("NewApi")
- fun getBondedDeviceAddress(context: Context): String? =
- if (hasCompanionDeviceApi(context)) {
- // Use new companion API
-
- val deviceManager = context.getSystemService(CompanionDeviceManager::class.java)
- val associations = deviceManager.associations
- val result = associations.firstOrNull()
- debug("reading bonded devices: $result")
- result
- } else {
- // Use classic API and a preferences string
-
- val allPaired =
- getBluetoothAdapter(context)?.bondedDevices.orEmpty().map { it.address }.toSet()
-
- // If the user has unpaired our device, treat things as if we don't have one
- val address = InterfaceService.getPrefs(context).getString(DEVADDR_KEY, null)
-
- if (address != null && !allPaired.contains(address)) {
- warn("Ignoring stale bond to ${address.anonymize}")
- null
- } else
- address
- }
-*/
-
- /// Can we use the modern BLE scan API?
- fun hasCompanionDeviceApi(context: Context): Boolean =
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
- val res =
- context.packageManager.hasSystemFeature(PackageManager.FEATURE_COMPANION_DEVICE_SETUP)
- debug("CompanionDevice API available=$res")
- res
- } else {
- warn("CompanionDevice API not available, falling back to classic scan")
- false
- }
-
- /** FIXME - when adding companion device support back in, use this code to set companion device from setBondedDevice
- * if (BluetoothInterface.hasCompanionDeviceApi(this)) {
- // We only keep an association to one device at a time...
- if (addr != null) {
- val deviceManager = getSystemService(CompanionDeviceManager::class.java)
-
- deviceManager.associations.forEach { old ->
- if (addr != old) {
- BluetoothInterface.debug("Forgetting old BLE association $old")
- deviceManager.disassociate(old)
- }
- }
- }
- */
-
/**
* this is created in onCreate()
* We do an ugly hack of keeping it in the singleton so we can share it for the rare software update case
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 ec276708d..e75105d1e 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt
@@ -696,7 +696,9 @@ class MeshService : Service(), Logging {
id = packet.id,
dataType = data.portnumValue,
bytes = bytes,
- hopLimit = hopLimit
+ hopLimit = hopLimit,
+ channel = packet.channel,
+ delayed = packet.delayedValue
)
}
}
@@ -719,7 +721,7 @@ class MeshService : Service(), Logging {
// we only care about old text messages, we just store those...
if (dataPacket.dataType == Portnums.PortNum.TEXT_MESSAGE_APP_VALUE) {
// discard old messages if needed then add the new one
- while (recentDataPackets.size > 50)
+ while (recentDataPackets.size > 100)
recentDataPackets.removeAt(0)
// FIXME - possible kotlin bug in 1.3.72 - it seems that if we start with the (globally shared) emptyList,
@@ -919,6 +921,16 @@ class MeshService : Service(), Logging {
p.time = System.currentTimeMillis() // update time to the actual time we started sending
// debug("Sending to radio: ${packet.toPIIString()}")
sendToRadio(packet)
+
+ if (packet.hasDecoded()) {
+ val packetToSave = Packet(
+ UUID.randomUUID().toString(),
+ "packet",
+ System.currentTimeMillis(),
+ packet.toString()
+ )
+ insertPacket(packetToSave)
+ }
}
private fun processQueuedPackets() {
@@ -1191,8 +1203,10 @@ class MeshService : Service(), Logging {
when (intent.action) {
RadioInterfaceService.RADIO_CONNECTED_ACTION -> {
try {
+ // sleep now disabled by default on ESP32, permanent is true unless isPowerSaving enabled
+ val lsEnabled = radioConfig?.preferences?.isPowerSaving ?: false
val connected = intent.getBooleanExtra(EXTRA_CONNECTED, false)
- val permanent = intent.getBooleanExtra(EXTRA_PERMANENT, false)
+ val permanent = intent.getBooleanExtra(EXTRA_PERMANENT, false) || !lsEnabled
onConnectionChanged(
when {
connected -> ConnectionState.CONNECTED
@@ -1309,13 +1323,14 @@ class MeshService : Service(), Logging {
if (asStr != null)
hwModelStr = asStr
}
+ setFirmwareUpdateFilename(hwModelStr)
val mi = with(myInfo) {
MyNodeInfo(
myNodeNum,
hasGps,
hwModelStr,
firmwareVersion,
- firmwareUpdateFilename != null,
+ firmwareUpdateFilename?.appLoad != null && firmwareUpdateFilename?.spiffs != null,
isBluetoothInterface && SoftwareUpdateService.shouldUpdate(
this@MeshService,
DeviceVersion(firmwareVersion)
@@ -1328,9 +1343,7 @@ class MeshService : Service(), Logging {
airUtilTx
)
}
-
newMyNodeInfo = mi
- setFirmwareUpdateFilename(mi)
}
}
@@ -1560,7 +1573,7 @@ class MeshService : Service(), Logging {
try {
val mi = myNodeInfo
if (mi != null) {
- debug("Sending our position/time to=$destNum lat=$lat, lon=$lon, alt=$alt")
+ debug("Sending our position/time to=$destNum lat=${lat.anonymize}, lon=${lon.anonymize}, alt=$alt")
val position = MeshProtos.Position.newBuilder().also {
it.longitudeI = Position.degI(lon)
@@ -1670,12 +1683,12 @@ class MeshService : Service(), Logging {
/***
* Return the filename we will install on the device
*/
- private fun setFirmwareUpdateFilename(info: MyNodeInfo) {
+ private fun setFirmwareUpdateFilename(model: String?) {
firmwareUpdateFilename = try {
- if (info.firmwareVersion != null && info.model != null)
+ if (model != null)
SoftwareUpdateService.getUpdateFilename(
this,
- info.model
+ model
)
else
null
@@ -1782,10 +1795,12 @@ class MeshService : Service(), Logging {
this@MeshService.setOwner(myId, longName, shortName)
}
- override fun delete(position: Int) {
- if (position >= 0) {
- recentDataPackets.removeAt(position)
- }
+ override fun deleteMessage(packetId: Int) {
+ val packet = recentDataPackets.find {it.id == packetId}
+ if (packet != null) {
+ recentDataPackets.remove(packet)
+ debug("Deleting message id=${packet.id}")
+ } else debug("Nothing to delete, message id=${packetId} not found")
}
override fun deleteAllMessages() {
diff --git a/app/src/main/java/com/geeksville/mesh/service/RadioInterfaceService.kt b/app/src/main/java/com/geeksville/mesh/service/RadioInterfaceService.kt
index bd9b5eead..9b6366699 100644
--- a/app/src/main/java/com/geeksville/mesh/service/RadioInterfaceService.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/RadioInterfaceService.kt
@@ -2,24 +2,27 @@ 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
import android.os.IBinder
import androidx.core.content.edit
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.ServiceLifecycleDispatcher
+import androidx.lifecycle.coroutineScope
import com.geeksville.android.BinaryLogFile
import com.geeksville.android.GeeksvilleApplication
import com.geeksville.android.Logging
import com.geeksville.concurrent.handledLaunch
import com.geeksville.mesh.IRadioInterfaceService
+import com.geeksville.mesh.repository.bluetooth.BluetoothRepository
import com.geeksville.util.anonymize
import com.geeksville.util.ignoreException
import com.geeksville.util.toRemoteExceptions
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.cancel
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.*
+import kotlinx.coroutines.flow.collect
+import javax.inject.Inject
open class RadioNotConnectedException(message: String = "Not connected to radio") :
@@ -35,8 +38,18 @@ open class RadioNotConnectedException(message: String = "Not connected to radio"
* Note - this class intentionally dumb. It doesn't understand protobuf framing etc...
* It is designed to be simple so it can be stubbed out with a simulated version as needed.
*/
+@AndroidEntryPoint
class RadioInterfaceService : Service(), Logging {
+ // The following is due to the fact that AIDL prevents us from extending from `LifecycleService`:
+ private val lifecycleOwner: LifecycleOwner = LifecycleOwner { lifecycleDispatcher.lifecycle }
+ private val lifecycleDispatcher: ServiceLifecycleDispatcher by lazy {
+ ServiceLifecycleDispatcher(lifecycleOwner)
+ }
+
+ @Inject
+ lateinit var bluetoothRepository: BluetoothRepository
+
companion object : Logging {
/**
* The RECEIVED_FROMRADIO
@@ -104,7 +117,7 @@ class RadioInterfaceService : Service(), Logging {
@SuppressLint("NewApi")
fun getBondedDeviceAddress(context: Context): String? {
// If the user has unpaired our device, treat things as if we don't have one
- var address = getDeviceAddress(context)
+ val address = getDeviceAddress(context)
/// Interfaces can filter addresses to indicate that address is no longer acceptable
if (address != null) {
@@ -142,16 +155,6 @@ class RadioInterfaceService : Service(), Logging {
/// true if our interface is currently connected to a device
private var isConnected = false
- /**
- * If the user turns on bluetooth after we start, make sure to try and reconnected then
- */
- private val bluetoothStateReceiver = BluetoothStateReceiver { enabled ->
- if (enabled)
- startInterface() // If bluetooth just got turned on, try to restart our ble link (which might be bluetooth)
- else if (radioIf is BluetoothInterface)
- stopInterface() // Was using bluetooth, need to shutdown
- }
-
private fun broadcastConnectionChanged(isConnected: Boolean, isPermanent: Boolean) {
debug("Broadcasting connection=$isConnected")
val intent = Intent(RADIO_CONNECTED_ACTION)
@@ -197,19 +200,35 @@ class RadioInterfaceService : Service(), Logging {
override fun onCreate() {
runningService = this
+ lifecycleDispatcher.onServicePreSuperOnCreate()
super.onCreate()
- registerReceiver(bluetoothStateReceiver, bluetoothStateReceiver.intentFilter)
+
+ lifecycleOwner.lifecycle.coroutineScope.launch {
+ bluetoothRepository.state.collect { state ->
+ if (state.enabled) {
+ startInterface()
+ } else {
+ stopInterface()
+ }
+ }
+ }
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ lifecycleDispatcher.onServicePreSuperOnStart()
+ return super.onStartCommand(intent, flags, startId)
}
override fun onDestroy() {
- unregisterReceiver(bluetoothStateReceiver)
stopInterface()
serviceScope.cancel("Destroying RadioInterface")
runningService = null
+ lifecycleDispatcher.onServicePreSuperOnDestroy()
super.onDestroy()
}
override fun onBind(intent: Intent?): IBinder? {
+ lifecycleDispatcher.onServicePreSuperOnBind()
return binder
}
diff --git a/app/src/main/java/com/geeksville/mesh/service/SoftwareUpdateService.kt b/app/src/main/java/com/geeksville/mesh/service/SoftwareUpdateService.kt
index 8326a5168..7fe354101 100644
--- a/app/src/main/java/com/geeksville/mesh/service/SoftwareUpdateService.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/SoftwareUpdateService.kt
@@ -243,7 +243,7 @@ class SoftwareUpdateService : JobIntentService(), Logging {
false // If we fail parsing our update info
}
- /** Return a Pair of apploadfilename, spiffs filename this device needs to use as an update (or null if no update needed)
+ /** Return a Pair of appload filename, spiffs filename this device needs to use as an update (or null if no update needed)
*/
fun getUpdateFilename(
context: Context,
@@ -290,9 +290,15 @@ class SoftwareUpdateService : JobIntentService(), Logging {
* you can use it for the software update.
*/
fun doUpdate(context: Context, sync: SafeBluetooth, assets: UpdateFilenames) {
+ // calculate total firmware size (spiffs + appLoad)
+ var totalFirmwareSize = 0
+ if (assets.appLoad != null && assets.spiffs != null) {
+ totalFirmwareSize += context.assets.open(assets.appLoad).available()
+ totalFirmwareSize += context.assets.open(assets.spiffs).available()
+ }
// 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) }
+ assets.spiffs?.let { doUpdate(context, sync, it, FLASH_REGION_SPIFFS, totalFirmwareSize) }
} 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")
@@ -301,7 +307,7 @@ class SoftwareUpdateService : JobIntentService(), Logging {
errormsg("Device rejected invalid spiffs partition")
}
- assets.appLoad?.let { doUpdate(context, sync, it, FLASH_REGION_APPLOAD) }
+ assets.appLoad?.let { doUpdate(context, sync, it, FLASH_REGION_APPLOAD, totalFirmwareSize) }
sendProgress(context, ProgressSuccess, true)
}
@@ -317,7 +323,8 @@ class SoftwareUpdateService : JobIntentService(), Logging {
context: Context,
sync: SafeBluetooth,
assetName: String,
- flashRegion: Int = FLASH_REGION_APPLOAD
+ flashRegion: Int = FLASH_REGION_APPLOAD,
+ totalFirmwareSize: Int = 0
) {
val isAppload = flashRegion == FLASH_REGION_APPLOAD
@@ -378,13 +385,15 @@ class SoftwareUpdateService : JobIntentService(), Logging {
// Send all the blocks
var oldProgress = -1 // used to limit # of log spam
while (firmwareNumSent < 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
+ // If we are doing the spiffs partition, we limit progress to a max of maxProgress
+ // when updating the appload partition, progress from (100 - maxProgress) to 100%
+ // maxProgress = spiffs% = 100% - appLoad%; (int * 10 + 5) / 10 used for rounding
+ val maxProgress = ((firmwareSize * 1000 / totalFirmwareSize) + 5) / 10
+ val minProgress = if (flashRegion != FLASH_REGION_APPLOAD)
+ 0 else (100 - maxProgress)
sendProgress(
context,
- firmwareNumSent * maxProgress / firmwareSize,
+ minProgress + firmwareNumSent * maxProgress / firmwareSize,
isAppload
)
if (progress != oldProgress) {
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 27dfa8148..649247d05 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/ChannelFragment.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/ChannelFragment.kt
@@ -1,5 +1,6 @@
package com.geeksville.mesh.ui
+import android.Manifest
import android.content.ActivityNotFoundException
import android.content.Intent
import android.graphics.ColorMatrix
@@ -13,14 +14,15 @@ import android.view.ViewGroup
import android.view.inputmethod.EditorInfo
import android.widget.ArrayAdapter
import android.widget.ImageView
+import androidx.activity.result.contract.ActivityResultContracts
import androidx.fragment.app.activityViewModels
import com.geeksville.analytics.DataPair
import com.geeksville.android.GeeksvilleApplication
import com.geeksville.android.Logging
import com.geeksville.android.hideKeyboard
+import com.geeksville.android.isGooglePlayAvailable
import com.geeksville.mesh.AppOnlyProtos
import com.geeksville.mesh.ChannelProtos
-import com.geeksville.mesh.MainActivity
import com.geeksville.mesh.R
import com.geeksville.mesh.android.hasCameraPermission
import com.geeksville.mesh.databinding.ChannelFragmentBinding
@@ -31,8 +33,12 @@ import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.service.MeshService
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
+import com.google.mlkit.vision.barcode.common.Barcode
+import com.google.mlkit.vision.codescanner.GmsBarcodeScannerOptions
+import com.google.mlkit.vision.codescanner.GmsBarcodeScanning
import com.google.protobuf.ByteString
-import com.google.zxing.integration.android.IntentIntegrator
+import com.journeyapps.barcodescanner.ScanContract
+import com.journeyapps.barcodescanner.ScanOptions
import dagger.hilt.android.AndroidEntryPoint
import java.security.SecureRandom
@@ -65,7 +71,7 @@ class ChannelFragment : ScreenFragment("Channel"), Logging {
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
- ): View? {
+ ): View {
_binding = ChannelFragmentBinding.inflate(inflater, container, false)
return binding.root
}
@@ -188,6 +194,52 @@ class ChannelFragment : ScreenFragment("Channel"), Logging {
}
}
+ private fun zxingScan() {
+ debug("Starting zxing QR code scanner")
+ val zxingScan = ScanOptions()
+ zxingScan.setCameraId(0)
+ zxingScan.setPrompt("")
+ zxingScan.setBeepEnabled(false)
+ zxingScan.setDesiredBarcodeFormats(ScanOptions.QR_CODE)
+ barcodeLauncher.launch(zxingScan)
+ }
+
+ private fun requestPermissionAndScan() {
+ MaterialAlertDialogBuilder(requireContext())
+ .setTitle(R.string.camera_required)
+ .setMessage(R.string.why_camera_required)
+ .setNeutralButton(R.string.cancel) { _, _ ->
+ debug("Camera permission denied")
+ }
+ .setPositiveButton(getString(R.string.accept)) { _, _ ->
+ requestPermissionAndScanLauncher.launch(Manifest.permission.CAMERA)
+ }
+ .show()
+ }
+
+
+ private fun mlkitScan() {
+ debug("Starting ML Kit QR code scanner")
+ val options = GmsBarcodeScannerOptions.Builder()
+ .setBarcodeFormats(
+ Barcode.FORMAT_QR_CODE
+ )
+ .build()
+ val scanner = GmsBarcodeScanning.getClient(requireContext(), options)
+ scanner.startScan()
+ .addOnSuccessListener { barcode ->
+ if (barcode.rawValue != null)
+ model.setRequestChannelUrl(Uri.parse(barcode.rawValue))
+ }
+ .addOnFailureListener {
+ Snackbar.make(
+ requireView(),
+ R.string.channel_invalid,
+ Snackbar.LENGTH_SHORT
+ ).show()
+ }
+ }
+
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
@@ -195,7 +247,7 @@ class ChannelFragment : ScreenFragment("Channel"), Logging {
requireActivity().hideKeyboard()
}
- binding.resetButton.setOnClickListener { _ ->
+ binding.resetButton.setOnClickListener {
// User just locked it, we should warn and then apply changes to radio
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.reset_to_defaults)
@@ -211,30 +263,19 @@ class ChannelFragment : ScreenFragment("Channel"), Logging {
}
binding.scanButton.setOnClickListener {
- if ((requireActivity() as MainActivity).hasCameraPermission()) {
- debug("Starting QR code scanner")
- val zxingScan = IntentIntegrator.forSupportFragment(this)
- zxingScan.setCameraId(0)
- zxingScan.setPrompt("")
- zxingScan.setBeepEnabled(false)
- zxingScan.setDesiredBarcodeFormats(IntentIntegrator.QR_CODE)
- zxingScan.initiateScan()
+ if (isGooglePlayAvailable(requireContext())) {
+ mlkitScan()
} else {
- MaterialAlertDialogBuilder(requireContext())
- .setTitle(R.string.camera_required)
- .setMessage(R.string.why_camera_required)
- .setNeutralButton(R.string.cancel) { _, _ ->
- debug("Camera permission denied")
- }
- .setPositiveButton(getString(R.string.accept)) { _, _ ->
- (requireActivity() as MainActivity).requestCameraPermission()
- }
- .show()
+ if (requireContext().hasCameraPermission()) {
+ zxingScan()
+ } else {
+ requestPermissionAndScan()
+ }
}
}
// Note: Do not use setOnCheckedChanged here because we don't want to be called when we programmatically disable editing
- binding.editableCheckbox.setOnClickListener { _ ->
+ binding.editableCheckbox.setOnClickListener {
/// We use this to determine if the user tried to install a custom name
var originalName = ""
@@ -275,7 +316,7 @@ class ChannelFragment : ScreenFragment("Channel"), Logging {
val random = SecureRandom()
val bytes = ByteArray(32)
random.nextBytes(bytes)
- newSettings.name = newName
+ newSettings.name = newName.take(11)
newSettings.psk = ByteString.copyFrom(bytes)
} else {
debug("Switching back to default channel")
@@ -299,14 +340,14 @@ class ChannelFragment : ScreenFragment("Channel"), Logging {
shareChannel()
}
- model.channels.observe(viewLifecycleOwner, {
+ model.channels.observe(viewLifecycleOwner) {
setGUIfromModel()
- })
+ }
// If connection state changes, we might need to enable/disable buttons
- model.isConnected.observe(viewLifecycleOwner, {
+ model.isConnected.observe(viewLifecycleOwner) {
setGUIfromModel()
- })
+ }
}
private fun getModemConfig(selectedChannelOptionString: String): ChannelProtos.ChannelSettings.ModemConfig {
@@ -314,18 +355,18 @@ class ChannelFragment : ScreenFragment("Channel"), Logging {
if (getString(item.configRes) == selectedChannelOptionString)
return item.modemConfig
}
-
return ChannelProtos.ChannelSettings.ModemConfig.UNRECOGNIZED
}
- override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
- val result = IntentIntegrator.parseActivityResult(requestCode, resultCode, data)
- if (result != null) {
- if (result.contents != null) {
- ((requireActivity() as MainActivity).perhapsChangeChannel(Uri.parse(result.contents)))
- }
- } else {
- super.onActivityResult(requestCode, resultCode, data)
+ private val requestPermissionAndScanLauncher =
+ registerForActivityResult(ActivityResultContracts.RequestPermission()) { allowed ->
+ if (allowed) zxingScan()
+ }
+
+ // Register zxing launcher and result handler
+ private val barcodeLauncher = registerForActivityResult(ScanContract()) { result ->
+ if (result.contents != null) {
+ model.setRequestChannelUrl(Uri.parse(result.contents))
}
}
}
diff --git a/app/src/main/java/com/geeksville/mesh/ui/ContactsFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/ContactsFragment.kt
new file mode 100644
index 000000000..d6843439f
--- /dev/null
+++ b/app/src/main/java/com/geeksville/mesh/ui/ContactsFragment.kt
@@ -0,0 +1,358 @@
+package com.geeksville.mesh.ui
+
+import android.graphics.Color
+import android.graphics.drawable.GradientDrawable
+import android.os.Bundle
+import android.view.*
+import androidx.core.content.ContextCompat
+import androidx.core.os.bundleOf
+import androidx.fragment.app.activityViewModels
+import androidx.fragment.app.setFragmentResult
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import com.geeksville.android.Logging
+import com.geeksville.mesh.DataPacket
+import com.geeksville.mesh.MainActivity
+import com.geeksville.mesh.R
+import com.geeksville.mesh.databinding.AdapterContactLayoutBinding
+import com.geeksville.mesh.databinding.FragmentContactsBinding
+import com.geeksville.mesh.model.UIViewModel
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import dagger.hilt.android.AndroidEntryPoint
+import java.text.DateFormat
+import java.util.*
+
+@AndroidEntryPoint
+class ContactsFragment : ScreenFragment("Messages"), Logging {
+
+ private var actionMode: ActionMode? = null
+ private var _binding: FragmentContactsBinding? = 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.SHORT)
+ private val timeFormat: DateFormat =
+ DateFormat.getTimeInstance(DateFormat.SHORT)
+
+ private fun getShortDateTime(time: Date): String {
+ // return time if within 24 hours, otherwise date/time
+ val oneDayMsec = 60 * 60 * 24 * 1000L
+ return if (System.currentTimeMillis() - time.time > oneDayMsec) {
+ dateTimeFormat.format(time)
+ } else timeFormat.format(time)
+ }
+
+ // 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: AdapterContactLayoutBinding) :
+ RecyclerView.ViewHolder(itemView.root) {
+ val shortName = itemView.shortName
+ val longName = itemView.longName
+ val lastMessageTime = itemView.lastMessageTime
+ val lastMessageText = itemView.lastMessageText
+ }
+
+ private val contactsAdapter = object : RecyclerView.Adapter() {
+
+ /**
+ * Called when RecyclerView needs a new [ViewHolder] of the given type to represent
+ * an item.
+ *
+ *
+ * This new ViewHolder should be constructed with a new View that can represent the items
+ * of the given type. You can either create a new View manually or inflate it from an XML
+ * layout file.
+ *
+ *
+ * The new ViewHolder will be used to display items of the adapter using
+ * [.onBindViewHolder]. Since it will be re-used to display
+ * different items in the data set, it is a good idea to cache references to sub views of
+ * the View to avoid unnecessary [View.findViewById] calls.
+ *
+ * @param parent The ViewGroup into which the new View will be added after it is bound to
+ * an adapter position.
+ * @param viewType The view type of the new View.
+ *
+ * @return A new ViewHolder that holds a View of the given view type.
+ * @see .getItemViewType
+ * @see .onBindViewHolder
+ */
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
+ val inflater = LayoutInflater.from(requireContext())
+
+ // Inflate the custom layout
+ val contactsView = AdapterContactLayoutBinding.inflate(inflater, parent, false)
+
+ // Return a new holder instance
+ return ViewHolder(contactsView)
+ }
+
+ private var messages = arrayOf()
+ private var contacts = arrayOf()
+ private var selectedList = ArrayList()
+
+ /**
+ * Returns the total number of items in the data set held by the adapter.
+ *
+ * @return The total number of items in this adapter.
+ */
+ override fun getItemCount(): Int = contacts.size
+
+ /**
+ * Called by RecyclerView to display the data at the specified position. This method should
+ * update the contents of the [ViewHolder.itemView] to reflect the item at the given
+ * position.
+ *
+ *
+ * Note that unlike [android.widget.ListView], RecyclerView will not call this method
+ * again if the position of the item changes in the data set unless the item itself is
+ * invalidated or the new position cannot be determined. For this reason, you should only
+ * use the `position` parameter while acquiring the related data item inside
+ * this method and should not keep a copy of it. If you need the position of an item later
+ * on (e.g. in a click listener), use [ViewHolder.getAdapterPosition] which will
+ * have the updated adapter position.
+ *
+ * Override [.onBindViewHolder] instead if Adapter can
+ * handle efficient partial bind.
+ *
+ * @param holder The ViewHolder which should be updated to represent the contents of the
+ * item at the given position in the data set.
+ * @param position The position of the item within the adapter's data set.
+ */
+ override fun onBindViewHolder(holder: ViewHolder, position: Int) {
+ val contact = contacts[position]
+
+ // Determine if this is my message (originated on this device)
+ val isLocal = contact.from == DataPacket.ID_LOCAL
+ val isBroadcast = contact.to == DataPacket.ID_BROADCAST
+ val contactId = if (isLocal || isBroadcast) contact.to else contact.from
+
+ // grab usernames from NodeInfo
+ val nodes = model.nodeDB.nodes.value!!
+ val node = nodes[if (isLocal) contact.to else contact.from]
+
+ //grab channel names from RadioConfig
+ val channels = model.channels.value
+ val primaryChannel = channels?.primaryChannel
+
+ val shortName = node?.user?.shortName ?: "???"
+ val longName =
+ if (isBroadcast) primaryChannel?.name ?: getString(R.string.channel_name)
+ else node?.user?.longName ?: getString(R.string.unknown_username)
+
+ holder.shortName.text = if (isBroadcast) "All" else shortName
+ holder.longName.text = longName
+
+ val text = if (isLocal) contact.text else "$shortName: ${contact.text}"
+ holder.lastMessageText.text = text
+
+ if (contact.time != 0L) {
+ holder.lastMessageTime.visibility = View.VISIBLE
+ holder.lastMessageTime.text = getShortDateTime(Date(contact.time))
+ } else holder.lastMessageTime.visibility = View.INVISIBLE
+
+ holder.itemView.setOnLongClickListener {
+ if (actionMode == null) {
+ actionMode =
+ (activity as MainActivity).startActionMode(object : ActionMode.Callback {
+ override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
+ mode.menuInflater.inflate(R.menu.menu_messages, menu)
+ mode.title = "1"
+ return true
+ }
+
+ override fun onPrepareActionMode(
+ mode: ActionMode,
+ menu: Menu
+ ): Boolean {
+ clickItem(holder, contactId)
+ return true
+ }
+
+ override fun onActionItemClicked(
+ mode: ActionMode,
+ item: MenuItem
+ ): Boolean {
+ when (item.itemId) {
+ R.id.deleteButton -> {
+ val messagesByContactId = ArrayList()
+ selectedList.forEach { contactId ->
+ messagesByContactId += messages.filter {
+ if (contactId == DataPacket.ID_BROADCAST)
+ it.to == DataPacket.ID_BROADCAST
+ else
+ it.from == contactId && it.to != DataPacket.ID_BROADCAST
+ || it.from == DataPacket.ID_LOCAL && it.to == contactId
+ }
+ }
+ val deleteMessagesString = resources.getQuantityString(
+ R.plurals.delete_messages,
+ messagesByContactId.size,
+ messagesByContactId.size
+ )
+ MaterialAlertDialogBuilder(requireContext())
+ .setMessage(deleteMessagesString)
+ .setPositiveButton(getString(R.string.delete)) { _, _ ->
+ debug("User clicked deleteButton")
+ // all items selected --> deleteAllMessages()
+ if (messagesByContactId.size == messages.size) {
+ model.messagesState.deleteAllMessages()
+ } else {
+ messagesByContactId.forEach {
+ model.messagesState.deleteMessage(it)
+ }
+ }
+ mode.finish()
+ }
+ .setNeutralButton(R.string.cancel) { _, _ ->
+ }
+ .show()
+ }
+ R.id.selectAllButton -> {
+ // if all selected -> unselect all
+ if (selectedList.size == contacts.size) {
+ selectedList.clear()
+ mode.finish()
+ } else {
+ // else --> select all
+ selectedList.clear()
+
+ contacts.forEach {
+ if (it.from == DataPacket.ID_LOCAL || it.to == DataPacket.ID_BROADCAST)
+ selectedList.add(it.to!!) else selectedList.add(it.from!!)
+ }
+ }
+ actionMode?.title = selectedList.size.toString()
+ notifyDataSetChanged()
+ }
+ }
+ return true
+ }
+
+ override fun onDestroyActionMode(mode: ActionMode) {
+ selectedList.clear()
+ notifyDataSetChanged()
+ actionMode = null
+ }
+ })
+ } else {
+ // when action mode is enabled
+ clickItem(holder, contactId)
+ }
+ true
+ }
+ holder.itemView.setOnClickListener {
+ if (actionMode != null) clickItem(holder, contactId)
+ else {
+ debug("calling MessagesFragment filter:$contactId")
+ setFragmentResult(
+ "requestKey",
+ bundleOf("contactId" to contactId, "contactName" to longName)
+ )
+ parentFragmentManager.beginTransaction()
+ .replace(R.id.mainActivityLayout, MessagesFragment())
+ .addToBackStack(null)
+ .commit()
+ }
+ }
+
+ if (selectedList.contains(contactId)) {
+ holder.itemView.background = GradientDrawable().apply {
+ shape = GradientDrawable.RECTANGLE
+ cornerRadius = 32f
+ setColor(Color.rgb(127, 127, 127))
+ }
+ } else {
+ holder.itemView.background = GradientDrawable().apply {
+ shape = GradientDrawable.RECTANGLE
+ cornerRadius = 32f
+ setColor(
+ ContextCompat.getColor(
+ holder.itemView.context,
+ R.color.colorAdvancedBackground
+ )
+ )
+ }
+ }
+ }
+
+ private fun clickItem(
+ holder: ViewHolder,
+ contactId: String? = DataPacket.ID_BROADCAST
+ ) {
+ val position = holder.bindingAdapterPosition
+ if (contactId != null && !selectedList.contains(contactId)) {
+ selectedList.add(contactId)
+ } else {
+ selectedList.remove(contactId)
+ }
+ if (selectedList.isEmpty()) {
+ // finish action mode when no items selected
+ actionMode?.finish()
+ } else {
+ // show total items selected on action mode title
+ actionMode?.title = selectedList.size.toString()
+ }
+ notifyItemChanged(position)
+ }
+
+ /// Called when our contacts DB changes
+ fun onContactsChanged(contactsIn: Collection) {
+ contacts = contactsIn.sortedByDescending { it.time }.toTypedArray()
+ notifyDataSetChanged() // FIXME, this is super expensive and redraws all nodes
+ }
+
+ /// Called when our message DB changes
+ fun onMessagesChanged(msgIn: Collection) {
+ messages = msgIn.toTypedArray()
+ }
+
+ fun onChannelsChanged() {
+ val oldBroadcast = contacts.find { it.to == DataPacket.ID_BROADCAST }
+ if (oldBroadcast != null) {
+ notifyItemChanged(contacts.indexOf(oldBroadcast))
+ }
+ }
+ }
+
+ override fun onPause() {
+ actionMode?.finish()
+ super.onPause()
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater, container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ _binding = FragmentContactsBinding.inflate(inflater, container, false)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ binding.contactsView.adapter = contactsAdapter
+ binding.contactsView.layoutManager = LinearLayoutManager(requireContext())
+
+ model.channels.observe(viewLifecycleOwner) {
+ contactsAdapter.onChannelsChanged()
+ }
+
+ model.nodeDB.nodes.observe(viewLifecycleOwner) {
+ contactsAdapter.notifyDataSetChanged()
+ }
+
+ model.messagesState.contacts.observe(viewLifecycleOwner) {
+ debug("New contacts received: ${it.size}")
+ contactsAdapter.onContactsChanged(it.values)
+ }
+
+ model.messagesState.messages.observe(viewLifecycleOwner) {
+ contactsAdapter.onMessagesChanged(it)
+ }
+ }
+}
diff --git a/app/src/main/java/com/geeksville/mesh/ui/MessagesFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/MessagesFragment.kt
index a7c90157f..33738d0c2 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/MessagesFragment.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/MessagesFragment.kt
@@ -1,24 +1,24 @@
package com.geeksville.mesh.ui
-import android.app.AlertDialog
import android.graphics.Color
+import android.graphics.drawable.GradientDrawable
import android.os.Bundle
import android.text.InputType
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
+import android.view.*
import android.view.inputmethod.EditorInfo
import android.widget.EditText
import android.widget.ImageView
import android.widget.TextView
import androidx.cardview.widget.CardView
import androidx.core.content.ContextCompat
+import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
-import androidx.lifecycle.Observer
+import androidx.fragment.app.setFragmentResultListener
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.geeksville.android.Logging
import com.geeksville.mesh.DataPacket
+import com.geeksville.mesh.MainActivity
import com.geeksville.mesh.MessageStatus
import com.geeksville.mesh.R
import com.geeksville.mesh.databinding.AdapterMessageLayoutBinding
@@ -26,6 +26,7 @@ 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 com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint
import java.text.DateFormat
import java.util.*
@@ -37,31 +38,30 @@ fun EditText.on(actionId: Int, func: () -> Unit) {
if (actionId == receivedActionId) {
func()
}
-
true
}
}
@AndroidEntryPoint
-class MessagesFragment : ScreenFragment("Messages"), Logging {
+class MessagesFragment : Fragment(), Logging {
+ private var actionMode: ActionMode? = null
private var _binding: MessagesFragmentBinding? = null
// This property is only valid between onCreateView and onDestroyView.
private val binding get() = _binding!!
+ private var contactId: String = DataPacket.ID_BROADCAST
+ private var contactName: String = DataPacket.ID_BROADCAST
private val model: UIViewModel by activityViewModels()
// Allows textMultiline with IME_ACTION_SEND
- fun EditText.onActionSend(func: () -> Unit) {
- setImeOptions(EditorInfo.IME_ACTION_SEND)
- setRawInputType(InputType.TYPE_CLASS_TEXT)
+ private fun EditText.onActionSend(func: () -> Unit) {
setOnEditorActionListener { _, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_SEND) {
func()
}
-
true
}
}
@@ -73,22 +73,21 @@ class MessagesFragment : ScreenFragment("Messages"), Logging {
private fun getShortDateTime(time: Date): String {
// return time if within 24 hours, otherwise date/time
- val one_day = 60 * 60 * 24 * 1000
- if (System.currentTimeMillis() - time.time > one_day) {
- return dateTimeFormat.format(time)
- } else return timeFormat.format(time)
+ val oneDayMsec = 60 * 60 * 24 * 1000L
+ return if (System.currentTimeMillis() - time.time > oneDayMsec) {
+ dateTimeFormat.format(time)
+ } else timeFormat.format(time)
}
-
// 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: AdapterMessageLayoutBinding) :
RecyclerView.ViewHolder(itemView.root) {
+ val card: CardView = itemView.Card
val username: Chip = itemView.username
val messageText: TextView = itemView.messageText
val messageTime: TextView = itemView.messageTime
val messageStatusIcon: ImageView = itemView.messageStatusIcon
- val card: CardView = itemView.Card
}
private val messagesAdapter = object : RecyclerView.Adapter() {
@@ -119,8 +118,6 @@ class MessagesFragment : ScreenFragment("Messages"), Logging {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val inflater = LayoutInflater.from(requireContext())
- // Inflate the custom layout
-
// Inflate the custom layout
val contactViewBinding = AdapterMessageLayoutBinding.inflate(inflater, parent, false)
@@ -128,6 +125,9 @@ class MessagesFragment : ScreenFragment("Messages"), Logging {
return ViewHolder(contactViewBinding)
}
+ var messages = arrayOf()
+ var selectedList = ArrayList()
+
/**
* Returns the total number of items in the data set held by the adapter.
*
@@ -159,69 +159,56 @@ class MessagesFragment : ScreenFragment("Messages"), Logging {
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val msg = messages[position]
val nodes = model.nodeDB.nodes.value!!
- val node = nodes.get(msg.from)
- // Determine if this is my message (originated on this device).
- // val isMe = model.myNodeInfo.value?.myNodeNum == node?.num
- val isMe = msg.from == "^local"
+
+ // Determine if this is my message (originated on this device)
+ val isLocal = msg.from == DataPacket.ID_LOCAL
+ val isBroadcast = (msg.to == DataPacket.ID_BROADCAST
+ || msg.delayed == 1) // MeshProtos.MeshPacket.Delayed.DELAYED_BROADCAST_VALUE == 1
+
+ // Filter messages by contactId
+ if (contactId == DataPacket.ID_BROADCAST) {
+ if (isBroadcast) {
+ holder.card.visibility = View.VISIBLE
+ } else holder.card.visibility = View.GONE
+ } else {
+ if (msg.from == contactId && msg.to != DataPacket.ID_BROADCAST
+ || msg.from == DataPacket.ID_LOCAL && msg.to == contactId) {
+ holder.card.visibility = View.VISIBLE
+ } else holder.card.visibility = View.GONE
+ }
// Set cardview offset and color.
val marginParams = holder.card.layoutParams as ViewGroup.MarginLayoutParams
val messageOffset = resources.getDimensionPixelOffset(R.dimen.message_offset)
- holder.card.setOnLongClickListener {
- val deleteMessageDialog = AlertDialog.Builder(context)
- deleteMessageDialog.setMessage(R.string.delete_selected_message)
- deleteMessageDialog.setPositiveButton(
- R.string.delete
- ) { _, _ ->
- model.messagesState.deleteMessage((messages[position]), position)
- }
- deleteMessageDialog.setNeutralButton(
- R.string.cancel
- ) { _, _ ->
- }
- deleteMessageDialog.setNegativeButton(
- R.string.delete_all_messages
- ) { _, _ ->
- model.messagesState.deleteAllMessages()
- }
- deleteMessageDialog.create()
- deleteMessageDialog.show()
- true
- }
- if (isMe) {
+ if (isLocal) {
+ holder.messageText.textAlignment = View.TEXT_ALIGNMENT_TEXT_END
marginParams.leftMargin = messageOffset
marginParams.rightMargin = 0
context?.let {
- holder.card.setCardBackgroundColor(
- ContextCompat.getColor(
- it,
- R.color.colorMyMsg
- )
- )
+ holder.card.setCardBackgroundColor(ContextCompat.getColor(it, R.color.colorMyMsg))
}
} else {
+ holder.messageText.textAlignment = View.TEXT_ALIGNMENT_TEXT_START
marginParams.rightMargin = messageOffset
marginParams.leftMargin = 0
context?.let {
- holder.card.setCardBackgroundColor(
- ContextCompat.getColor(
- it,
- R.color.colorMsg
- )
- )
+ holder.card.setCardBackgroundColor(ContextCompat.getColor(it, R.color.colorMsg))
}
}
// Hide the username chip for my messages
- if (isMe) {
+ if (isLocal) {
holder.username.visibility = View.GONE
} else {
holder.username.visibility = View.VISIBLE
// If we can't find the sender, just use the ID
+ val node = nodes[msg.from]
val user = node?.user
holder.username.text = user?.shortName ?: msg.from
}
if (msg.errorMessage != null) {
- context?.let { holder.card.setCardBackgroundColor(Color.RED) }
+ holder.itemView.context?.let {
+ holder.card.setCardBackgroundColor(Color.RED)
+ }
holder.messageText.text = msg.errorMessage
} else {
holder.messageText.text = msg.text
@@ -243,9 +230,122 @@ class MessagesFragment : ScreenFragment("Messages"), Logging {
} else
holder.messageStatusIcon.visibility = View.INVISIBLE
+
+ holder.itemView.setOnLongClickListener {
+ if (actionMode == null) {
+ actionMode = (activity as MainActivity).startActionMode(object : ActionMode.Callback {
+ override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
+ mode.menuInflater.inflate(R.menu.menu_messages, menu)
+ mode.title = "1"
+ return true
+ }
+
+ override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
+ clickItem(holder)
+ return true
+ }
+
+ override fun onActionItemClicked(
+ mode: ActionMode,
+ item: MenuItem
+ ): Boolean {
+ when (item.itemId) {
+ R.id.deleteButton -> {
+ val deleteMessagesString = resources.getQuantityString(
+ R.plurals.delete_messages,
+ selectedList.size,
+ selectedList.size
+ )
+ MaterialAlertDialogBuilder(requireContext())
+ .setMessage(deleteMessagesString)
+ .setPositiveButton(getString(R.string.delete)) { _, _ ->
+ debug("User clicked deleteButton")
+ // all items selected --> deleteAllMessages()
+ if (selectedList.size == messages.size) {
+ model.messagesState.deleteAllMessages()
+ } else {
+ selectedList.forEach {
+ model.messagesState.deleteMessage(it)
+ }
+ }
+ mode.finish()
+ }
+ .setNeutralButton(R.string.cancel) { _, _ ->
+ }
+ .show()
+ }
+ R.id.selectAllButton -> {
+ // filter messages by ContactId
+ val messagesByContactId = messages.filter {
+ if (contactId == DataPacket.ID_BROADCAST)
+ it.to == DataPacket.ID_BROADCAST
+ else
+ it.from == contactId && it.to != DataPacket.ID_BROADCAST
+ || it.from == DataPacket.ID_LOCAL && it.to == contactId
+ }
+ // if all selected -> unselect all
+ if (selectedList.size == messagesByContactId.size) {
+ selectedList.clear()
+ mode.finish()
+ } else {
+ // else --> select all
+ selectedList.clear()
+ selectedList.addAll(messagesByContactId)
+ }
+ actionMode?.title = selectedList.size.toString()
+ notifyDataSetChanged()
+ }
+ }
+ return true
+ }
+
+ override fun onDestroyActionMode(mode: ActionMode) {
+ selectedList.clear()
+ notifyDataSetChanged()
+ actionMode = null
+ }
+ })
+ } else {
+ // when action mode is enabled
+ clickItem(holder)
+ }
+ true
+ }
+ holder.itemView.setOnClickListener {
+ if (actionMode != null) clickItem(holder)
+ }
+
+ if (selectedList.contains(msg)) {
+ holder.itemView.background = GradientDrawable().apply {
+ shape = GradientDrawable.RECTANGLE
+ cornerRadius = 32f
+ setColor(Color.rgb(127, 127, 127))
+ }
+ } else {
+ holder.itemView.background = GradientDrawable().apply {
+ shape = GradientDrawable.RECTANGLE
+ cornerRadius = 32f
+ setColor(ContextCompat.getColor(holder.itemView.context, R.color.colorAdvancedBackground))
+ }
+ }
}
- private var messages = arrayOf()
+ private fun clickItem(holder: ViewHolder) {
+ val position = holder.bindingAdapterPosition
+ if (!selectedList.contains(messages[position])) {
+ selectedList.add(messages[position])
+ } else {
+ selectedList.remove(messages[position])
+ }
+ if (selectedList.isEmpty()) {
+ // finish action mode when no items selected
+ actionMode?.finish()
+ } else {
+ // show total items selected on action mode title
+ actionMode?.title = selectedList.size.toString()
+ }
+ notifyItemChanged(position)
+ }
/// Called when our node DB changes
fun onMessagesChanged(msgIn: Collection) {
@@ -258,22 +358,35 @@ class MessagesFragment : ScreenFragment("Messages"), Logging {
}
}
+ override fun onPause() {
+ actionMode?.finish()
+ super.onPause()
+ }
+
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
- ): View? {
+ ): View {
_binding = MessagesFragmentBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
+
+ setFragmentResultListener("requestKey") { _, bundle->
+ // get the result from bundle
+ contactId = bundle.getString("contactId").toString()
+ contactName = bundle.getString("contactName").toString()
+ binding.messageTitle.text = contactName
+ }
+
binding.sendButton.setOnClickListener {
- debug("sendButton click")
+ debug("User clicked sendButton")
val str = binding.messageInputText.text.toString().trim()
if (str.isNotEmpty())
- model.messagesState.sendMessage(str)
+ model.messagesState.sendMessage(str, contactId)
binding.messageInputText.setText("") // blow away the string the user just entered
// requireActivity().hideKeyboard()
@@ -295,34 +408,20 @@ class MessagesFragment : ScreenFragment("Messages"), Logging {
layoutManager.stackFromEnd = true // We want the last rows to always be shown
binding.messageListView.layoutManager = layoutManager
- model.messagesState.messages.observe(viewLifecycleOwner, Observer {
+ model.messagesState.messages.observe(viewLifecycleOwner) {
debug("New messages received: ${it.size}")
messagesAdapter.onMessagesChanged(it)
- })
+ }
// If connection state _OR_ myID changes we have to fix our ability to edit outgoing messages
- fun updateTextEnabled() {
- binding.textInputLayout.isEnabled =
- model.isConnected.value != MeshService.ConnectionState.DISCONNECTED
+ model.isConnected.observe(viewLifecycleOwner) { connectionState ->
+ // If we don't know our node ID and we are offline don't let user try to send
+ val connected = connectionState == MeshService.ConnectionState.CONNECTED
+ binding.textInputLayout.isEnabled = connected
+ binding.sendButton.isEnabled = connected
// Just being connected is enough to allow sending texts I think
// && model.nodeDB.myId.value != null && model.radioConfig.value != null
}
-
- model.isConnected.observe(viewLifecycleOwner, Observer { _ ->
- // If we don't know our node ID and we are offline don't let user try to send
- updateTextEnabled()
- })
-
- /* model.nodeDB.myId.observe(viewLifecycleOwner, Observer { _ ->
- // If we don't know our node ID and we are offline don't let user try to send
- updateTextEnabled()
- })
-
- model.radioConfig.observe(viewLifecycleOwner, Observer { _ ->
- // If we don't know our node ID and we are offline don't let user try to send
- updateTextEnabled()
- }) */
}
-
}
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 b44a31fd1..1ad1e60b4 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt
@@ -1,7 +1,6 @@
package com.geeksville.mesh.ui
import android.annotation.SuppressLint
-import android.app.Activity
import android.app.Application
import android.app.PendingIntent
import android.bluetooth.BluetoothDevice
@@ -21,9 +20,13 @@ import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.EditorInfo
import android.widget.*
+import androidx.activity.result.IntentSenderRequest
+import androidx.activity.result.contract.ActivityResultContracts
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
+import com.geeksville.analytics.DataPair
import com.geeksville.android.GeeksvilleApplication
import com.geeksville.android.Logging
import com.geeksville.android.hideKeyboard
@@ -33,6 +36,7 @@ import com.geeksville.mesh.R
import com.geeksville.mesh.RadioConfigProtos
import com.geeksville.mesh.android.*
import com.geeksville.mesh.databinding.SettingsFragmentBinding
+import com.geeksville.mesh.model.BluetoothViewModel
import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.service.*
import com.geeksville.mesh.service.SoftwareUpdateService.Companion.ACTION_UPDATE_PROGRESS
@@ -114,20 +118,17 @@ class BTScanModel(app: Application) : AndroidViewModel(app), Logging {
debug("BTScanModel created")
}
- open class DeviceListEntry(val name: String, val address: String, val bonded: Boolean) {
- val bluetoothAddress
- get() =
- if (isBluetooth)
- address.substring(1)
- else
- null
+ /** *fullAddress* = interface prefix + address (example: "x7C:9E:BD:F0:BE:BE") */
+ open class DeviceListEntry(val name: String, val fullAddress: String, val bonded: Boolean) {
+ val prefix get() = fullAddress[0]
+ val address get() = fullAddress.substring(1)
override fun toString(): String {
- return "DeviceListEntry(name=${name.anonymize}, addr=${address.anonymize})"
+ return "DeviceListEntry(name=${name.anonymize}, addr=${fullAddress.anonymize}, bonded=$bonded)"
}
- val isBluetooth: Boolean get() = address[0] == 'x'
- val isSerial: Boolean get() = address[0] == 's'
+ val isBLE: Boolean get() = prefix == 'x'
+ val isUSB: Boolean get() = prefix == 's'
}
class USBDeviceListEntry(usbManager: UsbManager, val usb: UsbSerialDriver) : DeviceListEntry(
@@ -141,7 +142,10 @@ class BTScanModel(app: Application) : AndroidViewModel(app), Logging {
debug("BTScanModel cleared")
}
- val bluetoothAdapter = context.bluetoothManager?.adapter
+ private val bluetoothAdapter = context.bluetoothManager?.adapter
+ private val deviceManager get() = context.deviceManager
+ val hasCompanionDeviceApi get() = context.hasCompanionDeviceApi()
+ private val hasConnectPermission get() = context.hasConnectPermission()
private val usbManager get() = context.usbManager
var selectedAddress: String? = null
@@ -158,15 +162,6 @@ class BTScanModel(app: Application) : AndroidViewModel(app), Logging {
null
}
- /// If this address is for a USB device, return the macaddr portion, else null
- val selectedUSB: String?
- get() = selectedAddress?.let { a ->
- if (a[0] == 's')
- a.substring(1)
- else
- null
- }
-
/// Use the string for the NopInterface
val selectedNotNull: String get() = selectedAddress ?: "n"
@@ -220,7 +215,7 @@ class BTScanModel(app: Application) : AndroidViewModel(app), Logging {
private fun addDevice(entry: DeviceListEntry) {
val oldDevs = devices.value!!
- oldDevs[entry.address] = entry // Add/replace entry
+ oldDevs[entry.fullAddress] = entry // Add/replace entry
devices.value = oldDevs // trigger gui updates
}
@@ -232,9 +227,11 @@ class BTScanModel(app: Application) : AndroidViewModel(app), Logging {
scanner?.stopScan(scanCallback)
} catch (ex: Throwable) {
warn("Ignoring error stopping scan, probably BT adapter was disabled suddenly: ${ex.message}")
+ } finally {
+ scanner = null
+ _spinner.value = false
}
- scanner = null
- }
+ } else _spinner.value = false
}
/**
@@ -257,13 +254,13 @@ class BTScanModel(app: Application) : AndroidViewModel(app), Logging {
DeviceListEntry("Meshtastic_32ac", "xbb", true) */
)
- devices.value = (testnodes.map { it.address to it }).toMap().toMutableMap()
+ devices.value = (testnodes.map { it.fullAddress to it }).toMap().toMutableMap()
// If nothing was selected, by default select the first thing we see
if (selectedAddress == null)
changeScanSelection(
GeeksvilleApplication.currentActivity as MainActivity,
- testnodes.first().address
+ testnodes.first().fullAddress
)
true
@@ -286,6 +283,9 @@ class BTScanModel(app: Application) : AndroidViewModel(app), Logging {
// Include a placeholder for "None"
addDevice(DeviceListEntry(context.getString(R.string.none), "n", true))
+ // Include CompanionDeviceManager valid associations
+ addDeviceAssociations()
+
usbDrivers.forEach { d ->
addDevice(
USBDeviceListEntry(usbManager, d)
@@ -299,13 +299,20 @@ class BTScanModel(app: Application) : AndroidViewModel(app), Logging {
}
}
+ fun startScan () {
+ if (hasCompanionDeviceApi) {
+ startCompanionScan()
+ } else startClassicScan()
+ }
+
@SuppressLint("MissingPermission")
- fun startScan() {
+ private fun startClassicScan() {
/// The following call might return null if the user doesn't have bluetooth access permissions
val bluetoothLeScanner: BluetoothLeScanner? = bluetoothAdapter?.bluetoothLeScanner
if (bluetoothLeScanner != null) { // could be null if bluetooth is disabled
- debug("starting scan")
+ debug("starting classic scan")
+ _spinner.value = true
// filter and only accept devices that have our service
val filter =
@@ -324,6 +331,91 @@ class BTScanModel(app: Application) : AndroidViewModel(app), Logging {
}
}
+ /**
+ * @return DeviceListEntry from full Address (prefix + address).
+ * If Bluetooth is enabled and BLE Address is valid, get remote device information.
+ */
+ @SuppressLint("MissingPermission")
+ fun getDeviceListEntry(fullAddress: String, bonded: Boolean = false): DeviceListEntry {
+ val address = fullAddress.substring(1)
+ val device = bluetoothAdapter?.getRemoteDevice(address)
+ return if (device != null && device.name != null) {
+ DeviceListEntry(device.name, fullAddress, device.bondState != BluetoothDevice.BOND_NONE)
+ } else {
+ DeviceListEntry(address, fullAddress, bonded)
+ }
+ }
+
+ @SuppressLint("NewApi")
+ fun addDeviceAssociations() {
+ if (hasCompanionDeviceApi) deviceManager?.associations?.forEach { bleAddress ->
+ val bleDevice = getDeviceListEntry("x$bleAddress", true)
+ // Disassociate after pairing is removed (if BLE is disabled, assume bonded)
+ if (bleDevice.name.startsWith("Mesh") && !bleDevice.bonded) {
+ debug("Forgetting old BLE association ${bleAddress.anonymize}")
+ deviceManager?.disassociate(bleAddress)
+ }
+ addDevice(bleDevice)
+ }
+ }
+
+ private val _spinner = MutableLiveData(false)
+ val spinner: LiveData get() = _spinner
+
+ private val _associationRequest = MutableLiveData(null)
+ val associationRequest: LiveData get() = _associationRequest
+
+ /**
+ * Called immediately after fragment observes CompanionDeviceManager activity result
+ */
+ fun clearAssociationRequest() {
+ _associationRequest.value = null
+ }
+
+ @SuppressLint("NewApi")
+ private fun associationRequest(): AssociationRequest {
+ // To skip filtering based on name and supported feature flags (UUIDs),
+ // don't include calls to setNamePattern() and addServiceUuid(),
+ // respectively. This example uses Bluetooth.
+ // We only look for Mesh (rather than the full name) because NRF52 uses a very short name
+ val deviceFilter: BluetoothDeviceFilter = BluetoothDeviceFilter.Builder()
+ .setNamePattern(Pattern.compile("Mesh.*"))
+ // .addServiceUuid(ParcelUuid(RadioInterfaceService.BTM_SERVICE_UUID), null)
+ .build()
+
+ // The argument provided in setSingleDevice() determines whether a single
+ // device name or a list of device names is presented to the user as
+ // pairing options.
+ return AssociationRequest.Builder()
+ .addDeviceFilter(deviceFilter)
+ .setSingleDevice(false)
+ .build()
+ }
+
+ @SuppressLint("NewApi")
+ private fun startCompanionScan() {
+ debug("starting companion scan")
+ _spinner.value = true
+ deviceManager?.associate(
+ associationRequest(),
+ @SuppressLint("NewApi")
+ object : CompanionDeviceManager.Callback() {
+ override fun onDeviceFound(chooserLauncher: IntentSender) {
+ debug("CompanionDeviceManager - device found")
+ _spinner.value = false
+ chooserLauncher.let {
+ val request: IntentSenderRequest = IntentSenderRequest.Builder(it).build()
+ _associationRequest.value = request
+ }
+ }
+
+ override fun onFailure(error: CharSequence?) {
+ warn("BLE selection service failed $error")
+ }
+ }, null
+ )
+ }
+
val devices = object : MutableLiveData>(mutableMapOf()) {
/**
@@ -339,7 +431,7 @@ class BTScanModel(app: Application) : AndroidViewModel(app), Logging {
*/
override fun onInactive() {
super.onInactive()
- // stopScan()
+ stopScan()
}
}
@@ -348,25 +440,24 @@ class BTScanModel(app: Application) : AndroidViewModel(app), Logging {
fun onSelected(activity: MainActivity, it: DeviceListEntry): Boolean {
// If the device is paired, let user select it, otherwise start the pairing flow
if (it.bonded) {
- changeScanSelection(activity, it.address)
+ changeScanSelection(activity, it.fullAddress)
return true
} else {
// Handle requestng USB or bluetooth permissions for the device
debug("Requesting permissions for the device")
exceptionReporter {
- val bleAddress = it.bluetoothAddress
- if (bleAddress != null) {
+ if (it.isBLE) {
// Request bonding for bluetooth
// We ignore missing BT adapters, because it lets us run on the emulator
bluetoothAdapter
- ?.getRemoteDevice(bleAddress)?.let { device ->
+ ?.getRemoteDevice(it.fullAddress)?.let { device ->
requestBonding(activity, device) { state ->
if (state == BOND_BONDED) {
errorText.value = activity.getString(R.string.pairing_completed)
changeScanSelection(
activity,
- it.address
+ it.fullAddress
)
} else {
errorText.value =
@@ -380,7 +471,7 @@ class BTScanModel(app: Application) : AndroidViewModel(app), Logging {
}
}
- if (it.isSerial) {
+ if (it.isUSB) {
it as USBDeviceListEntry
val ACTION_USB_PERMISSION = "com.geeksville.mesh.USB_PERMISSION"
@@ -399,7 +490,7 @@ class BTScanModel(app: Application) : AndroidViewModel(app), Logging {
)
) {
info("User approved USB access")
- changeScanSelection(activity, it.address)
+ changeScanSelection(activity, it.fullAddress)
// Force the GUI to redraw
devices.value = devices.value
@@ -447,20 +538,13 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
private val binding get() = _binding!!
private val scanModel: BTScanModel by activityViewModels()
+ private val bluetoothViewModel: BluetoothViewModel by activityViewModels()
private val model: UIViewModel by activityViewModels()
// FIXME - move this into a standard GUI helper class
private val guiJob = Job()
private val mainScope = CoroutineScope(Dispatchers.Main + guiJob)
- private val hasCompanionDeviceApi: Boolean by lazy {
- BluetoothInterface.hasCompanionDeviceApi(requireContext())
- }
-
- private val deviceManager: CompanionDeviceManager by lazy {
- requireContext().getSystemService(Context.COMPANION_DEVICE_SERVICE) as CompanionDeviceManager
- }
-
private val myActivity get() = requireActivity() as MainActivity
override fun onDestroy() {
@@ -472,6 +556,10 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
model.meshService?.let { service ->
debug("User started firmware update")
+ GeeksvilleApplication.analytics.track(
+ "firmware_update",
+ DataPair("content_type", "start")
+ )
binding.updateFirmwareButton.isEnabled = false // Disable until things complete
binding.updateProgressBar.visibility = View.VISIBLE
binding.updateProgressBar.progress = 0 // start from scratch
@@ -513,6 +601,10 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
} else
when (progress) {
ProgressSuccess -> {
+ GeeksvilleApplication.analytics.track(
+ "firmware_update",
+ DataPair("content_type", "success")
+ )
binding.scanStatusText.setText(R.string.update_successful)
binding.updateProgressBar.visibility = View.GONE
}
@@ -521,6 +613,10 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
binding.updateProgressBar.visibility = View.GONE
}
else -> {
+ GeeksvilleApplication.analytics.track(
+ "firmware_update",
+ DataPair("content_type", "failure")
+ )
binding.scanStatusText.setText(R.string.update_failed)
binding.updateProgressBar.visibility = View.VISIBLE
}
@@ -624,9 +720,12 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
regionAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
spinner.adapter = regionAdapter
- model.bluetoothEnabled.observe(viewLifecycleOwner) {
- if (it) binding.changeRadioButton.show()
- else binding.changeRadioButton.hide()
+ bluetoothViewModel.enabled.observe(viewLifecycleOwner) { enabled ->
+ if (enabled) {
+ binding.changeRadioButton.show()
+ scanModel.setupScan()
+ if (binding.scanStatusText.text == getString(R.string.requires_bluetooth)) updateNodeInfo()
+ } else binding.changeRadioButton.hide()
}
model.ownerName.observe(viewLifecycleOwner) { name ->
@@ -653,12 +752,28 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
updateNodeInfo()
}
+ scanModel.devices.observe(viewLifecycleOwner) { devices ->
+ updateDevicesButtons(devices)
+ }
+
scanModel.errorText.observe(viewLifecycleOwner) { errMsg ->
if (errMsg != null) {
binding.scanStatusText.text = errMsg
}
}
+ // show the spinner when [spinner] is true
+ scanModel.spinner.observe(viewLifecycleOwner) { show ->
+ binding.scanProgressBar.visibility = if (show) View.VISIBLE else View.GONE
+ }
+
+ scanModel.associationRequest.observe(viewLifecycleOwner) { request ->
+ request?.let {
+ associationResultLauncher.launch(request)
+ scanModel.clearAssociationRequest()
+ }
+ }
+
binding.updateFirmwareButton.setOnClickListener {
MaterialAlertDialogBuilder(requireContext())
.setMessage("${getString(R.string.update_firmware)}?")
@@ -741,8 +856,7 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
b.text = device.name
b.id = View.generateViewId()
b.isEnabled = enabled
- b.isChecked =
- device.address == scanModel.selectedNotNull && device.bonded // Only show checkbox if device is still paired
+ b.isChecked = device.fullAddress == scanModel.selectedNotNull
binding.deviceRadioGroup.addView(b)
b.setOnClickListener {
@@ -751,24 +865,18 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
b.isChecked =
scanModel.onSelected(myActivity, device)
-
- if (!b.isSelected) {
- binding.scanStatusText.text = getString(R.string.please_pair)
- }
}
}
- @SuppressLint("MissingPermission")
private fun updateDevicesButtons(devices: MutableMap?) {
// Remove the old radio buttons and repopulate
binding.deviceRadioGroup.removeAllViews()
if (devices == null) return
- val adapter = scanModel.bluetoothAdapter
var hasShownOurDevice = false
devices.values.forEach { device ->
- if (device.address == scanModel.selectedNotNull)
+ if (device.fullAddress == scanModel.selectedNotNull)
hasShownOurDevice = true
addDeviceButton(device, true)
}
@@ -779,150 +887,79 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
if (!hasShownOurDevice) {
// Note: we pull this into a tempvar, because otherwise some other thread can change selectedAddress after our null check
// and before use
- val bleAddr = scanModel.selectedBluetooth
-
- if (bleAddr != null && adapter != null && myActivity.hasConnectPermission()) {
- val bDevice =
- adapter.getRemoteDevice(bleAddr)
- if (bDevice.name != null) { // ignore nodes that node have a name, that means we've lost them since they appeared
- val curDevice = BTScanModel.DeviceListEntry(
- bDevice.name,
- scanModel.selectedAddress!!,
- bDevice.bondState == BOND_BONDED
- )
- addDeviceButton(
- curDevice,
- model.isConnected.value == MeshService.ConnectionState.CONNECTED
- )
- }
- } else if (scanModel.selectedUSB != null) {
- // Must be a USB device, show a placeholder disabled entry
- val curDevice = BTScanModel.DeviceListEntry(
- scanModel.selectedUSB!!,
- scanModel.selectedAddress!!,
- false
+ val curAddr = scanModel.selectedAddress
+ if (curAddr != null) {
+ val curDevice = scanModel.getDeviceListEntry(curAddr)
+ addDeviceButton(
+ curDevice,
+ model.isConnected.value == MeshService.ConnectionState.CONNECTED
)
- addDeviceButton(curDevice, false)
}
}
// get rid of the warning text once at least one device is paired.
// If we are running on an emulator, always leave this message showing so we can test the worst case layout
- val curRadio = RadioInterfaceService.getBondedDeviceAddress(requireContext())
+ val curRadio = scanModel.selectedAddress
if (curRadio != null && !MockInterface.addressValid(requireContext(), "")) {
binding.warningNotPaired.visibility = View.GONE
// binding.scanStatusText.text = getString(R.string.current_pair).format(curRadio)
- } else if (model.bluetoothEnabled.value == true){
+ } else if (bluetoothViewModel.enabled.value == true){
binding.warningNotPaired.visibility = View.VISIBLE
binding.scanStatusText.text = getString(R.string.not_paired_yet)
}
}
- private fun initClassicScan() {
-
- scanModel.devices.observe(viewLifecycleOwner) { devices ->
- updateDevicesButtons(devices)
- }
-
- binding.changeRadioButton.setOnClickListener {
- debug("User clicked changeRadioButton")
- if (!myActivity.hasScanPermission()) {
- myActivity.requestScanPermission()
- } else {
- checkLocationEnabled()
- scanLeDevice()
- }
- }
- }
-
// per https://developer.android.com/guide/topics/connectivity/bluetooth/find-ble-devices
private fun scanLeDevice() {
var scanning = false
- val SCAN_PERIOD: Long = 5000 // Stops scanning after 5 seconds
+ val SCAN_PERIOD: Long = 10000 // Stops scanning after 10 seconds
if (!scanning) { // Stops scanning after a pre-defined scan period.
Handler(Looper.getMainLooper()).postDelayed({
scanning = false
- binding.scanProgressBar.visibility = View.GONE
scanModel.stopScan()
}, SCAN_PERIOD)
scanning = true
- binding.scanProgressBar.visibility = View.VISIBLE
scanModel.startScan()
} else {
scanning = false
- binding.scanProgressBar.visibility = View.GONE
scanModel.stopScan()
}
}
- private fun startCompanionScan() {
- // Disable the change button until our scan has some results
- binding.changeRadioButton.isEnabled = false
-
- // To skip filtering based on name and supported feature flags (UUIDs),
- // don't include calls to setNamePattern() and addServiceUuid(),
- // respectively. This example uses Bluetooth.
- // We only look for Mesh (rather than the full name) because NRF52 uses a very short name
- val deviceFilter: BluetoothDeviceFilter = BluetoothDeviceFilter.Builder()
- .setNamePattern(Pattern.compile("Mesh.*"))
- // .addServiceUuid(ParcelUuid(RadioInterfaceService.BTM_SERVICE_UUID), null)
- .build()
-
- // The argument provided in setSingleDevice() determines whether a single
- // device name or a list of device names is presented to the user as
- // pairing options.
- val pairingRequest: AssociationRequest = AssociationRequest.Builder()
- .addDeviceFilter(deviceFilter)
- .setSingleDevice(false)
- .build()
-
- // When the app tries to pair with the Bluetooth device, show the
- // appropriate pairing request dialog to the user.
- deviceManager.associate(
- pairingRequest,
- object : CompanionDeviceManager.Callback() {
- override fun onDeviceFound(chooserLauncher: IntentSender) {
- debug("Found one device - enabling changeRadioButton")
- binding.changeRadioButton.isEnabled = true
- binding.changeRadioButton.setOnClickListener {
- debug("User clicked changeRadioButton")
- try {
- startIntentSenderForResult(
- chooserLauncher,
- MainActivity.SELECT_DEVICE_REQUEST_CODE, null, 0, 0, 0, null
- )
- } catch (ex: Throwable) {
- errormsg("CompanionDevice startIntentSenderForResult error")
- }
- }
- }
-
- override fun onFailure(error: CharSequence?) {
- warn("BLE selection service failed $error")
- // changeDeviceSelection(myActivity, null) // deselect any device
- }
- }, null
- )
- }
-
- private fun initModernScan() {
-
- scanModel.devices.observe(viewLifecycleOwner) { devices ->
- updateDevicesButtons(devices)
- startCompanionScan()
- }
+ @SuppressLint("MissingPermission")
+ val associationResultLauncher = registerForActivityResult(
+ ActivityResultContracts.StartIntentSenderForResult()
+ ) {
+ it.data
+ ?.getParcelableExtra(CompanionDeviceManager.EXTRA_DEVICE)
+ ?.let { device ->
+ scanModel.onSelected(
+ myActivity,
+ BTScanModel.DeviceListEntry(
+ device.name,
+ "x${device.address}",
+ device.bondState == BOND_BONDED
+ )
+ )
+ }
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
initCommonUI()
- if (hasCompanionDeviceApi)
- initModernScan()
- else
- initClassicScan()
+
+ binding.changeRadioButton.setOnClickListener {
+ debug("User clicked changeRadioButton")
+ if (!myActivity.hasScanPermission()) {
+ myActivity.requestScanPermission()
+ } else {
+ if (!scanModel.hasCompanionDeviceApi) checkLocationEnabled()
+ scanLeDevice()
+ }
+ }
}
// If the user has not turned on location access throw up a toast warning
@@ -1025,45 +1062,14 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
myActivity.registerReceiver(updateProgressReceiver, updateProgressFilter)
- // Keep reminding user BLE is still off
- val hasUSB = SerialInterface.findDrivers(myActivity).isNotEmpty()
- if (!hasUSB) {
- // Warn user if BLE is disabled
- if (scanModel.bluetoothAdapter?.isEnabled != true) {
- showSnackbar(getString(R.string.error_bluetooth))
- } else {
- if (binding.provideLocationCheckbox.isChecked)
- checkLocationEnabled(getString(R.string.location_disabled))
- }
- }
- }
-
- @SuppressLint("MissingPermission")
- override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
- if (hasCompanionDeviceApi && myActivity.hasConnectPermission()
- && requestCode == MainActivity.SELECT_DEVICE_REQUEST_CODE
- && resultCode == Activity.RESULT_OK
- ) {
- val deviceToPair: BluetoothDevice =
- data?.getParcelableExtra(CompanionDeviceManager.EXTRA_DEVICE)!!
-
- // We only keep an association to one device at a time...
- deviceManager.associations.forEach { old ->
- if (deviceToPair.address != old) {
- debug("Forgetting old BLE association ${old.anonymize}")
- deviceManager.disassociate(old)
- }
- }
- scanModel.onSelected(
- myActivity,
- BTScanModel.DeviceListEntry(
- deviceToPair.name,
- "x${deviceToPair.address}",
- deviceToPair.bondState == BOND_BONDED
- )
- )
- } else {
- super.onActivityResult(requestCode, resultCode, data)
- }
+ // Warn user if BLE is disabled
+ if (scanModel.selectedBluetooth != null && bluetoothViewModel.enabled.value == false) {
+ Toast.makeText(
+ requireContext(),
+ getString(R.string.error_bluetooth),
+ Toast.LENGTH_LONG
+ ).show()
+ } else if (binding.provideLocationCheckbox.isChecked)
+ checkLocationEnabled(getString(R.string.location_disabled))
}
}
diff --git a/app/src/main/java/com/geeksville/mesh/ui/UsersFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/UsersFragment.kt
index 2e93c72e8..6c1893e00 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/UsersFragment.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/UsersFragment.kt
@@ -7,8 +7,10 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.content.ContextCompat
+import androidx.core.os.bundleOf
import androidx.core.text.HtmlCompat
import androidx.fragment.app.activityViewModels
+import androidx.fragment.app.setFragmentResult
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.geeksville.android.Logging
@@ -165,13 +167,29 @@ class UsersFragment : ScreenFragment("Users"), Logging {
holder.signalView.visibility = View.VISIBLE
}
}
+ holder.itemView.setOnLongClickListener {
+ if (position > 0) {
+ debug("calling MessagesFragment filter:${n.user?.id}")
+ setFragmentResult(
+ "requestKey",
+ bundleOf("contactId" to n.user?.id, "contactName" to name)
+ )
+ parentFragmentManager.beginTransaction()
+ .replace(R.id.mainActivityLayout, MessagesFragment())
+ .addToBackStack(null)
+ .commit()
+ }
+ true
+ }
}
private var nodes = arrayOf()
/// Called when our node DB changes
- fun onNodesChanged(nodesIn: Collection) {
- nodes = nodesIn.toTypedArray()
+ fun onNodesChanged(nodesIn: Array) {
+ if (nodesIn.size > 1)
+ nodesIn.sortWith(compareByDescending { it.lastHeard }, 1)
+ nodes = nodesIn
notifyDataSetChanged() // FIXME, this is super expensive and redraws all nodes
}
}
@@ -210,9 +228,9 @@ class UsersFragment : ScreenFragment("Users"), Logging {
binding.nodeListView.adapter = nodesAdapter
binding.nodeListView.layoutManager = LinearLayoutManager(requireContext())
- model.nodeDB.nodes.observe(viewLifecycleOwner, {
- nodesAdapter.onNodesChanged(it.values)
- })
+ model.nodeDB.nodes.observe(viewLifecycleOwner) {
+ nodesAdapter.onNodesChanged(it.values.toTypedArray())
+ }
}
}
diff --git a/app/src/main/proto b/app/src/main/proto
index 2930129e8..f1476bf2f 160000
--- a/app/src/main/proto
+++ b/app/src/main/proto
@@ -1 +1 @@
-Subproject commit 2930129e8eac348c094bbedeb929d86efafc2b62
+Subproject commit f1476bf2f687a3926a98a9d8c86d5c2bba99c3cf
diff --git a/app/src/main/res/drawable/ic_twotone_delete_24.xml b/app/src/main/res/drawable/ic_twotone_delete_24.xml
new file mode 100644
index 000000000..b77afdc91
--- /dev/null
+++ b/app/src/main/res/drawable/ic_twotone_delete_24.xml
@@ -0,0 +1,15 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_twotone_select_all_24.xml b/app/src/main/res/drawable/ic_twotone_select_all_24.xml
new file mode 100644
index 000000000..c997121c7
--- /dev/null
+++ b/app/src/main/res/drawable/ic_twotone_select_all_24.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
index 1ce253cc3..5a9e3b695 100644
--- a/app/src/main/res/layout/activity_main.xml
+++ b/app/src/main/res/layout/activity_main.xml
@@ -62,11 +62,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:tabIconTint="@color/tab_color_selector"
- app:tabIndicatorColor="@color/selectedColor"
- >
-
-
-
+ app:tabIndicatorColor="@color/selectedColor" />
-
-
-
-
-
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/adapter_contact_layout.xml b/app/src/main/res/layout/adapter_contact_layout.xml
new file mode 100644
index 000000000..b1aa9e7ce
--- /dev/null
+++ b/app/src/main/res/layout/adapter_contact_layout.xml
@@ -0,0 +1,62 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/adapter_message_layout.xml b/app/src/main/res/layout/adapter_message_layout.xml
index 9d9f5db77..3c9b0e059 100644
--- a/app/src/main/res/layout/adapter_message_layout.xml
+++ b/app/src/main/res/layout/adapter_message_layout.xml
@@ -42,7 +42,6 @@
android:layout_marginEnd="8dp"
android:autoLink="all"
android:text="@string/sample_message"
- android:textIsSelectable="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/username"
app:layout_constraintTop_toTopOf="parent" />
@@ -62,9 +61,9 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
- android:layout_marginBottom="8dp"
android:contentDescription="@string/message_reception_time"
android:text="3 minutes ago"
+ android:textSize="12sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/messageStatusIcon"
app:layout_constraintTop_toBottomOf="@id/messageText" />
diff --git a/app/src/main/res/layout/channel_fragment.xml b/app/src/main/res/layout/channel_fragment.xml
index 77b252e7d..4f2bdb17f 100644
--- a/app/src/main/res/layout/channel_fragment.xml
+++ b/app/src/main/res/layout/channel_fragment.xml
@@ -13,6 +13,8 @@
android:layout_marginTop="16dp"
android:layout_marginEnd="64dp"
android:hint="@string/channel_name"
+ app:counterEnabled="true"
+ app:counterMaxLength="11"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
@@ -23,7 +25,6 @@
android:layout_height="wrap_content"
android:digits="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz1234567890- "
android:imeOptions="actionDone"
- android:maxLength="15"
android:singleLine="true"
android:text="@string/unset" />
diff --git a/app/src/main/res/layout/fragment_contacts.xml b/app/src/main/res/layout/fragment_contacts.xml
new file mode 100644
index 000000000..a96d827ca
--- /dev/null
+++ b/app/src/main/res/layout/fragment_contacts.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/messages_fragment.xml b/app/src/main/res/layout/messages_fragment.xml
index 411a6d8e0..5cbbd5b92 100644
--- a/app/src/main/res/layout/messages_fragment.xml
+++ b/app/src/main/res/layout/messages_fragment.xml
@@ -2,28 +2,52 @@
+ android:layout_height="match_parent"
+ android:background="@color/colorAdvancedBackground">
+
+
+
+
+
+
+
+
+
+ app:layout_constraintTop_toBottomOf="@id/toolbar" />
@@ -43,7 +67,6 @@
android:id="@+id/sendButton"
android:layout_width="64dp"
android:layout_height="64dp"
- android:layout_marginBottom="4dp"
android:contentDescription="@string/send_text"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
diff --git a/app/src/main/res/menu/menu_messages.xml b/app/src/main/res/menu/menu_messages.xml
new file mode 100644
index 000000000..1182d80a6
--- /dev/null
+++ b/app/src/main/res/menu/menu_messages.xml
@@ -0,0 +1,14 @@
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml
index d29c99100..1a493906f 100644
--- a/app/src/main/res/values-cs/strings.xml
+++ b/app/src/main/res/values-cs/strings.xml
@@ -48,11 +48,11 @@
Sdílet
Odpojeno
Zařízení spí
- Pripojeno: %s z %s je online
+ Pripojeno: %1$s z %2$s je online
Seznam vysílačů v síti
Aktualizace softwaru
Připojeno k vysílači (%s)
- Nepřipojeno, zvolte si vysílač
+ Nepřipojeno
Připojené k uspanému vysílači.
Aktualizovat na %s
Aplikace je příliš stará
diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml
index 72f5ff439..5dad4704c 100644
--- a/app/src/main/res/values-el/strings.xml
+++ b/app/src/main/res/values-el/strings.xml
@@ -1,142 +1,73 @@
-
Ρυθμίσεις
-
Όνομα Καναλιού
-
Επιλογές Καναλιού
-
Κοινή χρήση
-
Κώδικας QR
-
Αναίρεση
-
Κατάσταση Σύνδεσης
-
Εικονίδιο εφαρμογής
-
Άγνωστο Όνομα Χρήστη
-
Avatar Χρήστη
-
-
Βρήκα το σημείο, είναι διπλα στη τίγρη. Φοβάμαι λίγο.
-
Αποστολή κειμένου
-
Δεν έχετε κάνει ακόμη pair μια συσκευή συμβατή με Meshtastic με το τηλέφωνο. Παρακαλώ κάντε pair μια συσκευή και ορίστε το όνομα χρήστη.\n\nΗ εφαρμογή ανοιχτού κώδικα βρίσκεται σε alpha-testing, αν εντοπίσετε προβλήματα παρακαλώ δημοσιεύστε τα στο forum: meshtastic.discourse.group.\n\nΠερισσότερες πληροφορίες στην ιστοσελίδα - www.meshtastic.org.
-
Όνομα Χρήστη αναιρέθηκε
-
Όνομα
-
Ανώνυμα στατιστικά χρήσης και αναφορές crash.
-
Αναζήτηση συσκευών Meshtastic …
-
Η εφαρμογή απαιτεί bluetooth πρόσβαση. Παρακαλώ παρέχεται σχετική άδεια χρήσης στις ρυθμίσεις του android.
-
Σφάλμα - η εφαρμογή απαιτεί bluetooth
-
Αρχή pairing
-
Pairing απέτυχε
-
Διεύθυνση URL για συμμετοχή σε Meshtastic mesh
-
Αποδοχή
-
Ακύρωση
-
Αλλαγή καναλιού
-
Είστε βέβαιοι ότι θέλετε να αλλάξετε κανάλι? Η επικοινωνία με άλλες συσκευές θα σταματήσεις μέχρι να μοιραστείτε τις ρυθμίσεις του νέου καναλιού.
-
Λήψη URL νέου καναλιού
-
Θέλετε να αλλάξετε ‘%s’ κανάλι?
-
Έχετε απενεργοποιήσει την ανάλυση δεδομένων. Δυστυχώς ο πάροχος χαρτών μας (mapbox) απαιτεί η ανάλυση δεδομένων να επιτρέπεται στο ‘δωρεάν’ πακέτο. Επομένως έχουμε απενεργοποιήσει το χάρτη.\n\n
Αν επιθυμείτε να δείτε το χάρτη, θα πρέπει να ενεργοποιήσετε την ανάλυση δεδομένων στις Ρυθμίσεις (επίσης - προσωρινά - θα πρέπει να επανεκκινήσετε την εφαρμογή).\n\n
Αν θεωρείτε ότι πρέπει να πληρώνουμε το mapbox (η να αλλάξουμε πάροχο χαρτών), παρακαλώ δημοσιεύστε στο meshtastic.discourse.group
-
Λείπει μια απαιτούμενη άδεια, Meshtastic δεν θα λειτοργεί σωστά. Ενεργοποιήστε τις ρυθμίσεις εφαρμογής Android.
-
Radio σε κατάσταση ύπνου, δεν γίνεται αλλαγή καναλιού
-
Αναφορά Bug
-
Αναφέρετε ένα bug
-
Είστε σίγουροι ότι θέλετε να αναφέρετε ένα bug? Μετά την αναφορά δημοσιεύστε στο meshtastic.discourse.group ώστε να συνδέσουμε την αναφορά με το συμβάν.
-
Αναφορά
-
Επιλογή radio
-
Έχετε κάνει pair με radio %s
-
Δεν έχετε κάνει pair με radio ακόμη.
-
Αλλαγή radio
-
Παρακαλώ κάντε pair μια συσκευή στις ρυθμίσεις Android.
-
Η διαδικασία pairing ολοκληρώθηκε, εκκίνηση υπηρεσίας
-
Η διαδικασία pairing απέτυχε, παρακαλώ επιλέξτε πάλι
-
Ο εντοπισμός τοποθεσίας είναι απενεργοποιημένος, δε μπορούμε να μοιραστούμε τη θέση σας με το mesh.
-
Κοινοποίηση
-
Αποσυνδεδεμένο
-
Συσκευή σε ύπνωση
-
- Συνδεδεμένος: %s από %s online
-
+ Συνδεδεμένος: %1$s από %2$s online
Λίστα κόμβων δικτύου
-
Αναβάθμιση Firmware
-
Συνδεδεμένο στο radio
-
Συνδεδεμένο στο radio (%s)
-
- Αποσυνδεδεμένο, επιλέξτε radio
-
+ Αποσυνδεδεμένο
Συνδεδεμένο στο radio, αλλά βρίσκεται σε ύπνωση
-
Αναβάθμιση σε %s
-
Εφαρμογή πολύ παλαιά
-
Πρέπει να ενημερώσετε την εφαρμογή μέσω Google Play store (ή Github). Είναι πολύ παλαιά ώστε να συνδεθεί με το radio.
-
Κανένα (απενεργοποιημένο)
-
Μικρή εμβέλεια (αλλά γρήγορο)
-
Μεσαία εμβέλεια (αλλά γρήγορο)
-
Μεγάλη εμβέλεια (αλλά αργό)
-
Πολύ μεγάλη εμβέλεια (αλλά αργό)
-
ΜΗ ΑΝΑΓΝΩΡΙΣΙΜΟ
-
Ειδοποιήσεις Υπηρεσίας Meshtastic
-
Πρέπει να ενεργοποιήσετε τις υπηρεσίες εντοπισμού τοποθεσίας στις ρυθμίσεις Android
-
Σχετικά
-
Λίστα κόμβων στο mesh
-
Μηνύματα
-
Αυτό το κανάλι URL δεν είναι ορθό και δεν μπορεί να χρησιμοποιηθεί
diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml
index bdaa4ffea..c819e9f7a 100644
--- a/app/src/main/res/values-es/strings.xml
+++ b/app/src/main/res/values-es/strings.xml
@@ -45,12 +45,12 @@
Compartir
Desconectado
Dispositivo en reposo
- Conectado: %s de %s en línea
+ Conectado: %1$s de %2$s en línea
Una lista de nodos en la red
Actualizar el firmware
Conectado a la radio
Conectado a la radio (%s)
- No está conectado seleccione la radio de abajo
+ No está conectado
Conectado a la radio pero está en reposo
Actualizar a %s
Es necesario actualizar la aplicación
diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml
index 98dc00114..a33798259 100644
--- a/app/src/main/res/values-fr/strings.xml
+++ b/app/src/main/res/values-fr/strings.xml
@@ -49,12 +49,12 @@
Partager
Déconnecté
Appareil en veille
- Connecté: %s sur %s en ligne
+ Connecté: %1$s sur %2$s en ligne
Une liste de nœuds dans le réseau
Mise à jour du Firmware
Connecté à une radio
Connecté à la radio (%s)
- Non connecté, veuillez sélectionner une radio ci-dessous
+ Non connecté
Connecté à la radio, mais en mode veille
Aucun (désactivé)
Vous devez mettre à jour l\'application sur le Google Play Store (ou Github). Cette version n\'est plus compatible avec la radio.
diff --git a/app/src/main/res/values-ht/strings.xml b/app/src/main/res/values-ht/strings.xml
index 8c02da04e..e88ad5ec0 100644
--- a/app/src/main/res/values-ht/strings.xml
+++ b/app/src/main/res/values-ht/strings.xml
@@ -46,12 +46,12 @@
Pataje
Dekonekte
Aparèy ap dòmi
- Konekte: %s nan %s disponib
+ Konekte: %1$s nan %2$s disponib
Yon lis ne elektwonik nan rezo a
Mete ajou mikrolojisyèl
Konekte ak radyo
Konekte ak radyo (%s)
- Pa konekte, chwazi radyo anba a
+ Pa konekte
Konekte ak radyo, men li ap dòmi
Mizajou %s
Aplikasyon twò ansyen
diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml
index 2b567d09c..a1b714bd0 100644
--- a/app/src/main/res/values-hu/strings.xml
+++ b/app/src/main/res/values-hu/strings.xml
@@ -47,16 +47,16 @@
Megosztás
Szétkapcsolva
Az eszköz alszik
- Kapcsolódva: %s a %s-ból(ből) elérhető
+ Kapcsolódva: %1$s a %2$s-ból(ből) elérhető
Hálózati állomások listája
Firmware frissítés
Kapcsolódva a rádióhoz
Kapcsolódva a(z) %s rádióhoz
- Nincs kapcsolat, válasszon egy rádiót alább
+ Nincs kapcsolat
Kapcsolódva a rádióhoz, de az alvó üzemmódban van
Frissítés %s verzióra
Az alkalmazás frissítése szükséges
- Frissítenie kell ezt az alkalmazást a Google Play áruházban (vagy a GitHub-ról), mert túl régi, hogy kommunikálni tudjob ezzel a rádió firmware-rel. Kérem olvassa el a tudnivalókat ebből a wiki-ből.
+ Frissítenie kell ezt az alkalmazást a Google Play áruházban (vagy a GitHub-ról), mert túl régi, hogy kommunikálni tudjob ezzel a rádió firmware-rel. Kérem olvassa el a tudnivalókat ebből a docs-ből.
Egyik sem (letiltás)
Rövid hatótáv (nagyon gyors)
Közepes hatótáv (gyors)
diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml
index bc1d039f6..87171e0ca 100644
--- a/app/src/main/res/values-ja/strings.xml
+++ b/app/src/main/res/values-ja/strings.xml
@@ -48,11 +48,11 @@ mapboxの有償プラン(または代替地図プロバイダ)を検討さ
シェア
切断
スリープ
- 接続済み:%s人オンライン%s人中
+ 接続済み:%1$s人オンライン%2$s人中
ネットワーク内のノードリスト
ファームウェアアップデート
Meshtasticデバイスに接続しました。(%s)
- 接続されていません。下記のMeshtasticデバイスを選択してください。
+ 接続されていません
接続しましたが、Meshtasticデバイスはスリープ状態です。
%s更新
\ No newline at end of file
diff --git a/app/src/main/res/values-ko-rKR/strings.xml b/app/src/main/res/values-ko-rKR/strings.xml
index b624cf4a3..943c2db26 100644
--- a/app/src/main/res/values-ko-rKR/strings.xml
+++ b/app/src/main/res/values-ko-rKR/strings.xml
@@ -46,12 +46,12 @@
공유
연결 해제
장치 잠자기
- 연결: %s 온라인( 전체 %s)
+ 연결: %1$s 온라인( 전체 %2$s)
네트워크안은 모든 노드의 목록
펌웨어 업데이트
라디오로 연결됨
라디오로 연결됨 (%s)
- 연결되지 않음, 아래에서 라이오를 선택하세요.
+ 연결되지 않음
라디오에 연결됨, 해당 라이도는 잠자기중.
%s로 업데이트
너무 오래된 앱
diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml
index 3df9b39ed..6e485167e 100644
--- a/app/src/main/res/values-nl/strings.xml
+++ b/app/src/main/res/values-nl/strings.xml
@@ -48,12 +48,12 @@
Deel
Niet verbonden
Apparaat in slaapstand
- Verbonden: %s van %s online
+ Verbonden: %1$s van %2$s online
Een lijst van de aansluitpunten in het netwerk
Programma Updaten
Verbonden met een radio
Verbonden met radio (%s)
- Niet verbonden, selecteer radio hieronder
+ Niet verbonden
Verbonden met radio in slaapstand
Updaten naar %s
Applicatie te oud
diff --git a/app/src/main/res/values-no/strings.xml b/app/src/main/res/values-no/strings.xml
index e17082877..41172192d 100644
--- a/app/src/main/res/values-no/strings.xml
+++ b/app/src/main/res/values-no/strings.xml
@@ -48,12 +48,12 @@
Del
Frakoblet
Enhet sover
- Tilkoblet: %s av %s på nett
+ Tilkoblet: %1$s av %2$s på nett
En liste over noder i nettverket
Oppdater Firmware
Tilkoblet radio
Tilkoblet til radio (%s)
- Ikke tilkoblet. velg radio nedenfor
+ Ikke tilkoblet
Tilkoblet radio, men den sover
Oppdater til %s
Applikasjon for gammel
diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml
index d5e0e74c0..fd81af1ea 100644
--- a/app/src/main/res/values-pl/strings.xml
+++ b/app/src/main/res/values-pl/strings.xml
@@ -53,7 +53,7 @@
Udostępnij
Rozłączone
Urządzenie uśpione.
- Połączono: %s of %s online
+ Połączono: %1$s of %2$s online
Lista użytkowników w sieci
Aktualizuj oprogramowanie.
Połączony z urządzeniem
@@ -62,7 +62,7 @@
Połączony z urządzeniem, ale jest w trybie uśpienia.
Zaktualizuj do %s.
Wymagana jest aktualizacja aplikacji.
- Musisz zaktualizować tę aplikację w Google Play store (lub ręcznie przez Github). Wersja aplikacji jest za stara, aby komunikować się z urządzeniem. Więcej informacji na ten temat znajdziesz tu: wiki
+ Musisz zaktualizować tę aplikację w Google Play store (lub ręcznie przez Github). Wersja aplikacji jest za stara, aby komunikować się z urządzeniem. Więcej informacji na ten temat znajdziesz tu: docs
Żadne (Wyłącz)
Bliski zasięg (ale szybki transfer)
Średni zasięg (ale szybki transfer)
diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml
index 17b3cc324..6cacd6031 100644
--- a/app/src/main/res/values-pt-rBR/strings.xml
+++ b/app/src/main/res/values-pt-rBR/strings.xml
@@ -1,5 +1,5 @@
-
+
Configurações
Nome do canal
Opções do canal
@@ -48,16 +48,16 @@
Compartilhar
Desconectado
Dispositivo em suspensão (sleep)
- Conectado: %s de %s online
+ Conectado: %1$s de %2$s online
Lista de dispositivos na rede
Atualizar Firmware
Conectado ao rádio
Conectado ao rádio (%s)
- Não conectado, selecione um rádio abaixo
+ Não conectado
Conectado ao rádio, mas ele está em suspensão (sleep)
Atualização para %s
Atualização do aplicativo necessária
- Será necessário atualizar este aplicativo no Google Play (ou Github). Versão muito antiga para comunicar com o firmware do rádio. Favor consultar wiki.
+ Será necessário atualizar este aplicativo no Google Play (ou Github). Versão muito antiga para comunicar com o firmware do rádio. Favor consultar docs.
Nenhum (desabilitado)
Curto alcance / rápido
Médio alcance / rápido
@@ -113,7 +113,13 @@
Permitir (exibe diálogo)
Fornecer localização para mesh
Permissão da câmera
- Precisamos acessar a câmera para escanear códigos QR. Nenhuma foto ou video são armazenados.
+ Precisamos acessar a câmera para escanear códigos QR. Nenhuma foto ou vídeo são armazenados.
Curto alcance / lento
Médio alcance / lento
+
+ - Excluir mensagem?
+ - Excluir %s mensagens?
+
+ Excluir
+ Selecionar tudo
diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml
index 367449525..6c5f2049d 100644
--- a/app/src/main/res/values-pt/strings.xml
+++ b/app/src/main/res/values-pt/strings.xml
@@ -1,4 +1,4 @@
-
+
Configurações
Nome do Canal
Opções do Canal
@@ -47,12 +47,12 @@
Partilha
Desconectado
Dispositivo a dormir
- Conectado: %s de %s online
+ Conectado: %1$s de %2$s online
Lista de nós na rede
Atualizar Firmware
Conectado ao rádio
Conectado ao rádio (%s)
- Não conectado, escolha um rádio em baixo
+ Não conectado
Conectado ao rádio, mas está a dormir
Atualização para %s
A aplicação é muito antiga
@@ -112,8 +112,14 @@
Cancelar (sem acesso ao rádio)
Permitir (exibe diálogo)
Fornecer localização para mesh
- Precisamos acessar a câmera para escanear códigos QR. Nenhuma foto ou video são armazenados.
+ Precisamos acessar a câmera para escanear códigos QR. Nenhuma foto ou vídeo são armazenados.
Permissão da câmera
Curto alcance / lento
Médio alcance / lento
+
+ - Excluir mensagem?
+ - Excluir %s mensagens?
+
+ Excluir
+ Selecionar tudo
diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml
index ca6d109dd..b94ca5f16 100644
--- a/app/src/main/res/values-ro/strings.xml
+++ b/app/src/main/res/values-ro/strings.xml
@@ -48,12 +48,12 @@
Distribuie
Deconectat
Dispozitiv în sleep mode
- Connectat: %s din %s online
+ Connectat: %1$s din %2$s online
O lista cu nodurile din rețea
Updateaza firmware-ul
Connectat la dispozitiv
Conectat la dispozitivul (%s)
- Neconectat, selectează dispozitivul din lista de jos
+ Neconectat
Connectat la dispozitivi, dar e în modul de sleep
Updateaza către %s
Aplicație prea veche
diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml
index 43e8cc17e..1a56fb74e 100644
--- a/app/src/main/res/values-sk/strings.xml
+++ b/app/src/main/res/values-sk/strings.xml
@@ -48,16 +48,16 @@
Zdieľať
Odpojené
Vysielač uspatý
- Pripojený: %s z %s je online
+ Pripojený: %1$s z %2$s je online
Zoznam vysielačov v sieti
Aktualizácia firmvéru
Pripojené k vysielaču
Pripojené k vysielaču (%s)
- Nepripojené, zvoľte si vysielač.
+ Nepripojené
Pripojené k uspatému vysielaču.
Aktualizovať na %s
Aplikácia je príliš stará
- Musíte aktualizovať aplikáciu na Google Play store (alebo z Github). Je príliš stará pre komunikáciu s touto verziou firmvéru vysielača. Viac informácií k tejto téme nájdete na Meshtastic wiki.
+ Musíte aktualizovať aplikáciu na Google Play store (alebo z Github). Je príliš stará pre komunikáciu s touto verziou firmvéru vysielača. Viac informácií k tejto téme nájdete na Meshtastic docs.
Žiaden (zakázať)
Nie, ďakujem
Pripomenúť neskôr
diff --git a/app/src/main/res/values-sl/strings.xml b/app/src/main/res/values-sl/strings.xml
index aaffb5498..9210ad913 100644
--- a/app/src/main/res/values-sl/strings.xml
+++ b/app/src/main/res/values-sl/strings.xml
@@ -46,12 +46,12 @@
Deliti
Prekinjeno
Naprava je v "spanju"
- Povezano: %s od %s je na mreži
+ Povezano: %1$s od %2$s je na mreži
Seznam vozlišč v omrežju
Posodobite vdelano programsko opremo
Povezana z radiem
Povezana z radiem (%s)
- Ni povezano. Izberite radio spodaj
+ Ni povezano
Povezan z radiem, vendar radio "spi"
Posodobi v %s
Aplikacija je prestara
diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml
index 811ecab89..e5376bf7f 100644
--- a/app/src/main/res/values-tr/strings.xml
+++ b/app/src/main/res/values-tr/strings.xml
@@ -48,12 +48,12 @@
Paylaş
Bağlantı sonlandı
Cihaz uyku durumunda
- Bağlandı: %s / %s online
+ Bağlandı: %1$s / %2$s online
Ağdaki node listesi
Yazılım güncelle
Radyoya bağlandı
(%s) telsizine bağlandı
- Bağlı değil, aşağıdan bir radyo seçiniz
+ Bağlı değil
Telsize bağlandı, ancak uyku durumunda
%s\'e güncelle
Uygulama çok eski
diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml
index 37137fb43..31692c05b 100644
--- a/app/src/main/res/values-zh/strings.xml
+++ b/app/src/main/res/values-zh/strings.xml
@@ -48,16 +48,16 @@
分享
断开连接
设备休眠中
- 连接: %s 中 %s 在线
+ 连接: %1$s 中 %2$s 在线
网络中节点列表
更新固件
连接设备
连接到设备(%s)
- 未连接,请选择下方的设备
+ 未连接
已连接到设备,正在休眠中
更新到%s
需要应用程序更新
- 您必须在 Google Play或Github上更新此应用程序.固件太旧,请阅读我们的 wiki 这个话题.
+ 您必须在 Google Play或Github上更新此应用程序.固件太旧,请阅读我们的 docs 这个话题.
无(禁用)
短距离(速度快)
中等距离(速度快)
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 8833a0bf9..25eb243cc 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -52,16 +52,16 @@
Share
Disconnected
Device sleeping
- Connected: %s of %s online
+ Connected: %1$s of %2$s online
A list of nodes in the network
Update Firmware
Connected to radio
Connected to radio (%s)
- Not connected, select radio below
+ Not connected
Connected to radio, but it is sleeping
Update to %s
Application update required
- You must update this application on the app store (or Github). It is too old to talk to this radio firmware. Please read our wiki on this topic.
+ You must update this application on the app store (or Github). It is too old to talk to this radio firmware. Please read our docs on this topic.
None (disable)
Short Range / Fast
Medium Range / Fast
@@ -120,7 +120,10 @@
We must be granted access to the camera to read QR codes. No pictures or videos will be saved.
Short Range / Slow
Medium Range / Slow
- Delete selected message?
+
+ - Delete message?
+ - Delete %s messages?
+
Delete
- Delete All Messages
+ Select all
\ No newline at end of file
diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml
index 664b688ac..54da37240 100644
--- a/app/src/main/res/values/styles.xml
+++ b/app/src/main/res/values/styles.xml
@@ -11,7 +11,8 @@
- true
- @style/menu_item_color
-
+ - @style/MyActionMode
+ - true
+
+