Skip to content

Commit 901cff4

Browse files
jamesarichCopilot
andcommitted
fix(permissions): proactively request ACCESS_LOCAL_NETWORK to suppress NSD picker
On Android 17 (API 37) with targetSdk=37, the platform falls back to the system 'Choose a device to connect' picker on every NsdManager.discoverServices() call when ACCESS_LOCAL_NETWORK isn't granted. Removing the manifest entry in the previous commit only suppressed the runtime grant flavor of the prompt — the picker still fired as the platform's mediated fallback. This restores the manifest declaration and wires a runtime permission request into ConnectionsScreen so users get a proper one-time grant prompt instead of a per-scan picker. Because ACCESS_LOCAL_NETWORK is in the NEARBY_DEVICES permission group, users who have already granted Bluetooth permissions are silently auto-granted with no UI shown. - Re-add ACCESS_LOCAL_NETWORK to AndroidManifest.xml with comment explaining the Android 17 LNP enforcement and dual TAK/NSD use case. - Add isLocalNetworkPermissionGranted() expect/actual helper to core/ui. - Gate ConnectionsScreen network auto-start on the runtime grant so a previously-persisted networkAutoScan=true does not surface the picker for users who haven't granted the permission. - Manual scan toggle now requests the permission before starting and persists the user's intent so the launcher's onGranted callback can start the scan. - Add ScannerViewModel.persistNetworkAutoScanIntent for the deferred-start path. - Update TakPermissionUtil comment — pre-existing comment claimed the permission was 'NOT required for NSD', which was true on Android 16 but false on Android 17 with targetSdk=37. Verified on Pixel 6a / Android 17 / API 37: NSD discovery now runs without the picker, and the permission is silently auto-granted via the NEARBY_DEVICES group when Bluetooth perms are already held. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent be7c008 commit 901cff4

8 files changed

Lines changed: 77 additions & 9 deletions

File tree

app/src/main/AndroidManifest.xml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,15 @@
5252

5353
<uses-permission android:name="android.permission.WAKE_LOCK" />
5454

55+
<!--
56+
Android 17 (API 37) Local Network Protection: targetSdk=37 apps are blocked
57+
from local-network access by default. Required for both NSD/mDNS device
58+
discovery on the Connections screen and the built-in TAK Server's localhost
59+
loopback binding. Requested at runtime via rememberRequestLocalNetworkPermission.
60+
See: https://developer.android.com/privacy-and-security/local-network-permission
61+
-->
62+
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
63+
5564
<!--
5665
This permission is optional but recommended so we can be smart
5766
about when to send data.

core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,21 @@ actual fun rememberRequestLocalNetworkPermission(onGranted: () -> Unit, onDenied
271271
return remember(launcher) { { launcher.launch(android.Manifest.permission.ACCESS_LOCAL_NETWORK) } }
272272
}
273273

274+
@Composable
275+
actual fun isLocalNetworkPermissionGranted(): Boolean {
276+
if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.S) {
277+
// Pre-Android 12, no runtime local-network permission exists; access is implicit via INTERNET.
278+
return true
279+
}
280+
val context = LocalContext.current
281+
return rememberOnResumeState {
282+
androidx.core.content.ContextCompat.checkSelfPermission(
283+
context,
284+
android.Manifest.permission.ACCESS_LOCAL_NETWORK,
285+
) == android.content.pm.PackageManager.PERMISSION_GRANTED
286+
}
287+
}
288+
274289
@Composable
275290
actual fun isLocationPermissionGranted(): Boolean {
276291
val context = LocalContext.current

core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,12 @@ expect fun rememberSaveFileLauncher(
7171
@Composable
7272
expect fun rememberRequestLocalNetworkPermission(onGranted: () -> Unit, onDenied: () -> Unit = {}): () -> Unit
7373

74+
/**
75+
* Returns whether ACCESS_LOCAL_NETWORK is currently granted. Always `true` on platforms / API levels that don't gate
76+
* local-network access behind a runtime permission.
77+
*/
78+
@Composable expect fun isLocalNetworkPermissionGranted(): Boolean
79+
7480
/** Returns a launcher to request the POST_NOTIFICATIONS permission. No-op on platforms that don't require it. */
7581
@Composable
7682
expect fun rememberRequestNotificationPermission(onGranted: () -> Unit, onDenied: () -> Unit = {}): () -> Unit

core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/NoopStubs.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ actual fun rememberOpenFileLauncher(onUriReceived: (CommonUri?) -> Unit): (mimeT
6161
@Composable
6262
actual fun rememberRequestLocalNetworkPermission(onGranted: () -> Unit, onDenied: () -> Unit): () -> Unit = {}
6363

64+
@Composable actual fun isLocalNetworkPermissionGranted(): Boolean = true
65+
6466
@Composable
6567
actual fun rememberRequestNotificationPermission(onGranted: () -> Unit, onDenied: () -> Unit): () -> Unit = {}
6668

core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,9 @@ actual fun rememberRequestLocalNetworkPermission(onGranted: () -> Unit, onDenied
140140
onGranted()
141141
}
142142

143+
/** JVM — local network permission is always considered granted on Desktop. */
144+
@Composable actual fun isLocalNetworkPermissionGranted(): Boolean = true
145+
143146
/** JVM no-op — Desktop does not require runtime notification permissions. */
144147
@Composable
145148
actual fun rememberRequestNotificationPermission(onGranted: () -> Unit, onDenied: () -> Unit): () -> Unit = {

feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,15 @@ open class ScannerViewModel(
265265
uiPrefs.setNetworkAutoScan(_isNetworkScanning.value)
266266
}
267267

268+
/**
269+
* Persist the user's intent to auto-scan the network on next screen entry without flipping the active scan flag.
270+
* Used by the Connections screen when it must defer the actual scan start until after the system permission grant
271+
* dialog resolves — the persisted intent ensures auto-start fires once permission is granted.
272+
*/
273+
fun persistNetworkAutoScanIntent(enabled: Boolean) {
274+
uiPrefs.setNetworkAutoScan(enabled)
275+
}
276+
268277
// ── Device selection / disconnect ───────────────────────────────────────────────────────
269278

270279
fun changeDeviceAddress(address: String) {

feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ import org.meshtastic.core.ui.component.MainAppBar
6464
import org.meshtastic.core.ui.icon.Language
6565
import org.meshtastic.core.ui.icon.MeshtasticIcons
6666
import org.meshtastic.core.ui.icon.NoDevice
67+
import org.meshtastic.core.ui.util.isLocalNetworkPermissionGranted
68+
import org.meshtastic.core.ui.util.rememberRequestLocalNetworkPermission
6769
import org.meshtastic.core.ui.viewmodel.ConnectionStatus
6870
import org.meshtastic.core.ui.viewmodel.ConnectionsViewModel
6971
import org.meshtastic.feature.connections.MOCK_DEVICE_PREFIX
@@ -118,12 +120,23 @@ fun ConnectionsScreen(
118120

119121
val bleAutoScan by scanModel.bleAutoScan.collectAsStateWithLifecycle()
120122
val networkAutoScan by scanModel.networkAutoScan.collectAsStateWithLifecycle()
123+
val localNetworkPermissionGranted = isLocalNetworkPermissionGranted()
124+
125+
// Android 17 (API 37) gates NSD/mDNS behind ACCESS_LOCAL_NETWORK. Without this prompt the platform
126+
// falls back to the system "Choose a device to connect" picker on every discoverServices() call.
127+
// Granting the permission upfront lets discovery run silently in-app.
128+
val requestLocalNetworkPermission =
129+
rememberRequestLocalNetworkPermission(
130+
onGranted = { scanModel.startNetworkScan() },
131+
onDenied = { scanModel.stopNetworkScan() },
132+
)
121133

122134
// Auto-start scans on screen entry when the user has previously opted in via the toggle. Stop on exit so we don't
123-
// drain battery in the background.
124-
DisposableEffect(Unit) {
135+
// drain battery in the background. Network auto-start is additionally gated on the runtime local-network grant so
136+
// we don't trigger the system picker for users who declined the permission.
137+
DisposableEffect(localNetworkPermissionGranted) {
125138
if (bleAutoScan) scanModel.startBleScan()
126-
if (networkAutoScan) scanModel.startNetworkScan()
139+
if (networkAutoScan && localNetworkPermissionGranted) scanModel.startNetworkScan()
127140
onDispose {
128141
scanModel.stopBleScan()
129142
scanModel.stopNetworkScan()
@@ -263,7 +276,18 @@ fun ConnectionsScreen(
263276
isNetworkScanning = isNetworkScanning,
264277
onSelectDevice = { scanModel.onSelected(it) },
265278
onToggleBleScan = { scanModel.toggleBleScan() },
266-
onToggleNetworkScan = { scanModel.toggleNetworkScan() },
279+
onToggleNetworkScan = {
280+
if (isNetworkScanning || localNetworkPermissionGranted) {
281+
scanModel.toggleNetworkScan()
282+
} else {
283+
// Prefer requesting the runtime grant over letting the platform fall
284+
// back to the system NSD picker. Persist the user's intent so that if
285+
// they grant after the prompt, the scan starts via the launcher's
286+
// onGranted callback and stays on for next session.
287+
scanModel.persistNetworkAutoScanIntent(true)
288+
requestLocalNetworkPermission()
289+
}
290+
},
267291
onAddManualAddress = { _, fullAddress ->
268292
val displayAddress = fullAddress.removePrefix(TCP_DEVICE_PREFIX)
269293
scanModel.addRecentAddress(fullAddress, displayAddress)

feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/TakPermissionUtil.kt

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,11 @@ import org.meshtastic.core.ui.util.rememberRequestLocalNetworkPermission
2121

2222
@Composable
2323
actual fun TakPermissionHandler(isTakServerEnabled: Boolean, onPermissionResult: (Boolean) -> Unit) {
24-
// ACCESS_LOCAL_NETWORK permission (Android 12+) is only required for TAK Server's localhost
25-
// socket binding (127.0.0.1:8087). It is NOT required for NSD mDNS service discovery
26-
// (which uses CHANGE_WIFI_MULTICAST_STATE). By gating the permission request to only when
27-
// TAK Server is explicitly enabled, casual users avoid the system dialog and the app works
28-
// without the permission if TAK is disabled.
24+
// ACCESS_LOCAL_NETWORK runtime permission (Android 17 / API 37+) is required for the TAK Server's
25+
// localhost socket binding (127.0.0.1:8087). It is also required globally for NSD/mDNS device discovery
26+
// when targetSdk >= 37, and is requested up-front from the Connections screen, so it will usually
27+
// already be granted by the time the user enables TAK. This composable handles the standalone case
28+
// (e.g. user opens TAK settings before ever tapping the network-scan toggle).
2929
val requestPermission =
3030
rememberRequestLocalNetworkPermission(
3131
onGranted = { onPermissionResult(true) },

0 commit comments

Comments
 (0)