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