Skip to content

Commit e29cfc5

Browse files
authored
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 <[email protected]>
1 parent 38abb03 commit e29cfc5

File tree

2 files changed

+65
-35
lines changed

2 files changed

+65
-35
lines changed

android/src/main/java/com/tailscale/ipn/ui/view/SharedViews.kt

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ package com.tailscale.ipn.ui.view
55

66
import androidx.annotation.StringRes
77
import androidx.compose.foundation.clickable
8+
import androidx.compose.foundation.focusable
89
import androidx.compose.foundation.interaction.MutableInteractionSource
910
import androidx.compose.foundation.layout.Box
1011
import androidx.compose.foundation.layout.RowScope
@@ -32,9 +33,12 @@ import androidx.compose.ui.unit.dp
3233
import com.tailscale.ipn.ui.theme.topAppBar
3334
import com.tailscale.ipn.ui.theme.ts_color_light_blue
3435
import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV
36+
import com.tailscale.ipn.util.TSLog
3537

3638
typealias BackNavigation = () -> Unit
3739

40+
val TAG = "SharedViews"
41+
3842
// Header view for all secondary screens
3943
// @see TopAppBar actions for additional actions (usually a row of icons)
4044
@OptIn(ExperimentalMaterial3Api::class)
@@ -45,10 +49,16 @@ fun Header(
4549
actions: @Composable RowScope.() -> Unit = {},
4650
onBack: (() -> Unit)? = null
4751
) {
48-
val f = FocusRequester()
52+
val focusRequester = remember { FocusRequester() }
4953

5054
if (isAndroidTV()) {
51-
LaunchedEffect(Unit) { f.requestFocus() }
55+
LaunchedEffect(focusRequester) {
56+
try {
57+
focusRequester.requestFocus()
58+
} catch (e: Exception) {
59+
TSLog.d(TAG, "Focus request failed")
60+
}
61+
}
5262
}
5363

5464
TopAppBar(
@@ -61,23 +71,29 @@ fun Header(
6171
},
6272
colors = MaterialTheme.colorScheme.topAppBar,
6373
actions = actions,
64-
navigationIcon = { onBack?.let { BackArrow(action = it, focusRequester = f) } },
74+
navigationIcon = { onBack?.let { BackArrow(action = it, focusRequester = focusRequester) } },
6575
)
6676
}
6777

6878
@Composable
6979
fun BackArrow(action: () -> Unit, focusRequester: FocusRequester) {
80+
val modifier =
81+
if (isAndroidTV()) {
82+
Modifier.focusRequester(focusRequester)
83+
.focusable() // Ensure the composable can receive focus
84+
} else {
85+
Modifier
86+
}
7087

71-
Box(modifier = Modifier.padding(start = 8.dp, end = 8.dp)) {
88+
Box(modifier = modifier.padding(start = 8.dp, end = 8.dp)) {
7289
Icon(
7390
Icons.AutoMirrored.Filled.ArrowBack,
7491
contentDescription = "Go back to the previous screen",
7592
modifier =
76-
Modifier.focusRequester(focusRequester)
77-
.clickable(
78-
interactionSource = remember { MutableInteractionSource() },
79-
indication = ripple(bounded = false),
80-
onClick = { action() }))
93+
Modifier.clickable(
94+
interactionSource = remember { MutableInteractionSource() },
95+
indication = ripple(bounded = true),
96+
onClick = action))
8197
}
8298
}
8399

@@ -96,7 +112,7 @@ fun SimpleActivityIndicator(size: Int = 32) {
96112
@Composable
97113
fun ActivityIndicator(progress: Double, size: Int = 32) {
98114
LinearProgressIndicator(
99-
progress = { progress.toFloat() },
115+
progress = progress.toFloat(),
100116
modifier = Modifier.width(size.dp),
101117
color = ts_color_light_blue,
102118
trackColor = MaterialTheme.colorScheme.secondary,

android/src/main/java/com/tailscale/ipn/ui/view/TaildropView.kt

Lines changed: 39 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
package com.tailscale.ipn.ui.view
55

66
import android.text.format.Formatter
7+
import androidx.compose.foundation.focusable
78
import androidx.compose.foundation.layout.Arrangement
89
import androidx.compose.foundation.layout.Column
910
import androidx.compose.foundation.layout.Row
@@ -19,10 +20,14 @@ import androidx.compose.material3.MaterialTheme
1920
import androidx.compose.material3.Scaffold
2021
import androidx.compose.material3.Text
2122
import androidx.compose.runtime.Composable
23+
import androidx.compose.runtime.LaunchedEffect
2224
import androidx.compose.runtime.collectAsState
2325
import androidx.compose.runtime.getValue
26+
import androidx.compose.runtime.remember
2427
import androidx.compose.ui.Alignment
2528
import androidx.compose.ui.Modifier
29+
import androidx.compose.ui.focus.FocusRequester
30+
import androidx.compose.ui.focus.focusRequester
2631
import androidx.compose.ui.platform.LocalContext
2732
import androidx.compose.ui.res.painterResource
2833
import androidx.compose.ui.res.stringResource
@@ -36,6 +41,7 @@ import com.tailscale.ipn.ui.util.Lists.SectionDivider
3641
import com.tailscale.ipn.ui.util.set
3742
import com.tailscale.ipn.ui.viewModel.TaildropViewModel
3843
import com.tailscale.ipn.ui.viewModel.TaildropViewModelFactory
44+
import com.tailscale.ipn.util.TSLog
3945
import kotlinx.coroutines.CoroutineScope
4046
import kotlinx.coroutines.flow.StateFlow
4147

@@ -46,36 +52,44 @@ fun TaildropView(
4652
viewModel: TaildropViewModel =
4753
viewModel(factory = TaildropViewModelFactory(requestedTransfers, applicationScope))
4854
) {
49-
Scaffold(
50-
contentWindowInsets = WindowInsets.Companion.statusBars,
51-
topBar = { Header(R.string.share) }) { paddingInsets ->
52-
val showDialog = viewModel.showDialog.collectAsState().value
55+
val TAG = "TaildropView"
56+
val focusRequester = remember { FocusRequester() }
5357

54-
// Show the error overlay
55-
showDialog?.let { ErrorDialog(type = it, action = { viewModel.showDialog.set(null) }) }
58+
// Automatically request focus when the composable is displayed
59+
LaunchedEffect(Unit) {
60+
try {
61+
focusRequester.requestFocus()
62+
} catch (e: Exception) {
63+
TSLog.w(TAG, "Focus request failed: ${e.message}")
64+
}
65+
}
5666

57-
Column(modifier = Modifier.padding(paddingInsets)) {
58-
FileShareHeader(
59-
fileTransfers = requestedTransfers.collectAsState().value,
60-
totalSize = viewModel.totalSize)
67+
Scaffold(contentWindowInsets = WindowInsets.statusBars, topBar = { Header(R.string.share) }) {
68+
paddingInsets ->
69+
Column(modifier = Modifier.focusRequester(focusRequester).focusable().padding(paddingInsets)) {
70+
val showDialog = viewModel.showDialog.collectAsState().value
6171

62-
when (viewModel.state.collectAsState().value) {
63-
Ipn.State.Running -> {
64-
val peers by viewModel.myPeers.collectAsState()
65-
val context = LocalContext.current
66-
FileSharePeerList(
67-
peers = peers,
68-
stateViewGenerator = { peerId ->
69-
viewModel.TrailingContentForPeer(peerId = peerId)
70-
},
71-
onShare = { viewModel.share(context, it) })
72-
}
73-
else -> {
74-
FileShareConnectView { viewModel.startVPN() }
75-
}
76-
}
72+
showDialog?.let { ErrorDialog(type = it, action = { viewModel.showDialog.set(null) }) }
73+
74+
FileShareHeader(
75+
fileTransfers = requestedTransfers.collectAsState().value,
76+
totalSize = viewModel.totalSize)
77+
78+
when (viewModel.state.collectAsState().value) {
79+
Ipn.State.Running -> {
80+
val peers by viewModel.myPeers.collectAsState()
81+
val context = LocalContext.current
82+
FileSharePeerList(
83+
peers = peers,
84+
stateViewGenerator = { peerId -> viewModel.TrailingContentForPeer(peerId = peerId) },
85+
onShare = { viewModel.share(context, it) })
86+
}
87+
else -> {
88+
FileShareConnectView { viewModel.startVPN() }
7789
}
7890
}
91+
}
92+
}
7993
}
8094

8195
@Composable

0 commit comments

Comments
 (0)