From 36128b9865036d3c9724c21a2671156408700e3f Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Sat, 26 Sep 2026 22:45:23 +0200 Subject: [PATCH] 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. --- Source/Android/app/build.gradle.kts | 2 + .../Android/app/src/main/AndroidManifest.xml | 1 + .../features/netplay/ui/NetplaySetupScreen.kt | 136 +++++++++++++++++- .../app/src/main/res/values/strings.xml | 2 + Source/Android/gradle/libs.versions.toml | 2 + 5 files changed, 138 insertions(+), 5 deletions(-) diff --git a/Source/Android/app/build.gradle.kts b/Source/Android/app/build.gradle.kts index 2fc4cb7a94..551f0e921b 100644 --- a/Source/Android/app/build.gradle.kts +++ b/Source/Android/app/build.gradle.kts @@ -165,6 +165,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 { diff --git a/Source/Android/app/src/main/AndroidManifest.xml b/Source/Android/app/src/main/AndroidManifest.xml index 96876acd46..5ff6ff090d 100644 --- a/Source/Android/app/src/main/AndroidManifest.xml +++ b/Source/Android/app/src/main/AndroidManifest.xml @@ -26,6 +26,7 @@ + diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplaySetupScreen.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplaySetupScreen.kt index ae222e8722..8b532b22de 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplaySetupScreen.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplaySetupScreen.kt @@ -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() @@ -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)) + } + } + } + } } } diff --git a/Source/Android/app/src/main/res/values/strings.xml b/Source/Android/app/src/main/res/values/strings.xml index 2204f4380f..96c8e3345c 100644 --- a/Source/Android/app/src/main/res/values/strings.xml +++ b/Source/Android/app/src/main/res/values/strings.xml @@ -1062,4 +1062,6 @@ It can efficiently compress both junk data and encrypted Wii data. Share address Retry %1$s/%2$s MiB + 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. + Give permission diff --git a/Source/Android/gradle/libs.versions.toml b/Source/Android/gradle/libs.versions.toml index a4311b8e5a..d03ac50c0e 100644 --- a/Source/Android/gradle/libs.versions.toml +++ b/Source/Android/gradle/libs.versions.toml @@ -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" }