Skip to content

Commit 52cded0

Browse files
authored
Request ACCESS_LOCAL_NETWORK when needed (#6866)
1 parent 2a11b41 commit 52cded0

15 files changed

Lines changed: 277 additions & 6 deletions

File tree

app/src/main/AndroidManifest.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
88
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
9+
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
910
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
1011
<uses-permission android:name="android.permission.BLUETOOTH"
1112
android:maxSdkVersion="30"/>

app/src/main/kotlin/io/homeassistant/companion/android/frontend/FrontendViewModel.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -511,6 +511,7 @@ internal class FrontendViewModel @VisibleForTesting constructor(
511511
private fun loadServer() {
512512
urlFlowJob?.cancel()
513513
urlFlowJob = viewModelScope.launch {
514+
permissionManager.checkLocalNetworkPermission()
514515
val currentState = _viewState.value
515516
val path = when (currentState) {
516517
is FrontendViewState.LoadServer -> currentState.path

app/src/main/kotlin/io/homeassistant/companion/android/frontend/permissions/PermissionManager.kt

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package io.homeassistant.companion.android.frontend.permissions
22

3+
import android.Manifest
34
import android.annotation.SuppressLint
45
import android.os.Build
56
import android.webkit.PermissionRequest as WebViewPermissionRequest
@@ -102,6 +103,29 @@ internal class PermissionManager @VisibleForTesting constructor(
102103
}
103104
}
104105

106+
/**
107+
* Ensures the app has Android 17+'s local network access permission so the WebView and
108+
* websocket layers can reach a Home Assistant instance over the LAN.
109+
*
110+
* Returns `true` immediately on pre-API 37 devices (local network access is implicit) and when
111+
* the permission is already granted. Otherwise enqueues a [PermissionRequest.LocalNetwork],
112+
* suspends until the user responds, and returns whether they granted it. Callers should still
113+
* attempt the URL load on denial — the cloud fallback or non-LAN setups may continue to work.
114+
*
115+
* @return `true` if local network access is available (granted or not required); `false` if the
116+
* user declined the permission
117+
*/
118+
@SuppressLint("NewApi")
119+
suspend fun checkLocalNetworkPermission(): Boolean {
120+
if (sdkInt < Build.VERSION_CODES.CINNAMON_BUN) return true
121+
if (permissionChecker.hasPermission(Manifest.permission.ACCESS_LOCAL_NETWORK)) return true
122+
123+
Timber.d("Local network permission required, awaiting user response")
124+
return queue.awaitResult { onResult ->
125+
PermissionRequest.LocalNetwork(onResult = onResult)
126+
}
127+
}
128+
105129
/**
106130
* Ensures the app has permission to write to external storage for a download.
107131
*

app/src/main/kotlin/io/homeassistant/companion/android/frontend/permissions/PermissionRequest.kt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,19 @@ internal sealed interface PermissionRequest {
4848
class ExternalStorage(override val onResult: (Boolean) -> Unit) :
4949
SinglePermission(permission = Manifest.permission.WRITE_EXTERNAL_STORAGE)
5050

51+
/**
52+
* A request for the Android 17+ local network access permission
53+
* ([Manifest.permission.ACCESS_LOCAL_NETWORK]).
54+
*
55+
* Required so the WebView and websocket layers can reach a Home Assistant instance over the
56+
* LAN once API 37 enforcement is active. The permission shares the `NEARBY_DEVICES` group
57+
* with Bluetooth, so users who have already granted any other permission in that group
58+
* (e.g. `BLUETOOTH_SCAN`) will see the request auto-grant without UI.
59+
*/
60+
@RequiresApi(Build.VERSION_CODES.CINNAMON_BUN)
61+
class LocalNetwork(override val onResult: (Boolean) -> Unit) :
62+
SinglePermission(permission = Manifest.permission.ACCESS_LOCAL_NETWORK)
63+
5164
/**
5265
* A notification permission request rendered as a bottom sheet (with an explicit allow/deny)
5366
* before the system dialog, plus a separate dismiss path when the user closes the sheet without

app/src/main/kotlin/io/homeassistant/companion/android/onboarding/OnboardingNavigation.kt

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -249,19 +249,38 @@ private fun NavGraphBuilder.commonScreens(
249249
if (navController.canGoBack()) {
250250
navController.popBackStack()
251251
} else {
252-
// This should only happens when we open the onboarding from the settings.
252+
// This should only happen when we open the onboarding from the settings.
253253
// Once we have a navigation graph for the whole app this could be dropped.
254254
// For more context see: https://github.com/home-assistant/android/pull/5897#pullrequestreview-3316313923
255255
(navController.context as? Activity)?.finish()
256256
}
257257
},
258258
onManualSetupClick = navController::navigateToManualServer,
259+
onLocalNetworkPermissionDenied = {
260+
// Replace ServerDiscovery on the back stack so that pressing back from ManualServer
261+
// returns to the entry before discovery (e.g. Welcome) instead of bouncing back
262+
// through the denied-permission redirect.
263+
navController.navigateToManualServer(
264+
navOptions = navOptions {
265+
popUpTo<ServerDiscoveryRoute> { inclusive = true }
266+
},
267+
)
268+
},
259269
onHelpClick = {
260270
navController.navigateToUri(URL_GETTING_STARTED_DOCUMENTATION, onShowSnackbar)
261271
},
262272
)
263273
manualServerScreen(
264-
onBackClick = navController::popBackStack,
274+
onBackClick = {
275+
if (navController.canGoBack()) {
276+
navController.popBackStack()
277+
} else {
278+
// This should only happen when we open the onboarding from the settings with local network permission rejected.
279+
// Once we have a navigation graph for the whole app this could be dropped.
280+
// For more context see: https://github.com/home-assistant/android/pull/5897#pullrequestreview-3316313923
281+
(navController.context as? Activity)?.finish()
282+
}
283+
},
265284
onConnectTo = {
266285
navController.navigateToConnection(it.toString())
267286
},
@@ -336,6 +355,7 @@ private fun NavController.navigateAfterDeviceRegistration(
336355
hasPlainTextAccess = hasPlainTextAccess,
337356
navOptions = navOptions,
338357
)
358+
339359
else -> navigateToLocationForSecureConnectionConditionally(
340360
serverId = serverId,
341361
hasPlainTextAccess = hasPlainTextAccess,

app/src/main/kotlin/io/homeassistant/companion/android/onboarding/serverdiscovery/ServerDiscoveryScreen.kt

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package io.homeassistant.companion.android.onboarding.serverdiscovery
22

3+
import android.Manifest
4+
import android.os.Build
35
import androidx.annotation.VisibleForTesting
46
import androidx.compose.animation.core.FastOutSlowInEasing
57
import androidx.compose.animation.core.LinearEasing
@@ -45,6 +47,7 @@ import androidx.compose.material3.Scaffold
4547
import androidx.compose.material3.Text
4648
import androidx.compose.material3.ripple
4749
import androidx.compose.runtime.Composable
50+
import androidx.compose.runtime.LaunchedEffect
4851
import androidx.compose.runtime.getValue
4952
import androidx.compose.runtime.mutableStateOf
5053
import androidx.compose.runtime.remember
@@ -65,6 +68,9 @@ import androidx.compose.ui.semantics.semantics
6568
import androidx.compose.ui.text.style.TextAlign
6669
import androidx.compose.ui.unit.dp
6770
import androidx.lifecycle.compose.collectAsStateWithLifecycle
71+
import com.google.accompanist.permissions.ExperimentalPermissionsApi
72+
import com.google.accompanist.permissions.isGranted
73+
import com.google.accompanist.permissions.rememberPermissionState
6874
import io.homeassistant.companion.android.R
6975
import io.homeassistant.companion.android.common.R as commonR
7076
import io.homeassistant.companion.android.common.compose.composable.HAAccentButton
@@ -97,10 +103,20 @@ internal fun ServerDiscoveryScreen(
97103
onConnectClick: (server: URL) -> Unit,
98104
onHelpClick: suspend () -> Unit,
99105
onManualSetupClick: () -> Unit,
106+
onLocalNetworkPermissionDenied: () -> Unit,
100107
viewModel: ServerDiscoveryViewModel,
101108
modifier: Modifier = Modifier,
102109
) {
110+
val permissionGranted = rememberLocalNetworkPermissionGranted(
111+
onDenied = onLocalNetworkPermissionDenied,
112+
)
113+
114+
// Until the permission is granted, render nothing so the user doesn't briefly see the
115+
// discovery UI before the denial-handler navigates away or the dialog dismisses.
116+
if (!permissionGranted) return
117+
103118
val discoveryState by viewModel.discoveryFlow.collectAsStateWithLifecycle(Started)
119+
LaunchedEffect(Unit) { viewModel.onLocalNetworkPermissionChecked() }
104120

105121
ServerDiscoveryScreen(
106122
discoveryState = discoveryState,
@@ -113,6 +129,32 @@ internal fun ServerDiscoveryScreen(
113129
)
114130
}
115131

132+
/**
133+
* Manages the Android 17+ `ACCESS_LOCAL_NETWORK` permission for the discovery screen.
134+
*
135+
* Returns `true` once the screen can render LAN-dependent UI safely — either the permission is
136+
* not required on this SDK, was already granted on entry, or has just been granted via the
137+
* system dialog. On the first composition where it is not yet granted, the system dialog is
138+
* launched. If the user denies, [onDenied] fires and the returned value stays `false` (the
139+
* caller is expected to navigate away). Discovery cannot succeed without the permission, so the
140+
* screen redirects to manual setup rather than leaving the user on an empty discovery list that
141+
* will eventually time out.
142+
*/
143+
@OptIn(ExperimentalPermissionsApi::class)
144+
@Composable
145+
private fun rememberLocalNetworkPermissionGranted(onDenied: () -> Unit): Boolean {
146+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.CINNAMON_BUN) return true
147+
148+
val permissionState = rememberPermissionState(Manifest.permission.ACCESS_LOCAL_NETWORK) { granted ->
149+
if (!granted) onDenied()
150+
}
151+
val granted = permissionState.status.isGranted
152+
LaunchedEffect(Unit) {
153+
if (!granted) permissionState.launchPermissionRequest()
154+
}
155+
return granted
156+
}
157+
116158
@Composable
117159
internal fun ServerDiscoveryScreen(
118160
discoveryState: DiscoveryState,

app/src/main/kotlin/io/homeassistant/companion/android/onboarding/serverdiscovery/ServerDiscoveryViewModel.kt

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,9 +92,25 @@ internal class ServerDiscoveryViewModel @VisibleForTesting constructor(
9292
*/
9393
val discoveryFlow = _discoveryFlow.delayFirstThrottle(DELAY_BEFORE_DISPLAY_DISCOVERY)
9494

95-
init {
96-
discoverInstances()
95+
/**
96+
* Guards [onLocalNetworkPermissionChecked] against starting discovery more than once across
97+
* recompositions or repeated permission-result callbacks from the screen.
98+
*/
99+
private var discoveryStarted = false
97100

101+
/**
102+
* Notifies the ViewModel that the screen has finished the Android 17+ local network
103+
* permission flow (either granted, denied, or skipped on pre-API 37 devices) and discovery
104+
* can now begin. Subsequent calls are no-ops.
105+
*
106+
* Discovery is gated on this signal because NSD can't reach LAN-served Home Assistant
107+
* instances on Android 17+ without `ACCESS_LOCAL_NETWORK`; starting earlier would risk a
108+
* silent failure followed by a [NoServerFound] timeout that hides the real cause.
109+
*/
110+
fun onLocalNetworkPermissionChecked() {
111+
if (discoveryStarted) return
112+
discoveryStarted = true
113+
discoverInstances()
98114
watchForNoServerFound()
99115
}
100116

app/src/main/kotlin/io/homeassistant/companion/android/onboarding/serverdiscovery/navigation/ServerDiscoveryNavigation.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,13 +40,15 @@ internal fun NavGraphBuilder.serverDiscoveryScreen(
4040
onBackClick: () -> Unit,
4141
onHelpClick: suspend () -> Unit,
4242
onManualSetupClick: () -> Unit,
43+
onLocalNetworkPermissionDenied: () -> Unit,
4344
) {
4445
composable<ServerDiscoveryRoute> {
4546
ServerDiscoveryScreen(
4647
onConnectClick = onConnectClick,
4748
onBackClick = onBackClick,
4849
onHelpClick = onHelpClick,
4950
onManualSetupClick = onManualSetupClick,
51+
onLocalNetworkPermissionDenied = onLocalNetworkPermissionDenied,
5052
viewModel = hiltViewModel(),
5153
)
5254
}

app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewActivity.kt

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ import androidx.compose.ui.unit.DpSize
6565
import androidx.compose.ui.unit.LayoutDirection
6666
import androidx.compose.ui.unit.dp
6767
import androidx.core.app.ActivityCompat
68+
import androidx.core.content.ContextCompat
6869
import androidx.core.content.getSystemService
6970
import androidx.core.content.res.ResourcesCompat
7071
import androidx.core.graphics.ColorUtils
@@ -213,6 +214,19 @@ class WebViewActivity :
213214
presenter.startScanningForImprov()
214215
}
215216
}
217+
218+
/**
219+
* Android 17+ requires `ACCESS_LOCAL_NETWORK` for any LAN traffic. Once the user grants it
220+
* after the WebView has already attempted to load, reload so the in-flight or failed-LAN
221+
* connection is retried with permission. Denial is left to surface through the existing
222+
* connection-error UI.
223+
*/
224+
private val requestLocalNetworkPermission =
225+
registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
226+
if (isGranted && ::webView.isInitialized) {
227+
webView.reload()
228+
}
229+
}
216230
private val writeNfcTag = registerForActivityResult(WriteNfcTag()) { messageId ->
217231
sendExternalBusMessage(
218232
ExternalBusMessage(
@@ -359,6 +373,8 @@ class WebViewActivity :
359373

360374
super.onCreate(savedInstanceState)
361375

376+
maybeRequestLocalNetworkPermission()
377+
362378
if (intent.extras?.containsKey(EXTRA_SERVER) == true) {
363379
intent.extras?.getInt(EXTRA_SERVER)?.let {
364380
lifecycleScope.launch {
@@ -1336,6 +1352,25 @@ class WebViewActivity :
13361352
presenter.onStart(this)
13371353
}
13381354

1355+
/**
1356+
* Requests `ACCESS_LOCAL_NETWORK` on Android 17+ if it is not already granted.
1357+
*
1358+
* The system permission dialog is asynchronous: the WebView keeps loading in parallel, and
1359+
* a successful grant triggers a reload via [requestLocalNetworkPermission]. On pre-API 37
1360+
* devices the permission does not exist and no action is taken.
1361+
*/
1362+
private fun maybeRequestLocalNetworkPermission() {
1363+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.CINNAMON_BUN) return
1364+
if (ContextCompat.checkSelfPermission(
1365+
this,
1366+
android.Manifest.permission.ACCESS_LOCAL_NETWORK,
1367+
) == PackageManager.PERMISSION_GRANTED
1368+
) {
1369+
return
1370+
}
1371+
requestLocalNetworkPermission.launch(android.Manifest.permission.ACCESS_LOCAL_NETWORK)
1372+
}
1373+
13391374
override fun onResume() {
13401375
super.onResume()
13411376
lifecycleScope.launch {

app/src/main/res/xml/changelog_master.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
<changelog xmlns:tools="http://schemas.android.com/tools"
33
tools:ignore="MissingDefaultResource">
44
<release version="2026.5.4 - Main" versioncode="3">
5+
<change>Added support for Android 17's local network permission</change>
56
<change>Bug fixes and dependency updates</change>
67
</release>
78
<release version="2026.5.4 - Wear" versioncode="2">

0 commit comments

Comments
 (0)