From 44ba20a24e75583ce58652332ba0fa77da9690f9 Mon Sep 17 00:00:00 2001 From: Percy Wegmann Date: Wed, 27 Mar 2024 08:51:34 -0500 Subject: [PATCH] android/ui: prompt for permissions and show list of permissions with statuses Updates #ENG-2948 Signed-off-by: Percy Wegmann --- .../java/com/tailscale/ipn/MainActivity.kt | 19 ++++- .../com/tailscale/ipn/ui/model/Permissions.kt | 47 ++++++++++++ .../com/tailscale/ipn/ui/view/MainView.kt | 41 +++------- .../tailscale/ipn/ui/view/PermissionsView.kt | 75 +++++++++++++++++++ .../com/tailscale/ipn/ui/view/SettingsView.kt | 2 +- .../ipn/ui/viewModel/SettingsViewModel.kt | 6 ++ android/src/main/res/values/strings.xml | 11 ++- 7 files changed, 166 insertions(+), 35 deletions(-) create mode 100644 android/src/main/java/com/tailscale/ipn/ui/model/Permissions.kt create mode 100644 android/src/main/java/com/tailscale/ipn/ui/view/PermissionsView.kt diff --git a/android/src/main/java/com/tailscale/ipn/MainActivity.kt b/android/src/main/java/com/tailscale/ipn/MainActivity.kt index d5debf3..5f0ee8e 100644 --- a/android/src/main/java/com/tailscale/ipn/MainActivity.kt +++ b/android/src/main/java/com/tailscale/ipn/MainActivity.kt @@ -10,6 +10,7 @@ import android.content.RestrictionsManager import android.net.Uri import android.net.VpnService import android.os.Bundle +import android.provider.Settings import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.compose.setContent @@ -39,8 +40,9 @@ import com.tailscale.ipn.ui.view.ManagedByView import com.tailscale.ipn.ui.view.MullvadExitNodePicker import com.tailscale.ipn.ui.view.MullvadExitNodePickerList import com.tailscale.ipn.ui.view.PeerDetails +import com.tailscale.ipn.ui.view.PermissionsView import com.tailscale.ipn.ui.view.RunExitNodeView -import com.tailscale.ipn.ui.view.Settings +import com.tailscale.ipn.ui.view.SettingsView import com.tailscale.ipn.ui.view.TailnetLockSetupView import com.tailscale.ipn.ui.view.UserSwitcherView import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav @@ -87,6 +89,7 @@ class MainActivity : ComponentActivity() { onNavigateToMDMSettings = { navController.navigate("mdmSettings") }, onNavigateToManagedBy = { navController.navigate("managedBy") }, onNavigateToUserSwitcher = { navController.navigate("userSwitcher") }, + onNavigateToPermissions = { navController.navigate("permissions") }, onBackPressed = { navController.popBackStack() }, ) @@ -104,7 +107,7 @@ class MainActivity : ComponentActivity() { onNavigateToRunAsExitNode = { navController.navigate("runExitNode") }) composable("main") { MainView(navigation = mainViewNav) } - composable("settings") { Settings(settingsNav) } + composable("settings") { SettingsView(settingsNav) } navigation(startDestination = "list", route = "exitNodes") { composable("list") { ExitNodePicker(exitNodePickerNav) } composable("mullvad") { MullvadExitNodePickerList(exitNodePickerNav) } @@ -128,6 +131,9 @@ class MainActivity : ComponentActivity() { composable("mdmSettings") { MDMSettingsDebugView(nav = backNav) } composable("managedBy") { ManagedByView(nav = backNav) } composable("userSwitcher") { UserSwitcherView(nav = backNav) } + composable("permissions") { + PermissionsView(nav = backNav, openApplicationSettings = ::openApplicationSettings) + } } } } @@ -196,6 +202,15 @@ class MainActivity : ComponentActivity() { Log.i("VPN", "VPN permission granted") } } + + private fun openApplicationSettings() { + val intent = + Intent( + Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.fromParts("package", packageName, null)) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + startActivity(intent) + } } class VpnPermissionContract : ActivityResultContract() { diff --git a/android/src/main/java/com/tailscale/ipn/ui/model/Permissions.kt b/android/src/main/java/com/tailscale/ipn/ui/model/Permissions.kt new file mode 100644 index 0000000..c16a913 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/model/Permissions.kt @@ -0,0 +1,47 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package com.tailscale.ipn.ui.model + +import android.Manifest +import android.os.Build +import com.tailscale.ipn.R + +object Permissions { + /** + * All permissions that Tailscale requires. MainView takes care of prompting for permissions, and + * PermissionsView provides a list of permissions with corresponding statuses and a link to the + * application settings. + * + * When new permissions are needed, just add them to this list and the necessary strings to + * strings.xml and the rest should take care of itself. + */ + val all: List + get() { + val result = mutableListOf() + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + result.add( + Permission( + Manifest.permission.WRITE_EXTERNAL_STORAGE, + R.string.permission_write_external_storage, + R.string.permission_write_external_storage_needed, + R.string.permission_write_external_storage_granted, + )) + } else { + result.add( + Permission( + Manifest.permission.POST_NOTIFICATIONS, + R.string.permission_post_notifications, + R.string.permission_post_notifications_needed, + R.string.permission_post_notifications_granted)) + } + return result + } +} + +data class Permission( + val name: String, + val title: Int, + val neededDescription: Int, + val grantedDescription: Int +) diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt index 9180a08..c8ce7cd 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt @@ -3,9 +3,6 @@ package com.tailscale.ipn.ui.view -import android.Manifest -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -39,10 +36,8 @@ import androidx.compose.material3.SearchBar import androidx.compose.material3.SearchBarDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -61,6 +56,8 @@ import com.google.accompanist.permissions.shouldShowRationale import com.tailscale.ipn.R import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.model.IpnLocal +import com.tailscale.ipn.ui.model.Permission +import com.tailscale.ipn.ui.model.Permissions import com.tailscale.ipn.ui.model.StableNodeID import com.tailscale.ipn.ui.model.Tailcfg import com.tailscale.ipn.ui.theme.ts_color_light_green @@ -69,7 +66,6 @@ import com.tailscale.ipn.ui.util.PeerSet import com.tailscale.ipn.ui.util.flag import com.tailscale.ipn.ui.util.itemsWithDividers import com.tailscale.ipn.ui.viewModel.MainViewModel -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow // Navigation actions for the MainView @@ -119,7 +115,7 @@ fun MainView(navigation: MainViewNavigation, viewModel: MainViewModel = viewMode when (state.value) { Ipn.State.Running -> { - PromptWriteStoragePermissionsIfNecessary() + PromptPermissionsIfNecessary(permissions = Permissions.all) val selfPeerId = viewModel.selfPeerId.collectAsState(initial = "") Row( @@ -411,31 +407,14 @@ fun PeerList( @OptIn(ExperimentalPermissionsApi::class) @Composable -fun PromptWriteStoragePermissionsIfNecessary() { - val writeStoragePermissionState = - rememberPermissionState(Manifest.permission.WRITE_EXTERNAL_STORAGE) - - val showDialog = remember { MutableStateFlow(false) } - - val requestPermissionLauncher = - rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> - if (!granted) { - showDialog.value = true - } +fun PromptPermissionsIfNecessary(permissions: List) { + permissions.forEach { permission -> + val state = rememberPermissionState(permission.name) + if (!state.status.isGranted && !state.status.shouldShowRationale) { + // We don't have the permission and can ask for it + ErrorDialog(title = permission.title, message = permission.neededDescription) { + state.launchPermissionRequest() } - - LaunchedEffect(writeStoragePermissionState) { - if (!writeStoragePermissionState.status.isGranted && - writeStoragePermissionState.status.shouldShowRationale) { - showDialog.value = true - } else { - requestPermissionLauncher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE) - } - } - - if (showDialog.collectAsState().value) { - ErrorDialog(title = R.string.permission_required, message = R.string.taildrop_requires_write) { - showDialog.value = false } } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/PermissionsView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/PermissionsView.kt new file mode 100644 index 0000000..a002741 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/view/PermissionsView.kt @@ -0,0 +1,75 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package com.tailscale.ipn.ui.view + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.KeyboardArrowRight +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberMultiplePermissionsState +import com.tailscale.ipn.R +import com.tailscale.ipn.ui.model.Permissions +import com.tailscale.ipn.ui.util.itemsWithDividers + +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun PermissionsView(nav: BackNavigation, openApplicationSettings: () -> Unit) { + Scaffold(topBar = { Header(title = R.string.permissions, onBack = nav.onBack) }) { innerPadding -> + val permissions = Permissions.all + val permissionStates = + rememberMultiplePermissionsState(permissions = permissions.map { it.name }) + val permissionsWithStates = permissions.zip(permissionStates.permissions) + + LazyColumn(modifier = Modifier.padding(innerPadding)) { + itemsWithDividers(permissionsWithStates) { (permission, state) -> + var modifier: Modifier = Modifier + if (!state.status.isGranted) { + modifier = modifier.clickable { openApplicationSettings() } + } + ListItem( + modifier = modifier, + leadingContent = { + Icon( + if (state.status.isGranted) Icons.Filled.CheckCircle else Icons.Filled.Warning, + modifier = Modifier.size(24.dp), + contentDescription = + stringResource(if (state.status.isGranted) R.string.ok else R.string.warning)) + }, + headlineContent = { + Text(stringResource(permission.title), style = MaterialTheme.typography.titleMedium) + }, + supportingContent = { + Text( + stringResource( + if (state.status.isGranted) permission.grantedDescription + else permission.neededDescription)) + }, + trailingContent = { + if (!state.status.isGranted) { + Icon( + Icons.AutoMirrored.Outlined.KeyboardArrowRight, + modifier = Modifier.size(24.dp), + contentDescription = stringResource(R.string.more)) + } + }, + ) + } + } + } +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/SettingsView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/SettingsView.kt index d3208e1..91f66c8 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/SettingsView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/SettingsView.kt @@ -38,7 +38,7 @@ import com.tailscale.ipn.ui.viewModel.SettingsViewModel import com.tailscale.ipn.ui.viewModel.SettingsViewModelFactory @Composable -fun Settings( +fun SettingsView( settingsNav: SettingsNav, viewModel: SettingsViewModel = viewModel(factory = SettingsViewModelFactory(settingsNav)) ) { diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/SettingsViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/SettingsViewModel.kt index 6ee31ee..4b7db2e 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/SettingsViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/SettingsViewModel.kt @@ -80,6 +80,7 @@ data class SettingsNav( val onNavigateToMDMSettings: () -> Unit, val onNavigateToManagedBy: () -> Unit, val onNavigateToUserSwitcher: () -> Unit, + val onNavigateToPermissions: () -> Unit, val onBackPressed: () -> Unit, ) @@ -124,6 +125,11 @@ class SettingsViewModel(val navigation: SettingsNav) : IpnViewModel() { SettingType.NAV, onClick = { navigation.onNavigateToTailnetLock() }, enabled = MutableStateFlow(true)), + Setting( + titleRes = R.string.permissions, + SettingType.NAV, + onClick = { navigation.onNavigateToPermissions() }, + enabled = MutableStateFlow(true)), Setting( titleRes = R.string.about, SettingType.NAV, diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml index f8c8173..fcf5c8f 100644 --- a/android/src/main/res/values/strings.xml +++ b/android/src/main/res/values/strings.xml @@ -15,6 +15,7 @@ Selected Offline OK + Warning Tailscale @@ -161,6 +162,14 @@ Run As Exit Node visibility + Permissions Permission Required - Please grant access to write to external storage to be able to receive files with Taildrop. + Storage + Please grant Tailscale the Storage permission in order to receive files with Taildrop. + Thank you for granting Tailscale the Storage permission. + Notifications + Please grant Tailscale the Notifications permission in order to receive important status notifications. + Thank you for granting Tailscale the Notifications permission. + +