android: adds support for user avatars and some general cleanup (#202)

* android: show user avatars and styling fixes

Updates tailscale/corp#18202
fixes ENG-2852

Load and show the user avatar in the right places.  There's a universal Avatar composable for this that should work everywhere we need it.  This  uses the coil-compose lib which seems to be standard practice and will handle caching for us.

Restyles a few headers to match the about screen and corrects some layout issues with the height of columns.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>

* android: add localizations and view model cleanup to match IPNManager

Updates tailscale/corp#18202

Simplifies the view models a bit for readability and localizes a few things that weren't previously localized

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>

* android: fix peer categorization

Updates tailscale/corp#18202

Fixes a null predicate issue for searching and removes the self nodes if there are no matches.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>

* android: rename avatar loader to avatar and add header

Updates tailscale/corp#18202

Rename the AvatarLoader class to Avatar and move it to views.  Add the proper headers.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>

---------

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
Co-authored-by: Andrea Gottardo <andrea@tailscale.com>
pull/205/head
Jonathan Nobels 2 months ago committed by GitHub
parent f275656c25
commit 16ec19757d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -92,6 +92,8 @@ dependencies {
implementation "androidx.navigation:navigation-dynamic-features-fragment:$nav_version"
implementation "androidx.navigation:navigation-compose:$nav_version"
// Supporting libraries.
implementation("io.coil-kt:coil-compose:1.3.1")
// Tailscale dependencies.
implementation ':ipn@aar'

@ -74,6 +74,7 @@ class MainActivity : ComponentActivity() {
ExitNodePicker(ExitNodePickerViewModel(manager.model))
}
composable(
"peerDetails/{nodeId}",
arguments = listOf(navArgument("nodeId") { type = NavType.StringType })
) {
@ -120,7 +121,6 @@ class MainActivity : ComponentActivity() {
startActivity(browserIntent)
}
override fun onResume() {
super.onResume()
val restrictionsManager =

@ -35,8 +35,8 @@ class PeerCategorizer(val model: IpnModel) {
var selfPeers = (grouped[selfNode.User] ?: emptyList()).sortedBy { it.ComputedName }
grouped.remove(selfNode.User)
val currentNode = selfPeers.first { it.ID == selfNode.ID }
currentNode.let {
val currentNode = selfPeers.firstOrNull { it.ID == selfNode.ID }
currentNode?.let {
selfPeers = selfPeers.filter { it.ID != currentNode.ID }
selfPeers = listOf(currentNode) + selfPeers
}
@ -49,6 +49,10 @@ class PeerCategorizer(val model: IpnModel) {
}
val me = netmap.currentUserProfile()
return listOf(PeerSet(me, selfPeers)) + sorted
return if (selfPeers.isEmpty()) {
sorted
} else {
listOf(PeerSet(me, selfPeers)) + sorted
}
}
}

@ -17,10 +17,13 @@ class TimeUtil {
val diff = (expTime - now) / 1000
if(diff < 0){
if (diff < 0) {
return "expired"
}
// (jonathan) TODO: This is incorrect in a couple of ways
// - It needs to be in a composable so we can use stringResource
// - The string resources need to be proper plurals
return when (diff) {
in 0..60 -> "under a minute"
in 61..3600 -> "in ${diff / 60} minutes"
@ -42,4 +45,5 @@ class TimeUtil {
val i = Instant.from(ta)
return Date.from(i)
}
}
}

@ -0,0 +1,51 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Person
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import coil.annotation.ExperimentalCoilApi
import coil.compose.rememberImagePainter
import com.tailscale.ipn.ui.model.IpnLocal
@OptIn(ExperimentalCoilApi::class)
@Composable
fun Avatar(profile: IpnLocal.LoginProfile?, size: Int = 50) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(size.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.tertiaryContainer)
) {
profile?.UserProfile?.ProfilePicURL?.let { url ->
val painter = rememberImagePainter(data = url)
Image(
painter = painter,
contentDescription = null,
modifier = Modifier.size(size.dp)
)
} ?: run {
Icon(
imageVector = Icons.Default.Person,
contentDescription = null,
tint = MaterialTheme.colorScheme.onTertiaryContainer,
modifier = Modifier.size((size * .8f).dp)
)
}
}
}

@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
@ -46,10 +47,11 @@ fun BugReportView(viewModel: BugReportViewModel) {
val handler = LocalUriHandler.current
Surface(color = MaterialTheme.colorScheme.surface) {
Column(modifier = defaultPaddingModifier().fillMaxWidth()) {
Column(modifier = defaultPaddingModifier().fillMaxWidth().fillMaxHeight()) {
Text(text = stringResource(id = R.string.bug_report_title),
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.height(8.dp))
@ -70,6 +72,7 @@ fun BugReportView(viewModel: BugReportViewModel) {
Text(text = stringResource(id = R.string.bug_report_id_desc),
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Left,
color = MaterialTheme.colorScheme.secondary,
style = MaterialTheme.typography.bodySmall)
}
}
@ -98,7 +101,9 @@ fun ReportIdRow(bugReportIdFlow: StateFlow<String>) {
@Composable
fun contactText(): AnnotatedString {
val annotatedString = buildAnnotatedString {
append(stringResource(id = R.string.bug_report_instructions_prefix))
withStyle(style = SpanStyle(color = MaterialTheme.colorScheme.primary)) {
append(stringResource(id = R.string.bug_report_instructions_prefix))
}
pushStringAnnotation(tag = "reportLink", annotation = Links.SUPPORT_URL)
withStyle(style = SpanStyle(color = Color.Blue)) {
@ -106,7 +111,9 @@ fun contactText(): AnnotatedString {
}
pop()
append(stringResource(id = R.string.bug_report_instructions_suffix))
withStyle(style = SpanStyle(color = MaterialTheme.colorScheme.primary)) {
append(stringResource(id = R.string.bug_report_instructions_suffix))
}
}
return annotatedString
}

@ -76,9 +76,11 @@ fun MainView(viewModel: MainViewModel, navigation: MainViewNavigation) {
val isOn = viewModel.vpnToggleState.collectAsState(initial = false)
Switch(onCheckedChange = { viewModel.toggleVpn() }, checked = isOn.value)
StateDisplay(viewModel.stateStr, viewModel.userName)
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) {
SettingsButton(user.value, navigation.onNavigateToSettings)
StateDisplay(viewModel.stateRes, viewModel.userName)
Box(modifier = Modifier
.weight(1f)
.clickable { navigation.onNavigateToSettings() }, contentAlignment = Alignment.CenterEnd) {
Avatar(profile = user.value, size = 36)
}
}
@ -128,12 +130,13 @@ fun ExitNodeStatus(navAction: () -> Unit, exitNode: String = stringResource(id =
}
@Composable
fun StateDisplay(state: StateFlow<String>, tailnet: String) {
val stateStr = state.collectAsState(initial = "--")
fun StateDisplay(state: StateFlow<Int>, tailnet: String) {
val stateVal = state.collectAsState(initial = R.string.placeholder)
val stateStr = stringResource(id = stateVal.value)
Column(modifier = Modifier.padding(6.dp)) {
Text(text = "${tailnet}", style = MaterialTheme.typography.titleMedium)
Text(text = "${stateStr.value}", style = MaterialTheme.typography.bodyMedium)
Text(text = tailnet, style = MaterialTheme.typography.titleMedium)
Text(text = stateStr, style = MaterialTheme.typography.bodyMedium)
}
}
@ -158,10 +161,15 @@ fun StartingView() {
modifier =
Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.secondaryContainer),
.background(MaterialTheme.colorScheme.background),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) { Text(text = stringResource(id = R.string.starting), style = MaterialTheme.typography.titleMedium) }
) {
Text(text = stringResource(id = R.string.starting),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
)
}
}
@Composable
@ -170,15 +178,18 @@ fun ConnectView(user: IpnLocal.LoginProfile?, connectAction: () -> Unit, loginAc
modifier =
Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.secondaryContainer),
.background(MaterialTheme.colorScheme.background),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(text = stringResource(id = R.string.not_connected), style = MaterialTheme.typography.titleMedium)
Text(text = stringResource(id = R.string.not_connected),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary)
if (user != null && !user.isEmpty()) {
val tailnetName = user.NetworkProfile?.DomainName ?: ""
Text(stringResource(id = R.string.connect_to_tailnet, tailnetName),
style = MaterialTheme.typography.bodyMedium
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary
)
Button(onClick = connectAction) { Text(text = stringResource(id = R.string.connect)) }
} else {

@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@ -36,24 +37,33 @@ import com.tailscale.ipn.ui.viewModel.PeerDetailsViewModel
fun PeerDetails(viewModel: PeerDetailsViewModel) {
Surface(color = MaterialTheme.colorScheme.surface) {
Column(modifier = Modifier.padding(horizontal = 8.dp)) {
Column(modifier = Modifier.padding(horizontal = 8.dp).fillMaxHeight()) {
Column(modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally) {
Text(text = viewModel.nodeName, style = MaterialTheme.typography.titleMedium)
Text(text = viewModel.nodeName,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
)
Row(verticalAlignment = Alignment.CenterVertically) {
Box(modifier = Modifier
.size(8.dp)
.background(color = viewModel.connectedColor, shape = RoundedCornerShape(percent = 50))) {}
Spacer(modifier = Modifier.size(8.dp))
Text(text = stringResource(id = viewModel.connectedStrRes), style = MaterialTheme.typography.bodyMedium)
Text(text = stringResource(id = viewModel.connectedStrRes),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary
)
}
}
Spacer(modifier = Modifier.size(8.dp))
Text(text = stringResource(id = R.string.addresses_section), style = MaterialTheme.typography.titleMedium)
Text(text = stringResource(id = R.string.addresses_section),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
)
Column(modifier = settingsRowModifier()) {
viewModel.addresses.forEach {

@ -5,10 +5,13 @@
package com.tailscale.ipn.ui.view
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.ClickableText
@ -30,9 +33,9 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.Links
import com.tailscale.ipn.ui.model.IpnLocal
@ -55,7 +58,21 @@ fun Settings(viewModel: SettingsViewModel) {
Surface(color = MaterialTheme.colorScheme.surface) {
Column(modifier = defaultPaddingModifier()) {
Column(modifier = defaultPaddingModifier().fillMaxHeight()) {
Text(text = stringResource(id = R.string.settings_title),
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.height(8.dp))
// The login/logout button here is probably in the wrong location, but we need something
// somewhere for the time being. FUS should probably be implemented for V0 given that
// it's relatively simple to do so with localAPI. On iOS, the UI for user switching is
// all in the FUS screen.
viewModel.user?.let { user ->
UserView(profile = user, viewModel.isAdmin, adminText(), onClick = {
handler.openUri(Links.ADMIN_URL)
@ -70,7 +87,6 @@ fun Settings(viewModel: SettingsViewModel) {
}
}
Spacer(modifier = Modifier.height(8.dp))
viewModel.settings.forEach { settingBundle ->
@ -102,11 +118,18 @@ fun Settings(viewModel: SettingsViewModel) {
@Composable
fun UserView(profile: IpnLocal.LoginProfile?, isAdmin: Boolean, adminText: AnnotatedString, onClick: () -> Unit) {
Column(modifier = defaultPaddingModifier()) {
Column(modifier = settingsRowModifier().padding(8.dp)) {
Text(text = profile?.UserProfile?.DisplayName
?: "", style = MaterialTheme.typography.titleMedium)
Text(text = profile?.Name ?: "", style = MaterialTheme.typography.bodyMedium)
Column {
Row(modifier = settingsRowModifier().padding(8.dp)) {
Box(modifier = defaultPaddingModifier()) {
Avatar(profile = profile, size = 36)
}
Column(verticalArrangement = Arrangement.Center) {
Text(text = profile?.UserProfile?.DisplayName
?: "", style = MaterialTheme.typography.titleMedium)
Text(text = profile?.Name ?: "", style = MaterialTheme.typography.bodyMedium)
}
}
if (isAdmin) {
@ -150,7 +173,9 @@ fun SettingsSwitchRow(setting: Setting) {
@Composable
fun adminText(): AnnotatedString {
val annotatedString = buildAnnotatedString {
append(stringResource(id = R.string.settings_admin_prefix))
withStyle(style = SpanStyle(color = MaterialTheme.colorScheme.primary)) {
append(stringResource(id = R.string.settings_admin_prefix))
}
pushStringAnnotation(tag = "link", annotation = Links.ADMIN_URL)
withStyle(style = SpanStyle(color = Color.Blue)) {

@ -7,21 +7,21 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.tailscale.ipn.ui.localapi.LocalApiClient
import com.tailscale.ipn.ui.model.BugReportID
import com.tailscale.ipn.ui.service.set
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
class BugReportViewModel(localAPI: LocalApiClient) : ViewModel() {
private var _bugReportID: MutableStateFlow<BugReportID> = MutableStateFlow("")
var bugReportID: StateFlow<String> = _bugReportID
var bugReportID: StateFlow<BugReportID> = MutableStateFlow("")
init {
viewModelScope.launch {
localAPI.getBugReportId {
when (it.successful) {
true -> _bugReportID.value = it.success ?: "(Error fetching ID)"
false -> _bugReportID.value = "(Error fetching ID)"
true -> bugReportID.set(it.success ?: "(Error fetching ID)")
false -> bugReportID.set("(Error fetching ID)")
}
}
}

@ -6,64 +6,57 @@ package com.tailscale.ipn.ui.viewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.model.Ipn.State
import com.tailscale.ipn.ui.service.IpnActions
import com.tailscale.ipn.ui.service.IpnModel
import com.tailscale.ipn.ui.service.set
import com.tailscale.ipn.ui.util.PeerCategorizer
import com.tailscale.ipn.ui.util.PeerSet
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
class MainViewModel(val model: IpnModel, val actions: IpnActions) : ViewModel() {
private val _stateStr = MutableStateFlow<String>("")
private val _tailnetName = MutableStateFlow<String>("")
private val _vpnToggleState = MutableStateFlow<Boolean>(false)
private val _peers = MutableStateFlow<List<PeerSet>>(emptyList<PeerSet>())
// The user readable state of the system
val stateStr = _stateStr.asStateFlow()
// The current state of the IPN for determining view visibility
val ipnState = model.state
// The name of the tailnet
val tailnetName = _tailnetName.asStateFlow()
val stateRes: StateFlow<Int> = MutableStateFlow(State.NoState.userStringRes())
// The expected state of the VPN toggle
val vpnToggleState = _vpnToggleState.asStateFlow()
val vpnToggleState: StateFlow<Boolean> = MutableStateFlow(false)
// The list of peers
val peers = _peers.asStateFlow()
val peers: StateFlow<List<PeerSet>> = MutableStateFlow(emptyList<PeerSet>())
// The current state of the IPN for determining view visibility
val ipnState = model.state
// The logged in user
val loggedInUser = model.loggedInUser
// The active search term for filtering peers
val searchTerm = MutableStateFlow<String>("")
val searchTerm: StateFlow<String> = MutableStateFlow("")
init {
viewModelScope.launch {
model.state.collect { state ->
_stateStr.value = state.userString()
_vpnToggleState.value = (state == State.Running || state == State.Starting)
stateRes.set(state.userStringRes())
vpnToggleState.set((state == State.Running || state == State.Starting))
}
}
viewModelScope.launch {
model.netmap.collect { netmap ->
_tailnetName.value = netmap?.Domain ?: ""
_peers.value = PeerCategorizer(model).groupedAndFilteredPeers(searchTerm.value)
peers.set(PeerCategorizer(model).groupedAndFilteredPeers(searchTerm.value))
}
}
}
fun searchPeers(searchTerm: String) {
this.searchTerm.value = searchTerm
this.searchTerm.set(searchTerm)
viewModelScope.launch {
_peers.value = PeerCategorizer(model).groupedAndFilteredPeers(searchTerm)
peers.set(PeerCategorizer(model).groupedAndFilteredPeers(searchTerm))
}
}
@ -85,15 +78,15 @@ class MainViewModel(val model: IpnModel, val actions: IpnActions) : ViewModel()
}
private fun State?.userString(): String {
private fun State?.userStringRes(): Int {
return when (this) {
State.NoState -> "Waiting..."
State.InUseOtherUser -> "--"
State.NeedsLogin -> "Please Login"
State.NeedsMachineAuth -> "--"
State.Stopped -> "Stopped"
State.Starting -> "Starting"
State.Running -> "Connected"
else -> "--"
State.NoState -> R.string.waiting
State.InUseOtherUser -> R.string.placeholder
State.NeedsLogin -> R.string.please_login
State.NeedsMachineAuth -> R.string.placeholder
State.Stopped -> R.string.stopped
State.Starting -> R.string.starting
State.Running -> R.string.connected
else -> R.string.placeholder
}
}

@ -30,14 +30,14 @@
<!-- Strings for the settings screen -->
<string name="settings_title">Settings</string>
<string name="settings_admin_prefix">You can manage your account from the admin console.&#160;</string>
<string name="settings_admin_link">View admin console...</string>
<string name="settings_admin_link">View admin console</string>
<string name="about">About</string>
<string name="bug_report">Bug Report</string>
<string name="use_ts_dns">Use Tailscale DNS</string>
<!-- Strings for the main scren -->
<string name="exit_node">Exit Node</string>
<string name="starting">Staring...</string>
<string name="starting">Starting…</string>
<string name="connect_to_tailnet">"Connect to your %1$s tailnet"</string>
<!-- Strings for peer details -->
@ -49,5 +49,19 @@
<string name="current_mdm_settings">Current MDM Settings</string>
<string name="mdm_settings">MDM Settings</string>
<!-- State strings -->
<string name="waiting">Waiting…</string>
<string name="placeholder">--</string>
<string name="please_login">Please Login</string>
<string name="stopped">Stopped</string>
<!-- Time conversion templates -->
<string name="expired">expired</string>
<string name="under_a_minute">under a minute</string>
<string name="in_x_minutes">in %1$s minutes</string>
<string name="in_x_hours">in %1$s hours</string>
<string name="in_x_days">in %1$s days</string>
<string name="in_x_months">in %1$s months</string>
<string name="in_x_years">in %1$s years</string>
</resources>

Loading…
Cancel
Save