fix(android): request NEARBY_WIFI_DEVICES at runtime so mDNS discovery works on real devices
apple / swift (push) Successful in 53s
ci / web (push) Successful in 30s
ci / docs-site (push) Successful in 38s
android / android (push) Successful in 3m23s
deb / build-publish (push) Successful in 2m4s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m31s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m8s
docker / deploy-docs (push) Successful in 5s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 7m58s
ci / rust (push) Successful in 4m4s

NsdManager service discovery needs NEARBY_WIFI_DEVICES on Android 13+. The app DECLARED it but
never REQUESTED it, so on a real device the permission stayed denied and discoverServices silently
found nothing — no prompt, no hosts. (It only worked on the emulator because the permission was
granted via `adb pm grant`.) Request it (mirroring the mic RECORD_AUDIO flow) when the connect
screen appears, and start/restart discovery once granted; on API < 33 discovery starts immediately
(the permission doesn't apply there). The advertised hosts the Apple clients already see will then
appear here too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-16 16:14:57 +02:00
parent 0c1afeefea
commit 1bcb786382
@@ -1,6 +1,7 @@
package io.unom.punktfunk package io.unom.punktfunk
import android.Manifest import android.Manifest
import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
@@ -13,8 +14,10 @@ import android.view.WindowManager
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle import androidx.activity.SystemBarStyle
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.awaitFirstDown
@@ -244,11 +247,22 @@ private fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
// mDNS discovery scoped to this screen; NsdManager callbacks arrive on the main thread, so the // mDNS discovery scoped to this screen; NsdManager callbacks arrive on the main thread, so the
// onChange callback can set Compose state directly. (Emulator SLIRP drops multicast → empty.) // onChange callback can set Compose state directly. (Emulator SLIRP drops multicast → empty.)
// NsdManager discovery needs NEARBY_WIFI_DEVICES on Android 13+ (a runtime permission) — without
// it discoverServices silently finds nothing. Request it once, then (re)start discovery on grant.
val discovery = remember { HostDiscovery(context) } val discovery = remember { HostDiscovery(context) }
var discovered by remember { mutableStateOf<List<DiscoveredHost>>(emptyList()) } var discovered by remember { mutableStateOf<List<DiscoveredHost>>(emptyList()) }
DisposableEffect(Unit) { var nearbyGranted by remember { mutableStateOf(hasNearbyPermission(context)) }
val nearbyLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { granted -> nearbyGranted = granted }
LaunchedEffect(Unit) {
if (!nearbyGranted && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
nearbyLauncher.launch(Manifest.permission.NEARBY_WIFI_DEVICES)
}
}
DisposableEffect(nearbyGranted) {
discovery.onChange = { discovered = it } discovery.onChange = { discovered = it }
discovery.start() if (nearbyGranted) discovery.start()
onDispose { onDispose {
discovery.onChange = null discovery.onChange = null
discovery.stop() discovery.stop()
@@ -576,6 +590,12 @@ private fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
} }
} }
/** NsdManager discovery needs NEARBY_WIFI_DEVICES on API 33+; below that it doesn't apply. */
private fun hasNearbyPermission(context: Context): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(context, Manifest.permission.NEARBY_WIFI_DEVICES) ==
PackageManager.PERMISSION_GRANTED
/** Left-aligned section header above each block of the connect screen. */ /** Left-aligned section header above each block of the connect screen. */
@Composable @Composable
private fun SectionLabel(text: String) { private fun SectionLabel(text: String) {