diff --git a/android/README.md b/android/README.md index edef6a103..371a0a1b6 100644 --- a/android/README.md +++ b/android/README.md @@ -6,6 +6,9 @@ Notes and resources for MPD android maintainers. ### Version control + +git ignoring .idea directory completely until a good reason emerges not to + * [How to manage projects under Version Control Systems (jetbrains.com)](https://intellij-support.jetbrains.com/hc/en-us/articles/206544839-How-to-manage-projects-under-Version-Control-Systems) * [gradle.xml should work like workspace.xml? (jetbrains.com)](https://youtrack.jetbrains.com/issue/IDEA-55923) @@ -15,3 +18,30 @@ Notes and resources for MPD android maintainers. * [Include prebuilt native libraries (developer.android.com)](https://developer.android.com/studio/projects/gradle-external-native-builds#jniLibs) +### Permissions + +#### Files access + +The required permission depends on android SDK version: + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) + Manifest.permission.READ_MEDIA_AUDIO + else + Manifest.permission.READ_EXTERNAL_STORAGE + +#### Permission request + +[Request runtime permissions](https://developer.android.com/training/permissions/requesting) + +Since Android 6.0 (API level 23): + +Android will ignore permission request and will not show the request dialog +if the user's action implies "don't ask again." +This leaves the app in a crippled state and the user confused. +Google says "don't try to convince the user", so it returns false for `shouldShowRequestPermissionRationale`. + +To help the user proceed, we show the `Request permission` button only if `shouldShowRequestPermissionRationale == true` +because there's a good chance the permission request dialog will not be ignored. + +If `shouldShowRequestPermissionRationale == false` we instead show the "rationale" message and a button to open +the app info dialog where the user can explicitly grand the permission. \ No newline at end of file diff --git a/android/app/src/main/java/org/musicpd/ui/StatusScreen.kt b/android/app/src/main/java/org/musicpd/ui/StatusScreen.kt index 8b24ec143..b913c57b5 100644 --- a/android/app/src/main/java/org/musicpd/ui/StatusScreen.kt +++ b/android/app/src/main/java/org/musicpd/ui/StatusScreen.kt @@ -1,6 +1,7 @@ package org.musicpd.ui import android.Manifest +import android.os.Build import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -24,54 +25,36 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp 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 org.musicpd.R +import org.musicpd.utils.openAppSettings @OptIn(ExperimentalPermissionsApi::class) @Composable fun StatusScreen(settingsViewModel: SettingsViewModel) { - val storagePermissionState = rememberPermissionState( - Manifest.permission.READ_EXTERNAL_STORAGE - ) - - if (storagePermissionState.status.shouldShowRationale) { - Column( - Modifier - .padding(4.dp) - .fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - Text(stringResource(id = R.string.external_files_permission_request)) - Button(onClick = { }) { - Text("Request permission") - } - } + val storagePermissionState = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + rememberPermissionState( + Manifest.permission.READ_MEDIA_AUDIO + ) } else { - Column( - Modifier - .padding(4.dp) - .fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - NetworkAddress() - ServerStatus(settingsViewModel) - if (!storagePermissionState.status.isGranted) { - OutlinedButton( - onClick = { storagePermissionState.launchPermissionRequest() }, Modifier - .padding(4.dp) - .fillMaxWidth() - ) { - Text( - "Request external storage permission", - color = MaterialTheme.colorScheme.secondary - ) - } - } - } + rememberPermissionState( + Manifest.permission.READ_EXTERNAL_STORAGE + ) + } + + Column( + Modifier + .padding(4.dp) + .fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + NetworkAddress() + ServerStatus(settingsViewModel) + AudioMediaPermission(storagePermissionState) } } @@ -116,4 +99,44 @@ fun ServerStatus(settingsViewModel: SettingsViewModel) { Text(text = statusUiState.statusMessage) } } +} + +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun AudioMediaPermission(storagePermissionState: PermissionState) { + val permissionStatus = storagePermissionState.status + if (!permissionStatus.isGranted) { + val context = LocalContext.current + Column( + Modifier + .padding(4.dp) + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + stringResource(id = R.string.external_files_permission_request), + Modifier.padding(16.dp) + ) + if (storagePermissionState.status.shouldShowRationale) { + Button(onClick = { + storagePermissionState.launchPermissionRequest() + }) { + Text("Request permission") + } + } else { + OutlinedButton( + onClick = { + openAppSettings(context, context.packageName) + }, + Modifier.padding(16.dp) + ) { + Text( + stringResource(id = R.string.title_open_app_info), + color = MaterialTheme.colorScheme.secondary + ) + } + } + } + } } \ No newline at end of file diff --git a/android/app/src/main/java/org/musicpd/utils/IntentUtils.kt b/android/app/src/main/java/org/musicpd/utils/IntentUtils.kt new file mode 100644 index 000000000..806ef51a4 --- /dev/null +++ b/android/app/src/main/java/org/musicpd/utils/IntentUtils.kt @@ -0,0 +1,30 @@ +package org.musicpd.utils + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.provider.Settings +import android.util.Log + +private const val TAG = "IntentUtils" + +fun openAppSettings( + context: Context, + packageName: String +) { + try { + context.startActivity(Intent().apply { + setAction(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) + setData(Uri.parse("package:$packageName")) + addCategory(Intent.CATEGORY_DEFAULT) + addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY) + addFlags(Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS) + }) + } catch (e: ActivityNotFoundException) { + Log.e( + TAG, + "failed to open app settings for package: $packageName", e + ) + } +} diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 92f8139b2..32877eb03 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -10,4 +10,5 @@ Prevent suspend when MPD is running (Wakelock) Pause MPD when headphones disconnect MPD requires access to external files to play local music. Please grant the permission. + Open app info