Merge pull request #14889 from tom-pratt/local-network-permission

Request ACCESS_LOCAL_NETWORK permission on android 17 and up
This commit is contained in:
Jordan Woyak
2026-10-05 15:26:10 -05:00
committed by GitHub
5 changed files with 138 additions and 5 deletions
+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))
}
}
}
}
}
}
@@ -1063,4 +1063,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" }