Request ACCESS_LOCAL_NETWORK permission on android 17 and up

Show the permission dialog the first time host or connect is clicked.

While the permission is not granted, show an info box at the bottom of the netplay setup screen explaining the need for the permission with a button to grant permission. Launch the app settings menu where the permission can be granted manually if the user has denied twice previously preventing us from showing the permission dialog.

Workarounds using the shouldShowRationale flag and detecting if the dialog showed via onPause to account for well known limitations in the android permissions API.
This commit is contained in:
Tom Pratt authored and OatmealDome committed 2026-10-07 19:55:50 -04:00
1 parent 5b295d5587
commit b5ded04314
5 files changed
+138 -5

No files matched your search

+2
View File
@@ -170,6 +170,8 @@ dependencies {
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.tooling)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.accompanist)
}
fun getGitVersion(): String {
@@ -26,6 +26,7 @@
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK"/>
<uses-permission android:name="com.android.providers.tv.permission.READ_EPG_DATA"/>
<uses-permission android:name="com.android.providers.tv.permission.WRITE_EPG_DATA"/>
<uses-permission android:name="android.permission.VIBRATE"/>
@@ -1,9 +1,14 @@
// SPDX-License-Identifier: GPL-2.0-or-later
@file:OptIn(ExperimentalMaterial3Api::class)
@file:OptIn(ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class)
package org.dolphinemu.dolphinemu.features.netplay.ui
import android.Manifest
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.provider.Settings
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -19,6 +24,7 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
@@ -42,13 +48,22 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.PermissionState
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.google.accompanist.permissions.shouldShowRationale
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
@@ -58,6 +73,7 @@ import org.dolphinemu.dolphinemu.features.netplay.model.ConnectionType
import org.dolphinemu.dolphinemu.ui.theme.DolphinScaffold
import org.dolphinemu.dolphinemu.ui.theme.DolphinTheme
import org.dolphinemu.dolphinemu.ui.theme.MenuSpacer
import org.dolphinemu.dolphinemu.ui.theme.OutlinedBox
private data class ErrorDialogState(val message: String) {
val onDismissed = CompletableDeferred<Unit>()
@@ -87,6 +103,23 @@ fun NetplaySetupScreen(
onHostClicked: () -> Unit,
onConnectClicked: () -> Unit,
) {
val localNetworkPermissionState: PermissionState? =
if (
connectionType is ConnectionType.DirectConnection &&
Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN
) {
rememberPermissionState(Manifest.permission.ACCESS_LOCAL_NETWORK) { _ ->
// Try to connect even if the user denied the permission. The warning is on screen
// so it's up to them to determine if they are using local network and need to grant it.
when (connectionRole) {
ConnectionRole.Host -> onHostClicked()
ConnectionRole.Connect -> onConnectClicked()
}
}
} else {
null
}
DolphinScaffold(
title = { Text(stringResource(R.string.netplay_setup_title)) },
navigationIcon = {
@@ -99,9 +132,19 @@ fun NetplaySetupScreen(
},
floatingActionButton = {
ExtendedFloatingActionButton(
onClick = when(connectionRole) {
ConnectionRole.Host -> onHostClicked
ConnectionRole.Connect -> onConnectClicked
onClick = {
if (
localNetworkPermissionState != null &&
!localNetworkPermissionState.status.isGranted &&
!localNetworkPermissionState.status.shouldShowRationale
) {
localNetworkPermissionState.launchPermissionRequest()
} else {
when (connectionRole) {
ConnectionRole.Host -> onHostClicked()
ConnectionRole.Connect -> onConnectClicked()
}
}
},
) {
if (connecting) {
@@ -238,6 +281,10 @@ private fun NetplaySetupContent(
onUseUpnpChanged = onUseUpnpChanged,
)
}
LocalNetworkPermissionInfo(
connectionType = connectionType,
)
}
@Composable
@@ -374,8 +421,87 @@ private fun HostMenu(
)
}
}
}
}
MenuSpacer()
@Composable
private fun LocalNetworkPermissionInfo(
connectionType: ConnectionType,
) {
if (
connectionType is ConnectionType.DirectConnection &&
Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN
) {
val context = LocalContext.current
// Android permissions do not differentiate between isGranted being false because the user
// has never been asked and isGranted being false because the user denied twice. We only
// get shouldShowRationale which, if the user keeps denying goes false, true, always false.
// So if the value does not change after calling launchPermissionRequest we know we are in
// the final denied state and launch app settings for the user to manually grant from there.
var previousShouldShowRationale by rememberSaveable { mutableStateOf(false) }
// Check if the permission dialog actually showed by detecting an onPause event, this allows
// us to detect if the dialog was dismissed with the back button which would prevent
// previousShouldShowRationale from toggling as described above.
var dialogShown by rememberSaveable { mutableStateOf(false) }
LifecycleEventEffect(Lifecycle.Event.ON_PAUSE) { dialogShown = true }
lateinit var permissionState: PermissionState
permissionState =
rememberPermissionState(Manifest.permission.ACCESS_LOCAL_NETWORK) { granted ->
if (
!granted &&
!dialogShown &&
previousShouldShowRationale == permissionState.status.shouldShowRationale
) {
context.startActivity(
Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.fromParts("package", context.packageName, null)
)
)
}
previousShouldShowRationale = permissionState.status.shouldShowRationale
}
if (!permissionState.status.isGranted) {
MenuSpacer()
OutlinedBox(
label = {
Icon(
imageVector = Icons.Default.Warning,
tint = MaterialTheme.colorScheme.error,
contentDescription = null,
)
}
) {
Column(
modifier = Modifier
.fillMaxWidth()
) {
Text(
text = stringResource(R.string.netplay_local_network_permission_info),
color = MaterialTheme.colorScheme.secondary,
style = MaterialTheme.typography.bodyMedium,
)
Spacer(modifier = Modifier.height(8.dp))
TextButton(
onClick = {
previousShouldShowRationale = permissionState.status.shouldShowRationale
dialogShown = false
permissionState.launchPermissionRequest()
},
modifier = Modifier.align(Alignment.End)
) {
Text(text = stringResource(R.string.netplay_give_permission))
}
}
}
}
}
}
@@ -1062,4 +1062,6 @@ It can efficiently compress both junk data and encrypted Wii data.
<string name="netplay_address_share">Share address</string>
<string name="netplay_address_retry">Retry</string>
<string name="netplay_transfer_progress">%1$s/%2$s MiB</string>
<string name="netplay_local_network_permission_info">Dolphin needs permission to connect to devices on a local network. If you only want to use netplay over the internet, the permission is not required.</string>
<string name="netplay_give_permission">Give permission</string>
</resources>
+2
View File
@@ -26,6 +26,7 @@ slidingpanelayout = "1.2.0"
swiperefreshlayout = "1.2.0"
tvprovider = "1.1.0"
uiautomator = "2.4.0"
accompanist = "0.37.3"
[libraries]
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose" }
@@ -64,6 +65,7 @@ filepicker = { group = "com.nononsenseapps", name = "filepicker", version.ref =
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutinesAndroid" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
accompanist = { group = "com.google.accompanist", name = "accompanist-permissions", version.ref = "accompanist" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }