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.Uri
import android.net.VpnService import android.net.VpnService
import android.os.Bundle import android.os.Bundle
import android.provider.Settings
import android.util.Log import android.util.Log
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent 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.MullvadExitNodePicker
import com.tailscale.ipn.ui.view.MullvadExitNodePickerList import com.tailscale.ipn.ui.view.MullvadExitNodePickerList
import com.tailscale.ipn.ui.view.PeerDetails 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.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.TailnetLockSetupView
import com.tailscale.ipn.ui.view.UserSwitcherView import com.tailscale.ipn.ui.view.UserSwitcherView
import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav
@ -87,6 +89,7 @@ class MainActivity : ComponentActivity() {
onNavigateToMDMSettings = { navController.navigate("mdmSettings") }, onNavigateToMDMSettings = { navController.navigate("mdmSettings") },
onNavigateToManagedBy = { navController.navigate("managedBy") }, onNavigateToManagedBy = { navController.navigate("managedBy") },
onNavigateToUserSwitcher = { navController.navigate("userSwitcher") }, onNavigateToUserSwitcher = { navController.navigate("userSwitcher") },
onNavigateToPermissions = { navController.navigate("permissions") },
onBackPressed = { navController.popBackStack() }, onBackPressed = { navController.popBackStack() },
) )
@ -104,7 +107,7 @@ class MainActivity : ComponentActivity() {
onNavigateToRunAsExitNode = { navController.navigate("runExitNode") }) onNavigateToRunAsExitNode = { navController.navigate("runExitNode") })
composable("main") { MainView(navigation = mainViewNav) } composable("main") { MainView(navigation = mainViewNav) }
composable("settings") { Settings(settingsNav) } composable("settings") { SettingsView(settingsNav) }
navigation(startDestination = "list", route = "exitNodes") { navigation(startDestination = "list", route = "exitNodes") {
composable("list") { ExitNodePicker(exitNodePickerNav) } composable("list") { ExitNodePicker(exitNodePickerNav) }
composable("mullvad") { MullvadExitNodePickerList(exitNodePickerNav) } composable("mullvad") { MullvadExitNodePickerList(exitNodePickerNav) }
@ -128,6 +131,9 @@ class MainActivity : ComponentActivity() {
composable("mdmSettings") { MDMSettingsDebugView(nav = backNav) } composable("mdmSettings") { MDMSettingsDebugView(nav = backNav) }
composable("managedBy") { ManagedByView(nav = backNav) } composable("managedBy") { ManagedByView(nav = backNav) }
composable("userSwitcher") { UserSwitcherView(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") 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>() { 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 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.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@ -39,10 +36,8 @@ import androidx.compose.material3.SearchBar
import androidx.compose.material3.SearchBarDefaults import androidx.compose.material3.SearchBarDefaults
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip 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.R
import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.IpnLocal 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.StableNodeID
import com.tailscale.ipn.ui.model.Tailcfg import com.tailscale.ipn.ui.model.Tailcfg
import com.tailscale.ipn.ui.theme.ts_color_light_green 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.flag
import com.tailscale.ipn.ui.util.itemsWithDividers import com.tailscale.ipn.ui.util.itemsWithDividers
import com.tailscale.ipn.ui.viewModel.MainViewModel import com.tailscale.ipn.ui.viewModel.MainViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
// Navigation actions for the MainView // Navigation actions for the MainView
@ -119,7 +115,7 @@ fun MainView(navigation: MainViewNavigation, viewModel: MainViewModel = viewMode
when (state.value) { when (state.value) {
Ipn.State.Running -> { Ipn.State.Running -> {
PromptWriteStoragePermissionsIfNecessary() PromptPermissionsIfNecessary(permissions = Permissions.all)
val selfPeerId = viewModel.selfPeerId.collectAsState(initial = "") val selfPeerId = viewModel.selfPeerId.collectAsState(initial = "")
Row( Row(
@ -411,31 +407,14 @@ fun PeerList(
@OptIn(ExperimentalPermissionsApi::class) @OptIn(ExperimentalPermissionsApi::class)
@Composable @Composable
fun PromptWriteStoragePermissionsIfNecessary() { fun PromptPermissionsIfNecessary(permissions: List<Permission>) {
val writeStoragePermissionState = permissions.forEach { permission ->
rememberPermissionState(Manifest.permission.WRITE_EXTERNAL_STORAGE) val state = rememberPermissionState(permission.name)
if (!state.status.isGranted && !state.status.shouldShowRationale) {
val showDialog = remember { MutableStateFlow(false) } // We don't have the permission and can ask for it
ErrorDialog(title = permission.title, message = permission.neededDescription) {
val requestPermissionLauncher = state.launchPermissionRequest()
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
if (!granted) {
showDialog.value = true
}
} }
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 import com.tailscale.ipn.ui.viewModel.SettingsViewModelFactory
@Composable @Composable
fun Settings( fun SettingsView(
settingsNav: SettingsNav, settingsNav: SettingsNav,
viewModel: SettingsViewModel = viewModel(factory = SettingsViewModelFactory(settingsNav)) viewModel: SettingsViewModel = viewModel(factory = SettingsViewModelFactory(settingsNav))
) { ) {

@ -80,6 +80,7 @@ data class SettingsNav(
val onNavigateToMDMSettings: () -> Unit, val onNavigateToMDMSettings: () -> Unit,
val onNavigateToManagedBy: () -> Unit, val onNavigateToManagedBy: () -> Unit,
val onNavigateToUserSwitcher: () -> Unit, val onNavigateToUserSwitcher: () -> Unit,
val onNavigateToPermissions: () -> Unit,
val onBackPressed: () -> Unit, val onBackPressed: () -> Unit,
) )
@ -124,6 +125,11 @@ class SettingsViewModel(val navigation: SettingsNav) : IpnViewModel() {
SettingType.NAV, SettingType.NAV,
onClick = { navigation.onNavigateToTailnetLock() }, onClick = { navigation.onNavigateToTailnetLock() },
enabled = MutableStateFlow(true)), enabled = MutableStateFlow(true)),
Setting(
titleRes = R.string.permissions,
SettingType.NAV,
onClick = { navigation.onNavigateToPermissions() },
enabled = MutableStateFlow(true)),
Setting( Setting(
titleRes = R.string.about, titleRes = R.string.about,
SettingType.NAV, SettingType.NAV,

@ -15,6 +15,7 @@
<string name="selected">Selected</string> <string name="selected">Selected</string>
<string name="offline">Offline</string> <string name="offline">Offline</string>
<string name="ok">OK</string> <string name="ok">OK</string>
<string name="warning">Warning</string>
<!-- Strings for the about screen --> <!-- Strings for the about screen -->
<string name="app_name">Tailscale</string> <string name="app_name">Tailscale</string>
@ -161,6 +162,14 @@
<string name="run_as_exit_node_visibility">Run As Exit Node visibility</string> <string name="run_as_exit_node_visibility">Run As Exit Node visibility</string>
<!-- Permissions Management --> <!-- Permissions Management -->
<string name="permissions">Permissions</string>
<string name="permission_required">Permission Required</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> </resources>

Loading…
Cancel
Save