android/ui: permissions styling feedback

Updates tailscale/corp#18202

Signed-off-by: Percy Wegmann <percy@tailscale.com>
pull/288/head
Percy Wegmann 2 months ago committed by Percy Wegmann
parent 54dccff232
commit c3dac5954e

@ -5,9 +5,55 @@ package com.tailscale.ipn.ui.model
import android.Manifest import android.Manifest
import android.os.Build import android.os.Build
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.core.app.NotificationManagerCompat
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.PermissionState
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberMultiplePermissionsState
import com.google.accompanist.permissions.shouldShowRationale
import com.tailscale.ipn.R import com.tailscale.ipn.R
object Permissions { object Permissions {
/** Permissions to prompt for on MainView. */
@OptIn(ExperimentalPermissionsApi::class)
val prompt: List<Pair<Permission, PermissionState>>
@Composable
get() {
val permissionStates = rememberMultiplePermissionsState(permissions = all.map { it.name })
return all.zip(permissionStates.permissions).filter { (permission, state) ->
!state.status.isGranted && !state.status.shouldShowRationale
}
}
/** All permissions with granted status. */
@OptIn(ExperimentalPermissionsApi::class)
val withGrantedStatus: List<Pair<Permission, Boolean>>
@Composable
get() {
val permissionStates = rememberMultiplePermissionsState(permissions = all.map { it.name })
val result = mutableListOf<Pair<Permission, Boolean>>()
result.addAll(
all.zip(permissionStates.permissions).map { (permission, state) ->
Pair(permission, state.status.isGranted)
})
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
// On Android versions prior to 13, we have to programmatically check if notifications are
// being allowed.
val notificationsEnabled =
NotificationManagerCompat.from(LocalContext.current).areNotificationsEnabled()
result.add(
Pair(
Permission(
"",
R.string.permission_post_notifications,
R.string.permission_post_notifications_needed),
notificationsEnabled))
}
return result
}
/** /**
* All permissions that Tailscale requires. MainView takes care of prompting for permissions, and * 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 * PermissionsView provides a list of permissions with corresponding statuses and a link to the
@ -16,26 +62,29 @@ object Permissions {
* When new permissions are needed, just add them to this list and the necessary strings to * 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. * strings.xml and the rest should take care of itself.
*/ */
val all: List<Permission> private val all: List<Permission> by lazy {
get() { val result = mutableListOf<Permission>()
val result = mutableListOf<Permission>() if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { result.add(
result.add( Permission(
Permission( Manifest.permission.WRITE_EXTERNAL_STORAGE,
Manifest.permission.WRITE_EXTERNAL_STORAGE, R.string.permission_write_external_storage,
R.string.permission_write_external_storage, R.string.permission_write_external_storage_needed,
R.string.permission_write_external_storage_needed, ))
)) }
} if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { result.add(
result.add( Permission(
Permission( Manifest.permission.POST_NOTIFICATIONS,
Manifest.permission.POST_NOTIFICATIONS, R.string.permission_post_notifications,
R.string.permission_post_notifications, R.string.permission_post_notifications_needed))
R.string.permission_post_notifications_needed))
}
return result
} }
result
}
} }
data class Permission(val name: String, val title: Int, val description: Int) data class Permission(
val name: String,
val title: Int,
val description: Int,
)

@ -58,14 +58,10 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
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.Netmap import com.tailscale.ipn.ui.model.Netmap
import com.tailscale.ipn.ui.model.Permission
import com.tailscale.ipn.ui.model.Permissions import com.tailscale.ipn.ui.model.Permissions
import com.tailscale.ipn.ui.model.Tailcfg import com.tailscale.ipn.ui.model.Tailcfg
import com.tailscale.ipn.ui.theme.disabled import com.tailscale.ipn.ui.theme.disabled
@ -141,9 +137,9 @@ fun MainView(navigation: MainViewNavigation, viewModel: MainViewModel = viewMode
when (state) { when (state) {
Ipn.State.Running -> { Ipn.State.Running -> {
PromptPermissionsIfNecessary(permissions = Permissions.all) PromptPermissionsIfNecessary()
ExpiryNotificationIfNeccessary( ExpiryNotificationIfNecessary(
netmap = netmap.value, action = { viewModel.login {} }) netmap = netmap.value, action = { viewModel.login {} })
ExitNodeStatus(navAction = navigation.onNavigateToExitNodes, viewModel = viewModel) ExitNodeStatus(navAction = navigation.onNavigateToExitNodes, viewModel = viewModel)
@ -413,7 +409,7 @@ fun PeerList(
} }
@Composable @Composable
fun ExpiryNotificationIfNeccessary(netmap: Netmap.NetworkMap?, action: () -> Unit = {}) { fun ExpiryNotificationIfNecessary(netmap: Netmap.NetworkMap?, action: () -> Unit = {}) {
// Key expiry warning shown only if the key is expiring within 24 hours (or has already expired) // Key expiry warning shown only if the key is expiring within 24 hours (or has already expired)
val networkMap = netmap ?: return val networkMap = netmap ?: return
if (!TimeUtil.isWithin24Hours(networkMap.SelfNode.KeyExpiry)) { if (!TimeUtil.isWithin24Hours(networkMap.SelfNode.KeyExpiry)) {
@ -446,14 +442,13 @@ fun ExpiryNotificationIfNeccessary(netmap: Netmap.NetworkMap?, action: () -> Uni
@OptIn(ExperimentalPermissionsApi::class) @OptIn(ExperimentalPermissionsApi::class)
@Composable @Composable
fun PromptPermissionsIfNecessary(permissions: List<Permission>) { fun PromptPermissionsIfNecessary() {
permissions.forEach { permission -> Permissions.prompt.forEach { (permission, state) ->
val state = rememberPermissionState(permission.name) ErrorDialog(
if (!state.status.isGranted && !state.status.shouldShowRationale) { title = permission.title,
// We don't have the permission and can ask for it message = permission.description,
ErrorDialog(title = permission.title, message = permission.description) { buttonText = R.string._continue) {
state.launchPermissionRequest() state.launchPermissionRequest()
} }
}
} }
} }

@ -18,8 +18,6 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.google.accompanist.permissions.ExperimentalPermissionsApi 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.R
import com.tailscale.ipn.ui.model.Permissions import com.tailscale.ipn.ui.model.Permissions
import com.tailscale.ipn.ui.theme.success import com.tailscale.ipn.ui.theme.success
@ -28,31 +26,23 @@ import com.tailscale.ipn.ui.util.itemsWithDividers
@OptIn(ExperimentalPermissionsApi::class) @OptIn(ExperimentalPermissionsApi::class)
@Composable @Composable
fun PermissionsView(nav: BackNavigation, openApplicationSettings: () -> Unit) { fun PermissionsView(nav: BackNavigation, openApplicationSettings: () -> Unit) {
val permissions = Permissions.withGrantedStatus
Scaffold(topBar = { Header(titleRes = R.string.permissions, onBack = nav.onBack) }) { innerPadding Scaffold(topBar = { Header(titleRes = 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)) { LazyColumn(modifier = Modifier.padding(innerPadding)) {
itemsWithDividers(permissionsWithStates) { (permission, state) -> itemsWithDividers(permissions) { (permission, granted) ->
var modifier: Modifier = Modifier
if (!state.status.isGranted) {
modifier = modifier.clickable { openApplicationSettings() }
}
ListItem( ListItem(
modifier = modifier, modifier = Modifier.clickable { openApplicationSettings() },
leadingContent = { leadingContent = {
Icon( Icon(
if (state.status.isGranted) painterResource(R.drawable.check_circle) if (granted) painterResource(R.drawable.check_circle)
else painterResource(R.drawable.xmark_circle), else painterResource(R.drawable.xmark_circle),
tint = tint =
if (state.status.isGranted) MaterialTheme.colorScheme.success if (granted) MaterialTheme.colorScheme.success
else MaterialTheme.colorScheme.onSurfaceVariant, else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(24.dp), modifier = Modifier.size(24.dp),
contentDescription = contentDescription =
stringResource(if (state.status.isGranted) R.string.ok else R.string.warning)) stringResource(if (granted) R.string.ok else R.string.warning))
}, },
headlineContent = { headlineContent = {
Text(stringResource(permission.title), style = MaterialTheme.typography.titleMedium) Text(stringResource(permission.title), style = MaterialTheme.typography.titleMedium)

@ -26,8 +26,9 @@ enum class SettingType {
// enabled: Whether the setting is enabled // enabled: Whether the setting is enabled
// value: The value of the setting for textual settings // value: The value of the setting for textual settings
// isOn: The value of the setting for switch settings // isOn: The value of the setting for switch settings
// onClick: The action to take when the setting is clicked (typicall for navigation) // onClick: The action to take when the setting is clicked (typically for navigation)
// onToggle: The action to take when the setting is toggled (typically for switches) // onToggle: The action to take when the setting is toggled (typically for switches)
// icon: An optional Composable that draws a trailing icon to display with nav settings
// //
// Behavior is undefined if you mix the types here. Switch settings should supply an // Behavior is undefined if you mix the types here. Switch settings should supply an
// isOn and onToggle, while navigation settings should supply an onClick and an optional // isOn and onToggle, while navigation settings should supply an onClick and an optional
@ -40,7 +41,7 @@ data class Setting(
val enabled: StateFlow<Boolean> = MutableStateFlow(true), val enabled: StateFlow<Boolean> = MutableStateFlow(true),
val isOn: StateFlow<Boolean?>? = null, val isOn: StateFlow<Boolean?>? = null,
val onClick: () -> Unit = {}, val onClick: () -> Unit = {},
val onToggle: (Boolean) -> Unit = {} val onToggle: (Boolean) -> Unit = {},
) )
data class SettingsNav( data class SettingsNav(

@ -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="_continue">Continue</string>
<string name="warning">Warning</string> <string name="warning">Warning</string>
<string name="search">Search\n</string> <string name="search">Search\n</string>

Loading…
Cancel
Save