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 + +