From 960af02bebdab7efc02e477d2e19b2254fcb8e02 Mon Sep 17 00:00:00 2001 From: Zane Schepke Date: Sun, 13 Oct 2024 00:20:57 -0400 Subject: [PATCH] feat: improve stats ui --- .../wireguardautotunnel/ui/MainActivity.kt | 1 - .../ui/common/ExpandingRowListItem.kt | 69 ++++ .../ui/common/RowListItem.kt | 108 ------- .../ui/screens/main/MainScreen.kt | 303 ++---------------- .../ui/screens/main/MainViewModel.kt | 11 +- .../main/components/AutoTunnelRowItem.kt | 78 +++++ .../screens/main/components/TunnelRowItem.kt | 204 ++++++++++++ .../main/components/TunnelStatisticsBox.kt | 56 ++++ .../wireguardautotunnel/ui/theme/Theme.kt | 14 +- .../util/extensions/TunnelExtensions.kt | 16 + 10 files changed, 458 insertions(+), 402 deletions(-) create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/ExpandingRowListItem.kt delete mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/RowListItem.kt create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/components/AutoTunnelRowItem.kt create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/components/TunnelRowItem.kt create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/components/TunnelStatisticsBox.kt diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/MainActivity.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/MainActivity.kt index 78a5ad6..cdfbe96 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/MainActivity.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/MainActivity.kt @@ -62,7 +62,6 @@ import com.zaneschepke.wireguardautotunnel.ui.theme.WireguardAutoTunnelTheme import com.zaneschepke.wireguardautotunnel.util.Constants import com.zaneschepke.wireguardautotunnel.util.extensions.requestTunnelTileServiceStateUpdate import dagger.hilt.android.AndroidEntryPoint -import xyz.teamgravity.pin_lock_compose.PinManager import javax.inject.Inject @AndroidEntryPoint diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/ExpandingRowListItem.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/ExpandingRowListItem.kt new file mode 100644 index 0000000..a83a97d --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/ExpandingRowListItem.kt @@ -0,0 +1,69 @@ +package com.zaneschepke.wireguardautotunnel.ui.common + +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +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.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +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.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun ExpandingRowListItem( + leading: @Composable () -> Unit, + text: String, + onHold: () -> Unit = {}, + onClick: () -> Unit, + trailing: @Composable () -> Unit, + isExpanded: Boolean, + expanded: @Composable () -> Unit = {}, + focusRequester: FocusRequester, +) { + Box( + modifier = + Modifier + .focusRequester(focusRequester) + .animateContentSize() + .clip(RoundedCornerShape(30.dp)) + .combinedClickable( + onClick = { onClick() }, + onLongClick = { onHold() }, + ), + ) { + Column { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 15.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(15.dp), + modifier = Modifier.fillMaxWidth(13 / 20f), + ) { + leading() + Text(text, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.labelLarge) + } + trailing() + } + if (isExpanded) expanded() + } + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/RowListItem.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/RowListItem.kt deleted file mode 100644 index 3f7832c..0000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/RowListItem.kt +++ /dev/null @@ -1,108 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.ui.common - -import androidx.compose.animation.animateContentSize -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.combinedClickable -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.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -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.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import com.zaneschepke.wireguardautotunnel.R -import com.zaneschepke.wireguardautotunnel.service.tunnel.statistics.TunnelStatistics -import com.zaneschepke.wireguardautotunnel.util.NumberUtils -import com.zaneschepke.wireguardautotunnel.util.extensions.toThreeDecimalPlaceString - -@OptIn(ExperimentalFoundationApi::class) -@Composable -fun RowListItem( - icon: @Composable () -> Unit, - text: String, - onHold: () -> Unit, - onClick: () -> Unit, - rowButton: @Composable () -> Unit, - expanded: Boolean, - statistics: TunnelStatistics?, - focusRequester: FocusRequester, -) { - Box( - modifier = - Modifier - .focusRequester(focusRequester) - .animateContentSize() - .clip(RoundedCornerShape(30.dp)) - .combinedClickable( - onClick = { onClick() }, - onLongClick = { onHold() }, - ), - ) { - Column { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 15.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(15.dp), - modifier = Modifier.fillMaxWidth(13 / 20f), - ) { - icon() - Text(text, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.labelLarge) - } - rowButton() - } - if (expanded) { - statistics?.getPeers()?.forEach { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(end = 10.dp, bottom = 10.dp, start = 45.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(30.dp, Alignment.Start), - ) { - val handshakeEpoch = statistics.peerStats(it)!!.latestHandshakeEpochMillis - val peerId = it.toBase64().subSequence(0, 3).toString() + "***" - val handshakeSec = - NumberUtils.getSecondsBetweenTimestampAndNow(handshakeEpoch) - val handshake = - if (handshakeSec == null) stringResource(R.string.never) else "$handshakeSec " + stringResource(R.string.sec) - val peerTx = statistics.peerStats(it)!!.txBytes - val peerRx = statistics.peerStats(it)!!.rxBytes - val peerTxMB = NumberUtils.bytesToMB(peerTx).toThreeDecimalPlaceString() - val peerRxMB = NumberUtils.bytesToMB(peerRx).toThreeDecimalPlaceString() - Column( - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - Text(stringResource(R.string.peer).lowercase() + ": $peerId", style = MaterialTheme.typography.bodySmall) - Text("tx: $peerTxMB MB", style = MaterialTheme.typography.bodySmall) - } - Column( - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - Text(stringResource(R.string.handshake) + ": $handshake", style = MaterialTheme.typography.bodySmall) - Text("rx: $peerRxMB MB", style = MaterialTheme.typography.bodySmall) - } - } - } - } - } - } -} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/MainScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/MainScreen.kt index a04adbf..c267f31 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/MainScreen.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/MainScreen.kt @@ -1,39 +1,26 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.main import android.annotation.SuppressLint +import android.net.VpnService import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity.RESULT_OK import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.focusable import androidx.compose.foundation.gestures.ScrollableDefaults import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.overscroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.rounded.Bolt -import androidx.compose.material.icons.rounded.Circle -import androidx.compose.material.icons.rounded.CopyAll -import androidx.compose.material.icons.rounded.Delete -import androidx.compose.material.icons.rounded.Info -import androidx.compose.material.icons.rounded.Settings -import androidx.compose.material.icons.rounded.Smartphone -import androidx.compose.material.icons.rounded.Star import androidx.compose.material3.FabPosition import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold -import androidx.compose.material3.Switch import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -45,41 +32,30 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.buildAnnotatedString import androidx.hilt.navigation.compose.hiltViewModel import com.journeyapps.barcodescanner.ScanContract import com.journeyapps.barcodescanner.ScanOptions -import com.wireguard.android.backend.GoBackend import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig -import com.zaneschepke.wireguardautotunnel.service.tunnel.HandshakeStatus -import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelState import com.zaneschepke.wireguardautotunnel.ui.AppUiState import com.zaneschepke.wireguardautotunnel.ui.Route import com.zaneschepke.wireguardautotunnel.ui.common.NestedScrollListener -import com.zaneschepke.wireguardautotunnel.ui.common.RowListItem import com.zaneschepke.wireguardautotunnel.ui.common.dialog.InfoDialog import com.zaneschepke.wireguardautotunnel.ui.common.functions.rememberFileImportLauncherForResult import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController +import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.AutoTunnelRowItem import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.GettingStartedLabel import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.ScrollDismissFab import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.TunnelImportSheet +import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.TunnelRowItem import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.VpnDeniedDialog -import com.zaneschepke.wireguardautotunnel.ui.theme.SilverTree -import com.zaneschepke.wireguardautotunnel.ui.theme.Corn -import com.zaneschepke.wireguardautotunnel.ui.theme.iconSize import com.zaneschepke.wireguardautotunnel.util.Constants -import com.zaneschepke.wireguardautotunnel.util.extensions.handshakeStatus import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv -import com.zaneschepke.wireguardautotunnel.util.extensions.mapPeerStats import com.zaneschepke.wireguardautotunnel.util.extensions.openWebUrl import com.zaneschepke.wireguardautotunnel.util.extensions.startTunnelBackground import kotlinx.coroutines.delay @@ -88,7 +64,6 @@ import kotlinx.coroutines.delay @OptIn(ExperimentalFoundationApi::class) @Composable fun MainScreen(viewModel: MainViewModel = hiltViewModel(), uiState: AppUiState, focusRequester: FocusRequester) { - val haptic = LocalHapticFeedback.current val context = LocalContext.current val navController = LocalNavController.current val snackbar = SnackbarController.current @@ -114,7 +89,7 @@ fun MainScreen(viewModel: MainViewModel = hiltViewModel(), uiState: AppUiState, LaunchedEffect(Unit) { if (context.isRunningOnTv()) { delay(Constants.FOCUS_REQUEST_DELAY) - kotlin.runCatching { + runCatching { focusRequester.requestFocus() }.onFailure { delay(Constants.FOCUS_REQUEST_DELAY) @@ -158,6 +133,8 @@ fun MainScreen(viewModel: MainViewModel = hiltViewModel(), uiState: AppUiState, } fun onTunnelToggle(checked: Boolean, tunnel: TunnelConfig) { + val intent = if (uiState.settings.isKernelEnabled) null else VpnService.prepare(context) + if (intent != null) return vpnActivityResultState.launch(intent) if (!checked) viewModel.onTunnelStop(tunnel).also { return } if (uiState.settings.isKernelEnabled) { context.startTunnelBackground(tunnel.id) @@ -180,13 +157,12 @@ fun MainScreen(viewModel: MainViewModel = hiltViewModel(), uiState: AppUiState, Scaffold( modifier = Modifier.pointerInput(Unit) { - if (uiState.tunnels.isNotEmpty()) { - detectTapGestures( - onTap = { - selectedTunnel = null - }, - ) - } + if (uiState.tunnels.isEmpty()) return@pointerInput + detectTapGestures( + onTap = { + selectedTunnel = null + }, + ) }, floatingActionButtonPosition = FabPosition.End, floatingActionButton = { @@ -231,68 +207,9 @@ fun MainScreen(viewModel: MainViewModel = hiltViewModel(), uiState: AppUiState, GettingStartedLabel(onClick = { context.openWebUrl(it) }) } } - item { - if (uiState.settings.isAutoTunnelEnabled) { - val itemFocusRequester = remember { FocusRequester() } - val autoTunnelingLabel = - buildAnnotatedString { - append(stringResource(id = R.string.auto_tunneling)) - append(": ") - if (uiState.settings.isAutoTunnelPaused) { - append( - stringResource(id = R.string.paused), - ) - } else { - append( - stringResource(id = R.string.active), - ) - } - } - RowListItem( - icon = { - val icon = Icons.Rounded.Bolt - Icon( - icon, - icon.name, - modifier = - Modifier - .size(iconSize), - tint = - if (uiState.settings.isAutoTunnelPaused) { - Color.Gray - } else { - SilverTree - }, - ) - }, - text = autoTunnelingLabel.text, - rowButton = { - if (uiState.settings.isAutoTunnelPaused) { - TextButton( - modifier = Modifier.focusRequester(itemFocusRequester), - onClick = { viewModel.resumeAutoTunneling() }, - ) { - Text(stringResource(id = R.string.resume)) - } - } else { - TextButton( - modifier = Modifier.focusRequester(itemFocusRequester), - onClick = { viewModel.pauseAutoTunneling() }, - ) { - Text(stringResource(id = R.string.pause)) - } - } - }, - onClick = { - if (context.isRunningOnTv()) { - itemFocusRequester.requestFocus() - } - }, - onHold = {}, - expanded = false, - statistics = null, - focusRequester = focusRequester, - ) + if (uiState.settings.isAutoTunnelEnabled) { + item { + AutoTunnelRowItem(uiState.settings, { viewModel.onToggleAutoTunnelingPause() }, focusRequester) } } items( @@ -304,186 +221,18 @@ fun MainScreen(viewModel: MainViewModel = hiltViewModel(), uiState: AppUiState, it.isActive } val expanded = uiState.generalState.isTunnelStatsExpanded - val leadingIconColor = - ( - if ( - isActive && uiState.vpnState.statistics != null - ) { - uiState.vpnState.statistics.mapPeerStats() - .map { it.value?.handshakeStatus() } - .let { statuses -> - when { - statuses.all { it == HandshakeStatus.HEALTHY } -> SilverTree - statuses.any { it == HandshakeStatus.STALE } -> Corn - statuses.all { it == HandshakeStatus.NOT_STARTED } -> - Color.Gray - - else -> { - Color.Gray - } - } - } - } else { - Color.Gray - } - ) - val itemFocusRequester = remember { FocusRequester() } - RowListItem( - icon = { - val circleIcon = Icons.Rounded.Circle - val icon = - if (tunnel.isPrimaryTunnel) { - Icons.Rounded.Star - } else if (tunnel.isMobileDataTunnel) { - Icons.Rounded.Smartphone - } else { - circleIcon - } - Icon( - icon, - icon.name, - tint = leadingIconColor, - modifier = Modifier.size(iconSize), - ) - }, - text = tunnel.name, - onHold = { - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - selectedTunnel = tunnel - }, - onClick = { - if (!context.isRunningOnTv()) { - if ( - isActive - ) { - viewModel.onExpandedChanged(!expanded) - } - } else { - selectedTunnel = tunnel - itemFocusRequester.requestFocus() - } - }, - statistics = uiState.vpnState.statistics, - expanded = expanded && isActive, + TunnelRowItem( + isActive, + expanded, + selectedTunnel?.id == tunnel.id, + tunnel, + vpnState = uiState.vpnState, + { selectedTunnel = tunnel }, + { viewModel.onExpandedChanged(!expanded) }, + onDelete = { showDeleteTunnelAlertDialog = true }, + onCopy = { viewModel.onCopyTunnel(tunnel) }, + onSwitchClick = { onTunnelToggle(it, tunnel) }, focusRequester = focusRequester, - rowButton = { - if ( - tunnel.id == selectedTunnel?.id && - !context.isRunningOnTv() - ) { - Row { - IconButton( - onClick = { - selectedTunnel?.let { - navController.navigate( - Route.Option(it.id), - ) - } - }, - ) { - val icon = Icons.Rounded.Settings - Icon( - icon, - icon.name, - ) - } - IconButton( - modifier = Modifier.focusable(), - onClick = { viewModel.onCopyTunnel(selectedTunnel) }, - ) { - val icon = Icons.Rounded.CopyAll - Icon(icon, icon.name) - } - IconButton( - enabled = !isActive, - modifier = Modifier.focusable(), - onClick = { showDeleteTunnelAlertDialog = true }, - ) { - val icon = Icons.Rounded.Delete - Icon(icon, icon.name) - } - } - } else { - @Composable - fun TunnelSwitch() = Switch( - modifier = Modifier.focusRequester(itemFocusRequester), - checked = isActive, - onCheckedChange = { checked -> - val intent = if (uiState.settings.isKernelEnabled) null else GoBackend.VpnService.prepare(context) - if (intent != null) return@Switch vpnActivityResultState.launch(intent) - onTunnelToggle(checked, tunnel) - }, - ) - if (context.isRunningOnTv()) { - Row { - IconButton( - onClick = { - selectedTunnel = tunnel - selectedTunnel?.let { - navController.navigate( - Route.Option(it.id), - ) - } - }, - ) { - val icon = Icons.Rounded.Settings - Icon( - icon, - icon.name, - ) - } - IconButton( - modifier = Modifier.focusRequester(focusRequester), - onClick = { - if ( - uiState.vpnState.status == TunnelState.UP && - (uiState.vpnState.tunnelConfig?.name == tunnel.name) - ) { - viewModel.onExpandedChanged(!expanded) - } else { - snackbar.showMessage( - context.getString(R.string.turn_on_tunnel), - ) - } - }, - ) { - val icon = Icons.Rounded.Info - Icon(icon, icon.name) - } - IconButton( - onClick = { viewModel.onCopyTunnel(tunnel) }, - ) { - val icon = Icons.Rounded.CopyAll - Icon(icon, icon.name) - } - IconButton( - onClick = { - if ( - uiState.vpnState.status == TunnelState.UP && - tunnel.name == uiState.vpnState.tunnelConfig?.name - ) { - snackbar.showMessage( - context.getString(R.string.turn_off_tunnel), - ) - } else { - selectedTunnel = tunnel - showDeleteTunnelAlertDialog = true - } - }, - ) { - val icon = Icons.Rounded.Delete - Icon( - icon, - icon.name, - ) - } - TunnelSwitch() - } - } else { - TunnelSwitch() - } - } - }, ) } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/MainViewModel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/MainViewModel.kt index e2b4e7b..8792d60 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/MainViewModel.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/MainViewModel.kt @@ -194,17 +194,10 @@ constructor( saveTunnelConfigFromStream(stream, name) } - fun pauseAutoTunneling() = viewModelScope.launch { + fun onToggleAutoTunnelingPause() = viewModelScope.launch { val settings = appDataRepository.settings.getSettings() appDataRepository.settings.save( - settings.copy(isAutoTunnelPaused = true), - ) - } - - fun resumeAutoTunneling() = viewModelScope.launch { - val settings = appDataRepository.settings.getSettings() - appDataRepository.settings.save( - settings.copy(isAutoTunnelPaused = false), + settings.copy(isAutoTunnelPaused = !settings.isAutoTunnelPaused), ) } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/components/AutoTunnelRowItem.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/components/AutoTunnelRowItem.kt new file mode 100644 index 0000000..54f6170 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/components/AutoTunnelRowItem.kt @@ -0,0 +1,78 @@ +package com.zaneschepke.wireguardautotunnel.ui.screens.main.components + +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Bolt +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.buildAnnotatedString +import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.data.domain.Settings +import com.zaneschepke.wireguardautotunnel.ui.common.ExpandingRowListItem +import com.zaneschepke.wireguardautotunnel.ui.theme.SilverTree +import com.zaneschepke.wireguardautotunnel.ui.theme.iconSize +import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv + +@Composable +fun AutoTunnelRowItem(settings: Settings, onToggle: () -> Unit, focusRequester: FocusRequester) { + val context = LocalContext.current + val itemFocusRequester = remember { FocusRequester() } + val autoTunnelingLabel = + buildAnnotatedString { + append(stringResource(id = R.string.auto_tunneling)) + append(": ") + if (settings.isAutoTunnelPaused) { + append( + stringResource(id = R.string.paused), + ) + } else { + append( + stringResource(id = R.string.active), + ) + } + } + ExpandingRowListItem( + leading = { + val icon = Icons.Rounded.Bolt + Icon( + icon, + icon.name, + modifier = + Modifier + .size(iconSize).scale(1.5f), + tint = + if (settings.isAutoTunnelPaused) { + Color.Gray + } else { + SilverTree + }, + ) + }, + text = autoTunnelingLabel.text, + trailing = { + TextButton( + modifier = Modifier.focusRequester(itemFocusRequester), + onClick = { onToggle() }, + ) { + Text(stringResource(id = if (settings.isAutoTunnelPaused) R.string.resume else R.string.pause)) + } + }, + onClick = { + if (context.isRunningOnTv()) { + itemFocusRequester.requestFocus() + } + }, + isExpanded = false, + focusRequester = focusRequester, + ) +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/components/TunnelRowItem.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/components/TunnelRowItem.kt new file mode 100644 index 0000000..cf8201a --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/components/TunnelRowItem.kt @@ -0,0 +1,204 @@ +package com.zaneschepke.wireguardautotunnel.ui.screens.main.components + +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Circle +import androidx.compose.material.icons.rounded.CopyAll +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.Info +import androidx.compose.material.icons.rounded.Settings +import androidx.compose.material.icons.rounded.Smartphone +import androidx.compose.material.icons.rounded.Star +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Switch +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback +import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig +import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnState +import com.zaneschepke.wireguardautotunnel.ui.Route +import com.zaneschepke.wireguardautotunnel.ui.common.ExpandingRowListItem +import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController +import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController +import com.zaneschepke.wireguardautotunnel.ui.theme.iconSize +import com.zaneschepke.wireguardautotunnel.util.extensions.asColor +import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv + +@Composable +fun TunnelRowItem( + isActive: Boolean, + expanded: Boolean, + isSelected: Boolean, + tunnel: TunnelConfig, + vpnState: VpnState, + onHold: () -> Unit, + onClick: () -> Unit, + onCopy: () -> Unit, + onDelete: () -> Unit, + onSwitchClick: (checked: Boolean) -> Unit, + focusRequester: FocusRequester, +) { + val leadingIconColor = if (!isActive) Color.Gray else vpnState.statistics.asColor() + val context = LocalContext.current + val snackbar = SnackbarController.current + val navController = LocalNavController.current + val haptic = LocalHapticFeedback.current + val itemFocusRequester = remember { FocusRequester() } + ExpandingRowListItem( + leading = { + val circleIcon = Icons.Rounded.Circle + val icon = + if (tunnel.isPrimaryTunnel) { + Icons.Rounded.Star + } else if (tunnel.isMobileDataTunnel) { + Icons.Rounded.Smartphone + } else { + circleIcon + } + Icon( + icon, + icon.name, + tint = leadingIconColor, + modifier = Modifier.size(iconSize), + ) + }, + text = tunnel.name, + onHold = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + onHold() + }, + onClick = { + if (!context.isRunningOnTv()) { + if (isActive) { + onClick() + } + } else { + onHold() + itemFocusRequester.requestFocus() + } + }, + isExpanded = expanded && isActive, + expanded = { if (isActive && expanded) TunnelStatisticsBox(vpnState.statistics, tunnel) }, + focusRequester = focusRequester, + trailing = { + if ( + isSelected && + !context.isRunningOnTv() + ) { + Row { + IconButton( + onClick = { + navController.navigate( + Route.Option(tunnel.id), + ) + }, + ) { + val icon = Icons.Rounded.Settings + Icon( + icon, + icon.name, + ) + } + IconButton( + modifier = Modifier.focusable(), + onClick = { onCopy() }, + ) { + val icon = Icons.Rounded.CopyAll + Icon(icon, icon.name) + } + IconButton( + enabled = !isActive, + modifier = Modifier.focusable(), + onClick = { onDelete() }, + ) { + val icon = Icons.Rounded.Delete + Icon(icon, icon.name) + } + } + } else { + if (context.isRunningOnTv()) { + Row { + IconButton( + onClick = { + onHold() + navController.navigate( + Route.Option(tunnel.id), + ) + }, + ) { + val icon = Icons.Rounded.Settings + Icon( + icon, + icon.name, + ) + } + IconButton( + modifier = Modifier.focusRequester(focusRequester), + onClick = { + if (isActive) { + onClick() + } else { + snackbar.showMessage( + context.getString(R.string.turn_on_tunnel), + ) + } + }, + ) { + val icon = Icons.Rounded.Info + Icon(icon, icon.name) + } + IconButton( + onClick = { onCopy() }, + ) { + val icon = Icons.Rounded.CopyAll + Icon(icon, icon.name) + } + IconButton( + onClick = { + if (isActive) { + snackbar.showMessage( + context.getString(R.string.turn_off_tunnel), + ) + } else { + onHold() + onDelete() + } + }, + ) { + val icon = Icons.Rounded.Delete + Icon( + icon, + icon.name, + ) + } + Switch( + modifier = Modifier.focusRequester(itemFocusRequester), + checked = isActive, + onCheckedChange = { checked -> + onSwitchClick(checked) + }, + ) + } + } else { + Switch( + modifier = Modifier.focusRequester(itemFocusRequester), + checked = isActive, + onCheckedChange = { checked -> + onSwitchClick(checked) + }, + ) + } + } + }, + ) +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/components/TunnelStatisticsBox.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/components/TunnelStatisticsBox.kt new file mode 100644 index 0000000..f2cd4fe --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/components/TunnelStatisticsBox.kt @@ -0,0 +1,56 @@ +package com.zaneschepke.wireguardautotunnel.ui.screens.main.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig +import com.zaneschepke.wireguardautotunnel.service.tunnel.statistics.TunnelStatistics +import com.zaneschepke.wireguardautotunnel.util.NumberUtils +import com.zaneschepke.wireguardautotunnel.util.extensions.toThreeDecimalPlaceString + +@Composable +fun TunnelStatisticsBox(statistics: TunnelStatistics?, tunnelConfig: TunnelConfig) { + val config = TunnelConfig.configFromAmQuick(tunnelConfig.wgQuick) + + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(end = 10.dp, bottom = 10.dp, start = 45.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(30.dp, Alignment.Start), + ) { + config.peers.forEach { + val peerId = it.publicKey.toBase64().subSequence(0, 3).toString() + "***" + val peerRx = statistics?.peerStats(it.publicKey)?.rxBytes ?: 0 + val peerTx = statistics?.peerStats(it.publicKey)?.txBytes ?: 0 + val peerTxMB = NumberUtils.bytesToMB(peerTx).toThreeDecimalPlaceString() + val peerRxMB = NumberUtils.bytesToMB(peerRx).toThreeDecimalPlaceString() + val handshake = statistics?.peerStats(it.publicKey)?.latestHandshakeEpochMillis?.let { + "${NumberUtils.getSecondsBetweenTimestampAndNow(it)} ${stringResource(R.string.sec)}" + } ?: stringResource(R.string.never) + Column( + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text(stringResource(R.string.peer).lowercase() + ": $peerId", style = MaterialTheme.typography.bodySmall) + Text("tx: $peerTxMB MB", style = MaterialTheme.typography.bodySmall) + } + Column( + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text(stringResource(R.string.handshake) + ": $handshake", style = MaterialTheme.typography.bodySmall) + Text("rx: $peerRxMB MB", style = MaterialTheme.typography.bodySmall) + } + } + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/theme/Theme.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/theme/Theme.kt index 560311f..4d9072b 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/theme/Theme.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/theme/Theme.kt @@ -44,13 +44,13 @@ fun WireguardAutoTunnelTheme( ) { val context = LocalContext.current val colorScheme = when { - (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) -> { - if (useDarkTheme) { - dynamicDarkColorScheme(context) - } else { - dynamicLightColorScheme(context) - } - } + (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) -> { + if (useDarkTheme) { + dynamicDarkColorScheme(context) + } else { + dynamicLightColorScheme(context) + } + } useDarkTheme -> DarkColorScheme // TODO force dark theme for now until light theme designed else -> DarkColorScheme diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/extensions/TunnelExtensions.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/extensions/TunnelExtensions.kt index 6f5b6c5..c3513d5 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/extensions/TunnelExtensions.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/extensions/TunnelExtensions.kt @@ -1,8 +1,11 @@ package com.zaneschepke.wireguardautotunnel.util.extensions +import androidx.compose.ui.graphics.Color import com.wireguard.config.Peer import com.zaneschepke.wireguardautotunnel.service.tunnel.HandshakeStatus import com.zaneschepke.wireguardautotunnel.service.tunnel.statistics.TunnelStatistics +import com.zaneschepke.wireguardautotunnel.ui.theme.Corn +import com.zaneschepke.wireguardautotunnel.ui.theme.SilverTree import com.zaneschepke.wireguardautotunnel.util.Constants import com.zaneschepke.wireguardautotunnel.util.NumberUtils import org.amnezia.awg.config.Config @@ -48,6 +51,19 @@ fun Peer.isReachable(): Boolean { return reachable } +fun TunnelStatistics?.asColor(): Color { + return this?.mapPeerStats() + ?.map { it.value?.handshakeStatus() } + ?.let { statuses -> + when { + statuses.all { it == HandshakeStatus.HEALTHY } -> SilverTree + statuses.any { it == HandshakeStatus.STALE } -> Corn + statuses.all { it == HandshakeStatus.NOT_STARTED } -> Color.Gray + else -> Color.Gray + } + } ?: Color.Gray +} + fun Config.toWgQuickString(): String { val amQuick = toAwgQuickString(true) val lines = amQuick.lines().toMutableList()