summaryrefslogtreecommitdiff
path: root/Source/Android/app/src/main/java
diff options
context:
space:
mode:
authorTom Pratt <tom.pratt@outlook.com>2026-09-26 22:45:23 +0200
committerOatmealDome <julian@oatmealdome.me>2026-10-07 19:55:50 -0400
commitb5ded04314feafe27ebcc8db4557d697192be3ea (patch)
treed55e59abb59bf9b1b0c5c919ca6a912cbaae116a /Source/Android/app/src/main/java
parent5b295d55879734ed7be312dc009596f4ce961848 (diff)
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.
Diffstat (limited to 'Source/Android/app/src/main/java')
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplaySetupScreen.kt136
1 files changed, 131 insertions, 5 deletions
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<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(
)
}
}
+ }
+}
+
+@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) }
- MenuSpacer()
+ // 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))
+ }
+ }
+ }
+ }
}
}