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..b8734afd9 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
diff --git a/app/build.gradle b/app/build.gradle
index 9dcd3403e..90ca7c514 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 20260 // format is Mmmss (where M is 1+the numeric major number
+ versionName "1.2.60"
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"
diff --git a/app/src/main/aidl/com/geeksville/mesh/IMeshService.aidl b/app/src/main/aidl/com/geeksville/mesh/IMeshService.aidl
index 8b5010c86..8242c67be 100644
--- a/app/src/main/aidl/com/geeksville/mesh/IMeshService.aidl
+++ b/app/src/main/aidl/com/geeksville/mesh/IMeshService.aidl
@@ -66,8 +66,7 @@ interface IMeshService {
*/
void send(inout DataPacket packet);
-
- void delete(int position);
+ void deleteMessage(int packetId);
void deleteAllMessages();
diff --git a/app/src/main/java/com/geeksville/mesh/ApplicationModule.kt b/app/src/main/java/com/geeksville/mesh/ApplicationModule.kt
index 834bfea7d..b84d8b553 100644
--- a/app/src/main/java/com/geeksville/mesh/ApplicationModule.kt
+++ b/app/src/main/java/com/geeksville/mesh/ApplicationModule.kt
@@ -3,6 +3,9 @@ package com.geeksville.mesh
import android.app.Application
import android.content.Context
import android.content.SharedPreferences
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.ProcessLifecycleOwner
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@@ -15,4 +18,14 @@ object ApplicationModule {
fun provideSharedPreferences(application: Application): SharedPreferences {
return application.getSharedPreferences("ui-prefs", Context.MODE_PRIVATE)
}
+
+ @Provides
+ fun provideProcessLifecycleOwner(): LifecycleOwner {
+ return ProcessLifecycleOwner.get()
+ }
+
+ @Provides
+ fun provideProcessLifecycle(processLifecycleOwner: LifecycleOwner): Lifecycle {
+ return processLifecycleOwner.lifecycle
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/com/geeksville/mesh/CoroutineDispatchers.kt b/app/src/main/java/com/geeksville/mesh/CoroutineDispatchers.kt
new file mode 100644
index 000000000..9922b19eb
--- /dev/null
+++ b/app/src/main/java/com/geeksville/mesh/CoroutineDispatchers.kt
@@ -0,0 +1,15 @@
+package com.geeksville.mesh
+
+import kotlinx.coroutines.Dispatchers
+import javax.inject.Inject
+
+/**
+ * Wrapper around `Dispatchers` to allow for easier testing when using dispatchers
+ * in injected classes.
+ */
+class CoroutineDispatchers @Inject constructor() {
+ val main = Dispatchers.Main
+ val mainImmediate = Dispatchers.Main.immediate
+ val default = Dispatchers.Default
+ val io = Dispatchers.IO
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/geeksville/mesh/MainActivity.kt b/app/src/main/java/com/geeksville/mesh/MainActivity.kt
index 044dbf6f9..d7bf8c0dd 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
@@ -40,6 +39,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
@@ -134,11 +134,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)
@@ -187,28 +183,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 +355,7 @@ class MainActivity : AppCompatActivity(), Logging,
}
}
- updateBluetoothEnabled()
+ bluetoothViewModel.permissionsUpdated()
}
@@ -445,12 +419,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.
@@ -569,7 +537,6 @@ class MainActivity : AppCompatActivity(), Logging,
}
override fun onDestroy() {
- unregisterReceiver(btStateReceiver)
unregisterMeshReceiver()
mainScope.cancel("Activity going away")
super.onDestroy()
@@ -1003,17 +970,17 @@ 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()
+ }
}
try {
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..a678034fc 100644
--- a/app/src/main/java/com/geeksville/mesh/model/MessagesState.kt
+++ b/app/src/main/java/com/geeksville/mesh/model/MessagesState.kt
@@ -95,12 +95,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 +116,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..11e5daf5b 100644
--- a/app/src/main/java/com/geeksville/mesh/model/UIState.kt
+++ b/app/src/main/java/com/geeksville/mesh/model/UIState.kt
@@ -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()
}
@@ -229,10 +225,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 +235,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 +272,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/MeshService.kt b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt
index ec276708d..a28c52a9b 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt
@@ -1309,13 +1309,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 +1329,7 @@ class MeshService : Service(), Logging {
airUtilTx
)
}
-
newMyNodeInfo = mi
- setFirmwareUpdateFilename(mi)
}
}
@@ -1560,7 +1559,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 +1669,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 +1781,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/MessagesFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/MessagesFragment.kt
index a7c90157f..cf8edcf2f 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/MessagesFragment.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/MessagesFragment.kt
@@ -1,12 +1,10 @@
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
@@ -14,11 +12,11 @@ import android.widget.TextView
import androidx.cardview.widget.CardView
import androidx.core.content.ContextCompat
import androidx.fragment.app.activityViewModels
-import androidx.lifecycle.Observer
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 +24,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,7 +36,6 @@ fun EditText.on(actionId: Int, func: () -> Unit) {
if (actionId == receivedActionId) {
func()
}
-
true
}
}
@@ -45,6 +43,7 @@ fun EditText.on(actionId: Int, func: () -> Unit) {
@AndroidEntryPoint
class MessagesFragment : ScreenFragment("Messages"), Logging {
+ private var actionMode: ActionMode? = null
private var _binding: MessagesFragmentBinding? = null
// This property is only valid between onCreateView and onDestroyView.
@@ -53,7 +52,7 @@ class MessagesFragment : ScreenFragment("Messages"), Logging {
private val model: UIViewModel by activityViewModels()
// Allows textMultiline with IME_ACTION_SEND
- fun EditText.onActionSend(func: () -> Unit) {
+ private fun EditText.onActionSend(func: () -> Unit) {
setImeOptions(EditorInfo.IME_ACTION_SEND)
setRawInputType(InputType.TYPE_CLASS_TEXT)
setOnEditorActionListener { _, actionId, _ ->
@@ -61,7 +60,6 @@ class MessagesFragment : ScreenFragment("Messages"), Logging {
if (actionId == EditorInfo.IME_ACTION_SEND) {
func()
}
-
true
}
}
@@ -73,22 +71,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 +116,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 +123,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,7 +157,7 @@ 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"
@@ -167,48 +165,19 @@ class MessagesFragment : ScreenFragment("Messages"), Logging {
// 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) {
+ 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
@@ -217,11 +186,14 @@ class MessagesFragment : ScreenFragment("Messages"), Logging {
} 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 +215,114 @@ 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 -> {
+ // if all selected -> unselect all
+ if (selectedList.size == messages.size) {
+ selectedList.clear()
+ mode.finish()
+ } else {
+ // else --> select all
+ selectedList.clear()
+ selectedList.addAll(messages)
+ }
+ 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}"
+ }
+ notifyItemChanged(position)
+ }
/// Called when our node DB changes
fun onMessagesChanged(msgIn: Collection) {
@@ -258,10 +335,15 @@ 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
}
@@ -269,7 +351,7 @@ class MessagesFragment : ScreenFragment("Messages"), Logging {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.sendButton.setOnClickListener {
- debug("sendButton click")
+ debug("User clicked sendButton")
val str = binding.messageInputText.text.toString().trim()
if (str.isNotEmpty())
@@ -295,34 +377,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..83fd0561f 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt
@@ -24,6 +24,7 @@ import android.widget.*
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.AndroidViewModel
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 +34,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
@@ -447,6 +449,7 @@ 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
@@ -472,6 +475,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 +520,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 +532,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,7 +639,7 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
regionAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
spinner.adapter = regionAdapter
- model.bluetoothEnabled.observe(viewLifecycleOwner) {
+ bluetoothViewModel.enabled.observe(viewLifecycleOwner) {
if (it) binding.changeRadioButton.show()
else binding.changeRadioButton.hide()
}
@@ -813,7 +828,7 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
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)
}
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/adapter_message_layout.xml b/app/src/main/res/layout/adapter_message_layout.xml
index 9d9f5db77..25f104d45 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" />
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..23fb43959 100644
--- a/app/src/main/res/values-cs/strings.xml
+++ b/app/src/main/res/values-cs/strings.xml
@@ -48,7 +48,7 @@
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)
diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml
index 72f5ff439..31072a981 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..297425da4 100644
--- a/app/src/main/res/values-es/strings.xml
+++ b/app/src/main/res/values-es/strings.xml
@@ -45,7 +45,7 @@
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
diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml
index 98dc00114..14b590c53 100644
--- a/app/src/main/res/values-fr/strings.xml
+++ b/app/src/main/res/values-fr/strings.xml
@@ -49,7 +49,7 @@
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
diff --git a/app/src/main/res/values-ht/strings.xml b/app/src/main/res/values-ht/strings.xml
index 8c02da04e..9f1fa4f71 100644
--- a/app/src/main/res/values-ht/strings.xml
+++ b/app/src/main/res/values-ht/strings.xml
@@ -46,7 +46,7 @@
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
diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml
index 2b567d09c..ab0c66d30 100644
--- a/app/src/main/res/values-hu/strings.xml
+++ b/app/src/main/res/values-hu/strings.xml
@@ -47,7 +47,7 @@
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
diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml
index bc1d039f6..b38ebb5aa 100644
--- a/app/src/main/res/values-ja/strings.xml
+++ b/app/src/main/res/values-ja/strings.xml
@@ -48,7 +48,7 @@ mapboxの有償プラン(または代替地図プロバイダ)を検討さ
シェア
切断
スリープ
- 接続済み:%s人オンライン%s人中
+ 接続済み:%1$s人オンライン%2$s人中
ネットワーク内のノードリスト
ファームウェアアップデート
Meshtasticデバイスに接続しました。(%s)
diff --git a/app/src/main/res/values-ko-rKR/strings.xml b/app/src/main/res/values-ko-rKR/strings.xml
index b624cf4a3..cad8b33c9 100644
--- a/app/src/main/res/values-ko-rKR/strings.xml
+++ b/app/src/main/res/values-ko-rKR/strings.xml
@@ -46,7 +46,7 @@
공유
연결 해제
장치 잠자기
- 연결: %s 온라인( 전체 %s)
+ 연결: %1$s 온라인( 전체 %2$s)
네트워크안은 모든 노드의 목록
펌웨어 업데이트
라디오로 연결됨
diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml
index 3df9b39ed..fb4323827 100644
--- a/app/src/main/res/values-nl/strings.xml
+++ b/app/src/main/res/values-nl/strings.xml
@@ -48,7 +48,7 @@
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
diff --git a/app/src/main/res/values-no/strings.xml b/app/src/main/res/values-no/strings.xml
index e17082877..437e1d080 100644
--- a/app/src/main/res/values-no/strings.xml
+++ b/app/src/main/res/values-no/strings.xml
@@ -48,7 +48,7 @@
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
diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml
index d5e0e74c0..5b9bb650e 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
diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml
index 17b3cc324..b2544e4e2 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,7 +48,7 @@
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
@@ -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..5a60314ce 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,7 +47,7 @@
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
@@ -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..bd51f8773 100644
--- a/app/src/main/res/values-ro/strings.xml
+++ b/app/src/main/res/values-ro/strings.xml
@@ -48,7 +48,7 @@
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
diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml
index 43e8cc17e..cbeabe0a3 100644
--- a/app/src/main/res/values-sk/strings.xml
+++ b/app/src/main/res/values-sk/strings.xml
@@ -48,7 +48,7 @@
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
diff --git a/app/src/main/res/values-sl/strings.xml b/app/src/main/res/values-sl/strings.xml
index aaffb5498..a5fc7d495 100644
--- a/app/src/main/res/values-sl/strings.xml
+++ b/app/src/main/res/values-sl/strings.xml
@@ -46,7 +46,7 @@
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
diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml
index 811ecab89..3d2b68d3e 100644
--- a/app/src/main/res/values-tr/strings.xml
+++ b/app/src/main/res/values-tr/strings.xml
@@ -48,7 +48,7 @@
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ı
diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml
index 37137fb43..a456fb349 100644
--- a/app/src/main/res/values-zh/strings.xml
+++ b/app/src/main/res/values-zh/strings.xml
@@ -48,7 +48,7 @@
分享
断开连接
设备休眠中
- 连接: %s 中 %s 在线
+ 连接: %1$s 中 %2$s 在线
网络中节点列表
更新固件
连接设备
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 8833a0bf9..38c4573a5 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -52,7 +52,7 @@
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
@@ -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
+
+