Compare commits

..

60 Commits

Author SHA1 Message Date
Zane Schepke 9e0e17787d chore: bump version, update screenshots 2025-04-06 02:44:04 -04:00
Zane Schepke b4c5b51644 fix: tunnel service handling of multiple tunnels and tunnel job restarts 2025-04-06 01:46:22 -04:00
Zane Schepke 76191c46f3 fix: pop backstack on config save 2025-04-05 21:10:47 -04:00
Zane Schepke ecf5036f56 fix: nav backstack bug 2025-04-05 20:58:20 -04:00
Zane Schepke 1c0d968cfb fix: auto tunnel tile state bug 2025-04-05 20:17:16 -04:00
Zane Schepke bda1a2080a fix: tunnel tile default to previously active tunnel 2025-04-05 19:58:39 -04:00
Zane Schepke 14a71e3118 fix: stop kill switch on trusted auto tunnel bug 2025-04-05 19:12:55 -04:00
Zane Schepke 57391290c5 refactor: major ui refactor and bug fixes (#660)
refactor: improved error handling
fix: vpn kill switch with domain endpoints resolution
fix: bugs in ping restarts and config change restarts
feat: new nav bar
feat: expose network status info
feat: ui enhancements
2025-04-05 18:11:21 -04:00
Zane Schepke cd623c0c0c fix: ui bug 2025-04-02 00:54:44 -04:00
Zane Schepke 212c6cf088 feat: add screen security to config screen 2025-04-01 22:45:36 -04:00
Zane Schepke ca47127bff refactor: state management (#656) 2025-04-01 22:18:38 -04:00
Zane Schepke e63733286c refactor: advanced screen 2025-03-31 17:52:14 -04:00
Zane Schepke 36c76565f7 refactor: optimize log toggle 2025-03-31 17:08:39 -04:00
Zane Schepke 47f8de8c57 refactor: support screen, add matrix link 2025-03-31 16:40:40 -04:00
Zane Schepke 5740012101 chore: fmt and bump hilt 2025-03-31 14:30:51 -04:00
Zane Schepke 6f5bb24cfa fix: dns64 and ip version detection improvements 2025-03-31 13:13:44 -04:00
Zane Schepke 5f791ffda1 chore: bump ksp 2025-03-30 18:48:47 -04:00
Zane Schepke ec244eeda3 chore: bump deps 2025-03-30 18:46:26 -04:00
Hendrik Volkmer ff2a2cc082 feat: Add option to add config via URL (#623)
Co-authored-by: Zane Schepke <zanecschepke@gmail.com>
2025-03-30 18:37:07 -04:00
Zane Schepke a873546e9e fix: bugs in config changes and ping tunnel jobs (#650) 2025-03-30 18:31:26 -04:00
Zane Schepke 757669ddbe docs: update matrix link 2025-03-23 15:35:18 -04:00
Zane Schepke c71c4e5b29 chore: bump version and notes 2025-03-19 22:35:21 -04:00
Zane Schepke 7f0fea3766 fix: improve wifi monitoring to better handle permission changes 2025-03-19 21:51:54 -04:00
Zane Schepke 53c19762ef fix: attempt to improve tile sync 2025-03-16 23:12:39 -04:00
Zane Schepke c98fa04f73 fix: auto tunnel and tunnel regressions 2025-03-16 20:10:44 -04:00
Zane Schepke aba0f7d4d3 chore: bump deps 2025-03-16 02:05:55 -04:00
Zane Schepke fa517b2124 fix: race conditions (#621) 2025-03-16 02:04:09 -04:00
Zane Schepke d7e2648393 docs: update readme links 2025-03-15 19:22:34 -04:00
Zane Schepke 53ff3bb1e5 chore: bump version with notes 2025-03-15 13:10:53 -04:00
Zane Schepke 97ede3d5b4 fix: set prefer ipv4 as default to mimic old wg behavior 2025-03-15 12:44:56 -04:00
Zane Schepke dcd15f7bd8 fix: adding new peer bug
Closes #612
2025-03-15 11:30:10 -04:00
Zane Schepke 6031d85edd fix: re-enable shortcuts
Closes #616
2025-03-15 00:16:02 -04:00
Weblate (bot) a71f8f86b1 feat: Translations update from Hosted Weblate (#556)
Co-authored-by: Zane Schepke <zanecschepke@gmail.com>
Co-authored-by: solokot <solokot@gmail.com>
Co-authored-by: lateweb <weblate@techkoala.net>
Co-authored-by: Matthaiks <kitynska@gmail.com>
Co-authored-by: Kachelkaiser <kachelkaiser@outlook.com>
Co-authored-by: CyanWolf <hydemr@pm.me>
Co-authored-by: Henrik Sozzi <henrik_sozzi@hotmail.com>
Co-authored-by: 大王叫我来巡山 <hamburger2048@users.noreply.hosted.weblate.org>
Co-authored-by: x86_64-pc-linux-gnu <x86_64-pc-linux-gnu@proton.me>
Co-authored-by: mak7im01 <mak7im02@gmail.com>
Co-authored-by: heykanspor <meingithub@heykan.de>
2025-03-14 22:51:26 -04:00
Zane Schepke 007c9f4c5d fix: static ipv6 endpoint bug 2025-03-14 22:49:08 -04:00
Zane Schepke e32a99db77 chore: fix fi 2025-03-08 23:37:16 -05:00
Zane Schepke 6670a62e2f chore: fix fastlane 2025-03-08 23:07:02 -05:00
Zane Schepke 34b20bd7f7 fix: pt region localization 2025-03-08 22:52:53 -05:00
Zane Schepke 01e15099ca chore: bump version with notes 2025-03-08 22:27:17 -05:00
Zane Schepke 8f2fd93e77 fix: improve local logging 2025-03-08 22:11:23 -05:00
𝗛𝗼𝗹𝗶 94197c9943 feat: Update Turkish Translation (#605) 2025-03-08 21:32:41 -05:00
Zane Schepke 39fc9014d8 fix: bump deps for dns fix 2025-03-08 21:31:31 -05:00
Zane Schepke 8860b45230 fix: service manager bug 2025-03-02 17:51:48 -05:00
Zane Schepke e220b26d88 fix: temporarily disallow start/stop of pinger while tunnel running 2025-03-02 17:25:29 -05:00
Zane Schepke 2302b473b4 refactor: tunnel toggling 2025-03-02 16:47:55 -05:00
Zane Schepke b01473073f fix: add restart support htc quickboot 2025-03-02 12:09:57 -05:00
Zane Schepke 3ae9a24ca4 refactor: restore on restart/update 2025-03-02 12:01:28 -05:00
Zane Schepke dc3f7fa736 refactor: improve service manager 2025-03-02 11:38:36 -05:00
Zane Schepke 93d6f8aa45 fix: module flavors 2025-03-01 21:07:47 -05:00
Zane Schepke 3ea4aea5cf fix: improve network status monitoring 2025-03-01 20:17:19 -05:00
Zane Schepke 68b41c8925 fix: startup/nav bugs 2025-03-01 12:15:37 -05:00
Zane Schepke 06de1f24c2 fix: tunnel race condition 2025-03-01 11:21:07 -05:00
Zane Schepke a39feeeea6 fix: proguard rules 2025-02-23 23:21:58 -05:00
Zane Schepke c1619ff012 temp disable proguard 2025-02-23 23:15:40 -05:00
Zane Schepke 2534b86005 fix: add back rules 2025-02-23 22:50:20 -05:00
Zane Schepke 15c550737c bump deps, remove redudant proguard rules 2025-02-23 22:39:15 -05:00
Zane Schepke e1e7e27bb5 fix: proguard (#590) 2025-02-23 16:41:51 -05:00
Zane Schepke 8021c133a5 fix: proguard 2025-02-23 15:58:08 -05:00
Zane Schepke 6009445a15 bump tunnel deps 2025-02-23 15:16:12 -05:00
Zane Schepke f80af9dd5e fix: tunnel state bug 2025-02-22 23:21:25 -05:00
Zane Schepke 3f912ed532 fix: tunnel change restart logic 2025-02-22 13:07:15 -05:00
282 changed files with 8957 additions and 6814 deletions
+7 -7
View File
@@ -4,7 +4,7 @@ WG Tunnel
<div align="center">
An alternative Android client app for [WireGuard®](https://www.wireguard.com/)
An alternative Android client app for [WireGuard](https://www.wireguard.com/)
and [AmneziaWG](https://docs.amnezia.org/documentation/amnezia-wg/)
<br />
<br />
@@ -23,14 +23,14 @@ and [AmneziaWG](https://docs.amnezia.org/documentation/amnezia-wg/)
[![Google Play](https://img.shields.io/badge/Google_Play-414141?style=for-the-badge&logo=google-play&logoColor=white)](https://play.google.com/store/apps/details?id=com.zaneschepke.wireguardautotunnel)
[![F-Droid](https://img.shields.io/static/v1?style=for-the-badge&message=F-Droid&color=1976D2&logo=F-Droid&logoColor=FFFFFF&label=)](https://f-droid.org/packages/com.zaneschepke.wireguardautotunnel/)
[![Personal](https://img.shields.io/static/v1?style=for-the-badge&message=Personal&color=1976D2&logo=F-Droid&logoColor=FFFFFF&label=)](https://github.com/zaneschepke/fdroid)
[![Obtainium](https://img.shields.io/badge/Obtainium-414141?style=for-the-badge&logo=Obtainium&logoColor=white)](https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22com.zaneschepke.wireguardautotunnel%22%2C%22url%22%3A%22https%3A%2F%2Fgithub.com%2Fzaneschepke%2Fwgtunnel%22%2C%22author%22%3A%22zaneschepke%22%2C%22name%22%3A%22WG%20Tunnel%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22includePrereleases%5C%22%3Afalse%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22filterReleaseNotesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22verifyLatestTag%5C%22%3Atrue%2C%5C%22sortMethodChoice%5C%22%3A%5C%22date%5C%22%2C%5C%22useLatestAssetDateAsReleaseDate%5C%22%3Afalse%2C%5C%22releaseTitleAsVersion%5C%22%3Afalse%2C%5C%22trackOnly%5C%22%3Afalse%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%5C%22%2C%5C%22versionDetection%5C%22%3Atrue%2C%5C%22releaseDateAsVersion%5C%22%3Afalse%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22WG%20Tunnel%5C%22%2C%5C%22appAuthor%5C%22%3A%5C%22Zane%20Schepke%5C%22%2C%5C%22shizukuPretendToBeGooglePlay%5C%22%3Afalse%2C%5C%22allowInsecure%5C%22%3Afalse%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22%5C%22%2C%5C%22refreshBeforeDownload%5C%22%3Afalse%7D%22%2C%22overrideSource%22%3Anull%7D)
</div>
<div align="center">
[![Discord](https://img.shields.io/badge/Discord-%235865F2.svg?style=for-the-badge&logo=discord&logoColor=white)](https://discord.gg/rbRRNh6H7V)
[![Telegram](https://img.shields.io/badge/Telegram-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/wgtunnel)
[<img src="https://img.shields.io/badge/Telegram-26A5E4.svg?style=for-the-badge&logo=Telegram&logoColor=white">](https://t.me/wgtunnel)
[<img src="https://img.shields.io/badge/Matrix-000000.svg?style=for-the-badge&logo=Matrix&logoColor=white">](https://matrix.to/#/#wg-tunnel-space:matrix.org)
</div>
<details open="open">
@@ -49,7 +49,7 @@ and [AmneziaWG](https://docs.amnezia.org/documentation/amnezia-wg/)
<div style="text-align: left;">
## About
Inspired by the official [wireguard-android](https://github.com/WireGuard/wireguard-android) app, WG Tunnel was created to address features and support missing from the official app. This app combines support for both [WireGuard®](https://www.wireguard.com/)
Inspired by the official [wireguard-android](https://github.com/WireGuard/wireguard-android) app, WG Tunnel was created to address features and support missing from the official app. This app combines support for both [WireGuard](https://www.wireguard.com/)
and [AmneziaWG](https://docs.amnezia.org/documentation/amnezia-wg/), with its primary feature of auto-tunneling (on-demand tunneling).
</div>
@@ -61,14 +61,14 @@ and [AmneziaWG](https://docs.amnezia.org/documentation/amnezia-wg/), with its pr
Thank you to the following:
- All of the users that have helped contribute to the project with ideas, translations, feedback, bug reports, testing, and donations.
- [WireGuard®](https://www.wireguard.com/) - © Jason A. Donenfeld (https://github.com/WireGuard/wireguard-android)
- [WireGuard](https://www.wireguard.com/) - Jason A. Donenfeld (https://github.com/WireGuard/wireguard-android)
- [AmneziaWG](https://docs.amnezia.org/documentation/amnezia-wg/) - Amnezia Team (https://github.com/amnezia-vpn/amneziawg-android)
## Screenshots
</div>
<div style="display: flex; flex-wrap: wrap; justify-content: center; gap: 10px;">
<div style="display: flex; flex-wrap: wrap; justify-content: left; gap: 10px;">
<img label="Main" src="fastlane/metadata/android/en-US/images/phoneScreenshots/main_screen.png" width="200" />
<img label="Settings" src="fastlane/metadata/android/en-US/images/phoneScreenshots/settings_screen.png" width="200" />
<img label="Auto" src="fastlane/metadata/android/en-US/images/phoneScreenshots/auto_screen.png" width="200" />
+4 -7
View File
@@ -86,7 +86,6 @@ android {
}
debug {
applicationIdSuffix = ".debug"
versionNameSuffix = "-debug"
resValue("string", "app_name", "WG Tunnel - Debug")
isDebuggable = true
resValue("string", "provider", "\"${Constants.APP_NAME}.provider.debug\"")
@@ -95,7 +94,6 @@ android {
create(Constants.PRERELEASE) {
initWith(buildTypes.getByName(Constants.RELEASE))
applicationIdSuffix = ".prerelease"
versionNameSuffix = "-pre"
resValue("string", "app_name", "WG Tunnel - Pre")
resValue("string", "provider", "\"${Constants.APP_NAME}.provider.pre\"")
}
@@ -103,7 +101,6 @@ android {
create(Constants.NIGHTLY) {
initWith(buildTypes.getByName(Constants.RELEASE))
applicationIdSuffix = ".nightly"
versionNameSuffix = "-nightly"
resValue("string", "app_name", "WG Tunnel - Nightly")
resValue("string", "provider", "\"${Constants.APP_NAME}.provider.nightly\"")
}
@@ -144,8 +141,8 @@ android {
}
dependencies {
implementation(project(":logcatter"))
implementation(project(":networkmonitor"))
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
@@ -160,6 +157,7 @@ dependencies {
implementation(libs.androidx.material3)
implementation(libs.androidx.appcompat)
implementation(libs.material)
implementation(libs.androidx.storage)
// test
testImplementation(libs.junit)
@@ -204,13 +202,12 @@ dependencies {
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.process)
// icons
implementation(libs.material.icons.extended)
// serialization
implementation(libs.kotlinx.serialization.json)
// barcode scanning
// ui
implementation(libs.zxing.android.embedded)
implementation(libs.material.icons.extended)
// bio
implementation(libs.androidx.biometric.ktx)
+38 -1
View File
@@ -2,4 +2,41 @@
-keepclassmembers class * extends androidx.datastore.preferences.protobuf.GeneratedMessageLite {
<fields>;
}
}
# Keep all classes in the org.xbill.DNS package and subpackages
-keep class org.xbill.DNS.** { *; }
-dontwarn org.xbill.DNS.**
# Preserve JNA classes if used (e.g., for IPHlpAPI on Windows)
-keep class com.sun.jna.** { *; }
-dontwarn com.sun.jna.**
# Keep DNS resolver configuration classes that might be loaded dynamically
-keep class org.xbill.DNS.config.** { *; }
-dontwarn org.xbill.DNS.config.**
-keep class org.xbill.DNS.** { *; }
# Prevent optimization issues with native or reflection-based calls
-dontoptimize
-dontshrink
# Uncomment the above if errors persist, but use sparingly as theyre broad
# Suppress warnings about missing classes if not all features are used
-dontwarn java.lang.management.**
-dontwarn sun.nio.ch.**
-dontwarn com.google.api.client.http.GenericUrl
-dontwarn com.google.api.client.http.HttpHeaders
-dontwarn com.google.api.client.http.HttpRequest
-dontwarn com.google.api.client.http.HttpRequestFactory
-dontwarn com.google.api.client.http.HttpResponse
-dontwarn com.google.api.client.http.HttpTransport
-dontwarn com.google.api.client.http.javanet.NetHttpTransport$Builder
-dontwarn com.google.api.client.http.javanet.NetHttpTransport
-dontwarn javax.lang.model.element.Modifier
-dontwarn org.joda.time.Instant
-dontwarn org.slf4j.impl.StaticLoggerBinder
-dontwarn org.slf4j.impl.StaticMDCBinder
-dontwarn org.slf4j.impl.StaticMarkerBinder
+38 -1
View File
@@ -21,4 +21,41 @@
#-renamesourcefileattribute SourceFile
-keepclassmembers class * extends androidx.datastore.preferences.protobuf.GeneratedMessageLite {
<fields>;
}
}
# Keep all classes in the org.xbill.DNS package and subpackages
-keep class org.xbill.DNS.** { *; }
-dontwarn org.xbill.DNS.**
# Preserve JNA classes if used (e.g., for IPHlpAPI on Windows)
-keep class com.sun.jna.** { *; }
-dontwarn com.sun.jna.**
# Keep DNS resolver configuration classes that might be loaded dynamically
-keep class org.xbill.DNS.config.** { *; }
-dontwarn org.xbill.DNS.config.**
-keep class org.xbill.DNS.** { *; }
# Prevent optimization issues with native or reflection-based calls
-dontoptimize
-dontshrink
# Uncomment the above if errors persist, but use sparingly as theyre broad
# Suppress warnings about missing classes if not all features are used
-dontwarn java.lang.management.**
-dontwarn sun.nio.ch.**
-dontwarn com.google.api.client.http.GenericUrl
-dontwarn com.google.api.client.http.HttpHeaders
-dontwarn com.google.api.client.http.HttpRequest
-dontwarn com.google.api.client.http.HttpRequestFactory
-dontwarn com.google.api.client.http.HttpResponse
-dontwarn com.google.api.client.http.HttpTransport
-dontwarn com.google.api.client.http.javanet.NetHttpTransport$Builder
-dontwarn com.google.api.client.http.javanet.NetHttpTransport
-dontwarn javax.lang.model.element.Modifier
-dontwarn org.joda.time.Instant
-dontwarn org.slf4j.impl.StaticLoggerBinder
-dontwarn org.slf4j.impl.StaticMDCBinder
-dontwarn org.slf4j.impl.StaticMarkerBinder
+6 -19
View File
@@ -3,13 +3,8 @@
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!--foreground service exempt android 14-->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SYSTEM_EXEMPTED" />
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"
@@ -68,7 +63,7 @@
<activity
android:name=".MainActivity"
android:exported="true"
android:windowSoftInputMode="adjustResize"
android:windowSoftInputMode="adjustNothing"
android:theme="@style/Theme.WireguardAutoTunnel"
android:configChanges="orientation|screenSize|keyboardHidden"
>
@@ -168,25 +163,17 @@
</service>
<receiver
android:name=".core.broadcast.BootReceiver"
android:name=".core.broadcast.RestartReceiver"
android:enabled="true"
android:exported="false">
android:exported="true">
<intent-filter>
<category android:name="android.intent.category.DEFAULT" />
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.ACTION_BOOT_COMPLETED" />
<action android:name="android.intent.action.QUICKBOOT_POWERON" />
<action android:name="com.htc.intent.action.QUICKBOOT_POWERON" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
<receiver
android:name=".core.broadcast.AppUpdateReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
<receiver
android:name=".core.broadcast.KernelReceiver"
android:exported="false"
@@ -1,23 +1,35 @@
package com.zaneschepke.wireguardautotunnel
import android.Manifest
import android.annotation.SuppressLint
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Color
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import androidx.activity.SystemBarStyle
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.ActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Bolt
import androidx.compose.material.icons.rounded.Home
import androidx.compose.material.icons.rounded.QuestionMark
import androidx.compose.material.icons.rounded.Settings
@@ -25,51 +37,62 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarData
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.CompositionLocalProvider
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.Modifier
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.navigation.toRoute
import com.zaneschepke.wireguardautotunnel.core.shortcut.ShortcutManager
import com.zaneschepke.networkmonitor.NetworkMonitor
import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager
import com.zaneschepke.wireguardautotunnel.core.worker.ServiceWorker
import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelStatus
import com.zaneschepke.wireguardautotunnel.domain.repository.AppStateRepository
import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.BottomNavBar
import com.zaneschepke.wireguardautotunnel.ui.common.dialog.VpnDeniedDialog
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.BottomNavItem
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.CustomBottomNavbar
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.DynamicTopAppBar
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.currentNavBackStackEntryAsNavBarState
import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.CustomSnackBar
import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarControllerProvider
import com.zaneschepke.wireguardautotunnel.ui.screens.main.ConfigScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.main.MainScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.main.OptionsScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.main.PinLockScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.main.ScannerScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.main.SplitTunnelScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.main.TunnelAutoTunnelScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.AdvancedScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.AppearanceScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.DisplayScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.KillSwitchScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.LanguageScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.LocationDisclosureScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.main.autotunnel.TunnelAutoTunnelScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.main.config.ConfigScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.main.scanner.ScannerScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.main.splittunnel.SplitTunnelScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.main.tunneloptions.TunnelOptionsScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.pin.PinLockScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.SettingsScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.autotunnel.AutoTunnelScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.support.LogsScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.appearance.AppearanceScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.appearance.display.DisplayScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.appearance.language.LanguageScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.AutoTunnelScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.advanced.AdvancedScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.disclosure.LocationDisclosureScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.killswitch.KillSwitchScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.support.SupportScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.support.logs.LogsScreen
import com.zaneschepke.wireguardautotunnel.ui.theme.WireguardAutoTunnelTheme
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.extensions.requestAutoTunnelTileServiceUpdate
import com.zaneschepke.wireguardautotunnel.util.extensions.goFromRoot
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
import dagger.hilt.android.AndroidEntryPoint
import org.amnezia.awg.backend.GoBackend.VpnService
import timber.log.Timber
import javax.inject.Inject
import kotlin.system.exitProcess
@@ -83,8 +106,11 @@ class MainActivity : AppCompatActivity() {
lateinit var tunnelManager: TunnelManager
@Inject
lateinit var shortcutManager: ShortcutManager
lateinit var networkMonitor: NetworkMonitor
private var lastLocationPermissionState: Boolean? = null
@SuppressLint("BatteryLife")
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge(
statusBarStyle = SystemBarStyle.Companion.auto(Color.TRANSPARENT, Color.TRANSPARENT),
@@ -99,169 +125,266 @@ class MainActivity : AppCompatActivity() {
installSplashScreen().apply {
setKeepOnScreenCondition {
!viewModel.isAppReady.value
!viewModel.appViewState.value.isAppReady
}
}
setContent {
val appUiState by viewModel.uiState.collectAsStateWithLifecycle()
val configurationChange by viewModel.configurationChange.collectAsStateWithLifecycle()
val navController = rememberNavController()
val appViewState by viewModel.appViewState.collectAsStateWithLifecycle()
LaunchedEffect(configurationChange) {
if (configurationChange) {
Intent(this@MainActivity, MainActivity::class.java).also {
startActivity(it)
exitProcess(0)
val navController = rememberNavController()
val backStackEntry by navController.currentBackStackEntryAsState()
val navBarState by currentNavBackStackEntryAsNavBarState(navController, backStackEntry, viewModel, appUiState)
val snackbar = remember { SnackbarHostState() }
var showVpnPermissionDialog by remember { mutableStateOf(false) }
var vpnPermissionDenied by remember { mutableStateOf(false) }
val vpnActivity =
rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult(),
onResult = {
if (it.resultCode != RESULT_OK) {
showVpnPermissionDialog = true
vpnPermissionDenied = true
} else {
vpnPermissionDenied = false
}
},
)
LaunchedEffect(appUiState.tunnels) {
if (!appViewState.isAppReady) {
viewModel.handleEvent(AppEvent.AppReadyCheck(appUiState.tunnels))
}
}
val batteryActivity = rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult(),
) { _: ActivityResult ->
viewModel.handleEvent(AppEvent.SetBatteryOptimizeDisableShown)
}
with(appViewState) {
LaunchedEffect(isConfigChanged) {
if (isConfigChanged) {
Intent(this@MainActivity, MainActivity::class.java).also {
startActivity(it)
exitProcess(0)
}
}
}
LaunchedEffect(errorMessage) {
errorMessage?.let {
snackbar.showSnackbar(it.asString(this@MainActivity))
viewModel.handleEvent(AppEvent.MessageShown)
}
}
LaunchedEffect(appUiState.activeTunnels) {
appUiState.activeTunnels
.mapNotNull { (tunnelConf, tunnelState) ->
(tunnelState.status as? TunnelStatus.Error)?.let { error ->
val message = error.error.toStringRes()
val context = this@MainActivity
snackbar.showSnackbar(context.getString(R.string.tunnel_error_template, context.getString(message)))
viewModel.handleEvent(AppEvent.ClearTunnelError(tunnelConf))
}
}
}
LaunchedEffect(popBackStack) {
if (popBackStack) {
navController.popBackStack()
viewModel.handleEvent(AppEvent.PopBackStack(false))
}
}
LaunchedEffect(requestVpnPermission) {
if (requestVpnPermission) {
if (!vpnPermissionDenied) {
vpnActivity.launch(VpnService.prepare(this@MainActivity))
} else {
showVpnPermissionDialog = true
}
viewModel.handleEvent(AppEvent.VpnPermissionRequested)
}
}
LaunchedEffect(requestBatteryPermission) {
if (requestBatteryPermission) {
batteryActivity.launch(
Intent().apply {
action = Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS
data = Uri.parse("package:${this@MainActivity.packageName}")
},
)
}
}
}
LaunchedEffect(Unit) {
viewModel.getEmitSplitTunnelApps(this@MainActivity)
}
LaunchedEffect(appUiState.autoTunnelActive) {
requestAutoTunnelTileServiceUpdate()
}
with(appUiState.appSettings) {
LaunchedEffect(isAutoTunnelEnabled) {
this@MainActivity.requestAutoTunnelTileServiceUpdate()
}
LaunchedEffect(isShortcutsEnabled) {
if (!isShortcutsEnabled) return@LaunchedEffect shortcutManager.removeShortcuts()
shortcutManager.addShortcuts()
}
}
ServiceWorker.start(this)
CompositionLocalProvider(LocalNavController provides navController) {
SnackbarControllerProvider { host ->
WireguardAutoTunnelTheme(theme = appUiState.generalState.theme) {
Scaffold(
contentWindowInsets = WindowInsets(0),
snackbarHost = {
SnackbarHost(host) { snackbarData: SnackbarData ->
CustomSnackBar(
snackbarData.visuals.message,
isRtl = false,
containerColor =
MaterialTheme.colorScheme.surfaceColorAtElevation(
2.dp,
),
)
}
},
bottomBar = {
BottomNavBar(
navController,
WireguardAutoTunnelTheme(theme = appUiState.generalState.theme) {
VpnDeniedDialog(showVpnPermissionDialog, onDismiss = { showVpnPermissionDialog = false })
Scaffold(
modifier = Modifier.pointerInput(Unit) {
detectTapGestures {
viewModel.handleEvent(AppEvent.SetSelectedTunnel(null))
}
},
snackbarHost = {
SnackbarHost(snackbar) { snackbarData: SnackbarData ->
CustomSnackBar(
snackbarData.visuals.message,
isRtl = false,
containerColor =
MaterialTheme.colorScheme.surfaceColorAtElevation(
2.dp,
),
)
}
},
topBar = {
DynamicTopAppBar(navBarState)
},
bottomBar = {
AnimatedVisibility(
visible = navBarState.showBottom,
enter = slideInVertically(initialOffsetY = { it }),
exit = slideOutVertically(targetOffsetY = { it }),
) {
CustomBottomNavbar(
listOf(
BottomNavItem(
name = stringResource(R.string.tunnels),
route = Route.Main,
icon = Icons.Rounded.Home,
onClick = { navController.goFromRoot(Route.Main) },
),
BottomNavItem(
name = stringResource(R.string.auto_tunnel),
route = Route.AutoTunnel,
icon = Icons.Rounded.Bolt,
onClick = {
val route = if (appUiState.generalState.isLocationDisclosureShown) Route.AutoTunnel else Route.LocationDisclosure
navController.goFromRoot(route)
},
active = appUiState.isAutoTunnelActive,
),
BottomNavItem(
name = stringResource(R.string.settings),
route = Route.Settings,
icon = Icons.Rounded.Settings,
onClick = { navController.goFromRoot(Route.Settings) },
),
BottomNavItem(
name = stringResource(R.string.support),
route = Route.Support,
icon = Icons.Rounded.QuestionMark,
onClick = { navController.goFromRoot(Route.Support) },
),
),
navBarState = navBarState,
)
},
) { padding ->
Box(modifier = Modifier.Companion.fillMaxSize().padding(padding)) {
NavHost(
navController,
enterTransition = { fadeIn(tween(Constants.TRANSITION_ANIMATION_TIME)) },
exitTransition = { fadeOut(tween(Constants.TRANSITION_ANIMATION_TIME)) },
startDestination = (if (appUiState.generalState.isPinLockEnabled) Route.Lock else Route.Main),
) {
composable<Route.Main> {
MainScreen(
uiState = appUiState,
)
}
composable<Route.Settings> {
SettingsScreen(
appViewModel = viewModel,
uiState = appUiState,
)
}
composable<Route.LocationDisclosure> {
LocationDisclosureScreen(viewModel, appUiState)
}
composable<Route.AutoTunnel> {
AutoTunnelScreen(
appUiState.appSettings,
)
}
composable<Route.Appearance> {
AppearanceScreen()
}
composable<Route.Language> {
LanguageScreen(appUiState, viewModel)
}
composable<Route.Display> {
DisplayScreen(appUiState)
}
composable<Route.Support> {
SupportScreen(appUiState, viewModel)
}
composable<Route.AutoTunnelAdvanced> {
AdvancedScreen(appUiState.appSettings, viewModel)
}
composable<Route.Logs> {
LogsScreen()
}
composable<Route.Config> {
val args = it.toRoute<Route.Config>()
val config =
appUiState.tunnels.firstOrNull { it.id == args.id }
ConfigScreen(config, viewModel)
}
composable<Route.TunnelOptions> {
val args = it.toRoute<Route.TunnelOptions>()
val config = appUiState.tunnels.first { it.id == args.id }
OptionsScreen(config)
}
composable<Route.Lock> {
PinLockScreen(viewModel)
}
composable<Route.Scanner> {
ScannerScreen()
}
composable<Route.KillSwitch> {
KillSwitchScreen(appUiState, viewModel)
}
composable<Route.SplitTunnel> {
val args = it.toRoute<Route.SplitTunnel>()
val config = appUiState.tunnels.first { it.id == args.id }
SplitTunnelScreen(config, viewModel)
}
composable<Route.TunnelAutoTunnel> {
val args = it.toRoute<Route.TunnelOptions>()
val config = appUiState.tunnels.first { it.id == args.id }
TunnelAutoTunnelScreen(config, appUiState.appSettings)
}
},
) { padding ->
Box(
modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface)
.padding(padding)
.consumeWindowInsets(padding)
.imePadding(),
) {
NavHost(
navController,
startDestination = (if (appUiState.generalState.isPinLockEnabled) Route.Lock else Route.Main),
) {
composable<Route.Main> {
MainScreen(appUiState, appViewState, viewModel)
}
composable<Route.Settings> {
SettingsScreen(appUiState, appViewState, viewModel)
}
composable<Route.LocationDisclosure> {
LocationDisclosureScreen(appUiState, viewModel)
}
composable<Route.AutoTunnel> {
AutoTunnelScreen(appUiState, viewModel)
}
composable<Route.Appearance> {
AppearanceScreen()
}
composable<Route.Language> {
LanguageScreen(appUiState, viewModel)
}
composable<Route.Display> {
DisplayScreen(appUiState, viewModel)
}
composable<Route.Support> {
SupportScreen()
}
composable<Route.AutoTunnelAdvanced> {
AdvancedScreen(appUiState)
}
composable<Route.Logs> {
LogsScreen(appViewState, viewModel)
}
composable<Route.Config> { backStack ->
val args = backStack.toRoute<Route.Config>()
val config = appUiState.tunnels.firstOrNull { it.id == args.id }
ConfigScreen(config, viewModel)
}
composable<Route.TunnelOptions> { backStack ->
val args = backStack.toRoute<Route.TunnelOptions>()
appUiState.tunnels.firstOrNull { it.id == args.id }?.let { config ->
TunnelOptionsScreen(config, appUiState, viewModel)
}
}
BackHandler {
if (navController.previousBackStackEntry == null || !navController.popBackStack()) {
this@MainActivity.finish()
composable<Route.Lock> {
PinLockScreen(viewModel)
}
composable<Route.Scanner> {
ScannerScreen(viewModel)
}
composable<Route.KillSwitch> {
KillSwitchScreen(appUiState, viewModel)
}
composable<Route.SplitTunnel> {
SplitTunnelScreen(viewModel)
}
composable<Route.TunnelAutoTunnel> { backStack ->
val args = backStack.toRoute<Route.TunnelOptions>()
appUiState.tunnels.firstOrNull { it.id == args.id }?.let {
TunnelAutoTunnelScreen(it, appUiState.appSettings, viewModel)
}
}
}
BackHandler {
if (navController.currentDestination?.route != Route.Main::class.qualifiedName) {
navController.popBackStack()
} else {
this@MainActivity.finish()
}
}
}
}
}
}
}
}
override fun onResume() {
super.onResume()
checkPermissionAndNotify()
}
private fun checkPermissionAndNotify() {
val hasLocation = ContextCompat.checkSelfPermission(
this,
Manifest.permission.ACCESS_FINE_LOCATION,
) == PackageManager.PERMISSION_GRANTED
if (lastLocationPermissionState != hasLocation) {
Timber.d("Location permission changed to: $hasLocation")
if (hasLocation) {
networkMonitor.sendLocationPermissionsGrantedBroadcast()
}
lastLocationPermissionState = hasLocation
}
}
}
@@ -11,6 +11,7 @@ import androidx.work.Configuration
import com.wireguard.android.backend.GoBackend
import com.zaneschepke.logcatter.LogReader
import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager
import com.zaneschepke.wireguardautotunnel.core.worker.ServiceWorker
import com.zaneschepke.wireguardautotunnel.di.ApplicationScope
import com.zaneschepke.wireguardautotunnel.di.IoDispatcher
import com.zaneschepke.wireguardautotunnel.di.MainDispatcher
@@ -18,7 +19,6 @@ import com.zaneschepke.wireguardautotunnel.domain.enums.BackendState
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.util.LocaleUtil
import com.zaneschepke.wireguardautotunnel.util.ReleaseTree
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
@@ -86,15 +86,14 @@ class WireGuardAutoTunnel : Application(), Configuration.Provider {
tunnelManager.startTunnel(it)
}
} else {
Timber.Forest.w("Always-on VPN is not enabled in app settings")
Timber.w("Always-on VPN is not enabled in app settings")
}
}
}
ServiceWorker.start(this)
applicationScope.launch {
withContext(mainDispatcher) {
if (appDataRepository.appState.isLocalLogsEnabled() && !isRunningOnTv()) logReader.initialize()
}
if (!appDataRepository.settings.get().isKernelEnabled) {
tunnelManager.setBackendState(BackendState.SERVICE_ACTIVE, emptyList())
}
@@ -103,6 +102,9 @@ class WireGuardAutoTunnel : Application(), Configuration.Provider {
LocaleUtil.changeLocale(it)
}
}
appDataRepository.appState.isLocalLogsEnabled().let { enabled ->
if (enabled) logReader.start()
}
}
}
@@ -132,6 +134,19 @@ class WireGuardAutoTunnel : Application(), Configuration.Provider {
return foreground
}
@Volatile
private var lastActiveTunnels: List<Int> = emptyList()
@Synchronized
fun getLastActiveTunnels(): List<Int> {
return lastActiveTunnels
}
@Synchronized
fun setLastActiveTunnels(newTunnels: List<Int>) {
lastActiveTunnels = newTunnels
}
lateinit var instance: WireGuardAutoTunnel
private set
}
@@ -1,44 +0,0 @@
package com.zaneschepke.wireguardautotunnel.core.broadcast
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.di.ApplicationScope
import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager
import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
@AndroidEntryPoint
class BootReceiver : BroadcastReceiver() {
@Inject
lateinit var appDataRepository: AppDataRepository
@Inject
@ApplicationScope
lateinit var applicationScope: CoroutineScope
@Inject
lateinit var serviceManager: ServiceManager
@Inject
lateinit var tunnelManager: TunnelManager
override fun onReceive(context: Context, intent: Intent) {
if (Intent.ACTION_BOOT_COMPLETED != intent.action) return
serviceManager.updateTunnelTile()
serviceManager.updateAutoTunnelTile()
applicationScope.launch {
with(appDataRepository.settings.get()) {
if (isRestoreOnBootEnabled) {
// If auto tunnel is enabled, just start it and let auto tunnel start appropriate tun
if (isAutoTunnelEnabled && !serviceManager.autoTunnelActive.value) return@launch serviceManager.startAutoTunnel(true)
tunnelManager.restorePreviousState()
}
}
}
}
}
@@ -3,10 +3,10 @@ package com.zaneschepke.wireguardautotunnel.core.broadcast
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository
import com.zaneschepke.wireguardautotunnel.di.ApplicationScope
import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager
import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager
import com.zaneschepke.wireguardautotunnel.di.ApplicationScope
import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@@ -32,8 +32,8 @@ class KernelReceiver : BroadcastReceiver() {
val action = intent.action ?: return
applicationScope.launch {
if (action == REFRESH_TUNNELS_ACTION) {
tunnelManager.runningTunnelNames().forEach {
val tunnel = tunnelRepository.findByTunnelName(it)
tunnelManager.runningTunnelNames().forEach { name ->
val tunnel = tunnelRepository.findByTunnelName(name)
tunnel?.let {
tunnelRepository.save(it.copy(isActive = true))
}
@@ -4,9 +4,9 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.zaneschepke.wireguardautotunnel.core.notification.NotificationManager
import com.zaneschepke.wireguardautotunnel.di.ApplicationScope
import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager
import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager
import com.zaneschepke.wireguardautotunnel.di.ApplicationScope
import com.zaneschepke.wireguardautotunnel.domain.enums.NotificationAction
import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository
import dagger.hilt.android.AndroidEntryPoint
@@ -36,11 +36,15 @@ class NotificationActionReceiver : BroadcastReceiver() {
NotificationAction.AUTO_TUNNEL_OFF.name -> serviceManager.stopAutoTunnel()
NotificationAction.TUNNEL_OFF.name -> {
val tunnelId = intent.getIntExtra(NotificationManager.EXTRA_ID, 0)
if (tunnelId == 0) return@launch tunnelManager.stopTunnel()
if (tunnelId == STOP_ALL_TUNNELS_ID) return@launch tunnelManager.stopTunnel()
val tunnel = tunnelRepository.getById(tunnelId)
tunnelManager.stopTunnel(tunnel)
}
}
}
}
companion object {
const val STOP_ALL_TUNNELS_ID = 0
}
}
@@ -3,42 +3,61 @@ package com.zaneschepke.wireguardautotunnel.core.broadcast
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.di.ApplicationScope
import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager
import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager
import com.zaneschepke.wireguardautotunnel.di.ApplicationScope
import com.zaneschepke.wireguardautotunnel.di.IoDispatcher
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@AndroidEntryPoint
class AppUpdateReceiver : BroadcastReceiver() {
class RestartReceiver : BroadcastReceiver() {
@Inject
lateinit var appDataRepository: AppDataRepository
@Inject
@ApplicationScope
lateinit var applicationScope: CoroutineScope
@Inject
lateinit var appDataRepository: AppDataRepository
@Inject
lateinit var serviceManager: ServiceManager
@Inject
lateinit var tunnelManager: TunnelManager
@Inject
@IoDispatcher
lateinit var ioDispatcher: CoroutineDispatcher
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != Intent.ACTION_MY_PACKAGE_REPLACED) return
val action = intent.action ?: return
if (action != Intent.ACTION_BOOT_COMPLETED &&
action != Intent.ACTION_MY_PACKAGE_REPLACED &&
action != "com.htc.intent.action.QUICKBOOT_POWERON"
) {
return
}
Timber.d("RestartReceiver triggered with action: ${intent.action}")
serviceManager.updateTunnelTile()
serviceManager.updateAutoTunnelTile()
applicationScope.launch {
with(appDataRepository.settings.get()) {
if (isRestoreOnBootEnabled) {
// If auto tunnel is enabled, just start it and let auto tunnel start appropriate tun
if (isAutoTunnelEnabled && !serviceManager.autoTunnelActive.value) return@launch serviceManager.startAutoTunnel(true)
applicationScope.launch(ioDispatcher) {
val settings = appDataRepository.settings.get()
if (settings.isRestoreOnBootEnabled) {
if (settings.isAutoTunnelEnabled && !serviceManager.autoTunnelActive.value) {
Timber.d("Starting auto-tunnel on boot/update")
serviceManager.startAutoTunnel()
} else {
Timber.d("Restoring previous tunnel state")
tunnelManager.restorePreviousState()
}
} else {
Timber.d("Restore on boot disabled, skipping")
}
}
}
@@ -1,161 +0,0 @@
package com.zaneschepke.wireguardautotunnel.core.network
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.net.wifi.SupplicantState
import android.net.wifi.WifiManager
import android.os.Build
import com.zaneschepke.wireguardautotunnel.domain.state.ConnectivityState
import com.zaneschepke.wireguardautotunnel.di.IoDispatcher
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.flowOn
import javax.inject.Inject
class InternetConnectivityMonitor
@Inject
constructor(
@ApplicationContext private val context: Context,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) : NetworkMonitor {
private val connectivityManager =
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
@get:Synchronized @set:Synchronized
private var wifiCapabilities: NetworkCapabilities? = null
@get:Synchronized @set:Synchronized
private var wifiNetworkChanged: Boolean = false
override val didWifiChangeSinceLastCapabilitiesQuery: Boolean
get() = wifiNetworkChanged
override val status = callbackFlow {
var wifiState: Boolean = false
var ethernetState: Boolean = false
var cellularState: Boolean = false
fun emitState() {
trySend(ConnectivityState(wifiState, ethernetState, cellularState))
}
val currentNetwork = connectivityManager.activeNetwork
if (currentNetwork == null) {
emitState()
}
fun updateCapabilityState(up: Boolean, network: Network) {
with(connectivityManager.getNetworkCapabilities(network)) {
when {
this == null -> return
hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> wifiState = up
hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) ->
cellularState = up
hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) ->
ethernetState = up
}
}
}
fun onWifiChange(network: Network, callback: () -> Unit) {
if (connectivityManager.getNetworkCapabilities(network)?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true) {
callback()
}
}
fun onAvailable(network: Network) {
onWifiChange(network) {
wifiNetworkChanged = true
}
}
fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) {
onWifiChange(network) {
wifiCapabilities = networkCapabilities
}
updateCapabilityState(true, network)
emitState()
}
val networkStatusCallback =
when (Build.VERSION.SDK_INT) {
in Build.VERSION_CODES.S..Int.MAX_VALUE -> {
object :
ConnectivityManager.NetworkCallback(
FLAG_INCLUDE_LOCATION_INFO,
) {
override fun onAvailable(network: Network) {
onAvailable(network)
}
override fun onLost(network: Network) {
updateCapabilityState(false, network)
emitState()
}
override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) {
onCapabilitiesChanged(network, networkCapabilities)
}
}
}
else -> {
object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
onAvailable(network)
}
override fun onLost(network: Network) {
updateCapabilityState(false, network)
emitState()
}
override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) {
onCapabilitiesChanged(network, networkCapabilities)
}
}
}
}
val request =
NetworkRequest.Builder()
.addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
.addTransportType(NetworkCapabilities.TRANSPORT_ETHERNET)
.addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR)
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.addCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
.build()
connectivityManager.registerNetworkCallback(request, networkStatusCallback)
awaitClose { connectivityManager.unregisterNetworkCallback(networkStatusCallback) }
}.flowOn(ioDispatcher)
override fun getWifiCapabilities(): NetworkCapabilities? {
wifiNetworkChanged = false
return wifiCapabilities
}
companion object {
fun getNetworkName(networkCapabilities: NetworkCapabilities, context: Context): String? {
var ssid = networkCapabilities.getWifiName()
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S) {
val wifiManager =
context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
@Suppress("DEPRECATION")
val info = wifiManager.connectionInfo
if (info.supplicantState === SupplicantState.COMPLETED) {
ssid = info.ssid
}
}
return ssid?.trim('"')
}
}
}
@@ -1,16 +0,0 @@
package com.zaneschepke.wireguardautotunnel.core.network
import android.net.NetworkCapabilities
import android.net.wifi.WifiInfo
import android.os.Build
fun NetworkCapabilities.getWifiName(): String? {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val info: WifiInfo
if (transportInfo is WifiInfo) {
info = transportInfo as WifiInfo
return info.ssid
}
}
return null
}
@@ -1,13 +0,0 @@
package com.zaneschepke.wireguardautotunnel.core.network
import android.net.NetworkCapabilities
import com.zaneschepke.wireguardautotunnel.domain.state.ConnectivityState
import kotlinx.coroutines.flow.Flow
interface NetworkMonitor {
val status: Flow<ConnectivityState>
// util to help limit location queries
val didWifiChangeSinceLastCapabilitiesQuery: Boolean
fun getWifiCapabilities(): NetworkCapabilities?
}
@@ -4,8 +4,8 @@ import android.app.Notification
import android.app.NotificationManager
import android.content.Context
import androidx.core.app.NotificationCompat
import com.zaneschepke.wireguardautotunnel.domain.enums.NotificationAction
import com.zaneschepke.wireguardautotunnel.core.notification.WireGuardNotification.NotificationChannels
import com.zaneschepke.wireguardautotunnel.domain.enums.NotificationAction
import com.zaneschepke.wireguardautotunnel.util.StringValue
interface NotificationManager {
@@ -39,7 +39,6 @@ interface NotificationManager {
fun show(notificationId: Int, notification: Notification)
companion object {
const val KERNEL_SERVICE_NOTIFICATION_ID = 123
const val AUTO_TUNNEL_NOTIFICATION_ID = 122
const val VPN_NOTIFICATION_ID = 100
const val EXTRA_ID = "id"
@@ -12,9 +12,9 @@ import android.graphics.Color
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat
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.MainActivity
import com.zaneschepke.wireguardautotunnel.core.notification.NotificationManager.Companion.EXTRA_ID
import com.zaneschepke.wireguardautotunnel.domain.enums.NotificationAction
import com.zaneschepke.wireguardautotunnel.util.StringValue
@@ -3,35 +3,43 @@ package com.zaneschepke.wireguardautotunnel.core.service
import android.app.Service
import android.content.Context
import android.content.Intent
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
import android.net.VpnService
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.zaneschepke.wireguardautotunnel.core.service.autotunnel.AutoTunnelService
import com.zaneschepke.wireguardautotunnel.core.service.tile.AutoTunnelControlTile
import com.zaneschepke.wireguardautotunnel.core.service.tile.TunnelControlTile
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.di.ApplicationScope
import com.zaneschepke.wireguardautotunnel.di.IoDispatcher
import com.zaneschepke.wireguardautotunnel.di.MainDispatcher
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.util.extensions.requestAutoTunnelTileServiceUpdate
import com.zaneschepke.wireguardautotunnel.util.extensions.requestTunnelTileServiceStateUpdate
import jakarta.inject.Inject
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import timber.log.Timber
@OptIn(ExperimentalCoroutinesApi::class)
class ServiceManager
@Inject constructor(private val context: Context, private val ioDispatcher: CoroutineDispatcher, private val appDataRepository: AppDataRepository) {
class ServiceManager @Inject constructor(
private val context: Context,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
@ApplicationScope private val applicationScope: CoroutineScope,
@MainDispatcher private val mainDispatcher: CoroutineDispatcher,
private val appDataRepository: AppDataRepository,
) {
private val autoTunnelMutex = Mutex()
private val _autoTunnelActive = MutableStateFlow(false)
val autoTunnelActive = _autoTunnelActive.asStateFlow()
var autoTunnelService = CompletableDeferred<AutoTunnelService>()
var backgroundService = CompletableDeferred<TunnelForegroundService>()
var autoTunnelTile = CompletableDeferred<AutoTunnelControlTile>()
var tunnelControlTile = CompletableDeferred<TunnelControlTile>()
private fun <T : Service> startService(cls: Class<T>, background: Boolean) {
runCatching {
@@ -44,76 +52,79 @@ class ServiceManager
}.onFailure { Timber.e(it) }
}
suspend fun startAutoTunnel(background: Boolean) {
val settings = appDataRepository.settings.get()
appDataRepository.settings.save(settings.copy(isAutoTunnelEnabled = true))
if (autoTunnelService.isCompleted) return _autoTunnelActive.update { true }
runCatching {
startService(AutoTunnelService::class.java, background)
autoTunnelService.await()
autoTunnelService.getCompleted().start()
_autoTunnelActive.update { true }
updateAutoTunnelTile()
}.onFailure {
Timber.e(it)
}
fun hasVpnPermission(): Boolean {
return VpnService.prepare(context) == null
}
suspend fun startBackgroundService(tunnelConf: TunnelConf) {
if (backgroundService.isCompleted) return
runCatching {
startService(TunnelForegroundService::class.java, true)
backgroundService.await()
backgroundService.getCompleted().start(tunnelConf)
}.onFailure {
Timber.e(it)
}
}
fun stopBackgroundService() {
if (!backgroundService.isCompleted) return
runCatching {
backgroundService.getCompleted().stop()
}.onFailure {
Timber.e(it)
}
}
suspend fun toggleAutoTunnel(background: Boolean) {
withContext(ioDispatcher) {
if (_autoTunnelActive.value) return@withContext stopAutoTunnel()
startAutoTunnel(background)
}
}
fun updateAutoTunnelTile() {
if (autoTunnelTile.isCompleted) {
autoTunnelTile.getCompleted().updateTileState()
} else {
context.requestAutoTunnelTileServiceUpdate()
}
}
fun updateTunnelTile() {
if (tunnelControlTile.isCompleted) {
tunnelControlTile.getCompleted().updateTileState()
} else {
context.requestTunnelTileServiceStateUpdate()
suspend fun startAutoTunnel() {
autoTunnelMutex.withLock {
val settings = appDataRepository.settings.get()
appDataRepository.settings.save(settings.copy(isAutoTunnelEnabled = true))
if (autoTunnelService.isCompleted) {
_autoTunnelActive.update { true }
return
}
runCatching {
autoTunnelService = CompletableDeferred()
startService(AutoTunnelService::class.java, !WireGuardAutoTunnel.isForeground())
_autoTunnelActive.update { true }
}.onFailure {
Timber.e(it)
_autoTunnelActive.update { false }
}
withContext(mainDispatcher) { updateAutoTunnelTile() }
}
}
suspend fun stopAutoTunnel() {
withContext(ioDispatcher) {
autoTunnelMutex.withLock {
val settings = appDataRepository.settings.get()
appDataRepository.settings.save(settings.copy(isAutoTunnelEnabled = false))
if (!autoTunnelService.isCompleted) return@withContext
if (!autoTunnelService.isCompleted) return
runCatching {
autoTunnelService.getCompleted().stop()
val service = autoTunnelService.await()
service.stop()
_autoTunnelActive.update { false }
updateAutoTunnelTile()
autoTunnelService = CompletableDeferred()
}.onFailure {
Timber.e(it)
}
withContext(mainDispatcher) { updateAutoTunnelTile() }
}
}
fun startTunnelForegroundService() {
if (backgroundService.isCompleted) return
runCatching {
backgroundService = CompletableDeferred()
startService(TunnelForegroundService::class.java, !WireGuardAutoTunnel.isForeground())
}.onFailure {
Timber.e(it)
}
}
suspend fun stopTunnelForegroundService() {
if (!backgroundService.isCompleted) return
runCatching {
val service = backgroundService.await()
service.stop()
backgroundService = CompletableDeferred()
}.onFailure {
Timber.e(it)
}
}
fun toggleAutoTunnel() {
applicationScope.launch(ioDispatcher) {
if (_autoTunnelActive.value) stopAutoTunnel() else startAutoTunnel()
}
}
fun updateAutoTunnelTile() {
context.requestAutoTunnelTileServiceUpdate()
}
fun updateTunnelTile() {
context.requestTunnelTileServiceStateUpdate()
}
}
@@ -5,14 +5,38 @@ import android.content.Intent
import android.os.IBinder
import androidx.core.app.ServiceCompat
import androidx.lifecycle.LifecycleService
import androidx.lifecycle.lifecycleScope
import com.zaneschepke.networkmonitor.NetworkMonitor
import com.zaneschepke.networkmonitor.NetworkStatus
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.domain.enums.NotificationAction
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.IoDispatcher
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.domain.enums.NotificationAction
import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelStatus
import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.extensions.distinctByKeys
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
@AndroidEntryPoint
@@ -24,9 +48,32 @@ class TunnelForegroundService : LifecycleService() {
@Inject
lateinit var serviceManager: ServiceManager
@Inject
lateinit var networkMonitor: NetworkMonitor
@Inject
@IoDispatcher
lateinit var ioDispatcher: CoroutineDispatcher
@Inject
lateinit var tunnelRepo: TunnelRepository
@Inject
lateinit var tunnelManager: TunnelManager
private val isNetworkConnected = MutableStateFlow(true)
private val tunnelJobs = ConcurrentHashMap<TunnelConf, Job>()
override fun onCreate() {
super.onCreate()
serviceManager.backgroundService.complete(this)
ServiceCompat.startForeground(
this@TunnelForegroundService,
NotificationManager.VPN_NOTIFICATION_ID,
onCreateNotification(),
Constants.SYSTEM_EXEMPT_SERVICE_TYPE_ID,
)
}
override fun onBind(intent: Intent): IBinder? {
@@ -35,30 +82,158 @@ class TunnelForegroundService : LifecycleService() {
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId)
serviceManager.backgroundService.complete(this)
return super.onStartCommand(intent, flags, startId)
}
fun start(tunnelConf: TunnelConf) {
ServiceCompat.startForeground(
this@TunnelForegroundService,
NotificationManager.KERNEL_SERVICE_NOTIFICATION_ID,
createNotification(tunnelConf),
NotificationManager.VPN_NOTIFICATION_ID,
onCreateNotification(),
Constants.SYSTEM_EXEMPT_SERVICE_TYPE_ID,
)
start()
return START_STICKY
}
fun start() = lifecycleScope.launch {
tunnelManager.activeTunnels.distinctByKeys().collect { tuns ->
if (tuns.isEmpty() && tunnelJobs.isEmpty()) return@collect
if (tuns.isEmpty() && tunnelJobs.isNotEmpty()) {
return@collect tunnelJobs.forEach { (key, _) ->
Timber.d("Stopping all tunnel jobs")
tunnelJobs[key]?.cancel()
tunnelJobs.remove(key)
}
}
val (jobsToStop, jobsToStart) = findMissingKeys(tuns, tunnelJobs)
if (jobsToStop.isEmpty() && jobsToStart.isEmpty()) return@collect
jobsToStop.forEach { tun ->
Timber.d("Stopping tunnel jobs for ${tun.tunName}")
tunnelJobs[tun]?.cancel()
tunnelJobs.remove(tun)
}
jobsToStart.forEach { tun ->
Timber.d("Starting tunnel jobs for ${tun.tunName}")
tunnelJobs += (tun to startTunnelJobs(tun))
}
updateServiceNotification()
}
}
// TODO Would be cool to have this include kill switch
// TODO also we need to include errors
private fun updateServiceNotification() {
val notification = when (tunnelJobs.size) {
0 -> onCreateNotification()
1 -> createTunnelNotification(tunnelJobs.keys.first())
else -> createTunnelsNotification()
}
ServiceCompat.startForeground(
this@TunnelForegroundService,
NotificationManager.VPN_NOTIFICATION_ID,
notification,
Constants.SYSTEM_EXEMPT_SERVICE_TYPE_ID,
)
}
// use same scope so we can cancel all of these
private fun startTunnelJobs(tunnelConf: TunnelConf) = lifecycleScope.launch {
// monitor if we have internet connectivity
launch { startNetworkMonitorJob() }
// job to trigger stats emit on interval
launch { startTunnelStatsJob(tunnelConf) }
// monitor changes to the tunnel config
launch { startTunnelConfChangesJob(tunnelConf) }
// monitor tunnel ping
launch { startPingJob(tunnelConf) }
}
private fun findMissingKeys(map1: Map<TunnelConf, Any>, map2: Map<TunnelConf, Any>): Pair<Set<TunnelConf>, Set<TunnelConf>> {
val missingMap1 = map2.keys - map1.keys
val missingMap2 = map1.keys - map2.keys
return missingMap1 to missingMap2
}
private suspend fun startTunnelConfChangesJob(tunnelConf: TunnelConf) {
tunnelRepo.flow
.flowOn(ioDispatcher)
.map { storedTunnels ->
storedTunnels.firstOrNull { it.id == tunnelConf.id }
}
.filterNotNull()
// only emit when one of these 3 values change
.distinctUntilChanged { old, new ->
old == new
}
.collect { storedTunnel ->
if (tunnelConf != storedTunnel) {
Timber.d("Config changed for ${storedTunnel.tunName}, bouncing")
// let this complete, even after cancel
withContext(NonCancellable) {
tunnelManager.bounceTunnel(storedTunnel, TunnelStatus.StopReason.CONFIG_CHANGED)
}
}
}
}
private suspend fun startNetworkMonitorJob() {
networkMonitor.networkStatusFlow
.flowOn(ioDispatcher)
.collectLatest { status ->
val isAvailable = status !is NetworkStatus.Disconnected
isNetworkConnected.value = isAvailable
Timber.d("Network available: $status")
}
}
private suspend fun startTunnelStatsJob(tunnel: TunnelConf) = coroutineScope {
while (isActive) {
tunnelManager.updateTunnelStatistics(tunnel)
delay(STATS_DELAY)
}
}
// TODO fix cooldown
private suspend fun startPingJob(tunnel: TunnelConf) = coroutineScope {
delay(PING_START_DELAY)
while (isActive) {
val shouldBounce = shouldBounceTunnel(tunnel)
val delayMs = if (shouldBounce) {
// let this complete, even after cancel
withContext(NonCancellable) {
tunnelManager.bounceTunnel(tunnel, TunnelStatus.StopReason.PING)
}
tunnel.pingCooldown ?: Constants.PING_COOLDOWN
} else {
tunnel.pingInterval ?: Constants.PING_INTERVAL
}
delay(delayMs)
}
}
private suspend fun shouldBounceTunnel(tunnel: TunnelConf): Boolean {
if (!isNetworkConnected.value) {
Timber.d("Network disconnected, skipping ping for ${tunnel.tunName}")
return false
}
return runCatching {
!tunnel.isTunnelPingable(ioDispatcher)
}.onFailure { e ->
Timber.e(e, "Ping check failed for ${tunnel.tunName}")
}.getOrDefault(true)
}
fun stop() {
stopForeground(STOP_FOREGROUND_REMOVE)
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
stopSelf()
}
override fun onDestroy() {
serviceManager.backgroundService = CompletableDeferred()
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
super.onDestroy()
}
private fun createNotification(tunnelConf: TunnelConf): Notification {
private fun createTunnelNotification(tunnelConf: TunnelConf): Notification {
return notificationManager.createNotification(
WireGuardNotification.NotificationChannels.VPN,
title = "${getString(R.string.tunnel_running)} - ${tunnelConf.tunName}",
@@ -67,4 +242,31 @@ class TunnelForegroundService : LifecycleService() {
),
)
}
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),
),
)
}
private fun onCreateNotification(): Notification {
return notificationManager.createNotification(
WireGuardNotification.NotificationChannels.VPN,
title = getString(R.string.tunnel_starting),
)
}
// TODO add notification handling and optional log reading for restart on handshake failures
companion object {
const val STATS_DELAY = 1_000L
const val PING_START_DELAY = 30_000L
// ipv6 disabled or block on network
// const val userspaceStartFailed = "Failed to send handshake initiation: write udp [::]"
// const val ipv6Fails = "Failed to send data packets: write udp [::]"
// const val ipv4Fails = "Failed to send data packets: write udp 0.0.0.0:51820"
}
}
@@ -1,44 +1,43 @@
package com.zaneschepke.wireguardautotunnel.core.service.autotunnel
import android.content.Intent
import android.net.NetworkCapabilities
import android.os.IBinder
import android.os.PowerManager
import androidx.core.app.ServiceCompat
import androidx.lifecycle.LifecycleService
import androidx.lifecycle.lifecycleScope
import com.wireguard.android.util.RootShell
import com.zaneschepke.networkmonitor.NetworkMonitor
import com.zaneschepke.networkmonitor.NetworkStatus
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.di.AppShell
import com.zaneschepke.wireguardautotunnel.di.IoDispatcher
import com.zaneschepke.wireguardautotunnel.di.MainImmediateDispatcher
import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager
import com.zaneschepke.wireguardautotunnel.domain.state.AutoTunnelState
import com.zaneschepke.wireguardautotunnel.domain.state.NetworkState
import com.zaneschepke.wireguardautotunnel.core.network.InternetConnectivityMonitor
import com.zaneschepke.wireguardautotunnel.core.network.NetworkMonitor
import com.zaneschepke.wireguardautotunnel.domain.state.ConnectivityState
import com.zaneschepke.wireguardautotunnel.domain.enums.NotificationAction
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.di.IoDispatcher
import com.zaneschepke.wireguardautotunnel.domain.entity.AppSettings
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.domain.enums.BackendState
import com.zaneschepke.wireguardautotunnel.domain.enums.NotificationAction
import com.zaneschepke.wireguardautotunnel.domain.events.AutoTunnelEvent
import com.zaneschepke.wireguardautotunnel.domain.events.KillSwitchEvent
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.domain.state.AutoTunnelState
import com.zaneschepke.wireguardautotunnel.domain.state.NetworkState
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.extensions.Tunnels
import com.zaneschepke.wireguardautotunnel.util.extensions.getCurrentWifiName
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@@ -49,10 +48,6 @@ import javax.inject.Provider
@AndroidEntryPoint
class AutoTunnelService : LifecycleService() {
@Inject
@AppShell
lateinit var rootShell: Provider<RootShell>
@Inject
lateinit var networkMonitor: NetworkMonitor
@@ -69,10 +64,6 @@ class AutoTunnelService : LifecycleService() {
@Inject
lateinit var serviceManager: ServiceManager
@Inject
@MainImmediateDispatcher
lateinit var mainImmediateDispatcher: CoroutineDispatcher
@Inject
lateinit var tunnelManager: TunnelManager
@@ -82,16 +73,12 @@ class AutoTunnelService : LifecycleService() {
private var wakeLock: PowerManager.WakeLock? = null
private var killSwitchJob: Job? = null
override fun onCreate() {
super.onCreate()
serviceManager.autoTunnelService.complete(this)
lifecycleScope.launch(mainImmediateDispatcher) {
runCatching {
launchWatcherNotification()
}.onFailure {
Timber.e(it)
}
}
launchWatcherNotification()
}
override fun onBind(intent: Intent): IBinder? {
@@ -100,20 +87,20 @@ class AutoTunnelService : LifecycleService() {
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId)
Timber.d("onStartCommand executed with startId: $startId")
serviceManager.autoTunnelService.complete(this)
return super.onStartCommand(intent, flags, startId)
start()
return START_STICKY
}
fun start() {
kotlin.runCatching {
lifecycleScope.launch(mainImmediateDispatcher) {
launchWatcherNotification()
initWakeLock()
}
launchWatcherNotification()
initWakeLock()
startAutoTunnelJob()
startAutoTunnelStateJob()
startKillSwitchJob()
killSwitchJob = startKillSwitchJob()
}.onFailure {
Timber.e(it)
}
@@ -126,9 +113,20 @@ class AutoTunnelService : LifecycleService() {
override fun onDestroy() {
serviceManager.autoTunnelService = CompletableDeferred()
restoreVpnKillSwitch()
super.onDestroy()
}
private fun restoreVpnKillSwitch() {
with(autoTunnelStateFlow.value) {
if (settings.isVpnKillSwitchEnabled && tunnelManager.getBackendState() != BackendState.KILL_SWITCH_ACTIVE) {
killSwitchJob?.cancel()
val allowedIps = if (settings.isLanOnKillSwitchEnabled) TunnelConf.LAN_BYPASS_ALLOWED_IPS else emptyList()
tunnelManager.setBackendState(BackendState.KILL_SWITCH_ACTIVE, allowedIps)
}
}
}
private fun launchWatcherNotification(description: String = getString(R.string.monitoring_state_changes)) {
val notification =
notificationManager.createNotification(
@@ -161,49 +159,54 @@ class AutoTunnelService : LifecycleService() {
}
}
private suspend fun buildNetworkState(connectivityState: ConnectivityState): NetworkState {
private fun buildNetworkState(networkStatus: NetworkStatus): NetworkState {
return with(autoTunnelStateFlow.value.networkState) {
val wifiName = when {
connectivityState.wifiAvailable &&
(wifiName == null || wifiName == Constants.UNREADABLE_SSID || networkMonitor.didWifiChangeSinceLastCapabilitiesQuery) -> {
networkMonitor.getWifiCapabilities()?.let { getWifiName(it) } ?: wifiName
val wifiName = when (networkStatus) {
is NetworkStatus.Connected -> {
networkStatus.wifiSsid
}
!connectivityState.wifiAvailable -> null
else -> wifiName
else -> null
}
copy(
isWifiConnected = connectivityState.wifiAvailable,
isMobileDataConnected = connectivityState.cellularAvailable,
isEthernetConnected = isEthernetConnected,
isWifiConnected = networkStatus.wifiConnected,
isMobileDataConnected = networkStatus.cellularConnected,
isEthernetConnected = networkStatus.ethernetConnected,
wifiName = wifiName,
)
}
}
@OptIn(ExperimentalCoroutinesApi::class)
private fun startAutoTunnelStateJob() = lifecycleScope.launch(ioDispatcher) {
combine(
combineSettings(),
networkMonitor.status.map {
buildNetworkState(it)
}.distinctUntilChanged(),
appDataRepository.get().settings.flow
.distinctUntilChanged { old, new -> old.isKernelEnabled == new.isKernelEnabled } // Only emit when isKernelEnabled changes
.flatMapLatest {
networkMonitor.networkStatusFlow
.flowOn(ioDispatcher)
.map { buildNetworkState(it) }
}
.distinctUntilChanged(),
) { double, networkState ->
AutoTunnelState(tunnelManager.activeTunnels().value, networkState, double.first, double.second)
AutoTunnelState(
tunnelManager.activeTunnels.value,
networkState,
double.first,
double.second,
)
}.collect { state ->
autoTunnelStateFlow.update {
it.copy(activeTunnels = state.activeTunnels, networkState = state.networkState, settings = state.settings, tunnels = state.tunnels)
it.copy(
activeTunnels = state.activeTunnels,
networkState = state.networkState,
settings = state.settings,
tunnels = state.tunnels,
)
}
}
}
private suspend fun getWifiName(wifiCapabilities: NetworkCapabilities): String? {
val setting = appDataRepository.get().settings.get()
return if (setting.isWifiNameByShellEnabled) {
rootShell.get().getCurrentWifiName()
} else {
InternetConnectivityMonitor.getNetworkName(wifiCapabilities, this@AutoTunnelService)
}
}
private fun combineSettings(): Flow<Pair<AppSettings, Tunnels>> {
return combine(
appDataRepository.get().settings.flow,
@@ -240,7 +243,7 @@ class AutoTunnelService : LifecycleService() {
Timber.d("Starting with debounce delay of: ${settings.debounceDelaySeconds} seconds")
autoTunnelStateFlow.debounce(settings.debounceDelayMillis()).collect { watcherState ->
if (watcherState == defaultState) return@collect
Timber.d("New auto tunnel state emitted")
Timber.d("New auto tunnel state emitted ${watcherState.networkState}")
when (val event = watcherState.asAutoTunnelEvent()) {
is AutoTunnelEvent.Start -> (event.tunnelConf ?: appDataRepository.get().getPrimaryOrFirstTunnel())?.let {
tunnelManager.startTunnel(it)
@@ -4,62 +4,65 @@ import android.content.Intent
import android.os.IBinder
import android.service.quicksettings.Tile
import android.service.quicksettings.TileService
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.di.ApplicationScope
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry
import androidx.lifecycle.lifecycleScope
import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@AndroidEntryPoint
class AutoTunnelControlTile : TileService() {
class AutoTunnelControlTile : TileService(), LifecycleOwner {
@Inject
lateinit var appDataRepository: AppDataRepository
@Inject
lateinit var serviceManager: ServiceManager
@Inject
@ApplicationScope
lateinit var applicationScope: CoroutineScope
private val lifecycleRegistry: LifecycleRegistry = LifecycleRegistry(this)
override fun onCreate() {
super.onCreate()
serviceManager.autoTunnelTile.complete(this)
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE)
}
override fun onDestroy() {
super.onDestroy()
serviceManager.autoTunnelTile = CompletableDeferred()
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY)
}
override fun onStartListening() {
super.onStartListening()
serviceManager.autoTunnelTile.complete(this)
applicationScope.launch {
if (appDataRepository.tunnels.getAll().isEmpty()) return@launch setUnavailable()
updateTileState()
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START)
Timber.d("Start listening called for auto tunnel tile")
lifecycleScope.launch {
serviceManager.autoTunnelActive.collect {
if (it) return@collect setActive()
setInactive()
}
}
}
fun updateTileState() {
serviceManager.autoTunnelActive.value.let {
if (it) setActive() else setInactive()
lifecycleScope.launch {
appDataRepository.tunnels.flow.collect {
if (it.isEmpty()) {
setUnavailable()
}
}
}
}
override fun onClick() {
super.onClick()
unlockAndRun {
applicationScope.launch {
lifecycleScope.launch {
if (serviceManager.autoTunnelActive.value) {
serviceManager.stopAutoTunnel()
setInactive()
} else {
serviceManager.startAutoTunnel(true)
serviceManager.startAutoTunnel()
setActive()
}
}
@@ -97,4 +100,7 @@ class AutoTunnelControlTile : TileService() {
qsTile.updateTile()
}
}
override val lifecycle: Lifecycle
get() = lifecycleRegistry
}
@@ -5,69 +5,128 @@ 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 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.di.ApplicationScope
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@AndroidEntryPoint
class TunnelControlTile : TileService() {
class TunnelControlTile : TileService(), LifecycleOwner {
@Inject
lateinit var appDataRepository: AppDataRepository
@Inject
@ApplicationScope
lateinit var applicationScope: CoroutineScope
@Inject
lateinit var serviceManager: ServiceManager
@Inject
lateinit var tunnelManager: TunnelManager
private val lifecycleRegistry: LifecycleRegistry = LifecycleRegistry(this)
private var isCollecting = false
override fun onCreate() {
super.onCreate()
serviceManager.tunnelControlTile.complete(this)
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE)
}
override fun onDestroy() {
super.onDestroy()
serviceManager.tunnelControlTile = CompletableDeferred()
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY)
}
override fun onStartListening() {
super.onStartListening()
Timber.d("Start listening called")
serviceManager.tunnelControlTile.complete(this)
applicationScope.launch {
updateTileState()
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START)
Timber.d("Start listening called for tunnel tile")
if (isCollecting) return
isCollecting = true
lifecycleScope.launch {
tunnelManager.activeTunnels.collect {
updateTileState()
}
}
}
fun updateTileState() = applicationScope.launch {
if (appDataRepository.tunnels.getAll().isEmpty()) return@launch setUnavailable()
with(tunnelManager.activeTunnels().value) {
if (isNotEmpty()) return@launch updateTile(if (size == 1) first().tunName else getString(R.string.multiple), true)
private suspend fun updateTileState() {
try {
val tunnels = appDataRepository.tunnels.getAll()
if (tunnels.isEmpty()) {
setUnavailable()
return
}
val activeTunnels = tunnelManager.activeTunnels.value
.filter { it.value.status.isUpOrStarting() }
when {
activeTunnels.isNotEmpty() -> {
val activeIds = activeTunnels.map { it.key.id }
// TODO improvements would be needed to make this work well with toggling multiple tunnels
// this would be better managed elsewhere
WireGuardAutoTunnel.setLastActiveTunnels(activeIds)
updateTileForActiveTunnels(activeTunnels)
}
else -> updateTileForLastActiveTunnels()
}
} catch (e: Exception) {
setUnavailable()
}
appDataRepository.getStartTunnelConfig()?.let {
updateTile(it.tunName, false)
}
private fun updateTileForActiveTunnels(activeTunnels: Map<TunnelConf, TunnelState>) {
val tileName = when (activeTunnels.size) {
1 -> activeTunnels.keys.first().tunName
else -> getString(R.string.multiple)
}
updateTile(tileName, true)
}
private suspend fun updateTileForLastActiveTunnels() {
val lastActiveIds = WireGuardAutoTunnel.getLastActiveTunnels()
when {
lastActiveIds.isEmpty() -> {
appDataRepository.getStartTunnelConfig()?.let { config ->
updateTile(config.tunName, false)
} ?: setUnavailable()
}
lastActiveIds.size > 1 -> updateTile(getString(R.string.multiple), false)
else -> {
val tunnelId = lastActiveIds.first()
appDataRepository.tunnels.getById(tunnelId)?.let { tunnel ->
updateTile(tunnel.tunName, false)
} ?: setUnavailable()
}
}
}
override fun onClick() {
super.onClick()
unlockAndRun {
applicationScope.launch {
if (tunnelManager.activeTunnels().value.isNotEmpty()) return@launch tunnelManager.stopTunnel()
appDataRepository.getStartTunnelConfig()?.let {
tunnelManager.startTunnel(it)
lifecycleScope.launch {
if (tunnelManager.activeTunnels.value.isNotEmpty()) return@launch tunnelManager.stopTunnel()
val lastActive = WireGuardAutoTunnel.getLastActiveTunnels()
if (lastActive.isEmpty()) {
appDataRepository.getStartTunnelConfig()?.let {
tunnelManager.startTunnel(it)
}
} else {
lastActive.forEach { id ->
appDataRepository.tunnels.getById(id)?.let {
tunnelManager.startTunnel(it)
}
}
}
}
}
@@ -127,4 +186,6 @@ class TunnelControlTile : TileService() {
Timber.e(it)
}
}
override val lifecycle: Lifecycle
get() = lifecycleRegistry
}
@@ -2,11 +2,12 @@ package com.zaneschepke.wireguardautotunnel.core.shortcut
import android.os.Bundle
import androidx.activity.ComponentActivity
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.di.ApplicationScope
import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager
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.di.ApplicationScope
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@@ -21,6 +22,9 @@ class ShortcutsActivity : ComponentActivity() {
@Inject
lateinit var serviceManager: ServiceManager
@Inject
lateinit var tunnelManager: TunnelManager
@Inject
@ApplicationScope
lateinit var applicationScope: CoroutineScope
@@ -39,17 +43,17 @@ class ShortcutsActivity : ComponentActivity() {
.firstOrNull { it.tunName == tunnelName }
} ?: appDataRepository.getStartTunnelConfig()
Timber.d("Shortcut action on name: ${tunnelConfig?.tunName}")
// tunnelConfig?.let {
// when (intent.action) {
// Action.START.name -> tunnelService.get().startTunnel(it)
// Action.STOP.name -> tunnelService.get().stopTunnel()
// else -> Unit
// }
// }
tunnelConfig?.let {
when (intent.action) {
Action.START.name -> tunnelManager.startTunnel(it)
Action.STOP.name -> tunnelManager.stopTunnel()
else -> Unit
}
}
}
AutoTunnelService::class.java.simpleName, LEGACY_AUTO_TUNNEL_SERVICE_NAME -> {
when (intent.action) {
Action.START.name -> serviceManager.startAutoTunnel(true)
Action.START.name -> serviceManager.startAutoTunnel()
Action.STOP.name -> serviceManager.stopAutoTunnel()
}
}
@@ -1,219 +1,204 @@
package com.zaneschepke.wireguardautotunnel.core.tunnel
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.zaneschepke.wireguardautotunnel.core.network.NetworkMonitor
import com.zaneschepke.wireguardautotunnel.core.notification.NotificationManager
import com.zaneschepke.wireguardautotunnel.core.notification.WireGuardNotification
import com.wireguard.android.backend.Tunnel
import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager
import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelProvider.Companion.CHECK_INTERVAL
import com.zaneschepke.wireguardautotunnel.di.ApplicationScope
import com.zaneschepke.wireguardautotunnel.di.IoDispatcher
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.domain.enums.BackendError
import com.zaneschepke.wireguardautotunnel.domain.enums.BackendState
import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelStatus
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState
import com.zaneschepke.wireguardautotunnel.domain.state.TunnelStatistics
import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.StringValue
import com.zaneschepke.wireguardautotunnel.util.extensions.cancelWithMessage
import com.zaneschepke.wireguardautotunnel.util.extensions.asTunnelState
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import timber.log.Timber
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.concurrent.thread
open class BaseTunnel(
abstract class BaseTunnel(
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
@ApplicationScope private val applicationScope: CoroutineScope,
private val networkMonitor: NetworkMonitor,
private val appDataRepository: AppDataRepository,
private val serviceManager: ServiceManager,
private val notificationManager: NotificationManager,
) : TunnelProvider {
internal val tunnels = MutableStateFlow<List<TunnelConf>>(emptyList())
private val activeTuns = MutableStateFlow<Map<TunnelConf, TunnelState>>(emptyMap())
private val tunThreads = ConcurrentHashMap<Int, Thread>()
override val activeTunnels = activeTuns.asStateFlow()
private val tunnelJobs = mutableMapOf<TunnelConf, Job>()
private val tunMutex = Mutex()
private val tunStatusMutex = Mutex()
private val isNetworkAvailable = AtomicBoolean(false)
private val isBouncing = AtomicBoolean(false)
init {
applicationScope.launch(ioDispatcher) {
launch {
startNetworkJob()
}
tunnels.collect { tuns ->
val previousTuns = tunnelJobs.keys.toSet()
val newTuns = tuns - previousTuns
val removedItems = previousTuns - tuns.toSet()
abstract suspend fun startBackend(tunnel: TunnelConf)
newTuns.forEach { tun ->
Timber.d("Starting tunnel jobs for tun ${tun.name}")
tunnelJobs[tun] = startTunnelJobs(tun)
abstract fun stopBackend(tunnel: TunnelConf)
override suspend fun clearError(tunnelConf: TunnelConf) = updateTunnelStatus(tunnelConf, TunnelStatus.Down)
override fun hasVpnPermission(): Boolean {
return serviceManager.hasVpnPermission()
}
protected suspend fun updateTunnelStatus(tunnelConf: TunnelConf, state: TunnelStatus? = null, stats: TunnelStatistics? = null) {
tunStatusMutex.withLock {
activeTuns.update { current ->
val originalConf = current.getKeyById(tunnelConf.id) ?: tunnelConf
val existingState = current.getValueById(tunnelConf.id) ?: TunnelState()
val newState = state ?: existingState.status
if (newState == TunnelStatus.Down) {
Timber.d("Removing tunnel ${tunnelConf.id} from activeTunnels as state is DOWN")
current - originalConf
} else if (existingState.status == newState && stats == null) {
Timber.d("Skipping redundant state update for ${tunnelConf.id}: $newState")
current
} else {
val updated = existingState.copy(
status = newState,
statistics = stats ?: existingState.statistics,
)
current + (originalConf to updated)
}
removedItems.forEach { tun ->
tunnelJobs[tun]?.cancelWithMessage("Canceling tunnel jobs for tunnel: ${tun.name}")
tunnelJobs.remove(tun)
}
serviceManager.updateTunnelTile()
}
}
}
private fun startTunnelJobs(tunnel: TunnelConf) = applicationScope.launch(ioDispatcher) {
launch {
startTunnelStatisticsJob(tunnel)
}
launch {
startPingJob(tunnel)
}
launch {
startTunnelConfigChangeJob(tunnel)
private suspend fun stopActiveTunnels() {
activeTunnels.value.forEach { (config, state) ->
if (state.status.isUpOrStarting()) {
stopTunnel(config)
delay(300)
}
}
}
override suspend fun bounceTunnel(tunnelConf: TunnelConf) {
if (tunnels.value.any { it.id == tunnelConf.id }) {
toggleTunnel(tunnelConf, TunnelStatus.DOWN)
toggleTunnel(tunnelConf, TunnelStatus.UP)
private fun configureTunnelCallbacks(tunnelConf: TunnelConf) {
Timber.d("Configuring TunnelConf instance: ${tunnelConf.hashCode()}")
tunnelConf.setStateChangeCallback { state ->
Timber.d("State change callback triggered for tunnel ${tunnelConf.id}: ${tunnelConf.tunName} with state $state at ${System.currentTimeMillis()}")
when (state) {
is Tunnel.State -> applicationScope.launch { updateTunnelStatus(tunnelConf, state.asTunnelState()) }
is org.amnezia.awg.backend.Tunnel.State -> applicationScope.launch { updateTunnelStatus(tunnelConf, state.asTunnelState()) }
}
serviceManager.updateTunnelTile()
}
}
override suspend fun setBackendState(backendState: BackendState, allowedIps: Collection<String>) {
}
override suspend fun runningTunnelNames(): Set<String> {
return emptySet()
}
override suspend fun activeTunnels(): StateFlow<List<TunnelConf>> {
return tunnels.asStateFlow()
override suspend fun updateTunnelStatistics(tunnel: TunnelConf) {
val stats = getStatistics(tunnel)
updateTunnelStatus(tunnel, null, stats)
}
override suspend fun startTunnel(tunnelConf: TunnelConf) {
if (tunnels.value.any { it.id == tunnelConf.id }) return Timber.w("Tunnel already running")
serviceManager.startBackgroundService(tunnelConf)
appDataRepository.tunnels.save(tunnelConf.copy(isActive = true))
}
override suspend fun stopTunnel(tunnelConf: TunnelConf?) {
}
open suspend fun toggleTunnel(tunnelConf: TunnelConf, state: TunnelStatus) {
}
open suspend fun getStatistics(tunnelConf: TunnelConf): TunnelStatistics {
throw NotImplementedError("Get statistics not implemented in base class")
}
internal suspend fun onTunnelStop(tunnelConf: TunnelConf) {
appDataRepository.tunnels.save(tunnelConf.copy(isActive = false))
removeFromActiveTunnels(tunnelConf)
if (tunnels.value.isEmpty()) serviceManager.stopBackgroundService()
}
internal suspend fun stopAllTunnels() {
tunnels.value.forEach {
stopTunnel(it)
}
}
internal fun addToActiveTunnels(conf: TunnelConf) {
tunnels.update {
it.toMutableList().apply {
add(conf)
}
}
}
private fun removeFromActiveTunnels(conf: TunnelConf) {
tunnels.update {
it.toMutableList().apply {
remove(conf)
}
}
}
private suspend fun startNetworkJob() = coroutineScope {
networkMonitor.status.distinctUntilChanged().collect {
isNetworkAvailable.set(!it.allOffline)
}
}
private suspend fun startPingJob(tunnel: TunnelConf) = coroutineScope {
while (isActive) {
if (isNetworkAvailable.get() && tunnel.isActive) {
val pingResult = tunnel.pingTunnel(ioDispatcher)
handlePingResult(tunnel, pingResult)
}
delay(CHECK_INTERVAL)
}
}
private suspend fun handlePingResult(tunnel: TunnelConf, pingResult: List<Boolean>) {
if (pingResult.contains(false)) {
if (isNetworkAvailable.get()) {
Timber.i("Ping result: target was not reachable, bouncing the tunnel")
bounceTunnel(tunnel)
delay(tunnel.pingCooldown ?: Constants.PING_COOLDOWN)
} else {
Timber.i("Ping result: target was not reachable, but no network available")
}
} else {
Timber.i("Ping result: all ping targets were reached successfully")
}
}
internal fun handleBackendThrowable(backendError: BackendError) {
val message = when (backendError) {
BackendError.Config -> StringValue.StringResource(R.string.start_failed_config)
BackendError.DNS -> StringValue.StringResource(R.string.dns_error)
BackendError.Unauthorized -> StringValue.StringResource(R.string.unauthorized)
}
if (WireGuardAutoTunnel.isForeground()) {
SnackbarController.showMessage(message)
} else {
notificationManager.show(
NotificationManager.VPN_NOTIFICATION_ID,
notificationManager.createNotification(
WireGuardNotification.NotificationChannels.VPN,
title = StringValue.StringResource(R.string.tunne_start_failed_title),
description = message,
),
)
}
}
private suspend fun startTunnelConfigChangeJob(tunnel: TunnelConf) = coroutineScope {
appDataRepository.tunnels.flow.collect { storageTuns ->
storageTuns.firstOrNull { it.id == tunnel.id }?.let { storageTun ->
if (tunnel.isQuickConfigChanged(storageTun) || tunnel.isPingConfigMatching(storageTun)) {
bounceTunnel(tunnel)
if (activeTuns.exists(tunnelConf.id) || tunThreads.containsKey(tunnelConf.id)) return
// stop active tunnels if we are userspace
if (this@BaseTunnel is UserspaceTunnel) stopActiveTunnels()
tunMutex.withLock {
// use thread to interrupt java backend if stuck (like in dns resolution)
tunThreads += tunnelConf.id to thread {
runBlocking {
try {
Timber.d("Starting tunnel ${tunnelConf.id}...")
startTunnelInner(tunnelConf)
Timber.d("Started complete for tunnel ${tunnelConf.name}...")
} catch (e: BackendError) {
Timber.e(e, "Failed to start tunnel ${tunnelConf.name} userspace")
updateTunnelStatus(tunnelConf, TunnelStatus.Error(e))
} catch (e: InterruptedException) {
Timber.i("Tunnel start has been interrupted as ${tunnelConf.name} failed to start")
}
}
}
}
}
private suspend fun startTunnelStatisticsJob(tunnel: TunnelConf) = coroutineScope {
while (isActive) {
val stats = getStatistics(tunnel)
tunnel.state.update {
it.copy(statistics = stats)
private suspend fun startTunnelInner(tunnelConf: TunnelConf) {
configureTunnelCallbacks(tunnelConf)
Timber.d("Started backend for tunnel ${tunnelConf.id}...")
startBackend(tunnelConf)
updateTunnelStatus(tunnelConf, TunnelStatus.Up)
Timber.d("DONE for tun ${tunnelConf.id}...")
saveTunnelActiveState(tunnelConf, true)
serviceManager.startTunnelForegroundService()
}
private suspend fun saveTunnelActiveState(tunnelConf: TunnelConf, active: Boolean) {
val tunnelCopy = tunnelConf.copyWithCallback(isActive = active)
appDataRepository.tunnels.save(tunnelCopy)
}
override suspend fun stopTunnel(tunnelConf: TunnelConf?, reason: TunnelStatus.StopReason) {
if (tunnelConf == null) return stopActiveTunnels()
tunMutex.withLock {
try {
val stuckStarting = activeTuns.isStarting(tunnelConf.id)
handleTunnelThreadCleanup(tunnelConf)
if (stuckStarting) return Timber.d("Stuck in starting, so just shutting down tunnel thread")
updateTunnelStatus(tunnelConf, TunnelStatus.Stopping(reason))
stopTunnelInner(tunnelConf)
} catch (e: BackendError) {
Timber.e(e, "Failed to stop tunnel ${tunnelConf.id}")
updateTunnelStatus(tunnelConf, TunnelStatus.Error(e))
}
delay(CHECK_INTERVAL)
}
}
private suspend fun stopTunnelInner(tunnelConf: TunnelConf) {
val tunnel = activeTuns.findTunnel(tunnelConf.id) ?: return
stopBackend(tunnel)
saveTunnelActiveState(tunnelConf, false)
removeActiveTunnel(tunnel)
handleServiceChangesOnStop()
}
private suspend fun handleServiceChangesOnStop() {
if (activeTuns.value.isEmpty() && !isBouncing.get()) return serviceManager.stopTunnelForegroundService()
}
private suspend fun handleTunnelThreadCleanup(tunnel: TunnelConf) {
Timber.d("Cleaning up thread for ${tunnel.name}")
try {
tunThreads[tunnel.id]?.let {
if (it.state != Thread.State.TERMINATED) {
it.interrupt()
updateTunnelStatus(tunnel, TunnelStatus.Down)
} else {
Timber.d("Thread already terminated")
}
}
} catch (e: Exception) {
Timber.e(e, "Failed to stop tunnel thread for ${tunnel.name}")
}
Timber.d("Removing thread for ${tunnel.name}")
tunThreads -= tunnel.id
}
private fun removeActiveTunnel(tunnelConf: TunnelConf) {
activeTuns.update { current ->
current.toMutableMap().apply { remove(tunnelConf) }
}
}
override suspend fun bounceTunnel(tunnelConf: TunnelConf, reason: TunnelStatus.StopReason) {
Timber.i("Bounce tunnel ${tunnelConf.name}")
isBouncing.set(true)
stopTunnel(tunnelConf, reason)
startTunnel(tunnelConf)
isBouncing.set(false)
}
override suspend fun runningTunnelNames(): Set<String> = activeTuns.value.keys.map { it.tunName }.toSet()
}
@@ -0,0 +1,51 @@
package com.zaneschepke.wireguardautotunnel.core.tunnel
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelStatus
import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState
import kotlinx.coroutines.flow.MutableStateFlow
fun Map<TunnelConf, TunnelState>.allDown(): Boolean {
return this.all { it.value.status.isDown() }
}
fun Map<TunnelConf, TunnelState>.hasActive(): Boolean {
return this.any { it.value.status.isUp() }
}
fun Map<TunnelConf, TunnelState>.getValueById(id: Int): TunnelState? {
val key = this.keys.find { it.id == id }
return key?.let { this@getValueById[it] }
}
fun Map<TunnelConf, TunnelState>.getKeyById(id: Int): TunnelConf? {
return this.keys.find { it.id == id }
}
fun Map<TunnelConf, TunnelState>.isUp(tunnelConf: TunnelConf): Boolean {
return this.getValueById(tunnelConf.id)?.status?.isUp() ?: false
}
fun MutableStateFlow<Map<TunnelConf, TunnelState>>.exists(id: Int): Boolean {
return this.value.any { it.key.id == id }
}
fun MutableStateFlow<Map<TunnelConf, TunnelState>>.isUp(id: Int): Boolean {
return this.value.any { it.key.id == id && it.value.status == TunnelStatus.Up }
}
fun MutableStateFlow<Map<TunnelConf, TunnelState>>.isStarting(id: Int): Boolean {
return this.value.any { it.key.id == id && it.value.status == TunnelStatus.Starting }
}
fun MutableStateFlow<Map<TunnelConf, TunnelState>>.findTunnel(id: Int): TunnelConf? {
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)
}
@@ -3,8 +3,6 @@ package com.zaneschepke.wireguardautotunnel.core.tunnel
import com.wireguard.android.backend.Backend
import com.wireguard.android.backend.BackendException
import com.wireguard.android.backend.Tunnel
import com.zaneschepke.wireguardautotunnel.core.network.NetworkMonitor
import com.zaneschepke.wireguardautotunnel.core.notification.NotificationManager
import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager
import com.zaneschepke.wireguardautotunnel.di.ApplicationScope
import com.zaneschepke.wireguardautotunnel.di.IoDispatcher
@@ -17,7 +15,6 @@ import com.zaneschepke.wireguardautotunnel.domain.state.WireGuardStatistics
import com.zaneschepke.wireguardautotunnel.util.extensions.toBackendError
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.withContext
import timber.log.Timber
import javax.inject.Inject
@@ -26,57 +23,44 @@ class KernelTunnel @Inject constructor(
@ApplicationScope private val applicationScope: CoroutineScope,
serviceManager: ServiceManager,
appDataRepository: AppDataRepository,
notificationManager: NotificationManager,
private val backend: Backend,
networkMonitor: NetworkMonitor,
) : BaseTunnel(ioDispatcher, applicationScope, networkMonitor, appDataRepository, serviceManager, notificationManager) {
) : BaseTunnel(ioDispatcher, applicationScope, appDataRepository, serviceManager) {
override suspend fun startTunnel(tunnelConf: TunnelConf) {
withContext(ioDispatcher) {
super.startTunnel(tunnelConf)
runCatching {
backend.setState(tunnelConf, Tunnel.State.UP, tunnelConf.toWgConfig())
addToActiveTunnels(tunnelConf)
}.onFailure {
onTunnelStop(tunnelConf)
if (it is BackendException) {
handleBackendThrowable(it.toBackendError())
} else {
Timber.e(it)
}
}
override fun getStatistics(tunnelConf: TunnelConf): TunnelStatistics? {
return try {
WireGuardStatistics(backend.getStatistics(tunnelConf))
} catch (e: Exception) {
Timber.e(e)
null
}
}
override suspend fun getStatistics(tunnelConf: TunnelConf): TunnelStatistics {
return WireGuardStatistics(backend.getStatistics(tunnelConf))
}
override suspend fun stopTunnel(tunnelConf: TunnelConf?) {
withContext(ioDispatcher) {
val tunnel = tunnels.value.firstOrNull { it.id == tunnelConf?.id }
runCatching {
tunnel?.let {
backend.setState(it, Tunnel.State.DOWN, it.toWgConfig())
onTunnelStop(it)
} ?: stopAllTunnels()
}.onFailure {
Timber.e(it)
}
override suspend fun startBackend(tunnel: TunnelConf) {
try {
updateTunnelStatus(tunnel, TunnelStatus.Starting)
backend.setState(tunnel, Tunnel.State.UP, tunnel.toWgConfig())
} catch (e: BackendException) {
throw e.toBackendError()
}
}
override suspend fun toggleTunnel(tunnelConf: TunnelConf, status: TunnelStatus) {
when (status) {
TunnelStatus.UP -> backend.setState(tunnelConf, Tunnel.State.UP, tunnelConf.toWgConfig())
TunnelStatus.DOWN -> backend.setState(tunnelConf, Tunnel.State.DOWN, tunnelConf.toWgConfig())
override fun stopBackend(tunnel: TunnelConf) {
Timber.i("Stopping tunnel ${tunnel.id} kernel")
try {
backend.setState(tunnel, Tunnel.State.DOWN, tunnel.toWgConfig())
} catch (e: BackendException) {
throw e.toBackendError()
}
}
override suspend fun setBackendState(backendState: BackendState, allowedIps: Collection<String>) {
override fun setBackendState(backendState: BackendState, allowedIps: Collection<String>) {
Timber.w("Not yet implemented for kernel")
}
override fun getBackendState(): BackendState {
return BackendState.INACTIVE
}
override suspend fun runningTunnelNames(): Set<String> {
return backend.runningTunnelNames
}
@@ -4,20 +4,21 @@ import com.zaneschepke.wireguardautotunnel.di.ApplicationScope
import com.zaneschepke.wireguardautotunnel.di.IoDispatcher
import com.zaneschepke.wireguardautotunnel.di.Kernel
import com.zaneschepke.wireguardautotunnel.di.Userspace
import com.zaneschepke.wireguardautotunnel.domain.entity.AppSettings
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.domain.enums.BackendState
import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelStatus
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.util.extensions.withData
import com.zaneschepke.wireguardautotunnel.domain.state.TunnelStatistics
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
import kotlinx.coroutines.withContext
import javax.inject.Inject
class TunnelManager @Inject constructor(
@@ -28,71 +29,85 @@ class TunnelManager @Inject constructor(
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) : TunnelProvider {
val appSettings: StateFlow<AppSettings?> = appDataRepository.settings.flow.stateIn(
scope = applicationScope.plus(ioDispatcher),
started = SharingStarted.Eagerly,
initialValue = null,
)
@OptIn(ExperimentalCoroutinesApi::class)
private val tunnelProviderFlow = appDataRepository.settings.flow
.filterNotNull()
.flatMapLatest { settings ->
MutableStateFlow(if (settings.isKernelEnabled) kernelTunnel else userspaceTunnel)
}
.stateIn(
scope = applicationScope.plus(ioDispatcher),
started = SharingStarted.Eagerly,
initialValue = userspaceTunnel,
)
override suspend fun activeTunnels(): StateFlow<List<TunnelConf>> {
return withContext(ioDispatcher) {
appSettings.filterNotNull().first().let {
if (it.isKernelEnabled) return@withContext kernelTunnel.activeTunnels()
userspaceTunnel.activeTunnels()
@OptIn(ExperimentalCoroutinesApi::class)
override val activeTunnels = appDataRepository.settings.flow
.filterNotNull()
.flatMapLatest { settings ->
if (settings.isKernelEnabled) {
kernelTunnel.activeTunnels
} else {
userspaceTunnel.activeTunnels
}
}
.stateIn(
scope = applicationScope.plus(ioDispatcher),
started = SharingStarted.Eagerly,
initialValue = emptyMap(),
)
override fun hasVpnPermission(): Boolean {
return userspaceTunnel.hasVpnPermission()
}
override suspend fun clearError(tunnelConf: TunnelConf) {
tunnelProviderFlow.value.clearError(tunnelConf)
}
override suspend fun updateTunnelStatistics(tunnel: TunnelConf) {
tunnelProviderFlow.value.updateTunnelStatistics(tunnel)
}
override suspend fun startTunnel(tunnelConf: TunnelConf) {
appSettings.withData {
if (it.isKernelEnabled) return@withData kernelTunnel.startTunnel(tunnelConf)
userspaceTunnel.startTunnel(tunnelConf)
}
tunnelProviderFlow.value.startTunnel(tunnelConf)
}
override suspend fun stopTunnel(tunnelConf: TunnelConf?) {
appSettings.withData {
if (it.isKernelEnabled) return@withData kernelTunnel.stopTunnel(tunnelConf)
userspaceTunnel.stopTunnel(tunnelConf)
}
override suspend fun stopTunnel(tunnelConf: TunnelConf?, reason: TunnelStatus.StopReason) {
tunnelProviderFlow.value.stopTunnel(tunnelConf)
}
override suspend fun bounceTunnel(tunnelConf: TunnelConf) {
appSettings.withData {
if (it.isKernelEnabled) return@withData kernelTunnel.stopTunnel(tunnelConf)
userspaceTunnel.stopTunnel(tunnelConf)
}
override suspend fun bounceTunnel(tunnelConf: TunnelConf, reason: TunnelStatus.StopReason) {
tunnelProviderFlow.value.bounceTunnel(tunnelConf)
}
override suspend fun setBackendState(backendState: BackendState, allowedIps: Collection<String>) {
appSettings.withData {
if (it.isKernelEnabled) return@withData kernelTunnel.setBackendState(backendState, allowedIps)
userspaceTunnel.setBackendState(backendState, allowedIps)
}
override fun setBackendState(backendState: BackendState, allowedIps: Collection<String>) {
tunnelProviderFlow.value.setBackendState(backendState, allowedIps)
}
override fun getBackendState(): BackendState {
return tunnelProviderFlow.value.getBackendState()
}
override suspend fun runningTunnelNames(): Set<String> {
appSettings.filterNotNull().first().let {
if (it.isKernelEnabled) return kernelTunnel.runningTunnelNames()
return userspaceTunnel.runningTunnelNames()
}
return tunnelProviderFlow.value.runningTunnelNames()
}
suspend fun restorePreviousState() {
withContext(ioDispatcher) {
with(appDataRepository.settings.get()) {
if (isRestoreOnBootEnabled) {
val previouslyActiveTuns = appDataRepository.tunnels.getActive()
// handle kernel mode
val tunsToStart = previouslyActiveTuns.filterNot { tun -> activeTunnels().value.any { tun.id == it.id } }
if (isKernelEnabled) {
return@withContext tunsToStart.forEach {
startTunnel(it)
}
}
// handle userspace
if (activeTunnels().value.isEmpty()) tunsToStart.firstOrNull()?.let { startTunnel(it) }
override fun getStatistics(tunnelConf: TunnelConf): TunnelStatistics? {
return tunnelProviderFlow.value.getStatistics(tunnelConf)
}
fun restorePreviousState() = applicationScope.launch(ioDispatcher) {
val settings = appDataRepository.settings.get()
if (settings.isRestoreOnBootEnabled) {
val previouslyActiveTuns = appDataRepository.tunnels.getActive()
val tunsToStart = previouslyActiveTuns.filterNot { tun -> activeTunnels.value.any { tun.id == it.key.id } }
if (settings.isKernelEnabled) {
return@launch tunsToStart.forEach {
startTunnel(it)
}
} else {
tunsToStart.firstOrNull()?.let { startTunnel(it) }
}
}
}
@@ -2,23 +2,21 @@ package com.zaneschepke.wireguardautotunnel.core.tunnel
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.domain.enums.BackendState
import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelStatus
import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState
import com.zaneschepke.wireguardautotunnel.domain.state.TunnelStatistics
import kotlinx.coroutines.flow.StateFlow
interface TunnelProvider {
suspend fun activeTunnels(): StateFlow<List<TunnelConf>>
suspend fun startTunnel(tunnelConf: TunnelConf)
suspend fun stopTunnel(tunnelConf: TunnelConf? = null)
suspend fun bounceTunnel(tunnelConf: TunnelConf)
suspend fun setBackendState(backendState: BackendState, allowedIps: Collection<String>)
suspend fun stopTunnel(tunnelConf: TunnelConf? = null, reason: TunnelStatus.StopReason = TunnelStatus.StopReason.USER)
suspend fun bounceTunnel(tunnelConf: TunnelConf, reason: TunnelStatus.StopReason = TunnelStatus.StopReason.USER)
fun setBackendState(backendState: BackendState, allowedIps: Collection<String>)
fun getBackendState(): BackendState
suspend fun runningTunnelNames(): Set<String>
companion object {
const val CHECK_INTERVAL = 1_000L
}
fun getStatistics(tunnelConf: TunnelConf): TunnelStatistics?
val activeTunnels: StateFlow<Map<TunnelConf, TunnelState>>
fun hasVpnPermission(): Boolean
suspend fun clearError(tunnelConf: TunnelConf)
suspend fun updateTunnelStatistics(tunnel: TunnelConf)
}
@@ -1,8 +1,5 @@
package com.zaneschepke.wireguardautotunnel.core.tunnel
import com.wireguard.android.backend.BackendException
import com.zaneschepke.wireguardautotunnel.core.network.NetworkMonitor
import com.zaneschepke.wireguardautotunnel.core.notification.NotificationManager
import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager
import com.zaneschepke.wireguardautotunnel.di.ApplicationScope
import com.zaneschepke.wireguardautotunnel.di.IoDispatcher
@@ -13,71 +10,98 @@ import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.domain.state.AmneziaStatistics
import com.zaneschepke.wireguardautotunnel.domain.state.TunnelStatistics
import com.zaneschepke.wireguardautotunnel.util.extensions.asAmBackendState
import com.zaneschepke.wireguardautotunnel.util.extensions.asBackendState
import com.zaneschepke.wireguardautotunnel.util.extensions.toBackendError
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.withContext
import org.amnezia.awg.backend.Backend
import org.amnezia.awg.backend.BackendException
import org.amnezia.awg.backend.Tunnel
import org.amnezia.awg.config.Config
import timber.log.Timber
import javax.inject.Inject
import kotlin.jvm.optionals.getOrNull
class UserspaceTunnel @Inject constructor(
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
@ApplicationScope private val applicationScope: CoroutineScope,
serviceManager: ServiceManager,
appDataRepository: AppDataRepository,
notificationManager: NotificationManager,
val serviceManager: ServiceManager,
val appDataRepository: AppDataRepository,
private val backend: Backend,
networkMonitor: NetworkMonitor,
) : TunnelProvider, BaseTunnel(ioDispatcher, applicationScope, networkMonitor, appDataRepository, serviceManager, notificationManager) {
) : BaseTunnel(ioDispatcher, applicationScope, appDataRepository, serviceManager) {
override suspend fun startTunnel(tunnelConf: TunnelConf) {
withContext(ioDispatcher) {
super.startTunnel(tunnelConf)
runCatching {
backend.setState(tunnelConf, Tunnel.State.UP, tunnelConf.toAmConfig())
addToActiveTunnels(tunnelConf)
}.onFailure {
onTunnelStop(tunnelConf)
if (it is BackendException) {
handleBackendThrowable(it.toBackendError())
} else {
Timber.e(it)
}
private var previousBackendState: Pair<BackendState, Boolean>? = null
override suspend fun startBackend(tunnel: TunnelConf) {
try {
updateTunnelStatus(tunnel, TunnelStatus.Starting)
val amConfig = tunnel.toAmConfig()
handleVpnKillSwitchWithDomainEndpoints(amConfig)
backend.setState(tunnel, Tunnel.State.UP, amConfig)
} catch (e: BackendException) {
Timber.e(e, "Failed to start up backend for tunnel ${tunnel.name}")
throw e.toBackendError()
}
}
override fun stopBackend(tunnel: TunnelConf) {
Timber.i("Stopping tunnel ${tunnel.name} userspace")
try {
backend.setState(tunnel, Tunnel.State.DOWN, tunnel.toAmConfig())
} catch (e: BackendException) {
Timber.e(e, "Failed to stop tunnel ${tunnel.id}")
throw e.toBackendError()
}
handlePreviouslyEnabledVpnKillSwitch()
}
// stop vpn kill switch if we need to resolve DNS for peer endpoints
private suspend fun handleVpnKillSwitchWithDomainEndpoints(config: Config) {
if (config.peers.any { it.endpoint.getOrNull()?.toString()?.isUrl() == true } &&
backend.backendState.asBackendState() == BackendState.KILL_SWITCH_ACTIVE
) {
val bypassLan = appDataRepository.settings.get().isLanOnKillSwitchEnabled
previousBackendState = Pair(BackendState.KILL_SWITCH_ACTIVE, bypassLan)
setBackendState(BackendState.SERVICE_ACTIVE, emptyList())
}
}
// restore vpn kill switch if needed
private fun handlePreviouslyEnabledVpnKillSwitch() {
// let auto tunnel handle this if it is active
if (!serviceManager.autoTunnelActive.value) {
previousBackendState?.let { (state, lanEnabled) ->
Timber.d("Restoring kill switch configuration")
val lan = if (lanEnabled) TunnelConf.LAN_BYPASS_ALLOWED_IPS else emptyList()
backend.setBackendState(state.asAmBackendState(), lan)
}
}
previousBackendState = null
}
override suspend fun getStatistics(tunnelConf: TunnelConf): TunnelStatistics {
return AmneziaStatistics(backend.getStatistics(tunnelConf))
}
override suspend fun toggleTunnel(tunnelConf: TunnelConf, status: TunnelStatus) {
when (status) {
TunnelStatus.UP -> backend.setState(tunnelConf, Tunnel.State.UP, tunnelConf.toAmConfig())
TunnelStatus.DOWN -> backend.setState(tunnelConf, Tunnel.State.DOWN, tunnelConf.toAmConfig())
override fun setBackendState(backendState: BackendState, allowedIps: Collection<String>) {
Timber.d("Setting backend state: $backendState with allowedIps: $allowedIps")
try {
backend.setBackendState(backendState.asAmBackendState(), allowedIps)
} catch (e: BackendException) {
throw e.toBackendError()
}
}
override suspend fun stopTunnel(tunnelConf: TunnelConf?) {
withContext(ioDispatcher) {
runCatching {
tunnels.value.firstOrNull { it.id == tunnelConf?.id }?.let {
backend.setState(it, Tunnel.State.DOWN, it.toAmConfig())
onTunnelStop(it)
} ?: stopAllTunnels()
}.onFailure {
Timber.e(it)
}
}
}
override suspend fun setBackendState(backendState: BackendState, allowedIps: Collection<String>) {
backend.setBackendState(backendState.asAmBackendState(), allowedIps)
override fun getBackendState(): BackendState {
return backend.backendState.asBackendState()
}
override suspend fun runningTunnelNames(): Set<String> {
return backend.runningTunnelNames
}
override fun getStatistics(tunnelConf: TunnelConf): TunnelStatistics? {
return try {
AmneziaStatistics(backend.getStatistics(tunnelConf))
} catch (e: Exception) {
Timber.e(e, "Failed to get stats for ${tunnelConf.tunName}")
null
}
}
}
@@ -52,8 +52,8 @@ class ServiceWorker @AssistedInject constructor(
override suspend fun doWork(): Result = withContext(ioDispatcher) {
Timber.i("Service worker started")
with(appDataRepository.settings.get()) {
if (isAutoTunnelEnabled && !serviceManager.autoTunnelActive.value) return@with serviceManager.startAutoTunnel(true)
if (tunnelManager.activeTunnels().value.isEmpty()) tunnelManager.restorePreviousState()
if (isAutoTunnelEnabled && !serviceManager.autoTunnelActive.value) return@with serviceManager.startAutoTunnel()
if (tunnelManager.activeTunnels.value.isEmpty()) tunnelManager.restorePreviousState()
}
Result.success()
}
@@ -1,7 +1,6 @@
package com.zaneschepke.wireguardautotunnel.data
import androidx.room.TypeConverter
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
class DatabaseListConverters {
@@ -1,10 +1,10 @@
package com.zaneschepke.wireguardautotunnel.data.repository
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.domain.repository.AppStateRepository
import com.zaneschepke.wireguardautotunnel.domain.repository.AppSettingRepository
import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.domain.repository.AppSettingRepository
import com.zaneschepke.wireguardautotunnel.domain.repository.AppStateRepository
import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository
import javax.inject.Inject
class AppDataRoomRepository
@@ -1,8 +1,8 @@
package com.zaneschepke.wireguardautotunnel.data.repository
import com.zaneschepke.wireguardautotunnel.data.DataStoreManager
import com.zaneschepke.wireguardautotunnel.domain.repository.AppStateRepository
import com.zaneschepke.wireguardautotunnel.data.model.GeneralState
import com.zaneschepke.wireguardautotunnel.domain.repository.AppStateRepository
import com.zaneschepke.wireguardautotunnel.ui.theme.Theme
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
@@ -1,10 +1,10 @@
package com.zaneschepke.wireguardautotunnel.data.repository
import com.zaneschepke.wireguardautotunnel.data.dao.SettingsDao
import com.zaneschepke.wireguardautotunnel.domain.repository.AppSettingRepository
import com.zaneschepke.wireguardautotunnel.data.model.Settings
import com.zaneschepke.wireguardautotunnel.di.IoDispatcher
import com.zaneschepke.wireguardautotunnel.domain.entity.AppSettings
import com.zaneschepke.wireguardautotunnel.domain.repository.AppSettingRepository
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
@@ -1,10 +1,10 @@
package com.zaneschepke.wireguardautotunnel.data.repository
import com.zaneschepke.wireguardautotunnel.data.dao.TunnelConfigDao
import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository
import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.di.IoDispatcher
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository
import com.zaneschepke.wireguardautotunnel.util.extensions.Tunnels
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.flowOn
@@ -30,6 +30,12 @@ class RoomTunnelRepository(
}
}
override suspend fun saveAll(tunnelConfs: List<TunnelConf>) {
withContext(ioDispatcher) {
tunnelConfigDao.saveAll(tunnelConfs.map(TunnelConfig::from))
}
}
override suspend fun updatePrimaryTunnel(tunnelConf: TunnelConf?) {
withContext(ioDispatcher) {
tunnelConfigDao.resetPrimaryTunnel()
@@ -4,17 +4,17 @@ import android.content.Context
import androidx.room.Room
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.data.AppDatabase
import com.zaneschepke.wireguardautotunnel.data.DataStoreManager
import com.zaneschepke.wireguardautotunnel.data.DatabaseCallback
import com.zaneschepke.wireguardautotunnel.data.dao.SettingsDao
import com.zaneschepke.wireguardautotunnel.data.dao.TunnelConfigDao
import com.zaneschepke.wireguardautotunnel.data.DataStoreManager
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRoomRepository
import com.zaneschepke.wireguardautotunnel.domain.repository.AppStateRepository
import com.zaneschepke.wireguardautotunnel.data.repository.DataStoreAppStateRepository
import com.zaneschepke.wireguardautotunnel.data.repository.RoomSettingsRepository
import com.zaneschepke.wireguardautotunnel.data.repository.RoomTunnelRepository
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.domain.repository.AppSettingRepository
import com.zaneschepke.wireguardautotunnel.domain.repository.AppStateRepository
import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository
import dagger.Module
import dagger.Provides
@@ -1,16 +0,0 @@
package com.zaneschepke.wireguardautotunnel.di
import com.zaneschepke.wireguardautotunnel.core.network.InternetConnectivityMonitor
import com.zaneschepke.wireguardautotunnel.core.network.NetworkMonitor
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
@Module
@InstallIn(SingletonComponent::class)
abstract class ServiceModule {
@Binds
abstract fun provideInternetConnectivityService(wifiService: InternetConnectivityMonitor): NetworkMonitor
}
@@ -4,14 +4,15 @@ import android.content.Context
import com.wireguard.android.backend.WgQuickBackend
import com.wireguard.android.util.RootShell
import com.wireguard.android.util.ToolsInstaller
import com.zaneschepke.wireguardautotunnel.core.network.NetworkMonitor
import com.zaneschepke.wireguardautotunnel.core.notification.NotificationManager
import com.zaneschepke.networkmonitor.AndroidNetworkMonitor
import com.zaneschepke.networkmonitor.NetworkMonitor
import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager
import com.zaneschepke.wireguardautotunnel.core.tunnel.KernelTunnel
import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager
import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelProvider
import com.zaneschepke.wireguardautotunnel.core.tunnel.UserspaceTunnel
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.domain.repository.AppSettingRepository
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@@ -19,6 +20,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.runBlocking
import org.amnezia.awg.backend.Backend
import org.amnezia.awg.backend.GoBackend
import org.amnezia.awg.backend.RootTunnelActionHandler
@@ -64,11 +66,9 @@ class TunnelModule {
@ApplicationScope applicationScope: CoroutineScope,
serviceManager: ServiceManager,
appDataRepository: AppDataRepository,
networkMonitor: NetworkMonitor,
notificationManager: NotificationManager,
backend: com.wireguard.android.backend.Backend,
): TunnelProvider {
return KernelTunnel(ioDispatcher, applicationScope, serviceManager, appDataRepository, notificationManager, backend, networkMonitor)
return KernelTunnel(ioDispatcher, applicationScope, serviceManager, appDataRepository, backend)
}
@Provides
@@ -79,11 +79,9 @@ class TunnelModule {
@ApplicationScope applicationScope: CoroutineScope,
serviceManager: ServiceManager,
appDataRepository: AppDataRepository,
notificationManager: NotificationManager,
networkMonitor: NetworkMonitor,
backend: Backend,
): TunnelProvider {
return UserspaceTunnel(ioDispatcher, applicationScope, serviceManager, appDataRepository, notificationManager, backend, networkMonitor)
return UserspaceTunnel(ioDispatcher, applicationScope, serviceManager, appDataRepository, backend)
}
@Provides
@@ -98,13 +96,21 @@ class TunnelModule {
return TunnelManager(kernelTunnel, userspaceTunnel, appDataRepository, applicationScope, ioDispatcher)
}
@Provides
@Singleton
fun provideNetworkMonitor(@ApplicationContext context: Context, settingsRepository: AppSettingRepository): NetworkMonitor {
return AndroidNetworkMonitor(context) { runBlocking { settingsRepository.get().isWifiNameByShellEnabled } }
}
@Singleton
@Provides
fun provideServiceManager(
@ApplicationContext context: Context,
@IoDispatcher ioDispatcher: CoroutineDispatcher,
@MainDispatcher mainCoroutineDispatcher: CoroutineDispatcher,
@ApplicationScope applicationScope: CoroutineScope,
appDataRepository: AppDataRepository,
): ServiceManager {
return ServiceManager(context, ioDispatcher, appDataRepository)
return ServiceManager(context, ioDispatcher, applicationScope, mainCoroutineDispatcher, appDataRepository)
}
}
@@ -1,18 +1,18 @@
package com.zaneschepke.wireguardautotunnel.domain.entity
import com.wireguard.android.backend.Tunnel
import com.wireguard.config.Config
import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.extensions.asTunnelState
import com.zaneschepke.wireguardautotunnel.util.extensions.defaultName
import com.zaneschepke.wireguardautotunnel.util.extensions.extractNameAndNumber
import com.zaneschepke.wireguardautotunnel.util.extensions.hasNumberInParentheses
import com.zaneschepke.wireguardautotunnel.util.extensions.isReachable
import com.zaneschepke.wireguardautotunnel.util.extensions.toWgQuickString
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext
import org.amnezia.awg.backend.Tunnel
import timber.log.Timber
import java.io.InputStream
import java.net.InetAddress
import java.nio.charset.StandardCharsets
import kotlin.coroutines.CoroutineContext
data class TunnelConf(
@@ -29,10 +29,67 @@ data class TunnelConf(
val pingCooldown: Long? = null,
val pingIp: String? = null,
val isEthernetTunnel: Boolean = false,
val isIpv4Preferred: Boolean = false,
) : Tunnel, com.wireguard.android.backend.Tunnel {
val isIpv4Preferred: Boolean = true,
@Transient
private var stateChangeCallback: ((Any) -> Unit)? = null,
) : Tunnel, org.amnezia.awg.backend.Tunnel {
val state = MutableStateFlow(TunnelState())
fun setStateChangeCallback(callback: (Any) -> Unit) {
stateChangeCallback = callback
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is TunnelConf) return false
return id == other.id && tunName == other.tunName && wgQuick == other.wgQuick && amQuick == other.amQuick &&
isPrimaryTunnel == other.isPrimaryTunnel && isMobileDataTunnel == other.isMobileDataTunnel &&
isEthernetTunnel == other.isEthernetTunnel && isPingEnabled == other.isPingEnabled && pingIp == other.pingIp &&
pingCooldown == other.pingCooldown && pingInterval == other.pingInterval && tunnelNetworks == other.tunnelNetworks &&
isIpv4Preferred == other.isIpv4Preferred
}
override fun hashCode(): Int {
var result = id
result = 31 * result + tunName.hashCode()
result = 31 * result + wgQuick.hashCode()
result = 31 * result + amQuick.hashCode()
return result
}
fun copyWithCallback(
id: Int = this.id,
tunName: String = this.tunName,
wgQuick: String = this.wgQuick,
tunnelNetworks: List<String> = this.tunnelNetworks,
isMobileDataTunnel: Boolean = this.isMobileDataTunnel,
isPrimaryTunnel: Boolean = this.isPrimaryTunnel,
amQuick: String = this.amQuick,
isActive: Boolean = this.isActive,
isPingEnabled: Boolean = this.isPingEnabled,
pingInterval: Long? = this.pingInterval,
pingCooldown: Long? = this.pingCooldown,
pingIp: String? = this.pingIp,
isEthernetTunnel: Boolean = this.isEthernetTunnel,
isIpv4Preferred: Boolean = this.isIpv4Preferred,
): TunnelConf {
return TunnelConf(
id, tunName, wgQuick, tunnelNetworks, isMobileDataTunnel, isPrimaryTunnel,
amQuick, isActive, isPingEnabled, pingInterval, pingCooldown, pingIp,
isEthernetTunnel, isIpv4Preferred,
).apply {
stateChangeCallback = this@TunnelConf.stateChangeCallback
// tunnelStatsCallback = this@TunnelConf.tunnelStatsCallback
// bounceTunnelCallback = this@TunnelConf.bounceTunnelCallback
}
}
// fun onUpdateStatistics() {
// tunnelStatsCallback?.invoke()
// }
//
// fun bounceTunnel(tunnelConf: TunnelConf, reason: TunnelStatus.StopReason) {
// bounceTunnelCallback?.invoke(tunnelConf, reason)
// }
fun toAmConfig(): org.amnezia.awg.config.Config {
return configFromAmQuick(amQuick.ifBlank { wgQuick })
@@ -42,49 +99,50 @@ data class TunnelConf(
return configFromWgQuick(wgQuick)
}
override fun getName(): String {
return tunName
}
override fun getName(): String = tunName
override fun isIpv4ResolutionPreferred(): Boolean {
return isIpv4Preferred
override fun isIpv4ResolutionPreferred(): Boolean = isIpv4Preferred
override fun onStateChange(newState: org.amnezia.awg.backend.Tunnel.State) {
stateChangeCallback?.invoke(newState)
}
override fun onStateChange(newState: Tunnel.State) {
state.update {
it.copy(state = newState.asTunnelState())
stateChangeCallback?.invoke(newState)
}
fun isTunnelConfigChanged(updatedConf: TunnelConf): Boolean {
return updatedConf.wgQuick != wgQuick || updatedConf.amQuick != amQuick || updatedConf.name != name
}
fun generateUniqueName(tunnelNames: List<String>): String {
var tunnelName = this.tunName
var num = 1
while (tunnelNames.any { it == tunnelName }) {
tunnelName = if (!tunnelName.hasNumberInParentheses()) {
"$name($num)"
} else {
val pair = tunnelName.extractNameAndNumber()
"${pair?.first}($num)"
}
num++
}
return tunnelName
}
override fun onStateChange(newState: com.wireguard.android.backend.Tunnel.State) {
state.update {
it.copy(state = newState.asTunnelState())
}
}
fun isQuickConfigChanged(updatedConf: TunnelConf): Boolean {
return updatedConf.wgQuick != wgQuick ||
updatedConf.amQuick != amQuick
}
fun isPingConfigMatching(updatedConf: TunnelConf): Boolean {
return updatedConf.isPingEnabled == isPingEnabled &&
pingIp == updatedConf.pingIp &&
updatedConf.pingCooldown == pingCooldown &&
updatedConf.pingInterval == pingInterval
}
suspend fun pingTunnel(context: CoroutineContext): List<Boolean> {
suspend fun isTunnelPingable(context: CoroutineContext): Boolean {
return withContext(context) {
val config = toWgConfig()
if (pingIp != null) {
Timber.i("Pinging custom ip")
listOf(InetAddress.getByName(pingIp).isReachable(Constants.PING_TIMEOUT.toInt()))
} else {
Timber.i("Pinging all peers")
config.peers.map { peer ->
peer.isReachable(isIpv4Preferred)
}
return@withContext InetAddress.getByName(pingIp)
.isReachable(Constants.PING_TIMEOUT.toInt()).also {
Timber.i("Ping reachable $pingIp: $it")
}
}
config.peers.map { peer ->
peer.isReachable()
}.all { true }.also {
Timber.i("Ping of all peers reachable: $it")
}
}
}
@@ -92,22 +150,22 @@ data class TunnelConf(
companion object {
fun configFromWgQuick(wgQuick: String): Config {
val inputStream: InputStream = wgQuick.byteInputStream()
return inputStream.bufferedReader(Charsets.UTF_8).use {
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(Charsets.UTF_8).use {
return inputStream.bufferedReader(StandardCharsets.UTF_8).use {
org.amnezia.awg.config.Config.parse(it)
}
}
fun tunnelConfigFromAmConfig(config: org.amnezia.awg.config.Config, name: String): TunnelConf {
fun tunnelConfigFromAmConfig(config: org.amnezia.awg.config.Config, name: String? = null): TunnelConf {
val amQuick = config.toAwgQuickString(true)
val wgQuick = config.toWgQuickString()
return TunnelConf(tunName = name, wgQuick = wgQuick, amQuick = amQuick)
return TunnelConf(tunName = name ?: config.defaultName(), wgQuick = wgQuick, amQuick = amQuick)
}
private const val IPV6_ALL_NETWORKS = "::/0"
@@ -1,7 +1,24 @@
package com.zaneschepke.wireguardautotunnel.domain.enums
sealed class BackendError() {
import com.zaneschepke.wireguardautotunnel.R
sealed class BackendError : Exception() {
data object DNS : BackendError()
data object Unauthorized : BackendError()
data object Config : BackendError()
data object KernelModuleName : BackendError()
data object InvalidConfig : BackendError()
data object NotAuthorized : BackendError()
data object ServiceNotRunning : BackendError()
data object Unknown : BackendError()
fun toStringRes() = when (this) {
Config -> R.string.config_error
DNS -> R.string.dns_resolve_error
InvalidConfig -> R.string.invalid_config_error
KernelModuleName -> R.string.kernel_name_error
NotAuthorized, Unauthorized -> R.string.auth_error
ServiceNotRunning -> R.string.service_running_error
Unknown -> R.string.unknown_error
}
}
@@ -1,15 +1,31 @@
package com.zaneschepke.wireguardautotunnel.domain.enums
enum class TunnelStatus {
UP,
DOWN,
;
sealed class TunnelStatus {
data class Error(val error: BackendError) : TunnelStatus()
data object Up : TunnelStatus()
data object Down : TunnelStatus()
data class Stopping(val reason: StopReason) : TunnelStatus()
data object Starting : TunnelStatus()
enum class StopReason {
USER,
PING,
CONFIG_CHANGED,
}
fun isDown(): Boolean {
return this == DOWN
return this == Down
}
fun isUp(): Boolean {
return this == UP
return this == Up
}
fun isUpOrStarting(): Boolean {
return this == Up || this == Starting
}
fun isDownOrStopping(): Boolean {
return this == Down || this is Stopping
}
}
@@ -11,6 +11,8 @@ interface TunnelRepository {
suspend fun save(tunnelConf: TunnelConf)
suspend fun saveAll(tunnelConfList: List<TunnelConf>)
suspend fun updatePrimaryTunnel(tunnelConf: TunnelConf?)
suspend fun updateMobileDataTunnel(tunnelConf: TunnelConf?)
@@ -1,13 +1,16 @@
package com.zaneschepke.wireguardautotunnel.domain.state
import com.zaneschepke.wireguardautotunnel.domain.events.KillSwitchEvent
import com.zaneschepke.wireguardautotunnel.core.tunnel.allDown
import com.zaneschepke.wireguardautotunnel.core.tunnel.hasActive
import com.zaneschepke.wireguardautotunnel.core.tunnel.isUp
import com.zaneschepke.wireguardautotunnel.domain.entity.AppSettings
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.domain.events.AutoTunnelEvent
import com.zaneschepke.wireguardautotunnel.domain.events.KillSwitchEvent
import com.zaneschepke.wireguardautotunnel.util.extensions.isMatchingToWildcardList
data class AutoTunnelState(
val activeTunnels: List<TunnelConf> = emptyList(),
val activeTunnels: Map<TunnelConf, TunnelState> = emptyMap(),
val networkState: NetworkState = NetworkState(),
val settings: AppSettings = AppSettings(),
val tunnels: List<TunnelConf> = emptyList(),
@@ -20,12 +23,12 @@ data class AutoTunnelState(
private fun isMobileTunnelDataChangeNeeded(): Boolean {
val preferredTunnel = preferredMobileDataTunnel()
return preferredTunnel != null &&
activeTunnels.isNotEmpty() && !activeTunnels.any { it.id == preferredTunnel.id }
activeTunnels.isNotEmpty() && !activeTunnels.isUp(preferredTunnel)
}
private fun isEthernetTunnelChangeNeeded(): Boolean {
val preferredTunnel = preferredEthernetTunnel()
return preferredTunnel != null && activeTunnels.isNotEmpty() && !activeTunnels.any { it.id == preferredTunnel.id }
return preferredTunnel != null && activeTunnels.isNotEmpty() && !activeTunnels.isUp(preferredTunnel)
}
private fun preferredMobileDataTunnel(): TunnelConf? {
@@ -45,11 +48,11 @@ data class AutoTunnelState(
}
private fun startOnEthernet(): Boolean {
return networkState.isEthernetConnected && settings.isTunnelOnEthernetEnabled && activeTunnels.isEmpty()
return networkState.isEthernetConnected && settings.isTunnelOnEthernetEnabled && activeTunnels.allDown()
}
private fun stopOnEthernet(): Boolean {
return networkState.isEthernetConnected && !settings.isTunnelOnEthernetEnabled && activeTunnels.isNotEmpty()
return networkState.isEthernetConnected && !settings.isTunnelOnEthernetEnabled && activeTunnels.hasActive()
}
// TODO test removed kill switch state check
@@ -62,16 +65,16 @@ data class AutoTunnelState(
return settings.isVpnKillSwitchEnabled && (!settings.isDisableKillSwitchOnTrustedEnabled || !isCurrentSSIDTrusted())
}
fun isNoConnectivity(): Boolean {
private fun isNoConnectivity(): Boolean {
return !networkState.isEthernetConnected && !networkState.isWifiConnected && !networkState.isMobileDataConnected
}
private fun stopOnMobileData(): Boolean {
return isMobileDataActive() && !settings.isTunnelOnMobileDataEnabled && activeTunnels.isNotEmpty()
return isMobileDataActive() && !settings.isTunnelOnMobileDataEnabled && activeTunnels.hasActive()
}
private fun startOnMobileData(): Boolean {
return isMobileDataActive() && settings.isTunnelOnMobileDataEnabled && activeTunnels.isEmpty()
return isMobileDataActive() && settings.isTunnelOnMobileDataEnabled && activeTunnels.allDown()
}
private fun changeOnMobileData(): Boolean {
@@ -83,24 +86,24 @@ data class AutoTunnelState(
}
private fun stopOnWifi(): Boolean {
return isWifiActive() && !settings.isTunnelOnWifiEnabled && activeTunnels.isNotEmpty()
return isWifiActive() && !settings.isTunnelOnWifiEnabled && activeTunnels.hasActive()
}
private fun stopOnTrustedWifi(): Boolean {
return isWifiActive() && settings.isTunnelOnWifiEnabled && activeTunnels.isNotEmpty() && isCurrentSSIDTrusted()
return isWifiActive() && settings.isTunnelOnWifiEnabled && activeTunnels.hasActive() && isCurrentSSIDTrusted()
}
private fun startOnUntrustedWifi(): Boolean {
return isWifiActive() && settings.isTunnelOnWifiEnabled && activeTunnels.isEmpty() && !isCurrentSSIDTrusted()
return isWifiActive() && settings.isTunnelOnWifiEnabled && activeTunnels.allDown() && !isCurrentSSIDTrusted()
}
private fun changeOnUntrustedWifi(): Boolean {
return isWifiActive() && settings.isTunnelOnWifiEnabled && activeTunnels.isNotEmpty() && !isCurrentSSIDTrusted() && !isWifiTunnelPreferred()
return isWifiActive() && settings.isTunnelOnWifiEnabled && activeTunnels.hasActive() && !isCurrentSSIDTrusted() && !isWifiTunnelPreferred()
}
private fun isWifiTunnelPreferred(): Boolean {
val preferred = preferredWifiTunnel()
return activeTunnels.any { it.id == preferred?.id }
return preferred?.let { activeTunnels.isUp(it) } ?: true
}
fun asAutoTunnelEvent(): AutoTunnelEvent {
@@ -4,7 +4,7 @@ import com.zaneschepke.wireguardautotunnel.domain.enums.BackendState
import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelStatus
data class TunnelState(
val state: TunnelStatus = TunnelStatus.DOWN,
val status: TunnelStatus = TunnelStatus.Down,
val backendState: BackendState = BackendState.INACTIVE,
val statistics: TunnelStatistics? = null,
)
@@ -52,7 +52,11 @@ sealed class Route {
@Serializable
data class SplitTunnel(
val id: Int,
) : Route()
) : Route() {
companion object {
const val KEY_ID = "id"
}
}
@Serializable
data class TunnelAutoTunnel(
@@ -0,0 +1,59 @@
package com.zaneschepke.wireguardautotunnel.ui.common
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDropDown
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
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.R
@Composable
fun <T> DropdownSelector(
currentValue: T,
options: List<T>,
onValueSelected: (T) -> Unit,
modifier: Modifier = Modifier,
label: @Composable (() -> Unit)? = null,
isExpanded: Boolean = false,
onDismiss: () -> Unit = {},
) {
Row(
horizontalArrangement = Arrangement.spacedBy(5.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (label != null) label()
Text(
text = currentValue.toString(),
style = MaterialTheme.typography.bodyMedium,
)
Icon(Icons.Default.ArrowDropDown, contentDescription = stringResource(R.string.dropdown))
}
DropdownMenu(
modifier = modifier.height(250.dp),
scrollState = rememberScrollState(),
containerColor = MaterialTheme.colorScheme.surface,
expanded = isExpanded,
onDismissRequest = onDismiss,
) {
options.forEach { option ->
DropdownMenuItem(
text = { Text(text = option.toString()) },
onClick = {
onValueSelected(option)
onDismiss() // Close dropdown after selection
},
)
}
}
}
@@ -1,13 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.common
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
class NestedScrollListener(val onUp: () -> Unit, val onDown: () -> Unit) : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
if (available.y < -1) onDown()
if (available.y > 1) onUp()
return Offset.Zero
}
}
@@ -1,82 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.common
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Clear
import androidx.compose.material.icons.rounded.Search
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
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.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import com.zaneschepke.wireguardautotunnel.R
@Composable
fun SearchBar(onQuery: (queryString: String) -> Unit) {
// Immediately update and keep track of query from text field changes.
var query: String by rememberSaveable { mutableStateOf("") }
var showClearIcon by rememberSaveable { mutableStateOf(false) }
if (query.isEmpty()) {
showClearIcon = false
} else if (query.isNotEmpty()) {
showClearIcon = true
}
TextField(
value = query,
onValueChange = { onQueryChanged ->
// If user makes changes to text, immediately updated it.
query = onQueryChanged
onQuery(onQueryChanged)
},
leadingIcon = {
val icon = Icons.Rounded.Search
Icon(
imageVector = icon,
tint = MaterialTheme.colorScheme.onBackground,
contentDescription = icon.name,
)
},
trailingIcon = {
if (showClearIcon) {
IconButton(onClick = { query = "" }) {
val icon = Icons.Rounded.Clear
Icon(
imageVector = icon,
tint = MaterialTheme.colorScheme.onBackground,
contentDescription = icon.name,
)
}
}
},
maxLines = 1,
colors =
TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
disabledContainerColor = Color.Transparent,
),
placeholder = { Text(text = stringResource(R.string.hint_search_packages)) },
textStyle = MaterialTheme.typography.bodySmall,
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text),
modifier =
Modifier
.fillMaxWidth()
.background(color = MaterialTheme.colorScheme.background, shape = RectangleShape),
)
}
@@ -0,0 +1,37 @@
package com.zaneschepke.wireguardautotunnel.ui.common.animation
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
@Composable
fun ShimmerEffect(modifier: Modifier = Modifier): Brush {
val shimmerColors = listOf(
Color.LightGray.copy(alpha = 0.9f),
Color.LightGray.copy(alpha = 0.3f),
Color.LightGray.copy(alpha = 0.9f),
)
val transition = rememberInfiniteTransition()
val translateAnim by transition.animateFloat(
initialValue = 0f,
targetValue = 1000f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 1200, easing = LinearEasing),
),
)
return Brush.linearGradient(
colors = shimmerColors,
start = Offset(0f, 0f),
end = Offset(translateAnim, translateAnim),
)
}
@@ -1,4 +1,4 @@
package com.zaneschepke.wireguardautotunnel.ui.common
package com.zaneschepke.wireguardautotunnel.ui.common.button
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Spacer
@@ -1,4 +1,4 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.settings.components
package com.zaneschepke.wireguardautotunnel.ui.common.button
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.size
@@ -23,8 +23,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.ui.theme.iconSize
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth
@androidx.compose.runtime.Composable
fun IconSurfaceButton(title: String, onClick: () -> Unit, selected: Boolean, leadingIcon: ImageVector? = null, description: String? = null) {
@@ -53,22 +51,22 @@ fun IconSurfaceButton(title: String, onClick: () -> Unit, selected: Boolean, lea
Column(
modifier =
Modifier
.padding(horizontal = 8.dp.scaledWidth(), vertical = 10.dp.scaledHeight())
.padding(end = 16.dp.scaledWidth()).padding(start = 8.dp.scaledWidth())
.padding(horizontal = 8.dp, vertical = 10.dp)
.padding(end = 16.dp).padding(start = 8.dp)
.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.Start,
) {
Row(
verticalAlignment = Alignment.Companion.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp.scaledWidth()),
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Row(
horizontalArrangement = Arrangement.spacedBy(
16.dp.scaledWidth(),
16.dp,
),
verticalAlignment = Alignment.Companion.CenterVertically,
modifier = Modifier.padding(vertical = if (description == null) 10.dp.scaledHeight() else 0.dp),
modifier = Modifier.padding(vertical = if (description == null) 10.dp else 0.dp),
) {
leadingIcon?.let {
Icon(
@@ -7,14 +7,13 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
@Composable
fun ScaledSwitch(checked: Boolean, onClick: (checked: Boolean) -> Unit, enabled: Boolean = true, modifier: Modifier = Modifier) {
Switch(
checked,
{ onClick(it) },
modifier.scale((52.dp.scaledHeight() / 52.dp)),
modifier.scale((52.dp / 52.dp)),
enabled = enabled,
colors = SwitchDefaults.colors().copy(
checkedThumbColor = MaterialTheme.colorScheme.background,
@@ -19,10 +19,9 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth
@Composable
fun SelectionItemButton(
@@ -31,20 +30,21 @@ fun SelectionItemButton(
trailing: (@Composable () -> Unit)? = null,
onClick: () -> Unit,
ripple: Boolean = true,
modifier: Modifier = Modifier,
) {
Card(
modifier =
Modifier
modifier
.clip(RoundedCornerShape(8.dp))
.clickable(
indication = if (ripple) ripple() else null,
interactionSource = remember { MutableInteractionSource() },
onClick = { onClick() },
)
.height(56.dp.scaledHeight()),
.height(56.dp),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.background,
containerColor = Color.Transparent,
),
) {
Row(
@@ -52,7 +52,7 @@ fun SelectionItemButton(
horizontalArrangement = Arrangement.Start,
modifier = Modifier
.fillMaxSize()
.padding(end = 10.dp.scaledWidth()),
.padding(end = 10.dp),
) {
leading?.let {
it()
@@ -0,0 +1,20 @@
package com.zaneschepke.wireguardautotunnel.ui.common.button.surface
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
@Composable
fun SelectionItemLabel(
textResId: Int,
style: androidx.compose.ui.text.TextStyle = MaterialTheme.typography.bodyMedium,
isDescription: Boolean = false,
) {
Text(
text = stringResource(textResId),
style = style.copy(
color = if (isDescription) MaterialTheme.colorScheme.outline else MaterialTheme.colorScheme.onSurface,
),
)
}
@@ -19,8 +19,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.ui.theme.iconSize
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth
@Composable
fun SurfaceSelectionGroupButton(items: List<SelectionItem>) {
@@ -38,12 +36,12 @@ fun SurfaceSelectionGroupButton(items: List<SelectionItem>) {
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp.scaledHeight()),
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.padding(start = 16.dp.scaledWidth())
.padding(start = 16.dp)
.weight(4f, false)
.fillMaxWidth(),
) {
@@ -60,8 +58,8 @@ fun SurfaceSelectionGroupButton(items: List<SelectionItem>) {
verticalArrangement = Arrangement.spacedBy(2.dp, Alignment.CenterVertically),
modifier = Modifier
.fillMaxWidth()
.padding(start = if (item.leadingIcon != null) 16.dp.scaledWidth() else 0.dp)
.padding(vertical = if (item.description == null) 16.dp.scaledHeight() else 6.dp.scaledHeight()),
.padding(start = if (item.leadingIcon != null) 16.dp else 0.dp)
.padding(vertical = if (item.description == null) 16.dp else 6.dp),
) {
item.title()
item.description?.let {
@@ -73,7 +71,7 @@ fun SurfaceSelectionGroupButton(items: List<SelectionItem>) {
Box(
contentAlignment = Alignment.CenterEnd,
modifier = Modifier
.padding(end = 24.dp.scaledWidth(), start = 16.dp.scaledWidth())
.padding(end = 24.dp, start = 16.dp)
.weight(1f),
) {
it()
@@ -35,9 +35,9 @@ fun ConfigurationTextBox(
singleLine = true,
interactionSource = interactionSource,
onValueChange = { onValueChange(it) },
label = { Text(label, color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.labelMedium) },
label = { Text(label, color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.bodyMedium) },
maxLines = 1,
placeholder = { Text(hint, color = MaterialTheme.colorScheme.outline, style = MaterialTheme.typography.labelLarge) },
placeholder = { Text(hint, color = MaterialTheme.colorScheme.outline, style = MaterialTheme.typography.bodyMedium) },
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
trailingIcon = trailing,
@@ -24,7 +24,6 @@ import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.ui.common.textbox.CustomTextField
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth
@Composable
fun SubmitConfigurationTextBox(
@@ -59,7 +58,7 @@ fun SubmitConfigurationTextBox(
.padding(
top = 5.dp,
bottom = 10.dp,
).fillMaxWidth().padding(end = 16.dp.scaledWidth()),
).fillMaxWidth().padding(end = 16.dp),
singleLine = true,
keyboardOptions = keyboardOptions,
keyboardActions = KeyboardActions(
@@ -1,10 +1,14 @@
package com.zaneschepke.wireguardautotunnel.ui.common.dialog
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.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import com.zaneschepke.wireguardautotunnel.R
@Composable
@@ -15,23 +19,32 @@ fun InfoDialog(
body: @Composable () -> Unit,
confirmText: @Composable () -> Unit,
) {
AlertDialog(
onDismissRequest = { onDismiss() },
confirmButton = {
TextButton(
onClick = {
onAttest()
MaterialTheme(
colorScheme = MaterialTheme.colorScheme.copy(),
) {
Surface(
color = MaterialTheme.colorScheme.surface,
tonalElevation = 0.dp,
) {
AlertDialog(
onDismissRequest = { onDismiss() },
confirmButton = {
TextButton(onClick = { onAttest() }) {
confirmText()
}
},
) {
confirmText()
}
},
dismissButton = {
TextButton(onClick = { onDismiss() }) {
Text(text = stringResource(R.string.cancel))
}
},
title = { title() },
text = { body() },
)
dismissButton = {
TextButton(onClick = { onDismiss() }) {
Text(text = stringResource(R.string.cancel))
}
},
containerColor = MaterialTheme.colorScheme.surface,
title = { title() },
text = { body() },
properties = DialogProperties(
usePlatformDefaultWidth = true,
),
)
}
}
}
@@ -1,4 +1,4 @@
package com.zaneschepke.wireguardautotunnel.ui.common.permission.vpn
package com.zaneschepke.wireguardautotunnel.ui.common.dialog
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material3.MaterialTheme
@@ -10,7 +10,6 @@ import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.common.dialog.InfoDialog
import com.zaneschepke.wireguardautotunnel.util.extensions.launchVpnSettings
@Composable
@@ -6,6 +6,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.text.font.FontWeight
@Composable
fun GroupLabel(title: String) {
@@ -16,6 +17,7 @@ fun GroupLabel(title: String) {
Text(
title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onBackground,
)
}
@@ -1,4 +1,4 @@
package com.zaneschepke.wireguardautotunnel.ui.common
package com.zaneschepke.wireguardautotunnel.ui.common.label
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
@@ -0,0 +1,93 @@
package com.zaneschepke.wireguardautotunnel.ui.common.navigation
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
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.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.ui.theme.SilverTree
@Composable
fun BottomBarTabs(tabs: List<BottomNavItem>, selectedTabIndex: Int, isChildRoute: Boolean, onTabSelected: (BottomNavItem) -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(64.dp)
.padding(horizontal = 8.dp)
.padding(top = 12.dp),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
) {
tabs.forEachIndexed { index, tab ->
Column(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.background(Color.Transparent)
.pointerInput(Unit) {
detectTapGestures {
if (index == selectedTabIndex && !isChildRoute) return@detectTapGestures
tab.onClick.invoke()
onTabSelected(tab)
}
},
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
val animatedColor by animateColorAsState(
targetValue = MaterialTheme.colorScheme.primary,
animationSpec = spring(stiffness = Spring.StiffnessLow),
label = "animatedColor",
)
val color = if (selectedTabIndex == index) animatedColor else MaterialTheme.colorScheme.onSurface
if (tab.active) {
BadgedBox(
badge = {
Badge(
modifier = Modifier
.offset(x = 8.dp, y = ((-8).dp))
.size(6.dp),
containerColor = SilverTree,
)
},
) {
Icon(
imageVector = tab.icon,
contentDescription = tab.name,
tint = color,
modifier = Modifier.size(24.dp),
)
}
} else {
Icon(
imageVector = tab.icon,
contentDescription = tab.name,
tint = color,
modifier = Modifier.size(24.dp),
)
}
}
}
}
}
@@ -1,73 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.common.navigation
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationBarItemDefaults
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.graphics.Color
import androidx.navigation.NavController
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.compose.currentBackStackEntryAsState
@Composable
fun BottomNavBar(navController: NavController, bottomNavItems: List<BottomNavItem>) {
var showBottomBar by rememberSaveable { mutableStateOf(true) }
val navBackStackEntry by navController.currentBackStackEntryAsState()
showBottomBar = bottomNavItems.any {
navBackStackEntry?.isCurrentRoute(it.route::class) == true
}
if (showBottomBar) {
NavigationBar(
containerColor = MaterialTheme.colorScheme.surface,
) {
bottomNavItems.forEachIndexed { index, item ->
val selected = navBackStackEntry.isCurrentRoute(item.route::class)
NavigationBarItem(
selected = selected,
onClick = {
if (selected) return@NavigationBarItem
navController.navigate(item.route) {
// Pop up to the start destination of the graph to
// avoid building up a large stack of destinations
// on the back stack as users select items
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
// Avoid multiple copies of the same destination when
// reselecting the same item
launchSingleTop = true
}
},
label = {
Text(
text = item.name,
style = MaterialTheme.typography.labelMedium,
)
},
icon = {
Icon(
imageVector = item.icon,
contentDescription = "${item.name} Icon",
)
},
colors = NavigationBarItemDefaults.colors().copy(
selectedIndicatorColor = Color.Transparent,
selectedIconColor = MaterialTheme.colorScheme.primary,
selectedTextColor = MaterialTheme.colorScheme.primary,
unselectedTextColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.55f),
unselectedIconColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.55f),
),
)
}
}
}
}
@@ -7,4 +7,6 @@ data class BottomNavItem(
val name: String,
val route: Route,
val icon: ImageVector,
val onClick: () -> Unit,
val active: Boolean = false,
)
@@ -0,0 +1,133 @@
package com.zaneschepke.wireguardautotunnel.ui.common.navigation
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.systemBars
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.RoundRect
import androidx.compose.ui.geometry.toRect
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.PathMeasure
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.ui.Route
@Composable
fun CustomBottomNavbar(tabs: List<BottomNavItem>, navBarState: NavBarState) {
var selectedTabIndex by remember { mutableIntStateOf(0) }
var isChildRoute by remember { mutableStateOf(false) }
LaunchedEffect(tabs) {
}
when (navBarState.route) {
Route.Main -> {
selectedTabIndex = 0
isChildRoute = false
}
Route.AutoTunnel -> {
selectedTabIndex = 1
isChildRoute = false
}
Route.Settings -> {
selectedTabIndex = 2
isChildRoute = false
}
Route.Support -> {
selectedTabIndex = 3
isChildRoute = false
}
else -> isChildRoute = true
}
val systemBars = WindowInsets.systemBars
val bottomPadding = with(LocalDensity.current) {
systemBars.getBottom(this).toDp()
}
val navHeight = 64.dp + bottomPadding
Box(
modifier = Modifier
.fillMaxWidth()
.height(navHeight)
.background(Color.Transparent),
) {
BottomBarTabs(
tabs = tabs,
selectedTabIndex = selectedTabIndex,
isChildRoute = isChildRoute,
onTabSelected = {
selectedTabIndex = tabs.indexOf(it)
},
)
val animatedSelectedTabIndex by animateFloatAsState(
targetValue = selectedTabIndex.toFloat(),
label = "animatedSelectedTabIndex",
animationSpec = spring(
stiffness = Spring.StiffnessLow,
dampingRatio = Spring.DampingRatioLowBouncy,
),
)
val animatedColor by animateColorAsState(
targetValue = MaterialTheme.colorScheme.primary,
label = "animatedColor",
animationSpec = spring(
stiffness = Spring.StiffnessLow,
),
)
Canvas(
modifier = Modifier
.fillMaxWidth()
.height(navHeight),
) {
val path = Path().apply {
addRoundRect(RoundRect(size.toRect(), CornerRadius(size.height)))
}
val length = PathMeasure().apply { setPath(path, false) }.length
val tabWidth = size.width / tabs.size
drawPath(
path,
brush = Brush.horizontalGradient(
colors = listOf(
animatedColor.copy(alpha = 0f),
animatedColor.copy(alpha = 1f),
animatedColor.copy(alpha = 1f),
animatedColor.copy(alpha = 0f),
),
startX = tabWidth * animatedSelectedTabIndex,
endX = tabWidth * (animatedSelectedTabIndex + 1),
),
style = Stroke(
width = 4f,
pathEffect = PathEffect.dashPathEffect(
intervals = floatArrayOf(length / 2, length),
),
),
)
}
}
}
@@ -0,0 +1,47 @@
package com.zaneschepke.wireguardautotunnel.ui.common.navigation
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DynamicTopAppBar(navBarState: NavBarState, modifier: Modifier = Modifier) {
TopAppBar(
modifier = modifier,
colors = TopAppBarDefaults.topAppBarColors().copy(Color.Transparent),
title = {
AnimatedVisibility(
visible = navBarState.showTop,
enter = slideInVertically() + fadeIn(),
exit = slideOutVertically() + fadeOut(),
) {
Box(modifier = Modifier.padding(start = 10.dp)) {
navBarState.topTitle?.invoke()
}
}
},
actions = {
AnimatedVisibility(
visible = navBarState.showTop,
enter = slideInVertically() + fadeIn(),
exit = slideOutVertically() + fadeOut(),
) {
Box(modifier = Modifier.padding(end = 10.dp)) {
navBarState.topTrailing?.invoke()
}
}
},
)
}
@@ -0,0 +1,217 @@
package com.zaneschepke.wireguardautotunnel.ui.common.navigation
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.Edit
import androidx.compose.material.icons.rounded.Menu
import androidx.compose.material.icons.rounded.PlayArrow
import androidx.compose.material.icons.rounded.Save
import androidx.compose.material.icons.rounded.Stop
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.produceState
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavController
import androidx.navigation.toRoute
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
import com.zaneschepke.wireguardautotunnel.ui.theme.Brick
import com.zaneschepke.wireguardautotunnel.ui.theme.SilverTree
import com.zaneschepke.wireguardautotunnel.ui.theme.iconSize
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
data class NavBarState(
val showTop: Boolean = true,
val showBottom: Boolean = true,
val topTitle: @Composable (() -> Unit)? = null,
val topTrailing: @Composable (() -> Unit)? = null,
val route: Route? = null,
)
@Composable
fun currentNavBackStackEntryAsNavBarState(
navController: NavController,
backStackEntry: NavBackStackEntry?,
viewModel: AppViewModel,
uiState: AppUiState,
): State<NavBarState> {
return produceState(initialValue = NavBarState(), key1 = backStackEntry, key2 = uiState) {
value = when {
backStackEntry.isCurrentRoute(Route.Main::class) -> {
NavBarState(
showTop = true, showBottom = true,
{ Text(stringResource(R.string.tunnels)) },
{
IconButton(
onClick = { viewModel.handleEvent(AppEvent.ToggleBottomSheet) },
) {
val icon = Icons.Rounded.Add
Icon(icon, stringResource(R.string.add_tunnel), modifier = Modifier.size(iconSize))
}
},
route = Route.Main,
)
}
backStackEntry.isCurrentRoute(Route.AutoTunnel::class) -> {
NavBarState(
showTop = true, showBottom = true,
{ Text(stringResource(R.string.auto_tunnel)) },
{
IconButton(
onClick = { viewModel.handleEvent(AppEvent.ToggleAutoTunnel) },
) {
val (icon, description, color) = if (uiState.appSettings.isAutoTunnelEnabled) {
Triple(Icons.Rounded.Stop, R.string.stop_auto, Brick)
} else {
Triple(Icons.Rounded.PlayArrow, R.string.start_auto, SilverTree)
}
Icon(icon, stringResource(description), tint = color, modifier = Modifier.size(iconSize))
}
},
route = Route.AutoTunnel,
)
}
backStackEntry.isCurrentRoute(Route.AutoTunnelAdvanced::class) -> {
NavBarState(
showTop = true, showBottom = true,
{ Text(stringResource(R.string.advanced_settings)) },
route = Route.AutoTunnelAdvanced,
)
}
backStackEntry.isCurrentRoute(Route.Settings::class) -> {
NavBarState(
showTop = true, showBottom = true,
{ Text(stringResource(R.string.settings)) },
{
IconButton(
onClick = { viewModel.handleEvent(AppEvent.ToggleBottomSheet) },
) {
val icon = Icons.Rounded.Menu
Icon(icon, stringResource(R.string.quick_actions), modifier = Modifier.size(iconSize))
}
},
route = Route.Settings,
)
}
backStackEntry.isCurrentRoute(Route.KillSwitch::class) -> {
NavBarState(
showTop = true, showBottom = true,
{ Text(stringResource(R.string.kill_switch)) },
route = Route.KillSwitch,
)
}
backStackEntry.isCurrentRoute(Route.Appearance::class) -> {
NavBarState(
showTop = true, showBottom = true,
{ Text(stringResource(R.string.appearance)) },
route = Route.Appearance,
)
}
backStackEntry.isCurrentRoute(Route.Language::class) -> {
NavBarState(
showTop = true, showBottom = true,
{ Text(stringResource(R.string.language)) },
route = Route.Language,
)
}
backStackEntry.isCurrentRoute(Route.Display::class) -> {
NavBarState(
showTop = true, showBottom = true,
{ Text(stringResource(R.string.display_theme)) },
route = Route.Display,
)
}
backStackEntry.isCurrentRoute(Route.Logs::class) -> {
NavBarState(
showTop = true, showBottom = false,
{ Text(stringResource(R.string.logs)) },
{
IconButton(
onClick = { viewModel.handleEvent(AppEvent.ToggleBottomSheet) },
) {
val icon = Icons.Rounded.Menu
Icon(icon, stringResource(R.string.quick_actions), modifier = Modifier.size(iconSize))
}
},
route = Route.Logs,
)
}
backStackEntry.isCurrentRoute(Route.TunnelOptions::class) -> {
val args = backStackEntry?.toRoute<Route.TunnelOptions>()
val tunnel = uiState.tunnels.find { it.id == args?.id }
NavBarState(
showTop = true, showBottom = true,
{ tunnel?.name?.let { Text(it) } },
{
IconButton(
onClick = { tunnel?.id?.let { navController.navigate(Route.Config(id = it)) } },
) {
val icon = Icons.Rounded.Edit
Icon(icon, stringResource(R.string.edit_tunnel), modifier = Modifier.size(iconSize))
}
},
route = args?.let { Route.TunnelOptions(it.id) },
)
}
backStackEntry.isCurrentRoute(Route.SplitTunnel::class) -> {
val args = backStackEntry?.toRoute<Route.SplitTunnel>()
val name = uiState.tunnels.find { it.id == args?.id }?.name
NavBarState(
showTop = true, showBottom = true,
{ name?.let { Text(it) } },
{
IconButton(
onClick = { viewModel.handleEvent(AppEvent.InvokeScreenAction) },
) {
val icon = Icons.Rounded.Save
Icon(icon, stringResource(R.string.save), modifier = Modifier.size(iconSize))
}
},
route = args?.let { Route.SplitTunnel(it.id) },
)
}
backStackEntry.isCurrentRoute(Route.Config::class) -> {
val args = backStackEntry?.toRoute<Route.Config>()
val name = uiState.tunnels.find { it.id == args?.id }?.name
NavBarState(
showTop = true, showBottom = true,
{ name?.let { Text(it) } },
{
IconButton(
onClick = { viewModel.handleEvent(AppEvent.InvokeScreenAction) },
) {
val icon = Icons.Rounded.Save
Icon(icon, stringResource(R.string.save), modifier = Modifier.size(iconSize))
}
},
route = args?.let { Route.Config(it.id) },
)
}
backStackEntry.isCurrentRoute(Route.TunnelAutoTunnel::class) -> {
val args = backStackEntry?.toRoute<Route.TunnelAutoTunnel>()
val name = uiState.tunnels.find { it.id == args?.id }?.name
NavBarState(
showTop = true, showBottom = true,
{ name?.let { Text(it) } },
route = args?.let { Route.TunnelAutoTunnel(it.id) },
)
}
backStackEntry.isCurrentRoute(Route.Support::class) -> {
NavBarState(
showTop = true, showBottom = true,
{ Text(stringResource(R.string.support)) },
route = Route.Support,
)
}
else -> NavBarState(showTop = false, showBottom = false)
}
}
}
@@ -1,37 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.common.navigation
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TopNavBar(title: String, trailing: @Composable () -> Unit = {}, showBack: Boolean = true) {
val navController = LocalNavController.current
CenterAlignedTopAppBar(
title = {
Text(title)
},
navigationIcon = {
if (showBack) {
IconButton(onClick = {
navController.popBackStack()
}) {
val icon = Icons.AutoMirrored.Outlined.ArrowBack
Icon(
imageVector = icon,
contentDescription = icon.name,
)
}
}
},
actions = {
trailing()
},
)
}
@@ -1,38 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.common.permission.vpn
import android.net.VpnService
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity.RESULT_OK
import androidx.compose.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.platform.LocalContext
@Composable
inline fun <T> withVpnPermission(crossinline onSuccess: (t: T) -> Unit): (t: T) -> Unit {
val context = LocalContext.current
var showVpnPermissionDialog by remember { mutableStateOf(false) }
val vpnActivity =
rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult(),
onResult = {
if (it.resultCode != RESULT_OK) showVpnPermissionDialog = true
},
)
VpnDeniedDialog(showVpnPermissionDialog, onDismiss = { showVpnPermissionDialog = false })
return {
val intent = VpnService.prepare(context)
if (intent != null) {
vpnActivity.launch(intent)
} else {
onSuccess(it)
}
}
}
@@ -1,35 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.common.permission
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.ActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import com.zaneschepke.wireguardautotunnel.util.extensions.isBatteryOptimizationsDisabled
@Composable
inline fun withIgnoreBatteryOpt(ignore: Boolean, crossinline callback: () -> Unit): () -> Unit {
val context = LocalContext.current
val batteryActivity =
rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult(),
) { result: ActivityResult ->
// we only ask once
callback()
}
return {
if (ignore || context.isBatteryOptimizationsDisabled()) {
callback()
} else {
batteryActivity.launch(
Intent().apply {
action = Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS
data = Uri.parse("package:${context.packageName}")
},
)
}
}
}
@@ -9,6 +9,7 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
import com.zaneschepke.wireguardautotunnel.R
@Composable
fun AuthorizationPrompt(onSuccess: () -> Unit, onFailure: () -> Unit, onError: (String) -> Unit) {
@@ -18,33 +19,20 @@ fun AuthorizationPrompt(onSuccess: () -> Unit, onFailure: () -> Unit, onError: (
val isBiometricAvailable =
remember {
when (bio) {
BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE -> {
onError("Biometrics not available")
false
}
BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> {
onError("Biometrics not created")
false
}
BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> {
onError("Biometric hardware not found")
onError(context.getString(R.string.bio_not_created))
false
}
BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED -> {
onError("Biometric security update required")
onError(context.getString(R.string.bio_update_required))
false
}
BiometricManager.BIOMETRIC_ERROR_UNSUPPORTED -> {
onError("Biometrics not supported")
false
}
BiometricManager.BIOMETRIC_STATUS_UNKNOWN -> {
onError("Biometrics status unknown")
BiometricManager.BIOMETRIC_ERROR_UNSUPPORTED, BiometricManager.BIOMETRIC_STATUS_UNKNOWN,
BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE, BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE,
-> {
onError(context.getString(R.string.bio_not_supported))
false
}
@@ -58,8 +46,8 @@ fun AuthorizationPrompt(onSuccess: () -> Unit, onFailure: () -> Unit, onError: (
val promptInfo =
BiometricPrompt.PromptInfo.Builder()
.setAllowedAuthenticators(BIOMETRIC_WEAK or DEVICE_CREDENTIAL)
.setTitle("Biometric Authentication")
.setSubtitle("Log in using your biometric credential")
.setTitle(context.getString(R.string.bio_auth_title))
.setSubtitle(context.getString(R.string.bio_subtitle))
.build()
val biometricPrompt =
@@ -1,108 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.common.snackbar
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.platform.LocalContext
import com.zaneschepke.wireguardautotunnel.util.StringValue
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import kotlin.coroutines.EmptyCoroutineContext
private val LocalSnackbarController = staticCompositionLocalOf {
SnackbarController(
host = SnackbarHostState(),
scope = CoroutineScope(EmptyCoroutineContext),
)
}
private val channel = Channel<SnackbarChannelMessage>(capacity = 1)
@Composable
fun SnackbarControllerProvider(content: @Composable (snackbarHost: SnackbarHostState) -> Unit) {
val snackHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
val snackController = remember(scope) { SnackbarController(snackHostState, scope) }
val context = LocalContext.current
DisposableEffect(snackController, scope) {
val job = scope.launch {
for (payload in channel) {
snackController.showMessage(
message = payload.message.asString(context),
duration = payload.duration,
action = payload.action,
)
}
}
onDispose {
job.cancel()
}
}
CompositionLocalProvider(LocalSnackbarController provides snackController) {
content(
snackHostState,
)
}
}
@Immutable
class SnackbarController(
private val host: SnackbarHostState,
private val scope: CoroutineScope,
) {
companion object {
val current
@Composable
@ReadOnlyComposable
get() = LocalSnackbarController.current
fun showMessage(message: StringValue, action: SnackbarAction? = null, duration: SnackbarDuration = SnackbarDuration.Short) {
channel.trySend(
SnackbarChannelMessage(
message = message,
duration = duration,
action = action,
),
)
}
}
fun showMessage(message: String, action: SnackbarAction? = null, duration: SnackbarDuration = SnackbarDuration.Short) {
scope.launch {
/**
* note: uncomment this line if you want snackbar to be displayed immediately,
* rather than being enqueued and waiting [duration] * current_queue_size
*/
host.currentSnackbarData?.dismiss()
val result =
host.showSnackbar(
message = message,
actionLabel = action?.title,
duration = duration,
)
if (result == SnackbarResult.ActionPerformed) {
action?.onActionPress?.invoke()
}
}
}
}
data class SnackbarChannelMessage(
val message: StringValue,
val action: SnackbarAction?,
val duration: SnackbarDuration = SnackbarDuration.Short,
)
data class SnackbarAction(val title: String, val onActionPress: () -> Unit)
@@ -1,20 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.common.text
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@Composable
fun SectionTitle(title: String, padding: Dp) {
Text(
title,
textAlign = TextAlign.Start,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(padding, bottom = 5.dp, top = 5.dp),
)
}
@@ -0,0 +1,109 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel
import android.Manifest
import android.os.Build
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.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.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton
import com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.components.AdvancedSettingsItem
import com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.components.NetworkTunnelingItems
import com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.components.WifiTunnelingItems
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.BackgroundLocationDialog
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.LocationServicesDialog
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
import com.zaneschepke.wireguardautotunnel.util.extensions.isLocationServicesEnabled
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun AutoTunnelScreen(uiState: AppUiState, viewModel: AppViewModel) {
val context = LocalContext.current
val fineLocationState = rememberPermissionState(Manifest.permission.ACCESS_FINE_LOCATION)
var currentText by remember { mutableStateOf("") }
var isBackgroundLocationGranted by remember { mutableStateOf(true) }
var showLocationServicesAlertDialog by remember { mutableStateOf(false) }
var showLocationDialog by remember { mutableStateOf(false) }
fun checkFineLocationGranted() {
isBackgroundLocationGranted = fineLocationState.status.isGranted
}
fun isWifiNameReadable(): Boolean {
return when {
!isBackgroundLocationGranted || !fineLocationState.status.isGranted -> {
showLocationDialog = true
false
}
!context.isLocationServicesEnabled() -> {
showLocationServicesAlertDialog = true
false
}
else -> true
}
}
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) checkFineLocationGranted()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
if (context.isRunningOnTv() && Build.VERSION.SDK_INT == Build.VERSION_CODES.Q) {
checkFineLocationGranted()
} else {
val backgroundLocationState = rememberPermissionState(Manifest.permission.ACCESS_BACKGROUND_LOCATION)
isBackgroundLocationGranted = backgroundLocationState.status.isGranted
}
}
LaunchedEffect(uiState.appSettings.trustedNetworkSSIDs) {
currentText = ""
}
LocationServicesDialog(
showLocationServicesAlertDialog,
onDismiss = { showLocationServicesAlertDialog = false },
onAttest = { showLocationServicesAlertDialog = false },
)
BackgroundLocationDialog(
showLocationDialog,
onDismiss = { showLocationDialog = false },
onAttest = { showLocationDialog = false },
)
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(24.dp, Alignment.Top),
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(vertical = 24.dp)
.padding(horizontal = 24.dp),
) {
SurfaceSelectionGroupButton(
items = WifiTunnelingItems(uiState, viewModel, currentText, { currentText = it }, { isWifiNameReadable() }),
)
SurfaceSelectionGroupButton(
items = NetworkTunnelingItems(uiState, viewModel),
)
SurfaceSelectionGroupButton(
items = listOf(AdvancedSettingsItem()),
)
}
}
@@ -0,0 +1,37 @@
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.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.advanced.components.DebounceDelaySelector
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
@Composable
fun AdvancedScreen(appUiState: AppUiState) {
val appViewModel: AppViewModel = hiltViewModel()
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(24.dp, Alignment.Top),
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(top = 24.dp)
.padding(horizontal = 24.dp),
) {
DebounceDelaySelector(
currentDelay = appUiState.appSettings.debounceDelaySeconds,
onEvent = appViewModel::handleEvent,
)
}
}
@@ -0,0 +1,48 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.advanced.components
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.PauseCircle
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.res.stringResource
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.common.DropdownSelector
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@Composable
fun DebounceDelaySelector(currentDelay: Int, onEvent: (AppEvent) -> Unit) {
var isDropDownExpanded by remember { mutableStateOf(false) }
SurfaceSelectionGroupButton(
listOf(
SelectionItem(
leadingIcon = Icons.Outlined.PauseCircle,
title = {
Text(
stringResource(R.string.debounce_delay),
style = MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.onSurface,
),
)
},
onClick = { isDropDownExpanded = true },
trailing = {
DropdownSelector(
currentValue = currentDelay,
options = (0..10).toList(),
onValueSelected = { num -> onEvent(AppEvent.SetDebounceDelay(num)) },
isExpanded = isDropDownExpanded,
onDismiss = { isDropDownExpanded = false },
)
},
),
),
)
}
@@ -0,0 +1,31 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.components
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.common.button.ForwardButton
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController
@Composable
fun AdvancedSettingsItem(): SelectionItem {
val navController = LocalNavController.current
return SelectionItem(
leadingIcon = Icons.Outlined.Settings,
title = {
Text(
stringResource(R.string.advanced_settings),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
trailing = {
ForwardButton { navController.navigate(Route.AutoTunnelAdvanced) }
},
onClick = { navController.navigate(Route.AutoTunnelAdvanced) },
)
}
@@ -0,0 +1,98 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.components
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.AirplanemodeActive
import androidx.compose.material.icons.outlined.SettingsEthernet
import androidx.compose.material.icons.outlined.SignalCellular4Bar
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@Composable
fun NetworkTunnelingItems(uiState: AppUiState, viewModel: AppViewModel): List<SelectionItem> {
return listOf(
SelectionItem(
leadingIcon = Icons.Outlined.SignalCellular4Bar,
title = {
Text(
stringResource(R.string.tunnel_mobile_data),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
trailing = {
ScaledSwitch(
enabled = !uiState.appSettings.isAlwaysOnVpnEnabled,
checked = uiState.appSettings.isTunnelOnMobileDataEnabled,
onClick = { viewModel.handleEvent(AppEvent.ToggleAutoTunnelOnCellular) },
)
},
description = {
val cellularActive = remember(uiState.networkStatus) { uiState.networkStatus?.cellularConnected ?: false }
Text(
text = if (cellularActive) stringResource(R.string.active) else stringResource(R.string.inactive),
style = MaterialTheme.typography.bodySmall.copy(color = MaterialTheme.colorScheme.outline),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
onClick = { viewModel.handleEvent(AppEvent.ToggleAutoTunnelOnCellular) },
),
SelectionItem(
leadingIcon = Icons.Outlined.SettingsEthernet,
title = {
Text(
stringResource(R.string.tunnel_on_ethernet),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
trailing = {
ScaledSwitch(
enabled = !uiState.appSettings.isAlwaysOnVpnEnabled,
checked = uiState.appSettings.isTunnelOnEthernetEnabled,
onClick = { viewModel.handleEvent(AppEvent.ToggleAutoTunnelOnEthernet) },
)
},
description = {
val ethernetActive = remember(uiState.networkStatus) { uiState.networkStatus?.ethernetConnected ?: false }
Text(
text = if (ethernetActive) stringResource(R.string.active) else stringResource(R.string.inactive),
style = MaterialTheme.typography.bodySmall.copy(color = MaterialTheme.colorScheme.outline),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
onClick = { viewModel.handleEvent(AppEvent.ToggleAutoTunnelOnEthernet) },
),
SelectionItem(
leadingIcon = Icons.Outlined.AirplanemodeActive,
title = {
Text(
stringResource(R.string.stop_on_no_internet),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
description = {
Text(
stringResource(R.string.stop_on_internet_loss),
style = MaterialTheme.typography.bodySmall.copy(MaterialTheme.colorScheme.outline),
)
},
trailing = {
ScaledSwitch(
checked = uiState.appSettings.isStopOnNoInternetEnabled,
onClick = { viewModel.handleEvent(AppEvent.ToggleStopTunnelOnNoInternet) },
)
},
onClick = { viewModel.handleEvent(AppEvent.ToggleStopTunnelOnNoInternet) },
),
)
}
@@ -1,4 +1,4 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.settings.autotunnel.components
package com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.components
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
@@ -26,11 +26,9 @@ import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.common.ClickableIconButton
import com.zaneschepke.wireguardautotunnel.ui.common.button.ClickableIconButton
import com.zaneschepke.wireguardautotunnel.ui.common.textbox.CustomTextField
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth
@OptIn(ExperimentalLayoutApi::class)
@Composable
@@ -43,7 +41,7 @@ fun TrustedNetworkTextBox(
supporting: @Composable () -> Unit,
) {
val context = LocalContext.current
Column(verticalArrangement = Arrangement.spacedBy(10.dp.scaledHeight())) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
FlowRow(
modifier =
Modifier.fillMaxWidth(),
@@ -83,7 +81,7 @@ fun TrustedNetworkTextBox(
.padding(
top = 5.dp,
bottom = 10.dp,
).fillMaxWidth().padding(end = 16.dp.scaledWidth()),
).fillMaxWidth().padding(end = 16.dp),
singleLine = true,
keyboardOptions =
KeyboardOptions(
@@ -0,0 +1,196 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Code
import androidx.compose.material.icons.outlined.Filter1
import androidx.compose.material.icons.outlined.Security
import androidx.compose.material.icons.outlined.VpnKeyOff
import androidx.compose.material.icons.outlined.Wifi
import androidx.compose.material3.Icon
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.remember
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.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.zaneschepke.networkmonitor.NetworkStatus
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.LearnMoreLinkLabel
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
import com.zaneschepke.wireguardautotunnel.ui.theme.iconSize
import com.zaneschepke.wireguardautotunnel.util.extensions.openWebUrl
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@Composable
fun WifiTunnelingItems(
uiState: AppUiState,
viewModel: AppViewModel,
currentText: String,
onTextChange: (String) -> Unit,
isWifiNameReadable: () -> Boolean,
): List<SelectionItem> {
val context = LocalContext.current
val baseItems = listOf(
SelectionItem(
leadingIcon = Icons.Outlined.Wifi,
title = {
Text(
stringResource(R.string.tunnel_on_wifi),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
trailing = {
ScaledSwitch(
enabled = !uiState.appSettings.isAlwaysOnVpnEnabled,
checked = uiState.appSettings.isTunnelOnWifiEnabled,
onClick = { viewModel.handleEvent(AppEvent.ToggleAutoTunnelOnWifi) },
)
},
description = {
val wifiName by remember(uiState.networkStatus) {
derivedStateOf {
(uiState.networkStatus as? NetworkStatus.Connected)
?.takeIf { it.wifiConnected }
?.wifiSsid
}
}
Text(
text = wifiName?.let { stringResource(R.string.wifi_name_template, it) } ?: stringResource(R.string.inactive),
style = MaterialTheme.typography.bodySmall.copy(color = MaterialTheme.colorScheme.outline),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
onClick = { viewModel.handleEvent(AppEvent.ToggleAutoTunnelOnWifi) },
),
SelectionItem(
leadingIcon = Icons.Outlined.Code,
title = {
Text(
stringResource(R.string.wifi_name_via_shell),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
description = {
Text(
stringResource(R.string.use_root_shell_for_wifi),
style = MaterialTheme.typography.bodySmall.copy(MaterialTheme.colorScheme.outline),
)
},
trailing = {
ScaledSwitch(
checked = uiState.appSettings.isWifiNameByShellEnabled,
onClick = { viewModel.handleEvent(AppEvent.ToggleRootShellWifi) },
)
},
onClick = { viewModel.handleEvent(AppEvent.ToggleRootShellWifi) },
),
)
return if (uiState.appSettings.isTunnelOnWifiEnabled) {
baseItems + listOf(
SelectionItem(
leadingIcon = Icons.Outlined.Filter1,
title = {
Text(
stringResource(R.string.use_wildcards),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
description = {
LearnMoreLinkLabel({ context.openWebUrl(it) }, stringResource(R.string.docs_wildcards))
},
trailing = {
ScaledSwitch(
checked = uiState.appSettings.isWildcardsEnabled,
onClick = { viewModel.handleEvent(AppEvent.ToggleAutoTunnelWildcards) },
)
},
onClick = { viewModel.handleEvent(AppEvent.ToggleAutoTunnelWildcards) },
),
SelectionItem(
title = {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.weight(4f, false).fillMaxWidth(),
) {
val icon = Icons.Outlined.Security
Icon(
icon,
icon.name,
modifier = Modifier.size(iconSize),
)
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(2.dp, Alignment.CenterVertically),
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp)
.padding(vertical = 6.dp),
) {
Text(
stringResource(R.string.trusted_wifi_names),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
}
}
}
},
description = {
TrustedNetworkTextBox(
uiState.appSettings.trustedNetworkSSIDs,
onDelete = { viewModel.handleEvent(AppEvent.DeleteTrustedSSID(it)) },
currentText = currentText,
onSave = { ssid ->
if (uiState.appSettings.isWifiNameByShellEnabled || isWifiNameReadable()) {
viewModel.handleEvent(AppEvent.SaveTrustedSSID(ssid))
}
},
onValueChange = onTextChange,
supporting = { if (uiState.appSettings.isWildcardsEnabled) WildcardsLabel() },
)
},
),
SelectionItem(
leadingIcon = Icons.Outlined.VpnKeyOff,
title = {
Text(
stringResource(R.string.kill_switch_off),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
trailing = {
ScaledSwitch(
enabled = uiState.appSettings.isVpnKillSwitchEnabled,
checked = uiState.appSettings.isDisableKillSwitchOnTrustedEnabled,
onClick = { viewModel.handleEvent(AppEvent.ToggleStopKillSwitchOnTrusted) },
)
},
onClick = { viewModel.handleEvent(AppEvent.ToggleStopKillSwitchOnTrusted) },
),
)
} else {
baseItems
}
}
@@ -1,4 +1,4 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.settings.autotunnel.components
package com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.components
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -1,808 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.layout.Arrangement
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.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Save
import androidx.compose.material.icons.rounded.ContentCopy
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.MoreVert
import androidx.compose.material.icons.rounded.Refresh
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.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.ui.common.config.ConfigurationTextBox
import com.zaneschepke.wireguardautotunnel.ui.common.label.GroupLabel
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.TopNavBar
import com.zaneschepke.wireguardautotunnel.ui.common.prompt.AuthorizationPrompt
import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController
import com.zaneschepke.wireguardautotunnel.ui.enums.InterfaceActions
import com.zaneschepke.wireguardautotunnel.ui.enums.PeerActions
import com.zaneschepke.wireguardautotunnel.ui.state.InterfaceProxy
import com.zaneschepke.wireguardautotunnel.ui.state.PeerProxy
import com.zaneschepke.wireguardautotunnel.ui.theme.iconSize
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth
import org.amnezia.awg.crypto.KeyPair
@Composable
fun ConfigScreen(tunnelConf: TunnelConf?, appViewModel: AppViewModel) {
val context = LocalContext.current
val snackbar = SnackbarController.current
val clipboardManager: ClipboardManager = LocalClipboardManager.current
val keyboardController = LocalSoftwareKeyboardController.current
val navController = LocalNavController.current
var isInterfaceDropDownExpanded by remember {
mutableStateOf(false)
}
val popBackStack by appViewModel.popBackStack.collectAsStateWithLifecycle(false)
val configPair = Pair(tunnelConf?.tunName ?: "", tunnelConf?.toAmConfig())
var tunnelName by remember {
mutableStateOf(configPair.first)
}
var interfaceState by remember {
mutableStateOf(configPair.second?.let { InterfaceProxy.from(it.`interface`) } ?: InterfaceProxy())
}
var showAmneziaValues by remember {
mutableStateOf(configPair.second?.`interface`?.junkPacketCount?.isPresent == true)
}
var showScripts by remember {
mutableStateOf(false)
}
val peersState = remember {
(configPair.second?.peers?.map { PeerProxy.from(it) } ?: listOf(PeerProxy())).toMutableStateList()
}
var showAuthPrompt by remember { mutableStateOf(false) }
var isAuthenticated by remember { mutableStateOf(false) }
val keyboardActions = KeyboardActions(onDone = { keyboardController?.hide() })
val keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done)
if (showAuthPrompt) {
AuthorizationPrompt(
onSuccess = {
showAuthPrompt = false
isAuthenticated = true
},
onError = {
showAuthPrompt = false
snackbar.showMessage(
context.getString(R.string.error_authentication_failed),
)
},
onFailure = {
showAuthPrompt = false
snackbar.showMessage(
context.getString(R.string.error_authorization_failed),
)
},
)
}
LaunchedEffect(popBackStack) {
if (popBackStack) navController.popBackStack()
}
Scaffold(
topBar = {
TopNavBar(stringResource(R.string.edit_tunnel), trailing = {
IconButton(onClick = {
tunnelConf?.let {
appViewModel.updateExistingTunnelConfig(
it,
tunnelName,
peersState,
interfaceState,
)
} ?: appViewModel.saveNewTunnel(tunnelName, peersState, interfaceState)
}) {
val icon = Icons.Outlined.Save
Icon(
imageVector = icon,
contentDescription = icon.name,
)
}
})
},
) { padding ->
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(24.dp.scaledHeight(), Alignment.Top),
modifier =
Modifier
.fillMaxSize()
.padding(padding)
.imePadding()
.verticalScroll(rememberScrollState())
.padding(top = 24.dp.scaledHeight())
.padding(horizontal = 24.dp.scaledWidth()),
) {
Surface(
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surface,
) {
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(5.dp, Alignment.Top),
modifier = Modifier
.padding(16.dp.scaledWidth())
.focusGroup(),
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier.fillMaxWidth(),
) {
GroupLabel(
stringResource(R.string.interface_),
)
Column {
IconButton(
modifier = Modifier.size(iconSize),
onClick = {
isInterfaceDropDownExpanded = true
},
) {
val icon = Icons.Rounded.MoreVert
Icon(icon, icon.name)
}
DropdownMenu(
containerColor = MaterialTheme.colorScheme.surface,
expanded = isInterfaceDropDownExpanded,
modifier = Modifier.shadow(12.dp).background(MaterialTheme.colorScheme.surface),
onDismissRequest = {
isInterfaceDropDownExpanded = false
},
) {
val isAmneziaCompatibilitySet = interfaceState.isAmneziaCompatibilityModeSet()
InterfaceActions.entries.forEach { action ->
DropdownMenuItem(
text = {
Text(
text = when (action) {
InterfaceActions.TOGGLE_SHOW_SCRIPTS -> if (showScripts) {
stringResource(R.string.hide_scripts)
} else {
stringResource(R.string.show_scripts)
}
InterfaceActions.TOGGLE_AMNEZIA_VALUES -> if (showAmneziaValues) {
stringResource(R.string.hide_amnezia_properties)
} else {
stringResource(R.string.show_amnezia_properties)
}
InterfaceActions.SET_AMNEZIA_COMPATIBILITY -> if (isAmneziaCompatibilitySet) {
stringResource(R.string.remove_amnezia_compatibility)
} else {
stringResource(R.string.enable_amnezia_compatibility)
}
},
)
},
onClick = {
isInterfaceDropDownExpanded = false
when (action) {
InterfaceActions.TOGGLE_AMNEZIA_VALUES -> showAmneziaValues = !showAmneziaValues
InterfaceActions.TOGGLE_SHOW_SCRIPTS -> showScripts = !showScripts
InterfaceActions.SET_AMNEZIA_COMPATIBILITY -> if (isAmneziaCompatibilitySet) {
showAmneziaValues = false
interfaceState = interfaceState.resetAmneziaProperties()
} else {
showAmneziaValues = true
interfaceState = interfaceState.toAmneziaCompatibilityConfig()
}
}
},
)
}
}
}
}
ConfigurationTextBox(
value = tunnelName,
onValueChange = { tunnelName = it },
keyboardActions = keyboardActions,
label = stringResource(R.string.name),
hint = stringResource(R.string.tunnel_name).lowercase(),
modifier =
Modifier
.fillMaxWidth(),
)
val privateKeyEnabled = (tunnelConf == null) || isAuthenticated
OutlinedTextField(
textStyle = MaterialTheme.typography.labelLarge,
modifier =
Modifier
.fillMaxWidth()
.clickable { showAuthPrompt = true },
value = interfaceState.privateKey,
visualTransformation =
if (privateKeyEnabled) {
VisualTransformation.None
} else {
PasswordVisualTransformation()
},
enabled = privateKeyEnabled,
onValueChange = { interfaceState = interfaceState.copy(privateKey = it) },
trailingIcon = {
IconButton(
enabled = privateKeyEnabled,
modifier = Modifier.focusRequester(FocusRequester.Default).size(iconSize),
onClick = {
val keypair = KeyPair()
interfaceState = interfaceState.copy(
privateKey = keypair.privateKey.toBase64(),
publicKey = keypair.publicKey.toBase64(),
)
},
) {
Icon(
Icons.Rounded.Refresh,
stringResource(R.string.rotate_keys),
tint = if (privateKeyEnabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.outline,
)
}
},
label = {
Text(
stringResource(R.string.private_key),
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.labelMedium,
)
},
singleLine = true,
placeholder = {
Text(
stringResource(R.string.base64_key),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.outline,
)
},
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
)
OutlinedTextField(
textStyle = MaterialTheme.typography.labelLarge,
modifier =
Modifier
.fillMaxWidth()
.focusRequester(FocusRequester.Default),
value = interfaceState.publicKey,
enabled = false,
onValueChange = {
interfaceState = interfaceState.copy(publicKey = it)
},
trailingIcon = {
IconButton(
modifier = Modifier.focusRequester(FocusRequester.Default).size(iconSize),
onClick = {
clipboardManager.setText(
AnnotatedString(interfaceState.publicKey),
)
},
) {
Icon(
Icons.Rounded.ContentCopy,
stringResource(R.string.copy_public_key),
tint = MaterialTheme.colorScheme.onSurface,
)
}
},
label = {
Text(
stringResource(R.string.public_key),
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.labelMedium,
)
},
singleLine = true,
placeholder = {
Text(
stringResource(R.string.base64_key),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.outline,
)
},
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
)
ConfigurationTextBox(
value = interfaceState.addresses,
onValueChange = {
interfaceState = interfaceState.copy(addresses = it)
},
keyboardActions = keyboardActions,
label = stringResource(R.string.addresses),
hint = stringResource(R.string.comma_separated_list),
modifier =
Modifier
.fillMaxWidth()
.padding(end = 5.dp),
)
ConfigurationTextBox(
value = interfaceState.listenPort,
onValueChange = {
interfaceState = interfaceState.copy(listenPort = it)
},
keyboardActions = keyboardActions,
label = stringResource(R.string.listen_port),
hint = stringResource(R.string.random),
modifier = Modifier.fillMaxWidth(),
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(5.dp),
) {
ConfigurationTextBox(
value = interfaceState.dnsServers,
onValueChange = {
interfaceState = interfaceState.copy(dnsServers = it)
},
keyboardActions = keyboardActions,
label = stringResource(R.string.dns_servers),
hint = stringResource(R.string.comma_separated_list),
modifier =
Modifier
.fillMaxWidth(3 / 5f)
.padding(end = 5.dp),
)
ConfigurationTextBox(
value = interfaceState.mtu,
onValueChange = {
interfaceState = interfaceState.copy(mtu = it)
},
keyboardActions = keyboardActions,
label = stringResource(R.string.mtu),
hint = stringResource(R.string.auto),
modifier = Modifier.width(IntrinsicSize.Min),
)
}
if (showScripts) {
ConfigurationTextBox(
value = interfaceState.preUp,
onValueChange = {
interfaceState = interfaceState.copy(preUp = it)
},
keyboardActions = keyboardActions,
label = stringResource(R.string.pre_up),
hint = stringResource(R.string.comma_separated_list).lowercase(),
modifier = Modifier.fillMaxWidth(),
)
ConfigurationTextBox(
value = interfaceState.postUp,
onValueChange = {
interfaceState = interfaceState.copy(postUp = it)
},
keyboardActions = keyboardActions,
label = stringResource(R.string.post_up),
hint = stringResource(R.string.comma_separated_list).lowercase(),
modifier = Modifier.fillMaxWidth(),
)
ConfigurationTextBox(
value = interfaceState.preDown,
onValueChange = {
interfaceState = interfaceState.copy(preDown = it)
},
keyboardActions = keyboardActions,
label = stringResource(R.string.pre_down),
hint = stringResource(R.string.comma_separated_list).lowercase(),
modifier = Modifier.fillMaxWidth(),
)
ConfigurationTextBox(
value = interfaceState.postDown,
onValueChange = {
interfaceState = interfaceState.copy(postDown = it)
},
keyboardActions = keyboardActions,
label = stringResource(R.string.post_down),
hint = stringResource(R.string.comma_separated_list).lowercase(),
modifier = Modifier.fillMaxWidth(),
)
}
if (showAmneziaValues) {
ConfigurationTextBox(
value = interfaceState.junkPacketCount,
onValueChange = {
interfaceState = interfaceState.copy(junkPacketCount = it)
},
keyboardActions = keyboardActions,
label = stringResource(R.string.junk_packet_count),
hint = stringResource(R.string.junk_packet_count).lowercase(),
modifier =
Modifier
.fillMaxWidth(),
)
ConfigurationTextBox(
value = interfaceState.junkPacketMinSize,
onValueChange = {
interfaceState = interfaceState.copy(junkPacketMinSize = it)
},
keyboardActions = keyboardActions,
label = stringResource(R.string.junk_packet_minimum_size),
hint =
stringResource(
R.string.junk_packet_minimum_size,
).lowercase(),
modifier =
Modifier
.fillMaxWidth(),
)
ConfigurationTextBox(
value = interfaceState.junkPacketMaxSize,
onValueChange = {
interfaceState = interfaceState.copy(junkPacketMaxSize = it)
},
keyboardActions = keyboardActions,
label = stringResource(R.string.junk_packet_maximum_size),
hint =
stringResource(
R.string.junk_packet_maximum_size,
).lowercase(),
modifier =
Modifier
.fillMaxWidth(),
)
ConfigurationTextBox(
value = interfaceState.initPacketJunkSize,
onValueChange = {
interfaceState = interfaceState.copy(initPacketJunkSize = it)
},
keyboardActions = keyboardActions,
label = stringResource(R.string.init_packet_junk_size),
hint = stringResource(R.string.init_packet_junk_size).lowercase(),
modifier =
Modifier
.fillMaxWidth(),
)
ConfigurationTextBox(
value = interfaceState.responsePacketJunkSize,
onValueChange = {
interfaceState = interfaceState.copy(responsePacketJunkSize = it)
},
keyboardActions = keyboardActions,
label = stringResource(R.string.response_packet_junk_size),
hint =
stringResource(
R.string.response_packet_junk_size,
).lowercase(),
modifier =
Modifier
.fillMaxWidth(),
)
ConfigurationTextBox(
value = interfaceState.initPacketMagicHeader,
onValueChange = {
interfaceState = interfaceState.copy(initPacketMagicHeader = it)
},
keyboardActions = keyboardActions,
label = stringResource(R.string.init_packet_magic_header),
hint =
stringResource(
R.string.init_packet_magic_header,
).lowercase(),
modifier =
Modifier
.fillMaxWidth(),
)
ConfigurationTextBox(
value = interfaceState.responsePacketMagicHeader,
onValueChange = {
interfaceState = interfaceState.copy(responsePacketMagicHeader = it)
},
keyboardActions = keyboardActions,
label = stringResource(R.string.response_packet_magic_header),
hint =
stringResource(
R.string.response_packet_magic_header,
).lowercase(),
modifier =
Modifier
.fillMaxWidth(),
)
ConfigurationTextBox(
value = interfaceState.underloadPacketMagicHeader,
onValueChange = {
interfaceState = interfaceState.copy(underloadPacketMagicHeader = it)
},
keyboardActions = keyboardActions,
label = stringResource(R.string.underload_packet_magic_header),
hint =
stringResource(
R.string.underload_packet_magic_header,
).lowercase(),
modifier =
Modifier
.fillMaxWidth(),
)
ConfigurationTextBox(
value = interfaceState.transportPacketMagicHeader,
onValueChange = {
interfaceState = interfaceState.copy(transportPacketMagicHeader = it)
},
keyboardActions = keyboardActions,
label = stringResource(R.string.transport_packet_magic_header),
hint =
stringResource(
R.string.transport_packet_magic_header,
).lowercase(),
modifier =
Modifier
.fillMaxWidth(),
)
}
}
}
peersState.forEachIndexed { index, peer ->
var isPeerDropDownExpanded by remember {
mutableStateOf(false)
}
val isLanExcluded = peer.isLanExcluded()
Surface(
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surface,
) {
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(10.dp, Alignment.Top),
modifier = Modifier
.padding(16.dp.scaledWidth())
.focusGroup(),
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier.fillMaxWidth(),
) {
GroupLabel(
stringResource(R.string.peer),
)
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(
modifier = Modifier.size(iconSize),
onClick = {
// TODO make a dialog to confirm this
peersState.removeAt(index)
},
) {
val icon = Icons.Rounded.Delete
Icon(icon, icon.name)
}
Column {
IconButton(
modifier = Modifier.size(iconSize),
onClick = {
isPeerDropDownExpanded = true
},
) {
val icon = Icons.Rounded.MoreVert
Icon(icon, icon.name)
}
DropdownMenu(
containerColor = MaterialTheme.colorScheme.surface,
expanded = isPeerDropDownExpanded,
modifier = Modifier.shadow(12.dp).background(MaterialTheme.colorScheme.surface),
onDismissRequest = {
isPeerDropDownExpanded = false
},
) {
PeerActions.entries.forEach { action ->
DropdownMenuItem(
text = {
Text(
text = when (action) {
PeerActions.EXCLUDE_LAN -> if (isLanExcluded) {
stringResource(R.string.include_lan)
} else {
stringResource(R.string.exclude_lan)
}
},
)
},
onClick = {
isPeerDropDownExpanded = false
when (action) {
PeerActions.EXCLUDE_LAN -> if (isLanExcluded) {
peersState[index] = peer.includeLan()
} else {
peersState[index] = peer.excludeLan()
}
}
},
)
}
}
}
}
}
ConfigurationTextBox(
value = peer.publicKey,
onValueChange = { value ->
peersState[index] = peersState[index].copy(publicKey = value)
},
keyboardActions = keyboardActions,
label = stringResource(R.string.public_key),
hint = stringResource(R.string.base64_key),
modifier = Modifier.fillMaxWidth(),
)
val presharedKeyEnabled = (tunnelConf == null) || isAuthenticated ||
with(configPair.second?.peers[index]?.preSharedKey) { this?.isEmpty == true || this?.isPresent == false }
OutlinedTextField(
textStyle = MaterialTheme.typography.labelLarge,
modifier =
Modifier
.fillMaxWidth()
.clickable { showAuthPrompt = true },
value = peer.preSharedKey,
visualTransformation =
if (presharedKeyEnabled) {
VisualTransformation.None
} else {
PasswordVisualTransformation()
},
enabled = presharedKeyEnabled,
onValueChange = { value -> peersState[index] = peersState[index].copy(preSharedKey = value) },
label = {
Text(
stringResource(R.string.preshared_key),
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.labelMedium,
)
},
singleLine = true,
placeholder = {
Text(
stringResource(R.string.optional),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.outline,
)
},
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
)
OutlinedTextField(
textStyle = MaterialTheme.typography.labelLarge,
modifier = Modifier.fillMaxWidth(),
value = peer.persistentKeepalive,
enabled = true,
onValueChange = { value ->
peersState[index] = peersState[index].copy(persistentKeepalive = value)
},
trailingIcon = {
Text(
stringResource(R.string.seconds),
modifier = Modifier.padding(end = 10.dp),
style = MaterialTheme.typography.labelMedium,
)
},
label = {
Text(stringResource(R.string.persistent_keepalive), color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.labelMedium)
},
singleLine = true,
placeholder = {
Text(stringResource(R.string.optional_no_recommend), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.outline)
},
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
)
ConfigurationTextBox(
value = peer.endpoint,
onValueChange = { value ->
peersState[index] = peersState[index].copy(endpoint = value)
},
keyboardActions = keyboardActions,
label = stringResource(R.string.endpoint),
hint = stringResource(R.string.endpoint).lowercase(),
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
textStyle = MaterialTheme.typography.labelLarge,
modifier = Modifier.fillMaxWidth(),
value = peer.allowedIps,
enabled = true,
onValueChange = { value ->
peersState[index] = peersState[index].copy(allowedIps = value)
},
label = {
Text(
stringResource(R.string.allowed_ips),
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.labelMedium,
)
},
singleLine = true,
placeholder = {
Text(stringResource(R.string.comma_separated_list), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.outline)
},
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
)
}
}
}
Row(
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.fillMaxSize()
.padding(bottom = 140.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
) {
TextButton(onClick = {
peersState.add(PeerProxy())
}) {
Text(stringResource(R.string.add_peer))
}
}
}
}
}
}
@@ -2,128 +2,63 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.main
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.gestures.ScrollableDefaults
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
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.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.overscroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material3.FabPosition
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.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.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.common.NestedScrollListener
import com.zaneschepke.wireguardautotunnel.ui.common.dialog.InfoDialog
import com.zaneschepke.wireguardautotunnel.ui.common.functions.rememberFileImportLauncherForResult
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.TopNavBar
import com.zaneschepke.wireguardautotunnel.ui.common.permission.vpn.withVpnPermission
import com.zaneschepke.wireguardautotunnel.ui.common.permission.withIgnoreBatteryOpt
import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController
import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.AutoTunnelRowItem
import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.GettingStartedLabel
import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.ScrollDismissFab
import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.TunnelImportSheet
import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.TunnelRowItem
import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.TunnelList
import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.UrlImportDialog
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
import com.zaneschepke.wireguardautotunnel.ui.state.AppViewState
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
import com.zaneschepke.wireguardautotunnel.util.extensions.openWebUrl
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
import com.zaneschepke.wireguardautotunnel.viewmodel.MainViewModel
import java.text.Collator
import java.util.Locale
import com.zaneschepke.wireguardautotunnel.util.StringValue
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun MainScreen(viewModel: MainViewModel = hiltViewModel(), uiState: AppUiState) {
val context = LocalContext.current
fun MainScreen(appUiState: AppUiState, appViewState: AppViewState, viewModel: AppViewModel) {
val navController = LocalNavController.current
val clipboard = LocalClipboardManager.current
val snackbar = SnackbarController.current
var showBottomSheet by remember { mutableStateOf(false) }
var isFabVisible by rememberSaveable { mutableStateOf(true) }
var showDeleteTunnelAlertDialog by remember { mutableStateOf(false) }
var selectedTunnel by remember { mutableStateOf<TunnelConf?>(null) }
val isRunningOnTv = remember { context.isRunningOnTv() }
var showUrlImportDialog by remember { mutableStateOf(false) }
val activeTunnels by viewModel.activeTunnels.collectAsStateWithLifecycle(emptyList())
val tunnelFileImportResultLauncher = rememberFileImportLauncherForResult(
onNoFileExplorer = { viewModel.handleEvent(AppEvent.ShowMessage(StringValue.StringResource(R.string.error_no_file_explorer))) },
onData = { data -> viewModel.handleEvent(AppEvent.ImportTunnelFromFile(data)) },
)
val collator = Collator.getInstance(Locale.getDefault())
val sortedTunnels = remember(uiState.tunnels) {
uiState.tunnels.sortedWith(compareBy(collator) { it.tunName })
}
val startAutoTunnel = withVpnPermission<Unit> { viewModel.onToggleAutoTunnel() }
val startTunnel = withVpnPermission<TunnelConf> {
viewModel.onTunnelStart(it)
}
val autoTunnelToggleBattery = withIgnoreBatteryOpt(uiState.generalState.isBatteryOptimizationDisableShown) {
if (!uiState.generalState.isBatteryOptimizationDisableShown) viewModel.setBatteryOptimizeDisableShown()
if (uiState.appSettings.isKernelEnabled) {
viewModel.onToggleAutoTunnel()
} else {
startAutoTunnel.invoke(Unit)
val requestPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
if (!isGranted) {
viewModel.handleEvent(
AppEvent.ShowMessage(StringValue.StringResource(R.string.camera_permission_required)),
)
return@rememberLauncherForActivityResult
}
}
val nestedScrollConnection = remember {
NestedScrollListener({ isFabVisible = false }, { isFabVisible = true })
}
val tunnelFileImportResultLauncher = rememberFileImportLauncherForResult(onNoFileExplorer = {
snackbar.showMessage(
context.getString(R.string.error_no_file_explorer),
)
}, onData = { data ->
viewModel.onTunnelFileSelected(data, context)
})
val requestPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { isGranted ->
if (!isGranted) return@rememberLauncherForActivityResult snackbar.showMessage("Camera permission required")
navController.navigate(Route.Scanner)
}
if (showDeleteTunnelAlertDialog) {
if (showDeleteTunnelAlertDialog && appViewState.selectedTunnel != null) {
InfoDialog(
onDismiss = { showDeleteTunnelAlertDialog = false },
onAttest = {
selectedTunnel?.let { viewModel.onDelete(it) }
appViewState.selectedTunnel.let { viewModel.handleEvent(AppEvent.DeleteTunnel(it)) }
showDeleteTunnelAlertDialog = false
selectedTunnel = null
viewModel.handleEvent(AppEvent.SetSelectedTunnel(null))
},
title = { Text(text = stringResource(R.string.delete_tunnel)) },
body = { Text(text = stringResource(R.string.delete_tunnel_message)) },
@@ -131,122 +66,46 @@ fun MainScreen(viewModel: MainViewModel = hiltViewModel(), uiState: AppUiState)
)
}
fun onTunnelToggle(checked: Boolean, tunnel: TunnelConf) {
if (!checked) {
viewModel.onTunnelStop(tunnel)
return
}
if (uiState.appSettings.isKernelEnabled) {
viewModel.onTunnelStart(tunnel)
} else {
startTunnel.invoke(tunnel)
}
}
TunnelImportSheet(
appViewState.showBottomSheet,
onDismiss = { viewModel.handleEvent(AppEvent.ToggleBottomSheet) },
onFileClick = { tunnelFileImportResultLauncher.launch(Constants.ALLOWED_TV_FILE_TYPES) },
onQrClick = { requestPermissionLauncher.launch(android.Manifest.permission.CAMERA) },
onClipboardClick = { clipboard.getText()?.text?.let { viewModel.handleEvent(AppEvent.ImportTunnelFromClipboard(it)) } },
onManualImportClick = { navController.navigate(Route.Config(Constants.MANUAL_TUNNEL_CONFIG_ID)) },
onUrlClick = { showUrlImportDialog = true },
)
Scaffold(
modifier =
Modifier.pointerInput(Unit) {
if (uiState.tunnels.isEmpty()) return@pointerInput
detectTapGestures(
onTap = {
selectedTunnel = null
},
)
},
floatingActionButtonPosition = FabPosition.End,
floatingActionButton = {
if (!isRunningOnTv) {
ScrollDismissFab({
val icon = Icons.Filled.Add
Icon(
imageVector = icon,
contentDescription = icon.name,
tint = MaterialTheme.colorScheme.onPrimary,
)
}, isVisible = isFabVisible, onClick = {
showBottomSheet = true
})
}
},
topBar = {
if (isRunningOnTv) {
TopNavBar(
showBack = false,
title = stringResource(R.string.app_name),
trailing = {
IconButton(onClick = {
showBottomSheet = true
}) {
val icon = Icons.Outlined.Add
Icon(
imageVector = icon,
contentDescription = icon.name,
)
}
},
)
}
},
) { padding ->
TunnelImportSheet(
showBottomSheet,
onDismiss = { showBottomSheet = false },
onFileClick = { tunnelFileImportResultLauncher.launch(Constants.ALLOWED_TV_FILE_TYPES) },
onQrClick = { requestPermissionLauncher.launch(android.Manifest.permission.CAMERA) },
onClipboardClick = {
clipboard.getText()?.text?.let {
viewModel.onClipboardImport(it)
}
},
onManualImportClick = {
navController.navigate(
Route.Config(Constants.MANUAL_TUNNEL_CONFIG_ID),
)
if (showUrlImportDialog) {
UrlImportDialog(
onDismiss = { showUrlImportDialog = false },
onConfirm = { url ->
viewModel.handleEvent(AppEvent.ImportTunnelFromUrl(url))
showUrlImportDialog = false
},
)
LazyColumn(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(5.dp, Alignment.Top),
modifier =
Modifier
.fillMaxSize().padding(padding).padding(top = 24.dp.scaledHeight())
.overscroll(ScrollableDefaults.overscrollEffect())
.nestedScroll(nestedScrollConnection),
state = rememberLazyListState(0, uiState.tunnels.count()),
userScrollEnabled = true,
reverseLayout = false,
flingBehavior = ScrollableDefaults.flingBehavior(),
) {
if (uiState.tunnels.isEmpty()) {
item {
GettingStartedLabel(onClick = { context.openWebUrl(it) })
}
} else {
item {
AutoTunnelRowItem(uiState) {
autoTunnelToggleBattery.invoke()
}
}
}
items(
sortedTunnels,
key = { tunnel -> tunnel.id },
) { tunnel ->
val expanded = uiState.generalState.isTunnelStatsExpanded
val tunnelState = activeTunnels.firstOrNull { it.id == tunnel.id }?.state?.collectAsStateWithLifecycle()
TunnelRowItem(
tunnel.isActive,
expanded,
selectedTunnel?.id == tunnel.id,
tunnel,
tunnelState = tunnelState?.value ?: TunnelState(),
{ selectedTunnel = tunnel },
{ viewModel.onExpandedChanged(!expanded) },
onDelete = { showDeleteTunnelAlertDialog = true },
onCopy = { viewModel.onCopyTunnel(tunnel) },
onSwitchClick = { onTunnelToggle(it, tunnel) },
)
}
}
}
TunnelList(
appUiState = appUiState,
activeTunnels = appUiState.activeTunnels,
selectedTunnel = appViewState.selectedTunnel,
onSetSelectedTunnel = { viewModel.handleEvent(AppEvent.SetSelectedTunnel(it)) },
onDeleteTunnel = {
viewModel.handleEvent(AppEvent.SetSelectedTunnel(it))
showDeleteTunnelAlertDialog = true
},
onToggleTunnel = { tunnel, checked ->
if (checked) viewModel.handleEvent(AppEvent.StartTunnel(tunnel)) else viewModel.handleEvent(AppEvent.StopTunnel(tunnel))
},
onExpandStats = { viewModel.handleEvent(AppEvent.ToggleTunnelStatsExpanded) },
onCopyTunnel = {
viewModel.handleEvent(AppEvent.CopyTunnel(it))
viewModel.handleEvent(AppEvent.SetSelectedTunnel(null))
},
modifier = Modifier
.fillMaxSize()
.padding(top = 12.dp, bottom = 24.dp).padding(horizontal = 12.dp),
viewModel = viewModel,
)
}
@@ -1,269 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
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.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Check
import androidx.compose.material.icons.outlined.Save
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MultiChoiceSegmentedButtonRow
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
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.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.toMutableStateList
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.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.accompanist.drawablepainter.rememberDrawablePainter
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.ui.common.button.SelectionItemButton
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.TopNavBar
import com.zaneschepke.wireguardautotunnel.ui.common.textbox.CustomTextField
import com.zaneschepke.wireguardautotunnel.ui.enums.SplitOptions
import com.zaneschepke.wireguardautotunnel.ui.state.InterfaceProxy
import com.zaneschepke.wireguardautotunnel.ui.theme.iconSize
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth
import java.text.Collator
import java.util.Locale
@Composable
fun SplitTunnelScreen(tunnelConf: TunnelConf, viewModel: AppViewModel) {
val context = LocalContext.current
val navController = LocalNavController.current
val inputHeight = 45.dp
val collator = Collator.getInstance(Locale.getDefault())
val popBackStack by viewModel.popBackStack.collectAsStateWithLifecycle(false)
LaunchedEffect(popBackStack) {
if (popBackStack) navController.popBackStack()
}
val splitTunnelApps by viewModel.splitTunnelApps.collectAsStateWithLifecycle()
var proxyInterface by remember { mutableStateOf(InterfaceProxy()) }
var selectedSplitOption by remember { mutableStateOf(SplitOptions.ALL) }
val selectedPackages = remember { mutableStateListOf<String>() }
LaunchedEffect(Unit) {
proxyInterface = InterfaceProxy.from(tunnelConf.toAmConfig().`interface`)
val pair = when {
proxyInterface.excludedApplications.isNotEmpty() -> Pair(SplitOptions.EXCLUDE, proxyInterface.excludedApplications)
proxyInterface.includedApplications.isNotEmpty() -> Pair(SplitOptions.INCLUDE, proxyInterface.includedApplications)
else -> Pair(SplitOptions.ALL, mutableSetOf())
}
selectedSplitOption = pair.first
selectedPackages.addAll(pair.second)
}
var query: String by remember { mutableStateOf("") }
val sortedPackages by remember {
derivedStateOf {
splitTunnelApps.sortedWith(compareBy(collator) { it.name }).filter { it.name.lowercase().contains(query.lowercase()) }.toMutableStateList()
}
}
LaunchedEffect(Unit) {
// clean up any split tunnel packages for apps that were uninstalled
viewModel.cleanUpUninstalledApps(tunnelConf, splitTunnelApps.map { it.`package` })
}
Scaffold(
topBar = {
TopNavBar(stringResource(R.string.tunneling_apps), trailing = {
IconButton(onClick = {
proxyInterface.apply {
includedApplications.clear()
excludedApplications.clear()
}
when (selectedSplitOption) {
SplitOptions.INCLUDE -> proxyInterface.includedApplications.apply {
addAll(selectedPackages)
}
SplitOptions.EXCLUDE -> proxyInterface.excludedApplications.apply {
addAll(selectedPackages)
}
SplitOptions.ALL -> Unit
}
viewModel.updateExistingTunnelConfig(tunnelConf, `interface` = proxyInterface)
}) {
val icon = Icons.Outlined.Save
Icon(
imageVector = icon,
contentDescription = icon.name,
)
}
})
},
) { padding ->
Column(
verticalArrangement = Arrangement.spacedBy(24.dp.scaledHeight(), Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.padding(padding)
.padding(top = 24.dp.scaledHeight()),
) {
MultiChoiceSegmentedButtonRow(
modifier = Modifier.background(color = MaterialTheme.colorScheme.background).fillMaxWidth()
.padding(horizontal = 24.dp.scaledWidth()).height(inputHeight),
) {
SplitOptions.entries.forEachIndexed { index, entry ->
val active = selectedSplitOption == entry
SegmentedButton(
shape = SegmentedButtonDefaults.itemShape(index = index, count = SplitOptions.entries.size, baseShape = RoundedCornerShape(8.dp)),
icon = {
SegmentedButtonDefaults.Icon(active = active, activeContent = {
val icon = Icons.Outlined.Check
Icon(imageVector = icon, icon.name, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(SegmentedButtonDefaults.IconSize))
}) {
Icon(
imageVector = entry.icon(),
contentDescription = entry.icon().name,
modifier = Modifier.size(SegmentedButtonDefaults.IconSize),
)
}
},
colors = SegmentedButtonDefaults.colors().copy(
activeContainerColor = MaterialTheme.colorScheme.surface,
inactiveContainerColor = MaterialTheme.colorScheme.background,
),
onCheckedChange = {
selectedSplitOption = entry
},
checked = active,
) {
Text(
entry.text().asString(context)
.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() },
color = MaterialTheme.colorScheme.onBackground,
style = MaterialTheme.typography.labelMedium,
)
}
}
}
if (selectedSplitOption != SplitOptions.ALL) {
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth(),
) {
CustomTextField(
textStyle = MaterialTheme.typography.labelMedium.copy(
color = MaterialTheme.colorScheme.onBackground,
),
value = query,
onValueChange = { input ->
query = input
},
interactionSource = remember { MutableInteractionSource() },
label = {},
leading = {
val icon = Icons.Outlined.Search
Icon(icon, icon.name)
},
containerColor = MaterialTheme.colorScheme.background,
modifier =
Modifier
.fillMaxWidth().height(inputHeight).padding(horizontal = 24.dp.scaledWidth()),
singleLine = true,
keyboardOptions =
KeyboardOptions(
capitalization = KeyboardCapitalization.None,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(),
)
LazyColumn(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top,
contentPadding = PaddingValues(top = 10.dp),
) {
items(sortedPackages, key = { it.`package` }) { app ->
val checked = selectedPackages.contains(app.`package`)
val onClick = {
if (checked) selectedPackages.remove(app.`package`) else selectedPackages.add(app.`package`)
}
SelectionItemButton(
{
Image(
rememberDrawablePainter(app.icon),
app.name,
modifier =
Modifier
.padding(horizontal = 24.dp.scaledWidth())
.size(
iconSize,
),
)
},
buttonText = app.name,
onClick = {
onClick()
},
trailing = {
Row(
modifier = Modifier
.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
Checkbox(
checked = checked,
onCheckedChange = {
onClick()
},
)
}
},
)
}
}
}
}
}
}
}
@@ -1,175 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main
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.fillMaxWidth
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.outlined.PhoneAndroid
import androidx.compose.material.icons.outlined.Security
import androidx.compose.material.icons.outlined.SettingsEthernet
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
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.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.domain.entity.AppSettings
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.TopNavBar
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.autotunnel.components.TrustedNetworkTextBox
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.autotunnel.components.WildcardsLabel
import com.zaneschepke.wireguardautotunnel.ui.theme.iconSize
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth
import com.zaneschepke.wireguardautotunnel.viewmodel.TunnelAutoTunnelViewModel
@Composable
fun TunnelAutoTunnelScreen(tunnelConf: TunnelConf, appSettings: AppSettings, tunnelAutoTunnelViewModel: TunnelAutoTunnelViewModel = hiltViewModel()) {
var currentText by remember { mutableStateOf("") }
LaunchedEffect(tunnelConf.tunnelNetworks) {
currentText = ""
}
Scaffold(
topBar = {
TopNavBar(tunnelConf.tunName)
},
) { padding ->
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(24.dp.scaledHeight(), Alignment.Top),
modifier =
Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(top = 24.dp.scaledHeight())
.padding(horizontal = 24.dp.scaledWidth()),
) {
SurfaceSelectionGroupButton(
buildList {
addAll(
listOf(
SelectionItem(
Icons.Outlined.PhoneAndroid,
title = {
Text(
stringResource(R.string.mobile_tunnel),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
description = {
Text(
stringResource(R.string.mobile_data_tunnel),
style = MaterialTheme.typography.bodySmall.copy(MaterialTheme.colorScheme.outline),
)
},
trailing = {
ScaledSwitch(
tunnelConf.isMobileDataTunnel,
onClick = { tunnelAutoTunnelViewModel.onToggleIsMobileDataTunnel(tunnelConf) },
)
},
onClick = { tunnelAutoTunnelViewModel.onToggleIsMobileDataTunnel(tunnelConf) },
),
SelectionItem(
Icons.Outlined.SettingsEthernet,
title = {
Text(
stringResource(R.string.ethernet_tunnel),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
description = {
Text(
stringResource(R.string.set_ethernet_tunnel),
style = MaterialTheme.typography.bodySmall.copy(MaterialTheme.colorScheme.outline),
)
},
trailing = {
ScaledSwitch(
tunnelConf.isEthernetTunnel,
onClick = { tunnelAutoTunnelViewModel.onToggleIsEthernetTunnel(tunnelConf) },
)
},
onClick = { tunnelAutoTunnelViewModel.onToggleIsEthernetTunnel(tunnelConf) },
),
),
)
add(
SelectionItem(
title = {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp.scaledHeight()),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.weight(4f, false)
.fillMaxWidth(),
) {
val icon = Icons.Outlined.Security
Icon(
icon,
icon.name,
modifier = Modifier.size(iconSize),
)
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(2.dp, Alignment.CenterVertically),
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp.scaledWidth())
.padding(vertical = 6.dp.scaledHeight()),
) {
Text(
stringResource(R.string.use_tunnel_on_wifi_name),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
}
}
}
},
description = {
TrustedNetworkTextBox(
tunnelConf.tunnelNetworks,
onDelete = { tunnelAutoTunnelViewModel.onDeleteRunSSID(it, tunnelConf) },
currentText = currentText,
onSave = { tunnelAutoTunnelViewModel.onSaveRunSSID(it, tunnelConf) },
onValueChange = { currentText = it },
supporting = {
if (appSettings.isWildcardsEnabled) {
WildcardsLabel()
}
},
)
},
),
)
},
)
}
}
}
@@ -1,253 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
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.Dns
import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material.icons.outlined.NetworkPing
import androidx.compose.material.icons.outlined.Star
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
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.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton
import com.zaneschepke.wireguardautotunnel.ui.common.config.SubmitConfigurationTextBox
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.TopNavBar
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.ForwardButton
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.extensions.isValidIpv4orIpv6Address
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth
import com.zaneschepke.wireguardautotunnel.viewmodel.TunnelOptionsViewModel
import kotlin.text.isBlank
import kotlin.text.isNullOrBlank
import kotlin.text.toLong
@Composable
fun OptionsScreen(tunnelConf: TunnelConf, viewModel: TunnelOptionsViewModel = hiltViewModel()) {
val navController = LocalNavController.current
var currentText by remember { mutableStateOf("") }
LaunchedEffect(tunnelConf.tunnelNetworks) {
currentText = ""
}
val onPingToggle = {
viewModel.saveTunnel(tunnelConf.copy(isPingEnabled = !tunnelConf.isPingEnabled))
}
Scaffold(
topBar = {
TopNavBar(tunnelConf.tunName)
},
) {
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(24.dp.scaledHeight(), Alignment.Top),
modifier =
Modifier
.fillMaxSize()
.padding(it)
.imePadding()
.verticalScroll(rememberScrollState())
.padding(top = 24.dp.scaledHeight())
.padding(horizontal = 24.dp.scaledWidth()),
) {
SurfaceSelectionGroupButton(
listOf(
SelectionItem(
Icons.Outlined.Star,
title = {
Text(
stringResource(R.string.primary_tunnel),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
description = {
Text(
stringResource(R.string.set_primary_tunnel),
style = MaterialTheme.typography.bodySmall.copy(MaterialTheme.colorScheme.outline),
)
},
trailing = {
ScaledSwitch(
tunnelConf.isPrimaryTunnel,
onClick = { viewModel.onTogglePrimaryTunnel(tunnelConf) },
)
},
onClick = { viewModel.onTogglePrimaryTunnel(tunnelConf) },
),
SelectionItem(
Icons.Outlined.Bolt,
title = {
Text(
stringResource(R.string.auto_tunneling),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
description = {
Text(
stringResource(R.string.tunnel_specific_settings),
style = MaterialTheme.typography.bodySmall.copy(MaterialTheme.colorScheme.outline),
)
},
onClick = {
navController.navigate(Route.TunnelAutoTunnel(id = tunnelConf.id))
},
trailing = {
ForwardButton { navController.navigate(Route.TunnelAutoTunnel(id = tunnelConf.id)) }
},
),
SelectionItem(
Icons.Outlined.Edit,
title = {
Text(
stringResource(R.string.edit_tunnel),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
onClick = {
navController.navigate(Route.Config(id = tunnelConf.id))
},
trailing = {
ForwardButton { navController.navigate(Route.Config(id = tunnelConf.id)) }
},
),
SelectionItem(
Icons.Outlined.Dns,
title = {
Text(
stringResource(R.string.server_ipv4),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
description = {
Text(
stringResource(R.string.prefer_ipv4),
style = MaterialTheme.typography.bodySmall.copy(MaterialTheme.colorScheme.outline),
)
},
trailing = {
ScaledSwitch(
tunnelConf.isIpv4Preferred,
onClick = { viewModel.onToggleIpv4(tunnelConf) },
)
},
onClick = { viewModel.onToggleIpv4(tunnelConf) },
),
SelectionItem(
Icons.AutoMirrored.Outlined.CallSplit,
title = {
Text(
stringResource(R.string.splt_tunneling),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
onClick = {
navController.navigate(Route.SplitTunnel(id = tunnelConf.id))
},
trailing = {
ForwardButton { navController.navigate(Route.SplitTunnel(id = tunnelConf.id)) }
},
),
),
)
SurfaceSelectionGroupButton(
buildList {
add(
SelectionItem(
Icons.Outlined.NetworkPing,
title = {
Text(
stringResource(R.string.restart_on_ping),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
trailing = {
ScaledSwitch(
checked = tunnelConf.isPingEnabled,
onClick = { onPingToggle() },
)
},
onClick = { onPingToggle() },
),
)
if (tunnelConf.isPingEnabled) {
add(
SelectionItem(
title = {},
description = {
SubmitConfigurationTextBox(
tunnelConf.pingIp,
stringResource(R.string.set_custom_ping_ip),
stringResource(R.string.default_ping_ip),
isErrorValue = { !it.isNullOrBlank() && !it.isValidIpv4orIpv6Address() },
onSubmit = {
viewModel.saveTunnel(
tunnelConf.copy(pingIp = it.ifBlank { null }),
)
},
)
fun isSecondsError(seconds: String?): Boolean {
return seconds?.let { value -> if (value.isBlank()) false else value.toLong() >= Long.MAX_VALUE / 1000 } == true
}
SubmitConfigurationTextBox(
tunnelConf.pingInterval?.let { (it / 1000).toString() },
stringResource(R.string.set_custom_ping_internal),
"(${stringResource(R.string.optional_default)} ${Constants.PING_INTERVAL / 1000})",
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Done,
),
isErrorValue = ::isSecondsError,
onSubmit = {
viewModel.onPingIntervalChange(tunnelConf, it)
},
)
SubmitConfigurationTextBox(
tunnelConf.pingCooldown?.let { (it / 1000).toString() },
stringResource(R.string.set_custom_ping_cooldown),
"(${stringResource(R.string.optional_default)} ${Constants.PING_COOLDOWN / 1000})",
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
),
isErrorValue = ::isSecondsError,
onSubmit = { viewModel.onPingCoolDownChange(tunnelConf, it) },
)
},
),
)
}
},
)
}
}
}
@@ -0,0 +1,51 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main.autotunnel
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.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.unit.dp
import com.zaneschepke.wireguardautotunnel.domain.entity.AppSettings
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton
import com.zaneschepke.wireguardautotunnel.ui.screens.main.autotunnel.components.EthernetTunnelItem
import com.zaneschepke.wireguardautotunnel.ui.screens.main.autotunnel.components.MobileDataTunnelItem
import com.zaneschepke.wireguardautotunnel.ui.screens.main.autotunnel.components.WifiTunnelItem
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
@Composable
fun TunnelAutoTunnelScreen(tunnelConf: TunnelConf, appSettings: AppSettings, viewModel: AppViewModel) {
var currentText by remember { mutableStateOf("") }
LaunchedEffect(tunnelConf.tunnelNetworks) {
currentText = ""
}
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(24.dp, Alignment.Top),
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(top = 24.dp)
.padding(horizontal = 24.dp),
) {
SurfaceSelectionGroupButton(
items = buildList {
add(MobileDataTunnelItem(tunnelConf, viewModel))
add(EthernetTunnelItem(tunnelConf, viewModel))
add(WifiTunnelItem(tunnelConf, appSettings, viewModel, currentText) { currentText = it })
},
)
}
}
@@ -0,0 +1,40 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main.autotunnel.components
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.SettingsEthernet
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@Composable
fun EthernetTunnelItem(tunnelConf: TunnelConf, viewModel: AppViewModel): SelectionItem {
return SelectionItem(
leadingIcon = Icons.Outlined.SettingsEthernet,
title = {
Text(
text = stringResource(R.string.ethernet_tunnel),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
description = {
Text(
text = stringResource(R.string.set_ethernet_tunnel),
style = MaterialTheme.typography.bodySmall.copy(MaterialTheme.colorScheme.outline),
)
},
trailing = {
ScaledSwitch(
checked = tunnelConf.isEthernetTunnel,
onClick = { viewModel.handleEvent(AppEvent.ToggleEthernetTunnel(tunnelConf)) },
)
},
onClick = { viewModel.handleEvent(AppEvent.ToggleEthernetTunnel(tunnelConf)) },
)
}
@@ -0,0 +1,40 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main.autotunnel.components
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.PhoneAndroid
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@Composable
fun MobileDataTunnelItem(tunnelConf: TunnelConf, viewModel: AppViewModel): SelectionItem {
return SelectionItem(
leadingIcon = Icons.Outlined.PhoneAndroid,
title = {
Text(
text = stringResource(R.string.mobile_tunnel),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
description = {
Text(
text = stringResource(R.string.mobile_data_tunnel),
style = MaterialTheme.typography.bodySmall.copy(MaterialTheme.colorScheme.outline),
)
},
trailing = {
ScaledSwitch(
checked = tunnelConf.isMobileDataTunnel,
onClick = { viewModel.handleEvent(AppEvent.ToggleMobileDataTunnel(tunnelConf)) },
)
},
onClick = { viewModel.handleEvent(AppEvent.ToggleMobileDataTunnel(tunnelConf)) },
)
}
@@ -0,0 +1,76 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main.autotunnel.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Security
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
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.domain.entity.AppSettings
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
import com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.components.TrustedNetworkTextBox
import com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.components.WildcardsLabel
import com.zaneschepke.wireguardautotunnel.ui.theme.iconSize
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@Composable
fun WifiTunnelItem(
tunnelConf: TunnelConf,
appSettings: AppSettings,
viewModel: AppViewModel,
currentText: String,
onTextChange: (String) -> Unit,
): SelectionItem {
return SelectionItem(
title = {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
) {
Icon(
imageVector = Icons.Outlined.Security,
contentDescription = stringResource(R.string.use_tunnel_on_wifi_name),
modifier = Modifier.size(iconSize),
)
Text(
text = stringResource(R.string.use_tunnel_on_wifi_name),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
modifier = Modifier
.padding(start = 16.dp),
)
}
},
description = {
TrustedNetworkTextBox(
trustedNetworks = tunnelConf.tunnelNetworks,
onDelete = { viewModel.handleEvent(AppEvent.DeleteTunnelRunSSID(it, tunnelConf)) },
currentText = currentText,
onSave = {
viewModel.handleEvent(AppEvent.AddTunnelRunSSID(it, tunnelConf))
onTextChange("") // Reset the text field after saving
},
onValueChange = onTextChange,
supporting = {
if (appSettings.isWildcardsEnabled) {
WildcardsLabel()
}
},
)
},
)
}
@@ -1,57 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main.components
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Bolt
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
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 com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
import com.zaneschepke.wireguardautotunnel.ui.common.ExpandingRowListItem
import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch
import com.zaneschepke.wireguardautotunnel.ui.theme.SilverTree
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
@Composable
fun AutoTunnelRowItem(appUiState: AppUiState, onToggle: () -> Unit) {
val context = LocalContext.current
ExpandingRowListItem(
leading = {
val icon = Icons.Rounded.Bolt
Icon(
icon,
icon.name,
modifier =
Modifier
.size(16.dp).scale(1.5f),
tint =
if (!appUiState.autoTunnelActive) {
Color.Gray
} else {
SilverTree
},
)
},
text = stringResource(R.string.auto_tunneling),
trailing = {
ScaledSwitch(
appUiState.appSettings.isAutoTunnelEnabled,
onClick = {
onToggle()
},
)
},
onClick = {
if (context.isRunningOnTv()) {
onToggle()
}
},
isExpanded = false,
)
}
@@ -8,10 +8,12 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ContentPasteGo
import androidx.compose.material.icons.filled.Create
import androidx.compose.material.icons.filled.FileOpen
import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.QrCode
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
@@ -33,12 +35,14 @@ fun TunnelImportSheet(
onQrClick: () -> Unit,
onManualImportClick: () -> Unit,
onClipboardClick: () -> Unit,
onUrlClick: () -> Unit,
) {
val sheetState = rememberModalBottomSheetState()
val context = LocalContext.current
if (show) {
ModalBottomSheet(
containerColor = MaterialTheme.colorScheme.surface,
onDismissRequest = {
onDismiss()
},
@@ -110,6 +114,27 @@ fun TunnelImportSheet(
}
}
HorizontalDivider()
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable {
onDismiss()
onUrlClick()
}
.padding(10.dp),
) {
Icon(
Icons.Filled.Link,
contentDescription = stringResource(id = R.string.add_from_url),
modifier = Modifier.padding(10.dp),
)
Text(
stringResource(id = R.string.add_from_url),
modifier = Modifier.padding(10.dp),
)
}
HorizontalDivider()
Row(
modifier =
Modifier
@@ -0,0 +1,80 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main.components
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.gestures.ScrollableDefaults
import androidx.compose.foundation.layout.Arrangement
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.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.core.tunnel.getValueById
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
import com.zaneschepke.wireguardautotunnel.util.extensions.openWebUrl
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import java.text.Collator
import java.util.*
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun TunnelList(
appUiState: AppUiState,
activeTunnels: Map<TunnelConf, TunnelState>,
selectedTunnel: TunnelConf?,
onSetSelectedTunnel: (TunnelConf?) -> Unit,
onDeleteTunnel: (TunnelConf) -> Unit,
onToggleTunnel: (TunnelConf, Boolean) -> Unit,
onExpandStats: () -> Unit,
onCopyTunnel: (TunnelConf) -> Unit,
modifier: Modifier = Modifier,
viewModel: AppViewModel,
) {
val context = LocalContext.current
val collator = Collator.getInstance(Locale.getDefault())
val sortedTunnels = remember(appUiState.tunnels) {
appUiState.tunnels.sortedWith(compareBy(collator) { it.tunName })
}
LazyColumn(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(5.dp, Alignment.Top),
modifier = modifier
.pointerInput(Unit) {
if (appUiState.tunnels.isEmpty()) return@pointerInput
}.overscroll(ScrollableDefaults.overscrollEffect()),
state = rememberLazyListState(0, appUiState.tunnels.count()),
userScrollEnabled = true,
reverseLayout = false,
flingBehavior = ScrollableDefaults.flingBehavior(),
) {
if (appUiState.tunnels.isEmpty()) {
item {
GettingStartedLabel(onClick = { context.openWebUrl(it) })
}
}
items(sortedTunnels, key = { it.id }) { tunnel ->
val tunnelState = activeTunnels.getValueById(tunnel.id) ?: TunnelState()
TunnelRowItem(
isActive = tunnelState.status.isUpOrStarting(),
expanded = appUiState.generalState.isTunnelStatsExpanded,
isSelected = selectedTunnel?.id == tunnel.id,
tunnel = tunnel,
tunnelState = tunnelState,
onSetSelectedTunnel = { onSetSelectedTunnel(it) },
onClick = onExpandStats,
onCopy = { onCopyTunnel(tunnel) },
onDelete = { onDeleteTunnel(tunnel) },
onSwitchClick = { checked -> onToggleTunnel(tunnel, checked) },
viewModel = viewModel,
)
}
}
}
@@ -1,7 +1,8 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main.components
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Circle
@@ -23,6 +24,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
@@ -31,9 +33,11 @@ import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.common.ExpandingRowListItem
import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController
import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController
import com.zaneschepke.wireguardautotunnel.util.StringValue
import com.zaneschepke.wireguardautotunnel.util.extensions.asColor
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@Composable
fun TunnelRowItem(
@@ -42,153 +46,174 @@ fun TunnelRowItem(
isSelected: Boolean,
tunnel: TunnelConf,
tunnelState: TunnelState,
onHold: () -> Unit,
onSetSelectedTunnel: (TunnelConf?) -> Unit,
onClick: () -> Unit,
onCopy: () -> Unit,
onDelete: () -> Unit,
onSwitchClick: (checked: Boolean) -> Unit,
onSwitchClick: (Boolean) -> Unit,
viewModel: AppViewModel,
) {
val leadingIconColor = if (!isActive) Color.Gray else tunnelState.statistics.asColor()
val context = LocalContext.current
val snackbar = SnackbarController.current
val navController = LocalNavController.current
val haptic = LocalHapticFeedback.current
val itemFocusRequester = remember { FocusRequester() }
val isTv = context.isRunningOnTv()
val leadingIconColor = if (!isActive) Color.Gray else tunnelState.statistics.asColor()
val (leadingIcon, size) = when {
tunnel.isPrimaryTunnel -> Pair(Icons.Rounded.Star, 16.dp)
tunnel.isMobileDataTunnel -> Pair(Icons.Rounded.Smartphone, 16.dp)
tunnel.isEthernetTunnel -> Pair(Icons.Rounded.SettingsEthernet, 16.dp)
else -> Pair(Icons.Rounded.Circle, 14.dp)
}
ExpandingRowListItem(
leading = {
val icon = when {
tunnel.isPrimaryTunnel -> Icons.Rounded.Star
tunnel.isMobileDataTunnel -> Icons.Rounded.Smartphone
tunnel.isEthernetTunnel -> Icons.Rounded.SettingsEthernet
else -> Icons.Rounded.Circle
}
Icon(
icon,
icon.name,
leadingIcon,
stringResource(R.string.status),
tint = leadingIconColor,
modifier = Modifier.size(16.dp),
modifier = Modifier.size(size),
)
},
text = tunnel.tunName,
onHold = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onHold()
onSetSelectedTunnel(tunnel)
},
onClick = {
if (!context.isRunningOnTv()) {
if (isActive) {
onClick()
}
if (!isTv) {
if (isActive) onClick()
} else {
onHold()
onSetSelectedTunnel(tunnel)
itemFocusRequester.requestFocus()
}
},
isExpanded = expanded && isActive,
expanded = { if (isActive && expanded) TunnelStatisticsRow(tunnelState.statistics, tunnel) },
trailing = {
if (
isSelected &&
!context.isRunningOnTv()
) {
Row {
if (isSelected && !isTv) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
) {
IconButton(
modifier = Modifier.weight(1f),
onClick = {
navController.navigate(
Route.TunnelOptions(tunnel.id),
)
onSetSelectedTunnel(null)
navController.navigate(Route.TunnelOptions(tunnel.id))
},
) {
val icon = Icons.Rounded.Settings
Icon(
icon,
icon.name,
Icons.Rounded.Settings,
stringResource(id = R.string.settings),
modifier = Modifier.size(24.dp),
)
}
IconButton(
modifier = Modifier.focusable(),
onClick = { onCopy() },
modifier = Modifier.weight(1f),
onClick = {
onCopy()
onSetSelectedTunnel(null)
},
) {
val icon = Icons.Rounded.CopyAll
Icon(icon, icon.name)
Icon(
Icons.Rounded.CopyAll,
stringResource(R.string.copy),
modifier = Modifier.size(24.dp),
)
}
IconButton(
modifier = Modifier.weight(1f),
enabled = !isActive,
modifier = Modifier.focusable(),
onClick = { onDelete() },
) {
val icon = Icons.Rounded.Delete
Icon(icon, icon.name)
}
}
} else {
if (context.isRunningOnTv()) {
Row {
IconButton(
onClick = {
onHold()
navController.navigate(
Route.TunnelOptions(tunnel.id),
)
},
) {
val icon = Icons.Rounded.Settings
Icon(
icon,
icon.name,
)
}
IconButton(
onClick = {
if (isActive) {
onClick()
} else {
snackbar.showMessage(
context.getString(R.string.turn_on_tunnel),
)
}
},
) {
val icon = Icons.Rounded.Info
Icon(icon, icon.name)
}
IconButton(
onClick = { onCopy() },
) {
val icon = Icons.Rounded.CopyAll
Icon(icon, icon.name)
}
IconButton(
onClick = {
if (isActive) {
snackbar.showMessage(
context.getString(R.string.turn_off_tunnel),
)
} else {
onHold()
onDelete()
}
},
) {
val icon = Icons.Rounded.Delete
Icon(
icon,
icon.name,
)
}
ScaledSwitch(
modifier = Modifier.focusRequester(itemFocusRequester),
checked = isActive,
onClick = onSwitchClick,
Icon(
Icons.Rounded.Delete,
stringResource(R.string.delete_tunnel),
modifier = Modifier.size(24.dp),
)
}
}
} else if (isTv) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
) {
IconButton(
modifier = Modifier.weight(1f),
onClick = {
navController.navigate(Route.TunnelOptions(tunnel.id))
onSetSelectedTunnel(null)
},
) {
Icon(
Icons.Rounded.Settings,
stringResource(id = R.string.settings),
modifier = Modifier.size(24.dp),
)
}
IconButton(
modifier = Modifier.weight(1f),
onClick = {
if (isActive) {
onClick()
} else {
viewModel.handleEvent(
AppEvent.ShowMessage(StringValue.StringResource(R.string.turn_on_tunnel)),
)
}
},
) {
Icon(
Icons.Rounded.Info,
stringResource(R.string.info),
modifier = Modifier.size(24.dp),
)
}
IconButton(
modifier = Modifier.weight(1f),
onClick = onCopy,
) {
Icon(
Icons.Rounded.CopyAll,
stringResource(R.string.copy),
modifier = Modifier.size(24.dp),
)
}
IconButton(
modifier = Modifier.weight(1f),
onClick = {
if (isActive) {
viewModel.handleEvent(
AppEvent.ShowMessage(StringValue.StringResource(R.string.turn_off_tunnel)),
)
} else {
onDelete()
}
},
) {
Icon(
Icons.Rounded.Delete,
stringResource(R.string.delete_tunnel),
modifier = Modifier.size(24.dp),
)
}
} else {
ScaledSwitch(
modifier = Modifier.focusRequester(itemFocusRequester),
modifier = Modifier
.focusRequester(itemFocusRequester)
.weight(1f),
checked = isActive,
onClick = onSwitchClick,
)
}
} else {
ScaledSwitch(
modifier = Modifier.focusRequester(itemFocusRequester),
checked = isActive,
onClick = onSwitchClick,
)
}
},
)
@@ -0,0 +1,55 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.res.stringResource
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.R
@Composable
fun UrlImportDialog(onDismiss: () -> Unit, onConfirm: (String) -> Unit) {
var url by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.add_from_url)) },
text = {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
) {
OutlinedTextField(
value = url,
onValueChange = { url = it },
label = { Text(stringResource(R.string.enter_config_url)) },
modifier = Modifier.fillMaxWidth(),
)
}
},
confirmButton = {
TextButton(
onClick = { onConfirm(url) },
enabled = url.isNotBlank(),
) {
Text(stringResource(R.string.okay))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.cancel))
}
},
)
}
@@ -0,0 +1,108 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main.config
import android.view.WindowManager
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.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
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.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.zaneschepke.wireguardautotunnel.MainActivity
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.ui.common.prompt.AuthorizationPrompt
import com.zaneschepke.wireguardautotunnel.ui.screens.main.config.components.AddPeerButton
import com.zaneschepke.wireguardautotunnel.ui.screens.main.config.components.InterfaceSection
import com.zaneschepke.wireguardautotunnel.ui.screens.main.config.components.PeersSection
import com.zaneschepke.wireguardautotunnel.util.StringValue
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@Composable
fun ConfigScreen(tunnelConf: TunnelConf?, appViewModel: AppViewModel, viewModel: ConfigViewModel = hiltViewModel()) {
val context = LocalContext.current
val keyboardController = LocalSoftwareKeyboardController.current
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
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)
}
}
LaunchedEffect(Unit) {
// set callback for navbar to invoke save
appViewModel.handleEvent(
AppEvent.SetScreenAction {
keyboardController?.hide()
viewModel.save(tunnelConf)
},
)
}
LaunchedEffect(tunnelConf) {
viewModel.initFromTunnel(tunnelConf)
}
LaunchedEffect(uiState.success) {
if (uiState.success == true) {
appViewModel.handleEvent(AppEvent.ShowMessage(StringValue.StringResource(R.string.config_changes_saved)))
appViewModel.handleEvent(AppEvent.PopBackStack(true))
}
}
LaunchedEffect(uiState.message) {
uiState.message?.let { message ->
appViewModel.handleEvent(AppEvent.ShowMessage(message))
viewModel.setMessage(null)
}
}
if (uiState.showAuthPrompt) {
AuthorizationPrompt(
onSuccess = {
viewModel.toggleShowAuthPrompt()
viewModel.onAuthenticated()
},
onError = {
viewModel.toggleShowAuthPrompt()
appViewModel.handleEvent(AppEvent.ShowMessage(StringValue.StringResource(R.string.error_authentication_failed)))
},
onFailure = {
viewModel.toggleShowAuthPrompt()
appViewModel.handleEvent(AppEvent.ShowMessage(StringValue.StringResource(R.string.error_authorization_failed)))
},
)
}
Column(
verticalArrangement = Arrangement.spacedBy(24.dp, Alignment.Top),
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp),
) {
InterfaceSection(uiState, viewModel)
PeersSection(uiState, viewModel)
AddPeerButton(viewModel)
}
}
@@ -0,0 +1,173 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main.config
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.di.IoDispatcher
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository
import com.zaneschepke.wireguardautotunnel.ui.screens.main.config.state.ConfigUiState
import com.zaneschepke.wireguardautotunnel.ui.state.ConfigProxy
import com.zaneschepke.wireguardautotunnel.ui.state.InterfaceProxy
import com.zaneschepke.wireguardautotunnel.ui.state.PeerProxy
import com.zaneschepke.wireguardautotunnel.util.StringValue
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class ConfigViewModel @Inject constructor(
private val tunnelRepository: TunnelRepository,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) : ViewModel() {
private val _uiState = MutableStateFlow(ConfigUiState())
val uiState: StateFlow<ConfigUiState> = _uiState.asStateFlow()
fun initFromTunnel(tunnelConf: TunnelConf?) {
if (tunnelConf == null) return
_uiState.update {
val proxy = ConfigProxy.from(tunnelConf.toAmConfig())
it.copy(
tunnelName = tunnelConf.name,
configProxy = proxy,
showScripts = proxy.hasScripts(),
showAmneziaValues = proxy.`interface`.junkPacketCount.isNotBlank(),
isAuthenticated = false,
)
}
}
fun updateTunnelName(name: String) {
_uiState.update {
it.copy(tunnelName = name)
}
}
fun updateInterface(newInterface: InterfaceProxy) {
_uiState.update {
it.copy(
configProxy = it.configProxy.copy(
`interface` = newInterface,
),
)
}
}
fun toggleAmneziaValues() {
_uiState.update {
it.copy(showAmneziaValues = !it.showAmneziaValues)
}
}
fun toggleScripts() {
_uiState.update {
it.copy(showScripts = !it.showScripts)
}
}
fun toggleAmneziaCompatibility() {
val (show, `interface`) = with(_uiState.value.configProxy) {
if (`interface`.isAmneziaCompatibilityModeSet()) {
Pair(false, `interface`.resetAmneziaProperties())
} else {
Pair(true, `interface`.toAmneziaCompatibilityConfig())
}
}
_uiState.update {
it.copy(
showAmneziaValues = show,
configProxy = it.configProxy.copy(
`interface` = `interface`,
),
)
}
}
fun addPeer() {
_uiState.update { currentState ->
currentState.copy(
configProxy = currentState.configProxy.copy(
peers = currentState.configProxy.peers + PeerProxy(),
),
)
}
}
fun removePeer(index: Int) {
_uiState.update { currentState ->
currentState.copy(
configProxy = currentState.configProxy.copy(
peers = currentState.configProxy.peers.toMutableList().apply { removeAt(index) },
),
)
}
}
fun updatePeer(index: Int, peer: PeerProxy) {
_uiState.update { currentState ->
currentState.copy(
configProxy = currentState.configProxy.copy(
peers = currentState.configProxy.peers.toMutableList().apply { set(index, peer) },
),
)
}
}
fun toggleLanExclusion(index: Int) {
val peer = _uiState.value.configProxy.peers[index]
val updated = if (peer.isLanExcluded()) peer.includeLan() else peer.excludeLan()
updatePeer(index, updated)
}
fun setMessage(message: StringValue?) {
_uiState.update {
it.copy(message = message)
}
}
// TODO improve error messaging
fun save(tunnelConf: TunnelConf?) = viewModelScope.launch(ioDispatcher) {
val message = try {
val saveConfig = buildTunnelConfFromState(tunnelConf)
tunnelRepository.save(saveConfig)
_uiState.update { it.copy(success = true) }
} catch (e: Exception) {
setMessage(
e.message?.let { message ->
(StringValue.DynamicString(message))
} ?: StringValue.StringResource(R.string.unknown_error),
)
}
}
private fun buildTunnelConfFromState(tunnelConf: TunnelConf?): TunnelConf {
val (wg, am) = _uiState.value.configProxy.buildConfigs()
val name = _uiState.value.tunnelName
return tunnelConf?.copyWithCallback(
tunName = name,
amQuick = am.toAwgQuickString(true),
wgQuick = wg.toWgQuickString(true),
) ?: TunnelConf(
tunName = name, amQuick = am.toAwgQuickString(true),
wgQuick = wg.toWgQuickString(true),
)
}
fun onAuthenticated() {
_uiState.update {
it.copy(isAuthenticated = true)
}
}
fun toggleShowAuthPrompt() {
_uiState.update {
it.copy(showAuthPrompt = !it.showAuthPrompt)
}
}
}
@@ -0,0 +1,28 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main.config.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.screens.main.config.ConfigViewModel
@Composable
fun AddPeerButton(viewModel: ConfigViewModel) {
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(bottom = 24.dp),
) {
TextButton(onClick = { viewModel.addPeer() }) {
Text(stringResource(R.string.add_peer))
}
}
}
@@ -0,0 +1,72 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main.config.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.size
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.ui.theme.iconSize
@Composable
fun InterfaceDropdown(
expanded: Boolean,
onExpandedChange: (Boolean) -> Unit,
showScripts: Boolean,
showAmneziaValues: Boolean,
isAmneziaCompatibilitySet: Boolean,
onToggleScripts: () -> Unit,
onToggleAmneziaValues: () -> Unit,
onToggleAmneziaCompatibility: () -> Unit,
) {
Column {
IconButton(
modifier = Modifier.size(iconSize),
onClick = { onExpandedChange(true) },
) {
Icon(Icons.Rounded.MoreVert, contentDescription = "More")
}
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)
},
)
}
}
}
@@ -0,0 +1,220 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main.config.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
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.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.common.config.ConfigurationTextBox
import com.zaneschepke.wireguardautotunnel.ui.state.InterfaceProxy
@Composable
fun InterfaceFields(
interfaceState: InterfaceProxy,
showAuthPrompt: () -> Unit,
isAuthenticated: Boolean,
showScripts: Boolean,
showAmneziaValues: Boolean,
onInterfaceChange: (InterfaceProxy) -> Unit,
) {
val keyboardController = LocalSoftwareKeyboardController.current
val clipboardManager = LocalClipboardManager.current
val keyboardActions = KeyboardActions(onDone = { keyboardController?.hide() })
val keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done)
OutlinedTextField(
value = interfaceState.privateKey,
onValueChange = { onInterfaceChange(interfaceState.copy(privateKey = it)) },
label = { Text(stringResource(R.string.private_key), style = MaterialTheme.typography.bodyMedium) },
modifier = Modifier.fillMaxWidth().clickable {
if (!isAuthenticated) showAuthPrompt()
},
visualTransformation = if (isAuthenticated) VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = {
IconButton(enabled = isAuthenticated, onClick = {
val keypair = com.wireguard.crypto.KeyPair()
onInterfaceChange(
interfaceState.copy(
privateKey = keypair.privateKey.toBase64(),
publicKey = keypair.publicKey.toBase64(),
),
)
}) {
Icon(
Icons.Rounded.Refresh,
stringResource(R.string.rotate_keys),
tint = if (isAuthenticated) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.outline,
)
}
},
enabled = isAuthenticated,
singleLine = true,
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
)
OutlinedTextField(
value = interfaceState.publicKey,
onValueChange = { onInterfaceChange(interfaceState.copy(publicKey = it)) },
label = { Text(stringResource(R.string.public_key), style = MaterialTheme.typography.bodyMedium) },
enabled = false,
modifier = Modifier.fillMaxWidth(),
singleLine = true,
trailingIcon = {
IconButton(onClick = {
clipboardManager.setText(AnnotatedString(interfaceState.publicKey))
}) {
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.comma_separated_list),
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(),
)
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.comma_separated_list),
modifier = Modifier.weight(3f),
)
ConfigurationTextBox(
value = interfaceState.mtu,
onValueChange = { onInterfaceChange(interfaceState.copy(mtu = it)) },
label = stringResource(R.string.mtu),
hint = stringResource(R.string.auto),
modifier = Modifier.weight(2f),
)
}
if (showScripts) {
ConfigurationTextBox(
value = interfaceState.preUp,
onValueChange = { onInterfaceChange(interfaceState.copy(preUp = it)) },
label = stringResource(R.string.pre_up),
hint = stringResource(R.string.comma_separated_list).lowercase(),
modifier = Modifier.fillMaxWidth(),
)
ConfigurationTextBox(
value = interfaceState.postUp,
onValueChange = { onInterfaceChange(interfaceState.copy(postUp = it)) },
label = stringResource(R.string.post_up),
hint = stringResource(R.string.comma_separated_list).lowercase(),
modifier = Modifier.fillMaxWidth(),
)
ConfigurationTextBox(
value = interfaceState.preDown,
onValueChange = { onInterfaceChange(interfaceState.copy(preDown = it)) },
label = stringResource(R.string.pre_down),
hint = stringResource(R.string.comma_separated_list).lowercase(),
modifier = Modifier.fillMaxWidth(),
)
ConfigurationTextBox(
value = interfaceState.postDown,
onValueChange = { onInterfaceChange(interfaceState.copy(postDown = it)) },
label = stringResource(R.string.post_down),
hint = stringResource(R.string.comma_separated_list).lowercase(),
modifier = Modifier.fillMaxWidth(),
)
}
if (showAmneziaValues) {
ConfigurationTextBox(
value = interfaceState.junkPacketCount,
onValueChange = { onInterfaceChange(interfaceState.copy(junkPacketCount = it)) },
label = stringResource(R.string.junk_packet_count),
hint = stringResource(R.string.junk_packet_count).lowercase(),
modifier = Modifier.fillMaxWidth(),
)
ConfigurationTextBox(
value = interfaceState.junkPacketMinSize,
onValueChange = { onInterfaceChange(interfaceState.copy(junkPacketMinSize = it)) },
label = stringResource(R.string.junk_packet_minimum_size),
hint = stringResource(R.string.junk_packet_minimum_size).lowercase(),
modifier = Modifier.fillMaxWidth(),
)
ConfigurationTextBox(
value = interfaceState.junkPacketMaxSize,
onValueChange = { onInterfaceChange(interfaceState.copy(junkPacketMaxSize = it)) },
label = stringResource(R.string.junk_packet_maximum_size),
hint = stringResource(R.string.junk_packet_maximum_size).lowercase(),
modifier = Modifier.fillMaxWidth(),
)
ConfigurationTextBox(
value = interfaceState.initPacketJunkSize,
onValueChange = { onInterfaceChange(interfaceState.copy(initPacketJunkSize = it)) },
label = stringResource(R.string.init_packet_junk_size),
hint = stringResource(R.string.init_packet_junk_size).lowercase(),
modifier = Modifier.fillMaxWidth(),
)
ConfigurationTextBox(
value = interfaceState.responsePacketJunkSize,
onValueChange = { onInterfaceChange(interfaceState.copy(responsePacketJunkSize = it)) },
label = stringResource(R.string.response_packet_junk_size),
hint = stringResource(R.string.response_packet_junk_size).lowercase(),
modifier = Modifier.fillMaxWidth(),
)
ConfigurationTextBox(
value = interfaceState.initPacketMagicHeader,
onValueChange = { onInterfaceChange(interfaceState.copy(initPacketMagicHeader = it)) },
label = stringResource(R.string.init_packet_magic_header),
hint = stringResource(R.string.init_packet_magic_header).lowercase(),
modifier = Modifier.fillMaxWidth(),
)
ConfigurationTextBox(
value = interfaceState.responsePacketMagicHeader,
onValueChange = { onInterfaceChange(interfaceState.copy(responsePacketMagicHeader = it)) },
label = stringResource(R.string.response_packet_magic_header),
hint = stringResource(R.string.response_packet_magic_header).lowercase(),
modifier = Modifier.fillMaxWidth(),
)
ConfigurationTextBox(
value = interfaceState.underloadPacketMagicHeader,
onValueChange = { onInterfaceChange(interfaceState.copy(underloadPacketMagicHeader = it)) },
label = stringResource(R.string.underload_packet_magic_header),
hint = stringResource(R.string.underload_packet_magic_header).lowercase(),
modifier = Modifier.fillMaxWidth(),
)
ConfigurationTextBox(
value = interfaceState.transportPacketMagicHeader,
onValueChange = { onInterfaceChange(interfaceState.copy(transportPacketMagicHeader = it)) },
label = stringResource(R.string.transport_packet_magic_header),
hint = stringResource(R.string.transport_packet_magic_header).lowercase(),
modifier = Modifier.fillMaxWidth(),
)
}
}

Some files were not shown because too many files have changed in this diff Show More