android: fix android TV focus requester crash (#580)

Properly attach FocusRequester to the root column of TaildropView so that there is a focusable UI element available to receive the focus

Fixes tailscale/corp#25007

Signed-off-by: kari-ts <kari@tailscale.com>
pull/581/head
kari-ts 1 year ago committed by GitHub
parent 38abb03168
commit e29cfc5411
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -5,6 +5,7 @@ package com.tailscale.ipn.ui.view
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.RowScope
@ -32,9 +33,12 @@ import androidx.compose.ui.unit.dp
import com.tailscale.ipn.ui.theme.topAppBar import com.tailscale.ipn.ui.theme.topAppBar
import com.tailscale.ipn.ui.theme.ts_color_light_blue import com.tailscale.ipn.ui.theme.ts_color_light_blue
import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV
import com.tailscale.ipn.util.TSLog
typealias BackNavigation = () -> Unit typealias BackNavigation = () -> Unit
val TAG = "SharedViews"
// Header view for all secondary screens // Header view for all secondary screens
// @see TopAppBar actions for additional actions (usually a row of icons) // @see TopAppBar actions for additional actions (usually a row of icons)
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@ -45,10 +49,16 @@ fun Header(
actions: @Composable RowScope.() -> Unit = {}, actions: @Composable RowScope.() -> Unit = {},
onBack: (() -> Unit)? = null onBack: (() -> Unit)? = null
) { ) {
val f = FocusRequester() val focusRequester = remember { FocusRequester() }
if (isAndroidTV()) { if (isAndroidTV()) {
LaunchedEffect(Unit) { f.requestFocus() } LaunchedEffect(focusRequester) {
try {
focusRequester.requestFocus()
} catch (e: Exception) {
TSLog.d(TAG, "Focus request failed")
}
}
} }
TopAppBar( TopAppBar(
@ -61,23 +71,29 @@ fun Header(
}, },
colors = MaterialTheme.colorScheme.topAppBar, colors = MaterialTheme.colorScheme.topAppBar,
actions = actions, actions = actions,
navigationIcon = { onBack?.let { BackArrow(action = it, focusRequester = f) } }, navigationIcon = { onBack?.let { BackArrow(action = it, focusRequester = focusRequester) } },
) )
} }
@Composable @Composable
fun BackArrow(action: () -> Unit, focusRequester: FocusRequester) { fun BackArrow(action: () -> Unit, focusRequester: FocusRequester) {
val modifier =
if (isAndroidTV()) {
Modifier.focusRequester(focusRequester)
.focusable() // Ensure the composable can receive focus
} else {
Modifier
}
Box(modifier = Modifier.padding(start = 8.dp, end = 8.dp)) { Box(modifier = modifier.padding(start = 8.dp, end = 8.dp)) {
Icon( Icon(
Icons.AutoMirrored.Filled.ArrowBack, Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Go back to the previous screen", contentDescription = "Go back to the previous screen",
modifier = modifier =
Modifier.focusRequester(focusRequester) Modifier.clickable(
.clickable(
interactionSource = remember { MutableInteractionSource() }, interactionSource = remember { MutableInteractionSource() },
indication = ripple(bounded = false), indication = ripple(bounded = true),
onClick = { action() })) onClick = action))
} }
} }
@ -96,7 +112,7 @@ fun SimpleActivityIndicator(size: Int = 32) {
@Composable @Composable
fun ActivityIndicator(progress: Double, size: Int = 32) { fun ActivityIndicator(progress: Double, size: Int = 32) {
LinearProgressIndicator( LinearProgressIndicator(
progress = { progress.toFloat() }, progress = progress.toFloat(),
modifier = Modifier.width(size.dp), modifier = Modifier.width(size.dp),
color = ts_color_light_blue, color = ts_color_light_blue,
trackColor = MaterialTheme.colorScheme.secondary, trackColor = MaterialTheme.colorScheme.secondary,

@ -4,6 +4,7 @@
package com.tailscale.ipn.ui.view package com.tailscale.ipn.ui.view
import android.text.format.Formatter import android.text.format.Formatter
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@ -19,10 +20,14 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
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.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@ -36,6 +41,7 @@ import com.tailscale.ipn.ui.util.Lists.SectionDivider
import com.tailscale.ipn.ui.util.set import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.viewModel.TaildropViewModel import com.tailscale.ipn.ui.viewModel.TaildropViewModel
import com.tailscale.ipn.ui.viewModel.TaildropViewModelFactory import com.tailscale.ipn.ui.viewModel.TaildropViewModelFactory
import com.tailscale.ipn.util.TSLog
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -46,15 +52,25 @@ fun TaildropView(
viewModel: TaildropViewModel = viewModel: TaildropViewModel =
viewModel(factory = TaildropViewModelFactory(requestedTransfers, applicationScope)) viewModel(factory = TaildropViewModelFactory(requestedTransfers, applicationScope))
) { ) {
Scaffold( val TAG = "TaildropView"
contentWindowInsets = WindowInsets.Companion.statusBars, val focusRequester = remember { FocusRequester() }
topBar = { Header(R.string.share) }) { paddingInsets ->
// Automatically request focus when the composable is displayed
LaunchedEffect(Unit) {
try {
focusRequester.requestFocus()
} catch (e: Exception) {
TSLog.w(TAG, "Focus request failed: ${e.message}")
}
}
Scaffold(contentWindowInsets = WindowInsets.statusBars, topBar = { Header(R.string.share) }) {
paddingInsets ->
Column(modifier = Modifier.focusRequester(focusRequester).focusable().padding(paddingInsets)) {
val showDialog = viewModel.showDialog.collectAsState().value val showDialog = viewModel.showDialog.collectAsState().value
// Show the error overlay
showDialog?.let { ErrorDialog(type = it, action = { viewModel.showDialog.set(null) }) } showDialog?.let { ErrorDialog(type = it, action = { viewModel.showDialog.set(null) }) }
Column(modifier = Modifier.padding(paddingInsets)) {
FileShareHeader( FileShareHeader(
fileTransfers = requestedTransfers.collectAsState().value, fileTransfers = requestedTransfers.collectAsState().value,
totalSize = viewModel.totalSize) totalSize = viewModel.totalSize)
@ -65,9 +81,7 @@ fun TaildropView(
val context = LocalContext.current val context = LocalContext.current
FileSharePeerList( FileSharePeerList(
peers = peers, peers = peers,
stateViewGenerator = { peerId -> stateViewGenerator = { peerId -> viewModel.TrailingContentForPeer(peerId = peerId) },
viewModel.TrailingContentForPeer(peerId = peerId)
},
onShare = { viewModel.share(context, it) }) onShare = { viewModel.share(context, it) })
} }
else -> { else -> {

Loading…
Cancel
Save