android/ui: prompt for permissions and show list of permissions with statuses

Updates #ENG-2948

Signed-off-by: Percy Wegmann <percy@tailscale.com>
pull/251/head
Percy Wegmann 2 months ago committed by Percy Wegmann
parent 8e063051b6
commit 44ba20a24e

@ -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<Unit, Boolean>() {

@ -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<Permission>
get() {
val result = mutableListOf<Permission>()
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
)

@ -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<Permission>) {
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
}
}
}

@ -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))
}
},
)
}
}
}
}

@ -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))
) {

@ -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,

@ -15,6 +15,7 @@
<string name="selected">Selected</string>
<string name="offline">Offline</string>
<string name="ok">OK</string>
<string name="warning">Warning</string>
<!-- Strings for the about screen -->
<string name="app_name">Tailscale</string>
@ -161,6 +162,14 @@
<string name="run_as_exit_node_visibility">Run As Exit Node visibility</string>
<!-- Permissions Management -->
<string name="permissions">Permissions</string>
<string name="permission_required">Permission Required</string>
<string name="taildrop_requires_write">Please grant access to write to external storage to be able to receive files with Taildrop.</string>
<string name="permission_write_external_storage">Storage</string>
<string name="permission_write_external_storage_needed">Please grant Tailscale the Storage permission in order to receive files with Taildrop.</string>
<string name="permission_write_external_storage_granted">Thank you for granting Tailscale the Storage permission.</string>
<string name="permission_post_notifications">Notifications</string>
<string name="permission_post_notifications_needed">Please grant Tailscale the Notifications permission in order to receive important status notifications.</string>
<string name="permission_post_notifications_granted">Thank you for granting Tailscale the Notifications permission.</string>
</resources>

Loading…
Cancel
Save