feat(nsd): Add support for Android 14+ NSD resolving (#3731)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2025-11-18 12:12:52 -06:00 committed by GitHub
parent deedd00995
commit 7feab79da3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 124 additions and 62 deletions

View file

@ -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<List<NsdServiceInfo>> = discoverServices(serviceType).mapLatest { serviceList ->
serviceList
.mapNotNull { resolveService(it) }
}
internal fun NsdManager.serviceList(serviceType: String): Flow<List<NsdServiceInfo>> =
discoverServices(serviceType).mapLatest { serviceList -> serviceList.mapNotNull { resolveService(it) } }
private fun NsdManager.discoverServices(
serviceType: String,
protocolType: Int = NsdManager.PROTOCOL_DNS_SD,
): Flow<List<NsdServiceInfo>> = callbackFlow {
val serviceList = CopyOnWriteArrayList<NsdServiceInfo>()
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)
}

View file

@ -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")

View file

@ -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}")

View file

@ -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(

View file

@ -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(

View file

@ -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
}

View file

@ -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) {

View file

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