diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6637546f..c47f2a45 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -223,6 +223,8 @@ dependencies { // shizuku implementation(libs.shizuku.api) implementation(libs.shizuku.provider) + + implementation(libs.reorderable) } tasks.register("copyLicenseeJsonToAssets") { diff --git a/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/18.json b/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/18.json new file mode 100644 index 00000000..d1825018 --- /dev/null +++ b/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/18.json @@ -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')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/MainActivity.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/MainActivity.kt index 912acbfe..ffe9a912 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/MainActivity.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/MainActivity.kt @@ -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 { SortScreen(appUiState, viewModel) } } } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/AppDatabase.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/AppDatabase.kt index 55025121..0517f9a3 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/AppDatabase.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/AppDatabase.kt @@ -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, ) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/dao/TunnelConfigDao.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/dao/TunnelConfigDao.kt index 2ceb8b43..d857c2b5 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/dao/TunnelConfigDao.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/dao/TunnelConfigDao.kt @@ -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> + @Query("SELECT * FROM tunnelconfig ORDER BY position ASC") + fun getAllFlow(): Flow> } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/entity/TunnelConfig.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/entity/TunnelConfig.kt index 29148d2d..e699d49c 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/entity/TunnelConfig.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/entity/TunnelConfig.kt @@ -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 { diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/mapper/TunnelConfigMapper.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/mapper/TunnelConfigMapper.kt index 1cd14684..e9a32706 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/mapper/TunnelConfigMapper.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/mapper/TunnelConfigMapper.kt @@ -21,6 +21,7 @@ object TunnelConfigMapper { pingIp, isEthernetTunnel, isIpv4Preferred, + position, ) } } @@ -42,6 +43,7 @@ object TunnelConfigMapper { pingIp, isEthernetTunnel, isIpv4Preferred, + position, ) } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/model/TunnelConf.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/model/TunnelConf.kt index 7f410f7d..d5c85705 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/model/TunnelConf.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/model/TunnelConf.kt @@ -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 { diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/Route.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/Route.kt index 044673ce..38f4acee 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/Route.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/Route.kt @@ -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() } 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 index 65c07ef3..ab33c77e 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/ExpandingRowListItem.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/ExpandingRowListItem.kt @@ -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, ) { diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/navigation/components/currentNavBackStackEntryAsNavBarState.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/navigation/components/currentNavBackStackEntryAsNavBarState.kt index f9831327..858f4a81 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/navigation/components/currentNavBackStackEntryAsNavBarState.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/navigation/components/currentNavBackStackEntryAsNavBarState.kt @@ -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, diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/components/TunnelList.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/components/TunnelList.kt index 8ae5e31c..1d7be55d 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/components/TunnelList.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/components/TunnelList.kt @@ -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, + ), ) } } 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 index bb466bd4..218978fb 100644 --- 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 @@ -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, ) } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/sort/SortScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/sort/SortScreen.kt new file mode 100644 index 00000000..c3f863ad --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/sort/SortScreen.kt @@ -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(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, + ), + ) + } + } + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/extensions/Extensions.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/extensions/Extensions.kt index 9395b94f..eb0517ce 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/extensions/Extensions.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/extensions/Extensions.kt @@ -24,3 +24,7 @@ typealias Packages = List fun MutableList.addAllUnique(elements: Collection, comparator: (T, T) -> Boolean) { addAll(elements.filterNot { new -> this.any { existing -> comparator(existing, new) } }) } + +fun > List.isSortedBy(selector: (T) -> R): Boolean { + return zipWithNext().all { (a, b) -> selector(a) <= selector(b) } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/AppViewModel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/AppViewModel.kt index 33f2a31d..6a53b216 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/AppViewModel.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/AppViewModel.kt @@ -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() + val uiEvent: SharedFlow = _uiEvent.asSharedFlow() + private val _logs = MutableStateFlow>(emptyList()) val logs: StateFlow> = _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) { + appDataRepository.tunnels.saveAll(tunnels) + } + private suspend fun handleSetDetectionMethod( detectionMethod: AndroidNetworkMonitor.WifiDetectionMethod, appSettings: AppSettings, diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/event/AppEvent.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/event/AppEvent.kt index 6a28a6c6..9e327593 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/event/AppEvent.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/event/AppEvent.kt @@ -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) : AppEvent() } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/event/UiEvent.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/event/UiEvent.kt new file mode 100644 index 00000000..2ac34c1c --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/event/UiEvent.kt @@ -0,0 +1,5 @@ +package com.zaneschepke.wireguardautotunnel.viewmodel.event + +sealed class UiEvent { + data object SortTunnels : UiEvent() +} \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 86a3a6c7..4d992a5a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -281,4 +281,8 @@ Release notes Shizuku not detected + Sort + Drag Handle + Move Up + Move Down diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ab929e0f..03d0ce2a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -35,6 +35,7 @@ workRuntimeKtxVersion = "2.10.1" zxingAndroidEmbedded = "4.3.0" coreSplashscreen = "1.0.1" gradlePlugins-grgit = "5.3.2" +reorderable = "2.5.1" #plugins material = "1.12.0" @@ -110,6 +111,7 @@ timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" } zxing-android-embedded = { module = "com.journeyapps:zxing-android-embedded", version.ref = "zxingAndroidEmbedded" } desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" } androidx-biometric-ktx = { module = "androidx.biometric:biometric-ktx", version.ref = "biometricKtx" } +reorderable = { module = "sh.calvin.reorderable:reorderable", version.ref = "reorderable" } # tunnel tunnel = { module = "com.zaneschepke:wireguard-android", version.ref = "tunnel" }