diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..9bea0b01c --- /dev/null +++ b/.gitmodules @@ -0,0 +1,6 @@ +[submodule "hevtunnel/src/main/jni/hev-socks5-tunnel"] + path = hevtunnel/src/main/jni/hev-socks5-tunnel + url = https://github.com/heiher/hev-socks5-tunnel +[submodule "tunnel/tools/amneziawg-tools"] + path = tunnel/tools/amneziawg-tools + url = https://github.com/amnezia-vpn/amneziawg-tools diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8658ec425..a2e415d48 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,9 +1,8 @@ -import com.android.build.gradle.internal.api.BaseVariantOutputImpl +import com.android.build.api.dsl.ApplicationExtension import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { alias(libs.plugins.android.application) - alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlinxSerialization) alias(libs.plugins.ksp) alias(libs.plugins.compose.compiler) @@ -11,7 +10,26 @@ plugins { alias(libs.plugins.licensee) } -android { +ksp { + arg("room.schemaLocation", "$projectDir/schemas") +} + +licensee { + allowedLicenses().forEach { allow(it) } + allowedLicenseUrls().forEach { allowUrl(it) } + // foss, but missing licenses + ignoreDependencies("com.github.T8RIN.QuickieExtended") + ignoreDependencies("com.github.topjohnwu.libsu") +} + +kotlin { + compilerOptions { + jvmTarget = JvmTarget.JVM_17 + freeCompilerArgs = listOf("-XXLanguage:+PropertyParamAnnotationDefaultTargetMode") + } +} + +configure { namespace = Constants.APP_ID compileSdk = Constants.TARGET_SDK @@ -22,8 +40,6 @@ android { includeInBundle = false } - ksp { arg("room.schemaLocation", "$projectDir/schemas") } - // fix okhttp proguard issue packaging { resources { pickFirsts.add("okhttp3/internal/publicsuffix/publicsuffixes.gz") } } @@ -119,28 +135,16 @@ android { targetCompatibility = JavaVersion.VERSION_17 } - kotlin { - compilerOptions { - jvmTarget = JvmTarget.JVM_17 - freeCompilerArgs = listOf("-XXLanguage:+PropertyParamAnnotationDefaultTargetMode") - } - } - buildFeatures { compose = true buildConfig = true + resValues = true } packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" } } +} - licensee { - allowedLicenses().forEach { allow(it) } - allowedLicenseUrls().forEach { allowUrl(it) } - // foss, but missing license - ignoreDependencies("com.github.T8RIN.QuickieExtended") - } - - android.applicationVariants.all { - val variant = this +androidComponents { + onVariants { variant -> val abiNameMap = mapOf( @@ -150,21 +154,42 @@ android { "x86_64" to "x64", ) - variant.outputs.all { - val output = this as BaseVariantOutputImpl - val abi = output.getFilter("ABI") + val variantCap = + variant.name.replaceFirstChar { it.uppercase() } + + tasks.register("rename${variantCap}Apk") { + + val apkFolder = + layout.buildDirectory.dir("outputs/apk/${variant.name}") + + from(apkFolder) + + include("*.apk") + + into(layout.buildDirectory.dir("renamed-apks")) - val baseFileName = "${Constants.APP_NAME}-${variant.flavorName}-v${variant.versionName}" + rename { originalName -> - val outputFileName = - if (!abi.isNullOrEmpty()) { - val shortAbiName = abiNameMap.getOrDefault(abi, abi) - "${baseFileName}-${shortAbiName}.apk" + val abi = + abiNameMap.entries.find { entry -> + originalName.contains(entry.key) + }?.value + + val versionName = + variant.outputs.single().versionName.get() + + val flavorName = + variant.productFlavors.joinToString("-") { it.second } + + val baseName = + "${Constants.APP_NAME}-${flavorName}-v$versionName" + + if (abi != null) { + "$baseName-$abi.apk" } else { - "${baseFileName}.apk" + "$baseName.apk" } - - output.outputFileName = outputFileName + } } } } @@ -172,6 +197,7 @@ android { dependencies { implementation(project(":logcatter")) implementation(project(":networkmonitor")) + implementation(project(":tunnel")) // Core foundations implementation(libs.bundles.androidx.core.full) @@ -208,9 +234,6 @@ dependencies { // State management implementation(libs.bundles.orbit.mvi) - // Tunnel - implementation(libs.bundles.wireguard.tunnel) - // Shizuku implementation(libs.bundles.shizuku) @@ -268,7 +291,7 @@ tasks.register("copyLicenseeJsonToAssets") { tasks.named("preBuild") { dependsOn("copyLicenseeJsonToAssets") } // https://gist.github.com/obfusk/61046e09cee352ae6dd109911534b12e#fix-proposed-by-linsui-disable-baseline-profiles -tasks.whenTaskAdded { +tasks.configureEach { if (name.contains("ArtProfile")) { enabled = false } diff --git a/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/30.json b/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/30.json new file mode 100644 index 000000000..39ad657e9 --- /dev/null +++ b/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/30.json @@ -0,0 +1,506 @@ +{ + "formatVersion": 1, + "database": { + "version": 30, + "identityHash": "28560c6b408d8f5ef28844723e940395", + "entities": [ + { + "tableName": "tunnel_config", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` 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, `quick_config` TEXT NOT NULL DEFAULT '', `dynamic_dns` INTEGER NOT NULL DEFAULT false, `is_ethernet_tunnel` INTEGER NOT NULL DEFAULT false, `prefer_ipv6` INTEGER NOT NULL DEFAULT false, `position` INTEGER NOT NULL DEFAULT 0, `auto_tunnel_apps` TEXT NOT NULL DEFAULT '[]', `is_metered` INTEGER NOT NULL DEFAULT false, `ipv4_fallback` INTEGER NOT NULL DEFAULT false, `ipv6_restore` INTEGER NOT NULL DEFAULT false)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "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": "quickConfig", + "columnName": "quick_config", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "dynamicDnsEnabled", + "columnName": "dynamic_dns", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "isEthernetTunnel", + "columnName": "is_ethernet_tunnel", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "isIpv6Preferred", + "columnName": "prefer_ipv6", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "autoTunnelApps", + "columnName": "auto_tunnel_apps", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'[]'" + }, + { + "fieldPath": "isMetered", + "columnName": "is_metered", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "ipv4FallbackEnabled", + "columnName": "ipv4_fallback", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "ipv6RestoreEnabled", + "columnName": "ipv6_restore", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_tunnel_config_name", + "unique": true, + "columnNames": [ + "name" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_tunnel_config_name` ON `${TABLE_NAME}` (`name`)" + } + ] + }, + { + "tableName": "proxy_settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `socks5_proxy_enabled` INTEGER NOT NULL DEFAULT 0, `socks5_proxy_bind_address` TEXT, `http_proxy_enable` INTEGER NOT NULL DEFAULT 0, `http_proxy_bind_address` TEXT, `proxy_username` TEXT, `proxy_password` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "socks5ProxyEnabled", + "columnName": "socks5_proxy_enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "socks5ProxyBindAddress", + "columnName": "socks5_proxy_bind_address", + "affinity": "TEXT" + }, + { + "fieldPath": "httpProxyEnabled", + "columnName": "http_proxy_enable", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "httpProxyBindAddress", + "columnName": "http_proxy_bind_address", + "affinity": "TEXT" + }, + { + "fieldPath": "proxyUsername", + "columnName": "proxy_username", + "affinity": "TEXT" + }, + { + "fieldPath": "proxyPassword", + "columnName": "proxy_password", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "general_settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `is_shortcuts_enabled` INTEGER NOT NULL DEFAULT 0, `is_restore_on_boot_enabled` INTEGER NOT NULL DEFAULT 0, `is_multi_tunnel_enabled` INTEGER NOT NULL DEFAULT 0, `global_split_tunnel_enabled` INTEGER NOT NULL DEFAULT 0, `app_mode` INTEGER NOT NULL DEFAULT 0, `theme` TEXT NOT NULL DEFAULT 'AUTOMATIC', `locale` TEXT, `remote_key` TEXT, `is_remote_control_enabled` INTEGER NOT NULL DEFAULT 0, `is_pin_lock_enabled` INTEGER NOT NULL DEFAULT 0, `is_always_on_vpn_enabled` INTEGER NOT NULL DEFAULT 0, `already_donated` INTEGER NOT NULL DEFAULT 0, `screen_recording_security` INTEGER NOT NULL DEFAULT 1, `global_amnezia_enabled` INTEGER NOT NULL DEFAULT 0, `tunnel_scripting_enabled` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isShortcutsEnabled", + "columnName": "is_shortcuts_enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isRestoreOnBootEnabled", + "columnName": "is_restore_on_boot_enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isMultiTunnelEnabled", + "columnName": "is_multi_tunnel_enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isGlobalSplitTunnelEnabled", + "columnName": "global_split_tunnel_enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "tunnelMode", + "columnName": "app_mode", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'AUTOMATIC'" + }, + { + "fieldPath": "locale", + "columnName": "locale", + "affinity": "TEXT" + }, + { + "fieldPath": "remoteKey", + "columnName": "remote_key", + "affinity": "TEXT" + }, + { + "fieldPath": "isRemoteControlEnabled", + "columnName": "is_remote_control_enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isPinLockEnabled", + "columnName": "is_pin_lock_enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isAlwaysOnVpnEnabled", + "columnName": "is_always_on_vpn_enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "alreadyDonated", + "columnName": "already_donated", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "screenRecordingSecurityEnabled", + "columnName": "screen_recording_security", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "isGlobalAmneziaEnabled", + "columnName": "global_amnezia_enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "tunnelScriptingEnabled", + "columnName": "tunnel_scripting_enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "auto_tunnel_settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `is_tunnel_enabled` INTEGER NOT NULL DEFAULT 0, `is_tunnel_on_mobile_data_enabled` INTEGER NOT NULL DEFAULT 0, `trusted_network_ssids` TEXT NOT NULL DEFAULT '', `is_tunnel_on_ethernet_enabled` INTEGER NOT NULL DEFAULT 0, `is_tunnel_on_wifi_enabled` INTEGER NOT NULL DEFAULT 0, `is_wildcards_enabled` INTEGER NOT NULL DEFAULT 0, `is_stop_on_no_internet_enabled` INTEGER NOT NULL DEFAULT 0, `is_tunnel_on_unsecure_enabled` INTEGER NOT NULL DEFAULT 0, `wifi_detection_method` INTEGER NOT NULL DEFAULT 0, `start_on_boot` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isAutoTunnelEnabled", + "columnName": "is_tunnel_enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isTunnelOnMobileDataEnabled", + "columnName": "is_tunnel_on_mobile_data_enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "trustedNetworkSSIDs", + "columnName": "trusted_network_ssids", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "isTunnelOnEthernetEnabled", + "columnName": "is_tunnel_on_ethernet_enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isTunnelOnWifiEnabled", + "columnName": "is_tunnel_on_wifi_enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isWildcardsEnabled", + "columnName": "is_wildcards_enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isStopOnNoInternetEnabled", + "columnName": "is_stop_on_no_internet_enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isTunnelOnUnsecureEnabled", + "columnName": "is_tunnel_on_unsecure_enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "wifiDetectionMethod", + "columnName": "wifi_detection_method", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "startOnBoot", + "columnName": "start_on_boot", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "monitoring_settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `is_local_logs_enabled` INTEGER NOT NULL DEFAULT 0, `tunnel_statistics_enabled` INTEGER NOT NULL DEFAULT 1, `tunnel_statistics_poll_interval` INTEGER NOT NULL DEFAULT 3)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isLocalLogsEnabled", + "columnName": "is_local_logs_enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "tunnelStatisticsEnabled", + "columnName": "tunnel_statistics_enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "tunnelStatisticsPollInterval", + "columnName": "tunnel_statistics_poll_interval", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "3" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "dns_settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `dns_protocol` INTEGER NOT NULL DEFAULT 0, `dns_endpoint` TEXT, `global_tunnel_dns_enabled` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "dnsProtocol", + "columnName": "dns_protocol", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "dnsEndpoint", + "columnName": "dns_endpoint", + "affinity": "TEXT" + }, + { + "fieldPath": "isGlobalTunnelDnsEnabled", + "columnName": "global_tunnel_dns_enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "lockdown_settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `bypass_lan` INTEGER NOT NULL DEFAULT 0, `metered` INTEGER NOT NULL DEFAULT 0, `dual_stack` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bypassLan", + "columnName": "bypass_lan", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "metered", + "columnName": "metered", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "dualStack", + "columnName": "dual_stack", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + } + ], + "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, '28560c6b408d8f5ef28844723e940395')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3ca3a2edd..4d9484943 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -6,13 +6,11 @@ - + - - - @@ -197,33 +195,6 @@ network connectivity monitoring."/> - - - - - - - - - - - - - - - >(Pair(null, null)) + var requestingTunnelMode by remember { + mutableStateOf>(Pair(null, null)) } val startingStack = buildList { @@ -201,14 +206,14 @@ class MainActivity : AppCompatActivity() { } else { vpnPermissionDenied = false showVpnPermissionDialog = false - val (appMode, config) = requestingAppMode + val (appMode, config) = requestingTunnelMode when (appMode) { - AppMode.VPN -> if (config != null) viewModel.startTunnel(config) - AppMode.LOCK_DOWN -> viewModel.setAppMode(AppMode.LOCK_DOWN) + TunnelMode.VPN -> if (config != null) viewModel.startTunnel(config) + TunnelMode.LOCK_DOWN -> viewModel.setAppMode(TunnelMode.LOCK_DOWN) else -> Unit } } - requestingAppMode = Pair(null, null) + requestingTunnelMode = Pair(null, null) }, ) @@ -218,7 +223,8 @@ class MainActivity : AppCompatActivity() { GlobalSideEffect.ConfigChanged -> restartApp() GlobalSideEffect.PopBackStack -> navController.pop() is GlobalSideEffect.RequestVpnPermission -> { - requestingAppMode = Pair(sideEffect.requestingMode, sideEffect.config) + requestingTunnelMode = + Pair(sideEffect.requestingMode, sideEffect.config) vpnActivity.launch(VpnService.prepare(this@MainActivity)) } @@ -313,13 +319,40 @@ class MainActivity : AppCompatActivity() { } } + val isPinVisible by remember { derivedStateOf { showLock } } + + val currentRoute by remember { + derivedStateOf { backStack.lastOrNull() as? Route } + } + + LaunchedEffect( + uiState.isScreenRecordingProtectionEnabled, + currentRoute, + isPinVisible, + ) { + val isSecureRoute = currentRoute is SecureRoute + + val shouldProtect = + uiState.isScreenRecordingProtectionEnabled && + (isSecureRoute || isPinVisible) + + if (shouldProtect) { + window.setFlags( + WindowManager.LayoutParams.FLAG_SECURE, + WindowManager.LayoutParams.FLAG_SECURE, + ) + } else { + delay(500L) + window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) + awaitCancellation() + } + } + if (showLock) { PinManager.initialize(context = this@MainActivity) PinLockScreen() } else { - val currentRoute by remember { - derivedStateOf { backStack.lastOrNull() as? Route } - } + val currentTab by remember { derivedStateOf { Tab.fromRoute(currentRoute ?: Route.Tunnels) } } @@ -332,7 +365,7 @@ class MainActivity : AppCompatActivity() { ) Box(modifier = Modifier.fillMaxSize()) { - if (uiState.appMode == AppMode.LOCK_DOWN) { + if (uiState.tunnelMode == TunnelMode.LOCK_DOWN) { AppAlertBanner( stringResource(R.string.locked_down) .uppercase(Locale.current.platformLocale), @@ -346,11 +379,7 @@ class MainActivity : AppCompatActivity() { snackbarState.SnackbarHost( modifier = Modifier.align(Alignment.BottomCenter) - .padding( - bottom = - if (LocalIsAndroidTV.current) 120.dp - else 80.dp - ) + .padding(bottom = 80.dp) ) { info -> CustomSnackBar( message = info.message, @@ -387,7 +416,6 @@ class MainActivity : AppCompatActivity() { bottom = padding.calculateBottomPadding(), ) .consumeWindowInsets(padding) - .imePadding() ) { NavDisplay( backStack = backStack, @@ -438,6 +466,13 @@ class MainActivity : AppCompatActivity() { ) TunnelSettingsScreen(viewModel) } + entry { key -> + val viewModel: TunnelViewModel = + koinViewModel( + parameters = { parametersOf(key.id) } + ) + ConfigScreen(viewModel, key.live) + } entry { key -> val viewModel: SplitTunnelViewModel = koinViewModel( @@ -445,12 +480,12 @@ class MainActivity : AppCompatActivity() { ) SplitTunnelScreen(viewModel) } - entry { key -> - val viewModel: ConfigViewModel = + entry { key -> + val viewModel: ConfigEditViewModel = koinViewModel( parameters = { parametersOf(key.id) } ) - ConfigScreen(viewModel) + ConfigEditScreen(viewModel) } entry { LocationDisclosureScreen() @@ -459,26 +494,20 @@ class MainActivity : AppCompatActivity() { entry { WifiSettingsScreen() } - entry { - AutoTunnelAdvancedScreen() - } entry { WifiDetectionMethodScreen() } entry { SettingsScreen() } - entry { - TunnelMonitoringScreen() - } entry { AndroidIntegrationsScreen() } entry { DnsSettingsScreen() } entry { key -> - val viewModel: ConfigViewModel = + val viewModel: ConfigEditViewModel = koinViewModel( parameters = { parametersOf(key.id) } ) - ConfigScreen(viewModel) + ConfigEditScreen(viewModel) } entry { key -> val viewModel: SplitTunnelViewModel = @@ -487,6 +516,13 @@ class MainActivity : AppCompatActivity() { ) SplitTunnelScreen(viewModel) } + entry { key -> + val viewModel: TunnelViewModel = + koinViewModel( + parameters = { parametersOf(key.id) } + ) + IPv6Screen(viewModel) + } entry { LockdownSettingsScreen() } @@ -502,7 +538,9 @@ class MainActivity : AppCompatActivity() { entry { key -> PreferredTunnelScreen(key.tunnelNetwork) } - entry { PingTargetScreen() } + entry { TunnelGlobalsScreen() } + entry { SecurityScreen() } + entry { MonitoringScreen() } }, ) } @@ -514,70 +552,54 @@ class MainActivity : AppCompatActivity() { } } - override fun onResume() { - super.onResume() - networkMonitor.checkPermissionsAndUpdateState() - WireGuardAutoTunnel.setUiActive(true) - } - - override fun onPause() { - super.onPause() - WireGuardAutoTunnel.setUiActive(false) - } - - fun performBackup() = - lifecycleScope.launch { - // reset active tuns before backup to prevent trying to start them without permission on - // restore - tunnelRepository.resetActiveTunnels() - roomBackup - .database(appDatabase) - .backupLocation(RoomBackup.BACKUP_FILE_LOCATION_CUSTOM_DIALOG) - .enableLogDebug(true) - .maxFileCount(5) - .apply { - onCompleteListener { success, _, _ -> - lifecycleScope.launch { - if (success) { - showToast( - getString( - R.string.backup_success, - getString(R.string.restarting_app), - ) + fun performBackup() = lifecycleScope.launch { + roomBackup + .database(appDatabase) + .backupLocation(RoomBackup.BACKUP_FILE_LOCATION_CUSTOM_DIALOG) + .enableLogDebug(true) + .maxFileCount(5) + .apply { + onCompleteListener { success, _, _ -> + lifecycleScope.launch { + if (success) { + showToast( + getString( + R.string.backup_success, + getString(R.string.restarting_app), ) - restartApp() - } else { - showToast(R.string.backup_failed) - } + ) + restartApp() + } else { + showToast(R.string.backup_failed) } } } - .backup() - } + } + .backup() + } - fun performRestore() = - lifecycleScope.launch { - roomBackup - .database(appDatabase) - .enableLogDebug(true) - .backupLocation(RoomBackup.BACKUP_FILE_LOCATION_CUSTOM_DIALOG) - .apply { - onCompleteListener { success, _, _ -> - lifecycleScope.launch { - if (success) { - showToast( - getString( - R.string.restore_success, - getString(R.string.restarting_app), - ) + fun performRestore() = lifecycleScope.launch { + roomBackup + .database(appDatabase) + .enableLogDebug(true) + .backupLocation(RoomBackup.BACKUP_FILE_LOCATION_CUSTOM_DIALOG) + .apply { + onCompleteListener { success, _, _ -> + lifecycleScope.launch { + if (success) { + showToast( + getString( + R.string.restore_success, + getString(R.string.restarting_app), ) - restartApp() - } else { - showToast(R.string.restore_failed) - } + ) + restartApp() + } else { + showToast(R.string.restore_failed) } } } - .restore() - } + } + .restore() + } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/WireGuardAutoTunnel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/WireGuardAutoTunnel.kt index 1c0b95a98..d70a102ef 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/WireGuardAutoTunnel.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/WireGuardAutoTunnel.kt @@ -2,29 +2,33 @@ package com.zaneschepke.wireguardautotunnel import android.app.Application import android.os.StrictMode -import com.zaneschepke.logcatter.LogReader -import com.zaneschepke.wireguardautotunnel.core.notification.NotificationMonitor +import com.zaneschepke.tunnel.backend.Backend +import com.zaneschepke.tunnel.di.tunnelModule +import com.zaneschepke.tunnel.service.VpnService +import com.zaneschepke.wireguardautotunnel.core.event.TunnelEventDispatcher +import com.zaneschepke.wireguardautotunnel.core.notification.NotificationService +import com.zaneschepke.wireguardautotunnel.core.orchestration.AppBoostrapCoordinator +import com.zaneschepke.wireguardautotunnel.core.orchestration.TunnelCoordinator +import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelProvider import com.zaneschepke.wireguardautotunnel.di.Dispatcher import com.zaneschepke.wireguardautotunnel.di.Scope import com.zaneschepke.wireguardautotunnel.di.appModule +import com.zaneschepke.wireguardautotunnel.di.coordinatorModule import com.zaneschepke.wireguardautotunnel.di.databaseModule import com.zaneschepke.wireguardautotunnel.di.dispatchersModule import com.zaneschepke.wireguardautotunnel.di.networkModule -import com.zaneschepke.wireguardautotunnel.di.tunnelModule +import com.zaneschepke.wireguardautotunnel.di.tunnelBackendProviderModule import com.zaneschepke.wireguardautotunnel.di.workerModule -import com.zaneschepke.wireguardautotunnel.domain.repository.MonitoringSettingsRepository import com.zaneschepke.wireguardautotunnel.util.ReleaseTree import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.distinctUntilChangedBy -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import org.koin.android.ext.android.get import org.koin.android.ext.android.inject import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidLogger import org.koin.androidx.workmanager.koin.workManagerFactory +import org.koin.core.annotation.KoinViewModelScopeApi import org.koin.core.component.KoinComponent import org.koin.core.context.GlobalContext.startKoin import org.koin.core.lazyModules @@ -36,22 +40,36 @@ class WireGuardAutoTunnel : Application(), KoinComponent { private val applicationScope: CoroutineScope by inject(named(Scope.APPLICATION)) private val ioDispatcher: CoroutineDispatcher by inject(named(Dispatcher.IO)) - private val logReader: LogReader by inject() - private val monitoringRepository: MonitoringSettingsRepository by inject() - private val notificationMonitor: NotificationMonitor by inject() + private val boostrapCoordinator: AppBoostrapCoordinator by inject() + private val notificationService: NotificationService by inject() + + private val tunnelCoordinator: TunnelCoordinator by inject() + + private val backend: Backend by inject() + + @OptIn(KoinViewModelScopeApi::class) override fun onCreate() { super.onCreate() startKoin { androidContext(this@WireGuardAutoTunnel) if (BuildConfig.DEBUG) androidLogger() workManagerFactory() - modules(dispatchersModule, appModule, databaseModule, tunnelModule, workerModule) + modules( + dispatchersModule, + appModule, + databaseModule, + tunnelBackendProviderModule, + tunnelModule, + workerModule, + coordinatorModule, + ) options(viewModelScopeFactory()) lazyModules(networkModule) } instance = this + notificationService.createAllChannels() if (BuildConfig.DEBUG) { Timber.plant(Timber.DebugTree()) StrictMode.setThreadPolicy( @@ -66,44 +84,30 @@ class WireGuardAutoTunnel : Application(), KoinComponent { Timber.plant(ReleaseTree()) } - applicationScope.launch(ioDispatcher) { - launch { - monitoringRepository.flow - .distinctUntilChangedBy { it.isLocalLogsEnabled } - .collect { settings -> - if (settings.isLocalLogsEnabled) { - logReader.start() - } else { - logReader.stop() - } - } + backend.setAlwaysOnCallback( + object : VpnService.AlwaysOnCallback { + override fun alwaysOnTriggered() { + applicationScope.launch { tunnelCoordinator.startDefault() } + } } - launch { notificationMonitor.handleApplicationNotifications() } - } - } + ) - companion object { - private val _uiActive = MutableStateFlow(false) + val dispatcher = get() + val coordinator = get() + val provider = get() - val uiActive: StateFlow - get() = _uiActive + // for notifications + dispatcher.bind( + applicationScope, + provider.events, + provider.backendStatus, + coordinator.errors, + ) - fun setUiActive(active: Boolean) { - _uiActive.update { active } - } - - @Volatile private var lastActiveTunnels: List = emptyList() - - @Synchronized - fun getLastActiveTunnels(): List { - return lastActiveTunnels - } - - @Synchronized - fun setLastActiveTunnels(newTunnels: List) { - lastActiveTunnels = newTunnels - } + applicationScope.launch(ioDispatcher) { boostrapCoordinator.bootstrap() } + } + companion object { lateinit var instance: WireGuardAutoTunnel private set } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/broadcast/KernelReceiver.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/broadcast/KernelReceiver.kt deleted file mode 100644 index 4924148a0..000000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/broadcast/KernelReceiver.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.core.broadcast - -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager -import com.zaneschepke.wireguardautotunnel.di.Scope -import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch -import org.koin.core.component.KoinComponent -import org.koin.core.component.inject -import org.koin.core.qualifier.named - -class KernelReceiver : BroadcastReceiver(), KoinComponent { - - private val applicationScope: CoroutineScope by inject(named(Scope.APPLICATION)) - private val tunnelRepository: TunnelRepository by inject() - private val tunnelManager: TunnelManager by inject() - - override fun onReceive(context: Context, intent: Intent) { - val action = intent.action ?: return - applicationScope.launch { - if (action == REFRESH_TUNNELS_ACTION) { - tunnelManager.runningTunnelNames().forEach { name -> - val tunnel = tunnelRepository.findByTunnelName(name) - tunnel?.let { tunnelRepository.save(it.copy(isActive = true)) } - } - } - } - } - - companion object { - const val REFRESH_TUNNELS_ACTION = "com.wireguard.android.action.REFRESH_TUNNEL_STATES" - } -} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/broadcast/NotificationActionReceiver.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/broadcast/NotificationActionReceiver.kt index de6d6083f..c738c3b02 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/broadcast/NotificationActionReceiver.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/broadcast/NotificationActionReceiver.kt @@ -3,11 +3,11 @@ package com.zaneschepke.wireguardautotunnel.core.broadcast import android.content.BroadcastReceiver import android.content.Context import android.content.Intent -import com.zaneschepke.wireguardautotunnel.core.notification.NotificationManager -import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager +import com.zaneschepke.wireguardautotunnel.core.notification.NotificationService +import com.zaneschepke.wireguardautotunnel.core.orchestration.AutoTunnelCoordinator +import com.zaneschepke.wireguardautotunnel.core.orchestration.TunnelCoordinator import com.zaneschepke.wireguardautotunnel.di.Scope import com.zaneschepke.wireguardautotunnel.domain.enums.NotificationAction -import com.zaneschepke.wireguardautotunnel.domain.repository.AutoTunnelSettingsRepository import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent @@ -17,20 +17,30 @@ import org.koin.core.qualifier.named class NotificationActionReceiver : BroadcastReceiver(), KoinComponent { - private val tunnelManager: TunnelManager by inject() - private val autoTunnelRepository: AutoTunnelSettingsRepository by inject() + private val tunnelCoordinator: TunnelCoordinator by inject() + + private val autoTunnelCoordinator: AutoTunnelCoordinator by inject() + private val applicationScope: CoroutineScope = get(named(Scope.APPLICATION)) override fun onReceive(context: Context, intent: Intent) { + applicationScope.launch { when (intent.action) { - NotificationAction.AUTO_TUNNEL_OFF.name -> - autoTunnelRepository.updateAutoTunnelEnabled(false) + NotificationAction.AUTO_TUNNEL_OFF.name -> { + autoTunnelCoordinator.disable() + } + NotificationAction.TUNNEL_OFF.name -> { - val tunnelId = intent.getIntExtra(NotificationManager.EXTRA_ID, 0) - if (tunnelId == STOP_ALL_TUNNELS_ID) - return@launch tunnelManager.stopActiveTunnels() - tunnelManager.stopTunnel(tunnelId) + + val tunnelId = + intent.getIntExtra(NotificationService.EXTRA_ID, STOP_ALL_TUNNELS_ID) + + if (tunnelId == STOP_ALL_TUNNELS_ID) { + tunnelCoordinator.stopActiveTunnels() + return@launch + } + tunnelCoordinator.stopTunnel(tunnelId) } } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/broadcast/RemoteControlReceiver.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/broadcast/RemoteControlReceiver.kt index 2c0b876b4..f9d1a35cc 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/broadcast/RemoteControlReceiver.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/broadcast/RemoteControlReceiver.kt @@ -3,9 +3,10 @@ package com.zaneschepke.wireguardautotunnel.core.broadcast import android.content.BroadcastReceiver import android.content.Context import android.content.Intent -import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager +import com.zaneschepke.wireguardautotunnel.core.orchestration.AutoTunnelCoordinator +import com.zaneschepke.wireguardautotunnel.core.orchestration.TunnelCoordinator import com.zaneschepke.wireguardautotunnel.di.Scope -import com.zaneschepke.wireguardautotunnel.domain.repository.AutoTunnelSettingsRepository +import com.zaneschepke.wireguardautotunnel.domain.model.GeneralSettings import com.zaneschepke.wireguardautotunnel.domain.repository.GeneralSettingRepository import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository import com.zaneschepke.wireguardautotunnel.util.Constants @@ -14,15 +15,15 @@ import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent import org.koin.core.component.inject import org.koin.core.qualifier.named -import timber.log.Timber class RemoteControlReceiver : BroadcastReceiver(), KoinComponent { private val applicationScope: CoroutineScope by inject(named(Scope.APPLICATION)) + private val settingsRepository: GeneralSettingRepository by inject() private val tunnelsRepository: TunnelRepository by inject() - private val autoTunnelSettingsRepository: AutoTunnelSettingsRepository by inject() - private val tunnelManager: TunnelManager by inject() + private val tunnelCoordinator: TunnelCoordinator by inject() + private val autoTunnelCoordinator: AutoTunnelCoordinator by inject() enum class Action(private val suffix: String) { START_TUNNEL("START_TUNNEL"), @@ -47,45 +48,63 @@ class RemoteControlReceiver : BroadcastReceiver(), KoinComponent { } override fun onReceive(context: Context, intent: Intent) { - Timber.i("onReceive") + val action = intent.action ?: return - val appAction = Action.fromAction(action) ?: return Timber.w("Unknown action $action") + val appAction = Action.fromAction(action) ?: return + applicationScope.launch { val settings = settingsRepository.getGeneralSettings() - if (!settings.isRemoteControlEnabled) return@launch Timber.w("Remote control disabled") - val key = settings.remoteKey ?: return@launch Timber.w("Remote control key missing") - if (key != intent.getStringExtra(EXTRA_KEY)?.trim()) - return@launch Timber.w("Invalid remote control key") + + if (!settings.isRemoteControlEnabled) return@launch + + if (!validateKey(settings, intent)) return@launch + when (appAction) { Action.START_TUNNEL -> { - val tunnelName = - intent.getStringExtra(EXTRA_TUN_NAME) ?: return@launch startDefaultTunnel() val tunnel = - tunnelsRepository.findByTunnelName(tunnelName) - ?: return@launch startDefaultTunnel() - tunnelManager.startTunnel(tunnel) + resolveTunnel(intent) + ?: tunnelsRepository.getDefaultTunnel() + ?: return@launch + + tunnelCoordinator.startTunnel(tunnel) } + Action.STOP_TUNNEL -> { - val tunnelName = - intent.getStringExtra(EXTRA_TUN_NAME) - ?: return@launch tunnelManager.stopActiveTunnels() - val tunnel = - tunnelsRepository.findByTunnelName(tunnelName) - ?: return@launch tunnelManager.stopActiveTunnels() - tunnelManager.stopTunnel(tunnel.id) + val tunnelName = intent.getStringExtra(EXTRA_TUN_NAME) + + if (tunnelName == null) { + tunnelCoordinator.stopActiveTunnels() + return@launch + } + + val tunnel = tunnelsRepository.findByTunnelName(tunnelName) ?: return@launch + + tunnelCoordinator.stopTunnel(tunnel.id) + } + + Action.START_AUTO_TUNNEL -> { + autoTunnelCoordinator.enable() + } + + Action.STOP_AUTO_TUNNEL -> { + autoTunnelCoordinator.disable() } - Action.START_AUTO_TUNNEL -> - autoTunnelSettingsRepository.updateAutoTunnelEnabled(true) - Action.STOP_AUTO_TUNNEL -> - autoTunnelSettingsRepository.updateAutoTunnelEnabled(false) } } } - private suspend fun startDefaultTunnel() { - tunnelsRepository.getDefaultTunnel()?.let { tunnel -> tunnelManager.startTunnel(tunnel) } + private fun validateKey(settings: GeneralSettings, intent: Intent): Boolean { + + val expected = settings.remoteKey?.trim() ?: return false + + val actual = intent.getStringExtra(EXTRA_KEY)?.trim() + + return expected == actual } + private suspend fun resolveTunnel(intent: Intent) = + intent.getStringExtra(EXTRA_TUN_NAME)?.let { tunnelsRepository.findByTunnelName(it) } + companion object { const val EXTRA_TUN_NAME = "tunnelName" const val EXTRA_KEY = "key" diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/broadcast/RestartReceiver.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/broadcast/RestartReceiver.kt index 4b836423f..773e3e7d3 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/broadcast/RestartReceiver.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/broadcast/RestartReceiver.kt @@ -4,7 +4,7 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import com.zaneschepke.logcatter.LogReader -import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager +import com.zaneschepke.wireguardautotunnel.core.orchestration.StartupCoordinator import com.zaneschepke.wireguardautotunnel.di.Scope import com.zaneschepke.wireguardautotunnel.domain.repository.AppStateRepository import kotlinx.coroutines.CoroutineScope @@ -19,7 +19,7 @@ class RestartReceiver : BroadcastReceiver(), KoinComponent { private val applicationScope: CoroutineScope = get(named(Scope.APPLICATION)) - private val tunnelManager: TunnelManager by inject() + private val startupCoordinator: StartupCoordinator by inject() private val appStateRepository: AppStateRepository by inject() @@ -32,11 +32,11 @@ class RestartReceiver : BroadcastReceiver(), KoinComponent { Intent.ACTION_BOOT_COMPLETED, "android.intent.action.QUICKBOOT_POWERON", "com.htc.intent.action.QUICKBOOT_POWERON" -> { - tunnelManager.handleReboot() + startupCoordinator.applyStartupPolicy() } Intent.ACTION_MY_PACKAGE_REPLACED -> { Timber.i("Restoring state on package upgrade") - tunnelManager.handleRestore() + startupCoordinator.applyStartupPolicy() logReader.deleteAndClearLogs() appStateRepository.setShouldShowDonationSnackbar(true) } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/event/TunnelErrorEvent.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/event/TunnelErrorEvent.kt new file mode 100644 index 000000000..8e6fc6c87 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/event/TunnelErrorEvent.kt @@ -0,0 +1,21 @@ +package com.zaneschepke.wireguardautotunnel.core.event + +import com.zaneschepke.tunnel.util.BackendException + +sealed interface TunnelErrorEvent { + data class VpnPermissionDenied(val tunnelId: Int) : TunnelErrorEvent + + data class StateConflict(val tunnelId: Int, val message: String) : TunnelErrorEvent + + data class InternalFailure(val tunnelId: Int?, val message: String) : TunnelErrorEvent + + companion object { + fun from(throwable: Throwable, id: Int?): TunnelErrorEvent { + return when (throwable) { + is BackendException.StateConflict -> StateConflict(id ?: -1, throwable.message) + is BackendException.Unauthorized -> InternalFailure(id, "Unauthorized") + else -> InternalFailure(id, throwable.message ?: "Unknown") + } + } + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/event/TunnelEventDispatcher.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/event/TunnelEventDispatcher.kt new file mode 100644 index 000000000..a09b80209 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/event/TunnelEventDispatcher.kt @@ -0,0 +1,81 @@ +package com.zaneschepke.wireguardautotunnel.core.event + +import com.zaneschepke.tunnel.event.TunnelEvent +import com.zaneschepke.tunnel.state.BackendStatus +import com.zaneschepke.wireguardautotunnel.core.notification.TunnelNotificationService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach + +class TunnelEventDispatcher(private val notificationManager: TunnelNotificationService) { + + fun bind( + scope: CoroutineScope, + providerEvents: Flow, + providerStatus: StateFlow, + coordinatorErrors: Flow, + ) { + + // informational events + providerEvents + .distinctUntilChanged() + .onEach { event -> + when (event) { + is TunnelEvent.FallbackToIpv4 -> { + notificationManager.showIpv4Fallback(event.tunnelId) + } + + is TunnelEvent.RecoveredToIpv6 -> { + notificationManager.showIpv6Recovery(event.tunnelId) + } + + is TunnelEvent.DynamicDnsUpdate -> { + notificationManager.showDynamicDnsUpdate(event.tunnelId) + } + + is TunnelEvent.NoRootShellAccess -> { + notificationManager.showRootShellAccess() + } + } + } + .launchIn(scope) + + // errors from the coordinator + coordinatorErrors + .distinctUntilChanged() + .onEach { error -> + when (error) { + is TunnelErrorEvent.VpnPermissionDenied -> { + notificationManager.showVpnRequired() + } + + is TunnelErrorEvent.StateConflict -> { + notificationManager.showStateConflict(error.tunnelId) + } + + is TunnelErrorEvent.InternalFailure -> { + notificationManager.showError(error.message) + } + } + } + .launchIn(scope) + + // update persistent notification for services with the tunnel states + providerStatus + .map { it.activeTunnels } + .distinctUntilChangedBy { map -> + val stateSignature = + map.entries + .sortedBy { it.key } + .map { (_, tunnel) -> tunnel.transportState to tunnel.bootstrapState } + map.size to stateSignature + } + .onEach { status -> notificationManager.updatePersistentNotifications(status) } + .launchIn(scope) + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/notification/WireGuardNotification.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/notification/AndroidNotificationService.kt similarity index 66% rename from app/src/main/java/com/zaneschepke/wireguardautotunnel/core/notification/WireGuardNotification.kt rename to app/src/main/java/com/zaneschepke/wireguardautotunnel/core/notification/AndroidNotificationService.kt index 466fc6051..baa3e3e2e 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/notification/WireGuardNotification.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/notification/AndroidNotificationService.kt @@ -3,26 +3,24 @@ package com.zaneschepke.wireguardautotunnel.core.notification import android.Manifest import android.app.Notification import android.app.NotificationChannel +import android.app.NotificationManager.IMPORTANCE_LOW import android.app.PendingIntent import android.content.Context import android.content.Intent import android.content.pm.PackageManager import androidx.core.app.ActivityCompat import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationCompat.Action +import androidx.core.app.NotificationCompat.Builder import androidx.core.app.NotificationManagerCompat import com.zaneschepke.wireguardautotunnel.MainActivity import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.core.broadcast.NotificationActionReceiver -import com.zaneschepke.wireguardautotunnel.core.notification.NotificationManager.Companion.EXTRA_ID +import com.zaneschepke.wireguardautotunnel.core.notification.NotificationService.Companion.EXTRA_ID import com.zaneschepke.wireguardautotunnel.domain.enums.NotificationAction import com.zaneschepke.wireguardautotunnel.util.StringValue -class WireGuardNotification(override val context: Context) : NotificationManager { - - enum class NotificationChannels { - VPN, - AUTO_TUNNEL, - } +class AndroidNotificationService(override val context: Context) : NotificationService { private val notificationManager = NotificationManagerCompat.from(context) @@ -30,16 +28,15 @@ class WireGuardNotification(override val context: Context) : NotificationManager channel: NotificationChannels, title: String, subText: String?, - actions: Collection, + actions: Collection, description: String, showTimestamp: Boolean, - importance: Int, onGoing: Boolean, onlyAlertOnce: Boolean, groupKey: String?, isGroupSummary: Boolean, ): Notification { - notificationManager.createNotificationChannel(channel.asChannel(importance)) + notificationManager.createNotificationChannel(channel.asChannel()) return channel .asBuilder() .apply { @@ -58,7 +55,6 @@ class WireGuardNotification(override val context: Context) : NotificationManager setContentText(description) setOnlyAlertOnce(onlyAlertOnce) setOngoing(onGoing) - setPriority(NotificationCompat.PRIORITY_LOW) setShowWhen(showTimestamp) setSmallIcon(R.drawable.ic_notification) if (groupKey != null) { @@ -75,10 +71,9 @@ class WireGuardNotification(override val context: Context) : NotificationManager channel: NotificationChannels, title: StringValue, subText: String?, - actions: Collection, + actions: Collection, description: StringValue, showTimestamp: Boolean, - importance: Int, onGoing: Boolean, onlyAlertOnce: Boolean, groupKey: String?, @@ -91,16 +86,17 @@ class WireGuardNotification(override val context: Context) : NotificationManager actions, description.asString(context), showTimestamp, - importance, onGoing, onlyAlertOnce, + groupKey, + isGroupSummary, ) } override fun createNotificationAction( notificationAction: NotificationAction, extraId: Int?, - ): NotificationCompat.Action { + ): Action { val pendingIntent = PendingIntent.getBroadcast( context, @@ -139,40 +135,49 @@ class WireGuardNotification(override val context: Context) : NotificationManager private fun NotificationChannels.asBuilder(): NotificationCompat.Builder { return when (this) { - NotificationChannels.AUTO_TUNNEL -> { - NotificationCompat.Builder( - context, - context.getString(R.string.auto_tunnel_channel_id), - ) - } + NotificationChannels.AUTO_TUNNEL -> + Builder(context, context.getString(R.string.auto_tunnel_channel_id)) + NotificationChannels.VPN -> Builder(context, context.getString(R.string.vpn_channel_id)) - NotificationChannels.VPN -> { - NotificationCompat.Builder(context, context.getString(R.string.vpn_channel_id)) - } + NotificationChannels.PROXY -> + Builder(context, context.getString(R.string.proxy_channel_id)) } } - private fun NotificationChannels.asChannel(importance: Int): NotificationChannel { - return when (this) { - NotificationChannels.VPN -> { - NotificationChannel( - context.getString(R.string.vpn_channel_id), - context.getString(R.string.vpn_channel_name), - importance, - ) - .apply { description = context.getString(R.string.vpn_channel_description) } - } + enum class NotificationChannels(val channelId: Int, val importance: Int) { + VPN(R.string.vpn_channel_id, IMPORTANCE_LOW), + AUTO_TUNNEL(R.string.auto_tunnel_channel_id, IMPORTANCE_LOW), + PROXY(R.string.proxy_channel_id, IMPORTANCE_LOW), + } - NotificationChannels.AUTO_TUNNEL -> { - NotificationChannel( - context.getString(R.string.auto_tunnel_channel_id), - context.getString(R.string.auto_tunnel_channel_name), - importance, - ) - .apply { - description = context.getString(R.string.auto_tunnel_channel_description) + fun NotificationChannels.asChannel(): NotificationChannel { + return NotificationChannel( + context.getString(channelId), + context.getString( + when (this) { + NotificationChannels.VPN -> R.string.vpn + NotificationChannels.AUTO_TUNNEL -> R.string.auto_tunnel + NotificationChannels.PROXY -> R.string.proxy } + ), + importance, + ) + .apply { + description = + context.getString( + when (this@asChannel) { + NotificationChannels.VPN -> R.string.vpn_channel_description + NotificationChannels.AUTO_TUNNEL -> + R.string.auto_tunnel_channel_description + NotificationChannels.PROXY -> R.string.proxy_channel_description + } + ) } + } + + override fun createAllChannels() { + NotificationChannels.entries.forEach { channel -> + notificationManager.createNotificationChannel(channel.asChannel()) } } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/notification/AndroidTunnelNotificationService.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/notification/AndroidTunnelNotificationService.kt new file mode 100644 index 000000000..85485fa0c --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/notification/AndroidTunnelNotificationService.kt @@ -0,0 +1,189 @@ +package com.zaneschepke.wireguardautotunnel.core.notification + +import com.zaneschepke.tunnel.model.BackendMode +import com.zaneschepke.tunnel.state.ActiveTunnel +import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.core.notification.AndroidNotificationService.NotificationChannels +import com.zaneschepke.wireguardautotunnel.core.notification.NotificationService.Companion.PROXY_GROUP_KEY +import com.zaneschepke.wireguardautotunnel.core.notification.NotificationService.Companion.PROXY_NOTIFICATION_ID +import com.zaneschepke.wireguardautotunnel.core.notification.NotificationService.Companion.TUNNEL_ERROR_NOTIFICATION_ID +import com.zaneschepke.wireguardautotunnel.core.notification.NotificationService.Companion.TUNNEL_MESSAGES_NOTIFICATION_ID +import com.zaneschepke.wireguardautotunnel.core.notification.NotificationService.Companion.VPN_GROUP_KEY +import com.zaneschepke.wireguardautotunnel.core.notification.NotificationService.Companion.VPN_NOTIFICATION_ID +import com.zaneschepke.wireguardautotunnel.domain.enums.NotificationAction +import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository +import com.zaneschepke.wireguardautotunnel.ui.state.DisplayTunnelState + +class AndroidTunnelNotificationService( + private val notificationService: NotificationService, + private val tunnelRepository: TunnelRepository, +) : TunnelNotificationService { + + override suspend fun updatePersistentNotifications(activeTunnels: Map) { + + val vpnTunnels = activeTunnels.filterValues { it.mode is BackendMode.Vpn } + + val proxyTunnels = activeTunnels.filterValues { it.mode is BackendMode.Proxy } + + updateGroupNotification( + tunnels = vpnTunnels, + notificationId = VPN_NOTIFICATION_ID, + channel = NotificationChannels.VPN, + groupKey = VPN_GROUP_KEY, + ) + + updateGroupNotification( + tunnels = proxyTunnels, + notificationId = PROXY_NOTIFICATION_ID, + channel = NotificationChannels.PROXY, + groupKey = PROXY_GROUP_KEY, + ) + } + + private suspend fun updateGroupNotification( + tunnels: Map, + notificationId: Int, + channel: NotificationChannels, + groupKey: String, + ) { + + if (tunnels.isEmpty()) { + notificationService.remove(notificationId) + return + } + + val context = notificationService.context + + val lines = tunnels.mapNotNull { (id, activeTunnel) -> + val tunnel = tunnelRepository.getById(id) ?: return@mapNotNull null + val display = DisplayTunnelState.from(activeTunnel) + + context.getString( + R.string.notification_tunnel_status_format, + tunnel.name, + display.asLocalizedString(context), + ) + } + + val description = lines.joinToString("\n") + + val stopActions = + tunnels.keys.map { + notificationService.createNotificationAction( + notificationAction = NotificationAction.TUNNEL_OFF, + extraId = it, + ) + } + + val title = + when (channel) { + NotificationChannels.VPN -> context.getString(R.string.vpn) + + NotificationChannels.PROXY -> context.getString(R.string.proxy) + + NotificationChannels.AUTO_TUNNEL -> context.getString(R.string.auto_tunnel) + } + + val notification = + notificationService.createNotification( + channel = channel, + title = title, + description = description, + actions = stopActions, + onGoing = true, + onlyAlertOnce = true, + groupKey = groupKey, + ) + + notificationService.show(notificationId, notification) + } + + override suspend fun showIpv4Fallback(tunnelId: Int) { + + val context = notificationService.context + val name = tunnelName(tunnelId) + + showMessage( + title = context.getString(R.string.ipv4_fallback), + message = context.getString(R.string.notification_ipv4_fallback_message, name), + ) + } + + override suspend fun showIpv6Recovery(tunnelId: Int) { + + val context = notificationService.context + val name = tunnelName(tunnelId) + + showMessage( + title = context.getString(R.string.ipv6_recovery), + message = context.getString(R.string.notification_ipv6_recovery_message, name), + ) + } + + override suspend fun showDynamicDnsUpdate(tunnelId: Int) { + + val context = notificationService.context + val name = tunnelName(tunnelId) + + showMessage( + title = context.getString(R.string.dynamic_dns_update), + message = context.getString(R.string.notification_dynamic_dns_message, name), + ) + } + + override suspend fun showVpnRequired() { + + showError(notificationService.context.getString(R.string.vpn_permission_required)) + } + + override suspend fun showStateConflict(tunnelId: Int) { + + val context = notificationService.context + val name = tunnelName(tunnelId) + + showError(context.getString(R.string.notification_tunnel_already_running, name)) + } + + override suspend fun showRootShellAccess() { + // TODO could improve with fix action + val context = notificationService.context + showError(context.getString(R.string.error_root_denied)) + } + + override suspend fun showError(message: String) { + + val notification = + notificationService.createNotification( + channel = NotificationChannels.VPN, + title = notificationService.context.getString(R.string.error), + description = message, + onGoing = false, + onlyAlertOnce = true, + groupKey = VPN_GROUP_KEY, + ) + + notificationService.show(TUNNEL_ERROR_NOTIFICATION_ID, notification) + } + + private fun showMessage(title: String, message: String) { + + val notification = + notificationService.createNotification( + channel = NotificationChannels.VPN, + title = title, + description = message, + onGoing = false, + onlyAlertOnce = true, + groupKey = VPN_GROUP_KEY, + ) + + notificationService.show(TUNNEL_MESSAGES_NOTIFICATION_ID, notification) + } + + private suspend fun tunnelName(id: Int): String { + + val context = notificationService.context + + return tunnelRepository.getById(id)?.name ?: context.getString(R.string.unknown, id) + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/notification/NotificationMonitor.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/notification/NotificationMonitor.kt deleted file mode 100644 index 74e0cf4d1..000000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/notification/NotificationMonitor.kt +++ /dev/null @@ -1,61 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.core.notification - -import com.zaneschepke.wireguardautotunnel.R -import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel -import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager -import com.zaneschepke.wireguardautotunnel.util.StringValue -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch - -class NotificationMonitor( - private val tunnelManager: TunnelManager, - private val notificationManager: NotificationManager, -) { - suspend fun handleApplicationNotifications() = coroutineScope { - launch { handleTunnelErrors() } - launch { handleTunnelMessages() } - } - - private suspend fun handleTunnelErrors() = - tunnelManager.errorEvents.collectLatest { (tunName, error) -> - if (!WireGuardAutoTunnel.uiActive.value) { - val notification = - notificationManager.createNotification( - WireGuardNotification.NotificationChannels.VPN, - title = - tunName?.let { StringValue.DynamicString(it) } - ?: StringValue.StringResource(R.string.tunnel), - description = - StringValue.StringResource( - R.string.tunnel_error_template, - error.stringRes, - ), - groupKey = NotificationManager.VPN_GROUP_KEY, - ) - notificationManager.show( - NotificationManager.TUNNEL_ERROR_NOTIFICATION_ID, - notification, - ) - } - } - - private suspend fun handleTunnelMessages() = - tunnelManager.messageEvents.collectLatest { (tunName, message) -> - if (!WireGuardAutoTunnel.uiActive.value) { - val notification = - notificationManager.createNotification( - WireGuardNotification.NotificationChannels.VPN, - title = - tunName?.let { StringValue.DynamicString(it) } - ?: StringValue.StringResource(R.string.tunnel), - description = message.toStringValue(), - groupKey = NotificationManager.VPN_GROUP_KEY, - ) - notificationManager.show( - NotificationManager.TUNNEL_MESSAGES_NOTIFICATION_ID, - notification, - ) - } - } -} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/notification/NotificationManager.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/notification/NotificationService.kt similarity index 87% rename from app/src/main/java/com/zaneschepke/wireguardautotunnel/core/notification/NotificationManager.kt rename to app/src/main/java/com/zaneschepke/wireguardautotunnel/core/notification/NotificationService.kt index 57226ced4..aec7f5ee6 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/notification/NotificationManager.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/notification/NotificationService.kt @@ -1,14 +1,13 @@ package com.zaneschepke.wireguardautotunnel.core.notification import android.app.Notification -import android.app.NotificationManager import android.content.Context import androidx.core.app.NotificationCompat -import com.zaneschepke.wireguardautotunnel.core.notification.WireGuardNotification.NotificationChannels +import com.zaneschepke.wireguardautotunnel.core.notification.AndroidNotificationService.NotificationChannels import com.zaneschepke.wireguardautotunnel.domain.enums.NotificationAction import com.zaneschepke.wireguardautotunnel.util.StringValue -interface NotificationManager { +interface NotificationService { val context: Context fun createNotification( @@ -18,7 +17,6 @@ interface NotificationManager { actions: Collection = emptyList(), description: String = "", showTimestamp: Boolean = true, - importance: Int = NotificationManager.IMPORTANCE_LOW, onGoing: Boolean = false, onlyAlertOnce: Boolean = true, groupKey: String? = null, @@ -32,13 +30,14 @@ interface NotificationManager { actions: Collection = emptyList(), description: StringValue, showTimestamp: Boolean = true, - importance: Int = NotificationManager.IMPORTANCE_LOW, onGoing: Boolean = false, onlyAlertOnce: Boolean = true, groupKey: String? = null, isGroupSummary: Boolean = false, ): Notification + fun createAllChannels() + fun createNotificationAction( notificationAction: NotificationAction, extraId: Int? = null, @@ -50,6 +49,7 @@ interface NotificationManager { companion object { const val VPN_GROUP_KEY = "VPN_GROUP" + const val PROXY_GROUP_KEY = "PROXY_GROUP" const val AUTO_TUNNEL_GROUP_KEY = "AUTO_TUNNEL_GROUP" const val AUTO_TUNNEL_LOCATION_PERMISSION_ID = 123 const val AUTO_TUNNEL_LOCATION_SERVICES_ID = 124 @@ -57,6 +57,7 @@ interface NotificationManager { const val AUTO_TUNNEL_NOTIFICATION_ID = 122 // for tunnel foreground notification const val VPN_NOTIFICATION_ID = 100 + const val PROXY_NOTIFICATION_ID = 103 const val TUNNEL_ERROR_NOTIFICATION_ID = 101 const val TUNNEL_MESSAGES_NOTIFICATION_ID = 102 const val EXTRA_ID = "id" diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/notification/TunnelNotificationService.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/notification/TunnelNotificationService.kt new file mode 100644 index 000000000..c48c97c78 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/notification/TunnelNotificationService.kt @@ -0,0 +1,22 @@ +package com.zaneschepke.wireguardautotunnel.core.notification + +import com.zaneschepke.tunnel.state.ActiveTunnel + +interface TunnelNotificationService { + + suspend fun updatePersistentNotifications(activeTunnels: Map) + + suspend fun showIpv4Fallback(tunnelId: Int) + + suspend fun showIpv6Recovery(tunnelId: Int) + + suspend fun showDynamicDnsUpdate(tunnelId: Int) + + suspend fun showVpnRequired() + + suspend fun showStateConflict(tunnelId: Int) + + suspend fun showRootShellAccess() + + suspend fun showError(message: String) +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/orchestration/AppBoostrapCoordinator.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/orchestration/AppBoostrapCoordinator.kt new file mode 100644 index 000000000..e558ef68a --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/orchestration/AppBoostrapCoordinator.kt @@ -0,0 +1,61 @@ +package com.zaneschepke.wireguardautotunnel.core.orchestration + +import com.zaneschepke.logcatter.LogReader +import com.zaneschepke.tunnel.backend.Backend +import com.zaneschepke.tunnel.model.DnsBoostrapConfig +import com.zaneschepke.tunnel.model.DnsBoostrapMode +import com.zaneschepke.wireguardautotunnel.domain.enums.DnsProtocol +import com.zaneschepke.wireguardautotunnel.domain.repository.DnsSettingsRepository +import com.zaneschepke.wireguardautotunnel.domain.repository.MonitoringSettingsRepository +import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.launch + +class AppBoostrapCoordinator( + private val monitoringRepository: MonitoringSettingsRepository, + private val dnsRepository: DnsSettingsRepository, + private val tunnelRepository: TunnelRepository, + private val backend: Backend, + private val logReader: LogReader, +) { + + suspend fun bootstrap() = coroutineScope { + launch { bootstrapDns() } + launch { bootstrapLogging() } + launch { ensureGlobalConfig() } + } + + private suspend fun bootstrapDns() { + val dnsSettings = dnsRepository.getDnsSettings() + + val mode = + when (dnsSettings.dnsProtocol) { + DnsProtocol.SYSTEM -> DnsBoostrapMode.System + DnsProtocol.DOH -> + DnsBoostrapMode.Custom(DnsBoostrapConfig.DoH(dnsSettings.dnsEndpoint)) + DnsProtocol.DOT -> + DnsBoostrapMode.Custom(DnsBoostrapConfig.DoT(dnsSettings.dnsEndpoint)) + DnsProtocol.UDP -> + DnsBoostrapMode.Custom(DnsBoostrapConfig.Plain(dnsSettings.dnsEndpoint)) + } + + backend.setBootstrapDnsMode(mode) + } + + private suspend fun bootstrapLogging() { + monitoringRepository.flow + .distinctUntilChangedBy { it.isLocalLogsEnabled } + .collect { settings -> + if (settings.isLocalLogsEnabled) { + logReader.start() + } else { + logReader.stop() + } + } + } + + private suspend fun ensureGlobalConfig() { + tunnelRepository.ensureGlobalConfigExists() + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/orchestration/AutoTunnelCoordinator.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/orchestration/AutoTunnelCoordinator.kt new file mode 100644 index 000000000..53df65b77 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/orchestration/AutoTunnelCoordinator.kt @@ -0,0 +1,41 @@ +package com.zaneschepke.wireguardautotunnel.core.orchestration + +import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager +import com.zaneschepke.wireguardautotunnel.core.service.autotunnel.AutoTunnelStateHolder +import com.zaneschepke.wireguardautotunnel.domain.repository.AutoTunnelSettingsRepository + +class AutoTunnelCoordinator( + private val repository: AutoTunnelSettingsRepository, + private val serviceManager: ServiceManager, + private val autoTunnelStateHolder: AutoTunnelStateHolder, +) { + + suspend fun shouldTakeOverBoot(): Boolean { + val settings = repository.getAutoTunnelSettings() + return settings.startOnBoot && settings.isAutoTunnelEnabled + } + + suspend fun restoreIfNeeded(): Boolean { + if (!shouldTakeOverBoot()) return false + + serviceManager.startAutoTunnelService() + return true + } + + suspend fun enable() { + repository.updateAutoTunnelEnabled(true) + serviceManager.startAutoTunnelService() + } + + suspend fun toggle() { + val running = autoTunnelStateHolder.active.value + if (running) { + disable() + } else enable() + } + + suspend fun disable() { + repository.updateAutoTunnelEnabled(false) + serviceManager.stopAutoTunnelService() + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/orchestration/ConfigReconciler.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/orchestration/ConfigReconciler.kt new file mode 100644 index 000000000..a4d490c29 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/orchestration/ConfigReconciler.kt @@ -0,0 +1,52 @@ +package com.zaneschepke.wireguardautotunnel.core.orchestration + +import com.zaneschepke.wireguardautotunnel.parser.Config +import com.zaneschepke.wireguardautotunnel.parser.InterfaceSection + +object ConfigReconciler { + private fun mergeInterface( + base: InterfaceSection, + global: InterfaceSection, + policy: ConfigReconcilePolicy, + ): InterfaceSection { + return base.copy( + dns = if (policy.dns) global.dns else base.dns, + includedApplications = + if (policy.splitTunnel) global.includedApplications else base.includedApplications, + excludedApplications = + if (policy.splitTunnel) global.excludedApplications else base.excludedApplications, + jC = if (policy.amnezia) global.jC else base.jC, + jMin = if (policy.amnezia) global.jMin else base.jMin, + jMax = if (policy.amnezia) global.jMax else base.jMax, + s1 = if (policy.amnezia) global.s1 else base.s1, + s2 = if (policy.amnezia) global.s2 else base.s2, + s3 = if (policy.amnezia) global.s3 else base.s3, + s4 = if (policy.amnezia) global.s4 else base.s4, + h1 = if (policy.amnezia) global.h1 else base.h1, + h2 = if (policy.amnezia) global.h2 else base.h2, + h3 = if (policy.amnezia) global.h3 else base.h3, + h4 = if (policy.amnezia) global.h4 else base.h4, + i1 = if (policy.amnezia) global.i1 else base.i1, + i2 = if (policy.amnezia) global.i2 else base.i2, + i3 = if (policy.amnezia) global.i3 else base.i3, + i4 = if (policy.amnezia) global.i4 else base.i4, + i5 = if (policy.amnezia) global.i5 else base.i5, + ) + } + + fun reconcileConfig(base: Config, global: Config?, policy: ConfigReconcilePolicy): Config { + if (global == null) return base + if (!policy.hasAnyOverrides) return base + + return base.copy(`interface` = mergeInterface(base.`interface`, global.`interface`, policy)) + } + + data class ConfigReconcilePolicy( + val dns: Boolean, + val splitTunnel: Boolean, + val amnezia: Boolean, + ) { + val hasAnyOverrides + get() = dns || splitTunnel || amnezia + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/orchestration/ShortcutCoordinator.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/orchestration/ShortcutCoordinator.kt new file mode 100644 index 000000000..a3179ac69 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/orchestration/ShortcutCoordinator.kt @@ -0,0 +1,84 @@ +package com.zaneschepke.wireguardautotunnel.core.orchestration + +import android.content.Intent +import com.zaneschepke.wireguardautotunnel.core.shortcut.ShortcutContract +import com.zaneschepke.wireguardautotunnel.domain.repository.GeneralSettingRepository +import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository + +class ShortcutCoordinator( + private val settingsRepository: GeneralSettingRepository, + private val tunnelsRepository: TunnelRepository, + private val tunnelCoordinator: TunnelCoordinator, + private val autoTunnelCoordinator: AutoTunnelCoordinator, +) { + + suspend fun handle(intent: Intent) { + + val settings = settingsRepository.getGeneralSettings() + + if (!settings.isShortcutsEnabled) return + + val shortcutType = + intent.getStringExtra(ShortcutContract.EXTRA_SHORTCUT_TYPE) + ?: legacyShortcutType(intent) + + when (shortcutType) { + ShortcutContract.ShortcutType.TUNNEL.value -> { + handleTunnelShortcut(intent) + } + + ShortcutContract.ShortcutType.AUTO_TUNNEL.value -> { + handleAutoTunnelShortcut(intent) + } + } + } + + private suspend fun handleAutoTunnelShortcut(intent: Intent) { + + when (intent.action) { + ShortcutContract.Action.START.name -> { + autoTunnelCoordinator.enable() + } + + ShortcutContract.Action.STOP.name -> { + autoTunnelCoordinator.disable() + } + } + } + + private fun legacyShortcutType(intent: Intent): String? { + + return when (intent.getStringExtra(ShortcutContract.EXTRA_CLASS_NAME)) { + ShortcutContract.Legacy.AUTO_TUNNEL_SERVICE_CLASS_NAME, + ShortcutContract.Legacy.AUTO_TUNNEL_SERVICE_NAME -> + ShortcutContract.ShortcutType.AUTO_TUNNEL.value + + ShortcutContract.Legacy.TUNNEL_PROVIDER_NAME, + ShortcutContract.Legacy.TUNNEL_SERVICE_NAME -> + ShortcutContract.ShortcutType.TUNNEL.value + + else -> null + } + } + + private suspend fun handleTunnelShortcut(intent: Intent) { + + val tunnelName = intent.getStringExtra(ShortcutContract.EXTRA_TUNNEL_NAME) + + val tunnel = + tunnelName?.let { tunnelsRepository.findByTunnelName(it) } + ?: tunnelsRepository.getDefaultTunnel() + + tunnel ?: return + + when (intent.action) { + ShortcutContract.Action.START.name -> { + tunnelCoordinator.startTunnel(config = tunnel) + } + + ShortcutContract.Action.STOP.name -> { + tunnelCoordinator.stopActiveTunnels() + } + } + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/orchestration/StartupCoordinator.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/orchestration/StartupCoordinator.kt new file mode 100644 index 000000000..f6aa17a3b --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/orchestration/StartupCoordinator.kt @@ -0,0 +1,53 @@ +package com.zaneschepke.wireguardautotunnel.core.orchestration + +import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager +import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelProvider +import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelMode +import com.zaneschepke.wireguardautotunnel.domain.repository.GeneralSettingRepository +import com.zaneschepke.wireguardautotunnel.domain.repository.LockdownSettingsRepository +import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository + +class StartupCoordinator( + private val tunnelCoordinator: TunnelCoordinator, + private val tunnelProvider: TunnelProvider, + private val settingsRepository: GeneralSettingRepository, + private val autoTunnelCoordinator: AutoTunnelCoordinator, + private val tunnelRepository: TunnelRepository, + private val lockdownRepository: LockdownSettingsRepository, + private val serviceManager: ServiceManager, +) { + + suspend fun applyStartupPolicy(): Result { + + val settings = settingsRepository.getGeneralSettings() + + if (!settings.isRestoreOnBootEnabled) { + return Result.success(Unit) + } + + val autoTunnelTookOver = autoTunnelCoordinator.restoreIfNeeded() + + if (autoTunnelTookOver) { + return Result.success(Unit) + } + + val mode = settings.tunnelMode + + if (mode == TunnelMode.VPN && !serviceManager.hasVpnPermission()) { + return Result.failure(IllegalStateException("VPN permission missing")) + } + + if (mode == TunnelMode.LOCK_DOWN) { + val lockdownSettings = lockdownRepository.getLockdownSettings() + tunnelProvider.setLockDown(lockdownSettings).getOrElse { + return Result.failure(it) + } + } + + val defaultTunnel = tunnelRepository.getDefaultTunnel() ?: return Result.success(Unit) + + tunnelCoordinator.startTunnel(defaultTunnel) + + return Result.success(Unit) + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/orchestration/TunnelCoordinator.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/orchestration/TunnelCoordinator.kt new file mode 100644 index 000000000..edaba8060 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/orchestration/TunnelCoordinator.kt @@ -0,0 +1,206 @@ +package com.zaneschepke.wireguardautotunnel.core.orchestration + +import com.zaneschepke.tunnel.model.BackendMode +import com.zaneschepke.wireguardautotunnel.core.event.TunnelErrorEvent +import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager +import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelProvider +import com.zaneschepke.wireguardautotunnel.data.repository.RoomDnsSettingsRepository +import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelActionSource +import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelMode +import com.zaneschepke.wireguardautotunnel.domain.events.TunnelActionEvent +import com.zaneschepke.wireguardautotunnel.domain.model.DnsSettings +import com.zaneschepke.wireguardautotunnel.domain.model.GeneralSettings +import com.zaneschepke.wireguardautotunnel.domain.model.MonitoringSettings +import com.zaneschepke.wireguardautotunnel.domain.model.ProxySettings +import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConfig +import com.zaneschepke.wireguardautotunnel.domain.repository.GeneralSettingRepository +import com.zaneschepke.wireguardautotunnel.domain.repository.MonitoringSettingsRepository +import com.zaneschepke.wireguardautotunnel.domain.repository.ProxySettingsRepository +import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +class TunnelCoordinator( + private val tunnelProvider: TunnelProvider, + private val serviceManager: ServiceManager, + settingsRepository: GeneralSettingRepository, + private val tunnelRepository: TunnelRepository, + dnsSettingsRepository: RoomDnsSettingsRepository, + monitoringSettingsRepository: MonitoringSettingsRepository, + proxyRepository: ProxySettingsRepository, + scope: CoroutineScope, +) { + + data class RuntimeSettingsSnapshot( + val general: GeneralSettings, + val dns: DnsSettings, + val monitoring: MonitoringSettings, + val proxy: ProxySettings, + ) + + private val runtimeSettingsSnapshot = + combine( + settingsRepository.flow, + dnsSettingsRepository.flow, + monitoringSettingsRepository.flow, + proxyRepository.flow, + ) { general, dns, monitoring, proxy -> + RuntimeSettingsSnapshot( + general = general, + dns = dns, + monitoring = monitoring, + proxy = proxy, + ) + } + + private val _actions = MutableSharedFlow() + val actions = _actions.asSharedFlow() + + private val runtimeSettingsSnapshotState = + runtimeSettingsSnapshot.stateIn( + scope = scope, + started = SharingStarted.Eagerly, + initialValue = null, + ) + + private suspend fun getSnapshot(): RuntimeSettingsSnapshot { + return runtimeSettingsSnapshotState.filterNotNull().first() + } + + private var lastActiveTunnels: List = emptyList() + private val tunnelMutex = Mutex() + private val _errors = MutableSharedFlow() + val errors = _errors.asSharedFlow() + + val backendStatus = tunnelProvider.backendStatus + + suspend fun startTunnel( + config: TunnelConfig, + source: TunnelActionSource = TunnelActionSource.USER, + ) = tunnelMutex.withLock { startTunnelInternal(config, source) } + + suspend fun stopTunnel(id: Int, source: TunnelActionSource = TunnelActionSource.USER) = + tunnelMutex.withLock { + stopTunnelInternal(id, source) + } + + suspend fun stopActiveTunnels() = tunnelMutex.withLock { stopActiveTunnelsInternal() } + + private suspend fun startTunnelInternal( + tunnelConfig: TunnelConfig, + source: TunnelActionSource, + ) { + + val snapshot = getSnapshot() + val settings = snapshot.general + val dnsSettings = snapshot.dns + val proxySettings = snapshot.proxy + val monitoringSettings = snapshot.monitoring + + val config = tunnelConfig.getConfig() + val policy = + ConfigReconciler.ConfigReconcilePolicy( + dnsSettings.isGlobalTunnelDnsEnabled, + settings.isGlobalSplitTunnelEnabled, + settings.isGlobalAmneziaEnabled, + ) + + val runConfig = + if (policy.hasAnyOverrides) { + val globalConfig = tunnelRepository.globalTunnelFlow.firstOrNull()?.getConfig() + ConfigReconciler.reconcileConfig(config, globalConfig, policy) + } else config + + val backendMode = + when (settings.tunnelMode) { + TunnelMode.VPN -> { + + if (!serviceManager.hasVpnPermission()) { + _errors.emit(TunnelErrorEvent.VpnPermissionDenied(tunnelConfig.id)) + return + } + + BackendMode.Vpn(runConfig) + } + + TunnelMode.PROXY -> { + BackendMode.Proxy.Standard( + config = runConfig, + proxyConfig = proxySettings.toProxyConfig(), + ) + } + + TunnelMode.LOCK_DOWN -> { + + BackendMode.Proxy.KillSwitchPrimary(runConfig) + } + } + + // TODO for now, enforce single tunnel until multi-tunneling is implement + stopActiveTunnelsInternal() + + tunnelProvider + .startTunnel( + tunnel = + tunnelConfig.toBackendTunnel( + monitoringSettings, + settings.tunnelScriptingEnabled, + ), + mode = backendMode, + ) + .onSuccess { + _actions.emit( + TunnelActionEvent.Started(tunnelId = tunnelConfig.id, source = source) + ) + } + .onFailure { _errors.emit(TunnelErrorEvent.from(it, tunnelConfig.id)) } + } + + suspend fun startDefault() { + tunnelRepository.getDefaultTunnel()?.let { tunnel -> startTunnel(tunnel) } + } + + suspend fun toggleTunnels(source: TunnelActionSource = TunnelActionSource.USER) = + tunnelMutex.withLock { + val active = tunnelProvider.backendStatus.value.activeTunnels + + if (active.isNotEmpty()) { + lastActiveTunnels = active.keys.toList() + stopActiveTunnelsInternal() + return@withLock + } + + val tunnelsToStart = + when { + lastActiveTunnels.isNotEmpty() -> { + lastActiveTunnels.mapNotNull { tunnelRepository.getById(it) } + } + + else -> { + tunnelRepository.getDefaultTunnel()?.let(::listOf) ?: emptyList() + } + } + + tunnelsToStart.forEach { startTunnelInternal(it, source) } + } + + private suspend fun stopTunnelInternal(id: Int, source: TunnelActionSource) { + tunnelProvider + .stopTunnel(id) + .onSuccess { _actions.emit(TunnelActionEvent.Stopped(tunnelId = id, source = source)) } + .onFailure { _errors.emit(TunnelErrorEvent.from(it, id)) } + } + + private suspend fun stopActiveTunnelsInternal() { + tunnelProvider.stopActiveTunnels() + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/orchestration/TunnelModeCoordinator.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/orchestration/TunnelModeCoordinator.kt new file mode 100644 index 000000000..45fdaf913 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/orchestration/TunnelModeCoordinator.kt @@ -0,0 +1,53 @@ +package com.zaneschepke.wireguardautotunnel.core.orchestration + +import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelProvider +import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelMode +import com.zaneschepke.wireguardautotunnel.domain.repository.GeneralSettingRepository +import com.zaneschepke.wireguardautotunnel.domain.repository.LockdownSettingsRepository + +class TunnelModeCoordinator( + private val tunnelProvider: TunnelProvider, + private val settingsRepository: GeneralSettingRepository, + private val lockdownRepository: LockdownSettingsRepository, +) { + + suspend fun changeMode(newMode: TunnelMode): Result { + + val settings = settingsRepository.getGeneralSettings() + val oldMode = settings.tunnelMode + + if (oldMode == newMode) { + return Result.success(Unit) + } + + return runCatching { + tunnelProvider.stopActiveTunnels().getOrThrow() + exitMode(oldMode) + enterMode(newMode) + + settingsRepository.upsert(settings.copy(tunnelMode = newMode)) + } + } + + private suspend fun exitMode(oldMode: TunnelMode) { + when (oldMode) { + TunnelMode.LOCK_DOWN -> { + tunnelProvider.disableLockDown().getOrThrow() + } + else -> Unit + } + } + + private suspend fun enterMode(newMode: TunnelMode) { + when (newMode) { + TunnelMode.LOCK_DOWN -> { + val lockdownSettings = lockdownRepository.getLockdownSettings() + + tunnelProvider.setLockDown(lockdownSettings).getOrThrow() + } + + TunnelMode.VPN, + TunnelMode.PROXY -> Unit + } + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/service/BaseTunnelForegroundService.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/service/BaseTunnelForegroundService.kt deleted file mode 100644 index 7cce881f0..000000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/service/BaseTunnelForegroundService.kt +++ /dev/null @@ -1,232 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.core.service - -import android.app.Notification -import android.content.Intent -import android.os.IBinder -import android.text.format.Formatter -import androidx.core.app.ServiceCompat -import androidx.lifecycle.LifecycleService -import androidx.lifecycle.lifecycleScope -import com.zaneschepke.wireguardautotunnel.R -import com.zaneschepke.wireguardautotunnel.core.notification.NotificationManager -import com.zaneschepke.wireguardautotunnel.core.notification.WireGuardNotification -import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager -import com.zaneschepke.wireguardautotunnel.di.Dispatcher -import com.zaneschepke.wireguardautotunnel.domain.enums.NotificationAction -import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConfig -import com.zaneschepke.wireguardautotunnel.domain.repository.GeneralSettingRepository -import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository -import com.zaneschepke.wireguardautotunnel.util.extensions.distinctByKeys -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import org.koin.android.ext.android.inject -import org.koin.core.qualifier.named -import timber.log.Timber - -abstract class BaseTunnelForegroundService : LifecycleService(), TunnelService { - - private val notificationManager: NotificationManager by inject() - private val serviceManager: ServiceManager by inject() - private val tunnelManager: TunnelManager by inject() - private val ioDispatcher: CoroutineDispatcher by inject(named(Dispatcher.IO)) - private val settingsRepository: GeneralSettingRepository by inject() - private val tunnelsRepository: TunnelRepository by inject() - - protected abstract val fgsType: Int - - private var currentSingleTunnelId: Int? = null - - private var statsJob: Job? = null - - override fun onBind(intent: Intent): IBinder { - super.onBind(intent) - return LocalBinder(this) - } - - override fun onCreate() { - super.onCreate() - ServiceCompat.startForeground( - this, - NotificationManager.VPN_NOTIFICATION_ID, - onCreateNotification(), - fgsType, - ) - } - - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - super.onStartCommand(intent, flags, startId) - - ServiceCompat.startForeground( - this, - NotificationManager.VPN_NOTIFICATION_ID, - onCreateNotification(), - fgsType, - ) - - if ( - intent == null || - intent.component == null || - (intent.component?.packageName != this.packageName) - ) { - Timber.d("Service started by Always-on VPN feature") - lifecycleScope.launch { - val settings = settingsRepository.getGeneralSettings() - if (settings.isAlwaysOnVpnEnabled) { - val tunnel = tunnelsRepository.getDefaultTunnel() - tunnel?.let { tunnelManager.startTunnel(it) } - } else { - Timber.w("Always-on VPN is not enabled in app settings") - } - } - } else { - start() - } - - return START_STICKY - } - - override fun start() { - lifecycleScope.launch(ioDispatcher) { - tunnelManager.activeTunnels.distinctByKeys().collect { activeTunnels -> - val activeTunIds = activeTunnels.keys - val tunnels = tunnelsRepository.getAll() - val activeConfigs = tunnels.filter { activeTunIds.contains(it.id) } - - updateServiceNotification(activeConfigs) - restartStatsUpdaterIfNeeded(activeConfigs) - } - } - } - - private fun restartStatsUpdaterIfNeeded(activeConfigs: List) { - val single = activeConfigs.singleOrNull() - - if (single == null) { - statsJob?.cancel() - statsJob = null - currentSingleTunnelId = null - return - } - - if (currentSingleTunnelId == single.id && statsJob?.isActive == true) return - - statsJob?.cancel() - statsJob = null - currentSingleTunnelId = single.id - - statsJob = - lifecycleScope.launch(ioDispatcher) { - while (isActive) { - val traffic = readTraffic(single.id) - - notificationManager.show( - NotificationManager.VPN_NOTIFICATION_ID, - createTunnelNotification(single, consumedTraffic = traffic), - ) - - delay(1000) - } - } - } - - private fun readTraffic(tunnelId: Int): Pair? { - val active = tunnelManager.activeTunnels.value[tunnelId] ?: return null - val stats = active.statistics ?: return null - return stats.rx() to stats.tx() - } - - private fun updateServiceNotification(activeConfigs: List) { - val notification = - when (activeConfigs.size) { - 0 -> onCreateNotification() - 1 -> createTunnelNotification(activeConfigs.first(), consumedTraffic = null) - else -> createTunnelsNotification() - } - - ServiceCompat.startForeground( - this, - NotificationManager.VPN_NOTIFICATION_ID, - notification, - fgsType, - ) - } - - override fun stop() { - Timber.d("Stop called") - statsJob?.cancel() - statsJob = null - currentSingleTunnelId = null - - ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) - stopSelf() - } - - override fun onDestroy() { - serviceManager.handleTunnelServiceDestroy() - - statsJob?.cancel() - statsJob = null - currentSingleTunnelId = null - - ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) - Timber.d("onDestroy") - super.onDestroy() - } - - private fun createTunnelNotification( - tunnelConfig: TunnelConfig, - consumedTraffic: Pair?, - ): Notification { - - val subText = - consumedTraffic?.let { traffic -> - val formattedRx = "↓ ${formatBytes(traffic.first)}" - val formattedTx = "↑ ${formatBytes(traffic.second)}" - "$formattedRx $formattedTx" - } - - return notificationManager.createNotification( - WireGuardNotification.NotificationChannels.VPN, - title = tunnelConfig.name, - description = getString(R.string.tunnel_running), - subText = subText, - actions = - listOf( - notificationManager.createNotificationAction( - NotificationAction.TUNNEL_OFF, - tunnelConfig.id, - ) - ), - onGoing = true, - groupKey = NotificationManager.VPN_GROUP_KEY, - isGroupSummary = true, - ) - } - - private fun createTunnelsNotification(): Notification { - return notificationManager.createNotification( - WireGuardNotification.NotificationChannels.VPN, - title = "${getString(R.string.tunnel_running)} - ${getString(R.string.multiple)}", - actions = - listOf( - notificationManager.createNotificationAction(NotificationAction.TUNNEL_OFF, 0) - ), - groupKey = NotificationManager.VPN_GROUP_KEY, - isGroupSummary = true, - ) - } - - private fun onCreateNotification(): Notification { - return notificationManager.createNotification( - WireGuardNotification.NotificationChannels.VPN, - title = getString(R.string.tunnel_starting), - groupKey = NotificationManager.VPN_GROUP_KEY, - isGroupSummary = true, - ) - } - - private fun formatBytes(bytes: Long) = Formatter.formatFileSize(this, bytes) -} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/service/LocalBinder.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/service/LocalBinder.kt deleted file mode 100644 index e1c005a94..000000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/service/LocalBinder.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.core.service - -import android.os.Binder -import java.lang.ref.WeakReference - -class LocalBinder(service: TunnelService) : Binder() { - private val serviceRef = WeakReference(service) - - val service: TunnelService? - get() = serviceRef.get() -} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/service/ServiceManager.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/service/ServiceManager.kt index 24de47f20..442c26e1c 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/service/ServiceManager.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/service/ServiceManager.kt @@ -1,192 +1,21 @@ package com.zaneschepke.wireguardautotunnel.core.service -import android.content.ComponentName import android.content.Context import android.content.Intent -import android.content.ServiceConnection import android.net.VpnService -import android.os.IBinder import com.zaneschepke.wireguardautotunnel.core.service.autotunnel.AutoTunnelService -import com.zaneschepke.wireguardautotunnel.data.model.AppMode -import com.zaneschepke.wireguardautotunnel.domain.repository.AutoTunnelSettingsRepository -import com.zaneschepke.wireguardautotunnel.util.extensions.requestAutoTunnelTileServiceUpdate -import com.zaneschepke.wireguardautotunnel.util.extensions.requestTunnelTileServiceStateUpdate -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeoutOrNull -import timber.log.Timber -class ServiceManager( - private val context: Context, - ioDispatcher: CoroutineDispatcher, - applicationScope: CoroutineScope, - private val mainDispatcher: CoroutineDispatcher, - private val autoTunnelSettingsRepository: AutoTunnelSettingsRepository, -) { +class ServiceManager(private val context: Context) { - private val autoTunnelMutex = Mutex() - private val tunnelMutex = Mutex() - - private val _tunnelService = MutableStateFlow(null) - private val _autoTunnelService = MutableStateFlow(null) - val autoTunnelService = _autoTunnelService.asStateFlow() - val tunnelService = _tunnelService.asStateFlow() - - init { - applicationScope.launch(ioDispatcher) { - _autoTunnelService - .onEach { _ -> withContext(mainDispatcher) { updateAutoTunnelTile() } } - .launchIn(this) - } - applicationScope.launch(ioDispatcher) { - combine( - autoTunnelSettingsRepository.flow - .map { it.isAutoTunnelEnabled } - .distinctUntilChanged(), - _autoTunnelService, - ) { enabled, service -> - enabled to (service != null) - } - .collect { (enabled, isRunning) -> - when { - enabled && !isRunning -> { - autoTunnelMutex.withLock { startServiceInternal() } - } - !enabled && isRunning -> { - autoTunnelMutex.withLock { stopServiceInternal() } - } - } - } - } + fun startAutoTunnelService() { + context.startForegroundService(Intent(context, AutoTunnelService::class.java)) } - private val tunnelServiceConnection = - object : ServiceConnection { - override fun onServiceConnected(name: ComponentName, service: IBinder) { - val binder = service as? LocalBinder - _tunnelService.update { binder?.service } - val serviceClass = - when { - name.className.contains("VpnForegroundService") -> "VpnForegroundService" - name.className.contains("TunnelForegroundService") -> - "TunnelForegroundService" - else -> "Unknown" - } - Timber.d("$serviceClass connected") - } - - override fun onServiceDisconnected(name: ComponentName) { - _tunnelService.update { null } - val serviceClass = - when { - name.className.contains("VpnForegroundService") -> "VpnForegroundService" - name.className.contains("TunnelForegroundService") -> - "TunnelForegroundService" - else -> "Unknown" - } - Timber.d("$serviceClass disconnected") - } - } - - private val autoTunnelServiceConnection = - object : ServiceConnection { - override fun onServiceConnected(name: ComponentName, service: IBinder) { - val binder = service as? AutoTunnelService.LocalBinder - _autoTunnelService.update { binder?.service } - Timber.d("AutoTunnelService connected") - } - - override fun onServiceDisconnected(name: ComponentName) { - _autoTunnelService.update { null } - Timber.d("AutoTunnelService disconnected") - } - } + fun stopAutoTunnelService() { + context.stopService(Intent(context, AutoTunnelService::class.java)) + } fun hasVpnPermission(): Boolean { return VpnService.prepare(context) == null } - - private fun startServiceInternal() { - if (autoTunnelService.value == null) { - val intent = Intent(context, AutoTunnelService::class.java) - context.startForegroundService(intent) - context.bindService(intent, autoTunnelServiceConnection, Context.BIND_AUTO_CREATE) - } - } - - suspend fun startAutoTunnelService() = autoTunnelMutex.withLock { startServiceInternal() } - - private fun stopServiceInternal() { - _autoTunnelService.value?.stop() - try { - context.unbindService(autoTunnelServiceConnection) - } catch (e: Exception) { - Timber.e(e, "Failed to unbind AutoTunnelService") - } - _autoTunnelService.update { null } - } - - suspend fun startTunnelService(appMode: AppMode) = - tunnelMutex.withLock { - if (_tunnelService.value != null) { - Timber.d("Service already exists, waiting for disconnect") - withTimeoutOrNull(2000L) { _tunnelService.first { it == null } } - ?: Timber.w("Timeout waiting for existing service to disconnect") - } - if (_tunnelService.value == null) { - val serviceClass = - when (appMode) { - AppMode.VPN, - AppMode.LOCK_DOWN -> VpnForegroundService::class.java - AppMode.KERNEL, - AppMode.PROXY -> TunnelForegroundService::class.java - } - val intent = Intent(context, serviceClass) - context.startForegroundService(intent) - context.bindService(intent, tunnelServiceConnection, Context.BIND_AUTO_CREATE) - } else { - Timber.e("Service still not null after timeout") - } - } - - suspend fun stopTunnelService() = - tunnelMutex.withLock { - _tunnelService.value?.let { service -> - service.stop() - try { - context.unbindService(tunnelServiceConnection) - } catch (e: Exception) { - Timber.e(e, "Failed to unbind Tunnel Service") - } - } - } - - fun updateAutoTunnelTile() { - context.requestAutoTunnelTileServiceUpdate() - } - - fun updateTunnelTile() { - context.requestTunnelTileServiceStateUpdate() - } - - fun handleTunnelServiceDestroy() { - _tunnelService.update { null } - } - - fun handleAutoTunnelServiceDestroy() { - _autoTunnelService.update { null } - } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/service/TunnelForegroundService.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/service/TunnelForegroundService.kt deleted file mode 100644 index 10e71ce85..000000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/service/TunnelForegroundService.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.core.service - -import com.zaneschepke.wireguardautotunnel.util.Constants - -class TunnelForegroundService(override val fgsType: Int = Constants.SPECIAL_USE_SERVICE_TYPE_ID) : - BaseTunnelForegroundService() diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/service/TunnelService.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/service/TunnelService.kt deleted file mode 100644 index 6e4b10f5f..000000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/service/TunnelService.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.core.service - -interface TunnelService { - fun start() - - fun stop() -} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/service/VpnForegroundService.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/service/VpnForegroundService.kt deleted file mode 100644 index b504a7a0a..000000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/service/VpnForegroundService.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.core.service - -import com.zaneschepke.wireguardautotunnel.util.Constants - -class VpnForegroundService(override val fgsType: Int = Constants.SYSTEM_EXEMPT_SERVICE_TYPE_ID) : - BaseTunnelForegroundService() diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/service/autotunnel/AutoTunnelEngine.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/service/autotunnel/AutoTunnelEngine.kt new file mode 100644 index 000000000..a7126f265 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/service/autotunnel/AutoTunnelEngine.kt @@ -0,0 +1,100 @@ +package com.zaneschepke.wireguardautotunnel.core.service.autotunnel + +import com.zaneschepke.wireguardautotunnel.domain.events.AutoTunnelEvent +import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConfig +import com.zaneschepke.wireguardautotunnel.domain.state.ActiveNetwork +import com.zaneschepke.wireguardautotunnel.domain.state.AutoTunnelState + +class AutoTunnelEngine { + + fun evaluate(state: AutoTunnelState): AutoTunnelEvent { + return when (val decision = decide(state)) { + is Decision.Sync -> { + if (decision.start.isEmpty() && decision.stop.isEmpty()) { + AutoTunnelEvent.DoNothing + } else { + AutoTunnelEvent.Sync(start = decision.start, stop = decision.stop) + } + } + Decision.None -> AutoTunnelEvent.DoNothing + } + } + + private fun decide(state: AutoTunnelState): Decision { + val network = state.networkState + val settings = state.settings + val backend = state.backendStatus + + val activeTunnelIds = backend.activeTunnels.keys.toSet() + + val desiredTunnels = resolveDesiredTunnels(state).map { it.id }.toSet() + + // stop condition overrides everything + if (!network.hasInternet() && settings.isStopOnNoInternetEnabled) { + return Decision.Sync(start = emptySet(), stop = activeTunnelIds) + } + + val toStart = desiredTunnels - activeTunnelIds + val toStop = activeTunnelIds - desiredTunnels + + if (toStart.isEmpty() && toStop.isEmpty()) { + return Decision.None + } + + return Decision.Sync( + start = state.tunnels.filter { it.id in toStart }.toSet(), + stop = toStop, + ) + } + + private fun resolveDesiredTunnels(state: AutoTunnelState): List { + val network = state.networkState + val settings = state.settings + + val wifiActive = network.activeNetwork is ActiveNetwork.Wifi + val mobileActive = network.activeNetwork is ActiveNetwork.Cellular + val ethernetActive = network.activeNetwork is ActiveNetwork.Ethernet + + return when { + ethernetActive && settings.isTunnelOnEthernetEnabled -> + resolveByPriority(state) { it.isEthernetTunnel } + + mobileActive && settings.isTunnelOnMobileDataEnabled -> + resolveByPriority(state) { it.isMobileDataTunnel } + + wifiActive && settings.isTunnelOnWifiEnabled && !isWifiTrusted(state) -> + resolveWifiTunnels(state) + else -> emptyList() + } + } + + private fun resolveByPriority( + state: AutoTunnelState, + predicate: (TunnelConfig) -> Boolean, + ): List { + return listOfNotNull(state.tunnels.firstOrNull(predicate) ?: defaultTunnel(state)) + } + + private fun resolveWifiTunnels(state: AutoTunnelState): List { + val wifi = state.networkState.activeNetwork as? ActiveNetwork.Wifi ?: return emptyList() + + val matched = state.tunnels.filter { state.matchesNetwork(wifi.ssid, it.tunnelNetworks) } + + return matched.ifEmpty { listOfNotNull(defaultTunnel(state)) } + } + + private fun isWifiTrusted(state: AutoTunnelState): Boolean { + val wifi = state.networkState.activeNetwork as? ActiveNetwork.Wifi ?: return false + return state.matchesNetwork(wifi.ssid, state.settings.trustedNetworkSSIDs) + } + + private fun defaultTunnel(state: AutoTunnelState): TunnelConfig? { + return state.tunnels.firstOrNull { it.isPrimaryTunnel } ?: state.tunnels.firstOrNull() + } + + private sealed interface Decision { + data class Sync(val start: Set, val stop: Set) : Decision + + data object None : Decision + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/service/autotunnel/AutoTunnelService.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/service/autotunnel/AutoTunnelService.kt index 82900ecc0..2568a82b2 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/service/autotunnel/AutoTunnelService.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/service/autotunnel/AutoTunnelService.kt @@ -1,23 +1,21 @@ package com.zaneschepke.wireguardautotunnel.core.service.autotunnel import android.content.Intent -import android.os.Binder -import android.os.IBinder import androidx.core.app.ServiceCompat import androidx.lifecycle.LifecycleService import androidx.lifecycle.lifecycleScope import com.zaneschepke.networkmonitor.AndroidNetworkMonitor -import com.zaneschepke.networkmonitor.ConnectivityState -import com.zaneschepke.networkmonitor.NetworkMonitor +import com.zaneschepke.networkmonitor.StableNetworkEngine import com.zaneschepke.wireguardautotunnel.R -import com.zaneschepke.wireguardautotunnel.core.notification.NotificationManager -import com.zaneschepke.wireguardautotunnel.core.notification.WireGuardNotification -import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager -import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager -import com.zaneschepke.wireguardautotunnel.data.model.AppMode +import com.zaneschepke.wireguardautotunnel.core.notification.AndroidNotificationService +import com.zaneschepke.wireguardautotunnel.core.notification.NotificationService +import com.zaneschepke.wireguardautotunnel.core.orchestration.TunnelCoordinator import com.zaneschepke.wireguardautotunnel.di.Dispatcher import com.zaneschepke.wireguardautotunnel.domain.enums.NotificationAction +import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelActionSource +import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelMode import com.zaneschepke.wireguardautotunnel.domain.events.AutoTunnelEvent +import com.zaneschepke.wireguardautotunnel.domain.events.TunnelActionEvent import com.zaneschepke.wireguardautotunnel.domain.model.AutoTunnelSettings import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConfig import com.zaneschepke.wireguardautotunnel.domain.repository.AutoTunnelSettingsRepository @@ -27,24 +25,15 @@ import com.zaneschepke.wireguardautotunnel.domain.state.AutoTunnelState import com.zaneschepke.wireguardautotunnel.domain.state.toDomain import com.zaneschepke.wireguardautotunnel.util.Constants import com.zaneschepke.wireguardautotunnel.util.extensions.to -import com.zaneschepke.wireguardautotunnel.util.extensions.toMillis -import java.lang.ref.WeakReference import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.merge -import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -54,49 +43,67 @@ import timber.log.Timber class AutoTunnelService : LifecycleService() { - private val networkMonitor: NetworkMonitor by inject() + private val engine = AutoTunnelEngine() - private val notificationManager: NotificationManager by inject() + private val reconciliationMutex = Mutex() - private val ioDispatcher: CoroutineDispatcher by inject(named(Dispatcher.IO)) + private val networkEngine: StableNetworkEngine by inject() + + private val notificationService: NotificationService by inject() - private val serviceManager: ServiceManager by inject() + private val ioDispatcher: CoroutineDispatcher by inject(named(Dispatcher.IO)) - private val tunnelManager: TunnelManager by inject() + private val stateHolder: AutoTunnelStateHolder by inject() private val autoTunnelRepository: AutoTunnelSettingsRepository by inject() private val settingsRepository: GeneralSettingRepository by inject() private val tunnelsRepository: TunnelRepository by inject() - - private val defaultState = AutoTunnelState() - - private val autoTunMutex = Mutex() - - private val autoTunnelStateFlow = MutableStateFlow(defaultState) - + private val tunnelCoordinator: TunnelCoordinator by inject() private var autoTunnelJob: Job? = null private var permissionsJob: Job? = null - private var autoTunnelFailoverJob: Job? = null - - class LocalBinder(service: AutoTunnelService) : Binder() { - private val serviceRef = WeakReference(service) - - val service: AutoTunnelService? - get() = serviceRef.get() + private var overridesJob: Job? = null + + @Volatile private var manualOverrideState = ManualOverrideState() + + private data class PermissionWarningState( + val detectionMethod: AndroidNetworkMonitor.WifiDetectionMethod, + val locationServicesEnabled: Boolean, + val locationPermissionsEnabled: Boolean, + val ssidReadRequired: Boolean, + ) + + private data class ManualOverrideState( + val fingerprint: AutoTunnelState.NetworkFingerprint? = null, + val stoppedTunnelIds: Set = emptySet(), + val startedTunnelIds: Set = emptySet(), + ) + + private val autoTunnelStateFlow: Flow by lazy { + val networkFlow = networkEngine.stableState.mapNotNull { it?.state?.toDomain() } + + val settingsFlow = combineSettings() + + val backendFlow = + tunnelCoordinator.backendStatus.distinctUntilChangedBy { it.activeTunnels.keys.toSet() } + + combine(networkFlow, settingsFlow, backendFlow) { network, settings, backend -> + AutoTunnelState( + networkState = network, + settings = settings.second, + tunnelMode = settings.first, + tunnels = settings.third, + backendStatus = backend, + ) + } + .distinctUntilChanged() } - private val binder = LocalBinder(this) - override fun onCreate() { super.onCreate() + stateHolder.setActive(true) launchWatcherNotification() } - override fun onBind(intent: Intent): IBinder { - super.onBind(intent) - return binder - } - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { super.onStartCommand(intent, flags, startId) Timber.d("onStartCommand executed with startId: $startId") @@ -105,44 +112,88 @@ class AutoTunnelService : LifecycleService() { } fun start() { + stateHolder.setActive(true) launchWatcherNotification() autoTunnelJob?.cancel() autoTunnelJob = startAutoTunnelStateJob() permissionsJob?.cancel() permissionsJob = startLocationPermissionsNotificationJob() + overridesJob?.cancel() + overridesJob = startOverridesJob() } fun stop() { + stateHolder.setActive(false) stopSelf() } override fun onDestroy() { - serviceManager.handleAutoTunnelServiceDestroy() ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) + stateHolder.setActive(false) super.onDestroy() } + private fun startOverridesJob(): Job = + lifecycleScope.launch(ioDispatcher) { + tunnelCoordinator.actions.collect { action -> + reconciliationMutex.withLock { + manualOverrideState = + when (action) { + is TunnelActionEvent.Started -> { + + if (action.source != TunnelActionSource.USER) { + return@withLock + } + + manualOverrideState.copy( + startedTunnelIds = + manualOverrideState.startedTunnelIds + action.tunnelId, + stoppedTunnelIds = + manualOverrideState.stoppedTunnelIds - action.tunnelId, + ) + } + + is TunnelActionEvent.Stopped -> { + + if (action.source != TunnelActionSource.USER) { + return@withLock + } + + manualOverrideState.copy( + stoppedTunnelIds = + manualOverrideState.stoppedTunnelIds + action.tunnelId, + startedTunnelIds = + manualOverrideState.startedTunnelIds - action.tunnelId, + ) + } + } + + Timber.d("Updated manual overrides: $manualOverrideState") + } + } + } + private fun launchWatcherNotification( description: String = getString(R.string.monitoring_state_changes) ) { val notification = - notificationManager.createNotification( - WireGuardNotification.NotificationChannels.AUTO_TUNNEL, + notificationService.createNotification( + AndroidNotificationService.NotificationChannels.AUTO_TUNNEL, title = getString(R.string.auto_tunnel_title), description = description, actions = listOf( - notificationManager.createNotificationAction( + notificationService.createNotificationAction( NotificationAction.AUTO_TUNNEL_OFF ) ), onGoing = true, - groupKey = NotificationManager.AUTO_TUNNEL_GROUP_KEY, + groupKey = NotificationService.AUTO_TUNNEL_GROUP_KEY, isGroupSummary = true, ) ServiceCompat.startForeground( this, - NotificationManager.AUTO_TUNNEL_NOTIFICATION_ID, + NotificationService.AUTO_TUNNEL_NOTIFICATION_ID, notification, Constants.SPECIAL_USE_SERVICE_TYPE_ID, ) @@ -150,244 +201,149 @@ class AutoTunnelService : LifecycleService() { private fun startAutoTunnelStateJob(): Job = lifecycleScope.launch(ioDispatcher) { - val networkFlow = - debouncedConnectivityStateFlow - .flowOn(ioDispatcher) - .map { it.toDomain() } - .map(::NetworkChange) - .distinctUntilChanged() - - val settingsFlow = - combineSettings().map { (appMode, settings, tunnels) -> - SettingsChange(appMode, settings, tunnels) - } + autoTunnelStateFlow.collectLatest { state -> + reconciliationMutex.withLock { + updateFingerprintIfNeeded(state) - val tunnelsFlow = tunnelManager.activeTunnels.map(::ActiveTunnelsChange) + val rawEvent = engine.evaluate(state) - var reevaluationJob: Job? = null + val event = applyOverrides(rawEvent) - // get everything in sync before we use merge - combine(networkFlow, settingsFlow, tunnelsFlow) { network, settings, tunnels -> - autoTunnelStateFlow.update { - it.copy( - activeTunnels = tunnels.activeTunnels, - networkState = network.networkState, - settings = settings.settings, - tunnels = settings.tunnels, - ) - } - } - .first() + Timber.d("AutoTunnel reconciliation event: $event") - val initialState = autoTunnelStateFlow.value - if (initialState != defaultState) { - handleAutoTunnelEvent( - initialState.determineAutoTunnelEvent(NetworkChange(initialState.networkState)) - ) + handleAutoTunnelEvent(event) + } } + } - // use merge to limit the noise of a combine and also increase the scalability of auto - // tunnel handling new states - merge(networkFlow, settingsFlow, tunnelsFlow).collect { change -> - if (change !is ActiveTunnelsChange) { - Timber.d("New state changed to ${change.javaClass.simpleName}") - } + private fun updateFingerprintIfNeeded(state: AutoTunnelState) { + val fingerprint = state.networkFingerPrint - val previousState = autoTunnelStateFlow.value + if (manualOverrideState.fingerprint != fingerprint) { + Timber.d("Network changed, clearing overrides") - when (change) { - is NetworkChange -> { - Timber.d("Network change: ${change.networkState}") - reevaluationJob?.cancel() - autoTunnelStateFlow.update { it.copy(networkState = change.networkState) } - if (previousState.networkState == change.networkState) { - Timber.d("Duplicate network state change detected, ignoring") - return@collect - } - } - is SettingsChange -> { - reevaluationJob?.cancel() - autoTunnelStateFlow.update { - it.copy(settings = change.settings, tunnels = change.tunnels) - } - if ( - previousState.settings == change.settings && - previousState.tunnels == change.tunnels - ) { - Timber.d("Duplicate settings change detected, ignoring") - return@collect - } - } - is ActiveTunnelsChange -> { - autoTunnelStateFlow.update { it.copy(activeTunnels = change.activeTunnels) } - return@collect - } - } + manualOverrideState = ManualOverrideState(fingerprint = fingerprint) + } + } - handleAutoTunnelEvent(autoTunnelStateFlow.value.determineAutoTunnelEvent(change)) + private fun applyOverrides(event: AutoTunnelEvent): AutoTunnelEvent { - // re-evaluate network state after a short duration to prevent missed state changes - reevaluationJob = launch { - val snapshotNetwork = autoTunnelStateFlow.value.networkState - delay(REEVALUATE_CHECK_DELAY) - val currentState = autoTunnelStateFlow.value - if ( - currentState != defaultState && currentState.networkState != snapshotNetwork - ) { - Timber.d( - "Re-evaluating auto-tunnel state.. (network changed since snapshot)" - ) - handleAutoTunnelEvent(currentState.determineAutoTunnelEvent(change)) - } else { - Timber.d("Skipping re-eval: network unchanged or default state") - } - } - } + if (event !is AutoTunnelEvent.Sync) { + return event + } + + val filteredStart = + event.start.filterNot { it.id in manualOverrideState.stoppedTunnelIds }.toSet() + + val filteredStop = + event.stop.filterNot { it in manualOverrideState.startedTunnelIds }.toSet() + + if (filteredStart.isEmpty() && filteredStop.isEmpty()) { + return AutoTunnelEvent.DoNothing } - private fun combineSettings(): Flow>> { + return event.copy(start = filteredStart, stop = filteredStop) + } + + private fun combineSettings(): + Flow>> { return combine( - settingsRepository.flow.map { it.appMode }.distinctUntilChanged(), + settingsRepository.flow.map { it.tunnelMode }.distinctUntilChanged(), autoTunnelRepository.flow, - tunnelsRepository.userTunnelsFlow.map { tunnels -> - // isActive is ignored for equality checks so user can manually toggle off - // tunnel with auto-tunnel - tunnels.map { it.copy(isActive = false) } - }, + tunnelsRepository.userTunnelsFlow, ) { appMode, autoTunnel, tunnels -> Triple(appMode, autoTunnel, tunnels) } .distinctUntilChanged() } - private fun areAutoTunnelPermissionsRequiredTheSame( - old: AutoTunnelState, - new: AutoTunnelState, - ): Boolean { - return (old.settings.wifiDetectionMethod == new.settings.wifiDetectionMethod && - old.networkState.locationPermissionGranted == - new.networkState.locationPermissionGranted && - old.networkState.locationServicesEnabled == new.networkState.locationServicesEnabled && - old.tunnels == new.tunnels && - old.settings.trustedNetworkSSIDs == new.settings.trustedNetworkSSIDs) - } - - // watch for changes to location permission and notify user it will impact auto-tunneling - // TODO or a recheck button for location permission so we dont have to poll it private fun startLocationPermissionsNotificationJob(): Job = lifecycleScope.launch(ioDispatcher) { - var locationServicesShown = false - var locationPermissionsShown = false - - data class NetworkPermissionState( - val detectionMethod: AndroidNetworkMonitor.WifiDetectionMethod, - val locationServicesEnabled: Boolean, - val locationPermissionsEnabled: Boolean, - val ssidReadRequired: Boolean, - ) - autoTunnelStateFlow - .distinctUntilChanged(::areAutoTunnelPermissionsRequiredTheSame) - .map { - NetworkPermissionState( - it.settings.wifiDetectionMethod.to(), - it.networkState.locationServicesEnabled, - it.networkState.locationPermissionGranted, - (it.tunnels.any { tunnel -> tunnel.tunnelNetworks.isNotEmpty() } || - it.settings.trustedNetworkSSIDs.isNotEmpty()), + .map { state -> + PermissionWarningState( + detectionMethod = state.settings.wifiDetectionMethod.to(), + locationServicesEnabled = state.networkState.locationServicesEnabled, + locationPermissionsEnabled = state.networkState.locationPermissionGranted, + ssidReadRequired = + state.tunnels.any { it.tunnelNetworks.isNotEmpty() } || + state.settings.trustedNetworkSSIDs.isNotEmpty(), ) } + .distinctUntilChanged() .collect { state -> - when (state.detectionMethod) { - AndroidNetworkMonitor.WifiDetectionMethod.DEFAULT, - AndroidNetworkMonitor.WifiDetectionMethod.LEGACY -> { - if ( - !state.locationPermissionsEnabled && - !locationPermissionsShown && - state.ssidReadRequired - ) { - locationPermissionsShown = true - val notification = - notificationManager.createNotification( - WireGuardNotification.NotificationChannels.AUTO_TUNNEL, - title = getString(R.string.warning), - description = - getString(R.string.location_permissions_missing), - ) - notificationManager.show( - NotificationManager.AUTO_TUNNEL_LOCATION_PERMISSION_ID, - notification, - ) - } - if ( - !state.locationServicesEnabled && - !locationServicesShown && - state.ssidReadRequired - ) { - locationServicesShown = true - val notification = - notificationManager.createNotification( - WireGuardNotification.NotificationChannels.AUTO_TUNNEL, - title = getString(R.string.warning), - description = - getString(R.string.location_services_not_detected), - ) - notificationManager.show( - NotificationManager.AUTO_TUNNEL_LOCATION_SERVICES_ID, - notification, - ) - } - if (state.locationServicesEnabled || !state.ssidReadRequired) { - notificationManager.remove( - NotificationManager.AUTO_TUNNEL_LOCATION_SERVICES_ID + val wifiMode = state.detectionMethod + + if ( + wifiMode == AndroidNetworkMonitor.WifiDetectionMethod.DEFAULT || + wifiMode == AndroidNetworkMonitor.WifiDetectionMethod.LEGACY + ) { + + if (!state.ssidReadRequired) { + notificationService.remove( + NotificationService.AUTO_TUNNEL_LOCATION_SERVICES_ID + ) + notificationService.remove( + NotificationService.AUTO_TUNNEL_LOCATION_PERMISSION_ID + ) + return@collect + } + + if (!state.locationPermissionsEnabled) { + val notification = + notificationService.createNotification( + AndroidNotificationService.NotificationChannels.AUTO_TUNNEL, + title = getString(R.string.warning), + description = getString(R.string.location_permissions_missing), ) - locationServicesShown = false - } - if (state.locationPermissionsEnabled || !state.ssidReadRequired) { - notificationManager.remove( - NotificationManager.AUTO_TUNNEL_LOCATION_PERMISSION_ID + + notificationService.show( + NotificationService.AUTO_TUNNEL_LOCATION_PERMISSION_ID, + notification, + ) + } else { + notificationService.remove( + NotificationService.AUTO_TUNNEL_LOCATION_PERMISSION_ID + ) + } + + if (!state.locationServicesEnabled) { + val notification = + notificationService.createNotification( + AndroidNotificationService.NotificationChannels.AUTO_TUNNEL, + title = getString(R.string.warning), + description = getString(R.string.location_services_not_detected), ) - locationPermissionsShown = false - } + + notificationService.show( + NotificationService.AUTO_TUNNEL_LOCATION_SERVICES_ID, + notification, + ) + } else { + notificationService.remove( + NotificationService.AUTO_TUNNEL_LOCATION_SERVICES_ID + ) } - else -> Unit } } } - private suspend fun handleAutoTunnelEvent(autoTunnelEvent: AutoTunnelEvent) { - autoTunMutex.withLock { - when ( - val event = - autoTunnelEvent.also { - Timber.i("Auto tunnel event: ${it.javaClass.simpleName}") - } - ) { - is AutoTunnelEvent.Start -> - (event.tunnelConfig ?: tunnelsRepository.getDefaultTunnel())?.let { - tunnelManager.startTunnel(it).onFailure { e -> - Timber.e(e, "Auto-tunnel start failed for ${it.name}") - // TODO notify or retry - } - } - is AutoTunnelEvent.Stop -> tunnelManager.stopActiveTunnels() - AutoTunnelEvent.DoNothing -> Timber.i("Auto-tunneling: nothing to do") - } - } - } + private suspend fun handleAutoTunnelEvent(event: AutoTunnelEvent) { + when (event) { + is AutoTunnelEvent.Sync -> { - // restart network flow on debounce changes - @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) - private val debouncedConnectivityStateFlow: Flow by lazy { - autoTunnelRepository.flow - .map { it.debounceDelaySeconds.toMillis() } - .distinctUntilChanged() - .flatMapLatest { debounceMillis -> - networkMonitor.connectivityStateFlow.debounce(debounceMillis) + event.stop.forEach { tunnelId -> + Timber.d("Stopping tunnel: $tunnelId") + tunnelCoordinator.stopTunnel(tunnelId, TunnelActionSource.AUTO_TUNNEL) + } + + event.start.forEach { config -> + Timber.d("Starting tunnel: ${config.name}") + tunnelCoordinator.startTunnel(config, TunnelActionSource.AUTO_TUNNEL) + } } - } - companion object { - const val REEVALUATE_CHECK_DELAY = 3_000L + AutoTunnelEvent.DoNothing -> Unit + } } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/service/autotunnel/AutoTunnelStateHolder.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/service/autotunnel/AutoTunnelStateHolder.kt new file mode 100644 index 000000000..8f277d465 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/service/autotunnel/AutoTunnelStateHolder.kt @@ -0,0 +1,14 @@ +package com.zaneschepke.wireguardautotunnel.core.service.autotunnel + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +class AutoTunnelStateHolder { + + private val _active = MutableStateFlow(false) + val active: StateFlow = _active + + fun setActive(active: Boolean) { + _active.value = active + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/service/autotunnel/StateChange.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/service/autotunnel/StateChange.kt deleted file mode 100644 index 91b4b2831..000000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/service/autotunnel/StateChange.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.core.service.autotunnel - -import com.zaneschepke.wireguardautotunnel.data.model.AppMode -import com.zaneschepke.wireguardautotunnel.domain.model.AutoTunnelSettings -import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConfig -import com.zaneschepke.wireguardautotunnel.domain.state.NetworkState -import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState - -sealed interface StateChange - -data class NetworkChange(val networkState: NetworkState) : StateChange - -data class SettingsChange( - val appMode: AppMode, - val settings: AutoTunnelSettings, - val tunnels: List, -) : StateChange - -data class ActiveTunnelsChange(val activeTunnels: Map) : StateChange diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/service/tile/AutoTunnelControlTile.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/service/tile/AutoTunnelControlTile.kt index a9a245616..24b8ffb0b 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/service/tile/AutoTunnelControlTile.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/service/tile/AutoTunnelControlTile.kt @@ -1,109 +1,54 @@ package com.zaneschepke.wireguardautotunnel.core.service.tile -import android.content.Intent -import android.os.IBinder import android.service.quicksettings.Tile import android.service.quicksettings.TileService -import androidx.lifecycle.* -import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager -import com.zaneschepke.wireguardautotunnel.domain.repository.AutoTunnelSettingsRepository -import kotlin.concurrent.atomics.AtomicBoolean -import kotlin.concurrent.atomics.ExperimentalAtomicApi +import com.zaneschepke.wireguardautotunnel.core.orchestration.AutoTunnelCoordinator +import com.zaneschepke.wireguardautotunnel.core.service.autotunnel.AutoTunnelStateHolder +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancelChildren import kotlinx.coroutines.launch import org.koin.android.ext.android.inject -import timber.log.Timber -class AutoTunnelControlTile : TileService(), LifecycleOwner { +class AutoTunnelControlTile : TileService() { - private val autoTunnelSettingsRepository: AutoTunnelSettingsRepository by inject() + private val autoTunnelStateHolder: AutoTunnelStateHolder by inject() + private val autoTunnelCoordinator: AutoTunnelCoordinator by inject() - private val serviceManager: ServiceManager by inject() + private val tileScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) - @OptIn(ExperimentalAtomicApi::class) val isCollecting = AtomicBoolean(false) - - private val lifecycleRegistry: LifecycleRegistry = LifecycleRegistry(this) - - override fun onCreate() { - super.onCreate() - lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE) - } - - override fun onDestroy() { - super.onDestroy() - lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY) - } - - override fun onTileAdded() { - super.onTileAdded() - initTileState() + override fun onStartListening() { + observeState() } override fun onStopListening() { - super.onStopListening() - lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_STOP) - } - - @OptIn(ExperimentalAtomicApi::class) - private fun initTileState() { - lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START) - Timber.d("Start listening called for auto tunnel tile") - if (isCollecting.compareAndSet(expectedValue = false, newValue = true)) { - lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - serviceManager.autoTunnelService.collect { - if (it != null) return@collect setActive() - setInactive() - } - } - } - } + tileScope.coroutineContext.cancelChildren() } - override fun onStartListening() { - super.onStartListening() - initTileState() + override fun onClick() { + unlockAndRun { tileScope.launch { autoTunnelCoordinator.toggle() } } } - override fun onClick() { - super.onClick() - unlockAndRun { - lifecycleScope.launch { - if (serviceManager.autoTunnelService.value != null) { - autoTunnelSettingsRepository.updateAutoTunnelEnabled(false) - setInactive() - } else { - autoTunnelSettingsRepository.updateAutoTunnelEnabled(true) - setActive() - } + private fun observeState() { + tileScope.launch { + autoTunnelStateHolder.active.collect { active -> + if (active) setActive() else setInactive() } } } private fun setActive() { - qsTile?.let { - it.state = Tile.STATE_ACTIVE - it.updateTile() + qsTile?.apply { + state = Tile.STATE_ACTIVE + updateTile() } } private fun setInactive() { - qsTile?.let { - it.state = Tile.STATE_INACTIVE - it.updateTile() + qsTile?.apply { + state = Tile.STATE_INACTIVE + updateTile() } } - - /* This works around an annoying unsolved frameworks bug some people are hitting. */ - override fun onBind(intent: Intent): IBinder? { - var ret: IBinder? = null - try { - ret = super.onBind(intent) - } catch (_: Throwable) { - Timber.e("Failed to bind to AutoTunnelControlTile") - } - return ret - } - - override val lifecycle: Lifecycle - get() = lifecycleRegistry } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/service/tile/TunnelControlTile.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/service/tile/TunnelControlTile.kt index b432f3958..2ec2b2a34 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/service/tile/TunnelControlTile.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/service/tile/TunnelControlTile.kt @@ -1,213 +1,132 @@ package com.zaneschepke.wireguardautotunnel.core.service.tile -import android.content.Intent -import android.os.Build -import android.os.IBinder import android.service.quicksettings.Tile import android.service.quicksettings.TileService -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.LifecycleRegistry -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import com.zaneschepke.wireguardautotunnel.R -import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel -import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager -import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager +import com.zaneschepke.wireguardautotunnel.core.orchestration.TunnelCoordinator import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository -import kotlin.concurrent.atomics.AtomicBoolean -import kotlin.concurrent.atomics.ExperimentalAtomicApi +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext import org.koin.android.ext.android.inject -import timber.log.Timber -class TunnelControlTile : TileService(), LifecycleOwner { +class TunnelControlTile : TileService() { private val tunnelsRepository: TunnelRepository by inject() + private val tunnelCoordinator: TunnelCoordinator by inject() - private val serviceManager: ServiceManager by inject() + private val tileScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) - private val tunnelManager: TunnelManager by inject() - - @OptIn(ExperimentalAtomicApi::class) val isCollecting = AtomicBoolean(false) - - private val startLock = Mutex() - - private val lifecycleRegistry: LifecycleRegistry = LifecycleRegistry(this) - - override fun onCreate() { - super.onCreate() - lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE) - } + @Volatile private var observing = false override fun onDestroy() { + tileScope.cancel() super.onDestroy() - lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY) } - override fun onTileAdded() { - super.onTileAdded() - initTileState() + override fun onStartListening() { + super.onStartListening() + startObserving() } - @OptIn(ExperimentalAtomicApi::class) - private fun initTileState() { - lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START) - Timber.d("Start listening called for tunnel tile") - if (isCollecting.compareAndSet(expectedValue = false, newValue = true)) { - lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - tunnelManager.activeTunnels - .distinctUntilChangedBy { it.size } - .collect { updateTileState() } + private fun startObserving() { + if (observing) return + observing = true + + tileScope.launch { + val tunnels = withContext(Dispatchers.IO) { tunnelsRepository.getAll() } + + tunnelCoordinator.backendStatus + .distinctUntilChangedBy { it.activeTunnels.keys } + .collect { status -> + if (tunnels.isEmpty()) { + setUnavailable() + return@collect + } + + val active = status.activeTunnels + + if (active.isNotEmpty()) { + val names = tunnels.filter { active.containsKey(it.id) }.map { it.name } + + setActive(names) + } else { + setInactive() + } } - } } } - override fun onStartListening() { - super.onStartListening() - initTileState() - } - override fun onStopListening() { super.onStopListening() - lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_STOP) + observing = false + } + + override fun onClick() { + unlockAndRun { + tileScope.launch { + tunnelCoordinator.toggleTunnels() + updateTileState() + } + } } - private suspend fun updateTileState() { - try { + private fun updateTileState() { + tileScope.launch { val tunnels = tunnelsRepository.getAll() + if (tunnels.isEmpty()) { setUnavailable() - return + return@launch } - val activeTunnels = - tunnelManager.activeTunnels.value.filter { it.value.status.isUpOrStarting() } - - when { - activeTunnels.isNotEmpty() -> { - val activeIds = activeTunnels.map { it.key } - // TODO improvements would be needed to make this work well with toggling - // multiple tunnels - // this would be better managed elsewhere - WireGuardAutoTunnel.setLastActiveTunnels(activeIds) - val activeTunNames = - tunnels.filter { activeTunnels.keys.contains(it.id) }.map { it.name } - updateTileForActiveTunnels(activeTunNames) - } - else -> updateTileForLastActiveTunnels() - } - } catch (e: Exception) { - Timber.e(e, "Failed to update tunnel state") - setUnavailable() - } - } + val active = tunnelCoordinator.backendStatus.value.activeTunnels - private fun updateTileForActiveTunnels(activeTunnelNames: List) { - val tileName = - when (activeTunnelNames.size) { - 1 -> activeTunnelNames[0] - else -> getString(R.string.multiple) - } - updateTile(tileName, true) - } + if (active.isNotEmpty()) { + val names = tunnels.filter { active.containsKey(it.id) }.map { it.name } - private suspend fun updateTileForLastActiveTunnels() { - val lastActiveIds = WireGuardAutoTunnel.getLastActiveTunnels() - when { - lastActiveIds.isEmpty() -> { - tunnelsRepository.getStartTunnel()?.let { config -> updateTile(config.name, false) } - ?: setUnavailable() - } - lastActiveIds.size > 1 -> updateTile(getString(R.string.multiple), false) - else -> { - val tunnelId = lastActiveIds.first() - tunnelsRepository.getById(tunnelId)?.let { tunnel -> - updateTile(tunnel.name, false) - } ?: setUnavailable() + setActive(names) + } else { + setInactive() } } } - override fun onClick() { - super.onClick() - unlockAndRun { - lifecycleScope.launch { - startLock.withLock { - if (tunnelManager.activeTunnels.value.isNotEmpty()) - return@launch tunnelManager.stopActiveTunnels() - val lastActive = WireGuardAutoTunnel.getLastActiveTunnels() - if (lastActive.isEmpty()) { - tunnelsRepository.getStartTunnel()?.let { tunnelManager.startTunnel(it) } - } else { - lastActive.forEach { id -> - tunnelsRepository.getById(id)?.let { tunnelManager.startTunnel(it) } - } - } + private fun setActive(names: List) { + val label = + when { + names.isEmpty() -> "" + names.size == 1 -> names.first() + names.size <= 3 -> names.joinToString(", ") + else -> { + val visible = names.take(2).joinToString(", ") + "$visible +${names.size - 2}" } } - } - } - private fun setActive() { - qsTile?.let { - it.state = Tile.STATE_ACTIVE - it.updateTile() + qsTile?.apply { + state = Tile.STATE_ACTIVE + subtitle = label + updateTile() } } private fun setInactive() { - qsTile?.let { - it.state = Tile.STATE_INACTIVE - it.updateTile() + qsTile?.apply { + state = Tile.STATE_INACTIVE + subtitle = "" + updateTile() } } private fun setUnavailable() { - qsTile?.let { - it.state = Tile.STATE_UNAVAILABLE - setTileDescription("") - it.updateTile() - } - } - - private fun setTileDescription(description: String) { - qsTile?.let { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - it.subtitle = description - it.stateDescription = description - } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - it.subtitle = description - } - it.updateTile() - } - } - - private fun updateTile(name: String, active: Boolean) { - runCatching { - setTileDescription(name) - if (active) return setActive() - setInactive() - } - .onFailure { Timber.e(it) } - } - - /* This works around an annoying unsolved frameworks bug some people are hitting. */ - override fun onBind(intent: Intent): IBinder? { - var ret: IBinder? = null - try { - ret = super.onBind(intent) - } catch (_: Throwable) { - Timber.e("Failed to bind to TunnelControlTile") + qsTile?.apply { + state = Tile.STATE_UNAVAILABLE + subtitle = "" + updateTile() } - return ret } - - override val lifecycle: Lifecycle - get() = lifecycleRegistry } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/shortcut/DynamicShortcutManager.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/shortcut/DynamicShortcutManager.kt index f5c713deb..c28d65e73 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/shortcut/DynamicShortcutManager.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/shortcut/DynamicShortcutManager.kt @@ -34,7 +34,7 @@ class DynamicShortcutManager( intent = Intent(context, ShortcutsActivity::class.java).apply { putExtra("className", "WireGuardTunnelService") - action = ShortcutsActivity.Action.STOP.name + action = ShortcutContract.Action.STOP.name }, shortcutIcon = R.drawable.vpn_off, ), @@ -45,7 +45,7 @@ class DynamicShortcutManager( intent = Intent(context, ShortcutsActivity::class.java).apply { putExtra("className", "WireGuardTunnelService") - action = ShortcutsActivity.Action.START.name + action = ShortcutContract.Action.START.name }, shortcutIcon = R.drawable.vpn_on, ), @@ -56,7 +56,7 @@ class DynamicShortcutManager( intent = Intent(context, ShortcutsActivity::class.java).apply { putExtra("className", "WireGuardConnectivityWatcherService") - action = ShortcutsActivity.Action.START.name + action = ShortcutContract.Action.START.name }, shortcutIcon = R.drawable.auto_play, ), @@ -67,7 +67,7 @@ class DynamicShortcutManager( intent = Intent(context, ShortcutsActivity::class.java).apply { putExtra("className", "WireGuardConnectivityWatcherService") - action = ShortcutsActivity.Action.STOP.name + action = ShortcutContract.Action.STOP.name }, shortcutIcon = R.drawable.auto_pause, ), diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/shortcut/ShortcutContract.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/shortcut/ShortcutContract.kt new file mode 100644 index 000000000..ba864489c --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/shortcut/ShortcutContract.kt @@ -0,0 +1,31 @@ +package com.zaneschepke.wireguardautotunnel.core.shortcut + +object ShortcutContract { + + const val EXTRA_SHORTCUT_TYPE = "com.zaneschepke.wireguardautotunnel.shortcut.TYPE" + + const val EXTRA_TUNNEL_NAME = "tunnelName" + + const val EXTRA_CLASS_NAME = "className" + + enum class ShortcutType(val value: String) { + TUNNEL("tunnel"), + AUTO_TUNNEL("auto_tunnel"), + } + + enum class Action { + START, + STOP, + } + + object Legacy { + + const val TUNNEL_PROVIDER_NAME = "TunnelProvider" + + const val AUTO_TUNNEL_SERVICE_CLASS_NAME = "AutoTunnelService" + + const val TUNNEL_SERVICE_NAME = "WireGuardTunnelService" + + const val AUTO_TUNNEL_SERVICE_NAME = "WireGuardConnectivityWatcherService" + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/shortcut/ShortcutsActivity.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/shortcut/ShortcutsActivity.kt index d64c4541c..826200e85 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/shortcut/ShortcutsActivity.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/shortcut/ShortcutsActivity.kt @@ -2,73 +2,26 @@ package com.zaneschepke.wireguardautotunnel.core.shortcut import android.os.Bundle import androidx.activity.ComponentActivity -import com.zaneschepke.wireguardautotunnel.core.service.autotunnel.AutoTunnelService -import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager -import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelProvider +import com.zaneschepke.wireguardautotunnel.core.orchestration.ShortcutCoordinator import com.zaneschepke.wireguardautotunnel.di.Scope -import com.zaneschepke.wireguardautotunnel.domain.repository.AutoTunnelSettingsRepository -import com.zaneschepke.wireguardautotunnel.domain.repository.GeneralSettingRepository -import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import org.koin.android.ext.android.inject import org.koin.core.qualifier.named -import timber.log.Timber class ShortcutsActivity : ComponentActivity() { - private val settingsRepository: GeneralSettingRepository by inject() - private val autoTunnelSettingsRepository: AutoTunnelSettingsRepository by inject() - private val tunnelsRepository: TunnelRepository by inject() - private val tunnelManager: TunnelManager by inject() + private val shortcutCoordinator: ShortcutCoordinator by inject() + private val applicationScope: CoroutineScope by inject(named(Scope.APPLICATION)) override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + applicationScope.launch { - val settings = settingsRepository.getGeneralSettings() - if (settings.isShortcutsEnabled) { - when (intent.getStringExtra(CLASS_NAME_EXTRA_KEY)) { - LEGACY_TUNNEL_SERVICE_NAME, - TunnelProvider::class.java.simpleName -> { - val tunnelName = intent.getStringExtra(TUNNEL_NAME_EXTRA_KEY) - Timber.d("Tunnel name extra: $tunnelName") - val tunnelConfig = - tunnelName?.let { tunnelsRepository.findByTunnelName(it) } - ?: tunnelsRepository.getDefaultTunnel() - Timber.d("Shortcut action on name: ${tunnelConfig?.name}") - tunnelConfig?.let { - when (intent.action) { - Action.START.name -> tunnelManager.startTunnel(it) - Action.STOP.name -> tunnelManager.stopActiveTunnels() - else -> Unit - } - } - } - AutoTunnelService::class.java.simpleName, - LEGACY_AUTO_TUNNEL_SERVICE_NAME -> { - when (intent.action) { - Action.START.name -> - autoTunnelSettingsRepository.updateAutoTunnelEnabled(true) - Action.STOP.name -> - autoTunnelSettingsRepository.updateAutoTunnelEnabled(false) - } - } - } - } + shortcutCoordinator.handle(intent) + finish() } - finish() - } - - enum class Action { - START, - STOP, - } - - companion object { - const val LEGACY_TUNNEL_SERVICE_NAME = "WireGuardTunnelService" - const val LEGACY_AUTO_TUNNEL_SERVICE_NAME = "WireGuardConnectivityWatcherService" - const val TUNNEL_NAME_EXTRA_KEY = "tunnelName" - const val CLASS_NAME_EXTRA_KEY = "className" } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/Extensions.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/Extensions.kt deleted file mode 100644 index 5c1e1f0b9..000000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/Extensions.kt +++ /dev/null @@ -1,50 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.core.tunnel - -import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelStatus -import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConfig -import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState -import kotlinx.coroutines.flow.MutableStateFlow - -fun Map.allDown(): Boolean { - return this.all { it.value.status.isDown() } -} - -fun Map.hasActive(): Boolean { - return this.any { it.value.status.isUp() } -} - -fun Map.getValueById(id: Int): TunnelState? { - val key = this.keys.find { it.id == id } - return key?.let { this@getValueById[it] } -} - -fun Map.getKeyById(id: Int): TunnelConfig? { - return this.keys.find { it.id == id } -} - -fun Map.isUp(tunnelConfig: TunnelConfig): Boolean { - return this.getValueById(tunnelConfig.id)?.status?.isUp() ?: false -} - -fun MutableStateFlow>.exists(id: Int): Boolean { - return this.value.any { it.key.id == id } -} - -fun MutableStateFlow>.isUp(id: Int): Boolean { - return this.value.any { it.key.id == id && it.value.status is TunnelStatus.Up } -} - -fun MutableStateFlow>.isStarting(id: Int): Boolean { - return this.value.any { it.key.id == id && it.value.status == TunnelStatus.Starting } -} - -fun MutableStateFlow>.findTunnel(id: Int): TunnelConfig? { - return this.value.keys.find { it.id == id } -} - -private val URL_PATTERN = - Regex("""^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}:[0-9]{1,5}$""") - -fun String.isUrl(): Boolean { - return URL_PATTERN.matches(this) -} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/TunnelBackendProvider.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/TunnelBackendProvider.kt new file mode 100644 index 000000000..85f3bbd44 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/TunnelBackendProvider.kt @@ -0,0 +1,61 @@ +package com.zaneschepke.wireguardautotunnel.core.tunnel + +import com.zaneschepke.tunnel.Tunnel +import com.zaneschepke.tunnel.backend.Backend +import com.zaneschepke.tunnel.model.BackendMode +import com.zaneschepke.tunnel.state.BackendStatus +import com.zaneschepke.wireguardautotunnel.domain.events.BackendCoreException +import com.zaneschepke.wireguardautotunnel.domain.events.BackendMessage +import com.zaneschepke.wireguardautotunnel.domain.model.LockdownSettings +import kotlin.concurrent.atomics.ExperimentalAtomicApi +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.plus + +@OptIn(ExperimentalCoroutinesApi::class, ExperimentalAtomicApi::class) +class TunnelBackendProvider( + private val backend: Backend, + applicationScope: CoroutineScope, + ioDispatcher: CoroutineDispatcher, +) : TunnelProvider { + + override val backendStatus: StateFlow = + backend.status.stateIn( + scope = applicationScope.plus(ioDispatcher), + started = SharingStarted.Eagerly, + initialValue = BackendStatus(), + ) + + override val events = backend.events + + override suspend fun startTunnel(tunnel: Tunnel, mode: BackendMode): Result { + return backend.start(tunnel = tunnel, mode = mode) + } + + override suspend fun stopTunnel(tunnelId: Int): Result { + return backend.stop(tunnelId) + } + + override suspend fun stopActiveTunnels(): Result { + return backend.stopAllActiveTunnels() + } + + override suspend fun setLockDown(settings: LockdownSettings): Result { + return backend.setKillSwitch(settings.toKillSwitchConfig()) + } + + override suspend fun disableLockDown(): Result { + return backend.disableKillSwitch() + } + + @OptIn(ExperimentalCoroutinesApi::class) + private val localErrorEvents = MutableSharedFlow>() + + @OptIn(ExperimentalCoroutinesApi::class) + private val localMessageEvents = MutableSharedFlow>() +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/TunnelLifecycleManager.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/TunnelLifecycleManager.kt deleted file mode 100644 index d2f18174d..000000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/TunnelLifecycleManager.kt +++ /dev/null @@ -1,187 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.core.tunnel - -import com.zaneschepke.wireguardautotunnel.core.tunnel.backend.TunnelBackend -import com.zaneschepke.wireguardautotunnel.domain.enums.BackendMode -import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelStatus -import com.zaneschepke.wireguardautotunnel.domain.events.BackendCoreException -import com.zaneschepke.wireguardautotunnel.domain.events.BackendMessage -import com.zaneschepke.wireguardautotunnel.domain.events.UnknownError -import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConfig -import com.zaneschepke.wireguardautotunnel.domain.state.LogHealthState -import com.zaneschepke.wireguardautotunnel.domain.state.PingState -import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState -import com.zaneschepke.wireguardautotunnel.domain.state.TunnelStatistics -import java.util.concurrent.ConcurrentHashMap -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withTimeoutOrNull -import timber.log.Timber - -class TunnelLifecycleManager( - private val backend: TunnelBackend, - private val applicationScope: CoroutineScope, - private val ioDispatcher: CoroutineDispatcher, - private val sharedActiveTunnels: MutableStateFlow>, -) : TunnelProvider { - - override val activeTunnels: StateFlow> = sharedActiveTunnels.asStateFlow() - - private val _errorEvents = MutableSharedFlow>() - override val errorEvents: SharedFlow> = - _errorEvents.asSharedFlow() - - private val _messageEvents = MutableSharedFlow>() - override val messageEvents: SharedFlow> = - _messageEvents.asSharedFlow() - - private val tunnelJobs = ConcurrentHashMap() - private val tunMutex = Mutex() - private val tunStatusMutex = Mutex() - - override suspend fun startTunnel(tunnelConfig: TunnelConfig): Result = - tunMutex.withLock { - val id = tunnelConfig.id - if (sharedActiveTunnels.value.containsKey(id)) { - Timber.w("Tunnel is already running: ${tunnelConfig.name}") - return Result.failure(IllegalStateException("Tunnel already running")) - } - - val startupCompleted = CompletableDeferred>() - - val job = - applicationScope.launch(ioDispatcher) { - try { - updateTunnelStatus(id, TunnelStatus.Starting) - backend.tunnelStateFlow(tunnelConfig).collect { status -> - updateTunnelStatus(id, status) - - if (status != TunnelStatus.Starting && !startupCompleted.isCompleted) { - if (status is TunnelStatus.Up) { - startupCompleted.complete(Result.success(Unit)) - } else { - startupCompleted.complete(Result.failure(UnknownError())) - } - } - } - } catch (e: BackendCoreException) { - _errorEvents.emit(tunnelConfig.name to e) - updateTunnelStatus(id, TunnelStatus.Down) - startupCompleted.complete(Result.failure(e)) - } catch (_: CancellationException) {} finally { - tunnelJobs.remove(id) - sharedActiveTunnels.update { it - id } - } - } - - tunnelJobs[id] = job - job.invokeOnCompletion { tunnelJobs.remove(id) } - - try { - startupCompleted.await() - } catch (e: Throwable) { - job.cancel() - Result.failure(e) - } - } - - override suspend fun stopTunnel(tunnelId: Int) = - tunMutex.withLock { - val currentState = sharedActiveTunnels.value[tunnelId]?.status ?: return@withLock - updateTunnelStatus(tunnelId, TunnelStatus.Stopping) - tunnelJobs[tunnelId]?.cancel() - - withTimeoutOrNull(STOP_TIMEOUT_MS) { - activeTunnels.first { - !it.containsKey(tunnelId) || it[tunnelId]!!.status == TunnelStatus.Down - } - } - ?: run { - Timber.w("Stop timeout for $tunnelId (was $currentState); forcing kill") - forceStopTunnel(tunnelId) - } - } - - override suspend fun forceStopTunnel(tunnelId: Int) { - backend.forceStopTunnel(tunnelId) - tunnelJobs[tunnelId]?.cancel() - tunnelJobs.remove(tunnelId) - sharedActiveTunnels.update { it - tunnelId } - updateTunnelStatus(tunnelId, TunnelStatus.Down) - } - - override suspend fun stopActiveTunnels() { - sharedActiveTunnels.value.forEach { (id, state) -> - if (state.status.isUpOrStarting()) { - stopTunnel(id) - } - } - } - - override suspend fun updateTunnelStatus( - tunnelId: Int, - status: TunnelStatus?, - stats: TunnelStatistics?, - pingStates: Map?, - logHealthState: LogHealthState?, - ) = - tunStatusMutex.withLock { - sharedActiveTunnels.update { currentTuns -> - if (!currentTuns.containsKey(tunnelId) && status != TunnelStatus.Starting) { - Timber.d("Ignoring update for inactive tunnel $tunnelId") - return@update currentTuns - } - val existingState = currentTuns[tunnelId] ?: TunnelState() - val newStatus = status ?: existingState.status - if (newStatus == TunnelStatus.Down) { - Timber.d("Removing tunnel $tunnelId from activeTunnels as state is DOWN") - currentTuns - tunnelId - } else if ( - existingState.status == newStatus && - stats == null && - pingStates == null && - logHealthState == null - ) { - Timber.d("Skipping redundant state update for ${tunnelId}: $newStatus") - currentTuns - } else { - val updated = - existingState.copy( - status = newStatus, - statistics = stats ?: existingState.statistics, - pingStates = pingStates ?: existingState.pingStates, - logHealthState = logHealthState ?: existingState.logHealthState, - ) - currentTuns + (tunnelId to updated) - } - } - } - - override fun setBackendMode(backendMode: BackendMode) = backend.setBackendMode(backendMode) - - override fun getBackendMode(): BackendMode = backend.getBackendMode() - - override suspend fun runningTunnelNames(): Set = backend.runningTunnelNames() - - override fun handleDnsReresolve(tunnelConfig: TunnelConfig): Boolean = - backend.handleDnsReresolve(tunnelConfig) - - override fun getStatistics(tunnelId: Int): TunnelStatistics? = backend.getStatistics(tunnelId) - - companion object { - const val STOP_TIMEOUT_MS: Long = 5_000L - } -} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/TunnelManager.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/TunnelManager.kt deleted file mode 100644 index 50a717a0a..000000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/TunnelManager.kt +++ /dev/null @@ -1,359 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.core.tunnel - -import android.os.PowerManager -import com.zaneschepke.logcatter.LogReader -import com.zaneschepke.networkmonitor.NetworkMonitor -import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager -import com.zaneschepke.wireguardautotunnel.core.tunnel.backend.TunnelBackend -import com.zaneschepke.wireguardautotunnel.core.tunnel.handler.DynamicDnsHandler -import com.zaneschepke.wireguardautotunnel.core.tunnel.handler.TunnelActiveStatePersister -import com.zaneschepke.wireguardautotunnel.core.tunnel.handler.TunnelMonitorHandler -import com.zaneschepke.wireguardautotunnel.core.tunnel.handler.TunnelServiceHandler -import com.zaneschepke.wireguardautotunnel.data.model.AppMode -import com.zaneschepke.wireguardautotunnel.domain.enums.BackendMode -import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelStatus -import com.zaneschepke.wireguardautotunnel.domain.events.BackendCoreException -import com.zaneschepke.wireguardautotunnel.domain.events.BackendMessage -import com.zaneschepke.wireguardautotunnel.domain.events.NotAuthorized -import com.zaneschepke.wireguardautotunnel.domain.model.AutoTunnelSettings -import com.zaneschepke.wireguardautotunnel.domain.model.GeneralSettings -import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConfig -import com.zaneschepke.wireguardautotunnel.domain.repository.AutoTunnelSettingsRepository -import com.zaneschepke.wireguardautotunnel.domain.repository.GeneralSettingRepository -import com.zaneschepke.wireguardautotunnel.domain.repository.LockdownSettingsRepository -import com.zaneschepke.wireguardautotunnel.domain.repository.MonitoringSettingsRepository -import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository -import com.zaneschepke.wireguardautotunnel.domain.state.LogHealthState -import com.zaneschepke.wireguardautotunnel.domain.state.PingState -import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState -import com.zaneschepke.wireguardautotunnel.domain.state.TunnelStatistics -import com.zaneschepke.wireguardautotunnel.util.network.NetworkUtils -import kotlin.concurrent.atomics.AtomicBoolean -import kotlin.concurrent.atomics.AtomicReference -import kotlin.concurrent.atomics.ExperimentalAtomicApi -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.distinctUntilChangedBy -import kotlinx.coroutines.flow.filterNot -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.flow.merge -import kotlinx.coroutines.flow.shareIn -import kotlinx.coroutines.launch -import kotlinx.coroutines.plus -import kotlinx.coroutines.supervisorScope -import kotlinx.coroutines.withContext -import timber.log.Timber - -@OptIn(ExperimentalCoroutinesApi::class, ExperimentalAtomicApi::class) -class TunnelManager( - kernelBackend: TunnelBackend, - userspaceBackend: TunnelBackend, - proxyUserspaceBackend: TunnelBackend, - networkMonitor: NetworkMonitor, - networkUtils: NetworkUtils, - powerManager: PowerManager, - logReader: LogReader, - monitoringSettingsRepository: MonitoringSettingsRepository, - private val serviceManager: ServiceManager, - private val settingsRepository: GeneralSettingRepository, - private val autoTunnelSettingsRepository: AutoTunnelSettingsRepository, - private val lockdownSettingsRepository: LockdownSettingsRepository, - private val tunnelsRepository: TunnelRepository, - private val applicationScope: CoroutineScope, - private val ioDispatcher: CoroutineDispatcher, -) : TunnelProvider { - - private val _activeTunnels = MutableStateFlow>(emptyMap()) - override val activeTunnels: StateFlow> = _activeTunnels.asStateFlow() - - @OptIn(ExperimentalAtomicApi::class) val currentAppMode = AtomicReference(AppMode.VPN) - - private val defaultManager = - TunnelLifecycleManager(userspaceBackend, applicationScope, ioDispatcher, _activeTunnels) - - private val lifecycleManagers: Map = - mapOf( - AppMode.KERNEL to - TunnelLifecycleManager( - kernelBackend, - applicationScope, - ioDispatcher, - _activeTunnels, - ), - AppMode.VPN to defaultManager, - AppMode.PROXY to - TunnelLifecycleManager( - proxyUserspaceBackend, - applicationScope, - ioDispatcher, - _activeTunnels, - ), - AppMode.LOCK_DOWN to - TunnelLifecycleManager( - proxyUserspaceBackend, - applicationScope, - ioDispatcher, - _activeTunnels, - ), - ) - - @OptIn(ExperimentalAtomicApi::class) - private fun getProvider(): TunnelProvider { - return lifecycleManagers[currentAppMode.load()] ?: defaultManager - } - - override suspend fun startTunnel(tunnelConfig: TunnelConfig): Result = - getProvider().startTunnel(tunnelConfig) - - override suspend fun stopTunnel(tunnelId: Int) = getProvider().stopTunnel(tunnelId) - - override suspend fun forceStopTunnel(tunnelId: Int) = getProvider().forceStopTunnel(tunnelId) - - override suspend fun stopActiveTunnels() = getProvider().stopActiveTunnels() - - override fun setBackendMode(backendMode: BackendMode) = - getProvider().setBackendMode(backendMode) - - override fun getBackendMode(): BackendMode = getProvider().getBackendMode() - - override suspend fun runningTunnelNames(): Set = getProvider().runningTunnelNames() - - override fun handleDnsReresolve(tunnelConfig: TunnelConfig): Boolean = - getProvider().handleDnsReresolve(tunnelConfig) - - override fun getStatistics(tunnelId: Int): TunnelStatistics? = - getProvider().getStatistics(tunnelId) - - override suspend fun updateTunnelStatus( - tunnelId: Int, - status: TunnelStatus?, - stats: TunnelStatistics?, - pingStates: Map?, - logHealthState: LogHealthState?, - ) = getProvider().updateTunnelStatus(tunnelId, status, stats, pingStates, logHealthState) - - @OptIn(ExperimentalCoroutinesApi::class) - private val localErrorEvents = MutableSharedFlow>() - - @OptIn(ExperimentalCoroutinesApi::class) - private val localMessageEvents = MutableSharedFlow>() - - override val errorEvents: SharedFlow> = - merge(localErrorEvents, *lifecycleManagers.values.map { it.errorEvents }.toTypedArray()) - .shareIn( - scope = applicationScope + ioDispatcher, - started = SharingStarted.Eagerly, - replay = 0, - ) - - override val messageEvents: SharedFlow> = - merge(localMessageEvents, *lifecycleManagers.values.map { it.messageEvents }.toTypedArray()) - .shareIn( - scope = applicationScope.plus(ioDispatcher), - started = SharingStarted.Eagerly, - replay = 0, - ) - - private val tunnelServiceHandler = - TunnelServiceHandler( - activeTunnels = activeTunnels, - settingsRepository = settingsRepository, - serviceManager = serviceManager, - applicationScope = applicationScope, - ioDispatcher = ioDispatcher, - ) - - private val tunnelActiveStatePersister = - TunnelActiveStatePersister( - activeTunnels = activeTunnels, - tunnelsRepository = tunnelsRepository, - applicationScope = applicationScope, - ioDispatcher = ioDispatcher, - ) - - private val dynamicDnsHandler = - DynamicDnsHandler( - activeTunnels = activeTunnels, - tunnelsRepository = tunnelsRepository, - settingsRepository = settingsRepository, - localMessageEvents = localMessageEvents, - handleDnsReresolve = { config -> handleDnsReresolve(config) }, - applicationScope = applicationScope, - ioDispatcher = ioDispatcher, - ) - - private val fullTunnelMonitorHandler = - TunnelMonitorHandler( - activeTunnels = activeTunnels, - tunnelsRepository = tunnelsRepository, - settingsRepository = settingsRepository, - monitoringSettingsRepository = monitoringSettingsRepository, - networkMonitor = networkMonitor, - networkUtils = networkUtils, - powerManager = powerManager, - logReader = logReader, - getStatistics = { id -> getStatistics(id) }, - updateTunnelStatus = { id, status, stats, pings, logHealth -> - updateTunnelStatus(id, status, stats, pings, logHealth) - }, - applicationScope = applicationScope, - ioDispatcher = ioDispatcher, - ) - - init { - applicationScope.launch(ioDispatcher) { - val initialEmit = AtomicBoolean(true) - settingsRepository.flow - .filterNotNull() - .filterNot { it == GeneralSettings() } - .distinctUntilChangedBy { it.appMode } - .collect { settings -> - val isInitialEmit = initialEmit.exchange(false) - val previousMode = currentAppMode.exchange(settings.appMode) - - if (isInitialEmit) { - return@collect handleRestore(settings) - } - - if (previousMode != settings.appMode) { - handleModeChangeCleanup(previousMode) - } - if (settings.appMode == AppMode.LOCK_DOWN) { - handleLockDownModeInit() - } - } - } - } - - // TODO this can crash if we haven't started foreground service yet, especially for - // workerManager - private suspend fun handleLockDownModeInit() { - val lockdownSettings = lockdownSettingsRepository.getLockdownSettings() - val allowedIps = - if (lockdownSettings.bypassLan) TunnelConfig.IPV4_PUBLIC_NETWORKS else emptySet() - try { - if (serviceManager.hasVpnPermission()) { - setBackendMode( - BackendMode.KillSwitch( - allowedIps, - lockdownSettings.metered, - lockdownSettings.dualStack, - ) - ) - } else { - throw NotAuthorized() - } - } catch (e: BackendCoreException) { - localErrorEvents.tryEmit(null to e) - } - } - - private suspend fun handleModeChangeCleanup(previousAppMode: AppMode) { - lifecycleManagers[previousAppMode]?.stopActiveTunnels() - if (previousAppMode == AppMode.LOCK_DOWN) { - lifecycleManagers[previousAppMode]?.setBackendMode(BackendMode.Inactive) - } - } - - suspend fun handleRestore(settings: GeneralSettings? = null) = - withContext(ioDispatcher) { - val currentSettings = settings ?: settingsRepository.getGeneralSettings() - val autoTunnelSettings = autoTunnelSettingsRepository.getAutoTunnelSettings() - val tunnels = tunnelsRepository.userTunnelsFlow.firstOrNull() - if (autoTunnelSettings.isAutoTunnelEnabled) - return@withContext restoreAutoTunnel(autoTunnelSettings) - if (currentSettings.appMode == AppMode.LOCK_DOWN) handleLockDownModeInit() - if (tunnels?.any { it.isActive } == true) { - if (currentSettings.appMode == AppMode.VPN && !serviceManager.hasVpnPermission()) - return@withContext localErrorEvents.emit(null to NotAuthorized()) - when (currentSettings.appMode) { - AppMode.VPN, - AppMode.PROXY, - AppMode.LOCK_DOWN -> { - tunnels.firstOrNull { it.isActive }?.let { startTunnel(it) } - } - AppMode.KERNEL -> - tunnels.filter { it.isActive }.forEach { conf -> startTunnel(conf) } - } - } - } - - private suspend fun restoreAutoTunnel(autoTunnelSettings: AutoTunnelSettings) { - autoTunnelSettingsRepository.upsert(autoTunnelSettings.copy(isAutoTunnelEnabled = true)) - serviceManager.startAutoTunnelService() - } - - suspend fun handleReboot() = - withContext(ioDispatcher) { - val settings = settingsRepository.getGeneralSettings() - val autoTunnelSettings = autoTunnelSettingsRepository.getAutoTunnelSettings() - val defaultTunnel = tunnelsRepository.getDefaultTunnel() - if (autoTunnelSettings.startOnBoot) - return@withContext restoreAutoTunnel(autoTunnelSettings) - if (settings.isRestoreOnBootEnabled) { - tunnelsRepository.resetActiveTunnels() - when (settings.appMode) { - AppMode.LOCK_DOWN -> handleLockDownModeInit() - AppMode.VPN -> - if (!serviceManager.hasVpnPermission()) - return@withContext localErrorEvents.emit(null to NotAuthorized()) - AppMode.KERNEL, - AppMode.PROXY -> Unit - } - defaultTunnel?.let { startTunnel(it) } - } - } - - suspend fun restartActiveTunnel(id: Int) = - withContext(ioDispatcher) { - val activeIds = activeTunnels.value.keys.toList() - if (activeIds.isEmpty()) return@withContext - if (!activeIds.contains(id)) return@withContext - val tunnel = tunnelsRepository.getById(id) ?: return@withContext - restartTunnel(tunnel) - } - - suspend fun restartActiveTunnels() = - withContext(ioDispatcher) { - val activeIds = activeTunnels.value.keys.toList() - if (activeIds.isEmpty()) return@withContext - - val tunnels = tunnelsRepository.getAll() - if (tunnels.isEmpty()) return@withContext - - supervisorScope { - activeIds.forEach { id -> - val tunnel = - tunnels.find { it.id == id } - ?: run { - Timber.w("Tunnel config $id not found; skipping restart") - return@forEach - } - restartTunnel(tunnel) - } - } - } - - private suspend fun restartTunnel(tunnel: TunnelConfig) { - runCatching { stopTunnel(tunnel.id) } - .onFailure { e -> Timber.e(e, "Failed to stop tunnel ${tunnel.id} during restart") } - - delay(RESTART_TUNNEL_DELAY) - - runCatching { startTunnel(tunnel) } - .onFailure { e -> Timber.e(e, "Failed to restart tunnel ${tunnel.id}") } - } - - companion object { - const val RESTART_TUNNEL_DELAY = 300L - } -} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/TunnelProvider.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/TunnelProvider.kt index 63a5dc4bc..8ed5681c0 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/TunnelProvider.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/TunnelProvider.kt @@ -1,45 +1,26 @@ package com.zaneschepke.wireguardautotunnel.core.tunnel -import com.zaneschepke.wireguardautotunnel.domain.enums.BackendMode -import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelStatus -import com.zaneschepke.wireguardautotunnel.domain.events.BackendCoreException -import com.zaneschepke.wireguardautotunnel.domain.events.BackendMessage -import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConfig -import com.zaneschepke.wireguardautotunnel.domain.state.LogHealthState -import com.zaneschepke.wireguardautotunnel.domain.state.PingState -import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState -import com.zaneschepke.wireguardautotunnel.domain.state.TunnelStatistics -import kotlinx.coroutines.flow.SharedFlow +import com.zaneschepke.tunnel.Tunnel +import com.zaneschepke.tunnel.event.TunnelEvent +import com.zaneschepke.tunnel.model.BackendMode +import com.zaneschepke.tunnel.state.BackendStatus +import com.zaneschepke.wireguardautotunnel.domain.model.LockdownSettings +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow interface TunnelProvider { - suspend fun startTunnel(tunnelConfig: TunnelConfig): Result - suspend fun stopTunnel(tunnelId: Int) + suspend fun startTunnel(tunnel: Tunnel, mode: BackendMode): Result - suspend fun forceStopTunnel(tunnelId: Int) + suspend fun stopTunnel(tunnelId: Int): Result - suspend fun stopActiveTunnels() + suspend fun stopActiveTunnels(): Result - fun setBackendMode(backendMode: BackendMode) + suspend fun setLockDown(settings: LockdownSettings): Result - fun getBackendMode(): BackendMode + suspend fun disableLockDown(): Result - suspend fun runningTunnelNames(): Set + val backendStatus: StateFlow - fun handleDnsReresolve(tunnelConfig: TunnelConfig): Boolean - - fun getStatistics(tunnelId: Int): TunnelStatistics? - - val activeTunnels: StateFlow> - val errorEvents: SharedFlow> - val messageEvents: SharedFlow> - - suspend fun updateTunnelStatus( - tunnelId: Int, - status: TunnelStatus? = null, - stats: TunnelStatistics? = null, - pingStates: Map? = null, - logHealthState: LogHealthState? = null, - ) + val events: Flow } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/backend/KernelTunnel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/backend/KernelTunnel.kt deleted file mode 100644 index 5642afdf7..000000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/backend/KernelTunnel.kt +++ /dev/null @@ -1,128 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.core.tunnel.backend - -import com.wireguard.android.backend.Backend -import com.wireguard.android.backend.BackendException -import com.wireguard.android.backend.Tunnel -import com.wireguard.android.backend.WgQuickBackend -import com.zaneschepke.wireguardautotunnel.R -import com.zaneschepke.wireguardautotunnel.domain.enums.BackendMode -import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelStatus -import com.zaneschepke.wireguardautotunnel.domain.events.DnsFailure -import com.zaneschepke.wireguardautotunnel.domain.events.InvalidConfig -import com.zaneschepke.wireguardautotunnel.domain.events.KernelTunnelName -import com.zaneschepke.wireguardautotunnel.domain.events.KernelWireguardNotSupported -import com.zaneschepke.wireguardautotunnel.domain.events.UnknownError -import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConfig -import com.zaneschepke.wireguardautotunnel.domain.state.TunnelStatistics -import com.zaneschepke.wireguardautotunnel.domain.state.WireGuardStatistics -import com.zaneschepke.wireguardautotunnel.util.extensions.asTunnelState -import com.zaneschepke.wireguardautotunnel.util.extensions.toBackendCoreException -import java.util.concurrent.ConcurrentHashMap -import java.util.regex.Pattern -import kotlinx.coroutines.TimeoutCancellationException -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.channels.awaitClose -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.callbackFlow -import kotlinx.coroutines.flow.consumeAsFlow -import kotlinx.coroutines.launch -import timber.log.Timber - -class KernelTunnel(private val runConfigHelper: RunConfigHelper, private val backend: Backend) : - TunnelBackend { - - private val runtimeTunnels = ConcurrentHashMap() - - private fun validateWireGuardInterfaceName(name: String): Result { - if (name.isEmpty() || name.length > 15) - return Result.failure(KernelTunnelName(R.string.kernel_name_error)) - if (name == "." || name == "..") { - return Result.failure(KernelTunnelName(R.string.kernel_name_dots)) - } - val pattern = Pattern.compile("^[a-zA-Z0-9_=+.-]{1,15}$") - if (!pattern.matcher(name).matches()) { - return Result.failure(KernelTunnelName(R.string.kernel_name_special_characters)) - } - return Result.success(Unit) - } - - override fun tunnelStateFlow(tunnelConfig: TunnelConfig): Flow = callbackFlow { - if (!WgQuickBackend.hasKernelSupport()) throw KernelWireguardNotSupported() - validateWireGuardInterfaceName(tunnelConfig.name).onFailure { throw it } - - val stateChannel = Channel() - - val runtimeTunnel = RuntimeWgTunnel(tunnelConfig, stateChannel) - runtimeTunnels[tunnelConfig.id] = runtimeTunnel - - val consumerJob = launch { - stateChannel.consumeAsFlow().collect { state -> trySend(state.asTunnelState()) } - } - - try { - val runConfig = runConfigHelper.buildWgRunConfig(tunnelConfig) - backend.setState(runtimeTunnel, Tunnel.State.UP, runConfig) - } catch (e: TimeoutCancellationException) { - Timber.Forest.e("Startup timed out for ${tunnelConfig.name}") - throw DnsFailure() - } catch (e: BackendException) { - throw e.toBackendCoreException() - } catch (e: IllegalArgumentException) { - Timber.Forest.e(e, "Invalid backend arguments") - throw InvalidConfig() - } catch (e: Exception) { - Timber.Forest.e(e, "Error while setting tunnel state") - throw UnknownError() - } - - awaitClose { - try { - backend.setState(runtimeTunnel, Tunnel.State.DOWN, null) - } catch (e: BackendException) { - // Errors are emitted by caller (lifecycle manager) - } finally { - consumerJob.cancel() - stateChannel.close() - runtimeTunnels.remove(tunnelConfig.id) - trySend(TunnelStatus.Down) - } - } - } - - override fun getStatistics(tunnelId: Int): TunnelStatistics? { - return try { - val runtimeTunnel = runtimeTunnels[tunnelId] ?: return null - WireGuardStatistics(backend.getStatistics(runtimeTunnel)) - } catch (e: Exception) { - Timber.Forest.e(e, "Failed to get stats for $tunnelId") - null - } - } - - override fun setBackendMode(backendMode: BackendMode) { - Timber.Forest.w("Not yet implemented for kernel") - } - - override fun getBackendMode(): BackendMode { - return BackendMode.Inactive - } - - override fun handleDnsReresolve(tunnelConfig: TunnelConfig): Boolean { - throw NotImplementedError() - } - - override suspend fun runningTunnelNames(): Set { - return backend.runningTunnelNames - } - - override suspend fun forceStopTunnel(tunnelId: Int) { - val runtimeTunnel = runtimeTunnels[tunnelId] ?: return - try { - backend.setState(runtimeTunnel, Tunnel.State.DOWN, null) - } catch (e: BackendException) { - Timber.Forest.e(e, "Force stop failed for $tunnelId") - } finally { - runtimeTunnels.remove(tunnelId) - } - } -} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/backend/RunConfigHelper.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/backend/RunConfigHelper.kt deleted file mode 100644 index d0dba7d2b..000000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/backend/RunConfigHelper.kt +++ /dev/null @@ -1,101 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.core.tunnel.backend - -import com.zaneschepke.wireguardautotunnel.data.model.AppMode -import com.zaneschepke.wireguardautotunnel.data.model.DnsProtocol -import com.zaneschepke.wireguardautotunnel.domain.events.InvalidConfig -import com.zaneschepke.wireguardautotunnel.domain.model.DnsSettings -import com.zaneschepke.wireguardautotunnel.domain.model.GeneralSettings -import com.zaneschepke.wireguardautotunnel.domain.model.ProxySettings -import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConfig -import com.zaneschepke.wireguardautotunnel.domain.repository.DnsSettingsRepository -import com.zaneschepke.wireguardautotunnel.domain.repository.GeneralSettingRepository -import com.zaneschepke.wireguardautotunnel.domain.repository.ProxySettingsRepository -import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository -import java.util.Optional -import kotlinx.coroutines.flow.firstOrNull -import org.amnezia.awg.config.Config -import org.amnezia.awg.config.proxy.HttpProxy -import org.amnezia.awg.config.proxy.Socks5Proxy - -class RunConfigHelper( - private val settingsRepository: GeneralSettingRepository, - private val proxySettingsRepository: ProxySettingsRepository, - private val dnsSettingsRepository: DnsSettingsRepository, - private val tunnelsRepository: TunnelRepository, -) { - - private data class PrepResult( - val effectiveConfig: TunnelConfig, - val generalSettings: GeneralSettings, - val dnsSettings: DnsSettings, - ) - - private suspend fun prepare(tunnelConfig: TunnelConfig): PrepResult { - val generalSettings = settingsRepository.getGeneralSettings() - val dnsSettings = dnsSettingsRepository.getDnsSettings() - val effectiveConfig = - if ( - generalSettings.isGlobalSplitTunnelEnabled || dnsSettings.isGlobalTunnelDnsEnabled - ) { - val globalConfig = - tunnelsRepository.globalTunnelFlow.firstOrNull() ?: throw InvalidConfig() - tunnelConfig.copyWithGlobalValues( - globalConfig, - dnsSettings.isGlobalTunnelDnsEnabled, - generalSettings.isGlobalSplitTunnelEnabled, - ) - } else { - tunnelConfig - } - return PrepResult(effectiveConfig, generalSettings, dnsSettings) - } - - suspend fun buildAmRunConfig(tunnelConfig: TunnelConfig): Config { - val prep = prepare(tunnelConfig) - val proxies = - if (prep.generalSettings.appMode == AppMode.PROXY) { - val proxySettings = proxySettingsRepository.getProxySettings() - buildList { - if (proxySettings.socks5ProxyEnabled) { - add( - Socks5Proxy( - proxySettings.socks5ProxyBindAddress - ?: ProxySettings.DEFAULT_SOCKS_BIND_ADDRESS, - proxySettings.proxyUsername, - proxySettings.proxyPassword, - ) - ) - } - if (proxySettings.httpProxyEnabled) { - add( - HttpProxy( - proxySettings.httpProxyBindAddress - ?: ProxySettings.DEFAULT_HTTP_BIND_ADDRESS, - proxySettings.proxyUsername, - proxySettings.proxyPassword, - ) - ) - } - } - } else { - emptyList() - } - val amConfig = prep.effectiveConfig.toAmConfig() - return Config.Builder() - .setInterface(amConfig.`interface`) - .addPeers(amConfig.peers) - .addProxies(proxies) - .setDnsSettings( - org.amnezia.awg.config.DnsSettings( - prep.dnsSettings.dnsProtocol == DnsProtocol.DOH, - Optional.ofNullable(prep.dnsSettings.dnsEndpoint), - ) - ) - .build() - } - - suspend fun buildWgRunConfig(tunnelConfig: TunnelConfig): com.wireguard.config.Config { - val prep = prepare(tunnelConfig) - return prep.effectiveConfig.toWgConfig() - } -} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/backend/RuntimeAwgTunnel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/backend/RuntimeAwgTunnel.kt deleted file mode 100644 index 523740af9..000000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/backend/RuntimeAwgTunnel.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.core.tunnel.backend - -import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConfig -import kotlinx.coroutines.channels.Channel -import org.amnezia.awg.backend.Tunnel - -class RuntimeAwgTunnel( - private val tunnelConfig: TunnelConfig, - private val stateChannel: Channel, -) : Tunnel { - - override fun getName() = tunnelConfig.name - - override fun onStateChange(newState: Tunnel.State) { - stateChannel.trySend(newState) - } - - override fun isIpv4ResolutionPreferred() = tunnelConfig.isIpv4Preferred - - override fun isMetered() = tunnelConfig.isMetered -} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/backend/RuntimeWgTunnel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/backend/RuntimeWgTunnel.kt deleted file mode 100644 index d4e65b330..000000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/backend/RuntimeWgTunnel.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.core.tunnel.backend - -import com.wireguard.android.backend.Tunnel -import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConfig -import kotlinx.coroutines.channels.Channel - -class RuntimeWgTunnel( - private val config: TunnelConfig, - private val stateChannel: Channel, -) : Tunnel { - - override fun getName() = config.name - - override fun onStateChange(newState: Tunnel.State) { - stateChannel.trySend(newState) - } - - override fun isIpv4ResolutionPreferred() = config.isIpv4Preferred -} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/backend/TunnelBackend.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/backend/TunnelBackend.kt deleted file mode 100644 index 29dd69394..000000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/backend/TunnelBackend.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.core.tunnel.backend - -import com.zaneschepke.wireguardautotunnel.domain.enums.BackendMode -import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelStatus -import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConfig -import com.zaneschepke.wireguardautotunnel.domain.state.TunnelStatistics -import kotlinx.coroutines.flow.Flow - -interface TunnelBackend { - fun tunnelStateFlow(tunnelConfig: TunnelConfig): Flow - - fun getStatistics(tunnelId: Int): TunnelStatistics? - - fun setBackendMode(backendMode: BackendMode) - - fun getBackendMode(): BackendMode - - fun handleDnsReresolve(tunnelConfig: TunnelConfig): Boolean - - suspend fun runningTunnelNames(): Set - - suspend fun forceStopTunnel(tunnelId: Int) -} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/backend/UserspaceTunnel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/backend/UserspaceTunnel.kt deleted file mode 100644 index cedc4f970..000000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/backend/UserspaceTunnel.kt +++ /dev/null @@ -1,119 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.core.tunnel.backend - -import com.zaneschepke.wireguardautotunnel.domain.enums.BackendMode -import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelStatus -import com.zaneschepke.wireguardautotunnel.domain.events.DnsFailure -import com.zaneschepke.wireguardautotunnel.domain.events.InvalidConfig -import com.zaneschepke.wireguardautotunnel.domain.events.ServiceNotRunning -import com.zaneschepke.wireguardautotunnel.domain.events.UnknownError -import com.zaneschepke.wireguardautotunnel.domain.events.VpnUnauthorized -import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConfig -import com.zaneschepke.wireguardautotunnel.domain.state.AmneziaStatistics -import com.zaneschepke.wireguardautotunnel.domain.state.TunnelStatistics -import com.zaneschepke.wireguardautotunnel.util.extensions.asAmBackendMode -import com.zaneschepke.wireguardautotunnel.util.extensions.asBackendMode -import com.zaneschepke.wireguardautotunnel.util.extensions.asTunnelState -import com.zaneschepke.wireguardautotunnel.util.extensions.toBackendCoreException -import java.io.IOException -import java.util.concurrent.ConcurrentHashMap -import kotlinx.coroutines.TimeoutCancellationException -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.channels.awaitClose -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.callbackFlow -import kotlinx.coroutines.flow.consumeAsFlow -import kotlinx.coroutines.launch -import org.amnezia.awg.backend.Backend -import org.amnezia.awg.backend.BackendException -import org.amnezia.awg.backend.Tunnel -import timber.log.Timber - -class UserspaceTunnel(private val backend: Backend, private val runConfigHelper: RunConfigHelper) : - TunnelBackend { - - private val runtimeTunnels = ConcurrentHashMap() - - override fun tunnelStateFlow(tunnelConfig: TunnelConfig): Flow = callbackFlow { - val stateChannel = Channel() - - val runtimeTunnel = RuntimeAwgTunnel(tunnelConfig, stateChannel) - runtimeTunnels[tunnelConfig.id] = runtimeTunnel - - val consumerJob = launch { - stateChannel.consumeAsFlow().collect { awgState -> trySend(awgState.asTunnelState()) } - } - - try { - val runConfig = runConfigHelper.buildAmRunConfig(tunnelConfig) - backend.setState(runtimeTunnel, Tunnel.State.UP, runConfig) - } catch (_: TimeoutCancellationException) { - Timber.e("Startup timed out for ${tunnelConfig.name} (likely DNS hang)") - throw DnsFailure() - } catch (e: BackendException) { - throw e.toBackendCoreException() - } catch (_: IllegalArgumentException) { - throw InvalidConfig() - } catch (e: Exception) { - Timber.e(e, "Error while setting tunnel state") - throw UnknownError() - } - - awaitClose { - try { - backend.setState(runtimeTunnel, Tunnel.State.DOWN, null) - } catch (e: BackendException) { - // Errors emitted by caller - } finally { - consumerJob.cancel() - stateChannel.close() - runtimeTunnels.remove(tunnelConfig.id) - trySend(TunnelStatus.Down) - } - } - } - - override fun setBackendMode(backendMode: BackendMode) { - Timber.d("Setting backend mode: $backendMode") - try { - backend.backendMode = backendMode.asAmBackendMode() - } catch (e: BackendException) { - throw e.toBackendCoreException() - } catch (_: IOException) { - throw VpnUnauthorized() - } - } - - override fun getBackendMode(): BackendMode { - return backend.backendMode.asBackendMode() - } - - override fun handleDnsReresolve(tunnelConfig: TunnelConfig): Boolean { - val tunnel = runtimeTunnels[tunnelConfig.id] ?: throw ServiceNotRunning() - return backend.resolveDDNS(tunnelConfig.toAmConfig(), tunnel.isIpv4ResolutionPreferred) - } - - override suspend fun runningTunnelNames(): Set { - return backend.runningTunnelNames - } - - override fun getStatistics(tunnelId: Int): TunnelStatistics? { - return try { - val runtimeTunnel = runtimeTunnels[tunnelId] ?: return null - AmneziaStatistics(backend.getStatistics(runtimeTunnel)) - } catch (e: Exception) { - Timber.e(e, "Failed to get stats for $tunnelId") - null - } - } - - override suspend fun forceStopTunnel(tunnelId: Int) { - val runtimeTunnel = runtimeTunnels[tunnelId] ?: return - try { - backend.setState(runtimeTunnel, Tunnel.State.DOWN, null) - } catch (e: BackendException) { - Timber.e(e, "Force stop failed for $tunnelId") - } finally { - runtimeTunnels.remove(tunnelId) - } - } -} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/handler/DynamicDnsHandler.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/handler/DynamicDnsHandler.kt deleted file mode 100644 index c3728e95e..000000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/handler/DynamicDnsHandler.kt +++ /dev/null @@ -1,114 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.core.tunnel.handler - -import com.zaneschepke.wireguardautotunnel.data.model.AppMode -import com.zaneschepke.wireguardautotunnel.domain.events.BackendMessage -import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConfig -import com.zaneschepke.wireguardautotunnel.domain.repository.GeneralSettingRepository -import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository -import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState -import java.util.concurrent.ConcurrentHashMap -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch -import kotlinx.coroutines.plus -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import timber.log.Timber - -class DynamicDnsHandler( - private val activeTunnels: StateFlow>, - private val tunnelsRepository: TunnelRepository, - private val settingsRepository: GeneralSettingRepository, - private val localMessageEvents: MutableSharedFlow>, - private val handleDnsReresolve: (TunnelConfig) -> Boolean, - private val applicationScope: CoroutineScope, - private val ioDispatcher: CoroutineDispatcher, -) { - private val mutex = Mutex() - private val jobs = ConcurrentHashMap() - - init { - applicationScope.launch(ioDispatcher) { - combine(activeTunnels, settingsRepository.flow.filterNotNull()) { active, settings -> - active to settings - } - .collect { (activeTuns, settings) -> - mutex.withLock { - val activeIds = - activeTuns.keys - .filter { id -> - val config = - tunnelsRepository.getById(id) ?: return@filter false - config.restartOnPingFailure && - settings.appMode != AppMode.KERNEL - } - .toSet() - - (jobs.keys - activeIds).forEach { id -> - Timber.d("Shutting down Dynamic DNS monitoring job for tunnelId: $id") - jobs.remove(id)?.cancel() - } - - activeIds.forEach { id -> - if (jobs.containsKey(id)) return@forEach - val config = tunnelsRepository.getById(id) ?: return@forEach - val tunStateFlow = - activeTunnels - .map { it[id] } - .stateIn(applicationScope + ioDispatcher) - Timber.d("Starting Dynamic DNS monitoring job for tunnelId: $id") - jobs[id] = - applicationScope.launch(ioDispatcher) { - monitorDynamicDns(config, tunStateFlow) - } - } - } - } - } - } - - private suspend fun monitorDynamicDns( - config: TunnelConfig, - tunStateFlow: StateFlow, - ) { - var backoff = BASE_BACKOFF - while (true) { - val state = tunStateFlow.value ?: break - if (state.health() != TunnelState.Health.UNHEALTHY) { - backoff = BASE_BACKOFF - tunStateFlow.first { it?.health() == TunnelState.Health.UNHEALTHY || it == null } - continue - } - - runCatching { - val updated = handleDnsReresolve(config) - if (updated) { - localMessageEvents.emit(config.name to BackendMessage.DynamicDnsSuccess) - backoff = BASE_BACKOFF - } else { - Timber.i( - "Dynamic DNS check completed, current endpoint address is already up to date." - ) - } - } - .onFailure { Timber.e(it, "Failed to handle dns re-resolution for ${config.name}") } - - delay(backoff) - backoff = (backoff * 1.5).toLong().coerceAtMost(MAX_BACKOFF_TIME) - } - } - - companion object { - const val BASE_BACKOFF = 30_000L - const val MAX_BACKOFF_TIME = 300_000L - } -} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/handler/TunnelActiveStateHandler.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/handler/TunnelActiveStateHandler.kt deleted file mode 100644 index d60f64f5e..000000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/handler/TunnelActiveStateHandler.kt +++ /dev/null @@ -1,47 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.core.tunnel.handler - -import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository -import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.launch -import kotlinx.coroutines.supervisorScope - -class TunnelActiveStatePersister( - private val activeTunnels: StateFlow>, - private val tunnelsRepository: TunnelRepository, - applicationScope: CoroutineScope, - ioDispatcher: CoroutineDispatcher, -) { - private var previousActiveIds: Set = emptySet() - - init { - applicationScope.launch(ioDispatcher) { - activeTunnels.collect { currentActive -> - val currentActiveIds = currentActive.keys - if (currentActiveIds == previousActiveIds) return@collect - - val tunnels = tunnelsRepository.userTunnelsFlow.firstOrNull() ?: return@collect - val tunnelsById = tunnels.associateBy { it.id } - - val relevantIds = previousActiveIds + currentActiveIds - - supervisorScope { - relevantIds.forEach { id -> - launch { - val config = tunnelsById[id] ?: return@launch - val wasActive = previousActiveIds.contains(id) - val isActive = currentActiveIds.contains(id) - if (wasActive != isActive) { - tunnelsRepository.save(config.copy(isActive = isActive)) - } - } - } - } - previousActiveIds = currentActiveIds.toSet() - } - } - } -} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/handler/TunnelMonitoringHandler.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/handler/TunnelMonitoringHandler.kt deleted file mode 100644 index a8e888a9d..000000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/handler/TunnelMonitoringHandler.kt +++ /dev/null @@ -1,388 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.core.tunnel.handler - -import android.os.PowerManager -import com.zaneschepke.logcatter.LogReader -import com.zaneschepke.networkmonitor.NetworkMonitor -import com.zaneschepke.wireguardautotunnel.data.model.AppMode -import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelStatus -import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConfig -import com.zaneschepke.wireguardautotunnel.domain.repository.GeneralSettingRepository -import com.zaneschepke.wireguardautotunnel.domain.repository.MonitoringSettingsRepository -import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository -import com.zaneschepke.wireguardautotunnel.domain.state.FailureReason -import com.zaneschepke.wireguardautotunnel.domain.state.LogHealthState -import com.zaneschepke.wireguardautotunnel.domain.state.PingState -import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState -import com.zaneschepke.wireguardautotunnel.domain.state.TunnelStatistics -import com.zaneschepke.wireguardautotunnel.util.extensions.toMillis -import com.zaneschepke.wireguardautotunnel.util.network.NetworkUtils -import inet.ipaddr.AddressValueException -import inet.ipaddr.IPAddress -import inet.ipaddr.IPAddressString -import io.ktor.util.collections.ConcurrentMap -import java.util.concurrent.ConcurrentHashMap -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.Job -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.delay -import kotlinx.coroutines.ensureActive -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.distinctUntilChangedBy -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.mapNotNull -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import kotlinx.coroutines.plus -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withTimeout -import timber.log.Timber - -class TunnelMonitorHandler( - private val activeTunnels: StateFlow>, - private val tunnelsRepository: TunnelRepository, - private val settingsRepository: GeneralSettingRepository, - private val monitoringSettingsRepository: MonitoringSettingsRepository, - private val networkMonitor: NetworkMonitor, - private val networkUtils: NetworkUtils, - private val logReader: LogReader, - private val powerManager: PowerManager, - private val getStatistics: (Int) -> TunnelStatistics?, - private val updateTunnelStatus: - suspend ( - Int, TunnelStatus?, TunnelStatistics?, Map?, LogHealthState?, - ) -> Unit, - private val applicationScope: CoroutineScope, - private val ioDispatcher: CoroutineDispatcher, -) { - private val mutex = Mutex() - private val jobs = ConcurrentHashMap() - - init { - applicationScope.launch(ioDispatcher) { - activeTunnels.collect { activeTuns -> - mutex.withLock { - val activeIds = activeTuns.keys.toSet() - (jobs.keys - activeIds).forEach { id -> - Timber.d("Shutting down tunnel monitoring job for tunnelId: $id") - jobs.remove(id)?.cancel() - } - - val tunnels = tunnelsRepository.flow.firstOrNull() ?: return@collect - val tunnelsById = tunnels.associateBy { it.id } - - activeIds.forEach { id -> - if (jobs.containsKey(id)) return@forEach - val config = tunnelsById[id] ?: return@forEach - val settings = settingsRepository.flow.filterNotNull().first() - val tunStateFlow = - activeTunnels.map { it[id] }.stateIn(applicationScope + ioDispatcher) - jobs[id] = - applicationScope.launch(ioDispatcher) { - Timber.d("Starting tunnel monitoring job for tunnelId: $id") - startMonitoring( - config = config, - withLogs = settings.appMode != AppMode.KERNEL, - tunStateFlow = tunStateFlow, - getStatistics = { tunnelId -> getStatistics(tunnelId) }, - updateTunnelStatus = { tid, _, stats, pings, logHealth -> - updateTunnelStatus(tid, null, stats, pings, logHealth) - }, - ) - } - } - } - } - } - } - - @OptIn(FlowPreview::class) - private suspend fun startMonitoring( - config: TunnelConfig, - withLogs: Boolean, - tunStateFlow: StateFlow, - getStatistics: suspend (Int) -> TunnelStatistics?, - updateTunnelStatus: - suspend ( - Int, TunnelStatus?, TunnelStatistics?, Map?, LogHealthState?, - ) -> Unit, - ) = coroutineScope { - launch { startPingMonitor(config, tunStateFlow, updateTunnelStatus) } - launch { startWgStatsPoll(config.id, getStatistics, updateTunnelStatus) } - if (withLogs) launch { startLogsMonitor(config, updateTunnelStatus) } - } - - private suspend fun startLogsMonitor( - tunnelConfig: TunnelConfig, - updateTunnelStatus: - suspend ( - Int, TunnelStatus?, TunnelStatistics?, Map?, LogHealthState?, - ) -> Unit, - ) { - logReader.liveLogs - .filter { log -> log.tag.contains(tunnelConfig.name) } - .mapNotNull { log -> - val now = System.currentTimeMillis() - - when { - successLogRegex.containsMatchIn(log.message) -> - LogHealthState(isHealthy = true, timestamp = now) - - failureLogRegex.containsMatchIn(log.message) -> - LogHealthState(isHealthy = false, timestamp = now) - - else -> null - } - } - .distinctUntilChangedBy { it.isHealthy } - .collect { logHealthState -> - Timber.d("Tunnel log health updated for ${tunnelConfig.name}: $logHealthState") - updateTunnelStatus(tunnelConfig.id, null, null, null, logHealthState) - } - } - - private suspend fun startPingMonitor( - tunnelConfig: TunnelConfig, - tunStateFlow: StateFlow, - updateTunnelStatus: - suspend ( - Int, TunnelStatus?, TunnelStatistics?, Map?, LogHealthState?, - ) -> Unit, - ) = coroutineScope { - val pingStatsFlow = MutableStateFlow>(emptyMap()) - - val connectivityStateFlow = networkMonitor.connectivityStateFlow.stateIn(this) - - val isNetworkConnected = connectivityStateFlow.map { it.hasInternet() }.stateIn(this) - - combine( - settingsRepository.flow.distinctUntilChangedBy { it.appMode }, - monitoringSettingsRepository.flow, - ) { settings, monitorSettings -> - Pair(settings.appMode, monitorSettings) - } - .collectLatest { (appMode, settings) -> - if (!settings.isPingEnabled) return@collectLatest - // TODO for now until we get monitoring for these modes - if (appMode == AppMode.LOCK_DOWN || appMode == AppMode.PROXY) return@collectLatest - - Timber.d("Starting pinger for ${tunnelConfig.name} with settings") - - val config = tunnelConfig.toAmConfig() - - val pingablePeers = config.peers.filter { it.allowedIps.isNotEmpty() } - if (pingablePeers.isEmpty()) return@collectLatest - - suspend fun performPing() { - val updates = ConcurrentMap() - - pingablePeers - .map { it.publicKey.toBase64() to it } - .forEach { (key, peer) -> - ensureActive() - val previousState = pingStatsFlow.value[key] ?: PingState() - - val allowedIpStr = peer.allowedIps.firstOrNull()?.toString() - if (allowedIpStr == null) { - updates[key] = - previousState.copy( - isReachable = false, - failureReason = FailureReason.NoResolvedEndpoint, - lastPingAttemptMillis = System.currentTimeMillis(), - ) - return@forEach - } - - val host = - tunnelConfig.pingTarget - ?: run { - val parts = allowedIpStr.split("/") - val internalIp = - if (parts.size == 2) parts[0] else allowedIpStr - val prefix = - if (parts.size == 2) parts[1].toIntOrNull() ?: 32 - else 32 - val cleanedIp = internalIp.removeSurrounding("[", "]") - val defaultCloudflare = - if (cleanedIp.contains(":")) CLOUDFLARE_IPV6_IP - else CLOUDFLARE_IPV4_IP - - if (prefix <= 1) { - defaultCloudflare - } else { - try { - val addrStr = IPAddressString(cleanedIp) - val addr: IPAddress = - addrStr.address - ?: throw AddressValueException( - "Invalid IP: $cleanedIp" - ) - val isIpv6 = addr.isIPv6 - val cloudflareIp = - if (isIpv6) CLOUDFLARE_IPV6_IP - else CLOUDFLARE_IPV4_IP - val max = if (isIpv6) 128 else 32 - - if (prefix == max) { - addr.toCanonicalString() - } else { - val nextAddr: IPAddress? = addr.increment(1) - nextAddr?.toCanonicalString() ?: cloudflareIp - } - } catch (e: AddressValueException) { - Timber.e( - e, - "Failed to parse or increment IP: $cleanedIp", - ) - defaultCloudflare - } - } - } - - val attemptTime = System.currentTimeMillis() - val timeout = settings.tunnelPingTimeoutSeconds?.toMillis() ?: 5000L - runCatching { - withTimeout( - settings.tunnelPingTimeoutSeconds?.toMillis() ?: 5000L - ) { - val pingStats = - settings.tunnelPingTimeoutSeconds?.let { - networkUtils.pingWithStats( - host, - settings.tunnelPingAttempts, - it.toMillis(), - ) - } - ?: networkUtils.pingWithStats( - host, - settings.tunnelPingAttempts, - ) - - updates[key] = - previousState.copy( - transmitted = pingStats.transmitted, - received = pingStats.received, - packetLoss = pingStats.packetLoss, - rttMin = pingStats.rttMin, - rttMax = pingStats.rttMax, - rttAvg = pingStats.rttAvg, - rttStddev = pingStats.rttStddev, - isReachable = pingStats.isReachable, - failureReason = - if (pingStats.isReachable) null - else FailureReason.PingFailed, - lastSuccessfulPingMillis = - pingStats.lastSuccessfulPingMillis - ?: previousState.lastSuccessfulPingMillis, - pingTarget = host, - lastPingAttemptMillis = attemptTime, - ) - Timber.d( - "Ping completed for peer ${peer.publicKey.toBase64().substring(0, 5)}.. to host $host with stats: $pingStats" - ) - } - } - .onFailure { - Timber.e( - it, - "Ping failed for peer ${peer.publicKey} in ${tunnelConfig.name} to host $host", - ) - updates[key] = - previousState.copy( - isReachable = false, - failureReason = FailureReason.PingFailed, - pingTarget = host, - lastPingAttemptMillis = attemptTime, - ) - } - } - - if (updates.isNotEmpty()) { - ensureActive() - pingStatsFlow.update { updates } - updateTunnelStatus(tunnelConfig.id, null, null, updates, null) - } - } - - // Wait for the tunnel to be fully active - tunStateFlow.filter { state -> state?.status is TunnelStatus.Up }.first() - - // small delay to make sure tunnel is fully up before we actively monitor - delay(PING_MONITOR_START_DELAY) - - while (isActive) { - ensureActive() - if (!powerManager.isDeviceIdleMode) { - if (isNetworkConnected.value) { - performPing() - } else { - pingStatsFlow.update { current -> - current.mapValues { entry -> - entry.value.copy( - isReachable = false, - failureReason = FailureReason.NoConnectivity, - lastPingAttemptMillis = System.currentTimeMillis(), - ) - } - } - ensureActive() - updateTunnelStatus( - tunnelConfig.id, - null, - null, - pingStatsFlow.value, - null, - ) - } - } - delay(settings.tunnelPingIntervalSeconds.toMillis()) - } - } - } - - private suspend fun startWgStatsPoll( - tunnelId: Int, - getStatistics: suspend (Int) -> TunnelStatistics?, - updateTunnelStatus: - suspend ( - Int, TunnelStatus?, TunnelStatistics?, Map?, LogHealthState?, - ) -> Unit, - ) = coroutineScope { - while (isActive) { - ensureActive() - val stats = getStatistics(tunnelId) - ensureActive() - updateTunnelStatus(tunnelId, null, stats, null, null) - delay(STATS_DELAY) - } - } - - companion object { - private val successLogRegex = - Regex("Received handshake response|Receiving keepalive packet", RegexOption.IGNORE_CASE) - - private val failureLogRegex = - Regex( - "Failed to send handshake initiation: write udp|" + - "Handshake did not complete after 5 seconds, retrying|" + - "Failed to send data packets", - RegexOption.IGNORE_CASE, - ) - - const val CLOUDFLARE_IPV6_IP = "2606:4700:4700::1111" - const val CLOUDFLARE_IPV4_IP = "1.1.1.1" - const val STATS_DELAY = 1_000L - const val PING_MONITOR_START_DELAY = 5_000L - } -} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/handler/TunnelServiceHandler.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/handler/TunnelServiceHandler.kt deleted file mode 100644 index 7eaa973fc..000000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/handler/TunnelServiceHandler.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.core.tunnel.handler - -import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager -import com.zaneschepke.wireguardautotunnel.domain.model.GeneralSettings -import com.zaneschepke.wireguardautotunnel.domain.repository.GeneralSettingRepository -import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.launch -import timber.log.Timber - -class TunnelServiceHandler( - private val activeTunnels: StateFlow>, - private val settingsRepository: GeneralSettingRepository, - private val serviceManager: ServiceManager, - applicationScope: CoroutineScope, - ioDispatcher: CoroutineDispatcher, -) { - init { - applicationScope.launch(ioDispatcher) { - activeTunnels.collect { activeTuns -> - if (activeTuns.isEmpty()) { - Timber.d("Stopping tunnel service, no tunnels active.") - serviceManager.stopTunnelService() - } else if (serviceManager.tunnelService.value == null) { - val settings = settingsRepository.flow.firstOrNull() ?: GeneralSettings() - Timber.d("Starting tunnel foreground service for active tunnel.") - serviceManager.startTunnelService(settings.appMode) - } - serviceManager.updateTunnelTile() - } - } - } -} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/worker/ServiceWorker.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/worker/ServiceWorker.kt index 47413da16..a9f37177a 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/worker/ServiceWorker.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/worker/ServiceWorker.kt @@ -7,6 +7,7 @@ import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import androidx.work.WorkerParameters import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager +import com.zaneschepke.wireguardautotunnel.core.service.autotunnel.AutoTunnelStateHolder import com.zaneschepke.wireguardautotunnel.domain.repository.AutoTunnelSettingsRepository import java.util.concurrent.TimeUnit import timber.log.Timber @@ -16,6 +17,7 @@ class ServiceWorker( params: WorkerParameters, private val serviceManager: ServiceManager, private val autoTunnelSettingsRepository: AutoTunnelSettingsRepository, + private val autoTunnelStateHolder: AutoTunnelStateHolder, ) : CoroutineWorker(context, params) { companion object { @@ -42,14 +44,18 @@ class ServiceWorker( } override suspend fun doWork(): Result { - Timber.i("Service worker started") - with(autoTunnelSettingsRepository.getAutoTunnelSettings()) { - Timber.i("Checking to see if auto-tunnel has been killed by system") - if (isAutoTunnelEnabled && serviceManager.autoTunnelService.value == null) { - Timber.i("Service has been killed by system, restoring.") - serviceManager.startAutoTunnelService() - } + Timber.i("AutoTunnel reconciliation worker running") + + val settings = autoTunnelSettingsRepository.getAutoTunnelSettings() + + if (!settings.isAutoTunnelEnabled) { return Result.success() } + + if (autoTunnelStateHolder.active.value) return Result.success() + + serviceManager.startAutoTunnelService() + + return Result.success() } } 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 29413f5d4..1617f6ca2 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/AppDatabase.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/AppDatabase.kt @@ -1,10 +1,27 @@ package com.zaneschepke.wireguardautotunnel.data -import androidx.room.* +import androidx.room.AutoMigration +import androidx.room.Database +import androidx.room.DeleteColumn +import androidx.room.RenameColumn +import androidx.room.RoomDatabase +import androidx.room.TypeConverters import androidx.room.migration.AutoMigrationSpec import androidx.sqlite.db.SupportSQLiteDatabase -import com.zaneschepke.wireguardautotunnel.data.dao.* -import com.zaneschepke.wireguardautotunnel.data.entity.* +import com.zaneschepke.wireguardautotunnel.data.dao.AutoTunnelSettingsDao +import com.zaneschepke.wireguardautotunnel.data.dao.DnsSettingsDao +import com.zaneschepke.wireguardautotunnel.data.dao.GeneralSettingsDao +import com.zaneschepke.wireguardautotunnel.data.dao.LockdownSettingsDao +import com.zaneschepke.wireguardautotunnel.data.dao.MonitoringSettingsDao +import com.zaneschepke.wireguardautotunnel.data.dao.ProxySettingsDao +import com.zaneschepke.wireguardautotunnel.data.dao.TunnelConfigDao +import com.zaneschepke.wireguardautotunnel.data.entity.AutoTunnelSettings +import com.zaneschepke.wireguardautotunnel.data.entity.DnsSettings +import com.zaneschepke.wireguardautotunnel.data.entity.GeneralSettings +import com.zaneschepke.wireguardautotunnel.data.entity.LockdownSettings +import com.zaneschepke.wireguardautotunnel.data.entity.MonitoringSettings +import com.zaneschepke.wireguardautotunnel.data.entity.ProxySettings +import com.zaneschepke.wireguardautotunnel.data.entity.TunnelConfig @Database( entities = @@ -17,7 +34,7 @@ import com.zaneschepke.wireguardautotunnel.data.entity.* DnsSettings::class, LockdownSettings::class, ], - version = 29, + version = 30, autoMigrations = [ AutoMigration(from = 1, to = 2), @@ -45,6 +62,7 @@ import com.zaneschepke.wireguardautotunnel.data.entity.* AutoMigration(from = 24, to = 25), AutoMigration(from = 26, to = 27, spec = GlobalsMigration::class), AutoMigration(from = 27, to = 28, spec = DonationMigration::class), + AutoMigration(from = 29, to = 30, spec = SingleConfigMigration::class), ], exportSchema = true, ) @@ -129,3 +147,60 @@ class GlobalsMigration : AutoMigrationSpec @DeleteColumn(tableName = "general_settings", columnName = "custom_split_packages") class DonationMigration : AutoMigrationSpec + +@RenameColumn.Entries( + RenameColumn( + tableName = "tunnel_config", + fromColumnName = "is_ipv4_preferred", + toColumnName = "prefer_ipv6", + ), + RenameColumn( + tableName = "tunnel_config", + fromColumnName = "am_quick", + toColumnName = "quick_config", + ), + RenameColumn( + tableName = "tunnel_config", + fromColumnName = "restart_on_ping_failure", + toColumnName = "dynamic_dns", + ), +) +@DeleteColumn.Entries( + DeleteColumn(tableName = "tunnel_config", columnName = "wg_quick"), + DeleteColumn(tableName = "tunnel_config", columnName = "ping_target"), + DeleteColumn(tableName = "tunnel_config", columnName = "is_Active"), + DeleteColumn(tableName = "monitoring_settings", columnName = "is_ping_enabled"), + DeleteColumn(tableName = "monitoring_settings", columnName = "is_ping_monitoring_enabled"), + DeleteColumn(tableName = "monitoring_settings", columnName = "tunnel_ping_interval_sec"), + DeleteColumn(tableName = "monitoring_settings", columnName = "tunnel_ping_attempts"), + DeleteColumn(tableName = "monitoring_settings", columnName = "tunnel_ping_timeout_sec"), + DeleteColumn(tableName = "monitoring_settings", columnName = "show_detailed_ping_stats"), + DeleteColumn(tableName = "auto_tunnel_settings", columnName = "debounce_delay_seconds"), +) +class SingleConfigMigration : AutoMigrationSpec { + + override fun onPostMigrate(db: SupportSQLiteDatabase) { + db.execSQL( + """ + UPDATE tunnel_config + SET prefer_ipv6 = + CASE prefer_ipv6 + WHEN 1 THEN 0 + WHEN 0 THEN 1 + ELSE 0 + END + """ + ) + + db.execSQL( + """ + UPDATE general_settings + SET app_mode = CASE app_mode + WHEN 3 THEN 0 + ELSE app_mode + END + """ + .trimIndent() + ) + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/DatabaseConverters.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/DatabaseConverters.kt index 5bb0ddeaf..8f3a02263 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/DatabaseConverters.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/DatabaseConverters.kt @@ -1,9 +1,9 @@ package com.zaneschepke.wireguardautotunnel.data import androidx.room.TypeConverter -import com.zaneschepke.wireguardautotunnel.data.model.AppMode -import com.zaneschepke.wireguardautotunnel.data.model.DnsProtocol -import com.zaneschepke.wireguardautotunnel.data.model.WifiDetectionMethod +import com.zaneschepke.wireguardautotunnel.domain.enums.DnsProtocol +import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelMode +import com.zaneschepke.wireguardautotunnel.domain.enums.WifiDetectionMethod import kotlinx.serialization.json.Json class DatabaseConverters { @@ -57,9 +57,9 @@ class DatabaseConverters { @TypeConverter fun toStatus(value: Int): WifiDetectionMethod = WifiDetectionMethod.fromValue(value) - @TypeConverter fun toMode(value: Int): AppMode = AppMode.fromValue(value) + @TypeConverter fun toMode(value: Int): TunnelMode = TunnelMode.fromValue(value) - @TypeConverter fun fromMode(mode: AppMode): Int = mode.value + @TypeConverter fun fromMode(mode: TunnelMode): Int = mode.value @TypeConverter fun toDnsProtocol(value: Int): DnsProtocol = DnsProtocol.fromValue(value) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/dao/DnsSettingsDao.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/dao/DnsSettingsDao.kt index b4bde8d64..bdf0ad80c 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/dao/DnsSettingsDao.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/dao/DnsSettingsDao.kt @@ -13,4 +13,7 @@ interface DnsSettingsDao { @Upsert suspend fun upsert(dnsSettings: DnsSettings) @Query("SELECT * FROM dns_settings LIMIT 1") fun getDnsSettingsFlow(): Flow + + @Query("UPDATE dns_settings SET global_tunnel_dns_enabled = :enabled") + suspend fun updateGlobalDnsEnabled(enabled: Boolean) } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/dao/GeneralSettingsDao.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/dao/GeneralSettingsDao.kt index f4f799c6c..3d49c9676 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/dao/GeneralSettingsDao.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/dao/GeneralSettingsDao.kt @@ -4,7 +4,7 @@ import androidx.room.Dao import androidx.room.Query import androidx.room.Upsert import com.zaneschepke.wireguardautotunnel.data.entity.GeneralSettings -import com.zaneschepke.wireguardautotunnel.data.model.AppMode +import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelMode import kotlinx.coroutines.flow.Flow @Dao @@ -26,6 +26,12 @@ interface GeneralSettingsDao { @Query("UPDATE general_settings SET is_pin_lock_enabled = :enabled WHERE id = 1") suspend fun updatePinLockEnabled(enabled: Boolean) - @Query("UPDATE general_settings SET app_mode = :appMode WHERE id = 1") - suspend fun updateAppMode(appMode: AppMode) + @Query("UPDATE general_settings SET app_mode = :tunnelMode WHERE id = 1") + suspend fun updateAppMode(tunnelMode: TunnelMode) + + @Query("UPDATE general_settings SET global_amnezia_enabled = :enabled") + suspend fun updateGlobalAmneziaEnabled(enabled: Boolean) + + @Query("UPDATE general_settings SET screen_recording_security = :enabled") + suspend fun updateScreenRecordingSecurity(enabled: Boolean) } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/dao/MonitoringSettingsDao.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/dao/MonitoringSettingsDao.kt index eee221618..d33d68569 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/dao/MonitoringSettingsDao.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/dao/MonitoringSettingsDao.kt @@ -15,4 +15,26 @@ interface MonitoringSettingsDao { @Query("SELECT * FROM monitoring_settings LIMIT 1") fun getMonitoringSettingsFlow(): Flow + + @Query( + """ + UPDATE monitoring_settings + SET tunnel_statistics_poll_interval = :interval + WHERE id = ( + SELECT id FROM monitoring_settings LIMIT 1 + ) +""" + ) + suspend fun updateStatisticsInterval(interval: Int) + + @Query( + """ + UPDATE monitoring_settings + SET tunnel_statistics_enabled = :enabled + WHERE id = ( + SELECT id FROM monitoring_settings LIMIT 1 + ) +""" + ) + suspend fun updateStatisticsEnabled(enabled: Boolean) } 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 d08475405..1ce09e172 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 @@ -1,6 +1,11 @@ package com.zaneschepke.wireguardautotunnel.data.dao -import androidx.room.* +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Upsert import com.zaneschepke.wireguardautotunnel.data.entity.TunnelConfig import kotlinx.coroutines.flow.Flow @@ -11,17 +16,17 @@ interface TunnelConfigDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun saveAll(t: List) - @Query("SELECT * FROM tunnel_config WHERE id=:id") suspend fun getById(id: Long): TunnelConfig? + @Query("UPDATE tunnel_config SET is_metered = :value WHERE id = :id") + suspend fun setMetered(id: Int, value: Boolean) + + @Query("UPDATE tunnel_config SET dynamic_dns = :value WHERE id = :id") + suspend fun setDynamicDns(id: Int, value: Boolean) - @Query("UPDATE tunnel_config SET is_Active = 0 WHERE is_Active = 1") - suspend fun resetActiveTunnels() + @Query("SELECT * FROM tunnel_config WHERE id=:id") suspend fun getById(id: Long): TunnelConfig? @Query("SELECT * FROM tunnel_config WHERE name=:name") suspend fun getByName(name: String): TunnelConfig? - @Query("SELECT * FROM tunnel_config WHERE is_Active=1") - suspend fun getActive(): List - @Query("SELECT * FROM tunnel_config") suspend fun getAll(): List @Delete suspend fun delete(t: TunnelConfig) @@ -50,30 +55,15 @@ interface TunnelConfigDao { @Query( """ - SELECT * FROM tunnel_config - WHERE name != '${TunnelConfig.GLOBAL_CONFIG_NAME}' - ORDER BY - CASE WHEN is_primary_tunnel = 1 THEN 0 ELSE 1 END, - position ASC - LIMIT 1 - """ + SELECT * + FROM tunnel_config + WHERE name != '${TunnelConfig.GLOBAL_CONFIG_NAME}' + ORDER BY is_primary_tunnel DESC, position ASC + LIMIT 1 + """ ) suspend fun getDefaultTunnel(): TunnelConfig? - @Query( - """ - SELECT * FROM tunnel_config - WHERE name != '${TunnelConfig.GLOBAL_CONFIG_NAME}' - ORDER BY - CASE WHEN is_Active = 1 THEN 0 - WHEN is_primary_tunnel = 1 THEN 1 - ELSE 2 END, - position ASC - LIMIT 1 - """ - ) - suspend fun getStartTunnel(): TunnelConfig? - @Query("SELECT * FROM tunnel_config ORDER BY position") fun getAllFlow(): Flow> diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/entity/AutoTunnelSettings.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/entity/AutoTunnelSettings.kt index c5126897c..32aff7c3e 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/entity/AutoTunnelSettings.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/entity/AutoTunnelSettings.kt @@ -3,7 +3,7 @@ package com.zaneschepke.wireguardautotunnel.data.entity import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey -import com.zaneschepke.wireguardautotunnel.data.model.WifiDetectionMethod +import com.zaneschepke.wireguardautotunnel.domain.enums.WifiDetectionMethod @Entity(tableName = "auto_tunnel_settings") data class AutoTunnelSettings( @@ -22,8 +22,6 @@ data class AutoTunnelSettings( val isWildcardsEnabled: Boolean = false, @ColumnInfo(name = "is_stop_on_no_internet_enabled", defaultValue = "0") val isStopOnNoInternetEnabled: Boolean = false, - @ColumnInfo(name = "debounce_delay_seconds", defaultValue = "3") - val debounceDelaySeconds: Int = 3, @ColumnInfo(name = "is_tunnel_on_unsecure_enabled", defaultValue = "0") val isTunnelOnUnsecureEnabled: Boolean = false, @ColumnInfo(name = "wifi_detection_method", defaultValue = "0") diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/entity/DnsSettings.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/entity/DnsSettings.kt index e47dfef0f..bc0232f8b 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/entity/DnsSettings.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/entity/DnsSettings.kt @@ -3,7 +3,7 @@ package com.zaneschepke.wireguardautotunnel.data.entity import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey -import com.zaneschepke.wireguardautotunnel.data.model.DnsProtocol +import com.zaneschepke.wireguardautotunnel.domain.enums.DnsProtocol @Entity(tableName = "dns_settings") data class DnsSettings( diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/entity/GeneralSettings.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/entity/GeneralSettings.kt index a638372f6..cf222edae 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/entity/GeneralSettings.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/entity/GeneralSettings.kt @@ -3,7 +3,7 @@ package com.zaneschepke.wireguardautotunnel.data.entity import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey -import com.zaneschepke.wireguardautotunnel.data.model.AppMode +import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelMode @Entity(tableName = "general_settings") data class GeneralSettings( @@ -16,7 +16,8 @@ data class GeneralSettings( val isMultiTunnelEnabled: Boolean = false, @ColumnInfo(name = "global_split_tunnel_enabled", defaultValue = "0") val isGlobalSplitTunnelEnabled: Boolean = false, - @ColumnInfo(name = "app_mode", defaultValue = "0") val appMode: AppMode = AppMode.fromValue(0), + @ColumnInfo(name = "app_mode", defaultValue = "0") + val tunnelMode: TunnelMode = TunnelMode.fromValue(0), @ColumnInfo(name = "theme", defaultValue = "AUTOMATIC") val theme: String = "AUTOMATIC", @ColumnInfo(name = "locale") val locale: String? = null, @ColumnInfo(name = "remote_key") val remoteKey: String? = null, @@ -27,4 +28,10 @@ data class GeneralSettings( @ColumnInfo(name = "is_always_on_vpn_enabled", defaultValue = "0") val isAlwaysOnVpnEnabled: Boolean = false, @ColumnInfo(name = "already_donated", defaultValue = "0") val alreadyDonated: Boolean = false, + @ColumnInfo(name = "screen_recording_security", defaultValue = "1") + val screenRecordingSecurityEnabled: Boolean = true, + @ColumnInfo(name = "global_amnezia_enabled", defaultValue = "0") + val isGlobalAmneziaEnabled: Boolean = false, + @ColumnInfo(name = "tunnel_scripting_enabled", defaultValue = "0") + val tunnelScriptingEnabled: Boolean = true, ) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/entity/MonitoringSettings.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/entity/MonitoringSettings.kt index db97e3d7f..ff134c819 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/entity/MonitoringSettings.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/entity/MonitoringSettings.kt @@ -7,15 +7,10 @@ import androidx.room.PrimaryKey @Entity(tableName = "monitoring_settings") data class MonitoringSettings( @PrimaryKey(autoGenerate = true) val id: Int = 0, - @ColumnInfo(name = "is_ping_enabled", defaultValue = "0") val isPingEnabled: Boolean = false, - @ColumnInfo(name = "is_ping_monitoring_enabled", defaultValue = "1") - val isPingMonitoringEnabled: Boolean = true, - @ColumnInfo(name = "tunnel_ping_interval_sec", defaultValue = "30") - val tunnelPingIntervalSeconds: Int = 30, - @ColumnInfo(name = "tunnel_ping_attempts", defaultValue = "3") val tunnelPingAttempts: Int = 3, - @ColumnInfo(name = "tunnel_ping_timeout_sec") val tunnelPingTimeoutSeconds: Int? = null, - @ColumnInfo(name = "show_detailed_ping_stats", defaultValue = "0") - val showDetailedPingStats: Boolean = false, @ColumnInfo(name = "is_local_logs_enabled", defaultValue = "0") val isLocalLogsEnabled: Boolean = false, + @ColumnInfo(name = "tunnel_statistics_enabled", defaultValue = "1") + val tunnelStatisticsEnabled: Boolean = true, + @ColumnInfo(name = "tunnel_statistics_poll_interval", defaultValue = "3") + val tunnelStatisticsPollInterval: Int = 3, ) 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 cb1cdc8fa..c7df84206 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 @@ -9,26 +9,26 @@ import androidx.room.PrimaryKey data class TunnelConfig( @PrimaryKey(autoGenerate = true) val id: Int = 0, @ColumnInfo(name = "name") val name: String, - @ColumnInfo(name = "wg_quick") val wgQuick: String, @ColumnInfo(name = "tunnel_networks", defaultValue = "") val tunnelNetworks: Set = setOf(), @ColumnInfo(name = "is_mobile_data_tunnel", defaultValue = "false") val isMobileDataTunnel: Boolean = false, @ColumnInfo(name = "is_primary_tunnel", defaultValue = "false") val isPrimaryTunnel: Boolean = false, - @ColumnInfo(name = "am_quick", defaultValue = "") val amQuick: String = "", - @ColumnInfo(name = "is_Active", defaultValue = "false") val isActive: Boolean = false, - @ColumnInfo(name = "restart_on_ping_failure", defaultValue = "false") - val restartOnPingFailure: Boolean = false, - @ColumnInfo(name = "ping_target", defaultValue = "null") var pingTarget: String? = null, + @ColumnInfo(name = "quick_config", defaultValue = "") val quickConfig: String = "", + @ColumnInfo(name = "dynamic_dns", defaultValue = "false") + val dynamicDnsEnabled: Boolean = false, @ColumnInfo(name = "is_ethernet_tunnel", defaultValue = "false") val isEthernetTunnel: Boolean = false, - @ColumnInfo(name = "is_ipv4_preferred", defaultValue = "true") - val isIpv4Preferred: Boolean = true, + @ColumnInfo(name = "prefer_ipv6", defaultValue = "false") val isIpv6Preferred: Boolean = false, @ColumnInfo(name = "position", defaultValue = "0") val position: Int = 0, @ColumnInfo(name = "auto_tunnel_apps", defaultValue = "[]") val autoTunnelApps: Set = emptySet(), @ColumnInfo(name = "is_metered", defaultValue = "false") val isMetered: Boolean = false, + @ColumnInfo(name = "ipv4_fallback", defaultValue = "false") + val ipv4FallbackEnabled: Boolean = false, + @ColumnInfo(name = "ipv6_restore", defaultValue = "false") + val ipv6RestoreEnabled: Boolean = false, ) { companion object { const val GLOBAL_CONFIG_NAME = "4675ab06-903a-438b-8485-6ea4187a9512" diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/mapper/AutoTunnelSettingsMapper.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/mapper/AutoTunnelSettingsMapper.kt index e045ce8e5..5735a432b 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/mapper/AutoTunnelSettingsMapper.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/mapper/AutoTunnelSettingsMapper.kt @@ -13,7 +13,6 @@ fun Entity.toDomain(): Domain = isTunnelOnWifiEnabled = isTunnelOnWifiEnabled, isWildcardsEnabled = isWildcardsEnabled, isStopOnNoInternetEnabled = isStopOnNoInternetEnabled, - debounceDelaySeconds = debounceDelaySeconds, isTunnelOnUnsecureEnabled = isTunnelOnUnsecureEnabled, wifiDetectionMethod = wifiDetectionMethod, startOnBoot = startOnBoot, @@ -29,7 +28,6 @@ fun Domain.toEntity(): Entity = isTunnelOnWifiEnabled = isTunnelOnWifiEnabled, isWildcardsEnabled = isWildcardsEnabled, isStopOnNoInternetEnabled = isStopOnNoInternetEnabled, - debounceDelaySeconds = debounceDelaySeconds, isTunnelOnUnsecureEnabled = isTunnelOnUnsecureEnabled, wifiDetectionMethod = wifiDetectionMethod, startOnBoot = startOnBoot, diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/mapper/MonitoringSettingsMapper.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/mapper/MonitoringSettingsMapper.kt index 35fc8f7e8..14b98c865 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/mapper/MonitoringSettingsMapper.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/mapper/MonitoringSettingsMapper.kt @@ -6,23 +6,15 @@ import com.zaneschepke.wireguardautotunnel.domain.model.MonitoringSettings as Do fun Entity.toDomain(): Domain = Domain( id = id, - isPingEnabled = isPingEnabled, - isPingMonitoringEnabled = isPingMonitoringEnabled, - tunnelPingIntervalSeconds = tunnelPingIntervalSeconds, - tunnelPingAttempts = tunnelPingAttempts, - tunnelPingTimeoutSeconds = tunnelPingTimeoutSeconds, - showDetailedPingStats = showDetailedPingStats, + tunnelStatisticsEnabled = tunnelStatisticsEnabled, + tunnelStatisticsPollInterval = tunnelStatisticsPollInterval, isLocalLogsEnabled = isLocalLogsEnabled, ) fun Domain.toEntity(): Entity = Entity( id = id, - isPingEnabled = isPingEnabled, - isPingMonitoringEnabled = isPingMonitoringEnabled, - tunnelPingIntervalSeconds = tunnelPingIntervalSeconds, - tunnelPingAttempts = tunnelPingAttempts, - tunnelPingTimeoutSeconds = tunnelPingTimeoutSeconds, - showDetailedPingStats = showDetailedPingStats, + tunnelStatisticsEnabled = tunnelStatisticsEnabled, + tunnelStatisticsPollInterval = tunnelStatisticsPollInterval, isLocalLogsEnabled = isLocalLogsEnabled, ) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/mapper/SettingsMapper.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/mapper/SettingsMapper.kt index 2a679d88d..c67c6857b 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/mapper/SettingsMapper.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/mapper/SettingsMapper.kt @@ -11,7 +11,7 @@ fun Entity.toDomain(): Domain = isRestoreOnBootEnabled = isRestoreOnBootEnabled, isMultiTunnelEnabled = isMultiTunnelEnabled, isGlobalSplitTunnelEnabled = isGlobalSplitTunnelEnabled, - appMode = appMode, + tunnelMode = tunnelMode, theme = Theme.valueOf(theme.uppercase()), locale = locale, remoteKey = remoteKey, @@ -19,6 +19,9 @@ fun Entity.toDomain(): Domain = isPinLockEnabled = isPinLockEnabled, isAlwaysOnVpnEnabled = isAlwaysOnVpnEnabled, alreadyDonated = alreadyDonated, + screenRecordingSecurityEnabled = screenRecordingSecurityEnabled, + isGlobalAmneziaEnabled = isGlobalAmneziaEnabled, + tunnelScriptingEnabled = tunnelScriptingEnabled, ) fun Domain.toEntity(): Entity = @@ -28,7 +31,7 @@ fun Domain.toEntity(): Entity = isRestoreOnBootEnabled = isRestoreOnBootEnabled, isMultiTunnelEnabled = isMultiTunnelEnabled, isGlobalSplitTunnelEnabled = isGlobalSplitTunnelEnabled, - appMode = appMode, + tunnelMode = tunnelMode, theme = theme.name, locale = locale, remoteKey = remoteKey, @@ -36,4 +39,7 @@ fun Domain.toEntity(): Entity = isPinLockEnabled = isPinLockEnabled, isAlwaysOnVpnEnabled = isAlwaysOnVpnEnabled, alreadyDonated = alreadyDonated, + screenRecordingSecurityEnabled = screenRecordingSecurityEnabled, + isGlobalAmneziaEnabled = isGlobalAmneziaEnabled, + tunnelScriptingEnabled = tunnelScriptingEnabled, ) 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 036c849a9..7bfe8b6b4 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 @@ -7,36 +7,34 @@ fun Entity.toDomain(): Domain = Domain( id = id, name = name, - wgQuick = wgQuick, tunnelNetworks = tunnelNetworks, isMobileDataTunnel = isMobileDataTunnel, isPrimaryTunnel = isPrimaryTunnel, - amQuick = amQuick, - isActive = isActive, - restartOnPingFailure = restartOnPingFailure, - pingTarget = pingTarget, + quickConfig = quickConfig, + dynamicDnsEnabled = dynamicDnsEnabled, isEthernetTunnel = isEthernetTunnel, - isIpv4Preferred = isIpv4Preferred, + isIpv6Preferred = isIpv6Preferred, position = position, autoTunnelApps = autoTunnelApps, isMetered = isMetered, + ipv4FallbackEnabled = ipv4FallbackEnabled, + ipv6RestoreEnabled = ipv6RestoreEnabled, ) fun Domain.toEntity(): Entity = Entity( id = id, name = name, - wgQuick = wgQuick, tunnelNetworks = tunnelNetworks, isMobileDataTunnel = isMobileDataTunnel, isPrimaryTunnel = isPrimaryTunnel, - amQuick = amQuick, - isActive = isActive, - restartOnPingFailure = restartOnPingFailure, - pingTarget = pingTarget, + quickConfig = quickConfig, + dynamicDnsEnabled = dynamicDnsEnabled, isEthernetTunnel = isEthernetTunnel, - isIpv4Preferred = isIpv4Preferred, + isIpv6Preferred = isIpv6Preferred, position = position, autoTunnelApps = autoTunnelApps, isMetered = isMetered, + ipv4FallbackEnabled = ipv4FallbackEnabled, + ipv6RestoreEnabled = ipv6RestoreEnabled, ) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/model/AppMode.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/model/AppMode.kt deleted file mode 100644 index 593d1532c..000000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/model/AppMode.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.data.model - -enum class AppMode(val value: Int) { - VPN(0), - PROXY(1), - LOCK_DOWN(2), - KERNEL(3); - - companion object { - fun fromValue(value: Int): AppMode = entries.find { it.value == value } ?: VPN - } -} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/model/Dns.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/model/Dns.kt deleted file mode 100644 index 8d90ca04f..000000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/model/Dns.kt +++ /dev/null @@ -1,42 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.data.model - -import android.content.Context -import com.zaneschepke.wireguardautotunnel.R - -enum class DnsProtocol(val value: Int) { - SYSTEM(0), - DOH(1); - - fun asString(context: Context): String { - return when (this) { - SYSTEM -> context.getString(R.string.system) - DOH -> context.getString(R.string.doh) - } - } - - companion object { - fun fromValue(value: Int): DnsProtocol = - DnsProtocol.entries.find { it.value == value } ?: SYSTEM - } -} - -data class DnsSettings(val protocol: DnsProtocol = DnsProtocol.SYSTEM, val endpoint: String? = null) - -enum class DnsProvider(private val systemAddress: String, private val dohAddress: String) { - CLOUDFLARE("1.1.1.1", "https://1.1.1.1/dns-query"), - ADGUARD("94.140.14.14", "https://94.140.14.14/dns-query"); - - fun asAddress(protocol: DnsProtocol): String { - return when (protocol) { - DnsProtocol.SYSTEM -> systemAddress - DnsProtocol.DOH -> dohAddress - } - } - - companion object { - fun fromAddress(address: String): DnsProvider { - return entries.find { it.systemAddress == address || it.dohAddress == address } - ?: CLOUDFLARE - } - } -} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/network/KtorClient.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/network/KtorClient.kt index e16061084..372c8f64d 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/network/KtorClient.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/network/KtorClient.kt @@ -1,10 +1,10 @@ package com.zaneschepke.wireguardautotunnel.data.network -import io.ktor.client.* -import io.ktor.client.engine.okhttp.* -import io.ktor.client.plugins.* -import io.ktor.client.plugins.contentnegotiation.* -import io.ktor.serialization.kotlinx.json.* +import io.ktor.client.HttpClient +import io.ktor.client.engine.okhttp.OkHttp +import io.ktor.client.plugins.HttpTimeout +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.serialization.kotlinx.json.json import kotlinx.serialization.json.Json object KtorClient { diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/network/KtorGitHubApi.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/network/KtorGitHubApi.kt index cc326fc12..f63e8581c 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/network/KtorGitHubApi.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/network/KtorGitHubApi.kt @@ -1,11 +1,11 @@ package com.zaneschepke.wireguardautotunnel.data.network import com.zaneschepke.wireguardautotunnel.data.entity.GitHubRelease -import io.ktor.client.* -import io.ktor.client.call.* -import io.ktor.client.plugins.* -import io.ktor.client.request.* -import io.ktor.http.* +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.plugins.ClientRequestException +import io.ktor.client.request.get +import io.ktor.http.HttpStatusCode class KtorGitHubApi(private val client: HttpClient) : GitHubApi { override suspend fun getLatestRelease(owner: String, repo: String): Result { @@ -32,10 +32,9 @@ class KtorGitHubApi(private val client: HttpClient) : GitHubApi { client.get("https://api.github.com/repos/$owner/$repo/releases").body() // Find the first release with "nightly" in the tag_name (case-insensitive) - val nightlyRelease = - releases.firstOrNull { release -> - release.tagName.contains("nightly", ignoreCase = true) - } + val nightlyRelease = releases.firstOrNull { release -> + release.tagName.contains("nightly", ignoreCase = true) + } if (nightlyRelease != null) { Result.success(nightlyRelease) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/repository/InstalledAndroidPackageRepository.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/repository/InstalledAndroidPackageRepository.kt index e72302d85..b3a002a87 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/repository/InstalledAndroidPackageRepository.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/repository/InstalledAndroidPackageRepository.kt @@ -28,25 +28,24 @@ class InstalledAndroidPackageRepository( withContext(ioDispatcher) { val packages = context.packageManager.getInstalledPackages(0) - val installedPackages = - packages.mapNotNull { packageInfo -> - try { - val appInfo = - context.packageManager.getApplicationInfo(packageInfo.packageName, 0) - InstalledPackage( - name = - context.packageManager.getFriendlyAppName( - packageInfo.packageName, - appInfo, - ), - packageName = packageInfo.packageName, - uId = appInfo.uid, - ) - } catch (e: PackageManager.NameNotFoundException) { - Timber.e(e) - null - } + val installedPackages = packages.mapNotNull { packageInfo -> + try { + val appInfo = + context.packageManager.getApplicationInfo(packageInfo.packageName, 0) + InstalledPackage( + name = + context.packageManager.getFriendlyAppName( + packageInfo.packageName, + appInfo, + ), + packageName = packageInfo.packageName, + uId = appInfo.uid, + ) + } catch (e: PackageManager.NameNotFoundException) { + Timber.e(e) + null } + } cachedPackages = installedPackages diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/repository/RoomDnsSettingsRepository.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/repository/RoomDnsSettingsRepository.kt index 0b8b3e9a7..bf2fe5bc5 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/repository/RoomDnsSettingsRepository.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/repository/RoomDnsSettingsRepository.kt @@ -21,4 +21,8 @@ class RoomDnsSettingsRepository(private val dnsSettingsDao: DnsSettingsDao) : override suspend fun getDnsSettings(): Domain { return (dnsSettingsDao.getDnsSettings() ?: Entity()).toDomain() } + + override suspend fun updateGlobalDnsEnabled(enabled: Boolean) { + dnsSettingsDao.updateGlobalDnsEnabled(enabled) + } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/repository/RoomMonitoringSettingsRepository.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/repository/RoomMonitoringSettingsRepository.kt index 073f51056..1e9f9a666 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/repository/RoomMonitoringSettingsRepository.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/repository/RoomMonitoringSettingsRepository.kt @@ -22,4 +22,12 @@ class RoomMonitoringSettingsRepository(private val monitoringSettingsDao: Monito override suspend fun getMonitoringSettings(): Domain { return (monitoringSettingsDao.getMonitoringSettings() ?: Entity()).toDomain() } + + override suspend fun updateStatisticRefresh(statisticRefresh: Int) { + monitoringSettingsDao.updateStatisticsInterval(statisticRefresh) + } + + override suspend fun updateStatisticsEnabled(enabled: Boolean) { + monitoringSettingsDao.updateStatisticsEnabled(enabled) + } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/repository/RoomSettingsRepository.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/repository/RoomSettingsRepository.kt index 007bb9c12..bd8b5e17d 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/repository/RoomSettingsRepository.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/repository/RoomSettingsRepository.kt @@ -4,7 +4,7 @@ import com.zaneschepke.wireguardautotunnel.data.dao.GeneralSettingsDao import com.zaneschepke.wireguardautotunnel.data.entity.GeneralSettings as Entity import com.zaneschepke.wireguardautotunnel.data.mapper.toDomain import com.zaneschepke.wireguardautotunnel.data.mapper.toEntity -import com.zaneschepke.wireguardautotunnel.data.model.AppMode +import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelMode import com.zaneschepke.wireguardautotunnel.domain.model.GeneralSettings as Domain import com.zaneschepke.wireguardautotunnel.domain.repository.GeneralSettingRepository import com.zaneschepke.wireguardautotunnel.ui.theme.Theme @@ -34,7 +34,15 @@ class RoomSettingsRepository(private val settingsDao: GeneralSettingsDao) : settingsDao.updatePinLockEnabled(enabled) } - override suspend fun updateAppMode(appMode: AppMode) { - settingsDao.updateAppMode(appMode) + override suspend fun updateAppMode(tunnelMode: TunnelMode) { + settingsDao.updateAppMode(tunnelMode) + } + + override suspend fun updateGlobalAmneziaEnabled(enabled: Boolean) { + settingsDao.updateGlobalAmneziaEnabled(enabled) + } + + override suspend fun updateScreenRecordingSecurity(enabled: Boolean) { + settingsDao.updateScreenRecordingSecurity(enabled) } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/repository/RoomTunnelRepository.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/repository/RoomTunnelRepository.kt index 2c5b9bb6a..9e285cd36 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/repository/RoomTunnelRepository.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/repository/RoomTunnelRepository.kt @@ -6,6 +6,7 @@ import com.zaneschepke.wireguardautotunnel.data.mapper.toEntity import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConfig as Domain import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.map class RoomTunnelRepository(private val tunnelConfigDao: TunnelConfigDao) : TunnelRepository { @@ -25,6 +26,14 @@ class RoomTunnelRepository(private val tunnelConfigDao: TunnelConfigDao) : Tunne return tunnelConfigDao.getAll().map { it.toDomain() } } + override suspend fun setMetered(tunnelId: Int, value: Boolean) { + tunnelConfigDao.setMetered(tunnelId, value) + } + + override suspend fun setDynamicDns(tunnelId: Int, value: Boolean) { + tunnelConfigDao.setDynamicDns(tunnelId, value) + } + override suspend fun save(tunnelConfig: Domain) { tunnelConfigDao.upsert(tunnelConfig.toEntity()) } @@ -38,10 +47,6 @@ class RoomTunnelRepository(private val tunnelConfigDao: TunnelConfigDao) : Tunne tunnelConfig?.let { save(it.copy(isPrimaryTunnel = true)) } } - override suspend fun resetActiveTunnels() { - tunnelConfigDao.resetActiveTunnels() - } - override suspend fun updateMobileDataTunnel(tunnelConfig: Domain?) { tunnelConfigDao.resetMobileDataTunnel() tunnelConfig?.let { save(it.copy(isMobileDataTunnel = true)) } @@ -60,18 +65,10 @@ class RoomTunnelRepository(private val tunnelConfigDao: TunnelConfigDao) : Tunne return tunnelConfigDao.getById(id.toLong())?.toDomain() } - override suspend fun getActive(): List { - return tunnelConfigDao.getActive().map { it.toDomain() } - } - override suspend fun getDefaultTunnel(): Domain? { return tunnelConfigDao.getDefaultTunnel()?.toDomain() } - override suspend fun getStartTunnel(): Domain? { - return tunnelConfigDao.getStartTunnel()?.toDomain() - } - override suspend fun count(): Int { return tunnelConfigDao.count().toInt() } @@ -95,4 +92,10 @@ class RoomTunnelRepository(private val tunnelConfigDao: TunnelConfigDao) : Tunne override suspend fun delete(tunnels: List) { tunnelConfigDao.delete(tunnels.map { it.toEntity() }) } + + override suspend fun ensureGlobalConfigExists() { + if (globalTunnelFlow.firstOrNull() == null) { + save(Domain.generateDefaultGlobalConfig()) + } + } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/di/AppModule.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/di/AppModule.kt index ae94a7cec..f6ae5fd67 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/di/AppModule.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/di/AppModule.kt @@ -4,19 +4,31 @@ import android.content.Context import android.os.PowerManager import com.zaneschepke.logcatter.LogReader import com.zaneschepke.logcatter.LogcatReader -import com.zaneschepke.wireguardautotunnel.core.notification.NotificationManager -import com.zaneschepke.wireguardautotunnel.core.notification.NotificationMonitor -import com.zaneschepke.wireguardautotunnel.core.notification.WireGuardNotification +import com.zaneschepke.wireguardautotunnel.core.notification.AndroidNotificationService +import com.zaneschepke.wireguardautotunnel.core.notification.NotificationService import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager +import com.zaneschepke.wireguardautotunnel.core.service.autotunnel.AutoTunnelStateHolder import com.zaneschepke.wireguardautotunnel.core.shortcut.DynamicShortcutManager import com.zaneschepke.wireguardautotunnel.core.shortcut.ShortcutManager import com.zaneschepke.wireguardautotunnel.domain.repository.GlobalEffectRepository import com.zaneschepke.wireguardautotunnel.domain.repository.SelectedTunnelsRepository import com.zaneschepke.wireguardautotunnel.util.FileUtils import com.zaneschepke.wireguardautotunnel.util.network.NetworkUtils -import com.zaneschepke.wireguardautotunnel.viewmodel.* -import kotlinx.coroutines.CoroutineDispatcher +import com.zaneschepke.wireguardautotunnel.viewmodel.AutoTunnelViewModel +import com.zaneschepke.wireguardautotunnel.viewmodel.ConfigEditViewModel +import com.zaneschepke.wireguardautotunnel.viewmodel.DnsViewModel +import com.zaneschepke.wireguardautotunnel.viewmodel.LicenseViewModel +import com.zaneschepke.wireguardautotunnel.viewmodel.LockdownViewModel +import com.zaneschepke.wireguardautotunnel.viewmodel.LoggerViewModel +import com.zaneschepke.wireguardautotunnel.viewmodel.MonitoringViewModel +import com.zaneschepke.wireguardautotunnel.viewmodel.ProxySettingsViewModel +import com.zaneschepke.wireguardautotunnel.viewmodel.SettingsViewModel +import com.zaneschepke.wireguardautotunnel.viewmodel.SharedAppViewModel +import com.zaneschepke.wireguardautotunnel.viewmodel.SplitTunnelViewModel +import com.zaneschepke.wireguardautotunnel.viewmodel.SupportViewModel +import com.zaneschepke.wireguardautotunnel.viewmodel.TunnelViewModel import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import org.koin.android.ext.koin.androidContext import org.koin.core.annotation.KoinExperimentalAPI @@ -30,25 +42,15 @@ import org.koin.dsl.module @OptIn(KoinExperimentalAPI::class) val appModule = module { single(named(Scope.APPLICATION)) { - CoroutineScope(SupervisorJob() + get(named(Dispatcher.DEFAULT))) + CoroutineScope(SupervisorJob() + Dispatchers.Default) } - single { LogcatReader.init(storageDir = androidContext().filesDir.absolutePath) } single { androidContext().getSystemService(Context.POWER_SERVICE) as PowerManager } - singleOf(::NotificationMonitor) - singleOf(::WireGuardNotification) bind NotificationManager::class - single { - ServiceManager( - androidContext(), - get(named(Dispatcher.IO)), - get(named(Scope.APPLICATION)), - get(named(Dispatcher.MAIN)), - get(), - ) - } + singleOf(::AndroidNotificationService) bind NotificationService::class + single { ServiceManager(androidContext()) } singleOf(::GlobalEffectRepository) @@ -59,7 +61,7 @@ val appModule = module { single { NetworkUtils(get(named(Dispatcher.IO))) } viewModelOf(::AutoTunnelViewModel) - viewModel { (id: Int?) -> ConfigViewModel(get(), get(), get(), id) } + viewModel { (id: Int?) -> ConfigEditViewModel(get(), get(), get(), get(), get(), id) } viewModelOf(::DnsViewModel) viewModelOf(::LicenseViewModel) viewModelOf(::LockdownViewModel) @@ -71,4 +73,6 @@ val appModule = module { viewModel { (id: Int) -> SplitTunnelViewModel(get(), get(), get(), id) } viewModel { SupportViewModel(get(), get(named(Dispatcher.MAIN)), get()) } viewModel { (id: Int) -> TunnelViewModel(get(), get(), id) } + + singleOf(::AutoTunnelStateHolder) } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/di/CoordinatorModule.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/di/CoordinatorModule.kt new file mode 100644 index 000000000..1ed0070df --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/di/CoordinatorModule.kt @@ -0,0 +1,31 @@ +package com.zaneschepke.wireguardautotunnel.di + +import com.zaneschepke.wireguardautotunnel.core.orchestration.AppBoostrapCoordinator +import com.zaneschepke.wireguardautotunnel.core.orchestration.AutoTunnelCoordinator +import com.zaneschepke.wireguardautotunnel.core.orchestration.ShortcutCoordinator +import com.zaneschepke.wireguardautotunnel.core.orchestration.StartupCoordinator +import com.zaneschepke.wireguardautotunnel.core.orchestration.TunnelCoordinator +import com.zaneschepke.wireguardautotunnel.core.orchestration.TunnelModeCoordinator +import org.koin.core.module.dsl.singleOf +import org.koin.core.qualifier.named +import org.koin.dsl.module + +val coordinatorModule = module { + singleOf(::ShortcutCoordinator) + singleOf(::TunnelModeCoordinator) + singleOf(::StartupCoordinator) + singleOf(::AutoTunnelCoordinator) + single { + TunnelCoordinator( + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(named(Scope.APPLICATION)), + ) + } + singleOf(::AppBoostrapCoordinator) +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/di/Qualifiers.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/di/Qualifiers.kt index c8746b4bf..5df84146b 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/di/Qualifiers.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/di/Qualifiers.kt @@ -12,14 +12,3 @@ enum class Dispatcher { enum class Scope { APPLICATION } - -enum class Shell { - APP, - TUNNEL, -} - -enum class Core { - KERNEL, - PROXY_USERSPACE, - USERSPACE, -} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/di/TunnelModule.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/di/TunnelModule.kt index 84084db26..922f2b767 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/di/TunnelModule.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/di/TunnelModule.kt @@ -1,108 +1,106 @@ package com.zaneschepke.wireguardautotunnel.di -import com.wireguard.android.backend.WgQuickBackend -import com.wireguard.android.util.RootShell -import com.wireguard.android.util.ToolsInstaller +import android.app.Notification import com.zaneschepke.networkmonitor.AndroidNetworkMonitor import com.zaneschepke.networkmonitor.NetworkMonitor -import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager -import com.zaneschepke.wireguardautotunnel.core.tunnel.backend.KernelTunnel -import com.zaneschepke.wireguardautotunnel.core.tunnel.backend.RunConfigHelper -import com.zaneschepke.wireguardautotunnel.core.tunnel.backend.TunnelBackend -import com.zaneschepke.wireguardautotunnel.core.tunnel.backend.UserspaceTunnel +import com.zaneschepke.networkmonitor.StableNetworkEngine +import com.zaneschepke.tunnel.NotificationProvider +import com.zaneschepke.tunnel.backend.RootShell +import com.zaneschepke.tunnel.util.RootShellException +import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.core.event.TunnelEventDispatcher +import com.zaneschepke.wireguardautotunnel.core.notification.AndroidNotificationService.NotificationChannels +import com.zaneschepke.wireguardautotunnel.core.notification.AndroidTunnelNotificationService +import com.zaneschepke.wireguardautotunnel.core.notification.NotificationService +import com.zaneschepke.wireguardautotunnel.core.notification.NotificationService.Companion.PROXY_GROUP_KEY +import com.zaneschepke.wireguardautotunnel.core.notification.NotificationService.Companion.VPN_GROUP_KEY +import com.zaneschepke.wireguardautotunnel.core.notification.TunnelNotificationService +import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelBackendProvider +import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelProvider import com.zaneschepke.wireguardautotunnel.domain.repository.AutoTunnelSettingsRepository -import com.zaneschepke.wireguardautotunnel.util.RootShellUtils import com.zaneschepke.wireguardautotunnel.util.extensions.to import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.map -import org.amnezia.awg.backend.Backend -import org.amnezia.awg.backend.GoBackend -import org.amnezia.awg.backend.ProxyGoBackend -import org.amnezia.awg.backend.RootTunnelActionHandler +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout import org.koin.android.ext.koin.androidContext import org.koin.core.module.dsl.singleOf import org.koin.core.qualifier.named import org.koin.dsl.module +import timber.log.Timber -val tunnelModule = module { - single(named(Shell.TUNNEL)) { RootShell(androidContext()) } - single(named(Shell.APP)) { RootShell(androidContext()) } +val tunnelBackendProviderModule = module { + single { AndroidTunnelNotificationService(get(), get()) } + singleOf(::TunnelEventDispatcher) - single { RootShellUtils(get(named(Shell.APP)), get(named(Dispatcher.IO))) } + single { + val notificationService = get() + val context = androidContext() + object : NotificationProvider { + override val vpnInitNotification: Notification + get() = + notificationService.createNotification( + channel = NotificationChannels.VPN, + title = context.getString(R.string.initializing), + onGoing = true, + groupKey = VPN_GROUP_KEY, + ) - singleOf(::RunConfigHelper) + override val proxyInitNotification: Notification + get() = + notificationService.createNotification( + channel = NotificationChannels.PROXY, + title = context.getString(R.string.initializing), + onGoing = true, + groupKey = PROXY_GROUP_KEY, + ) - single(named(Core.USERSPACE)) { - GoBackend( - androidContext(), - RootTunnelActionHandler(org.amnezia.awg.util.RootShell(androidContext())), - ) - } - - single(named(Core.PROXY_USERSPACE)) { - ProxyGoBackend( - androidContext(), - RootTunnelActionHandler(org.amnezia.awg.util.RootShell(androidContext())), - ) - } - - single { - val shell = get(named(Shell.TUNNEL)) - WgQuickBackend( - androidContext(), - shell, - ToolsInstaller(androidContext(), shell), - com.wireguard.android.backend.RootTunnelActionHandler(shell), - ) - .apply { setMultipleTunnels(true) } - } + override val vpnNotificationId: Int + get() = NotificationService.VPN_NOTIFICATION_ID - single(named(Core.KERNEL)) { - KernelTunnel(get(), get()) + override val proxyNotificationId: Int + get() = NotificationService.PROXY_NOTIFICATION_ID + } } - single(qualifier = named(Core.USERSPACE)) { - UserspaceTunnel(get(named(Core.USERSPACE)), get()) - } - - single(qualifier = named(Core.PROXY_USERSPACE)) { - UserspaceTunnel(get(named(Core.PROXY_USERSPACE)), get()) + single { + StableNetworkEngine( + get(named(Scope.APPLICATION)), + get().connectivityStateFlow, + ) } single { AndroidNetworkMonitor( androidContext(), object : AndroidNetworkMonitor.ConfigurationListener { + override suspend fun runRootShellCommand(cmd: String): String? { + return try { + withTimeout(3_000) { + withContext(Dispatchers.IO) { + val result = RootShell.run(cmd) + result.output + } + } + } catch (e: RootShellException) { + Timber.e(e) + null + } + } + override val detectionMethod = get() .flow .distinctUntilChangedBy { it.wifiDetectionMethod } .map { it.wifiDetectionMethod.to() } - - override val rootShell = get(named(Shell.APP)) }, get(named(Scope.APPLICATION)), ) } - single { - TunnelManager( - get(named(Core.KERNEL)), - get(named(Core.USERSPACE)), - get(named(Core.PROXY_USERSPACE)), - get(), - get(), - get(), - get(), - get(), - get(), - get(), - get(), - get(), - get(), - get(named(Scope.APPLICATION)), - get(named(Dispatcher.IO)), - ) + single { + TunnelBackendProvider(get(), get(named(Scope.APPLICATION)), get(named(Dispatcher.IO))) } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/enums/DnsProtocol.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/enums/DnsProtocol.kt new file mode 100644 index 000000000..bab3900e6 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/enums/DnsProtocol.kt @@ -0,0 +1,24 @@ +package com.zaneschepke.wireguardautotunnel.domain.enums + +import android.content.Context +import com.zaneschepke.wireguardautotunnel.R + +enum class DnsProtocol(val value: Int) { + SYSTEM(0), + DOH(1), + DOT(2), + UDP(3); + + fun asString(context: Context): String { + return when (this) { + SYSTEM -> context.getString(R.string.system) + DOH -> context.getString(R.string.doh) + DOT -> context.getString(R.string.dot) + UDP -> context.getString(R.string.plain_dns) + } + } + + companion object { + fun fromValue(value: Int): DnsProtocol = entries.find { it.value == value } ?: SYSTEM + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/enums/MimicMode.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/enums/MimicMode.kt new file mode 100644 index 000000000..1ecea58cf --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/enums/MimicMode.kt @@ -0,0 +1,7 @@ +package com.zaneschepke.wireguardautotunnel.domain.enums + +enum class MimicMode { + QUIC, + DNS, + SIP, +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/enums/StatisticRefresh.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/enums/StatisticRefresh.kt new file mode 100644 index 000000000..f3271f00d --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/enums/StatisticRefresh.kt @@ -0,0 +1,22 @@ +package com.zaneschepke.wireguardautotunnel.domain.enums + +import android.content.Context +import com.zaneschepke.wireguardautotunnel.R + +enum class StatisticRefresh(val value: Int) { + LIVE(1), + BALANCED(3), + BATTERY_SAVER(10); + + fun asString(context: Context): String { + return when (this) { + LIVE -> context.getString(R.string.live) + BALANCED -> context.getString(R.string.balanced) + BATTERY_SAVER -> context.getString(R.string.balance_saver) + } + } + + companion object { + fun fromValue(value: Int): StatisticRefresh = entries.find { it.value == value } ?: BALANCED + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/enums/TunnelActionSource.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/enums/TunnelActionSource.kt new file mode 100644 index 000000000..7e67ddb14 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/enums/TunnelActionSource.kt @@ -0,0 +1,6 @@ +package com.zaneschepke.wireguardautotunnel.domain.enums + +enum class TunnelActionSource { + USER, + AUTO_TUNNEL, +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/enums/TunnelMode.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/enums/TunnelMode.kt new file mode 100644 index 000000000..270291fa3 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/enums/TunnelMode.kt @@ -0,0 +1,11 @@ +package com.zaneschepke.wireguardautotunnel.domain.enums + +enum class TunnelMode(val value: Int) { + VPN(0), + PROXY(1), + LOCK_DOWN(2); + + companion object { + fun fromValue(value: Int): TunnelMode = entries.find { it.value == value } ?: VPN + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/enums/TunnelStatus.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/enums/TunnelStatus.kt deleted file mode 100644 index 21bfe8a1d..000000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/enums/TunnelStatus.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.domain.enums - -sealed class TunnelStatus { - - data class Up(val startTime: Long) : TunnelStatus() - - data object Down : TunnelStatus() - - data object Stopping : TunnelStatus() - - data object Starting : TunnelStatus() - - fun isDown(): Boolean { - return this == Down - } - - fun isUp(): Boolean { - return this is Up - } - - fun isUpOrStarting(): Boolean { - return this is Up || this == Starting - } - - fun isDownOrStopping(): Boolean { - return this == Down || this is Stopping - } -} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/model/WifiDetectionMethod.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/enums/WifiDetectionMethod.kt similarity index 86% rename from app/src/main/java/com/zaneschepke/wireguardautotunnel/data/model/WifiDetectionMethod.kt rename to app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/enums/WifiDetectionMethod.kt index 4fb8cc6c2..81573b260 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/model/WifiDetectionMethod.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/enums/WifiDetectionMethod.kt @@ -1,4 +1,4 @@ -package com.zaneschepke.wireguardautotunnel.data.model +package com.zaneschepke.wireguardautotunnel.domain.enums enum class WifiDetectionMethod(val value: Int) { DEFAULT(0), diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/events/AutoTunnelEvent.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/events/AutoTunnelEvent.kt index b81d7d9aa..baa79f5e6 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/events/AutoTunnelEvent.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/events/AutoTunnelEvent.kt @@ -1,12 +1,10 @@ package com.zaneschepke.wireguardautotunnel.domain.events -import androidx.annotation.Keep import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConfig -sealed class AutoTunnelEvent { - @Keep data class Start(val tunnelConfig: TunnelConfig? = null) : AutoTunnelEvent() +sealed interface AutoTunnelEvent { - @Keep data object Stop : AutoTunnelEvent() + data class Sync(val start: Set, val stop: Set) : AutoTunnelEvent - @Keep data object DoNothing : AutoTunnelEvent() + data object DoNothing : AutoTunnelEvent } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/events/TunnelActionEvent.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/events/TunnelActionEvent.kt new file mode 100644 index 000000000..c35dc6fc7 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/events/TunnelActionEvent.kt @@ -0,0 +1,10 @@ +package com.zaneschepke.wireguardautotunnel.domain.events + +import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelActionSource + +sealed interface TunnelActionEvent { + + data class Started(val tunnelId: Int, val source: TunnelActionSource) : TunnelActionEvent + + data class Stopped(val tunnelId: Int, val source: TunnelActionSource) : TunnelActionEvent +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/model/AutoTunnelSettings.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/model/AutoTunnelSettings.kt index dba4a72f0..6234141c2 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/model/AutoTunnelSettings.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/model/AutoTunnelSettings.kt @@ -1,6 +1,6 @@ package com.zaneschepke.wireguardautotunnel.domain.model -import com.zaneschepke.wireguardautotunnel.data.model.WifiDetectionMethod +import com.zaneschepke.wireguardautotunnel.domain.enums.WifiDetectionMethod data class AutoTunnelSettings( val id: Int = 0, @@ -11,7 +11,6 @@ data class AutoTunnelSettings( val isTunnelOnWifiEnabled: Boolean = false, val isWildcardsEnabled: Boolean = false, val isStopOnNoInternetEnabled: Boolean = false, - val debounceDelaySeconds: Int = 3, val isTunnelOnUnsecureEnabled: Boolean = false, val wifiDetectionMethod: WifiDetectionMethod = WifiDetectionMethod.fromValue(0), val startOnBoot: Boolean = false, diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/model/DnsSettings.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/model/DnsSettings.kt index 1e37a64e8..73c4cc9b6 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/model/DnsSettings.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/model/DnsSettings.kt @@ -1,6 +1,6 @@ package com.zaneschepke.wireguardautotunnel.domain.model -import com.zaneschepke.wireguardautotunnel.data.model.DnsProtocol +import com.zaneschepke.wireguardautotunnel.domain.enums.DnsProtocol data class DnsSettings( val id: Int = 0, diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/model/GeneralSettings.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/model/GeneralSettings.kt index 26501138f..00619acae 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/model/GeneralSettings.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/model/GeneralSettings.kt @@ -1,6 +1,6 @@ package com.zaneschepke.wireguardautotunnel.domain.model -import com.zaneschepke.wireguardautotunnel.data.model.AppMode +import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelMode import com.zaneschepke.wireguardautotunnel.ui.theme.Theme data class GeneralSettings( @@ -9,7 +9,7 @@ data class GeneralSettings( val isRestoreOnBootEnabled: Boolean = false, val isMultiTunnelEnabled: Boolean = false, val isGlobalSplitTunnelEnabled: Boolean = false, - val appMode: AppMode = AppMode.fromValue(0), + val tunnelMode: TunnelMode = TunnelMode.fromValue(0), val theme: Theme = Theme.AUTOMATIC, val locale: String? = null, val remoteKey: String? = null, @@ -18,4 +18,7 @@ data class GeneralSettings( val isAlwaysOnVpnEnabled: Boolean = false, val isKillSwitchMetered: Boolean = true, val alreadyDonated: Boolean = false, + val screenRecordingSecurityEnabled: Boolean = true, + val isGlobalAmneziaEnabled: Boolean = false, + val tunnelScriptingEnabled: Boolean = true, ) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/model/LockdownSettings.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/model/LockdownSettings.kt index 6d43a4109..533c21a2e 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/model/LockdownSettings.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/model/LockdownSettings.kt @@ -1,8 +1,18 @@ package com.zaneschepke.wireguardautotunnel.domain.model +import com.zaneschepke.tunnel.model.KillSwitchConfig + data class LockdownSettings( val id: Long = 0L, val bypassLan: Boolean = false, val metered: Boolean = false, val dualStack: Boolean = false, -) +) { + fun toKillSwitchConfig(): KillSwitchConfig { + return KillSwitchConfig( + allowedIps = if (bypassLan) TunnelConfig.LAN_BYPASS_ALLOWED_IPS else emptySet(), + metered = metered, + dualStack = dualStack, + ) + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/model/MonitoringSettings.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/model/MonitoringSettings.kt index 627df646b..5d8876678 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/model/MonitoringSettings.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/model/MonitoringSettings.kt @@ -2,11 +2,7 @@ package com.zaneschepke.wireguardautotunnel.domain.model data class MonitoringSettings( val id: Int = 0, - val isPingEnabled: Boolean = false, - val isPingMonitoringEnabled: Boolean = true, - val tunnelPingIntervalSeconds: Int = 30, - val tunnelPingAttempts: Int = 3, - val tunnelPingTimeoutSeconds: Int? = null, - val showDetailedPingStats: Boolean = false, val isLocalLogsEnabled: Boolean = false, + val tunnelStatisticsEnabled: Boolean = true, + val tunnelStatisticsPollInterval: Int = 3, ) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/model/ProxySettings.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/model/ProxySettings.kt index 8958fca4f..acf8a7a48 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/model/ProxySettings.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/model/ProxySettings.kt @@ -1,5 +1,7 @@ package com.zaneschepke.wireguardautotunnel.domain.model +import com.zaneschepke.tunnel.model.ProxyConfig + data class ProxySettings( val id: Long = 0, val socks5ProxyEnabled: Boolean = false, @@ -9,6 +11,47 @@ data class ProxySettings( val proxyUsername: String? = null, val proxyPassword: String? = null, ) { + + fun toProxyConfig(): ProxyConfig { + val socks5 = + if (socks5ProxyEnabled) { + parseAddress(socks5ProxyBindAddress ?: DEFAULT_SOCKS_BIND_ADDRESS)?.let { + (host, port) -> + ProxyConfig.Socks5( + host = host, + port = port, + username = proxyUsername, + password = proxyPassword, + ) + } + } else null + + val http = + if (httpProxyEnabled) { + parseAddress(httpProxyBindAddress ?: DEFAULT_HTTP_BIND_ADDRESS)?.let { (host, port) + -> + ProxyConfig.Http( + host = host, + port = port, + username = proxyUsername, + password = proxyPassword, + ) + } + } else null + + return ProxyConfig(socks5 = socks5, http = http) + } + + private fun parseAddress(address: String): Pair? { + val parts = address.split(":") + if (parts.size != 2) return null + + val host = parts[0] + val port = parts[1].toIntOrNull() ?: return null + + return host to port + } + companion object { const val DEFAULT_SOCKS_BIND_ADDRESS = "127.0.0.1:25344" const val DEFAULT_HTTP_BIND_ADDRESS = "127.0.0.1:25345" diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/model/TunnelConfig.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/model/TunnelConfig.kt index 7ab0f1f0d..76b6becab 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/model/TunnelConfig.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/model/TunnelConfig.kt @@ -1,216 +1,110 @@ package com.zaneschepke.wireguardautotunnel.domain.model -import com.wireguard.config.Config +import com.zaneschepke.tunnel.Tunnel import com.zaneschepke.wireguardautotunnel.data.entity.TunnelConfig.Companion.GLOBAL_CONFIG_NAME +import com.zaneschepke.wireguardautotunnel.parser.Config +import com.zaneschepke.wireguardautotunnel.parser.InterfaceSection +import com.zaneschepke.wireguardautotunnel.parser.PeerSection +import com.zaneschepke.wireguardautotunnel.parser.crypto.Key +import com.zaneschepke.wireguardautotunnel.ui.state.TunnelSummary import com.zaneschepke.wireguardautotunnel.util.extensions.defaultName -import com.zaneschepke.wireguardautotunnel.util.extensions.isValidIpv4orIpv6Address -import java.io.InputStream -import java.nio.charset.StandardCharsets -import org.amnezia.awg.config.InetEndpoint -import org.amnezia.awg.config.InetNetwork -import org.amnezia.awg.config.Interface -import org.amnezia.awg.config.Peer -import org.amnezia.awg.crypto.KeyPair data class TunnelConfig( val id: Int = 0, val name: String, - val wgQuick: String, val tunnelNetworks: Set = setOf(), val isMobileDataTunnel: Boolean = false, val isPrimaryTunnel: Boolean = false, - val amQuick: String = "", - val isActive: Boolean = false, - val restartOnPingFailure: Boolean = false, - var pingTarget: String? = null, + val quickConfig: String = "", + val dynamicDnsEnabled: Boolean = false, + val pingTarget: String? = null, val isEthernetTunnel: Boolean = false, - val isIpv4Preferred: Boolean = true, + val isIpv6Preferred: Boolean = false, val position: Int = 0, val autoTunnelApps: Set = setOf(), val isMetered: Boolean = false, + val ipv4FallbackEnabled: Boolean = false, + val ipv6RestoreEnabled: Boolean = false, ) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other !is TunnelConfig) return false - return id == other.id && - name == other.name && - wgQuick == other.wgQuick && - amQuick == other.amQuick && - isPrimaryTunnel == other.isPrimaryTunnel && - isMobileDataTunnel == other.isMobileDataTunnel && - isEthernetTunnel == other.isEthernetTunnel && - pingTarget == other.pingTarget && - restartOnPingFailure == other.restartOnPingFailure && - tunnelNetworks == other.tunnelNetworks && - isIpv4Preferred == other.isIpv4Preferred && - isMetered == other.isMetered - } - - override fun hashCode(): Int { - var result = id - result = 31 * result + name.hashCode() - result = 31 * result + wgQuick.hashCode() - result = 31 * result + amQuick.hashCode() - return result - } - - fun isStaticallyConfigured(): Boolean { - return toAmConfig().peers.all { it.endpoint.get().host.isValidIpv4orIpv6Address() } - } - - fun toAmConfig(): org.amnezia.awg.config.Config { - return configFromAmQuick(amQuick.ifBlank { wgQuick }) - } + fun toSummary() = TunnelSummary(id = id, name = name) - fun toWgConfig(): Config { - return configFromWgQuick(wgQuick) + fun getConfig(): Config { + return Config.parseQuickString(quickConfig) } - fun copyWithGlobalValues( - globalTunnel: TunnelConfig, - includeDns: Boolean, - includeSpitTunneling: Boolean, - ): TunnelConfig { - val existingConfig = toAmConfig() - val globalConfig = globalTunnel.toAmConfig() - - val newInterfaceBuilder = - Interface.Builder().apply { - setKeyPair(existingConfig.`interface`.keyPair) - setAddresses(existingConfig.`interface`.addresses) - setDnsServers(existingConfig.`interface`.dnsServers) - setDnsSearchDomains(existingConfig.`interface`.dnsSearchDomains) - setExcludedApplications(existingConfig.`interface`.excludedApplications) - setIncludedApplications(existingConfig.`interface`.includedApplications) - existingConfig.`interface`.listenPort.ifPresent { setListenPort(it) } - existingConfig.`interface`.mtu.ifPresent { setMtu(it) } - existingConfig.`interface`.junkPacketCount.ifPresent { setJunkPacketCount(it) } - existingConfig.`interface`.junkPacketMinSize.ifPresent { setJunkPacketMinSize(it) } - existingConfig.`interface`.junkPacketMaxSize.ifPresent { setJunkPacketMaxSize(it) } - existingConfig.`interface`.initPacketJunkSize.ifPresent { - setInitPacketJunkSize(it) - } - existingConfig.`interface`.responsePacketJunkSize.ifPresent { - setResponsePacketJunkSize(it) - } - existingConfig.`interface`.initPacketMagicHeader.ifPresent { - setInitPacketMagicHeader(it) - } - existingConfig.`interface`.responsePacketMagicHeader.ifPresent { - setResponsePacketMagicHeader(it) - } - existingConfig.`interface`.underloadPacketMagicHeader.ifPresent { - setUnderloadPacketMagicHeader(it) - } - existingConfig.`interface`.transportPacketMagicHeader.ifPresent { - setTransportPacketMagicHeader(it) - } - existingConfig.`interface`.cookieReplyPacketJunkSize.ifPresent { - setCookieReplyPacketJunkSize(it) - } - existingConfig.`interface`.transportPacketJunkSize.ifPresent { - setTransportPacketJunkSize(it) - } - existingConfig.`interface`.specialJunkI1.ifPresent { setSpecialJunkI1(it) } - existingConfig.`interface`.specialJunkI2.ifPresent { setSpecialJunkI2(it) } - existingConfig.`interface`.specialJunkI3.ifPresent { setSpecialJunkI3(it) } - existingConfig.`interface`.specialJunkI4.ifPresent { setSpecialJunkI4(it) } - existingConfig.`interface`.specialJunkI5.ifPresent { setSpecialJunkI5(it) } - setPreUp(existingConfig.`interface`.preUp) - setPostUp(existingConfig.`interface`.postUp) - setPreDown(existingConfig.`interface`.preDown) - setPostDown(existingConfig.`interface`.postDown) - - if (includeDns) { - setDnsServers(globalConfig.`interface`.dnsServers) - setDnsSearchDomains(globalConfig.`interface`.dnsSearchDomains) - } - if (includeSpitTunneling) { - setExcludedApplications(globalConfig.`interface`.excludedApplications) - setIncludedApplications(globalConfig.`interface`.includedApplications) + val isGlobalConfig: Boolean + get() = name == GLOBAL_CONFIG_NAME + + fun toBackendTunnel(monitoringSettings: MonitoringSettings, scriptsEnabled: Boolean): Tunnel = + BackendTunnel(this, monitoringSettings, scriptsEnabled) + + private class BackendTunnel( + private val config: TunnelConfig, + private val monitoringSettings: MonitoringSettings, + override val scriptsEnabled: Boolean, + ) : Tunnel { + + override val id: Int + get() = config.id + + override val name: String + get() = config.name + + override val isMetered: Boolean + get() = config.isMetered + + override val ipStrategy: Tunnel.IpStrategy + get() = + if (config.isIpv6Preferred) + Tunnel.IpStrategy.PreferIpv6( + fallbackToIpv4Enabled = config.ipv4FallbackEnabled, + recoveryEnabled = config.ipv6RestoreEnabled, + ) + else Tunnel.IpStrategy.Ipv4Only + + override val features: Set + get() = buildSet { + if (monitoringSettings.tunnelStatisticsEnabled) { + add( + Tunnel.Feature.ActiveConfigMonitor( + monitoringSettings.tunnelStatisticsPollInterval + ) + ) } - } - val newInterface = newInterfaceBuilder.build() - val newConfigBuilder = - org.amnezia.awg.config.Config.Builder().apply { - setInterface(newInterface) - addPeers(existingConfig.peers) + if (config.dynamicDnsEnabled) { + add(Tunnel.Feature.DynamicDNS) + } } - val newAmConfig = newConfigBuilder.build() - return copy( - wgQuick = newAmConfig.toWgQuickString(true), - amQuick = newAmConfig.toAwgQuickString(true, false), - ) + override fun updateState(state: Tunnel.State) = Unit } companion object { - fun configFromWgQuick(wgQuick: String): Config { - val inputStream: InputStream = wgQuick.byteInputStream() - return inputStream.bufferedReader(StandardCharsets.UTF_8).use { Config.parse(it) } - } - - fun configFromAmQuick(amQuick: String): org.amnezia.awg.config.Config { - val inputStream: InputStream = amQuick.byteInputStream() - return inputStream.bufferedReader(StandardCharsets.UTF_8).use { - org.amnezia.awg.config.Config.parse(it) - } - } fun tunnelConfFromQuick(amQuick: String, name: String? = null): TunnelConfig { - val config = configFromAmQuick(amQuick) - val wgQuick = config.toWgQuickString(true) - return TunnelConfig( - name = name ?: config.defaultName(), - wgQuick = wgQuick, - amQuick = amQuick, - ) - } - - private fun tunnelConfFromAmConfig( - config: org.amnezia.awg.config.Config, - name: String? = null, - ): TunnelConfig { - val amQuick = config.toAwgQuickString(true, false) - val wgQuick = config.toWgQuickString(true) - return TunnelConfig( - name = name ?: config.defaultName(), - wgQuick = wgQuick, - amQuick = amQuick, - ) + val config = Config.parseQuickString(amQuick) + return TunnelConfig(name = name ?: config.defaultName(), quickConfig = amQuick) } fun generateDefaultGlobalConfig(): TunnelConfig { - val keyPair = KeyPair() + val privateKey: String = Key.generatePrivateKey().toBase64() + val publicKey = Config.generatePublicKeyFromPrivateKey(privateKey) val config = - org.amnezia.awg.config.Config.Builder() - .apply { - setInterface( - Interface.Builder() - .apply { - setKeyPair(keyPair) - parseAddresses("10.0.0.2/32") - } - .build() - ) - addPeer( - Peer.Builder() - .apply { - setPublicKey(keyPair.publicKey) - addAllowedIps(listOf(InetNetwork.parse("0.0.0.0/0"))) - setEndpoint(InetEndpoint.parse("server.example.com:51820")) - } - .build() - ) - } - .build() - return TunnelConfig( - name = GLOBAL_CONFIG_NAME, - amQuick = config.toAwgQuickString(false, false), - wgQuick = config.toWgQuickString(false), - ) + Config( + `interface` = + InterfaceSection(address = "10.0.0.2/32", privateKey = privateKey), + peers = + listOf( + PeerSection( + publicKey = publicKey, + endpoint = "server.example.com:51820", + allowedIPs = "0.0.0.0/0", + ) + ), + ) + return TunnelConfig(name = GLOBAL_CONFIG_NAME, quickConfig = config.asQuickString()) } private const val IPV6_ALL_NETWORKS = "::/0" diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/repository/DnsSettingsRepository.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/repository/DnsSettingsRepository.kt index 85392296d..282ad5608 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/repository/DnsSettingsRepository.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/repository/DnsSettingsRepository.kt @@ -9,4 +9,6 @@ interface DnsSettingsRepository { val flow: Flow suspend fun getDnsSettings(): DnsSettings + + suspend fun updateGlobalDnsEnabled(enabled: Boolean) } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/repository/GeneralSettingRepository.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/repository/GeneralSettingRepository.kt index 3abff95a6..9a3620184 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/repository/GeneralSettingRepository.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/repository/GeneralSettingRepository.kt @@ -1,6 +1,6 @@ package com.zaneschepke.wireguardautotunnel.domain.repository -import com.zaneschepke.wireguardautotunnel.data.model.AppMode +import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelMode import com.zaneschepke.wireguardautotunnel.domain.model.GeneralSettings import com.zaneschepke.wireguardautotunnel.ui.theme.Theme import kotlinx.coroutines.flow.Flow @@ -18,5 +18,9 @@ interface GeneralSettingRepository { suspend fun updatePinLockEnabled(enabled: Boolean) - suspend fun updateAppMode(appMode: AppMode) + suspend fun updateAppMode(tunnelMode: TunnelMode) + + suspend fun updateGlobalAmneziaEnabled(enabled: Boolean) + + suspend fun updateScreenRecordingSecurity(enabled: Boolean) } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/repository/MonitoringSettingsRepository.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/repository/MonitoringSettingsRepository.kt index c14c27121..ce90b4a69 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/repository/MonitoringSettingsRepository.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/repository/MonitoringSettingsRepository.kt @@ -9,4 +9,8 @@ interface MonitoringSettingsRepository { val flow: Flow suspend fun getMonitoringSettings(): MonitoringSettings + + suspend fun updateStatisticRefresh(statisticRefresh: Int) + + suspend fun updateStatisticsEnabled(enabled: Boolean) } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/repository/TunnelRepository.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/repository/TunnelRepository.kt index e1dfa21fe..a8ab606be 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/repository/TunnelRepository.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/repository/TunnelRepository.kt @@ -12,14 +12,16 @@ interface TunnelRepository { suspend fun getAll(): List + suspend fun setMetered(tunnelId: Int, value: Boolean) + + suspend fun setDynamicDns(tunnelId: Int, value: Boolean) + suspend fun save(tunnelConfig: TunnelConfig) suspend fun saveAll(tunnelConfigList: List) suspend fun updatePrimaryTunnel(tunnelConfig: TunnelConfig?) - suspend fun resetActiveTunnels() - suspend fun updateMobileDataTunnel(tunnelConfig: TunnelConfig?) suspend fun updateEthernetTunnel(tunnelConfig: TunnelConfig?) @@ -28,12 +30,8 @@ interface TunnelRepository { suspend fun getById(id: Int): TunnelConfig? - suspend fun getActive(): List - suspend fun getDefaultTunnel(): TunnelConfig? - suspend fun getStartTunnel(): TunnelConfig? - suspend fun count(): Int suspend fun findByTunnelName(name: String): TunnelConfig? @@ -45,4 +43,6 @@ interface TunnelRepository { suspend fun findPrimary(): List suspend fun delete(tunnels: List) + + suspend fun ensureGlobalConfigExists() } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/sideeffect/GlobalSideEffect.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/sideeffect/GlobalSideEffect.kt index 31e9d16d7..48baaedce 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/sideeffect/GlobalSideEffect.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/sideeffect/GlobalSideEffect.kt @@ -1,6 +1,6 @@ package com.zaneschepke.wireguardautotunnel.domain.sideeffect -import com.zaneschepke.wireguardautotunnel.data.model.AppMode +import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelMode import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConfig import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarType import com.zaneschepke.wireguardautotunnel.util.StringValue @@ -24,7 +24,7 @@ sealed class GlobalSideEffect { data object ConfigChanged : GlobalSideEffect() - data class RequestVpnPermission(val requestingMode: AppMode, val config: TunnelConfig?) : + data class RequestVpnPermission(val requestingMode: TunnelMode, val config: TunnelConfig?) : GlobalSideEffect() data class InstallApk(val apk: File) : GlobalSideEffect() diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/state/AmneziaStatistics.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/state/AmneziaStatistics.kt deleted file mode 100644 index 0bffe2fab..000000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/state/AmneziaStatistics.kt +++ /dev/null @@ -1,35 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.domain.state - -import org.amnezia.awg.backend.Statistics -import org.amnezia.awg.crypto.Key - -class AmneziaStatistics(private val statistics: Statistics) : TunnelStatistics() { - override fun peerStats(peerBase64: String): PeerStats? { - val key = Key.fromBase64(peerBase64) - val stats = statistics.peer(key) - return stats?.let { - PeerStats( - rxBytes = stats.rxBytes, - txBytes = stats.txBytes, - latestHandshakeEpochMillis = stats.latestHandshakeEpochMillis, - resolvedEndpoint = stats.resolvedEndpoint, - ) - } - } - - override fun isTunnelStale(): Boolean { - return statistics.isStale - } - - override fun getPeers(): Array { - return statistics.peers().map { it.toBase64() }.toTypedArray() - } - - override fun rx(): Long { - return statistics.totalRx() - } - - override fun tx(): Long { - return statistics.totalTx() - } -} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/state/AutoTunnelState.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/state/AutoTunnelState.kt index 62e2292e9..46fd4e54d 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/state/AutoTunnelState.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/state/AutoTunnelState.kt @@ -1,110 +1,45 @@ package com.zaneschepke.wireguardautotunnel.domain.state -import com.zaneschepke.wireguardautotunnel.core.service.autotunnel.ActiveTunnelsChange -import com.zaneschepke.wireguardautotunnel.core.service.autotunnel.NetworkChange -import com.zaneschepke.wireguardautotunnel.core.service.autotunnel.SettingsChange -import com.zaneschepke.wireguardautotunnel.core.service.autotunnel.StateChange -import com.zaneschepke.wireguardautotunnel.data.model.AppMode -import com.zaneschepke.wireguardautotunnel.domain.events.AutoTunnelEvent -import com.zaneschepke.wireguardautotunnel.domain.events.AutoTunnelEvent.DoNothing -import com.zaneschepke.wireguardautotunnel.domain.events.AutoTunnelEvent.Start +import com.zaneschepke.tunnel.state.BackendStatus +import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelMode import com.zaneschepke.wireguardautotunnel.domain.model.AutoTunnelSettings import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConfig import com.zaneschepke.wireguardautotunnel.util.extensions.isMatchingToWildcardList data class AutoTunnelState( - val activeTunnels: Map = emptyMap(), + val backendStatus: BackendStatus = BackendStatus(), val networkState: NetworkState = NetworkState(), val settings: AutoTunnelSettings = AutoTunnelSettings(), - val appMode: AppMode = AppMode.VPN, + val tunnelMode: TunnelMode = TunnelMode.VPN, val tunnels: List = emptyList(), ) { + fun matchesNetwork(ssid: String, candidates: Set): Boolean { + return if (settings.isWildcardsEnabled) { + candidates.isMatchingToWildcardList(ssid) + } else { + candidates.contains(ssid) + } + } - fun determineAutoTunnelEvent(stateChange: StateChange): AutoTunnelEvent { - when (stateChange) { - is NetworkChange, - is SettingsChange -> { - // Compute desired tunnel based on network conditions - var preferredTunnel: TunnelConfig? = null - if (ethernetActive && settings.isTunnelOnEthernetEnabled) { - preferredTunnel = preferredEthernetTunnel() - } else if (mobileDataActive && settings.isTunnelOnMobileDataEnabled) { - preferredTunnel = preferredMobileDataTunnel() - } else if (wifiActive && settings.isTunnelOnWifiEnabled && !isWifiTrusted()) { - preferredTunnel = preferredWifiTunnel() + data class NetworkFingerprint(val transport: String, val ssid: String?) + + val networkFingerPrint: NetworkFingerprint + get() = + when (networkState.activeNetwork) { + is ActiveNetwork.Wifi -> { + NetworkFingerprint(transport = "wifi", ssid = networkState.activeNetwork.ssid) } - // Override for no connectivity if enabled - if (!networkState.hasInternet() && settings.isStopOnNoInternetEnabled) { - preferredTunnel = null + is ActiveNetwork.Cellular -> { + NetworkFingerprint(transport = "cellular", ssid = null) } - // Determine current active tunnel (assuming only one can be active) - val currentTunnel = activeTunnels.entries.firstOrNull()?.key + is ActiveNetwork.Ethernet -> { + NetworkFingerprint(transport = "ethernet", ssid = null) + } - // Handle tunnel start/stop/change - if (preferredTunnel != null) { - if (currentTunnel != preferredTunnel.id) { - return Start(preferredTunnel) - } - } else { - if (currentTunnel != null) { - return AutoTunnelEvent.Stop - } + else -> { + NetworkFingerprint(transport = "none", ssid = null) } } - - is ActiveTunnelsChange -> Unit - } - return DoNothing - } - - private val ethernetActive: Boolean = networkState.activeNetwork is ActiveNetwork.Ethernet - private val mobileDataActive: Boolean = networkState.activeNetwork is ActiveNetwork.Cellular - private val wifiActive: Boolean = networkState.activeNetwork is ActiveNetwork.Wifi - - private fun preferredMobileDataTunnel(): TunnelConfig? { - return tunnels.firstOrNull { it.isMobileDataTunnel } - ?: tunnels.firstOrNull { it.isPrimaryTunnel } - ?: tunnels.firstOrNull() - } - - private fun preferredEthernetTunnel(): TunnelConfig? { - return tunnels.firstOrNull { it.isEthernetTunnel } - ?: tunnels.firstOrNull { it.isPrimaryTunnel } - ?: tunnels.firstOrNull() - } - - private fun preferredWifiTunnel(): TunnelConfig? { - return getTunnelWithMappedNetwork() - ?: tunnels.firstOrNull { it.isPrimaryTunnel } - ?: tunnels.firstOrNull() - } - - private fun isWifiTrusted(): Boolean { - return with(networkState.activeNetwork) { - this is ActiveNetwork.Wifi && isTrustedNetwork(this.ssid) - } - } - - private fun isTrustedNetwork(ssid: String): Boolean = - hasMatch(ssid, settings.trustedNetworkSSIDs) - - private fun hasMatch( - wifiName: String, - wifiNames: Set = settings.trustedNetworkSSIDs, - ): Boolean { - return if (settings.isWildcardsEnabled) { - wifiNames.isMatchingToWildcardList(wifiName) - } else { - wifiNames.contains(wifiName) - } - } - - private fun getTunnelWithMappedNetwork(): TunnelConfig? = - when (val network = networkState.activeNetwork) { - is ActiveNetwork.Wifi -> - tunnels.firstOrNull { hasMatch(network.ssid, it.tunnelNetworks) } - else -> null - } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/state/PingState.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/state/PingState.kt index 1b47ca68f..ecdeea5d9 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/state/PingState.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/state/PingState.kt @@ -1,26 +1,27 @@ -package com.zaneschepke.wireguardautotunnel.domain.state - -import com.zaneschepke.wireguardautotunnel.core.tunnel.handler.TunnelMonitorHandler.Companion.CLOUDFLARE_IPV4_IP - -enum class FailureReason { - NoConnectivity, - PingFailed, - NoResolvedEndpoint, - Timeout, - Unknown, -} - -data class PingState( - val transmitted: Int = 0, - val received: Int = 0, - val packetLoss: Double = 0.0, - val rttMin: Double = 0.0, - val rttMax: Double = 0.0, - val rttAvg: Double = 0.0, - val rttStddev: Double = 0.0, - val isReachable: Boolean = false, - val lastSuccessfulPingMillis: Long? = null, - val lastPingAttemptMillis: Long? = null, - val failureReason: FailureReason? = null, - val pingTarget: String = CLOUDFLARE_IPV4_IP, -) +// package com.zaneschepke.wireguardautotunnel.domain.state +// +// import +// com.zaneschepke.wireguardautotunnel.core.tunnel.handler.TunnelMonitorHandler.Companion.CLOUDFLARE_IPV4_IP +// +// enum class FailureReason { +// NoConnectivity, +// PingFailed, +// NoResolvedEndpoint, +// Timeout, +// Unknown, +// } +// +// data class PingState( +// val transmitted: Int = 0, +// val received: Int = 0, +// val packetLoss: Double = 0.0, +// val rttMin: Double = 0.0, +// val rttMax: Double = 0.0, +// val rttAvg: Double = 0.0, +// val rttStddev: Double = 0.0, +// val isReachable: Boolean = false, +// val lastSuccessfulPingMillis: Long? = null, +// val lastPingAttemptMillis: Long? = null, +// val failureReason: FailureReason? = null, +// val pingTarget: String = CLOUDFLARE_IPV4_IP, +// ) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/state/TunnelState.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/state/TunnelState.kt index a81de1540..4409575a4 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/state/TunnelState.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/state/TunnelState.kt @@ -1,68 +1,68 @@ -package com.zaneschepke.wireguardautotunnel.domain.state - -import com.zaneschepke.wireguardautotunnel.domain.enums.BackendMode -import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelStatus - -data class TunnelState( - val status: TunnelStatus = TunnelStatus.Down, - val backendState: BackendMode = BackendMode.Inactive, - val statistics: TunnelStatistics? = null, - val pingStates: Map? = null, - val logHealthState: LogHealthState? = null, -) { - - fun health(): Health { - if (status !is TunnelStatus.Up) return Health.UNKNOWN - val uptime = uptime() - val now = System.currentTimeMillis() - - if (pingStates == null && logHealthState == null && statistics == null) - return Health.UNKNOWN - - // Logs check take precedent - logHealthState?.let { log -> - if (!log.isHealthy) return Health.UNHEALTHY - val recent = (now - log.timestamp) <= LOG_HEALTH_SUCCESS_TIMEOUT_MS - if (recent) { - // Logs healthy but override if pings are unhealthy - if (pingStates?.any { !it.value.isReachable } == true) return Health.UNHEALTHY - return Health.HEALTHY - } - } - - // Ping health if no logs - pingStates?.let { pings -> - if (pings.any { !it.value.isReachable }) return Health.UNHEALTHY - return Health.HEALTHY - } - - // Stats health if no logs or pings - statistics?.let { stats -> - if (stats.isTunnelStale()) return Health.STALE - val rx = stats.rx() - if (uptime >= STATS_HEALTH_SUCCESS_TIMEOUT_MS && rx == 0L) return Health.UNHEALTHY - if (rx == 0L) return Health.UNKNOWN - return Health.HEALTHY - } - - return Health.UNKNOWN - } - - fun uptime(): Long { - val up = status as? TunnelStatus.Up ?: return 0L - if (up.startTime == 0L) return 0L - return System.currentTimeMillis() - up.startTime - } - - enum class Health { - UNKNOWN, - UNHEALTHY, - HEALTHY, - STALE, - } - - companion object { - const val LOG_HEALTH_SUCCESS_TIMEOUT_MS = 2 * 60 * 1000L // 2 minutes - const val STATS_HEALTH_SUCCESS_TIMEOUT_MS = 15 * 1000L // 15 sec - } -} +// package com.zaneschepke.wireguardautotunnel.domain.state +// +// import com.zaneschepke.wireguardautotunnel.domain.enums.BackendMode +// import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelStatus +// +// data class TunnelState( +// val status: TunnelStatus = TunnelStatus.Down, +// val backendState: BackendMode = BackendMode.Inactive, +// val statistics: TunnelStatistics? = null, +// val pingStates: Map? = null, +// val logHealthState: LogHealthState? = null, +// ) { +// +// fun health(): Health { +// if (status !is TunnelStatus.Up) return Health.UNKNOWN +// val uptime = uptime() +// val now = System.currentTimeMillis() +// +// if (pingStates == null && logHealthState == null && statistics == null) +// return Health.UNKNOWN +// +// // Logs check take precedent +// logHealthState?.let { log -> +// if (!log.isHealthy) return Health.UNHEALTHY +// val recent = (now - log.timestamp) <= LOG_HEALTH_SUCCESS_TIMEOUT_MS +// if (recent) { +// // Logs healthy but override if pings are unhealthy +// if (pingStates?.any { !it.value.isReachable } == true) return Health.UNHEALTHY +// return Health.HEALTHY +// } +// } +// +// // Ping health if no logs +// pingStates?.let { pings -> +// if (pings.any { !it.value.isReachable }) return Health.UNHEALTHY +// return Health.HEALTHY +// } +// +// // Stats health if no logs or pings +// statistics?.let { stats -> +// if (stats.isTunnelStale()) return Health.STALE +// val rx = stats.rx() +// if (uptime >= STATS_HEALTH_SUCCESS_TIMEOUT_MS && rx == 0L) return Health.UNHEALTHY +// if (rx == 0L) return Health.UNKNOWN +// return Health.HEALTHY +// } +// +// return Health.UNKNOWN +// } +// +// fun uptime(): Long { +// val up = status as? TunnelStatus.Up ?: return 0L +// if (up.startTime == 0L) return 0L +// return System.currentTimeMillis() - up.startTime +// } +// +// enum class Health { +// UNKNOWN, +// UNHEALTHY, +// HEALTHY, +// STALE, +// } +// +// companion object { +// const val LOG_HEALTH_SUCCESS_TIMEOUT_MS = 2 * 60 * 1000L // 2 minutes +// const val STATS_HEALTH_SUCCESS_TIMEOUT_MS = 15 * 1000L // 15 sec +// } +// } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/state/WireGuardStatistics.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/state/WireGuardStatistics.kt deleted file mode 100644 index ab76b3046..000000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/state/WireGuardStatistics.kt +++ /dev/null @@ -1,35 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.domain.state - -import com.wireguard.android.backend.Statistics -import com.wireguard.crypto.Key - -class WireGuardStatistics(private val statistics: Statistics) : TunnelStatistics() { - override fun peerStats(peerBase64: String): PeerStats? { - val key = Key.fromBase64(peerBase64) - val peerStats = statistics.peer(key) - return peerStats?.let { - PeerStats( - rxBytes = it.rxBytes, - txBytes = it.txBytes, - latestHandshakeEpochMillis = it.latestHandshakeEpochMillis, - resolvedEndpoint = it.resolvedEndpoint, - ) - } - } - - override fun isTunnelStale(): Boolean { - return statistics.isStale - } - - override fun getPeers(): Array { - return statistics.peers().map { it.toBase64() }.toTypedArray() - } - - override fun rx(): Long { - return statistics.totalRx() - } - - override fun tx(): Long { - return statistics.totalTx() - } -} 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 767695b87..ffdaa9d16 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,7 +3,13 @@ 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.layout.* +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.height +import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/animations/AnimatedFloatIcon.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/animations/AnimatedFloatIcon.kt index 2ed6a055f..3e9f18153 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/animations/AnimatedFloatIcon.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/animations/AnimatedFloatIcon.kt @@ -1,7 +1,12 @@ package com.zaneschepke.wireguardautotunnel.ui.common.animations import androidx.compose.animation.Crossfade -import androidx.compose.animation.core.* +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.core.updateTransition import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/banner/AppAlertBanner.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/banner/AppAlertBanner.kt index f3e068e49..aea8df6f2 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/banner/AppAlertBanner.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/banner/AppAlertBanner.kt @@ -1,7 +1,12 @@ package com.zaneschepke.wireguardautotunnel.ui.common.banner import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/button/ClickableIconButton.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/button/ClickableIconButton.kt index 3017dcefa..45eee9ddb 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/button/ClickableIconButton.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/button/ClickableIconButton.kt @@ -2,7 +2,11 @@ package com.zaneschepke.wireguardautotunnel.ui.common.button import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.size -import androidx.compose.material3.* +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/button/IconSurfaceButton.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/button/IconSurfaceButton.kt index d65f26612..3c02a34f2 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/button/IconSurfaceButton.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/button/IconSurfaceButton.kt @@ -2,7 +2,15 @@ package com.zaneschepke.wireguardautotunnel.ui.common.button import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/button/SheetButtonWithDivider.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/button/SheetButtonWithDivider.kt index d74362f5b..9aedee745 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/button/SheetButtonWithDivider.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/button/SheetButtonWithDivider.kt @@ -1,8 +1,13 @@ package com.zaneschepke.wireguardautotunnel.ui.common.button -import android.R.attr.onClick import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.ExpandMore import androidx.compose.material3.Icon diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/button/SurfaceRow.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/button/SurfaceRow.kt index af9f6c4d1..081154162 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/button/SurfaceRow.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/button/SurfaceRow.kt @@ -7,11 +7,22 @@ import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.focusable import androidx.compose.foundation.indication import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.ripple -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/button/SwitchWithDivider.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/button/SwitchWithDivider.kt index 827169ba6..d9140b784 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/button/SwitchWithDivider.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/button/SwitchWithDivider.kt @@ -1,7 +1,13 @@ package com.zaneschepke.wireguardautotunnel.ui.common.button import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/dialog/InfoDialog.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/dialog/InfoDialog.kt index 01a1e035f..11e29475b 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/dialog/InfoDialog.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/dialog/InfoDialog.kt @@ -1,6 +1,10 @@ package com.zaneschepke.wireguardautotunnel.ui.common.dialog -import androidx.compose.material3.* +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/dialog/VpnDeniedDialog.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/dialog/VpnDeniedDialog.kt index 5c6f15536..e725f5765 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/dialog/VpnDeniedDialog.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/dialog/VpnDeniedDialog.kt @@ -5,7 +5,11 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.* +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLinkStyles +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withLink import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.util.extensions.launchVpnSettings diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/dropdown/DropdownSelector.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/dropdown/DropdownSelector.kt index 64a81e798..172805fe4 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/dropdown/DropdownSelector.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/dropdown/DropdownSelector.kt @@ -7,7 +7,11 @@ import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.rememberScrollState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowDropDown -import androidx.compose.material3.* +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +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 diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/dropdown/LabelledNumberDropdown.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/dropdown/LabelledNumberDropdown.kt index 2d7adba55..e8f7b2043 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/dropdown/LabelledNumberDropdown.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/dropdown/LabelledNumberDropdown.kt @@ -1,6 +1,10 @@ package com.zaneschepke.wireguardautotunnel.ui.common.dropdown -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import com.zaneschepke.wireguardautotunnel.ui.common.button.SurfaceRow @Composable diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/functions/Functions.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/functions/Functions.kt index c5bc55f19..71e477acd 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/functions/Functions.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/functions/Functions.kt @@ -69,39 +69,58 @@ fun rememberFileImportLauncherForResult( @Composable fun rememberFileExportLauncherForResult( mimeType: String = FileUtils.ZIP_FILE_MIME_TYPE, - onResult: (Uri?) -> Unit, + onSuccess: (Uri) -> Unit, + onCanceled: () -> Unit, + onUnsupported: () -> Unit, ): ManagedActivityResultLauncher { val isTv = LocalIsAndroidTV.current + return rememberLauncherForActivityResult( contract = object : ActivityResultContracts.CreateDocument(mimeType) { override fun createIntent(context: Context, input: String): Intent { - super.createIntent(context, input) val intent = - Intent(Intent.ACTION_CREATE_DOCUMENT).apply { + super.createIntent(context, input).apply { addCategory(Intent.CATEGORY_OPENABLE) - type = - if (isTv) { - FileUtils.ALLOWED_TV_FILE_TYPES - } else { - mimeType - } putExtra(Intent.EXTRA_TITLE, input) + type = if (isTv) FileUtils.ALLOWED_TV_FILE_TYPES else mimeType + } + + // Detect Android TV stub pickers that do nothing + val activities = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.packageManager.queryIntentActivities( + intent, + PackageManager.ResolveInfoFlags.of( + PackageManager.MATCH_DEFAULT_ONLY.toLong() + ), + ) + } else { + context.packageManager.queryIntentActivities( + intent, + PackageManager.MATCH_DEFAULT_ONLY, + ) } - Timber.d("Returning SAF intent for launch") + val isStubOnly = activities.all { + val pkg = it.activityInfo.packageName + pkg.startsWith(FileUtils.GOOGLE_TV_EXPLORER_STUB) || + pkg.startsWith(FileUtils.ANDROID_TV_EXPLORER_STUB) + } + + if (isStubOnly) { + Timber.w("Detected Android TV stub file picker — export not supported") + onUnsupported() + } + return intent } } ) { uri -> - Timber.d("SAF onResult called with Uri: $uri") if (uri != null) { - Timber.d( - "Uri details: scheme=${uri.scheme}, authority=${uri.authority}, path=${uri.path}" - ) + onSuccess(uri) } else { - Timber.d("SAF picker canceled or failed to return a Uri") + onCanceled() } - onResult(uri) } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/security/SecureScreenFromRecording.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/security/SecureScreenFromRecording.kt deleted file mode 100644 index 974ebefcc..000000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/security/SecureScreenFromRecording.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.ui.common.security - -import android.view.WindowManager -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.ui.platform.LocalContext -import com.zaneschepke.wireguardautotunnel.MainActivity - -@Composable -fun SecureScreenFromRecording() { - val context = LocalContext.current - - val activity = context as? MainActivity - - // Secure screen due to sensitive information - DisposableEffect(Unit) { - activity - ?.window - ?.setFlags( - WindowManager.LayoutParams.FLAG_SECURE, - WindowManager.LayoutParams.FLAG_SECURE, - ) - onDispose { activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) } - } -} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/snackbar/CustomSnackbar.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/snackbar/CustomSnackbar.kt index 0b940aeba..03cf611b7 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/snackbar/CustomSnackbar.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/snackbar/CustomSnackbar.kt @@ -1,13 +1,26 @@ package com.zaneschepke.wireguardautotunnel.ui.common.snackbar -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Favorite import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.Info import androidx.compose.material.icons.rounded.Warning -import androidx.compose.material3.* +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Snackbar +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/snackbar/CustomSnackbarManager.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/snackbar/CustomSnackbarManager.kt index 851e2cc5a..2883e856e 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/snackbar/CustomSnackbarManager.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/snackbar/CustomSnackbarManager.kt @@ -2,7 +2,13 @@ package com.zaneschepke.wireguardautotunnel.ui.common.snackbar import androidx.compose.foundation.layout.Box import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/textbox/CustomTextField.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/textbox/CustomTextField.kt index 124114571..329ef7fd9 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/textbox/CustomTextField.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/textbox/CustomTextField.kt @@ -9,7 +9,11 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.TextFieldDefaults -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusProperties @@ -96,18 +100,18 @@ fun CustomTextField( }, contentPadding = OutlinedTextFieldDefaults.contentPadding(top = 0.dp, bottom = 0.dp), leadingIcon = leading, - trailingIcon = { - // TODO Android TV bug where this isn't clickable, need to revisit + trailingIcon = if (trailing != null) { - trailing( - Modifier.focusRequester(trailingFocusRequester).focusProperties { - if (editable) { - left = mainFocusRequester + { + trailing( + Modifier.focusRequester(trailingFocusRequester).focusProperties { + if (editable) { + left = mainFocusRequester + } } - } - ) - } - }, + ) + } + } else null, singleLine = singleLine, supportingText = supportingText, colors = diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/navigation/Route.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/navigation/Route.kt index 95bf8aa85..08ced8113 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/navigation/Route.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/navigation/Route.kt @@ -20,11 +20,21 @@ sealed class Route : NavKey { @Keep @Serializable data object Support : Route() - @Keep @Serializable data object Lock : Route() + @Keep + @Serializable + data object Lock : Route(), SecureRoute { + override val requiresProtection: Boolean + get() = true + } @Keep @Serializable data object License : Route() - @Keep @Serializable data object Logs : Route() + @Keep + @Serializable + data object Logs : Route(), SecureRoute { + override val requiresProtection: Boolean + get() = true + } @Keep @Serializable data object Appearance : Route() @@ -36,35 +46,92 @@ sealed class Route : NavKey { @Keep @Serializable data class TunnelSettings(val id: Int) : Route() - @Keep @Serializable data class Config(val id: Int?) : Route() + @Keep + @Serializable + data class Config(val id: Int, val live: Boolean = false) : Route(), SecureRoute { + override val requiresProtection: Boolean + get() = true + } - @Keep @Serializable data class SplitTunnel(val id: Int) : Route() + @Keep @Serializable data class IPv6(val id: Int) : Route() - @Keep @Serializable data class ConfigGlobal(val id: Int?) : Route() + @Keep + @Serializable + data class ConfigEdit(val id: Int?) : Route(), SecureRoute { + override val requiresProtection: Boolean + get() = true + } - @Keep @Serializable data class SplitTunnelGlobal(val id: Int) : Route() + @Keep + @Serializable + data class SplitTunnel(val id: Int) : Route(), SecureRoute { + override val requiresProtection: Boolean + get() = true + } + + @Keep + @Serializable + data class ConfigGlobal(val id: Int?) : Route(), SecureRoute { + override val requiresProtection: Boolean + get() = true + } + + @Keep + @Serializable + data class SplitTunnelGlobal(val id: Int) : Route(), SecureRoute { + override val requiresProtection: Boolean + get() = true + } @Keep @Serializable data object Sort : Route() @Keep @Serializable data object Settings : Route() - @Keep @Serializable data object TunnelMonitoring : Route() + @Keep + @Serializable + data object AndroidIntegrations : Route(), SecureRoute { + override val requiresProtection: Boolean + get() = true + } - @Keep @Serializable data object AndroidIntegrations : Route() + @Keep + @Serializable + data object Dns : Route(), SecureRoute { + override val requiresProtection: Boolean + get() = true + } - @Keep @Serializable data object Dns : Route() + @Keep + @Serializable + data object TunnelGlobals : Route(), SecureRoute { + override val requiresProtection: Boolean + get() = true + } - @Keep @Serializable data object ProxySettings : Route() + @Keep + @Serializable + data object ProxySettings : Route(), SecureRoute { + override val requiresProtection: Boolean + get() = true + } @Keep @Serializable data object LockdownSettings : Route() - @Keep @Serializable data object AutoTunnel : Route() - - @Keep @Serializable data object AdvancedAutoTunnel : Route() + @Keep + @Serializable + data object AutoTunnel : Route(), SecureRoute { + override val requiresProtection: Boolean + get() = true + } @Keep @Serializable data object WifiDetectionMethod : Route() - @Keep @Serializable data object WifiPreferences : Route() + @Keep + @Serializable + data object WifiPreferences : Route(), SecureRoute { + override val requiresProtection: Boolean + get() = true + } @Keep @Serializable data object LocationDisclosure : Route() @@ -72,9 +139,16 @@ sealed class Route : NavKey { @Keep @Serializable data object Addresses : Route() - @Keep @Serializable data class PreferredTunnel(val tunnelNetwork: TunnelNetwork) : Route() + @Keep + @Serializable + data class PreferredTunnel(val tunnelNetwork: TunnelNetwork) : Route(), SecureRoute { + override val requiresProtection: Boolean + get() = true + } - @Keep @Serializable data object PingTarget : Route() + @Keep @Serializable data object Security : Route() + + @Keep @Serializable data object Monitoring : Route() } @Serializable @@ -108,17 +182,17 @@ enum class Tab( is Route.Tunnels, Route.Sort, is Route.TunnelSettings, - is Route.Config, + is Route.ConfigEdit, is Route.Lock, + is Route.Config, + is Route.IPv6, is Route.SplitTunnel -> TUNNELS is Route.AutoTunnel, - Route.AdvancedAutoTunnel, Route.WifiDetectionMethod, Route.WifiPreferences, is Route.PreferredTunnel, Route.LocationDisclosure -> AUTOTUNNEL is Route.Settings, - Route.TunnelMonitoring, Route.AndroidIntegrations, Route.Dns, is Route.SplitTunnelGlobal, @@ -127,8 +201,10 @@ enum class Tab( Route.Appearance, Route.Language, Route.Display, - Route.PingTarget, is Route.ConfigGlobal, + Route.TunnelGlobals, + Route.Security, + Route.Monitoring, Route.Logs -> SETTINGS is Route.Support, Route.License, @@ -137,3 +213,7 @@ enum class Tab( } } } + +interface SecureRoute { + val requiresProtection: Boolean +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/navigation/components/BottomNavbar.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/navigation/components/BottomNavbar.kt index 32f4d5f78..971a2f3d1 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/navigation/components/BottomNavbar.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/navigation/components/BottomNavbar.kt @@ -5,7 +5,16 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.size -import androidx.compose.material3.* +import androidx.compose.material3.Badge +import androidx.compose.material3.BadgedBox +import androidx.compose.material3.BottomAppBarDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.FlexibleBottomAppBar +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.LocalRippleConfiguration +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.Stable diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/navigation/components/TvBackButton.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/navigation/components/TvBackButton.kt new file mode 100644 index 000000000..0b07895d7 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/navigation/components/TvBackButton.kt @@ -0,0 +1,35 @@ +package com.zaneschepke.wireguardautotunnel.ui.navigation.components + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.res.stringResource +import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.ui.LocalIsAndroidTV + +@Composable +fun TvBackButton(onClick: () -> Unit) { + val isTv = LocalIsAndroidTV.current + + val focusRequester = remember { FocusRequester() } + + LaunchedEffect(Unit) { + if (isTv) { + focusRequester.requestFocus() + } + } + + IconButton(onClick = onClick, modifier = Modifier.focusRequester(focusRequester)) { + Icon( + Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = stringResource(R.string.back), + ) + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/navigation/components/currentBackStackEntryAsNavbarState.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/navigation/components/currentBackStackEntryAsNavbarState.kt index f7eb298a1..6f57b9ac6 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/navigation/components/currentBackStackEntryAsNavbarState.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/navigation/components/currentBackStackEntryAsNavbarState.kt @@ -3,11 +3,20 @@ package com.zaneschepke.wireguardautotunnel.ui.navigation.components import android.os.Build import androidx.compose.foundation.layout.Row import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.automirrored.rounded.Sort import androidx.compose.material.icons.outlined.ContentPasteGo import androidx.compose.material.icons.outlined.CopyAll -import androidx.compose.material.icons.rounded.* +import androidx.compose.material.icons.outlined.RemoveRedEye +import androidx.compose.material.icons.rounded.Add +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.Download +import androidx.compose.material.icons.rounded.Edit +import androidx.compose.material.icons.rounded.Menu +import androidx.compose.material.icons.rounded.NetworkCheck +import androidx.compose.material.icons.rounded.QrCode2 +import androidx.compose.material.icons.rounded.Save +import androidx.compose.material.icons.rounded.SelectAll +import androidx.compose.material.icons.rounded.SortByAlpha import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable @@ -20,7 +29,37 @@ import androidx.compose.ui.res.stringResource import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.ui.navigation.NavController import com.zaneschepke.wireguardautotunnel.ui.navigation.Route -import com.zaneschepke.wireguardautotunnel.ui.navigation.Route.* +import com.zaneschepke.wireguardautotunnel.ui.navigation.Route.Addresses +import com.zaneschepke.wireguardautotunnel.ui.navigation.Route.AndroidIntegrations +import com.zaneschepke.wireguardautotunnel.ui.navigation.Route.Appearance +import com.zaneschepke.wireguardautotunnel.ui.navigation.Route.AutoTunnel +import com.zaneschepke.wireguardautotunnel.ui.navigation.Route.Config +import com.zaneschepke.wireguardautotunnel.ui.navigation.Route.ConfigEdit +import com.zaneschepke.wireguardautotunnel.ui.navigation.Route.ConfigGlobal +import com.zaneschepke.wireguardautotunnel.ui.navigation.Route.Display +import com.zaneschepke.wireguardautotunnel.ui.navigation.Route.Dns +import com.zaneschepke.wireguardautotunnel.ui.navigation.Route.Donate +import com.zaneschepke.wireguardautotunnel.ui.navigation.Route.IPv6 +import com.zaneschepke.wireguardautotunnel.ui.navigation.Route.Language +import com.zaneschepke.wireguardautotunnel.ui.navigation.Route.License +import com.zaneschepke.wireguardautotunnel.ui.navigation.Route.LocationDisclosure +import com.zaneschepke.wireguardautotunnel.ui.navigation.Route.Lock +import com.zaneschepke.wireguardautotunnel.ui.navigation.Route.LockdownSettings +import com.zaneschepke.wireguardautotunnel.ui.navigation.Route.Logs +import com.zaneschepke.wireguardautotunnel.ui.navigation.Route.Monitoring +import com.zaneschepke.wireguardautotunnel.ui.navigation.Route.PreferredTunnel +import com.zaneschepke.wireguardautotunnel.ui.navigation.Route.ProxySettings +import com.zaneschepke.wireguardautotunnel.ui.navigation.Route.Security +import com.zaneschepke.wireguardautotunnel.ui.navigation.Route.Settings +import com.zaneschepke.wireguardautotunnel.ui.navigation.Route.Sort +import com.zaneschepke.wireguardautotunnel.ui.navigation.Route.SplitTunnel +import com.zaneschepke.wireguardautotunnel.ui.navigation.Route.SplitTunnelGlobal +import com.zaneschepke.wireguardautotunnel.ui.navigation.Route.Support +import com.zaneschepke.wireguardautotunnel.ui.navigation.Route.TunnelGlobals +import com.zaneschepke.wireguardautotunnel.ui.navigation.Route.TunnelSettings +import com.zaneschepke.wireguardautotunnel.ui.navigation.Route.Tunnels +import com.zaneschepke.wireguardautotunnel.ui.navigation.Route.WifiDetectionMethod +import com.zaneschepke.wireguardautotunnel.ui.navigation.Route.WifiPreferences import com.zaneschepke.wireguardautotunnel.ui.navigation.TunnelNetwork import com.zaneschepke.wireguardautotunnel.ui.sideeffect.LocalSideEffect import com.zaneschepke.wireguardautotunnel.ui.state.GlobalAppUiState @@ -40,29 +79,9 @@ fun currentRouteAsNavbarState( return remember(route, globalState) { derivedStateOf { when (route) { - AdvancedAutoTunnel -> - NavbarState( - topLeading = { - IconButton(onClick = { navController.pop() }) { - Icon( - Icons.AutoMirrored.Rounded.ArrowBack, - stringResource(R.string.back), - ) - } - }, - showBottomItems = true, - topTitle = context.getString(R.string.advanced_settings), - ) Appearance -> NavbarState( - topLeading = { - IconButton(onClick = { navController.pop() }) { - Icon( - Icons.AutoMirrored.Rounded.ArrowBack, - stringResource(R.string.back), - ) - } - }, + topLeading = { TvBackButton { navController.pop() } }, showBottomItems = true, topTitle = context.getString(R.string.appearance), ) @@ -77,53 +96,35 @@ fun currentRouteAsNavbarState( ) Display -> NavbarState( - topLeading = { - IconButton(onClick = { navController.pop() }) { - Icon( - Icons.AutoMirrored.Rounded.ArrowBack, - stringResource(R.string.back), - ) - } - }, + topLeading = { TvBackButton { navController.pop() } }, showBottomItems = true, topTitle = context.getString(R.string.display_theme), ) Dns -> NavbarState( - topLeading = { - IconButton(onClick = { navController.pop() }) { - Icon( - Icons.AutoMirrored.Rounded.ArrowBack, - stringResource(R.string.back), - ) - } - }, + topLeading = { TvBackButton { navController.pop() } }, showBottomItems = true, topTitle = context.getString(R.string.dns_settings), + topTrailing = { + IconButton( + onClick = { + keyboardController?.hide() + sharedViewModel.postSideEffect(LocalSideEffect.SaveChanges) + } + ) { + Icon(Icons.Rounded.Save, stringResource(R.string.save)) + } + }, ) Language -> NavbarState( - topLeading = { - IconButton(onClick = { navController.pop() }) { - Icon( - Icons.AutoMirrored.Rounded.ArrowBack, - stringResource(R.string.back), - ) - } - }, + topLeading = { TvBackButton { navController.pop() } }, showBottomItems = true, topTitle = context.getString(R.string.language), ) LockdownSettings -> NavbarState( - topLeading = { - IconButton(onClick = { navController.pop() }) { - Icon( - Icons.AutoMirrored.Rounded.ArrowBack, - stringResource(R.string.back), - ) - } - }, + topLeading = { TvBackButton { navController.pop() } }, showBottomItems = true, topTitle = context.getString(R.string.lockdown_settings), topTrailing = { @@ -139,14 +140,7 @@ fun currentRouteAsNavbarState( ) License -> NavbarState( - topLeading = { - IconButton(onClick = { navController.pop() }) { - Icon( - Icons.AutoMirrored.Rounded.ArrowBack, - stringResource(R.string.back), - ) - } - }, + topLeading = { TvBackButton { navController.pop() } }, showBottomItems = true, topTitle = context.getString(R.string.licenses), ) @@ -154,14 +148,7 @@ fun currentRouteAsNavbarState( Lock -> NavbarState(showBottomItems = false) Logs -> NavbarState( - topLeading = { - IconButton(onClick = { navController.pop() }) { - Icon( - Icons.AutoMirrored.Rounded.ArrowBack, - stringResource(R.string.back), - ) - } - }, + topLeading = { TvBackButton { navController.pop() } }, showBottomItems = false, topTitle = context.getString(R.string.logs), topTrailing = { @@ -178,14 +165,7 @@ fun currentRouteAsNavbarState( ) ProxySettings -> NavbarState( - topLeading = { - IconButton(onClick = { navController.pop() }) { - Icon( - Icons.AutoMirrored.Rounded.ArrowBack, - stringResource(R.string.back), - ) - } - }, + topLeading = { TvBackButton { navController.pop() } }, showBottomItems = true, topTitle = context.getString(R.string.proxy_settings), topTrailing = { @@ -206,14 +186,7 @@ fun currentRouteAsNavbarState( ) Sort -> NavbarState( - topLeading = { - IconButton(onClick = { navController.pop() }) { - Icon( - Icons.AutoMirrored.Rounded.ArrowBack, - stringResource(R.string.back), - ) - } - }, + topLeading = { TvBackButton { navController.pop() } }, showBottomItems = true, topTitle = context.getString(R.string.sort), topTrailing = { @@ -247,23 +220,43 @@ fun currentRouteAsNavbarState( } }, ) - is Config, + is ConfigEdit, is ConfigGlobal -> { + val global = route !is ConfigEdit val tunnelName = - if (route is Config) globalState.tunnelNames[route.id] - else context.getString(R.string.global_dns_servers) + if (!global) globalState.tunnelNames[route.id] + else context.getString(R.string.configuration_globals) NavbarState( - topLeading = { - IconButton(onClick = { navController.pop() }) { - Icon( - Icons.AutoMirrored.Rounded.ArrowBack, - stringResource(R.string.back), - ) - } - }, + topLeading = { TvBackButton { navController.pop() } }, showBottomItems = true, topTitle = tunnelName ?: context.getString(R.string.new_tunnel), topTrailing = { + if (!global) + IconButton( + onClick = { + sharedViewModel.postSideEffect( + LocalSideEffect.ShowSensitive + ) + } + ) { + Icon( + Icons.Outlined.RemoveRedEye, + stringResource(R.string.show_password), + ) + } + if (globalState.tunnelNames.isNotEmpty()) + IconButton( + onClick = { + sharedViewModel.postSideEffect( + LocalSideEffect.Modal.SelectTunnel + ) + } + ) { + Icon( + Icons.Outlined.ContentPasteGo, + stringResource(R.string.copy_from), + ) + } IconButton( onClick = { keyboardController?.hide() @@ -281,14 +274,7 @@ fun currentRouteAsNavbarState( if (route is SplitTunnel) globalState.tunnelNames[route.id] else context.getString(R.string.global_split_tunneling) NavbarState( - topLeading = { - IconButton(onClick = { navController.pop() }) { - Icon( - Icons.AutoMirrored.Rounded.ArrowBack, - stringResource(R.string.back), - ) - } - }, + topLeading = { TvBackButton { navController.pop() } }, topTitle = tunnelName ?: "", topTrailing = { Row { @@ -323,57 +309,16 @@ fun currentRouteAsNavbarState( ) AndroidIntegrations -> NavbarState( - topLeading = { - IconButton(onClick = { navController.pop() }) { - Icon( - Icons.AutoMirrored.Rounded.ArrowBack, - stringResource(R.string.back), - ) - } - }, + topLeading = { TvBackButton { navController.pop() } }, topTitle = context.getString(R.string.android_integrations), showBottomItems = true, ) - TunnelMonitoring -> - NavbarState( - topLeading = { - IconButton(onClick = { navController.pop() }) { - Icon( - Icons.AutoMirrored.Rounded.ArrowBack, - stringResource(R.string.back), - ) - } - }, - topTitle = context.getString(R.string.ping_monitor), - showBottomItems = true, - ) is TunnelSettings -> { val tunnelName = globalState.tunnelNames[route.id] NavbarState( - topLeading = { - IconButton(onClick = { navController.pop() }) { - Icon( - Icons.AutoMirrored.Rounded.ArrowBack, - stringResource(R.string.back), - ) - } - }, + topLeading = { TvBackButton { navController.pop() } }, showBottomItems = true, topTitle = tunnelName ?: "", - topTrailing = { - Row { - IconButton( - onClick = { - sharedViewModel.postSideEffect(LocalSideEffect.Modal.QR) - } - ) { - Icon(Icons.Rounded.QrCode2, stringResource(R.string.show_qr)) - } - IconButton(onClick = { navController.push(Config(route.id)) }) { - Icon(Icons.Rounded.Edit, stringResource(R.string.edit_tunnel)) - } - } - }, ) } Tunnels -> { @@ -468,55 +413,27 @@ fun currentRouteAsNavbarState( } WifiDetectionMethod -> NavbarState( - topLeading = { - IconButton(onClick = { navController.pop() }) { - Icon( - Icons.AutoMirrored.Rounded.ArrowBack, - stringResource(R.string.back), - ) - } - }, + topLeading = { TvBackButton { navController.pop() } }, topTitle = context.getString(R.string.wifi_detection_method), showBottomItems = true, ) Donate -> { NavbarState( - topLeading = { - IconButton(onClick = { navController.pop() }) { - Icon( - Icons.AutoMirrored.Rounded.ArrowBack, - stringResource(R.string.back), - ) - } - }, + topLeading = { TvBackButton { navController.pop() } }, topTitle = context.getString(R.string.donate_title), showBottomItems = true, ) } Addresses -> { NavbarState( - topLeading = { - IconButton(onClick = { navController.pop() }) { - Icon( - Icons.AutoMirrored.Rounded.ArrowBack, - stringResource(R.string.back), - ) - } - }, + topLeading = { TvBackButton { navController.pop() } }, topTitle = context.getString(R.string.addresses), showBottomItems = true, ) } is WifiPreferences -> { NavbarState( - topLeading = { - IconButton(onClick = { navController.pop() }) { - Icon( - Icons.AutoMirrored.Rounded.ArrowBack, - stringResource(R.string.back), - ) - } - }, + topLeading = { TvBackButton { navController.pop() } }, topTitle = context.getString(R.string.wifi_settings), showBottomItems = true, ) @@ -529,31 +446,83 @@ fun currentRouteAsNavbarState( TunnelNetwork.WIFI -> context.getString(R.string.tunnel_mapping) } NavbarState( - topLeading = { - IconButton(onClick = { navController.pop() }) { - Icon( - Icons.AutoMirrored.Rounded.ArrowBack, - stringResource(R.string.back), - ) - } - }, + topLeading = { TvBackButton { navController.pop() } }, topTitle = title, showBottomItems = true, ) } - PingTarget -> + is Config -> { + val tunnelName = globalState.tunnelNames[route.id] ?: "" NavbarState( - topLeading = { - IconButton(onClick = { navController.pop() }) { - Icon( - Icons.AutoMirrored.Rounded.ArrowBack, - stringResource(R.string.back), - ) + topLeading = { TvBackButton { navController.pop() } }, + topTrailing = { + Row { + IconButton( + onClick = { + sharedViewModel.postSideEffect( + LocalSideEffect.ShowSensitive + ) + } + ) { + Icon( + Icons.Outlined.RemoveRedEye, + stringResource(R.string.toggle_sensitive_data_visibility), + ) + } + if (!route.live) { + IconButton( + onClick = { + sharedViewModel.postSideEffect(LocalSideEffect.Modal.QR) + } + ) { + Icon( + Icons.Rounded.QrCode2, + stringResource(R.string.show_qr), + ) + } + IconButton( + onClick = { navController.push(ConfigEdit(route.id)) } + ) { + Icon( + Icons.Rounded.Edit, + stringResource(R.string.edit_tunnel), + ) + } + } } }, - topTitle = context.getString(R.string.ping_target), + topTitle = tunnelName, + showBottomItems = true, + ) + } + is IPv6 -> { + NavbarState( + topLeading = { TvBackButton { navController.pop() } }, + topTitle = context.getString(R.string.ipv6_settings), + showBottomItems = true, + ) + } + is TunnelGlobals -> { + NavbarState( + topLeading = { TvBackButton { navController.pop() } }, + topTitle = context.getString(R.string.tunnel_globals), showBottomItems = true, ) + } + is Security -> { + NavbarState( + topLeading = { TvBackButton { navController.pop() } }, + topTitle = context.getString(R.string.security), + showBottomItems = true, + ) + } + is Monitoring -> { + NavbarState( + topLeading = { TvBackButton { navController.pop() } }, + topTitle = context.getString(R.string.monitoring), + showBottomItems = true, + ) + } null -> NavbarState() } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/AutoTunnelScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/AutoTunnelScreen.kt index 47874594d..10ab66eee 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/AutoTunnelScreen.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/AutoTunnelScreen.kt @@ -17,7 +17,6 @@ import androidx.compose.material.icons.outlined.ContentCopy import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.PublicOff import androidx.compose.material.icons.outlined.RestartAlt -import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.icons.outlined.SettingsEthernet import androidx.compose.material.icons.outlined.SignalCellular4Bar import androidx.compose.material.icons.outlined.Wifi @@ -26,11 +25,14 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember 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.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource @@ -41,10 +43,10 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.core.net.toUri -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.zaneschepke.networkmonitor.ActiveNetwork import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.ui.LocalIsAndroidTV import com.zaneschepke.wireguardautotunnel.ui.LocalNavController import com.zaneschepke.wireguardautotunnel.ui.common.button.SurfaceRow import com.zaneschepke.wireguardautotunnel.ui.common.button.SwitchWithDivider @@ -58,6 +60,7 @@ import com.zaneschepke.wireguardautotunnel.viewmodel.AutoTunnelViewModel import com.zaneschepke.wireguardautotunnel.viewmodel.SharedAppViewModel import org.koin.androidx.compose.koinViewModel import org.koin.compose.viewmodel.koinActivityViewModel +import org.orbitmvi.orbit.compose.collectAsState @OptIn(ExperimentalPermissionsApi::class) @Composable @@ -67,13 +70,22 @@ fun AutoTunnelScreen( ) { val context = LocalContext.current val navController = LocalNavController.current + val isTv = LocalIsAndroidTV.current val clipboard = rememberClipboardHelper() - val globalUiState by sharedViewModel.container.stateFlow.collectAsStateWithLifecycle() - val uiState by viewModel.container.stateFlow.collectAsStateWithLifecycle() + val globalUiState by sharedViewModel.collectAsState() + val uiState by viewModel.collectAsState() if (uiState.isLoading) return + val focusRequester = remember { FocusRequester() } + + LaunchedEffect(Unit) { + if (isTv) { + focusRequester.requestFocus() + } + } + val batteryActivity = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { sharedViewModel.disableBatteryOptimizationsShown() @@ -125,7 +137,7 @@ fun AutoTunnelScreen( fun onAutoTunnelClick() { if (!globalUiState.isBatteryOptimizationShown) return requestDisableBatteryOptimizations() - viewModel.toggleAutoTunnel(globalUiState.appMode) + viewModel.toggleAutoTunnel(globalUiState.tunnelMode) } SurfaceRow( @@ -144,6 +156,7 @@ fun AutoTunnelScreen( } }, onClick = { onAutoTunnelClick() }, + modifier = Modifier.focusRequester(focusRequester), ) } Column { @@ -319,11 +332,6 @@ fun AutoTunnelScreen( }, onClick = { viewModel.setStartAtBoot(!uiState.autoTunnelSettings.startOnBoot) }, ) - SurfaceRow( - leading = { Icon(Icons.Outlined.Settings, contentDescription = null) }, - title = stringResource(R.string.advanced_settings), - onClick = { navController.push(Route.AdvancedAutoTunnel) }, - ) } } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/advanced/AutoTunnelAdvancedScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/advanced/AutoTunnelAdvancedScreen.kt deleted file mode 100644 index 1b28f081d..000000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/advanced/AutoTunnelAdvancedScreen.kt +++ /dev/null @@ -1,50 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.advanced - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.PauseCircle -import androidx.compose.material3.Icon -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.zaneschepke.wireguardautotunnel.R -import com.zaneschepke.wireguardautotunnel.ui.common.dropdown.LabelledDropdown -import com.zaneschepke.wireguardautotunnel.ui.common.label.GroupLabel -import com.zaneschepke.wireguardautotunnel.ui.common.text.DescriptionText -import com.zaneschepke.wireguardautotunnel.viewmodel.AutoTunnelViewModel -import org.koin.androidx.compose.koinViewModel - -@Composable -fun AutoTunnelAdvancedScreen(viewModel: AutoTunnelViewModel = koinViewModel()) { - val autoTunnelState by viewModel.container.stateFlow.collectAsStateWithLifecycle() - Column( - horizontalAlignment = Alignment.Start, - verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.Top), - modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()), - ) { - Column { - GroupLabel( - stringResource(R.string.reliability), - modifier = Modifier.padding(horizontal = 16.dp), - ) - LabelledDropdown( - title = stringResource(R.string.debounce_delay), - description = { DescriptionText(stringResource(R.string.debounce_description)) }, - leading = { Icon(Icons.Outlined.PauseCircle, null) }, - onSelected = { selected -> viewModel.setDebounceDelay(selected!!) }, - options = (0..10).toList(), - currentValue = autoTunnelState.autoTunnelSettings.debounceDelaySeconds, - optionToString = { it?.toString() ?: stringResource(R.string._default) }, - ) - } - } -} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/components/TrustNetworksTextBox.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/components/TrustNetworksTextBox.kt index e16ac9e92..1df103c4a 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/components/TrustNetworksTextBox.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/components/TrustNetworksTextBox.kt @@ -2,7 +2,11 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.components import androidx.compose.animation.animateContentSize import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.relocation.BringIntoViewRequester import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.foundation.text.KeyboardActions diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/detection/WifiDetectionMethodScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/detection/WifiDetectionMethodScreen.kt index 2ec933578..f5ba81fef 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/detection/WifiDetectionMethodScreen.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/detection/WifiDetectionMethodScreen.kt @@ -10,19 +10,19 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.zaneschepke.wireguardautotunnel.data.model.WifiDetectionMethod +import com.zaneschepke.wireguardautotunnel.domain.enums.WifiDetectionMethod import com.zaneschepke.wireguardautotunnel.ui.common.button.IconSurfaceButton import com.zaneschepke.wireguardautotunnel.util.extensions.asDescriptionString import com.zaneschepke.wireguardautotunnel.util.extensions.asTitleString import com.zaneschepke.wireguardautotunnel.viewmodel.AutoTunnelViewModel import org.koin.androidx.compose.koinViewModel +import org.orbitmvi.orbit.compose.collectAsState @Composable fun WifiDetectionMethodScreen(viewModel: AutoTunnelViewModel = koinViewModel()) { val context = LocalContext.current - val autoTunnelState by viewModel.container.stateFlow.collectAsStateWithLifecycle() + val autoTunnelState by viewModel.collectAsState() Column( horizontalAlignment = Alignment.Start, diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/preferred/PreferredTunnelScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/preferred/PreferredTunnelScreen.kt index d586dbbe6..aa853ee21 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/preferred/PreferredTunnelScreen.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/preferred/PreferredTunnelScreen.kt @@ -42,7 +42,6 @@ import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConfig import com.zaneschepke.wireguardautotunnel.ui.LocalNavController @@ -54,6 +53,7 @@ import com.zaneschepke.wireguardautotunnel.ui.navigation.TunnelNetwork import com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.components.WildcardsLabel import com.zaneschepke.wireguardautotunnel.viewmodel.AutoTunnelViewModel import org.koin.androidx.compose.koinViewModel +import org.orbitmvi.orbit.compose.collectAsState @Composable fun PreferredTunnelScreen( @@ -62,7 +62,7 @@ fun PreferredTunnelScreen( ) { val navController = LocalNavController.current - val autoTunnelState by viewModel.container.stateFlow.collectAsStateWithLifecycle() + val autoTunnelState by viewModel.collectAsState() if (autoTunnelState.isLoading) return diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/wifi/WifiSettingsScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/wifi/WifiSettingsScreen.kt index 8583a5c61..62966e046 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/wifi/WifiSettingsScreen.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/wifi/WifiSettingsScreen.kt @@ -27,7 +27,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.ui.LocalNavController import com.zaneschepke.wireguardautotunnel.ui.common.banner.WarningBanner @@ -47,13 +46,14 @@ import com.zaneschepke.wireguardautotunnel.util.extensions.launchLocationService import com.zaneschepke.wireguardautotunnel.util.extensions.openWebUrl import com.zaneschepke.wireguardautotunnel.viewmodel.AutoTunnelViewModel import org.koin.androidx.compose.koinViewModel +import org.orbitmvi.orbit.compose.collectAsState @Composable fun WifiSettingsScreen(viewModel: AutoTunnelViewModel = koinViewModel()) { val context = LocalContext.current val navController = LocalNavController.current - val uiState by viewModel.container.stateFlow.collectAsStateWithLifecycle() + val uiState by viewModel.collectAsState() if (uiState.isLoading) return diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/SettingsScreen.kt index f225940fc..9eed4a5fb 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/SettingsScreen.kt @@ -7,19 +7,21 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.outlined.CallSplit import androidx.compose.material.icons.automirrored.outlined.ViewQuilt import androidx.compose.material.icons.outlined.Android import androidx.compose.material.icons.outlined.Dns import androidx.compose.material.icons.outlined.ExpandMore -import androidx.compose.material.icons.outlined.NetworkPing -import androidx.compose.material.icons.outlined.Pin +import androidx.compose.material.icons.outlined.MonitorHeart +import androidx.compose.material.icons.outlined.Public +import androidx.compose.material.icons.outlined.Security import androidx.compose.material.icons.outlined.SettingsBackupRestore +import androidx.compose.material.icons.outlined.Terminal import androidx.compose.material.icons.outlined.ViewHeadline import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -28,16 +30,18 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue 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.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.intl.Locale import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.zaneschepke.wireguardautotunnel.MainActivity import com.zaneschepke.wireguardautotunnel.R -import com.zaneschepke.wireguardautotunnel.data.model.AppMode +import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelMode +import com.zaneschepke.wireguardautotunnel.ui.LocalIsAndroidTV import com.zaneschepke.wireguardautotunnel.ui.LocalNavController import com.zaneschepke.wireguardautotunnel.ui.common.button.SheetButtonWithDivider import com.zaneschepke.wireguardautotunnel.ui.common.button.SurfaceRow @@ -48,7 +52,6 @@ import com.zaneschepke.wireguardautotunnel.ui.common.text.DescriptionText import com.zaneschepke.wireguardautotunnel.ui.navigation.Route import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.BackupBottomSheet import com.zaneschepke.wireguardautotunnel.ui.screens.settings.proxy.compoents.AppModeBottomSheet -import com.zaneschepke.wireguardautotunnel.ui.theme.Disabled import com.zaneschepke.wireguardautotunnel.util.StringValue import com.zaneschepke.wireguardautotunnel.util.extensions.asString import com.zaneschepke.wireguardautotunnel.util.extensions.asTitleString @@ -58,6 +61,7 @@ import com.zaneschepke.wireguardautotunnel.viewmodel.SettingsViewModel import com.zaneschepke.wireguardautotunnel.viewmodel.SharedAppViewModel import org.koin.androidx.compose.koinViewModel import org.koin.compose.viewmodel.koinActivityViewModel +import org.orbitmvi.orbit.compose.collectAsState @Composable fun SettingsScreen( @@ -66,23 +70,32 @@ fun SettingsScreen( ) { val context = LocalContext.current val navController = LocalNavController.current + val isTv = LocalIsAndroidTV.current val locale = Locale.current.platformLocale - val globalUiState by sharedViewModel.container.stateFlow.collectAsStateWithLifecycle() - val uiState by viewModel.container.stateFlow.collectAsStateWithLifecycle() + val globalUiState by sharedViewModel.collectAsState() + val uiState by viewModel.collectAsState() if (uiState.isLoading) return + val focusRequester = remember { FocusRequester() } + + LaunchedEffect(Unit) { + if (isTv) { + focusRequester.requestFocus() + } + } + var showBackupSheet by rememberSaveable { mutableStateOf(false) } var showAppModeSheet by rememberSaveable { mutableStateOf(false) } - val appMode = uiState.settings.appMode - val dnsEnabled by rememberSaveable(appMode) { mutableStateOf(appMode != AppMode.KERNEL) } + val appMode = uiState.settings.tunnelMode + val dnsEnabled by rememberSaveable(appMode) { mutableStateOf(true) } val showModeDivider by remember(appMode) { - derivedStateOf { appMode == AppMode.PROXY || appMode == AppMode.LOCK_DOWN } + derivedStateOf { appMode == TunnelMode.PROXY || appMode == TunnelMode.LOCK_DOWN } } fun performBackupRestore(action: () -> Unit) { @@ -100,7 +113,7 @@ fun SettingsScreen( showBackupSheet = false } if (showAppModeSheet) - AppModeBottomSheet(sharedViewModel::setAppMode, uiState.settings.appMode) { + AppModeBottomSheet(sharedViewModel::setAppMode, uiState.settings.tunnelMode) { showAppModeSheet = false } @@ -129,12 +142,12 @@ fun SettingsScreen( }, onClick = { when (appMode) { - AppMode.PROXY -> navController.push(Route.ProxySettings) - AppMode.LOCK_DOWN -> navController.push(Route.LockdownSettings) - AppMode.KERNEL, - AppMode.VPN -> showAppModeSheet = true + TunnelMode.PROXY -> navController.push(Route.ProxySettings) + TunnelMode.LOCK_DOWN -> navController.push(Route.LockdownSettings) + TunnelMode.VPN -> showAppModeSheet = true } }, + modifier = Modifier.focusRequester(focusRequester), ) SurfaceRow( leading = { @@ -160,82 +173,54 @@ fun SettingsScreen( }, ) SurfaceRow( - leading = { - Icon( - Icons.AutoMirrored.Outlined.CallSplit, - contentDescription = null, - tint = - if (globalUiState.appMode == AppMode.PROXY) Disabled - else MaterialTheme.colorScheme.onSurface, - ) - }, - enabled = globalUiState.appMode != AppMode.PROXY, - title = stringResource(R.string.global_split_tunneling), + leading = { Icon(Icons.Outlined.Public, contentDescription = null) }, + title = stringResource(R.string.tunnel_globals), + onClick = { navController.push(Route.TunnelGlobals) }, + ) + SurfaceRow( + leading = { Icon(Icons.Outlined.Terminal, contentDescription = null) }, + title = stringResource(R.string.tunnel_scripting), trailing = { modifier -> - SwitchWithDivider( - checked = uiState.settings.isGlobalSplitTunnelEnabled, - onClick = { viewModel.setGlobalSplitTunneling(it) }, + ThemedSwitch( + checked = uiState.settings.tunnelScriptingEnabled, + onClick = { viewModel.setTunnelScriptedEnabled(it) }, modifier = modifier, - enabled = globalUiState.appMode != AppMode.PROXY, ) }, - description = - if (globalUiState.appMode == AppMode.PROXY) { - { - DescriptionText( - stringResource(R.string.unavailable_in_mode), - disabled = true, - ) - } - } else null, + description = { + DescriptionText(stringResource(R.string.root_required_template, "").trim()) + }, onClick = { - uiState.globalTunnelConfig?.let { - navController.push(Route.SplitTunnelGlobal(id = it.id)) - } + viewModel.setTunnelScriptedEnabled(!uiState.settings.tunnelScriptingEnabled) }, ) + SurfaceRow( + leading = { Icon(Icons.Outlined.MonitorHeart, null) }, + title = stringResource(R.string.tunnel_monitoring), + onClick = { navController.push(Route.Monitoring) }, + ) SurfaceRow( leading = { Icon(Icons.Outlined.Android, null) }, title = stringResource(R.string.android_integrations), onClick = { navController.push(Route.AndroidIntegrations) }, ) } - Column { + Column(modifier = Modifier.padding(bottom = 16.dp)) { GroupLabel( - stringResource(R.string.monitoring), + stringResource(R.string.general), modifier = Modifier.padding(horizontal = 16.dp), ) SurfaceRow( leading = { - Icon( - Icons.Outlined.NetworkPing, - contentDescription = null, - tint = - if (globalUiState.appMode != AppMode.PROXY) - MaterialTheme.colorScheme.onSurface - else Disabled, - ) - }, - title = stringResource(R.string.ping_monitor), - enabled = globalUiState.appMode != AppMode.PROXY, - description = - if (globalUiState.appMode == AppMode.PROXY) { - { - DescriptionText( - stringResource(R.string.unavailable_in_mode), - disabled = true, - ) - } - } else null, - trailing = { modifier -> - SwitchWithDivider( - checked = uiState.monitoring.isPingEnabled, - onClick = { viewModel.setPingEnabled(it) }, - enabled = globalUiState.appMode != AppMode.PROXY, - modifier = modifier, - ) + Icon(Icons.AutoMirrored.Outlined.ViewQuilt, contentDescription = null) }, - onClick = { navController.push(Route.TunnelMonitoring) }, + title = stringResource(R.string.appearance), + onClick = { navController.push(Route.Appearance) }, + ) + SurfaceRow( + leading = { Icon(Icons.Outlined.Security, contentDescription = null) }, + title = stringResource(R.string.security), + onClick = { navController.push(Route.Security) }, ) SurfaceRow( leading = { Icon(Icons.Outlined.ViewHeadline, contentDescription = null) }, @@ -249,42 +234,6 @@ fun SettingsScreen( }, onClick = { navController.push(Route.Logs) }, ) - } - Column(modifier = Modifier.padding(bottom = 16.dp)) { - GroupLabel( - stringResource(R.string.general), - modifier = Modifier.padding(horizontal = 16.dp), - ) - SurfaceRow( - leading = { - Icon(Icons.AutoMirrored.Outlined.ViewQuilt, contentDescription = null) - }, - title = stringResource(R.string.appearance), - onClick = { navController.push(Route.Appearance) }, - ) - SurfaceRow( - leading = { Icon(Icons.Outlined.Pin, contentDescription = null) }, - title = stringResource(R.string.enable_app_lock), - trailing = { - ThemedSwitch( - checked = uiState.isPinLockEnabled, - onClick = { - if (it) { - navController.push(Route.Lock) - } else { - sharedViewModel.setPinLockEnabled(false) - } - }, - ) - }, - onClick = { - if (!uiState.isPinLockEnabled) { - navController.push(Route.Lock) - } else { - sharedViewModel.setPinLockEnabled(false) - } - }, - ) SurfaceRow( leading = { Icon(Icons.Outlined.SettingsBackupRestore, contentDescription = null) }, title = stringResource(R.string.backup_and_restore), diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/appearance/display/DisplayScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/appearance/display/DisplayScreen.kt index f4b492053..a56e8a1d7 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/appearance/display/DisplayScreen.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/appearance/display/DisplayScreen.kt @@ -12,24 +12,25 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.ui.common.button.SurfaceRow import com.zaneschepke.wireguardautotunnel.ui.theme.Theme import com.zaneschepke.wireguardautotunnel.viewmodel.SharedAppViewModel +import kotlin.enums.enumEntries import org.koin.compose.viewmodel.koinActivityViewModel +import org.orbitmvi.orbit.compose.collectAsState @Composable fun DisplayScreen(sharedViewModel: SharedAppViewModel = koinActivityViewModel()) { - val appState by sharedViewModel.container.stateFlow.collectAsStateWithLifecycle() + val appState by sharedViewModel.collectAsState() Column( horizontalAlignment = Alignment.Start, verticalArrangement = Arrangement.Top, modifier = Modifier.fillMaxSize(), ) { - enumValues().forEach { + enumEntries().forEach { val title = when (it) { Theme.DARK -> stringResource(R.string.dark) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/appearance/language/LanguageScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/appearance/language/LanguageScreen.kt index eb6278eb1..15a6a0029 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/appearance/language/LanguageScreen.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/appearance/language/LanguageScreen.kt @@ -15,18 +15,18 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.intl.Locale -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.ui.common.button.SurfaceRow import com.zaneschepke.wireguardautotunnel.util.LocaleUtil import com.zaneschepke.wireguardautotunnel.viewmodel.SharedAppViewModel import java.text.Collator import org.koin.compose.viewmodel.koinActivityViewModel +import org.orbitmvi.orbit.compose.collectAsState @Composable fun LanguageScreen(sharedViewModel: SharedAppViewModel = koinActivityViewModel()) { - val appState by sharedViewModel.container.stateFlow.collectAsStateWithLifecycle() + val appState by sharedViewModel.collectAsState() val collator = Collator.getInstance(Locale.current.platformLocale) val locales = diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/components/LearnMoreLinkLabel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/components/LearnMoreLinkLabel.kt index a91aba8cf..ab813240c 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/components/LearnMoreLinkLabel.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/components/LearnMoreLinkLabel.kt @@ -4,9 +4,13 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.* +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLinkStyles +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withLink import com.zaneschepke.wireguardautotunnel.R @Composable diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/dns/DnsSettingsScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/dns/DnsSettingsScreen.kt index 2d6c6ee29..a20b443e3 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/dns/DnsSettingsScreen.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/dns/DnsSettingsScreen.kt @@ -4,45 +4,55 @@ import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Cloud import androidx.compose.material.icons.outlined.Dns +import androidx.compose.material.icons.outlined.NetworkCheck import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.compose.ui.res.vectorResource -import androidx.compose.ui.text.intl.Locale import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.zaneschepke.networkmonitor.PrivateDnsMode import com.zaneschepke.wireguardautotunnel.R -import com.zaneschepke.wireguardautotunnel.data.model.DnsProtocol -import com.zaneschepke.wireguardautotunnel.data.model.DnsProvider -import com.zaneschepke.wireguardautotunnel.ui.LocalNavController +import com.zaneschepke.wireguardautotunnel.domain.enums.DnsProtocol import com.zaneschepke.wireguardautotunnel.ui.common.button.SurfaceRow -import com.zaneschepke.wireguardautotunnel.ui.common.button.SwitchWithDivider import com.zaneschepke.wireguardautotunnel.ui.common.dropdown.LabelledDropdown import com.zaneschepke.wireguardautotunnel.ui.common.label.GroupLabel -import com.zaneschepke.wireguardautotunnel.ui.navigation.Route -import com.zaneschepke.wireguardautotunnel.util.extensions.capitalize +import com.zaneschepke.wireguardautotunnel.ui.common.text.DescriptionText +import com.zaneschepke.wireguardautotunnel.ui.common.textbox.ConfigurationTextBox +import com.zaneschepke.wireguardautotunnel.ui.sideeffect.LocalSideEffect import com.zaneschepke.wireguardautotunnel.viewmodel.DnsViewModel +import com.zaneschepke.wireguardautotunnel.viewmodel.SharedAppViewModel import org.koin.androidx.compose.koinViewModel +import org.koin.compose.viewmodel.koinActivityViewModel +import org.orbitmvi.orbit.compose.collectAsState +import org.orbitmvi.orbit.compose.collectSideEffect @Composable -fun DnsSettingsScreen(viewModel: DnsViewModel = koinViewModel()) { +fun DnsSettingsScreen( + viewModel: DnsViewModel = koinViewModel(), + sharedViewModel: SharedAppViewModel = koinActivityViewModel(), +) { val context = LocalContext.current - val navController = LocalNavController.current - val dnsUiState by viewModel.container.stateFlow.collectAsStateWithLifecycle() + val uiState by viewModel.collectAsState() - if (dnsUiState.isLoading) return - val locale = Locale.current.platformLocale + if (uiState.isLoading) return + + sharedViewModel.collectSideEffect { effect -> + when (effect) { + is LocalSideEffect.SaveChanges -> { + viewModel.save() + } + else -> Unit + } + } Column( horizontalAlignment = Alignment.Start, @@ -50,49 +60,87 @@ fun DnsSettingsScreen(viewModel: DnsViewModel = koinViewModel()) { modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()), ) { Column { - GroupLabel(stringResource(R.string.endpoint), Modifier.padding(horizontal = 16.dp)) + GroupLabel(stringResource(R.string.system), Modifier.padding(horizontal = 16.dp)) + + SurfaceRow( + leading = { Icon(Icons.Outlined.NetworkCheck, contentDescription = null) }, + title = stringResource(R.string.current_system_dns), + description = { + val dnsInfo = uiState.systemDnsInfo + + val descriptionText = + if (dnsInfo == null) { + stringResource(R.string.no_system_dns_information) + } else { + when (dnsInfo.privateDnsMode) { + PrivateDnsMode.OFF -> { + if (dnsInfo.servers.isNotEmpty()) { + stringResource( + R.string.system_dns_servers, + dnsInfo.servers.joinToString(", "), + ) + } else { + stringResource(R.string.no_system_dns_detected) + } + } + + PrivateDnsMode.AUTOMATIC -> { + buildString { + append(stringResource(R.string.private_dns_automatic)) + + append("\n") + + append( + if (dnsInfo.servers.isNotEmpty()) { + stringResource( + R.string.system_dns_servers, + dnsInfo.servers.joinToString(", "), + ) + } else { + stringResource(R.string.no_system_dns_detected) + } + ) + } + } + + PrivateDnsMode.HOSTNAME -> { + stringResource( + R.string.private_dns_hostname, + dnsInfo.privateDnsHostname + ?: stringResource(R.string.unknown), + ) + } + } + } + + DescriptionText(descriptionText) + }, + ) + } + Column { + GroupLabel( + stringResource(R.string.peer_resolution), + Modifier.padding(horizontal = 16.dp), + ) LabelledDropdown( - title = stringResource(R.string.dns_protocol), + title = stringResource(R.string.resolution_method), leading = { Icon(Icons.Outlined.Dns, contentDescription = null) }, - currentValue = dnsUiState.dnsSettings.dnsProtocol, + currentValue = uiState.dnsSettings.dnsProtocol, onSelected = { selected -> selected?.let { viewModel.setDnsProtocol(it) } }, options = DnsProtocol.entries, optionToString = { (it ?: DnsProtocol.SYSTEM).asString(context) }, ) - AnimatedVisibility(dnsUiState.dnsSettings.dnsProtocol != DnsProtocol.SYSTEM) { - LabelledDropdown( - title = stringResource(R.string.dns_provider), - leading = { Icon(Icons.Outlined.Cloud, contentDescription = null) }, - currentValue = - dnsUiState.dnsSettings.dnsEndpoint?.let { DnsProvider.fromAddress(it) } - ?: DnsProvider.CLOUDFLARE, - onSelected = { selected -> selected?.let { viewModel.setDnsProvider(it) } }, - options = DnsProvider.entries, - optionToString = { it?.name ?: DnsProvider.CLOUDFLARE.name }, + AnimatedVisibility(uiState.dnsSettings.dnsProtocol != DnsProtocol.SYSTEM) { + ConfigurationTextBox( + modifier = + Modifier.padding(horizontal = 16.dp).padding(top = 8.dp).fillMaxWidth(), + hint = stringResource(R.string.dns_endpoint_hint), + label = stringResource(R.string.dns_endpoint_label), + value = uiState.dnsSettings.dnsEndpoint ?: "", + isError = uiState.peerResolutionEndpointError != null, + onValueChange = viewModel::setDnsEndpoint, ) } } - Column { - GroupLabel( - stringResource(R.string.tunnel).capitalize(locale), - Modifier.padding(horizontal = 16.dp), - ) - SurfaceRow( - leading = { - Icon(ImageVector.vectorResource(R.drawable.host), contentDescription = null) - }, - title = stringResource(R.string.global_dns_servers), - trailing = { modifier -> - SwitchWithDivider( - checked = dnsUiState.dnsSettings.isGlobalTunnelDnsEnabled, - onClick = { viewModel.setGlobalTunnelDnsEnabled(it) }, - modifier = modifier, - ) - }, - onClick = { - dnsUiState.globalConfig?.let { navController.push(Route.ConfigGlobal(it.id)) } - }, - ) - } } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/globals/TunnelGlobalsScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/globals/TunnelGlobalsScreen.kt new file mode 100644 index 000000000..900c0e42d --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/globals/TunnelGlobalsScreen.kt @@ -0,0 +1,93 @@ +package com.zaneschepke.wireguardautotunnel.ui.screens.settings.globals + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.CallSplit +import androidx.compose.material.icons.outlined.Description +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +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.domain.enums.TunnelMode +import com.zaneschepke.wireguardautotunnel.ui.LocalNavController +import com.zaneschepke.wireguardautotunnel.ui.common.button.SurfaceRow +import com.zaneschepke.wireguardautotunnel.ui.common.button.SwitchWithDivider +import com.zaneschepke.wireguardautotunnel.ui.common.text.DescriptionText +import com.zaneschepke.wireguardautotunnel.ui.navigation.Route +import com.zaneschepke.wireguardautotunnel.ui.theme.Disabled +import com.zaneschepke.wireguardautotunnel.viewmodel.SettingsViewModel +import com.zaneschepke.wireguardautotunnel.viewmodel.SharedAppViewModel +import org.koin.compose.viewmodel.koinActivityViewModel +import org.koin.compose.viewmodel.koinViewModel +import org.orbitmvi.orbit.compose.collectAsState + +@Composable +fun TunnelGlobalsScreen( + viewModel: SettingsViewModel = koinViewModel(), + sharedViewModel: SharedAppViewModel = koinActivityViewModel(), +) { + val navController = LocalNavController.current + val sharedUiState by sharedViewModel.collectAsState() + val uiState by viewModel.collectAsState() + if (uiState.isLoading) return + + Column( + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.Top), + modifier = Modifier.verticalScroll(rememberScrollState()).fillMaxSize(), + ) { + SurfaceRow( + leading = { + Icon( + Icons.AutoMirrored.Outlined.CallSplit, + contentDescription = null, + tint = + if (sharedUiState.tunnelMode == TunnelMode.PROXY) Disabled + else MaterialTheme.colorScheme.onSurface, + ) + }, + enabled = sharedUiState.tunnelMode != TunnelMode.PROXY, + title = stringResource(R.string.global_split_tunneling), + trailing = { modifier -> + SwitchWithDivider( + checked = uiState.settings.isGlobalSplitTunnelEnabled, + onClick = { viewModel.setGlobalSplitTunneling(it) }, + modifier = modifier, + enabled = sharedUiState.tunnelMode != TunnelMode.PROXY, + ) + }, + description = + if (sharedUiState.tunnelMode == TunnelMode.PROXY) { + { + DescriptionText( + stringResource(R.string.unavailable_in_mode), + disabled = true, + ) + } + } else null, + onClick = { + uiState.globalTunnelConfig?.let { + navController.push(Route.SplitTunnelGlobal(id = it.id)) + } + }, + ) + SurfaceRow( + leading = { Icon(Icons.Outlined.Description, contentDescription = null) }, + title = stringResource(R.string.configuration_globals), + onClick = { + uiState.globalTunnelConfig?.let { + navController.push(Route.ConfigGlobal(id = it.id)) + } + }, + ) + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/integrations/AndroidIntegrationsScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/integrations/AndroidIntegrationsScreen.kt index 943a63bad..f62d6e5d7 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/integrations/AndroidIntegrationsScreen.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/integrations/AndroidIntegrationsScreen.kt @@ -35,32 +35,29 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.ui.LocalIsAndroidTV import com.zaneschepke.wireguardautotunnel.ui.common.button.SurfaceRow import com.zaneschepke.wireguardautotunnel.ui.common.button.ThemedSwitch import com.zaneschepke.wireguardautotunnel.ui.common.functions.rememberClipboardHelper import com.zaneschepke.wireguardautotunnel.ui.common.label.GroupLabel -import com.zaneschepke.wireguardautotunnel.ui.common.security.SecureScreenFromRecording import com.zaneschepke.wireguardautotunnel.ui.common.text.DescriptionText import com.zaneschepke.wireguardautotunnel.util.extensions.launchVpnSettings import com.zaneschepke.wireguardautotunnel.viewmodel.SettingsViewModel import org.koin.androidx.compose.koinViewModel +import org.orbitmvi.orbit.compose.collectAsState @Composable fun AndroidIntegrationsScreen(viewModel: SettingsViewModel = koinViewModel()) { val context = LocalContext.current val isTv = LocalIsAndroidTV.current - val settingsState by viewModel.container.stateFlow.collectAsStateWithLifecycle() + val settingsState by viewModel.collectAsState() if (settingsState.isLoading) return val clipboard = rememberClipboardHelper() - SecureScreenFromRecording() - val isAlwaysOnEnabled = settingsState.settings.isAlwaysOnVpnEnabled LaunchedEffect(isAlwaysOnEnabled) { diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/lockdown/LockdownSettingsScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/lockdown/LockdownSettingsScreen.kt index 7e696a20a..94d5e0d83 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/lockdown/LockdownSettingsScreen.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/lockdown/LockdownSettingsScreen.kt @@ -15,16 +15,12 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.ui.common.button.SurfaceRow import com.zaneschepke.wireguardautotunnel.ui.common.button.ThemedSwitch @@ -36,6 +32,7 @@ import com.zaneschepke.wireguardautotunnel.viewmodel.LockdownViewModel import com.zaneschepke.wireguardautotunnel.viewmodel.SharedAppViewModel import org.koin.androidx.compose.koinViewModel import org.koin.compose.viewmodel.koinActivityViewModel +import org.orbitmvi.orbit.compose.collectAsState import org.orbitmvi.orbit.compose.collectSideEffect @Composable @@ -44,14 +41,10 @@ fun LockdownSettingsScreen( sharedViewModel: SharedAppViewModel = koinActivityViewModel(), ) { - val uiState by viewModel.container.stateFlow.collectAsStateWithLifecycle() + val uiState by viewModel.collectAsState() if (uiState.isLoading) return - var metered by remember { mutableStateOf(uiState.lockdownSettings.metered) } - var dualStack by remember { mutableStateOf(uiState.lockdownSettings.dualStack) } - var bypassLan by remember { mutableStateOf(uiState.lockdownSettings.bypassLan) } - sharedViewModel.collectSideEffect { if (it is LocalSideEffect.SaveChanges) viewModel.setShowSaveModal(true) } @@ -59,15 +52,7 @@ fun LockdownSettingsScreen( if (uiState.showSaveModal) { InfoDialog( onDismiss = { viewModel.setShowSaveModal(false) }, - onAttest = { - viewModel.setLockdownSettings( - uiState.lockdownSettings.copy( - metered = metered, - dualStack = dualStack, - bypassLan = bypassLan, - ) - ) - }, + onAttest = { viewModel.setLockdownSettings() }, title = stringResource(R.string.save_changes), body = { Text( @@ -103,15 +88,25 @@ fun LockdownSettingsScreen( ), ) }, - trailing = { ThemedSwitch(checked = bypassLan, onClick = { bypassLan = it }) }, - onClick = { bypassLan = !bypassLan }, + trailing = { + ThemedSwitch( + checked = uiState.bypassLan, + onClick = { viewModel.setBypassLan(it) }, + ) + }, + onClick = { viewModel.setBypassLan(!uiState.bypassLan) }, ) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { SurfaceRow( leading = { Icon(Icons.Outlined.DataUsage, contentDescription = null) }, title = stringResource(R.string.metered_tunnel), - trailing = { ThemedSwitch(checked = metered, onClick = { metered = it }) }, - onClick = { metered = !metered }, + trailing = { + ThemedSwitch( + checked = uiState.metered, + onClick = { viewModel.setMetered(it) }, + ) + }, + onClick = { viewModel.setMetered(!uiState.metered) }, ) } SurfaceRow( @@ -120,8 +115,13 @@ fun LockdownSettingsScreen( }, title = stringResource(R.string.dual_stack), description = { DescriptionText(stringResource(R.string.dual_stack_description)) }, - trailing = { ThemedSwitch(checked = dualStack, onClick = { dualStack = it }) }, - onClick = { dualStack = !dualStack }, + trailing = { + ThemedSwitch( + checked = uiState.dualStack, + onClick = { viewModel.setDualStack(it) }, + ) + }, + onClick = { viewModel.setDualStack(!uiState.dualStack) }, ) } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/monitoring/MonitoringScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/monitoring/MonitoringScreen.kt new file mode 100644 index 000000000..3895013bb --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/monitoring/MonitoringScreen.kt @@ -0,0 +1,72 @@ +package com.zaneschepke.wireguardautotunnel.ui.screens.settings.monitoring + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Analytics +import androidx.compose.material.icons.outlined.Timer +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.domain.enums.StatisticRefresh +import com.zaneschepke.wireguardautotunnel.ui.common.button.SurfaceRow +import com.zaneschepke.wireguardautotunnel.ui.common.button.ThemedSwitch +import com.zaneschepke.wireguardautotunnel.ui.common.dropdown.LabelledDropdown +import com.zaneschepke.wireguardautotunnel.ui.common.label.GroupLabel +import com.zaneschepke.wireguardautotunnel.viewmodel.MonitoringViewModel +import org.koin.androidx.compose.koinViewModel +import org.orbitmvi.orbit.compose.collectAsState + +@Composable +fun MonitoringScreen(viewModel: MonitoringViewModel = koinViewModel()) { + val context = LocalContext.current + val uiState by viewModel.collectAsState() + + if (uiState.isLoading) return + + Column( + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.Top), + modifier = Modifier.verticalScroll(rememberScrollState()).fillMaxSize(), + ) { + Column { + GroupLabel( + stringResource(R.string.statistics), + modifier = Modifier.padding(horizontal = 16.dp), + ) + SurfaceRow( + leading = { Icon(Icons.Outlined.Analytics, contentDescription = null) }, + title = stringResource(R.string.tunnel_statistics), + trailing = { + ThemedSwitch( + checked = uiState.tunnelStatisticsEnabled, + onClick = { viewModel.onLiveTunnelStatisticsChanged(it) }, + ) + }, + onClick = { + viewModel.onLiveTunnelStatisticsChanged(!uiState.tunnelStatisticsEnabled) + }, + ) + LabelledDropdown( + title = stringResource(R.string.refresh_rate), + leading = { Icon(Icons.Outlined.Timer, contentDescription = null) }, + currentValue = uiState.statisticRefresh, + onSelected = { selected -> + selected?.let { viewModel.onStatisticsIntervalChanged(it) } + }, + options = StatisticRefresh.entries, + optionToString = { (it ?: StatisticRefresh.BALANCED).asString(context) }, + ) + } + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/monitoring/TunnelMonitoringScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/monitoring/TunnelMonitoringScreen.kt deleted file mode 100644 index c9ab61363..000000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/monitoring/TunnelMonitoringScreen.kt +++ /dev/null @@ -1,111 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.ui.screens.settings.monitoring - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Adjust -import androidx.compose.material.icons.outlined.QueryStats -import androidx.compose.material.icons.outlined.Replay -import androidx.compose.material.icons.outlined.Timer -import androidx.compose.material.icons.outlined.TimerOff -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.zaneschepke.wireguardautotunnel.R -import com.zaneschepke.wireguardautotunnel.ui.LocalNavController -import com.zaneschepke.wireguardautotunnel.ui.common.button.SurfaceRow -import com.zaneschepke.wireguardautotunnel.ui.common.button.ThemedSwitch -import com.zaneschepke.wireguardautotunnel.ui.common.dropdown.LabelledDropdown -import com.zaneschepke.wireguardautotunnel.ui.common.label.GroupLabel -import com.zaneschepke.wireguardautotunnel.ui.navigation.Route -import com.zaneschepke.wireguardautotunnel.viewmodel.MonitoringViewModel -import org.koin.androidx.compose.koinViewModel - -@Composable -fun TunnelMonitoringScreen(viewModel: MonitoringViewModel = koinViewModel()) { - val navController = LocalNavController.current - val monitoringUiState by viewModel.container.stateFlow.collectAsStateWithLifecycle() - - if (monitoringUiState.isLoading) return - - Column( - horizontalAlignment = Alignment.Start, - verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.Top), - modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()), - ) { - Column { - GroupLabel( - stringResource(R.string.ping), - modifier = Modifier.padding(horizontal = 16.dp), - ) - LabelledDropdown( - title = stringResource(R.string.tunnel_ping_interval), - leading = { Icon(Icons.Outlined.Timer, contentDescription = null) }, - currentValue = monitoringUiState.monitoringSettings.tunnelPingIntervalSeconds, - onSelected = { selected -> - selected?.let { viewModel.setTunnelPingIntervalSeconds(it) } - }, - options = (10..60).step(10).toList(), - optionToString = { it?.toString() ?: stringResource(R.string._default) }, - ) - LabelledDropdown( - title = stringResource(R.string.attempts_per_interval), - leading = { Icon(Icons.Outlined.Replay, contentDescription = null) }, - currentValue = monitoringUiState.monitoringSettings.tunnelPingAttempts, - onSelected = { selected -> selected?.let { viewModel.setTunnelPingAttempts(it) } }, - options = (1..5).toList(), - optionToString = { it?.toString() ?: stringResource(R.string._default) }, - ) - LabelledDropdown( - title = stringResource(R.string.ping_timeout), - leading = { Icon(Icons.Outlined.TimerOff, contentDescription = null) }, - currentValue = monitoringUiState.monitoringSettings.tunnelPingTimeoutSeconds, - description = { - Text( - text = stringResource(R.string.timeout_all_attempts), - style = - MaterialTheme.typography.bodySmall.copy( - color = MaterialTheme.colorScheme.outline - ), - ) - }, - onSelected = { selected -> - selected?.let { viewModel.setTunnelPingTimeoutSeconds(it) } - }, - options = (10..20).toList() + null, - optionToString = { it?.toString() ?: stringResource(R.string._default) }, - ) - SurfaceRow( - leading = { Icon(Icons.Outlined.QueryStats, contentDescription = null) }, - title = stringResource(R.string.display_detailed_ping_stats), - trailing = { - ThemedSwitch( - checked = monitoringUiState.monitoringSettings.showDetailedPingStats, - onClick = { viewModel.setDetailedPingStats(it) }, - ) - }, - onClick = { - viewModel.setDetailedPingStats( - !monitoringUiState.monitoringSettings.showDetailedPingStats - ) - }, - ) - SurfaceRow( - leading = { Icon(Icons.Outlined.Adjust, contentDescription = null) }, - title = stringResource(R.string.set_custom_ping_target), - onClick = { navController.push(Route.PingTarget) }, - ) - } - } -} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/monitoring/logs/LogsScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/monitoring/logs/LogsScreen.kt index 1491279ed..0137585f5 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/monitoring/logs/LogsScreen.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/monitoring/logs/LogsScreen.kt @@ -17,15 +17,16 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontStyle -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.ui.screens.settings.monitoring.logs.components.LogList import com.zaneschepke.wireguardautotunnel.ui.screens.settings.monitoring.logs.components.LogsBottomSheet import com.zaneschepke.wireguardautotunnel.ui.sideeffect.LocalSideEffect +import com.zaneschepke.wireguardautotunnel.util.StringValue import com.zaneschepke.wireguardautotunnel.viewmodel.LoggerViewModel import com.zaneschepke.wireguardautotunnel.viewmodel.SharedAppViewModel import org.koin.androidx.compose.koinViewModel import org.koin.compose.viewmodel.koinActivityViewModel +import org.orbitmvi.orbit.compose.collectAsState import org.orbitmvi.orbit.compose.collectSideEffect @Composable @@ -33,7 +34,7 @@ fun LogsScreen( viewModel: LoggerViewModel = koinViewModel(), sharedViewModel: SharedAppViewModel = koinActivityViewModel(), ) { - val loggerState by viewModel.container.stateFlow.collectAsStateWithLifecycle() + val loggerState by viewModel.collectAsState() val lazyColumnListState = rememberLazyListState() var isAutoScrolling by rememberSaveable { mutableStateOf(true) } @@ -77,17 +78,28 @@ fun LogsScreen( if (showLogsSheet) { LogsBottomSheet( - { uri -> + onExport = { uri -> viewModel.exportLogs(uri) showLogsSheet = false }, - { + onDelete = { viewModel.deleteLogs() showLogsSheet = false }, - ) { - showLogsSheet = false - } + onCanceled = { + sharedViewModel.showSnackMessage( + StringValue.StringResource(R.string.export_canceled) + ) + showLogsSheet = false + }, + onUnsupported = { + sharedViewModel.showSnackMessage( + StringValue.StringResource(R.string.export_unsupported) + ) + showLogsSheet = false + }, + onDismiss = { showLogsSheet = false }, + ) } if (loggerState.messages.isEmpty()) { diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/monitoring/logs/components/LogsBottomSheet.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/monitoring/logs/components/LogsBottomSheet.kt index 55353ff08..e312f529e 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/monitoring/logs/components/LogsBottomSheet.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/monitoring/logs/components/LogsBottomSheet.kt @@ -16,29 +16,37 @@ import com.zaneschepke.wireguardautotunnel.ui.common.sheet.SheetOption import com.zaneschepke.wireguardautotunnel.util.Constants import com.zaneschepke.wireguardautotunnel.util.FileUtils import com.zaneschepke.wireguardautotunnel.util.extensions.hasSAFSupport +import com.zaneschepke.wireguardautotunnel.util.extensions.toUserFriendlyTimestamp +import java.time.Instant @OptIn(ExperimentalMaterial3Api::class) @Composable -fun LogsBottomSheet(onExport: (file: Uri?) -> Unit, onDelete: () -> Unit, onDismiss: () -> Unit) { +fun LogsBottomSheet( + onExport: (Uri) -> Unit, + onDelete: () -> Unit, + onCanceled: () -> Unit, + onUnsupported: () -> Unit, + onDismiss: () -> Unit, +) { val context = LocalContext.current - val selectedTunnelsExportLauncher = + val exportLauncher = rememberFileExportLauncherForResult( mimeType = FileUtils.ZIP_FILE_MIME_TYPE, - onResult = { file -> - if (file != null) { - onExport(file) - } else onDismiss() - }, + onSuccess = { uri -> onExport(uri) }, + onCanceled = onCanceled, + onUnsupported = onUnsupported, ) fun handleFileExport() { if (context.hasSAFSupport(FileUtils.ZIP_FILE_MIME_TYPE)) { - selectedTunnelsExportLauncher.launch( - "${Constants.BASE_LOG_FILE_NAME}_${BuildConfig.VERSION_NAME}_${BuildConfig.FLAVOR}.zip" - ) + val timestamp = Instant.now().toUserFriendlyTimestamp() + val fileName = + "${Constants.BASE_LOG_FILE_NAME}_${timestamp}_${BuildConfig.VERSION_NAME}_${BuildConfig.FLAVOR}.zip" + + exportLauncher.launch(fileName) } else { - onExport(null) + onUnsupported() } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/monitoring/ping/PingTargetScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/monitoring/ping/PingTargetScreen.kt deleted file mode 100644 index bf366d26f..000000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/monitoring/ping/PingTargetScreen.kt +++ /dev/null @@ -1,166 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.ui.screens.settings.monitoring.ping - -import android.util.Patterns -import androidx.compose.foundation.gestures.ScrollableDefaults -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -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.foundation.rememberOverscrollEffect -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Save -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -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.platform.LocalSoftwareKeyboardController -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardCapitalization -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.zaneschepke.wireguardautotunnel.R -import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConfig -import com.zaneschepke.wireguardautotunnel.ui.common.button.SurfaceRow -import com.zaneschepke.wireguardautotunnel.ui.common.label.GroupLabel -import com.zaneschepke.wireguardautotunnel.ui.common.text.DescriptionText -import com.zaneschepke.wireguardautotunnel.ui.common.textbox.CustomTextField -import com.zaneschepke.wireguardautotunnel.util.extensions.isValidIpv4orIpv6Address -import com.zaneschepke.wireguardautotunnel.viewmodel.MonitoringViewModel -import org.koin.androidx.compose.koinViewModel - -@Composable -fun PingTargetScreen(viewModel: MonitoringViewModel = koinViewModel()) { - - val settingsState by viewModel.container.stateFlow.collectAsStateWithLifecycle() - - if (settingsState.isLoading) return - - var selectedTunnel by remember { mutableStateOf(null) } - - var isError by remember { mutableStateOf(false) } - - LazyColumn( - horizontalAlignment = Alignment.Start, - verticalArrangement = Arrangement.Top, - modifier = Modifier.overscroll(rememberOverscrollEffect()), - state = rememberLazyListState(), - userScrollEnabled = true, - reverseLayout = false, - flingBehavior = ScrollableDefaults.flingBehavior(), - ) { - item { - DescriptionText( - stringResource(R.string.ping_target_description), - modifier = Modifier.padding(horizontal = 16.dp).padding(bottom = 16.dp), - ) - GroupLabel( - stringResource(R.string.tunnels), - modifier = Modifier.padding(horizontal = 16.dp), - ) - } - items(settingsState.tunnels, key = { it.id }) { tunnel -> - SurfaceRow( - title = tunnel.name, - onClick = { selectedTunnel = tunnel }, - description = { - DescriptionText( - stringResource( - R.string.ping_target_template, - tunnel.pingTarget ?: stringResource(R.string._default), - ) - ) - }, - expandedContent = { - if (selectedTunnel?.id == tunnel.id) { - val focusRequester = remember { FocusRequester() } - val keyboardController = LocalSoftwareKeyboardController.current - - var currentText by remember { mutableStateOf(tunnel.pingTarget ?: "") } - var isError by remember { mutableStateOf(false) } - - LaunchedEffect(currentText) { isError = false } - - LaunchedEffect(Unit) { - focusRequester.requestFocus() - keyboardController?.show() - } - - fun onClick() { - val isValid = - currentText.isBlank() || - currentText.isValidIpv4orIpv6Address() || - Patterns.DOMAIN_NAME.matcher(currentText).matches() - if (!isValid) { - isError = true - return - } - viewModel.setPingTarget(tunnel, currentText) - selectedTunnel = null - } - - CustomTextField( - isError = isError, - textStyle = - MaterialTheme.typography.bodySmall.copy( - color = MaterialTheme.colorScheme.onSurface - ), - value = currentText, - onValueChange = { currentText = it }, - interactionSource = remember { MutableInteractionSource() }, - label = { - Text( - stringResource(R.string.set_ping_target), - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.labelMedium, - ) - }, - supportingText = { - DescriptionText(stringResource(R.string.ip_or_hostname)) - }, - containerColor = MaterialTheme.colorScheme.surface, - modifier = - Modifier.fillMaxWidth() - .focusRequester(focusRequester) - .padding(top = 8.dp), - singleLine = true, - keyboardOptions = - KeyboardOptions( - capitalization = KeyboardCapitalization.None, - imeAction = ImeAction.Done, - ), - keyboardActions = KeyboardActions(onDone = { onClick() }), - trailing = { - if (currentText != "") { - IconButton(onClick = { onClick() }) { - Icon( - imageVector = Icons.Outlined.Save, - contentDescription = stringResource(R.string.save), - tint = MaterialTheme.colorScheme.primary, - ) - } - } - }, - ) - } - }, - ) - } - } -} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/proxy/ProxySettingsScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/proxy/ProxySettingsScreen.kt index 0649d6dcb..ece68f3c2 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/proxy/ProxySettingsScreen.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/proxy/ProxySettingsScreen.kt @@ -11,7 +11,8 @@ import androidx.compose.material.icons.outlined.RemoveRedEye import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.Text -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalSoftwareKeyboardController @@ -21,20 +22,19 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.intl.Locale import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.domain.model.ProxySettings import com.zaneschepke.wireguardautotunnel.ui.common.button.SurfaceRow import com.zaneschepke.wireguardautotunnel.ui.common.button.ThemedSwitch import com.zaneschepke.wireguardautotunnel.ui.common.dialog.InfoDialog import com.zaneschepke.wireguardautotunnel.ui.common.label.GroupLabel -import com.zaneschepke.wireguardautotunnel.ui.common.security.SecureScreenFromRecording import com.zaneschepke.wireguardautotunnel.ui.common.textbox.ConfigurationTextBox import com.zaneschepke.wireguardautotunnel.ui.sideeffect.LocalSideEffect import com.zaneschepke.wireguardautotunnel.viewmodel.ProxySettingsViewModel import com.zaneschepke.wireguardautotunnel.viewmodel.SharedAppViewModel import org.koin.androidx.compose.koinViewModel import org.koin.compose.viewmodel.koinActivityViewModel +import org.orbitmvi.orbit.compose.collectAsState import org.orbitmvi.orbit.compose.collectSideEffect @Composable @@ -42,58 +42,28 @@ fun ProxySettingsScreen( viewModel: ProxySettingsViewModel = koinViewModel(), sharedViewModel: SharedAppViewModel = koinActivityViewModel(), ) { - val uiState by viewModel.container.stateFlow.collectAsStateWithLifecycle() + val uiState by viewModel.collectAsState() if (uiState.isLoading) return val locale = Locale.current.platformLocale - val proxySettings by remember(uiState) { mutableStateOf(uiState.proxySettings) } - - var socks5Enabled by - remember(proxySettings) { mutableStateOf(uiState.proxySettings.socks5ProxyEnabled) } - var httpEnabled by - remember(proxySettings) { mutableStateOf(uiState.proxySettings.httpProxyEnabled) } - var socksBindAddress by - remember(proxySettings) { - mutableStateOf(uiState.proxySettings.socks5ProxyBindAddress ?: "") - } - var httpBindAddress by - remember(proxySettings) { mutableStateOf(uiState.proxySettings.httpProxyBindAddress ?: "") } - var proxyUsername by - remember(proxySettings) { mutableStateOf(uiState.proxySettings.proxyUsername ?: "") } - var proxyPassword by - remember(proxySettings) { mutableStateOf(uiState.proxySettings.proxyPassword ?: "") } - var passwordVisible by remember(proxySettings) { mutableStateOf(uiState.passwordVisible) } - val keyboardController = LocalSoftwareKeyboardController.current val keyboardActions = KeyboardActions(onDone = { keyboardController?.hide() }) val keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done) - fun saveChanges() { - viewModel.save( - ProxySettings( - socks5ProxyEnabled = socks5Enabled, - socks5ProxyBindAddress = socksBindAddress, - httpProxyEnabled = httpEnabled, - httpProxyBindAddress = httpBindAddress, - proxyUsername = proxyUsername, - proxyPassword = proxyPassword, - ) - ) - } - sharedViewModel.collectSideEffect { sideEffect -> if (sideEffect is LocalSideEffect.SaveChanges) { - if (uiState.activeTuns.isNotEmpty()) viewModel.setShowSaveModal(true) else saveChanges() + if (uiState.backendStatus.activeTunnels.isNotEmpty()) viewModel.setShowSaveModal(true) + else viewModel.save() } } if (uiState.showSaveModal) { InfoDialog( onDismiss = { viewModel.setShowSaveModal(false) }, - onAttest = { saveChanges() }, + onAttest = { viewModel.save() }, title = stringResource(R.string.save_changes), body = { Text( @@ -107,8 +77,6 @@ fun ProxySettingsScreen( ) } - SecureScreenFromRecording() - Column( horizontalAlignment = Alignment.Start, verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.Top), @@ -119,11 +87,14 @@ fun ProxySettingsScreen( leading = { Icon(Icons.Outlined.Forward5, contentDescription = null) }, title = stringResource(R.string.socks_5_proxy), trailing = { - ThemedSwitch(checked = socks5Enabled, onClick = { socks5Enabled = it }) + ThemedSwitch( + checked = uiState.socks5Enabled, + onClick = { viewModel.onSocks5EnabledChanged(it) }, + ) }, - onClick = { socks5Enabled = !socks5Enabled }, + onClick = { viewModel.onSocks5EnabledChanged(!uiState.socks5Enabled) }, ) - if (socks5Enabled) { + if (uiState.socks5Enabled) { ConfigurationTextBox( modifier = Modifier.padding(horizontal = 16.dp), hint = @@ -132,11 +103,11 @@ fun ProxySettingsScreen( ProxySettings.DEFAULT_SOCKS_BIND_ADDRESS, ), label = stringResource(R.string.socks_5_bind_address), - value = socksBindAddress, + value = uiState.socksBindAddress, isError = uiState.isSocks5BindAddressError, onValueChange = { if (uiState.isSocks5BindAddressError) viewModel.clearSocks5BindError() - socksBindAddress = it + viewModel.onSocksBindChanged(it) }, ) } @@ -145,10 +116,15 @@ fun ProxySettingsScreen( SurfaceRow( leading = { Icon(Icons.Outlined.Http, contentDescription = null) }, title = stringResource(R.string.http_proxy), - trailing = { ThemedSwitch(checked = httpEnabled, onClick = { httpEnabled = it }) }, - onClick = { httpEnabled = !httpEnabled }, + trailing = { + ThemedSwitch( + checked = uiState.httpEnabled, + onClick = { viewModel.onHttpEnabledChanged(it) }, + ) + }, + onClick = { viewModel.onHttpEnabledChanged(!uiState.httpEnabled) }, ) - if (httpEnabled) { + if (uiState.httpEnabled) { ConfigurationTextBox( hint = stringResource( @@ -156,17 +132,17 @@ fun ProxySettingsScreen( ProxySettings.DEFAULT_HTTP_BIND_ADDRESS, ), label = stringResource(R.string.http_bind_address), - value = httpBindAddress, + value = uiState.httpBindAddress, isError = uiState.isHttpBindAddressError, onValueChange = { if (uiState.isSocks5BindAddressError) viewModel.clearHttpBindError() - httpBindAddress = it + viewModel.onHttpBindChanged(it) }, modifier = Modifier.padding(horizontal = 12.dp), ) } } - if (socks5Enabled || httpEnabled) { + if (uiState.socks5Enabled || uiState.httpEnabled) { Column( horizontalAlignment = Alignment.Start, verticalArrangement = Arrangement.spacedBy(24.dp, Alignment.Top), @@ -179,10 +155,10 @@ fun ProxySettingsScreen( ) ) ConfigurationTextBox( - value = proxyUsername, + value = uiState.proxyUsername, onValueChange = { if (uiState.isUserNameError) viewModel.clearUsernameError() - proxyUsername = it + viewModel.onUsernameChanged(it) }, label = stringResource(R.string.username), isError = uiState.isUserNameError, @@ -191,10 +167,10 @@ fun ProxySettingsScreen( keyboardOptions = keyboardOptions, ) ConfigurationTextBox( - value = proxyPassword, + value = uiState.proxyPassword, onValueChange = { if (uiState.isUserNameError) viewModel.clearPasswordError() - proxyPassword = it + viewModel.onPasswordChanged(it) }, label = stringResource(R.string.password), isError = uiState.isPasswordError, @@ -202,7 +178,11 @@ fun ProxySettingsScreen( keyboardActions = keyboardActions, keyboardOptions = keyboardOptions, trailing = { - IconButton(onClick = { passwordVisible = !passwordVisible }) { + IconButton( + onClick = { + viewModel.onPasswordVisibilityChanged(!uiState.passwordVisible) + } + ) { Icon( Icons.Outlined.RemoveRedEye, stringResource(R.string.show_password), @@ -210,7 +190,7 @@ fun ProxySettingsScreen( } }, visualTransformation = - if (!passwordVisible) PasswordVisualTransformation() + if (!uiState.passwordVisible) PasswordVisualTransformation() else VisualTransformation.None, ) } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/proxy/compoents/AppModeBottomSheet.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/proxy/compoents/AppModeBottomSheet.kt index 19654d97c..8cae1a195 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/proxy/compoents/AppModeBottomSheet.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/proxy/compoents/AppModeBottomSheet.kt @@ -2,39 +2,34 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.settings.proxy.compoents import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalContext -import com.zaneschepke.wireguardautotunnel.data.model.AppMode -import com.zaneschepke.wireguardautotunnel.ui.LocalIsAndroidTV +import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelMode import com.zaneschepke.wireguardautotunnel.ui.common.sheet.CustomBottomSheet import com.zaneschepke.wireguardautotunnel.ui.common.sheet.SheetOption import com.zaneschepke.wireguardautotunnel.util.extensions.asIcon import com.zaneschepke.wireguardautotunnel.util.extensions.asTitleString -import com.zaneschepke.wireguardautotunnel.util.extensions.description +import kotlin.enums.enumEntries @Composable fun AppModeBottomSheet( - onAppModeChange: (AppMode) -> Unit, - appMode: AppMode, + onAppModeChange: (TunnelMode) -> Unit, + tunnelMode: TunnelMode, onDismiss: () -> Unit, ) { val context = LocalContext.current - val isTv = LocalIsAndroidTV.current CustomBottomSheet( - enumValues() - .filterNot { isTv && it == AppMode.KERNEL } - .map { - val icon = it.asIcon() - SheetOption( - icon, - label = it.asTitleString(context), - onClick = { - onDismiss() - onAppModeChange(it) - }, - selected = appMode == it, - description = it.description(context), - ) - } + enumEntries().map { + val icon = it.asIcon() + SheetOption( + icon, + label = it.asTitleString(context), + onClick = { + onDismiss() + onAppModeChange(it) + }, + selected = tunnelMode == it, + ) + } ) { onDismiss() } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/security/SecurityScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/security/SecurityScreen.kt new file mode 100644 index 000000000..8e44c46b9 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/security/SecurityScreen.kt @@ -0,0 +1,76 @@ +package com.zaneschepke.wireguardautotunnel.ui.screens.settings.security + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Pin +import androidx.compose.material.icons.outlined.ScreenLockPortrait +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +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.ui.LocalNavController +import com.zaneschepke.wireguardautotunnel.ui.common.button.SurfaceRow +import com.zaneschepke.wireguardautotunnel.ui.common.button.ThemedSwitch +import com.zaneschepke.wireguardautotunnel.ui.navigation.Route +import com.zaneschepke.wireguardautotunnel.viewmodel.SharedAppViewModel +import org.koin.compose.viewmodel.koinActivityViewModel +import org.orbitmvi.orbit.compose.collectAsState + +@Composable +fun SecurityScreen(viewModel: SharedAppViewModel = koinActivityViewModel()) { + + val uiState by viewModel.collectAsState() + + val navController = LocalNavController.current + + Column( + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.Top), + modifier = Modifier.verticalScroll(rememberScrollState()).fillMaxSize(), + ) { + SurfaceRow( + leading = { Icon(Icons.Outlined.ScreenLockPortrait, contentDescription = null) }, + title = stringResource(R.string.screen_recording_protection), + trailing = { + ThemedSwitch( + checked = uiState.isScreenRecordingProtectionEnabled, + onClick = { viewModel.setScreenRecordingSecurity(it) }, + ) + }, + onClick = { + viewModel.setScreenRecordingSecurity(!uiState.isScreenRecordingProtectionEnabled) + }, + ) + SurfaceRow( + leading = { Icon(Icons.Outlined.Pin, contentDescription = null) }, + title = stringResource(R.string.enable_app_lock), + trailing = { + ThemedSwitch( + checked = uiState.pinLockEnabled, + onClick = { + if (it) { + navController.push(Route.Lock) + } else { + viewModel.setPinLockEnabled(false) + } + }, + ) + }, + onClick = { + if (!uiState.pinLockEnabled) { + navController.push(Route.Lock) + } else { + viewModel.setPinLockEnabled(false) + } + }, + ) + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/support/SupportScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/support/SupportScreen.kt index 0f585e2d3..2daa40539 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/support/SupportScreen.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/support/SupportScreen.kt @@ -1,25 +1,45 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.support -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.Launch -import androidx.compose.material.icons.outlined.* +import androidx.compose.material.icons.outlined.Balance +import androidx.compose.material.icons.outlined.Book +import androidx.compose.material.icons.outlined.Favorite +import androidx.compose.material.icons.outlined.InstallMobile +import androidx.compose.material.icons.outlined.Mail +import androidx.compose.material.icons.outlined.Memory +import androidx.compose.material.icons.outlined.Policy +import androidx.compose.material.icons.outlined.Reviews +import androidx.compose.material.icons.outlined.Translate +import androidx.compose.material.icons.outlined.Web import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue 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.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.zaneschepke.wireguardautotunnel.BuildConfig import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.ui.LocalIsAndroidTV import com.zaneschepke.wireguardautotunnel.ui.LocalNavController import com.zaneschepke.wireguardautotunnel.ui.common.button.SurfaceRow import com.zaneschepke.wireguardautotunnel.ui.common.functions.rememberClipboardHelper @@ -29,16 +49,24 @@ import com.zaneschepke.wireguardautotunnel.ui.navigation.Route import com.zaneschepke.wireguardautotunnel.ui.screens.support.components.PermissionDialog import com.zaneschepke.wireguardautotunnel.ui.screens.support.components.UpdateDialog import com.zaneschepke.wireguardautotunnel.util.Constants -import com.zaneschepke.wireguardautotunnel.util.extensions.* +import com.zaneschepke.wireguardautotunnel.util.extensions.launchPlayStoreListing +import com.zaneschepke.wireguardautotunnel.util.extensions.launchPlayStoreReview +import com.zaneschepke.wireguardautotunnel.util.extensions.launchSupportEmail +import com.zaneschepke.wireguardautotunnel.util.extensions.openWebUrl +import com.zaneschepke.wireguardautotunnel.util.extensions.showToast import com.zaneschepke.wireguardautotunnel.viewmodel.SupportViewModel import org.koin.androidx.compose.koinViewModel +import org.orbitmvi.orbit.compose.collectAsState @Composable fun SupportScreen(viewModel: SupportViewModel = koinViewModel()) { val context = LocalContext.current val navController = LocalNavController.current + val isTv = LocalIsAndroidTV.current - val supportState by viewModel.container.stateFlow.collectAsStateWithLifecycle() + val supportState by viewModel.collectAsState() + + if (supportState.isLoading) return val clipboardManager = rememberClipboardHelper() @@ -47,6 +75,14 @@ fun SupportScreen(viewModel: SupportViewModel = koinViewModel()) { if(BuildConfig.DEBUG) "-debug" else "" }" } + val focusRequester = remember { FocusRequester() } + + LaunchedEffect(Unit) { + if (isTv) { + focusRequester.requestFocus() + } + } + var showPermissionDialog by rememberSaveable { mutableStateOf(false) } if (supportState.appUpdate != null) { @@ -77,6 +113,7 @@ fun SupportScreen(viewModel: SupportViewModel = koinViewModel()) { leading = { Icon(Icons.Outlined.Favorite, contentDescription = null) }, title = stringResource(R.string.donate), onClick = { navController.push(Route.Donate) }, + modifier = Modifier.focusRequester(focusRequester), ) SurfaceRow( stringResource(R.string.docs_description), diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/support/components/UpdateDialog.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/support/components/UpdateDialog.kt index 2b7cde5fc..67b691b76 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/support/components/UpdateDialog.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/support/components/UpdateDialog.kt @@ -16,10 +16,13 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.* +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLinkStyles +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withLink import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.zaneschepke.wireguardautotunnel.BuildConfig import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.ui.common.dialog.InfoDialog @@ -27,11 +30,12 @@ import com.zaneschepke.wireguardautotunnel.util.Constants import com.zaneschepke.wireguardautotunnel.util.extensions.canInstallPackages import com.zaneschepke.wireguardautotunnel.util.extensions.openWebUrl import com.zaneschepke.wireguardautotunnel.viewmodel.SupportViewModel +import org.orbitmvi.orbit.compose.collectAsState @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun UpdateDialog(viewModel: SupportViewModel, context: Context, onPermissionNeeded: () -> Unit) { - val supportState by viewModel.container.stateFlow.collectAsStateWithLifecycle() + val supportState by viewModel.collectAsState() InfoDialog( onDismiss = { viewModel.dismissUpdate() }, diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/support/donate/DonateScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/support/donate/DonateScreen.kt index b9decbfd4..84b7308ff 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/support/donate/DonateScreen.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/support/donate/DonateScreen.kt @@ -1,6 +1,10 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.support.donate -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons @@ -18,7 +22,6 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.zaneschepke.wireguardautotunnel.BuildConfig import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.ui.LocalNavController @@ -33,10 +36,11 @@ import com.zaneschepke.wireguardautotunnel.util.Constants import com.zaneschepke.wireguardautotunnel.util.extensions.openWebUrl import com.zaneschepke.wireguardautotunnel.viewmodel.SettingsViewModel import org.koin.androidx.compose.koinViewModel +import org.orbitmvi.orbit.compose.collectAsState @Composable fun DonateScreen(viewModel: SettingsViewModel = koinViewModel()) { - val uiState by viewModel.container.stateFlow.collectAsStateWithLifecycle() + val uiState by viewModel.collectAsState() if (uiState.isLoading) return val context = LocalContext.current val navController = LocalNavController.current diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/support/license/LicenseScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/support/license/LicenseScreen.kt index a3a4f6800..e75b3e6cd 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/support/license/LicenseScreen.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/support/license/LicenseScreen.kt @@ -10,15 +10,15 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.zaneschepke.wireguardautotunnel.ui.screens.support.license.components.LicenseList import com.zaneschepke.wireguardautotunnel.viewmodel.LicenseViewModel import org.koin.androidx.compose.koinViewModel +import org.orbitmvi.orbit.compose.collectAsState @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun LicenseScreen(viewModel: LicenseViewModel = koinViewModel()) { - val licenseUiState by viewModel.container.stateFlow.collectAsStateWithLifecycle() + val licenseUiState by viewModel.collectAsState() if (licenseUiState.isLoading) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/support/license/components/LicenseList.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/support/license/components/LicenseList.kt index 1e3de1790..4c9493d37 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/support/license/components/LicenseList.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/support/license/components/LicenseList.kt @@ -2,7 +2,11 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.support.license.component import LicenseFileEntry import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/TunnelsScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/TunnelsScreen.kt index 3559b8b4d..d796b5cc4 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/TunnelsScreen.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/TunnelsScreen.kt @@ -10,24 +10,28 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.ui.LocalNavController import com.zaneschepke.wireguardautotunnel.ui.common.dialog.InfoDialog import com.zaneschepke.wireguardautotunnel.ui.common.functions.rememberClipboardHelper +import com.zaneschepke.wireguardautotunnel.ui.common.functions.rememberFileExportLauncherForResult import com.zaneschepke.wireguardautotunnel.ui.common.functions.rememberFileImportLauncherForResult import com.zaneschepke.wireguardautotunnel.ui.navigation.Route -import com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.components.ExportTunnelsBottomSheet import com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.components.TunnelImportSheet import com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.components.TunnelList import com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.components.UrlImportDialog import com.zaneschepke.wireguardautotunnel.ui.sideeffect.LocalSideEffect import com.zaneschepke.wireguardautotunnel.util.FileUtils import com.zaneschepke.wireguardautotunnel.util.StringValue +import com.zaneschepke.wireguardautotunnel.util.extensions.hasSAFSupport +import com.zaneschepke.wireguardautotunnel.util.extensions.toUserFriendlyTimestamp import com.zaneschepke.wireguardautotunnel.viewmodel.SharedAppViewModel import io.github.g00fy2.quickie.QRResult import io.github.g00fy2.quickie.ScanQRCode +import java.time.Instant import org.koin.compose.viewmodel.koinActivityViewModel import org.orbitmvi.orbit.compose.collectSideEffect import timber.log.Timber @@ -36,12 +40,25 @@ import timber.log.Timber fun TunnelsScreen(sharedViewModel: SharedAppViewModel = koinActivityViewModel()) { val navController = LocalNavController.current val clipboard = rememberClipboardHelper() + val context = LocalContext.current val uiState by sharedViewModel.tunnelsUiState.collectAsStateWithLifecycle() if (uiState.isLoading) return - var showExportSheet by rememberSaveable { mutableStateOf(false) } + val selectedTunnelsExportLauncher = + rememberFileExportLauncherForResult( + onSuccess = { uri -> sharedViewModel.exportSelectedTunnels(uri) }, + onCanceled = { + sharedViewModel.showToast(StringValue.StringResource(R.string.export_canceled)) + }, + onUnsupported = { + sharedViewModel.showSnackMessage( + StringValue.StringResource(R.string.export_unsupported) + ) + }, + ) + var showImportSheet by rememberSaveable { mutableStateOf(false) } var showDeleteModal by rememberSaveable { mutableStateOf(false) } var showUrlDialog by rememberSaveable { mutableStateOf(false) } @@ -50,7 +67,16 @@ fun TunnelsScreen(sharedViewModel: SharedAppViewModel = koinActivityViewModel()) when (sideEffect) { LocalSideEffect.Sheet.ImportTunnels -> showImportSheet = true LocalSideEffect.Modal.DeleteTunnels -> showDeleteModal = true - LocalSideEffect.Sheet.ExportTunnels -> showExportSheet = true + LocalSideEffect.Sheet.ExportTunnels -> { + if (context.hasSAFSupport(FileUtils.ZIP_FILE_MIME_TYPE)) { + val fileName = "wgtunnel_export_${Instant.now().toUserFriendlyTimestamp()}.zip" + selectedTunnelsExportLauncher.launch(fileName) + } else { + sharedViewModel.showSnackMessage( + StringValue.StringResource(R.string.error_no_file_explorer) + ) + } + } LocalSideEffect.SelectedTunnels.Copy -> sharedViewModel.copySelectedTunnel() LocalSideEffect.SelectedTunnels.SelectAll -> sharedViewModel.toggleSelectAllTunnels() else -> Unit @@ -113,16 +139,6 @@ fun TunnelsScreen(sharedViewModel: SharedAppViewModel = koinActivityViewModel()) ) } - if (showExportSheet) { - ExportTunnelsBottomSheet({ type, uri -> - sharedViewModel.exportSelectedTunnels(type, uri) - showExportSheet = false - }) { - showExportSheet = false - sharedViewModel.clearSelectedTunnels() - } - } - if (showImportSheet) { TunnelImportSheet( onDismiss = { showImportSheet = false }, @@ -135,7 +151,7 @@ fun TunnelsScreen(sharedViewModel: SharedAppViewModel = koinActivityViewModel()) if (result != null) sharedViewModel.importFromClipboard(result) } }, - onManualImportClick = { navController.push(Route.Config(null)) }, + onManualImportClick = { navController.push(Route.ConfigEdit(null)) }, onUrlClick = { showUrlDialog = true }, ) } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/components/ExportTunnelsBottomSheet.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/components/ExportTunnelsBottomSheet.kt deleted file mode 100644 index 059d34c74..000000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/components/ExportTunnelsBottomSheet.kt +++ /dev/null @@ -1,80 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.components - -import android.net.Uri -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.FolderZip -import androidx.compose.runtime.* -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import com.zaneschepke.wireguardautotunnel.R -import com.zaneschepke.wireguardautotunnel.domain.enums.ConfigType -import com.zaneschepke.wireguardautotunnel.ui.common.functions.rememberFileExportLauncherForResult -import com.zaneschepke.wireguardautotunnel.ui.common.sheet.CustomBottomSheet -import com.zaneschepke.wireguardautotunnel.ui.common.sheet.SheetOption -import com.zaneschepke.wireguardautotunnel.util.FileUtils -import com.zaneschepke.wireguardautotunnel.util.extensions.hasSAFSupport -import java.time.Instant - -@Composable -fun ExportTunnelsBottomSheet( - onExport: (configType: ConfigType, uri: Uri?) -> Unit, - onDismiss: () -> Unit, -) { - val context = LocalContext.current - - var exportConfigType by remember { mutableStateOf(ConfigType.WG) } - var shouldExport by remember { mutableStateOf(false) } - - val selectedTunnelsExportLauncher = - rememberFileExportLauncherForResult( - mimeType = FileUtils.ZIP_FILE_MIME_TYPE, - onResult = { file -> - if (file != null) { - onExport(exportConfigType, file) - } else onDismiss() - }, - ) - - fun handleFileExport() { - if (context.hasSAFSupport(FileUtils.ZIP_FILE_MIME_TYPE)) { - val fileName = - when (exportConfigType) { - ConfigType.AM -> "am_export_${Instant.now().epochSecond}.zip" - ConfigType.WG -> "wg_export_${Instant.now().epochSecond}.zip" - } - selectedTunnelsExportLauncher.launch(fileName) - } else { - onExport(exportConfigType, null) - } - } - - LaunchedEffect(shouldExport) { - if (shouldExport) { - handleFileExport() - shouldExport = false - } - } - - CustomBottomSheet( - listOf( - SheetOption( - Icons.Outlined.FolderZip, - stringResource(R.string.export_tunnels_amnezia), - onClick = { - exportConfigType = ConfigType.AM - shouldExport = true - }, - ), - SheetOption( - Icons.Outlined.FolderZip, - stringResource(R.string.export_tunnels_wireguard), - onClick = { - exportConfigType = ConfigType.WG - shouldExport = true - }, - ), - ) - ) { - onDismiss() - } -} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/components/GettingStartedLabel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/components/GettingStartedLabel.kt index 73041857a..cd2dda1d4 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/components/GettingStartedLabel.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/components/GettingStartedLabel.kt @@ -10,9 +10,13 @@ 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.text.* +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLinkStyles +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withLink import androidx.compose.ui.unit.dp import com.zaneschepke.wireguardautotunnel.R diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/components/PeerStatisticsSection.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/components/PeerStatisticsSection.kt new file mode 100644 index 000000000..a0a61fbfa --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/components/PeerStatisticsSection.kt @@ -0,0 +1,46 @@ +package com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.components + +import android.text.format.Formatter +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.parser.ActivePeer +import com.zaneschepke.wireguardautotunnel.util.extensions.abbreviateKey +import com.zaneschepke.wireguardautotunnel.util.extensions.toAgoDisplay + +@Composable +fun PeerStatisticsSection(peer: ActivePeer) { + val context = LocalContext.current + val style = MaterialTheme.typography.bodySmall + val color = MaterialTheme.colorScheme.outline + + val rx = Formatter.formatFileSize(context, peer.rxBytes ?: 0L) + + val tx = Formatter.formatFileSize(context, peer.txBytes ?: 0L) + + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + StatText( + text = stringResource(R.string.peer_template, peer.publicKey.abbreviateKey()), + style = style, + color = color, + ) + + TransferStatsRow(rx = rx, tx = tx, style = style, color = color) + + StatText( + text = + stringResource( + R.string.handshake_template, + peer.lastHandshakeSeconds?.toAgoDisplay() + ?: stringResource(R.string.never).lowercase(), + ), + style = style, + color = color, + ) + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/components/ScrollDismissMultiFab.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/components/ScrollDismissMultiFab.kt deleted file mode 100644 index b19288e0a..000000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/components/ScrollDismissMultiFab.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.components - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically -import androidx.compose.foundation.focusGroup -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.FloatingActionButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp - -@Composable -fun ScrollDismissFab(icon: @Composable () -> Unit, isVisible: Boolean, onClick: () -> Unit) { - AnimatedVisibility( - visible = isVisible, - enter = slideInVertically(initialOffsetY = { it * 2 }), - exit = slideOutVertically(targetOffsetY = { it * 2 }), - modifier = Modifier.focusGroup(), - ) { - FloatingActionButton( - onClick = { onClick() }, - shape = RoundedCornerShape(16.dp), - containerColor = MaterialTheme.colorScheme.primary, - ) { - icon() - } - } -} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/components/StatText.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/components/StatText.kt new file mode 100644 index 000000000..bf1e71f2d --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/components/StatText.kt @@ -0,0 +1,11 @@ +package com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.components + +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle + +@Composable +fun StatText(text: String, style: TextStyle, color: Color) { + Text(text = text.lowercase(), style = style, color = color) +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/components/TransferStatsRow.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/components/TransferStatsRow.kt new file mode 100644 index 000000000..22dd6d1c8 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/components/TransferStatsRow.kt @@ -0,0 +1,47 @@ +package com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.ArrowDownward +import androidx.compose.material.icons.rounded.ArrowUpward +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.dp + +@Composable +fun TransferStatsRow(rx: String, tx: String, style: TextStyle, color: Color) { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + TransferMetric(icon = Icons.Rounded.ArrowDownward, text = rx, style = style, color = color) + + TransferMetric(icon = Icons.Rounded.ArrowUpward, text = tx, style = style, color = color) + } +} + +@Composable +private fun TransferMetric(icon: ImageVector, text: String, style: TextStyle, color: Color) { + + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = color, + modifier = Modifier.size(12.dp), + ) + + Text(text = text.lowercase(), style = style, color = color) + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/components/TunnelImportSheet.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/components/TunnelImportSheet.kt index 39cfaf0e5..d9dbc0d4c 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/components/TunnelImportSheet.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/components/TunnelImportSheet.kt @@ -1,7 +1,11 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.components import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.* +import androidx.compose.material.icons.outlined.ContentPasteGo +import androidx.compose.material.icons.outlined.Create +import androidx.compose.material.icons.outlined.FileOpen +import androidx.compose.material.icons.outlined.Link +import androidx.compose.material.icons.outlined.QrCode import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/components/TunnelList.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/components/TunnelList.kt index c2fd53498..8640ef64b 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/components/TunnelList.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/components/TunnelList.kt @@ -5,7 +5,7 @@ import androidx.compose.foundation.gestures.ScrollableDefaults import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.overscroll import androidx.compose.foundation.rememberOverscrollEffect @@ -13,24 +13,25 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Circle import androidx.compose.material3.Icon import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue 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.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import com.zaneschepke.tunnel.state.ActiveTunnel import com.zaneschepke.wireguardautotunnel.R -import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState +import com.zaneschepke.wireguardautotunnel.ui.LocalIsAndroidTV import com.zaneschepke.wireguardautotunnel.ui.LocalNavController import com.zaneschepke.wireguardautotunnel.ui.common.button.SurfaceRow import com.zaneschepke.wireguardautotunnel.ui.common.button.SwitchWithDivider import com.zaneschepke.wireguardautotunnel.ui.navigation.Route +import com.zaneschepke.wireguardautotunnel.ui.state.DisplayTunnelState import com.zaneschepke.wireguardautotunnel.ui.state.TunnelsUiState -import com.zaneschepke.wireguardautotunnel.util.extensions.asColor import com.zaneschepke.wireguardautotunnel.util.extensions.openWebUrl import com.zaneschepke.wireguardautotunnel.viewmodel.SharedAppViewModel @@ -43,6 +44,15 @@ fun TunnelList( ) { val navController = LocalNavController.current val context = LocalContext.current + val isTv = LocalIsAndroidTV.current + + val focusRequester = remember { FocusRequester() } + + LaunchedEffect(Unit) { + if (isTv) { + focusRequester.requestFocus() + } + } val lazyListState = rememberLazyListState() @@ -70,32 +80,33 @@ fun TunnelList( ) } } - items(uiState.tunnels, key = { it.id }) { tunnel -> - val tunnelState = - remember(uiState.activeTunnels) { - uiState.activeTunnels[tunnel.id] ?: TunnelState() + + itemsIndexed(items = uiState.tunnels, key = { _, tunnel -> tunnel.id }) { index, tunnel -> + val activeTunnel = + remember(tunnel.id, uiState.backendStatus.activeTunnels) { + uiState.backendStatus.activeTunnels[tunnel.id] ?: ActiveTunnel() } + + val displayState = remember(activeTunnel) { DisplayTunnelState.from(activeTunnel) } + val selected = remember(uiState.selectedTunnels) { uiState.selectedTunnels.any { it.id == tunnel.id } } - var leadingIconColor by - remember( - tunnelState.status, - tunnelState.logHealthState, - tunnelState.pingStates, - tunnelState.statistics, - ) { - mutableStateOf(tunnelState.health().asColor()) - } + + val isRunning = displayState != DisplayTunnelState.Disconnected SurfaceRow( - modifier = Modifier.animateItem(), + modifier = + Modifier.animateItem() + .then( + if (index == 0) Modifier.focusRequester(focusRequester) else Modifier + ), leading = { Icon( Icons.Rounded.Circle, contentDescription = stringResource(R.string.tunnel_monitoring), - tint = leadingIconColor, + tint = displayState.asColor(), modifier = Modifier.size(14.dp), ) }, @@ -110,25 +121,23 @@ fun TunnelList( }, selected = selected, expandedContent = - if (!tunnelState.status.isDown()) { - { - TunnelStatisticsRow( - tunnel, - tunnelState, - uiState.isPingEnabled, - uiState.showPingStats, - ) - } - } else null, + if (isRunning) { + { TunnelStatisticsRow(activeTunnel) } + } else { + null + }, onLongClick = { viewModel.toggleSelectedTunnel(tunnel.id) }, - trailing = { modifier -> + trailing = { rowModifier -> SwitchWithDivider( - checked = tunnelState.status.isUpOrStarting(), + checked = isRunning, onClick = { checked -> - if (checked) viewModel.startTunnel(tunnel) - else viewModel.stopTunnel(tunnel) + if (checked) { + viewModel.startTunnel(tunnel) + } else { + viewModel.stopTunnel(tunnel) + } }, - modifier = modifier, + modifier = rowModifier, ) }, ) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/components/TunnelOverviewSection.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/components/TunnelOverviewSection.kt new file mode 100644 index 000000000..da9b4b3f0 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/components/TunnelOverviewSection.kt @@ -0,0 +1,25 @@ +package com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import com.zaneschepke.tunnel.state.ActiveTunnel +import com.zaneschepke.wireguardautotunnel.util.extensions.statusText +import com.zaneschepke.wireguardautotunnel.util.extensions.uptimeText + +@Composable +fun TunnelOverviewSection(activeTunnel: ActiveTunnel, now: Long) { + val context = LocalContext.current + val style = MaterialTheme.typography.bodySmall + val color = MaterialTheme.colorScheme.outline + + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(text = activeTunnel.statusText(context), style = style, color = color) + + activeTunnel.uptimeText(context, now)?.let { Text(text = it, style = style, color = color) } + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/components/TunnelStatisticsRow.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/components/TunnelStatisticsRow.kt index a6614d6fe..6edbacef3 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/components/TunnelStatisticsRow.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/components/TunnelStatisticsRow.kt @@ -1,306 +1,30 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.components -import android.text.format.Formatter -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.layout.* -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.ArrowDownward -import androidx.compose.material.icons.rounded.ArrowUpward -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.intl.Locale import androidx.compose.ui.unit.dp -import com.zaneschepke.wireguardautotunnel.R -import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConfig -import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState -import com.zaneschepke.wireguardautotunnel.ui.common.label.lowercaseLabel -import com.zaneschepke.wireguardautotunnel.util.extensions.abbreviateKey -import com.zaneschepke.wireguardautotunnel.util.extensions.localizedDuration -import com.zaneschepke.wireguardautotunnel.util.extensions.millisAgo +import com.zaneschepke.tunnel.state.ActiveTunnel +import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.delay @Composable -fun TunnelStatisticsRow( - tunnel: TunnelConfig, - tunnelState: TunnelState, - pingEnabled: Boolean, - showDetailedStats: Boolean, -) { - val context = LocalContext.current - val textStyle = MaterialTheme.typography.bodySmall - val textColor = MaterialTheme.colorScheme.outline - val locale = Locale.current.platformLocale - - // needs to be set as peer stats for duplicates return as a single set of stats - val peers by - remember(tunnel) { - derivedStateOf { - TunnelConfig.configFromWgQuick(tunnel.wgQuick) - .peers - .map { it.publicKey.toBase64() } - .toSet() +fun TunnelStatisticsRow(activeTunnel: ActiveTunnel) { + val now by + produceState(System.currentTimeMillis()) { + while (true) { + delay(1.seconds) + value = System.currentTimeMillis() } } - var currentTimeMillis by remember { mutableLongStateOf(System.currentTimeMillis()) } - LaunchedEffect(Unit) { - while (true) { - delay(1000L) - currentTimeMillis = System.currentTimeMillis() - } - } + Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(10.dp)) { + TunnelOverviewSection(activeTunnel = activeTunnel, now = now) - val statistics = tunnelState.statistics - val peerText = lowercaseLabel(stringResource(R.string.peer)) - val handshakeText = lowercaseLabel(stringResource(R.string.handshake)) - val endpointText = lowercaseLabel(stringResource(R.string.endpoint)) - val neverText = lowercaseLabel(stringResource(R.string.never)) - - statistics?.let { stats -> - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(10.dp), - horizontalAlignment = Alignment.Start, - ) { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp), - ) { - Text( - "uptime: ${tunnelState.uptime().localizedDuration(locale)}", - style = textStyle, - color = textColor, - ) - } - } - - peers.forEach { peerBase64 -> - key(peerBase64) { - val peerStats = remember(stats, peerBase64) { stats.peerStats(peerBase64) } - peerStats?.let { stats -> - val endpoint by - remember(stats) { derivedStateOf { stats.resolvedEndpoint } } - val formattedRx by - remember(stats) { - derivedStateOf { - stats.rxBytes.let { Formatter.formatFileSize(context, it) } - } - } - val formattedTx by - remember(stats) { - derivedStateOf { - stats.txBytes.let { Formatter.formatFileSize(context, it) } - } - } - val handshake by - remember(stats) { - derivedStateOf { - stats.latestHandshakeEpochMillis.let { lastHandshake -> - if (lastHandshake == 0L) null - else lastHandshake.millisAgo().localizedDuration(locale) - } - } - } - val pingState by - remember(tunnelState.pingStates) { - derivedStateOf { - tunnelState.pingStates?.getOrDefault(peerBase64, null) - } - } - val lastPingedSeconds by - remember(pingState, currentTimeMillis) { - derivedStateOf { - pingState - ?.lastSuccessfulPingMillis - ?.millisAgo() - ?.localizedDuration(locale) - } - } - - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp), - ) { - Text( - "$peerText: ${peerBase64.abbreviateKey()}", - style = textStyle, - color = textColor, - ) - } - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - Icon( - Icons.Rounded.ArrowDownward, - contentDescription = null, - tint = textColor, - modifier = Modifier.size(12.dp), - ) - Text(formattedRx, style = textStyle, color = textColor) - } - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - Icon( - Icons.Rounded.ArrowUpward, - contentDescription = null, - tint = textColor, - modifier = Modifier.size(12.dp), - ) - Text(formattedTx, style = textStyle, color = textColor) - } - } - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp), - ) { - Text( - "$handshakeText: ${handshake?.let { lowercaseLabel(it) } ?: neverText}", - style = textStyle, - color = textColor, - ) - } - AnimatedVisibility(visible = true) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp), - ) { - Text( - "$endpointText: $endpoint", - style = textStyle, - color = textColor, - ) - } - } - AnimatedVisibility(visible = pingState != null && pingEnabled) { - pingState?.let { - val reachableText = - lowercaseLabel( - stringResource( - R.string.reachable_template, - stringResource( - if (it.isReachable) R.string._true - else R.string._false - ), - ) - ) - val pingTargetText = - lowercaseLabel( - stringResource( - R.string.ping_target_template, - it.pingTarget, - ) - ) - val latencyText = - lowercaseLabel( - stringResource(R.string.latency_template, it.rttAvg) - ) - val jitterText = - lowercaseLabel( - stringResource(R.string.jitter_template, it.rttStddev) - ) - val packetsSentText = - lowercaseLabel( - stringResource( - R.string.packets_sent_template, - it.transmitted, - ) - ) - val packetLossText = - lowercaseLabel( - stringResource( - R.string.packet_loss_template, - it.packetLoss, - ) - ) - val pingSuccessText = - lowercaseLabel( - stringResource( - R.string.ping_success_template, - lastPingedSeconds?.let { sec -> - lowercaseLabel(sec) - } ?: neverText, - ) - ) - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp), - ) { - Text( - reachableText, - style = textStyle, - color = textColor, - ) - Text( - pingTargetText, - style = textStyle, - color = textColor, - ) - } - if (showDetailedStats) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp), - ) { - Text( - latencyText, - style = textStyle, - color = textColor, - ) - Text( - jitterText, - style = textStyle, - color = textColor, - ) - } - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp), - ) { - Text( - packetsSentText, - style = textStyle, - color = textColor, - ) - Text( - packetLossText, - style = textStyle, - color = textColor, - ) - } - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp), - ) { - Text( - pingSuccessText, - style = textStyle, - color = textColor, - ) - } - } - } - } - } - } - } - } - } - } + activeTunnel.activeConfig?.peers?.forEach { peer -> PeerStatisticsSection(peer) } } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/components/UrlImportDialog.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/components/UrlImportDialog.kt index b4518ec7c..123c67aa5 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/components/UrlImportDialog.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/components/UrlImportDialog.kt @@ -4,7 +4,12 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.zaneschepke.wireguardautotunnel.R diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/config/ConfigScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/config/ConfigScreen.kt deleted file mode 100644 index 4d4e40037..000000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/config/ConfigScreen.kt +++ /dev/null @@ -1,141 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.config - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.intl.Locale -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.zaneschepke.wireguardautotunnel.R -import com.zaneschepke.wireguardautotunnel.data.entity.TunnelConfig -import com.zaneschepke.wireguardautotunnel.ui.common.dialog.InfoDialog -import com.zaneschepke.wireguardautotunnel.ui.common.security.SecureScreenFromRecording -import com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.config.components.AddPeerButton -import com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.config.components.InterfaceSection -import com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.config.components.PeersSection -import com.zaneschepke.wireguardautotunnel.ui.sideeffect.LocalSideEffect -import com.zaneschepke.wireguardautotunnel.ui.state.ConfigProxy -import com.zaneschepke.wireguardautotunnel.ui.state.PeerProxy -import com.zaneschepke.wireguardautotunnel.viewmodel.ConfigViewModel -import com.zaneschepke.wireguardautotunnel.viewmodel.SharedAppViewModel -import org.koin.compose.viewmodel.koinActivityViewModel -import org.orbitmvi.orbit.compose.collectSideEffect - -@Composable -fun ConfigScreen( - viewModel: ConfigViewModel, - sharedViewModel: SharedAppViewModel = koinActivityViewModel(), -) { - - val uiState by viewModel.container.stateFlow.collectAsStateWithLifecycle() - - if (uiState.isLoading) return - - val locale = Locale.current.platformLocale - - var configProxy by remember { - mutableStateOf(uiState.tunnel?.let { ConfigProxy.from(it.toAmConfig()) } ?: ConfigProxy()) - } - - var tunnelName by remember { mutableStateOf(uiState.tunnel?.name ?: "") } - val isGlobalConfig = rememberSaveable { tunnelName == TunnelConfig.GLOBAL_CONFIG_NAME } - - val isTunnelNameTaken by - remember(tunnelName) { derivedStateOf { uiState.unavailableNames.contains(tunnelName) } } - - sharedViewModel.collectSideEffect { sideEffect -> - if (sideEffect is LocalSideEffect.SaveChanges) - if (uiState.isRunning) viewModel.setShowSaveModal(true) - else viewModel.saveConfigProxy(configProxy, tunnelName) - } - - if (uiState.showSaveModal) { - InfoDialog( - onDismiss = { viewModel.setShowSaveModal(false) }, - onAttest = { viewModel.saveConfigProxy(configProxy, tunnelName) }, - title = stringResource(R.string.save_changes), - body = { - Text( - stringResource( - R.string.restart_message_template, - stringResource(R.string.tunnels).lowercase(locale), - ) - ) - }, - confirmText = stringResource(R.string._continue), - ) - } - - SecureScreenFromRecording() - - Column( - horizontalAlignment = Alignment.Start, - verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.Top), - modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()), - ) { - InterfaceSection( - isGlobalConfig, - configProxy = configProxy, - uiState.isRunning, - tunnelName, - isTunnelNameTaken, - onInterfaceChange = { configProxy = configProxy.copy(`interface` = it) }, - onTunnelNameChange = { tunnelName = it }, - onMimicQuic = { - configProxy = configProxy.copy(`interface` = configProxy.`interface`.setQuicMimic()) - }, - onMimicDns = { - configProxy = configProxy.copy(`interface` = configProxy.`interface`.setDnsMimic()) - }, - onMimicSip = { - configProxy = configProxy.copy(`interface` = configProxy.`interface`.setSipMimic()) - }, - ) - if (!isGlobalConfig) - PeersSection( - configProxy, - onRemove = { - configProxy = - configProxy.copy( - peers = configProxy.peers.toMutableList().apply { removeAt(it) } - ) - }, - onToggleLan = { index -> - configProxy = - configProxy.copy( - peers = - configProxy.peers.toMutableList().apply { - val peer = get(index) - val updated = - if (peer.isLanExcluded()) peer.includeLan() - else peer.excludeLan() - set(index, updated) - } - ) - }, - onUpdatePeer = { peer, index -> - configProxy = - configProxy.copy( - peers = configProxy.peers.toMutableList().apply { set(index, peer) } - ) - }, - ) - if (!isGlobalConfig) - AddPeerButton { - configProxy = configProxy.copy(peers = configProxy.peers + PeerProxy()) - } - } -} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/config/components/InterfaceDropdown.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/config/components/InterfaceDropdown.kt deleted file mode 100644 index 32c9b4058..000000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/config/components/InterfaceDropdown.kt +++ /dev/null @@ -1,101 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.config.components - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Column -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.MoreVert -import androidx.compose.material3.* -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import com.zaneschepke.wireguardautotunnel.R - -@Composable -fun InterfaceDropdown( - expanded: Boolean, - onExpandedChange: (Boolean) -> Unit, - showScripts: Boolean, - showAmneziaValues: Boolean, - isAmneziaCompatibilitySet: Boolean, - onToggleScripts: () -> Unit, - onToggleAmneziaValues: () -> Unit, - onToggleAmneziaCompatibility: () -> Unit, - onMimicQuic: () -> Unit, - onMimicDns: () -> Unit, - onMimicSip: () -> Unit, -) { - Column { - IconButton(onClick = { onExpandedChange(true) }) { - Icon( - Icons.Rounded.MoreVert, - contentDescription = stringResource(R.string.quick_actions), - ) - } - DropdownMenu( - expanded = expanded, - onDismissRequest = { onExpandedChange(false) }, - modifier = Modifier.shadow(12.dp).background(MaterialTheme.colorScheme.surface), - ) { - DropdownMenuItem( - text = { - Text( - if (showScripts) stringResource(R.string.hide_scripts) - else stringResource(R.string.show_scripts) - ) - }, - onClick = { - onToggleScripts() - onExpandedChange(false) - }, - ) - DropdownMenuItem( - text = { - Text( - if (showAmneziaValues) stringResource(R.string.hide_amnezia_properties) - else stringResource(R.string.show_amnezia_properties) - ) - }, - onClick = { - onToggleAmneziaValues() - onExpandedChange(false) - }, - ) - DropdownMenuItem( - text = { - Text( - if (isAmneziaCompatibilitySet) - stringResource(R.string.remove_amnezia_compatibility) - else stringResource(R.string.enable_amnezia_compatibility) - ) - }, - onClick = { - onToggleAmneziaCompatibility() - onExpandedChange(false) - }, - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.mimic_quic)) }, - onClick = { - onMimicQuic() - onExpandedChange(false) - }, - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.mimic_dns)) }, - onClick = { - onMimicDns() - onExpandedChange(false) - }, - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.mimic_sip)) }, - onClick = { - onMimicSip() - onExpandedChange(false) - }, - ) - } - } -} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/config/components/InterfaceFields.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/config/components/InterfaceFields.kt deleted file mode 100644 index 3423a3cc3..000000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/config/components/InterfaceFields.kt +++ /dev/null @@ -1,460 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.config.components - -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.RemoveRedEye -import androidx.compose.material.icons.rounded.ContentCopy -import androidx.compose.material.icons.rounded.Refresh -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalSoftwareKeyboardController -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.input.VisualTransformation -import androidx.compose.ui.text.intl.Locale -import androidx.compose.ui.unit.dp -import com.wireguard.crypto.KeyPair -import com.zaneschepke.wireguardautotunnel.R -import com.zaneschepke.wireguardautotunnel.ui.LocalIsAndroidTV -import com.zaneschepke.wireguardautotunnel.ui.common.functions.rememberClipboardHelper -import com.zaneschepke.wireguardautotunnel.ui.common.textbox.ConfigurationTextBox -import com.zaneschepke.wireguardautotunnel.ui.state.InterfaceProxy - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun InterfaceFields( - isGlobalConfig: Boolean, - interfaceState: InterfaceProxy, - showScripts: Boolean, - showAmneziaValues: Boolean, - onInterfaceChange: (InterfaceProxy) -> Unit, - showKey: Boolean, -) { - val locale = Locale.current.platformLocale - val keyboardController = LocalSoftwareKeyboardController.current - val isTv = LocalIsAndroidTV.current - val clipboardManager = rememberClipboardHelper() - val keyboardActions = KeyboardActions(onDone = { keyboardController?.hide() }) - val keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done) - var showPrivateKey by rememberSaveable { mutableStateOf(false) } - - LaunchedEffect(showKey) { showPrivateKey = showKey } - - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - if (!isGlobalConfig) - ConfigurationTextBox( - value = interfaceState.privateKey, - hint = - stringResource(R.string.hint_template, stringResource(R.string.base64_key)) - .lowercase(locale), - onValueChange = { onInterfaceChange(interfaceState.copy(privateKey = it)) }, - label = stringResource(R.string.private_key), - modifier = Modifier.fillMaxWidth(), - visualTransformation = - if (showPrivateKey) VisualTransformation.None - else PasswordVisualTransformation(), - trailing = - if (!isTv) { - { modifier -> - CompositionLocalProvider( - LocalMinimumInteractiveComponentSize provides 4.dp - ) { - Row(modifier = Modifier.padding(end = 4.dp)) { - IconButton( - onClick = { showPrivateKey = !showPrivateKey }, - modifier, - ) { - Icon( - Icons.Outlined.RemoveRedEye, - stringResource(R.string.show_password), - ) - } - IconButton( - enabled = true, - onClick = { - val keypair = KeyPair() - onInterfaceChange( - interfaceState.copy( - privateKey = keypair.privateKey.toBase64(), - publicKey = keypair.publicKey.toBase64(), - ) - ) - }, - ) { - Icon( - Icons.Rounded.Refresh, - stringResource(R.string.rotate_keys), - tint = MaterialTheme.colorScheme.onSurface, - ) - } - } - } - } - } else null, - enabled = true, - singleLine = true, - keyboardOptions = keyboardOptions, - keyboardActions = keyboardActions, - ) - if (!isGlobalConfig) - ConfigurationTextBox( - value = interfaceState.publicKey, - hint = - stringResource(R.string.hint_template, stringResource(R.string.base64_key)) - .lowercase(locale), - onValueChange = { onInterfaceChange(interfaceState.copy(publicKey = it)) }, - label = stringResource(R.string.public_key), - enabled = false, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - trailing = - if (!isTv) { - { _ -> - IconButton( - onClick = { clipboardManager.copy(interfaceState.publicKey) } - ) { - Icon( - Icons.Rounded.ContentCopy, - stringResource(R.string.copy_public_key), - tint = MaterialTheme.colorScheme.onSurface, - ) - } - } - } else null, - keyboardOptions = keyboardOptions, - keyboardActions = keyboardActions, - ) - if (!isGlobalConfig) - ConfigurationTextBox( - value = interfaceState.addresses, - onValueChange = { onInterfaceChange(interfaceState.copy(addresses = it)) }, - label = stringResource(R.string.addresses), - hint = - stringResource( - R.string.hint_template, - stringResource(R.string.comma_separated).lowercase(), - ) - .lowercase(locale), - modifier = Modifier.fillMaxWidth(), - ) - if (!isGlobalConfig) - ConfigurationTextBox( - value = interfaceState.listenPort, - onValueChange = { onInterfaceChange(interfaceState.copy(listenPort = it)) }, - label = stringResource(R.string.listen_port), - hint = stringResource(R.string.random), - modifier = Modifier.fillMaxWidth(), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - ) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { - ConfigurationTextBox( - value = interfaceState.dnsServers, - onValueChange = { onInterfaceChange(interfaceState.copy(dnsServers = it)) }, - label = stringResource(R.string.dns_servers), - hint = - stringResource(R.string.hint_template, stringResource(R.string.comma_separated)) - .lowercase(locale), - modifier = Modifier.weight(3f), - ) - if (!isGlobalConfig) - ConfigurationTextBox( - value = interfaceState.mtu, - onValueChange = { onInterfaceChange(interfaceState.copy(mtu = it)) }, - label = stringResource(R.string.mtu), - hint = stringResource(R.string.auto).lowercase(locale), - modifier = Modifier.weight(2f), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - ) - } - if (showScripts) { - ConfigurationTextBox( - value = interfaceState.preUp, - onValueChange = { onInterfaceChange(interfaceState.copy(preUp = it)) }, - label = stringResource(R.string.pre_up), - hint = - stringResource( - R.string.hint_template, - stringResource(R.string.comma_separated).lowercase(locale), - ), - modifier = Modifier.fillMaxWidth(), - ) - ConfigurationTextBox( - value = interfaceState.postUp, - onValueChange = { onInterfaceChange(interfaceState.copy(postUp = it)) }, - label = stringResource(R.string.post_up), - hint = - stringResource( - R.string.hint_template, - stringResource(R.string.comma_separated).lowercase(locale), - ), - modifier = Modifier.fillMaxWidth(), - ) - ConfigurationTextBox( - value = interfaceState.preDown, - onValueChange = { onInterfaceChange(interfaceState.copy(preDown = it)) }, - label = stringResource(R.string.pre_down), - hint = - stringResource( - R.string.hint_template, - stringResource(R.string.comma_separated).lowercase(locale), - ), - modifier = Modifier.fillMaxWidth(), - ) - ConfigurationTextBox( - value = interfaceState.postDown, - onValueChange = { onInterfaceChange(interfaceState.copy(postDown = it)) }, - label = stringResource(R.string.post_down), - hint = - stringResource( - R.string.hint_template, - stringResource(R.string.comma_separated).lowercase(locale), - ), - modifier = Modifier.fillMaxWidth(), - ) - } - if (showAmneziaValues) { - ConfigurationTextBox( - value = interfaceState.junkPacketCount, - onValueChange = { onInterfaceChange(interfaceState.copy(junkPacketCount = it)) }, - label = - stringResource(R.string.jc) + - " " + - stringResource( - R.string.hint_template, - stringResource(R.string.junk_packet_count).lowercase(locale), - ), - hint = stringResource(R.string.range_hint, 1, 128), - modifier = Modifier.fillMaxWidth(), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - ) - ConfigurationTextBox( - value = interfaceState.junkPacketMinSize, - onValueChange = { onInterfaceChange(interfaceState.copy(junkPacketMinSize = it)) }, - label = - stringResource(R.string.jmin) + - " " + - stringResource( - R.string.hint_template, - stringResource(R.string.junk_packet_minimum_size).lowercase(locale), - ), - hint = stringResource(R.string.range_hint, 1, 1279), - modifier = Modifier.fillMaxWidth(), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - ) - ConfigurationTextBox( - value = interfaceState.junkPacketMaxSize, - onValueChange = { onInterfaceChange(interfaceState.copy(junkPacketMaxSize = it)) }, - label = - stringResource(R.string.jmax) + - " " + - stringResource( - R.string.hint_template, - stringResource(R.string.junk_packet_maximum_size).lowercase(locale), - ), - hint = stringResource(R.string.range_hint, 2, 1280), - modifier = Modifier.fillMaxWidth(), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - ) - ConfigurationTextBox( - value = interfaceState.initPacketJunkSize, - onValueChange = { onInterfaceChange(interfaceState.copy(initPacketJunkSize = it)) }, - label = - stringResource(R.string.s1) + - " " + - stringResource( - R.string.hint_template, - stringResource(R.string.init_packet_junk_size).lowercase(locale), - ), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - hint = stringResource(R.string.range_hint, 0, 64), - modifier = Modifier.fillMaxWidth(), - ) - ConfigurationTextBox( - value = interfaceState.responsePacketJunkSize, - onValueChange = { - onInterfaceChange(interfaceState.copy(responsePacketJunkSize = it)) - }, - label = - stringResource(R.string.s2) + - " " + - stringResource( - R.string.hint_template, - stringResource(R.string.response_packet_junk_size).lowercase(locale), - ), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - hint = stringResource(R.string.range_hint, 0, 64), - modifier = Modifier.fillMaxWidth(), - ) - ConfigurationTextBox( - value = interfaceState.cookiePacketJunkSize, - onValueChange = { - onInterfaceChange(interfaceState.copy(cookiePacketJunkSize = it)) - }, - label = - stringResource(R.string.s3) + - " " + - stringResource( - R.string.hint_template, - stringResource(R.string.cookie_packet_junk_size).lowercase(locale), - ), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - hint = stringResource(R.string.range_hint, 0, 928), - modifier = Modifier.fillMaxWidth(), - ) - ConfigurationTextBox( - value = interfaceState.transportPacketJunkSize, - onValueChange = { - onInterfaceChange(interfaceState.copy(transportPacketJunkSize = it)) - }, - label = - stringResource(R.string.s4) + - " " + - stringResource( - R.string.hint_template, - stringResource(R.string.transport_packet_junk_size).lowercase(locale), - ), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - hint = stringResource(R.string.range_hint, 0, 928), - modifier = Modifier.fillMaxWidth(), - ) - ConfigurationTextBox( - value = interfaceState.initPacketMagicHeader, - onValueChange = { - onInterfaceChange(interfaceState.copy(initPacketMagicHeader = it)) - }, - label = - stringResource(R.string.h1) + - " " + - stringResource( - R.string.hint_template, - stringResource(R.string.init_packet_magic_header).lowercase(locale), - ), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - hint = stringResource(R.string.range_hint, 1, 4), - modifier = Modifier.fillMaxWidth(), - ) - ConfigurationTextBox( - value = interfaceState.responsePacketMagicHeader, - onValueChange = { - onInterfaceChange(interfaceState.copy(responsePacketMagicHeader = it)) - }, - label = - stringResource(R.string.h2) + - " " + - stringResource( - R.string.hint_template, - stringResource(R.string.response_packet_magic_header).lowercase(locale), - ), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - hint = stringResource(R.string.range_hint, 1, 4), - modifier = Modifier.fillMaxWidth(), - ) - ConfigurationTextBox( - value = interfaceState.underloadPacketMagicHeader, - onValueChange = { - onInterfaceChange(interfaceState.copy(underloadPacketMagicHeader = it)) - }, - label = - stringResource(R.string.h3) + - " " + - stringResource( - R.string.hint_template, - stringResource(R.string.underload_packet_magic_header).lowercase(locale), - ), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - hint = stringResource(R.string.range_hint, 1, 4), - modifier = Modifier.fillMaxWidth(), - ) - ConfigurationTextBox( - value = interfaceState.transportPacketMagicHeader, - onValueChange = { - onInterfaceChange(interfaceState.copy(transportPacketMagicHeader = it)) - }, - label = - stringResource(R.string.h4) + - " " + - stringResource( - R.string.hint_template, - stringResource(R.string.transport_packet_magic_header).lowercase(locale), - ), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - hint = stringResource(R.string.range_hint, 1, 4), - modifier = Modifier.fillMaxWidth(), - ) - ConfigurationTextBox( - value = interfaceState.i1, - onValueChange = { onInterfaceChange(interfaceState.copy(i1 = it)) }, - label = - stringResource(R.string.i1) + - " " + - stringResource( - R.string.hint_template, - stringResource(R.string.special_junk_packet).lowercase(locale), - ), - hint = stringResource(R.string.hint_template, ""), - modifier = Modifier.fillMaxWidth(), - ) - ConfigurationTextBox( - value = interfaceState.i2, - onValueChange = { onInterfaceChange(interfaceState.copy(i2 = it)) }, - label = - stringResource(R.string.i2) + - " " + - stringResource( - R.string.hint_template, - stringResource(R.string.special_junk_packet).lowercase(locale), - ), - hint = stringResource(R.string.hint_template, ""), - modifier = Modifier.fillMaxWidth(), - ) - ConfigurationTextBox( - value = interfaceState.i3, - onValueChange = { onInterfaceChange(interfaceState.copy(i3 = it)) }, - label = - stringResource(R.string.i3) + - " " + - stringResource( - R.string.hint_template, - stringResource(R.string.special_junk_packet).lowercase(locale), - ), - hint = stringResource(R.string.hint_template, ""), - modifier = Modifier.fillMaxWidth(), - ) - ConfigurationTextBox( - value = interfaceState.i4, - onValueChange = { onInterfaceChange(interfaceState.copy(i4 = it)) }, - label = - stringResource(R.string.i4) + - " " + - stringResource( - R.string.hint_template, - stringResource(R.string.special_junk_packet).lowercase(locale), - ), - hint = stringResource(R.string.hint_template, ""), - modifier = Modifier.fillMaxWidth(), - ) - ConfigurationTextBox( - value = interfaceState.i5, - onValueChange = { onInterfaceChange(interfaceState.copy(i5 = it)) }, - label = - stringResource(R.string.i5) + - " " + - stringResource( - R.string.hint_template, - stringResource(R.string.special_junk_packet).lowercase(locale), - ), - hint = stringResource(R.string.hint_template, ""), - modifier = Modifier.fillMaxWidth(), - ) - } - } -} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/config/components/InterfaceSection.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/config/components/InterfaceSection.kt deleted file mode 100644 index 1b5e13f98..000000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/config/components/InterfaceSection.kt +++ /dev/null @@ -1,164 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.config.components - -import androidx.compose.foundation.layout.* -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.RemoveRedEye -import androidx.compose.material.icons.rounded.Refresh -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.runtime.* -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.intl.Locale -import androidx.compose.ui.unit.dp -import com.wireguard.crypto.KeyPair -import com.zaneschepke.wireguardautotunnel.R -import com.zaneschepke.wireguardautotunnel.ui.LocalIsAndroidTV -import com.zaneschepke.wireguardautotunnel.ui.common.label.GroupLabel -import com.zaneschepke.wireguardautotunnel.ui.common.text.DescriptionText -import com.zaneschepke.wireguardautotunnel.ui.common.textbox.ConfigurationTextBox -import com.zaneschepke.wireguardautotunnel.ui.state.ConfigProxy -import com.zaneschepke.wireguardautotunnel.ui.state.InterfaceProxy - -@Composable -fun InterfaceSection( - isGlobalConfig: Boolean, - configProxy: ConfigProxy, - isRunning: Boolean, - tunnelName: String, - isTunnelNameTaken: Boolean, - onInterfaceChange: (InterfaceProxy) -> Unit, - onTunnelNameChange: (String) -> Unit, - onMimicQuic: () -> Unit, - onMimicDns: () -> Unit, - onMimicSip: () -> Unit, -) { - val isTv = LocalIsAndroidTV.current - var showAmneziaValues by rememberSaveable { - mutableStateOf(configProxy.`interface`.isAmneziaEnabled()) - } - var showPrivateKey by rememberSaveable { mutableStateOf(false) } - - var showScripts by rememberSaveable { mutableStateOf(configProxy.hasScripts()) } - var isDropDownExpanded by rememberSaveable { mutableStateOf(false) } - val isAmneziaCompatibilitySet = - remember(configProxy.`interface`) { - configProxy.`interface`.isAmneziaCompatibilityModeSet() - } - - fun toggleAmneziaCompat() { - val (show, interfaceProxy) = - if (configProxy.`interface`.isAmneziaCompatibilityModeSet()) { - Pair(false, configProxy.`interface`.resetAmneziaProperties()) - } else Pair(true, configProxy.`interface`.toAmneziaCompatibilityConfig()) - showAmneziaValues = show - onInterfaceChange(interfaceProxy) - } - - Surface(color = MaterialTheme.colorScheme.surface) { - Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { - Row( - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), - ) { - if (!isGlobalConfig) - GroupLabel( - stringResource(R.string.interface_), - modifier = Modifier.padding(horizontal = 16.dp), - ) - if (!isGlobalConfig) - Row { - if (isTv) { - IconButton(onClick = { showPrivateKey = !showPrivateKey }) { - Icon( - Icons.Outlined.RemoveRedEye, - stringResource(R.string.show_password), - ) - } - IconButton( - enabled = true, - onClick = { - val keypair = KeyPair() - onInterfaceChange( - configProxy.`interface`.copy( - privateKey = keypair.privateKey.toBase64(), - publicKey = keypair.publicKey.toBase64(), - ) - ) - }, - ) { - Icon( - Icons.Rounded.Refresh, - stringResource(R.string.rotate_keys), - tint = MaterialTheme.colorScheme.onSurface, - ) - } - } - InterfaceDropdown( - expanded = isDropDownExpanded, - onExpandedChange = { isDropDownExpanded = it }, - showScripts = showScripts, - showAmneziaValues = showAmneziaValues, - isAmneziaCompatibilitySet = isAmneziaCompatibilitySet, - onToggleScripts = { showScripts = !showScripts }, - onToggleAmneziaValues = { showAmneziaValues = !showAmneziaValues }, - onToggleAmneziaCompatibility = { toggleAmneziaCompat() }, - onMimicQuic = { - showAmneziaValues = true - onMimicQuic() - }, - onMimicDns = { - showAmneziaValues = true - onMimicDns() - }, - onMimicSip = { - showAmneziaValues = true - onMimicSip() - }, - ) - } - } - Column( - verticalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.padding(horizontal = 16.dp), - ) { - if (!isGlobalConfig) - ConfigurationTextBox( - value = tunnelName, - enabled = !isRunning, - onValueChange = onTunnelNameChange, - label = stringResource(R.string.name), - isError = isTunnelNameTaken, - supportingText = - if (isRunning) { - { - DescriptionText( - stringResource(R.string.tunnel_running_name_message) - ) - } - } else null, - hint = - stringResource( - R.string.hint_template, - stringResource(R.string.tunnel_name), - ) - .lowercase(locale = Locale.current.platformLocale), - modifier = Modifier.fillMaxWidth(), - ) - InterfaceFields( - isGlobalConfig, - interfaceState = configProxy.`interface`, - showScripts = showScripts, - showAmneziaValues = showAmneziaValues, - onInterfaceChange = onInterfaceChange, - showPrivateKey, - ) - } - } - } -} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/settings/TunnelSettingsScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/settings/TunnelSettingsScreen.kt index 111166a6c..f32ebd178 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/settings/TunnelSettingsScreen.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/settings/TunnelSettingsScreen.kt @@ -9,39 +9,34 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.CallSplit +import androidx.compose.material.icons.outlined.Bolt import androidx.compose.material.icons.outlined.DataUsage +import androidx.compose.material.icons.outlined.Description import androidx.compose.material.icons.outlined.Dns +import androidx.compose.material.icons.outlined.Public import androidx.compose.material.icons.outlined.Star import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource -import androidx.compose.ui.res.vectorResource import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.zaneschepke.wireguardautotunnel.R -import com.zaneschepke.wireguardautotunnel.data.model.AppMode +import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelMode import com.zaneschepke.wireguardautotunnel.ui.LocalNavController import com.zaneschepke.wireguardautotunnel.ui.common.button.SurfaceRow import com.zaneschepke.wireguardautotunnel.ui.common.button.ThemedSwitch import com.zaneschepke.wireguardautotunnel.ui.common.label.GroupLabel import com.zaneschepke.wireguardautotunnel.ui.common.text.DescriptionText import com.zaneschepke.wireguardautotunnel.ui.navigation.Route -import com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.settings.components.QrCodeDialog -import com.zaneschepke.wireguardautotunnel.ui.sideeffect.LocalSideEffect import com.zaneschepke.wireguardautotunnel.ui.theme.Disabled import com.zaneschepke.wireguardautotunnel.viewmodel.SharedAppViewModel import com.zaneschepke.wireguardautotunnel.viewmodel.TunnelViewModel import org.koin.compose.viewmodel.koinActivityViewModel -import org.orbitmvi.orbit.compose.collectSideEffect +import org.orbitmvi.orbit.compose.collectAsState @Composable fun TunnelSettingsScreen( @@ -50,28 +45,36 @@ fun TunnelSettingsScreen( ) { val navController = LocalNavController.current - val sharedUiState by sharedViewModel.container.stateFlow.collectAsStateWithLifecycle() + val sharedUiState by sharedViewModel.collectAsState() - val tunnelState by viewModel.container.stateFlow.collectAsStateWithLifecycle() + val uiState by viewModel.collectAsState() - if (tunnelState.isLoading) return - val tunnel = tunnelState.tunnel ?: return - - var showQrModal by rememberSaveable { mutableStateOf(false) } - - sharedViewModel.collectSideEffect { sideEffect -> - if (sideEffect is LocalSideEffect.Modal.QR) showQrModal = true - } - - if (showQrModal) { - QrCodeDialog(tunnelConfig = tunnel, onDismiss = { showQrModal = false }) - } + if (uiState.isLoading) return + val tunnel = uiState.tunnel ?: return Column( horizontalAlignment = Alignment.Start, verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.Top), modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()), ) { + Column { + GroupLabel( + stringResource(R.string.configuration), + modifier = Modifier.padding(horizontal = 16.dp), + ) + SurfaceRow( + leading = { Icon(Icons.Outlined.Description, contentDescription = null) }, + title = stringResource(R.string.view_configuration), + onClick = { navController.push(Route.Config(tunnel.id)) }, + ) + if (uiState.activeConfig != null) { + SurfaceRow( + leading = { Icon(Icons.Outlined.Bolt, contentDescription = null) }, + title = stringResource(R.string.view_live_tunnel), + onClick = { navController.push(Route.Config(tunnel.id, true)) }, + ) + } + } Column { GroupLabel( stringResource(R.string.general), @@ -97,61 +100,45 @@ fun TunnelSettingsScreen( }, onClick = { viewModel.togglePrimaryTunnel() }, ) + } + Column { + GroupLabel( + stringResource(R.string.network), + modifier = Modifier.padding(horizontal = 16.dp), + ) SurfaceRow( leading = { Icon( Icons.AutoMirrored.Outlined.CallSplit, contentDescription = null, tint = - if (sharedUiState.appMode == AppMode.PROXY) Disabled + if (sharedUiState.tunnelMode == TunnelMode.PROXY) Disabled else MaterialTheme.colorScheme.onSurface, ) }, - enabled = sharedUiState.appMode != AppMode.PROXY, + enabled = sharedUiState.tunnelMode != TunnelMode.PROXY, title = stringResource(R.string.splt_tunneling), - description = - if (sharedUiState.appMode == AppMode.PROXY) { - { - DescriptionText( - stringResource(R.string.unavailable_in_mode), - disabled = true, - ) - } - } else null, - onClick = { navController.push(Route.SplitTunnel(id = tunnel.id)) }, - ) - } - Column { - GroupLabel( - stringResource(R.string.other), - modifier = Modifier.padding(horizontal = 16.dp), - ) - SurfaceRow( - leading = { Icon(Icons.Outlined.Dns, contentDescription = null) }, - title = stringResource(R.string.ddns_auto_update), description = { - DescriptionText(stringResource(R.string.ddns_auto_update_description)) - }, - trailing = { - ThemedSwitch( - checked = tunnel.restartOnPingFailure, - onClick = { viewModel.setRestartOnPing(it) }, - ) + if (sharedUiState.tunnelMode == TunnelMode.PROXY) { + DescriptionText( + stringResource(R.string.unavailable_in_mode), + disabled = true, + ) + } else { + uiState.includedAppsCount?.let { + DescriptionText(stringResource(R.string.included_apps, it)) + } + uiState.excludedAppsCount?.let { + DescriptionText(stringResource(R.string.excluded_apps, it)) + } + } }, - onClick = { viewModel.setRestartOnPing(!tunnel.restartOnPingFailure) }, + onClick = { navController.push(Route.SplitTunnel(id = tunnel.id)) }, ) SurfaceRow( - leading = { - Icon(ImageVector.vectorResource(R.drawable.host), contentDescription = null) - }, - title = stringResource(R.string.prefer_ipv6_resolution), - trailing = { - ThemedSwitch( - checked = !tunnel.isIpv4Preferred, - onClick = { viewModel.setIpv4Preferred(!it) }, - ) - }, - onClick = { viewModel.setIpv4Preferred(!tunnel.isIpv4Preferred) }, + leading = { Icon(Icons.Outlined.Public, contentDescription = null) }, + title = stringResource(R.string.ipv6_settings), + onClick = { navController.push(Route.IPv6(tunnel.id)) }, ) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { SurfaceRow( @@ -160,14 +147,14 @@ fun TunnelSettingsScreen( Icons.Outlined.DataUsage, contentDescription = null, tint = - if (sharedUiState.appMode == AppMode.PROXY) Disabled + if (sharedUiState.tunnelMode == TunnelMode.PROXY) Disabled else MaterialTheme.colorScheme.onSurface, ) }, title = stringResource(R.string.metered_tunnel), - enabled = sharedUiState.appMode != AppMode.PROXY, + enabled = sharedUiState.tunnelMode != TunnelMode.PROXY, description = - if (sharedUiState.appMode == AppMode.PROXY) { + if (sharedUiState.tunnelMode == TunnelMode.PROXY) { { DescriptionText( stringResource(R.string.unavailable_in_mode), @@ -178,11 +165,31 @@ fun TunnelSettingsScreen( trailing = { ThemedSwitch( checked = tunnel.isMetered, - onClick = { viewModel.setMetered(it) }, - enabled = sharedUiState.appMode != AppMode.PROXY, + onClick = { viewModel.onMetered(it) }, + enabled = sharedUiState.tunnelMode != TunnelMode.PROXY, + ) + }, + onClick = { viewModel.onMetered(!tunnel.isMetered) }, + ) + } + Column { + GroupLabel( + stringResource(R.string.automation), + modifier = Modifier.padding(horizontal = 16.dp), + ) + SurfaceRow( + leading = { Icon(Icons.Outlined.Dns, contentDescription = null) }, + title = stringResource(R.string.ddns_auto_update), + description = { + DescriptionText(stringResource(R.string.ddns_auto_update_description)) + }, + trailing = { + ThemedSwitch( + checked = tunnel.dynamicDnsEnabled, + onClick = { viewModel.onDynamicDns(it) }, ) }, - onClick = { viewModel.setMetered(!tunnel.isMetered) }, + onClick = { viewModel.onDynamicDns(!tunnel.dynamicDnsEnabled) }, ) } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/settings/components/QrCodeDialog.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/settings/components/QrCodeDialog.kt deleted file mode 100644 index 1d1fa6847..000000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/settings/components/QrCodeDialog.kt +++ /dev/null @@ -1,223 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.settings.components - -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Check -import androidx.compose.material.icons.outlined.VpnKey -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.DialogProperties -import com.zaneschepke.wireguardautotunnel.MainActivity -import com.zaneschepke.wireguardautotunnel.R -import com.zaneschepke.wireguardautotunnel.domain.enums.ConfigType -import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConfig -import com.zaneschepke.wireguardautotunnel.util.extensions.isTextTooLargeForQr -import com.zaneschepke.wireguardautotunnel.util.extensions.setScreenBrightness -import com.zaneschepke.wireguardautotunnel.util.extensions.showToast -import io.github.alexzhirkevich.qrose.options.* -import io.github.alexzhirkevich.qrose.rememberQrCodePainter - -@Composable -fun QrCodeDialog(tunnelConfig: TunnelConfig, onDismiss: () -> Unit) { - val context = LocalContext.current - val activity = context as? MainActivity - - // Handle screen brightness - DisposableEffect(Unit) { - activity?.setScreenBrightness(1.0f) - onDispose { activity?.setScreenBrightness(-1f) } - } - - QrCodeAlertDialog(tunnelConfig = tunnelConfig, onDismiss = onDismiss) -} - -@Composable -private fun QrCodeAlertDialog(tunnelConfig: TunnelConfig, onDismiss: () -> Unit) { - AlertDialog( - containerColor = Color.White, - onDismissRequest = onDismiss, - confirmButton = { - TextButton(onClick = onDismiss) { - Text(stringResource(R.string.done), color = MaterialTheme.colorScheme.surface) - } - }, - title = { - Text( - text = tunnelConfig.name, - color = Color.Black, - style = MaterialTheme.typography.titleLarge, - ) - }, - text = { QrCodeContent(tunnelConfig = tunnelConfig, onDismiss) }, - properties = DialogProperties(usePlatformDefaultWidth = true), - ) -} - -@Composable -private fun QrCodeContent(tunnelConfig: TunnelConfig, onDismiss: () -> Unit) { - val context = LocalContext.current - var selectedOption by remember { mutableStateOf(ConfigType.WG) } - - val wgText = remember(tunnelConfig) { tunnelConfig.toWgConfig().toWgQuickString(true) } - val amText = remember(tunnelConfig) { tunnelConfig.toAmConfig().toAwgQuickString(true, false) } - - val isWgTooLarge by remember(wgText) { derivedStateOf { wgText.isTextTooLargeForQr() } } - val isAmTooLarge by remember(amText) { derivedStateOf { amText.isTextTooLargeForQr() } } - - val qrCodeText by - remember(selectedOption, wgText, amText) { - derivedStateOf { - when (selectedOption) { - ConfigType.AM -> amText - ConfigType.WG -> wgText - } - } - } - - LaunchedEffect(isWgTooLarge, isAmTooLarge) { - if (isWgTooLarge && isAmTooLarge) { - onDismiss() - context.showToast(R.string.text_too_large_for_qr) - } else if (isAmTooLarge && selectedOption == ConfigType.AM) { - selectedOption = ConfigType.WG - context.showToast(R.string.text_too_large_for_qr) - } else if (isWgTooLarge && selectedOption == ConfigType.WG) { - selectedOption = ConfigType.AM - context.showToast(R.string.text_too_large_for_qr) - } - } - - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.Top), - ) { - val qrCodePainter = rememberQrCodePainter(data = qrCodeText, options = createQrOptions()) - Image( - painter = qrCodePainter, - contentDescription = stringResource(R.string.show_qr), - modifier = - Modifier.size(300.dp) - .align(Alignment.CenterHorizontally) - .padding(16.dp) - .background(Color.White), - ) - ConfigTypeSelector( - selectedOption = selectedOption, - onOptionSelected = { newOption -> - val isTooLarge = - when (newOption) { - ConfigType.AM -> isAmTooLarge - ConfigType.WG -> isWgTooLarge - } - if (isTooLarge) { - context.showToast(R.string.text_too_large_for_qr) - } else { - selectedOption = newOption - } - }, - isWgTooLarge = isWgTooLarge, - isAmTooLarge = isAmTooLarge, - ) - } -} - -@Composable -private fun ConfigTypeSelector( - selectedOption: ConfigType, - onOptionSelected: (ConfigType) -> Unit, - isWgTooLarge: Boolean, - isAmTooLarge: Boolean, -) { - MultiChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp)) { - ConfigType.entries.sortedDescending().forEachIndexed { index, entry -> - val isActive = selectedOption == entry - val isEnabled = - when (entry) { - ConfigType.AM -> !isAmTooLarge - ConfigType.WG -> !isWgTooLarge - } - val typeName = - stringResource( - when (entry) { - ConfigType.AM -> R.string.amnezia - ConfigType.WG -> R.string.wireguard - } - ) - val activeContainerColor = Color.White - val inactiveContainerColor = Color.White - val activeContentColor = - if (isEnabled) Color.Black else MaterialTheme.colorScheme.outline - val inactiveContentColor = - if (isEnabled) Color.Black else MaterialTheme.colorScheme.outline - SegmentedButton( - shape = - SegmentedButtonDefaults.itemShape( - index = index, - count = ConfigType.entries.size, - baseShape = RoundedCornerShape(8.dp), - ), - icon = { - SegmentedButtonDefaults.Icon( - active = isActive, - activeContent = { - Icon( - imageVector = Icons.Outlined.Check, - contentDescription = stringResource(R.string.select), - tint = - if (isEnabled) MaterialTheme.colorScheme.primary - else MaterialTheme.colorScheme.outline, - modifier = Modifier.size(SegmentedButtonDefaults.IconSize), - ) - }, - ) { - Icon( - imageVector = Icons.Outlined.VpnKey, - contentDescription = typeName, - tint = - if (isEnabled) Color.Black else MaterialTheme.colorScheme.outline, - modifier = Modifier.size(SegmentedButtonDefaults.IconSize), - ) - } - }, - colors = - SegmentedButtonDefaults.colors( - activeContainerColor = activeContainerColor, - inactiveContainerColor = inactiveContainerColor, - activeContentColor = activeContentColor, - inactiveContentColor = inactiveContentColor, - ), - onCheckedChange = { onOptionSelected(entry) }, - checked = isActive, - ) { - Text( - text = typeName, - color = if (isEnabled) Color.Black else MaterialTheme.colorScheme.outline, - style = MaterialTheme.typography.labelMedium, - ) - } - } - } -} - -private fun createQrOptions(): QrOptions = QrOptions { - shapes { - darkPixel = QrPixelShape.circle() - ball = QrBallShape.circle() - frame = QrFrameShape.roundCorners(0.2f) - } - colors { - dark = QrBrush.solid(Color.Black) - frame = QrBrush.solid(Color.Black) - ball = QrBrush.solid(Color.Black) - } - errorCorrectionLevel = QrErrorCorrectionLevel.Medium -} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/settings/config/ConfigScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/settings/config/ConfigScreen.kt new file mode 100644 index 000000000..ba4104740 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/settings/config/ConfigScreen.kt @@ -0,0 +1,159 @@ +package com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.settings.config + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.settings.config.components.QrCodeDialog +import com.zaneschepke.wireguardautotunnel.ui.sideeffect.LocalSideEffect +import com.zaneschepke.wireguardautotunnel.ui.theme.ConfigHeaderColor +import com.zaneschepke.wireguardautotunnel.ui.theme.ConfigKeyColor +import com.zaneschepke.wireguardautotunnel.util.extensions.isTextTooLargeForQr +import com.zaneschepke.wireguardautotunnel.util.extensions.showToast +import com.zaneschepke.wireguardautotunnel.viewmodel.SharedAppViewModel +import com.zaneschepke.wireguardautotunnel.viewmodel.TunnelViewModel +import org.koin.compose.viewmodel.koinActivityViewModel +import org.orbitmvi.orbit.compose.collectAsState +import org.orbitmvi.orbit.compose.collectSideEffect + +@Composable +fun ConfigScreen( + viewModel: TunnelViewModel, + liveConfig: Boolean, + sharedViewModel: SharedAppViewModel = koinActivityViewModel(), +) { + + val context = LocalContext.current + val uiState by viewModel.collectAsState() + var showKeys by rememberSaveable { mutableStateOf(false) } + + if (uiState.isLoading) return + val tunnel = uiState.tunnel ?: return + + var showQrModal by rememberSaveable { mutableStateOf(false) } + + val rawConfig by + remember(liveConfig, uiState.activeConfig, uiState.tunnel?.quickConfig) { + derivedStateOf { + if (liveConfig) { + uiState.activeConfig?.asQuickString() ?: uiState.tunnel?.quickConfig ?: "" + } else { + uiState.tunnel?.quickConfig ?: "" + } + } + } + + sharedViewModel.collectSideEffect { sideEffect -> + when (sideEffect) { + is LocalSideEffect.Modal.QR -> { + if (tunnel.quickConfig.isTextTooLargeForQr()) { + context.showToast(R.string.text_too_large_for_qr) + } else { + showQrModal = true + } + } + is LocalSideEffect.ShowSensitive -> showKeys = !showKeys + else -> Unit + } + } + + if (showQrModal) { + QrCodeDialog(tunnelConfig = tunnel, onDismiss = { showQrModal = false }) + } + + Column( + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.Top), + modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()), + ) { + val displayText by + remember(rawConfig, showKeys) { derivedStateOf { maskSensitive(rawConfig, showKeys) } } + + val annotated by + remember(displayText) { derivedStateOf { buildConfigAnnotatedString(displayText) } } + + Text( + text = annotated, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(horizontal = 16.dp), + ) + } +} + +fun buildConfigAnnotatedString(text: String): AnnotatedString { + val headerRegex = "\\[(Interface|Peer)]".toRegex() + val keyRegex = "(?m)^[a-zA-Z0-9]+(?=\\s*=)".toRegex() + val commentRegex = "#.*".toRegex() + + val builder = AnnotatedString.Builder(text) + + // Headers + headerRegex.findAll(text).forEach { + builder.addStyle( + SpanStyle(color = ConfigHeaderColor, fontWeight = FontWeight.Bold), + it.range.first, + it.range.last + 1, + ) + } + + // Keys + keyRegex.findAll(text).forEach { + builder.addStyle(SpanStyle(color = ConfigKeyColor), it.range.first, it.range.last + 1) + } + + // Comments + commentRegex.findAll(text).forEach { match -> + val trimmed = match.value.trimEnd() + if (trimmed.isNotEmpty()) { + builder.addStyle( + SpanStyle(color = Color.Gray, fontStyle = FontStyle.Italic), + match.range.first, + match.range.first + trimmed.length, + ) + } + } + + return builder.toAnnotatedString() +} + +fun maskSensitive(text: String, showSensitive: Boolean): String { + if (showSensitive) return text + + val sensitiveKeys = listOf("PrivateKey", "PresharedKey", "PreSharedKey") + + // WireGuard keys are 44 chars + val maskedKey = "•".repeat(44) + + return text.lines().joinToString("\n") { line -> + val isSensitive = sensitiveKeys.any { line.trimStart().startsWith("$it =") } + + if (isSensitive) { + val prefix = line.substringBefore("=") + "= " + "$prefix$maskedKey" + } else { + line + } + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/settings/config/components/QrCodeDialog.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/settings/config/components/QrCodeDialog.kt new file mode 100644 index 000000000..dacb0cd40 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/settings/config/components/QrCodeDialog.kt @@ -0,0 +1,105 @@ +package com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.settings.config.components + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogProperties +import com.zaneschepke.wireguardautotunnel.MainActivity +import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConfig +import com.zaneschepke.wireguardautotunnel.util.extensions.setScreenBrightness +import io.github.alexzhirkevich.qrose.options.QrBallShape +import io.github.alexzhirkevich.qrose.options.QrBrush +import io.github.alexzhirkevich.qrose.options.QrErrorCorrectionLevel +import io.github.alexzhirkevich.qrose.options.QrFrameShape +import io.github.alexzhirkevich.qrose.options.QrOptions +import io.github.alexzhirkevich.qrose.options.QrPixelShape +import io.github.alexzhirkevich.qrose.options.circle +import io.github.alexzhirkevich.qrose.options.roundCorners +import io.github.alexzhirkevich.qrose.options.solid +import io.github.alexzhirkevich.qrose.rememberQrCodePainter + +@Composable +fun QrCodeDialog(tunnelConfig: TunnelConfig, onDismiss: () -> Unit) { + val context = LocalContext.current + val activity = context as? MainActivity + + // Handle screen brightness + DisposableEffect(Unit) { + activity?.setScreenBrightness(1.0f) + onDispose { activity?.setScreenBrightness(-1f) } + } + + QrCodeAlertDialog(tunnelConfig = tunnelConfig, onDismiss = onDismiss) +} + +@Composable +private fun QrCodeAlertDialog(tunnelConfig: TunnelConfig, onDismiss: () -> Unit) { + AlertDialog( + containerColor = Color.White, + onDismissRequest = onDismiss, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.done), color = MaterialTheme.colorScheme.surface) + } + }, + title = { + Text( + text = tunnelConfig.name, + color = Color.Black, + style = MaterialTheme.typography.titleLarge, + ) + }, + text = { QrCodeContent(tunnelConfig = tunnelConfig, onDismiss) }, + properties = DialogProperties(usePlatformDefaultWidth = true), + ) +} + +@Composable +private fun QrCodeContent(tunnelConfig: TunnelConfig, onDismiss: () -> Unit) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.Top), + ) { + val qrCodePainter = + rememberQrCodePainter(data = tunnelConfig.quickConfig, options = createQrOptions()) + Image( + painter = qrCodePainter, + contentDescription = stringResource(R.string.show_qr), + modifier = + Modifier.size(300.dp) + .align(Alignment.CenterHorizontally) + .padding(16.dp) + .background(Color.White), + ) + } +} + +private fun createQrOptions(): QrOptions = QrOptions { + shapes { + darkPixel = QrPixelShape.circle() + ball = QrBallShape.circle() + frame = QrFrameShape.roundCorners(0.2f) + } + colors { + dark = QrBrush.solid(Color.Black) + frame = QrBrush.solid(Color.Black) + ball = QrBrush.solid(Color.Black) + } + errorCorrectionLevel = QrErrorCorrectionLevel.Medium +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/settings/config/edit/ConfigEditScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/settings/config/edit/ConfigEditScreen.kt new file mode 100644 index 000000000..150e34c2c --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/settings/config/edit/ConfigEditScreen.kt @@ -0,0 +1,165 @@ +package com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.settings.config.edit + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.HdrAuto +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.intl.Locale +import androidx.compose.ui.unit.dp +import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.ui.LocalIsAndroidTV +import com.zaneschepke.wireguardautotunnel.ui.common.button.SurfaceRow +import com.zaneschepke.wireguardautotunnel.ui.common.button.ThemedSwitch +import com.zaneschepke.wireguardautotunnel.ui.common.dialog.InfoDialog +import com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.settings.config.edit.components.AddPeerButton +import com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.settings.config.edit.components.InterfaceSection +import com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.settings.config.edit.components.PeersSection +import com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.splittunnel.components.SelectTunnelModal +import com.zaneschepke.wireguardautotunnel.ui.sideeffect.LocalSideEffect +import com.zaneschepke.wireguardautotunnel.viewmodel.ConfigEditViewModel +import com.zaneschepke.wireguardautotunnel.viewmodel.SharedAppViewModel +import org.koin.compose.viewmodel.koinActivityViewModel +import org.orbitmvi.orbit.compose.collectAsState +import org.orbitmvi.orbit.compose.collectSideEffect + +@Composable +fun ConfigEditScreen( + viewModel: ConfigEditViewModel, + sharedViewModel: SharedAppViewModel = koinActivityViewModel(), +) { + + val uiState by viewModel.collectAsState() + + if (uiState.isLoading) return + + val locale = Locale.current.platformLocale + + var showSelectionDialog by rememberSaveable { mutableStateOf(false) } + + sharedViewModel.collectSideEffect { sideEffect -> + when (sideEffect) { + is LocalSideEffect.SaveChanges -> { + if (uiState.isRunning) viewModel.setShowSaveModal(true) else viewModel.save() + } + is LocalSideEffect.Modal.SelectTunnel -> { + showSelectionDialog = true + } + is LocalSideEffect.ShowSensitive -> { + viewModel.toggleShowSensitiveData() + } + else -> Unit + } + } + + SelectTunnelModal( + show = showSelectionDialog, + tunnels = uiState.tunnels, + selectedTunnelId = uiState.ui.selectedCopySourceTunnelId, + onSelect = viewModel::selectCopySource, + onAttest = { + viewModel.applyCopySource() + showSelectionDialog = false + }, + onDismiss = { + showSelectionDialog = false + viewModel.selectCopySource(null) + }, + ) + + if (uiState.ui.showSaveModal) { + InfoDialog( + onDismiss = { viewModel.setShowSaveModal(false) }, + onAttest = viewModel::save, + title = stringResource(R.string.save_changes), + body = { + Text( + stringResource( + R.string.restart_message_template, + stringResource(R.string.tunnels).lowercase(locale), + ) + ) + }, + confirmText = stringResource(R.string._continue), + ) + } + + Column( + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.Top), + modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()), + ) { + if (uiState.isGlobalConfig) { + Column { + SurfaceRow( + leading = { + Icon(ImageVector.vectorResource(R.drawable.host), contentDescription = null) + }, + title = stringResource(R.string.global_dns_servers), + trailing = { modifier -> + ThemedSwitch( + checked = uiState.globalSettings.dnsEnabled, + onClick = { viewModel.setGlobalTunnelDnsEnabled(it) }, + modifier = modifier, + ) + }, + onClick = { + viewModel.setGlobalTunnelDnsEnabled(!uiState.globalSettings.dnsEnabled) + }, + ) + SurfaceRow( + leading = { Icon(Icons.Outlined.HdrAuto, contentDescription = null) }, + title = stringResource(R.string.global_amnezia_configuration), + trailing = { modifier -> + ThemedSwitch( + checked = uiState.globalSettings.amneziaEnabled, + onClick = { viewModel.setGlobalAmneziaEnabled(it) }, + modifier = modifier, + ) + }, + onClick = { + viewModel.setGlobalAmneziaEnabled(!uiState.globalSettings.amneziaEnabled) + }, + ) + } + } + InterfaceSection( + uiState = uiState, + onInterfaceChange = viewModel::onInterfaceChange, + onTunnelNameChange = viewModel::onTunnelNameChange, + onMimic = viewModel::onMimicChange, + onToggleScripts = viewModel::toggleShowScripts, + onToggleAmneziaValues = viewModel::toggleShowAmneziaValues, + onToggleDropdown = viewModel::setInterfaceDropdownExpanded, + onToggleAmneziaCompat = viewModel::toggleAmneziaCompat, + ) + if (!uiState.isGlobalConfig) + PeersSection( + uiState = uiState, + onPeerDropdownExpanded = viewModel::setPeerDropdownExpanded, + onRemove = viewModel::onRemovePeer, + onToggleLan = viewModel::onToggleLan, + onUpdatePeer = viewModel::onUpdatePeer, + ) + if (!uiState.isGlobalConfig) AddPeerButton { viewModel.onAddPeer() } + if (LocalIsAndroidTV.current) { + Spacer(modifier = Modifier.height(300.dp)) + } + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/config/components/AddPeerButton.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/settings/config/edit/components/AddPeerButton.kt similarity index 90% rename from app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/config/components/AddPeerButton.kt rename to app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/settings/config/edit/components/AddPeerButton.kt index 653b0a6a6..6c2640704 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/config/components/AddPeerButton.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/settings/config/edit/components/AddPeerButton.kt @@ -1,4 +1,4 @@ -package com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.config.components +package com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.settings.config.edit.components import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/settings/config/edit/components/AmneziaSection.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/settings/config/edit/components/AmneziaSection.kt new file mode 100644 index 000000000..a9254ed21 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/settings/config/edit/components/AmneziaSection.kt @@ -0,0 +1,260 @@ +package com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.settings.config.edit.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.intl.Locale +import androidx.compose.ui.unit.dp +import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.ui.common.textbox.ConfigurationTextBox +import com.zaneschepke.wireguardautotunnel.ui.state.EditableInterface + +@Composable +fun AmneziaSection( + interfaceState: EditableInterface, + onInterfaceChange: (EditableInterface) -> Unit, +) { + val locale = Locale.current.platformLocale + + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + // Junk packets + ConfigurationTextBox( + value = interfaceState.junkPacketCount, + onValueChange = { onInterfaceChange(interfaceState.copy(junkPacketCount = it)) }, + label = + stringResource(R.string.jc) + + " " + + stringResource( + R.string.hint_template, + stringResource(R.string.junk_packet_count).lowercase(locale), + ), + hint = stringResource(R.string.range_hint, 1, 128), + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + ) + ConfigurationTextBox( + value = interfaceState.junkPacketMinSize, + onValueChange = { onInterfaceChange(interfaceState.copy(junkPacketMinSize = it)) }, + label = + stringResource(R.string.jmin) + + " " + + stringResource( + R.string.hint_template, + stringResource(R.string.junk_packet_minimum_size).lowercase(locale), + ), + hint = stringResource(R.string.range_hint, 1, 1279), + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + ) + ConfigurationTextBox( + value = interfaceState.junkPacketMaxSize, + onValueChange = { onInterfaceChange(interfaceState.copy(junkPacketMaxSize = it)) }, + label = + stringResource(R.string.jmax) + + " " + + stringResource( + R.string.hint_template, + stringResource(R.string.junk_packet_maximum_size).lowercase(locale), + ), + hint = stringResource(R.string.range_hint, 2, 1280), + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + ) + + // S1–S4 + ConfigurationTextBox( + value = interfaceState.initPacketJunkSize, + onValueChange = { onInterfaceChange(interfaceState.copy(initPacketJunkSize = it)) }, + label = + stringResource(R.string.s1) + + " " + + stringResource( + R.string.hint_template, + stringResource(R.string.init_packet_junk_size).lowercase(locale), + ), + hint = stringResource(R.string.range_hint, 0, 64), + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + ) + ConfigurationTextBox( + value = interfaceState.responsePacketJunkSize, + onValueChange = { onInterfaceChange(interfaceState.copy(responsePacketJunkSize = it)) }, + label = + stringResource(R.string.s2) + + " " + + stringResource( + R.string.hint_template, + stringResource(R.string.response_packet_junk_size).lowercase(locale), + ), + hint = stringResource(R.string.range_hint, 0, 64), + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + ) + ConfigurationTextBox( + value = interfaceState.cookiePacketJunkSize, + onValueChange = { onInterfaceChange(interfaceState.copy(cookiePacketJunkSize = it)) }, + label = + stringResource(R.string.s3) + + " " + + stringResource( + R.string.hint_template, + stringResource(R.string.cookie_packet_junk_size).lowercase(locale), + ), + hint = stringResource(R.string.range_hint, 0, 928), + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + ) + ConfigurationTextBox( + value = interfaceState.transportPacketJunkSize, + onValueChange = { + onInterfaceChange(interfaceState.copy(transportPacketJunkSize = it)) + }, + label = + stringResource(R.string.s4) + + " " + + stringResource( + R.string.hint_template, + stringResource(R.string.transport_packet_junk_size).lowercase(locale), + ), + hint = stringResource(R.string.range_hint, 0, 928), + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + ) + + // H1–H4 + ConfigurationTextBox( + value = interfaceState.initPacketMagicHeader, + onValueChange = { onInterfaceChange(interfaceState.copy(initPacketMagicHeader = it)) }, + label = + stringResource(R.string.h1) + + " " + + stringResource( + R.string.hint_template, + stringResource(R.string.init_packet_magic_header).lowercase(locale), + ), + hint = stringResource(R.string.range_hint, 1, 4), + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + ) + ConfigurationTextBox( + value = interfaceState.responsePacketMagicHeader, + onValueChange = { + onInterfaceChange(interfaceState.copy(responsePacketMagicHeader = it)) + }, + label = + stringResource(R.string.h2) + + " " + + stringResource( + R.string.hint_template, + stringResource(R.string.response_packet_magic_header).lowercase(locale), + ), + hint = stringResource(R.string.range_hint, 1, 4), + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + ) + ConfigurationTextBox( + value = interfaceState.underloadPacketMagicHeader, + onValueChange = { + onInterfaceChange(interfaceState.copy(underloadPacketMagicHeader = it)) + }, + label = + stringResource(R.string.h3) + + " " + + stringResource( + R.string.hint_template, + stringResource(R.string.underload_packet_magic_header).lowercase(locale), + ), + hint = stringResource(R.string.range_hint, 1, 4), + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + ) + ConfigurationTextBox( + value = interfaceState.transportPacketMagicHeader, + onValueChange = { + onInterfaceChange(interfaceState.copy(transportPacketMagicHeader = it)) + }, + label = + stringResource(R.string.h4) + + " " + + stringResource( + R.string.hint_template, + stringResource(R.string.transport_packet_magic_header).lowercase(locale), + ), + hint = stringResource(R.string.range_hint, 1, 4), + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + ) + + // I1–I5 + ConfigurationTextBox( + value = interfaceState.i1, + onValueChange = { onInterfaceChange(interfaceState.copy(i1 = it)) }, + label = + stringResource(R.string.i1) + + " " + + stringResource( + R.string.hint_template, + stringResource(R.string.special_junk_packet).lowercase(locale), + ), + hint = stringResource(R.string.hint_template, ""), + modifier = Modifier.fillMaxWidth(), + ) + ConfigurationTextBox( + value = interfaceState.i2, + onValueChange = { onInterfaceChange(interfaceState.copy(i2 = it)) }, + label = + stringResource(R.string.i2) + + " " + + stringResource( + R.string.hint_template, + stringResource(R.string.special_junk_packet).lowercase(locale), + ), + hint = stringResource(R.string.hint_template, ""), + modifier = Modifier.fillMaxWidth(), + ) + ConfigurationTextBox( + value = interfaceState.i3, + onValueChange = { onInterfaceChange(interfaceState.copy(i3 = it)) }, + label = + stringResource(R.string.i3) + + " " + + stringResource( + R.string.hint_template, + stringResource(R.string.special_junk_packet).lowercase(locale), + ), + hint = stringResource(R.string.hint_template, ""), + modifier = Modifier.fillMaxWidth(), + ) + ConfigurationTextBox( + value = interfaceState.i4, + onValueChange = { onInterfaceChange(interfaceState.copy(i4 = it)) }, + label = + stringResource(R.string.i4) + + " " + + stringResource( + R.string.hint_template, + stringResource(R.string.special_junk_packet).lowercase(locale), + ), + hint = stringResource(R.string.hint_template, ""), + modifier = Modifier.fillMaxWidth(), + ) + ConfigurationTextBox( + value = interfaceState.i5, + onValueChange = { onInterfaceChange(interfaceState.copy(i5 = it)) }, + label = + stringResource(R.string.i5) + + " " + + stringResource( + R.string.hint_template, + stringResource(R.string.special_junk_packet).lowercase(locale), + ), + hint = stringResource(R.string.hint_template, ""), + modifier = Modifier.fillMaxWidth(), + ) + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/settings/config/edit/components/BasicInterfaceFields.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/settings/config/edit/components/BasicInterfaceFields.kt new file mode 100644 index 000000000..9af079bed --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/settings/config/edit/components/BasicInterfaceFields.kt @@ -0,0 +1,120 @@ +package com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.settings.config.edit.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.ContentCopy +import androidx.compose.material.icons.rounded.Refresh +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.intl.Locale +import androidx.compose.ui.unit.dp +import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.parser.crypto.Key +import com.zaneschepke.wireguardautotunnel.ui.LocalIsAndroidTV +import com.zaneschepke.wireguardautotunnel.ui.common.functions.rememberClipboardHelper +import com.zaneschepke.wireguardautotunnel.ui.common.textbox.ConfigurationTextBox +import com.zaneschepke.wireguardautotunnel.ui.state.EditableInterface + +@Composable +fun BasicInterfaceFields( + interfaceState: EditableInterface, + onInterfaceChange: (EditableInterface) -> Unit, + showPrivateKey: Boolean, + keyboardOptions: KeyboardOptions, + keyboardActions: KeyboardActions, +) { + val locale = Locale.current.platformLocale + val clipboardManager = rememberClipboardHelper() + + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + ConfigurationTextBox( + value = interfaceState.privateKey, + onValueChange = { onInterfaceChange(interfaceState.copy(privateKey = it)) }, + label = stringResource(R.string.private_key), + hint = + stringResource(R.string.hint_template, stringResource(R.string.base64_key)) + .lowercase(locale), + visualTransformation = + if (showPrivateKey) VisualTransformation.None else PasswordVisualTransformation(), + trailing = + if (!LocalIsAndroidTV.current) { + { + IconButton( + onClick = { + val privateKey = Key.generatePrivateKey() + val publicKey = Key.generatePublicKey(privateKey) + onInterfaceChange( + interfaceState.copy( + privateKey = privateKey.toBase64(), + publicKey = publicKey.toBase64(), + ) + ) + } + ) { + Icon( + Icons.Rounded.Refresh, + stringResource(R.string.rotate_keys), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + } + } else null, + modifier = Modifier.fillMaxWidth(), + keyboardOptions = keyboardOptions.copy(imeAction = ImeAction.Done), + keyboardActions = keyboardActions, + singleLine = true, + ) + + ConfigurationTextBox( + value = interfaceState.publicKey, + onValueChange = { onInterfaceChange(interfaceState.copy(publicKey = it)) }, + label = stringResource(R.string.public_key), + hint = + stringResource(R.string.hint_template, stringResource(R.string.base64_key)) + .lowercase(locale), + enabled = false, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + trailing = { + IconButton( + onClick = { clipboardManager.copy(interfaceState.publicKey) } + ) { // reuse your clipboard helper + Icon(Icons.Rounded.ContentCopy, stringResource(R.string.copy_public_key)) + } + }, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + ) + + ConfigurationTextBox( + value = interfaceState.addresses, + onValueChange = { onInterfaceChange(interfaceState.copy(addresses = it)) }, + label = stringResource(R.string.addresses), + hint = + stringResource(R.string.hint_template, stringResource(R.string.comma_separated)) + .lowercase(locale), + modifier = Modifier.fillMaxWidth(), + ) + + ConfigurationTextBox( + value = interfaceState.listenPort, + onValueChange = { onInterfaceChange(interfaceState.copy(listenPort = it)) }, + label = stringResource(R.string.listen_port), + hint = stringResource(R.string.random), + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + ) + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/settings/config/edit/components/DnsMtuSection.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/settings/config/edit/components/DnsMtuSection.kt new file mode 100644 index 000000000..0b25d212e --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/settings/config/edit/components/DnsMtuSection.kt @@ -0,0 +1,53 @@ +package com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.settings.config.edit.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.intl.Locale +import androidx.compose.ui.unit.dp +import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.ui.common.textbox.ConfigurationTextBox +import com.zaneschepke.wireguardautotunnel.ui.state.EditableInterface + +@Composable +fun DnsMtuSection( + isGlobalConfig: Boolean, + globalDnsEnabled: Boolean, + interfaceState: EditableInterface, + onInterfaceChange: (EditableInterface) -> Unit, +) { + val showDns = !isGlobalConfig || globalDnsEnabled + val showMtu = !isGlobalConfig + + if (!showDns) return + + val locale = Locale.current.platformLocale + + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + ConfigurationTextBox( + value = interfaceState.dnsServers, + onValueChange = { onInterfaceChange(interfaceState.copy(dnsServers = it)) }, + label = stringResource(R.string.dns_servers), + hint = + stringResource(R.string.hint_template, stringResource(R.string.comma_separated)) + .lowercase(locale), + modifier = Modifier.fillMaxWidth(), + ) + + if (showMtu) { + ConfigurationTextBox( + value = interfaceState.mtu, + onValueChange = { onInterfaceChange(interfaceState.copy(mtu = it)) }, + label = stringResource(R.string.mtu), + hint = stringResource(R.string.auto).lowercase(locale), + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + ) + } + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/settings/config/edit/components/InterfaceDropdown.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/settings/config/edit/components/InterfaceDropdown.kt new file mode 100644 index 000000000..bfc886ce5 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/settings/config/edit/components/InterfaceDropdown.kt @@ -0,0 +1,108 @@ +package com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.settings.config.edit.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.MoreVert +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.domain.enums.MimicMode +import com.zaneschepke.wireguardautotunnel.ui.state.ConfigUiState + +@Composable +fun InterfaceDropdown( + uiState: ConfigUiState, + onToggleDropdown: (Boolean) -> Unit, + onToggleScripts: () -> Unit, + onToggleAmneziaValues: () -> Unit, + onToggleAmneziaCompatibility: () -> Unit, + onMimic: (MimicMode) -> Unit, +) { + Column { + IconButton(onClick = { onToggleDropdown(true) }) { + Icon( + Icons.Rounded.MoreVert, + contentDescription = stringResource(R.string.quick_actions), + ) + } + DropdownMenu( + expanded = uiState.ui.isInterfaceDropdownExpanded, + onDismissRequest = { onToggleDropdown(false) }, + modifier = Modifier.shadow(12.dp).background(MaterialTheme.colorScheme.surface), + ) { + if (!uiState.isGlobalConfig) + DropdownMenuItem( + text = { + Text( + if (uiState.ui.showScripts) stringResource(R.string.hide_scripts) + else stringResource(R.string.show_scripts) + ) + }, + onClick = { + onToggleScripts() + onToggleDropdown(false) + }, + ) + if (!uiState.isGlobalConfig) + DropdownMenuItem( + text = { + Text( + if (uiState.ui.showAmneziaValues) + stringResource(R.string.hide_amnezia_properties) + else stringResource(R.string.show_amnezia_properties) + ) + }, + onClick = { + onToggleAmneziaValues() + onToggleDropdown(false) + }, + ) + DropdownMenuItem( + text = { + Text( + if (uiState.isAmneziaCompatibilitySet) + stringResource(R.string.remove_amnezia_compatibility) + else stringResource(R.string.enable_amnezia_compatibility) + ) + }, + onClick = { + onToggleAmneziaCompatibility() + onToggleDropdown(false) + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.mimic_quic)) }, + onClick = { + onMimic(MimicMode.QUIC) + onToggleDropdown(false) + }, + ) + + DropdownMenuItem( + text = { Text(stringResource(R.string.mimic_dns)) }, + onClick = { + onMimic(MimicMode.DNS) + onToggleDropdown(false) + }, + ) + + DropdownMenuItem( + text = { Text(stringResource(R.string.mimic_sip)) }, + onClick = { + onMimic(MimicMode.SIP) + onToggleDropdown(false) + }, + ) + } + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/settings/config/edit/components/InterfaceFields.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/settings/config/edit/components/InterfaceFields.kt new file mode 100644 index 000000000..1e9a9e1a5 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/settings/config/edit/components/InterfaceFields.kt @@ -0,0 +1,51 @@ +package com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.settings.config.edit.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import com.zaneschepke.wireguardautotunnel.ui.state.ConfigUiState +import com.zaneschepke.wireguardautotunnel.ui.state.EditableInterface + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun InterfaceFields(uiState: ConfigUiState, onInterfaceChange: (EditableInterface) -> Unit) { + val keyboardController = LocalSoftwareKeyboardController.current + val keyboardActions = KeyboardActions(onDone = { keyboardController?.hide() }) + val keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done) + + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + if (!uiState.isGlobalConfig) { + BasicInterfaceFields( + interfaceState = uiState.draft.config.`interface`, + onInterfaceChange = onInterfaceChange, + showPrivateKey = uiState.ui.showSensitiveData, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + ) + } + + DnsMtuSection( + isGlobalConfig = uiState.isGlobalConfig, + globalDnsEnabled = uiState.globalSettings.dnsEnabled, + interfaceState = uiState.draft.config.`interface`, + onInterfaceChange = onInterfaceChange, + ) + + if (uiState.ui.showScripts) { + ScriptsSection(uiState.draft.config.`interface`, onInterfaceChange) + } + + if ( + (!uiState.isGlobalConfig && uiState.ui.showAmneziaValues) || + (uiState.isGlobalConfig && uiState.globalSettings.amneziaEnabled) + ) { + AmneziaSection(uiState.draft.config.`interface`, onInterfaceChange) + } + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/settings/config/edit/components/InterfaceSection.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/settings/config/edit/components/InterfaceSection.kt new file mode 100644 index 000000000..17c8a5138 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/settings/config/edit/components/InterfaceSection.kt @@ -0,0 +1,128 @@ +package com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.settings.config.edit.components + +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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Refresh +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +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.text.intl.Locale +import androidx.compose.ui.unit.dp +import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.domain.enums.MimicMode +import com.zaneschepke.wireguardautotunnel.parser.crypto.Key +import com.zaneschepke.wireguardautotunnel.ui.LocalIsAndroidTV +import com.zaneschepke.wireguardautotunnel.ui.common.label.GroupLabel +import com.zaneschepke.wireguardautotunnel.ui.common.text.DescriptionText +import com.zaneschepke.wireguardautotunnel.ui.common.textbox.ConfigurationTextBox +import com.zaneschepke.wireguardautotunnel.ui.state.ConfigUiState +import com.zaneschepke.wireguardautotunnel.ui.state.EditableInterface + +@Composable +fun InterfaceSection( + uiState: ConfigUiState, + onInterfaceChange: (EditableInterface) -> Unit, + onTunnelNameChange: (String) -> Unit, + onMimic: (MimicMode) -> Unit, + onToggleScripts: () -> Unit, + onToggleAmneziaValues: () -> Unit, + onToggleAmneziaCompat: () -> Unit, + onToggleDropdown: (Boolean) -> Unit, +) { + val isTv = LocalIsAndroidTV.current + + Surface(color = MaterialTheme.colorScheme.surface) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + if (!uiState.isGlobalConfig || uiState.globalInterfaceSettingsEnabled) { + Box( + modifier = Modifier.height(48.dp).padding(horizontal = 16.dp), + contentAlignment = Alignment.CenterStart, + ) { + GroupLabel(stringResource(R.string.interface_)) + } + } + if (!uiState.isGlobalConfig || uiState.showAmneziaGlobals) + Row { + if (isTv) { + if (!uiState.isGlobalConfig) + IconButton( + enabled = true, + onClick = { + val privateKey = Key.generatePrivateKey() + val publicKey = Key.generatePublicKey(privateKey) + onInterfaceChange( + uiState.draft.config.`interface`.copy( + privateKey = privateKey.toBase64(), + publicKey = publicKey.toBase64(), + ) + ) + }, + ) { + Icon( + Icons.Rounded.Refresh, + stringResource(R.string.rotate_keys), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + } + InterfaceDropdown( + uiState = uiState, + onToggleDropdown = onToggleDropdown, + onToggleScripts = onToggleScripts, + onToggleAmneziaValues = onToggleAmneziaValues, + onToggleAmneziaCompatibility = onToggleAmneziaCompat, + onMimic = { + onToggleAmneziaValues() + onMimic(it) + }, + ) + } + } + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.padding(horizontal = 16.dp), + ) { + if (!uiState.isGlobalConfig) + ConfigurationTextBox( + value = uiState.draft.tunnelName, + enabled = !uiState.isRunning, + onValueChange = onTunnelNameChange, + label = stringResource(R.string.name), + isError = uiState.isTunnelNameTaken, + supportingText = + if (uiState.isRunning) { + { + DescriptionText( + stringResource(R.string.tunnel_running_name_message) + ) + } + } else null, + hint = + stringResource( + R.string.hint_template, + stringResource(R.string.tunnel_name), + ) + .lowercase(locale = Locale.current.platformLocale), + modifier = Modifier.fillMaxWidth(), + ) + InterfaceFields(uiState = uiState, onInterfaceChange = onInterfaceChange) + } + } + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/config/components/PeerFields.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/settings/config/edit/components/PeerFields.kt similarity index 80% rename from app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/config/components/PeerFields.kt rename to app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/settings/config/edit/components/PeerFields.kt index d2616409c..4dbc97dea 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/config/components/PeerFields.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/settings/config/edit/components/PeerFields.kt @@ -1,4 +1,4 @@ -package com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.config.components +package com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.settings.config.edit.components import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -6,14 +6,14 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.RemoveRedEye -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource @@ -23,13 +23,11 @@ import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.intl.Locale import androidx.compose.ui.unit.dp import com.zaneschepke.wireguardautotunnel.R -import com.zaneschepke.wireguardautotunnel.ui.LocalIsAndroidTV import com.zaneschepke.wireguardautotunnel.ui.common.textbox.ConfigurationTextBox -import com.zaneschepke.wireguardautotunnel.ui.state.PeerProxy +import com.zaneschepke.wireguardautotunnel.ui.state.EditablePeer @Composable -fun PeerFields(peer: PeerProxy, onPeerChange: (PeerProxy) -> Unit, showKey: Boolean) { - val isTv = LocalIsAndroidTV.current +fun PeerFields(peer: EditablePeer, onPeerChange: (EditablePeer) -> Unit, showKey: Boolean) { val locale = Locale.current.platformLocale val keyboardController = LocalSoftwareKeyboardController.current val keyboardActions = KeyboardActions(onDone = { keyboardController?.hide() }) @@ -63,17 +61,6 @@ fun PeerFields(peer: PeerProxy, onPeerChange: (PeerProxy) -> Unit, showKey: Bool keyboardOptions = keyboardOptions, keyboardActions = keyboardActions, singleLine = true, - trailing = - if (!isTv) { - { modifier -> - IconButton(onClick = { showPresharedKey = !showPresharedKey }, modifier) { - Icon( - Icons.Outlined.RemoveRedEye, - stringResource(R.string.show_password), - ) - } - } - } else null, ) ConfigurationTextBox( value = peer.persistentKeepalive, diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/config/components/PeersSection.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/settings/config/edit/components/PeersSection.kt similarity index 60% rename from app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/config/components/PeersSection.kt rename to app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/settings/config/edit/components/PeersSection.kt index 7c1889b67..36171670d 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/config/components/PeersSection.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/settings/config/edit/components/PeersSection.kt @@ -1,36 +1,40 @@ -package com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.config.components +package com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.settings.config.edit.components import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* +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.material.icons.Icons -import androidx.compose.material.icons.outlined.RemoveRedEye import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.MoreVert -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +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.shadow import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.zaneschepke.wireguardautotunnel.R -import com.zaneschepke.wireguardautotunnel.ui.LocalIsAndroidTV import com.zaneschepke.wireguardautotunnel.ui.common.label.GroupLabel -import com.zaneschepke.wireguardautotunnel.ui.state.ConfigProxy -import com.zaneschepke.wireguardautotunnel.ui.state.PeerProxy +import com.zaneschepke.wireguardautotunnel.ui.state.ConfigUiState +import com.zaneschepke.wireguardautotunnel.ui.state.EditablePeer @Composable fun PeersSection( - configProxy: ConfigProxy, + uiState: ConfigUiState, onRemove: (index: Int) -> Unit, + onPeerDropdownExpanded: (Boolean) -> Unit, onToggleLan: (index: Int) -> Unit, - onUpdatePeer: (PeerProxy, index: Int) -> Unit, + onUpdatePeer: (EditablePeer, index: Int) -> Unit, ) { - val isTv = LocalIsAndroidTV.current - configProxy.peers.forEachIndexed { index, peer -> - var isDropDownExpanded by remember { mutableStateOf(false) } - var showPresharedKey by remember { mutableStateOf(false) } - + uiState.draft.config.peers.forEachIndexed { index, peer -> Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { Row( horizontalArrangement = Arrangement.SpaceBetween, @@ -48,23 +52,20 @@ fun PeersSection( contentDescription = stringResource(R.string.delete), ) } - if (isTv) - IconButton(onClick = { showPresharedKey = !showPresharedKey }) { - Icon( - Icons.Outlined.RemoveRedEye, - stringResource(R.string.show_password), - ) - } Column { - IconButton(onClick = { isDropDownExpanded = true }) { + IconButton( + onClick = { onPeerDropdownExpanded(!uiState.ui.isPeerDropdownExpanded) } + ) { Icon( Icons.Rounded.MoreVert, contentDescription = stringResource(R.string.quick_actions), ) } DropdownMenu( - expanded = isDropDownExpanded, - onDismissRequest = { isDropDownExpanded = false }, + expanded = uiState.ui.isPeerDropdownExpanded, + onDismissRequest = { + onPeerDropdownExpanded(!uiState.ui.isPeerDropdownExpanded) + }, modifier = Modifier.shadow(12.dp).background(MaterialTheme.colorScheme.surface), ) { @@ -78,14 +79,18 @@ fun PeersSection( }, onClick = { onToggleLan(index) - isDropDownExpanded = false + onPeerDropdownExpanded(false) }, ) } } } } - PeerFields(peer = peer, onPeerChange = { onUpdatePeer(it, index) }, showPresharedKey) + PeerFields( + peer = peer, + onPeerChange = { onUpdatePeer(it, index) }, + uiState.ui.showSensitiveData, + ) } } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/settings/config/edit/components/ScriptsSection.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/settings/config/edit/components/ScriptsSection.kt new file mode 100644 index 000000000..7f2baca96 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/settings/config/edit/components/ScriptsSection.kt @@ -0,0 +1,68 @@ +package com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.settings.config.edit.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.intl.Locale +import androidx.compose.ui.unit.dp +import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.ui.common.textbox.ConfigurationTextBox +import com.zaneschepke.wireguardautotunnel.ui.state.EditableInterface + +@Composable +fun ScriptsSection( + interfaceState: EditableInterface, + onInterfaceChange: (EditableInterface) -> Unit, +) { + val locale = Locale.current.platformLocale + + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + ConfigurationTextBox( + value = interfaceState.preUp, + onValueChange = { onInterfaceChange(interfaceState.copy(preUp = it)) }, + label = stringResource(R.string.pre_up), + hint = + stringResource( + R.string.hint_template, + stringResource(R.string.comma_separated).lowercase(locale), + ), + modifier = Modifier.fillMaxWidth(), + ) + ConfigurationTextBox( + value = interfaceState.postUp, + onValueChange = { onInterfaceChange(interfaceState.copy(postUp = it)) }, + label = stringResource(R.string.post_up), + hint = + stringResource( + R.string.hint_template, + stringResource(R.string.comma_separated).lowercase(locale), + ), + modifier = Modifier.fillMaxWidth(), + ) + ConfigurationTextBox( + value = interfaceState.preDown, + onValueChange = { onInterfaceChange(interfaceState.copy(preDown = it)) }, + label = stringResource(R.string.pre_down), + hint = + stringResource( + R.string.hint_template, + stringResource(R.string.comma_separated).lowercase(locale), + ), + modifier = Modifier.fillMaxWidth(), + ) + ConfigurationTextBox( + value = interfaceState.postDown, + onValueChange = { onInterfaceChange(interfaceState.copy(postDown = it)) }, + label = stringResource(R.string.post_down), + hint = + stringResource( + R.string.hint_template, + stringResource(R.string.comma_separated).lowercase(locale), + ), + modifier = Modifier.fillMaxWidth(), + ) + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/settings/ipv6/IPv6Intent.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/settings/ipv6/IPv6Intent.kt new file mode 100644 index 000000000..ff1c7fad9 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/settings/ipv6/IPv6Intent.kt @@ -0,0 +1,9 @@ +package com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.settings.ipv6 + +sealed class IPv6Intent { + data class ToggleIpv6Preferred(val value: Boolean) : IPv6Intent() + + data class ToggleRestore(val value: Boolean) : IPv6Intent() + + data class ToggleFallback(val value: Boolean) : IPv6Intent() +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/settings/ipv6/IPv6Screen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/settings/ipv6/IPv6Screen.kt new file mode 100644 index 000000000..b1468ea11 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/settings/ipv6/IPv6Screen.kt @@ -0,0 +1,105 @@ +package com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.settings.ipv6 + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Public +import androidx.compose.material.icons.outlined.Restore +import androidx.compose.material.icons.outlined.SwapHoriz +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +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.ui.common.button.SurfaceRow +import com.zaneschepke.wireguardautotunnel.ui.common.button.ThemedSwitch +import com.zaneschepke.wireguardautotunnel.ui.common.label.GroupLabel +import com.zaneschepke.wireguardautotunnel.ui.theme.Disabled +import com.zaneschepke.wireguardautotunnel.viewmodel.TunnelViewModel +import org.orbitmvi.orbit.compose.collectAsState + +@Composable +fun IPv6Screen(viewModel: TunnelViewModel) { + + val uiState by viewModel.collectAsState() + + if (uiState.isLoading) return + val tunnel = uiState.tunnel ?: return + + Column( + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.Top), + modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()), + ) { + Column { + GroupLabel( + stringResource(R.string.general), + modifier = Modifier.padding(horizontal = 16.dp), + ) + SurfaceRow( + leading = { Icon(Icons.Outlined.Public, contentDescription = null) }, + title = stringResource(R.string.use_ipv6), + trailing = { + ThemedSwitch( + checked = tunnel.isIpv6Preferred, + onClick = { viewModel.onIPv6Action(IPv6Intent.ToggleIpv6Preferred(it)) }, + ) + }, + onClick = { + viewModel.onIPv6Action(IPv6Intent.ToggleIpv6Preferred(!tunnel.isIpv6Preferred)) + }, + ) + } + Column { + val iconTint = + if (!tunnel.isIpv6Preferred) Disabled else MaterialTheme.colorScheme.onSurface + val enabled = tunnel.isIpv6Preferred + GroupLabel( + stringResource(R.string.automation), + modifier = Modifier.padding(horizontal = 16.dp), + ) + SurfaceRow( + leading = { + Icon(Icons.Outlined.SwapHoriz, contentDescription = null, tint = iconTint) + }, + title = stringResource(R.string.switch_to_ipv4), + onClick = { + viewModel.onIPv6Action(IPv6Intent.ToggleFallback(!tunnel.ipv4FallbackEnabled)) + }, + enabled = enabled, + trailing = { + ThemedSwitch( + checked = tunnel.ipv4FallbackEnabled, + onClick = { viewModel.onIPv6Action(IPv6Intent.ToggleFallback(it)) }, + enabled = enabled, + ) + }, + ) + SurfaceRow( + leading = { + Icon(Icons.Outlined.Restore, contentDescription = null, tint = iconTint) + }, + title = stringResource(R.string.switch_to_ipv6), + onClick = { + viewModel.onIPv6Action(IPv6Intent.ToggleRestore(!tunnel.ipv6RestoreEnabled)) + }, + enabled = enabled, + trailing = { + ThemedSwitch( + checked = tunnel.ipv6RestoreEnabled, + onClick = { viewModel.onIPv6Action(IPv6Intent.ToggleRestore(it)) }, + enabled = enabled, + ) + }, + ) + } + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/sort/SortScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/sort/SortScreen.kt index a71d76897..de6ae7881 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/sort/SortScreen.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/sort/SortScreen.kt @@ -1,7 +1,11 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.sort import androidx.compose.foundation.gestures.ScrollableDefaults -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/splittunnel/SplitTunnelScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/splittunnel/SplitTunnelScreen.kt index 591e80b14..35e9481ef 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/splittunnel/SplitTunnelScreen.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/splittunnel/SplitTunnelScreen.kt @@ -6,22 +6,20 @@ import androidx.compose.foundation.layout.size import androidx.compose.material3.CircularWavyProgressIndicator import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.splittunnel.components.SelectTunnelModal import com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.splittunnel.components.SplitTunnelContent -import com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.splittunnel.state.SplitOption import com.zaneschepke.wireguardautotunnel.ui.sideeffect.LocalSideEffect import com.zaneschepke.wireguardautotunnel.viewmodel.SharedAppViewModel import com.zaneschepke.wireguardautotunnel.viewmodel.SplitTunnelViewModel import org.koin.compose.viewmodel.koinActivityViewModel +import org.orbitmvi.orbit.compose.collectAsState import org.orbitmvi.orbit.compose.collectSideEffect @OptIn(ExperimentalMaterial3ExpressiveApi::class) @@ -31,63 +29,46 @@ fun SplitTunnelScreen( sharedViewModel: SharedAppViewModel = koinActivityViewModel(), ) { - val tunnelsUiState by sharedViewModel.tunnelsUiState.collectAsStateWithLifecycle() - val uiState by viewModel.container.stateFlow.collectAsStateWithLifecycle() + val uiState by viewModel.collectAsState() - var showDialog by remember { mutableStateOf(false) } + var showSelectionDialog by rememberSaveable { mutableStateOf(false) } if (uiState.isLoading) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularWavyProgressIndicator(waveSpeed = 60.dp, modifier = Modifier.size(48.dp)) } return } - val tunnel = uiState.tunnel ?: return - - var effectiveTunnel by remember { mutableStateOf(tunnel) } - - val conf by remember(effectiveTunnel) { derivedStateOf { effectiveTunnel.toAmConfig() } } - - var splitConfig by - remember(conf) { - mutableStateOf( - when { - conf.`interface`.excludedApplications.isNotEmpty() -> - Pair(SplitOption.EXCLUDE, conf.`interface`.excludedApplications.toSet()) - conf.`interface`.includedApplications.isNotEmpty() -> - Pair(SplitOption.INCLUDE, conf.`interface`.includedApplications.toSet()) - else -> Pair(SplitOption.ALL, emptySet()) - } - ) - } sharedViewModel.collectSideEffect { sideEffect -> - if (sideEffect is LocalSideEffect.SaveChanges) - viewModel.saveSplitTunnelSelection(splitConfig) - if (sideEffect is LocalSideEffect.Modal.SelectTunnel) showDialog = true + when (sideEffect) { + is LocalSideEffect.SaveChanges -> viewModel.save() + is LocalSideEffect.Modal.SelectTunnel -> showSelectionDialog = true + else -> Unit + } } SelectTunnelModal( - showDialog, - tunnelsUiState.tunnels, - onAttest = { conf -> - if (conf == null) return@SelectTunnelModal - effectiveTunnel = conf - showDialog = false + show = showSelectionDialog, + tunnels = uiState.tunnels, + selectedTunnelId = uiState.selectedCopySourceTunnelId, + onSelect = viewModel::selectCopySource, + onAttest = { + viewModel.applyCopySource() + showSelectionDialog = false + }, + onDismiss = { + showSelectionDialog = false + viewModel.selectCopySource(null) }, - onDismiss = { showDialog = false }, ) SplitTunnelContent( - splitConfig = splitConfig, + splitConfig = uiState.splitOption to uiState.selectedPackages, installedPackages = uiState.installedPackages, - onSplitOptionChange = { splitConfig = Pair(it, splitConfig.second) }, + onSplitOptionChange = { viewModel.setSplitOption(it) }, onAppSelectionToggle = { appPackage, enabled -> - val updated = - splitConfig.second.toMutableSet().apply { - if (!enabled) remove(appPackage) else add(appPackage) - } - splitConfig = Pair(splitConfig.first, updated) + viewModel.togglePackage(appPackage, enabled) }, ) } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/splittunnel/components/AppListItem.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/splittunnel/components/AppListItem.kt index 064f22795..527ee4295 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/splittunnel/components/AppListItem.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/splittunnel/components/AppListItem.kt @@ -9,7 +9,11 @@ import androidx.compose.foundation.layout.size import androidx.compose.material3.Checkbox import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.LocalMinimumInteractiveComponentSize -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/splittunnel/components/AppListSection.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/splittunnel/components/AppListSection.kt index 1c4e55b5e..76a8ac5a0 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/splittunnel/components/AppListSection.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/splittunnel/components/AppListSection.kt @@ -1,7 +1,12 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.splittunnel.components import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.text.KeyboardActions @@ -10,7 +15,12 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Search import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/splittunnel/components/SelectTunnelModal.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/splittunnel/components/SelectTunnelModal.kt index 52beef1e3..c84c497cc 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/splittunnel/components/SelectTunnelModal.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/splittunnel/components/SelectTunnelModal.kt @@ -11,60 +11,57 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Check import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.res.stringResource import com.zaneschepke.wireguardautotunnel.R -import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConfig import com.zaneschepke.wireguardautotunnel.ui.common.button.SurfaceRow import com.zaneschepke.wireguardautotunnel.ui.common.dialog.InfoDialog +import com.zaneschepke.wireguardautotunnel.ui.state.TunnelSummary @Composable fun SelectTunnelModal( show: Boolean, - tunnels: List, - onAttest: (tunnelConf: TunnelConfig?) -> Unit, + tunnels: List, + selectedTunnelId: Int?, + onSelect: (Int) -> Unit, + onAttest: (Int?) -> Unit, onDismiss: () -> Unit, ) { - var selectedTunnel by remember { mutableStateOf(null) } - if (show) { - InfoDialog( - title = stringResource(R.string.copy_from), - body = { - LazyColumn( - horizontalAlignment = Alignment.Start, - verticalArrangement = Arrangement.Top, - modifier = - Modifier.pointerInput(Unit) { if (tunnels.isEmpty()) return@pointerInput } - .overscroll(rememberOverscrollEffect()), - state = rememberLazyListState(), - userScrollEnabled = true, - reverseLayout = false, - flingBehavior = ScrollableDefaults.flingBehavior(), - ) { - items(tunnels, key = { it.id }) { tunnel -> - SurfaceRow( - title = tunnel.name, - trailing = - if (selectedTunnel?.id == tunnel.id) { - { - Icon( - Icons.Outlined.Check, - stringResource(id = R.string.selected), - tint = MaterialTheme.colorScheme.primary, - ) - } - } else null, - onClick = { selectedTunnel = tunnel }, - ) - } + if (!show) return + + InfoDialog( + title = stringResource(R.string.copy_from), + body = { + LazyColumn( + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.Top, + modifier = Modifier.overscroll(rememberOverscrollEffect()), + state = rememberLazyListState(), + userScrollEnabled = true, + flingBehavior = ScrollableDefaults.flingBehavior(), + ) { + items(tunnels, key = { it.id }) { tunnel -> + SurfaceRow( + title = tunnel.name, + trailing = + if (selectedTunnelId == tunnel.id) { + { + Icon( + Icons.Outlined.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } + } else null, + onClick = { onSelect(tunnel.id) }, + ) } - }, - onAttest = { onAttest(selectedTunnel) }, - onDismiss = { onDismiss() }, - confirmText = stringResource(R.string.copy), - ) - } + } + }, + onAttest = { onAttest(selectedTunnelId) }, + onDismiss = onDismiss, + confirmText = stringResource(R.string.copy), + ) } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/splittunnel/components/SplitOptionsSelector.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/splittunnel/components/SplitOptionsSelector.kt index fa2d7055a..dcc109939 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/splittunnel/components/SplitOptionsSelector.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/splittunnel/components/SplitOptionsSelector.kt @@ -8,7 +8,12 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Check -import androidx.compose.material3.* +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MultiChoiceSegmentedButtonRow +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/splittunnel/state/SplitTunnelUiState.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/splittunnel/state/SplitTunnelUiState.kt deleted file mode 100644 index f090a018b..000000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/splittunnel/state/SplitTunnelUiState.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.splittunnel.state - -// data class SplitTunnelUiState( -// val loading: Boolean = true, -// val tunnelConf: TunnelConf? = null, -// val tunneledApps: SplitTunnelApps = emptyList(), -// val queriedApps: SplitTunnelApps = emptyList(), -// val splitOption: SplitOption = SplitOption.ALL, -// val searchQuery: String = "", -// val success: Boolean? = null, -// ) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/splittunnel/state/TunnelApp.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/splittunnel/state/TunnelApp.kt index f9fdbda5a..cfe4ca81d 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/splittunnel/state/TunnelApp.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/splittunnel/state/TunnelApp.kt @@ -1,5 +1,3 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.splittunnel.state data class TunnelApp(val name: String, val `package`: String) - -typealias SplitTunnelApps = List> diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/sideeffect/LocalSideEffect.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/sideeffect/LocalSideEffect.kt index db4fd2e35..83b29cc11 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/sideeffect/LocalSideEffect.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/sideeffect/LocalSideEffect.kt @@ -14,6 +14,8 @@ sealed class LocalSideEffect { data object SaveChanges : LocalSideEffect() + data object ShowSensitive : LocalSideEffect() + sealed class Sheet : LocalSideEffect() { data object ImportTunnels : Sheet() diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/AutoTunnelUiState.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/AutoTunnelUiState.kt index 575ef33d8..c69321585 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/AutoTunnelUiState.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/AutoTunnelUiState.kt @@ -5,8 +5,8 @@ import com.zaneschepke.wireguardautotunnel.domain.model.AutoTunnelSettings import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConfig data class AutoTunnelUiState( - val autoTunnelActive: Boolean = false, val connectivityState: ConnectivityState? = null, + val autoTunnelActive: Boolean = false, val autoTunnelSettings: AutoTunnelSettings = AutoTunnelSettings(), val isBatteryOptimizationShown: Boolean = false, val isLocationDisclosureShown: Boolean = false, diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/ConfigProxy.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/ConfigProxy.kt deleted file mode 100644 index 4e7181ff1..000000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/ConfigProxy.kt +++ /dev/null @@ -1,42 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.ui.state - -import org.amnezia.awg.config.Config - -data class ConfigProxy( - val peers: List = emptyList(), - val `interface`: InterfaceProxy = InterfaceProxy(), -) { - - fun hasScripts(): Boolean { - return `interface`.preUp.isNotBlank() || - `interface`.preDown.isNotBlank() || - `interface`.postUp.isNotBlank() || - `interface`.postDown.isNotBlank() - } - - fun buildConfigs(): Pair { - return Pair( - com.wireguard.config.Config.Builder() - .apply { - addPeers(peers.map { it.toWgPeer() }) - setInterface(`interface`.toWgInterface()) - } - .build(), - Config.Builder() - .apply { - addPeers(peers.map { it.toAmPeer() }) - setInterface(`interface`.toAmInterface()) - } - .build(), - ) - } - - companion object { - fun from(amConfig: Config): ConfigProxy { - return ConfigProxy( - `interface` = InterfaceProxy.from(amConfig.`interface`), - peers = amConfig.peers.map { PeerProxy.from(it) }, - ) - } - } -} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/ConfigUiState.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/ConfigUiState.kt index af334482f..1f7689836 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/ConfigUiState.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/ConfigUiState.kt @@ -1,11 +1,65 @@ package com.zaneschepke.wireguardautotunnel.ui.state +import com.zaneschepke.wireguardautotunnel.data.entity.TunnelConfig as Entity import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConfig data class ConfigUiState( - val unavailableNames: List = emptyList(), val isLoading: Boolean = true, + val initialized: Boolean = false, + + // persisted/backend state val tunnel: TunnelConfig? = null, + val tunnels: List = emptyList(), val isRunning: Boolean = false, + + // editable draft + val draft: ConfigDraft = ConfigDraft(), + + // persisted settings + val globalSettings: GlobalSettingsState = GlobalSettingsState(), + + // ui + val ui: ConfigEditUiState = ConfigEditUiState(), +) { + val isGlobalConfig: Boolean + get() = tunnel?.name == Entity.GLOBAL_CONFIG_NAME + + val showAmneziaGlobals: Boolean + get() = isGlobalConfig && globalSettings.amneziaEnabled + + val globalInterfaceSettingsEnabled: Boolean + get() = globalSettings.dnsEnabled || globalSettings.amneziaEnabled + + val isAmneziaCompatibilitySet: Boolean + get() = draft.config.`interface`.isAmneziaCompatibilityModeSet() + + val isTunnelNameTaken: Boolean + get() = tunnels.any { it.id != tunnel?.id && it.name == draft.tunnelName } + + val isDirty: Boolean + get() { + val original = tunnel ?: return false + + return draft != + ConfigDraft( + tunnelName = original.name, + config = EditableConfig.from(original.getConfig()), + ) + } +} + +data class ConfigEditUiState( + val showScripts: Boolean = false, + val showSensitiveData: Boolean = false, + val showAmneziaValues: Boolean = false, + val isInterfaceDropdownExpanded: Boolean = false, + val isPeerDropdownExpanded: Boolean = false, val showSaveModal: Boolean = false, + val selectedCopySourceTunnelId: Int? = null, ) + +data class GlobalSettingsState(val dnsEnabled: Boolean = false, val amneziaEnabled: Boolean = false) + +data class ConfigDraft(val tunnelName: String = "", val config: EditableConfig = EditableConfig()) + +data class TunnelSummary(val id: Int, val name: String) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/DisplayTunnelState.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/DisplayTunnelState.kt new file mode 100644 index 000000000..fd838b4c0 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/DisplayTunnelState.kt @@ -0,0 +1,83 @@ +package com.zaneschepke.wireguardautotunnel.ui.state + +import android.content.Context +import androidx.annotation.StringRes +import androidx.compose.ui.graphics.Color +import com.zaneschepke.tunnel.Tunnel +import com.zaneschepke.tunnel.state.ActiveTunnel +import com.zaneschepke.tunnel.state.BootstrapState +import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.ui.theme.AlertRed +import com.zaneschepke.wireguardautotunnel.ui.theme.CoolGray +import com.zaneschepke.wireguardautotunnel.ui.theme.SilverTree +import com.zaneschepke.wireguardautotunnel.ui.theme.Straw + +sealed class DisplayTunnelState { + data object Connecting : DisplayTunnelState() + + data object ResolvingDns : DisplayTunnelState() + + data object Connected : DisplayTunnelState() + + data object Ready : DisplayTunnelState() + + data object Degraded : DisplayTunnelState() + + data object Disconnected : DisplayTunnelState() + + @StringRes + fun labelRes(): Int { + return when (this) { + ResolvingDns -> R.string.tunnel_state_resolving_dns + Connecting -> R.string.tunnel_state_starting + Connected -> R.string.tunnel_state_connected + Degraded -> R.string.tunnel_state_handshake_failure + Disconnected -> R.string.tunnel_state_disconnected + Ready -> R.string.ready + } + } + + fun asLocalizedString(context: Context): String { + return context.getString(this.labelRes()) + } + + fun asColor(): Color { + return when (this) { + Disconnected -> CoolGray + Connecting, + Ready, + ResolvingDns -> Straw + Degraded -> AlertRed + Connected -> SilverTree + } + } + + companion object { + fun from(activeTunnel: ActiveTunnel): DisplayTunnelState { + val transport = activeTunnel.transportState + val bootstrap = activeTunnel.bootstrapState + + return when { + transport is Tunnel.State.Down -> Disconnected + + (bootstrap is BootstrapState.Complete && + !activeTunnel.isPeerUpdating && + transport is Tunnel.State.Starting) || + bootstrap is BootstrapState.None && transport is Tunnel.State.Starting -> Ready + + bootstrap is BootstrapState.ResolvingDns || + (bootstrap is BootstrapState.Complete && activeTunnel.isPeerUpdating) -> + ResolvingDns + + transport is Tunnel.State.Starting -> Connecting + + transport is Tunnel.State.Up.Healthy -> Connected + + transport is Tunnel.State.Up.HandshakeFailure && !activeTunnel.isPeerUpdating -> + Degraded + + else -> Connecting + } + } + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/DnsUiState.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/DnsUiState.kt index 390f637b9..f027d0a90 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/DnsUiState.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/DnsUiState.kt @@ -1,10 +1,14 @@ package com.zaneschepke.wireguardautotunnel.ui.state +import com.zaneschepke.networkmonitor.DnsInfo import com.zaneschepke.wireguardautotunnel.domain.model.DnsSettings import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConfig +import com.zaneschepke.wireguardautotunnel.util.DnsError data class DnsUiState( - val isLoading: Boolean = true, val dnsSettings: DnsSettings = DnsSettings(), - val globalConfig: TunnelConfig? = null, + val isLoading: Boolean = true, + val globalTunnelConfig: TunnelConfig? = null, + val peerResolutionEndpointError: DnsError? = null, + val systemDnsInfo: DnsInfo? = null, ) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/EditableConfig.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/EditableConfig.kt new file mode 100644 index 000000000..8abfd162f --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/EditableConfig.kt @@ -0,0 +1,63 @@ +package com.zaneschepke.wireguardautotunnel.ui.state + +import com.zaneschepke.wireguardautotunnel.parser.Config +import com.zaneschepke.wireguardautotunnel.parser.InterfaceSection +import com.zaneschepke.wireguardautotunnel.util.extensions.toTrimmedList + +data class EditableConfig( + val `interface`: EditableInterface = EditableInterface(), + val peers: List = emptyList(), + val headerComments: List = emptyList(), +) { + + fun buildConfig(): Config { + val interfaceSection = + InterfaceSection( + privateKey = `interface`.privateKey.trim(), + address = `interface`.addresses.ifBlank { null }, + dns = `interface`.dnsServers.ifBlank { null }, + listenPort = `interface`.listenPort.toIntOrNull(), + mtu = `interface`.mtu.toIntOrNull(), + preUp = `interface`.preUp.toTrimmedList(), + postUp = `interface`.postUp.toTrimmedList(), + preDown = `interface`.preDown.toTrimmedList(), + postDown = `interface`.postDown.toTrimmedList(), + includedApplications = `interface`.includedApplications.toList(), + excludedApplications = `interface`.excludedApplications.toList(), + jC = `interface`.junkPacketCount.toIntOrNull(), + jMin = `interface`.junkPacketMinSize.toIntOrNull(), + jMax = `interface`.junkPacketMaxSize.toIntOrNull(), + s1 = `interface`.initPacketJunkSize.toIntOrNull(), + s2 = `interface`.responsePacketJunkSize.toIntOrNull(), + s3 = `interface`.transportPacketJunkSize.toIntOrNull(), + s4 = `interface`.cookiePacketJunkSize.toIntOrNull(), + h1 = `interface`.initPacketMagicHeader.ifBlank { null }, + h2 = `interface`.responsePacketMagicHeader.ifBlank { null }, + h3 = `interface`.underloadPacketMagicHeader.ifBlank { null }, + h4 = `interface`.transportPacketMagicHeader.ifBlank { null }, + i1 = `interface`.i1.ifBlank { null }, + i2 = `interface`.i2.ifBlank { null }, + i3 = `interface`.i3.ifBlank { null }, + i4 = `interface`.i4.ifBlank { null }, + i5 = `interface`.i5.ifBlank { null }, + ) + + val peerSections = peers.map { it.toPeerSection() } + + return Config( + `interface` = interfaceSection, + peers = peerSections, + headerComments = headerComments, + ) + } + + companion object { + fun from(config: Config): EditableConfig { + return EditableConfig( + `interface` = EditableInterface.from(config.`interface`), + peers = config.peers.map { EditablePeer.from(it) }, + headerComments = config.headerComments, + ) + } + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/InterfaceProxy.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/EditableInterface.kt similarity index 62% rename from app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/InterfaceProxy.kt rename to app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/EditableInterface.kt index ce8b1a29c..e92069f59 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/InterfaceProxy.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/EditableInterface.kt @@ -1,13 +1,12 @@ package com.zaneschepke.wireguardautotunnel.ui.state -import com.wireguard.config.Interface +import com.zaneschepke.wireguardautotunnel.parser.Config +import com.zaneschepke.wireguardautotunnel.parser.InterfaceSection import com.zaneschepke.wireguardautotunnel.util.extensions.ifNotBlank import com.zaneschepke.wireguardautotunnel.util.extensions.joinAndTrim -import com.zaneschepke.wireguardautotunnel.util.extensions.toTrimmedList -import java.util.* -import kotlin.jvm.optionals.getOrElse +import java.util.Base64 -data class InterfaceProxy( +data class EditableInterface( val privateKey: String = "", val publicKey: String = "", val addresses: String = "", @@ -16,6 +15,10 @@ data class InterfaceProxy( val mtu: String = "", val includedApplications: Set = emptySet(), val excludedApplications: Set = emptySet(), + val preUp: String = "", + val postUp: String = "", + val preDown: String = "", + val postDown: String = "", val junkPacketCount: String = "", val junkPacketMinSize: String = "", val junkPacketMaxSize: String = "", @@ -32,29 +35,9 @@ data class InterfaceProxy( val i3: String = "", val i4: String = "", val i5: String = "", - val preUp: String = "", - val postUp: String = "", - val preDown: String = "", - val postDown: String = "", ) { - fun toWgInterface(): Interface { - return Interface.Builder() - .apply { - parseAddresses(addresses) - parsePrivateKey(privateKey) - dnsServers.ifNotBlank { parseDnsServers(it) } - listenPort.ifNotBlank { parseListenPort(it) } - mtu.ifNotBlank { parseMtu(it) } - includeApplications(includedApplications) - excludeApplications(excludedApplications) - preUp.toTrimmedList().forEach { parsePreUp(it) } - postUp.toTrimmedList().forEach { parsePostUp(it) } - preDown.toTrimmedList().forEach { parsePreDown(it) } - postDown.toTrimmedList().forEach { parsePostDown(it) } - } - .build() - } + fun hasScripts(): Boolean = listOf(preUp, postUp, preDown, postDown).any { it.isNotBlank() } fun isAmneziaEnabled(): Boolean { return listOf( @@ -78,7 +61,7 @@ data class InterfaceProxy( .any { it.isNotBlank() } } - fun toAmneziaCompatibilityConfig(): InterfaceProxy { + fun toAmneziaCompatibilityConfig(): EditableInterface { return copy( junkPacketCount = "4", junkPacketMinSize = "40", @@ -99,7 +82,7 @@ data class InterfaceProxy( ) } - fun resetAmneziaProperties(): InterfaceProxy { + fun resetAmneziaProperties(): EditableInterface { return copy( junkPacketCount = "", junkPacketMinSize = "", @@ -120,7 +103,6 @@ data class InterfaceProxy( ) } - // TODO fix this later when we get amnezia to properly return 0 fun isAmneziaCompatibilityModeSet(): Boolean { return (initPacketJunkSize.toIntOrNull() ?: 0) == 0 && (responsePacketJunkSize.toIntOrNull() ?: 0) == 0 && @@ -134,38 +116,40 @@ data class InterfaceProxy( return isAmneziaCompatibilityModeSet() } - fun toAmInterface(): org.amnezia.awg.config.Interface { - return org.amnezia.awg.config.Interface.Builder() - .apply { - parseAddresses(addresses) - parsePrivateKey(privateKey) - dnsServers.ifNotBlank { parseDnsServers(it) } - listenPort.ifNotBlank { parseListenPort(it) } - mtu.ifNotBlank { parseMtu(it) } - includeApplications(includedApplications) - excludeApplications(excludedApplications) - preUp.toTrimmedList().forEach { parsePreUp(it) } - postUp.toTrimmedList().forEach { parsePostUp(it) } - preDown.toTrimmedList().forEach { parsePreDown(it) } - postDown.toTrimmedList().forEach { parsePostDown(it) } - junkPacketCount.ifNotBlank { parseJunkPacketCount(it) } - junkPacketMinSize.ifNotBlank { parseJunkPacketMinSize(it) } - junkPacketMaxSize.ifNotBlank { parseJunkPacketMaxSize(it) } - transportPacketJunkSize.ifNotBlank { parseTransportPacketJunkSize(it) } - cookiePacketJunkSize.ifNotBlank { parseCookieReplyPacketJunkSize(it) } - initPacketJunkSize.ifNotBlank { parseInitPacketJunkSize(it) } - responsePacketJunkSize.ifNotBlank { parseResponsePacketJunkSize(it) } - initPacketMagicHeader.ifNotBlank { parseInitPacketMagicHeader(it) } - responsePacketMagicHeader.ifNotBlank { parseResponsePacketMagicHeader(it) } - underloadPacketMagicHeader.ifNotBlank { parseUnderloadPacketMagicHeader(it) } - transportPacketMagicHeader.ifNotBlank { parseTransportPacketMagicHeader(it) } - i1.ifNotBlank { parseSpecialJunkI1(it) } - i2.ifNotBlank { parseSpecialJunkI2(it) } - i3.ifNotBlank { parseSpecialJunkI3(it) } - i4.ifNotBlank { parseSpecialJunkI4(it) } - i5.ifNotBlank { parseSpecialJunkI5(it) } - } - .build() + /** + * Mimics QUIC (HTTP/3) traffic by setting i1 to a QUIC initial packet and i2 to a follow-up + * frame. Adds j1 for extra obfuscation. Compatible with standard WireGuard servers. + */ + fun setQuicMimic(): EditableInterface { + return copy( + i1 = + "", + i2 = "", + ) + } + + /** + * Mimics DNS query traffic with a single i1 packet. DNS is typically single-packet, so no + * additional i/j packets are set. Compatible with standard WireGuard servers. + */ + fun setDnsMimic(): EditableInterface { + return copy( + i1 = "", + i2 = "", + ) + } + + /** + * Mimics SIP (VoIP) traffic with i1 as an INVITE packet and i2 as a follow-up (e.g., TRYING + * response). Adds j1 for extra obfuscation. Compatible with standard WireGuard servers. + */ + fun setSipMimic(): EditableInterface { + return copy( + i1 = + "", + i2 = + "", + ) } fun getValidationErrors(): List { @@ -177,10 +161,9 @@ data class InterfaceProxy( errors.add("Invalid private key format (must be 44-character Base64)") } - // Addresses validation (basic) if (addresses.isBlank()) { errors.add("Addresses are required") - } // More detailed CIDR validation can be added if needed + } listenPort.ifNotBlank { val port = it.toIntOrNull() @@ -264,42 +247,6 @@ data class InterfaceProxy( return errors } - /** - * Mimics QUIC (HTTP/3) traffic by setting i1 to a QUIC initial packet and i2 to a follow-up - * frame. Adds j1 for extra obfuscation. Compatible with standard WireGuard servers. - */ - fun setQuicMimic(): InterfaceProxy { - return copy( - i1 = - "", - i2 = "", - ) - } - - /** - * Mimics DNS query traffic with a single i1 packet. DNS is typically single-packet, so no - * additional i/j packets are set. Compatible with standard WireGuard servers. - */ - fun setDnsMimic(): InterfaceProxy { - return copy( - i1 = "", - i2 = "", - ) - } - - /** - * Mimics SIP (VoIP) traffic with i1 as an INVITE packet and i2 as a follow-up (e.g., TRYING - * response). Adds j1 for extra obfuscation. Compatible with standard WireGuard servers. - */ - fun setSipMimic(): InterfaceProxy { - return copy( - i1 = - "", - i2 = - "", - ) - } - private fun isValidBase64(str: String): Boolean { return try { Base64.getDecoder().decode(str) @@ -310,69 +257,45 @@ data class InterfaceProxy( } companion object { - fun from(i: Interface): InterfaceProxy { - val dnsString = - listOf( - i.dnsServers.joinToString(", ").replace("/", "").trim(), - i.dnsSearchDomains.joinAndTrim(), - ) - .filter { it.isNotEmpty() } - .joinToString(", ") - .takeIf { it.isNotBlank() } - return InterfaceProxy( - publicKey = i.keyPair.publicKey.toBase64().trim(), - privateKey = i.keyPair.privateKey.toBase64().trim(), - addresses = i.addresses.joinToString(", ").trim(), - dnsServers = dnsString ?: "", - listenPort = - if (i.listenPort.isPresent) i.listenPort.get().toString().trim() else "", - mtu = if (i.mtu.isPresent) i.mtu.get().toString().trim() else "", - includedApplications = i.includedApplications.toMutableSet(), - excludedApplications = i.excludedApplications.toMutableSet(), - preUp = i.preUp.joinAndTrim(), - postUp = i.postUp.joinAndTrim(), - preDown = i.preDown.joinAndTrim(), - postDown = i.postDown.joinAndTrim(), - ) - } + fun from(i: InterfaceSection): EditableInterface { + val pubKey = + if (i.privateKey.isNotBlank()) { + try { + Config.generatePublicKeyFromPrivateKey(i.privateKey) + } catch (e: Exception) { + "" + } + } else "" - fun from(i: org.amnezia.awg.config.Interface): InterfaceProxy { - val dnsString = - (i.dnsServers + i.dnsSearchDomains) - .joinToString(", ") - .replace("/", "") - .trim() - .takeIf { it.isNotBlank() } - return InterfaceProxy( - publicKey = i.keyPair.publicKey.toBase64().trim(), - privateKey = i.keyPair.privateKey.toBase64().trim(), - addresses = i.addresses.joinToString(", ").trim(), - dnsServers = dnsString ?: "", - listenPort = - if (i.listenPort.isPresent) i.listenPort.get().toString().trim() else "", - mtu = if (i.mtu.isPresent) i.mtu.get().toString().trim() else "", - includedApplications = i.includedApplications.toMutableSet(), - excludedApplications = i.excludedApplications.toMutableSet(), - preUp = i.preUp.joinAndTrim(), - postUp = i.postUp.joinAndTrim(), - preDown = i.preDown.joinAndTrim(), - postDown = i.postDown.joinAndTrim(), - junkPacketCount = i.junkPacketCount.getOrElse { "" }.toString(), - junkPacketMinSize = i.junkPacketMinSize.getOrElse { "" }.toString(), - junkPacketMaxSize = i.junkPacketMaxSize.getOrElse { "" }.toString(), - initPacketJunkSize = i.initPacketJunkSize.getOrElse { "" }.toString(), - transportPacketJunkSize = i.transportPacketJunkSize.getOrElse { "" }.toString(), - cookiePacketJunkSize = i.cookieReplyPacketJunkSize.getOrElse { "" }.toString(), - responsePacketJunkSize = i.responsePacketJunkSize.getOrElse { "" }.toString(), - initPacketMagicHeader = i.initPacketMagicHeader.getOrElse { "" }, - responsePacketMagicHeader = i.responsePacketMagicHeader.getOrElse { "" }, - underloadPacketMagicHeader = i.underloadPacketMagicHeader.getOrElse { "" }, - transportPacketMagicHeader = i.transportPacketMagicHeader.getOrElse { "" }, - i1 = i.specialJunkI1.getOrElse { "" }, - i2 = i.specialJunkI2.getOrElse { "" }, - i3 = i.specialJunkI3.getOrElse { "" }, - i4 = i.specialJunkI4.getOrElse { "" }, - i5 = i.specialJunkI5.getOrElse { "" }, + return EditableInterface( + privateKey = i.privateKey, + publicKey = pubKey, + addresses = i.address?.trim() ?: "", + dnsServers = i.dns?.trim() ?: "", + listenPort = i.listenPort?.toString() ?: "", + mtu = i.mtu?.toString() ?: "", + includedApplications = i.includedApplications.orEmpty().toSet(), + excludedApplications = i.excludedApplications.orEmpty().toSet(), + preUp = i.preUp?.joinAndTrim() ?: "", + postUp = i.postUp?.joinAndTrim() ?: "", + preDown = i.preDown?.joinAndTrim() ?: "", + postDown = i.postDown?.joinAndTrim() ?: "", + junkPacketCount = i.jC?.toString() ?: "", + junkPacketMinSize = i.jMin?.toString() ?: "", + junkPacketMaxSize = i.jMax?.toString() ?: "", + initPacketJunkSize = i.s1?.toString() ?: "", + responsePacketJunkSize = i.s2?.toString() ?: "", + transportPacketJunkSize = i.s3?.toString() ?: "", + cookiePacketJunkSize = i.s4?.toString() ?: "", + initPacketMagicHeader = i.h1 ?: "", + responsePacketMagicHeader = i.h2 ?: "", + underloadPacketMagicHeader = i.h3 ?: "", + transportPacketMagicHeader = i.h4 ?: "", + i1 = i.i1 ?: "", + i2 = i.i2 ?: "", + i3 = i.i3 ?: "", + i4 = i.i4 ?: "", + i5 = i.i5 ?: "", ) } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/EditablePeer.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/EditablePeer.kt new file mode 100644 index 000000000..a1cd6a2bf --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/EditablePeer.kt @@ -0,0 +1,42 @@ +package com.zaneschepke.wireguardautotunnel.ui.state + +import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConfig +import com.zaneschepke.wireguardautotunnel.parser.PeerSection +import com.zaneschepke.wireguardautotunnel.util.extensions.joinAndTrim + +data class EditablePeer( + val publicKey: String = "", + val preSharedKey: String = "", + val persistentKeepalive: String = "", + val endpoint: String = "", + val allowedIps: String = TunnelConfig.ALL_IPS.joinAndTrim(), +) { + + fun toPeerSection(): PeerSection = + PeerSection( + publicKey = publicKey.trim(), + allowedIPs = allowedIps.ifBlank { null }, + endpoint = endpoint.ifBlank { null }, + presharedKey = preSharedKey.ifBlank { null }, + persistentKeepalive = persistentKeepalive.toIntOrNull(), + ) + + fun isLanExcluded(): Boolean = + this.allowedIps.contains(TunnelConfig.LAN_BYPASS_ALLOWED_IPS.joinAndTrim()) + + fun includeLan(): EditablePeer = this.copy(allowedIps = TunnelConfig.ALL_IPS.joinAndTrim()) + + fun excludeLan(): EditablePeer = + this.copy(allowedIps = TunnelConfig.LAN_BYPASS_ALLOWED_IPS.joinAndTrim()) + + companion object { + fun from(peer: PeerSection): EditablePeer = + EditablePeer( + publicKey = peer.publicKey, + preSharedKey = peer.presharedKey ?: "", + persistentKeepalive = peer.persistentKeepalive?.toString() ?: "", + endpoint = peer.endpoint ?: "", + allowedIps = peer.allowedIPs ?: TunnelConfig.ALL_IPS.joinAndTrim(), + ) + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/GlobalAppUiState.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/GlobalAppUiState.kt index 42f708258..89e145515 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/GlobalAppUiState.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/GlobalAppUiState.kt @@ -1,6 +1,6 @@ package com.zaneschepke.wireguardautotunnel.ui.state -import com.zaneschepke.wireguardautotunnel.data.model.AppMode +import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelMode import com.zaneschepke.wireguardautotunnel.ui.theme.Theme import com.zaneschepke.wireguardautotunnel.util.LocaleUtil @@ -9,7 +9,7 @@ data class GlobalAppUiState( val theme: Theme = Theme.AUTOMATIC, val locale: String = LocaleUtil.OPTION_PHONE_LANGUAGE, val pinLockEnabled: Boolean = false, - val appMode: AppMode = AppMode.VPN, + val tunnelMode: TunnelMode = TunnelMode.VPN, val shouldShowDonationSnackbar: Boolean = false, val isLocationDisclosureShown: Boolean = false, val isBatteryOptimizationShown: Boolean = false, @@ -18,4 +18,5 @@ data class GlobalAppUiState( val selectedTunnelCount: Int = 0, val alreadyDonated: Boolean = false, val isPinVerified: Boolean = false, + val isScreenRecordingProtectionEnabled: Boolean = false, ) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/LockdownSettingsUiState.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/LockdownSettingsUiState.kt index 876b81ea8..eaae20c58 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/LockdownSettingsUiState.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/LockdownSettingsUiState.kt @@ -4,6 +4,9 @@ import com.zaneschepke.wireguardautotunnel.domain.model.LockdownSettings data class LockdownSettingsUiState( val lockdownSettings: LockdownSettings = LockdownSettings(), + val metered: Boolean = false, + val dualStack: Boolean = false, + val bypassLan: Boolean = false, val isLoading: Boolean = true, val showSaveModal: Boolean = false, ) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/MonitoringUiState.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/MonitoringUiState.kt index e6e48d964..f7ee0208c 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/MonitoringUiState.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/MonitoringUiState.kt @@ -1,12 +1,9 @@ package com.zaneschepke.wireguardautotunnel.ui.state -import com.zaneschepke.wireguardautotunnel.data.model.AppMode -import com.zaneschepke.wireguardautotunnel.domain.model.MonitoringSettings -import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConfig +import com.zaneschepke.wireguardautotunnel.domain.enums.StatisticRefresh data class MonitoringUiState( - val monitoringSettings: MonitoringSettings = MonitoringSettings(), - val tunnels: List = emptyList(), - val appMode: AppMode = AppMode.VPN, + val tunnelStatisticsEnabled: Boolean = false, + val statisticRefresh: StatisticRefresh = StatisticRefresh.BALANCED, val isLoading: Boolean = true, ) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/PeerProxy.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/PeerProxy.kt deleted file mode 100644 index 80f3f530a..000000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/PeerProxy.kt +++ /dev/null @@ -1,101 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.ui.state - -import com.wireguard.config.Peer -import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConfig -import com.zaneschepke.wireguardautotunnel.util.extensions.joinAndTrim - -data class PeerProxy( - val publicKey: String = "", - val preSharedKey: String = "", - val persistentKeepalive: String = "", - val endpoint: String = "", - val allowedIps: String = TunnelConfig.ALL_IPS.joinAndTrim(), -) { - fun toWgPeer(): Peer { - return Peer.Builder() - .apply { - parsePublicKey(publicKey) - if (preSharedKey.isNotBlank()) parsePreSharedKey(preSharedKey) - if (persistentKeepalive.isNotBlank()) parsePersistentKeepalive(persistentKeepalive) - if (endpoint.isNotBlank()) parseEndpoint(endpoint) - parseAllowedIPs(allowedIps) - } - .build() - } - - fun toAmPeer(): org.amnezia.awg.config.Peer { - return org.amnezia.awg.config.Peer.Builder() - .apply { - parsePublicKey(publicKey) - if (preSharedKey.isNotBlank()) parsePreSharedKey(preSharedKey) - if (persistentKeepalive.isNotBlank()) parsePersistentKeepalive(persistentKeepalive) - if (endpoint.isNotBlank()) parseEndpoint(endpoint) - parseAllowedIPs(allowedIps) - } - .build() - } - - fun isLanExcluded(): Boolean { - return this.allowedIps.contains(TunnelConfig.LAN_BYPASS_ALLOWED_IPS.joinAndTrim()) - } - - fun includeLan(): PeerProxy { - return this.copy(allowedIps = TunnelConfig.ALL_IPS.joinAndTrim()) - } - - fun excludeLan(): PeerProxy { - return this.copy(allowedIps = TunnelConfig.LAN_BYPASS_ALLOWED_IPS.joinAndTrim()) - } - - companion object { - fun from(peer: Peer): PeerProxy { - return PeerProxy( - publicKey = peer.publicKey.toBase64(), - preSharedKey = - if (peer.preSharedKey.isPresent) { - peer.preSharedKey.get().toBase64().trim() - } else { - "" - }, - persistentKeepalive = - if (peer.persistentKeepalive.isPresent) { - peer.persistentKeepalive.get().toString().trim() - } else { - "" - }, - endpoint = - if (peer.endpoint.isPresent) { - peer.endpoint.get().toString().trim() - } else { - "" - }, - allowedIps = peer.allowedIps.joinToString(", ").trim(), - ) - } - - fun from(peer: org.amnezia.awg.config.Peer): PeerProxy { - return PeerProxy( - publicKey = peer.publicKey.toBase64(), - preSharedKey = - if (peer.preSharedKey.isPresent) { - peer.preSharedKey.get().toBase64().trim() - } else { - "" - }, - persistentKeepalive = - if (peer.persistentKeepalive.isPresent) { - peer.persistentKeepalive.get().toString().trim() - } else { - "" - }, - endpoint = - if (peer.endpoint.isPresent) { - peer.endpoint.get().toString().trim() - } else { - "" - }, - allowedIps = peer.allowedIps.joinToString(", ").trim(), - ) - } - } -} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/ProxySettingsUiState.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/ProxySettingsUiState.kt index 1f95308a5..3b3c177a7 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/ProxySettingsUiState.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/ProxySettingsUiState.kt @@ -1,16 +1,24 @@ package com.zaneschepke.wireguardautotunnel.ui.state +import com.zaneschepke.tunnel.state.BackendStatus import com.zaneschepke.wireguardautotunnel.domain.model.ProxySettings -import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState data class ProxySettingsUiState( val proxySettings: ProxySettings = ProxySettings(), - val activeTuns: Map = emptyMap(), + + // edit fields + val socks5Enabled: Boolean = false, + val httpEnabled: Boolean = false, + val socksBindAddress: String = "", + val httpBindAddress: String = "", + val proxyUsername: String = "", + val proxyPassword: String = "", + val passwordVisible: Boolean = false, + val backendStatus: BackendStatus = BackendStatus(), val isSocks5BindAddressError: Boolean = false, val isHttpBindAddressError: Boolean = false, val isUserNameError: Boolean = false, val isPasswordError: Boolean = false, - val passwordVisible: Boolean = false, val isLoading: Boolean = true, val showSaveModal: Boolean = false, ) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/SplitTunnelUiState.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/SplitTunnelUiState.kt index 1cb002795..3bc20d27b 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/SplitTunnelUiState.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/SplitTunnelUiState.kt @@ -2,9 +2,14 @@ package com.zaneschepke.wireguardautotunnel.ui.state import com.zaneschepke.wireguardautotunnel.domain.model.InstalledPackage import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConfig +import com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.splittunnel.state.SplitOption data class SplitTunnelUiState( val installedPackages: List = emptyList(), val isLoading: Boolean = true, val tunnel: TunnelConfig? = null, + val tunnels: List = emptyList(), + val splitOption: SplitOption = SplitOption.ALL, + val selectedPackages: Set = emptySet(), + val selectedCopySourceTunnelId: Int? = null, ) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/TunnelUiState.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/TunnelUiState.kt index 72f5f2b4c..b279cb857 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/TunnelUiState.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/TunnelUiState.kt @@ -1,9 +1,12 @@ package com.zaneschepke.wireguardautotunnel.ui.state import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConfig +import com.zaneschepke.wireguardautotunnel.parser.ActiveConfig data class TunnelUiState( val tunnel: TunnelConfig? = null, - val isActive: Boolean = false, + val activeConfig: ActiveConfig? = null, + val includedAppsCount: Int? = null, + val excludedAppsCount: Int? = null, val isLoading: Boolean = true, ) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/TunnelsUiState.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/TunnelsUiState.kt index 1b6ead927..dd18d33d3 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/TunnelsUiState.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/TunnelsUiState.kt @@ -1,13 +1,11 @@ package com.zaneschepke.wireguardautotunnel.ui.state +import com.zaneschepke.tunnel.state.BackendStatus import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConfig -import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState data class TunnelsUiState( val tunnels: List = emptyList(), - val activeTunnels: Map = emptyMap(), + val backendStatus: BackendStatus = BackendStatus(), val selectedTunnels: List = emptyList(), - val isPingEnabled: Boolean = false, - val showPingStats: Boolean = false, val isLoading: Boolean = true, ) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/theme/Color.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/theme/Color.kt index c5bd00679..eba3c0720 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/theme/Color.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/theme/Color.kt @@ -21,6 +21,10 @@ val Straw = Color(0xFFD4C483) val Disabled = CoolGray.copy(alpha = 0.4f) +// Config colors +val ConfigHeaderColor = Color(0xFFBB86FC) +val ConfigKeyColor = Color(0xFF03DAC5) + sealed class ThemeColors( val background: Color, val surface: Color, 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 01790d2c3..f808cbc9a 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 @@ -4,7 +4,14 @@ import android.app.Activity import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material.ripple.RippleAlpha -import androidx.compose.material3.* +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.LocalRippleConfiguration +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RippleConfiguration +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.SideEffect diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/DnsValidator.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/DnsValidator.kt new file mode 100644 index 000000000..ed605e6ec --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/DnsValidator.kt @@ -0,0 +1,176 @@ +package com.zaneschepke.wireguardautotunnel.util + +import com.zaneschepke.wireguardautotunnel.domain.enums.DnsProtocol + +object DnsValidator { + + private const val DEFAULT_DOT_PORT = 853 + private const val DEFAULT_DNS_PORT = 53 + + sealed class Result { + data object Valid : Result() + + data class Invalid(val error: DnsError) : Result() + } + + fun normalize(protocol: DnsProtocol, input: String?): String { + val value = input?.trim().orEmpty() + + if (value.isEmpty()) return value + + return when (protocol) { + DnsProtocol.SYSTEM -> value + DnsProtocol.DOH -> normalizeDoH(value) + DnsProtocol.DOT -> normalizeDoT(value) + DnsProtocol.UDP -> normalizeUdp(value) + } + } + + fun validate(protocol: DnsProtocol, endpoint: String?): Result { + if (protocol == DnsProtocol.SYSTEM) return Result.Valid + + val value = endpoint?.trim().orEmpty() + if (value.isEmpty()) { + return Result.Invalid(DnsError.Empty) + } + + return when (protocol) { + DnsProtocol.SYSTEM -> Result.Valid + DnsProtocol.DOH -> validateDoH(value) + DnsProtocol.DOT -> validateDoT(value) + DnsProtocol.UDP -> validateUdp(value) + } + } + + private fun validateDoH(value: String): Result { + return try { + val uri = java.net.URI(value) + + if (uri.scheme != "https") { + return Result.Invalid(DnsError.InvalidScheme) + } + + if (uri.host.isNullOrBlank()) { + return Result.Invalid(DnsError.InvalidHost) + } + + Result.Valid + } catch (_: Exception) { + Result.Invalid(DnsError.InvalidUrl) + } + } + + private fun validateDoT(value: String): Result { + val parts = value.split(":") + + val host = parts.getOrNull(0)?.trim() + val port = parts.getOrNull(1)?.toIntOrNull() ?: 853 + + if (host.isNullOrBlank()) { + return Result.Invalid(DnsError.InvalidHost) + } + + if (!isValidHostOrIp(host)) { + return Result.Invalid(DnsError.InvalidIpOrHost) + } + + if (port !in 1..65535) { + return Result.Invalid(DnsError.InvalidPort) + } + + return Result.Valid + } + + private fun validateUdp(value: String): DnsValidator.Result { + val parts = value.split(":") + + val host = parts.getOrNull(0)?.trim() + val port = parts.getOrNull(1)?.toIntOrNull() ?: 53 + + if (host.isNullOrBlank()) { + return DnsValidator.Result.Invalid(DnsError.InvalidHost) + } + + // basic IP/hostname sanity check + if (!isValidHostOrIp(host)) { + return DnsValidator.Result.Invalid(DnsError.InvalidIpOrHost) + } + + if (port !in 1..65535) { + return DnsValidator.Result.Invalid(DnsError.InvalidPort) + } + + return DnsValidator.Result.Valid + } + + private fun isValidHostOrIp(value: String): Boolean { + return isValidIpv4(value) || isValidHostname(value) + } + + private fun isValidIpv4(value: String): Boolean { + val parts = value.split(".") + if (parts.size != 4) return false + + return parts.all { it.toIntOrNull()?.let { num -> num in 0..255 } == true } + } + + private fun isValidHostname(value: String): Boolean { + if (value.length > 253) return false + + val labels = value.split(".") + + return labels.all { label -> + label.matches(Regex("^[a-zA-Z0-9-]{1,63}$")) && + !label.startsWith("-") && + !label.endsWith("-") + } + } + + private fun normalizeDoH(value: String): String { + return if (value.startsWith("http://") || value.startsWith("https://")) { + value + } else { + "https://$value" + } + } + + private fun normalizeDoT(value: String): String { + val parts = value.split(":") + + val host = parts.getOrNull(0)?.trim().orEmpty() + val port = parts.getOrNull(1) + + return if (port == null) { + "$host:$DEFAULT_DOT_PORT" + } else { + value + } + } + + private fun normalizeUdp(value: String): String { + val parts = value.split(":") + + val host = parts.getOrNull(0)?.trim().orEmpty() + val port = parts.getOrNull(1) + + return if (port == null) { + "$host:$DEFAULT_DNS_PORT" + } else { + value + } + } +} + +sealed class DnsError { + data object Empty : DnsError() + + data object InvalidUrl : DnsError() + + data object InvalidScheme : DnsError() + + data object InvalidHost : DnsError() + + data object InvalidPort : DnsError() + + data object InvalidIpOrHost : DnsError() +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/FileUtils.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/FileUtils.kt index 661671954..f5243404e 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/FileUtils.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/FileUtils.kt @@ -12,7 +12,12 @@ import androidx.annotation.RequiresApi import com.zaneschepke.wireguardautotunnel.util.extensions.QuickConfig import com.zaneschepke.wireguardautotunnel.util.extensions.TunnelName import com.zaneschepke.wireguardautotunnel.util.extensions.getInputStreamFromUri -import java.io.* +import java.io.BufferedOutputStream +import java.io.File +import java.io.FileInputStream +import java.io.FileNotFoundException +import java.io.FileOutputStream +import java.io.IOException import java.util.zip.ZipEntry import java.util.zip.ZipInputStream import java.util.zip.ZipOutputStream diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/RootShellUtils.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/RootShellUtils.kt deleted file mode 100644 index be626a6eb..000000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/RootShellUtils.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.util - -import com.wireguard.android.util.RootShell -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.withContext - -class RootShellUtils( - private val rootShell: RootShell, - private val ioDispatcher: CoroutineDispatcher, -) { - - suspend fun requestRoot(): Boolean = - withContext(ioDispatcher) { - val accepted = - try { - rootShell.start() - true - } catch (_: Exception) { - false - } - accepted - } -} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/extensions/ContextExtensions.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/extensions/ContextExtensions.kt index 8c909abb5..9fd8d4a3e 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/extensions/ContextExtensions.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/extensions/ContextExtensions.kt @@ -26,7 +26,7 @@ import com.zaneschepke.wireguardautotunnel.util.Constants import com.zaneschepke.wireguardautotunnel.util.FileUtils import java.io.File import java.io.InputStream -import java.util.* +import java.util.Locale import kotlin.system.exitProcess import timber.log.Timber 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 d4b265402..c6ebc4e00 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 @@ -1,7 +1,12 @@ package com.zaneschepke.wireguardautotunnel.util.extensions +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter import kotlin.math.pow import kotlin.math.roundToInt +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds fun List.update(index: Int, item: T): List = toMutableList().apply { this[index] = item } @@ -21,3 +26,10 @@ fun Double.round(decimals: Int): Double { val factor = 10.0.pow(decimals) return (this * factor).roundToInt() / factor } + +fun Instant.toUserFriendlyTimestamp(): String = + DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss").withZone(ZoneId.systemDefault()).format(this) + +fun Long.secondsAgo(): Duration { + return (System.currentTimeMillis() - (this * 1000L)).milliseconds +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/extensions/StringExtensions.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/extensions/StringExtensions.kt index 37815ddf5..0575bac7f 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/extensions/StringExtensions.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/extensions/StringExtensions.kt @@ -1,6 +1,6 @@ package com.zaneschepke.wireguardautotunnel.util.extensions -import java.util.* +import java.util.Locale import timber.log.Timber val hasNumberInParentheses = """^(.+?)\((\d+)\)$""".toRegex() @@ -111,6 +111,6 @@ inline fun String?.ifNotBlank(block: (String) -> Unit): String? { return this } -fun String.isTextTooLargeForQr(maxBytes: Int = 1200): Boolean { +fun String.isTextTooLargeForQr(maxBytes: Int = 1500): Boolean { return toByteArray(Charsets.UTF_8).size > maxBytes } 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 c398b140c..6a6eb6b27 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,119 +1,151 @@ package com.zaneschepke.wireguardautotunnel.util.extensions -import com.wireguard.android.backend.BackendException import com.zaneschepke.wireguardautotunnel.R -import com.zaneschepke.wireguardautotunnel.domain.enums.BackendMode -import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelStatus -import com.zaneschepke.wireguardautotunnel.domain.events.* +import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConfig +import com.zaneschepke.wireguardautotunnel.parser.Config +import com.zaneschepke.wireguardautotunnel.parser.ConfigParseException +import com.zaneschepke.wireguardautotunnel.parser.InterfaceSection +import com.zaneschepke.wireguardautotunnel.parser.PeerSection import com.zaneschepke.wireguardautotunnel.util.NumberUtils import com.zaneschepke.wireguardautotunnel.util.StringValue -import org.amnezia.awg.backend.Backend -import org.amnezia.awg.backend.Tunnel -import org.amnezia.awg.config.BadConfigException -import org.amnezia.awg.config.Config -import timber.log.Timber -fun BadConfigException.asStringValue(): StringValue { - val reason = - when (this.reason) { - BadConfigException.Reason.INVALID_KEY -> R.string.invalid_key - BadConfigException.Reason.INVALID_NUMBER -> R.string.invalid_number - BadConfigException.Reason.INVALID_VALUE -> R.string.invalid_value - BadConfigException.Reason.MISSING_ATTRIBUTE -> R.string.missing_attribute - BadConfigException.Reason.MISSING_SECTION -> R.string.missing_section - BadConfigException.Reason.SYNTAX_ERROR -> R.string.syntax_error - BadConfigException.Reason.UNKNOWN_ATTRIBUTE -> R.string.unknown_attribute - BadConfigException.Reason.UNKNOWN_SECTION -> R.string.unknown_section - } - val location = this.location.name - return StringValue.StringResource(R.string.config_error_template, reason, location) +fun ConfigParseException.asStringValue(): StringValue { + return StringValue.StringResource( + R.string.config_error_template, + this.errorType.name, + this.field, + ) } -fun com.wireguard.config.BadConfigException.asStringValue(): StringValue { - val reason = - when (this.reason) { - com.wireguard.config.BadConfigException.Reason.INVALID_KEY -> R.string.invalid_key - com.wireguard.config.BadConfigException.Reason.INVALID_NUMBER -> R.string.invalid_number - com.wireguard.config.BadConfigException.Reason.INVALID_VALUE -> R.string.invalid_value - com.wireguard.config.BadConfigException.Reason.MISSING_ATTRIBUTE -> - R.string.missing_attribute - com.wireguard.config.BadConfigException.Reason.MISSING_SECTION -> - R.string.missing_section - com.wireguard.config.BadConfigException.Reason.SYNTAX_ERROR -> R.string.syntax_error - com.wireguard.config.BadConfigException.Reason.UNKNOWN_ATTRIBUTE -> - R.string.unknown_attribute - com.wireguard.config.BadConfigException.Reason.UNKNOWN_SECTION -> - R.string.unknown_section - } - val location = this.location.name - return StringValue.StringResource(R.string.config_error_template, reason, location) +fun Config.defaultName(): String { + return this.peers[0].host ?: NumberUtils.generateRandomTunnelName() } -fun Config.defaultName(): String { - return try { - this.peers[0].endpoint.get().host - } catch (e: Exception) { - Timber.e(e) - NumberUtils.generateRandomTunnelName() - } +fun PeerSection.isLanExcluded(): Boolean = + this.allowedIPs?.contains(TunnelConfig.LAN_BYPASS_ALLOWED_IPS.joinAndTrim()) == true + +fun PeerSection.includeLan(): PeerSection = + this.copy(allowedIPs = TunnelConfig.ALL_IPS.joinAndTrim()) + +fun PeerSection.excludeLan(): PeerSection = + this.copy(allowedIPs = TunnelConfig.LAN_BYPASS_ALLOWED_IPS.joinAndTrim()) + +/** + * Mimics QUIC (HTTP/3) traffic by setting i1 to a QUIC initial packet and i2 to a follow-up frame. + * Adds j1 for extra obfuscation. Compatible with standard WireGuard servers. + */ +fun InterfaceSection.setQuicMimic(): InterfaceSection { + return copy( + i1 = + "", + i2 = "", + ) +} + +/** + * Mimics DNS query traffic with a single i1 packet. DNS is typically single-packet, so no + * additional i/j packets are set. Compatible with standard WireGuard servers. + */ +fun InterfaceSection.setDnsMimic(): InterfaceSection { + return copy(i1 = "", i2 = "") } -fun Backend.BackendMode.asBackendMode(): BackendMode { - return when (val status = this) { - is Backend.BackendMode.KillSwitch -> - BackendMode.KillSwitch(status.allowedIps, status.isMetered, status.isDualStack) - else -> BackendMode.Inactive - } +/** + * Mimics SIP (VoIP) traffic with i1 as an INVITE packet and i2 as a follow-up (e.g., TRYING + * response). Adds j1 for extra obfuscation. Compatible with standard WireGuard servers. + */ +fun InterfaceSection.setSipMimic(): InterfaceSection { + return copy( + i1 = + "", + i2 = + "", + ) } -fun BackendMode.asAmBackendMode(): Backend.BackendMode { - return when (val status = this) { - is BackendMode.Inactive -> Backend.BackendMode.Inactive.INSTANCE - is BackendMode.KillSwitch -> - Backend.BackendMode.KillSwitch(status.allowedIps, status.isMetered, status.dualStack) - } +fun InterfaceSection.isAmneziaEnabled(): Boolean { + return listOfNotNull( + jC?.toString(), + jMin?.toString(), + jMax?.toString(), + s1?.toString(), + s2?.toString(), + s3?.toString(), + s4?.toString(), + h1, + h2, + h3, + h4, + i1, + i2, + i3, + i4, + i5, + ) + .any { it.isNotBlank() } } -fun Tunnel.State.asTunnelState(): TunnelStatus { - return when (this) { - Tunnel.State.DOWN -> TunnelStatus.Down - Tunnel.State.UP -> TunnelStatus.Up(System.currentTimeMillis()) - } +fun InterfaceSection.toAmneziaCompatibilityConfig(): InterfaceSection { + return copy( + jC = 4, + jMin = 40, + jMax = 70, + s1 = 0, + s2 = 0, + s3 = 0, + s4 = 0, + h1 = "1", + h2 = "2", + h3 = "3", + h4 = "4", + i1 = null, + i2 = null, + i3 = null, + i4 = null, + i5 = null, + ) } -fun BackendException.toBackendCoreException(): BackendCoreException { - return when (this.reason) { - BackendException.Reason.VPN_NOT_AUTHORIZED -> VpnUnauthorized() - BackendException.Reason.DNS_RESOLUTION_FAILURE -> DnsFailure() - BackendException.Reason.UNKNOWN_KERNEL_MODULE_NAME -> VpnUnauthorized() - BackendException.Reason.WG_QUICK_CONFIG_ERROR_CODE -> InvalidConfig() - BackendException.Reason.TUNNEL_MISSING_CONFIG -> InvalidConfig() - BackendException.Reason.UNABLE_TO_START_VPN -> VpnUnauthorized() - BackendException.Reason.TUN_CREATION_ERROR -> VpnUnauthorized() - BackendException.Reason.GO_ACTIVATION_ERROR_CODE -> UnknownError() - } +fun InterfaceSection.resetAmneziaProperties(): InterfaceSection { + return copy( + jC = null, + jMin = null, + jMax = null, + s1 = null, + s2 = null, + s3 = null, + s4 = null, + h1 = null, + h2 = null, + h3 = null, + h4 = null, + i1 = null, + i2 = null, + i3 = null, + i4 = null, + i5 = null, + ) } -fun org.amnezia.awg.backend.BackendException.toBackendCoreException(): BackendCoreException { - return when (this.reason) { - org.amnezia.awg.backend.BackendException.Reason.VPN_NOT_AUTHORIZED -> VpnUnauthorized() - org.amnezia.awg.backend.BackendException.Reason.DNS_RESOLUTION_FAILURE -> DnsFailure() - org.amnezia.awg.backend.BackendException.Reason.UNKNOWN_KERNEL_MODULE_NAME -> - VpnUnauthorized() - org.amnezia.awg.backend.BackendException.Reason.AWG_QUICK_CONFIG_ERROR_CODE -> - InvalidConfig() - org.amnezia.awg.backend.BackendException.Reason.TUNNEL_MISSING_CONFIG -> InvalidConfig() - org.amnezia.awg.backend.BackendException.Reason.UNABLE_TO_START_VPN -> VpnUnauthorized() - org.amnezia.awg.backend.BackendException.Reason.TUN_CREATION_ERROR -> VpnUnauthorized() - org.amnezia.awg.backend.BackendException.Reason.GO_ACTIVATION_ERROR_CODE -> UnknownError() - org.amnezia.awg.backend.BackendException.Reason.SERVICE_NOT_RUNNING -> ServiceNotRunning() - org.amnezia.awg.backend.BackendException.Reason.UAPI_UPDATE_FAILED -> UapiUpdateFailed() - } +fun InterfaceSection.isAmneziaCompatibilityModeSet(): Boolean { + return jC == 4 && + jMin == 40 && + jMax == 70 && + s1 == 0 && + s2 == 0 && + s3 == 0 && + s4 == 0 && + h1 == "1" && + h2 == "2" && + h3 == "3" && + h4 == "4" && + i1.isNullOrBlank() && + i2.isNullOrBlank() && + i3.isNullOrBlank() && + i4.isNullOrBlank() && + i5.isNullOrBlank() } -fun com.wireguard.android.backend.Tunnel.State.asTunnelState(): TunnelStatus { - return when (this) { - com.wireguard.android.backend.Tunnel.State.DOWN -> TunnelStatus.Down - com.wireguard.android.backend.Tunnel.State.UP -> TunnelStatus.Up(System.currentTimeMillis()) - } +fun InterfaceSection.isCompatibleWithStandardWireGuard(): Boolean { + return isAmneziaCompatibilityModeSet() } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/extensions/UiExtensions.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/extensions/UiExtensions.kt index 0af7a2256..36cc0e8e4 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/extensions/UiExtensions.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/extensions/UiExtensions.kt @@ -4,25 +4,24 @@ import android.content.Context import android.icu.text.MeasureFormat import android.icu.util.Measure import android.icu.util.MeasureUnit +import androidx.annotation.StringRes import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Lock -import androidx.compose.material.icons.outlined.Terminal import androidx.compose.material.icons.outlined.VpnKey import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.vectorResource import com.zaneschepke.networkmonitor.AndroidNetworkMonitor +import com.zaneschepke.tunnel.state.ActiveTunnel import com.zaneschepke.wireguardautotunnel.R -import com.zaneschepke.wireguardautotunnel.data.model.AppMode -import com.zaneschepke.wireguardautotunnel.data.model.WifiDetectionMethod -import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState -import com.zaneschepke.wireguardautotunnel.ui.theme.AlertRed -import com.zaneschepke.wireguardautotunnel.ui.theme.CoolGray -import com.zaneschepke.wireguardautotunnel.ui.theme.SilverTree -import com.zaneschepke.wireguardautotunnel.ui.theme.Straw +import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelMode +import com.zaneschepke.wireguardautotunnel.domain.enums.WifiDetectionMethod +import com.zaneschepke.wireguardautotunnel.ui.state.DisplayTunnelState +import com.zaneschepke.wireguardautotunnel.util.DnsError import java.util.Locale +import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds fun WifiDetectionMethod.asTitleString(context: Context): String { return when (this) { @@ -51,77 +50,89 @@ fun WifiDetectionMethod.asDescriptionString(context: Context): String? { } } -fun AppMode.asTitleString(context: Context): String { +fun TunnelMode.asTitleString(context: Context): String { return when (this) { - AppMode.VPN -> asString(context) - AppMode.PROXY -> context.getString(R.string.expiremental_template, asString(context)) - AppMode.KERNEL -> context.getString(R.string.root_required_template, asString(context)) - AppMode.LOCK_DOWN -> context.getString(R.string.expiremental_template, asString(context)) + TunnelMode.VPN -> asString(context) + TunnelMode.PROXY -> asString(context) + TunnelMode.LOCK_DOWN -> asString(context) } } -fun AppMode.asString(context: Context): String { +fun TunnelMode.asString(context: Context): String { return when (this) { - AppMode.VPN -> context.getString(R.string.vpn) - AppMode.PROXY -> context.getString(R.string.proxy) - AppMode.KERNEL -> context.getString(R.string.kernel) - AppMode.LOCK_DOWN -> context.getString(R.string.lockdown) + TunnelMode.VPN -> context.getString(R.string.vpn) + TunnelMode.PROXY -> context.getString(R.string.proxy) + TunnelMode.LOCK_DOWN -> context.getString(R.string.lockdown) } } -fun AppMode.description(context: Context): String? { - return if (this == AppMode.KERNEL) - context.getString(R.string.only_template, context.getString(R.string.wireguard)) - else null -} - @Composable -fun AppMode.asIcon(): ImageVector { - return when (this) { - AppMode.VPN -> Icons.Outlined.VpnKey - AppMode.PROXY -> ImageVector.vectorResource(R.drawable.proxy) - AppMode.KERNEL -> Icons.Outlined.Terminal - AppMode.LOCK_DOWN -> Icons.Outlined.Lock - } -} - -fun TunnelState.Health.asColor(): Color { +fun TunnelMode.asIcon(): ImageVector { return when (this) { - TunnelState.Health.UNKNOWN -> CoolGray - TunnelState.Health.UNHEALTHY -> AlertRed - TunnelState.Health.HEALTHY -> SilverTree - TunnelState.Health.STALE -> Straw + TunnelMode.VPN -> Icons.Outlined.VpnKey + TunnelMode.PROXY -> ImageVector.vectorResource(R.drawable.proxy) + TunnelMode.LOCK_DOWN -> Icons.Outlined.Lock } } -fun Long.localizedDuration(locale: Locale = Locale.getDefault()): String { - require(this >= 0L) { "Duration cannot be negative" } +fun Duration.localized(locale: Locale = Locale.getDefault()): String { + require(this >= Duration.ZERO) { "Duration cannot be negative" } - val duration = this.milliseconds - - if (duration < 1000.milliseconds) { + if (this < 1.seconds) { return MeasureFormat.getInstance(locale, MeasureFormat.FormatWidth.SHORT) .format(Measure(0, MeasureUnit.SECOND)) } - val totalSeconds = duration.inWholeSeconds - - val days = totalSeconds / 86_400 - val hours = (totalSeconds % 86_400) / 3_600 - val minutes = (totalSeconds % 3_600) / 60 - val seconds = totalSeconds % 60 - val measures = buildList { - if (days > 0) add(Measure(days, MeasureUnit.DAY)) - if (hours > 0) add(Measure(hours, MeasureUnit.HOUR)) - if (minutes > 0) add(Measure(minutes, MeasureUnit.MINUTE)) - if (seconds > 0) add(Measure(seconds, MeasureUnit.SECOND)) + if (inWholeDays > 0) add(Measure(inWholeDays, MeasureUnit.DAY)) + if (inWholeHours % 24 > 0) add(Measure(inWholeHours % 24, MeasureUnit.HOUR)) + if (inWholeMinutes % 60 > 0) add(Measure(inWholeMinutes % 60, MeasureUnit.MINUTE)) + if (inWholeSeconds % 60 > 0) add(Measure(inWholeSeconds % 60, MeasureUnit.SECOND)) } return MeasureFormat.getInstance(locale, MeasureFormat.FormatWidth.SHORT) .formatMeasures(*measures.toTypedArray()) } -fun Long.millisAgo(): Long { - return System.currentTimeMillis() - this +fun Long?.toAgoDisplay(currentTimeMillis: Long = System.currentTimeMillis()): String? { + val timestamp = this ?: return null + if (timestamp <= 0L) return null + + val nowSeconds = currentTimeMillis / 1000 + val secondsAgo = (nowSeconds - timestamp).coerceAtLeast(0L) + + return secondsAgo.seconds.localized() +} + +fun Long.toUptimeDisplay(currentTimeMillis: Long = System.currentTimeMillis()): String { + val elapsedMillis = (currentTimeMillis - this).coerceAtLeast(0L) + return elapsedMillis.milliseconds.localized() +} + +@StringRes +fun DnsError.labelRes(): Int { + return when (this) { + DnsError.Empty -> R.string.dns_error_empty + DnsError.InvalidUrl -> R.string.dns_error_invalid_url + DnsError.InvalidScheme -> R.string.dns_error_invalid_scheme + DnsError.InvalidHost -> R.string.dns_error_invalid_host + DnsError.InvalidPort -> R.string.dns_error_invalid_port + DnsError.InvalidIpOrHost -> R.string.dns_error_invalid_ip_or_host + } +} + +fun ActiveTunnel.statusText(context: Context): String { + return context.getString( + R.string.status_template, + DisplayTunnelState.from(this).asLocalizedString(context), + ) +} + +fun ActiveTunnel.uptimeText(context: Context, now: Long): String? { + + val startedAt = uptime ?: return null + + val uptimeDisplay = startedAt.toUptimeDisplay(now) + + return context.getString(R.string.uptime_template, uptimeDisplay) } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/network/NetworkUtils.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/network/NetworkUtils.kt index 9164fee72..42dfc7582 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/network/NetworkUtils.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/network/NetworkUtils.kt @@ -6,10 +6,15 @@ import com.zaneschepke.wireguardautotunnel.util.extensions.round import java.io.IOException import java.time.Instant import kotlin.math.sqrt -import kotlinx.coroutines.* +import kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout import timber.log.Timber class NetworkUtils(private val ioDispatcher: CoroutineDispatcher) { @@ -32,7 +37,7 @@ class NetworkUtils(private val ioDispatcher: CoroutineDispatcher) { count: Int, timeoutMillis: Long = (count * 2000L), ): PingStats { - return withTimeout(timeoutMillis) { + return withTimeout(timeoutMillis.milliseconds) { withContext(ioDispatcher) { val icmp = Icmp4a() val stats = PingStats() diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/AutoTunnelViewModel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/AutoTunnelViewModel.kt index ae81ee8a8..8928716aa 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/AutoTunnelViewModel.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/AutoTunnelViewModel.kt @@ -3,20 +3,23 @@ package com.zaneschepke.wireguardautotunnel.viewmodel import androidx.core.content.PermissionChecker.PERMISSION_GRANTED import androidx.lifecycle.ViewModel import com.zaneschepke.networkmonitor.NetworkMonitor +import com.zaneschepke.networkmonitor.StableNetworkEngine +import com.zaneschepke.tunnel.backend.RootShell import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.core.orchestration.AutoTunnelCoordinator import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager -import com.zaneschepke.wireguardautotunnel.data.model.AppMode -import com.zaneschepke.wireguardautotunnel.data.model.WifiDetectionMethod +import com.zaneschepke.wireguardautotunnel.core.service.autotunnel.AutoTunnelStateHolder +import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelMode +import com.zaneschepke.wireguardautotunnel.domain.enums.WifiDetectionMethod import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConfig import com.zaneschepke.wireguardautotunnel.domain.repository.AutoTunnelSettingsRepository import com.zaneschepke.wireguardautotunnel.domain.repository.GlobalEffectRepository import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository import com.zaneschepke.wireguardautotunnel.domain.sideeffect.GlobalSideEffect import com.zaneschepke.wireguardautotunnel.ui.state.AutoTunnelUiState -import com.zaneschepke.wireguardautotunnel.util.RootShellUtils import com.zaneschepke.wireguardautotunnel.util.StringValue import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull import org.orbitmvi.orbit.ContainerHost import org.orbitmvi.orbit.viewmodel.container import rikka.shizuku.Shizuku @@ -24,10 +27,12 @@ import rikka.shizuku.Shizuku class AutoTunnelViewModel( private val autoTunnelRepository: AutoTunnelSettingsRepository, private val serviceManager: ServiceManager, - private val networkMonitor: NetworkMonitor, + private val stableNetworkEngine: StableNetworkEngine, + networkMonitor: NetworkMonitor, private val globalEffectRepository: GlobalEffectRepository, + private val autoTunnelCoordinator: AutoTunnelCoordinator, private val tunnelsRepository: TunnelRepository, - private val rootShellUtils: RootShellUtils, + private val autoTunnelStateHolder: AutoTunnelStateHolder, ) : ContainerHost, ViewModel() { init { @@ -41,11 +46,11 @@ class AutoTunnelViewModel( ) { intent { combine( - networkMonitor.connectivityStateFlow, - serviceManager.autoTunnelService.map { it != null }, + stableNetworkEngine.stableState.mapNotNull { it?.state }, autoTunnelRepository.flow, tunnelsRepository.userTunnelsFlow, - ) { connectivity, active, autoTunnel, tunnels -> + autoTunnelStateHolder.active, + ) { connectivity, autoTunnel, tunnels, active -> state.copy( autoTunnelActive = active, connectivityState = connectivity, @@ -62,20 +67,19 @@ class AutoTunnelViewModel( globalEffectRepository.post(globalSideEffect) } - fun toggleAutoTunnel(appMode: AppMode) = intent { + fun toggleAutoTunnel(tunnelMode: TunnelMode) = intent { if (!state.autoTunnelActive) { - when (appMode) { - AppMode.VPN -> + when (tunnelMode) { + TunnelMode.VPN -> if (!serviceManager.hasVpnPermission()) return@intent postSideEffect( - GlobalSideEffect.RequestVpnPermission(AppMode.VPN, null) + GlobalSideEffect.RequestVpnPermission(TunnelMode.VPN, null) ) + else -> Unit } - autoTunnelRepository.upsert(state.autoTunnelSettings.copy(isAutoTunnelEnabled = true)) - } else { - autoTunnelRepository.upsert(state.autoTunnelSettings.copy(isAutoTunnelEnabled = false)) } + autoTunnelCoordinator.toggle() } fun setAutoTunnelOnWifiEnabled(to: Boolean) = intent { @@ -123,10 +127,6 @@ class AutoTunnelViewModel( autoTunnelRepository.upsert(state.autoTunnelSettings.copy(startOnBoot = to)) } - fun setDebounceDelay(to: Int) = intent { - autoTunnelRepository.upsert(state.autoTunnelSettings.copy(debounceDelaySeconds = to)) - } - fun setPreferredMobileDataTunnel(tunnel: TunnelConfig?) = intent { tunnelsRepository.updateMobileDataTunnel(tunnel) } @@ -152,7 +152,7 @@ class AutoTunnelViewModel( fun setWifiDetectionMethod(method: WifiDetectionMethod) = intent { when (method) { WifiDetectionMethod.ROOT -> { - val accepted = rootShellUtils.requestRoot() + val accepted = RootShell.requestRootPermission() val message = if (!accepted) StringValue.StringResource(R.string.error_root_denied) else StringValue.StringResource(R.string.root_accepted) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/ConfigEditViewModel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/ConfigEditViewModel.kt new file mode 100644 index 000000000..8b2878021 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/ConfigEditViewModel.kt @@ -0,0 +1,326 @@ +package com.zaneschepke.wireguardautotunnel.viewmodel + +import androidx.lifecycle.ViewModel +import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.core.orchestration.TunnelCoordinator +import com.zaneschepke.wireguardautotunnel.domain.enums.MimicMode +import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConfig +import com.zaneschepke.wireguardautotunnel.domain.repository.DnsSettingsRepository +import com.zaneschepke.wireguardautotunnel.domain.repository.GeneralSettingRepository +import com.zaneschepke.wireguardautotunnel.domain.repository.GlobalEffectRepository +import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository +import com.zaneschepke.wireguardautotunnel.domain.sideeffect.GlobalSideEffect +import com.zaneschepke.wireguardautotunnel.parser.ConfigParseException +import com.zaneschepke.wireguardautotunnel.ui.state.ConfigDraft +import com.zaneschepke.wireguardautotunnel.ui.state.ConfigEditUiState +import com.zaneschepke.wireguardautotunnel.ui.state.ConfigUiState +import com.zaneschepke.wireguardautotunnel.ui.state.EditableConfig +import com.zaneschepke.wireguardautotunnel.ui.state.EditableInterface +import com.zaneschepke.wireguardautotunnel.ui.state.EditablePeer +import com.zaneschepke.wireguardautotunnel.ui.state.GlobalSettingsState +import com.zaneschepke.wireguardautotunnel.util.StringValue +import com.zaneschepke.wireguardautotunnel.util.extensions.asStringValue +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import org.orbitmvi.orbit.ContainerHost +import org.orbitmvi.orbit.viewmodel.container +import timber.log.Timber + +class ConfigEditViewModel( + private val tunnelRepository: TunnelRepository, + private val dnsSettingsRepository: DnsSettingsRepository, + private val settingsRepository: GeneralSettingRepository, + private val globalEffectRepository: GlobalEffectRepository, + private val tunnelCoordinator: TunnelCoordinator, + val tunnelId: Int?, +) : ContainerHost, ViewModel() { + + override val container = + container( + ConfigUiState(), + buildSettings = { repeatOnSubscribedStopTimeout = 5000L }, + ) { + combine( + tunnelCoordinator.backendStatus, + tunnelRepository.flow, + dnsSettingsRepository.flow.map { it.isGlobalTunnelDnsEnabled }, + settingsRepository.flow.map { it.isGlobalAmneziaEnabled }, + ) { backendStatus, tunnels, globalDnsEnabled, globalAmneziaEnabled -> + val tunnel = tunnels.firstOrNull { it.id == tunnelId } + + Triple( + tunnel, + tunnels.filterNot { it.isGlobalConfig }.map { it.toSummary() }, + backendStatus.activeTunnels.containsKey(tunnelId), + ) to + GlobalSettingsState( + dnsEnabled = globalDnsEnabled, + amneziaEnabled = globalAmneziaEnabled, + ) + } + .collect { (backendData, globalSettings) -> + val (tunnel, tunnels, isRunning) = backendData + + intent { + if (!state.initialized && tunnel != null) { + + val config = EditableConfig.from(tunnel.getConfig()) + + reduce { + state.copy( + initialized = true, + isLoading = false, + tunnel = tunnel, + tunnels = tunnels, + isRunning = isRunning, + globalSettings = globalSettings, + ui = + ConfigEditUiState( + showScripts = config.`interface`.hasScripts(), + showAmneziaValues = + config.`interface`.isAmneziaEnabled(), + ), + draft = ConfigDraft(tunnelName = tunnel.name, config = config), + ) + } + + return@intent + } + + reduce { + state.copy( + isLoading = false, + tunnel = tunnel, + tunnels = tunnels, + isRunning = isRunning, + globalSettings = globalSettings, + ) + } + } + } + } + + fun save() = intent { + reduce { state.copy(ui = state.ui.copy(showSaveModal = false)) } + + if (state.isTunnelNameTaken) { + + postSideEffect( + GlobalSideEffect.Snackbar(StringValue.StringResource(R.string.tunnel_name_taken)) + ) + + return@intent + } + + if (state.draft.tunnelName.isBlank()) { + postSideEffect( + GlobalSideEffect.Snackbar(StringValue.StringResource(R.string.name_error_empty)) + ) + return@intent + } + + runCatching { + val config = state.draft.config.buildConfig() + + config.validate() + + val quickConfig = config.asQuickString() + + val tunnelConfig = + if (tunnelId == null) { + TunnelConfig.tunnelConfFromQuick(quickConfig, state.draft.tunnelName) + } else { + state.tunnel?.copy(name = state.draft.tunnelName, quickConfig = quickConfig) + } + + tunnelConfig?.let { + tunnelRepository.save(it) + + dnsSettingsRepository.updateGlobalDnsEnabled(state.globalSettings.dnsEnabled) + + settingsRepository.updateGlobalAmneziaEnabled( + state.globalSettings.amneziaEnabled + ) + + if (state.isRunning) { + tunnelCoordinator.stopTunnel(it.id) + tunnelCoordinator.startTunnel(it) + } + + postSideEffect( + GlobalSideEffect.Toast( + StringValue.StringResource(R.string.config_changes_saved) + ) + ) + + postSideEffect(GlobalSideEffect.PopBackStack) + } + } + .onFailure { + Timber.e(it) + + val message = + when (it) { + is ConfigParseException -> it.asStringValue() + else -> StringValue.StringResource(R.string.unknown_error) + } + + postSideEffect(GlobalSideEffect.Snackbar(message)) + } + } + + fun onRemovePeer(index: Int) = intent { + val updatedPeers = state.draft.config.peers.toMutableList().apply { removeAt(index) } + + reduce { + state.copy( + draft = state.draft.copy(config = state.draft.config.copy(peers = updatedPeers)) + ) + } + } + + suspend fun postSideEffect(globalSideEffect: GlobalSideEffect) { + globalEffectRepository.post(globalSideEffect) + } + + fun setShowSaveModal(show: Boolean) = intent { + reduce { state.copy(ui = state.ui.copy(showSaveModal = show)) } + } + + fun setGlobalTunnelDnsEnabled(to: Boolean) = intent { + reduce { state.copy(globalSettings = state.globalSettings.copy(dnsEnabled = to)) } + } + + fun onToggleLan(index: Int) = intent { + val updatedPeers = + state.draft.config.peers.toMutableList().apply { + val peer = get(index) + + val updated = + if (peer.isLanExcluded()) { + peer.includeLan() + } else { + peer.excludeLan() + } + + set(index, updated) + } + + reduce { + state.copy( + draft = state.draft.copy(config = state.draft.config.copy(peers = updatedPeers)) + ) + } + } + + fun onUpdatePeer(peer: EditablePeer, index: Int) = intent { + val updatedPeers = state.draft.config.peers.toMutableList().apply { set(index, peer) } + + reduce { + state.copy( + draft = state.draft.copy(config = state.draft.config.copy(peers = updatedPeers)) + ) + } + } + + fun onTunnelNameChange(name: String) = intent { + reduce { state.copy(draft = state.draft.copy(tunnelName = name)) } + } + + fun onAddPeer() = intent { + reduce { + state.copy( + draft = + state.draft.copy( + config = + state.draft.config.copy( + peers = state.draft.config.peers + EditablePeer() + ) + ) + ) + } + } + + fun onInterfaceChange(editableInterface: EditableInterface) = intent { + reduce { + state.copy( + draft = + state.draft.copy( + config = state.draft.config.copy(`interface` = editableInterface) + ) + ) + } + } + + fun applyCopySource() = intent { + val id = state.ui.selectedCopySourceTunnelId ?: return@intent + + val tunnel = tunnelRepository.getById(id) ?: return@intent + + reduce { + state.copy( + draft = state.draft.copy(config = EditableConfig.from(tunnel.getConfig())), + ui = state.ui.copy(selectedCopySourceTunnelId = null), + ) + } + } + + fun selectCopySource(tunnelId: Int?) = intent { + reduce { state.copy(ui = state.ui.copy(selectedCopySourceTunnelId = tunnelId)) } + } + + fun onMimicChange(mimic: MimicMode) = intent { + val current = state.draft.config.`interface` + + val updated = + when (mimic) { + MimicMode.QUIC -> current.setQuicMimic() + MimicMode.DNS -> current.setDnsMimic() + MimicMode.SIP -> current.setSipMimic() + } + + onInterfaceChange(updated) + } + + fun setGlobalAmneziaEnabled(to: Boolean) = intent { + reduce { state.copy(globalSettings = state.globalSettings.copy(amneziaEnabled = to)) } + } + + fun toggleShowScripts() = intent { + reduce { state.copy(ui = state.ui.copy(showScripts = !state.ui.showScripts)) } + } + + fun toggleShowAmneziaValues() = intent { + reduce { state.copy(ui = state.ui.copy(showAmneziaValues = !state.ui.showAmneziaValues)) } + } + + fun toggleAmneziaCompat() = intent { + val current = state.draft.config.`interface` + + val (show, updated) = + if (current.isAmneziaCompatibilityModeSet()) { + false to current.resetAmneziaProperties() + } else { + true to current.toAmneziaCompatibilityConfig() + } + + reduce { + state.copy( + draft = state.draft.copy(config = state.draft.config.copy(`interface` = updated)), + ui = state.ui.copy(showAmneziaValues = show), + ) + } + } + + fun toggleShowSensitiveData() = intent { + reduce { state.copy(ui = state.ui.copy(showSensitiveData = !state.ui.showSensitiveData)) } + } + + fun setInterfaceDropdownExpanded(expanded: Boolean) = intent { + reduce { state.copy(ui = state.ui.copy(isInterfaceDropdownExpanded = expanded)) } + } + + fun setPeerDropdownExpanded(expanded: Boolean) = intent { + reduce { state.copy(ui = state.ui.copy(isPeerDropdownExpanded = expanded)) } + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/ConfigViewModel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/ConfigViewModel.kt deleted file mode 100644 index cc9c450a3..000000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/ConfigViewModel.kt +++ /dev/null @@ -1,99 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.viewmodel - -import androidx.lifecycle.ViewModel -import com.zaneschepke.wireguardautotunnel.R -import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager -import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConfig -import com.zaneschepke.wireguardautotunnel.domain.repository.GlobalEffectRepository -import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository -import com.zaneschepke.wireguardautotunnel.domain.sideeffect.GlobalSideEffect -import com.zaneschepke.wireguardautotunnel.ui.state.ConfigProxy -import com.zaneschepke.wireguardautotunnel.ui.state.ConfigUiState -import com.zaneschepke.wireguardautotunnel.util.StringValue -import com.zaneschepke.wireguardautotunnel.util.extensions.asStringValue -import kotlinx.coroutines.flow.combine -import org.amnezia.awg.config.BadConfigException -import org.orbitmvi.orbit.ContainerHost -import org.orbitmvi.orbit.viewmodel.container -import timber.log.Timber - -class ConfigViewModel( - private val tunnelRepository: TunnelRepository, - private val globalEffectRepository: GlobalEffectRepository, - private val tunnelManager: TunnelManager, - val tunnelId: Int?, -) : ContainerHost, ViewModel() { - - override val container = - container( - ConfigUiState(), - buildSettings = { repeatOnSubscribedStopTimeout = 5000L }, - ) { - combine(tunnelManager.activeTunnels, tunnelRepository.flow) { activeTunnels, tuns -> - val tunnel = tuns.firstOrNull { it.id == tunnelId } - val tunnelNames = tuns.filter { it.id != tunnelId }.map { it.name } - val isRunning = activeTunnels.containsKey(tunnelId) - state.copy( - unavailableNames = tunnelNames, - isLoading = false, - tunnel = tunnel, - isRunning = isRunning, - ) - } - .collect { state -> reduce { state } } - } - - fun saveConfigProxy(configProxy: ConfigProxy, tunnelName: String) = intent { - reduce { state.copy(showSaveModal = false) } - if (state.unavailableNames.contains(tunnelName)) - return@intent postSideEffect( - GlobalSideEffect.Toast(StringValue.StringResource(R.string.tunnel_name_taken)) - ) - runCatching { - val (wg, am) = configProxy.buildConfigs() - val tunnelConfig = - if (tunnelId == null) { - TunnelConfig.tunnelConfFromQuick( - am.toAwgQuickString(true, false), - tunnelName, - ) - } else { - state.tunnel?.copy( - name = tunnelName, - amQuick = am.toAwgQuickString(true, false), - wgQuick = wg.toWgQuickString(true), - ) - } - if (tunnelConfig != null) { - tunnelRepository.save(tunnelConfig) - - if (state.isRunning) tunnelManager.restartActiveTunnel(tunnelConfig.id) - - postSideEffect( - GlobalSideEffect.Toast( - StringValue.StringResource(R.string.config_changes_saved) - ) - ) - postSideEffect(GlobalSideEffect.PopBackStack) - } - } - .onFailure { - Timber.e(it) - val message = - when (it) { - is BadConfigException -> it.asStringValue() - is com.wireguard.config.BadConfigException -> it.asStringValue() - else -> StringValue.StringResource(R.string.unknown_error) - } - postSideEffect(GlobalSideEffect.Snackbar(message)) - } - } - - suspend fun postSideEffect(globalSideEffect: GlobalSideEffect) { - globalEffectRepository.post(globalSideEffect) - } - - fun setShowSaveModal(showSaveModal: Boolean) = intent { - reduce { state.copy(showSaveModal = showSaveModal) } - } -} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/DnsViewModel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/DnsViewModel.kt index d1b4076c4..c0b590a77 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/DnsViewModel.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/DnsViewModel.kt @@ -1,12 +1,18 @@ package com.zaneschepke.wireguardautotunnel.viewmodel import androidx.lifecycle.ViewModel -import com.zaneschepke.wireguardautotunnel.data.model.DnsProtocol -import com.zaneschepke.wireguardautotunnel.data.model.DnsProvider -import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConfig +import com.zaneschepke.networkmonitor.NetworkMonitor +import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.domain.enums.DnsProtocol import com.zaneschepke.wireguardautotunnel.domain.repository.DnsSettingsRepository +import com.zaneschepke.wireguardautotunnel.domain.repository.GlobalEffectRepository import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository +import com.zaneschepke.wireguardautotunnel.domain.sideeffect.GlobalSideEffect +import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarType import com.zaneschepke.wireguardautotunnel.ui.state.DnsUiState +import com.zaneschepke.wireguardautotunnel.util.DnsValidator +import com.zaneschepke.wireguardautotunnel.util.StringValue +import com.zaneschepke.wireguardautotunnel.util.extensions.labelRes import kotlinx.coroutines.flow.combine import org.orbitmvi.orbit.ContainerHost import org.orbitmvi.orbit.viewmodel.container @@ -14,6 +20,8 @@ import org.orbitmvi.orbit.viewmodel.container class DnsViewModel( private val dnsSettingsRepository: DnsSettingsRepository, private val tunnelRepository: TunnelRepository, + private val networkMonitor: NetworkMonitor, + private val globalEffectRepository: GlobalEffectRepository, ) : ContainerHost, ViewModel() { override val container = @@ -21,33 +29,74 @@ class DnsViewModel( DnsUiState(), buildSettings = { repeatOnSubscribedStopTimeout = 5000L }, ) { - combine(dnsSettingsRepository.flow, tunnelRepository.globalTunnelFlow) { - dnsSettings, - globalTunnel -> - state.copy( - dnsSettings = dnsSettings, - isLoading = false, - globalConfig = globalTunnel, - ) + combine( + dnsSettingsRepository.flow, + tunnelRepository.globalTunnelFlow, + networkMonitor.connectivityStateFlow, + ) { dnsSettings, globalTunnelConfig, connectivity -> + if (state.isLoading) { + state.copy( + dnsSettings = dnsSettings, + globalTunnelConfig = globalTunnelConfig, + systemDnsInfo = connectivity.underlyingDnsInfo, + isLoading = false, + ) + } else { + state.copy(systemDnsInfo = connectivity.underlyingDnsInfo) + } } - .collect { reduce { it } } + .collect { newState -> reduce { newState } } } fun setDnsProtocol(to: DnsProtocol) = intent { - dnsSettingsRepository.upsert(state.dnsSettings.copy(dnsProtocol = to)) + reduce { + state.copy( + dnsSettings = state.dnsSettings.copy(dnsProtocol = to, dnsEndpoint = null), + peerResolutionEndpointError = null, + ) + } } - fun setDnsProvider(dnsProvider: DnsProvider) = intent { + fun save() = intent { + val protocol = state.dnsSettings.dnsProtocol + val endpoint = state.dnsSettings.dnsEndpoint + + when (val result = DnsValidator.validate(protocol, endpoint)) { + is DnsValidator.Result.Valid -> Unit + is DnsValidator.Result.Invalid -> { + reduce { state.copy(peerResolutionEndpointError = result.error) } + postSideEffect( + GlobalSideEffect.Snackbar( + StringValue.StringResource(result.error.labelRes()), + type = SnackbarType.WARNING, + ) + ) + return@intent + } + } + + val normalizedEndpoint = DnsValidator.normalize(protocol, endpoint) + dnsSettingsRepository.upsert( - state.dnsSettings.copy( - dnsEndpoint = dnsProvider.asAddress(state.dnsSettings.dnsProtocol) - ) + state.dnsSettings.copy(dnsEndpoint = normalizedEndpoint, dnsProtocol = protocol) + ) + + postSideEffect(GlobalSideEffect.PopBackStack) + postSideEffect( + GlobalSideEffect.Toast(StringValue.StringResource(R.string.config_changes_saved)) ) } - fun setGlobalTunnelDnsEnabled(to: Boolean) = intent { - dnsSettingsRepository.upsert(state.dnsSettings.copy(isGlobalTunnelDnsEnabled = to)) - if (state.globalConfig == null) - tunnelRepository.save(TunnelConfig.generateDefaultGlobalConfig()) + fun setDnsEndpoint(input: String) = intent { + reduce { + state.copy( + dnsSettings = state.dnsSettings.copy(dnsEndpoint = input), + peerResolutionEndpointError = null, + ) + } + } + + suspend fun postSideEffect(globalSideEffect: GlobalSideEffect) { + globalEffectRepository.post(globalSideEffect) } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/LockdownViewModel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/LockdownViewModel.kt index dd6d70f5c..f702f7bfa 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/LockdownViewModel.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/LockdownViewModel.kt @@ -2,10 +2,7 @@ package com.zaneschepke.wireguardautotunnel.viewmodel import androidx.lifecycle.ViewModel import com.zaneschepke.wireguardautotunnel.R -import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager -import com.zaneschepke.wireguardautotunnel.domain.enums.BackendMode -import com.zaneschepke.wireguardautotunnel.domain.model.LockdownSettings -import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConfig +import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelProvider import com.zaneschepke.wireguardautotunnel.domain.repository.GlobalEffectRepository import com.zaneschepke.wireguardautotunnel.domain.repository.LockdownSettingsRepository import com.zaneschepke.wireguardautotunnel.domain.sideeffect.GlobalSideEffect @@ -16,7 +13,7 @@ import org.orbitmvi.orbit.viewmodel.container class LockdownViewModel( private val lockdownSettingsRepository: LockdownSettingsRepository, - private val tunnelManager: TunnelManager, + private val tunnelProvider: TunnelProvider, private val globalEffectRepository: GlobalEffectRepository, ) : ContainerHost, ViewModel() { @@ -26,24 +23,32 @@ class LockdownViewModel( buildSettings = { repeatOnSubscribedStopTimeout = 5000L }, ) { lockdownSettingsRepository.flow.collect { - reduce { state.copy(lockdownSettings = it, isLoading = false) } + reduce { + state.copy( + lockdownSettings = it, + metered = it.metered, + dualStack = it.dualStack, + bypassLan = it.bypassLan, + isLoading = false, + ) + } } } - fun setLockdownSettings(lockdownSettings: LockdownSettings) = intent { + fun setLockdownSettings() = intent { reduce { state.copy(showSaveModal = false) } - lockdownSettingsRepository.upsert(lockdownSettings) - - tunnelManager.setBackendMode(BackendMode.Inactive) - val allowedIps = - if (lockdownSettings.bypassLan) TunnelConfig.LAN_BYPASS_ALLOWED_IPS else emptySet() - tunnelManager.setBackendMode( - BackendMode.KillSwitch( - allowedIps = allowedIps, - isMetered = lockdownSettings.metered, - dualStack = lockdownSettings.dualStack, + + val updated = + state.lockdownSettings.copy( + metered = state.metered, + dualStack = state.dualStack, + bypassLan = state.bypassLan, ) - ) + + lockdownSettingsRepository.upsert(updated) + + tunnelProvider.disableLockDown() + tunnelProvider.setLockDown(updated) postSideEffect(GlobalSideEffect.PopBackStack) postSideEffect( @@ -56,4 +61,10 @@ class LockdownViewModel( } fun setShowSaveModal(to: Boolean) = intent { reduce { state.copy(showSaveModal = to) } } + + fun setMetered(value: Boolean) = intent { reduce { state.copy(metered = value) } } + + fun setDualStack(value: Boolean) = intent { reduce { state.copy(dualStack = value) } } + + fun setBypassLan(value: Boolean) = intent { reduce { state.copy(bypassLan = value) } } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/LoggerViewModel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/LoggerViewModel.kt index 2d6d38f90..db8313d42 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/LoggerViewModel.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/LoggerViewModel.kt @@ -11,6 +11,8 @@ import com.zaneschepke.wireguardautotunnel.ui.state.LoggerUiState import com.zaneschepke.wireguardautotunnel.util.Constants import com.zaneschepke.wireguardautotunnel.util.FileUtils import com.zaneschepke.wireguardautotunnel.util.StringValue +import com.zaneschepke.wireguardautotunnel.util.extensions.toUserFriendlyTimestamp +import java.time.Instant import kotlinx.coroutines.ExperimentalCoroutinesApi import org.orbitmvi.orbit.ContainerHost import org.orbitmvi.orbit.viewmodel.container @@ -48,10 +50,19 @@ class LoggerViewModel( } fun exportLogs(uri: Uri?) = intent { + if (uri == null) { + postSideEffect( + GlobalSideEffect.Toast(StringValue.StringResource(R.string.export_unsupported)) + ) + return@intent + } + + val timestamp = Instant.now().toUserFriendlyTimestamp() val result = fileUtils.createNewShareFile( - "${Constants.BASE_LOG_FILE_NAME}_${BuildConfig.VERSION_NAME}_${BuildConfig.FLAVOR}.zip" + "${Constants.BASE_LOG_FILE_NAME}_${timestamp}_${BuildConfig.VERSION_NAME}_${BuildConfig.FLAVOR}.zip" ) + val onFailure = { action: Throwable -> Timber.e(action) intent { @@ -66,6 +77,7 @@ class LoggerViewModel( } Unit } + result.fold( onSuccess = { file -> try { diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/MonitoringViewModel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/MonitoringViewModel.kt index a160685de..308ddc94b 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/MonitoringViewModel.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/MonitoringViewModel.kt @@ -1,60 +1,37 @@ package com.zaneschepke.wireguardautotunnel.viewmodel import androidx.lifecycle.ViewModel -import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConfig +import com.zaneschepke.wireguardautotunnel.domain.enums.StatisticRefresh import com.zaneschepke.wireguardautotunnel.domain.repository.MonitoringSettingsRepository -import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository import com.zaneschepke.wireguardautotunnel.ui.state.MonitoringUiState -import kotlinx.coroutines.flow.combine import org.orbitmvi.orbit.ContainerHost import org.orbitmvi.orbit.viewmodel.container -class MonitoringViewModel( - private val monitoringSettingsRepository: MonitoringSettingsRepository, - private val tunnelRepository: TunnelRepository, - private val tunnelsRepository: TunnelRepository, -) : ContainerHost, ViewModel() { +class MonitoringViewModel(private val monitoringSettingsRepository: MonitoringSettingsRepository) : + ContainerHost, ViewModel() { override val container = container( MonitoringUiState(), buildSettings = { repeatOnSubscribedStopTimeout = 5000L }, ) { - combine(monitoringSettingsRepository.flow, tunnelRepository.userTunnelsFlow) { - monitoringSettings, - tunnels -> + monitoringSettingsRepository.flow.collect { + val statisticRefresh = StatisticRefresh.fromValue(it.tunnelStatisticsPollInterval) + reduce { state.copy( - monitoringSettings = monitoringSettings, - tunnels = tunnels, + statisticRefresh = statisticRefresh, + tunnelStatisticsEnabled = it.tunnelStatisticsEnabled, isLoading = false, ) } - .collect { reduce { it } } + } } - fun setTunnelPingIntervalSeconds(to: Int) = intent { - monitoringSettingsRepository.upsert( - state.monitoringSettings.copy(tunnelPingIntervalSeconds = to) - ) + fun onLiveTunnelStatisticsChanged(to: Boolean) = intent { + monitoringSettingsRepository.updateStatisticsEnabled(to) } - fun setTunnelPingAttempts(to: Int) = intent { - monitoringSettingsRepository.upsert(state.monitoringSettings.copy(tunnelPingAttempts = to)) - } - - fun setTunnelPingTimeoutSeconds(to: Int?) = intent { - monitoringSettingsRepository.upsert( - state.monitoringSettings.copy(tunnelPingTimeoutSeconds = to) - ) - } - - fun setDetailedPingStats(to: Boolean) = intent { - monitoringSettingsRepository.upsert( - state.monitoringSettings.copy(showDetailedPingStats = to) - ) - } - - fun setPingTarget(tunnel: TunnelConfig, target: String?) = intent { - tunnelsRepository.save(tunnel.copy(pingTarget = target?.ifBlank { null })) + fun onStatisticsIntervalChanged(statisticRefresh: StatisticRefresh) = intent { + monitoringSettingsRepository.updateStatisticRefresh(statisticRefresh.value) } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/ProxySettingsViewModel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/ProxySettingsViewModel.kt index bdcf082e4..f31fc8ee0 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/ProxySettingsViewModel.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/ProxySettingsViewModel.kt @@ -2,8 +2,7 @@ package com.zaneschepke.wireguardautotunnel.viewmodel import androidx.lifecycle.ViewModel import com.zaneschepke.wireguardautotunnel.R -import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager -import com.zaneschepke.wireguardautotunnel.domain.model.ProxySettings +import com.zaneschepke.wireguardautotunnel.core.orchestration.TunnelCoordinator import com.zaneschepke.wireguardautotunnel.domain.repository.GlobalEffectRepository import com.zaneschepke.wireguardautotunnel.domain.repository.ProxySettingsRepository import com.zaneschepke.wireguardautotunnel.domain.sideeffect.GlobalSideEffect @@ -17,7 +16,7 @@ import org.orbitmvi.orbit.viewmodel.container class ProxySettingsViewModel( private val proxySettingsRepository: ProxySettingsRepository, private val globalEffectRepository: GlobalEffectRepository, - private val tunnelManager: TunnelManager, + private val tunnelCoordinator: TunnelCoordinator, ) : ContainerHost, ViewModel() { override val container = @@ -25,44 +24,37 @@ class ProxySettingsViewModel( ProxySettingsUiState(), buildSettings = { repeatOnSubscribedStopTimeout = 5000L }, ) { - combine(tunnelManager.activeTunnels, proxySettingsRepository.flow) { - activeTuns, + combine(tunnelCoordinator.backendStatus, proxySettingsRepository.flow) { + backendStatus, settings -> - state.copy(proxySettings = settings, isLoading = false, activeTuns = activeTuns) + ProxySettingsUiState( + proxySettings = settings, + backendStatus = backendStatus, + isLoading = false, + socks5Enabled = settings.socks5ProxyEnabled, + httpEnabled = settings.httpProxyEnabled, + socksBindAddress = settings.socks5ProxyBindAddress ?: "", + httpBindAddress = settings.httpProxyBindAddress ?: "", + proxyUsername = settings.proxyUsername ?: "", + proxyPassword = settings.proxyPassword ?: "", + ) } .collect { reduce { it } } } - fun save(proxySettings: ProxySettings) = intent { + fun save() = intent { reduce { state.copy(showSaveModal = false) } + + val current = state + val updated = - state.proxySettings.copy( - socks5ProxyEnabled = proxySettings.socks5ProxyEnabled, - httpProxyEnabled = proxySettings.httpProxyEnabled, - httpProxyBindAddress = - if (proxySettings.httpProxyEnabled) { - proxySettings.httpProxyBindAddress?.ifBlank { null } - } else { - null - }, - socks5ProxyBindAddress = - if (proxySettings.socks5ProxyEnabled) { - proxySettings.socks5ProxyBindAddress?.ifBlank { null } - } else { - null - }, - proxyUsername = - if (proxySettings.socks5ProxyEnabled || proxySettings.httpProxyEnabled) { - proxySettings.proxyUsername?.ifBlank { null } - } else { - null - }, - proxyPassword = - if (proxySettings.socks5ProxyEnabled || proxySettings.httpProxyEnabled) { - proxySettings.proxyPassword?.ifBlank { null } - } else { - null - }, + current.proxySettings.copy( + socks5ProxyEnabled = current.socks5Enabled, + httpProxyEnabled = current.httpEnabled, + socks5ProxyBindAddress = current.socksBindAddress.ifBlank { null }, + httpProxyBindAddress = current.httpBindAddress.ifBlank { null }, + proxyUsername = current.proxyUsername.ifBlank { null }, + proxyPassword = current.proxyPassword.ifBlank { null }, ) val isHttpDefault = updated.httpProxyBindAddress == null @@ -106,7 +98,8 @@ class ProxySettingsViewModel( proxySettingsRepository.upsert(updated) - if (state.activeTuns.isNotEmpty()) tunnelManager.restartActiveTunnels() + tunnelCoordinator.toggleTunnels() + tunnelCoordinator.toggleTunnels() postSideEffect( GlobalSideEffect.Snackbar(StringValue.StringResource(R.string.config_changes_saved)) @@ -131,4 +124,32 @@ class ProxySettingsViewModel( } private fun areBothNullOrBothNotNull(s1: String?, s2: String?) = (s1 == null) == (s2 == null) + + fun onSocks5EnabledChanged(enabled: Boolean) = intent { + reduce { state.copy(socks5Enabled = enabled) } + } + + fun onHttpEnabledChanged(enabled: Boolean) = intent { + reduce { state.copy(httpEnabled = enabled) } + } + + fun onSocksBindChanged(value: String) = intent { + reduce { state.copy(socksBindAddress = value, isSocks5BindAddressError = false) } + } + + fun onHttpBindChanged(value: String) = intent { + reduce { state.copy(httpBindAddress = value, isHttpBindAddressError = false) } + } + + fun onUsernameChanged(value: String) = intent { + reduce { state.copy(proxyUsername = value, isUserNameError = false) } + } + + fun onPasswordChanged(value: String) = intent { + reduce { state.copy(proxyPassword = value, isPasswordError = false) } + } + + fun onPasswordVisibilityChanged(value: Boolean) = intent { + reduce { state.copy(passwordVisible = value) } + } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/SettingsViewModel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/SettingsViewModel.kt index 17d7460ba..f6261a6df 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/SettingsViewModel.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/SettingsViewModel.kt @@ -1,15 +1,17 @@ package com.zaneschepke.wireguardautotunnel.viewmodel import androidx.lifecycle.ViewModel +import com.zaneschepke.tunnel.backend.RootShell +import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.core.orchestration.TunnelCoordinator import com.zaneschepke.wireguardautotunnel.core.shortcut.ShortcutManager -import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager -import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConfig import com.zaneschepke.wireguardautotunnel.domain.repository.GeneralSettingRepository import com.zaneschepke.wireguardautotunnel.domain.repository.GlobalEffectRepository import com.zaneschepke.wireguardautotunnel.domain.repository.MonitoringSettingsRepository import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository import com.zaneschepke.wireguardautotunnel.domain.sideeffect.GlobalSideEffect import com.zaneschepke.wireguardautotunnel.ui.state.SettingUiState +import com.zaneschepke.wireguardautotunnel.util.StringValue import java.util.UUID import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged @@ -23,7 +25,7 @@ class SettingsViewModel( private val tunnelsRepository: TunnelRepository, private val monitoringRepository: MonitoringSettingsRepository, private val globalEffectRepository: GlobalEffectRepository, - private val tunnelManager: TunnelManager, + private val tunnelCoordinator: TunnelCoordinator, ) : ContainerHost, ViewModel() { override val container = @@ -37,7 +39,9 @@ class SettingsViewModel( tunnelsRepository.globalTunnelFlow, tunnelsRepository.userTunnelsFlow, monitoringRepository.flow, - tunnelManager.activeTunnels.map { it.isNotEmpty() }.distinctUntilChanged(), + tunnelCoordinator.backendStatus + .map { it.activeTunnels.isNotEmpty() } + .distinctUntilChanged(), ) { settings, tunnel, tunnels, monitoring, tunnelActive -> state.copy( settings = settings, @@ -74,18 +78,12 @@ class SettingsViewModel( fun setGlobalSplitTunneling(to: Boolean) = intent { settingsRepository.upsert(state.settings.copy(isGlobalSplitTunnelEnabled = to)) - if (state.globalTunnelConfig == null) - tunnelsRepository.save(TunnelConfig.generateDefaultGlobalConfig()) } fun setLocalLogging(to: Boolean) = intent { monitoringRepository.upsert(state.monitoring.copy(isLocalLogsEnabled = to)) } - fun setPingEnabled(to: Boolean) = intent { - monitoringRepository.upsert(state.monitoring.copy(isPingEnabled = to)) - } - fun setRemoteEnabled(to: Boolean) = intent { settingsRepository.upsert( state.settings.copy( @@ -95,6 +93,18 @@ class SettingsViewModel( ) } + fun setTunnelScriptedEnabled(to: Boolean) = intent { + if (to) { + val accepted = RootShell.requestRootPermission() + val message = + if (!accepted) StringValue.StringResource(R.string.error_root_denied) + else StringValue.StringResource(R.string.root_accepted) + postSideEffect(GlobalSideEffect.Snackbar(message)) + if (!accepted) return@intent + } + settingsRepository.upsert(state.settings.copy(tunnelScriptingEnabled = to)) + } + fun setAlreadyDonated(to: Boolean) = intent { settingsRepository.upsert(state.settings.copy(alreadyDonated = to)) } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/SharedAppViewModel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/SharedAppViewModel.kt index ce326db19..ce5bdd1d7 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/SharedAppViewModel.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/SharedAppViewModel.kt @@ -3,27 +3,26 @@ package com.zaneschepke.wireguardautotunnel.viewmodel import android.net.Uri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.wireguard.android.backend.WgQuickBackend import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.core.orchestration.TunnelCoordinator +import com.zaneschepke.wireguardautotunnel.core.orchestration.TunnelModeCoordinator import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager -import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager -import com.zaneschepke.wireguardautotunnel.data.model.AppMode -import com.zaneschepke.wireguardautotunnel.domain.enums.ConfigType +import com.zaneschepke.wireguardautotunnel.core.service.autotunnel.AutoTunnelStateHolder +import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelMode import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConfig import com.zaneschepke.wireguardautotunnel.domain.repository.AppStateRepository import com.zaneschepke.wireguardautotunnel.domain.repository.GeneralSettingRepository import com.zaneschepke.wireguardautotunnel.domain.repository.GlobalEffectRepository -import com.zaneschepke.wireguardautotunnel.domain.repository.MonitoringSettingsRepository import com.zaneschepke.wireguardautotunnel.domain.repository.SelectedTunnelsRepository import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository import com.zaneschepke.wireguardautotunnel.domain.sideeffect.GlobalSideEffect +import com.zaneschepke.wireguardautotunnel.parser.ConfigParseException import com.zaneschepke.wireguardautotunnel.ui.sideeffect.LocalSideEffect import com.zaneschepke.wireguardautotunnel.ui.state.GlobalAppUiState import com.zaneschepke.wireguardautotunnel.ui.state.TunnelsUiState import com.zaneschepke.wireguardautotunnel.ui.theme.Theme import com.zaneschepke.wireguardautotunnel.util.FileUtils import com.zaneschepke.wireguardautotunnel.util.LocaleUtil -import com.zaneschepke.wireguardautotunnel.util.RootShellUtils import com.zaneschepke.wireguardautotunnel.util.StringValue import com.zaneschepke.wireguardautotunnel.util.extensions.QuickConfig import com.zaneschepke.wireguardautotunnel.util.extensions.TunnelName @@ -45,9 +44,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.plus import kotlinx.coroutines.withContext -import org.amnezia.awg.config.BadConfigException import org.orbitmvi.orbit.ContainerHost import org.orbitmvi.orbit.viewmodel.container import timber.log.Timber @@ -56,13 +53,13 @@ import xyz.teamgravity.pin_lock_compose.PinManager class SharedAppViewModel( private val appStateRepository: AppStateRepository, private val serviceManager: ServiceManager, - private val tunnelManager: TunnelManager, + private val tunnelCoordinator: TunnelCoordinator, private val globalEffectRepository: GlobalEffectRepository, private val tunnelRepository: TunnelRepository, private val settingsRepository: GeneralSettingRepository, + private val autoTunnelStateHolder: AutoTunnelStateHolder, private val selectedTunnelsRepository: SelectedTunnelsRepository, - monitoringSettingsRepository: MonitoringSettingsRepository, - private val rootShellUtils: RootShellUtils, + private val tunnelModeCoordinator: TunnelModeCoordinator, private val httpClient: HttpClient, private val fileUtils: FileUtils, private val networkUtils: NetworkUtils, @@ -73,15 +70,12 @@ class SharedAppViewModel( val tunnelsUiState = combine( tunnelRepository.userTunnelsFlow, - monitoringSettingsRepository.flow, - tunnelManager.activeTunnels, + tunnelCoordinator.backendStatus, selectedTunnelsRepository.flow, - ) { tunnels, monitoringSettings, activeTuns, selectedTuns -> + ) { tunnels, backendStatus, selectedTuns -> TunnelsUiState( tunnels = tunnels, - isPingEnabled = monitoringSettings.isPingEnabled, - showPingStats = monitoringSettings.showDetailedPingStats, - activeTunnels = activeTuns, + backendStatus = backendStatus, selectedTunnels = selectedTuns, isLoading = false, ) @@ -98,53 +92,43 @@ class SharedAppViewModel( tunnelRepository.userTunnelsFlow .map { tuns -> tuns.associate { it.id to it.name } } .distinctUntilChanged(), - serviceManager.autoTunnelService.map { it != null }.distinctUntilChanged(), settingsRepository.flow, + autoTunnelStateHolder.active, tunnelsUiState .map { Pair(it.isLoading, it.selectedTunnels.size) } .distinctUntilChanged(), appStateRepository.flow, - ) { tunNames, autoTunnelActive, settings, (loading, selectedTunCount), appState + ) { tunNames, settings, autoTunnelActive, (loading, selectedTunCount), appState -> state.copy( theme = settings.theme, - appMode = settings.appMode, + tunnelMode = settings.tunnelMode, locale = settings.locale ?: LocaleUtil.OPTION_PHONE_LANGUAGE, tunnelNames = tunNames, alreadyDonated = settings.alreadyDonated, + isAutoTunnelActive = autoTunnelActive, isLocationDisclosureShown = appState.isLocationDisclosureShown, isBatteryOptimizationShown = appState.isBatteryOptimizationDisableShown, shouldShowDonationSnackbar = appState.shouldShowDonationSnackbar, selectedTunnelCount = selectedTunCount, pinLockEnabled = settings.isPinLockEnabled, - isAutoTunnelActive = autoTunnelActive, + isScreenRecordingProtectionEnabled = + settings.screenRecordingSecurityEnabled, isAppLoaded = !loading, ) } .collect { newState -> reduce { newState } } } - - intent { - tunnelManager.errorEvents.collect { (tunnel, message) -> - postSideEffect(GlobalSideEffect.Snackbar(message.toStringValue())) - } - } - - intent { - tunnelManager.messageEvents.collect { (_, message) -> - postSideEffect(GlobalSideEffect.Snackbar(message.toStringValue())) - } - } } fun startTunnel(tunnelConfig: TunnelConfig) = intent { - if (state.appMode == AppMode.VPN) { + if (state.tunnelMode == TunnelMode.VPN) { if (!serviceManager.hasVpnPermission()) return@intent postSideEffect( - GlobalSideEffect.RequestVpnPermission(AppMode.VPN, tunnelConfig) + GlobalSideEffect.RequestVpnPermission(TunnelMode.VPN, tunnelConfig) ) } - tunnelManager.startTunnel(tunnelConfig) + tunnelCoordinator.startTunnel(tunnelConfig) } fun postSideEffect(localSideEffect: LocalSideEffect) = intent { @@ -168,45 +152,17 @@ class SharedAppViewModel( } fun stopTunnel(tunnelConfig: TunnelConfig) = intent { - tunnelManager.stopTunnel(tunnelConfig.id) + tunnelCoordinator.stopTunnel(tunnelConfig.id) } - fun setAppMode(appMode: AppMode) = intent { - when (appMode) { - AppMode.VPN, - AppMode.PROXY -> Unit - AppMode.LOCK_DOWN -> { - if (!serviceManager.hasVpnPermission()) { - return@intent postSideEffect( - GlobalSideEffect.RequestVpnPermission(appMode, null) - ) - } - } - AppMode.KERNEL -> { - val accepted = rootShellUtils.requestRoot() - val message = - if (!accepted) StringValue.StringResource(R.string.error_root_denied) - else StringValue.StringResource(R.string.root_accepted) - postSideEffect(GlobalSideEffect.Snackbar(message)) - if (!accepted) return@intent - if (WgQuickBackend.hasKernelSupport()) - Timber.i( - "Device supports kernel backend. WireGuard module is built in, switching to kernel backend." - ) - else { - Timber.e("Device does not support kernel backend!") - intent { - postSideEffect( - GlobalSideEffect.Snackbar( - StringValue.StringResource(R.string.kernel_wireguard_unsupported) - ) - ) - } - return@intent - } + fun setAppMode(mode: TunnelMode) = intent { + if (mode == TunnelMode.VPN || mode == TunnelMode.LOCK_DOWN) { + if (!serviceManager.hasVpnPermission()) { + return@intent postSideEffect(GlobalSideEffect.RequestVpnPermission(mode, null)) } } - settingsRepository.updateAppMode(appMode) + + tunnelModeCoordinator.changeMode(mode) } fun setShouldShowDonationSnackbar(to: Boolean) = intent { @@ -252,12 +208,11 @@ class SharedAppViewModel( async { val config = try { - tunnel.toAmConfig() + tunnel.getConfig() } catch (e: Exception) { null } - val endpoint = - config?.peers?.firstOrNull()?.endpoint?.orElse(null)?.host + val endpoint = config?.peers?.firstOrNull()?.host if (endpoint != null) { val latency = try { @@ -282,14 +237,15 @@ class SharedAppViewModel( fun importTunnelConfigs(configs: Map) = intent { try { - val tunnelConfigs = - configs.map { (config, name) -> TunnelConfig.tunnelConfFromQuick(config, name) } + val tunnelConfigs = configs.map { (config, name) -> + TunnelConfig.tunnelConfFromQuick(config, name) + } tunnelRepository.saveTunnelsUniquely(tunnelConfigs, state.tunnelNames.map { it.value }) } catch (_: IOException) { postSideEffect( GlobalSideEffect.Snackbar(StringValue.StringResource(R.string.read_failed)) ) - } catch (e: BadConfigException) { + } catch (e: ConfigParseException) { postSideEffect(GlobalSideEffect.Snackbar(e.asStringValue())) } } @@ -356,7 +312,8 @@ class SharedAppViewModel( } fun deleteSelectedTunnels() = intent { - val activeTunIds = tunnelManager.activeTunnels.firstOrNull()?.map { it.key } + val activeTunIds = + tunnelCoordinator.backendStatus.firstOrNull()?.activeTunnels?.map { it.key } val selectedTuns = tunnelsUiState.value.selectedTunnels if (selectedTuns.any { activeTunIds?.contains(it.id) == true }) return@intent postSideEffect( @@ -370,26 +327,15 @@ class SharedAppViewModel( fun copySelectedTunnel() = intent { val selected = tunnelsUiState.value.selectedTunnels.firstOrNull() ?: return@intent - val copy = TunnelConfig.tunnelConfFromQuick(selected.amQuick, selected.name) + val copy = TunnelConfig.tunnelConfFromQuick(selected.quickConfig, selected.name) tunnelRepository.saveTunnelsUniquely(listOf(copy), state.tunnelNames.map { it.value }) clearSelectedTunnels() } - fun exportSelectedTunnels(configType: ConfigType, uri: Uri?) = intent { + fun exportSelectedTunnels(uri: Uri?) = intent { val selectedTunnels = tunnelsUiState.value.selectedTunnels - val (files, shareFileName) = - when (configType) { - ConfigType.AM -> - Pair( - createAmFiles(selectedTunnels), - "am-export_${Instant.now().epochSecond}.zip", - ) - ConfigType.WG -> - Pair( - createWgFiles(selectedTunnels), - "wg-export_${Instant.now().epochSecond}.zip", - ) - } + val files = createConfFiles(selectedTunnels) + val shareFileName = "wgtunnel-export_${Instant.now().epochSecond}.zip" val onFailure = { action: Throwable -> intent { postSideEffect( @@ -420,17 +366,14 @@ class SharedAppViewModel( .onFailure(onFailure) } - suspend fun createWgFiles(tunnels: Collection): List = - tunnels.mapNotNull { config -> - if (config.wgQuick.isNotBlank()) { - fileUtils.createFile(config.name, config.wgQuick) - } else null - } + fun setScreenRecordingSecurity(to: Boolean) = intent { + settingsRepository.updateScreenRecordingSecurity(to) + } - suspend fun createAmFiles(tunnels: Collection): List = + suspend fun createConfFiles(tunnels: Collection): List = tunnels.mapNotNull { config -> - if (config.amQuick.isNotBlank()) { - fileUtils.createFile(config.name, config.amQuick) + if (config.quickConfig.isNotBlank()) { + fileUtils.createFile(config.name, config.quickConfig) } else null } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/SplitTunnelViewModel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/SplitTunnelViewModel.kt index 1a98891d1..2449468ec 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/SplitTunnelViewModel.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/SplitTunnelViewModel.kt @@ -7,8 +7,8 @@ import com.zaneschepke.wireguardautotunnel.domain.repository.InstalledPackageRep import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository import com.zaneschepke.wireguardautotunnel.domain.sideeffect.GlobalSideEffect import com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.splittunnel.state.SplitOption -import com.zaneschepke.wireguardautotunnel.ui.state.ConfigProxy -import com.zaneschepke.wireguardautotunnel.ui.state.InterfaceProxy +import com.zaneschepke.wireguardautotunnel.ui.state.EditableConfig +import com.zaneschepke.wireguardautotunnel.ui.state.EditableInterface import com.zaneschepke.wireguardautotunnel.ui.state.SplitTunnelUiState import com.zaneschepke.wireguardautotunnel.util.StringValue import kotlinx.coroutines.flow.combine @@ -29,49 +29,112 @@ class SplitTunnelViewModel( SplitTunnelUiState(), buildSettings = { repeatOnSubscribedStopTimeout = 5000L }, ) { - val packagesFlow = flow { - val packages = packageRepository.getInstalledPackages() - emit(packages) - } + val packagesFlow = flow { emit(packageRepository.getInstalledPackages()) } + + val tunnelsFlow = tunnelRepository.userTunnelsFlow + + val currentTunnelFlow = + tunnelRepository.flow.map { list -> list.firstOrNull { it.id == tunnelId } } + + combine(packagesFlow, tunnelsFlow, currentTunnelFlow) { packages, tunnels, tunnel -> + val currentState = state + + val config = tunnel?.getConfig() + + val (initialOption, initialPkgs) = + when { + config?.`interface`?.allExcludedApps?.isNotEmpty() == true -> + SplitOption.EXCLUDE to config.`interface`.allExcludedApps.toSet() + + config?.`interface`?.allIncludedApps?.isNotEmpty() == true -> + SplitOption.INCLUDE to config.`interface`.allIncludedApps.toSet() + + else -> SplitOption.ALL to emptySet() + } - combine( - packagesFlow, - tunnelRepository.flow.map { it.firstOrNull { tun -> tun.id == tunnelId } }, - ) { packages, tunnel -> - SplitTunnelUiState(packages, false, tunnel) + val isInitialized = currentState.tunnel != null + + SplitTunnelUiState( + installedPackages = packages, + tunnels = tunnels.map { it.toSummary() }, + tunnel = tunnel, + isLoading = false, + splitOption = + if (isInitialized) currentState.splitOption else initialOption, + selectedPackages = + if (isInitialized) currentState.selectedPackages else initialPkgs, + selectedCopySourceTunnelId = currentState.selectedCopySourceTunnelId, + ) } - .collect { reduce { it } } + .collect { newState -> reduce { newState } } } suspend fun postSideEffect(globalSideEffect: GlobalSideEffect) { globalEffectRepository.post(globalSideEffect) } - fun saveSplitTunnelSelection(splitConfig: Pair>) = intent { + fun save() = intent { val tunnel = state.tunnel ?: return@intent - val config = tunnel.toAmConfig() - val (option, pkgs) = splitConfig - val configProxy = ConfigProxy.from(config) - val interfaceProxy = InterfaceProxy.from(config.`interface`) + val config = tunnel.getConfig() + val editableConfig = EditableConfig.from(config) + val editableInterface = EditableInterface.from(config.`interface`) val (included, excluded) = - when (option) { - SplitOption.INCLUDE -> Pair(pkgs, emptySet()) + when (state.splitOption) { + SplitOption.INCLUDE -> Pair(state.selectedPackages, emptySet()) SplitOption.ALL -> Pair(emptySet(), emptySet()) - SplitOption.EXCLUDE -> Pair(emptySet(), pkgs) + SplitOption.EXCLUDE -> Pair(emptySet(), state.selectedPackages) } val updatedInterface = - interfaceProxy.copy(includedApplications = included, excludedApplications = excluded) - val updatedConfig = configProxy.copy(`interface` = updatedInterface) - val (wg, am) = updatedConfig.buildConfigs() - tunnelRepository.save( - tunnel.copy( - amQuick = am.toAwgQuickString(true, false), - wgQuick = wg.toWgQuickString(true), - ) - ) + editableInterface.copy(includedApplications = included, excludedApplications = excluded) + val updatedProxyConfig = editableConfig.copy(`interface` = updatedInterface) + val updatedConfig = updatedProxyConfig.buildConfig() + tunnelRepository.save(tunnel.copy(quickConfig = updatedConfig.asQuickString())) postSideEffect( GlobalSideEffect.Snackbar(StringValue.StringResource(R.string.config_changes_saved)) ) postSideEffect(GlobalSideEffect.PopBackStack) } + + fun setSplitOption(option: SplitOption) = intent { reduce { state.copy(splitOption = option) } } + + fun togglePackage(pkg: String, enabled: Boolean) = intent { + reduce { + val updated = + state.selectedPackages.toMutableSet().apply { + if (enabled) add(pkg) else remove(pkg) + } + state.copy(selectedPackages = updated) + } + } + + fun applyCopySource() = intent { + val id = state.selectedCopySourceTunnelId ?: return@intent + + val tunnel = tunnelRepository.getById(id) ?: return@intent + + val config = tunnel.getConfig() + + val (option, pkgs) = + when { + config.`interface`.allExcludedApps.isNotEmpty() -> + SplitOption.EXCLUDE to config.`interface`.allExcludedApps.toSet() + + config.`interface`.allIncludedApps.isNotEmpty() -> + SplitOption.INCLUDE to config.`interface`.allIncludedApps.toSet() + + else -> SplitOption.ALL to emptySet() + } + + reduce { + state.copy( + splitOption = option, + selectedPackages = pkgs, + selectedCopySourceTunnelId = null, + ) + } + } + + fun selectCopySource(tunnelId: Int?) = intent { + reduce { state.copy(selectedCopySourceTunnelId = tunnelId) } + } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/TunnelViewModel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/TunnelViewModel.kt index f3b4fc7c8..e01316d64 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/TunnelViewModel.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/TunnelViewModel.kt @@ -1,8 +1,9 @@ package com.zaneschepke.wireguardautotunnel.viewmodel import androidx.lifecycle.ViewModel -import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager +import com.zaneschepke.wireguardautotunnel.core.orchestration.TunnelCoordinator import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository +import com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.settings.ipv6.IPv6Intent import com.zaneschepke.wireguardautotunnel.ui.state.TunnelUiState import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map @@ -11,7 +12,7 @@ import org.orbitmvi.orbit.viewmodel.container class TunnelViewModel( private val tunnelRepository: TunnelRepository, - private val tunnelManager: TunnelManager, + private val tunnelCoordinator: TunnelCoordinator, val tunnelId: Int, ) : ContainerHost, ViewModel() { @@ -24,31 +25,62 @@ class TunnelViewModel( tunnelRepository.userTunnelsFlow.map { it.firstOrNull { tun -> tun.id == tunnelId } }, - tunnelManager.activeTunnels.map { it.containsKey(tunnelId) }, + tunnelCoordinator.backendStatus.map { it.activeTunnels[tunnelId] }, ) { tunnel, active -> - state.copy(tunnel = tunnel, isActive = active, isLoading = false) + val config = tunnel?.getConfig() + val includedAppCount = + config?.`interface`?.includedApplications?.takeIf { it.isNotEmpty() }?.size + + val excludedAppCount = + config?.`interface`?.excludedApplications?.takeIf { it.isNotEmpty() }?.size + + state.copy( + tunnel = tunnel, + excludedAppsCount = excludedAppCount, + includedAppsCount = includedAppCount, + activeConfig = active?.activeConfig, + isLoading = false, + ) } .collect { reduce { it } } } - fun setRestartOnPing(to: Boolean) = intent { - val tunnel = state.tunnel ?: return@intent - tunnelRepository.save(tunnel.copy(restartOnPingFailure = to)) - } - fun togglePrimaryTunnel() = intent { val tunnel = state.tunnel ?: return@intent val update = if (tunnel.isPrimaryTunnel) null else tunnel tunnelRepository.updatePrimaryTunnel(update) } - fun setIpv4Preferred(to: Boolean) = intent { - val tunnel = state.tunnel ?: return@intent - tunnelRepository.save(tunnel.copy(isIpv4Preferred = to)) - } + fun onDynamicDns(to: Boolean) = intent { tunnelRepository.setDynamicDns(tunnelId, to) } + + fun onMetered(to: Boolean) = intent { tunnelRepository.setMetered(tunnelId, to) } - fun setMetered(to: Boolean) = intent { + fun onIPv6Action(iPv6Intent: IPv6Intent) = intent { val tunnel = state.tunnel ?: return@intent - tunnelRepository.save(tunnel.copy(isMetered = to)) + + val updated = + when (iPv6Intent) { + is IPv6Intent.ToggleFallback -> { + tunnel.copy(ipv4FallbackEnabled = iPv6Intent.value) + } + + is IPv6Intent.ToggleIpv6Preferred -> { + if (!iPv6Intent.value) { + tunnel.copy( + isIpv6Preferred = false, + ipv6RestoreEnabled = false, + ipv4FallbackEnabled = false, + ) + } else { + tunnel.copy(isIpv6Preferred = true) + } + } + + is IPv6Intent.ToggleRestore -> { + tunnel.copy(ipv6RestoreEnabled = iPv6Intent.value) + } + } + + tunnelRepository.save(updated) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f2797ec25..1e6ed717e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -4,6 +4,7 @@ Control tunnels and auto-tunnel features. https://hosted.weblate.org/translate/wg-tunnel/strings/en/?checksum=e52d7eb2e28a9a12Control tunnels and auto-tunnel features. VPN Channel + Proxy Channel VPN Notification Channel https://github.com/wgtunnel/wgtunnel/issues https://github.com/sponsors/zaneschepke @@ -136,7 +137,7 @@ Tunnel running Monitoring state changes Donate to project - Local logs monitor + Local logging Add from clipboard Stop on no internet Stop tunnel on internet loss @@ -144,10 +145,10 @@ Native kill switch Allow LAN traffic Bypass LAN for kill switch - A channel for VPN state notifications + Notifications for VPN tunnels. Auto-tunnel Channel - Auto-tunnel Notification Channel - A channel for auto-tunnel state notifications + Auto-tunnel + Notifications for auto-tunnel events. Stop Split tunneling Show scripts @@ -325,15 +326,17 @@ Unknown section Bad config. %1$s at location: %2$s. Failed. Proxies must have different ports. - Password cannot have spaces. - Tunnel name is already used. + Password cannot have spaces + Tunnel name is already used (%1$d–%2$d) Mimic QUIC Mimic DNS Mimic SIP Active tunnel update failed - Dynamic DNS auto-update - Auto-updates IP on DDNS changes + Dynamic DNS (DDNS) + + Re-resolves the server hostname and updates peer endpoints on DDNS changes + Prefer IPv6 peer resolution Disabled Feature unavailable in %1$s mode. @@ -426,6 +429,7 @@ Unavailable in current mode Global split tunneling Global DNS servers + Global Amnezia configuration Dual-stack Tunnels must support IPv4 and IPv6 Save changes @@ -466,4 +470,100 @@ https://apt.izzysoft.de/fdroid/index/apk/com.zaneschepke.wireguardautotunnel The URL must be secure and serve a .conf file. %1$s only + Export is not supported on this device + Export canceled + Notifications for proxy tunnels. + Initializing… + Network + View configuration + View live tunnel + Toggle sensitive data visibility + IPv6 settings + Automation + Use IPv6 when available + Switch to IPv4 if needed + Switch back to IPv6 + Peer Resolution + Resolution method + Tunnel DNS + DNS over TLS (DoT) + Plain DNS (port 53) + Current system DNS + Endpoint cannot be empty + Invalid URL format + DoH must use HTTPS + Host cannot be empty + Port must be between 1 and 65535 + Invalid IP address or hostname + DNS server endpoint + IP, hostname, or DoH URL + No system DNS information + Private DNS: automatic + Private DNS: hostname (%1$s) + Servers: %1$s + No system DNS detected + + Connected + Resolving DNS + Handshake failure + Disconnected + Starting + Stopping + + status: %1$s + uptime: %1$s + peer: %1$s + handshake: %1$s + + + %1$s • %2$s + + + IPv4 fallback + IPv6 recovery + Dynamic DNS update + + + %1$s switched to IPv4 connectivity + + + + %1$s recovered IPv6 connectivity + + + + %1$s updated after Dynamic DNS change + + + + + VPN permission required + + + + %1$s is already running + + + + %1$s apps included + + + + %1$s apps excluded + + + Error + Tunnel globals + Configuration globals + Screen recording protection + Security + Ready + Statistics + Live tunnel statistics + Statistics refresh rate + Balanced (3s) + Real-time (1s) + Battery Saver (10s) + Pre/Post script support + Tunnel name cannot be empty diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml index 9ee9997b0..6cebe0573 100644 --- a/app/src/main/res/xml/data_extraction_rules.xml +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -5,7 +5,7 @@ --> - diff --git a/build.gradle.kts b/build.gradle.kts index 9a8c1a0cc..9e627fce3 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,12 +1,14 @@ +import com.ncorti.ktfmt.gradle.tasks.KtfmtFormatTask + plugins { alias(libs.plugins.android.application) apply false - alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlinxSerialization) apply false alias(libs.plugins.ksp) apply false alias(libs.plugins.androidLibrary) apply false alias(libs.plugins.compose.compiler) apply false alias(libs.plugins.ktfmt) alias(libs.plugins.licensee) apply false + alias(libs.plugins.jetbrains.kotlin.jvm) apply false } subprojects { @@ -14,6 +16,13 @@ subprojects { plugin(rootProject.libs.plugins.ktfmt.get().pluginId) } + tasks.register("format") { + description = "Format Kotlin code style deviations." + source = project.fileTree(rootDir) + include("**/*.kt") + exclude("**/build/**", ".*generated.*", "**/amneziawg-tools/**", "**/.gradle/**") + } + ktfmt { kotlinLangStyle() } diff --git a/buildSrc/src/main/kotlin/Constants.kt b/buildSrc/src/main/kotlin/Constants.kt index 1ff415fd8..37fb0183c 100644 --- a/buildSrc/src/main/kotlin/Constants.kt +++ b/buildSrc/src/main/kotlin/Constants.kt @@ -1,8 +1,10 @@ object Constants { - const val VERSION_NAME = "4.3.1" - const val VERSION_CODE = 40301 - const val TARGET_SDK = 36 + const val VERSION_NAME = "5.0.0" + const val VERSION_CODE = 50000 + const val TARGET_SDK = 37 const val MIN_SDK = 26 + + const val NDK_VERSION = "28.2.13676358" const val APP_ID = "com.zaneschepke.wireguardautotunnel" const val APP_NAME = "wgtunnel" diff --git a/buildSrc/src/main/kotlin/Extensions.kt b/buildSrc/src/main/kotlin/Extensions.kt index 3c8c15b49..0e0ac4d53 100644 --- a/buildSrc/src/main/kotlin/Extensions.kt +++ b/buildSrc/src/main/kotlin/Extensions.kt @@ -1,18 +1,17 @@ - import org.ajoberstar.grgit.Grgit import org.gradle.api.Project import org.semver4j.Semver fun Project.languageList(): List { - return fileTree("../app/src/main/res") { include("**/strings.xml") } - .asSequence() - .map { stringFile -> stringFile.parentFile.name } - .map { valuesFolderName -> valuesFolderName.replace("values-", "") } - .filter { valuesFolderName -> valuesFolderName != "values" } - .map { languageCode -> languageCode.replace("-r", "_") } - .distinct() - .sorted() - .toList() + "en" + return fileTree("../app/src/main/res") { include("**/strings.xml") } + .asSequence() + .map { stringFile -> stringFile.parentFile.name } + .map { valuesFolderName -> valuesFolderName.replace("values-", "") } + .filter { valuesFolderName -> valuesFolderName != "values" } + .map { languageCode -> languageCode.replace("-r", "_") } + .distinct() + .sorted() + .toList() + "en" } fun allowedLicenses(): List { @@ -20,9 +19,14 @@ fun allowedLicenses(): List { } fun allowedLicenseUrls(): List { - return listOf("https://jsoup.org/license", "http://opensource.org/licenses/bsd-license.php", "https://github.com/journeyapps/zxing-android-embedded/blob/master/COPYING", - "https://github.com/RikkaApps/Shizuku-API/blob/master/LICENSE", "https://github.com/rafi0101/Android-Room-Database-Backup/blob/master/LICENSE", - "https://opensource.org/license/mit") + return listOf( + "https://jsoup.org/license", + "http://opensource.org/licenses/bsd-license.php", + "https://github.com/journeyapps/zxing-android-embedded/blob/master/COPYING", + "https://github.com/RikkaApps/Shizuku-API/blob/master/LICENSE", + "https://github.com/rafi0101/Android-Room-Database-Backup/blob/master/LICENSE", + "https://opensource.org/license/mit", + ) } fun buildLanguagesArray(languages: List): String { @@ -49,9 +53,7 @@ fun Project.getCommitCountSinceLastCommit(): Int { try { grgit = Grgit.open(mapOf("currentDir" to projectDir)) val headCommit = grgit.head() - val log = grgit.log(mapOf( - "includes" to listOf(headCommit.id) - )) + val log = grgit.log(mapOf("includes" to listOf(headCommit.id))) return log.size } catch (e: Exception) { logger.warn("Failed to get commit count: ${e.message}. Using fallback.") @@ -81,11 +83,8 @@ fun Project.computeVersionName(): String { return when { isNightlyBuild -> { // Bump patch for nightly - val nightlyVersion = Semver.of( - baseVersion.major, - baseVersion.minor, - baseVersion.patch + 1 - ) + val nightlyVersion = + Semver.of(baseVersion.major, baseVersion.minor, baseVersion.patch + 1) "${nightlyVersion}-nightly+git.${getGitCommitHash()}" } else -> Constants.VERSION_NAME @@ -106,4 +105,4 @@ fun Project.computeVersionCode(): Int { } return versionCode + getVersionCodeIncrement() -} \ No newline at end of file +} diff --git a/buildSrc/src/main/kotlin/LocalProperties.kt b/buildSrc/src/main/kotlin/LocalProperties.kt index 7e4b8e575..c6309648f 100644 --- a/buildSrc/src/main/kotlin/LocalProperties.kt +++ b/buildSrc/src/main/kotlin/LocalProperties.kt @@ -1,6 +1,6 @@ import java.io.File import java.io.FileInputStream -import java.util.* +import java.util.Properties object LocalProperties { @@ -16,4 +16,4 @@ object LocalProperties { fun get(key: String): String? = properties.getProperty(key) fun getOrDefault(key: String, default: String): String = properties.getProperty(key, default) -} \ No newline at end of file +} diff --git a/gradle.properties b/gradle.properties index 7f24146ca..e756036bc 100644 --- a/gradle.properties +++ b/gradle.properties @@ -21,13 +21,7 @@ kotlin.code.style=official # resources declared in the library itself and none from the library's dependencies, # thereby reducing the size of the R class for that library android.nonTransitiveRClass=true -android.defaults.buildfeatures.resvalues=true -android.sdk.defaultTargetSdkToCompileSdkIfUnset=false -android.enableAppCompileTimeRClass=false -android.usesSdkInManifest.disallowed=false android.uniquePackageNames=false android.dependency.useConstraints=true android.r8.strictFullModeForKeepRules=false -android.r8.optimizedResourceShrinking=false -android.builtInKotlin=false -android.newDsl=false +android.generateSyncIssueWhenLibraryConstraintsAreEnabled=false diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9aebc133e..cd47dbc12 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,11 +1,12 @@ [versions] +app="5.0.0" accompanist = "0.37.3" activityCompose = "1.13.0" -amneziawgAndroid = "2.3.7" androidx-junit = "1.3.0" icmp4a = "1.0.0" ipaddress = "5.6.2" -koinBom = "4.2.0" +koinBom = "4.2.1" +kotlinxCoroutinesAndroid = "1.11.0" leakcanaryAndroid = "3.0-alpha-8" orbitCompose = "11.0.0" roomdatabasebackup = "1.1.0" @@ -15,35 +16,38 @@ coreKtx = "1.18.0" datastorePreferences = "1.2.1" desugar_jdk_libs = "2.1.5" espressoCore = "3.7.0" -navigation3 = "1.0.1" +navigation3 = "1.1.2" junit = "4.13.2" -kotlinx-serialization-json = "1.10.0" -ktorClientCore = "3.4.2" +kotlinx-serialization-json = "1.11.0" +ktorClientCore = "3.5.0" lifecycle-runtime-compose = "2.10.0" -material3 = "1.5.0-alpha16" +material3 = "1.5.0-alpha20" pinLockCompose = "1.0.5" qrose = "1.1.2" roomVersion = "2.8.4" semver4j = "3.1.0" slf4jAndroid = "1.7.36" timber = "5.0.1" -tunnel = "1.4.0" -androidGradlePlugin = "8.13.2" -kotlin = "2.3.20" +androidGradlePlugin = "9.2.1" +kotlin = "2.3.21" ksp = "2.3.4" -composeBom = "2026.03.01" -compose = "1.10.6" +composeBom = "2026.05.01" +compose = "1.11.2" icons = "1.7.8" workRuntimeKtxVersion = "2.11.2" quickieFoss = "1.15.7" coreSplashscreen = "1.2.0" gradlePlugins-grgit = "5.3.3" -reorderable = "3.0.0" -material = "1.13.0" +reorderable = "3.1.0" +material = "1.14.0" storage = "1.6.0" ktfmt = "0.26.0" licensee = "1.14.1" lifecycleViewmodelNavigation3 = "2.10.0" +parser = "1.0.5" +relinker = "1.4.5" +libsu = "6.0.0" +jetbrainsKotlinJvm = "2.3.21" [bundles] # Core AndroidX foundations @@ -82,9 +86,6 @@ kotlinx-serialization = ["kotlinx-serialization-json"] # State management orbit-mvi = ["orbit-compose", "orbit-viewmodel", "orbit-core"] -# Tunnel/WireGuard -wireguard-tunnel = ["tunnel", "amneziawg-android"] - # Shizuku shizuku = ["shizuku-api", "shizuku-provider"] @@ -126,6 +127,7 @@ androidx-compose-ui = { module = "androidx.compose.ui:ui", version.ref = "compos androidx-compose-animation-graphics = { module = "androidx.compose.animation:animation-graphics", version.ref = "compose" } icmp4a = { module = "com.marsounjan:icmp4a", version.ref = "icmp4a" } ipaddress = { module = "com.github.seancfoley:ipaddress", version.ref = "ipaddress" } +kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinxCoroutinesAndroid" } leakcanary-android = { module = "com.squareup.leakcanary:leakcanary-android", version.ref = "leakcanaryAndroid" } material = { group = "com.google.android.material", name = "material", version.ref = "material" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" } @@ -187,13 +189,15 @@ desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref reorderable = { module = "sh.calvin.reorderable:reorderable", version.ref = "reorderable" } # tunnel -tunnel = { module = "com.zaneschepke:wireguard-android", version.ref = "tunnel" } -amneziawg-android = { module = "com.zaneschepke:amneziawg-android", version.ref = "amneziawgAndroid" } +amneziawg-parser = { module = "com.zaneschepke.wireguardautotunnel:amneziawg-parser", version.ref = "parser" } # shizuku shizuku-api = { module = "dev.rikka.shizuku:api", version.ref = "shizuku" } shizuku-provider = { module = "dev.rikka.shizuku:provider", version.ref = "shizuku" } +relinker = { module = "com.getkeepsafe.relinker:relinker", version.ref = "relinker" } +libsu = { module = "com.github.topjohnwu.libsu:core", version.ref = "libsu" } + [plugins] android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } @@ -203,4 +207,5 @@ androidLibrary = { id = "com.android.library", version.ref = "androidGradlePlugi compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } grgit = { id = "org.ajoberstar.grgit.service", version.ref = "gradlePlugins-grgit" } ktfmt = { id = "com.ncorti.ktfmt.gradle", version.ref = "ktfmt" } -licensee = { id = "app.cash.licensee", version.ref = "licensee" } \ No newline at end of file +licensee = { id = "app.cash.licensee", version.ref = "licensee" } +jetbrains-kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "jetbrainsKotlinJvm" } \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 2396194b6..fe51227a6 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,8 +1,8 @@ #Wed Oct 11 22:39:21 EDT 2023 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip -distributionSha256Sum=b266d5ff6b90eada6dc3b20cb090e3731302e553a27c5d3e4df1f0d76beaff06 +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip +distributionSha256Sum=2ab2958f2a1e51120c326cad6f385153bb11ee93b3c216c5fccebfdfbb7ec6cb validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/hevtunnel/.gitignore b/hevtunnel/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/hevtunnel/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/hevtunnel/build.gradle.kts b/hevtunnel/build.gradle.kts new file mode 100644 index 000000000..4c7f6bfa7 --- /dev/null +++ b/hevtunnel/build.gradle.kts @@ -0,0 +1,68 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.androidLibrary) +} + +android { + namespace = "com.zaneschepke.hevtunnel" + version= "1.0.1" + + compileSdk { + version = release(Constants.TARGET_SDK) + } + + ndkVersion = Constants.NDK_VERSION + + defaultConfig { + minSdk = Constants.MIN_SDK + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + + ndk { + abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64") + } + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + externalNativeBuild { + ndkBuild { + arguments.add("APP_CFLAGS+=-DPKGNAME=com/zaneschepke/hevtunnel -ffile-prefix-map=${rootDir}=.") + arguments.add("APP_LDFLAGS+=-Wl,--build-id=none") + } + } + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + + externalNativeBuild { + ndkBuild { + path = file("src/main/jni/Android.mk") + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlin { compilerOptions { jvmTarget = JvmTarget.JVM_17 } } +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + implementation(libs.material) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) +} \ No newline at end of file diff --git a/hevtunnel/consumer-rules.pro b/hevtunnel/consumer-rules.pro new file mode 100644 index 000000000..8348c9c1b --- /dev/null +++ b/hevtunnel/consumer-rules.pro @@ -0,0 +1 @@ +-keep class com.zaneschepke.hevtunnel.TProxyService { *; } \ No newline at end of file diff --git a/hevtunnel/proguard-rules.pro b/hevtunnel/proguard-rules.pro new file mode 100644 index 000000000..597c0cf53 --- /dev/null +++ b/hevtunnel/proguard-rules.pro @@ -0,0 +1,23 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile + +-keep class com.zaneschepke.hevtunnel.TProxyService { *; } \ No newline at end of file diff --git a/hevtunnel/src/androidTest/java/com/zaneschepke/hevtunnel/ExampleInstrumentedTest.kt b/hevtunnel/src/androidTest/java/com/zaneschepke/hevtunnel/ExampleInstrumentedTest.kt new file mode 100644 index 000000000..55e2c1921 --- /dev/null +++ b/hevtunnel/src/androidTest/java/com/zaneschepke/hevtunnel/ExampleInstrumentedTest.kt @@ -0,0 +1,22 @@ +package com.zaneschepke.hevtunnel + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.zaneschepke.hevtunnel.test", appContext.packageName) + } +} diff --git a/hevtunnel/src/main/AndroidManifest.xml b/hevtunnel/src/main/AndroidManifest.xml new file mode 100644 index 000000000..44008a433 --- /dev/null +++ b/hevtunnel/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/hevtunnel/src/main/java/com/zaneschepke/hevtunnel/HevTunnelConfig.kt b/hevtunnel/src/main/java/com/zaneschepke/hevtunnel/HevTunnelConfig.kt new file mode 100644 index 000000000..25e56127f --- /dev/null +++ b/hevtunnel/src/main/java/com/zaneschepke/hevtunnel/HevTunnelConfig.kt @@ -0,0 +1,11 @@ +package com.zaneschepke.hevtunnel + +data class HevTunnelConfig( + val mtu: Int, + val ipv4: String, + val ipv6: String, + val address: String, + val port: Int, + val username: String, + val password: String, +) diff --git a/hevtunnel/src/main/java/com/zaneschepke/hevtunnel/TProxyService.kt b/hevtunnel/src/main/java/com/zaneschepke/hevtunnel/TProxyService.kt new file mode 100644 index 000000000..9e7a50d55 --- /dev/null +++ b/hevtunnel/src/main/java/com/zaneschepke/hevtunnel/TProxyService.kt @@ -0,0 +1,47 @@ +package com.zaneschepke.hevtunnel + +import android.content.Context +import java.io.File +import java.io.FileOutputStream +import java.io.IOException + +object TProxyService { + private const val HEV_CONFIG_FILE_NAME: String = "tproxy.conf" + private const val TASK_STACK_SIZE = 24576 + + init { + System.loadLibrary("hev-socks5-tunnel") + } + + @JvmStatic external fun TProxyStartService(config_path: String?, fd: Int) + + @JvmStatic external fun TProxyStopService() + + @JvmStatic external fun TProxyGetStats(): LongArray? + + @Throws(IOException::class) + fun createHevTunnelConfig(config: HevTunnelConfig, context: Context): File { + val tproxyFile = File(context.cacheDir, HEV_CONFIG_FILE_NAME) + + val hevConf = + """ + misc: + task-stack-size: $TASK_STACK_SIZE + tunnel: + mtu: ${config.mtu} + ipv4: '${config.ipv4}' + ipv6: '${config.ipv6}' + socks5: + address: '${config.address}' + port: ${config.port} + username: '${config.username}' + password: '${config.password}' + udp: 'udp' + """ + .trimIndent() + + FileOutputStream(tproxyFile, false).use { fos -> fos.write(hevConf.toByteArray()) } + + return tproxyFile + } +} diff --git a/hevtunnel/src/main/jni/Android.mk b/hevtunnel/src/main/jni/Android.mk new file mode 100644 index 000000000..0c86f503a --- /dev/null +++ b/hevtunnel/src/main/jni/Android.mk @@ -0,0 +1,17 @@ +# Copyright (C) 2023 The Android Open Source Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +include $(call all-subdir-makefiles) +LOCAL_MODULE := hev-socks5-tunnel diff --git a/hevtunnel/src/main/jni/hev-socks5-tunnel b/hevtunnel/src/main/jni/hev-socks5-tunnel new file mode 160000 index 000000000..5ec615875 --- /dev/null +++ b/hevtunnel/src/main/jni/hev-socks5-tunnel @@ -0,0 +1 @@ +Subproject commit 5ec615875f3bcabaf0319103b931e00592fc883a diff --git a/hevtunnel/src/test/java/com/zaneschepke/hevtunnel/ExampleUnitTest.kt b/hevtunnel/src/test/java/com/zaneschepke/hevtunnel/ExampleUnitTest.kt new file mode 100644 index 000000000..15a7dd88e --- /dev/null +++ b/hevtunnel/src/test/java/com/zaneschepke/hevtunnel/ExampleUnitTest.kt @@ -0,0 +1,16 @@ +package com.zaneschepke.hevtunnel + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} diff --git a/logcatter/build.gradle.kts b/logcatter/build.gradle.kts index eb6a998a6..83363cbf6 100644 --- a/logcatter/build.gradle.kts +++ b/logcatter/build.gradle.kts @@ -2,7 +2,6 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { alias(libs.plugins.androidLibrary) - alias(libs.plugins.kotlin.android) } android { diff --git a/logcatter/src/main/java/com/zaneschepke/logcatter/LogcatManager.kt b/logcatter/src/main/java/com/zaneschepke/logcatter/LogcatManager.kt index ac645d2f0..ae745e347 100644 --- a/logcatter/src/main/java/com/zaneschepke/logcatter/LogcatManager.kt +++ b/logcatter/src/main/java/com/zaneschepke/logcatter/LogcatManager.kt @@ -3,11 +3,17 @@ package com.zaneschepke.logcatter import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import com.zaneschepke.logcatter.model.LogMessage -import kotlinx.coroutines.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -46,13 +52,12 @@ class LogcatManager(pid: Int, logDir: String, maxFileSize: Long, maxFolderSize: mutex.withLock { if (isStarted) return stopInternal() - logJob = - logScope.launch { - logcatReader.readLogs().collect { logMessage -> - _bufferedLogs.emit(logMessage) - _liveLogs.emit(logMessage) - } + logJob = logScope.launch { + logcatReader.readLogs().collect { logMessage -> + _bufferedLogs.emit(logMessage) + _liveLogs.emit(logMessage) } + } isStarted = true } } diff --git a/networkmonitor/build.gradle.kts b/networkmonitor/build.gradle.kts index 41a6350dc..9e9f2ed42 100644 --- a/networkmonitor/build.gradle.kts +++ b/networkmonitor/build.gradle.kts @@ -2,7 +2,6 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { alias(libs.plugins.androidLibrary) - alias(libs.plugins.kotlin.android) } android { @@ -43,8 +42,6 @@ dependencies { androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) - implementation(libs.tunnel) - // shizuku implementation(libs.shizuku.api) implementation(libs.shizuku.provider) diff --git a/networkmonitor/src/main/java/com/zaneschepke/networkmonitor/AndroidNetworkMonitor.kt b/networkmonitor/src/main/java/com/zaneschepke/networkmonitor/AndroidNetworkMonitor.kt index d4db0b539..8c9700d10 100644 --- a/networkmonitor/src/main/java/com/zaneschepke/networkmonitor/AndroidNetworkMonitor.kt +++ b/networkmonitor/src/main/java/com/zaneschepke/networkmonitor/AndroidNetworkMonitor.kt @@ -4,6 +4,7 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter +import android.database.ContentObserver import android.location.LocationManager import android.net.ConnectivityManager import android.net.Network @@ -11,17 +12,37 @@ import android.net.NetworkCapabilities import android.net.NetworkRequest import android.net.wifi.WifiManager import android.os.Build -import com.wireguard.android.util.RootShell -import com.zaneschepke.networkmonitor.AndroidNetworkMonitor.WifiDetectionMethod.* +import android.provider.Settings +import com.zaneschepke.networkmonitor.AndroidNetworkMonitor.WifiDetectionMethod.DEFAULT +import com.zaneschepke.networkmonitor.AndroidNetworkMonitor.WifiDetectionMethod.LEGACY +import com.zaneschepke.networkmonitor.AndroidNetworkMonitor.WifiDetectionMethod.ROOT +import com.zaneschepke.networkmonitor.AndroidNetworkMonitor.WifiDetectionMethod.SHIZUKU import com.zaneschepke.networkmonitor.shizuku.ShizukuShell -import com.zaneschepke.networkmonitor.util.* +import com.zaneschepke.networkmonitor.util.getCurrentSecurityType +import com.zaneschepke.networkmonitor.util.getWifiSsid +import com.zaneschepke.networkmonitor.util.hasRequiredLocationPermissions +import com.zaneschepke.networkmonitor.util.isAirplaneModeOn +import com.zaneschepke.networkmonitor.util.isLocationServicesEnabled +import java.net.Inet6Address import kotlin.concurrent.atomics.AtomicReference import kotlin.concurrent.atomics.ExperimentalAtomicApi +import kotlin.time.Duration.Companion.milliseconds import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.channels.awaitClose -import kotlinx.coroutines.flow.* +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.withTimeoutOrNull import timber.log.Timber @@ -35,12 +56,16 @@ class AndroidNetworkMonitor( interface ConfigurationListener { val detectionMethod: Flow - val rootShell: RootShell + + // maybe this shouldn't just be a string result + suspend fun runRootShellCommand(cmd: String): String? } companion object { const val LOCATION_SERVICES_FILTER: String = "android.location.PROVIDERS_CHANGED" const val ANDROID_UNKNOWN_SSID: String = "" + const val WIFI_SSID_SHELL_COMMAND = + "cmd wifi status | grep -i 'connected to' | cut -d'\"' -f2" const val SHELL_COMMAND_TIMEOUT_MS = 2_000L } @@ -83,8 +108,80 @@ class AndroidNetworkMonitor( // location queries in Legacy mode private val lastKnownActiveNetwork = MutableStateFlow(ActiveNetwork.Disconnected) - // recreate defaultNetwork flow on permission/detection method changes to get newly available - // network info + private val privateDnsFlow: Flow = callbackFlow { + val contentResolver = appContext.contentResolver + + val modeUri = Settings.Global.getUriFor("private_dns_mode") + val specifierUri = Settings.Global.getUriFor("private_dns_specifier") + + val observer = + object : ContentObserver(null) { + override fun onChange(selfChange: Boolean) { + trySend(getCurrentPrivateDnsSettings()) + } + } + + contentResolver.registerContentObserver(modeUri, false, observer) + contentResolver.registerContentObserver(specifierUri, false, observer) + + // initial value + trySend(getCurrentPrivateDnsSettings()) + + awaitClose { contentResolver.unregisterContentObserver(observer) } + } + + private data class PrivateDnsSettings(val mode: PrivateDnsMode, val hostname: String?) + + private fun getCurrentPrivateDnsSettings(): PrivateDnsSettings { + val modeStr = + Settings.Global.getString(appContext.contentResolver, "private_dns_mode") ?: "off" + + val mode = + when (modeStr) { + "hostname" -> PrivateDnsMode.HOSTNAME + "opportunistic" -> PrivateDnsMode.AUTOMATIC + else -> PrivateDnsMode.OFF + } + + val hostname = + Settings.Global.getString(appContext.contentResolver, "private_dns_specifier") + + return PrivateDnsSettings(mode, hostname) + } + + private fun getDnsServers(network: Network?): List { + if (network == null) return emptyList() + val linkProperties = connectivityManager?.getLinkProperties(network) ?: return emptyList() + return linkProperties.dnsServers.map { it.hostAddress } + } + + fun hasIpv6Support(network: Network?, activeNetwork: ActiveNetwork): Boolean { + if (network == null || activeNetwork is ActiveNetwork.Disconnected) return false + + val lp = connectivityManager?.getLinkProperties(network) ?: return false + + val hasGlobalIpv6 = + lp.linkAddresses.any { + val addr = it.address + addr is Inet6Address && + !addr.isLinkLocalAddress && + !addr.isLoopbackAddress && + !addr.isMulticastAddress + } + + val hasIpv6DefaultRoute = + lp.routes.any { route -> route.isDefaultRoute && (route.gateway is Inet6Address) } + + val hasNat64Prefix = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + lp.nat64Prefix != null + } else false + + val hasSupport = (hasGlobalIpv6 && hasIpv6DefaultRoute) || hasNat64Prefix + + return hasSupport + } + @OptIn(ExperimentalCoroutinesApi::class) private val defaultNetworkFlow: Flow = combine(configurationListener.detectionMethod, permissionsChangedFlow) { detectionMethod, _ @@ -100,8 +197,6 @@ class AndroidNetworkMonitor( object : ConnectivityManager.NetworkCallback(FLAG_INCLUDE_LOCATION_INFO) { override fun onAvailable(network: Network) { - // ignore onAvailable has it doesn't contain detailed network - // information in capabilities Timber.d("Default onAvailable: $network") } @@ -152,8 +247,6 @@ class AndroidNetworkMonitor( } } - // recreate Wi-Fi flow on permission/detection method changes to get newly available network - // info @OptIn(ExperimentalCoroutinesApi::class) private val wifiFlow: Flow = combine(configurationListener.detectionMethod, permissionsChangedFlow) { detectionMethod, _ @@ -296,28 +389,6 @@ class AndroidNetworkMonitor( } } - private val cellularStateFlow: Flow = - cellularFlow - .map { event -> - when (event) { - is TransportEvent.CapabilitiesChanged -> event.networkCapabilities - is TransportEvent.Lost -> null - else -> null - } - } - .stateIn(applicationScope, SharingStarted.Eagerly, null) - - private val ethernetStateFlow: Flow = - ethernetFlow - .map { event -> - when (event) { - is TransportEvent.CapabilitiesChanged -> event.networkCapabilities - is TransportEvent.Lost -> null - else -> null - } - } - .stateIn(applicationScope, SharingStarted.Eagerly, null) - private suspend fun getSsidByDetectionMethod( detectionMethod: WifiDetectionMethod?, networkCapabilities: NetworkCapabilities?, @@ -347,11 +418,11 @@ class AndroidNetworkMonitor( wifiManager?.getWifiSsid() ?: ANDROID_UNKNOWN_SSID } ROOT -> - withTimeoutOrNull(SHELL_COMMAND_TIMEOUT_MS) { - configurationListener.rootShell.getCurrentWifiName() + withTimeoutOrNull(SHELL_COMMAND_TIMEOUT_MS.milliseconds) { + configurationListener.runRootShellCommand(WIFI_SSID_SHELL_COMMAND) } ?: ANDROID_UNKNOWN_SSID SHIZUKU -> - withTimeoutOrNull(SHELL_COMMAND_TIMEOUT_MS) { + withTimeoutOrNull(SHELL_COMMAND_TIMEOUT_MS.milliseconds) { ShizukuShell(applicationScope) .singleResponseCommand(WIFI_SSID_SHELL_COMMAND) } ?: ANDROID_UNKNOWN_SSID @@ -370,33 +441,31 @@ class AndroidNetworkMonitor( private data class NetworkData( val defaultNetworkEvent: TransportEvent, val wifiNetworkEvent: TransportEvent, - val cellularCaps: NetworkCapabilities?, - val ethernetCaps: NetworkCapabilities?, + val cellularEvent: TransportEvent, + val ethernetEvent: TransportEvent, ) // combine our network flows to keep sync private val networkFlows: Flow = - combine(defaultNetworkFlow, wifiFlow, cellularStateFlow, ethernetStateFlow) { + combine(defaultNetworkFlow, wifiFlow, cellularFlow, ethernetFlow) { defaultEvent, - wifiCaps, - cellularCaps, - ethernetCaps -> - NetworkData(defaultEvent, wifiCaps, cellularCaps, ethernetCaps) + wifiEvent, + cellularEvent, + ethernetEvent -> + NetworkData(defaultEvent, wifiEvent, cellularEvent, ethernetEvent) } @OptIn(ExperimentalAtomicApi::class) private val vpnActiveState = AtomicReference(false) @OptIn(ExperimentalCoroutinesApi::class, ExperimentalAtomicApi::class, FlowPreview::class) override val connectivityStateFlow: SharedFlow = - combine(networkFlows, airplaneModeFlow, configurationListener.detectionMethod) { - networkData, - isAirplaneOn, - detectionMethod -> + combine( + networkFlows, + airplaneModeFlow, + configurationListener.detectionMethod, + privateDnsFlow, + ) { networkData, isAirplaneOn, detectionMethod, privateDnsSettings -> val defaultEvent = networkData.defaultNetworkEvent - val wifiEvent = networkData.wifiNetworkEvent - val cellularCaps = networkData.cellularCaps - val ethernetCaps = networkData.ethernetCaps - val permissions = when (defaultEvent) { is TransportEvent.Permissions -> defaultEvent.permissions @@ -427,13 +496,8 @@ class AndroidNetworkMonitor( ) } - val vpnPreviouslyActive = - vpnActiveState.exchange( - defaultCaps.hasTransport(NetworkCapabilities.TRANSPORT_VPN) - ) - val isVpnActive = vpnActiveState.load() + val isVpnActive = defaultCaps.hasTransport(NetworkCapabilities.TRANSPORT_VPN) - // determine vpn state val vpnState: VpnState = if (!isVpnActive) { VpnState.Inactive @@ -446,74 +510,85 @@ class AndroidNetworkMonitor( ) } - val activeNetwork: ActiveNetwork = - run { - if (!isVpnActive) { - when { - defaultCaps.hasTransport( - NetworkCapabilities.TRANSPORT_ETHERNET - ) -> ActiveNetwork.Ethernet - defaultCaps.hasTransport( - NetworkCapabilities.TRANSPORT_WIFI - ) -> { - val ssid = - getSsidByDetectionMethod( - detectionMethod, - defaultCaps, - defaultNetwork, - ) - ActiveNetwork.Wifi( - ssid, - wifiManager?.getCurrentSecurityType(), - defaultNetwork.toString(), - ) - } - defaultCaps.hasTransport( - NetworkCapabilities.TRANSPORT_CELLULAR - ) && !isAirplaneOn -> ActiveNetwork.Cellular - else -> ActiveNetwork.Disconnected - } - } else { - val fromCaps = - when { - ethernetCaps != null -> ActiveNetwork.Ethernet - wifiEvent is TransportEvent.CapabilitiesChanged -> { - val ssid = - getSsidByDetectionMethod( - detectionMethod, - wifiEvent.networkCapabilities, - wifiEvent.network, - ) - ActiveNetwork.Wifi( - ssid, - wifiManager?.getCurrentSecurityType(), - wifiEvent.network.toString(), - ) - } - cellularCaps != null && !isAirplaneOn -> - ActiveNetwork.Cellular - else -> null - } - - fromCaps - ?: if (!vpnPreviouslyActive) { - lastKnownActiveNetwork.value - } else { - ActiveNetwork.Disconnected - } - } + val physicalNetwork: ActiveNetwork = + when { + // Ethernet + networkData.ethernetEvent is TransportEvent.CapabilitiesChanged && + networkData.ethernetEvent.networkCapabilities?.hasTransport( + NetworkCapabilities.TRANSPORT_ETHERNET + ) == true -> ActiveNetwork.Ethernet(networkData.ethernetEvent.network) + + // WiFi + networkData.wifiNetworkEvent is TransportEvent.CapabilitiesChanged -> { + val wifiEvent = networkData.wifiNetworkEvent + val ssid = + getSsidByDetectionMethod( + detectionMethod, + wifiEvent.networkCapabilities, + wifiEvent.network, + ) + ActiveNetwork.Wifi( + ssid, + wifiManager?.getCurrentSecurityType(), + wifiEvent.network.toString(), + wifiEvent.network, + ) } - .also { network -> lastKnownActiveNetwork.value = network } + + networkData.cellularEvent is TransportEvent.CapabilitiesChanged && + networkData.cellularEvent.networkCapabilities?.hasTransport( + NetworkCapabilities.TRANSPORT_CELLULAR + ) == true && + !isAirplaneOn -> + ActiveNetwork.Cellular(networkData.cellularEvent.network) + + else -> ActiveNetwork.Disconnected + } + + val activeNetwork: ActiveNetwork = + if (!isVpnActive) { + physicalNetwork + } else { + lastKnownActiveNetwork.value + } + + if (physicalNetwork != ActiveNetwork.Disconnected) { + lastKnownActiveNetwork.value = physicalNetwork + } + + val underlyingNetwork: Network? = + when (val last = lastKnownActiveNetwork.value) { + is ActiveNetwork.Wifi -> last.network + is ActiveNetwork.Cellular -> last.network + is ActiveNetwork.Ethernet -> last.network + else -> null + } + + val effectiveDns = + DnsInfo( + servers = getDnsServers(defaultNetwork), + privateDnsMode = privateDnsSettings.mode, + privateDnsHostname = privateDnsSettings.hostname, + ) + val underlyingDns = + DnsInfo( + servers = getDnsServers(underlyingNetwork), + privateDnsMode = privateDnsSettings.mode, + privateDnsHostname = privateDnsSettings.hostname, + ) ConnectivityState( activeNetwork = activeNetwork, locationPermissionsGranted = permissions.locationPermissionGranted, locationServicesEnabled = permissions.locationServicesEnabled, vpnState = vpnState, + effectiveDnsInfo = effectiveDns, + underlyingDnsInfo = underlyingDns, + hasIpv6 = hasIpv6Support(underlyingNetwork, activeNetwork), ) } .distinctUntilChanged() - .debounce { 300L } + .debounce(300.milliseconds) .shareIn(applicationScope, SharingStarted.Eagerly, replay = 1) // utility to send local broadcast to trigger a recheck of location permissions onResume, @@ -613,7 +688,6 @@ class AndroidNetworkMonitor( permissionReceiver?.let { appContext.unregisterReceiver(it) } locationServicesReceiver?.let { appContext.unregisterReceiver(it) } airplaneReceiver?.let { appContext.unregisterReceiver(it) } - defaultNetworkCallback?.let { connectivityManager?.unregisterNetworkCallback(it) } wifiCallback?.let { connectivityManager?.unregisterNetworkCallback(it) } cellularCallback?.let { connectivityManager?.unregisterNetworkCallback(it) } diff --git a/networkmonitor/src/main/java/com/zaneschepke/networkmonitor/ConnectivityState.kt b/networkmonitor/src/main/java/com/zaneschepke/networkmonitor/ConnectivityState.kt index 15b3d9d2b..5e55bd39b 100644 --- a/networkmonitor/src/main/java/com/zaneschepke/networkmonitor/ConnectivityState.kt +++ b/networkmonitor/src/main/java/com/zaneschepke/networkmonitor/ConnectivityState.kt @@ -1,5 +1,6 @@ package com.zaneschepke.networkmonitor +import android.net.Network import com.zaneschepke.networkmonitor.util.WifiSecurityType data class ConnectivityState( @@ -7,6 +8,9 @@ data class ConnectivityState( val locationPermissionsGranted: Boolean, val locationServicesEnabled: Boolean, val vpnState: VpnState, + val effectiveDnsInfo: DnsInfo = DnsInfo(), + val underlyingDnsInfo: DnsInfo = DnsInfo(), + val hasIpv6: Boolean = false, ) { fun hasInternet(): Boolean = activeNetwork !is ActiveNetwork.Disconnected @@ -31,14 +35,27 @@ data class ConnectivityState( data class Permissions(val locationServicesEnabled: Boolean, val locationPermissionGranted: Boolean) sealed class ActiveNetwork { + fun key(): String { + return when (this) { + is Wifi -> "wifi:${networkId}" + is Cellular -> "cell:${network?.hashCode() ?: 0}" + is Ethernet -> "eth:${network?.hashCode() ?: 0}" + Disconnected -> "none" + } + } + data object Disconnected : ActiveNetwork() - data class Wifi(val ssid: String, val securityType: WifiSecurityType?, val networkId: String) : - ActiveNetwork() + data class Wifi( + val ssid: String, + val securityType: WifiSecurityType?, + val networkId: String, + val network: Network?, + ) : ActiveNetwork() - data object Cellular : ActiveNetwork() + data class Cellular(val network: Network?) : ActiveNetwork() - data object Ethernet : ActiveNetwork() + data class Ethernet(val network: Network?) : ActiveNetwork() } sealed interface VpnState { diff --git a/networkmonitor/src/main/java/com/zaneschepke/networkmonitor/Dns.kt b/networkmonitor/src/main/java/com/zaneschepke/networkmonitor/Dns.kt new file mode 100644 index 000000000..bd62df4a7 --- /dev/null +++ b/networkmonitor/src/main/java/com/zaneschepke/networkmonitor/Dns.kt @@ -0,0 +1,19 @@ +package com.zaneschepke.networkmonitor + +enum class PrivateDnsMode { + OFF, + AUTOMATIC, + HOSTNAME, +} + +data class DnsInfo( + val servers: List = emptyList(), + val privateDnsMode: PrivateDnsMode = PrivateDnsMode.OFF, + val privateDnsHostname: String? = null, +) { + val isEmpty: Boolean + get() = servers.isEmpty() + + override fun toString() = + "DnsInfo(servers=$servers, mode=$privateDnsMode, hostname=$privateDnsHostname)" +} diff --git a/networkmonitor/src/main/java/com/zaneschepke/networkmonitor/StableNetworkEngine.kt b/networkmonitor/src/main/java/com/zaneschepke/networkmonitor/StableNetworkEngine.kt new file mode 100644 index 000000000..d3eba1468 --- /dev/null +++ b/networkmonitor/src/main/java/com/zaneschepke/networkmonitor/StableNetworkEngine.kt @@ -0,0 +1,44 @@ +package com.zaneschepke.networkmonitor + +import com.zaneschepke.networkmonitor.model.StableNetworkSnapshot +import kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.launch + +@OptIn(FlowPreview::class) +class StableNetworkEngine( + private val scope: CoroutineScope, + private val upstream: Flow, +) { + + private val _stableState = MutableStateFlow(null) + val stableState: StateFlow = _stableState.asStateFlow() + + private var lastKey: String? = null + private var stableSinceMs: Long = 0L + + init { + scope.launch { + upstream.debounce(150.milliseconds).collect { state -> + val key = state.activeNetwork.key() + val now = System.currentTimeMillis() + + if (key != lastKey) { + lastKey = key + stableSinceMs = now + } + + val snapshot = + StableNetworkSnapshot(key = key, state = state, stableSinceMs = stableSinceMs) + + _stableState.value = snapshot + } + } + } +} diff --git a/networkmonitor/src/main/java/com/zaneschepke/networkmonitor/model/StableNetworkSnapshot.kt b/networkmonitor/src/main/java/com/zaneschepke/networkmonitor/model/StableNetworkSnapshot.kt new file mode 100644 index 000000000..9f93d2658 --- /dev/null +++ b/networkmonitor/src/main/java/com/zaneschepke/networkmonitor/model/StableNetworkSnapshot.kt @@ -0,0 +1,9 @@ +package com.zaneschepke.networkmonitor.model + +import com.zaneschepke.networkmonitor.ConnectivityState + +data class StableNetworkSnapshot( + val key: String, + val state: ConnectivityState, + val stableSinceMs: Long, +) diff --git a/networkmonitor/src/main/java/com/zaneschepke/networkmonitor/shizuku/ShizukuShell.kt b/networkmonitor/src/main/java/com/zaneschepke/networkmonitor/shizuku/ShizukuShell.kt index 1d777ff3d..a5bcf451a 100644 --- a/networkmonitor/src/main/java/com/zaneschepke/networkmonitor/shizuku/ShizukuShell.kt +++ b/networkmonitor/src/main/java/com/zaneschepke/networkmonitor/shizuku/ShizukuShell.kt @@ -6,6 +6,7 @@ import java.io.FileInputStream import java.io.InputStreamReader import kotlin.coroutines.resumeWithException import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine import moe.shizuku.server.IRemoteProcess @@ -30,7 +31,7 @@ class ShizukuShell(private val applicationScope: CoroutineScope) { } fun command(command: String, listener: CommandResultListener, lineBundle: Int = 50) { - applicationScope.launch { + applicationScope.launch(Dispatchers.IO) { var process: IRemoteProcess? = null var inputStreamPfd: ParcelFileDescriptor? = null var errorStreamPfd: ParcelFileDescriptor? = null diff --git a/networkmonitor/src/main/java/com/zaneschepke/networkmonitor/util/Extensions.kt b/networkmonitor/src/main/java/com/zaneschepke/networkmonitor/util/Extensions.kt index d0d89d310..8ba643461 100644 --- a/networkmonitor/src/main/java/com/zaneschepke/networkmonitor/util/Extensions.kt +++ b/networkmonitor/src/main/java/com/zaneschepke/networkmonitor/util/Extensions.kt @@ -10,20 +10,11 @@ import android.net.wifi.WifiManager import android.os.Build import android.provider.Settings import androidx.core.content.ContextCompat -import com.wireguard.android.util.RootShell import com.zaneschepke.networkmonitor.AndroidNetworkMonitor.Companion.ANDROID_UNKNOWN_SSID import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import timber.log.Timber -const val WIFI_SSID_SHELL_COMMAND = "cmd wifi status | grep -i 'connected to' | cut -d'\"' -f2" - -fun RootShell.getCurrentWifiName(): String { - val response = mutableListOf() - run(response, WIFI_SSID_SHELL_COMMAND) - return response.firstOrNull() ?: ANDROID_UNKNOWN_SSID -} - @Suppress("DEPRECATION") fun WifiManager.getCurrentSecurityType(): WifiSecurityType? { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { diff --git a/pinger/.gitignore b/pinger/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/pinger/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/pinger/build.gradle.kts b/pinger/build.gradle.kts new file mode 100644 index 000000000..187551e27 --- /dev/null +++ b/pinger/build.gradle.kts @@ -0,0 +1,15 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + id("java-library") + alias(libs.plugins.jetbrains.kotlin.jvm) +} +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} +kotlin { compilerOptions { jvmTarget = JvmTarget.JVM_17 } } + +dependencies { + implementation(libs.kotlinx.coroutines.android) +} diff --git a/pinger/src/main/java/com/zaneschepke/pinger/Pinger.kt b/pinger/src/main/java/com/zaneschepke/pinger/Pinger.kt new file mode 100644 index 000000000..6bfef7807 --- /dev/null +++ b/pinger/src/main/java/com/zaneschepke/pinger/Pinger.kt @@ -0,0 +1,8 @@ +package com.zaneschepke.pinger + +import com.zaneschepke.pinger.model.PingConfig +import com.zaneschepke.pinger.model.PingStats + +interface Pinger { + suspend fun ping(config: PingConfig): PingStats +} diff --git a/pinger/src/main/java/com/zaneschepke/pinger/Socks5SocketFactory.kt b/pinger/src/main/java/com/zaneschepke/pinger/Socks5SocketFactory.kt new file mode 100644 index 000000000..bc8fd91d3 --- /dev/null +++ b/pinger/src/main/java/com/zaneschepke/pinger/Socks5SocketFactory.kt @@ -0,0 +1,136 @@ +package com.zaneschepke.pinger + +import java.io.DataInputStream +import java.io.DataOutputStream +import java.io.IOException +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.Socket +import javax.net.SocketFactory + +class Socks5SocketFactory( + private val proxyHost: String, + private val proxyPort: Int, + private val username: String?, + private val password: String?, +) : SocketFactory() { + + private val proxyAddress = InetSocketAddress(proxyHost, proxyPort) + + override fun createSocket() = Socket() + + override fun createSocket(host: String?, port: Int): Socket { + return createSocks5Socket(host ?: throw IOException("Host is required"), port) + } + + override fun createSocket( + host: String?, + port: Int, + localHost: InetAddress?, + localPort: Int, + ): Socket { + val socket = createSocks5Socket(host ?: throw IOException("Host is required"), port) + if (localHost != null) socket.bind(InetSocketAddress(localHost, localPort)) + return socket + } + + override fun createSocket(host: InetAddress?, port: Int): Socket { + if (host == null) throw IOException("Host address is required") + return createSocks5Socket(host.hostAddress, port) + } + + override fun createSocket( + address: InetAddress?, + port: Int, + localAddress: InetAddress?, + localPort: Int, + ): Socket { + if (address == null) throw IOException("Host address is required") + val socket = createSocks5Socket(address.hostAddress, port) + if (localAddress != null) socket.bind(InetSocketAddress(localAddress, localPort)) + return socket + } + + private fun createSocks5Socket(targetHost: String, targetPort: Int): Socket { + val socket = Socket() + socket.connect(proxyAddress, 10_000) + + val input = DataInputStream(socket.getInputStream()) + val output = DataOutputStream(socket.getOutputStream()) + + // Greeting + output.writeByte(0x05) + if (username != null && password != null) { + output.writeByte(2) + output.writeByte(0x00) + output.writeByte(0x02) + } else { + output.writeByte(1) + output.writeByte(0x00) + } + output.flush() + + val version = input.readUnsignedByte() + if (version != 0x05) throw IOException("Bad SOCKS version: $version") + val method = input.readUnsignedByte() + + if (method == 0x02) { + if (username == null || password == null) + throw IOException("SOCKS5 requires credentials") + output.writeByte(0x01) // auth version + val u = username.toByteArray(Charsets.UTF_8) + output.writeByte(u.size) + output.write(u) + val p = password.toByteArray(Charsets.UTF_8) + output.writeByte(p.size) + output.write(p) + output.flush() + + val authVer = input.readUnsignedByte() + if (authVer != 0x01) throw IOException("Bad auth version") + if (input.readUnsignedByte() != 0x00) throw IOException("SOCKS5 authentication failed") + } else if (method != 0x00) { + throw IOException("Unsupported SOCKS method: $method") + } + + // CONNECT + output.writeByte(0x05) // version + output.writeByte(0x01) // CONNECT + output.writeByte(0x00) // reserved + + val addr = InetAddress.getByName(targetHost) + when (addr) { + is java.net.Inet4Address -> { + output.writeByte(0x01) + output.write(addr.address) + } + is java.net.Inet6Address -> { + output.writeByte(0x04) + output.write(addr.address) + } + else -> { // domain name + output.writeByte(0x03) + val bytes = targetHost.toByteArray(Charsets.UTF_8) + output.writeByte(bytes.size) + output.write(bytes) + } + } + output.writeShort(targetPort) + output.flush() + + // Read reply + val repVer = input.readUnsignedByte() + if (repVer != 0x05) throw IOException("Bad reply version") + val status = input.readUnsignedByte() + if (status != 0x00) throw IOException("SOCKS5 connect failed: status $status") + input.skip(1) // reserved + when (input.readUnsignedByte()) { + 0x01 -> input.skip(4) + 0x04 -> input.skip(16) + 0x03 -> input.skip(input.readUnsignedByte().toLong()) + } + input.skip(2) // port + + return socket + } +} diff --git a/pinger/src/main/java/com/zaneschepke/pinger/TcpConnectPinger.kt b/pinger/src/main/java/com/zaneschepke/pinger/TcpConnectPinger.kt new file mode 100644 index 000000000..685130595 --- /dev/null +++ b/pinger/src/main/java/com/zaneschepke/pinger/TcpConnectPinger.kt @@ -0,0 +1,96 @@ +package com.zaneschepke.pinger + +import com.zaneschepke.pinger.model.PingConfig +import com.zaneschepke.pinger.model.PingStats +import java.io.IOException +import java.net.InetSocketAddress +import java.net.Proxy +import java.net.Socket +import kotlin.math.pow +import kotlin.math.sqrt +import kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class TcpConnectPinger : Pinger { + + override suspend fun ping(config: PingConfig): PingStats = + withContext(Dispatchers.IO) { + val rtts = mutableListOf() + var received = 0 + + repeat(config.count) { i -> + val start = System.currentTimeMillis() + var socket: Socket? = null + + try { + socket = createSocket(config) + socket.soTimeout = config.timeoutMs + + val address = InetSocketAddress(config.targetHost, config.targetPort) + socket.connect(address, config.timeoutMs) + + val rtt = (System.currentTimeMillis() - start).toDouble() + rtts.add(rtt) + received++ + } catch (_: IOException) { + // timeout, refused, proxy issue so packet lost + } finally { + socket?.close() + } + + if (i < config.count - 1) { + kotlinx.coroutines.delay(config.delayBetweenPingsMs.milliseconds) + } + } + + val transmitted = config.count + val loss = + if (transmitted == 0) 0.0 else ((transmitted - received) * 100.0 / transmitted) + + if (rtts.isEmpty()) { + return@withContext PingStats().handleOffline() + } + + return@withContext PingStats( + transmitted = transmitted, + received = received, + packetLoss = loss, + rttMin = rtts.minOrNull() ?: 0.0, + rttAvg = rtts.average(), + rttMax = rtts.maxOrNull() ?: 0.0, + rttStddev = calculateStdDev(rtts), + isReachable = received > 0, + lastSuccessfulPingMillis = System.currentTimeMillis(), + ) + } + + private fun createSocket(config: PingConfig): Socket { + return when { + config.proxy?.type() == Proxy.Type.SOCKS && + config.proxyUsername != null && + config.proxyPassword != null -> { + val addr = config.proxy.address() as InetSocketAddress + Socks5SocketFactory( + proxyHost = addr.hostString, + proxyPort = addr.port, + username = config.proxyUsername, + password = config.proxyPassword, + ) + .createSocket() + } + + // no auth proxy + else -> { + config.proxy?.let { Socket(it) } ?: Socket() + } + } + } + + private fun calculateStdDev(values: List): Double { + if (values.size <= 1) return 0.0 + val mean = values.average() + val variance = values.sumOf { (it - mean).pow(2) } / (values.size - 1) + return sqrt(variance) + } +} diff --git a/pinger/src/main/java/com/zaneschepke/pinger/model/PingConfig.kt b/pinger/src/main/java/com/zaneschepke/pinger/model/PingConfig.kt new file mode 100644 index 000000000..b3a33e03c --- /dev/null +++ b/pinger/src/main/java/com/zaneschepke/pinger/model/PingConfig.kt @@ -0,0 +1,14 @@ +package com.zaneschepke.pinger.model + +import java.net.Proxy + +data class PingConfig( + val targetHost: String = "1.1.1.1", + val targetPort: Int = 443, + val count: Int = 4, + val timeoutMs: Int = 3000, + val proxy: Proxy? = null, + val proxyUsername: String? = null, + val proxyPassword: String? = null, + val delayBetweenPingsMs: Long = 200L, +) diff --git a/pinger/src/main/java/com/zaneschepke/pinger/model/PingStats.kt b/pinger/src/main/java/com/zaneschepke/pinger/model/PingStats.kt new file mode 100644 index 000000000..c61e9c00f --- /dev/null +++ b/pinger/src/main/java/com/zaneschepke/pinger/model/PingStats.kt @@ -0,0 +1,26 @@ +package com.zaneschepke.pinger.model + +data class PingStats( + var transmitted: Int = 0, + var received: Int = 0, + var packetLoss: Double = 0.0, // percentage + var rttMin: Double = 0.0, + var rttAvg: Double = 0.0, + var rttMax: Double = 0.0, + var rttStddev: Double = 0.0, + var isReachable: Boolean = false, + var lastSuccessfulPingMillis: Long? = null, +) { + fun handleOffline(): PingStats { + return copy( + transmitted = 0, + received = 0, + packetLoss = 0.0, + rttMin = 0.0, + rttAvg = 0.0, + rttMax = 0.0, + rttStddev = 0.0, + isReachable = false, + ) + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 0662c7bc3..f1e4f5960 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -7,7 +7,7 @@ pluginManagement { } } plugins { - id("org.gradle.toolchains.foojay-resolver-convention") version "0.10.0" + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" } dependencyResolutionManagement { @@ -25,3 +25,7 @@ rootProject.name = "WG Tunnel" include(":app") include(":logcatter") include(":networkmonitor") +include(":tunnel") +include(":hevtunnel") +include(":pinger") +include(":pinger") diff --git a/tunnel/.gitignore b/tunnel/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/tunnel/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/tunnel/build.gradle.kts b/tunnel/build.gradle.kts new file mode 100644 index 000000000..ff532c38c --- /dev/null +++ b/tunnel/build.gradle.kts @@ -0,0 +1,90 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.androidLibrary) +} + +android { + namespace = "com.zaneschepke.tunnel" + compileSdk { + version = release(Constants.TARGET_SDK) + } + + ndkVersion = Constants.NDK_VERSION + + defaultConfig { + minSdk = Constants.MIN_SDK + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + externalNativeBuild { + cmake { + path("tools/CMakeLists.txt") + } + } + + val basePackageName = namespace + + buildTypes { + all { + externalNativeBuild { + cmake { + targets("libam-go.so", "libam.so", "libam-quick.so") + arguments("-DGRADLE_USER_HOME=${project.gradle.gradleUserHomeDir}") + arguments("-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON") + } + } + } + + release { + externalNativeBuild { + cmake { + arguments("-DANDROID_PACKAGE_NAME=$basePackageName") + } + } + } + + debug { + externalNativeBuild { + cmake { + arguments("-DANDROID_PACKAGE_NAME=$basePackageName.debug") + } + } + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlin { compilerOptions { jvmTarget = JvmTarget.JVM_17 } } +} + +dependencies { + implementation(project(":hevtunnel")) + api(project(":pinger")) + implementation(project(":networkmonitor")) + + implementation(libs.androidx.lifecycle.service) + + implementation(libs.relinker) + + api(libs.amneziawg.parser) + implementation(libs.libsu) + + implementation(libs.timber) + + implementation(platform(libs.koin.bom)) + implementation(libs.koin.core) + implementation(libs.koin.android) + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + implementation(libs.material) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) +} \ No newline at end of file diff --git a/tunnel/consumer-rules.pro b/tunnel/consumer-rules.pro new file mode 100644 index 000000000..e69de29bb diff --git a/tunnel/proguard-rules.pro b/tunnel/proguard-rules.pro new file mode 100644 index 000000000..481bb4348 --- /dev/null +++ b/tunnel/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/tunnel/src/androidTest/java/com/zaneschepke/tunnel/ExampleInstrumentedTest.kt b/tunnel/src/androidTest/java/com/zaneschepke/tunnel/ExampleInstrumentedTest.kt new file mode 100644 index 000000000..dfd6c20ff --- /dev/null +++ b/tunnel/src/androidTest/java/com/zaneschepke/tunnel/ExampleInstrumentedTest.kt @@ -0,0 +1,22 @@ +package com.zaneschepke.tunnel + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.zaneschepke.tunnel.test", appContext.packageName) + } +} diff --git a/tunnel/src/main/AndroidManifest.xml b/tunnel/src/main/AndroidManifest.xml new file mode 100644 index 000000000..2bd0036cc --- /dev/null +++ b/tunnel/src/main/AndroidManifest.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tunnel/src/main/java/com/zaneschepke/tunnel/DnsConfigManager.kt b/tunnel/src/main/java/com/zaneschepke/tunnel/DnsConfigManager.kt new file mode 100644 index 000000000..4e1ffdb58 --- /dev/null +++ b/tunnel/src/main/java/com/zaneschepke/tunnel/DnsConfigManager.kt @@ -0,0 +1,48 @@ +package com.zaneschepke.tunnel + +import com.zaneschepke.tunnel.model.DnsBootstrapResult +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONObject + +internal object DnsConfigManager { + private external fun setDNSConfig(configJson: String) + + fun update(protocol: String, upstream: String) { + val config = + JSONObject().apply { + put("protocol", protocol) + put("upstream", upstream) + } + setDNSConfig(config.toString()) + } + + private external fun resolveBootstrap(host: String, bypass: Int): String + + suspend fun resolveHostBootstrap(host: String, bypass: Boolean): DnsBootstrapResult = + withContext(Dispatchers.IO) { + val raw = resolveBootstrap(host, if (bypass) 1 else 0) + + if (raw.startsWith("ERR|")) { + throw RuntimeException(raw.removePrefix("ERR|")) + } + + val parts = raw.split(";") + + val v4 = + parts + .firstOrNull { it.startsWith("v4=") } + ?.removePrefix("v4=") + ?.takeIf { it.isNotBlank() } + ?.split(",") ?: emptyList() + + val v6 = + parts + .firstOrNull { it.startsWith("v6=") } + ?.removePrefix("v6=") + ?.takeIf { it.isNotBlank() } + ?.split(",") ?: emptyList() + + DnsBootstrapResult(ipv4 = v4, ipv6 = v6) + } +} diff --git a/tunnel/src/main/java/com/zaneschepke/tunnel/NotificationProvider.kt b/tunnel/src/main/java/com/zaneschepke/tunnel/NotificationProvider.kt new file mode 100644 index 000000000..ad1f9c98c --- /dev/null +++ b/tunnel/src/main/java/com/zaneschepke/tunnel/NotificationProvider.kt @@ -0,0 +1,10 @@ +package com.zaneschepke.tunnel + +import android.app.Notification + +interface NotificationProvider { + val vpnInitNotification: Notification + val proxyInitNotification: Notification + val vpnNotificationId: Int + val proxyNotificationId: Int +} diff --git a/tunnel/src/main/java/com/zaneschepke/tunnel/ProxyBackend.kt b/tunnel/src/main/java/com/zaneschepke/tunnel/ProxyBackend.kt new file mode 100644 index 000000000..889d8b35e --- /dev/null +++ b/tunnel/src/main/java/com/zaneschepke/tunnel/ProxyBackend.kt @@ -0,0 +1,23 @@ +package com.zaneschepke.tunnel + +import com.zaneschepke.tunnel.backend.SocketProtector +import timber.log.Timber + +internal object ProxyBackend { + external fun awgStartProxy(ifName: String, config: String, uapiPath: String, bypass: Int): Int + + external fun awgUpdateProxyTunnelPeers(handle: Int, settings: String): Int + + external fun awgStopProxy() + + external fun awgTurnProxyTunnelOff(handle: Int) + + external fun awgGetProxyConfig(handle: Int): String + + fun setSocketProtector(sp: SocketProtector?) { + Timber.d("setSocketProtector called with ${if (sp != null) "protector" else "null"}") + awgSetSocketProtector(sp) + } + + private external fun awgSetSocketProtector(sp: SocketProtector?) +} diff --git a/tunnel/src/main/java/com/zaneschepke/tunnel/StatusCallback.kt b/tunnel/src/main/java/com/zaneschepke/tunnel/StatusCallback.kt new file mode 100644 index 000000000..fb2727644 --- /dev/null +++ b/tunnel/src/main/java/com/zaneschepke/tunnel/StatusCallback.kt @@ -0,0 +1,6 @@ +package com.zaneschepke.tunnel + +internal fun interface StatusCallback { + + fun onStatusChanged(handle: Int, statusCode: Int) +} diff --git a/tunnel/src/main/java/com/zaneschepke/tunnel/Tunnel.kt b/tunnel/src/main/java/com/zaneschepke/tunnel/Tunnel.kt new file mode 100644 index 000000000..1fc93fbf8 --- /dev/null +++ b/tunnel/src/main/java/com/zaneschepke/tunnel/Tunnel.kt @@ -0,0 +1,40 @@ +package com.zaneschepke.tunnel + +interface Tunnel { + val id: Int + val name: String + val isMetered: Boolean + val scriptsEnabled: Boolean + + val ipStrategy: IpStrategy + val features: Set + + fun updateState(state: State) + + sealed interface State { + sealed class Up : State { + data object Healthy : Up() + + data object HandshakeFailure : Up() + } + + data object Down : State + + data object Starting : State + } + + sealed interface IpStrategy { + data object Ipv4Only : IpStrategy + + data class PreferIpv6( + val fallbackToIpv4Enabled: Boolean = true, + val recoveryEnabled: Boolean = true, + ) : IpStrategy + } + + sealed interface Feature { + data object DynamicDNS : Feature + + data class ActiveConfigMonitor(val intervalSeconds: Int = 3) : Feature + } +} diff --git a/tunnel/src/main/java/com/zaneschepke/tunnel/TunnelLibraryInitializer.kt b/tunnel/src/main/java/com/zaneschepke/tunnel/TunnelLibraryInitializer.kt new file mode 100644 index 000000000..fb12e2202 --- /dev/null +++ b/tunnel/src/main/java/com/zaneschepke/tunnel/TunnelLibraryInitializer.kt @@ -0,0 +1,12 @@ +package com.zaneschepke.tunnel + +import android.content.Context +import com.getkeepsafe.relinker.ReLinker +import timber.log.Timber + +internal object TunnelLibraryInitializer { + fun ensureLoaded(context: Context) { + ReLinker.loadLibrary(context, "am-go") + Timber.d("Native library loaded") + } +} diff --git a/tunnel/src/main/java/com/zaneschepke/tunnel/VpnBackend.kt b/tunnel/src/main/java/com/zaneschepke/tunnel/VpnBackend.kt new file mode 100644 index 000000000..f1c4bc6ed --- /dev/null +++ b/tunnel/src/main/java/com/zaneschepke/tunnel/VpnBackend.kt @@ -0,0 +1,27 @@ +package com.zaneschepke.tunnel + +import timber.log.Timber + +internal object VpnBackend { + + fun setStatusCallback(callback: StatusCallback?) { + Timber.d("setStatusCallback called with ${if (callback != null) "callback" else "null"}") + awgSetStatusCallback(callback) + } + + private external fun awgSetStatusCallback(callback: StatusCallback?) + + external fun awgGetConfig(handle: Int): String? + + external fun awgGetSocketV4(handle: Int): Int + + external fun awgGetSocketV6(handle: Int): Int + + external fun awgTurnOff(handle: Int) + + external fun awgTurnOn(ifName: String, tunFd: Int, settings: String, uapiPath: String): Int + + external fun awgUpdateTunnelPeers(handle: Int, settings: String): Int + + external fun awgVersion(): String +} diff --git a/tunnel/src/main/java/com/zaneschepke/tunnel/backend/Backend.kt b/tunnel/src/main/java/com/zaneschepke/tunnel/backend/Backend.kt new file mode 100644 index 000000000..25f0d8f92 --- /dev/null +++ b/tunnel/src/main/java/com/zaneschepke/tunnel/backend/Backend.kt @@ -0,0 +1,37 @@ +package com.zaneschepke.tunnel.backend + +import com.zaneschepke.tunnel.NotificationProvider +import com.zaneschepke.tunnel.Tunnel +import com.zaneschepke.tunnel.event.TunnelEvent +import com.zaneschepke.tunnel.model.BackendMode +import com.zaneschepke.tunnel.model.DnsBoostrapMode +import com.zaneschepke.tunnel.model.KillSwitchConfig +import com.zaneschepke.tunnel.service.VpnService +import com.zaneschepke.tunnel.state.BackendStatus +import kotlin.reflect.KClass +import kotlinx.coroutines.flow.Flow + +interface Backend { + + val notificationProvider: NotificationProvider + + suspend fun start(tunnel: Tunnel, mode: BackendMode): Result + + fun setAlwaysOnCallback(alwaysOnCallback: VpnService.AlwaysOnCallback) + + suspend fun stop(id: Int): Result + + suspend fun setKillSwitch(config: KillSwitchConfig): Result + + suspend fun disableKillSwitch(): Result + + suspend fun setBootstrapDnsMode(mode: DnsBoostrapMode) + + suspend fun stopAllOfType(modeClass: KClass): Result + + suspend fun stopAllActiveTunnels(): Result + + val status: Flow + + val events: Flow +} diff --git a/tunnel/src/main/java/com/zaneschepke/tunnel/backend/DefaultEngineStateProvider.kt b/tunnel/src/main/java/com/zaneschepke/tunnel/backend/DefaultEngineStateProvider.kt new file mode 100644 index 000000000..af9d5dbbb --- /dev/null +++ b/tunnel/src/main/java/com/zaneschepke/tunnel/backend/DefaultEngineStateProvider.kt @@ -0,0 +1,16 @@ +package com.zaneschepke.tunnel.backend + +import com.zaneschepke.tunnel.state.EngineState +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +internal class DefaultEngineStateProvider : EngineStateProvider { + + private val _state = MutableStateFlow(EngineState()) + + override val state: Flow = _state + + fun update(transform: (EngineState) -> EngineState) { + _state.value = transform(_state.value) + } +} diff --git a/tunnel/src/main/java/com/zaneschepke/tunnel/backend/DynamicDnsController.kt b/tunnel/src/main/java/com/zaneschepke/tunnel/backend/DynamicDnsController.kt new file mode 100644 index 000000000..3c9d52d89 --- /dev/null +++ b/tunnel/src/main/java/com/zaneschepke/tunnel/backend/DynamicDnsController.kt @@ -0,0 +1,61 @@ +package com.zaneschepke.tunnel.backend + +import com.zaneschepke.tunnel.model.DnsBootstrapResult +import com.zaneschepke.tunnel.model.PublicKey + +class DynamicDnsController( + private val stabilityWindowMs: Long, + private val failureWindowMs: Long, + private val minResolveIntervalMs: Long, +) { + + private var lastStableHealthySinceMs = -1L + private var failureWindowStartMs = -1L + private var lastDnsResolveMs = 0L + private var lastCache = emptyMap() + + fun shouldResolve(now: Long, isHealthy: Boolean, isHandshakeFailure: Boolean): Boolean { + + if (isHealthy) { + lastStableHealthySinceMs = now + } + + if (isHandshakeFailure) { + if (failureWindowStartMs < 0) failureWindowStartMs = now + } else { + failureWindowStartMs = -1L + } + + val stableEnough = + lastStableHealthySinceMs > 0 && now - lastStableHealthySinceMs >= stabilityWindowMs + + val failureEnough = + failureWindowStartMs > 0 && now - failureWindowStartMs >= failureWindowMs + + val rateLimited = now - lastDnsResolveMs >= minResolveIntervalMs + + return stableEnough && failureEnough && rateLimited + } + + fun markResolved(now: Long) { + lastDnsResolveMs = now + } + + fun diff(resolved: Map): List { + + val changed = buildList { + for ((key, cache) in resolved) { + val old = lastCache[key] + if (old == null || old.ipv4 != cache.ipv4 || old.ipv6 != cache.ipv6) { + add(key) + } + } + } + + if (changed.isNotEmpty()) { + lastCache = resolved + } + + return changed + } +} diff --git a/tunnel/src/main/java/com/zaneschepke/tunnel/backend/EngineStateProvider.kt b/tunnel/src/main/java/com/zaneschepke/tunnel/backend/EngineStateProvider.kt new file mode 100644 index 000000000..d9458142f --- /dev/null +++ b/tunnel/src/main/java/com/zaneschepke/tunnel/backend/EngineStateProvider.kt @@ -0,0 +1,8 @@ +package com.zaneschepke.tunnel.backend + +import com.zaneschepke.tunnel.state.EngineState +import kotlinx.coroutines.flow.Flow + +internal interface EngineStateProvider { + val state: Flow +} diff --git a/tunnel/src/main/java/com/zaneschepke/tunnel/backend/KillSwitch.kt b/tunnel/src/main/java/com/zaneschepke/tunnel/backend/KillSwitch.kt new file mode 100644 index 000000000..b1baaafd7 --- /dev/null +++ b/tunnel/src/main/java/com/zaneschepke/tunnel/backend/KillSwitch.kt @@ -0,0 +1,11 @@ +package com.zaneschepke.tunnel.backend + +import com.zaneschepke.tunnel.model.KillSwitchConfig + +internal interface KillSwitch { + fun setKillSwitch(config: KillSwitchConfig?) + + fun startHevSocks5Bridge() + + fun stopHevSocks5Bridge() +} diff --git a/tunnel/src/main/java/com/zaneschepke/tunnel/backend/RootShell.kt b/tunnel/src/main/java/com/zaneschepke/tunnel/backend/RootShell.kt new file mode 100644 index 000000000..6dc27c515 --- /dev/null +++ b/tunnel/src/main/java/com/zaneschepke/tunnel/backend/RootShell.kt @@ -0,0 +1,34 @@ +package com.zaneschepke.tunnel.backend + +import com.topjohnwu.superuser.Shell +import com.zaneschepke.tunnel.model.ShellResult +import timber.log.Timber + +object RootShell { + + fun hasRootPermission(): Boolean { + return Shell.isAppGrantedRoot() == true + } + + fun requestRootPermission(): Boolean { + return try { + val result = Shell.cmd("true").exec() + result.isSuccess + } catch (e: Exception) { + false + } + } + + fun run(command: String): ShellResult { + try { + val result = Shell.cmd(command).exec() + + Timber.d("Root shell command result: ${result.out.joinToString("\n")}") + + return ShellResult(code = result.code, stdout = result.out, stderr = result.err) + } catch (e: Exception) { + Timber.e(e, "Root command failed") + throw e + } + } +} diff --git a/tunnel/src/main/java/com/zaneschepke/tunnel/backend/ServiceHolder.kt b/tunnel/src/main/java/com/zaneschepke/tunnel/backend/ServiceHolder.kt new file mode 100644 index 000000000..967d186b3 --- /dev/null +++ b/tunnel/src/main/java/com/zaneschepke/tunnel/backend/ServiceHolder.kt @@ -0,0 +1,164 @@ +package com.zaneschepke.tunnel.backend + +import android.content.Context +import android.content.Intent +import com.zaneschepke.tunnel.StatusCallback +import com.zaneschepke.tunnel.VpnBackend +import com.zaneschepke.tunnel.service.TunnelService +import com.zaneschepke.tunnel.service.VpnService +import com.zaneschepke.tunnel.state.NativeTunnelStatus +import com.zaneschepke.tunnel.util.BackendException +import java.lang.ref.WeakReference +import java.util.concurrent.CompletableFuture +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException +import kotlin.concurrent.atomics.AtomicBoolean +import kotlin.concurrent.atomics.ExperimentalAtomicApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import timber.log.Timber + +internal class ServiceHolder(private val context: Context) { + + internal val uapiPath = context.dataDir.absolutePath + + @OptIn(ExperimentalAtomicApi::class) + private val nativeCallbacksRegistered = AtomicBoolean(false) + + private val _nativeStatuses = MutableSharedFlow(extraBufferCapacity = 64) + + val nativeStatuses = _nativeStatuses.asSharedFlow() + + private val statusCallback = StatusCallback { handle, code -> + val status = NativeTunnelStatus.NativeTunnelStatusCode.from(code) + + if (status == null) { + Timber.d("Unknown native status code: $code") + return@StatusCallback + } + + Timber.d("Native Callback - Handle: $handle, Code: $status") + + _nativeStatuses.tryEmit(NativeTunnelStatus(handle = handle, code = status)) + } + + fun set(service: VpnService) { + vpnService.complete(service) + } + + fun set(service: TunnelService) { + tunnelService.complete(service) + } + + fun getVpnService(): VpnService { + + vpnService.getNow(null)?.let { + return it + } + + try { + if (android.net.VpnService.prepare(context) != null) { + throw BackendException.Unauthorized("Permission unavailable to use VpnService") + } + + context.startForegroundService(Intent(context, VpnService::class.java)) + } catch (e: Exception) { + Timber.e(e, "Error starting VPN service") + } + + return try { + vpnService.get(2, TimeUnit.SECONDS) + } catch (e: TimeoutException) { + Timber.e(e, "Timed out getting VpnService") + throw BackendException.InternalError("Failed to get VpnService") + } + } + + fun getTunnelService(): TunnelService { + + tunnelService.getNow(null)?.let { + return it + } + + try { + context.startForegroundService(Intent(context, TunnelService::class.java)) + } catch (e: Exception) { + Timber.e(e, "Error starting TunnelService") + } + + return try { + tunnelService.get(2, TimeUnit.SECONDS) + } catch (e: TimeoutException) { + Timber.e(e, "Timed out getting TunnelService") + throw BackendException.InternalError("Failed to get TunnelService") + } + } + + fun stopVpnService() { + val service = vpnService.getNow(null) ?: return + + Timber.d("Stopping VpnService") + + service.stopSelf() + } + + fun stopTunnelService() { + val service = tunnelService.getNow(null) ?: return + + Timber.d("Stopping TunnelService") + + service.stopSelf() + } + + @OptIn(ExperimentalAtomicApi::class) + fun ensureNativeCallbacksRegistered() { + if (!nativeCallbacksRegistered.compareAndSet(expectedValue = false, newValue = true)) { + return + } + + VpnBackend.setStatusCallback(statusCallback) + + Timber.d("Registered native status callback") + } + + @OptIn(ExperimentalAtomicApi::class) + fun maybeUnregisterNativeCallbacks() { + val vpnAlive = vpnService.getNow(null) != null + val tunnelAlive = tunnelService.getNow(null) != null + + if (vpnAlive || tunnelAlive) { + return + } + + if (!nativeCallbacksRegistered.compareAndSet(expectedValue = true, newValue = false)) { + return + } + + VpnBackend.setStatusCallback(null) + + Timber.d("Unregistered native status callback") + } + + fun clear(service: VpnService) { + if (vpnService.getNow(null) === service) { + vpnService = CompletableFuture() + } + + maybeUnregisterNativeCallbacks() + } + + fun clear(service: TunnelService) { + if (tunnelService.getNow(null) === service) { + tunnelService = CompletableFuture() + } + maybeUnregisterNativeCallbacks() + } + + companion object { + const val DEFAULT_MTU = 1280 + // for consumer to set AOVPN callback + var alwaysOnCallback: WeakReference? = null + @Volatile var vpnService = CompletableFuture() + @Volatile var tunnelService = CompletableFuture() + } +} diff --git a/tunnel/src/main/java/com/zaneschepke/tunnel/backend/SocketProtector.kt b/tunnel/src/main/java/com/zaneschepke/tunnel/backend/SocketProtector.kt new file mode 100644 index 000000000..f37597fa4 --- /dev/null +++ b/tunnel/src/main/java/com/zaneschepke/tunnel/backend/SocketProtector.kt @@ -0,0 +1,5 @@ +package com.zaneschepke.tunnel.backend + +internal interface SocketProtector { + fun bypass(fd: Int): Int +} diff --git a/tunnel/src/main/java/com/zaneschepke/tunnel/backend/TunnelActor.kt b/tunnel/src/main/java/com/zaneschepke/tunnel/backend/TunnelActor.kt new file mode 100644 index 000000000..26916ce2e --- /dev/null +++ b/tunnel/src/main/java/com/zaneschepke/tunnel/backend/TunnelActor.kt @@ -0,0 +1,691 @@ +package com.zaneschepke.tunnel.backend + +import com.zaneschepke.networkmonitor.StableNetworkEngine +import com.zaneschepke.tunnel.DnsConfigManager +import com.zaneschepke.tunnel.Tunnel +import com.zaneschepke.tunnel.event.ActorEvent +import com.zaneschepke.tunnel.event.ActorEvent.ActiveConfigUpdated +import com.zaneschepke.tunnel.event.ActorEvent.BootstrapStateChanged +import com.zaneschepke.tunnel.event.ActorEvent.EngineStatus +import com.zaneschepke.tunnel.event.ActorEvent.KillSwitchStateChanged +import com.zaneschepke.tunnel.event.ActorEvent.PeersUpdated +import com.zaneschepke.tunnel.event.ActorEvent.ResolvedPeersApplied +import com.zaneschepke.tunnel.event.ActorEvent.TunnelStarted +import com.zaneschepke.tunnel.event.ActorEvent.TunnelStopped +import com.zaneschepke.tunnel.event.TunnelEvent +import com.zaneschepke.tunnel.model.BackendMode +import com.zaneschepke.tunnel.model.DnsBootstrapResult +import com.zaneschepke.tunnel.model.PublicKey +import com.zaneschepke.tunnel.model.RunningTunnel +import com.zaneschepke.tunnel.model.TunnelCommand +import com.zaneschepke.tunnel.state.ActiveTunnel +import com.zaneschepke.tunnel.state.ActorState +import com.zaneschepke.tunnel.state.BootstrapState +import com.zaneschepke.tunnel.state.NativeTunnelStatus +import com.zaneschepke.tunnel.state.TunnelRuntimeState +import com.zaneschepke.tunnel.util.RootShellException +import com.zaneschepke.tunnel.util.buildResolvedPeers +import com.zaneschepke.tunnel.util.exponentialBackoffForever +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.supervisorScope +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import timber.log.Timber + +internal class TunnelActor( + private val scope: CoroutineScope, + private val engine: TunnelEngine, + private val stableNetworkEngine: StableNetworkEngine, +) { + + private val inbox = Channel(Channel.UNLIMITED) + + // track running hooks to prevent service shutdown until post down hooks complete + private val _runningPostDownHooks = MutableStateFlow(0) + val runningHooks = _runningPostDownHooks.asStateFlow() + + private val _state = + MutableStateFlow(ActorState(byTunnelId = emptyMap(), byHandle = emptyMap())) + + val state: StateFlow = _state.asStateFlow() + + private val _events = MutableSharedFlow(extraBufferCapacity = 32) + + val events = _events.asSharedFlow() + + private val tunnelJobs = mutableMapOf() + + init { + scope.launch { + engine.status.distinctUntilChanged().collect { status -> + when (status.code) { + NativeTunnelStatus.NativeTunnelStatusCode.STOPPED -> { + val tunnelId = _state.value.byHandle[status.handle] ?: return@collect + stopTunnel(tunnelId, status.handle) + } + + NativeTunnelStatus.NativeTunnelStatusCode.HEALTHY, + NativeTunnelStatus.NativeTunnelStatusCode.HANDSHAKE_FAILURE -> { + apply(EngineStatus(status)) + } + } + } + } + + scope.launch { + for (cmd in inbox) { + try { + when (cmd) { + is TunnelCommand.Start -> { + val mode = cmd.mode + + val result = engine.start(cmd.tunnel, cmd.mode) + apply(TunnelStarted(result, cmd)) + + val runtime = _state.value.byTunnelId[result.tunnelId] ?: continue + + val job = + startTunnelJobs( + tunnelId = result.tunnelId, + runtime = runtime, + removedPeerEndpoint = result.removedPeerEndpoint, + ) + + tunnelJobs[result.tunnelId] = job + + job.invokeOnCompletion { tunnelJobs.remove(result.tunnelId, job) } + } + + is TunnelCommand.Stop -> { + val runtime = _state.value.byTunnelId[cmd.tunnelId] ?: continue + + engine.stop(runtime.running.handle, runtime.running.mode) + } + + is TunnelCommand.UpdatePeers -> { + val runtime = _state.value.byTunnelId[cmd.tunnelId] ?: continue + val running = runtime.running + + val peers = running.buildResolvedPeers(preferIpv6 = cmd.preferIpv6) + + engine.updatePeers( + handle = running.handle, + mode = running.mode, + peers = peers, + ) + + apply( + PeersUpdated( + tunnelId = cmd.tunnelId, + peers = peers, + preferIpv6 = cmd.preferIpv6, + ) + ) + } + + is TunnelCommand.ApplyResolvedPeers -> { + val runtime = _state.value.byTunnelId[cmd.tunnelId] ?: continue + val running = runtime.running + + engine.updatePeers( + handle = running.handle, + mode = running.mode, + peers = cmd.peers, + ) + + apply( + ResolvedPeersApplied( + tunnelId = cmd.tunnelId, + cache = cmd.cache, + peers = cmd.peers, + ) + ) + } + + is TunnelCommand.UpdateActiveConfig -> { + val runtime = _state.value.byTunnelId[cmd.tunnelId] ?: continue + val running = runtime.running + + val activeConfig = engine.getActiveConfig(running.handle, running.mode) + + if (runtime.active.activeConfig == activeConfig) { + continue + } + + apply( + ActiveConfigUpdated( + tunnelId = cmd.tunnelId, + activeConfig = activeConfig, + ) + ) + } + + is TunnelCommand.SetBootstrapState -> { + apply( + BootstrapStateChanged( + tunnelId = cmd.tunnelId, + bootstrapState = cmd.state, + ) + ) + } + + is TunnelCommand.UpdateKillSwitch -> { + apply(KillSwitchStateChanged(cmd.enabled)) + } + + is TunnelCommand.RunHook -> { + val isPostDown = cmd.phase == TunnelCommand.RunHook.Phase.PostDown + + if (isPostDown) { + _runningPostDownHooks.update { it + 1 } + } + + try { + cmd.cmds?.forEach { cmd -> + withTimeout(3_000) { + withContext(Dispatchers.IO) { RootShell.run(cmd) } + } + } + } catch (t: Throwable) { + Timber.w(t, "Root shell commands failed") + if (t is RootShellException.NoRootAccess) { + _events.emit( + TunnelEvent.NoRootShellAccess(tunnelId = cmd.tunnelId) + ) + } + } finally { + if (isPostDown) { + _runningPostDownHooks.update { (it - 1).coerceAtLeast(0) } + } + } + } + } + } catch (t: Throwable) { + Timber.e(t, "Tunnel command failed: $cmd") + } + } + } + } + + suspend fun send(cmd: TunnelCommand) { + inbox.send(cmd) + } + + private fun apply(event: ActorEvent) { + _state.value = reduce(_state.value, event) + } + + private fun startTunnelJobs( + tunnelId: Int, + runtime: TunnelRuntimeState, + removedPeerEndpoint: Boolean, + ): Job { + return scope.launch { + supervisorScope { + val running = runtime.running + + if (!running.mode.config.peers.all { it.isStaticallyConfigured }) { + startDnsBootstrapJob(tunnelId) + } + + if (removedPeerEndpoint) { + when (val strategy = running.tunnel.ipStrategy) { + Tunnel.IpStrategy.Ipv4Only -> Unit + + is Tunnel.IpStrategy.PreferIpv6 -> { + if (strategy.recoveryEnabled || strategy.fallbackToIpv4Enabled) { + startIpv6Job(tunnelId, strategy) + } + } + } + } + + running.tunnel.features.forEach { feature -> + when (feature) { + is Tunnel.Feature.ActiveConfigMonitor -> { + startActiveConfigJob(tunnelId, feature.intervalSeconds) + } + + Tunnel.Feature.DynamicDNS -> { + startDynamicDnsJob(tunnelId) + } + } + } + + awaitCancellation() + } + } + } + + private fun stopTunnel(tunnelId: Int, handle: Int) { + tunnelJobs.remove(tunnelId)?.cancel() + apply(TunnelStopped(tunnelId, handle)) + } + + private fun CoroutineScope.startDnsBootstrapJob(tunnelId: Int) = launch { + send(TunnelCommand.SetBootstrapState(tunnelId, BootstrapState.ResolvingDns)) + + val runtime = state.value.byTunnelId[tunnelId] ?: return@launch + + val running = runtime.running + + val cache = resolvePeers(running) + ensureActive() + + val updatedRunning = running.copy(peerBootstrapCache = cache) + + val networkHasIpv6 = stableNetworkEngine.stableState.value?.state?.hasIpv6 ?: false + + val peers = + updatedRunning.buildResolvedPeers( + preferIpv6 = running.currentPreferIpv6 && networkHasIpv6 + ) + + send(TunnelCommand.ApplyResolvedPeers(tunnelId = tunnelId, cache = cache, peers = peers)) + send(TunnelCommand.SetBootstrapState(tunnelId, BootstrapState.Complete)) + } + + private fun CoroutineScope.startDynamicDnsJob(tunnelId: Int) = launch { + val controller = + DynamicDnsController( + stabilityWindowMs = DDNS_STABILITY_WINDOW, + failureWindowMs = DDNS_FAILURE_WINDOW, + minResolveIntervalMs = DDNS_MIN_RESOLVE_INTERVAL, + ) + + combine( + stableNetworkEngine.stableState.filterNotNull(), + state.mapNotNull { it.byTunnelId[tunnelId]?.active }, + ) { stable, activeTunnel -> + stable to activeTunnel + } + .collect { (stable, activeTunnel) -> + val runtime = state.value.byTunnelId[tunnelId] ?: return@collect + + if (!stable.state.hasInternet()) return@collect + + val now = System.currentTimeMillis() + + val shouldResolve = + controller.shouldResolve( + now = now, + isHealthy = activeTunnel.transportState is Tunnel.State.Up.Healthy, + isHandshakeFailure = + activeTunnel.transportState is Tunnel.State.Up.HandshakeFailure, + ) + + if (!shouldResolve) return@collect + + val resolved = resolvePeers(runtime.running) + ensureActive() + + val changed = controller.diff(resolved) + if (changed.isEmpty()) return@collect + + controller.markResolved(now) + + _events.emit( + TunnelEvent.DynamicDnsUpdate(tunnelId = tunnelId, changedPeers = changed) + ) + + val latestRuntime = state.value.byTunnelId[tunnelId] ?: return@collect + + send( + TunnelCommand.ApplyResolvedPeers( + tunnelId = tunnelId, + cache = resolved, + peers = + latestRuntime.running + .copy(peerBootstrapCache = resolved) + .buildResolvedPeers( + preferIpv6 = latestRuntime.running.currentPreferIpv6 + ), + ) + ) + } + } + + private fun CoroutineScope.startIpv6Job(tunnelId: Int, strategy: Tunnel.IpStrategy.PreferIpv6) = + launch { + var currentNetworkKey: String? = null + var hasRecoveredOnThisNetwork = false + var hasFallenBackOnThisNetwork = false + var healthySinceMs: Long? = null + var failureCount = 0 + var firstFailureTime = 0L + var ipv6Bad = false + + combine( + stableNetworkEngine.stableState.filterNotNull(), + state.mapNotNull { it.byTunnelId[tunnelId]?.active }, + ) { stable, activeTunnel -> + val newKey = stable.key + + if (newKey != currentNetworkKey) { + currentNetworkKey = newKey + hasRecoveredOnThisNetwork = false + hasFallenBackOnThisNetwork = false + healthySinceMs = null + failureCount = 0 + firstFailureTime = 0L + ipv6Bad = false + + Timber.d("Stable network changed resetting IPv6 state ($newKey)") + } + + val now = System.currentTimeMillis() + + val isUsingIpv6 = + activeTunnel.activeConfig?.peers?.any { + it.endpoint?.startsWith("[") == true + } ?: false + + val isHealthy = activeTunnel.transportState is Tunnel.State.Up.Healthy + val isHandshakeFailure = + activeTunnel.transportState is Tunnel.State.Up.HandshakeFailure + + healthySinceMs = if (isHealthy) healthySinceMs ?: now else null + val healthyDuration = healthySinceMs?.let { now - it } ?: 0L + + Timber.d( + "IPv6 strategy | net=$newKey | usingIPv6=$isUsingIpv6 | healthy=$isHealthy | healthyDuration=${healthyDuration}ms | hasRecovered=$hasRecoveredOnThisNetwork | hasFallback=$hasFallenBackOnThisNetwork | hasIPv6=${stable.state.hasIpv6} | ipv6Bad=$ipv6Bad | state=${activeTunnel.transportState}" + ) + + if (!isHandshakeFailure) { + failureCount = 0 + firstFailureTime = 0L + } + + // Fallback IPv6 to IPv4 + if ( + strategy.fallbackToIpv4Enabled && + isHandshakeFailure && + isUsingIpv6 && + !hasFallenBackOnThisNetwork + ) { + + if (failureCount == 0) firstFailureTime = now + failureCount++ + + val failureDuration = now - firstFailureTime + + Timber.d( + "IPv6 strategy | Fallback check: failureCount=$failureCount duration=${failureDuration}ms" + ) + + if ( + failureCount >= IPV4_FALLBACK_FAILURE_COUNT && + failureDuration >= IPV4_FALLBACK_FAILURE_DURATION + ) { + + hasFallenBackOnThisNetwork = true + ipv6Bad = true + + Timber.d("Fallback to IPv4 triggered on $newKey (marking IPv6 bad)") + + _events.emit(TunnelEvent.FallbackToIpv4(tunnelId)) + + send(TunnelCommand.UpdatePeers(tunnelId, preferIpv6 = false)) + } + } + + // Recovery IPv4 to IPv6 + if ( + strategy.recoveryEnabled && + !isUsingIpv6 && + !hasRecoveredOnThisNetwork && + healthySinceMs != null && + stable.state.hasIpv6 && + !ipv6Bad + ) { + + Timber.d( + "IPv6 strategy | Recovery check: healthy for ${healthyDuration}ms (need >= ${RECOVERY_STABILITY_WINDOW}ms)" + ) + + if (healthyDuration >= RECOVERY_STABILITY_WINDOW) { + hasRecoveredOnThisNetwork = true + + Timber.d( + "Recovered to IPv6 on $newKey (healthy for ${healthyDuration}ms)" + ) + + _events.emit(TunnelEvent.RecoveredToIpv6(tunnelId)) + + send(TunnelCommand.UpdatePeers(tunnelId, preferIpv6 = true)) + } + } + } + .collect {} + } + + private fun CoroutineScope.startActiveConfigJob(tunnelId: Int, interval: Int) = launch { + while (isActive) { + send(TunnelCommand.UpdateActiveConfig(tunnelId)) + delay(interval.seconds) + } + } + + private fun reduce(state: ActorState, event: ActorEvent): ActorState { + return when (event) { + is EngineStatus -> { + val tunnelId = state.byHandle[event.status.handle] ?: return state + val runtime = state.byTunnelId[tunnelId] ?: return state + + val newTransportState = + when (event.status.code) { + NativeTunnelStatus.NativeTunnelStatusCode.HEALTHY -> Tunnel.State.Up.Healthy + + NativeTunnelStatus.NativeTunnelStatusCode.HANDSHAKE_FAILURE -> + Tunnel.State.Up.HandshakeFailure + + NativeTunnelStatus.NativeTunnelStatusCode.STOPPED -> + return state // should never happen + } + + val now = System.currentTimeMillis() + + val updatedActive = + runtime.active.copy( + transportState = newTransportState, + lastStateChangeMs = now, + lastHealthChangeMs = + if (newTransportState is Tunnel.State.Up.Healthy) { + now + } else { + runtime.active.lastHealthChangeMs + }, + ) + + state.copy( + byTunnelId = + state.byTunnelId + (tunnelId to runtime.copy(active = updatedActive)) + ) + } + is TunnelStarted -> { + val result = event.result + val cmd = event.cmd + + val running = + RunningTunnel( + handle = result.handle, + interfaceName = result.interfaceName, + mode = result.mode, + tunnel = cmd.tunnel, + currentPreferIpv6 = cmd.tunnel.ipStrategy is Tunnel.IpStrategy.PreferIpv6, + ) + + val runtime = + TunnelRuntimeState( + running = running, + active = + ActiveTunnel( + transportState = Tunnel.State.Starting, + interfaceName = result.interfaceName, + mode = result.mode, + uptime = System.currentTimeMillis(), + activeConfig = null, + ), + ) + + state.copy( + byTunnelId = state.byTunnelId + (result.tunnelId to runtime), + byHandle = state.byHandle + (result.handle to result.tunnelId), + ) + } + + is TunnelStopped -> { + state.copy( + byTunnelId = state.byTunnelId - event.tunnelId, + byHandle = state.byHandle - event.handle, + ) + } + is PeersUpdated -> { + val runtime = state.byTunnelId[event.tunnelId] ?: return state + + val now = System.currentTimeMillis() + + val updatedActive = runtime.active.copy(lastPeerUpdateMs = now) + + val updatedRunning = + runtime.running.copy( + currentPreferIpv6 = event.preferIpv6, + resolvedPeers = event.peers, + ) + + state.copy( + byTunnelId = + state.byTunnelId + + (event.tunnelId to + runtime.copy(running = updatedRunning, active = updatedActive)) + ) + } + is ResolvedPeersApplied -> { + val runtime = state.byTunnelId[event.tunnelId] ?: return state + val running = runtime.running + + val now = System.currentTimeMillis() + + val updatedActive = runtime.active.copy(lastPeerUpdateMs = now) + + val updatedRunning = + running.copy(resolvedPeers = event.peers, peerBootstrapCache = event.cache) + + state.copy( + byTunnelId = + state.byTunnelId + + (event.tunnelId to + runtime.copy(running = updatedRunning, active = updatedActive)) + ) + } + + is ActiveConfigUpdated -> { + val runtime = state.byTunnelId[event.tunnelId] ?: return state + + val updated = + runtime.copy(active = runtime.active.copy(activeConfig = event.activeConfig)) + + state.copy(byTunnelId = state.byTunnelId + (event.tunnelId to updated)) + } + is BootstrapStateChanged -> { + val runtime = state.byTunnelId[event.tunnelId] ?: return state + + val updated = + runtime.copy( + active = runtime.active.copy(bootstrapState = event.bootstrapState) + ) + + state.copy(byTunnelId = state.byTunnelId + (event.tunnelId to updated)) + } + + is KillSwitchStateChanged -> { + state.copy(killSwitchEnabled = event.enabled) + } + } + } + + suspend fun resolvePeers(runningTunnel: RunningTunnel): Map { + + val peersToResolve = runningTunnel.mode.config.peers.filter { !it.isStaticallyConfigured } + + if (peersToResolve.isEmpty()) return emptyMap() + + val results = mutableMapOf() + + exponentialBackoffForever { + val bypassNeeded = + runningTunnel.mode is BackendMode.Vpn || state.value.killSwitchEnabled + + Timber.d("Peer resolution attempt (resolved=${results.size}/${peersToResolve.size})") + + for (peer in peersToResolve) { + + // already resolved + if (results.containsKey(peer.publicKey)) continue + + val endpoint = peer.endpoint ?: continue + val host = endpoint.substringBeforeLast(":") + + val dnsResult = + try { + DnsConfigManager.resolveHostBootstrap(host = host, bypass = bypassNeeded) + } catch (e: Exception) { + Timber.w(e, "DNS failed for $host") + continue + } + + if (dnsResult.ipv4.isEmpty() && dnsResult.ipv6.isEmpty()) { + Timber.w("No IPs for $host") + continue + } + + results[peer.publicKey] = + dnsResult.copy( + ipv4 = dnsResult.ipv4, + // normalize + ipv6 = dnsResult.ipv6.map { "[$it]" }, + ) + + Timber.d("Resolved $host to ${results[peer.publicKey]}") + } + + // exit + if (results.size == peersToResolve.size) { + return@exponentialBackoffForever + } + + // force retry + throw IllegalStateException("Incomplete resolution, retrying...") + } + + return results + } + + companion object { + private const val DDNS_MIN_RESOLVE_INTERVAL = 30_000L + private const val DDNS_FAILURE_WINDOW = 10_000L + private const val DDNS_STABILITY_WINDOW = 15_000L + private const val IPV4_FALLBACK_FAILURE_COUNT = 4 + private const val IPV4_FALLBACK_FAILURE_DURATION = 10_000L + private const val RECOVERY_STABILITY_WINDOW = 5_000L + } +} diff --git a/tunnel/src/main/java/com/zaneschepke/tunnel/backend/TunnelBackend.kt b/tunnel/src/main/java/com/zaneschepke/tunnel/backend/TunnelBackend.kt new file mode 100644 index 000000000..b5d4effb5 --- /dev/null +++ b/tunnel/src/main/java/com/zaneschepke/tunnel/backend/TunnelBackend.kt @@ -0,0 +1,271 @@ +package com.zaneschepke.tunnel.backend + +import com.zaneschepke.networkmonitor.NetworkMonitor +import com.zaneschepke.networkmonitor.PrivateDnsMode +import com.zaneschepke.tunnel.DnsConfigManager +import com.zaneschepke.tunnel.NotificationProvider +import com.zaneschepke.tunnel.Tunnel +import com.zaneschepke.tunnel.event.TunnelEvent +import com.zaneschepke.tunnel.model.BackendMode +import com.zaneschepke.tunnel.model.DnsBoostrapConfig +import com.zaneschepke.tunnel.model.DnsBoostrapMode +import com.zaneschepke.tunnel.model.KillSwitchConfig +import com.zaneschepke.tunnel.model.TunnelCommand +import com.zaneschepke.tunnel.service.VpnService +import com.zaneschepke.tunnel.state.BackendStatus +import com.zaneschepke.tunnel.state.KillSwitchState +import com.zaneschepke.tunnel.util.BackendException +import java.lang.ref.WeakReference +import kotlin.reflect.KClass +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.koin.java.KoinJavaComponent.inject +import timber.log.Timber + +class TunnelBackend( + private val scope: CoroutineScope, + private val networkMonitor: NetworkMonitor, + override val notificationProvider: NotificationProvider, +) : Backend { + + private val serviceHolder: ServiceHolder by inject(ServiceHolder::class.java) + private val actor: TunnelActor by inject(TunnelActor::class.java) + + private val _status = MutableStateFlow(BackendStatus()) + override val status: Flow = _status.asStateFlow() + + private val _events = MutableSharedFlow(extraBufferCapacity = 32) + override val events = _events.asSharedFlow() + + private var dnsConfigJob: Job? = null + + init { + scope.launch { + var hadVpnTunnels = false + var hadProxyTunnels = false + + actor.state.collect { actorState -> + val hasVpnNow = + actorState.byTunnelId.values.any { it.running.mode is BackendMode.Vpn } + + val hasProxyNow = + actorState.byTunnelId.values.any { it.running.mode is BackendMode.Proxy } + + val activeTunnels = actorState.byTunnelId.mapValues { it.value.active } + + _status.update { current -> current.copy(activeTunnels = activeTunnels) } + + // VPN cleanup + if (hadVpnTunnels && !hasVpnNow) { + + ensureActive() + actor.runningHooks.first { it == 0 } + + val latestState = actor.state.value + val stillHasVpn = + latestState.byTunnelId.values.any { it.running.mode is BackendMode.Vpn } + + if (!shouldKeepVpnServiceAlive(stillHasVpn)) { + Timber.d("Stopping VPN service after hooks completed") + serviceHolder.stopVpnService() + } else { + Timber.d("VPN shutdown aborted — state changed during hook wait") + } + } + + // Proxy cleanup + if (hadProxyTunnels && !hasProxyNow) { + + ensureActive() + actor.runningHooks.first { it == 0 } + + val latestState = actor.state.value + val stillHasProxy = + latestState.byTunnelId.values.any { it.running.mode is BackendMode.Proxy } + + if (!stillHasProxy) { + Timber.d("Stopping tunnel service after hooks completed") + serviceHolder.stopTunnelService() + } else { + Timber.d("Proxy shutdown aborted — state changed during hook wait") + } + } + + hadVpnTunnels = hasVpnNow + hadProxyTunnels = hasProxyNow + } + } + } + + private fun shouldKeepVpnServiceAlive(hasVpnTunnels: Boolean): Boolean { + return hasVpnTunnels || _status.value.killSwitch.enabled + } + + override suspend fun start(tunnel: Tunnel, mode: BackendMode): Result = runCatching { + val existing = actor.state.value.byTunnelId[tunnel.id] + + if (existing != null) { + Timber.d("Tunnel ${tunnel.id} already running — ignoring start") + return@runCatching + } + + val scriptsEnabled = tunnel.scriptsEnabled + + val preUp = mode.config.`interface`.preUp + if (!preUp.isNullOrEmpty() && scriptsEnabled) { + actor.send(TunnelCommand.RunHook(tunnel.id, TunnelCommand.RunHook.Phase.PreUp, preUp)) + } + actor.send(TunnelCommand.Start(tunnel, mode)) + + val postUp = mode.config.`interface`.postUp + if (!postUp.isNullOrEmpty() && scriptsEnabled) { + actor.send(TunnelCommand.RunHook(tunnel.id, TunnelCommand.RunHook.Phase.PostUp, postUp)) + } + } + + override fun setAlwaysOnCallback(alwaysOnCallback: VpnService.AlwaysOnCallback) { + ServiceHolder.alwaysOnCallback = WeakReference(alwaysOnCallback) + } + + override suspend fun stop(id: Int): Result = runCatching { + val runtime = + actor.state.value.byTunnelId[id] + ?: throw BackendException.StateConflict( + "Tunnel $id is not active or no longer exists" + ) + + val scriptsEnabled = runtime.running.tunnel.scriptsEnabled + val mode = runtime.running.mode + + val preDown = mode.config.`interface`.preDown + if (!preDown.isNullOrEmpty() && scriptsEnabled) { + actor.send(TunnelCommand.RunHook(id, TunnelCommand.RunHook.Phase.PreDown, preDown)) + } + actor.send(TunnelCommand.Stop(id)) + + val postDown = mode.config.`interface`.postDown + if (!postDown.isNullOrEmpty() && scriptsEnabled) { + actor.send(TunnelCommand.RunHook(id, TunnelCommand.RunHook.Phase.PostDown, postDown)) + } + } + + override suspend fun setKillSwitch(config: KillSwitchConfig) = runCatching { + val service = serviceHolder.getVpnService() + service.setKillSwitch(config) + + actor.send(TunnelCommand.UpdateKillSwitch(true)) + + _status.update { current -> + current.copy(killSwitch = current.killSwitch.copy(enabled = true, config = config)) + } + } + + override suspend fun disableKillSwitch() = runCatching { + val service = serviceHolder.getVpnService() + service.setKillSwitch(null) + + actor.send(TunnelCommand.UpdateKillSwitch(false)) + + _status.update { current -> + current.copy( + killSwitch = + KillSwitchState( + enabled = false, + config = null, + primaryTunnel = current.killSwitch.primaryTunnel, + ) + ) + } + } + + override suspend fun setBootstrapDnsMode(mode: DnsBoostrapMode) { + _status.update { it.copy(dnsMode = mode) } + + when (mode) { + is DnsBoostrapMode.Custom -> { + Timber.d("DNS Boostrap mode set to custom, disabling system dns monitoring") + dnsConfigJob?.cancel() + dnsConfigJob = null + + DnsConfigManager.update( + mode.config.protocol, + mode.config.upstream ?: DnsBoostrapConfig.DEFAULT_UPSTREAM, + ) + } + + DnsBoostrapMode.System -> { + startSystemDnsMonitoring() + } + } + } + + override suspend fun stopAllOfType(modeClass: KClass): Result = + runCatching { + val idsToStop = + _status.value.activeTunnels + .filter { (_, activeTunnel) -> modeClass.isInstance(activeTunnel.mode) } + .keys + + idsToStop.forEach { id -> stop(id) } + } + + override suspend fun stopAllActiveTunnels(): Result = runCatching { + _status.value.activeTunnels.forEach { (id, _) -> stop(id) } + } + + private fun startSystemDnsMonitoring() { + if (dnsConfigJob?.isActive == true) return + + dnsConfigJob = scope.launch { + networkMonitor.connectivityStateFlow + .distinctUntilChangedBy { it.underlyingDnsInfo } + .collect { state -> + val dns = state.underlyingDnsInfo + + val config = + when (dns.privateDnsMode) { + PrivateDnsMode.OFF -> { + DnsBoostrapConfig.Plain( + dns.servers.firstOrNull() ?: DnsBoostrapConfig.DEFAULT_UPSTREAM + ) + } + + PrivateDnsMode.AUTOMATIC -> { + dns.privateDnsHostname + ?.takeIf { it.isNotBlank() } + ?.let { DnsBoostrapConfig.DoT(it) } + ?: DnsBoostrapConfig.Plain( + dns.servers.firstOrNull() + ?: DnsBoostrapConfig.DEFAULT_UPSTREAM + ) + } + + PrivateDnsMode.HOSTNAME -> { + dns.privateDnsHostname + ?.takeIf { it.isNotBlank() } + ?.let { DnsBoostrapConfig.DoT(it) } + ?: DnsBoostrapConfig.Plain( + dns.servers.firstOrNull() + ?: DnsBoostrapConfig.DEFAULT_UPSTREAM + ) + } + } + + DnsConfigManager.update( + config.protocol, + config.upstream ?: DnsBoostrapConfig.DEFAULT_UPSTREAM, + ) + } + } + } +} diff --git a/tunnel/src/main/java/com/zaneschepke/tunnel/backend/TunnelEngine.kt b/tunnel/src/main/java/com/zaneschepke/tunnel/backend/TunnelEngine.kt new file mode 100644 index 000000000..22715a061 --- /dev/null +++ b/tunnel/src/main/java/com/zaneschepke/tunnel/backend/TunnelEngine.kt @@ -0,0 +1,24 @@ +package com.zaneschepke.tunnel.backend + +import com.zaneschepke.tunnel.Tunnel +import com.zaneschepke.tunnel.model.BackendMode +import com.zaneschepke.tunnel.state.EngineStartResult +import com.zaneschepke.tunnel.state.EngineState +import com.zaneschepke.tunnel.state.NativeTunnelStatus +import com.zaneschepke.wireguardautotunnel.parser.ActiveConfig +import com.zaneschepke.wireguardautotunnel.parser.PeerSection +import kotlinx.coroutines.flow.Flow + +internal interface TunnelEngine { + + val status: Flow + val state: Flow + + suspend fun start(tunnel: Tunnel, mode: BackendMode): EngineStartResult + + suspend fun stop(handle: Int, mode: BackendMode) + + suspend fun updatePeers(handle: Int, mode: BackendMode, peers: List) + + suspend fun getActiveConfig(handle: Int, mode: BackendMode): ActiveConfig? +} diff --git a/tunnel/src/main/java/com/zaneschepke/tunnel/backend/TunnelEventBus.kt b/tunnel/src/main/java/com/zaneschepke/tunnel/backend/TunnelEventBus.kt new file mode 100644 index 000000000..4d45cce39 --- /dev/null +++ b/tunnel/src/main/java/com/zaneschepke/tunnel/backend/TunnelEventBus.kt @@ -0,0 +1,28 @@ +package com.zaneschepke.tunnel.backend + +import com.zaneschepke.tunnel.StatusCallback +import com.zaneschepke.tunnel.VpnBackend +import com.zaneschepke.tunnel.state.NativeTunnelStatus +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.receiveAsFlow + +object TunnelEventBus { + + private val channel = Channel(Channel.BUFFERED) + val flow = channel.receiveAsFlow() + + private val callback = StatusCallback { handle, code -> + val status = NativeTunnelStatus.NativeTunnelStatusCode.from(code) ?: return@StatusCallback + + channel.trySend(NativeTunnelStatus(handle = handle, code = status)) + } + + fun start() { + VpnBackend.setStatusCallback(callback) + } + + fun stop() { + VpnBackend.setStatusCallback(null) + channel.close() + } +} diff --git a/tunnel/src/main/java/com/zaneschepke/tunnel/backend/WireGuardTunnelEngine.kt b/tunnel/src/main/java/com/zaneschepke/tunnel/backend/WireGuardTunnelEngine.kt new file mode 100644 index 000000000..060ac55b6 --- /dev/null +++ b/tunnel/src/main/java/com/zaneschepke/tunnel/backend/WireGuardTunnelEngine.kt @@ -0,0 +1,200 @@ +package com.zaneschepke.tunnel.backend + +import com.zaneschepke.tunnel.ProxyBackend +import com.zaneschepke.tunnel.Tunnel +import com.zaneschepke.tunnel.VpnBackend +import com.zaneschepke.tunnel.model.BackendMode +import com.zaneschepke.tunnel.model.ProxyConfig +import com.zaneschepke.tunnel.state.EngineStartResult +import com.zaneschepke.tunnel.state.EngineState +import com.zaneschepke.tunnel.state.NativeTunnelStatus +import com.zaneschepke.tunnel.util.BackendException +import com.zaneschepke.wireguardautotunnel.parser.ActiveConfig +import com.zaneschepke.wireguardautotunnel.parser.Config +import com.zaneschepke.wireguardautotunnel.parser.PeerSection +import java.io.IOException +import java.net.ServerSocket +import java.util.UUID +import kotlinx.coroutines.flow.Flow + +internal class WireGuardTunnelEngine( + private val serviceHolder: ServiceHolder, + stateProvider: EngineStateProvider, +) : TunnelEngine { + + private val proxyPass = UUID.randomUUID().toString() + + override val status: Flow = serviceHolder.nativeStatuses + + override val state: Flow = stateProvider.state + + override suspend fun start(tunnel: Tunnel, mode: BackendMode): EngineStartResult { + + val ifName = WGT_INTERFACE_PREFIX + tunnel.id + + val (config, removedPeerEndpoint) = buildConfig(mode) + + val handle = + when (mode) { + is BackendMode.Proxy.KillSwitchPrimary -> { + val proxyConfig = buildBridgeProxyConfig() + startProxyTunnel(ifName, config, proxyConfig, true) + } + is BackendMode.Proxy.Standard -> { + val proxyConfig = mode.proxyConfig + startProxyTunnel(ifName, config, proxyConfig, false) + } + is BackendMode.Vpn -> { + startVpnTunnel(tunnel, ifName, config) + } + } + + if (handle < 0) { + throw BackendException.InternalError("Native start failed: $handle") + } + + return EngineStartResult( + tunnelId = tunnel.id, + handle = handle, + interfaceName = ifName, + mode = mode, + removedPeerEndpoint = removedPeerEndpoint, + ) + } + + private fun buildConfig(mode: BackendMode): Pair { + var removedPeerEndpoint = false + return mode.config.copy( + peers = + mode.config.peers.map { peer -> + if (!peer.isStaticallyConfigured) { + removedPeerEndpoint = true + rewriteDynamicEndpoint(peer) + } else peer + } + ) to removedPeerEndpoint + } + + private fun buildBridgeProxyConfig(): ProxyConfig { + return ProxyConfig( + socks5 = + ProxyConfig.Socks5( + port = getAvailablePort(), + username = LOCKDOWN_USER, + password = proxyPass, + ) + ) + } + + override suspend fun updatePeers(handle: Int, mode: BackendMode, peers: List) { + val config = mode.config.copy(peers = peers) + + when (mode) { + is BackendMode.Proxy -> { + ProxyBackend.awgUpdateProxyTunnelPeers(handle, config.asQuickString()) + } + is BackendMode.Vpn -> { + VpnBackend.awgUpdateTunnelPeers(handle, config.asQuickString()) + } + } + } + + override suspend fun getActiveConfig(handle: Int, mode: BackendMode): ActiveConfig? { + val rawConfig = + when (mode) { + is BackendMode.Proxy -> ProxyBackend.awgGetProxyConfig(handle) + is BackendMode.Vpn -> VpnBackend.awgGetConfig(handle) + } + return rawConfig?.let { ActiveConfig.parseFromIpc(it) } + } + + @Throws(IOException::class) + private fun getAvailablePort(): Int { + ServerSocket(0).use { socket -> + socket.setReuseAddress(true) + return socket.getLocalPort() + } + } + + // omit peer endpoint while boostrapping + private fun rewriteDynamicEndpoint(peer: PeerSection): PeerSection { + return peer.copy(endpoint = null) + } + + override suspend fun stop(handle: Int, mode: BackendMode) { + when (mode) { + is BackendMode.Proxy -> { + ProxyBackend.awgTurnProxyTunnelOff(handle) + } + is BackendMode.Vpn -> { + VpnBackend.awgTurnOff(handle) + } + } + } + + private fun startVpnTunnel(tunnel: Tunnel, ifName: String, config: Config): Int { + + val service = serviceHolder.getVpnService() + + val fd = + service.createTunInterface(tunnel, config)?.detachFd() + ?: throw BackendException.Unauthorized("Failed to create tun interface") + + val handle = + VpnBackend.awgTurnOn(ifName, fd, config.asQuickString(), serviceHolder.uapiPath) + + if (handle < 0) { + throw BackendException.InternalError("Internal native error with code: $handle") + } + + service.protect(VpnBackend.awgGetSocketV4(handle)) + service.protect(VpnBackend.awgGetSocketV6(handle)) + + return handle + } + + private fun startProxyTunnel( + ifName: String, + config: Config, + proxyConfig: ProxyConfig, + withBridge: Boolean, + ): Int { + + val quickConfig = buildProxiedQuickString(config, proxyConfig) + + if (!withBridge) { + serviceHolder.getTunnelService() + } + + val handle = + ProxyBackend.awgStartProxy( + ifName, + quickConfig, + serviceHolder.uapiPath, + if (withBridge) 1 else 0, + ) + + if (handle < 0) { + throw BackendException.InternalError("Internal native error") + } + + if (withBridge) { + serviceHolder.getVpnService().startHevSocks5Bridge() + } + + return handle + } + + private fun buildProxiedQuickString(config: Config, proxyConfig: ProxyConfig): String { + return buildString { + append(config.asQuickString()) + append(System.lineSeparator()) + append(proxyConfig.toQuickString()) + } + } + + companion object { + const val LOCKDOWN_USER = "local" + const val WGT_INTERFACE_PREFIX = "wgtun" + } +} diff --git a/tunnel/src/main/java/com/zaneschepke/tunnel/di/TunnelModule.kt b/tunnel/src/main/java/com/zaneschepke/tunnel/di/TunnelModule.kt new file mode 100644 index 000000000..978dabf2e --- /dev/null +++ b/tunnel/src/main/java/com/zaneschepke/tunnel/di/TunnelModule.kt @@ -0,0 +1,34 @@ +package com.zaneschepke.tunnel.di + +import com.zaneschepke.tunnel.TunnelLibraryInitializer +import com.zaneschepke.tunnel.backend.Backend +import com.zaneschepke.tunnel.backend.DefaultEngineStateProvider +import com.zaneschepke.tunnel.backend.EngineStateProvider +import com.zaneschepke.tunnel.backend.ServiceHolder +import com.zaneschepke.tunnel.backend.TunnelActor +import com.zaneschepke.tunnel.backend.TunnelBackend +import com.zaneschepke.tunnel.backend.TunnelEngine +import com.zaneschepke.tunnel.backend.WireGuardTunnelEngine +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import org.koin.android.ext.koin.androidContext +import org.koin.core.qualifier.named +import org.koin.dsl.module + +val tunnelModule = module { + single(createdAtStart = true) { TunnelLibraryInitializer.ensureLoaded(androidContext()) } + + single(named(CoroutineScopes.IO_SCOPE)) { CoroutineScope(SupervisorJob() + Dispatchers.IO) } + + single { ServiceHolder(androidContext()) } + // expect networkMonitor and NotificationProvider to be available to koin from app + single { TunnelBackend(get(named(CoroutineScopes.IO_SCOPE)), get(), get()) } + single { TunnelActor(get(named(CoroutineScopes.IO_SCOPE)), get(), get()) } + single { DefaultEngineStateProvider() } + single { WireGuardTunnelEngine(get(), get()) } +} + +enum class CoroutineScopes { + IO_SCOPE +} diff --git a/tunnel/src/main/java/com/zaneschepke/tunnel/event/ActorEvent.kt b/tunnel/src/main/java/com/zaneschepke/tunnel/event/ActorEvent.kt new file mode 100644 index 000000000..349177922 --- /dev/null +++ b/tunnel/src/main/java/com/zaneschepke/tunnel/event/ActorEvent.kt @@ -0,0 +1,39 @@ +package com.zaneschepke.tunnel.event + +import com.zaneschepke.tunnel.model.DnsBootstrapResult +import com.zaneschepke.tunnel.model.PublicKey +import com.zaneschepke.tunnel.model.TunnelCommand +import com.zaneschepke.tunnel.state.BootstrapState +import com.zaneschepke.tunnel.state.EngineStartResult +import com.zaneschepke.tunnel.state.NativeTunnelStatus +import com.zaneschepke.wireguardautotunnel.parser.ActiveConfig +import com.zaneschepke.wireguardautotunnel.parser.PeerSection + +sealed class ActorEvent { + data class EngineStatus(val status: NativeTunnelStatus) : ActorEvent() + + data class TunnelStarted(val result: EngineStartResult, val cmd: TunnelCommand.Start) : + ActorEvent() + + data class TunnelStopped(val tunnelId: Int, val handle: Int) : ActorEvent() + + data class PeersUpdated( + val tunnelId: Int, + val peers: List, + val preferIpv6: Boolean, + ) : ActorEvent() + + data class ResolvedPeersApplied( + val tunnelId: Int, + val cache: Map, + val peers: List, + ) : ActorEvent() + + data class ActiveConfigUpdated(val tunnelId: Int, val activeConfig: ActiveConfig?) : + ActorEvent() + + data class BootstrapStateChanged(val tunnelId: Int, val bootstrapState: BootstrapState) : + ActorEvent() + + data class KillSwitchStateChanged(val enabled: Boolean) : ActorEvent() +} diff --git a/tunnel/src/main/java/com/zaneschepke/tunnel/event/TunnelEvent.kt b/tunnel/src/main/java/com/zaneschepke/tunnel/event/TunnelEvent.kt new file mode 100644 index 000000000..cdc854ec4 --- /dev/null +++ b/tunnel/src/main/java/com/zaneschepke/tunnel/event/TunnelEvent.kt @@ -0,0 +1,15 @@ +package com.zaneschepke.tunnel.event + +import com.zaneschepke.tunnel.model.PublicKey + +sealed interface TunnelEvent { + + // future runtime re-resolution + data class DynamicDnsUpdate(val tunnelId: Int, val changedPeers: List) : TunnelEvent + + data class FallbackToIpv4(val tunnelId: Int) : TunnelEvent + + data class RecoveredToIpv6(val tunnelId: Int) : TunnelEvent + + data class NoRootShellAccess(val tunnelId: Int) : TunnelEvent +} diff --git a/tunnel/src/main/java/com/zaneschepke/tunnel/model/BackendMode.kt b/tunnel/src/main/java/com/zaneschepke/tunnel/model/BackendMode.kt new file mode 100644 index 000000000..a40ed6e27 --- /dev/null +++ b/tunnel/src/main/java/com/zaneschepke/tunnel/model/BackendMode.kt @@ -0,0 +1,24 @@ +package com.zaneschepke.tunnel.model + +import com.zaneschepke.wireguardautotunnel.parser.Config + +sealed class BackendMode { + abstract val config: Config + + abstract fun withConfig(config: Config): BackendMode + + sealed class Proxy : BackendMode() { + + data class Standard(override val config: Config, val proxyConfig: ProxyConfig) : Proxy() { + override fun withConfig(config: Config) = copy(config = config) + } + + data class KillSwitchPrimary(override val config: Config) : Proxy() { + override fun withConfig(config: Config) = copy(config = config) + } + } + + data class Vpn(override val config: Config) : BackendMode() { + override fun withConfig(config: Config) = copy(config = config) + } +} diff --git a/tunnel/src/main/java/com/zaneschepke/tunnel/model/DnsBootstrap.kt b/tunnel/src/main/java/com/zaneschepke/tunnel/model/DnsBootstrap.kt new file mode 100644 index 000000000..a96cf66d5 --- /dev/null +++ b/tunnel/src/main/java/com/zaneschepke/tunnel/model/DnsBootstrap.kt @@ -0,0 +1,36 @@ +package com.zaneschepke.tunnel.model + +sealed class DnsBoostrapMode { + + data object System : DnsBoostrapMode() + + data class Custom(val config: DnsBoostrapConfig) : DnsBoostrapMode() +} + +sealed class DnsBoostrapConfig(open val upstream: String?) { + abstract val protocol: String + + data class Plain(override val upstream: String?) : DnsBoostrapConfig(upstream) { + override val protocol: String + get() = "plain" + } + + data class DoH(override val upstream: String?) : DnsBoostrapConfig(upstream) { + override val protocol: String + get() = "dot" + } + + data class DoT(override val upstream: String?) : DnsBoostrapConfig(upstream) { + override val protocol: String + get() = "dot" + } + + companion object { + const val DEFAULT_UPSTREAM = "1.1.1.1" + } +} + +data class DnsBootstrapResult( + val ipv4: List = emptyList(), + val ipv6: List = emptyList(), +) diff --git a/tunnel/src/main/java/com/zaneschepke/tunnel/model/DnsConfig.kt b/tunnel/src/main/java/com/zaneschepke/tunnel/model/DnsConfig.kt new file mode 100644 index 000000000..1f3b3cf36 --- /dev/null +++ b/tunnel/src/main/java/com/zaneschepke/tunnel/model/DnsConfig.kt @@ -0,0 +1,5 @@ +package com.zaneschepke.tunnel.model + +import java.net.InetAddress + +data class DnsConfig(val dnsServers: List, val searchDomains: List) diff --git a/tunnel/src/main/java/com/zaneschepke/tunnel/model/KillSwitchConfig.kt b/tunnel/src/main/java/com/zaneschepke/tunnel/model/KillSwitchConfig.kt new file mode 100644 index 000000000..196a9f38d --- /dev/null +++ b/tunnel/src/main/java/com/zaneschepke/tunnel/model/KillSwitchConfig.kt @@ -0,0 +1,7 @@ +package com.zaneschepke.tunnel.model + +data class KillSwitchConfig( + val allowedIps: Set, + val metered: Boolean, + val dualStack: Boolean, +) diff --git a/tunnel/src/main/java/com/zaneschepke/tunnel/model/ProxyConfig.kt b/tunnel/src/main/java/com/zaneschepke/tunnel/model/ProxyConfig.kt new file mode 100644 index 000000000..a25f63840 --- /dev/null +++ b/tunnel/src/main/java/com/zaneschepke/tunnel/model/ProxyConfig.kt @@ -0,0 +1,38 @@ +package com.zaneschepke.tunnel.model + +data class ProxyConfig(val socks5: Socks5? = null, val http: Http? = null) { + + fun toQuickString(): String = buildString { + socks5?.let { + appendLine("[Socks5]") + appendLine("BindAddress = ${it.host}:${it.port}") + it.username?.let { u -> appendLine("Username = $u") } + it.password?.let { p -> appendLine("Password = $p") } + } + + if (socks5 != null && http != null) { + appendLine() + } + + http?.let { + appendLine("[http]") + appendLine("BindAddress = ${it.host}:${it.port}") + it.username?.let { u -> appendLine("Username = $u") } + it.password?.let { p -> appendLine("Password = $p") } + } + } + + data class Socks5( + val host: String = "127.0.0.1", + val port: Int, + val username: String? = null, + val password: String? = null, + ) + + data class Http( + val host: String = "127.0.0.1", + val port: Int, + val username: String? = null, + val password: String? = null, + ) +} diff --git a/tunnel/src/main/java/com/zaneschepke/tunnel/model/RunningTunnel.kt b/tunnel/src/main/java/com/zaneschepke/tunnel/model/RunningTunnel.kt new file mode 100644 index 000000000..4f52fefbf --- /dev/null +++ b/tunnel/src/main/java/com/zaneschepke/tunnel/model/RunningTunnel.kt @@ -0,0 +1,16 @@ +package com.zaneschepke.tunnel.model + +import com.zaneschepke.tunnel.Tunnel +import com.zaneschepke.wireguardautotunnel.parser.PeerSection + +typealias PublicKey = String + +data class RunningTunnel( + val handle: Int, + val interfaceName: String, + val tunnel: Tunnel, + val mode: BackendMode, + val currentPreferIpv6: Boolean = false, + val resolvedPeers: List? = null, + val peerBootstrapCache: Map = emptyMap(), +) diff --git a/tunnel/src/main/java/com/zaneschepke/tunnel/model/ScriptDirection.kt b/tunnel/src/main/java/com/zaneschepke/tunnel/model/ScriptDirection.kt new file mode 100644 index 000000000..25e02a9f3 --- /dev/null +++ b/tunnel/src/main/java/com/zaneschepke/tunnel/model/ScriptDirection.kt @@ -0,0 +1,6 @@ +package com.zaneschepke.tunnel.model + +enum class ScriptDirection { + UP, + DOWN, +} diff --git a/tunnel/src/main/java/com/zaneschepke/tunnel/model/ShellResult.kt b/tunnel/src/main/java/com/zaneschepke/tunnel/model/ShellResult.kt new file mode 100644 index 000000000..26b8a24bc --- /dev/null +++ b/tunnel/src/main/java/com/zaneschepke/tunnel/model/ShellResult.kt @@ -0,0 +1,16 @@ +package com.zaneschepke.tunnel.model + +data class ShellResult( + val code: Int, + val stdout: List, + val stderr: List = emptyList(), +) { + val isSuccess: Boolean + get() = code == 0 + + val isFailure: Boolean + get() = !isSuccess + + val output: String + get() = (stdout + stderr).joinToString("\n") +} diff --git a/tunnel/src/main/java/com/zaneschepke/tunnel/model/TunnelCommand.kt b/tunnel/src/main/java/com/zaneschepke/tunnel/model/TunnelCommand.kt new file mode 100644 index 000000000..f6eb31438 --- /dev/null +++ b/tunnel/src/main/java/com/zaneschepke/tunnel/model/TunnelCommand.kt @@ -0,0 +1,36 @@ +package com.zaneschepke.tunnel.model + +import com.zaneschepke.tunnel.Tunnel +import com.zaneschepke.tunnel.state.BootstrapState +import com.zaneschepke.wireguardautotunnel.parser.PeerSection + +sealed class TunnelCommand { + + data class Start(val tunnel: Tunnel, val mode: BackendMode) : TunnelCommand() + + data class Stop(val tunnelId: Int) : TunnelCommand() + + data class UpdateActiveConfig(val tunnelId: Int) : TunnelCommand() + + data class UpdateKillSwitch(val enabled: Boolean) : TunnelCommand() + + data class ApplyResolvedPeers( + val tunnelId: Int, + val cache: Map, + val peers: List, + ) : TunnelCommand() + + data class UpdatePeers(val tunnelId: Int, val preferIpv6: Boolean) : TunnelCommand() + + data class SetBootstrapState(val tunnelId: Int, val state: BootstrapState) : TunnelCommand() + + data class RunHook(val tunnelId: Int, val phase: Phase, val cmds: List?) : + TunnelCommand() { + enum class Phase { + PreUp, + PostUp, + PreDown, + PostDown, + } + } +} diff --git a/tunnel/src/main/java/com/zaneschepke/tunnel/service/TunnelService.kt b/tunnel/src/main/java/com/zaneschepke/tunnel/service/TunnelService.kt new file mode 100644 index 000000000..ba32f1a42 --- /dev/null +++ b/tunnel/src/main/java/com/zaneschepke/tunnel/service/TunnelService.kt @@ -0,0 +1,64 @@ +package com.zaneschepke.tunnel.service + +import android.content.Intent +import androidx.core.app.ServiceCompat +import androidx.lifecycle.LifecycleService +import com.zaneschepke.tunnel.backend.Backend +import com.zaneschepke.tunnel.backend.ServiceHolder +import com.zaneschepke.tunnel.backend.ServiceHolder.Companion.alwaysOnCallback +import com.zaneschepke.tunnel.model.BackendMode +import kotlinx.coroutines.runBlocking +import org.koin.java.KoinJavaComponent.inject +import timber.log.Timber + +class TunnelService : LifecycleService() { + + private val backend: Backend by inject(Backend::class.java) + private val serviceHolder: ServiceHolder by inject(ServiceHolder::class.java) + + override fun onCreate() { + ServiceHolder.tunnelService.complete(this) + serviceHolder.ensureNativeCallbacksRegistered() + launchForegroundNotification() + super.onCreate() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + super.onStartCommand(intent, flags, startId) + ServiceHolder.tunnelService.complete(this) + launchForegroundNotification() + + // Service restarted by system, reuse always-on VPN callback + if ( + intent == null || + intent.component == null || + (intent.component!!.packageName != packageName) + ) { + Timber.d("TunnelService started by system") + alwaysOnCallback?.get()?.alwaysOnTriggered() + } + + return START_STICKY + } + + override fun onDestroy() { + runBlocking { backend.stopAllOfType(BackendMode.Proxy.Standard::class) } + + ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) + serviceHolder.clear(this) + super.onDestroy() + } + + fun launchForegroundNotification() { + ServiceCompat.startForeground( + this, + backend.notificationProvider.proxyNotificationId, + backend.notificationProvider.proxyInitNotification, + SPECIAL_USE_SERVICE_TYPE_ID, + ) + } + + companion object { + private const val SPECIAL_USE_SERVICE_TYPE_ID = 1 shl 30 + } +} diff --git a/tunnel/src/main/java/com/zaneschepke/tunnel/service/VpnService.kt b/tunnel/src/main/java/com/zaneschepke/tunnel/service/VpnService.kt new file mode 100644 index 000000000..5a04a67fc --- /dev/null +++ b/tunnel/src/main/java/com/zaneschepke/tunnel/service/VpnService.kt @@ -0,0 +1,257 @@ +package com.zaneschepke.tunnel.service + +import android.content.Intent +import android.os.Build +import android.os.ParcelFileDescriptor +import android.system.OsConstants +import androidx.core.app.ServiceCompat +import com.zaneschepke.hevtunnel.HevTunnelConfig +import com.zaneschepke.hevtunnel.TProxyService +import com.zaneschepke.tunnel.ProxyBackend +import com.zaneschepke.tunnel.Tunnel +import com.zaneschepke.tunnel.backend.Backend +import com.zaneschepke.tunnel.backend.KillSwitch +import com.zaneschepke.tunnel.backend.ServiceHolder +import com.zaneschepke.tunnel.backend.ServiceHolder.Companion.DEFAULT_MTU +import com.zaneschepke.tunnel.backend.ServiceHolder.Companion.alwaysOnCallback +import com.zaneschepke.tunnel.backend.ServiceHolder.Companion.vpnService +import com.zaneschepke.tunnel.backend.SocketProtector +import com.zaneschepke.tunnel.model.BackendMode +import com.zaneschepke.tunnel.model.KillSwitchConfig +import com.zaneschepke.tunnel.util.parseDns +import com.zaneschepke.tunnel.util.parseInetNetwork +import com.zaneschepke.wireguardautotunnel.parser.Config +import java.io.IOException +import java.net.ServerSocket +import java.util.UUID +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import org.koin.java.KoinJavaComponent.inject +import timber.log.Timber + +class VpnService : android.net.VpnService(), KillSwitch, SocketProtector { + + private val backend: Backend by inject(Backend::class.java) + private val serviceHolder: ServiceHolder by inject(ServiceHolder::class.java) + + private val defaultPass = UUID.randomUUID().toString() + + private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private var hevBridgeJob: Job? = null + private var fd: ParcelFileDescriptor? = null + + val builder: Builder + get() = Builder() + + override fun onCreate() { + vpnService.complete(this) + // We call this for all backend modes as it is shared for bootstrapping bypass + ProxyBackend.setSocketProtector(this) + serviceHolder.ensureNativeCallbacksRegistered() + launchForegroundNotification() + super.onCreate() + } + + fun launchForegroundNotification() { + ServiceCompat.startForeground( + this, + backend.notificationProvider.vpnNotificationId, + backend.notificationProvider.vpnInitNotification, + SYSTEM_EXEMPT_SERVICE_TYPE_ID, + ) + } + + override fun onDestroy() { + Timber.d("VpnService destroyed") + + ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) + + ProxyBackend.setSocketProtector(null) + + disableKillSwitch() + hevBridgeJob?.cancel() + + serviceScope.cancel() + + runBlocking { + backend.stopAllOfType(BackendMode.Vpn::class) + backend.stopAllOfType(BackendMode.Proxy.KillSwitchPrimary::class) + } + + serviceHolder.clear(this) + + super.onDestroy() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + vpnService.complete(this) + launchForegroundNotification() + + // Service restarted by system or Always-on VPN started + if ( + intent == null || + intent.component == null || + (intent.component!!.packageName != packageName) + ) { + Timber.d("VpnService started by system") + alwaysOnCallback?.get()?.alwaysOnTriggered() + } + return START_STICKY + } + + private fun startHevBridge(): Job { + val job = serviceScope.launch { + try { + val port = getAvailablePort() + val fd = fd ?: throw IOException("No VPN interface fd available") + val config = + HevTunnelConfig( + port = port, + mtu = DEFAULT_MTU, + ipv4 = IPV4_INTERFACE_ADDRESS, + ipv6 = IPV6_INTERFACE_ADDRESS, + address = LOCALHOST, + username = DEFAULT_USERNAME, + password = defaultPass, + ) + val hevConfigFile = TProxyService.createHevTunnelConfig(config, this@VpnService) + TProxyService.TProxyStartService(hevConfigFile.absolutePath, fd.fd) + } catch (e: IOException) { + Timber.e(e) + } + } + job.invokeOnCompletion { + TProxyService.TProxyStopService() + hevBridgeJob = null + } + return job + } + + @Throws(IOException::class) + private fun getAvailablePort(): Int { + ServerSocket(0).use { socket -> + socket.setReuseAddress(true) + return socket.getLocalPort() + } + } + + private fun disableKillSwitch() { + fd?.close() + fd = null + } + + override fun setKillSwitch(config: KillSwitchConfig?) { + if (config == null) return disableKillSwitch() + fd = + builder + .apply { + setSession(LOCKDOWN_SESSION_NAME) + addAddress(IPV4_INTERFACE_ADDRESS, 32) + if (config.dualStack) addAddress(IPV6_INTERFACE_ADDRESS, 128) + if (config.allowedIps.isEmpty()) { + addRoute(IPV4_DEFAULT_ROUTE, 0) + } else { + config.allowedIps.forEach { net -> + Timber.d("Adding allowedIp to kill switch: $net") + val (address, prefix) = net.parseInetNetwork() + addRoute(address, prefix) + } + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + builder.setMetered(config.metered) + } + addRoute(IPV6_DEFAULT_ROUTE, 0) + setMtu(DEFAULT_MTU) + addDnsServer(DEFAULT_DNS_SERVER) + } + .establish() + } + + fun createTunInterface(tunnel: Tunnel, config: Config): ParcelFileDescriptor? { + return builder + .apply { + setSession(tunnel.name) + + config.`interface`.includedApplications?.forEach { addAllowedApplication(it) } + config.`interface`.excludedApplications?.forEach { addDisallowedApplication(it) } + + config.`interface`.address?.split(",")?.forEach { rawAddress -> + val (address, prefixLength) = rawAddress.parseInetNetwork() + addAddress(address, prefixLength) + } + + config.`interface`.dns?.let { rawDns -> + val dnsConfig = rawDns.parseDns() + dnsConfig.dnsServers.forEach { addDnsServer(it) } + dnsConfig.searchDomains.forEach { addSearchDomain(it) } + } + + config.peers.forEach { peer -> + peer.allowedIPs?.split(",")?.forEach { entry -> + val (address, prefix) = entry.parseInetNetwork() + Timber.d("Adding route from config: $address/$prefix") + addRoute(address, prefix) + } + } + + allowFamily(OsConstants.AF_INET) + allowFamily(OsConstants.AF_INET6) + + val mtu = config.`interface`.mtu ?: DEFAULT_MTU + setMtu(mtu) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + setMetered(tunnel.isMetered) + } + + setUnderlyingNetworks(null) + setBlocking(true) + } + .establish() + } + + override fun startHevSocks5Bridge() { + if (hevBridgeJob != null) return + hevBridgeJob = startHevBridge() + } + + override fun stopHevSocks5Bridge() { + hevBridgeJob?.cancel() + hevBridgeJob = null + } + + override fun bypass(fd: Int): Int { + Timber.d("Bypassing VPN fd: $fd") + val bypassed = + try { + if (protect(fd)) 1 else 0 + } catch (e: Exception) { + Timber.e(e, "Failed to protect VPN fd") + 0 + } + Timber.d("Socket protected result: $fd") + return bypassed + } + + interface AlwaysOnCallback { + fun alwaysOnTriggered() + } + + companion object { + private const val LOCKDOWN_SESSION_NAME = "Lockdown" + private const val LOCALHOST = "127.0.0.1" + private const val IPV4_INTERFACE_ADDRESS = "10.0.0.1" + private const val IPV6_INTERFACE_ADDRESS = "2001:db8::1" + private const val DEFAULT_USERNAME = "local" + private const val IPV4_DEFAULT_ROUTE = "0.0.0.0" + private const val IPV6_DEFAULT_ROUTE = "::" + private const val DEFAULT_DNS_SERVER = "1.1.1.1" + + private const val SYSTEM_EXEMPT_SERVICE_TYPE_ID = 1 shl 10 + } +} diff --git a/tunnel/src/main/java/com/zaneschepke/tunnel/state/ActiveTunnel.kt b/tunnel/src/main/java/com/zaneschepke/tunnel/state/ActiveTunnel.kt new file mode 100644 index 000000000..67224956a --- /dev/null +++ b/tunnel/src/main/java/com/zaneschepke/tunnel/state/ActiveTunnel.kt @@ -0,0 +1,26 @@ +package com.zaneschepke.tunnel.state + +import com.zaneschepke.pinger.model.PingStats +import com.zaneschepke.tunnel.Tunnel +import com.zaneschepke.tunnel.model.BackendMode +import com.zaneschepke.wireguardautotunnel.parser.ActiveConfig + +data class ActiveTunnel( + val transportState: Tunnel.State = Tunnel.State.Down, + val bootstrapState: BootstrapState = BootstrapState.None, + val lastStateChangeMs: Long = System.currentTimeMillis(), + val lastHealthChangeMs: Long = 0L, + val interfaceName: String? = null, + val activeConfig: ActiveConfig? = null, + val pingStats: PingStats? = null, + val mode: BackendMode? = null, + val uptime: Long? = null, + val lastPeerUpdateMs: Long = 0L, +) { + val isPeerUpdating: Boolean + get() = System.currentTimeMillis() - lastPeerUpdateMs < PEER_UPDATE_GRACE_MS + + companion object { + private const val PEER_UPDATE_GRACE_MS = 8_000L + } +} diff --git a/tunnel/src/main/java/com/zaneschepke/tunnel/state/ActorState.kt b/tunnel/src/main/java/com/zaneschepke/tunnel/state/ActorState.kt new file mode 100644 index 000000000..5287c24b6 --- /dev/null +++ b/tunnel/src/main/java/com/zaneschepke/tunnel/state/ActorState.kt @@ -0,0 +1,7 @@ +package com.zaneschepke.tunnel.state + +data class ActorState( + val byTunnelId: Map, + val byHandle: Map, + val killSwitchEnabled: Boolean = false, +) diff --git a/tunnel/src/main/java/com/zaneschepke/tunnel/state/BackendStatus.kt b/tunnel/src/main/java/com/zaneschepke/tunnel/state/BackendStatus.kt new file mode 100644 index 000000000..cca742ed7 --- /dev/null +++ b/tunnel/src/main/java/com/zaneschepke/tunnel/state/BackendStatus.kt @@ -0,0 +1,9 @@ +package com.zaneschepke.tunnel.state + +import com.zaneschepke.tunnel.model.DnsBoostrapMode + +data class BackendStatus( + val killSwitch: KillSwitchState = KillSwitchState(), + val activeTunnels: Map = emptyMap(), + val dnsMode: DnsBoostrapMode = DnsBoostrapMode.System, +) diff --git a/tunnel/src/main/java/com/zaneschepke/tunnel/state/BootstrapState.kt b/tunnel/src/main/java/com/zaneschepke/tunnel/state/BootstrapState.kt new file mode 100644 index 000000000..c1cc24798 --- /dev/null +++ b/tunnel/src/main/java/com/zaneschepke/tunnel/state/BootstrapState.kt @@ -0,0 +1,11 @@ +package com.zaneschepke.tunnel.state + +sealed class BootstrapState { + data object None : BootstrapState() + + data object ResolvingDns : BootstrapState() + + data object Complete : BootstrapState() + + data class Failed(val error: Throwable? = null) : BootstrapState() +} diff --git a/tunnel/src/main/java/com/zaneschepke/tunnel/state/DnsState.kt b/tunnel/src/main/java/com/zaneschepke/tunnel/state/DnsState.kt new file mode 100644 index 000000000..9f6f6e673 --- /dev/null +++ b/tunnel/src/main/java/com/zaneschepke/tunnel/state/DnsState.kt @@ -0,0 +1,10 @@ +package com.zaneschepke.tunnel.state + +import com.zaneschepke.tunnel.model.DnsBoostrapMode + +data class DnsState( + val bootstrapMode: DnsBoostrapMode = DnsBoostrapMode.System, + val currentUpstream: String? = null, + val lastError: String? = null, + val isResolving: Boolean = false, +) diff --git a/tunnel/src/main/java/com/zaneschepke/tunnel/state/EngineStartResult.kt b/tunnel/src/main/java/com/zaneschepke/tunnel/state/EngineStartResult.kt new file mode 100644 index 000000000..c039a11f2 --- /dev/null +++ b/tunnel/src/main/java/com/zaneschepke/tunnel/state/EngineStartResult.kt @@ -0,0 +1,11 @@ +package com.zaneschepke.tunnel.state + +import com.zaneschepke.tunnel.model.BackendMode + +data class EngineStartResult( + val tunnelId: Int, + val handle: Int, + val interfaceName: String, + val mode: BackendMode, + val removedPeerEndpoint: Boolean, +) diff --git a/tunnel/src/main/java/com/zaneschepke/tunnel/state/EngineState.kt b/tunnel/src/main/java/com/zaneschepke/tunnel/state/EngineState.kt new file mode 100644 index 000000000..78ffb1e68 --- /dev/null +++ b/tunnel/src/main/java/com/zaneschepke/tunnel/state/EngineState.kt @@ -0,0 +1,7 @@ +package com.zaneschepke.tunnel.state + +internal data class EngineState( + val tunnels: Map = emptyMap(), + val killSwitch: KillSwitchState = KillSwitchState(), + val dns: DnsState = DnsState(), +) diff --git a/tunnel/src/main/java/com/zaneschepke/tunnel/state/KillSwitchState.kt b/tunnel/src/main/java/com/zaneschepke/tunnel/state/KillSwitchState.kt new file mode 100644 index 000000000..14042de49 --- /dev/null +++ b/tunnel/src/main/java/com/zaneschepke/tunnel/state/KillSwitchState.kt @@ -0,0 +1,9 @@ +package com.zaneschepke.tunnel.state + +import com.zaneschepke.tunnel.model.KillSwitchConfig + +data class KillSwitchState( + val enabled: Boolean = false, + val config: KillSwitchConfig? = null, + val primaryTunnel: Long? = null, +) diff --git a/tunnel/src/main/java/com/zaneschepke/tunnel/state/NativeTunnelStatus.kt b/tunnel/src/main/java/com/zaneschepke/tunnel/state/NativeTunnelStatus.kt new file mode 100644 index 000000000..86e9877c8 --- /dev/null +++ b/tunnel/src/main/java/com/zaneschepke/tunnel/state/NativeTunnelStatus.kt @@ -0,0 +1,15 @@ +package com.zaneschepke.tunnel.state + +data class NativeTunnelStatus(val handle: Int, val code: NativeTunnelStatusCode) { + enum class NativeTunnelStatusCode(val code: Int) { + HEALTHY(0), + HANDSHAKE_FAILURE(1), + STOPPED(99); + + companion object { + fun from(code: Int): NativeTunnelStatusCode? { + return entries.firstOrNull { it.code == code } + } + } + } +} diff --git a/tunnel/src/main/java/com/zaneschepke/tunnel/state/TunnelRuntimeState.kt b/tunnel/src/main/java/com/zaneschepke/tunnel/state/TunnelRuntimeState.kt new file mode 100644 index 000000000..7ec998082 --- /dev/null +++ b/tunnel/src/main/java/com/zaneschepke/tunnel/state/TunnelRuntimeState.kt @@ -0,0 +1,5 @@ +package com.zaneschepke.tunnel.state + +import com.zaneschepke.tunnel.model.RunningTunnel + +data class TunnelRuntimeState(val running: RunningTunnel, val active: ActiveTunnel) diff --git a/tunnel/src/main/java/com/zaneschepke/tunnel/util/BackendException.kt b/tunnel/src/main/java/com/zaneschepke/tunnel/util/BackendException.kt new file mode 100644 index 000000000..215be876d --- /dev/null +++ b/tunnel/src/main/java/com/zaneschepke/tunnel/util/BackendException.kt @@ -0,0 +1,9 @@ +package com.zaneschepke.tunnel.util + +sealed class BackendException : Exception() { + class StateConflict(override val message: String) : BackendException() + + class InternalError(override val message: String) : BackendException() + + class Unauthorized(override val message: String) : BackendException() +} diff --git a/tunnel/src/main/java/com/zaneschepke/tunnel/util/Backoff.kt b/tunnel/src/main/java/com/zaneschepke/tunnel/util/Backoff.kt new file mode 100644 index 000000000..d3052c59e --- /dev/null +++ b/tunnel/src/main/java/com/zaneschepke/tunnel/util/Backoff.kt @@ -0,0 +1,30 @@ +package com.zaneschepke.tunnel.util + +import kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import timber.log.Timber + +suspend fun exponentialBackoffForever( + initialDelayMs: Long = 500, + factor: Double = 2.0, + maxDelayMs: Long = 30_000, + block: suspend () -> Unit, +) = coroutineScope { + var delayMs = initialDelayMs + + while (isActive) { + try { + block() + Timber.d("exponentialBackoffForever: block succeeded, exiting loop") + return@coroutineScope + } catch (e: Exception) { + Timber.w(e, "Backoff operation failed, retrying in ${delayMs}ms...") + + delay(delayMs.milliseconds) + + delayMs = (delayMs * factor).toLong().coerceAtMost(maxDelayMs) + } + } +} diff --git a/tunnel/src/main/java/com/zaneschepke/tunnel/util/Extensions.kt b/tunnel/src/main/java/com/zaneschepke/tunnel/util/Extensions.kt new file mode 100644 index 000000000..477871d94 --- /dev/null +++ b/tunnel/src/main/java/com/zaneschepke/tunnel/util/Extensions.kt @@ -0,0 +1,89 @@ +package com.zaneschepke.tunnel.util + +import android.os.Build +import com.zaneschepke.tunnel.model.DnsBootstrapResult +import com.zaneschepke.tunnel.model.DnsConfig +import com.zaneschepke.tunnel.model.RunningTunnel +import com.zaneschepke.wireguardautotunnel.parser.PeerSection +import java.net.Inet4Address +import java.net.InetAddress + +/** Parses a CIDR string and returns the address + prefix length */ +internal fun String.parseInetNetwork(): Pair { + val slashIndex = lastIndexOf('/') + val rawAddress: String + val rawMask: String? + + if (slashIndex >= 0) { + rawAddress = substring(0, slashIndex).trim() + rawMask = substring(slashIndex + 1).trim() + } else { + rawAddress = trim() + rawMask = null + } + + val address = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + android.net.InetAddresses.parseNumericAddress(rawAddress) + } else { + InetAddress.getByName(rawAddress) + } + + val maxMask = if (address is Inet4Address) 32 else 128 + val mask = rawMask?.toIntOrNull() ?: maxMask + + if (mask !in 0..maxMask) { + throw IllegalArgumentException("Invalid network mask: $rawMask (must be 0-$maxMask)") + } + + return address to mask +} + +internal fun String.parseDns(): DnsConfig { + val servers = mutableListOf() + val domains = mutableListOf() + + split(",").forEach { item -> + val trimmed = item.trim() + if (trimmed.isBlank()) return@forEach + + try { + val ip = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + android.net.InetAddresses.parseNumericAddress(trimmed) + } else { + InetAddress.getByName(trimmed) + } + servers.add(ip) + } catch (_: Exception) { + domains.add(trimmed) + } + } + + return DnsConfig(servers, domains) +} + +internal fun RunningTunnel.buildResolvedPeers(preferIpv6: Boolean): List { + + fun selectIp(cache: DnsBootstrapResult, preferIpv6: Boolean): String? { + + val ipv4 = cache.ipv4.firstOrNull() + val ipv6 = cache.ipv6.firstOrNull() + + return when { + preferIpv6 -> ipv6 ?: ipv4 + else -> ipv4 ?: ipv6 + } + } + + return mode.config.peers.map { peer -> + val endpoint = peer.endpoint ?: return@map peer + val port = endpoint.substringAfterLast(":") + + val dnsCache = peerBootstrapCache[peer.publicKey] ?: return@map peer + + val selectedIp = selectIp(cache = dnsCache, preferIpv6 = preferIpv6) ?: return@map peer + + peer.copy(endpoint = "$selectedIp:$port") + } +} diff --git a/tunnel/src/main/java/com/zaneschepke/tunnel/util/RootShellException.kt b/tunnel/src/main/java/com/zaneschepke/tunnel/util/RootShellException.kt new file mode 100644 index 000000000..cf0a9fad4 --- /dev/null +++ b/tunnel/src/main/java/com/zaneschepke/tunnel/util/RootShellException.kt @@ -0,0 +1,29 @@ +package com.zaneschepke.tunnel.util + +sealed class RootShellException( + override val message: String, + override val cause: Throwable? = null, +) : Exception(message, cause) { + + class NoRootAccess : + RootShellException("Root access is not granted. Please grant root permissions.") + + class CommandFailed(val command: String, val exitCode: Int, val stderr: String? = null) : + RootShellException( + buildString { + append("Root command failed") + append(" (exit code: $exitCode)") + append(": $command") + + if (!stderr.isNullOrBlank()) { + append("\n$stderr") + } + } + ) + + class CommandTimedOut(val command: String, val timeoutMs: Long) : + RootShellException("Root command timed out after ${timeoutMs}ms: $command") + + class ShellDied(cause: Throwable? = null) : + RootShellException("Root shell terminated unexpectedly", cause) +} diff --git a/tunnel/src/test/java/com/zaneschepke/tunnel/ExampleUnitTest.kt b/tunnel/src/test/java/com/zaneschepke/tunnel/ExampleUnitTest.kt new file mode 100644 index 000000000..d87818fbe --- /dev/null +++ b/tunnel/src/test/java/com/zaneschepke/tunnel/ExampleUnitTest.kt @@ -0,0 +1,16 @@ +package com.zaneschepke.tunnel + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} diff --git a/tunnel/tools/CMakeLists.txt b/tunnel/tools/CMakeLists.txt new file mode 100644 index 000000000..201003ed1 --- /dev/null +++ b/tunnel/tools/CMakeLists.txt @@ -0,0 +1,49 @@ +# SPDX-License-Identifier: Apache-2.0 +# +# Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. +# Modified for WG Tunnel WireGuard kernel and AmneziaWG userspace + +cmake_minimum_required(VERSION 3.4.1) +project("WGTunnel") + +set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${CMAKE_LIBRARY_OUTPUT_DIRECTORY}") +add_link_options(LINKER:--build-id=none) +add_compile_options(-Wall -Werror) + +# AmneziaWG tools for userspace config +add_executable(libam-quick.so amneziawg-tools/src/wg-quick/android.c) +target_compile_options(libam-quick.so PUBLIC -std=gnu11 -DAWG_PACKAGE_NAME=\"${ANDROID_PACKAGE_NAME}\") +target_link_libraries(libam-quick.so -ldl) + +file(GLOB AM_SOURCES amneziawg-tools/src/*.c) +add_executable(libam.so ${AM_SOURCES}) +target_include_directories(libam.so PUBLIC + "${CMAKE_CURRENT_SOURCE_DIR}/amneziawg-tools/src/uapi/linux/" + "${CMAKE_CURRENT_SOURCE_DIR}/amneziawg-tools/src/" +) +target_compile_options(libam.so PUBLIC -std=gnu11 -DRUNSTATEDIR=\"/data/data/${ANDROID_PACKAGE_NAME}/cache\") + +# Amnezia userspace go build +add_custom_target(libam-go.so + WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/libwg-go" + COMMENT "Building amneziawg-go" + VERBATIM + COMMAND "${ANDROID_HOST_PREBUILTS}/bin/make" + ANDROID_ARCH_NAME=${ANDROID_ARCH_NAME} + ANDROID_PACKAGE_NAME=${ANDROID_PACKAGE_NAME} + GRADLE_USER_HOME=${GRADLE_USER_HOME} + CC=${CMAKE_C_COMPILER} + CFLAGS=${CMAKE_C_FLAGS} + LDFLAGS=${CMAKE_SHARED_LINKER_FLAGS} + SYSROOT=${CMAKE_SYSROOT} + TARGET=${CMAKE_C_COMPILER_TARGET} + DESTDIR=${CMAKE_LIBRARY_OUTPUT_DIRECTORY} + BUILDDIR=${CMAKE_LIBRARY_OUTPUT_DIRECTORY}/../generated-src + DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/libwg-go/main.go + ${CMAKE_CURRENT_SOURCE_DIR}/libwg-go/vpn/vpn.go + ${CMAKE_CURRENT_SOURCE_DIR}/libwg-go/vpn/vpn_jni.c + ${CMAKE_CURRENT_SOURCE_DIR}/libwg-go/proxy/proxy.go + ${CMAKE_CURRENT_SOURCE_DIR}/libwg-go/proxy/proxy_jni.c + ${CMAKE_CURRENT_SOURCE_DIR}/libwg-go/dns/dns.go + ${CMAKE_CURRENT_SOURCE_DIR}/libwg-go/dns/dns_jni.c +) \ No newline at end of file diff --git a/tunnel/tools/amneziawg-tools b/tunnel/tools/amneziawg-tools new file mode 160000 index 000000000..5d6179a6d --- /dev/null +++ b/tunnel/tools/amneziawg-tools @@ -0,0 +1 @@ +Subproject commit 5d6179a6d0842e98dfb349c28cf1bd8e4b9d1079 diff --git a/tunnel/tools/libwg-go/.gitignore b/tunnel/tools/libwg-go/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/tunnel/tools/libwg-go/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/tunnel/tools/libwg-go/Makefile b/tunnel/tools/libwg-go/Makefile new file mode 100644 index 000000000..ca4f24404 --- /dev/null +++ b/tunnel/tools/libwg-go/Makefile @@ -0,0 +1,58 @@ +# SPDX-License-Identifier: Apache-2.0 +# +# Copyright © 2017-2023 WireGuard LLC. All Rights Reserved. + +BUILDDIR ?= $(CURDIR)/build +DESTDIR ?= $(CURDIR)/out + +NDK_GO_ARCH_MAP_x86 := 386 +NDK_GO_ARCH_MAP_x86_64 := amd64 +NDK_GO_ARCH_MAP_arm := arm +NDK_GO_ARCH_MAP_arm64 := arm64 +NDK_GO_ARCH_MAP_mips := mipsx +NDK_GO_ARCH_MAP_mips64 := mips64x + +comma := , +CLANG_FLAGS := --target=$(TARGET) --sysroot=$(SYSROOT) +export CGO_CFLAGS := $(CLANG_FLAGS) $(subst -mthumb,-marm,$(CFLAGS)) -I$(CURDIR)/vpn +export CGO_LDFLAGS := $(CLANG_FLAGS) $(patsubst -Wl$(comma)--build-id=%,-Wl$(comma)--build-id=none,$(LDFLAGS)) -Wl,-soname=libam-go.so +export GOARCH := $(NDK_GO_ARCH_MAP_$(ANDROID_ARCH_NAME)) +export GOOS := android +export CGO_ENABLED := 1 +export GOTOOLCHAIN := local + +GO_VERSION := 1.24.4 +GO_DIR := $(BUILDDIR)/go-$(GO_VERSION) + +export GOROOT := $(GO_DIR) +export PATH := $(GO_DIR)/bin:$(PATH) + +GO_PLATFORM := $(shell uname -s | tr '[:upper:]' '[:lower:]')-$(NDK_GO_ARCH_MAP_$(shell uname -m)) +GO_TARBALL := go$(GO_VERSION).$(GO_PLATFORM).tar.gz +GO_HASH_darwin-amd64 := 69bef555e114b4a2252452b6e7049afc31fbdf2d39790b669165e89525cd3f5c +GO_HASH_darwin-arm64 := 27973684b515eaf461065054e6b572d9390c05e69ba4a423076c160165336470 +GO_HASH_linux-amd64 := 77e5da33bb72aeaef1ba4418b6fe511bc4d041873cbf82e5aa6318740df98717 + +default: $(DESTDIR)/libam-go.so + +$(GRADLE_USER_HOME)/caches/golang/$(GO_TARBALL): + mkdir -p "$(dir $@)" + flock "$@.lock" -c ' \ + [ -f "$@" ] && exit 0; \ + curl -o "$@.tmp" "https://dl.google.com/go/$(GO_TARBALL)" && \ + echo "$(GO_HASH_$(GO_PLATFORM)) $@.tmp" | sha256sum -c && \ + mv "$@.tmp" "$@"' + +$(BUILDDIR)/go-$(GO_VERSION)/.prepared: $(GRADLE_USER_HOME)/caches/golang/$(GO_TARBALL) + mkdir -p "$(dir $@)" + flock "$@.lock" -c ' \ + [ -f "$@" ] && exit 0; \ + tar -C "$(dir $@)" --strip-components=1 -xzf "$^" && \ + patch -p1 -f -N -r- -d "$(dir $@)" < goruntime-boottime-over-monotonic.diff && \ + touch "$@"' + +$(DESTDIR)/libam-go.so: export PATH := $(BUILDDIR)/go-$(GO_VERSION)/bin/:$(PATH) +$(DESTDIR)/libam-go.so: $(BUILDDIR)/go-$(GO_VERSION)/.prepared go.mod + go build -tags linux,notest,cgo -ldflags="-X github.com/amnezia-vpn/amneziawg-go/ipc.socketDirectory=/data/data/$(ANDROID_PACKAGE_NAME)/cache/amneziawg -buildid=" -v -trimpath -buildvcs=false -o "$@" -buildmode c-shared + +.DELETE_ON_ERROR: \ No newline at end of file diff --git a/tunnel/tools/libwg-go/dns/dns.go b/tunnel/tools/libwg-go/dns/dns.go new file mode 100644 index 000000000..a3089e013 --- /dev/null +++ b/tunnel/tools/libwg-go/dns/dns.go @@ -0,0 +1,507 @@ +package dns + +/* +#cgo LDFLAGS: -landroid +#include "vpn_jni.h" +*/ +import "C" +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/netip" + "net/url" + "strings" + "sync" + "syscall" + "time" + + "github.com/miekg/dns" + "github.com/wgtunnel/android/shared" + "golang.org/x/sys/unix" +) + +const defaultPlain = "udp://1.1.1.1:53" + +var ( + currentConfig DNSConfig = DNSConfig{ + "plain", + "1.1.1.1:53", + } + configMu sync.RWMutex +) + +type DNSConfig struct { + Protocol string `json:"protocol"` // plain, doh, or dot + Upstream string `json:"upstream"` +} + +type Resolved struct { + V4 []netip.Addr + V6 []netip.Addr +} + +type ResolverOptions struct { + UpstreamURL string + Timeout time.Duration +} + +func DefaultOptions() ResolverOptions { + return ResolverOptions{ + UpstreamURL: defaultPlain, + Timeout: 5 * time.Second, + } +} + +//export SetDNSConfig +func SetDNSConfig(config string) { + var cfg DNSConfig + if err := json.Unmarshal([]byte(config), &cfg); err != nil { + shared.LogError("DNS", "Failed to parse DNSConfig: %v", err) + return + } + if cfg.Protocol != "plain" && cfg.Protocol != "doh" && cfg.Protocol != "dot" { + cfg.Protocol = "plain" + } + configMu.Lock() + currentConfig = cfg + configMu.Unlock() + shared.LogDebug("DNS", "DNS config updated: %s %s", cfg.Protocol, cfg.Upstream) +} + +//export ResolveBootstrap +func ResolveBootstrap(host *C.char, bypass C.int) *C.char { + h := C.GoString(host) + bp := bypass == 1 + shared.LogDebug("DNS", "ResolveBootstrap called for host=%s (bypass=%t)", h, bp) + + v4, v6, err := Resolve(h, bp) + if err != nil { + shared.LogError("DNS", "ResolveBootstrap failed for %s: %v", h, err) + return C.CString("ERR|" + err.Error()) + } + + v4Str := make([]string, len(v4)) + for i, ip := range v4 { + v4Str[i] = ip.String() + } + v6Str := make([]string, len(v6)) + for i, ip := range v6 { + v6Str[i] = ip.String() + } + + result := "v4=" + strings.Join(v4Str, ",") + ";v6=" + strings.Join(v6Str, ",") + shared.LogDebug("DNS", "ResolveBootstrap success for %s: %s", h, result) + return C.CString(result) +} + +func getConfig() DNSConfig { + configMu.RLock() + defer configMu.RUnlock() + return currentConfig +} + +func parseUpstream(upstreamURL string) (network, address string, err error) { + shared.LogDebug("DNS", "Parsing upstream URL: %s", upstreamURL) + u := upstreamURL + if !strings.Contains(u, "://") { + u = "udp://" + u + } + + parsed, err := url.Parse(u) + if err != nil { + shared.LogError("DNS", "parseUpstream failed for %q: %v", upstreamURL, err) + return "", "", fmt.Errorf("invalid upstream URL %q: %w", upstreamURL, err) + } + + switch parsed.Scheme { + case "udp", "": + network = "udp" + case "tcp": + network = "tcp" + default: + err = fmt.Errorf("unsupported upstream scheme %q (only udp:// and tcp:// supported for plain DNS)", parsed.Scheme) + shared.LogError("DNS", "%v", err) + return "", "", err + } + + host := parsed.Hostname() + port := parsed.Port() + if port == "" { + port = "53" + } + address = net.JoinHostPort(host, port) + shared.LogDebug("DNS", "Parsed upstream -> network=%s address=%s", network, address) + return network, address, nil +} + +func resolveServerAddr(ctx context.Context, address string, bypass bool) (string, error) { + host, port, err := net.SplitHostPort(address) + if err != nil { + shared.LogError("DNS", "resolveServerAddr: invalid address %q: %v", address, err) + return "", err + } + + if net.ParseIP(host) != nil { + return address, nil + } + + shared.LogDebug("DNS", "resolveServerAddr: bootstrapping upstream hostname %s (bypass=%t)", host, bypass) + + bootstrapDialer := GetDialer(bypass) + resolver := &net.Resolver{ + PreferGo: true, + Dial: func(ctx context.Context, network, _ string) (net.Conn, error) { + return bootstrapDialer.DialContext(ctx, network, "1.1.1.1:53") + }, + } + + ips, err := resolver.LookupIP(ctx, "ip", host) + if err != nil { + shared.LogError("DNS", "Failed to resolve upstream hostname %s (bypass=%t): %v", host, bypass, err) + return "", fmt.Errorf("failed to resolve upstream hostname %s: %w", host, err) + } + if len(ips) == 0 { + err = errors.New("no IPs found for upstream hostname") + shared.LogError("DNS", "%v for %s", err, host) + return "", err + } + + addr := net.JoinHostPort(ips[0].String(), port) + shared.LogDebug("DNS", "Resolved upstream %s -> %s", host, addr) + return addr, nil +} +func resolveInner(host string, ipType uint16, network, serverAddr string, bypass bool) ([]netip.Addr, error) { + req := &dns.Msg{} + req.Id = dns.Id() + req.RecursionDesired = true + req.SetQuestion(dns.Fqdn(host), ipType) + req.SetEdns0(4096, true) + + client := &dns.Client{ + Net: network, + Dialer: GetDialer(bypass), + Timeout: 5 * time.Second, + UDPSize: 4096, + } + + res, _, err := client.Exchange(req, serverAddr) + if err != nil { + shared.LogError("DNS", "resolveInner: DNS exchange failed for %s (type=%d, server=%s, bypass=%t): %v", host, ipType, serverAddr, bypass, err) + return nil, err + } + if res.Rcode != dns.RcodeSuccess { + shared.LogError("DNS", "resolveInner: DNS query failed with Rcode %d for %s", res.Rcode, host) + return nil, fmt.Errorf("DNS query failed with Rcode: %d", res.Rcode) + } + + var addr []netip.Addr + for _, ans := range res.Answer { + switch ipType { + case dns.TypeA: + if a, ok := ans.(*dns.A); ok { + if ip, err := netip.ParseAddr(a.A.String()); err == nil { + addr = append(addr, ip) + } + } + case dns.TypeAAAA: + if aaaa, ok := ans.(*dns.AAAA); ok { + if ip, err := netip.ParseAddr(aaaa.AAAA.String()); err == nil { + addr = append(addr, ip) + } + } + } + } + return addr, nil +} + +func resolvePlain(host, upstreamURL string, bypass bool) ([]netip.Addr, []netip.Addr, error) { + shared.LogDebug("DNS", "resolvePlain: %s with upstream=%s (bypass=%t)", host, upstreamURL, bypass) + network, addr, err := parseUpstream(upstreamURL) + if err != nil { + return nil, nil, err + } + + serverAddr, err := resolveServerAddr(context.Background(), addr, bypass) + if err != nil { + return nil, nil, err + } + + var wg sync.WaitGroup + var v4, v6 []netip.Addr + var v4Err, v6Err error + wg.Add(2) + go func() { v4, v4Err = resolveInner(host, dns.TypeA, network, serverAddr, bypass); wg.Done() }() + go func() { v6, v6Err = resolveInner(host, dns.TypeAAAA, network, serverAddr, bypass); wg.Done() }() + wg.Wait() + + if v4Err != nil && v6Err != nil { + shared.LogError("DNS", "resolvePlain failed for %s: both A and AAAA failed", host) + return nil, nil, errors.Join(v4Err, v6Err) + } + if len(v4) == 0 && len(v6) == 0 { + err = errors.New("no IP addresses found") + shared.LogError("DNS", "%v for %s", err, host) + return nil, nil, err + } + return v4, v6, nil +} + +func resolveDoH(host, dohURL string, bypass bool) ([]netip.Addr, []netip.Addr, error) { + shared.LogDebug("DNS", "Resolving DOH: %s with %s", host, dohURL) + var v4, v6 []netip.Addr + var v4Err, v6Err error + + var wg sync.WaitGroup + wg.Add(2) + + go func() { + defer wg.Done() + v4, v4Err = doDoHQuery(dohURL, host, dns.TypeA, bypass) + }() + go func() { + defer wg.Done() + v6, v6Err = doDoHQuery(dohURL, host, dns.TypeAAAA, bypass) + }() + wg.Wait() + + if v4Err != nil && v6Err != nil { + shared.LogError("DNS", "resolveDoH failed for %s: both queries failed", host) + return nil, nil, errors.Join(v4Err, v6Err) + } + return v4, v6, nil +} + +func doDoHQuery(dohURL, host string, qtype uint16, bypass bool) ([]netip.Addr, error) { + req := &dns.Msg{} + req.Id = dns.Id() + req.RecursionDesired = true + req.SetEdns0(4096, true) + req.SetQuestion(dns.Fqdn(host), qtype) + + transport := &http.Transport{ + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + h, port, _ := net.SplitHostPort(addr) + if net.ParseIP(h) == nil { + ips, err := CustomResolver(bypass).LookupIP(ctx, "ip", h) + if err == nil && len(ips) > 0 { + h = ips[0].String() + } + } + return GetDialer(bypass).DialContext(ctx, network, net.JoinHostPort(h, port)) + }, + } + + client := &http.Client{ + Transport: transport, + Timeout: 5 * time.Second, + } + + wire, err := req.Pack() + if err != nil { + return nil, err + } + + httpReq, err := http.NewRequestWithContext(context.Background(), "POST", dohURL, bytes.NewReader(wire)) + if err != nil { + return nil, err + } + httpReq.Header.Set("Content-Type", "application/dns-message") + httpReq.Header.Set("Accept", "application/dns-message") + + resp, err := client.Do(httpReq) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + shared.LogError("DNS", "doDoHQuery: DoH server returned HTTP %d for %s", resp.StatusCode, host) + return nil, fmt.Errorf("DoH HTTP %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var res dns.Msg + if err := res.Unpack(body); err != nil { + return nil, err + } + if res.Rcode != dns.RcodeSuccess { + shared.LogError("DNS", "doDoHQuery: DoH Rcode %d for %s", res.Rcode, host) + return nil, fmt.Errorf("DoH Rcode %d", res.Rcode) + } + + var addrs []netip.Addr + for _, ans := range res.Answer { + if qtype == dns.TypeA { + if a, ok := ans.(*dns.A); ok { + if ip, _ := netip.ParseAddr(a.A.String()); ip.Is4() { + addrs = append(addrs, ip) + } + } + } else if qtype == dns.TypeAAAA { + if aaaa, ok := ans.(*dns.AAAA); ok { + if ip, _ := netip.ParseAddr(aaaa.AAAA.String()); ip.Is6() { + addrs = append(addrs, ip) + } + } + } + } + return addrs, nil +} + +func resolveDoT(host, dotUpstream string, bypass bool) ([]netip.Addr, []netip.Addr, error) { + shared.LogDebug("DNS", "resolveDoT: %s with upstream=%s (bypass=%t)", host, dotUpstream, bypass) + + var v4, v6 []netip.Addr + var v4Err, v6Err error + + var wg sync.WaitGroup + wg.Add(2) + + go func() { + defer wg.Done() + v4, v4Err = doDoTQuery(dotUpstream, host, dns.TypeA, bypass) + }() + go func() { + defer wg.Done() + v6, v6Err = doDoTQuery(dotUpstream, host, dns.TypeAAAA, bypass) + }() + wg.Wait() + + if v4Err != nil && v6Err != nil { + shared.LogError("DNS", "resolveDoT failed for %s: both A and AAAA queries failed (bypass=%t)", host, bypass) + return nil, nil, errors.Join(v4Err, v6Err) + } + + shared.LogDebug("DNS", "resolveDoT success for %s: %d v4, %d v6 (bypass=%t)", host, len(v4), len(v6), bypass) + return v4, v6, nil +} + +func doDoTQuery(dotUpstream, host string, qtype uint16, bypass bool) ([]netip.Addr, error) { + // Normalize upstream to host:port + sni, port, err := net.SplitHostPort(dotUpstream) + if err != nil { + sni = dotUpstream + port = "853" + dotUpstream = net.JoinHostPort(sni, port) + } + + // Resolve hostname using bypass resolver + serverAddr, err := resolveServerAddr(context.Background(), dotUpstream, bypass) + if err != nil { + return nil, err + } + + req := &dns.Msg{} + req.Id = dns.Id() + req.RecursionDesired = true + req.SetEdns0(4096, true) + req.SetQuestion(dns.Fqdn(host), qtype) + + client := &dns.Client{ + Net: "tcp-tls", + Dialer: GetDialer(bypass), + Timeout: 5 * time.Second, + TLSConfig: &tls.Config{ + ServerName: sni, + InsecureSkipVerify: false, + }, + } + + res, _, err := client.Exchange(req, serverAddr) + if err != nil { + return nil, err + } + if res.Rcode != dns.RcodeSuccess { + return nil, fmt.Errorf("DoT query failed with Rcode: %d", res.Rcode) + } + + var addrs []netip.Addr + for _, ans := range res.Answer { + switch qtype { + case dns.TypeA: + if a, ok := ans.(*dns.A); ok { + if ip, _ := netip.ParseAddr(a.A.String()); ip.Is4() { + addrs = append(addrs, ip) + } + } + case dns.TypeAAAA: + if aaaa, ok := ans.(*dns.AAAA); ok { + if ip, _ := netip.ParseAddr(aaaa.AAAA.String()); ip.Is6() { + addrs = append(addrs, ip) + } + } + } + } + return addrs, nil +} + +// Resolve runs the correct protocol based on the global config +func Resolve(host string, bypass bool) ([]netip.Addr, []netip.Addr, error) { + cfg := getConfig() + shared.LogDebug("DNS", "Resolve(%s, bypass=%t) protocol=%s upstream=%s", host, bypass, cfg.Protocol, cfg.Upstream) + + var v4, v6 []netip.Addr + var err error + switch cfg.Protocol { + case "doh": + v4, v6, err = resolveDoH(host, cfg.Upstream, bypass) + case "dot": + v4, v6, err = resolveDoT(host, cfg.Upstream, bypass) + default: + v4, v6, err = resolvePlain(host, cfg.Upstream, bypass) + } + + if err != nil { + shared.LogError("DNS", "Final Resolve failed for %s: %v", host, err) + } else { + shared.LogDebug("DNS", "Resolve success for %s: %d v4, %d v6", host, len(v4), len(v6)) + } + return v4, v6, err +} + +func CustomResolver(bypass bool) *net.Resolver { + return &net.Resolver{ + PreferGo: true, + Dial: func(ctx context.Context, network, address string) (net.Conn, error) { + d := GetDialer(bypass) + return d.DialContext(ctx, network, address) + }, + } +} + +func GetDialer(bypass bool) *net.Dialer { + if !bypass { + return &net.Dialer{ + LocalAddr: nil, + } + } + + return &net.Dialer{ + Control: func(network, address string, c syscall.RawConn) error { + var opErr error + err := c.Control(func(fd uintptr) { + if C.bypass_socket(C.int(fd)) == 0 { + opErr = unix.EACCES + shared.LogError("DNS", "Failed to bypass socket FD: %d", fd) + } else { + shared.LogDebug("DNS", "Bypassed DNS socket FD: %d", fd) + } + }) + if err != nil { + return err + } + return opErr + }, + } +} diff --git a/tunnel/tools/libwg-go/dns/dns_jni.c b/tunnel/tools/libwg-go/dns/dns_jni.c new file mode 100644 index 000000000..47aae0fa0 --- /dev/null +++ b/tunnel/tools/libwg-go/dns/dns_jni.c @@ -0,0 +1,59 @@ +#include +#include + +struct go_string { const char *str; long n; }; + +extern void SetDNSConfig(struct go_string handle); +extern char* ResolveBootstrap(const char* host, int bypass); + +JNIEXPORT void JNICALL Java_com_zaneschepke_tunnel_DnsConfigManager_setDNSConfig( + JNIEnv* env, jclass clazz, jstring json) +{ + if (json == NULL) { + return; + } + + const char* cjson = (*env)->GetStringUTFChars(env, json, 0); + if (cjson != NULL) { + size_t len = (*env)->GetStringUTFLength(env, json); + + SetDNSConfig((struct go_string){ + .str = cjson, + .n = (long)len + }); + + (*env)->ReleaseStringUTFChars(env, json, cjson); + } +} + +JNIEXPORT jstring JNICALL +Java_com_zaneschepke_tunnel_DnsConfigManager_resolveBootstrap( + JNIEnv* env, + jclass clazz, + jstring host, + jboolean bypass) +{ + if (host == NULL) { + return (*env)->NewStringUTF(env, "{\"error\":\"invalid host\"}"); + } + + const char* chost = (*env)->GetStringUTFChars(env, host, NULL); + if (chost == NULL) { + return (*env)->NewStringUTF(env, "{\"error\":\"out of memory\"}"); + } + + char* resultC = ResolveBootstrap( + (char*)chost, + bypass ? 1 : 0 + ); + + (*env)->ReleaseStringUTFChars(env, host, chost); + + if (resultC == NULL) { + return (*env)->NewStringUTF(env, "{\"error\":\"null response\"}"); + } + + jstring jresult = (*env)->NewStringUTF(env, resultC); + free(resultC); + return jresult; +} \ No newline at end of file diff --git a/tunnel/tools/libwg-go/go.mod b/tunnel/tools/libwg-go/go.mod new file mode 100644 index 000000000..5f203e23c --- /dev/null +++ b/tunnel/tools/libwg-go/go.mod @@ -0,0 +1,37 @@ +module github.com/wgtunnel/android + +go 1.24.4 + +require ( + github.com/amnezia-vpn/amneziawg-go v0.2.16 + github.com/artem-russkikh/wireproxy-awg v1.0.12 + github.com/cenkalti/backoff/v5 v5.0.3 + github.com/miekg/dns v1.1.69 + golang.org/x/sys v0.38.0 +) + +require ( + github.com/MakeNowJust/heredoc/v2 v2.0.1 // indirect + github.com/go-ini/ini v1.67.0 // indirect + github.com/google/btree v1.1.3 // indirect + github.com/things-go/go-socks5 v0.1.0 // indirect + golang.org/x/crypto v0.45.0 // indirect + golang.org/x/mod v0.30.0 // indirect + golang.org/x/net v0.47.0 // indirect + golang.org/x/sync v0.18.0 // indirect + golang.org/x/time v0.14.0 // indirect + golang.org/x/tools v0.39.0 // indirect + golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect + gvisor.dev/gvisor v0.0.0-20231202080848-1f7806d17489 // indirect +) + +replace github.com/amnezia-vpn/amneziawg-go => github.com/wgtunnel/amneziawg-go v0.0.0-20260309041639-0569d899c9bf + +replace github.com/artem-russkikh/wireproxy-awg => github.com/wgtunnel/wireproxy-awg v0.0.0-20260309043206-ff4200f20ff2 + +replace github.com/things-go/go-socks5 => github.com/wgtunnel/go-socks5 v0.0.0-20260307052555-86f8d93b9534 + +// local dev +//replace github.com/amnezia-vpn/amneziawg-go => ../../../../amneziawg-go +// +//replace github.com/artem-russkikh/wireproxy-awg => ../../../../wireproxy-awg diff --git a/tunnel/tools/libwg-go/go.sum b/tunnel/tools/libwg-go/go.sum new file mode 100644 index 000000000..c499dc086 --- /dev/null +++ b/tunnel/tools/libwg-go/go.sum @@ -0,0 +1,46 @@ +github.com/MakeNowJust/heredoc/v2 v2.0.1 h1:rlCHh70XXXv7toz95ajQWOWQnN4WNLt0TdpZYIR/J6A= +github.com/MakeNowJust/heredoc/v2 v2.0.1/go.mod h1:6/2Abh5s+hc3g9nbWLe9ObDIOhaRrqsyY9MWy+4JdRM= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= +github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= +github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= +github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/miekg/dns v1.1.69 h1:Kb7Y/1Jo+SG+a2GtfoFUfDkG//csdRPwRLkCsxDG9Sc= +github.com/miekg/dns v1.1.69/go.mod h1:7OyjD9nEba5OkqQ/hB4fy3PIoxafSZJtducccIelz3g= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/wgtunnel/amneziawg-go v0.0.0-20260309041639-0569d899c9bf h1:uBuzF4PYzGsH2qswDPHPwCvobmTamPUgBtvtXMjNvPc= +github.com/wgtunnel/amneziawg-go v0.0.0-20260309041639-0569d899c9bf/go.mod h1:34ORGpgqg0Zthlumd0Vd3WCOjMlesLc+n5lhk7CHk+U= +github.com/wgtunnel/go-socks5 v0.0.0-20260307052555-86f8d93b9534 h1:hpF1TN8okGWv9j4lwPcWVMNDeu+1o52HB6NcEYw0WPs= +github.com/wgtunnel/go-socks5 v0.0.0-20260307052555-86f8d93b9534/go.mod h1:1YBHVYG7Oli5ae+Pwkp630cPAwY1pjUPmohO1n0Emg0= +github.com/wgtunnel/wireproxy-awg v0.0.0-20260309043206-ff4200f20ff2 h1:5U3FgY+o072foLwxWDg5T2XGHc/z1ixm6vEH4a1vC7k= +github.com/wgtunnel/wireproxy-awg v0.0.0-20260309043206-ff4200f20ff2/go.mod h1:42SFUX4WBnKNdHsMt2MZ1d4xGg2PwKzkjKZKAPSFlD4= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= +golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= +golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= +golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= +golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= +golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gvisor.dev/gvisor v0.0.0-20231202080848-1f7806d17489 h1:ze1vwAdliUAr68RQ5NtufWaXaOg8WUO2OACzEV+TNdE= +gvisor.dev/gvisor v0.0.0-20231202080848-1f7806d17489/go.mod h1:10sU+Uh5KKNv1+2x2A0Gvzt8FjD3ASIhorV3YsauXhk= diff --git a/tunnel/tools/libwg-go/goruntime-boottime-over-monotonic.diff b/tunnel/tools/libwg-go/goruntime-boottime-over-monotonic.diff new file mode 100644 index 000000000..de4ec080a --- /dev/null +++ b/tunnel/tools/libwg-go/goruntime-boottime-over-monotonic.diff @@ -0,0 +1,171 @@ +From 61f3ae8298d1c503cbc31539e0f3a73446c7db9d Mon Sep 17 00:00:00 2001 +From: "Jason A. Donenfeld" +Date: Tue, 21 Mar 2023 15:33:56 +0100 +Subject: [PATCH] [release-branch.go1.20] runtime: use CLOCK_BOOTTIME in + nanotime on Linux + +This makes timers account for having expired while a computer was +asleep, which is quite common on mobile devices. Note that BOOTTIME is +identical to MONOTONIC, except that it takes into account time spent +in suspend. In Linux 4.17, the kernel will actually make MONOTONIC act +like BOOTTIME anyway, so this switch will additionally unify the +timer behavior across kernels. + +BOOTTIME was introduced into Linux 2.6.39-rc1 with 70a08cca1227d in +2011. + +Fixes #24595 + +Change-Id: I7b2a6ca0c5bc5fce57ec0eeafe7b68270b429321 +--- + src/runtime/sys_linux_386.s | 4 ++-- + src/runtime/sys_linux_amd64.s | 2 +- + src/runtime/sys_linux_arm.s | 4 ++-- + src/runtime/sys_linux_arm64.s | 4 ++-- + src/runtime/sys_linux_mips64x.s | 4 ++-- + src/runtime/sys_linux_mipsx.s | 2 +- + src/runtime/sys_linux_ppc64x.s | 2 +- + src/runtime/sys_linux_s390x.s | 2 +- + 8 files changed, 12 insertions(+), 12 deletions(-) + +diff --git a/src/runtime/sys_linux_386.s b/src/runtime/sys_linux_386.s +index 12a294153d..17e3524b40 100644 +--- a/src/runtime/sys_linux_386.s ++++ b/src/runtime/sys_linux_386.s +@@ -352,13 +352,13 @@ noswitch: + + LEAL 8(SP), BX // &ts (struct timespec) + MOVL BX, 4(SP) +- MOVL $1, 0(SP) // CLOCK_MONOTONIC ++ MOVL $7, 0(SP) // CLOCK_BOOTTIME + CALL AX + JMP finish + + fallback: + MOVL $SYS_clock_gettime, AX +- MOVL $1, BX // CLOCK_MONOTONIC ++ MOVL $7, BX // CLOCK_BOOTTIME + LEAL 8(SP), CX + INVOKE_SYSCALL + +diff --git a/src/runtime/sys_linux_amd64.s b/src/runtime/sys_linux_amd64.s +index c7a89ba536..01f0a6a26e 100644 +--- a/src/runtime/sys_linux_amd64.s ++++ b/src/runtime/sys_linux_amd64.s +@@ -255,7 +255,7 @@ noswitch: + SUBQ $16, SP // Space for results + ANDQ $~15, SP // Align for C code + +- MOVL $1, DI // CLOCK_MONOTONIC ++ MOVL $7, DI // CLOCK_BOOTTIME + LEAQ 0(SP), SI + MOVQ runtime·vdsoClockgettimeSym(SB), AX + CMPQ AX, $0 +diff --git a/src/runtime/sys_linux_arm.s b/src/runtime/sys_linux_arm.s +index 7b8c4f0e04..9798a1334e 100644 +--- a/src/runtime/sys_linux_arm.s ++++ b/src/runtime/sys_linux_arm.s +@@ -11,7 +11,7 @@ + #include "textflag.h" + + #define CLOCK_REALTIME 0 +-#define CLOCK_MONOTONIC 1 ++#define CLOCK_BOOTTIME 7 + + // for EABI, as we don't support OABI + #define SYS_BASE 0x0 +@@ -374,7 +374,7 @@ finish: + + // func nanotime1() int64 + TEXT runtime·nanotime1(SB),NOSPLIT,$12-8 +- MOVW $CLOCK_MONOTONIC, R0 ++ MOVW $CLOCK_BOOTTIME, R0 + MOVW $spec-12(SP), R1 // timespec + + MOVW runtime·vdsoClockgettimeSym(SB), R4 +diff --git a/src/runtime/sys_linux_arm64.s b/src/runtime/sys_linux_arm64.s +index 38ff6ac330..6b819c5441 100644 +--- a/src/runtime/sys_linux_arm64.s ++++ b/src/runtime/sys_linux_arm64.s +@@ -14,7 +14,7 @@ + #define AT_FDCWD -100 + + #define CLOCK_REALTIME 0 +-#define CLOCK_MONOTONIC 1 ++#define CLOCK_BOOTTIME 7 + + #define SYS_exit 93 + #define SYS_read 63 +@@ -338,7 +338,7 @@ noswitch: + BIC $15, R1 + MOVD R1, RSP + +- MOVW $CLOCK_MONOTONIC, R0 ++ MOVW $CLOCK_BOOTTIME, R0 + MOVD runtime·vdsoClockgettimeSym(SB), R2 + CBZ R2, fallback + +diff --git a/src/runtime/sys_linux_mips64x.s b/src/runtime/sys_linux_mips64x.s +index 47f2da524d..a8b387f193 100644 +--- a/src/runtime/sys_linux_mips64x.s ++++ b/src/runtime/sys_linux_mips64x.s +@@ -326,7 +326,7 @@ noswitch: + AND $~15, R1 // Align for C code + MOVV R1, R29 + +- MOVW $1, R4 // CLOCK_MONOTONIC ++ MOVW $7, R4 // CLOCK_BOOTTIME + MOVV $0(R29), R5 + + MOVV runtime·vdsoClockgettimeSym(SB), R25 +@@ -336,7 +336,7 @@ noswitch: + // see walltime for detail + BEQ R2, R0, finish + MOVV R0, runtime·vdsoClockgettimeSym(SB) +- MOVW $1, R4 // CLOCK_MONOTONIC ++ MOVW $7, R4 // CLOCK_BOOTTIME + MOVV $0(R29), R5 + JMP fallback + +diff --git a/src/runtime/sys_linux_mipsx.s b/src/runtime/sys_linux_mipsx.s +index 5e6b6c1504..7f5fd2a80e 100644 +--- a/src/runtime/sys_linux_mipsx.s ++++ b/src/runtime/sys_linux_mipsx.s +@@ -243,7 +243,7 @@ TEXT runtime·walltime(SB),NOSPLIT,$8-12 + RET + + TEXT runtime·nanotime1(SB),NOSPLIT,$8-8 +- MOVW $1, R4 // CLOCK_MONOTONIC ++ MOVW $7, R4 // CLOCK_BOOTTIME + MOVW $4(R29), R5 + MOVW $SYS_clock_gettime, R2 + SYSCALL +diff --git a/src/runtime/sys_linux_ppc64x.s b/src/runtime/sys_linux_ppc64x.s +index d0427a4807..05ee9fede9 100644 +--- a/src/runtime/sys_linux_ppc64x.s ++++ b/src/runtime/sys_linux_ppc64x.s +@@ -298,7 +298,7 @@ fallback: + JMP return + + TEXT runtime·nanotime1(SB),NOSPLIT,$16-8 +- MOVD $1, R3 // CLOCK_MONOTONIC ++ MOVD $7, R3 // CLOCK_BOOTTIME + + MOVD R1, R15 // R15 is unchanged by C code + MOVD g_m(g), R21 // R21 = m +diff --git a/src/runtime/sys_linux_s390x.s b/src/runtime/sys_linux_s390x.s +index 1448670b91..7d2ee3231c 100644 +--- a/src/runtime/sys_linux_s390x.s ++++ b/src/runtime/sys_linux_s390x.s +@@ -296,7 +296,7 @@ fallback: + RET + + TEXT runtime·nanotime1(SB),NOSPLIT,$32-8 +- MOVW $1, R2 // CLOCK_MONOTONIC ++ MOVW $7, R2 // CLOCK_BOOTTIME + + MOVD R15, R7 // Backup stack pointer + +-- +2.17.1 + diff --git a/tunnel/tools/libwg-go/main.go b/tunnel/tools/libwg-go/main.go new file mode 100644 index 000000000..e91a4adbd --- /dev/null +++ b/tunnel/tools/libwg-go/main.go @@ -0,0 +1,9 @@ +package main + +import ( + _ "github.com/wgtunnel/android/dns" + _ "github.com/wgtunnel/android/proxy" + _ "github.com/wgtunnel/android/vpn" +) + +func main() {} diff --git a/tunnel/tools/libwg-go/proxy/proxy.go b/tunnel/tools/libwg-go/proxy/proxy.go new file mode 100644 index 000000000..54c92b115 --- /dev/null +++ b/tunnel/tools/libwg-go/proxy/proxy.go @@ -0,0 +1,259 @@ +package proxy + +/* +#include "vpn_jni.h" +*/ +import "C" +import ( + "context" + "net" + "sync" + "syscall" + + "github.com/amnezia-vpn/amneziawg-go/conn" + "github.com/amnezia-vpn/amneziawg-go/device" + "github.com/amnezia-vpn/amneziawg-go/ipc" + "github.com/amnezia-vpn/amneziawg-go/tun/netstack" + wireproxyawg "github.com/artem-russkikh/wireproxy-awg" + "github.com/wgtunnel/android/shared" +) + +import "C" + +var ( + ctx context.Context + cancelFunc context.CancelFunc + tag string + virtualTunnelHandles map[int32]*wireproxyawg.VirtualTun +) + +func init() { + tag = "AwgProxy" + virtualTunnelHandles = make(map[int32]*wireproxyawg.VirtualTun) +} + +//export awgStartProxy +func awgStartProxy(interfaceName string, config string, uapiPath string, bypass int32) int32 { + + conf, err := wireproxyawg.ParseConfigString(config) + if err != nil { + shared.LogError(tag, "Invalid config file", err) + return -1 + } + + handle, err := shared.GenerateUniqueHandle() + if err != nil { + shared.LogError(tag, "Error generating handle: %v", err) + return -1 + } + + setting, err := wireproxyawg.CreateIPCRequest(conf.Device, false) + + if err != nil { + shared.LogError(tag, "Create IPC request failed", err) + return -1 + } + + tun, tnet, err := netstack.CreateNetTUN(setting.DeviceAddr, setting.DNS, setting.MTU) + if err != nil { + shared.LogError(tag, "Create TUN failed", err) + return -1 + } + + name, err := tun.Name() + + shared.LogDebug(tag, "Creating device with domain blocking enabled: %v", conf.Device.DomainBlockingEnabled) + + bind := conn.NewStdNetBind() + stdBind, ok := bind.(*conn.StdNetBind) + if !ok { + return -1 + } + + if bypass == 1 { + stdBind.SetControl(protectControlFunc) + } + + statusCB := func(code device.StatusCode) { + go C.awgNotifyStatus(C.int32_t(handle), C.int32_t(code)) + } + + dev := device.NewDevice(tun, stdBind, shared.NewLogger("Tun/"+interfaceName), conf.Device.DomainBlockingEnabled, statusCB) + + err = dev.IpcSet(setting.IpcRequest) + + if err != nil { + shared.LogError(tag, "Ipc setting failed", err) + return -1 + } + + dev.DisableSomeRoamingForBrokenMobileSemantics() + + uapiFile, err := ipc.UAPIOpen(uapiPath, name) + + var uapi net.Listener + + if err != nil { + shared.LogError(tag, "UAPIOpen: %v", err) + } else { + uapi, err = ipc.UAPIListen(uapiPath, name, uapiFile) + if err != nil { + uapiFile.Close() + shared.LogError(tag, "UAPIListen: %v", err) + } else { + go func() { + for { + connection, err := uapi.Accept() + if err != nil { + return + } + go dev.IpcHandle(connection) + } + }() + } + } + + err = dev.Up() + if err != nil { + shared.LogError(tag, "Failed to bring up device", err) + uapiFile.Close() + dev.Close() + return -1 + } + + virtualTun := &wireproxyawg.VirtualTun{ + Tnet: tnet, + Dev: dev, + Logger: shared.NewLogger("Proxy"), + Uapi: uapi, + Conf: conf.Device, + PingRecord: make(map[string]uint64), + PingRecordLock: new(sync.Mutex), + } + + virtualTunnelHandles[handle] = virtualTun + + // Create cancellable context + ctx, cancelFunc = context.WithCancel(context.Background()) + + // Spawn all routines with context + for _, spawner := range conf.Routines { + shared.LogDebug(tag, "Spawning routine..") + go func(s wireproxyawg.RoutineSpawner) { + if err := s.SpawnRoutine(ctx, virtualTun); err != nil { + shared.LogError(tag, "Routine failed: %v", err) + } + }(spawner) + } + + shared.LogDebug(tag, "Done starting proxy and tunnel") + return handle +} + +//export awgUpdateProxyTunnelPeers +func awgUpdateProxyTunnelPeers(tunnelHandle int32, settings string) int32 { + handle, ok := virtualTunnelHandles[tunnelHandle] + if !ok { + shared.LogError(tag, "Tunnel is not up") + return -1 + } + + conf, err := wireproxyawg.ParseConfigString(settings) + if err != nil { + shared.LogError(tag, "Invalid config file", err) + return -1 + } + + ipcRequest, err := wireproxyawg.CreatePeerIPCRequest(conf.Device) + if err != nil { + shared.LogError(tag, "CreateIPCRequest: %v", err) + return -1 + } + + err = handle.Dev.IpcSet(ipcRequest.IpcRequest) + if err != nil { + shared.LogError(tag, "IpcSet: %v", err) + return -1 + } + + shared.LogDebug(tag, "Configuration updated successfully") + return 0 +} + +//export awgGetProxyConfig +func awgGetProxyConfig(tunnelHandle int32) *C.char { + handle, ok := virtualTunnelHandles[tunnelHandle] + if !ok { + shared.LogError(tag, "Tunnel is not up") + return nil + } + settings, err := handle.Dev.IpcGet() + if err != nil { + shared.LogError(tag, "Failed to get device config: %v", err) + return nil + } + return C.CString(settings) +} + +//export awgStopProxy +func awgStopProxy() { + if cancelFunc != nil { + shared.LogDebug(tag, "Stopping proxy routines..") + cancelFunc() + cancelFunc = nil + } + handles := make([]int32, 0, len(virtualTunnelHandles)) + for h := range virtualTunnelHandles { + handles = append(handles, h) + } + for _, handle := range handles { + awgTurnProxyTunnelOff(handle) + } + virtualTunnelHandles = make(map[int32]*wireproxyawg.VirtualTun) + shared.LogDebug(tag, "Proxy fully reset: %d handles closed", len(handles)) +} + +// control hook to bypass sockets +func protectControlFunc(network, address string, c syscall.RawConn) error { + var opErr error + err := c.Control(func(fd uintptr) { + if C.bypass_socket(C.int(fd)) == 0 { + opErr = syscall.EACCES + shared.LogError(tag, "Failed to protect socket FD: %d", fd) + } else { + shared.LogDebug(tag, "Protected socket FD: %d", fd) + } + }) + if err != nil { + return err + } + return opErr +} + +//export awgTurnProxyTunnelOff +func awgTurnProxyTunnelOff(virtualTunnelHandle int32) { + virtualTun, ok := virtualTunnelHandles[virtualTunnelHandle] + if !ok { + shared.LogError(tag, "Tunnel handle %d not found", virtualTunnelHandle) + return + } + shared.LogDebug(tag, "Tearing down tunnel %d", virtualTunnelHandle) + + // Close UAPI listener and underlying file + if virtualTun.Uapi != nil { + virtualTun.Uapi.Close() + } + + if virtualTun.Dev != nil { + virtualTun.Dev.Close() + } + + go C.awgNotifyStatus( + C.int32_t(virtualTunnelHandle), + C.int32_t(shared.StatusStop), + ) + + delete(virtualTunnelHandles, virtualTunnelHandle) + shared.ReleaseHandle(virtualTunnelHandle) + shared.LogDebug(tag, "Tunnel %d fully closed (UAPI/Dev/Bind purged)", virtualTunnelHandle) +} diff --git a/tunnel/tools/libwg-go/proxy/proxy_jni.c b/tunnel/tools/libwg-go/proxy/proxy_jni.c new file mode 100644 index 000000000..fd91182ac --- /dev/null +++ b/tunnel/tools/libwg-go/proxy/proxy_jni.c @@ -0,0 +1,300 @@ +#include +#include +#include +#include +#include + +#define LOG_TAG "AmneziaWG/BypassSocket" +#define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, LOG_TAG, __VA_ARGS__) +#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__) +#define LOGW(...) __android_log_print(ANDROID_LOG_WARN, LOG_TAG, __VA_ARGS__) + +struct go_string { const char *str; long n; }; + +extern int awgStartProxy(struct go_string ifname, struct go_string settings, struct go_string uapipath, int bypass); +extern void awgStopProxy(); +extern char *awgGetProxyConfig(int handle); +extern int awgUpdateProxyTunnelPeers(int handle, struct go_string settings); +extern void awgTurnProxyTunnelOff(int handle); + +// Global JNI state +static JavaVM *g_jvm = NULL; + +// Socket protector +static jobject g_protector = NULL; +static jmethodID g_protectMethod = NULL; + +// Status callback (nullable) +static jobject g_statusCallbackObj = NULL; +static jmethodID g_statusCallbackMethod = NULL; + +JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *reserved) { + g_jvm = vm; + LOGD("JNI_OnLoad: g_jvm cached = %p", g_jvm); + return JNI_VERSION_1_6; +} + +JNIEXPORT void JNICALL JNI_OnUnload(JavaVM *vm, void *reserved) { + JNIEnv *env = NULL; + if ((*vm)->GetEnv(vm, (void**)&env, JNI_VERSION_1_6) == JNI_OK) { + if (g_protector != NULL) { + (*env)->DeleteGlobalRef(env, g_protector); + g_protector = NULL; + } + if (g_statusCallbackObj != NULL) { + (*env)->DeleteGlobalRef(env, g_statusCallbackObj); + g_statusCallbackObj = NULL; + } + } + g_protectMethod = NULL; + g_statusCallbackMethod = NULL; + g_jvm = NULL; + LOGD("JNI_OnUnload: cleared all globals"); +} + +JNIEXPORT jint JNICALL Java_com_zaneschepke_tunnel_ProxyBackend_awgStartProxy(JNIEnv *env, jclass c, jstring ifname, jstring settings, jstring uapipath, jint bypass) +{ + const char *ifname_str = (*env)->GetStringUTFChars(env, ifname, 0); + size_t ifname_len = (*env)->GetStringUTFLength(env, ifname); + const char *settings_str = (*env)->GetStringUTFChars(env, settings, 0); + size_t settings_len = (*env)->GetStringUTFLength(env, settings); + const char *uapipath_str = (*env)->GetStringUTFChars(env, uapipath, 0); + size_t uapipath_len = (*env)->GetStringUTFLength(env, uapipath); + int ret = awgStartProxy((struct go_string){ + .str = ifname_str, + .n = ifname_len + }, (struct go_string){ + .str = settings_str, + .n = settings_len + }, (struct go_string){ + .str = uapipath_str, + .n = uapipath_len + },bypass); + (*env)->ReleaseStringUTFChars(env, ifname, ifname_str); + (*env)->ReleaseStringUTFChars(env, settings, settings_str); + (*env)->ReleaseStringUTFChars(env, uapipath, uapipath_str); + return ret; +} + +JNIEXPORT void JNICALL Java_com_zaneschepke_tunnel_ProxyBackend_awgStopProxy(JNIEnv *env, jclass c) +{ + awgStopProxy(); +} + +JNIEXPORT void JNICALL Java_com_zaneschepke_tunnel_ProxyBackend_awgTurnProxyTunnelOff(JNIEnv *env, jclass c, jint handle) +{ + awgTurnProxyTunnelOff(handle); +} + +JNIEXPORT jstring JNICALL Java_com_zaneschepke_tunnel_ProxyBackend_awgGetProxyConfig(JNIEnv *env, jclass c, jint handle) +{ + jstring ret; + char *config = awgGetProxyConfig(handle); + if (!config) + return NULL; + ret = (*env)->NewStringUTF(env, config); + free(config); + return ret; +} + +JNIEXPORT void JNICALL Java_com_zaneschepke_tunnel_ProxyBackend_awgSetSocketProtector( + JNIEnv *env, jclass c, jobject protector) { + + LOGD("JNI: awgSetSocketProtector called from Kotlin - protector=%p", protector); + + // Clear old protector + if (g_protector != NULL) { + (*env)->DeleteGlobalRef(env, g_protector); + g_protector = NULL; + g_protectMethod = NULL; + LOGD("JNI: Cleared previous socket protector"); + } + + if (protector != NULL) { + g_protector = (*env)->NewGlobalRef(env, protector); + LOGD("JNI: Created new global ref for protector = %p", g_protector); + + jclass protectorClass = (*env)->GetObjectClass(env, protector); + if (protectorClass != NULL) { + g_protectMethod = (*env)->GetMethodID(env, protectorClass, "bypass", "(I)I"); + (*env)->DeleteLocalRef(env, protectorClass); + } + + if (g_protectMethod != NULL) { + LOGD("JNI: Socket protector SUCCESSFULLY REGISTERED (methodID = %p)", g_protectMethod); + } else { + LOGE("JNI: FAILED to get bypass method ID"); + } + } else { + LOGD("JNI: Socket protector CLEARED (null passed)"); + } +} + +int bypass_socket(int fd) { + if (fd < 0) { + LOGE("Invalid FD passed to bypass_socket: %d", fd); + return 0; // Fail early on bad FD + } + + JNIEnv *env = NULL; + jboolean attached = JNI_FALSE; + jint rs = -1; + + LOGD("bypass_socket called with FD: %d", fd); + + if (g_jvm == NULL) { + LOGE("g_jvm is NULL - not initialized in JNI_OnLoad?"); + return 0; + } + + rs = (*g_jvm)->GetEnv(g_jvm, (void **)&env, JNI_VERSION_1_6); + LOGD("GetEnv returned: %d (env=%p)", rs, env); + + if (rs == JNI_EDETACHED) { + LOGD("Thread detached, attempting AttachCurrentThread"); + int retries = 3; + while (retries-- > 0 && (*g_jvm)->AttachCurrentThread(g_jvm, &env, NULL) != JNI_OK) { + usleep(10000); // 10ms backoff + } + if (retries < 0) { + LOGE("AttachCurrentThread failed after retries"); + return 0; + } + attached = JNI_TRUE; + LOGD("Attached successfully, env=%p", env); + } else if (rs != JNI_OK) { + LOGE("GetEnv failed with %d (not OK or detached)", rs); + return 0; + } else { + LOGD("Thread already attached, env=%p", env); + } + + if (env == NULL) { + LOGE("Env is NULL after attachment/GetEnv"); + if (attached) { + (*g_jvm)->DetachCurrentThread(g_jvm); + } + return 0; + } + + if (g_protector == NULL) { + LOGE("g_protector is NULL - VpnService ref not set?"); + if (attached) { + (*g_jvm)->DetachCurrentThread(g_jvm); + } + return 0; + } + LOGD("g_protector ref valid: %p", g_protector); + + if (g_protectMethod == NULL) { + LOGE("g_protectMethod is NULL - method ID not cached?"); + if (attached) { + (*g_jvm)->DetachCurrentThread(g_jvm); + } + return 0; + } + LOGD("g_protectMethod valid"); + + // Clear any pending exceptions before call + if ((*env)->ExceptionCheck(env)) { + LOGE("Pending exception before CallIntMethod - clearing"); + (*env)->ExceptionClear(env); + } + + int result = (*env)->CallIntMethod(env, g_protector, g_protectMethod, fd); + LOGD("CallIntMethod returned: %d", result); + + // Check for exceptions after call + if ((*env)->ExceptionCheck(env)) { + LOGE("Exception thrown from CallIntMethod - describing"); + (*env)->ExceptionDescribe(env); // Logs the exception to logcat + (*env)->ExceptionClear(env); + result = 0; // Fail on exception + } + + if (attached) { + (*g_jvm)->DetachCurrentThread(g_jvm); + LOGD("Detached thread"); + } + + LOGD("bypass_socket returning: %d for FD %d", result, fd); + return result; +} + +JNIEXPORT jint JNICALL Java_com_zaneschepke_tunnel_ProxyBackend_awgUpdateProxyTunnelPeers(JNIEnv *env, jclass c, jint handle, jstring settings) +{ + const char *settings_str = (*env)->GetStringUTFChars(env, settings, 0); + size_t settings_len = (*env)->GetStringUTFLength(env, settings); + int ret = awgUpdateProxyTunnelPeers(handle, (struct go_string){ + .str = settings_str, + .n = settings_len + }); + (*env)->ReleaseStringUTFChars(env, settings, settings_str); + return ret; +} + +JNIEXPORT void JNICALL Java_com_zaneschepke_tunnel_VpnBackend_awgSetStatusCallback( + JNIEnv *env, jclass clazz, jobject callback) { + LOGD("JNI: awgSetStatusCallback called - callback=%p", callback); + + if (g_statusCallbackObj != NULL) { + (*env)->DeleteGlobalRef(env, g_statusCallbackObj); + g_statusCallbackObj = NULL; + g_statusCallbackMethod = NULL; + } + + if (callback != NULL) { + g_statusCallbackObj = (*env)->NewGlobalRef(env, callback); + jclass callbackClass = (*env)->GetObjectClass(env, callback); + if (callbackClass != NULL) { + // UPDATED signature: (II)V → only handle + statusCode + g_statusCallbackMethod = (*env)->GetMethodID(env, callbackClass, + "onStatusChanged", "(II)V"); + (*env)->DeleteLocalRef(env, callbackClass); + } + if (g_statusCallbackMethod != NULL) { + LOGD("JNI: Status callback SUCCESSFULLY REGISTERED (2-param)"); + } else { + LOGE("JNI: FAILED to get onStatusChanged method ID"); + } + } else { + LOGD("JNI: Status callback CLEARED"); + } +} + + +/* Helper that both VPN and Proxy Go code will call (modelled exactly after your bypass_socket) */ +void awgNotifyStatus(int32_t handle, int32_t code) { + JNIEnv *env = NULL; + jboolean attached = JNI_FALSE; + + if (g_jvm == NULL || g_statusCallbackObj == NULL || g_statusCallbackMethod == NULL) { + LOGW("JNI: awgNotifyStatus called but no callback registered"); + return; + } + + jint rs = (*g_jvm)->GetEnv(g_jvm, (void**)&env, JNI_VERSION_1_6); + if (rs == JNI_EDETACHED) { + if ((*g_jvm)->AttachCurrentThread(g_jvm, &env, NULL) != JNI_OK) { + LOGE("JNI: AttachCurrentThread failed"); + return; + } + attached = JNI_TRUE; + } else if (rs != JNI_OK) { + return; + } + + if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env); + + (*env)->CallVoidMethod(env, g_statusCallbackObj, g_statusCallbackMethod, + (jint)handle, (jint)code); + + if ((*env)->ExceptionCheck(env)) { + (*env)->ExceptionDescribe(env); + (*env)->ExceptionClear(env); + } + + if (attached) { + (*g_jvm)->DetachCurrentThread(g_jvm); + } +} \ No newline at end of file diff --git a/tunnel/tools/libwg-go/shared/const.go b/tunnel/tools/libwg-go/shared/const.go new file mode 100644 index 000000000..49eee6f7b --- /dev/null +++ b/tunnel/tools/libwg-go/shared/const.go @@ -0,0 +1,5 @@ +package shared + +const ( + StatusStop = 99 +) diff --git a/tunnel/tools/libwg-go/shared/handle.go b/tunnel/tools/libwg-go/shared/handle.go new file mode 100644 index 000000000..69867fdf7 --- /dev/null +++ b/tunnel/tools/libwg-go/shared/handle.go @@ -0,0 +1,46 @@ +package shared + +/* +#include +*/ +import "C" +import ( + "fmt" + "math" + "sync" +) + +var ( + handleMu sync.Mutex + usedHandles = make(map[int32]bool) + nextHandle int32 = 0 +) + +// GenerateUniqueHandle returns a globally unique handle across all backends. +func GenerateUniqueHandle() (int32, error) { + handleMu.Lock() + defer handleMu.Unlock() + + for i := 0; i < math.MaxInt32; i++ { + h := nextHandle + nextHandle++ + if nextHandle < 0 { + nextHandle = 0 + } + if !usedHandles[h] { + usedHandles[h] = true + return h, nil + } + } + return -1, fmt.Errorf("no free handles available") +} + +// ReleaseHandle marks a handle as free again. +func ReleaseHandle(handle int32) { + if handle < 0 { + return + } + handleMu.Lock() + delete(usedHandles, handle) + handleMu.Unlock() +} diff --git a/tunnel/tools/libwg-go/shared/logger.go b/tunnel/tools/libwg-go/shared/logger.go new file mode 100644 index 000000000..5b6fd580e --- /dev/null +++ b/tunnel/tools/libwg-go/shared/logger.go @@ -0,0 +1,62 @@ +package shared + +// #cgo LDFLAGS: -llog +// #include +import "C" +import ( + "fmt" + "os" + "os/signal" + "runtime" + "unsafe" + + "github.com/amnezia-vpn/amneziawg-go/device" + "golang.org/x/sys/unix" +) + +func cstring(s string) *C.char { + b, err := unix.BytePtrFromString(s) + if err != nil { + b := [1]C.char{} + return &b[0] + } + return (*C.char)(unsafe.Pointer(b)) +} + +func init() { + signals := make(chan os.Signal) + signal.Notify(signals, unix.SIGUSR2) + go func() { + buf := make([]byte, os.Getpagesize()) + for { + select { + case <-signals: + n := runtime.Stack(buf, true) + if n == len(buf) { + n-- + } + buf[n] = 0 + C.__android_log_write(C.ANDROID_LOG_ERROR, cstring("AmneziaWG/Stacktrace"), (*C.char)(unsafe.Pointer(&buf[0]))) + } + } + }() +} + +func LogDebug(tag string, format string, args ...interface{}) { + C.__android_log_write(C.ANDROID_LOG_DEBUG, cstring(tag), cstring(fmt.Sprintf(format, args...))) +} + +func LogError(tag string, format string, args ...interface{}) { + C.__android_log_write(C.ANDROID_LOG_ERROR, cstring(tag), cstring(fmt.Sprintf(format, args...))) +} + +func NewLogger(tag string) *device.Logger { + return &device.Logger{ + Verbosef: func(format string, args ...any) { + LogDebug(tag, format, args...) + }, + Errorf: func(format string, args ...any) { + LogError(tag, format, args...) + }, + } +} diff --git a/tunnel/tools/libwg-go/vpn/vpn.go b/tunnel/tools/libwg-go/vpn/vpn.go new file mode 100644 index 000000000..1fb1e1729 --- /dev/null +++ b/tunnel/tools/libwg-go/vpn/vpn.go @@ -0,0 +1,248 @@ +/* SPDX-License-Identifier: Apache-2.0 + * + * Copyright © 2017-2022 Jason A. Donenfeld . All Rights Reserved. + */ + +package vpn + +/* +#include "vpn_jni.h" +*/ +import "C" +import ( + "net" + "runtime/debug" + "strings" + + "github.com/amnezia-vpn/amneziawg-go/conn" + "github.com/amnezia-vpn/amneziawg-go/device" + "github.com/amnezia-vpn/amneziawg-go/ipc" + "github.com/amnezia-vpn/amneziawg-go/tun" + wireproxyawg "github.com/artem-russkikh/wireproxy-awg" + "github.com/wgtunnel/android/shared" + "golang.org/x/sys/unix" +) + +type TunnelHandle struct { + device *device.Device + uapi net.Listener +} + +var ( + tag string + tunnelHandles = make(map[int32]TunnelHandle) +) + +func init() { + tag = "AwgVPN" +} + +//export awgTurnOn +func awgTurnOn(interfaceName string, tunFd int32, settings string, uapiPath string) int32 { + tunnel, name, err := tun.CreateUnmonitoredTUNFromFD(int(tunFd)) + + if err != nil { + unix.Close(int(tunFd)) + shared.LogError(tag, "CreateUnmonitoredTUNFromFD: %v", err) + return -1 + } + + conf, err := wireproxyawg.ParseConfigString(settings) + if err != nil { + shared.LogError(tag, "Invalid config file", err) + unix.Close(int(tunFd)) + if tunnel != nil { + tunnel.Close() + } + return -1 + } + + shared.LogDebug(tag, "Creating device with domain blocking enabled: %v", conf.Device.DomainBlockingEnabled) + + handle, err2 := shared.GenerateUniqueHandle() + + statusCB := func(code device.StatusCode) { + go C.awgNotifyStatus(C.int32_t(handle), C.int32_t(code)) + } + + tunDevice := device.NewDevice(tunnel, conn.NewStdNetBind(), shared.NewLogger("Tun/"+interfaceName), conf.Device.DomainBlockingEnabled, statusCB) + + ipcRequest, err := wireproxyawg.CreateIPCRequest(conf.Device, false) + if err != nil { + shared.LogError(tag, "CreateIPCRequest: %v", err) + unix.Close(int(tunFd)) + shared.ReleaseHandle(handle) + return -1 + } + + err = tunDevice.IpcSet(ipcRequest.IpcRequest) + if err != nil { + unix.Close(int(tunFd)) + shared.ReleaseHandle(handle) + shared.LogError(tag, "IpcSet: %v", err) + return -1 + } + tunDevice.DisableSomeRoamingForBrokenMobileSemantics() + + var uapi net.Listener + + uapiFile, err := ipc.UAPIOpen(uapiPath, name) + + if err != nil { + shared.LogError(tag, "UAPIOpen: %v", err) + } else { + uapi, err = ipc.UAPIListen(uapiPath, name, uapiFile) // uapiPath as rootdir, name as interface + if err != nil { + uapiFile.Close() + shared.LogError(tag, "UAPIListen: %v", err) + } else { + go func() { + for { + connection, err := uapi.Accept() + if err != nil { + return + } + go tunDevice.IpcHandle(connection) + } + }() + } + } + + err = tunDevice.Up() + if err != nil { + shared.LogError(tag, "Unable to bring up device: %v", err) + uapiFile.Close() + shared.ReleaseHandle(handle) + tunDevice.Close() + return -1 + } + shared.LogDebug(tag, "Device started") + + if err2 != nil { + shared.LogError(tag, "Unable to find empty handle", err2) + uapiFile.Close() + shared.ReleaseHandle(handle) + tunDevice.Close() + return -1 + } + + tunnelHandles[handle] = TunnelHandle{device: tunDevice, uapi: uapi} + + return handle +} + +//export awgUpdateTunnelPeers +func awgUpdateTunnelPeers(tunnelHandle int32, settings string) int32 { + handle, ok := tunnelHandles[tunnelHandle] + if !ok { + shared.LogError(tag, "Tunnel is not up") + return -1 + } + + conf, err := wireproxyawg.ParseConfigString(settings) + if err != nil { + shared.LogError(tag, "Invalid config file", err) + return -1 + } + + ipcRequest, err := wireproxyawg.CreatePeerIPCRequest(conf.Device) + if err != nil { + shared.LogError(tag, "CreateIPCRequest: %v", err) + return -1 + } + + err = handle.device.IpcSet(ipcRequest.IpcRequest) + if err != nil { + shared.LogError(tag, "IpcSet: %v", err) + return -1 + } + + shared.LogDebug(tag, "Configuration updated successfully") + return 0 +} + +//export awgTurnOff +func awgTurnOff(tunnelHandle int32) { + handle, ok := tunnelHandles[tunnelHandle] + if !ok { + shared.LogError(tag, "Tunnel is not up") + return + } + + go C.awgNotifyStatus( + C.int32_t(tunnelHandle), + C.int32_t(shared.StatusStop), + ) + + delete(tunnelHandles, tunnelHandle) + if handle.uapi != nil { + handle.uapi.Close() + } + handle.device.Close() + shared.ReleaseHandle(tunnelHandle) +} + +//export awgGetSocketV4 +func awgGetSocketV4(tunnelHandle int32) int32 { + handle, ok := tunnelHandles[tunnelHandle] + if !ok { + return -1 + } + bind, _ := handle.device.Bind().(conn.PeekLookAtSocketFd) + if bind == nil { + return -1 + } + fd, err := bind.PeekLookAtSocketFd4() + if err != nil { + return -1 + } + return int32(fd) +} + +//export awgGetSocketV6 +func awgGetSocketV6(tunnelHandle int32) int32 { + handle, ok := tunnelHandles[tunnelHandle] + if !ok { + return -1 + } + bind, _ := handle.device.Bind().(conn.PeekLookAtSocketFd) + if bind == nil { + return -1 + } + fd, err := bind.PeekLookAtSocketFd6() + if err != nil { + return -1 + } + return int32(fd) +} + +//export awgGetConfig +func awgGetConfig(tunnelHandle int32) *C.char { + handle, ok := tunnelHandles[tunnelHandle] + if !ok { + return nil + } + settings, err := handle.device.IpcGet() + if err != nil { + return nil + } + return C.CString(settings) +} + +//export awgVersion +func awgVersion() *C.char { + info, ok := debug.ReadBuildInfo() + if !ok { + return C.CString("unknown") + } + for _, dep := range info.Deps { + if dep.Path == "github.com/amnezia-vpn/amneziawg-go" { + parts := strings.Split(dep.Version, "-") + if len(parts) == 3 && len(parts[2]) == 12 { + return C.CString(parts[2][:7]) + } + return C.CString(dep.Version) + } + } + return C.CString("unknown") +} diff --git a/tunnel/tools/libwg-go/vpn/vpn_jni.c b/tunnel/tools/libwg-go/vpn/vpn_jni.c new file mode 100644 index 000000000..2a51b2e97 --- /dev/null +++ b/tunnel/tools/libwg-go/vpn/vpn_jni.c @@ -0,0 +1,91 @@ +/* SPDX-License-Identifier: Apache-2.0 + * + * Copyright © 2017-2021 Jason A. Donenfeld . All Rights Reserved. + */ + +#include +#include +#include +#include + +struct go_string { const char *str; long n; }; +extern int awgTurnOn(struct go_string ifname, int tun_fd, struct go_string settings, struct go_string uapipath); +extern void awgTurnOff(int handle); +extern int awgGetSocketV4(int handle); +extern int awgGetSocketV6(int handle); +extern char *awgGetConfig(int handle); +extern char *awgVersion(); +extern int awgUpdateTunnelPeers(int handle, struct go_string settings); + +JNIEXPORT jint JNICALL Java_com_zaneschepke_tunnel_VpnBackend_awgTurnOn(JNIEnv *env, jclass c, jstring ifname, jint tun_fd, jstring settings, jstring uapipath) +{ +const char *ifname_str = (*env)->GetStringUTFChars(env, ifname, 0); +size_t ifname_len = (*env)->GetStringUTFLength(env, ifname); +const char *settings_str = (*env)->GetStringUTFChars(env, settings, 0); +size_t settings_len = (*env)->GetStringUTFLength(env, settings); +const char *uapipath_str = (*env)->GetStringUTFChars(env, uapipath, 0); +size_t uapipath_len = (*env)->GetStringUTFLength(env, uapipath); +int ret = awgTurnOn((struct go_string){ +.str = ifname_str, +.n = ifname_len +}, tun_fd, (struct go_string){ +.str = settings_str, +.n = settings_len +}, (struct go_string){ +.str = uapipath_str, +.n = uapipath_len +}); +(*env)->ReleaseStringUTFChars(env, ifname, ifname_str); +(*env)->ReleaseStringUTFChars(env, settings, settings_str); +(*env)->ReleaseStringUTFChars(env, uapipath, uapipath_str); +return ret; +} + +JNIEXPORT void JNICALL Java_com_zaneschepke_tunnel_VpnBackend_awgTurnOff(JNIEnv *env, jclass c, jint handle) +{ +awgTurnOff(handle); +} + +JNIEXPORT jint JNICALL Java_com_zaneschepke_tunnel_VpnBackend_awgGetSocketV4(JNIEnv *env, jclass c, jint handle) +{ +return awgGetSocketV4(handle); +} + +JNIEXPORT jint JNICALL Java_com_zaneschepke_tunnel_VpnBackend_awgGetSocketV6(JNIEnv *env, jclass c, jint handle) +{ +return awgGetSocketV6(handle); +} + +JNIEXPORT jstring JNICALL Java_com_zaneschepke_tunnel_VpnBackend_awgGetConfig(JNIEnv *env, jclass c, jint handle) +{ +jstring ret; +char *config = awgGetConfig(handle); +if (!config) +return NULL; +ret = (*env)->NewStringUTF(env, config); +free(config); +return ret; +} + +JNIEXPORT jstring JNICALL Java_com_zaneschepke_tunnel_VpnBackend_awgVersion(JNIEnv *env, jclass c) +{ +jstring ret; +char *version = awgVersion(); +if (!version) +return NULL; +ret = (*env)->NewStringUTF(env, version); +free(version); +return ret; +} + +JNIEXPORT jint JNICALL Java_com_zaneschepke_tunnel_VpnBackend_awgUpdateTunnelPeers(JNIEnv *env, jclass c, jint handle, jstring settings) +{ +const char *settings_str = (*env)->GetStringUTFChars(env, settings, 0); +size_t settings_len = (*env)->GetStringUTFLength(env, settings); +int ret = awgUpdateTunnelPeers(handle, (struct go_string){ +.str = settings_str, +.n = settings_len +}); +(*env)->ReleaseStringUTFChars(env, settings, settings_str); +return ret; +} diff --git a/tunnel/tools/libwg-go/vpn/vpn_jni.h b/tunnel/tools/libwg-go/vpn/vpn_jni.h new file mode 100644 index 000000000..4e02f2f51 --- /dev/null +++ b/tunnel/tools/libwg-go/vpn/vpn_jni.h @@ -0,0 +1,8 @@ +#pragma once + +#include + +int bypass_socket(int fd); + +/* Status callback bridge used by Go/C */ +void awgNotifyStatus(int32_t handle, int32_t code); \ No newline at end of file