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" }