diff --git a/Source/Android/app/build.gradle.kts b/Source/Android/app/build.gradle.kts
index 871f93fcd5..9c696ccb0b 100644
--- a/Source/Android/app/build.gradle.kts
+++ b/Source/Android/app/build.gradle.kts
@@ -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 {
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" }