feat!: tunnel sorting

#847
closes #846
closes #299
This commit is contained in:
Zane Schepke
2025-07-17 11:44:23 -04:00
parent 5563292a87
commit 872ff83a12
20 changed files with 596 additions and 78 deletions
+2
View File
@@ -223,6 +223,8 @@ dependencies {
// shizuku
implementation(libs.shizuku.api)
implementation(libs.shizuku.provider)
implementation(libs.reorderable)
}
tasks.register<Copy>("copyLicenseeJsonToAssets") {
@@ -0,0 +1,302 @@
{
"formatVersion": 1,
"database": {
"version": 18,
"identityHash": "505728bad740c12bab998a066b569333",
"entities": [
{
"tableName": "Settings",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `is_tunnel_enabled` INTEGER NOT NULL, `is_tunnel_on_mobile_data_enabled` INTEGER NOT NULL, `trusted_network_ssids` TEXT NOT NULL, `is_always_on_vpn_enabled` INTEGER NOT NULL, `is_tunnel_on_ethernet_enabled` INTEGER NOT NULL, `is_shortcuts_enabled` INTEGER NOT NULL DEFAULT false, `is_tunnel_on_wifi_enabled` INTEGER NOT NULL DEFAULT false, `is_kernel_enabled` INTEGER NOT NULL DEFAULT false, `is_restore_on_boot_enabled` INTEGER NOT NULL DEFAULT false, `is_multi_tunnel_enabled` INTEGER NOT NULL DEFAULT false, `is_ping_enabled` INTEGER NOT NULL DEFAULT false, `is_amnezia_enabled` INTEGER NOT NULL DEFAULT false, `is_wildcards_enabled` INTEGER NOT NULL DEFAULT false, `is_stop_on_no_internet_enabled` INTEGER NOT NULL DEFAULT false, `is_vpn_kill_switch_enabled` INTEGER NOT NULL DEFAULT false, `is_kernel_kill_switch_enabled` INTEGER NOT NULL DEFAULT false, `is_lan_on_kill_switch_enabled` INTEGER NOT NULL DEFAULT false, `debounce_delay_seconds` INTEGER NOT NULL DEFAULT 3, `is_disable_kill_switch_on_trusted_enabled` INTEGER NOT NULL DEFAULT false, `is_tunnel_on_unsecure_enabled` INTEGER NOT NULL DEFAULT false, `split_tunnel_apps` TEXT NOT NULL DEFAULT '', `wifi_detection_method` INTEGER NOT NULL DEFAULT 0)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isAutoTunnelEnabled",
"columnName": "is_tunnel_enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isTunnelOnMobileDataEnabled",
"columnName": "is_tunnel_on_mobile_data_enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "trustedNetworkSSIDs",
"columnName": "trusted_network_ssids",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isAlwaysOnVpnEnabled",
"columnName": "is_always_on_vpn_enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isTunnelOnEthernetEnabled",
"columnName": "is_tunnel_on_ethernet_enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isShortcutsEnabled",
"columnName": "is_shortcuts_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isTunnelOnWifiEnabled",
"columnName": "is_tunnel_on_wifi_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isKernelEnabled",
"columnName": "is_kernel_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isRestoreOnBootEnabled",
"columnName": "is_restore_on_boot_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isMultiTunnelEnabled",
"columnName": "is_multi_tunnel_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isPingEnabled",
"columnName": "is_ping_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isAmneziaEnabled",
"columnName": "is_amnezia_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isWildcardsEnabled",
"columnName": "is_wildcards_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isStopOnNoInternetEnabled",
"columnName": "is_stop_on_no_internet_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isVpnKillSwitchEnabled",
"columnName": "is_vpn_kill_switch_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isKernelKillSwitchEnabled",
"columnName": "is_kernel_kill_switch_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isLanOnKillSwitchEnabled",
"columnName": "is_lan_on_kill_switch_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "debounceDelaySeconds",
"columnName": "debounce_delay_seconds",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "3"
},
{
"fieldPath": "isDisableKillSwitchOnTrustedEnabled",
"columnName": "is_disable_kill_switch_on_trusted_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isTunnelOnUnsecureEnabled",
"columnName": "is_tunnel_on_unsecure_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "splitTunnelApps",
"columnName": "split_tunnel_apps",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "wifiDetectionMethod",
"columnName": "wifi_detection_method",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
},
{
"tableName": "TunnelConfig",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `wg_quick` TEXT NOT NULL, `tunnel_networks` TEXT NOT NULL DEFAULT '', `is_mobile_data_tunnel` INTEGER NOT NULL DEFAULT false, `is_primary_tunnel` INTEGER NOT NULL DEFAULT false, `am_quick` TEXT NOT NULL DEFAULT '', `is_Active` INTEGER NOT NULL DEFAULT false, `is_ping_enabled` INTEGER NOT NULL DEFAULT false, `ping_interval` INTEGER DEFAULT null, `ping_cooldown` INTEGER DEFAULT null, `ping_ip` TEXT DEFAULT null, `is_ethernet_tunnel` INTEGER NOT NULL DEFAULT false, `is_ipv4_preferred` INTEGER NOT NULL DEFAULT true, `position` INTEGER NOT NULL DEFAULT 0)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "wgQuick",
"columnName": "wg_quick",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "tunnelNetworks",
"columnName": "tunnel_networks",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "isMobileDataTunnel",
"columnName": "is_mobile_data_tunnel",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isPrimaryTunnel",
"columnName": "is_primary_tunnel",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "amQuick",
"columnName": "am_quick",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "isActive",
"columnName": "is_Active",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isPingEnabled",
"columnName": "is_ping_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "pingInterval",
"columnName": "ping_interval",
"affinity": "INTEGER",
"defaultValue": "null"
},
{
"fieldPath": "pingCooldown",
"columnName": "ping_cooldown",
"affinity": "INTEGER",
"defaultValue": "null"
},
{
"fieldPath": "pingIp",
"columnName": "ping_ip",
"affinity": "TEXT",
"defaultValue": "null"
},
{
"fieldPath": "isEthernetTunnel",
"columnName": "is_ethernet_tunnel",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isIpv4Preferred",
"columnName": "is_ipv4_preferred",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "true"
},
{
"fieldPath": "position",
"columnName": "position",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_TunnelConfig_name",
"unique": true,
"columnNames": [
"name"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_TunnelConfig_name` ON `${TABLE_NAME}` (`name`)"
}
]
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '505728bad740c12bab998a066b569333')"
]
}
}
@@ -54,6 +54,7 @@ import com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.disclosure.Loca
import com.zaneschepke.wireguardautotunnel.ui.screens.main.MainScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.main.autotunnel.TunnelAutoTunnelScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.main.config.ConfigScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.main.sort.SortScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.main.splittunnel.SplitTunnelScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.main.tunneloptions.TunnelOptionsScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.pin.PinLockScreen
@@ -322,6 +323,7 @@ class MainActivity : AppCompatActivity() {
)
}
}
composable<Route.Sort> { SortScreen(appUiState, viewModel) }
}
}
}
@@ -13,7 +13,7 @@ import com.zaneschepke.wireguardautotunnel.data.entity.TunnelConfig
@Database(
entities = [Settings::class, TunnelConfig::class],
version = 17,
version = 18,
autoMigrations =
[
AutoMigration(from = 1, to = 2),
@@ -32,6 +32,7 @@ import com.zaneschepke.wireguardautotunnel.data.entity.TunnelConfig
AutoMigration(from = 14, to = 15),
AutoMigration(from = 15, to = 16),
AutoMigration(from = 16, to = 17, spec = WifiDetectionMigration::class),
AutoMigration(from = 17, to = 18),
],
exportSchema = true,
)
@@ -46,5 +46,6 @@ interface TunnelConfigDao {
@Query("SELECT * FROM TUNNELCONFIG WHERE is_mobile_data_tunnel=1")
suspend fun findByMobileDataTunnel(): TunnelConfigs
@Query("SELECT * FROM tunnelconfig") fun getAllFlow(): Flow<MutableList<TunnelConfig>>
@Query("SELECT * FROM tunnelconfig ORDER BY position ASC")
fun getAllFlow(): Flow<MutableList<TunnelConfig>>
}
@@ -24,9 +24,10 @@ data class TunnelConfig(
@ColumnInfo(name = "ping_cooldown", defaultValue = "null") val pingCooldown: Long? = null,
@ColumnInfo(name = "ping_ip", defaultValue = "null") var pingIp: String? = null,
@ColumnInfo(name = "is_ethernet_tunnel", defaultValue = "false")
var isEthernetTunnel: Boolean = false,
val isEthernetTunnel: Boolean = false,
@ColumnInfo(name = "is_ipv4_preferred", defaultValue = "true")
var isIpv4Preferred: Boolean = true,
val isIpv4Preferred: Boolean = true,
@ColumnInfo(name = "position", defaultValue = "0") val position: Int = 0,
) {
companion object {
@@ -21,6 +21,7 @@ object TunnelConfigMapper {
pingIp,
isEthernetTunnel,
isIpv4Preferred,
position,
)
}
}
@@ -42,6 +43,7 @@ object TunnelConfigMapper {
pingIp,
isEthernetTunnel,
isIpv4Preferred,
position,
)
}
}
@@ -26,6 +26,7 @@ data class TunnelConf(
val pingIp: String? = null,
val isEthernetTunnel: Boolean = false,
val isIpv4Preferred: Boolean = true,
val position: Int = 0,
@Transient private var stateChangeCallback: ((Any) -> Unit)? = null,
) : Tunnel, org.amnezia.awg.backend.Tunnel {
@@ -45,4 +45,6 @@ sealed class Route {
@Serializable data class TunnelAutoTunnel(val id: Int) : Route()
@Serializable data object Logs : Route()
@Serializable data object Sort : Route()
}
@@ -3,66 +3,41 @@ package com.zaneschepke.wireguardautotunnel.ui.common
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.indication
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalIsAndroidTV
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ExpandingRowListItem(
leading: @Composable () -> Unit,
text: String,
onHold: () -> Unit,
onClick: () -> Unit,
trailing: @Composable () -> Unit,
isSelected: Boolean,
expanded: @Composable () -> Unit,
modifier: Modifier = Modifier,
) {
val isTv = LocalIsAndroidTV.current
val haptic = LocalHapticFeedback.current
val interactionSource = remember { MutableInteractionSource() }
Box(
modifier =
Modifier.animateContentSize()
modifier
.animateContentSize()
.clip(RoundedCornerShape(8.dp))
.background(
if (isSelected) MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)
else Color.Transparent
)
.then(
if (!isTv) {
Modifier.combinedClickable(
interactionSource = interactionSource,
indication = ripple(),
onClick = onClick,
onLongClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onHold()
},
)
} else Modifier
)
) {
Column {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp),
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp).height(48.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
@@ -4,6 +4,7 @@ import android.os.Build
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.Sort
import androidx.compose.material.icons.rounded.*
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -11,6 +12,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
@@ -28,6 +30,7 @@ import com.zaneschepke.wireguardautotunnel.ui.theme.SilverTree
import com.zaneschepke.wireguardautotunnel.ui.theme.iconSize
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
import com.zaneschepke.wireguardautotunnel.viewmodel.event.UiEvent
@Composable
fun currentNavBackStackEntryAsNavBarState(
@@ -60,35 +63,40 @@ fun currentNavBackStackEntryAsNavBarState(
Row {
if (selectedCount == 0) {
val showSort = remember(uiState.tunnels) { uiState.tunnels.size > 1 }
if (showSort)
ActionIconButton(Icons.AutoMirrored.Rounded.Sort, R.string.sort) {
navController.navigate(Route.Sort)
}
ActionIconButton(Icons.Rounded.Add, R.string.add_tunnel) {
viewModel.handleEvent(
AppEvent.SetBottomSheet(AppViewState.BottomSheet.IMPORT_TUNNELS)
)
}
} else {
ActionIconButton(Icons.Rounded.SelectAll, R.string.select_all) {
viewModel.handleEvent(AppEvent.ToggleSelectAllTunnels)
}
// due to permissions, and SAF issues on TV, not support less than Android 10 on
// Android TV for file exports
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ActionIconButton(Icons.Rounded.Download, R.string.download) {
viewModel.handleEvent(
AppEvent.SetBottomSheet(AppViewState.BottomSheet.EXPORT_TUNNELS)
)
}
return@Row
}
ActionIconButton(Icons.Rounded.SelectAll, R.string.select_all) {
viewModel.handleEvent(AppEvent.ToggleSelectAllTunnels)
}
// due to permissions, and SAF issues on TV, not support less than Android 10 on
// Android TV for file exports
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ActionIconButton(Icons.Rounded.Download, R.string.download) {
viewModel.handleEvent(
AppEvent.SetBottomSheet(AppViewState.BottomSheet.EXPORT_TUNNELS)
)
}
}
if (selectedCount == 1) {
ActionIconButton(Icons.Rounded.CopyAll, R.string.copy) {
viewModel.handleEvent(AppEvent.CopySelectedTunnel)
}
if (selectedCount == 1) {
ActionIconButton(Icons.Rounded.CopyAll, R.string.copy) {
viewModel.handleEvent(AppEvent.CopySelectedTunnel)
}
}
if (showDelete) {
ActionIconButton(Icons.Rounded.Delete, R.string.delete_tunnel) {
viewModel.handleEvent(AppEvent.SetShowModal(AppViewState.ModalType.DELETE))
}
if (showDelete) {
ActionIconButton(Icons.Rounded.Delete, R.string.delete_tunnel) {
viewModel.handleEvent(AppEvent.SetShowModal(AppViewState.ModalType.DELETE))
}
}
}
@@ -212,6 +220,25 @@ fun currentNavBackStackEntryAsNavBarState(
route = Route.Support,
)
backStackEntry.isCurrentRoute(Route.Sort::class) -> {
NavBarState(
showTop = true,
showBottom = true,
topTitle = { Text(stringResource(R.string.sort)) },
topTrailing = {
Row {
ActionIconButton(Icons.Rounded.SortByAlpha, R.string.sort) {
viewModel.handleUiEvent(UiEvent.SortTunnels)
}
ActionIconButton(Icons.Rounded.Save, R.string.save) {
viewModel.handleEvent(AppEvent.InvokeScreenAction)
}
}
},
route = Route.Sort,
)
}
backStackEntry.isCurrentRoute(Route.License::class) -> {
NavBarState(
showTop = true,
@@ -1,7 +1,9 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main.components
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.gestures.ScrollableDefaults
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
@@ -25,8 +27,6 @@ import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
import com.zaneschepke.wireguardautotunnel.util.extensions.openWebUrl
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
import java.text.Collator
import java.util.*
@OptIn(ExperimentalFoundationApi::class)
@Composable
@@ -40,17 +40,10 @@ fun TunnelList(
val isTv = LocalIsAndroidTV.current
val context = LocalContext.current
val navController = LocalNavController.current
val collator = Collator.getInstance(Locale.getDefault())
val sortedTunnels =
remember(appUiState.tunnels) {
appUiState.tunnels.sortedWith(
compareBy(
// primary tunnel first
{ !it.isPrimaryTunnel },
{ collator.compare(it.tunName, "") },
)
)
}
val lazyListState = rememberLazyListState()
val sortedTunnels = remember(appUiState.tunnels) { appUiState.tunnels.sortedBy { it.position } }
LazyColumn(
horizontalAlignment = Alignment.Start,
@@ -59,7 +52,7 @@ fun TunnelList(
modifier
.pointerInput(Unit) { if (appUiState.tunnels.isEmpty()) return@pointerInput }
.overscroll(rememberOverscrollEffect()),
state = rememberLazyListState(0, appUiState.tunnels.count()),
state = lazyListState,
userScrollEnabled = true,
reverseLayout = false,
flingBehavior = ScrollableDefaults.flingBehavior(),
@@ -78,19 +71,31 @@ fun TunnelList(
isSelected = selected,
tunnel = tunnel,
tunnelState = tunnelState,
onClick = {
if (selectedTunnels.isNotEmpty() && !isTv) {
viewModel.handleEvent(AppEvent.ToggleSelectedTunnel(tunnel))
} else {
navController.navigate(Route.TunnelOptions(tunnel.id))
viewModel.handleEvent(AppEvent.ClearSelectedTunnels)
}
onTvClick = {
navController.navigate(Route.TunnelOptions(tunnel.id))
viewModel.handleEvent(AppEvent.ClearSelectedTunnels)
},
onToggleSelectedTunnel = {
viewModel.handleEvent(AppEvent.ToggleSelectedTunnel(it))
},
onSwitchClick = { checked -> onToggleTunnel(tunnel, checked) },
isTv = isTv,
modifier =
Modifier.combinedClickable(
onClick = {
if (selectedTunnels.isNotEmpty() && !isTv) {
viewModel.handleEvent(AppEvent.ToggleSelectedTunnel(tunnel))
} else {
navController.navigate(Route.TunnelOptions(tunnel.id))
viewModel.handleEvent(AppEvent.ClearSelectedTunnels)
}
},
onLongClick = {
if (!isTv) viewModel.handleEvent(AppEvent.ToggleSelectedTunnel(tunnel))
},
interactionSource = remember { MutableInteractionSource() },
indication = null,
),
)
}
}
@@ -34,10 +34,11 @@ fun TunnelRowItem(
isSelected: Boolean,
tunnel: TunnelConf,
tunnelState: TunnelState,
onClick: () -> Unit,
onTvClick: () -> Unit,
onToggleSelectedTunnel: (TunnelConf) -> Unit,
onSwitchClick: (Boolean) -> Unit,
isTv: Boolean,
modifier: Modifier = Modifier,
) {
val leadingIconColor =
remember(state) {
@@ -76,8 +77,6 @@ fun TunnelRowItem(
}
},
text = tunnel.tunName,
onHold = { if (!isTv) onToggleSelectedTunnel(tunnel) },
onClick = { if (!isTv) onClick() },
expanded = {
if (tunnelState.status != TunnelStatus.Down) {
TunnelStatisticsRow(tunnelState.statistics, tunnel)
@@ -89,7 +88,7 @@ fun TunnelRowItem(
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
) {
if (isTv) {
IconButton(onClick = onClick) {
IconButton(onClick = onTvClick) {
Icon(
Icons.Rounded.Settings,
contentDescription = stringResource(R.string.settings),
@@ -100,5 +99,6 @@ fun TunnelRowItem(
}
},
isSelected = isSelected,
modifier = modifier,
)
}
@@ -0,0 +1,168 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main.sort
import androidx.compose.foundation.gestures.ScrollableDefaults
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.overscroll
import androidx.compose.foundation.rememberOverscrollEffect
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDownward
import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material.icons.filled.DragHandle
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.common.ExpandingRowListItem
import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalIsAndroidTV
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
import com.zaneschepke.wireguardautotunnel.util.extensions.isSortedBy
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
import com.zaneschepke.wireguardautotunnel.viewmodel.event.UiEvent
import sh.calvin.reorderable.DragGestureDetector
import sh.calvin.reorderable.ReorderableItem
import sh.calvin.reorderable.rememberReorderableLazyListState
@Composable
fun SortScreen(appUiState: AppUiState, viewModel: AppViewModel) {
val hapticFeedback = LocalHapticFeedback.current
val isTv = LocalIsAndroidTV.current
var sortAscending by remember { mutableStateOf<Boolean?>(null) }
var sortedTunnels by remember { mutableStateOf(appUiState.tunnels.sortedBy { it.position }) }
LaunchedEffect(Unit) {
viewModel.uiEvent.collect { uiEvent ->
when (uiEvent) {
UiEvent.SortTunnels -> {
sortAscending =
when (sortAscending) {
null -> !sortedTunnels.isSortedBy { it.name }
true -> false
false -> null
}
sortedTunnels =
when (sortAscending) {
true -> sortedTunnels.sortedBy { it.name }
false -> sortedTunnels.sortedByDescending { it.name }
null -> sortedTunnels.sortedBy { it.position }
}
}
}
}
}
LaunchedEffect(Unit) {
viewModel.handleEvent(
AppEvent.SetScreenAction {
viewModel.handleEvent(
AppEvent.SaveAllConfigs(
sortedTunnels.mapIndexed { index, conf -> conf.copy(position = index) }
)
)
viewModel.handleEvent(AppEvent.PopBackStack(true))
}
)
}
val lazyListState = rememberLazyListState()
val reorderableLazyListState =
rememberReorderableLazyListState(
lazyListState,
scrollThresholdPadding = WindowInsets.systemBars.asPaddingValues(),
) { from, to ->
sortedTunnels =
sortedTunnels.toMutableList().apply { add(to.index, removeAt(from.index)) }
hapticFeedback.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick)
}
LazyColumn(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(5.dp, Alignment.Top),
modifier =
Modifier.pointerInput(Unit) { if (appUiState.tunnels.isEmpty()) return@pointerInput }
.overscroll(rememberOverscrollEffect())
.padding(horizontal = 16.dp, vertical = 24.dp),
state = lazyListState,
userScrollEnabled = true,
reverseLayout = false,
flingBehavior = ScrollableDefaults.flingBehavior(),
) {
itemsIndexed(sortedTunnels, key = { _, tunnel -> tunnel.id }) { index, tunnel ->
ReorderableItem(reorderableLazyListState, tunnel.id) { isDragging ->
ExpandingRowListItem(
leading = {},
text = tunnel.name,
trailing = {
if (!isTv)
Icon(
Icons.Default.DragHandle,
stringResource(
com.zaneschepke.wireguardautotunnel.R.string.drag_handle
),
)
else
Row {
if (index != 0)
IconButton(
onClick = {
sortedTunnels =
sortedTunnels.toMutableList().apply {
add(index + 1, removeAt(index))
}
}
) {
Icon(
Icons.Default.ArrowUpward,
stringResource(
com.zaneschepke.wireguardautotunnel.R.string.move_up
),
)
}
if (index != sortedTunnels.count() - 1)
IconButton(
onClick = {
sortedTunnels =
sortedTunnels.toMutableList().apply {
add(index - 1, removeAt(index))
}
}
) {
Icon(
Icons.Default.ArrowDownward,
stringResource(R.string.move_down),
)
}
}
},
isSelected = isDragging,
expanded = {},
modifier =
Modifier.draggableHandle(
onDragStarted = {
hapticFeedback.performHapticFeedback(
HapticFeedbackType.GestureThresholdActivate
)
},
onDragStopped = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureEnd)
},
dragGestureDetector = DragGestureDetector.LongPress,
),
)
}
}
}
}
@@ -24,3 +24,7 @@ typealias Packages = List<PackageInfo>
fun <T> MutableList<T>.addAllUnique(elements: Collection<T>, comparator: (T, T) -> Boolean) {
addAll(elements.filterNot { new -> this.any { existing -> comparator(existing, new) } })
}
fun <T, R : Comparable<R>> List<T>.isSortedBy(selector: (T) -> R): Boolean {
return zipWithNext().all { (a, b) -> selector(a) <= selector(b) }
}
@@ -34,6 +34,7 @@ import com.zaneschepke.wireguardautotunnel.util.*
import com.zaneschepke.wireguardautotunnel.util.extensions.addAllUnique
import com.zaneschepke.wireguardautotunnel.util.extensions.withFirstState
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
import com.zaneschepke.wireguardautotunnel.viewmodel.event.UiEvent
import dagger.hilt.android.lifecycle.HiltViewModel
import java.io.IOException
import java.net.URL
@@ -79,6 +80,9 @@ constructor(
private val _appViewState = MutableStateFlow(AppViewState())
val appViewState = _appViewState.asStateFlow()
private val _uiEvent = MutableSharedFlow<UiEvent>()
val uiEvent: SharedFlow<UiEvent> = _uiEvent.asSharedFlow()
private val _logs = MutableStateFlow<List<LogMessage>>(emptyList())
val logs: StateFlow<List<LogMessage>> = _logs.asStateFlow()
private val maxLogSize = Constants.MAX_LOG_SIZE
@@ -126,6 +130,9 @@ constructor(
}
}
fun handleUiEvent(event: UiEvent) =
viewModelScope.launch(mainDispatcher) { _uiEvent.emit(event) }
fun handleEvent(event: AppEvent) =
viewModelScope.launch(ioDispatcher) {
uiState.withFirstState { state ->
@@ -215,10 +222,15 @@ constructor(
is AppEvent.SetDetectionMethod ->
handleSetDetectionMethod(event.detectionMethod, state.appSettings)
is AppEvent.SaveAllConfigs -> saveAllTunnels(event.tunnels)
}
}
}
private suspend fun saveAllTunnels(tunnels: List<TunnelConf>) {
appDataRepository.tunnels.saveAll(tunnels)
}
private suspend fun handleSetDetectionMethod(
detectionMethod: AndroidNetworkMonitor.WifiDetectionMethod,
appSettings: AppSettings,
@@ -125,4 +125,6 @@ sealed class AppEvent {
data class SetShowModal(val modalType: AppViewState.ModalType) : AppEvent()
data object ToggleSelectAllTunnels : AppEvent()
data class SaveAllConfigs(val tunnels: List<TunnelConf>) : AppEvent()
}
@@ -0,0 +1,5 @@
package com.zaneschepke.wireguardautotunnel.viewmodel.event
sealed class UiEvent {
data object SortTunnels : UiEvent()
}
+4
View File
@@ -281,4 +281,8 @@
</string>
<string name="release_notes">Release notes</string>
<string name="shizuku_not_detected">Shizuku not detected</string>
<string name="sort">Sort</string>
<string name="drag_handle">Drag Handle</string>
<string name="move_up">Move Up</string>
<string name="move_down">Move Down</string>
</resources>