From 7feab79da30e018679e63a52c311ae00b00597c7 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Tue, 18 Nov 2025 12:12:52 -0600 Subject: [PATCH] feat(nsd): Add support for Android 14+ NSD resolving (#3731) Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com> --- .../mesh/repository/network/NsdManager.kt | 123 ++++++++++++------ .../mesh/repository/radio/StreamInterface.kt | 2 +- .../geeksville/mesh/service/MeshService.kt | 9 -- .../main/java/com/geeksville/mesh/ui/Main.kt | 4 +- .../ui/connections/components/BLEDevices.kt | 36 ++++- .../geeksville/mesh/ui/contact/Contacts.kt | 6 +- .../com/geeksville/mesh/ui/sharing/Channel.kt | 4 +- .../mesh/ui/sharing/ChannelViewModel.kt | 2 +- 8 files changed, 124 insertions(+), 62 deletions(-) diff --git a/app/src/main/java/com/geeksville/mesh/repository/network/NsdManager.kt b/app/src/main/java/com/geeksville/mesh/repository/network/NsdManager.kt index 4c88bd046..47a47f7b8 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/network/NsdManager.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/network/NsdManager.kt @@ -17,57 +17,63 @@ package com.geeksville.mesh.repository.network +import android.annotation.SuppressLint import android.net.nsd.NsdManager import android.net.nsd.NsdServiceInfo +import android.os.Build +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.asExecutor import kotlinx.coroutines.cancel import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.suspendCancellableCoroutine +import timber.log.Timber import java.util.concurrent.CopyOnWriteArrayList import kotlin.coroutines.resume @OptIn(ExperimentalCoroutinesApi::class) -internal fun NsdManager.serviceList( - serviceType: String, -): Flow> = discoverServices(serviceType).mapLatest { serviceList -> - serviceList - .mapNotNull { resolveService(it) } -} +internal fun NsdManager.serviceList(serviceType: String): Flow> = + discoverServices(serviceType).mapLatest { serviceList -> serviceList.mapNotNull { resolveService(it) } } private fun NsdManager.discoverServices( serviceType: String, protocolType: Int = NsdManager.PROTOCOL_DNS_SD, ): Flow> = callbackFlow { val serviceList = CopyOnWriteArrayList() - val discoveryListener = object : NsdManager.DiscoveryListener { - override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) { - cancel("Start Discovery failed: Error code: $errorCode") - } + val discoveryListener = + object : NsdManager.DiscoveryListener { + override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) { + cancel("Start Discovery failed: Error code: $errorCode") + } - override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) { - cancel("Stop Discovery failed: Error code: $errorCode") - } + override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) { + cancel("Stop Discovery failed: Error code: $errorCode") + } - override fun onDiscoveryStarted(serviceType: String) { - } + override fun onDiscoveryStarted(serviceType: String) { + Timber.d("NSD Service discovery started") + } - override fun onDiscoveryStopped(serviceType: String) { - close() - } + override fun onDiscoveryStopped(serviceType: String) { + Timber.d("NSD Service discovery stopped") + close() + } - override fun onServiceFound(serviceInfo: NsdServiceInfo) { - serviceList += serviceInfo - trySend(serviceList) - } + override fun onServiceFound(serviceInfo: NsdServiceInfo) { + Timber.d("NSD Service found: $serviceInfo") + serviceList += serviceInfo + trySend(serviceList) + } - override fun onServiceLost(serviceInfo: NsdServiceInfo) { - serviceList.removeAll { it.serviceName == serviceInfo.serviceName } - trySend(serviceList) + override fun onServiceLost(serviceInfo: NsdServiceInfo) { + Timber.d("NSD Service lost: $serviceInfo") + serviceList.removeAll { it.serviceName == serviceInfo.serviceName } + trySend(serviceList) + } } - } trySend(emptyList()) // Emit an initial empty list discoverServices(serviceType, protocolType, discoveryListener) @@ -80,17 +86,60 @@ private fun NsdManager.discoverServices( } } -private suspend fun NsdManager.resolveService( - serviceInfo: NsdServiceInfo, -): NsdServiceInfo? = suspendCancellableCoroutine { continuation -> - val listener = object : NsdManager.ResolveListener { - override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) { - continuation.resume(null) - } +@SuppressLint("NewApi") +private suspend fun NsdManager.resolveService(serviceInfo: NsdServiceInfo): NsdServiceInfo? = + suspendCancellableCoroutine { continuation -> + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + val callback = + object : NsdManager.ServiceInfoCallback { + override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) { + continuation.resume(null) + } - override fun onServiceResolved(serviceInfo: NsdServiceInfo) { - continuation.resume(serviceInfo) + override fun onServiceUpdated(updatedServiceInfo: NsdServiceInfo) { + if (updatedServiceInfo.hostAddresses.isNotEmpty()) { + continuation.resume(updatedServiceInfo) + try { + unregisterServiceInfoCallback(this) + } catch (e: IllegalArgumentException) { + Timber.w(e, "Already unregistered") + } + } + } + + override fun onServiceLost() { + continuation.resume(null) + try { + unregisterServiceInfoCallback(this) + } catch (e: IllegalArgumentException) { + Timber.w(e, "Already unregistered") + } + } + + override fun onServiceInfoCallbackUnregistered() { + // No op + } + } + registerServiceInfoCallback(serviceInfo, Dispatchers.Main.asExecutor(), callback) + continuation.invokeOnCancellation { + try { + unregisterServiceInfoCallback(callback) + } catch (e: IllegalArgumentException) { + Timber.w(e, "Already unregistered") + } + } + } else { + val listener = + object : NsdManager.ResolveListener { + override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) { + continuation.resume(null) + } + + override fun onServiceResolved(serviceInfo: NsdServiceInfo) { + continuation.resume(serviceInfo) + } + } + @Suppress("DEPRECATION") + resolveService(serviceInfo, listener) } } - resolveService(serviceInfo, listener) -} diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/StreamInterface.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/StreamInterface.kt index 1134c6c02..a7e712e63 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/radio/StreamInterface.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/radio/StreamInterface.kt @@ -87,7 +87,7 @@ abstract class StreamInterface(protected val service: RadioInterfaceService) : I /** Print device serial debug output somewhere */ private fun debugOut(b: Byte) { - when (val c = b.toChar()) { + when (val c = b.toInt().toChar()) { '\r' -> {} // ignore '\n' -> { Timber.d("DeviceLog: $debugLineBuf") 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 e26980e92..6fa5eb2df 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt @@ -2242,15 +2242,6 @@ class MeshService : Service() { rememberReaction(packet.copy { from = myNodeNum }) } - fun clearDatabases() = serviceScope.handledLaunch { - Timber.d("Clearing nodeDB") - discardNodeDB() - nodeRepository.clearNodeDB() - - Timber.d("Clearing packetDB") - packetRepository.get().clearPacketDB() - } - private fun updateLastAddress(deviceAddr: String?) { val currentAddr = meshPrefs.deviceAddress Timber.d("setDeviceAddress: received request to change to: ${deviceAddr.anonymize}") diff --git a/app/src/main/java/com/geeksville/mesh/ui/Main.kt b/app/src/main/java/com/geeksville/mesh/ui/Main.kt index b6a2196dc..5ecf182c1 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/Main.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/Main.kt @@ -47,6 +47,7 @@ import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.material3.PlainTooltip import androidx.compose.material3.Text +import androidx.compose.material3.TooltipAnchorPosition import androidx.compose.material3.TooltipBox import androidx.compose.material3.TooltipDefaults import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo @@ -292,7 +293,8 @@ fun MainScreen(uIViewModel: UIViewModel = hiltViewModel(), scanModel: BTScanMode item( icon = { TooltipBox( - positionProvider = TooltipDefaults.rememberTooltipPositionProvider(), + positionProvider = + TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above), tooltip = { PlainTooltip { Text( diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/components/BLEDevices.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/components/BLEDevices.kt index 4e82cdd0c..6d9adf3a7 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/connections/components/BLEDevices.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/connections/components/BLEDevices.kt @@ -52,6 +52,7 @@ import com.geeksville.mesh.model.DeviceListEntry import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.MultiplePermissionsState import com.google.accompanist.permissions.rememberMultiplePermissionsState +import com.google.accompanist.permissions.rememberPermissionState import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.service.ConnectionState @@ -98,19 +99,42 @@ fun BLEDevices( if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { listOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT) } else { - listOf(Manifest.permission.ACCESS_FINE_LOCATION) + listOf( + Manifest.permission.BLUETOOTH, + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACCESS_COARSE_LOCATION, + ) } } val context = LocalContext.current - val permsMissing = stringResource(Res.string.permission_missing) + val permsMissing = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + stringResource(Res.string.permission_missing_31) + } else { + stringResource(Res.string.permission_missing) + } val coroutineScope = rememberCoroutineScope() + val singlePermissionState = + rememberPermissionState( + permission = Manifest.permission.ACCESS_BACKGROUND_LOCATION, + onPermissionResult = { granted -> + scanModel.refreshPermissions() + scanModel.startScan() + }, + ) + val permissionsState = rememberMultiplePermissionsState( permissions = bluetoothPermissionsList, - onPermissionsResult = { - if (it.values.all { granted -> granted } && bluetoothEnabled) { + onPermissionsResult = { permissions -> + val granted = permissions.values.all { it } + if (permissions.getOrDefault(Manifest.permission.ACCESS_COARSE_LOCATION, false)) { + coroutineScope.launch { context.showToast(permsMissing) } + singlePermissionState.launchPermissionRequest() + } + if (granted) { scanModel.refreshPermissions() scanModel.startScan() } else { @@ -121,8 +145,8 @@ fun BLEDevices( val settingsLauncher = rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) { - // Eventually auto scan once bluetooth is available - // checkPermissionsAndScan(permissionsState, scanModel, bluetoothEnabled) + scanModel.refreshPermissions() + scanModel.startScan() } Column( diff --git a/app/src/main/java/com/geeksville/mesh/ui/contact/Contacts.kt b/app/src/main/java/com/geeksville/mesh/ui/contact/Contacts.kt index 8fc4a5b6b..6801b46ea 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/contact/Contacts.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/contact/Contacts.kt @@ -155,11 +155,7 @@ fun ContactsScreen( // if it's a node, look up the nodeNum including the ! val nodeKey = contact.contactKey.substring(1) val node = viewModel.getNode(nodeKey) - - if (node != null) { - // navigate to node details. - onNavigateToNodeDetails(node.num) - } + onNavigateToNodeDetails(node.num) } else { // Channels } diff --git a/app/src/main/java/com/geeksville/mesh/ui/sharing/Channel.kt b/app/src/main/java/com/geeksville/mesh/ui/sharing/Channel.kt index 094a3e4ea..79e070200 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/sharing/Channel.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/sharing/Channel.kt @@ -363,12 +363,12 @@ fun ChannelScreen( item { PreferenceFooter( enabled = enabled, - negativeText = Res.string.reset, + negativeText = stringResource(Res.string.reset), onNegativeClicked = { focusManager.clearFocus() showResetDialog = true }, - positiveText = Res.string.scan, + positiveText = stringResource(Res.string.scan), onPositiveClicked = { focusManager.clearFocus() if (cameraPermissionState.status.isGranted) { diff --git a/app/src/main/java/com/geeksville/mesh/ui/sharing/ChannelViewModel.kt b/app/src/main/java/com/geeksville/mesh/ui/sharing/ChannelViewModel.kt index 382244825..a7a7a76c7 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/sharing/ChannelViewModel.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/sharing/ChannelViewModel.kt @@ -60,7 +60,7 @@ constructor( // managed mode disables all access to configuration val isManaged: Boolean - get() = localConfig.value.device.isManaged || localConfig.value.security.isManaged + get() = localConfig.value.security.isManaged var txEnabled: Boolean get() = localConfig.value.lora.txEnabled