Compare commits

..

1 Commits

Author SHA1 Message Date
Zane Schepke 34d71a6096 feat: mobile data only auto-tunneling
Added support for configuring auto-tunneling to only tunnel on mobile data with no location permissions necessary.

Improved UI on support screen and updated support resource links.

Fixed UI bug where analytics expansion could show on deactivated tunnels.
2023-12-05 01:14:19 -05:00
110 changed files with 3387 additions and 4519 deletions
+1 -1
View File
@@ -70,7 +70,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with: with:
# fix hardcode changelog file name # fix hardcode changelog file name
body_path: ${{ github.workspace }}/fastlane/metadata/android/en-US/changelogs/33200.txt body_path: ${{ github.workspace }}/fastlane/metadata/android/en-US/changelogs/32300.txt
tag_name: ${{ github.ref_name }} tag_name: ${{ github.ref_name }}
name: Release ${{ github.ref_name }} name: Release ${{ github.ref_name }}
draft: false draft: false
+31 -53
View File
@@ -23,10 +23,6 @@ android {
arg("room.schemaLocation", "$projectDir/schemas") arg("room.schemaLocation", "$projectDir/schemas")
} }
sourceSets {
getByName("debug").assets.srcDirs(files("$projectDir/schemas")) // Room
}
resourceConfigurations.addAll(listOf("en")) resourceConfigurations.addAll(listOf("en"))
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -37,51 +33,33 @@ android {
signingConfigs { signingConfigs {
create(Constants.RELEASE) { create(Constants.RELEASE) {
val properties = val properties = Properties().apply {
Properties().apply { //created local file for signing details
// created local file for signing details try {
try { load(file("signing.properties").reader())
load(file("signing.properties").reader()) } catch (_ : Exception) {
} catch (_: Exception) { load(file("signing_template.properties").reader())
load(file("signing_template.properties").reader())
}
} }
}
// try to get secrets from env first for pipeline build, then properties file for local build //try to get secrets from env first for pipeline build, then properties file for local build
storeFile = file( storeFile = file(System.getenv().getOrDefault(Constants.KEY_STORE_PATH_VAR, properties.getProperty(Constants.KEY_STORE_PATH_VAR)))
System.getenv().getOrDefault( storePassword = System.getenv().getOrDefault(Constants.STORE_PASS_VAR, properties.getProperty(Constants.STORE_PASS_VAR))
Constants.KEY_STORE_PATH_VAR, keyAlias = System.getenv().getOrDefault(Constants.KEY_ALIAS_VAR, properties.getProperty(Constants.KEY_ALIAS_VAR))
properties.getProperty(Constants.KEY_STORE_PATH_VAR) keyPassword = System.getenv().getOrDefault(Constants.KEY_PASS_VAR, properties.getProperty(Constants.KEY_PASS_VAR))
)
)
storePassword = System.getenv().getOrDefault(
Constants.STORE_PASS_VAR,
properties.getProperty(Constants.STORE_PASS_VAR)
)
keyAlias = System.getenv().getOrDefault(
Constants.KEY_ALIAS_VAR,
properties.getProperty(Constants.KEY_ALIAS_VAR)
)
keyPassword = System.getenv().getOrDefault(
Constants.KEY_PASS_VAR,
properties.getProperty(Constants.KEY_PASS_VAR)
)
} }
} }
buildTypes { buildTypes {
// don't strip //don't strip
packaging.jniLibs.keepDebugSymbols.addAll( packaging.jniLibs.keepDebugSymbols.addAll(listOf("libwg-go.so", "libwg-quick.so", "libwg.so"))
listOf("libwg-go.so", "libwg-quick.so", "libwg.so")
)
applicationVariants.all { applicationVariants.all {
val variant = this val variant = this
variant.outputs variant.outputs
.map { it as com.android.build.gradle.internal.api.BaseVariantOutputImpl } .map { it as com.android.build.gradle.internal.api.BaseVariantOutputImpl }
.forEach { output -> .forEach { output ->
val outputFileName = val outputFileName = "${Constants.APP_NAME}-${variant.flavorName}-${variant.buildType.name}-${variant.versionName}.apk"
"${Constants.APP_NAME}-${variant.flavorName}-${variant.buildType.name}-${variant.versionName}.apk"
output.outputFileName = outputFileName output.outputFileName = outputFileName
} }
} }
@@ -107,7 +85,8 @@ android {
} }
create("general") { create("general") {
dimension = Constants.TYPE dimension = Constants.TYPE
if (BuildHelper.isReleaseBuild(gradle) && BuildHelper.isGeneralFlavor(gradle)) { if (BuildHelper.isReleaseBuild(gradle) && BuildHelper.isGeneralFlavor(gradle))
{
apply(plugin = "com.google.gms.google-services") apply(plugin = "com.google.gms.google-services")
apply(plugin = "com.google.firebase.crashlytics") apply(plugin = "com.google.firebase.crashlytics")
} }
@@ -124,6 +103,7 @@ android {
buildFeatures { buildFeatures {
compose = true compose = true
buildConfig = true buildConfig = true
} }
composeOptions { composeOptions {
kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get() kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get()
@@ -149,22 +129,20 @@ dependencies {
implementation(libs.androidx.material3) implementation(libs.androidx.material3)
implementation(libs.androidx.appcompat) implementation(libs.androidx.appcompat)
// test //test
testImplementation(libs.junit) testImplementation(libs.junit)
testImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test) androidTestImplementation(libs.androidx.compose.ui.test)
androidTestImplementation(libs.androidx.room.testing)
debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.manifest) debugImplementation(libs.androidx.compose.manifest)
// wg //wg
implementation(libs.tunnel) implementation(libs.tunnel)
coreLibraryDesugaring(libs.desugar.jdk.libs) coreLibraryDesugaring(libs.desugar.jdk.libs)
// logging //logging
implementation(libs.timber) implementation(libs.timber)
// compose navigation // compose navigation
@@ -175,41 +153,41 @@ dependencies {
implementation(libs.hilt.android) implementation(libs.hilt.android)
ksp(libs.hilt.android.compiler) ksp(libs.hilt.android.compiler)
// accompanist //accompanist
implementation(libs.accompanist.systemuicontroller) implementation(libs.accompanist.systemuicontroller)
implementation(libs.accompanist.permissions) implementation(libs.accompanist.permissions)
implementation(libs.accompanist.flowlayout) implementation(libs.accompanist.flowlayout)
implementation(libs.accompanist.drawablepainter) implementation(libs.accompanist.drawablepainter)
// storage //room
implementation(libs.androidx.room.runtime) implementation(libs.androidx.room.runtime)
ksp(libs.androidx.room.compiler) ksp(libs.androidx.room.compiler)
implementation(libs.androidx.room.ktx) implementation(libs.androidx.room.ktx)
implementation(libs.androidx.datastore.preferences)
// lifecycle //lifecycle
implementation(libs.lifecycle.runtime.compose) implementation(libs.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.process) implementation(libs.androidx.lifecycle.process)
// icons
//icons
implementation(libs.material.icons.extended) implementation(libs.material.icons.extended)
// serialization //serialization
implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.serialization.json)
// firebase crashlytics //firebase crashlytics
generalImplementation(platform(libs.firebase.bom)) generalImplementation(platform(libs.firebase.bom))
generalImplementation(libs.google.firebase.crashlytics.ktx) generalImplementation(libs.google.firebase.crashlytics.ktx)
generalImplementation(libs.google.firebase.analytics.ktx) generalImplementation(libs.google.firebase.analytics.ktx)
// barcode scanning //barcode scanning
implementation(libs.zxing.android.embedded) implementation(libs.zxing.android.embedded)
implementation(libs.zxing.core) implementation(libs.zxing.core)
// bio //bio
implementation(libs.androidx.biometric.ktx) implementation(libs.androidx.biometric.ktx)
// shortcuts //shortcuts
implementation(libs.androidx.core) implementation(libs.androidx.core)
implementation(libs.androidx.core.google.shortcuts) implementation(libs.androidx.core.google.shortcuts)
} }
-4
View File
@@ -1,5 +1 @@
-dontwarn com.google.errorprone.annotations.** -dontwarn com.google.errorprone.annotations.**
-keepclassmembers class * extends androidx.datastore.preferences.protobuf.GeneratedMessageLite {
<fields>;
}
-5
View File
@@ -19,8 +19,3 @@
# If you keep the line number information, uncomment this to # If you keep the line number information, uncomment this to
# hide the original source file name. # hide the original source file name.
#-renamesourcefileattribute SourceFile #-renamesourcefileattribute SourceFile
-keepclassmembers class * extends androidx.datastore.preferences.protobuf.GeneratedMessageLite {
<fields>;
}
@@ -1,154 +0,0 @@
{
"formatVersion": 1,
"database": {
"version": 4,
"identityHash": "aee55639422df8dadfe74c3bad204477",
"entities": [
{
"tableName": "Settings",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `is_tunnel_enabled` INTEGER NOT NULL, `is_tunnel_on_mobile_data_enabled` INTEGER NOT NULL, `trusted_network_ssids` TEXT NOT NULL, `default_tunnel` TEXT, `is_always_on_vpn_enabled` INTEGER NOT NULL, `is_tunnel_on_ethernet_enabled` INTEGER NOT NULL, `is_shortcuts_enabled` INTEGER NOT NULL DEFAULT false, `is_battery_saver_enabled` INTEGER NOT NULL DEFAULT false, `is_tunnel_on_wifi_enabled` INTEGER NOT NULL DEFAULT false, `is_kernel_enabled` INTEGER NOT NULL DEFAULT false, `is_restore_on_boot_enabled` INTEGER NOT NULL DEFAULT false, `is_multi_tunnel_enabled` INTEGER NOT NULL DEFAULT false)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isAutoTunnelEnabled",
"columnName": "is_tunnel_enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isTunnelOnMobileDataEnabled",
"columnName": "is_tunnel_on_mobile_data_enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "trustedNetworkSSIDs",
"columnName": "trusted_network_ssids",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "defaultTunnel",
"columnName": "default_tunnel",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "isAlwaysOnVpnEnabled",
"columnName": "is_always_on_vpn_enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isTunnelOnEthernetEnabled",
"columnName": "is_tunnel_on_ethernet_enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isShortcutsEnabled",
"columnName": "is_shortcuts_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isBatterySaverEnabled",
"columnName": "is_battery_saver_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isTunnelOnWifiEnabled",
"columnName": "is_tunnel_on_wifi_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isKernelEnabled",
"columnName": "is_kernel_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isRestoreOnBootEnabled",
"columnName": "is_restore_on_boot_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isMultiTunnelEnabled",
"columnName": "is_multi_tunnel_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "TunnelConfig",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `wg_quick` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "wgQuick",
"columnName": "wg_quick",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_TunnelConfig_name",
"unique": true,
"columnNames": [
"name"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_TunnelConfig_name` ON `${TABLE_NAME}` (`name`)"
}
],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'aee55639422df8dadfe74c3bad204477')"
]
}
}
@@ -1,161 +0,0 @@
{
"formatVersion": 1,
"database": {
"version": 5,
"identityHash": "bc15003a44746e18b9c260ec49737089",
"entities": [
{
"tableName": "Settings",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `is_tunnel_enabled` INTEGER NOT NULL, `is_tunnel_on_mobile_data_enabled` INTEGER NOT NULL, `trusted_network_ssids` TEXT NOT NULL, `default_tunnel` TEXT, `is_always_on_vpn_enabled` INTEGER NOT NULL, `is_tunnel_on_ethernet_enabled` INTEGER NOT NULL, `is_shortcuts_enabled` INTEGER NOT NULL DEFAULT false, `is_battery_saver_enabled` INTEGER NOT NULL DEFAULT false, `is_tunnel_on_wifi_enabled` INTEGER NOT NULL DEFAULT false, `is_kernel_enabled` INTEGER NOT NULL DEFAULT false, `is_restore_on_boot_enabled` INTEGER NOT NULL DEFAULT false, `is_multi_tunnel_enabled` INTEGER NOT NULL DEFAULT false, `is_auto_tunnel_paused` INTEGER NOT NULL DEFAULT false)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isAutoTunnelEnabled",
"columnName": "is_tunnel_enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isTunnelOnMobileDataEnabled",
"columnName": "is_tunnel_on_mobile_data_enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "trustedNetworkSSIDs",
"columnName": "trusted_network_ssids",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "defaultTunnel",
"columnName": "default_tunnel",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "isAlwaysOnVpnEnabled",
"columnName": "is_always_on_vpn_enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isTunnelOnEthernetEnabled",
"columnName": "is_tunnel_on_ethernet_enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isShortcutsEnabled",
"columnName": "is_shortcuts_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isBatterySaverEnabled",
"columnName": "is_battery_saver_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isTunnelOnWifiEnabled",
"columnName": "is_tunnel_on_wifi_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isKernelEnabled",
"columnName": "is_kernel_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isRestoreOnBootEnabled",
"columnName": "is_restore_on_boot_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isMultiTunnelEnabled",
"columnName": "is_multi_tunnel_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isAutoTunnelPaused",
"columnName": "is_auto_tunnel_paused",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "TunnelConfig",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `wg_quick` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "wgQuick",
"columnName": "wg_quick",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_TunnelConfig_name",
"unique": true,
"columnNames": [
"name"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_TunnelConfig_name` ON `${TABLE_NAME}` (`name`)"
}
],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'bc15003a44746e18b9c260ec49737089')"
]
}
}
@@ -1,69 +0,0 @@
package com.zaneschepke.wireguardautotunnel
import androidx.room.testing.MigrationTestHelper
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.zaneschepke.wireguardautotunnel.data.AppDatabase
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import java.io.IOException
@RunWith(AndroidJUnit4::class)
class MigrationTest {
private val dbName = "migration-test"
@get:Rule
val helper: MigrationTestHelper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
AppDatabase::class.java
)
@Test
@Throws(IOException::class)
fun migrate4To5() {
helper.createDatabase(dbName, 4).apply {
// Database has schema version 1. Insert some data using SQL queries.
// You can't use DAO classes because they expect the latest schema.
execSQL(
"INSERT INTO Settings (is_tunnel_enabled," +
"is_tunnel_on_mobile_data_enabled," +
"trusted_network_ssids," +
"default_tunnel," +
"is_always_on_vpn_enabled," +
"is_tunnel_on_ethernet_enabled," +
"is_shortcuts_enabled," +
"is_battery_saver_enabled," +
"is_tunnel_on_wifi_enabled," +
"is_kernel_enabled," +
"is_restore_on_boot_enabled," +
"is_multi_tunnel_enabled)" +
" VALUES " +
"('false'," +
"'false'," +
"'[trustedSSID1,trustedSSID2]'," +
"'defaultTunnel'," +
"'false'," +
"'false'," +
"'false'," +
"'false'," +
"'false'," +
"'false'," +
"'false'," +
"'false')"
)
execSQL(
"INSERT INTO TunnelConfig (name, wg_quick)" +
" VALUES ('hello', 'hello')"
)
// Prepare for the next version.
close()
}
// Re-open the database with version 2 and provide
// MIGRATION_1_2 as the migration process.
helper.runMigrationsAndValidate(dbName, 5, true)
// MigrationTestHelper automatically verifies the schema changes,
// but you need to validate that the data was migrated properly.
}
}
+9 -19
View File
@@ -17,11 +17,8 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.CHANGE_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.ACCESS_BACKGROUND_LOCATION"/>
<!--foreground service exempt android 14--> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SYSTEM_EXEMPTED"/> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_REMOTE_MESSAGING"/>
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE"/>
<!--foreground service permissions--> <!--foreground service permissions-->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.WAKE_LOCK" /> <uses-permission android:name="android.permission.WAKE_LOCK" />
@@ -45,7 +42,6 @@
</queries> </queries>
<application <application
android:allowBackup="true" android:allowBackup="true"
android:enableOnBackInvokedCallback="true"
android:name=".WireGuardAutoTunnel" android:name=".WireGuardAutoTunnel"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules" android:fullBackupContent="@xml/backup_rules"
@@ -55,7 +51,7 @@
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.WireguardAutoTunnel" android:theme="@style/Theme.WireguardAutoTunnel"
tools:targetApi="tiramisu"> tools:targetApi="31">
<activity <activity
android:name=".ui.MainActivity" android:name=".ui.MainActivity"
android:exported="true" android:exported="true"
@@ -84,8 +80,7 @@
<service <service
android:name=".service.foreground.ForegroundService" android:name=".service.foreground.ForegroundService"
android:enabled="true" android:enabled="true"
android:foregroundServiceType="systemExempted|specialUse" android:foregroundServiceType="remoteMessaging"
tools:node="merge"
android:exported="false"> android:exported="false">
</service> </service>
<service <service
@@ -107,8 +102,7 @@
android:permission="android.permission.BIND_VPN_SERVICE" android:permission="android.permission.BIND_VPN_SERVICE"
android:enabled="true" android:enabled="true"
android:persistent="true" android:persistent="true"
android:foregroundServiceType="systemExempted|specialUse" android:foregroundServiceType="remoteMessaging"
tools:node="merge"
android:exported="false"> android:exported="false">
<intent-filter> <intent-filter>
<action android:name="android.net.VpnService"/> <action android:name="android.net.VpnService"/>
@@ -121,18 +115,14 @@
android:enabled="true" android:enabled="true"
android:stopWithTask="false" android:stopWithTask="false"
android:persistent="true" android:persistent="true"
android:foregroundServiceType="systemExempted|specialUse" android:foregroundServiceType="location"
tools:node="merge" android:permission=""
android:exported="false"> android:exported="false">
</service> </service>
<receiver android:enabled="true" android:name=".receiver.BootReceiver" <receiver android:enabled="true" android:name=".receiver.BootReceiver"
android:exported="false"> android:exported="true">
<intent-filter> <intent-filter>
<category android:name="android.intent.category.DEFAULT" /> <action android:name="android.intent.action.BOOT_COMPLETED"/>
<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" />
</intent-filter> </intent-filter>
</receiver> </receiver>
<receiver android:exported="false" android:name=".receiver.NotificationActionReceiver"/> <receiver android:exported="false" android:name=".receiver.NotificationActionReceiver"/>
@@ -1,12 +1,14 @@
package com.zaneschepke.wireguardautotunnel.util package com.zaneschepke.wireguardautotunnel
object Constants { object Constants {
const val MANUAL_TUNNEL_CONFIG_ID = "0" const val MANUAL_TUNNEL_CONFIG_ID = "0"
const val BATTERY_SAVER_WATCHER_WAKE_LOCK_TIMEOUT = 10 * 60 * 1000L // 10 minutes const val WATCHER_SERVICE_WAKE_LOCK_TIMEOUT = 10*60*1000L /*10 minute*/
const val DEFAULT_WATCHER_WAKE_LOCK_TIMEOUT = 30 * 60 * 1000L // 30 minutes const val VPN_CONNECTIVITY_CHECK_INTERVAL = 3000L
const val VPN_STATISTIC_CHECK_INTERVAL = 1000L const val VPN_STATISTIC_CHECK_INTERVAL = 1000L
const val VPN_CONNECTED_NOTIFICATION_DELAY = 3000L const val TOGGLE_TUNNEL_DELAY = 500L
const val TOGGLE_TUNNEL_DELAY = 300L const val FADE_IN_ANIMATION_DURATION = 1000
const val SLIDE_IN_ANIMATION_DURATION = 500
const val SLIDE_IN_TRANSITION_OFFSET = 1000
const val CONF_FILE_EXTENSION = ".conf" const val CONF_FILE_EXTENSION = ".conf"
const val ZIP_FILE_EXTENSION = ".zip" const val ZIP_FILE_EXTENSION = ".zip"
const val URI_CONTENT_SCHEME = "content" const val URI_CONTENT_SCHEME = "content"
@@ -15,8 +17,4 @@ object Constants {
const val GOOGLE_TV_EXPLORER_STUB = "com.google.android.tv.frameworkpackagestubs" const val GOOGLE_TV_EXPLORER_STUB = "com.google.android.tv.frameworkpackagestubs"
const val ANDROID_TV_EXPLORER_STUB = "com.android.tv.frameworkpackagestubs" const val ANDROID_TV_EXPLORER_STUB = "com.android.tv.frameworkpackagestubs"
const val EMAIL_MIME_TYPE = "message/rfc822" const val EMAIL_MIME_TYPE = "message/rfc822"
const val SYSTEM_EXEMPT_SERVICE_TYPE_ID = 1024
const val SUBSCRIPTION_TIMEOUT = 5_000L
const val FOCUS_REQUEST_DELAY = 500L
} }
@@ -0,0 +1,31 @@
package com.zaneschepke.wireguardautotunnel
import android.content.BroadcastReceiver
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import java.math.BigDecimal
import java.text.DecimalFormat
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
fun BroadcastReceiver.goAsync(
context: CoroutineContext = EmptyCoroutineContext,
block: suspend CoroutineScope.() -> Unit
) {
val pendingResult = goAsync()
@OptIn(DelicateCoroutinesApi::class) // Must run globally; there's no teardown callback.
GlobalScope.launch(context) {
try {
block()
} finally {
pendingResult.finish()
}
}
}
fun BigDecimal.toThreeDecimalPlaceString() : String {
val df = DecimalFormat("#.###")
return df.format(this)
}
@@ -1,64 +1,46 @@
package com.zaneschepke.wireguardautotunnel package com.zaneschepke.wireguardautotunnel
import android.app.Application import android.app.Application
import android.content.ComponentName import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.service.quicksettings.TileService
import androidx.lifecycle.ProcessLifecycleOwner import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.zaneschepke.wireguardautotunnel.data.datastore.DataStoreManager import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa
import com.zaneschepke.wireguardautotunnel.data.model.Settings import com.zaneschepke.wireguardautotunnel.repository.model.Settings
import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepository
import com.zaneschepke.wireguardautotunnel.service.tile.TunnelControlTile
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import java.io.IOException
import javax.inject.Inject import javax.inject.Inject
@HiltAndroidApp @HiltAndroidApp
class WireGuardAutoTunnel : Application() { class WireGuardAutoTunnel : Application() {
@Inject
lateinit var settingsRepository: SettingsRepository
@Inject @Inject
lateinit var dataStoreManager: DataStoreManager lateinit var settingsRepo : SettingsDoa
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
instance = this if(BuildConfig.DEBUG) {
if (BuildConfig.DEBUG) Timber.plant(Timber.DebugTree()) Timber.plant(Timber.DebugTree())
initSettings()
with(ProcessLifecycleOwner.get()) {
lifecycleScope.launch {
try {
// load preferences into memory
dataStoreManager.init()
requestTileServiceStateUpdate()
} catch (e: IOException) {
Timber.e("Failed to load preferences")
}
}
} }
initSettings()
} }
private fun initSettings() { private fun initSettings() {
with(ProcessLifecycleOwner.get()) { with(ProcessLifecycleOwner.get()) {
lifecycleScope.launch { lifecycleScope.launch {
if (settingsRepository.getAll().isEmpty()) { if(settingsRepo.getAll().isEmpty()) {
settingsRepository.save(Settings()) settingsRepo.save(Settings())
} }
} }
} }
} }
companion object { companion object {
lateinit var instance: WireGuardAutoTunnel private set fun isRunningOnAndroidTv(context : Context) : Boolean {
fun isRunningOnAndroidTv(): Boolean { return context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK)
return instance.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK)
}
fun requestTileServiceStateUpdate() {
TileService.requestListeningState(instance, ComponentName(instance, TunnelControlTile::class.java))
} }
} }
} }
@@ -1,29 +0,0 @@
package com.zaneschepke.wireguardautotunnel.data
import androidx.room.AutoMigration
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import com.zaneschepke.wireguardautotunnel.data.model.Settings
import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig
@Database(
entities = [Settings::class, TunnelConfig::class],
version = 5,
autoMigrations = [
AutoMigration(from = 1, to = 2), AutoMigration(from = 2, to = 3), AutoMigration(
from = 3,
to = 4
),AutoMigration(
from = 4,
to = 5
)
],
exportSchema = true
)
@TypeConverters(DatabaseListConverters::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun settingDao(): SettingsDao
abstract fun tunnelConfigDoa(): TunnelConfigDao
}
@@ -1,39 +0,0 @@
package com.zaneschepke.wireguardautotunnel.data.datastore
import android.content.Context
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
class DataStoreManager(private val context: Context) {
companion object {
val LOCATION_DISCLOSURE_SHOWN = booleanPreferencesKey("LOCATION_DISCLOSURE_SHOWN")
}
// preferences
private val preferencesKey = "preferences"
private val Context.dataStore by preferencesDataStore(
name = preferencesKey
)
suspend fun init() {
context.dataStore.data.first()
}
suspend fun <T> saveToDataStore(key: Preferences.Key<T>, value: T) =
context.dataStore.edit {
it[key] = value
}
fun <T> getFromStoreFlow(key: Preferences.Key<T>) = context.dataStore.data.map {
it[key]
}
suspend fun <T> getFromStore(key: Preferences.Key<T>) = context.dataStore.data.first { it.contains(key) }[key]
val locationDisclosureFlow: Flow<Boolean?> = context.dataStore.data.map {
it[LOCATION_DISCLOSURE_SHOWN]
}
}
@@ -1,53 +0,0 @@
package com.zaneschepke.wireguardautotunnel.data.model
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity
data class Settings(
@PrimaryKey(autoGenerate = true) val id: Int = 0,
@ColumnInfo(name = "is_tunnel_enabled") var isAutoTunnelEnabled: Boolean = false,
@ColumnInfo(name = "is_tunnel_on_mobile_data_enabled") var isTunnelOnMobileDataEnabled: Boolean = false,
@ColumnInfo(name = "trusted_network_ssids") var trustedNetworkSSIDs: MutableList<String> = mutableListOf(),
@ColumnInfo(name = "default_tunnel") var defaultTunnel: String? = null,
@ColumnInfo(name = "is_always_on_vpn_enabled") var isAlwaysOnVpnEnabled: Boolean = false,
@ColumnInfo(name = "is_tunnel_on_ethernet_enabled") var isTunnelOnEthernetEnabled: Boolean = false,
@ColumnInfo(
name = "is_shortcuts_enabled",
defaultValue = "false"
) var isShortcutsEnabled: Boolean = false,
@ColumnInfo(
name = "is_battery_saver_enabled",
defaultValue = "false"
) var isBatterySaverEnabled: Boolean = false,
@ColumnInfo(
name = "is_tunnel_on_wifi_enabled",
defaultValue = "false"
) var isTunnelOnWifiEnabled: Boolean = false,
@ColumnInfo(
name = "is_kernel_enabled",
defaultValue = "false"
) var isKernelEnabled: Boolean = false,
@ColumnInfo(
name = "is_restore_on_boot_enabled",
defaultValue = "false"
) var isRestoreOnBootEnabled: Boolean = false,
@ColumnInfo(
name = "is_multi_tunnel_enabled",
defaultValue = "false"
) var isMultiTunnelEnabled: Boolean = false,
@ColumnInfo(
name = "is_auto_tunnel_paused",
defaultValue = "false"
) var isAutoTunnelPaused: Boolean = false,
) {
fun isTunnelConfigDefault(tunnelConfig: TunnelConfig): Boolean {
return if (defaultTunnel != null) {
val defaultConfig = TunnelConfig.from(defaultTunnel!!)
(tunnelConfig.id == defaultConfig.id)
} else {
false
}
}
}
@@ -1,12 +0,0 @@
package com.zaneschepke.wireguardautotunnel.data.repository
import com.zaneschepke.wireguardautotunnel.data.model.Settings
import kotlinx.coroutines.flow.Flow
interface SettingsRepository {
suspend fun save(settings : Settings)
fun getSettingsFlow() : Flow<Settings>
suspend fun getSettings() : Settings
suspend fun getAll() : List<Settings>
}
@@ -1,24 +0,0 @@
package com.zaneschepke.wireguardautotunnel.data.repository
import com.zaneschepke.wireguardautotunnel.data.SettingsDao
import com.zaneschepke.wireguardautotunnel.data.model.Settings
import kotlinx.coroutines.flow.Flow
class SettingsRepositoryImpl(private val settingsDoa: SettingsDao) : SettingsRepository {
override suspend fun save(settings: Settings) {
settingsDoa.save(settings)
}
override fun getSettingsFlow(): Flow<Settings> {
return settingsDoa.getSettingsFlow()
}
override suspend fun getSettings(): Settings {
return settingsDoa.getAll().firstOrNull() ?: Settings()
}
override suspend fun getAll(): List<Settings> {
return settingsDoa.getAll()
}
}
@@ -1,14 +0,0 @@
package com.zaneschepke.wireguardautotunnel.data.repository
import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.util.TunnelConfigs
import kotlinx.coroutines.flow.Flow
interface TunnelConfigRepository {
fun getTunnelConfigsFlow() : Flow<TunnelConfigs>
suspend fun getAll() : TunnelConfigs
suspend fun save(tunnelConfig: TunnelConfig)
suspend fun delete(tunnelConfig: TunnelConfig)
suspend fun count() : Int
}
@@ -1,28 +0,0 @@
package com.zaneschepke.wireguardautotunnel.data.repository
import com.zaneschepke.wireguardautotunnel.data.TunnelConfigDao
import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.util.TunnelConfigs
import kotlinx.coroutines.flow.Flow
class TunnelConfigRepositoryImpl(private val tunnelConfigDao: TunnelConfigDao) : TunnelConfigRepository {
override fun getTunnelConfigsFlow(): Flow<TunnelConfigs> {
return tunnelConfigDao.getAllFlow()
}
override suspend fun getAll(): TunnelConfigs {
return tunnelConfigDao.getAll()
}
override suspend fun save(tunnelConfig: TunnelConfig) {
tunnelConfigDao.save(tunnelConfig)
}
override suspend fun delete(tunnelConfig: TunnelConfig) {
tunnelConfigDao.delete(tunnelConfig)
}
override suspend fun count(): Int {
return tunnelConfigDao.count().toInt()
}
}
@@ -3,7 +3,7 @@ package com.zaneschepke.wireguardautotunnel.module
import android.content.Context import android.content.Context
import androidx.room.Room import androidx.room.Room
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.data.AppDatabase import com.zaneschepke.wireguardautotunnel.repository.AppDatabase
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
@@ -16,14 +16,10 @@ import javax.inject.Singleton
class DatabaseModule { class DatabaseModule {
@Provides @Provides
@Singleton @Singleton
fun provideDatabase( fun provideDatabase(@ApplicationContext context : Context) : AppDatabase {
@ApplicationContext context: Context
): AppDatabase {
return Room.databaseBuilder( return Room.databaseBuilder(
context, context,
AppDatabase::class.java, AppDatabase::class.java, context.getString(R.string.db_name))
context.getString(R.string.db_name)
)
.fallbackToDestructiveMigration() .fallbackToDestructiveMigration()
.build() .build()
} }
@@ -1,7 +0,0 @@
package com.zaneschepke.wireguardautotunnel.module
import javax.inject.Qualifier
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class Kernel
@@ -1,51 +1,27 @@
package com.zaneschepke.wireguardautotunnel.module package com.zaneschepke.wireguardautotunnel.module
import android.content.Context import com.zaneschepke.wireguardautotunnel.repository.AppDatabase
import com.zaneschepke.wireguardautotunnel.data.AppDatabase import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa
import com.zaneschepke.wireguardautotunnel.data.SettingsDao import com.zaneschepke.wireguardautotunnel.repository.TunnelConfigDao
import com.zaneschepke.wireguardautotunnel.data.TunnelConfigDao
import com.zaneschepke.wireguardautotunnel.data.datastore.DataStoreManager
import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepository
import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepositoryImpl
import com.zaneschepke.wireguardautotunnel.data.repository.TunnelConfigRepository
import com.zaneschepke.wireguardautotunnel.data.repository.TunnelConfigRepositoryImpl
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton import javax.inject.Singleton
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
class RepositoryModule { class RepositoryModule {
@Singleton @Singleton
@Provides @Provides
fun provideSettingsDoa(appDatabase: AppDatabase): SettingsDao { fun provideSettingsRepository(appDatabase: AppDatabase) : SettingsDoa {
return appDatabase.settingDao() return appDatabase.settingDao()
} }
@Singleton @Singleton
@Provides @Provides
fun provideTunnelConfigDoa(appDatabase: AppDatabase): TunnelConfigDao { fun provideTunnelConfigRepository(appDatabase: AppDatabase) : TunnelConfigDao {
return appDatabase.tunnelConfigDoa() return appDatabase.tunnelConfigDoa()
} }
@Singleton
@Provides
fun provideTunnelConfigRepository(tunnelConfigDao: TunnelConfigDao): TunnelConfigRepository {
return TunnelConfigRepositoryImpl(tunnelConfigDao)
}
@Singleton
@Provides
fun provideSettingsRepository(settingsDao: SettingsDao): SettingsRepository {
return SettingsRepositoryImpl(settingsDao)
}
@Singleton
@Provides
fun providePreferencesDataStore(@ApplicationContext context: Context): DataStoreManager {
return DataStoreManager(context)
}
} }
@@ -15,19 +15,20 @@ import dagger.hilt.android.scopes.ServiceScoped
@Module @Module
@InstallIn(ServiceComponent::class) @InstallIn(ServiceComponent::class)
abstract class ServiceModule { abstract class ServiceModule {
@Binds
@ServiceScoped
abstract fun provideNotificationService(wireGuardNotification: WireGuardNotification): NotificationService
@Binds @Binds
@ServiceScoped @ServiceScoped
abstract fun provideWifiService(wifiService: WifiService): NetworkService<WifiService> abstract fun provideNotificationService(wireGuardNotification: WireGuardNotification) : NotificationService
@Binds @Binds
@ServiceScoped @ServiceScoped
abstract fun provideMobileDataService(mobileDataService: MobileDataService): NetworkService<MobileDataService> abstract fun provideWifiService(wifiService: WifiService) : NetworkService<WifiService>
@Binds @Binds
@ServiceScoped @ServiceScoped
abstract fun provideEthernetService(ethernetService: EthernetService): NetworkService<EthernetService> abstract fun provideMobileDataService(mobileDataService : MobileDataService) : NetworkService<MobileDataService>
@Binds
@ServiceScoped
abstract fun provideEthernetService(ethernetService: EthernetService) : NetworkService<EthernetService>
} }
@@ -3,10 +3,6 @@ package com.zaneschepke.wireguardautotunnel.module
import android.content.Context import android.content.Context
import com.wireguard.android.backend.Backend import com.wireguard.android.backend.Backend
import com.wireguard.android.backend.GoBackend import com.wireguard.android.backend.GoBackend
import com.wireguard.android.backend.WgQuickBackend
import com.wireguard.android.util.RootShell
import com.wireguard.android.util.ToolsInstaller
import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepository
import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService
import com.zaneschepke.wireguardautotunnel.service.tunnel.WireGuardTunnel import com.zaneschepke.wireguardautotunnel.service.tunnel.WireGuardTunnel
import dagger.Module import dagger.Module
@@ -19,40 +15,16 @@ import javax.inject.Singleton
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
class TunnelModule { class TunnelModule {
@Provides
@Singleton
fun provideRootShell(
@ApplicationContext context: Context
): RootShell {
return RootShell(context)
}
@Provides @Provides
@Singleton @Singleton
@Userspace fun provideBackend(@ApplicationContext context : Context) : Backend {
fun provideUserspaceBackend(
@ApplicationContext context: Context
): Backend {
return GoBackend(context) return GoBackend(context)
} }
@Provides @Provides
@Singleton @Singleton
@Kernel fun provideVpnService(backend: Backend) : VpnService {
fun provideKernelBackend( return WireGuardTunnel(backend)
@ApplicationContext context: Context,
rootShell: RootShell
): Backend {
return WgQuickBackend(context, rootShell, ToolsInstaller(context, rootShell))
}
@Provides
@Singleton
fun provideVpnService(
@Userspace userspaceBackend: Backend,
@Kernel kernelBackend: Backend,
settingsRepository : SettingsRepository
): VpnService {
return WireGuardTunnel(userspaceBackend, kernelBackend, settingsRepository)
} }
} }
@@ -1,7 +0,0 @@
package com.zaneschepke.wireguardautotunnel.module
import javax.inject.Qualifier
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class Userspace
@@ -3,23 +3,32 @@ package com.zaneschepke.wireguardautotunnel.receiver
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepository import com.zaneschepke.wireguardautotunnel.goAsync
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import com.zaneschepke.wireguardautotunnel.util.goAsync
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.cancel
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class BootReceiver : BroadcastReceiver() { class BootReceiver : BroadcastReceiver() {
@Inject @Inject
lateinit var settingsRepository: SettingsRepository lateinit var settingsRepo : SettingsDoa
override fun onReceive(context: Context?, intent: Intent?) = goAsync {
if (Intent.ACTION_BOOT_COMPLETED != intent?.action) return@goAsync override fun onReceive(context: Context, intent: Intent) = goAsync {
if(settingsRepository.getSettings().isAutoTunnelEnabled) { if (intent.action == Intent.ACTION_BOOT_COMPLETED) {
ServiceManager.startWatcherServiceForeground(context!!) try {
val settings = settingsRepo.getAll()
if (settings.isNotEmpty()) {
val setting = settings.first()
if (setting.isAutoTunnelEnabled && setting.defaultTunnel != null) {
ServiceManager.startWatcherService(context, setting.defaultTunnel!!)
}
}
} finally {
cancel()
}
} }
} }
} }
@@ -3,10 +3,10 @@ package com.zaneschepke.wireguardautotunnel.receiver
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepository import com.zaneschepke.wireguardautotunnel.Constants
import com.zaneschepke.wireguardautotunnel.goAsync
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.goAsync
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -14,19 +14,19 @@ import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class NotificationActionReceiver : BroadcastReceiver() { class NotificationActionReceiver : BroadcastReceiver() {
@Inject
lateinit var settingsRepository: SettingsRepository
override fun onReceive( @Inject
context: Context, lateinit var settingsRepo : SettingsDoa
intent: Intent? override fun onReceive(context: Context, intent: Intent?) = goAsync {
) = goAsync {
try { try {
val settings = settingsRepository.getSettings() val settings = settingsRepo.getAll()
if (settings.defaultTunnel != null) { if (settings.isNotEmpty()) {
ServiceManager.stopVpnService(context) val setting = settings.first()
delay(Constants.TOGGLE_TUNNEL_DELAY) if (setting.defaultTunnel != null) {
ServiceManager.startVpnServiceForeground(context, settings.defaultTunnel.toString()) ServiceManager.stopVpnService(context)
delay(Constants.TOGGLE_TUNNEL_DELAY)
ServiceManager.startVpnService(context, setting.defaultTunnel.toString())
}
} }
} finally { } finally {
cancel() cancel()
@@ -0,0 +1,17 @@
package com.zaneschepke.wireguardautotunnel.repository
import androidx.room.AutoMigration
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import com.zaneschepke.wireguardautotunnel.repository.model.Settings
import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig
@Database(entities = [Settings::class, TunnelConfig::class], version = 3, autoMigrations = [
AutoMigration(from = 1, to = 2), AutoMigration(from = 2, to = 3)
], exportSchema = true)
@TypeConverters(DatabaseListConverters::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun settingDao(): SettingsDoa
abstract fun tunnelConfigDoa() : TunnelConfigDao
}
@@ -1,4 +1,4 @@
package com.zaneschepke.wireguardautotunnel.data package com.zaneschepke.wireguardautotunnel.repository
import androidx.room.TypeConverter import androidx.room.TypeConverter
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
@@ -9,13 +9,12 @@ class DatabaseListConverters {
fun listToString(value: MutableList<String>): String { fun listToString(value: MutableList<String>): String {
return Json.encodeToString(value) return Json.encodeToString(value)
} }
@TypeConverter @TypeConverter
fun stringToList(value: String): MutableList<String> { fun stringToList(value: String): MutableList<String> {
if (value.isEmpty()) return mutableListOf() if(value.isEmpty()) return mutableListOf()
return try { return try {
Json.decodeFromString<MutableList<String>>(value) Json.decodeFromString<MutableList<String>>(value)
} catch (e: Exception) { } catch (e : Exception) {
val list = value.split(",").toMutableList() val list = value.split(",").toMutableList()
val json = listToString(list) val json = listToString(list)
Json.decodeFromString<MutableList<String>>(json) Json.decodeFromString<MutableList<String>>(json)
@@ -1,15 +1,16 @@
package com.zaneschepke.wireguardautotunnel.data package com.zaneschepke.wireguardautotunnel.repository
import androidx.room.Dao import androidx.room.Dao
import androidx.room.Delete import androidx.room.Delete
import androidx.room.Insert import androidx.room.Insert
import androidx.room.OnConflictStrategy import androidx.room.OnConflictStrategy
import androidx.room.Query import androidx.room.Query
import com.zaneschepke.wireguardautotunnel.data.model.Settings import com.zaneschepke.wireguardautotunnel.repository.model.Settings
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@Dao @Dao
interface SettingsDao { interface SettingsDoa {
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun save(t: Settings) suspend fun save(t: Settings)
@@ -22,9 +23,6 @@ interface SettingsDao {
@Query("SELECT * FROM settings") @Query("SELECT * FROM settings")
suspend fun getAll(): List<Settings> suspend fun getAll(): List<Settings>
@Query("SELECT * FROM settings LIMIT 1")
fun getSettingsFlow(): Flow<Settings>
@Query("SELECT * FROM settings") @Query("SELECT * FROM settings")
fun getAllFlow(): Flow<MutableList<Settings>> fun getAllFlow(): Flow<MutableList<Settings>>
@@ -1,15 +1,16 @@
package com.zaneschepke.wireguardautotunnel.data package com.zaneschepke.wireguardautotunnel.repository
import androidx.room.Dao import androidx.room.Dao
import androidx.room.Delete import androidx.room.Delete
import androidx.room.Insert import androidx.room.Insert
import androidx.room.OnConflictStrategy import androidx.room.OnConflictStrategy
import androidx.room.Query import androidx.room.Query
import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@Dao @Dao
interface TunnelConfigDao { interface TunnelConfigDao{
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun save(t: TunnelConfig) suspend fun save(t: TunnelConfig)
@@ -0,0 +1,28 @@
package com.zaneschepke.wireguardautotunnel.repository.model
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity
data class Settings(
@PrimaryKey(autoGenerate = true) val id : Int = 0,
@ColumnInfo(name = "is_tunnel_enabled") var isAutoTunnelEnabled : Boolean = false,
@ColumnInfo(name = "is_tunnel_on_mobile_data_enabled") var isTunnelOnMobileDataEnabled : Boolean = false,
@ColumnInfo(name = "trusted_network_ssids") var trustedNetworkSSIDs : MutableList<String> = mutableListOf(),
@ColumnInfo(name = "default_tunnel") var defaultTunnel : String? = null,
@ColumnInfo(name = "is_always_on_vpn_enabled") var isAlwaysOnVpnEnabled : Boolean = false,
@ColumnInfo(name = "is_tunnel_on_ethernet_enabled") var isTunnelOnEthernetEnabled : Boolean = false,
@ColumnInfo(name = "is_shortcuts_enabled", defaultValue = "false") var isShortcutsEnabled : Boolean = false,
@ColumnInfo(name = "is_battery_saver_enabled", defaultValue = "false") var isBatterySaverEnabled : Boolean = false,
@ColumnInfo(name = "is_tunnel_on_wifi_enabled", defaultValue = "false") var isTunnelOnWifiEnabled : Boolean = false,
) {
fun isTunnelConfigDefault(tunnelConfig: TunnelConfig) : Boolean {
return if (defaultTunnel != null) {
val defaultConfig = TunnelConfig.from(defaultTunnel!!)
(tunnelConfig.id == defaultConfig.id)
} else {
false
}
}
}
@@ -1,4 +1,4 @@
package com.zaneschepke.wireguardautotunnel.data.model package com.zaneschepke.wireguardautotunnel.repository.model
import androidx.room.ColumnInfo import androidx.room.ColumnInfo
import androidx.room.Entity import androidx.room.Entity
@@ -12,19 +12,20 @@ import java.io.InputStream
@Entity(indices = [Index(value = ["name"], unique = true)]) @Entity(indices = [Index(value = ["name"], unique = true)])
@Serializable @Serializable
data class TunnelConfig( data class TunnelConfig(
@PrimaryKey(autoGenerate = true) val id: Int = 0, @PrimaryKey(autoGenerate = true) val id : Int = 0,
@ColumnInfo(name = "name") var name: String, @ColumnInfo(name = "name") var name : String,
@ColumnInfo(name = "wg_quick") var wgQuick: String @ColumnInfo(name = "wg_quick") var wgQuick : String,
) { ){
override fun toString(): String { override fun toString(): String {
return Json.encodeToString(serializer(), this) return Json.encodeToString(serializer(), this)
} }
companion object { companion object {
fun from(string: String): TunnelConfig {
fun from(string : String) : TunnelConfig {
return Json.decodeFromString<TunnelConfig>(string) return Json.decodeFromString<TunnelConfig>(string)
} }
fun configFromQuick(wgQuick: String): Config { fun configFromQuick(wgQuick: String): Config {
val inputStream: InputStream = wgQuick.byteInputStream() val inputStream: InputStream = wgQuick.byteInputStream()
val reader = inputStream.bufferedReader(Charsets.UTF_8) val reader = inputStream.bufferedReader(Charsets.UTF_8)
@@ -6,7 +6,9 @@ import android.os.IBinder
import androidx.lifecycle.LifecycleService import androidx.lifecycle.LifecycleService
import timber.log.Timber import timber.log.Timber
open class ForegroundService : LifecycleService() { open class ForegroundService : LifecycleService() {
private var isServiceStarted = false private var isServiceStarted = false
override fun onBind(intent: Intent): IBinder? { override fun onBind(intent: Intent): IBinder? {
@@ -15,11 +17,7 @@ open class ForegroundService : LifecycleService() {
return null return null
} }
override fun onStartCommand( override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
intent: Intent?,
flags: Int,
startId: Int
): Int {
super.onStartCommand(intent, flags, startId) super.onStartCommand(intent, flags, startId)
Timber.d("onStartCommand executed with startId: $startId") Timber.d("onStartCommand executed with startId: $startId")
if (intent != null) { if (intent != null) {
@@ -43,18 +41,19 @@ open class ForegroundService : LifecycleService() {
return START_STICKY return START_STICKY
} }
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
Timber.d("The service has been destroyed") Timber.d("The service has been destroyed")
} }
protected open fun startService(extras: Bundle?) { protected open fun startService(extras : Bundle?) {
if (isServiceStarted) return if (isServiceStarted) return
Timber.d("Starting ${this.javaClass.simpleName}") Timber.d("Starting ${this.javaClass.simpleName}")
isServiceStarted = true isServiceStarted = true
} }
protected open fun stopService(extras: Bundle?) { protected open fun stopService(extras : Bundle?) {
Timber.d("Stopping ${this.javaClass.simpleName}") Timber.d("Stopping ${this.javaClass.simpleName}")
try { try {
stopForeground(STOP_FOREGROUND_REMOVE) stopForeground(STOP_FOREGROUND_REMOVE)
@@ -16,60 +16,44 @@ object ServiceManager {
.getRunningServices(Integer.MAX_VALUE) .getRunningServices(Integer.MAX_VALUE)
.any { it.service.className == service.name } .any { it.service.className == service.name }
fun <T : Service> getServiceState( fun <T : Service> getServiceState(context: Context, cls : Class<T>): ServiceState {
context: Context,
cls: Class<T>
): ServiceState {
val isServiceRunning = context.isServiceRunning(cls) val isServiceRunning = context.isServiceRunning(cls)
return if (isServiceRunning) ServiceState.STARTED else ServiceState.STOPPED return if(isServiceRunning) ServiceState.STARTED else ServiceState.STOPPED
} }
private fun <T : Service> actionOnService( private fun <T : Service> actionOnService(action: Action, context: Context, cls : Class<T>, extras : Map<String,String>? = null) {
action: Action,
context: Context,
cls: Class<T>,
extras: Map<String, String>? = null
) {
if (getServiceState(context, cls) == ServiceState.STOPPED && action == Action.STOP) return if (getServiceState(context, cls) == ServiceState.STOPPED && action == Action.STOP) return
if (getServiceState(context, cls) == ServiceState.STARTED && action == Action.START) return if (getServiceState(context, cls) == ServiceState.STARTED && action == Action.START) return
val intent = val intent = Intent(context, cls).also {
Intent(context, cls).also { it.action = action.name
it.action = action.name extras?.forEach {(k, v) ->
extras?.forEach { (k, v) -> it.putExtra(k, v)
it.putExtra(k, v)
}
} }
}
intent.component?.javaClass intent.component?.javaClass
try { try {
when (action) { when(action) {
Action.START_FOREGROUND -> { Action.START_FOREGROUND -> {
context.startForegroundService(intent) context.startForegroundService(intent)
} }
Action.START -> { Action.START -> {
context.startService(intent) context.startService(intent)
} }
Action.STOP -> context.startService(intent) Action.STOP -> context.startService(intent)
} }
} catch (e: Exception) { } catch (e : Exception) {
Timber.e(e.message) Timber.e(e.message)
} }
} }
fun startVpnService( fun startVpnService(context : Context, tunnelConfig : String) {
context: Context,
tunnelConfig: String
) {
actionOnService( actionOnService(
Action.START, Action.START,
context, context,
WireGuardTunnelService::class.java, WireGuardTunnelService::class.java,
mapOf(context.getString(R.string.tunnel_extras_key) to tunnelConfig) mapOf(context.getString(R.string.tunnel_extras_key) to tunnelConfig))
)
} }
fun stopVpnService(context : Context) {
fun stopVpnService(context: Context) {
actionOnService( actionOnService(
Action.STOP, Action.STOP,
context, context,
@@ -77,43 +61,41 @@ object ServiceManager {
) )
} }
fun startVpnServiceForeground( fun startVpnServiceForeground(context : Context, tunnelConfig : String) {
context: Context,
tunnelConfig: String
) {
actionOnService( actionOnService(
Action.START_FOREGROUND, Action.START_FOREGROUND,
context, context,
WireGuardTunnelService::class.java, WireGuardTunnelService::class.java,
mapOf(context.getString(R.string.tunnel_extras_key) to tunnelConfig) mapOf(context.getString(R.string.tunnel_extras_key) to tunnelConfig))
)
} }
fun startWatcherServiceForeground( private fun startWatcherServiceForeground(context : Context, tunnelConfig : String) {
context: Context,
) {
actionOnService( actionOnService(
Action.START_FOREGROUND, Action.START, context,
context, WireGuardConnectivityWatcherService::class.java, mapOf(context.
WireGuardConnectivityWatcherService::class.java getString(R.string.tunnel_extras_key) to
) tunnelConfig))
} }
fun startWatcherService( fun startWatcherService(context : Context, tunnelConfig : String) {
context: Context
) {
actionOnService( actionOnService(
Action.START, Action.START, context,
context, WireGuardConnectivityWatcherService::class.java, mapOf(context.
WireGuardConnectivityWatcherService::class.java getString(R.string.tunnel_extras_key) to
) tunnelConfig))
} }
fun stopWatcherService(context: Context) { fun stopWatcherService(context : Context) {
actionOnService( actionOnService(
Action.STOP, Action.STOP, context,
context, WireGuardConnectivityWatcherService::class.java)
WireGuardConnectivityWatcherService::class.java }
)
fun toggleWatcherServiceForeground(context: Context, tunnelConfig : String) {
when(getServiceState( context,
WireGuardConnectivityWatcherService::class.java,)) {
ServiceState.STARTED -> stopWatcherService(context)
ServiceState.STOPPED -> startWatcherServiceForeground(context, tunnelConfig)
}
} }
} }
@@ -7,12 +7,12 @@ import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.os.PowerManager import android.os.PowerManager
import android.os.SystemClock import android.os.SystemClock
import androidx.core.app.ServiceCompat
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.wireguard.android.backend.Tunnel import com.wireguard.android.backend.Tunnel
import com.zaneschepke.wireguardautotunnel.Constants
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.data.model.Settings import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa
import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepository import com.zaneschepke.wireguardautotunnel.repository.model.Settings
import com.zaneschepke.wireguardautotunnel.service.network.EthernetService import com.zaneschepke.wireguardautotunnel.service.network.EthernetService
import com.zaneschepke.wireguardautotunnel.service.network.MobileDataService import com.zaneschepke.wireguardautotunnel.service.network.MobileDataService
import com.zaneschepke.wireguardautotunnel.service.network.NetworkService import com.zaneschepke.wireguardautotunnel.service.network.NetworkService
@@ -20,13 +20,10 @@ import com.zaneschepke.wireguardautotunnel.service.network.NetworkStatus
import com.zaneschepke.wireguardautotunnel.service.network.WifiService import com.zaneschepke.wireguardautotunnel.service.network.WifiService
import com.zaneschepke.wireguardautotunnel.service.notification.NotificationService import com.zaneschepke.wireguardautotunnel.service.notification.NotificationService
import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService
import com.zaneschepke.wireguardautotunnel.util.Constants
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import timber.log.Timber import timber.log.Timber
@@ -34,334 +31,252 @@ import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class WireGuardConnectivityWatcherService : ForegroundService() { class WireGuardConnectivityWatcherService : ForegroundService() {
private val foregroundId = 122
@Inject lateinit var wifiService: NetworkService<WifiService> private val foregroundId = 122
@Inject lateinit var mobileDataService: NetworkService<MobileDataService> @Inject
lateinit var wifiService: NetworkService<WifiService>
@Inject lateinit var ethernetService: NetworkService<EthernetService> @Inject
lateinit var mobileDataService: NetworkService<MobileDataService>
@Inject lateinit var settingsRepository: SettingsRepository @Inject
lateinit var ethernetService: NetworkService<EthernetService>
@Inject lateinit var notificationService: NotificationService @Inject
lateinit var settingsRepo: SettingsDoa
@Inject lateinit var vpnService: VpnService @Inject
lateinit var notificationService: NotificationService
private val networkEventsFlow = MutableStateFlow(WatcherState()) @Inject
data class WatcherState( lateinit var vpnService: VpnService
val isWifiConnected: Boolean = false,
val isVpnConnected : Boolean = false,
val isEthernetConnected: Boolean = false,
val isMobileDataConnected: Boolean = false,
val currentNetworkSSID: String = "",
val settings: Settings = Settings()
)
private lateinit var watcherJob: Job private var isWifiConnected = false
private var isEthernetConnected = false
private var isMobileDataConnected = false
private var currentNetworkSSID = ""
private var wakeLock: PowerManager.WakeLock? = null private lateinit var watcherJob: Job
private val tag = this.javaClass.name private lateinit var setting: Settings
private lateinit var tunnelConfig: String
override fun onCreate() { private var wakeLock: PowerManager.WakeLock? = null
super.onCreate() private val tag = this.javaClass.name
lifecycleScope.launch(Dispatchers.Main) {
try {
if(settingsRepository.getSettings().isAutoTunnelPaused) { override fun onCreate() {
launchWatcherPausedNotification() super.onCreate()
} else launchWatcherNotification() lifecycleScope.launch(Dispatchers.Main) {
} catch (e: Exception) { launchWatcherNotification()
Timber.e("Failed to start watcher service, not enough permissions") }
}
} }
}
override fun startService(extras: Bundle?) { override fun startService(extras: Bundle?) {
super.startService(extras) super.startService(extras)
try { launchWatcherNotification()
// we need this lock so our service gets not affected by Doze Mode val tunnelId = extras?.getString(getString(R.string.tunnel_extras_key))
lifecycleScope.launch { initWakeLock() } if (tunnelId != null) {
cancelWatcherJob() this.tunnelConfig = tunnelId
startWatcherJob() }
} catch (e: Exception) { // we need this lock so our service gets not affected by Doze Mode
Timber.e("Failed to launch watcher service, no permissions") lifecycleScope.launch {
initWakeLock()
}
cancelWatcherJob()
if (this::tunnelConfig.isInitialized) {
startWatcherJob()
} else {
stopService(extras)
}
} }
}
override fun stopService(extras: Bundle?) { override fun stopService(extras: Bundle?) {
super.stopService(extras) super.stopService(extras)
wakeLock?.let { wakeLock?.let {
if (it.isHeld) { if (it.isHeld) {
it.release() it.release()
} }
}
cancelWatcherJob()
stopSelf()
} }
cancelWatcherJob()
stopSelf()
}
private fun launchWatcherNotification(description: String = getString(R.string.watcher_notification_text_active)) { private fun launchWatcherNotification() {
val notification = val notification = notificationService.createNotification(
notificationService.createNotification(
channelId = getString(R.string.watcher_channel_id), channelId = getString(R.string.watcher_channel_id),
channelName = getString(R.string.watcher_channel_name), channelName = getString(R.string.watcher_channel_name),
title = getString(R.string.auto_tunnel_title), description = getString(R.string.watcher_notification_text),
description = description) vibration = false
ServiceCompat.startForeground( )
this, foregroundId, notification, Constants.SYSTEM_EXEMPT_SERVICE_TYPE_ID) super.startForeground(foregroundId, notification)
}
private fun launchWatcherPausedNotification() {
launchWatcherNotification(getString(R.string.watcher_notification_text_paused))
} }
// TODO could this be restarting service in a bad state? //try to start task again if killed
// try to start task again if killed override fun onTaskRemoved(rootIntent: Intent) {
override fun onTaskRemoved(rootIntent: Intent) { Timber.d("Task Removed called")
Timber.d("Task Removed called") val restartServiceIntent = Intent(rootIntent)
val restartServiceIntent = Intent(rootIntent) val restartServicePendingIntent: PendingIntent = PendingIntent.getService(
val restartServicePendingIntent: PendingIntent = this, 1, restartServiceIntent,
PendingIntent.getService( PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE
this, )
1, applicationContext.getSystemService(Context.ALARM_SERVICE)
restartServiceIntent, val alarmService: AlarmManager =
PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE) applicationContext.getSystemService(Context.ALARM_SERVICE) as AlarmManager
applicationContext.getSystemService(Context.ALARM_SERVICE) alarmService.set(
val alarmService: AlarmManager = AlarmManager.ELAPSED_REALTIME,
applicationContext.getSystemService(Context.ALARM_SERVICE) as AlarmManager SystemClock.elapsedRealtime() + 1000,
alarmService.set( restartServicePendingIntent
AlarmManager.ELAPSED_REALTIME, )
SystemClock.elapsedRealtime() + 1000,
restartServicePendingIntent)
}
private suspend fun initWakeLock() {
val isBatterySaverOn =
withContext(lifecycleScope.coroutineContext) {
settingsRepository.getSettings().isBatterySaverEnabled
}
wakeLock =
(getSystemService(Context.POWER_SERVICE) as PowerManager).run {
newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "$tag::lock").apply {
if (isBatterySaverOn) {
Timber.d("Initiating wakelock with timeout")
acquire(Constants.BATTERY_SAVER_WATCHER_WAKE_LOCK_TIMEOUT)
} else {
Timber.d("Initiating wakelock with zero timeout")
acquire(Constants.DEFAULT_WATCHER_WAKE_LOCK_TIMEOUT)
}
}
}
}
private fun cancelWatcherJob() {
if (this::watcherJob.isInitialized) {
watcherJob.cancel()
} }
}
private fun startWatcherJob() { private suspend fun initWakeLock() {
watcherJob = val isBatterySaverOn = withContext(lifecycleScope.coroutineContext) {
lifecycleScope.launch(Dispatchers.IO) { settingsRepo.getAll().firstOrNull()?.isBatterySaverEnabled ?: false
val setting = settingsRepository.getSettings() }
launch { wakeLock =
Timber.d("Starting wifi watcher") (getSystemService(Context.POWER_SERVICE) as PowerManager).run {
watchForWifiConnectivityChanges() newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "$tag::lock").apply {
} if (isBatterySaverOn) {
if (setting.isTunnelOnMobileDataEnabled) { Timber.d("Initiating wakelock with timeout")
launch { acquire(Constants.WATCHER_SERVICE_WAKE_LOCK_TIMEOUT)
Timber.d("Starting mobile data watcher") } else {
watchForMobileDataConnectivityChanges() Timber.d("Initiating wakelock with zero timeout")
acquire()
}
}
} }
} }
if (setting.isTunnelOnEthernetEnabled) {
launch { private fun cancelWatcherJob() {
Timber.d("Starting ethernet data watcher") if (this::watcherJob.isInitialized) {
watchForEthernetConnectivityChanges() watcherJob.cancel()
} }
} }
launch {
Timber.d("Starting vpn state watcher") private fun startWatcherJob() {
watchForVpnConnectivityChanges() watcherJob = lifecycleScope.launch(Dispatchers.IO) {
val settings = settingsRepo.getAll()
if (settings.isNotEmpty()) {
setting = settings[0]
} }
launch { launch {
Timber.d("Starting settings watcher") watchForWifiConnectivityChanges()
watchForSettingsChanges()
} }
launch { if (setting.isTunnelOnMobileDataEnabled) {
Timber.d("Starting management watcher") launch {
manageVpn() watchForMobileDataConnectivityChanges()
}
}
}
private suspend fun watchForMobileDataConnectivityChanges() {
mobileDataService.networkStatus.collect {
when (it) {
is NetworkStatus.Available -> {
Timber.d("Gained Mobile data connection")
networkEventsFlow.value = networkEventsFlow.value.copy(
isMobileDataConnected = true
)
}
is NetworkStatus.CapabilitiesChanged -> {
networkEventsFlow.value = networkEventsFlow.value.copy(
isMobileDataConnected = true
)
Timber.d("Mobile data capabilities changed")
}
is NetworkStatus.Unavailable -> {
networkEventsFlow.value = networkEventsFlow.value.copy(
isMobileDataConnected = false
)
Timber.d("Lost mobile data connection")
}
}
}
}
private suspend fun watchForSettingsChanges() {
settingsRepository.getSettingsFlow().collect {
if(networkEventsFlow.value.settings.isAutoTunnelPaused != it.isAutoTunnelPaused) {
when(it.isAutoTunnelPaused) {
true -> launchWatcherPausedNotification()
false -> launchWatcherNotification()
} }
} }
networkEventsFlow.value = networkEventsFlow.value.copy( if (setting.isTunnelOnEthernetEnabled) {
settings = it launch {
) watchForEthernetConnectivityChanges()
} }
} }
launch {
private suspend fun watchForVpnConnectivityChanges() { manageVpn()
vpnService.vpnState.collect {
when(it.status) {
Tunnel.State.DOWN -> networkEventsFlow.value = networkEventsFlow.value.copy(
isVpnConnected = false
)
Tunnel.State.UP -> networkEventsFlow.value = networkEventsFlow.value.copy(
isVpnConnected = true
)
else -> {}
} }
} }
} }
private suspend fun watchForEthernetConnectivityChanges() { private suspend fun watchForMobileDataConnectivityChanges() {
ethernetService.networkStatus.collect { mobileDataService.networkStatus.collect {
when (it) { when (it) {
is NetworkStatus.Available -> { is NetworkStatus.Available -> {
Timber.d("Gained Ethernet connection") Timber.d("Gained Mobile data connection")
networkEventsFlow.value = networkEventsFlow.value.copy( isMobileDataConnected = true
isEthernetConnected = true }
)
}
is NetworkStatus.CapabilitiesChanged -> {
Timber.d("Ethernet capabilities changed")
networkEventsFlow.value = networkEventsFlow.value.copy(
isEthernetConnected = true
)
}
is NetworkStatus.Unavailable -> {
networkEventsFlow.value = networkEventsFlow.value.copy(
isEthernetConnected = false
)
Timber.d("Lost Ethernet connection")
}
}
}
}
private suspend fun watchForWifiConnectivityChanges() { is NetworkStatus.CapabilitiesChanged -> {
wifiService.networkStatus.collect { isMobileDataConnected = true
when (it) { Timber.d("Mobile data capabilities changed")
is NetworkStatus.Available -> { }
Timber.d("Gained Wi-Fi connection")
networkEventsFlow.value = networkEventsFlow.value.copy(
isWifiConnected = true
)
}
is NetworkStatus.CapabilitiesChanged -> {
Timber.d("Wifi capabilities changed")
networkEventsFlow.value = networkEventsFlow.value.copy(
isWifiConnected = true
)
val ssid = wifiService.getNetworkName(it.networkCapabilities) ?: ""
Timber.d("Detected SSID: $ssid")
networkEventsFlow.value = networkEventsFlow.value.copy(
currentNetworkSSID = ssid
)
}
is NetworkStatus.Unavailable -> {
networkEventsFlow.value = networkEventsFlow.value.copy(
isWifiConnected = false
)
Timber.d("Lost Wi-Fi connection")
}
}
}
}
//TODO clean this up is NetworkStatus.Unavailable -> {
private suspend fun manageVpn() { isMobileDataConnected = false
networkEventsFlow.collectLatest { Timber.d("Lost mobile data connection")
Timber.i("New watcher state: $it")
if (!it.settings.isAutoTunnelPaused && it.settings.defaultTunnel != null) {
delay(Constants.TOGGLE_TUNNEL_DELAY)
when {
((it.isEthernetConnected &&
it.settings.isTunnelOnEthernetEnabled &&
!it.isVpnConnected)) -> {
ServiceManager.startVpnServiceForeground(this, it.settings.defaultTunnel!!)
Timber.i("Condition 1 met")
}
(!it.isEthernetConnected &&
it.settings.isTunnelOnMobileDataEnabled &&
!it.isWifiConnected &&
it.isMobileDataConnected &&
!it.isVpnConnected) -> {
ServiceManager.startVpnServiceForeground(this, it.settings.defaultTunnel!!)
Timber.i("Condition 2 met")
}
(!it.isEthernetConnected &&
!it.settings.isTunnelOnMobileDataEnabled &&
!it.isWifiConnected &&
it.isVpnConnected) -> {
ServiceManager.stopVpnService(this)
Timber.i("Condition 3 met")
}
(!it.isEthernetConnected &&
it.isWifiConnected &&
!it.settings.trustedNetworkSSIDs.contains(it.currentNetworkSSID) &&
it.settings.isTunnelOnWifiEnabled &&
(!it.isVpnConnected)) -> {
ServiceManager.startVpnServiceForeground(this, it.settings.defaultTunnel!!)
Timber.i("Condition 4 met")
}
(!it.isEthernetConnected &&
(it.isWifiConnected && it.settings.trustedNetworkSSIDs.contains(it.currentNetworkSSID)) &&
(it.isVpnConnected)) -> {
ServiceManager.stopVpnService(this)
Timber.i("Condition 5 met")
}
(!it.isEthernetConnected &&
(it.isWifiConnected &&
!it.settings.isTunnelOnWifiEnabled &&
(it.isVpnConnected))) -> {
ServiceManager.stopVpnService(this)
Timber.i("Condition 6 met")
}
(!it.isEthernetConnected &&
!it.isWifiConnected &&
!it.isMobileDataConnected &&
(it.isVpnConnected)) -> {
ServiceManager.stopVpnService(this)
Timber.i("Condition 7 met")
}
else -> {
Timber.i("No condition met")
} }
} }
} }
} }
}
private suspend fun watchForEthernetConnectivityChanges() {
ethernetService.networkStatus.collect {
when (it) {
is NetworkStatus.Available -> {
Timber.d("Gained Ethernet connection")
isEthernetConnected = true
}
is NetworkStatus.CapabilitiesChanged -> {
Timber.d("Ethernet capabilities changed")
isEthernetConnected = true
}
is NetworkStatus.Unavailable -> {
isEthernetConnected = false
Timber.d("Lost Ethernet connection")
}
}
}
}
private suspend fun watchForWifiConnectivityChanges() {
wifiService.networkStatus.collect {
when (it) {
is NetworkStatus.Available -> {
Timber.d("Gained Wi-Fi connection")
isWifiConnected = true
}
is NetworkStatus.CapabilitiesChanged -> {
Timber.d("Wifi capabilities changed")
isWifiConnected = true
currentNetworkSSID = wifiService.getNetworkName(it.networkCapabilities) ?: ""
}
is NetworkStatus.Unavailable -> {
isWifiConnected = false
Timber.d("Lost Wi-Fi connection")
}
}
}
}
private suspend fun manageVpn() {
while (true) {
if (isEthernetConnected && setting.isTunnelOnEthernetEnabled && vpnService.getState() == Tunnel.State.DOWN) {
ServiceManager.startVpnService(this, tunnelConfig)
}
if (!isEthernetConnected && setting.isTunnelOnMobileDataEnabled &&
!isWifiConnected &&
isMobileDataConnected
&& vpnService.getState() == Tunnel.State.DOWN
) {
ServiceManager.startVpnService(this, tunnelConfig)
} else if (!isEthernetConnected && !setting.isTunnelOnMobileDataEnabled &&
!isWifiConnected &&
vpnService.getState() == Tunnel.State.UP
) {
ServiceManager.stopVpnService(this)
} else if (!isEthernetConnected && isWifiConnected &&
!setting.trustedNetworkSSIDs.contains(currentNetworkSSID) &&
setting.isTunnelOnWifiEnabled &&
(vpnService.getState() != Tunnel.State.UP)
) {
ServiceManager.startVpnService(this, tunnelConfig)
} else if (!isEthernetConnected && (isWifiConnected &&
setting.trustedNetworkSSIDs.contains(currentNetworkSSID)) &&
(vpnService.getState() == Tunnel.State.UP)
) {
ServiceManager.stopVpnService(this)
} else if (!isEthernetConnected && (isWifiConnected &&
!setting.isTunnelOnWifiEnabled &&
(vpnService.getState() == Tunnel.State.UP)
)) {
ServiceManager.stopVpnService(this)
}
delay(Constants.VPN_CONNECTIVITY_CHECK_INTERVAL)
}
}
} }
@@ -3,178 +3,162 @@ package com.zaneschepke.wireguardautotunnel.service.foreground
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import androidx.core.app.ServiceCompat
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepository
import com.zaneschepke.wireguardautotunnel.data.repository.TunnelConfigRepository
import com.zaneschepke.wireguardautotunnel.receiver.NotificationActionReceiver import com.zaneschepke.wireguardautotunnel.receiver.NotificationActionReceiver
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa
import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.service.notification.NotificationService import com.zaneschepke.wireguardautotunnel.service.notification.NotificationService
import com.zaneschepke.wireguardautotunnel.service.tunnel.HandshakeStatus import com.zaneschepke.wireguardautotunnel.service.tunnel.HandshakeStatus
import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.handshakeStatus
import com.zaneschepke.wireguardautotunnel.util.mapPeerStats
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class WireGuardTunnelService : ForegroundService() { class WireGuardTunnelService : ForegroundService() {
private val foregroundId = 123 private val foregroundId = 123
@Inject @Inject
lateinit var vpnService: VpnService lateinit var vpnService : VpnService
@Inject @Inject
lateinit var settingsRepository: SettingsRepository lateinit var settingsRepo: SettingsDoa
@Inject @Inject
lateinit var tunnelConfigRepository: TunnelConfigRepository lateinit var notificationService : NotificationService
@Inject private lateinit var job : Job
lateinit var notificationService: NotificationService
private lateinit var job: Job private var tunnelName : String = ""
private var tunnelName: String = ""
private var didShowConnected = false
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
lifecycleScope.launch(Dispatchers.Main) { lifecycleScope.launch(Dispatchers.Main) {
if(tunnelConfigRepository.getAll().isNotEmpty()) { launchVpnStartingNotification()
launchVpnNotification()
}
} }
} }
override fun startService(extras: Bundle?) { override fun startService(extras : Bundle?) {
super.startService(extras) super.startService(extras)
cancelJob() launchVpnStartingNotification()
val tunnelConfigString = extras?.getString(getString(R.string.tunnel_extras_key)) val tunnelConfigString = extras?.getString(getString(R.string.tunnel_extras_key))
val tunnelConfig = tunnelConfigString?.let { cancelJob()
TunnelConfig.from(it)
}
tunnelName = tunnelConfig?.name ?: ""
job = lifecycleScope.launch(Dispatchers.IO) { job = lifecycleScope.launch(Dispatchers.IO) {
launch { launch {
if (tunnelConfig != null) { if(tunnelConfigString != null) {
try { try {
val tunnelConfig = TunnelConfig.from(tunnelConfigString)
tunnelName = tunnelConfig.name
vpnService.startTunnel(tunnelConfig)
} catch (e : Exception) {
Timber.e("Problem starting tunnel: ${e.message}")
stopService(extras)
}
} else {
Timber.d("Tunnel config null, starting default tunnel")
val settings = settingsRepo.getAll()
if(settings.isNotEmpty()) {
val setting = settings[0]
if(setting.defaultTunnel != null && setting.isAlwaysOnVpnEnabled) {
val tunnelConfig = TunnelConfig.from(setting.defaultTunnel!!)
tunnelName = tunnelConfig.name tunnelName = tunnelConfig.name
vpnService.startTunnel(tunnelConfig) vpnService.startTunnel(tunnelConfig)
} catch (e: Exception) {
Timber.e("Problem starting tunnel: ${e.message}")
stopService(extras)
} }
} else {
Timber.d("Tunnel config null, starting default tunnel or first")
val settings = settingsRepository.getSettings()
val tunnels = tunnelConfigRepository.getAll()
if (settings.isAlwaysOnVpnEnabled) {
val tunnel = if(settings.defaultTunnel != null) {
TunnelConfig.from(settings.defaultTunnel!!)
} else if(tunnels.isNotEmpty()) {
tunnels.first()
} else {
null
}
if(tunnel != null) {
tunnelName = tunnel.name
vpnService.startTunnel(tunnel)
}
}
}
}
//TODO add failed to connect notification
launch {
vpnService.vpnState.collect { state ->
state.statistics
?.mapPeerStats()
?.map { it.value?.handshakeStatus() }
.let { statuses ->
when {
statuses?.all { it == HandshakeStatus.HEALTHY } == true -> {
if(!didShowConnected){
delay(Constants.VPN_CONNECTED_NOTIFICATION_DELAY)
launchVpnNotification(getString(R.string.tunnel_start_title),"${getString(R.string.tunnel_start_text)} $tunnelName")
didShowConnected = true
}
}
statuses?.any { it == HandshakeStatus.STALE } == true -> {}
statuses?.all { it == HandshakeStatus.NOT_STARTED } == true -> {}
else -> {}
}
}
} }
} }
} }
launch {
var didShowConnected = false
var didShowFailedHandshakeNotification = false
vpnService.handshakeStatus.collect {
when(it) {
HandshakeStatus.NOT_STARTED -> {
}
HandshakeStatus.NEVER_CONNECTED -> {
if(!didShowFailedHandshakeNotification) {
launchVpnConnectionFailedNotification(getString(R.string.initial_connection_failure_message))
didShowFailedHandshakeNotification = true
didShowConnected = false
}
}
HandshakeStatus.HEALTHY -> {
if(!didShowConnected) {
launchVpnConnectedNotification()
didShowConnected = true
}
}
HandshakeStatus.UNHEALTHY -> {
if(!didShowFailedHandshakeNotification) {
launchVpnConnectionFailedNotification(getString(R.string.lost_connection_failure_message))
didShowFailedHandshakeNotification = true
didShowConnected = false
}
}
}
}
}
}
} }
override fun stopService(extras: Bundle?) { override fun stopService(extras : Bundle?) {
super.stopService(extras) super.stopService(extras)
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
vpnService.stopTunnel() vpnService.stopTunnel()
didShowConnected = false
} }
cancelJob() cancelJob()
stopSelf() stopSelf()
} }
private fun launchVpnNotification(title : String = getString(R.string.vpn_starting),description : String = getString(R.string.attempt_connection)) { private fun launchVpnConnectedNotification() {
val notification = val notification = notificationService.createNotification(
notificationService.createNotification( channelId = getString(R.string.vpn_channel_id),
channelId = getString(R.string.vpn_channel_id), channelName = getString(R.string.vpn_channel_name),
channelName = getString(R.string.vpn_channel_name), title = getString(R.string.tunnel_start_title),
title = title, onGoing = false,
onGoing = false, vibration = false,
vibration = false, showTimestamp = true,
showTimestamp = true, description = "${getString(R.string.tunnel_start_text)} $tunnelName"
description = description
)
ServiceCompat.startForeground(
this,
foregroundId,
notification,
Constants.SYSTEM_EXEMPT_SERVICE_TYPE_ID
) )
super.startForeground(foregroundId, notification)
} }
private fun launchVpnConnectionFailedNotification(message: String) { private fun launchVpnStartingNotification() {
val notification = val notification = notificationService.createNotification(
notificationService.createNotification( channelId = getString(R.string.vpn_channel_id),
channelId = getString(R.string.vpn_channel_id), channelName = getString(R.string.vpn_channel_name),
channelName = getString(R.string.vpn_channel_name), title = getString(R.string.vpn_starting),
action = onGoing = false,
PendingIntent.getBroadcast( vibration = false,
this, showTimestamp = true,
0, description = getString(R.string.attempt_connection)
Intent(this, NotificationActionReceiver::class.java),
PendingIntent.FLAG_IMMUTABLE
),
actionText = getString(R.string.restart),
title = getString(R.string.vpn_connection_failed),
onGoing = false,
vibration = true,
showTimestamp = true,
description = message
)
ServiceCompat.startForeground(
this,
foregroundId,
notification,
Constants.SYSTEM_EXEMPT_SERVICE_TYPE_ID
) )
super.startForeground(foregroundId, notification)
} }
private fun launchVpnConnectionFailedNotification(message : String) {
val notification = notificationService.createNotification(
channelId = getString(R.string.vpn_channel_id),
channelName = getString(R.string.vpn_channel_name),
action = PendingIntent.getBroadcast(this,0,
Intent(this, NotificationActionReceiver::class.java),PendingIntent.FLAG_IMMUTABLE),
actionText = getString(R.string.restart),
title = getString(R.string.vpn_connection_failed),
onGoing = false,
vibration = true,
showTimestamp = true,
description = message
)
super.startForeground(foregroundId, notification)
}
private fun cancelJob() { private fun cancelJob() {
if (this::job.isInitialized) { if(this::job.isInitialized) {
job.cancel() job.cancel()
} }
} }
@@ -14,82 +14,69 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
abstract class BaseNetworkService<T : BaseNetworkService<T>>(
val context: Context, abstract class BaseNetworkService<T : BaseNetworkService<T>>(val context: Context, networkCapability : Int) : NetworkService<T> {
networkCapability: Int
) : NetworkService<T> {
private val connectivityManager = private val connectivityManager =
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
private val wifiManager = private val wifiManager =
context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
override val networkStatus = override val networkStatus = callbackFlow {
callbackFlow { val networkStatusCallback = when (Build.VERSION.SDK_INT) {
val networkStatusCallback = in Build.VERSION_CODES.S..Int.MAX_VALUE -> {
when (Build.VERSION.SDK_INT) { object : ConnectivityManager.NetworkCallback(
in Build.VERSION_CODES.S..Int.MAX_VALUE -> { FLAG_INCLUDE_LOCATION_INFO
object : ConnectivityManager.NetworkCallback( ) {
FLAG_INCLUDE_LOCATION_INFO override fun onAvailable(network: Network) {
) { trySend(NetworkStatus.Available(network))
override fun onAvailable(network: Network) {
trySend(NetworkStatus.Available(network))
}
override fun onLost(network: Network) {
trySend(NetworkStatus.Unavailable(network))
}
override fun onCapabilitiesChanged(
network: Network,
networkCapabilities: NetworkCapabilities
) {
trySend(
NetworkStatus.CapabilitiesChanged(
network,
networkCapabilities
)
)
}
}
} }
else -> { override fun onLost(network: Network) {
object : ConnectivityManager.NetworkCallback() { trySend(NetworkStatus.Unavailable(network))
override fun onAvailable(network: Network) { }
trySend(NetworkStatus.Available(network))
}
override fun onLost(network: Network) { override fun onCapabilitiesChanged(
trySend(NetworkStatus.Unavailable(network)) network: Network,
} networkCapabilities: NetworkCapabilities
) {
override fun onCapabilitiesChanged( trySend(NetworkStatus.CapabilitiesChanged(network, networkCapabilities))
network: Network,
networkCapabilities: NetworkCapabilities
) {
trySend(
NetworkStatus.CapabilitiesChanged(
network,
networkCapabilities
)
)
}
}
} }
} }
val request = }
NetworkRequest.Builder()
.addTransportType(networkCapability)
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.addCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
.build()
connectivityManager.registerNetworkCallback(request, networkStatusCallback)
awaitClose { else -> {
connectivityManager.unregisterNetworkCallback(networkStatusCallback) object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
trySend(NetworkStatus.Available(network))
}
override fun onLost(network: Network) {
trySend(NetworkStatus.Unavailable(network))
}
override fun onCapabilitiesChanged(
network: Network,
networkCapabilities: NetworkCapabilities
) {
trySend(NetworkStatus.CapabilitiesChanged(network, networkCapabilities))
}
}
} }
} }
val request = NetworkRequest.Builder()
.addTransportType(networkCapability)
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.addCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
.build()
connectivityManager.registerNetworkCallback(request, networkStatusCallback)
awaitClose {
connectivityManager.unregisterNetworkCallback(networkStatusCallback)
}
}
override fun getNetworkName(networkCapabilities: NetworkCapabilities): String? { override fun getNetworkName(networkCapabilities: NetworkCapabilities): String? {
var ssid: String? = getWifiNameFromCapabilities(networkCapabilities) var ssid: String? = getWifiNameFromCapabilities(networkCapabilities)
@@ -102,6 +89,7 @@ abstract class BaseNetworkService<T : BaseNetworkService<T>>(
return ssid?.trim('"') return ssid?.trim('"')
} }
companion object { companion object {
private fun getWifiNameFromCapabilities(networkCapabilities: NetworkCapabilities): String? { private fun getWifiNameFromCapabilities(networkCapabilities: NetworkCapabilities): String? {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
@@ -117,20 +105,13 @@ abstract class BaseNetworkService<T : BaseNetworkService<T>>(
} }
inline fun <Result> Flow<NetworkStatus>.map( inline fun <Result> Flow<NetworkStatus>.map(
crossinline onUnavailable: suspend (network: Network) -> Result, crossinline onUnavailable: suspend (network : Network) -> Result,
crossinline onAvailable: suspend (network: Network) -> Result, crossinline onAvailable: suspend (network : Network) -> Result,
crossinline onCapabilitiesChanged: suspend ( crossinline onCapabilitiesChanged: suspend (network : Network, networkCapabilities : NetworkCapabilities) -> Result,
network: Network, ): Flow<Result> = map { status ->
networkCapabilities: NetworkCapabilities when (status) {
) -> Result is NetworkStatus.Unavailable -> onUnavailable(status.network)
): Flow<Result> = is NetworkStatus.Available -> onAvailable(status.network)
map { status -> is NetworkStatus.CapabilitiesChanged -> onCapabilitiesChanged(status.network, status.networkCapabilities)
when (status) {
is NetworkStatus.Unavailable -> onUnavailable(status.network)
is NetworkStatus.Available -> onAvailable(status.network)
is NetworkStatus.CapabilitiesChanged -> onCapabilitiesChanged(
status.network,
status.networkCapabilities
)
}
} }
}
@@ -5,9 +5,6 @@ import android.net.NetworkCapabilities
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject import javax.inject.Inject
class EthernetService class EthernetService @Inject constructor(@ApplicationContext context: Context) :
@Inject BaseNetworkService<EthernetService>(context, NetworkCapabilities.TRANSPORT_ETHERNET) {
constructor( }
@ApplicationContext context: Context
) :
BaseNetworkService<EthernetService>(context, NetworkCapabilities.TRANSPORT_ETHERNET)
@@ -5,9 +5,6 @@ import android.net.NetworkCapabilities
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject import javax.inject.Inject
class MobileDataService class MobileDataService @Inject constructor(@ApplicationContext context: Context) :
@Inject BaseNetworkService<MobileDataService>(context, NetworkCapabilities.TRANSPORT_CELLULAR) {
constructor( }
@ApplicationContext context: Context
) :
BaseNetworkService<MobileDataService>(context, NetworkCapabilities.TRANSPORT_CELLULAR)
@@ -5,6 +5,5 @@ import kotlinx.coroutines.flow.Flow
interface NetworkService<T> { interface NetworkService<T> {
fun getNetworkName(networkCapabilities: NetworkCapabilities): String? fun getNetworkName(networkCapabilities: NetworkCapabilities): String?
val networkStatus: Flow<NetworkStatus> val networkStatus: Flow<NetworkStatus>
} }
@@ -4,10 +4,7 @@ import android.net.Network
import android.net.NetworkCapabilities import android.net.NetworkCapabilities
sealed class NetworkStatus { sealed class NetworkStatus {
class Available(val network: Network) : NetworkStatus() class Available(val network : Network) : NetworkStatus()
class Unavailable(val network : Network) : NetworkStatus()
class Unavailable(val network: Network) : NetworkStatus() class CapabilitiesChanged(val network : Network, val networkCapabilities : NetworkCapabilities) : NetworkStatus()
class CapabilitiesChanged(val network: Network, val networkCapabilities: NetworkCapabilities) :
NetworkStatus()
} }
@@ -5,9 +5,6 @@ import android.net.NetworkCapabilities
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject import javax.inject.Inject
class WifiService class WifiService @Inject constructor(@ApplicationContext context: Context) :
@Inject BaseNetworkService<WifiService>(context, NetworkCapabilities.TRANSPORT_WIFI) {
constructor( }
@ApplicationContext context: Context
) :
BaseNetworkService<WifiService>(context, NetworkCapabilities.TRANSPORT_WIFI)
@@ -3,7 +3,6 @@ package com.zaneschepke.wireguardautotunnel.service.notification
import android.app.Notification import android.app.Notification
import android.app.NotificationManager import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
import androidx.core.app.NotificationCompat
interface NotificationService { interface NotificationService {
fun createNotification( fun createNotification(
@@ -13,11 +12,10 @@ interface NotificationService {
action: PendingIntent? = null, action: PendingIntent? = null,
actionText: String? = null, actionText: String? = null,
description: String, description: String,
showTimestamp: Boolean = false, showTimestamp : Boolean = false,
importance: Int = NotificationManager.IMPORTANCE_HIGH, importance: Int = NotificationManager.IMPORTANCE_HIGH,
vibration: Boolean = false, vibration: Boolean = false,
onGoing: Boolean = true, onGoing: Boolean = true,
lights: Boolean = true, lights: Boolean = true
onlyAlertOnce: Boolean = true,
): Notification ): Notification
} }
@@ -7,29 +7,14 @@ import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.Color import android.graphics.Color
import androidx.core.app.NotificationCompat
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.MainActivity import com.zaneschepke.wireguardautotunnel.ui.MainActivity
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject import javax.inject.Inject
class WireGuardNotification class WireGuardNotification @Inject constructor(@ApplicationContext private val context: Context) : NotificationService {
@Inject
constructor(
@ApplicationContext private val context: Context
) : NotificationService {
private val notificationManager =
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
private val watcherBuilder: NotificationCompat.Builder = private val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
NotificationCompat.Builder(
context,
context.getString(R.string.watcher_channel_id)
)
private val tunnelBuilder: NotificationCompat.Builder = NotificationCompat.Builder(
context,
context.getString(R.string.vpn_channel_id)
)
override fun createNotification( override fun createNotification(
channelId: String, channelId: String,
@@ -42,22 +27,20 @@ constructor(
importance: Int, importance: Int,
vibration: Boolean, vibration: Boolean,
onGoing: Boolean, onGoing: Boolean,
lights: Boolean, lights: Boolean
onlyAlertOnce: Boolean,
): Notification { ): Notification {
val channel = val channel = NotificationChannel(
NotificationChannel( channelId,
channelId, channelName,
channelName, importance
importance ).let {
).let { it.description = title
it.description = title it.enableLights(lights)
it.enableLights(lights) it.lightColor = Color.RED
it.lightColor = Color.RED it.enableVibration(vibration)
it.enableVibration(vibration) it.vibrationPattern = longArrayOf(100, 200, 300, 400, 500, 400, 300, 200, 400)
it.vibrationPattern = longArrayOf(100,200,300) it
it }
}
notificationManager.createNotificationChannel(channel) notificationManager.createNotificationChannel(channel)
val pendingIntent: PendingIntent = val pendingIntent: PendingIntent =
Intent(context, MainActivity::class.java).let { notificationIntent -> Intent(context, MainActivity::class.java).let { notificationIntent ->
@@ -69,31 +52,23 @@ constructor(
) )
} }
val builder = when(channelId) { val builder: Notification.Builder =
context.getString(R.string.watcher_channel_id) -> watcherBuilder Notification.Builder(
context.getString(R.string.vpn_channel_id) -> tunnelBuilder context,
else -> { channelId
NotificationCompat.Builder( )
context,
channelId
)
}
}
return builder.let { return builder.let {
if (action != null && actionText != null) { if(action != null && actionText != null) {
//TODO find a not deprecated way to do this
it.addAction( it.addAction(
NotificationCompat.Action.Builder(0, actionText, action) Notification.Action.Builder(0, actionText, action)
.build() .build())
) it.setAutoCancel(true)
it.setAutoCancel(true)
} }
it.setContentTitle(title) it.setContentTitle(title)
.setContentText(description) .setContentText(description)
.setOnlyAlertOnce(onlyAlertOnce)
.setContentIntent(pendingIntent) .setContentIntent(pendingIntent)
.setOngoing(onGoing) .setOngoing(onGoing)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setShowWhen(showTimestamp) .setShowWhen(showTimestamp)
.setSmallIcon(R.mipmap.ic_launcher_foreground) .setSmallIcon(R.mipmap.ic_launcher_foreground)
.build() .build()
@@ -1,75 +1,66 @@
package com.zaneschepke.wireguardautotunnel.service.shortcut package com.zaneschepke.wireguardautotunnel.service.shortcut
import android.os.Bundle import android.os.Bundle
import android.view.View
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa
import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepository import com.zaneschepke.wireguardautotunnel.repository.TunnelConfigDao
import com.zaneschepke.wireguardautotunnel.data.repository.TunnelConfigRepository import com.zaneschepke.wireguardautotunnel.repository.model.Settings
import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.service.foreground.Action import com.zaneschepke.wireguardautotunnel.service.foreground.Action
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import com.zaneschepke.wireguardautotunnel.service.foreground.WireGuardTunnelService import com.zaneschepke.wireguardautotunnel.service.foreground.WireGuardTunnelService
import com.zaneschepke.wireguardautotunnel.util.WgTunnelException
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class ShortcutsActivity : ComponentActivity() { class ShortcutsActivity : ComponentActivity() {
@Inject
lateinit var settingsRepository: SettingsRepository
@Inject @Inject
lateinit var tunnelConfigRepository: TunnelConfigRepository lateinit var settingsRepo : SettingsDoa
private suspend fun toggleWatcherServicePause() { @Inject
val settings = settingsRepository.getSettings() lateinit var tunnelConfigRepo : TunnelConfigDao
if (settings.isAutoTunnelEnabled) {
val pauseAutoTunnel = !settings.isAutoTunnelPaused private fun attemptWatcherServiceToggle(tunnelConfig : String) {
settingsRepository.save(settings.copy( lifecycleScope.launch(Dispatchers.Main) {
isAutoTunnelPaused = pauseAutoTunnel val settings = getSettings()
)) if(settings.isAutoTunnelEnabled) {
ServiceManager.toggleWatcherServiceForeground(this@ShortcutsActivity, tunnelConfig)
} }
} }
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(View(this)) if(intent.getStringExtra(CLASS_NAME_EXTRA_KEY)
if (intent.getStringExtra(CLASS_NAME_EXTRA_KEY) .equals(WireGuardTunnelService::class.java.simpleName)) {
.equals(WireGuardTunnelService::class.java.simpleName)
) {
lifecycleScope.launch(Dispatchers.Main) { lifecycleScope.launch(Dispatchers.Main) {
val settings = settingsRepository.getSettings() val settings = getSettings()
if (settings.isShortcutsEnabled) { if(settings.isShortcutsEnabled) {
try { try {
val tunnelName = intent.getStringExtra(TUNNEL_NAME_EXTRA_KEY) val tunnelName = intent.getStringExtra(TUNNEL_NAME_EXTRA_KEY)
val tunnelConfig = val tunnelConfig = if(tunnelName != null) {
if (tunnelName != null) { tunnelConfigRepo.getAll().firstOrNull { it.name == tunnelName }
tunnelConfigRepository.getAll().firstOrNull { it.name == tunnelName } } else {
if(settings.defaultTunnel == null) {
tunnelConfigRepo.getAll().first()
} else { } else {
if (settings.defaultTunnel == null) { TunnelConfig.from(settings.defaultTunnel!!)
tunnelConfigRepository.getAll().first()
} else {
TunnelConfig.from(settings.defaultTunnel!!)
}
} }
tunnelConfig ?: return@launch
toggleWatcherServicePause()
when (intent.action) {
Action.STOP.name -> ServiceManager.stopVpnService(
this@ShortcutsActivity
)
Action.START.name -> ServiceManager.startVpnServiceForeground(
this@ShortcutsActivity,
tunnelConfig.toString()
)
} }
} catch (e: Exception) { tunnelConfig ?: return@launch
attemptWatcherServiceToggle(tunnelConfig.toString())
when(intent.action){
Action.STOP.name -> ServiceManager.stopVpnService(this@ShortcutsActivity)
Action.START.name -> ServiceManager.startVpnService(this@ShortcutsActivity, tunnelConfig.toString())
}
} catch (e : Exception) {
Timber.e(e.message) Timber.e(e.message)
finish()
} }
} }
} }
@@ -77,6 +68,14 @@ class ShortcutsActivity : ComponentActivity() {
finish() finish()
} }
private suspend fun getSettings() : Settings {
val settings = settingsRepo.getAll()
return if (settings.isNotEmpty()) {
settings.first()
} else {
throw WgTunnelException("Settings empty")
}
}
companion object { companion object {
const val TUNNEL_NAME_EXTRA_KEY = "tunnelName" const val TUNNEL_NAME_EXTRA_KEY = "tunnelName"
const val CLASS_NAME_EXTRA_KEY = "className" const val CLASS_NAME_EXTRA_KEY = "className"
@@ -4,67 +4,51 @@ import android.os.Build
import android.service.quicksettings.Tile import android.service.quicksettings.Tile
import android.service.quicksettings.TileService import android.service.quicksettings.TileService
import com.wireguard.android.backend.Tunnel import com.wireguard.android.backend.Tunnel
import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepository import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa
import com.zaneschepke.wireguardautotunnel.data.repository.TunnelConfigRepository import com.zaneschepke.wireguardautotunnel.repository.TunnelConfigDao
import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class TunnelControlTile() : TileService() { class TunnelControlTile : TileService() {
@Inject @Inject
lateinit var tunnelConfigRepository: TunnelConfigRepository lateinit var settingsRepo : SettingsDoa
@Inject @Inject
lateinit var settingsRepository: SettingsRepository lateinit var configRepo : TunnelConfigDao
@Inject @Inject
lateinit var vpnService: VpnService lateinit var vpnService : VpnService
private val scope = CoroutineScope(Dispatchers.IO) private val scope = CoroutineScope(Dispatchers.Main)
private var tunnelName : String? = null private lateinit var job : Job
override fun onStartListening() { override fun onStartListening() {
super.onStartListening() job = scope.launch {
Timber.d("On start listening called") updateTileState()
scope.launch {
vpnService.vpnState.collect {
when(it.status) {
Tunnel.State.UP -> setActive()
Tunnel.State.DOWN -> setInactive()
else -> setInactive()
}
val tunnels = tunnelConfigRepository.getAll()
if(tunnels.isEmpty()) {
setUnavailable()
return@collect
}
tunnelName = it.name.ifBlank {
val settings = settingsRepository.getSettings()
if (settings.defaultTunnel != null) {
TunnelConfig.from(settings.defaultTunnel!!).name
} else tunnels.firstOrNull()?.name
}
setTileDescription(tunnelName ?: "")
}
} }
} super.onStartListening()
override fun onDestroy() {
super.onDestroy()
scope.cancel()
} }
override fun onTileRemoved() { override fun onTileRemoved() {
super.onTileRemoved() super.onTileRemoved()
cancelJob()
}
override fun onDestroy() {
super.onDestroy()
scope.cancel() scope.cancel()
} }
@@ -73,17 +57,16 @@ class TunnelControlTile() : TileService() {
unlockAndRun { unlockAndRun {
scope.launch { scope.launch {
try { try {
val tunnelConfig = tunnelConfigRepository.getAll().first { it.name == tunnelName } val tunnel = determineTileTunnel()
toggleWatcherServicePause() if(tunnel != null) {
if (vpnService.getState() == Tunnel.State.UP) { attemptWatcherServiceToggle(tunnel.toString())
ServiceManager.stopVpnService(this@TunnelControlTile) if(vpnService.getState() == Tunnel.State.UP) {
} else { ServiceManager.stopVpnService(this@TunnelControlTile)
ServiceManager.startVpnServiceForeground( } else {
this@TunnelControlTile, ServiceManager.startVpnServiceForeground(this@TunnelControlTile, tunnel.toString())
tunnelConfig.toString() }
)
} }
} catch (e: Exception) { } catch (e : Exception) {
Timber.e(e.message) Timber.e(e.message)
} finally { } finally {
cancel() cancel()
@@ -92,40 +75,74 @@ class TunnelControlTile() : TileService() {
} }
} }
private fun toggleWatcherServicePause() { private suspend fun determineTileTunnel() : TunnelConfig? {
var tunnelConfig : TunnelConfig? = null
val settings = settingsRepo.getAll()
if (settings.isNotEmpty()) {
val setting = settings.first()
tunnelConfig = if (setting.defaultTunnel != null) {
TunnelConfig.from(setting.defaultTunnel!!)
} else {
val configs = configRepo.getAll()
val config = if(configs.isNotEmpty()) {
configs.first()
} else {
null
}
config
}
}
return tunnelConfig
}
private fun attemptWatcherServiceToggle(tunnelConfig : String) {
scope.launch { scope.launch {
val settings = settingsRepository.getSettings() val settings = settingsRepo.getAll()
if (settings.isAutoTunnelEnabled) { if (settings.isNotEmpty()) {
val pauseAutoTunnel = !settings.isAutoTunnelPaused val setting = settings.first()
settingsRepository.save(settings.copy( if(setting.isAutoTunnelEnabled) {
isAutoTunnelPaused = pauseAutoTunnel ServiceManager.toggleWatcherServiceForeground(this@TunnelControlTile, tunnelConfig)
)) }
} }
} }
} }
private fun setActive() { private suspend fun updateTileState() {
qsTile.state = Tile.STATE_ACTIVE vpnService.state.collect {
qsTile.updateTile() try {
when(it) {
Tunnel.State.UP -> {
qsTile.state = Tile.STATE_ACTIVE
}
Tunnel.State.DOWN -> {
qsTile.state = Tile.STATE_INACTIVE
}
else -> {
qsTile.state = Tile.STATE_UNAVAILABLE
}
}
val config = determineTileTunnel()
setTileDescription(config?.name ?: this.resources.getString(R.string.no_tunnel_available))
qsTile.updateTile()
} catch (e : Exception) {
Timber.e("Unable to update tile state")
}
}
} }
private fun setInactive() { private fun setTileDescription(description : String) {
qsTile.state = Tile.STATE_INACTIVE
qsTile.updateTile()
}
private fun setUnavailable() {
qsTile.state = Tile.STATE_UNAVAILABLE
qsTile.updateTile()
}
private fun setTileDescription(description: String) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
qsTile.subtitle = description qsTile.subtitle = description
} }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
qsTile.stateDescription = description qsTile.stateDescription = description
} }
qsTile.updateTile() }
private fun cancelJob() {
if(this::job.isInitialized) {
job.cancel()
}
} }
} }
@@ -2,15 +2,13 @@ package com.zaneschepke.wireguardautotunnel.service.tunnel
enum class HandshakeStatus { enum class HandshakeStatus {
HEALTHY, HEALTHY,
STALE, UNHEALTHY,
UNKNOWN, NEVER_CONNECTED,
NOT_STARTED NOT_STARTED;
;
companion object { companion object {
private const val WG_TYPICAL_HANDSHAKE_INTERVAL_WHEN_HEALTHY_SEC = 180 private const val WG_TYPICAL_HANDSHAKE_INTERVAL_WHEN_HEALTHY_SEC = 120
const val STATUS_CHANGE_TIME_BUFFER = 30 const val UNHEALTHY_TIME_LIMIT_SEC = WG_TYPICAL_HANDSHAKE_INTERVAL_WHEN_HEALTHY_SEC + 60
const val STALE_TIME_LIMIT_SEC = WG_TYPICAL_HANDSHAKE_INTERVAL_WHEN_HEALTHY_SEC + STATUS_CHANGE_TIME_BUFFER
const val NEVER_CONNECTED_TO_UNHEALTHY_TIME_LIMIT_SEC = 30 const val NEVER_CONNECTED_TO_UNHEALTHY_TIME_LIMIT_SEC = 30
} }
} }
@@ -1,15 +1,18 @@
package com.zaneschepke.wireguardautotunnel.service.tunnel package com.zaneschepke.wireguardautotunnel.service.tunnel
import com.wireguard.android.backend.Statistics
import com.wireguard.android.backend.Tunnel import com.wireguard.android.backend.Tunnel
import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig import com.wireguard.crypto.Key
import kotlinx.coroutines.flow.StateFlow import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig
import kotlinx.coroutines.flow.SharedFlow
interface VpnService : Tunnel { interface VpnService : Tunnel {
suspend fun startTunnel(tunnelConfig: TunnelConfig): Tunnel.State suspend fun startTunnel(tunnelConfig : TunnelConfig) : Tunnel.State
suspend fun stopTunnel() suspend fun stopTunnel()
val state : SharedFlow<Tunnel.State>
val vpnState: StateFlow<VpnState> val tunnelName : SharedFlow<String>
val statistics : SharedFlow<Statistics>
fun getState(): Tunnel.State val lastHandshake : SharedFlow<Map<Key,Long>>
val handshakeStatus : SharedFlow<HandshakeStatus>
fun getState() : Tunnel.State
} }
@@ -1,10 +0,0 @@
package com.zaneschepke.wireguardautotunnel.service.tunnel
import com.wireguard.android.backend.Statistics
import com.wireguard.android.backend.Tunnel
data class VpnState(
val status : Tunnel.State = Tunnel.State.DOWN,
val name : String = "",
val statistics : Statistics? = null
)
@@ -3,147 +3,140 @@ package com.zaneschepke.wireguardautotunnel.service.tunnel
import com.wireguard.android.backend.Backend import com.wireguard.android.backend.Backend
import com.wireguard.android.backend.BackendException import com.wireguard.android.backend.BackendException
import com.wireguard.android.backend.Statistics import com.wireguard.android.backend.Statistics
import com.wireguard.android.backend.Tunnel.State import com.wireguard.android.backend.Tunnel
import com.wireguard.config.Config import com.wireguard.crypto.Key
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel import com.zaneschepke.wireguardautotunnel.Constants
import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepository import com.zaneschepke.wireguardautotunnel.util.NumberUtils
import com.zaneschepke.wireguardautotunnel.module.Kernel
import com.zaneschepke.wireguardautotunnel.module.Userspace
import com.zaneschepke.wireguardautotunnel.util.Constants
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
class WireGuardTunnel
@Inject class WireGuardTunnel @Inject constructor(private val backend : Backend) : VpnService {
constructor(
@Userspace private val userspaceBackend: Backend, private val _tunnelName = MutableStateFlow("")
@Kernel private val kernelBackend: Backend, override val tunnelName get() = _tunnelName.asStateFlow()
private val settingsRepository: SettingsRepository
) : VpnService { private val _state = MutableSharedFlow<Tunnel.State>(
private val _vpnState = MutableStateFlow(VpnState()) onBufferOverflow = BufferOverflow.DROP_OLDEST,
override val vpnState: StateFlow<VpnState> = _vpnState.asStateFlow() replay = 1)
private val _handshakeStatus = MutableSharedFlow<HandshakeStatus>(replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST)
override val state get() = _state.asSharedFlow()
private val _statistics = MutableSharedFlow<Statistics>(replay = 1)
override val statistics get() = _statistics.asSharedFlow()
private val _lastHandshake = MutableSharedFlow<Map<Key, Long>>(replay = 1)
override val lastHandshake get() = _lastHandshake.asSharedFlow()
override val handshakeStatus: SharedFlow<HandshakeStatus>
get() = _handshakeStatus.asSharedFlow()
private val scope = CoroutineScope(Dispatchers.IO) private val scope = CoroutineScope(Dispatchers.IO)
private lateinit var statsJob: Job private lateinit var statsJob : Job
private var config: Config? = null
private var backend: Backend = userspaceBackend override suspend fun startTunnel(tunnelConfig: TunnelConfig) : Tunnel.State{
private var backendIsUserspace = true
init {
scope.launch {
settingsRepository.getSettingsFlow().collect {
if (it.isKernelEnabled && backendIsUserspace) {
Timber.d("Setting kernel backend")
backend = kernelBackend
backendIsUserspace = false
} else if (!it.isKernelEnabled && !backendIsUserspace) {
Timber.d("Setting userspace backend")
backend = userspaceBackend
backendIsUserspace = true
}
}
}
}
override suspend fun startTunnel(tunnelConfig: TunnelConfig): State {
return try { return try {
stopTunnelOnConfigChange(tunnelConfig) stopTunnelOnConfigChange(tunnelConfig)
emitTunnelName(tunnelConfig.name) emitTunnelName(tunnelConfig.name)
config = TunnelConfig.configFromQuick(tunnelConfig.wgQuick) val config = TunnelConfig.configFromQuick(tunnelConfig.wgQuick)
val state = val state = backend.setState(
backend.setState( this, Tunnel.State.UP, config)
this, _state.emit(state)
State.UP,
config
)
emitTunnelState(state)
state state
} catch (e: Exception) { } catch (e : Exception) {
Timber.e("Failed to start tunnel with error: ${e.message}") Timber.e("Failed to start tunnel with error: ${e.message}")
State.DOWN Tunnel.State.DOWN
} }
} }
private fun emitTunnelState(state: State) { private suspend fun emitTunnelName(name : String) {
_vpnState.tryEmit( _tunnelName.emit(name)
_vpnState.value.copy(
status = state
)
)
}
private fun emitBackendStatistics(statistics: Statistics) {
_vpnState.tryEmit(
_vpnState.value.copy(
statistics = statistics
)
)
}
private suspend fun emitTunnelName(name: String) {
_vpnState.emit(
_vpnState.value.copy(
name = name
)
)
} }
private suspend fun stopTunnelOnConfigChange(tunnelConfig: TunnelConfig) { private suspend fun stopTunnelOnConfigChange(tunnelConfig: TunnelConfig) {
if (getState() == State.UP && _vpnState.value.name != tunnelConfig.name) { if(getState() == Tunnel.State.UP && _tunnelName.value != tunnelConfig.name) {
stopTunnel() stopTunnel()
} }
} }
override fun getName(): String { override fun getName(): String {
return _vpnState.value.name return _tunnelName.value
} }
override suspend fun stopTunnel() { override suspend fun stopTunnel() {
try { try {
if (getState() == State.UP) { if(getState() == Tunnel.State.UP) {
val state = backend.setState(this, State.DOWN, null) val state = backend.setState(this, Tunnel.State.DOWN, null)
emitTunnelState(state) _state.emit(state)
} }
} catch (e: BackendException) { } catch (e : BackendException) {
Timber.e("Failed to stop tunnel with error: ${e.message}") Timber.e("Failed to stop tunnel with error: ${e.message}")
} }
} }
override fun getState(): State { override fun getState(): Tunnel.State {
return backend.getState(this) return backend.getState(this)
} }
override fun onStateChange(state: State) { override fun onStateChange(state : Tunnel.State) {
val tunnel = this val tunnel = this
emitTunnelState(state) _state.tryEmit(state)
WireGuardAutoTunnel.requestTileServiceStateUpdate() if(state == Tunnel.State.UP) {
if (state == State.UP) { statsJob = scope.launch {
statsJob = val handshakeMap = HashMap<Key, Long>()
scope.launch { var neverHadHandshakeCounter = 0
while (true) { while (true) {
val statistics = backend.getStatistics(tunnel) val statistics = backend.getStatistics(tunnel)
emitBackendStatistics(statistics) _statistics.emit(statistics)
delay(Constants.VPN_STATISTIC_CHECK_INTERVAL) statistics.peers().forEach {
val handshakeEpoch = statistics.peer(it)?.latestHandshakeEpochMillis ?: 0L
handshakeMap[it] = handshakeEpoch
if(handshakeEpoch == 0L) {
if(neverHadHandshakeCounter >= HandshakeStatus.NEVER_CONNECTED_TO_UNHEALTHY_TIME_LIMIT_SEC) {
_handshakeStatus.emit(HandshakeStatus.NEVER_CONNECTED)
} else {
_handshakeStatus.emit(HandshakeStatus.NOT_STARTED)
}
if(neverHadHandshakeCounter <= HandshakeStatus.NEVER_CONNECTED_TO_UNHEALTHY_TIME_LIMIT_SEC) {
neverHadHandshakeCounter += (1 * Constants.VPN_STATISTIC_CHECK_INTERVAL/1000).toInt()
}
return@forEach
}
if((NumberUtils.getSecondsBetweenTimestampAndNow(handshakeEpoch) ?: 0L) >= HandshakeStatus.UNHEALTHY_TIME_LIMIT_SEC) {
_handshakeStatus.emit(HandshakeStatus.UNHEALTHY)
} else {
_handshakeStatus.emit(HandshakeStatus.HEALTHY)
}
} }
_lastHandshake.emit(handshakeMap)
delay(Constants.VPN_STATISTIC_CHECK_INTERVAL)
} }
}
if (state == State.DOWN) {
if (this::statsJob.isInitialized) {
statsJob.cancel()
} }
} }
if(state == Tunnel.State.DOWN) {
if(this::statsJob.isInitialized) {
statsJob.cancel()
}
_handshakeStatus.tryEmit(HandshakeStatus.NOT_STARTED)
_lastHandshake.tryEmit(emptyMap())
}
} }
} }
@@ -1,13 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui
import androidx.lifecycle.ViewModel
import com.zaneschepke.wireguardautotunnel.data.SettingsDao
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
@HiltViewModel
class ActivityViewModel @Inject constructor(
private val settingsRepo: SettingsDao,
) : ViewModel() {
}
@@ -6,11 +6,16 @@ import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.provider.Settings import android.provider.Settings
import android.view.KeyEvent
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.focusable import androidx.compose.animation.ExitTransition
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.slideInHorizontally
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarData import androidx.compose.material3.SnackbarData
@@ -26,7 +31,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
@@ -36,6 +41,7 @@ import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState import com.google.accompanist.permissions.rememberPermissionState
import com.wireguard.android.backend.GoBackend import com.wireguard.android.backend.GoBackend
import com.zaneschepke.wireguardautotunnel.Constants
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.common.PermissionRequestFailedScreen import com.zaneschepke.wireguardautotunnel.ui.common.PermissionRequestFailedScreen
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.BottomNavBar import com.zaneschepke.wireguardautotunnel.ui.common.navigation.BottomNavBar
@@ -46,23 +52,22 @@ import com.zaneschepke.wireguardautotunnel.ui.screens.settings.SettingsScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.support.SupportScreen import com.zaneschepke.wireguardautotunnel.ui.screens.support.SupportScreen
import com.zaneschepke.wireguardautotunnel.ui.theme.TransparentSystemBars import com.zaneschepke.wireguardautotunnel.ui.theme.TransparentSystemBars
import com.zaneschepke.wireguardautotunnel.ui.theme.WireguardAutoTunnelTheme import com.zaneschepke.wireguardautotunnel.ui.theme.WireguardAutoTunnelTheme
import com.zaneschepke.wireguardautotunnel.util.Constants
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
@OptIn(
@OptIn(ExperimentalAnimationApi::class,
ExperimentalPermissionsApi::class ExperimentalPermissionsApi::class
) )
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContent { setContent {
// val activityViewModel = hiltViewModel<ActivityViewModel>()
val navController = rememberNavController() val navController = rememberNavController()
val focusRequester = remember { FocusRequester()} val focusRequester = remember { FocusRequester() }
WireguardAutoTunnelTheme { WireguardAutoTunnelTheme {
TransparentSystemBars() TransparentSystemBars()
@@ -79,62 +84,68 @@ class MainActivity : AppCompatActivity() {
} }
var vpnIntent by remember { mutableStateOf(GoBackend.VpnService.prepare(this)) } var vpnIntent by remember { mutableStateOf(GoBackend.VpnService.prepare(this)) }
val vpnActivityResultState = val vpnActivityResultState = rememberLauncherForActivityResult(
rememberLauncherForActivityResult( ActivityResultContracts.StartActivityForResult(),
ActivityResultContracts.StartActivityForResult(), onResult = {
onResult = { val accepted = (it.resultCode == RESULT_OK)
val accepted = (it.resultCode == RESULT_OK) if (accepted) {
if (accepted) { vpnIntent = null
vpnIntent = null
}
} }
) })
LaunchedEffect(vpnIntent) { LaunchedEffect(vpnIntent) {
if (vpnIntent != null) { if (vpnIntent != null) {
vpnActivityResultState.launch(vpnIntent) vpnActivityResultState.launch(vpnIntent)
} else { } else requestNotificationPermission()
requestNotificationPermission()
}
} }
fun showSnackBarMessage(message: String) { fun showSnackBarMessage(message : String) {
lifecycleScope.launch(Dispatchers.Main) { lifecycleScope.launch(Dispatchers.Main) {
val result = snackbarHostState.showSnackbar( val result = snackbarHostState.showSnackbar(
message = message, message = message,
actionLabel = applicationContext.getString(R.string.okay), actionLabel = applicationContext.getString(R.string.okay),
duration = SnackbarDuration.Short duration = SnackbarDuration.Short,
) )
when (result) { when (result) {
SnackbarResult.ActionPerformed, SnackbarResult.Dismissed -> { SnackbarResult.ActionPerformed -> { snackbarHostState.currentSnackbarData?.dismiss() }
snackbarHostState.currentSnackbarData?.dismiss() SnackbarResult.Dismissed -> { snackbarHostState.currentSnackbarData?.dismiss() }
}
} }
} }
} }
Scaffold( Scaffold(snackbarHost = {
snackbarHost = {
SnackbarHost(snackbarHostState) { snackbarData: SnackbarData -> SnackbarHost(snackbarHostState) { snackbarData: SnackbarData ->
CustomSnackBar( CustomSnackBar(
snackbarData.visuals.message, snackbarData.visuals.message,
isRtl = false, isRtl = false,
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation( containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp)
2.dp
)
) )
} }
}, },
modifier = Modifier.focusable().focusProperties { up = focusRequester }, modifier = Modifier.onKeyEvent {
bottomBar = if (it.nativeKeyEvent.action == KeyEvent.ACTION_UP) {
if (vpnIntent == null && notificationPermissionState.status.isGranted) { when (it.nativeKeyEvent.keyCode) {
{ BottomNavBar(navController, listOf( KeyEvent.KEYCODE_DPAD_UP -> {
Screen.Main.navItem, try {
Screen.Settings.navItem, focusRequester.requestFocus()
Screen.Support.navItem)) } } catch(e : IllegalStateException) {
Timber.e("No D-Pad focus request modifier added to element on screen")
}
false
} else -> {
false
}
}
} else {
false
}
},
bottomBar = if (vpnIntent == null && notificationPermissionState.status.isGranted) {
{ BottomNavBar(navController, Routes.navItems) }
} else { } else {
{} {}
} },
) { padding -> )
{ padding ->
if (vpnIntent != null) { if (vpnIntent != null) {
PermissionRequestFailedScreen( PermissionRequestFailedScreen(
padding = padding, padding = padding,
@@ -151,11 +162,7 @@ class MainActivity : AppCompatActivity() {
val intentSettings = val intentSettings =
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
intentSettings.data = intentSettings.data =
Uri.fromParts( Uri.fromParts(Constants.URI_PACKAGE_SCHEME, this.packageName, null)
Constants.URI_PACKAGE_SCHEME,
this.packageName,
null
)
startActivity(intentSettings) startActivity(intentSettings)
}, },
message = getString(R.string.notification_permission_required), message = getString(R.string.notification_permission_required),
@@ -163,40 +170,65 @@ class MainActivity : AppCompatActivity() {
) )
return@Scaffold return@Scaffold
} }
NavHost(navController, startDestination = Screen.Main.route) {
composable( NavHost(navController, startDestination = Routes.Main.name) {
Screen.Main.route, composable(Routes.Main.name, enterTransition = {
) { when (initialState.destination.route) {
MainScreen(padding = padding, focusRequester = focusRequester, showSnackbarMessage = { message -> Routes.Settings.name, Routes.Support.name ->
showSnackBarMessage(message) slideInHorizontally(
}, navController = navController) initialOffsetX = { -Constants.SLIDE_IN_TRANSITION_OFFSET },
} animationSpec = tween(Constants.SLIDE_IN_ANIMATION_DURATION)
composable(Screen.Settings.route, )
) {
SettingsScreen(padding = padding, showSnackbarMessage = { message -> else -> {
showSnackBarMessage(message) fadeIn(animationSpec = tween(Constants.FADE_IN_ANIMATION_DURATION))
}, focusRequester = focusRequester) }
}
composable(Screen.Support.route,
) {
SupportScreen(padding = padding, focusRequester = focusRequester,
showSnackbarMessage = { message ->
showSnackBarMessage(message)
})
}
composable("${Screen.Config.route}/{id}") {
val id = it.arguments?.getString("id")
if (!id.isNullOrBlank()) {
//https://dagger.dev/hilt/view-model#assisted-injection
ConfigScreen(
navController = navController,
id = id,
showSnackbarMessage = { message ->
showSnackBarMessage(message)
},
focusRequester = focusRequester
)
} }
}, exitTransition = {
ExitTransition.None
}
) {
MainScreen(padding = padding, showSnackbarMessage = { message -> showSnackBarMessage(message) }, navController = navController)
}
composable(Routes.Settings.name, enterTransition = {
when (initialState.destination.route) {
Routes.Main.name ->
slideInHorizontally(
initialOffsetX = { Constants.SLIDE_IN_TRANSITION_OFFSET },
animationSpec = tween(Constants.SLIDE_IN_ANIMATION_DURATION)
)
Routes.Support.name -> {
slideInHorizontally(
initialOffsetX = { -Constants.SLIDE_IN_TRANSITION_OFFSET },
animationSpec = tween(Constants.SLIDE_IN_ANIMATION_DURATION)
)
}
else -> {
fadeIn(animationSpec = tween(Constants.FADE_IN_ANIMATION_DURATION))
}
}
}) { SettingsScreen(padding = padding, showSnackbarMessage = { message -> showSnackBarMessage(message) }, focusRequester = focusRequester) }
composable(Routes.Support.name, enterTransition = {
when (initialState.destination.route) {
Routes.Settings.name, Routes.Main.name ->
slideInHorizontally(
initialOffsetX = { Constants.SLIDE_IN_ANIMATION_DURATION },
animationSpec = tween(Constants.SLIDE_IN_ANIMATION_DURATION)
)
else -> {
fadeIn(animationSpec = tween(Constants.FADE_IN_ANIMATION_DURATION))
}
}
}) { SupportScreen(padding = padding, focusRequester) }
composable("${Routes.Config.name}/{id}", enterTransition = {
fadeIn(animationSpec = tween(Constants.FADE_IN_ANIMATION_DURATION))
}) { it ->
val id = it.arguments?.getString("id")
if(!id.isNullOrBlank()) {
ConfigScreen(navController = navController, id = id, showSnackbarMessage = { message -> showSnackBarMessage(message) }, focusRequester = focusRequester)}
} }
} }
} }
@@ -0,0 +1,35 @@
package com.zaneschepke.wireguardautotunnel.ui
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Home
import androidx.compose.material.icons.rounded.QuestionMark
import androidx.compose.material.icons.rounded.Settings
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.BottomNavItem
enum class Routes {
Main,
Settings,
Support,
Config;
companion object {
val navItems = listOf(
BottomNavItem(
name = "Tunnels",
route = Main.name,
icon = Icons.Rounded.Home,
),
BottomNavItem(
name = "Settings",
route = Settings.name,
icon = Icons.Rounded.Settings,
),
BottomNavItem(
name = "Support",
route = Support.name,
icon = Icons.Rounded.QuestionMark,
)
)
}
}
@@ -1,33 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Home
import androidx.compose.material.icons.rounded.QuestionMark
import androidx.compose.material.icons.rounded.Settings
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.BottomNavItem
sealed class Screen(val route : String) {
data object Main: Screen("main") {
val navItem = BottomNavItem(
name = "Tunnels",
route = route,
icon = Icons.Rounded.Home
)
}
data object Settings: Screen("settings") {
val navItem = BottomNavItem(
name = "Settings",
route = route,
icon = Icons.Rounded.Settings
)
}
data object Support: Screen("support") {
val navItem = BottomNavItem(
name = "Support",
route = route,
icon = Icons.Rounded.QuestionMark
)
}
data object Config : Screen("config")
}
@@ -14,25 +14,17 @@ import androidx.compose.ui.res.stringResource
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
@Composable @Composable
fun ClickableIconButton( fun ClickableIconButton(onIconClick : () -> Unit, text : String, icon : ImageVector, enabled : Boolean) {
onClick: () -> Unit, TextButton(onClick = {},
onIconClick: () -> Unit,
text: String,
icon: ImageVector,
enabled: Boolean
) {
TextButton(
onClick = onClick,
enabled = enabled enabled = enabled
) { ) {
Text(text, Modifier.weight(1f, false)) Text(text)
Spacer(modifier = Modifier.size(ButtonDefaults.IconSpacing)) Spacer(modifier = Modifier.size(ButtonDefaults.IconSpacing))
Icon( Icon(
imageVector = icon, imageVector = icon,
contentDescription = stringResource(R.string.delete), contentDescription = stringResource(R.string.delete),
modifier = modifier = Modifier.size(ButtonDefaults.IconSize).clickable {
Modifier.size(ButtonDefaults.IconSize).weight(1f, false).clickable { if(enabled) {
if (enabled) {
onIconClick() onIconClick()
} }
} }
@@ -16,21 +16,13 @@ import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@Composable @Composable
fun PermissionRequestFailedScreen( fun PermissionRequestFailedScreen(padding : PaddingValues, onRequestAgain : () -> Unit, message : String, buttonText : String ) {
padding: PaddingValues,
onRequestAgain: () -> Unit,
message: String,
buttonText: String
) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
Column( Column(horizontalAlignment = Alignment.CenterHorizontally,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
modifier = modifier = Modifier
Modifier
.fillMaxSize() .fillMaxSize()
.padding(padding) .padding(padding)) {
) {
Text(message, textAlign = TextAlign.Center, modifier = Modifier.padding(15.dp)) Text(message, textAlign = TextAlign.Center, modifier = Modifier.padding(15.dp))
Button(onClick = { Button(onClick = {
scope.launch { scope.launch {
@@ -18,23 +18,17 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.wireguard.android.backend.Statistics import com.wireguard.android.backend.Statistics
import com.zaneschepke.wireguardautotunnel.toThreeDecimalPlaceString
import com.zaneschepke.wireguardautotunnel.util.NumberUtils import com.zaneschepke.wireguardautotunnel.util.NumberUtils
import com.zaneschepke.wireguardautotunnel.util.toThreeDecimalPlaceString
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun RowListItem( fun RowListItem(icon : @Composable () -> Unit, text : String, onHold : () -> Unit,
icon: @Composable () -> Unit, onClick: () -> Unit, rowButton : @Composable () -> Unit,
text: String, expanded : Boolean, statistics: Statistics?
onHold: () -> Unit, ) {
onClick: () -> Unit,
rowButton: @Composable () -> Unit,
expanded: Boolean,
statistics: Statistics?
) {
Box( Box(
modifier = modifier = Modifier
Modifier
.animateContentSize() .animateContentSize()
.clip(RoundedCornerShape(30.dp)) .clip(RoundedCornerShape(30.dp))
.combinedClickable( .combinedClickable(
@@ -48,27 +42,22 @@ fun RowListItem(
) { ) {
Column { Column {
Row( Row(
modifier = modifier = Modifier
Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 15.dp, vertical = 5.dp), .padding(horizontal = 14.dp, vertical = 5.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween horizontalArrangement = Arrangement.SpaceBetween
) { ) {
Row( Row(verticalAlignment = Alignment.CenterVertically,) {
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(.60f)
) {
icon() icon()
Text(text) Text(text)
} }
rowButton() rowButton()
} }
if (expanded) { if(expanded) {
statistics?.peers()?.forEach { statistics?.peers()?.forEach {
Row( Row(
modifier = modifier = Modifier
Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(end = 10.dp, bottom = 10.dp, start = 10.dp), .padding(end = 10.dp, bottom = 10.dp, start = 10.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -77,11 +66,9 @@ fun RowListItem(
val handshakeEpoch = statistics.peer(it)!!.latestHandshakeEpochMillis val handshakeEpoch = statistics.peer(it)!!.latestHandshakeEpochMillis
val peerTx = statistics.peer(it)!!.txBytes val peerTx = statistics.peer(it)!!.txBytes
val peerRx = statistics.peer(it)!!.rxBytes val peerRx = statistics.peer(it)!!.rxBytes
val peerId = it.toBase64().subSequence(0, 3).toString() + "***" val peerId = it.toBase64().subSequence(0,3).toString() + "***"
val handshakeSec = val handshakeSec = NumberUtils.getSecondsBetweenTimestampAndNow(handshakeEpoch)
NumberUtils.getSecondsBetweenTimestampAndNow(handshakeEpoch) val handshake = if(handshakeSec == null) "never" else "$handshakeSec secs ago"
val handshake =
if (handshakeSec == null) "never" else "$handshakeSec secs ago"
val peerTxMB = NumberUtils.bytesToMB(peerTx).toThreeDecimalPlaceString() val peerTxMB = NumberUtils.bytesToMB(peerTx).toThreeDecimalPlaceString()
val peerRxMB = NumberUtils.bytesToMB(peerRx).toThreeDecimalPlaceString() val peerRxMB = NumberUtils.bytesToMB(peerRx).toThreeDecimalPlaceString()
val fontSize = 9.sp val fontSize = 9.sp
@@ -25,7 +25,9 @@ import androidx.compose.ui.text.input.KeyboardType
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
@Composable @Composable
fun SearchBar(onQuery: (queryString: String) -> Unit) { fun SearchBar(
onQuery : (queryString : String) -> Unit
) {
// Immediately update and keep track of query from text field changes. // Immediately update and keep track of query from text field changes.
var query: String by rememberSaveable { mutableStateOf("") } var query: String by rememberSaveable { mutableStateOf("") }
var showClearIcon by rememberSaveable { mutableStateOf(false) } var showClearIcon by rememberSaveable { mutableStateOf(false) }
@@ -62,18 +64,16 @@ fun SearchBar(onQuery: (queryString: String) -> Unit) {
} }
}, },
maxLines = 1, maxLines = 1,
colors = colors = TextFieldDefaults.colors(
TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent, focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent, unfocusedContainerColor = Color.Transparent,
disabledContainerColor = Color.Transparent disabledContainerColor = Color.Transparent,
), ),
placeholder = { Text(text = stringResource(R.string.hint_search_packages)) }, placeholder = { Text(text = stringResource(R.string.hint_search_packages)) },
textStyle = MaterialTheme.typography.bodySmall, textStyle = MaterialTheme.typography.bodySmall,
singleLine = true, singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text),
modifier = modifier = Modifier
Modifier
.fillMaxWidth() .fillMaxWidth()
.background(color = MaterialTheme.colorScheme.background, shape = RectangleShape) .background(color = MaterialTheme.colorScheme.background, shape = RectangleShape)
) )
@@ -10,15 +10,9 @@ import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardCapitalization
@Composable @Composable
fun ConfigurationTextBox( fun
value: String, ConfigurationTextBox(value : String, hint : String, onValueChange : (String) -> Unit, keyboardActions : KeyboardActions, label : String, modifier: Modifier) {
hint: String, OutlinedTextField(
onValueChange: (String) -> Unit,
keyboardActions: KeyboardActions,
label: String,
modifier: Modifier
) {
OutlinedTextField(
modifier = modifier, modifier = modifier,
value = value, value = value,
singleLine = true, singleLine = true,
@@ -30,11 +24,10 @@ fun ConfigurationTextBox(
placeholder = { placeholder = {
Text(hint) Text(hint)
}, },
keyboardOptions = keyboardOptions = KeyboardOptions(
KeyboardOptions(
capitalization = KeyboardCapitalization.None, capitalization = KeyboardCapitalization.None,
imeAction = ImeAction.Done imeAction = ImeAction.Done
), ),
keyboardActions = keyboardActions keyboardActions = keyboardActions,
) )
} }
@@ -12,17 +12,10 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
@Composable @Composable
fun ConfigurationToggle( fun ConfigurationToggle(label : String, enabled : Boolean, checked : Boolean, padding : Dp,
label: String, onCheckChanged : () -> Unit, modifier : Modifier = Modifier) {
enabled: Boolean,
checked: Boolean,
padding: Dp,
onCheckChanged: () -> Unit,
modifier: Modifier = Modifier
) {
Row( Row(
modifier = modifier = Modifier
Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(padding), .padding(padding),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -11,14 +11,12 @@ import androidx.navigation.NavController
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.currentBackStackEntryAsState
@Composable @Composable
fun BottomNavBar( fun BottomNavBar(navController : NavController, bottomNavItems : List<BottomNavItem>) {
navController: NavController,
bottomNavItems: List<BottomNavItem>
) {
val backStackEntry = navController.currentBackStackEntryAsState() val backStackEntry = navController.currentBackStackEntryAsState()
NavigationBar( NavigationBar(
containerColor = MaterialTheme.colorScheme.background containerColor = MaterialTheme.colorScheme.background,
) { ) {
bottomNavItems.forEach { item -> bottomNavItems.forEach { item ->
val selected = item.route == backStackEntry.value?.destination?.route val selected = item.route == backStackEntry.value?.destination?.route
@@ -29,13 +27,13 @@ fun BottomNavBar(
label = { label = {
Text( Text(
text = item.name, text = item.name,
fontWeight = FontWeight.SemiBold fontWeight = FontWeight.SemiBold,
) )
}, },
icon = { icon = {
Icon( Icon(
imageVector = item.icon, imageVector = item.icon,
contentDescription = "${item.name} Icon" contentDescription = "${item.name} Icon",
) )
} }
) )
@@ -11,87 +11,69 @@ import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
@Composable @Composable
fun AuthorizationPrompt( fun AuthorizationPrompt(onSuccess : () -> Unit, onFailure : () -> Unit, onError : (String) -> Unit) {
onSuccess: () -> Unit,
onFailure: () -> Unit,
onError: (String) -> Unit
) {
val context = LocalContext.current val context = LocalContext.current
val biometricManager = BiometricManager.from(context) val biometricManager = BiometricManager.from(context)
val bio = biometricManager.canAuthenticate(BIOMETRIC_WEAK or DEVICE_CREDENTIAL) val bio = biometricManager.canAuthenticate(BIOMETRIC_WEAK or DEVICE_CREDENTIAL)
val isBiometricAvailable = val isBiometricAvailable = remember {
remember { when(bio){
when (bio) { BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE -> {
BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE -> { onError("Biometrics not available")
onError("Biometrics not available") false
false
}
BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> {
onError("Biometrics not created")
false
}
BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> {
onError("Biometric hardware not found")
false
}
BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED -> {
onError("Biometric security update required")
false
}
BiometricManager.BIOMETRIC_ERROR_UNSUPPORTED -> {
onError("Biometrics not supported")
false
}
BiometricManager.BIOMETRIC_STATUS_UNKNOWN -> {
onError("Biometrics status unknown")
false
}
BiometricManager.BIOMETRIC_SUCCESS -> true
else -> false
} }
BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> {
onError("Biometrics not created")
false
}
BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> {
onError("Biometric hardware not found")
false
}
BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED -> {
onError("Biometric security update required")
false
}
BiometricManager.BIOMETRIC_ERROR_UNSUPPORTED -> {
onError("Biometrics not supported")
false
}
BiometricManager.BIOMETRIC_STATUS_UNKNOWN -> {
onError("Biometrics status unknown")
false
}
BiometricManager.BIOMETRIC_SUCCESS -> true
else -> false
} }
if (isBiometricAvailable) { }
if(isBiometricAvailable) {
val executor = remember { ContextCompat.getMainExecutor(context) } val executor = remember { ContextCompat.getMainExecutor(context) }
val promptInfo = val promptInfo = BiometricPrompt.PromptInfo.Builder()
BiometricPrompt.PromptInfo.Builder() .setAllowedAuthenticators(BIOMETRIC_WEAK or DEVICE_CREDENTIAL)
.setAllowedAuthenticators(BIOMETRIC_WEAK or DEVICE_CREDENTIAL) .setTitle("Biometric Authentication")
.setTitle("Biometric Authentication") .setSubtitle("Log in using your biometric credential")
.setSubtitle("Log in using your biometric credential") .build()
.build()
val biometricPrompt = val biometricPrompt = BiometricPrompt(
BiometricPrompt( context as FragmentActivity,
context as FragmentActivity, executor,
executor, object : BiometricPrompt.AuthenticationCallback() {
object : BiometricPrompt.AuthenticationCallback() { override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
override fun onAuthenticationError( super.onAuthenticationError(errorCode, errString)
errorCode: Int, onFailure()
errString: CharSequence
) {
super.onAuthenticationError(errorCode, errString)
onFailure()
}
override fun onAuthenticationSucceeded(
result: BiometricPrompt.AuthenticationResult
) {
super.onAuthenticationSucceeded(result)
onSuccess()
}
override fun onAuthenticationFailed() {
super.onAuthenticationFailed()
onFailure()
}
} }
)
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
super.onAuthenticationSucceeded(result)
onSuccess()
}
override fun onAuthenticationFailed() {
super.onAuthenticationFailed()
onFailure()
}
}
)
biometricPrompt.authenticate(promptInfo) biometricPrompt.authenticate(promptInfo)
} }
} }
@@ -34,14 +34,11 @@ fun CustomSnackBar(
containerColor: Color = MaterialTheme.colorScheme.surface containerColor: Color = MaterialTheme.colorScheme.surface
) { ) {
val context = LocalContext.current val context = LocalContext.current
Snackbar( Snackbar(containerColor = containerColor,
containerColor = containerColor, modifier = Modifier.fillMaxWidth(
modifier = if(WireGuardAutoTunnel.isRunningOnAndroidTv(context)) 1/3f else 2/3f).padding(bottom = 100.dp),
Modifier.fillMaxWidth(
if (WireGuardAutoTunnel.isRunningOnAndroidTv()) 1 / 3f else 2 / 3f
).padding(bottom = 100.dp),
shape = RoundedCornerShape(16.dp) shape = RoundedCornerShape(16.dp)
) { ) {
CompositionLocalProvider( CompositionLocalProvider(
LocalLayoutDirection provides LocalLayoutDirection provides
if (isRtl) LayoutDirection.Rtl else LayoutDirection.Ltr if (isRtl) LayoutDirection.Rtl else LayoutDirection.Ltr
@@ -1,22 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.common.screen
import androidx.compose.foundation.focusable
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.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun LoadingScreen() {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top,
modifier = Modifier.fillMaxSize().focusable().padding()) {
Column(modifier = Modifier.padding(120.dp)) { CircularProgressIndicator() }
}
}
@@ -12,10 +12,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@Composable @Composable
fun SectionTitle( fun SectionTitle(title : String, padding : Dp) {
title: String,
padding: Dp
) {
Text( Text(
title, title,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
@@ -3,27 +3,22 @@ package com.zaneschepke.wireguardautotunnel.ui.models
import com.wireguard.config.Interface import com.wireguard.config.Interface
data class InterfaceProxy( data class InterfaceProxy(
var privateKey: String = "", var privateKey : String = "",
var publicKey: String = "", var publicKey : String = "",
var addresses: String = "", var addresses : String = "",
var dnsServers: String = "", var dnsServers : String = "",
var listenPort: String = "", var listenPort : String = "",
var mtu: String = "" var mtu : String = "",
) { ){
companion object { companion object {
fun from(i: Interface): InterfaceProxy { fun from(i : Interface) : InterfaceProxy {
return InterfaceProxy( return InterfaceProxy(
publicKey = i.keyPair.publicKey.toBase64().trim(), publicKey = i.keyPair.publicKey.toBase64().trim(),
privateKey = i.keyPair.privateKey.toBase64().trim(), privateKey = i.keyPair.privateKey.toBase64().trim(),
addresses = i.addresses.joinToString(", ").trim(), addresses = i.addresses.joinToString(", ").trim(),
dnsServers = i.dnsServers.joinToString(", ").replace("/", "").trim(), dnsServers = i.dnsServers.joinToString(", ").replace("/", "").trim(),
listenPort = if (i.listenPort.isPresent) { listenPort = if(i.listenPort.isPresent) i.listenPort.get().toString().trim() else "",
i.listenPort.get().toString() mtu = if(i.mtu.isPresent) i.mtu.get().toString().trim() else ""
.trim()
} else {
""
},
mtu = if (i.mtu.isPresent) i.mtu.get().toString().trim() else ""
) )
} }
} }
@@ -3,47 +3,30 @@ package com.zaneschepke.wireguardautotunnel.ui.models
import com.wireguard.config.Peer import com.wireguard.config.Peer
data class PeerProxy( data class PeerProxy(
var publicKey: String = "", var publicKey : String = "",
var preSharedKey: String = "", var preSharedKey : String = "",
var persistentKeepalive: String = "", var persistentKeepalive : String = "",
var endpoint: String = "", var endpoint : String = "",
var allowedIps: String = IPV4_WILDCARD.joinToString(", ").trim() var allowedIps: String = IPV4_WILDCARD.joinToString(", ").trim()
) { ){
companion object { companion object {
fun from(peer: Peer): PeerProxy { fun from(peer : Peer) : PeerProxy {
return PeerProxy( return PeerProxy(
publicKey = peer.publicKey.toBase64(), publicKey = peer.publicKey.toBase64(),
preSharedKey = if (peer.preSharedKey.isPresent) { preSharedKey = if(peer.preSharedKey.isPresent) peer.preSharedKey.get().toBase64().trim() else "",
peer.preSharedKey.get().toBase64() persistentKeepalive = if(peer.persistentKeepalive.isPresent) peer.persistentKeepalive.get().toString().trim() else "",
.trim() endpoint = if(peer.endpoint.isPresent) peer.endpoint.get().toString().trim() else "",
} else {
""
},
persistentKeepalive = if (peer.persistentKeepalive.isPresent) {
peer.persistentKeepalive.get()
.toString().trim()
} else {
""
},
endpoint = if (peer.endpoint.isPresent) {
peer.endpoint.get().toString()
.trim()
} else {
""
},
allowedIps = peer.allowedIps.joinToString(", ").trim() allowedIps = peer.allowedIps.joinToString(", ").trim()
) )
} }
val IPV4_PUBLIC_NETWORKS = setOf(
val IPV4_PUBLIC_NETWORKS = "0.0.0.0/5", "8.0.0.0/7", "11.0.0.0/8", "12.0.0.0/6", "16.0.0.0/4", "32.0.0.0/3",
setOf( "64.0.0.0/2", "128.0.0.0/3", "160.0.0.0/5", "168.0.0.0/6", "172.0.0.0/12",
"0.0.0.0/5", "8.0.0.0/7", "11.0.0.0/8", "12.0.0.0/6", "16.0.0.0/4", "32.0.0.0/3", "172.32.0.0/11", "172.64.0.0/10", "172.128.0.0/9", "173.0.0.0/8", "174.0.0.0/7",
"64.0.0.0/2", "128.0.0.0/3", "160.0.0.0/5", "168.0.0.0/6", "172.0.0.0/12", "176.0.0.0/4", "192.0.0.0/9", "192.128.0.0/11", "192.160.0.0/13", "192.169.0.0/16",
"172.32.0.0/11", "172.64.0.0/10", "172.128.0.0/9", "173.0.0.0/8", "174.0.0.0/7", "192.170.0.0/15", "192.172.0.0/14", "192.176.0.0/12", "192.192.0.0/10",
"176.0.0.0/4", "192.0.0.0/9", "192.128.0.0/11", "192.160.0.0/13", "192.169.0.0/16", "193.0.0.0/8", "194.0.0.0/7", "196.0.0.0/6", "200.0.0.0/5", "208.0.0.0/4"
"192.170.0.0/15", "192.172.0.0/14", "192.176.0.0/12", "192.192.0.0/10", )
"193.0.0.0/8", "194.0.0.0/7", "196.0.0.0/6", "200.0.0.0/5", "208.0.0.0/4"
)
val IPV4_WILDCARD = setOf("0.0.0.0/0") val IPV4_WILDCARD = setOf("0.0.0.0/0")
} }
} }
@@ -49,6 +49,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.ExperimentalComposeUiApi
@@ -64,6 +65,7 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -71,24 +73,22 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController import androidx.navigation.NavController
import com.google.accompanist.drawablepainter.DrawablePainter import com.google.accompanist.drawablepainter.DrawablePainter
import com.zaneschepke.wireguardautotunnel.Constants
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.zaneschepke.wireguardautotunnel.ui.Screen import com.zaneschepke.wireguardautotunnel.ui.Routes
import com.zaneschepke.wireguardautotunnel.ui.common.SearchBar import com.zaneschepke.wireguardautotunnel.ui.common.SearchBar
import com.zaneschepke.wireguardautotunnel.ui.common.config.ConfigurationTextBox import com.zaneschepke.wireguardautotunnel.ui.common.config.ConfigurationTextBox
import com.zaneschepke.wireguardautotunnel.ui.common.prompt.AuthorizationPrompt import com.zaneschepke.wireguardautotunnel.ui.common.prompt.AuthorizationPrompt
import com.zaneschepke.wireguardautotunnel.ui.common.screen.LoadingScreen
import com.zaneschepke.wireguardautotunnel.ui.common.text.SectionTitle import com.zaneschepke.wireguardautotunnel.ui.common.text.SectionTitle
import com.zaneschepke.wireguardautotunnel.util.Constants import kotlinx.coroutines.Dispatchers
import com.zaneschepke.wireguardautotunnel.util.Event import kotlinx.coroutines.launch
import com.zaneschepke.wireguardautotunnel.util.Result import timber.log.Timber
import kotlinx.coroutines.delay
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@OptIn( @OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class,
ExperimentalComposeUiApi::class, ExperimentalFoundationApi::class
ExperimentalMaterial3Api::class, )
ExperimentalFoundationApi::class)
@Composable @Composable
fun ConfigScreen( fun ConfigScreen(
viewModel: ConfigViewModel = hiltViewModel(), viewModel: ConfigViewModel = hiltViewModel(),
@@ -97,454 +97,566 @@ fun ConfigScreen(
showSnackbarMessage: (String) -> Unit, showSnackbarMessage: (String) -> Unit,
id: String id: String
) { ) {
val context = LocalContext.current
val clipboardManager: ClipboardManager = LocalClipboardManager.current
val keyboardController = LocalSoftwareKeyboardController.current
var showApplicationsDialog by remember { mutableStateOf(false) }
var showAuthPrompt by remember { mutableStateOf(false) }
var isAuthenticated by remember { mutableStateOf(false) }
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val context = LocalContext.current
val scope = rememberCoroutineScope()
val clipboardManager: ClipboardManager = LocalClipboardManager.current
val keyboardController = LocalSoftwareKeyboardController.current
val tunnel by viewModel.tunnel.collectAsStateWithLifecycle(null)
val tunnelName = viewModel.tunnelName.collectAsStateWithLifecycle()
val packages by viewModel.packages.collectAsStateWithLifecycle()
val checkedPackages by viewModel.checkedPackages.collectAsStateWithLifecycle()
val include by viewModel.include.collectAsStateWithLifecycle()
val isAllApplicationsEnabled by viewModel.isAllApplicationsEnabled.collectAsStateWithLifecycle()
val proxyPeers by viewModel.proxyPeers.collectAsStateWithLifecycle()
val proxyInterface by viewModel.interfaceProxy.collectAsStateWithLifecycle()
var showApplicationsDialog by remember { mutableStateOf(false) }
var showAuthPrompt by remember { mutableStateOf(false) }
var isAuthenticated by remember { mutableStateOf(false) }
val baseTextBoxModifier = Modifier.onFocusChanged {
if(WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
keyboardController?.hide()
}
}
val keyboardActions = KeyboardActions(
onDone = {
keyboardController?.hide()
}
)
val keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.None,
imeAction = ImeAction.Done
)
val fillMaxHeight = .85f
val fillMaxWidth = .85f
val screenPadding = 5.dp
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.init(id) scope.launch(Dispatchers.IO) {
} try {
viewModel.onScreenLoad(id)
LaunchedEffect(uiState.loading) { } catch (e : Exception) {
if(!uiState.loading && WireGuardAutoTunnel.isRunningOnAndroidTv()) { showSnackbarMessage(e.message!!)
delay(Constants.FOCUS_REQUEST_DELAY) navController.navigate(Routes.Main.name)
focusRequester.requestFocus()
}
}
if (uiState.loading) {
LoadingScreen()
return
}
val keyboardActions = KeyboardActions(onDone = { keyboardController?.hide() })
val keyboardOptions =
KeyboardOptions(imeAction = ImeAction.Done)
val fillMaxHeight = .85f
val fillMaxWidth = .85f
val screenPadding = 5.dp
val applicationButtonText = {
"Tunneling apps: " +
if (uiState.isAllApplicationsEnabled) {
"all"
} else {
"${uiState.checkedPackageNames.size} " + (if (uiState.include) "included" else "excluded")
}
}
if (showAuthPrompt) {
AuthorizationPrompt(
onSuccess = {
showAuthPrompt = false
isAuthenticated = true
},
onError = { error ->
showAuthPrompt = false
showSnackbarMessage(Event.Error.AuthenticationFailed.message)
},
onFailure = {
showAuthPrompt = false
showSnackbarMessage(Event.Error.AuthorizationFailed.message)
})
}
if (showApplicationsDialog) {
val sortedPackages =
remember(uiState.packages) { uiState.packages.sortedBy { viewModel.getPackageLabel(it) } }
AlertDialog(onDismissRequest = { showApplicationsDialog = false }) {
Surface(
tonalElevation = 2.dp,
shadowElevation = 2.dp,
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surface,
modifier =
Modifier.fillMaxWidth()
.fillMaxHeight(if (uiState.isAllApplicationsEnabled) 1 / 5f else 4 / 5f)) {
Column(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 7.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween) {
Text(stringResource(id = R.string.tunnel_all))
Switch(
checked = uiState.isAllApplicationsEnabled,
onCheckedChange = { viewModel.onAllApplicationsChange(it) })
}
if (!uiState.isAllApplicationsEnabled) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 7.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween) {
Text(stringResource(id = R.string.include))
Checkbox(
checked = uiState.include,
onCheckedChange = { viewModel.onIncludeChange(!uiState.include) })
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween) {
Text(stringResource(id = R.string.exclude))
Checkbox(
checked = !uiState.include,
onCheckedChange = { viewModel.onIncludeChange(!uiState.include) })
}
}
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 7.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween) {
SearchBar(viewModel::emitQueriedPackages)
}
Spacer(Modifier.padding(5.dp))
LazyColumn(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Top,
modifier = Modifier.fillMaxHeight(4 / 5f)) {
items(sortedPackages, key = { it.packageName }) { pack ->
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxSize().padding(5.dp)) {
Row(modifier = Modifier.fillMaxWidth(fillMaxWidth)) {
val drawable =
pack.applicationInfo?.loadIcon(context.packageManager)
if (drawable != null) {
Image(
painter = DrawablePainter(drawable),
stringResource(id = R.string.icon),
modifier = Modifier.size(50.dp, 50.dp))
} else {
Icon(
Icons.Rounded.Android,
stringResource(id = R.string.edit),
modifier = Modifier.size(50.dp, 50.dp))
}
Text(
viewModel.getPackageLabel(pack),
modifier = Modifier.padding(5.dp))
}
Checkbox(
modifier = Modifier.fillMaxSize(),
checked =
(uiState.checkedPackageNames.contains(pack.packageName)),
onCheckedChange = {
if (it) {
viewModel.onAddCheckedPackage(pack.packageName)
} else {
viewModel.onRemoveCheckedPackage(pack.packageName)
}
})
}
}
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxSize().padding(top = 5.dp),
horizontalArrangement = Arrangement.Center) {
TextButton(onClick = { showApplicationsDialog = false }) {
Text(stringResource(R.string.done))
}
}
} }
} }
} }
}
Scaffold( val applicationButtonText = {
floatingActionButtonPosition = FabPosition.End, "Tunneling apps: " +
floatingActionButton = { if (isAllApplicationsEnabled) "all"
val secondaryColor = MaterialTheme.colorScheme.secondary else "${checkedPackages.size} " + (if (include) "included" else "excluded")
val hoverColor = MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp)
var fobColor by remember { mutableStateOf(secondaryColor) } }
FloatingActionButton(
modifier = if(showAuthPrompt) {
Modifier.padding(bottom = 90.dp).onFocusChanged { AuthorizationPrompt(onSuccess = {
if (WireGuardAutoTunnel.isRunningOnAndroidTv()) { showAuthPrompt = false
fobColor = if (it.isFocused) hoverColor else secondaryColor isAuthenticated = true },
} onError = { error ->
}, showSnackbarMessage(error)
onClick = { showAuthPrompt = false
viewModel.onSaveAllChanges().let { },
when (it) { onFailure = {
is Result.Success -> { showAuthPrompt = false
showSnackbarMessage(it.data.message) showSnackbarMessage(context.getString(R.string.authentication_failed))
navController.navigate(Screen.Main.route) })
} }
is Result.Error -> showSnackbarMessage(it.error.message)
} if (showApplicationsDialog) {
} val sortedPackages = remember(packages) {
}, packages.sortedBy { viewModel.getPackageLabel(it) }
containerColor = fobColor, }
shape = RoundedCornerShape(16.dp)) { AlertDialog(onDismissRequest = {
Icon( showApplicationsDialog = false
imageVector = Icons.Rounded.Save,
contentDescription = stringResource(id = R.string.save_changes),
tint = Color.DarkGray)
}
}) { }) {
Column { Surface(
Column( tonalElevation = 2.dp,
horizontalAlignment = Alignment.CenterHorizontally, shadowElevation = 2.dp,
verticalArrangement = Arrangement.Top, shape = RoundedCornerShape(12.dp),
modifier = color = MaterialTheme.colorScheme.surface,
Modifier.verticalScroll(rememberScrollState()).weight(1f, true).fillMaxSize()) { modifier = Modifier
Surface( .fillMaxWidth()
tonalElevation = 2.dp, .fillMaxHeight(if (isAllApplicationsEnabled) 1 / 5f else 4 / 5f)
shadowElevation = 2.dp, ) {
shape = RoundedCornerShape(12.dp), Column(
color = MaterialTheme.colorScheme.surface, modifier = Modifier.fillMaxWidth()
modifier = ) {
(if (WireGuardAutoTunnel.isRunningOnAndroidTv()) { Row(
Modifier.fillMaxHeight(fillMaxHeight).fillMaxWidth(fillMaxWidth) modifier = Modifier
} else { .fillMaxWidth()
Modifier.fillMaxWidth(fillMaxWidth) .padding(horizontal = 20.dp, vertical = 7.dp),
}) verticalAlignment = Alignment.CenterVertically,
.padding(top = 50.dp, bottom = 10.dp)) { horizontalArrangement = Arrangement.SpaceBetween
Column( ) {
Text(stringResource(id = R.string.tunnel_all))
Switch(
checked = isAllApplicationsEnabled,
onCheckedChange = {
viewModel.onAllApplicationsChange(it)
}
)
}
if (!isAllApplicationsEnabled) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(
horizontal = 20.dp,
vertical = 7.dp
),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(stringResource(id = R.string.include))
Checkbox(
checked = include,
onCheckedChange = {
viewModel.onIncludeChange(!include)
}
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(stringResource(id = R.string.exclude))
Checkbox(
checked = !include,
onCheckedChange = {
viewModel.onIncludeChange(!include)
}
)
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(
horizontal = 20.dp,
vertical = 7.dp
),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
SearchBar(viewModel::emitQueriedPackages)
}
Spacer(Modifier.padding(5.dp))
LazyColumn(
horizontalAlignment = Alignment.Start, horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Top, verticalArrangement = Arrangement.Top,
modifier = Modifier.padding(15.dp).focusGroup()) { modifier = Modifier
SectionTitle( .fillMaxHeight(4 / 5f)
stringResource(R.string.interface_), padding = screenPadding) ) {
ConfigurationTextBox( items(
value = uiState.tunnelName, sortedPackages,
onValueChange = { value -> viewModel.onTunnelNameChange(value) }, key = { it.packageName }) { pack ->
keyboardActions = keyboardActions, Row(
label = stringResource(R.string.name), verticalAlignment = Alignment.CenterVertically,
hint = stringResource(R.string.tunnel_name).lowercase(), horizontalArrangement = Arrangement.SpaceBetween,
modifier = modifier = Modifier
Modifier .fillMaxSize()
.fillMaxWidth() .padding(5.dp)
.focusRequester(focusRequester)) ) {
OutlinedTextField( Row(
modifier = modifier = Modifier.fillMaxWidth(
Modifier.fillMaxWidth().clickable { fillMaxWidth
showAuthPrompt = true )
}, ) {
value = uiState.interfaceProxy.privateKey, val drawable =
visualTransformation = pack.applicationInfo?.loadIcon(
if ((id == Constants.MANUAL_TUNNEL_CONFIG_ID) || context.packageManager
isAuthenticated) )
VisualTransformation.None if (drawable != null) {
else PasswordVisualTransformation(), Image(
enabled = painter = DrawablePainter(
(id == Constants.MANUAL_TUNNEL_CONFIG_ID) || isAuthenticated, drawable
onValueChange = { value -> viewModel.onPrivateKeyChange(value) }, ),
trailingIcon = { stringResource(id = R.string.icon),
IconButton( modifier = Modifier.size(
modifier = Modifier.focusRequester(FocusRequester.Default), 50.dp,
onClick = { viewModel.generateKeyPair() }) { 50.dp
Icon( )
Icons.Rounded.Refresh, )
stringResource(R.string.rotate_keys), } else {
tint = Color.White) Icon(
Icons.Rounded.Android,
stringResource(id = R.string.edit),
modifier = Modifier.size(
50.dp,
50.dp
)
)
} }
}, Text(
label = { Text(stringResource(R.string.private_key)) }, viewModel.getPackageLabel(pack),
singleLine = true, modifier = Modifier.padding(5.dp)
placeholder = { Text(stringResource(R.string.base64_key)) }, )
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions)
OutlinedTextField(
modifier =
Modifier
.fillMaxWidth()
.focusRequester(FocusRequester.Default),
value = uiState.interfaceProxy.publicKey,
enabled = false,
onValueChange = {},
trailingIcon = {
IconButton(
modifier = Modifier.focusRequester(FocusRequester.Default),
onClick = {
clipboardManager.setText(
AnnotatedString(uiState.interfaceProxy.publicKey))
}) {
Icon(
Icons.Rounded.ContentCopy,
stringResource(R.string.copy_public_key),
tint = Color.White)
}
},
label = { Text(stringResource(R.string.public_key)) },
singleLine = true,
placeholder = { Text(stringResource(R.string.base64_key)) },
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions)
Row(modifier = Modifier.fillMaxWidth()) {
ConfigurationTextBox(
value = uiState.interfaceProxy.addresses,
onValueChange = { value ->
viewModel.onAddressesChanged(value)
},
keyboardActions = keyboardActions,
label = stringResource(R.string.addresses),
hint = stringResource(R.string.comma_separated_list),
modifier =
Modifier
.fillMaxWidth(3 / 5f)
.padding(end = 5.dp))
ConfigurationTextBox(
value = uiState.interfaceProxy.listenPort,
onValueChange = { value ->
viewModel.onListenPortChanged(value)
},
keyboardActions = keyboardActions,
label = stringResource(R.string.listen_port),
hint = stringResource(R.string.random),
modifier = Modifier.width(IntrinsicSize.Min))
}
Row(modifier = Modifier.fillMaxWidth()) {
ConfigurationTextBox(
value = uiState.interfaceProxy.dnsServers,
onValueChange = { value ->
viewModel.onDnsServersChanged(value)
},
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 = uiState.interfaceProxy.mtu,
onValueChange = { value -> viewModel.onMtuChanged(value) },
keyboardActions = keyboardActions,
label = stringResource(R.string.mtu),
hint = stringResource(R.string.auto),
modifier = Modifier.width(IntrinsicSize.Min))
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxSize().padding(top = 5.dp),
horizontalArrangement = Arrangement.Center) {
TextButton(onClick = { showApplicationsDialog = true }) {
Text(applicationButtonText())
} }
} Checkbox(
modifier = Modifier.fillMaxSize(),
checked = (checkedPackages.contains(pack.packageName)),
onCheckedChange = {
if (it) viewModel.onAddCheckedPackage(
pack.packageName
) else viewModel.onRemoveCheckedPackage(
pack.packageName
)
}
)
}
} }
} }
uiState.proxyPeers.forEachIndexed { index, peer -> }
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxSize()
.padding(top = 5.dp),
horizontalArrangement = Arrangement.Center
) {
TextButton(
onClick = {
showApplicationsDialog = false
}) {
Text(stringResource(R.string.done))
}
}
}
}
}
}
if (tunnel != null) {
Scaffold(
floatingActionButtonPosition = FabPosition.End,
floatingActionButton = {
val secondaryColor = MaterialTheme.colorScheme.secondary
val hoverColor = MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp)
var fobColor by remember { mutableStateOf(secondaryColor) }
FloatingActionButton(
modifier = Modifier.padding(bottom = 90.dp).onFocusChanged {
if(WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
fobColor = if (it.isFocused) hoverColor else secondaryColor }
},
onClick = {
scope.launch {
try {
viewModel.onSaveAllChanges()
navController.navigate(Routes.Main.name)
showSnackbarMessage(context.resources.getString(R.string.config_changes_saved))
} catch (e : Exception) {
Timber.e(e.message)
showSnackbarMessage(e.message!!)
}
}
},
containerColor = fobColor,
shape = RoundedCornerShape(16.dp),
) {
Icon(
imageVector = Icons.Rounded.Save,
contentDescription = stringResource(id = R.string.save_changes),
tint = Color.DarkGray,
)
}
}) {
Column {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top,
modifier = Modifier
.verticalScroll(rememberScrollState())
.weight(1f, true)
.fillMaxSize()
) {
Surface( Surface(
tonalElevation = 2.dp, tonalElevation = 2.dp,
shadowElevation = 2.dp, shadowElevation = 2.dp,
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surface, color = MaterialTheme.colorScheme.surface,
modifier = modifier = (if (WireGuardAutoTunnel.isRunningOnAndroidTv(context))
(if (WireGuardAutoTunnel.isRunningOnAndroidTv()) { Modifier
Modifier.fillMaxHeight(fillMaxHeight).fillMaxWidth(fillMaxWidth) .fillMaxHeight(fillMaxHeight)
} else { .fillMaxWidth(fillMaxWidth)
Modifier.fillMaxWidth(fillMaxWidth) else Modifier.fillMaxWidth(fillMaxWidth)).padding(
}) top = 50.dp,
.padding(top = 10.dp, bottom = 10.dp)) { bottom = 10.dp
Column( )
horizontalAlignment = Alignment.Start, ) {
verticalArrangement = Arrangement.Top, Column(
modifier = horizontalAlignment = Alignment.Start,
Modifier.padding(horizontal = 15.dp).padding(bottom = 10.dp)) { verticalArrangement = Arrangement.Top,
modifier = Modifier.padding(15.dp).focusGroup()
) {
SectionTitle(stringResource(R.string.interface_), padding = screenPadding)
ConfigurationTextBox(
value = tunnelName.value,
onValueChange = { value ->
viewModel.onTunnelNameChange(value)
},
keyboardActions = keyboardActions,
label = stringResource(R.string.name),
hint = stringResource(R.string.tunnel_name).lowercase(),
modifier = baseTextBoxModifier.fillMaxWidth().focusRequester(focusRequester)
)
OutlinedTextField(
modifier = baseTextBoxModifier.fillMaxWidth().clickable {
showAuthPrompt = true
},
value = proxyInterface.privateKey,
visualTransformation = if((id == Constants.MANUAL_TUNNEL_CONFIG_ID) || isAuthenticated) VisualTransformation.None else PasswordVisualTransformation(),
enabled = (id == Constants.MANUAL_TUNNEL_CONFIG_ID) || isAuthenticated,
onValueChange = { value ->
viewModel.onPrivateKeyChange(value)
},
trailingIcon = {
IconButton(
modifier = Modifier.focusRequester(FocusRequester.Default),
onClick = {
viewModel.generateKeyPair()
}) {
Icon(
Icons.Rounded.Refresh,
stringResource(R.string.rotate_keys),
tint = Color.White
)
}
},
label = { Text(stringResource(R.string.private_key)) },
singleLine = true,
placeholder = { Text(stringResource(R.string.base64_key)) },
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions
)
OutlinedTextField(
modifier = baseTextBoxModifier.fillMaxWidth().focusRequester(FocusRequester.Default),
value = proxyInterface.publicKey,
enabled = false,
onValueChange = {},
trailingIcon = {
IconButton(
modifier = Modifier.focusRequester(FocusRequester.Default),
onClick = {
clipboardManager.setText(AnnotatedString(proxyInterface.publicKey))
}) {
Icon(
Icons.Rounded.ContentCopy,
stringResource(R.string.copy_public_key),
tint = Color.White
)
}
},
label = { Text(stringResource(R.string.public_key)) },
singleLine = true,
placeholder = { Text(stringResource(R.string.base64_key)) },
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions
)
Row(modifier = Modifier.fillMaxWidth()) {
ConfigurationTextBox(
value = proxyInterface.addresses,
onValueChange = { value ->
viewModel.onAddressesChanged(value)
},
keyboardActions = keyboardActions,
label = stringResource(R.string.addresses),
hint = stringResource(R.string.comma_separated_list),
modifier = baseTextBoxModifier
.fillMaxWidth(3 / 5f)
.padding(end = 5.dp)
)
ConfigurationTextBox(
value = proxyInterface.listenPort,
onValueChange = { value -> viewModel.onListenPortChanged(value) },
keyboardActions = keyboardActions,
label = stringResource(R.string.listen_port),
hint = stringResource(R.string.random),
modifier = baseTextBoxModifier.width(IntrinsicSize.Min)
)
}
Row(modifier = Modifier.fillMaxWidth()) {
ConfigurationTextBox(
value = proxyInterface.dnsServers,
onValueChange = { value -> viewModel.onDnsServersChanged(value) },
keyboardActions = keyboardActions,
label = stringResource(R.string.dns_servers),
hint = stringResource(R.string.comma_separated_list),
modifier = baseTextBoxModifier
.fillMaxWidth(3 / 5f)
.padding(end = 5.dp)
)
ConfigurationTextBox(
value = proxyInterface.mtu,
onValueChange = { value -> viewModel.onMtuChanged(value) },
keyboardActions = keyboardActions,
label = stringResource(R.string.mtu),
hint = stringResource(R.string.auto),
modifier = baseTextBoxModifier.width(IntrinsicSize.Min)
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxSize()
.padding(top = 5.dp),
horizontalArrangement = Arrangement.Center
) {
TextButton(
onClick = {
showApplicationsDialog = true
}) {
Text(applicationButtonText())
}
}
}
}
proxyPeers.forEachIndexed { index, peer ->
Surface(
tonalElevation = 2.dp,
shadowElevation = 2.dp,
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surface,
modifier = (if (WireGuardAutoTunnel.isRunningOnAndroidTv(context))
Modifier
.fillMaxHeight(fillMaxHeight)
.fillMaxWidth(fillMaxWidth)
else Modifier.fillMaxWidth(fillMaxWidth)).padding(
top = 10.dp,
bottom = 10.dp
)
) {
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Top,
modifier = Modifier
.padding(horizontal = 15.dp)
.padding(bottom = 10.dp)
) {
Row( Row(
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(horizontal = 5.dp)) { modifier = Modifier
SectionTitle( .fillMaxWidth()
stringResource(R.string.peer), padding = screenPadding) .padding(horizontal = 5.dp)
IconButton(onClick = { viewModel.onDeletePeer(index) }) { ) {
SectionTitle(stringResource(R.string.peer), padding = screenPadding)
IconButton(
onClick = {
viewModel.onDeletePeer(index)
}
) {
Icon(Icons.Rounded.Delete, stringResource(R.string.delete)) Icon(Icons.Rounded.Delete, stringResource(R.string.delete))
}
} }
}
ConfigurationTextBox( ConfigurationTextBox(
value = peer.publicKey, value = peer.publicKey,
onValueChange = { value -> onValueChange = { value ->
viewModel.onPeerPublicKeyChange(index, value) viewModel.onPeerPublicKeyChange(
index,
value
)
}, },
keyboardActions = keyboardActions, keyboardActions = keyboardActions,
label = stringResource(R.string.public_key), label = stringResource(R.string.public_key),
hint = stringResource(R.string.base64_key), hint = stringResource(R.string.base64_key),
modifier = Modifier.fillMaxWidth()) modifier = baseTextBoxModifier.fillMaxWidth()
)
ConfigurationTextBox( ConfigurationTextBox(
value = peer.preSharedKey, value = peer.preSharedKey,
onValueChange = { value -> onValueChange = { value ->
viewModel.onPreSharedKeyChange(index, value) viewModel.onPreSharedKeyChange(
index,
value
)
}, },
keyboardActions = keyboardActions, keyboardActions = keyboardActions,
label = stringResource(R.string.preshared_key), label = stringResource(R.string.preshared_key),
hint = stringResource(R.string.optional), hint = stringResource(R.string.optional),
modifier = Modifier.fillMaxWidth()) modifier = baseTextBoxModifier.fillMaxWidth()
)
OutlinedTextField( OutlinedTextField(
modifier = Modifier.fillMaxWidth(), modifier = baseTextBoxModifier.fillMaxWidth(),
value = peer.persistentKeepalive, value = peer.persistentKeepalive,
enabled = true, enabled = true,
onValueChange = { value -> onValueChange = { value ->
viewModel.onPersistentKeepaliveChanged(index, value) viewModel.onPersistentKeepaliveChanged(index, value)
},
trailingIcon = {
Text(
stringResource(R.string.seconds),
modifier = Modifier.padding(end = 10.dp))
}, },
trailingIcon = { Text(stringResource(R.string.seconds), modifier = Modifier.padding(end = 10.dp)) },
label = { Text(stringResource(R.string.persistent_keepalive)) }, label = { Text(stringResource(R.string.persistent_keepalive)) },
singleLine = true, singleLine = true,
placeholder = { placeholder = { Text(stringResource(R.string.optional_no_recommend)) },
Text(stringResource(R.string.optional_no_recommend))
},
keyboardOptions = keyboardOptions, keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions) keyboardActions = keyboardActions
)
ConfigurationTextBox( ConfigurationTextBox(
value = peer.endpoint, value = peer.endpoint,
onValueChange = { value -> onValueChange = { value ->
viewModel.onEndpointChange(index, value) viewModel.onEndpointChange(
index,
value
)
}, },
keyboardActions = keyboardActions, keyboardActions = keyboardActions,
label = stringResource(R.string.endpoint), label = stringResource(R.string.endpoint),
hint = stringResource(R.string.endpoint).lowercase(), hint = stringResource(R.string.endpoint).lowercase(),
modifier = Modifier.fillMaxWidth()) modifier = baseTextBoxModifier.fillMaxWidth()
)
OutlinedTextField( OutlinedTextField(
modifier = Modifier.fillMaxWidth(), modifier = baseTextBoxModifier.fillMaxWidth(),
value = peer.allowedIps, value = peer.allowedIps,
enabled = true, enabled = true,
onValueChange = { value -> onValueChange = { value ->
viewModel.onAllowedIpsChange(index, value) viewModel.onAllowedIpsChange(
index,
value
)
}, },
label = { Text(stringResource(R.string.allowed_ips)) }, label = { Text(stringResource(R.string.allowed_ips)) },
singleLine = true, singleLine = true,
placeholder = { placeholder = { Text(stringResource(R.string.comma_separated_list)) },
Text(stringResource(R.string.comma_separated_list))
},
keyboardOptions = keyboardOptions, keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions) keyboardActions = keyboardActions
} )
}
} }
} }
Row( Row(
horizontalArrangement = Arrangement.SpaceEvenly, horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxSize().padding(bottom = 140.dp)) { modifier = Modifier
.fillMaxSize()
.padding(bottom = 140.dp)
) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center) { horizontalArrangement = Arrangement.Center
TextButton(onClick = { viewModel.addEmptyPeer() }) { ) {
TextButton(
onClick = {
viewModel.addEmptyPeer()
}) {
Text(stringResource(R.string.add_peer)) Text(stringResource(R.string.add_peer))
}
} }
} }
}
}
if(WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
Spacer(modifier = Modifier.weight(.17f))
} }
if (WireGuardAutoTunnel.isRunningOnAndroidTv()) {
Spacer(modifier = Modifier.weight(.17f))
} }
}
} }
}
} }
@@ -1,18 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.config
import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.ui.models.InterfaceProxy
import com.zaneschepke.wireguardautotunnel.ui.models.PeerProxy
import com.zaneschepke.wireguardautotunnel.util.Packages
data class ConfigUiState(
val proxyPeers: List<PeerProxy> = arrayListOf(PeerProxy()),
val interfaceProxy: InterfaceProxy = InterfaceProxy(),
val packages: Packages = emptyList(),
val checkedPackageNames: List<String> = emptyList(),
val include: Boolean = true,
val isAllApplicationsEnabled : Boolean = false,
val loading: Boolean = true,
val tunnel: TunnelConfig? = null,
val tunnelName: String = ""
)
@@ -5,6 +5,8 @@ import android.app.Application
import android.content.pm.PackageInfo import android.content.pm.PackageInfo
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.toMutableStateList
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.wireguard.config.Config import com.wireguard.config.Config
@@ -12,301 +14,382 @@ import com.wireguard.config.Interface
import com.wireguard.config.Peer import com.wireguard.config.Peer
import com.wireguard.crypto.Key import com.wireguard.crypto.Key
import com.wireguard.crypto.KeyPair import com.wireguard.crypto.KeyPair
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel import com.zaneschepke.wireguardautotunnel.Constants
import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa
import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepository import com.zaneschepke.wireguardautotunnel.repository.TunnelConfigDao
import com.zaneschepke.wireguardautotunnel.data.repository.TunnelConfigRepository import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.ui.models.InterfaceProxy import com.zaneschepke.wireguardautotunnel.ui.models.InterfaceProxy
import com.zaneschepke.wireguardautotunnel.ui.models.PeerProxy import com.zaneschepke.wireguardautotunnel.ui.models.PeerProxy
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.Event
import com.zaneschepke.wireguardautotunnel.util.NumberUtils import com.zaneschepke.wireguardautotunnel.util.NumberUtils
import com.zaneschepke.wireguardautotunnel.util.Result import com.zaneschepke.wireguardautotunnel.util.WgTunnelException
import com.zaneschepke.wireguardautotunnel.util.removeAt
import com.zaneschepke.wireguardautotunnel.util.update
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class ConfigViewModel class ConfigViewModel @Inject constructor(private val application : Application,
@Inject private val tunnelRepo : TunnelConfigDao,
constructor( private val settingsRepo : SettingsDoa
private val application: Application,
private val tunnelConfigRepository: TunnelConfigRepository,
private val settingsRepository: SettingsRepository,
) : ViewModel() { ) : ViewModel() {
private val packageManager = application.packageManager private val _tunnel = MutableStateFlow<TunnelConfig?>(null)
private val _tunnelName = MutableStateFlow("")
val tunnelName get() = _tunnelName.asStateFlow()
val tunnel get() = _tunnel.asStateFlow()
private val _uiState = MutableStateFlow(ConfigUiState()) private var _proxyPeers = MutableStateFlow(mutableStateListOf<PeerProxy>())
val uiState = _uiState.asStateFlow() val proxyPeers get() = _proxyPeers.asStateFlow()
fun init(tunnelId : String) = viewModelScope.launch(Dispatchers.IO) { private var _interface = MutableStateFlow(InterfaceProxy())
val packages = getQueriedPackages("") val interfaceProxy = _interface.asStateFlow()
val state = if(tunnelId != Constants.MANUAL_TUNNEL_CONFIG_ID) {
val tunnelConfig = private val _packages = MutableStateFlow(emptyList<PackageInfo>())
tunnelConfigRepository.getAll().firstOrNull { it.id.toString() == tunnelId } val packages get() = _packages.asStateFlow()
if (tunnelConfig != null) { private val packageManager = application.packageManager
val config = TunnelConfig.configFromQuick(tunnelConfig.wgQuick)
val proxyPeers = config.peers.map { PeerProxy.from(it) } private val _checkedPackages = MutableStateFlow(mutableStateListOf<String>())
val proxyInterface = InterfaceProxy.from(config.`interface`) val checkedPackages get() = _checkedPackages.asStateFlow()
var include = true private val _include = MutableStateFlow(true)
var isAllApplicationsEnabled = false val include get() = _include.asStateFlow()
val checkedPackages =
if (config.`interface`.includedApplications.isNotEmpty()) { private val _isAllApplicationsEnabled = MutableStateFlow(false)
config.`interface`.includedApplications val isAllApplicationsEnabled get() = _isAllApplicationsEnabled.asStateFlow()
} else if (config.`interface`.excludedApplications.isNotEmpty()) { private val _isDefaultTunnel = MutableStateFlow(false)
include = false val isDefaultTunnel = _isDefaultTunnel.asStateFlow()
config.`interface`.excludedApplications
} else { private lateinit var tunnelConfig: TunnelConfig
isAllApplicationsEnabled = true
emptySet() suspend fun onScreenLoad(id : String) {
} if(id != Constants.MANUAL_TUNNEL_CONFIG_ID) {
ConfigUiState( tunnelConfig = getTunnelConfigById(id) ?: throw WgTunnelException("Config not found")
proxyPeers, emitScreenData()
proxyInterface,
packages,
checkedPackages.toList(),
include,
isAllApplicationsEnabled,
false,
tunnelConfig,
tunnelConfig.name)
} else {
ConfigUiState(loading = false, packages = packages)
}
} else { } else {
ConfigUiState(loading = false, packages = packages) emitEmptyScreenData()
} }
_uiState.value = state
} }
fun onTunnelNameChange(name: String) {
_uiState.value = _uiState.value.copy(tunnelName = name)
}
fun onIncludeChange(include: Boolean) { private fun emitEmptyScreenData() {
_uiState.value = _uiState.value.copy(include = include) tunnelConfig = TunnelConfig(name = NumberUtils.generateRandomTunnelName(), wgQuick = "")
} viewModelScope.launch {
emitTunnelConfig()
fun onAddCheckedPackage(packageName: String) { emitPeerProxy(PeerProxy())
_uiState.value = emitInterfaceProxy(InterfaceProxy())
_uiState.value.copy(checkedPackageNames = _uiState.value.checkedPackageNames + packageName) emitTunnelConfigName()
} emitDefaultTunnelStatus()
emitQueriedPackages("")
fun onAllApplicationsChange(isAllApplicationsEnabled: Boolean) { emitTunnelAllApplicationsEnabled()
_uiState.value = _uiState.value.copy(isAllApplicationsEnabled = isAllApplicationsEnabled)
}
fun onRemoveCheckedPackage(packageName: String) {
_uiState.value =
_uiState.value.copy(checkedPackageNames = _uiState.value.checkedPackageNames - packageName)
}
private fun getQueriedPackages(query: String): List<PackageInfo> {
return getAllInternetCapablePackages().filter {
getPackageLabel(it).lowercase().contains(query.lowercase())
}
}
fun getPackageLabel(packageInfo: PackageInfo): String {
return packageInfo.applicationInfo.loadLabel(application.packageManager).toString()
}
private fun getAllInternetCapablePackages(): List<PackageInfo> {
return getPackagesHoldingPermissions(arrayOf(Manifest.permission.INTERNET))
}
private fun getPackagesHoldingPermissions(permissions: Array<String>): List<PackageInfo> {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
packageManager.getPackagesHoldingPermissions(
permissions, PackageManager.PackageInfoFlags.of(0L))
} else {
packageManager.getPackagesHoldingPermissions(permissions, 0)
}
}
private fun isAllApplicationsEnabled(): Boolean {
return _uiState.value.isAllApplicationsEnabled
}
private fun saveConfig(tunnelConfig: TunnelConfig) =
viewModelScope.launch {
tunnelConfigRepository.save(tunnelConfig)
}
private fun updateTunnelConfig(tunnelConfig: TunnelConfig?) =
viewModelScope.launch {
if (tunnelConfig != null) {
saveConfig(tunnelConfig).join()
WireGuardAutoTunnel.requestTileServiceStateUpdate()
updateSettingsDefaultTunnel(tunnelConfig)
} }
}
private suspend fun updateSettingsDefaultTunnel(tunnelConfig: TunnelConfig) {
val settings = settingsRepository.getSettingsFlow().first()
if (settings.defaultTunnel != null) {
if (tunnelConfig.id == TunnelConfig.from(settings.defaultTunnel!!).id) {
settingsRepository.save(settings.copy(defaultTunnel = tunnelConfig.toString()))
}
} }
}
private fun buildPeerListFromProxyPeers(): List<Peer> {
return _uiState.value.proxyPeers.map { private suspend fun emitScreenData() {
val builder = Peer.Builder() emitTunnelConfig()
if (it.allowedIps.isNotEmpty()) builder.parseAllowedIPs(it.allowedIps.trim()) emitPeersFromConfig()
if (it.publicKey.isNotEmpty()) builder.parsePublicKey(it.publicKey.trim()) emitInterfaceFromConfig()
if (it.preSharedKey.isNotEmpty()) builder.parsePreSharedKey(it.preSharedKey.trim()) emitTunnelConfigName()
if (it.endpoint.isNotEmpty()) builder.parseEndpoint(it.endpoint.trim()) emitDefaultTunnelStatus()
if (it.persistentKeepalive.isNotEmpty()) { emitQueriedPackages("")
builder.parsePersistentKeepalive(it.persistentKeepalive.trim()) emitCurrentPackageConfigurations()
}
builder.build()
} }
}
private fun emptyCheckedPackagesList() { private suspend fun emitDefaultTunnelStatus() {
_uiState.value = _uiState.value.copy(checkedPackageNames = emptyList()) val settings = settingsRepo.getAll()
} if(settings.isNotEmpty()) {
_isDefaultTunnel.value = settings.first().isTunnelConfigDefault(tunnelConfig)
private fun buildInterfaceListFromProxyInterface(): Interface {
val builder = Interface.Builder()
builder.parsePrivateKey(_uiState.value.interfaceProxy.privateKey.trim())
builder.parseAddresses(_uiState.value.interfaceProxy.addresses.trim())
builder.parseDnsServers(_uiState.value.interfaceProxy.dnsServers.trim())
if (_uiState.value.interfaceProxy.mtu.isNotEmpty())
builder.parseMtu(_uiState.value.interfaceProxy.mtu.trim())
if (_uiState.value.interfaceProxy.listenPort.isNotEmpty()) {
builder.parseListenPort(_uiState.value.interfaceProxy.listenPort.trim())
}
if (isAllApplicationsEnabled()) emptyCheckedPackagesList()
if (_uiState.value.include) builder.includeApplications(_uiState.value.checkedPackageNames)
if (!_uiState.value.include) builder.excludeApplications(_uiState.value.checkedPackageNames)
return builder.build()
}
fun onSaveAllChanges(): Result<Event> {
return try {
val peerList = buildPeerListFromProxyPeers()
val wgInterface = buildInterfaceListFromProxyInterface()
val config = Config.Builder().addPeers(peerList).setInterface(wgInterface).build()
val tunnelConfig =
_uiState.value.tunnel?.copy(
name = _uiState.value.tunnelName, wgQuick = config.toWgQuickString())
updateTunnelConfig(tunnelConfig)
Result.Success(Event.Message.ConfigSaved)
} catch (e: Exception) {
Result.Error(Event.Error.Exception(e))
}
}
fun onPeerPublicKeyChange(index: Int, value: String) {
_uiState.value =
_uiState.value.copy(
proxyPeers =
_uiState.value.proxyPeers.update(
index, _uiState.value.proxyPeers[index].copy(publicKey = value)))
}
fun onPreSharedKeyChange(index: Int, value: String) {
_uiState.value =
_uiState.value.copy(
proxyPeers =
_uiState.value.proxyPeers.update(
index, _uiState.value.proxyPeers[index].copy(preSharedKey = value)))
}
fun onEndpointChange(index: Int, value: String) {
_uiState.value =
_uiState.value.copy(
proxyPeers =
_uiState.value.proxyPeers.update(
index, _uiState.value.proxyPeers[index].copy(endpoint = value)))
}
fun onAllowedIpsChange(index: Int, value: String) {
_uiState.value =
_uiState.value.copy(
proxyPeers =
_uiState.value.proxyPeers.update(
index, _uiState.value.proxyPeers[index].copy(allowedIps = value)))
}
fun onPersistentKeepaliveChanged(index: Int, value: String) {
_uiState.value =
_uiState.value.copy(
proxyPeers =
_uiState.value.proxyPeers.update(
index, _uiState.value.proxyPeers[index].copy(persistentKeepalive = value)))
}
fun onDeletePeer(index: Int) {
_uiState.value = _uiState.value.copy(
proxyPeers = _uiState.value.proxyPeers.removeAt(index)
)
}
fun addEmptyPeer() {
_uiState.value = _uiState.value.copy(proxyPeers = _uiState.value.proxyPeers + PeerProxy())
}
fun generateKeyPair() {
val keyPair = KeyPair()
_uiState.value =
_uiState.value.copy(
interfaceProxy =
_uiState.value.interfaceProxy.copy(
privateKey = keyPair.privateKey.toBase64(),
publicKey = keyPair.publicKey.toBase64()))
}
fun onAddressesChanged(value: String) {
_uiState.value =
_uiState.value.copy(interfaceProxy = _uiState.value.interfaceProxy.copy(addresses = value))
}
fun onListenPortChanged(value: String) {
_uiState.value =
_uiState.value.copy(interfaceProxy = _uiState.value.interfaceProxy.copy(listenPort = value))
}
fun onDnsServersChanged(value: String) {
_uiState.value =
_uiState.value.copy(interfaceProxy = _uiState.value.interfaceProxy.copy(dnsServers = value))
}
fun onMtuChanged(value: String) {
_uiState.value =
_uiState.value.copy(interfaceProxy = _uiState.value.interfaceProxy.copy(mtu = value))
}
private fun onInterfacePublicKeyChange(value: String) {
_uiState.value =
_uiState.value.copy(interfaceProxy = _uiState.value.interfaceProxy.copy(publicKey = value))
}
fun onPrivateKeyChange(value: String) {
_uiState.value =
_uiState.value.copy(interfaceProxy = _uiState.value.interfaceProxy.copy(privateKey = value))
if (NumberUtils.isValidKey(value)) {
val pair = KeyPair(Key.fromBase64(value))
onInterfacePublicKeyChange(pair.publicKey.toBase64())
} else {
onInterfacePublicKeyChange("")
}
}
fun emitQueriedPackages(query: String) {
val packages =
getAllInternetCapablePackages().filter {
getPackageLabel(it).lowercase().contains(query.lowercase())
} }
_uiState.value = _uiState.value.copy(packages = packages) }
}
private fun emitInterfaceFromConfig() {
val config = TunnelConfig.configFromQuick(tunnelConfig.wgQuick)
_interface.value = InterfaceProxy.from(config.`interface`)
}
private fun emitPeersFromConfig() {
val config = TunnelConfig.configFromQuick(tunnelConfig.wgQuick)
config.peers.forEach{
_proxyPeers.value.add(PeerProxy.from(it))
}
}
private fun emitPeerProxy(peerProxy: PeerProxy) {
_proxyPeers.value.add(peerProxy)
}
private fun emitInterfaceProxy(interfaceProxy: InterfaceProxy) {
_interface.value = interfaceProxy
}
private suspend fun getTunnelConfigById(id : String) : TunnelConfig? {
return try {
tunnelRepo.getById(id.toLong())
} catch (_ : Exception) {
null
}
}
private suspend fun emitTunnelConfig() {
_tunnel.emit(tunnelConfig)
}
private suspend fun emitTunnelConfigName() {
_tunnelName.emit(tunnelConfig.name)
}
fun onTunnelNameChange(name : String) {
_tunnelName.value = name
}
fun onIncludeChange(include : Boolean) {
_include.value = include
}
fun onAddCheckedPackage(packageName : String) {
_checkedPackages.value.add(packageName)
}
fun onAllApplicationsChange(isAllApplicationsEnabled : Boolean) {
_isAllApplicationsEnabled.value = isAllApplicationsEnabled
}
fun onRemoveCheckedPackage(packageName : String) {
_checkedPackages.value.remove(packageName)
}
private suspend fun emitSplitTunnelConfiguration(config : Config) {
val excludedApps = config.`interface`.excludedApplications
val includedApps = config.`interface`.includedApplications
if (excludedApps.isNotEmpty() || includedApps.isNotEmpty()) {
emitTunnelAllApplicationsDisabled()
determineAppInclusionState(excludedApps, includedApps)
} else {
emitTunnelAllApplicationsEnabled()
}
}
private suspend fun determineAppInclusionState(excludedApps : Set<String>, includedApps : Set<String>) {
if (excludedApps.isEmpty()) {
emitIncludedAppsExist()
emitCheckedApps(includedApps)
} else {
emitExcludedAppsExist()
emitCheckedApps(excludedApps)
}
}
private suspend fun emitIncludedAppsExist() {
_include.emit(true)
}
private suspend fun emitExcludedAppsExist() {
_include.emit(false)
}
private suspend fun emitCheckedApps(apps : Set<String>) {
_checkedPackages.emit(apps.toMutableStateList())
}
private suspend fun emitTunnelAllApplicationsEnabled() {
_isAllApplicationsEnabled.emit(true)
}
private suspend fun emitTunnelAllApplicationsDisabled() {
_isAllApplicationsEnabled.emit(false)
}
private fun emitCurrentPackageConfigurations() {
viewModelScope.launch(Dispatchers.IO) {
val config = TunnelConfig.configFromQuick(tunnelConfig.wgQuick)
emitSplitTunnelConfiguration(config)
}
}
fun emitQueriedPackages(query : String) {
viewModelScope.launch(Dispatchers.IO) {
val packages = getAllInternetCapablePackages().filter {
getPackageLabel(it).lowercase().contains(query.lowercase())
}
_packages.emit(packages)
}
}
fun getPackageLabel(packageInfo : PackageInfo) : String {
return packageInfo.applicationInfo.loadLabel(application.packageManager).toString()
}
private fun getAllInternetCapablePackages() : List<PackageInfo> {
return getPackagesHoldingPermissions(arrayOf(Manifest.permission.INTERNET))
}
private fun getPackagesHoldingPermissions(permissions: Array<String>): List<PackageInfo> {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
packageManager.getPackagesHoldingPermissions(permissions, PackageManager.PackageInfoFlags.of(0L))
} else {
packageManager.getPackagesHoldingPermissions(permissions, 0)
}
}
private fun isAllApplicationsEnabled() : Boolean {
return _isAllApplicationsEnabled.value
}
private fun isIncludeApplicationsEnabled() : Boolean {
return _include.value
}
private suspend fun saveConfig(tunnelConfig: TunnelConfig) {
tunnelRepo.save(tunnelConfig)
}
private suspend fun updateTunnelConfig(tunnelConfig: TunnelConfig?) {
if(tunnelConfig != null) {
saveConfig(tunnelConfig)
updateSettingsDefaultTunnel(tunnelConfig)
}
}
private suspend fun updateSettingsDefaultTunnel(tunnelConfig: TunnelConfig) {
val settings = settingsRepo.getAll()
if(settings.isNotEmpty()) {
val setting = settings[0]
if(setting.defaultTunnel != null) {
if(tunnelConfig.id == TunnelConfig.from(setting.defaultTunnel!!).id) {
settingsRepo.save(setting.copy(
defaultTunnel = tunnelConfig.toString()
))
}
}
}
}
fun buildPeerListFromProxyPeers() : List<Peer> {
return _proxyPeers.value.map {
val builder = Peer.Builder()
if (it.allowedIps.isNotEmpty()) builder.parseAllowedIPs(it.allowedIps.trim())
if (it.publicKey.isNotEmpty()) builder.parsePublicKey(it.publicKey.trim())
if (it.preSharedKey.isNotEmpty()) builder.parsePreSharedKey(it.preSharedKey.trim())
if (it.endpoint.isNotEmpty()) builder.parseEndpoint(it.endpoint.trim())
if (it.persistentKeepalive.isNotEmpty()) builder.parsePersistentKeepalive(it.persistentKeepalive.trim())
builder.build()
}
}
fun buildInterfaceListFromProxyInterface() : Interface {
val builder = Interface.Builder()
builder.parsePrivateKey(_interface.value.privateKey.trim())
builder.parseAddresses(_interface.value.addresses.trim())
builder.parseDnsServers(_interface.value.dnsServers.trim())
if(_interface.value.mtu.isNotEmpty()) builder.parseMtu(_interface.value.mtu.trim())
if(_interface.value.listenPort.isNotEmpty()) builder.parseListenPort(_interface.value.listenPort.trim())
if(isAllApplicationsEnabled()) _checkedPackages.value.clear()
if(_include.value) builder.includeApplications(_checkedPackages.value)
if(!_include.value) builder.excludeApplications(_checkedPackages.value)
return builder.build()
}
suspend fun onSaveAllChanges() {
try {
val peerList = buildPeerListFromProxyPeers()
val wgInterface = buildInterfaceListFromProxyInterface()
val config = Config.Builder().addPeers(peerList).setInterface(wgInterface).build()
val tunnelConfig = _tunnel.value?.copy(
name = _tunnelName.value,
wgQuick = config.toWgQuickString()
)
updateTunnelConfig(tunnelConfig)
} catch (e : Exception) {
throw WgTunnelException("Error: ${e.cause?.message?.lowercase() ?: "unknown error occurred"}")
}
}
fun onPeerPublicKeyChange(index: Int, publicKey: String) {
_proxyPeers.value[index] = _proxyPeers.value[index].copy(
publicKey = publicKey
)
}
fun onPreSharedKeyChange(index: Int, value: String) {
_proxyPeers.value[index] = _proxyPeers.value[index].copy(
preSharedKey = value
)
}
fun onEndpointChange(index: Int, value: String) {
_proxyPeers.value[index] = _proxyPeers.value[index].copy(
endpoint = value
)
}
fun onAllowedIpsChange(index: Int, value: String) {
_proxyPeers.value[index] = _proxyPeers.value[index].copy(
allowedIps = value
)
}
fun onPersistentKeepaliveChanged(index : Int, value : String) {
_proxyPeers.value[index] = _proxyPeers.value[index].copy(
persistentKeepalive = value
)
}
fun onDeletePeer(index: Int) {
proxyPeers.value.removeAt(index)
}
fun addEmptyPeer() {
_proxyPeers.value.add(PeerProxy())
}
fun generateKeyPair() {
val keyPair = KeyPair()
_interface.value = _interface.value.copy(
privateKey = keyPair.privateKey.toBase64(),
publicKey = keyPair.publicKey.toBase64()
)
}
fun onAddressesChanged(value: String) {
_interface.value = _interface.value.copy(
addresses = value
)
}
fun onListenPortChanged(value: String) {
_interface.value = _interface.value.copy(
listenPort = value
)
}
fun onDnsServersChanged(value: String) {
_interface.value = _interface.value.copy(
dnsServers = value
)
}
fun onMtuChanged(value: String) {
_interface.value = _interface.value.copy(
mtu = value
)
}
private fun onInterfacePublicKeyChange(value : String) {
_interface.value = _interface.value.copy(
publicKey = value
)
}
fun onPrivateKeyChange(value: String) {
_interface.value = _interface.value.copy(
privateKey = value
)
if(NumberUtils.isValidKey(value)) {
val pair = KeyPair(Key.fromBase64(value))
onInterfacePublicKeyChange(pair.publicKey.toBase64())
} else {
onInterfacePublicKeyChange("")
}
}
} }
@@ -8,36 +8,27 @@ import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable import androidx.compose.foundation.focusable
import androidx.compose.foundation.gestures.ScrollableDefaults
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredWidth
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.overscroll
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Create import androidx.compose.material.icons.filled.Create
import androidx.compose.material.icons.filled.FileOpen import androidx.compose.material.icons.filled.FileOpen
import androidx.compose.material.icons.filled.QrCode import androidx.compose.material.icons.filled.QrCode
import androidx.compose.material.icons.rounded.Add import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.Bolt
import androidx.compose.material.icons.rounded.Circle import androidx.compose.material.icons.rounded.Circle
import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.Edit import androidx.compose.material.icons.rounded.Edit
@@ -51,18 +42,14 @@ import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MaterialTheme.typography
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -74,10 +61,13 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@@ -89,474 +79,439 @@ import androidx.navigation.NavController
import com.journeyapps.barcodescanner.ScanContract import com.journeyapps.barcodescanner.ScanContract
import com.journeyapps.barcodescanner.ScanOptions import com.journeyapps.barcodescanner.ScanOptions
import com.wireguard.android.backend.Tunnel import com.wireguard.android.backend.Tunnel
import com.zaneschepke.wireguardautotunnel.Constants
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.service.tunnel.HandshakeStatus import com.zaneschepke.wireguardautotunnel.service.tunnel.HandshakeStatus
import com.zaneschepke.wireguardautotunnel.ui.CaptureActivityPortrait import com.zaneschepke.wireguardautotunnel.ui.CaptureActivityPortrait
import com.zaneschepke.wireguardautotunnel.ui.Screen import com.zaneschepke.wireguardautotunnel.ui.Routes
import com.zaneschepke.wireguardautotunnel.ui.common.RowListItem import com.zaneschepke.wireguardautotunnel.ui.common.RowListItem
import com.zaneschepke.wireguardautotunnel.ui.common.screen.LoadingScreen import com.zaneschepke.wireguardautotunnel.ui.theme.brickRed
import com.zaneschepke.wireguardautotunnel.ui.theme.corn
import com.zaneschepke.wireguardautotunnel.ui.theme.mint import com.zaneschepke.wireguardautotunnel.ui.theme.mint
import com.zaneschepke.wireguardautotunnel.util.Constants import com.zaneschepke.wireguardautotunnel.util.WgTunnelException
import com.zaneschepke.wireguardautotunnel.util.Event
import com.zaneschepke.wireguardautotunnel.util.Result
import com.zaneschepke.wireguardautotunnel.util.handshakeStatus
import com.zaneschepke.wireguardautotunnel.util.mapPeerStats
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun MainScreen( fun MainScreen(
viewModel: MainViewModel = hiltViewModel(), viewModel: MainViewModel = hiltViewModel(),
padding: PaddingValues, padding: PaddingValues,
focusRequester: FocusRequester,
showSnackbarMessage: (String) -> Unit, showSnackbarMessage: (String) -> Unit,
navController: NavController navController: NavController
) { ) {
val haptic = LocalHapticFeedback.current
val context = LocalContext.current
val isVisible = rememberSaveable { mutableStateOf(true) }
val scope = rememberCoroutineScope { Dispatchers.IO }
val sheetState = rememberModalBottomSheetState() val haptic = LocalHapticFeedback.current
var showBottomSheet by remember { mutableStateOf(false) } val context = LocalContext.current
val isVisible = rememberSaveable { mutableStateOf(true) }
val scope = rememberCoroutineScope { Dispatchers.IO }
var showPrimaryChangeAlertDialog by remember { mutableStateOf(false) } val sheetState = rememberModalBottomSheetState()
var selectedTunnel by remember { mutableStateOf<TunnelConfig?>(null) } var showBottomSheet by remember { mutableStateOf(false) }
val uiState by viewModel.uiState.collectAsStateWithLifecycle() var showPrimaryChangeAlertDialog by remember { mutableStateOf(false) }
val tunnels by viewModel.tunnels.collectAsStateWithLifecycle(mutableListOf())
val handshakeStatus by viewModel.handshakeStatus.collectAsStateWithLifecycle(HandshakeStatus.NOT_STARTED)
var selectedTunnel by remember { mutableStateOf<TunnelConfig?>(null) }
val state by viewModel.state.collectAsStateWithLifecycle(Tunnel.State.DOWN)
val tunnelName by viewModel.tunnelName.collectAsStateWithLifecycle("")
val settings by viewModel.settings.collectAsStateWithLifecycle()
val statistics by viewModel.statistics.collectAsStateWithLifecycle(null)
LaunchedEffect(uiState.loading) { // Nested scroll for control FAB
if (!uiState.loading && WireGuardAutoTunnel.isRunningOnAndroidTv()) { val nestedScrollConnection = remember {
delay(Constants.FOCUS_REQUEST_DELAY) object : NestedScrollConnection {
focusRequester.requestFocus() override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
// Hide FAB
if (available.y < -1) {
isVisible.value = false
}
// Show FAB
if (available.y > 1) {
isVisible.value = true
}
return Offset.Zero
}
}
} }
}
if (uiState.loading) { val tunnelFileImportResultLauncher = rememberLauncherForActivityResult(object : ActivityResultContracts.GetContent() {
LoadingScreen() override fun createIntent(context: Context, input: String): Intent {
return val intent = super.createIntent(context, input)
}
val tunnelFileImportResultLauncher = /* AndroidTV now comes with stubs that do nothing but display a Toast less helpful than
rememberLauncherForActivityResult( * what we can do, so detect this and throw an exception that we can catch later. */
object : ActivityResultContracts.GetContent() { val activitiesToResolveIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
override fun createIntent(context: Context, input: String): Intent { context.packageManager.queryIntentActivities(intent, PackageManager.ResolveInfoFlags.of(PackageManager.MATCH_DEFAULT_ONLY.toLong()))
val intent = super.createIntent(context, input) } else {
context.packageManager.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY)
/* AndroidTV now comes with stubs that do nothing but display a Toast less helpful than
* what we can do, so detect this and throw an exception that we can catch later. */
val activitiesToResolveIntent =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.packageManager.queryIntentActivities(
intent,
PackageManager.ResolveInfoFlags.of(
PackageManager.MATCH_DEFAULT_ONLY.toLong()))
} else {
context.packageManager.queryIntentActivities(
intent, PackageManager.MATCH_DEFAULT_ONLY)
}
if (activitiesToResolveIntent.all {
val name = it.activityInfo.packageName
name.startsWith(Constants.GOOGLE_TV_EXPLORER_STUB) ||
name.startsWith(Constants.ANDROID_TV_EXPLORER_STUB)
}) {
showSnackbarMessage(Event.Error.FileExplorerRequired.message)
}
return intent
} }
}) { data -> if (activitiesToResolveIntent.all {
if (data == null) return@rememberLauncherForActivityResult val name = it.activityInfo.packageName
name.startsWith(Constants.GOOGLE_TV_EXPLORER_STUB) || name.startsWith(Constants.ANDROID_TV_EXPLORER_STUB)
}) {
throw WgTunnelException(context.getString(R.string.no_file_explorer))
}
return intent
}
}) { data ->
if (data == null) return@rememberLauncherForActivityResult
scope.launch(Dispatchers.IO) {
try {
viewModel.onTunnelFileSelected(data)
} catch (e : WgTunnelException) {
showSnackbarMessage(e.message)
}
}
}
val scanLauncher = rememberLauncherForActivityResult(
contract = ScanContract(),
onResult = {
scope.launch { scope.launch {
viewModel.onTunnelFileSelected(data).let { try {
when (it) { viewModel.onTunnelQrResult(it.contents)
is Result.Error -> showSnackbarMessage(it.error.message) } catch (e: Exception) {
is Result.Success -> {} when(e) {
is WgTunnelException -> {
showSnackbarMessage(e.message)
} else -> {
showSnackbarMessage("No QR code scanned")
}
}
} }
}
} }
} }
val scanLauncher = )
rememberLauncherForActivityResult(
contract = ScanContract(),
onResult = {
if (it.contents != null) {
scope.launch {
viewModel.onTunnelQrResult(it.contents).let { result ->
when (result) {
is Result.Success -> {}
is Result.Error -> showSnackbarMessage(result.error.message)
}
}
}
}
})
AnimatedVisibility(showPrimaryChangeAlertDialog) { if(showPrimaryChangeAlertDialog) {
AlertDialog( AlertDialog(
onDismissRequest = { showPrimaryChangeAlertDialog = false }, onDismissRequest = {
confirmButton = { showPrimaryChangeAlertDialog = false
TextButton( },
onClick = { confirmButton = {
viewModel.onDefaultTunnelChange(selectedTunnel) TextButton(onClick = {
showPrimaryChangeAlertDialog = false scope.launch {
selectedTunnel = null viewModel.onDefaultTunnelChange(selectedTunnel)
}) { showPrimaryChangeAlertDialog = false
Text(text = stringResource(R.string.okay)) selectedTunnel = null
} }
},
dismissButton = {
TextButton(onClick = { showPrimaryChangeAlertDialog = false }) {
Text(text = stringResource(R.string.cancel))
}
},
title = { Text(text = stringResource(R.string.primary_tunnel_change)) },
text = { Text(text = stringResource(R.string.primary_tunnel_change_question)) })
}
fun onTunnelToggle(checked: Boolean, tunnel: TunnelConfig) {
if (checked) viewModel.onTunnelStart(tunnel) else viewModel.onTunnelStop()
}
Scaffold(
modifier =
Modifier.pointerInput(Unit) {
detectTapGestures(
onTap = {
if (!WireGuardAutoTunnel.isRunningOnAndroidTv()) selectedTunnel = null
}) })
}, { Text(text = stringResource(R.string.okay)) }
floatingActionButtonPosition = FabPosition.End, },
topBar = { dismissButton = {
if (uiState.settings.isAutoTunnelEnabled) TextButton(onClick = {
TopAppBar( showPrimaryChangeAlertDialog = false
title = { })
Row( { Text(text = stringResource(R.string.cancel)) }
horizontalArrangement = Arrangement.SpaceBetween, },
verticalAlignment = Alignment.CenterVertically, title = { Text(text = stringResource(R.string.primary_tunnel_change)) },
modifier = Modifier.requiredWidth(LocalConfiguration.current.screenWidthDp.dp).padding(end = 5.dp)) { text = { Text(text = stringResource(R.string.primary_tunnel_change_question)) }
Row { )
Icon( }
Icons.Rounded.Bolt,
stringResource(id = R.string.auto), fun onTunnelToggle(checked : Boolean , tunnel : TunnelConfig) {
modifier = Modifier.size(25.dp), try {
tint = if(uiState.settings.isAutoTunnelPaused) Color.Gray else mint) if (checked) viewModel.onTunnelStart(tunnel) else viewModel.onTunnelStop()
Text( } catch (e : Exception) {
"Auto-tunneling: ${if(uiState.settings.isAutoTunnelPaused) "paused" else "active" }", showSnackbarMessage(e.message!!)
style = typography.bodyLarge, }
modifier = Modifier.padding(start = 10.dp)) }
}
if(uiState.settings.isAutoTunnelPaused) TextButton( Scaffold(
onClick = { viewModel.resumeAutoTunneling() }, modifier = Modifier.pointerInput(Unit) {
modifier = Modifier.padding(end = 10.dp)) { detectTapGestures(onTap = {
Text("Resume") selectedTunnel = null
} else TextButton( })
onClick = { viewModel.pauseAutoTunneling() }, },
modifier = Modifier.padding(end = 10.dp)) { floatingActionButtonPosition = FabPosition.End,
Text("Pause") floatingActionButton = {
} AnimatedVisibility(
} visible = isVisible.value,
}, enter = slideInVertically(initialOffsetY = { it * 2 }),
) exit = slideOutVertically(targetOffsetY = { it * 2 }),
}, ) {
floatingActionButton = { val secondaryColor = MaterialTheme.colorScheme.secondary
AnimatedVisibility( val hoverColor = MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp)
visible = isVisible.value, var fobColor by remember { mutableStateOf(secondaryColor) }
enter = slideInVertically(initialOffsetY = { it * 2 }), FloatingActionButton(
exit = slideOutVertically(targetOffsetY = { it * 2 })) { modifier = Modifier
val secondaryColor = MaterialTheme.colorScheme.secondary .padding(bottom = 90.dp)
val hoverColor = MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp) .onFocusChanged {
var fobColor by remember { mutableStateOf(secondaryColor) } if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
FloatingActionButton( fobColor = if (it.isFocused) hoverColor else secondaryColor
modifier =
(if (WireGuardAutoTunnel.isRunningOnAndroidTv() &&
uiState.tunnels.isEmpty())
Modifier.focusRequester(focusRequester)
else Modifier)
.padding(bottom = 90.dp)
.onFocusChanged {
if (WireGuardAutoTunnel.isRunningOnAndroidTv()) {
fobColor = if (it.isFocused) hoverColor else secondaryColor
} }
}, }
onClick = { showBottomSheet = true }, ,
containerColor = fobColor, onClick = {
shape = RoundedCornerShape(16.dp)) { showBottomSheet = true
},
containerColor = fobColor,
shape = RoundedCornerShape(16.dp),
) {
Icon( Icon(
imageVector = Icons.Rounded.Add, imageVector = Icons.Rounded.Add,
contentDescription = stringResource(id = R.string.add_tunnel), contentDescription = stringResource(id = R.string.add_tunnel),
tint = Color.DarkGray) tint = Color.DarkGray,
} )
}
} }
}) { innerPadding -> }
AnimatedVisibility(uiState.tunnels.isEmpty(), exit = fadeOut(), enter = fadeIn()) { ) {
Column( if (tunnels.isEmpty()) {
horizontalAlignment = Alignment.CenterHorizontally, Column(
verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxSize().padding(padding)) { verticalArrangement = Arrangement.Center,
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
Text(text = stringResource(R.string.no_tunnels), fontStyle = FontStyle.Italic) Text(text = stringResource(R.string.no_tunnels), fontStyle = FontStyle.Italic)
} }
} }
if (showBottomSheet) { if (showBottomSheet) {
ModalBottomSheet( ModalBottomSheet(
onDismissRequest = { showBottomSheet = false }, sheetState = sheetState) { onDismissRequest = {
showBottomSheet = false
},
sheetState = sheetState
) {
// Sheet content // Sheet content
Row( Row(
modifier = modifier = Modifier
Modifier.fillMaxWidth() .fillMaxWidth()
.clickable { .clickable {
showBottomSheet = false showBottomSheet = false
tunnelFileImportResultLauncher.launch(Constants.ALLOWED_FILE_TYPES) try {
tunnelFileImportResultLauncher.launch(Constants.ALLOWED_FILE_TYPES)
} catch (e: Exception) {
showSnackbarMessage(e.message!!)
} }
.padding(10.dp)) { }
Icon( .padding(10.dp)
Icons.Filled.FileOpen, ) {
contentDescription = stringResource(id = R.string.open_file), Icon(
modifier = Modifier.padding(10.dp)) Icons.Filled.FileOpen,
Text( contentDescription = stringResource(id = R.string.open_file),
stringResource(id = R.string.add_tunnels_text), modifier = Modifier.padding(10.dp)
modifier = Modifier.padding(10.dp)) )
} Text(
if (!WireGuardAutoTunnel.isRunningOnAndroidTv()) { stringResource(id = R.string.add_tunnels_text),
Divider() modifier = Modifier.padding(10.dp)
Row( )
modifier = }
Modifier.fillMaxWidth() if(!WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
.clickable { Divider()
scope.launch { Row(modifier = Modifier
showBottomSheet = false .fillMaxWidth()
val scanOptions = ScanOptions() .clickable {
scanOptions.setDesiredBarcodeFormats(ScanOptions.QR_CODE) scope.launch {
scanOptions.setOrientationLocked(true) showBottomSheet = false
scanOptions.setPrompt(context.getString(R.string.scanning_qr)) val scanOptions = ScanOptions()
scanOptions.setBeepEnabled(false) scanOptions.setDesiredBarcodeFormats(ScanOptions.QR_CODE)
scanOptions.captureActivity = CaptureActivityPortrait::class.java scanOptions.setOrientationLocked(true)
scanLauncher.launch(scanOptions) scanOptions.setPrompt(context.getString(R.string.scanning_qr))
} scanOptions.setBeepEnabled(false)
} scanOptions.captureActivity = CaptureActivityPortrait::class.java
.padding(10.dp)) { scanLauncher.launch(scanOptions)
}
}
.padding(10.dp)
) {
Icon( Icon(
Icons.Filled.QrCode, Icons.Filled.QrCode,
contentDescription = stringResource(id = R.string.qr_scan), contentDescription = stringResource(id = R.string.qr_scan),
modifier = Modifier.padding(10.dp)) modifier = Modifier.padding(10.dp)
)
Text( Text(
stringResource(id = R.string.add_from_qr), stringResource(id = R.string.add_from_qr),
modifier = Modifier.padding(10.dp)) modifier = Modifier.padding(10.dp)
} )
}
} }
Divider() Divider()
Row( Row(
modifier = modifier = Modifier
Modifier.fillMaxWidth() .fillMaxWidth()
.clickable { .clickable {
showBottomSheet = false showBottomSheet = false
navController.navigate( navController.navigate("${Routes.Config.name}/${Constants.MANUAL_TUNNEL_CONFIG_ID}")
"${Screen.Config.route}/${Constants.MANUAL_TUNNEL_CONFIG_ID}") }
} .padding(10.dp)
.padding(10.dp)) { ) {
Icon( Icon(
Icons.Filled.Create, Icons.Filled.Create,
contentDescription = stringResource(id = R.string.create_import), contentDescription = stringResource(id = R.string.create_import),
modifier = Modifier.padding(10.dp)) modifier = Modifier.padding(10.dp)
Text( )
stringResource(id = R.string.create_import), Text(
modifier = Modifier.padding(10.dp)) stringResource(id = R.string.create_import),
} modifier = Modifier.padding(10.dp)
} )
}
}
} }
Column(
LazyColumn(
horizontalAlignment = Alignment.Start, horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Top, verticalArrangement = Arrangement.Top,
modifier = modifier = Modifier
Modifier.fillMaxWidth().fillMaxHeight(.90f).overscroll(ScrollableDefaults.overscrollEffect()).padding(innerPadding), .fillMaxSize()
state = rememberLazyListState(0, uiState.tunnels.count()), .padding(padding)
userScrollEnabled = true, ) {
reverseLayout = true, LazyColumn(
flingBehavior = ScrollableDefaults.flingBehavior()) { modifier = Modifier
items(uiState.tunnels, .fillMaxSize()
key = { tunnel -> tunnel.id }) { tunnel -> .nestedScroll(nestedScrollConnection),
val leadingIconColor = ) {
(if (uiState.vpnState.name == tunnel.name && items(tunnels, key = { tunnel -> tunnel.id }) { tunnel ->
uiState.vpnState.status == Tunnel.State.UP) { val leadingIconColor = (if (tunnelName == tunnel.name) when (handshakeStatus) {
uiState.vpnState.statistics HandshakeStatus.HEALTHY -> mint
?.mapPeerStats() HandshakeStatus.UNHEALTHY -> brickRed
?.map { it.value?.handshakeStatus() } HandshakeStatus.NOT_STARTED -> Color.Gray
.let { statuses -> HandshakeStatus.NEVER_CONNECTED -> brickRed
when { } else {Color.Gray})
statuses?.all { it == HandshakeStatus.HEALTHY } == true -> mint val focusRequester = remember { FocusRequester() }
statuses?.any { it == HandshakeStatus.STALE } == true -> corn val expanded = remember {
statuses?.all { it == HandshakeStatus.NOT_STARTED } == true -> mutableStateOf(false)
Color.Gray }
else -> { RowListItem(icon = {
Color.Gray if (settings.isTunnelConfigDefault(tunnel))
}
}
}
} else {
Color.Gray
})
val expanded = remember { mutableStateOf(false) }
RowListItem(
icon = {
if (uiState.settings.isTunnelConfigDefault(tunnel)) {
Icon( Icon(
Icons.Rounded.Star, Icons.Rounded.Star, stringResource(R.string.status),
stringResource(R.string.status),
tint = leadingIconColor, tint = leadingIconColor,
modifier = Modifier.padding(end = 10.dp).size(20.dp)) modifier = Modifier
} else { .padding(end = 10.dp)
Icon( .size(20.dp)
Icons.Rounded.Circle, )
stringResource(R.string.status), else Icon(
tint = leadingIconColor, Icons.Rounded.Circle, stringResource(R.string.status),
modifier = Modifier.padding(end = 15.dp).size(15.dp)) tint = leadingIconColor,
} modifier = Modifier
}, .padding(end = 15.dp)
.size(15.dp)
)
},
text = tunnel.name, text = tunnel.name,
onHold = { onHold = {
if ((uiState.vpnState.status == Tunnel.State.UP) && if (state == Tunnel.State.UP && tunnel.name == tunnelName) {
(tunnel.name == uiState.vpnState.name)) { showSnackbarMessage(context.resources.getString(R.string.turn_off_tunnel))
showSnackbarMessage(Event.Message.TunnelOffAction.message) return@RowListItem
return@RowListItem }
} haptic.performHapticFeedback(HapticFeedbackType.LongPress)
haptic.performHapticFeedback(HapticFeedbackType.LongPress) selectedTunnel = tunnel
selectedTunnel = tunnel
}, },
onClick = { onClick = {
if (!WireGuardAutoTunnel.isRunningOnAndroidTv()) { if (!WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
if (uiState.vpnState.status == Tunnel.State.UP && if(state == Tunnel.State.UP && (tunnelName == tunnel.name) ) {
(uiState.vpnState.name == tunnel.name)) { expanded.value = !expanded.value
expanded.value = !expanded.value }
} else {
selectedTunnel = tunnel
focusRequester.requestFocus()
} }
} else {
selectedTunnel = tunnel
focusRequester.requestFocus()
}
}, },
statistics = uiState.vpnState.statistics, statistics = statistics,
expanded = expanded.value, expanded = expanded.value,
rowButton = { rowButton = {
if (tunnel.id == selectedTunnel?.id && if (tunnel.id == selectedTunnel?.id && !WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
!WireGuardAutoTunnel.isRunningOnAndroidTv()) { Row {
Row { if(!settings.isTunnelConfigDefault(tunnel)) {
if (!uiState.settings.isTunnelConfigDefault(tunnel)) { IconButton(onClick = {
IconButton( if(settings.isAutoTunnelEnabled) {
onClick = { showSnackbarMessage(context.resources.getString(R.string.turn_off_auto))
if (uiState.settings.isAutoTunnelEnabled && !uiState.settings.isAutoTunnelPaused) { } else showPrimaryChangeAlertDialog = true
showSnackbarMessage( }) {
Event.Message.AutoTunnelOffAction.message) Icon(Icons.Rounded.Star, stringResource(id = R.string.set_primary))
} else { }
showPrimaryChangeAlertDialog = true
}
}) {
Icon(
Icons.Rounded.Star,
stringResource(id = R.string.set_primary))
} }
} IconButton(onClick = {
IconButton( navController.navigate("${Routes.Config.name}/${selectedTunnel?.id}")
onClick = { }) {
if (uiState.settings.isAutoTunnelEnabled && uiState.settings.isTunnelConfigDefault(tunnel) Icon(Icons.Rounded.Edit, stringResource(id = R.string.edit))
&& !uiState.settings.isAutoTunnelPaused) { }
showSnackbarMessage( IconButton(
Event.Message.AutoTunnelOffAction.message) modifier = Modifier.focusable(),
} else navController.navigate( onClick = { viewModel.onDelete(tunnel) }) {
"${Screen.Config.route}/${selectedTunnel?.id}") Icon(
}) { Icons.Rounded.Delete,
Icon(Icons.Rounded.Edit, stringResource(id = R.string.edit)) stringResource(id = R.string.delete)
} )
IconButton( }
modifier = Modifier.focusable(), }
onClick = { viewModel.onDelete(tunnel) }) { } else {
Icon(Icons.Rounded.Delete, stringResource(id = R.string.delete)) val checked = state == Tunnel.State.UP && tunnel.name == tunnelName
} if(!checked) expanded.value = false
} @Composable
} else { fun TunnelSwitch() = Switch(
val checked by remember {
derivedStateOf {
(uiState.vpnState.status == Tunnel.State.UP &&
tunnel.name == uiState.vpnState.name)
}
}
if (!checked) expanded.value = false
@Composable
fun TunnelSwitch() =
Switch(
modifier = Modifier.focusRequester(focusRequester), modifier = Modifier.focusRequester(focusRequester),
checked = checked, checked = checked,
onCheckedChange = { checked -> onCheckedChange = { checked ->
if (!checked) expanded.value = false if(!checked) expanded.value = false
onTunnelToggle(checked, tunnel) onTunnelToggle(checked, tunnel)
}) }
if (WireGuardAutoTunnel.isRunningOnAndroidTv()) { )
Row { if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
if (!uiState.settings.isTunnelConfigDefault(tunnel)) { Row {
IconButton( if(!settings.isTunnelConfigDefault(tunnel)) {
onClick = { IconButton(onClick = {
if (uiState.settings.isAutoTunnelEnabled) { if(settings.isAutoTunnelEnabled) {
showSnackbarMessage( showSnackbarMessage(context.resources.getString(R.string.turn_off_auto))
Event.Message.AutoTunnelOffAction.message) } else showPrimaryChangeAlertDialog = true
} else { }) {
selectedTunnel = tunnel Icon(Icons.Rounded.Star, stringResource(id = R.string.set_primary))
showPrimaryChangeAlertDialog = true }
} }
}) { IconButton(
Icon( modifier = Modifier.focusRequester(focusRequester),
Icons.Rounded.Star, onClick = {
stringResource(id = R.string.set_primary)) if(state == Tunnel.State.UP && (tunnelName == tunnel.name) ) {
} expanded.value = !expanded.value
}
}) {
Icon(Icons.Rounded.Info, stringResource(R.string.info))
}
IconButton(onClick = {
if (state == Tunnel.State.UP && tunnel.name == tunnelName)
showSnackbarMessage(
context.resources.getString(
R.string.turn_off_tunnel
)
)
else {
navController.navigate("${Routes.Config.name}/${tunnel.id}")
}
}) {
Icon(
Icons.Rounded.Edit,
stringResource(id = R.string.edit)
)
}
IconButton(onClick = {
if (state == Tunnel.State.UP && tunnel.name == tunnelName)
showSnackbarMessage(
context.resources.getString(
R.string.turn_off_tunnel
)
)
else {
viewModel.onDelete(tunnel)
}
}) {
Icon(
Icons.Rounded.Delete,
stringResource(id = R.string.delete)
)
}
TunnelSwitch()
}
} else {
TunnelSwitch()
} }
IconButton(
modifier = Modifier.focusRequester(focusRequester),
onClick = {
if (uiState.vpnState.status == Tunnel.State.UP &&
(uiState.vpnState.name == tunnel.name)) {
expanded.value = !expanded.value
} else {
showSnackbarMessage(Event.Message.TunnelOnAction.message)
}
}) {
Icon(Icons.Rounded.Info, stringResource(R.string.info))
}
IconButton(
onClick = {
if (uiState.vpnState.status == Tunnel.State.UP &&
tunnel.name == uiState.vpnState.name) {
showSnackbarMessage(Event.Message.TunnelOffAction.message)
} else {
navController.navigate(
"${Screen.Config.route}/${tunnel.id}")
}
}) {
Icon(Icons.Rounded.Edit, stringResource(id = R.string.edit))
}
IconButton(
onClick = {
if (uiState.vpnState.status == Tunnel.State.UP &&
tunnel.name == uiState.vpnState.name) {
showSnackbarMessage(Event.Message.TunnelOffAction.message)
} else {
viewModel.onDelete(tunnel)
}
}) {
Icon(
Icons.Rounded.Delete,
stringResource(id = R.string.delete))
}
TunnelSwitch()
}
} else {
TunnelSwitch()
} }
}
}) })
} }
} }
} }
}
} }
@@ -1,12 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main
import com.zaneschepke.wireguardautotunnel.data.model.Settings
import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnState
import com.zaneschepke.wireguardautotunnel.util.TunnelConfigs
data class MainUiState(
val settings : Settings = Settings(),
val tunnels : TunnelConfigs = emptyList(),
val vpnState: VpnState = VpnState(),
val loading : Boolean = true
)
@@ -8,254 +8,252 @@ import android.provider.OpenableColumns
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.wireguard.config.Config import com.wireguard.config.Config
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel import com.zaneschepke.wireguardautotunnel.Constants
import com.zaneschepke.wireguardautotunnel.data.model.Settings import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa
import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepository import com.zaneschepke.wireguardautotunnel.repository.TunnelConfigDao
import com.zaneschepke.wireguardautotunnel.data.repository.TunnelConfigRepository import com.zaneschepke.wireguardautotunnel.repository.model.Settings
import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceState import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceState
import com.zaneschepke.wireguardautotunnel.service.foreground.WireGuardConnectivityWatcherService import com.zaneschepke.wireguardautotunnel.service.foreground.WireGuardConnectivityWatcherService
import com.zaneschepke.wireguardautotunnel.service.foreground.WireGuardTunnelService import com.zaneschepke.wireguardautotunnel.service.foreground.WireGuardTunnelService
import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService
import com.zaneschepke.wireguardautotunnel.service.tunnel.WireGuardTunnel
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.Event
import com.zaneschepke.wireguardautotunnel.util.NumberUtils import com.zaneschepke.wireguardautotunnel.util.NumberUtils
import com.zaneschepke.wireguardautotunnel.util.Result import com.zaneschepke.wireguardautotunnel.util.WgTunnelException
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.InputStream import java.io.InputStream
import java.util.zip.ZipInputStream import java.util.zip.ZipInputStream
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class MainViewModel class MainViewModel @Inject constructor(
@Inject
constructor(
private val application: Application, private val application: Application,
private val tunnelConfigRepository: TunnelConfigRepository, private val tunnelRepo: TunnelConfigDao,
private val settingsRepository: SettingsRepository, private val settingsRepo: SettingsDoa,
private val vpnService: VpnService private val vpnService: VpnService
) : ViewModel() { ) : ViewModel() {
val uiState = val tunnels get() = tunnelRepo.getAllFlow()
combine( val state get() = vpnService.state
settingsRepository.getSettingsFlow(),
tunnelConfigRepository.getTunnelConfigsFlow(),
vpnService.vpnState,
) { settings, tunnels, vpnState ->
validateWatcherServiceState(settings)
MainUiState(settings, tunnels, vpnState, false)
}
.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(Constants.SUBSCRIPTION_TIMEOUT),
MainUiState())
private fun validateWatcherServiceState(settings: Settings) = viewModelScope.launch(Dispatchers.IO) { val handshakeStatus get() = vpnService.handshakeStatus
val watcherState = val tunnelName get() = vpnService.tunnelName
ServiceManager.getServiceState( private val _settings = MutableStateFlow(Settings())
application.applicationContext, WireGuardConnectivityWatcherService::class.java) val settings get() = _settings.asStateFlow()
if (settings.isAutoTunnelEnabled && val statistics get() = vpnService.statistics
watcherState == ServiceState.STOPPED) {
ServiceManager.startWatcherService(application.applicationContext)
}
}
private fun stopWatcherService() = viewModelScope.launch(Dispatchers.IO) { init {
ServiceManager.stopWatcherService(application.applicationContext) viewModelScope.launch(Dispatchers.IO) {
} settingsRepo.getAllFlow().filter { it.isNotEmpty() }.collect {
fun onDelete(tunnel: TunnelConfig) { val settings = it.first()
viewModelScope.launch(Dispatchers.IO) { validateWatcherServiceState(settings)
if (tunnelConfigRepository.count() == 1) { _settings.emit(settings)
stopWatcherService() }
val settings = settingsRepository.getSettings()
settings.defaultTunnel = null
settings.isAutoTunnelEnabled = false
settings.isAlwaysOnVpnEnabled = false
saveSettings(settings)
}
tunnelConfigRepository.delete(tunnel)
WireGuardAutoTunnel.requestTileServiceStateUpdate()
}
}
fun onTunnelStart(tunnelConfig: TunnelConfig) = viewModelScope.launch(Dispatchers.IO) {
stopActiveTunnel().await()
startTunnel(tunnelConfig)
}
private fun startTunnel(tunnelConfig: TunnelConfig) = viewModelScope.launch(Dispatchers.IO) {
ServiceManager.startVpnService(application.applicationContext, tunnelConfig.toString())
}
private fun stopActiveTunnel() =
viewModelScope.async(Dispatchers.IO) {
if (ServiceManager.getServiceState(
application.applicationContext, WireGuardTunnelService::class.java) ==
ServiceState.STARTED) {
onTunnelStop()
delay(Constants.TOGGLE_TUNNEL_DELAY)
} }
} }
fun onTunnelStop() = viewModelScope.launch(Dispatchers.IO) { private fun validateWatcherServiceState(settings: Settings) {
ServiceManager.stopVpnService(application.applicationContext) val watcherState = ServiceManager.getServiceState(
} application.applicationContext,
WireGuardConnectivityWatcherService::class.java
private fun validateConfigString(config: String) { )
TunnelConfig.configFromQuick(config) if (settings.isAutoTunnelEnabled && watcherState == ServiceState.STOPPED && settings.defaultTunnel != null) {
} ServiceManager.startWatcherService(
application.applicationContext,
suspend fun onTunnelQrResult(result: String) : Result<Unit> { settings.defaultTunnel!!
return try { )
validateConfigString(result)
val tunnelConfig =
TunnelConfig(name = NumberUtils.generateRandomTunnelName(), wgQuick = result)
addTunnel(tunnelConfig)
Result.Success(Unit)
} catch (e: Exception) {
Result.Error(Event.Error.InvalidQrCode)
} }
} }
private suspend fun saveTunnelConfigFromStream(stream: InputStream, fileName: String) {
val bufferReader = stream.bufferedReader(charset = Charsets.UTF_8)
val config = Config.parse(bufferReader)
val tunnelName = getNameFromFileName(fileName)
addTunnel(TunnelConfig(name = tunnelName, wgQuick = config.toWgQuickString()))
withContext(Dispatchers.IO) { stream.close() }
}
private fun getInputStreamFromUri(uri: Uri): InputStream? { fun onDelete(tunnel: TunnelConfig) {
return application.applicationContext.contentResolver.openInputStream(uri) viewModelScope.launch {
} if (tunnelRepo.count() == 1L) {
ServiceManager.stopWatcherService(application.applicationContext)
suspend fun onTunnelFileSelected(uri: Uri) : Result<Unit> { val settings = settingsRepo.getAll()
try { if (settings.isNotEmpty()) {
if(isValidUriContentScheme(uri)){ val setting = settings[0]
val fileName = getFileName(application.applicationContext, uri) setting.defaultTunnel = null
when (getFileExtensionFromFileName(fileName)) { setting.isAutoTunnelEnabled = false
Constants.CONF_FILE_EXTENSION -> saveTunnelFromConfUri(fileName, uri).let { setting.isAlwaysOnVpnEnabled = false
when(it) { settingsRepo.save(setting)
is Result.Error -> return Result.Error(Event.Error.FileReadFailed)
is Result.Success -> return it
}
}
Constants.ZIP_FILE_EXTENSION -> saveTunnelsFromZipUri(uri)
else -> return Result.Error(Event.Error.InvalidFileExtension)
} }
return Result.Success(Unit) }
} else { tunnelRepo.delete(tunnel)
return Result.Error(Event.Error.InvalidFileExtension) }
}
fun onTunnelStart(tunnelConfig: TunnelConfig) {
viewModelScope.launch {
stopActiveTunnel()
startTunnel(tunnelConfig)
}
}
private fun startTunnel(tunnelConfig: TunnelConfig) {
ServiceManager.startVpnService(application.applicationContext, tunnelConfig.toString())
}
private suspend fun stopActiveTunnel() {
if (ServiceManager.getServiceState(
application.applicationContext,
WireGuardTunnelService::class.java,
) == ServiceState.STARTED
) {
onTunnelStop()
delay(Constants.TOGGLE_TUNNEL_DELAY)
}
}
fun onTunnelStop() {
ServiceManager.stopVpnService(application.applicationContext)
}
private fun validateConfigString(config: String) {
TunnelConfig.configFromQuick(config)
}
suspend fun onTunnelQrResult(result: String) {
try {
validateConfigString(result)
val tunnelConfig =
TunnelConfig(name = NumberUtils.generateRandomTunnelName(), wgQuick = result)
addTunnel(tunnelConfig)
} catch (e : Exception) {
throw WgTunnelException(e)
}
}
private suspend fun saveTunnelConfigFromStream(stream: InputStream, fileName: String) {
val bufferReader = stream.bufferedReader(charset = Charsets.UTF_8)
val config = Config.parse(bufferReader)
val tunnelName = getNameFromFileName(fileName)
addTunnel(TunnelConfig(name = tunnelName, wgQuick = config.toWgQuickString()))
withContext(Dispatchers.IO) {
stream.close()
}
}
private fun getInputStreamFromUri(uri: Uri): InputStream {
return application.applicationContext.contentResolver.openInputStream(uri)
?: throw WgTunnelException(application.getString(R.string.stream_failed))
}
suspend fun onTunnelFileSelected(uri: Uri) {
try {
val fileName = getFileName(application.applicationContext, uri)
val fileExtension = getFileExtensionFromFileName(fileName)
when(fileExtension){
Constants.CONF_FILE_EXTENSION -> saveTunnelFromConfUri(fileName, uri)
Constants.ZIP_FILE_EXTENSION -> saveTunnelsFromZipUri(uri)
else -> throw WgTunnelException(application.getString(R.string.file_extension_message))
} }
} catch (e: Exception) { } catch (e: Exception) {
return Result.Error(Event.Error.FileReadFailed) throw WgTunnelException(e)
} }
} }
private suspend fun saveTunnelsFromZipUri(uri: Uri) { private suspend fun saveTunnelsFromZipUri(uri: Uri) {
ZipInputStream(getInputStreamFromUri(uri)).use { zip -> ZipInputStream(getInputStreamFromUri(uri)).use { zip ->
generateSequence { zip.nextEntry } generateSequence { zip.nextEntry }
.filterNot { .filterNot { it.isDirectory ||
it.isDirectory || getFileExtensionFromFileName(it.name) != Constants.CONF_FILE_EXTENSION getFileExtensionFromFileName(it.name) != Constants.CONF_FILE_EXTENSION }
} .forEach {
.forEach { val name = getNameFromFileName(it.name)
val name = getNameFromFileName(it.name) val config = Config.parse(zip)
val config = Config.parse(zip) viewModelScope.launch(Dispatchers.IO) {
viewModelScope.launch(Dispatchers.IO) { addTunnel(TunnelConfig(name = name, wgQuick = config.toWgQuickString()))
addTunnel(TunnelConfig(name = name, wgQuick = config.toWgQuickString())) }
} }
} }
}
}
private suspend fun saveTunnelFromConfUri(name: String, uri: Uri) : Result<Unit> {
val stream = getInputStreamFromUri(uri)
return if(stream != null) {
saveTunnelConfigFromStream(stream, name)
Result.Success(Unit)
} else {
Result.Error(Event.Error.FileReadFailed)
}
}
private suspend fun addTunnel(tunnelConfig: TunnelConfig) {
saveTunnel(tunnelConfig)
WireGuardAutoTunnel.requestTileServiceStateUpdate()
}
fun pauseAutoTunneling() = viewModelScope.launch {
settingsRepository.save(uiState.value.settings.copy(isAutoTunnelPaused = true))
} }
fun resumeAutoTunneling() = viewModelScope.launch { private suspend fun saveTunnelFromConfUri(name : String, uri: Uri) {
settingsRepository.save(uiState.value.settings.copy(isAutoTunnelPaused = false)) val stream = getInputStreamFromUri(uri)
saveTunnelConfigFromStream(stream, name)
} }
private suspend fun saveTunnel(tunnelConfig: TunnelConfig) { private suspend fun addTunnel(tunnelConfig: TunnelConfig) {
tunnelConfigRepository.save(tunnelConfig) saveTunnel(tunnelConfig)
}
private fun getFileNameByCursor(context: Context, uri: Uri): String? {
context.contentResolver.query(uri, null, null, null, null)?.use {
return getDisplayNameByCursor(it)
} }
return null
}
private fun getDisplayNameColumnIndex(cursor: Cursor): Int? { private suspend fun saveTunnel(tunnelConfig: TunnelConfig) {
val columnIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) tunnelRepo.save(tunnelConfig)
return if (columnIndex != -1) {
return columnIndex
} else {
null
} }
}
private fun getDisplayNameByCursor(cursor: Cursor): String? { private fun getFileNameByCursor(context: Context, uri: Uri): String {
return if (cursor.moveToFirst()) { val cursor = context.contentResolver.query(uri, null, null, null, null)
val index = getDisplayNameColumnIndex(cursor) if (cursor != null) {
if (index != null) { cursor.use {
cursor.getString(index) return getDisplayNameByCursor(it)
} else null }
} else null } else {
} throw WgTunnelException("Failed to initialize cursor")
}
private fun isValidUriContentScheme(uri: Uri): Boolean {
return uri.scheme == Constants.URI_CONTENT_SCHEME
}
private fun getFileName(context: Context, uri: Uri): String {
return getFileNameByCursor(context, uri) ?: NumberUtils.generateRandomTunnelName()
}
private fun getNameFromFileName(fileName: String): String {
return fileName.substring(0, fileName.lastIndexOf('.'))
}
private fun getFileExtensionFromFileName(fileName: String): String {
return try {
fileName.substring(fileName.lastIndexOf('.'))
} catch (e: Exception) {
""
} }
}
private fun saveSettings(settings: Settings) = private fun getDisplayNameColumnIndex(cursor: Cursor): Int {
viewModelScope.launch(Dispatchers.IO) { settingsRepository.save(settings) } val columnIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (columnIndex == -1) {
fun onDefaultTunnelChange(selectedTunnel: TunnelConfig?) = viewModelScope.launch { throw WgTunnelException("Cursor out of bounds")
if (selectedTunnel != null) { }
saveSettings(uiState.value.settings.copy(defaultTunnel = selectedTunnel.toString())).join() return columnIndex
WireGuardAutoTunnel.requestTileServiceStateUpdate() }
private fun getDisplayNameByCursor(cursor: Cursor): String {
if (cursor.moveToFirst()) {
val index = getDisplayNameColumnIndex(cursor)
return cursor.getString(index)
} else {
throw WgTunnelException("Cursor failed to move to first")
}
}
private fun validateUriContentScheme(uri: Uri) {
if (uri.scheme != Constants.URI_CONTENT_SCHEME) {
throw WgTunnelException(application.getString(R.string.file_extension_message))
}
}
private fun getFileName(context: Context, uri: Uri): String {
validateUriContentScheme(uri)
return try {
getFileNameByCursor(context, uri)
} catch (_: Exception) {
NumberUtils.generateRandomTunnelName()
}
}
private fun getNameFromFileName(fileName: String): String {
return fileName.substring(0, fileName.lastIndexOf('.'))
}
private fun getFileExtensionFromFileName(fileName: String): String {
return try {
fileName.substring(fileName.lastIndexOf('.'))
} catch (e: Exception) {
""
}
}
suspend fun onDefaultTunnelChange(selectedTunnel: TunnelConfig?) {
if (selectedTunnel != null) {
_settings.emit(
_settings.value.copy(
defaultTunnel = selectedTunnel.toString()
)
)
settingsRepo.save(_settings.value)
}
} }
}
} }
@@ -4,7 +4,7 @@ import android.Manifest
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS import android.provider.Settings
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
@@ -30,7 +30,6 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.outlined.Add import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.rounded.LocationOff import androidx.compose.material.icons.rounded.LocationOff
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -39,20 +38,21 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
@@ -65,444 +65,424 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState import com.google.accompanist.permissions.rememberPermissionState
import com.wireguard.android.backend.Tunnel
import com.wireguard.android.backend.WgQuickBackend
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.zaneschepke.wireguardautotunnel.ui.common.ClickableIconButton import com.zaneschepke.wireguardautotunnel.ui.common.ClickableIconButton
import com.zaneschepke.wireguardautotunnel.ui.common.config.ConfigurationToggle import com.zaneschepke.wireguardautotunnel.ui.common.config.ConfigurationToggle
import com.zaneschepke.wireguardautotunnel.ui.common.prompt.AuthorizationPrompt import com.zaneschepke.wireguardautotunnel.ui.common.prompt.AuthorizationPrompt
import com.zaneschepke.wireguardautotunnel.ui.common.screen.LoadingScreen
import com.zaneschepke.wireguardautotunnel.ui.common.text.SectionTitle import com.zaneschepke.wireguardautotunnel.ui.common.text.SectionTitle
import com.zaneschepke.wireguardautotunnel.util.Event import com.zaneschepke.wireguardautotunnel.util.StorageUtil
import com.zaneschepke.wireguardautotunnel.util.FileUtils
import com.zaneschepke.wireguardautotunnel.util.Result
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.File import java.io.File
@OptIn( @OptIn(
ExperimentalPermissionsApi::class, ExperimentalPermissionsApi::class,
ExperimentalLayoutApi::class) ExperimentalLayoutApi::class, ExperimentalComposeUiApi::class
)
@Composable @Composable
fun SettingsScreen( fun SettingsScreen(
viewModel: SettingsViewModel = hiltViewModel(), viewModel: SettingsViewModel = hiltViewModel(),
padding: PaddingValues, padding: PaddingValues,
showSnackbarMessage: (String) -> Unit, showSnackbarMessage: (String) -> Unit,
focusRequester: FocusRequester focusRequester: FocusRequester,
) { ) {
val scope = rememberCoroutineScope { Dispatchers.IO }
val context = LocalContext.current
val focusManager = LocalFocusManager.current
val scrollState = rememberScrollState()
val interactionSource = remember { MutableInteractionSource() }
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val scope = rememberCoroutineScope { Dispatchers.IO }
val context = LocalContext.current
val focusManager = LocalFocusManager.current
val keyboardController = LocalSoftwareKeyboardController.current
val interactionSource = remember { MutableInteractionSource() }
val fineLocationState = rememberPermissionState(Manifest.permission.ACCESS_FINE_LOCATION) val settings by viewModel.settings.collectAsStateWithLifecycle()
var currentText by remember { mutableStateOf("") } val trustedSSIDs by viewModel.trustedSSIDs.collectAsStateWithLifecycle()
var isBackgroundLocationGranted by remember { mutableStateOf(true) } val tunnels by viewModel.tunnels.collectAsStateWithLifecycle(mutableListOf())
var showLocationServicesAlertDialog by remember { mutableStateOf(false) } val fineLocationState = rememberPermissionState(Manifest.permission.ACCESS_FINE_LOCATION)
var didExportFiles by remember { mutableStateOf(false) } var currentText by remember { mutableStateOf("") }
var showAuthPrompt by remember { mutableStateOf(false) } val scrollState = rememberScrollState()
val focusRequester2 = remember { FocusRequester() } var isLocationDisclaimerNeeded by remember { mutableStateOf(true) }
var isBackgroundLocationGranted by remember { mutableStateOf(true) }
var showAuthPrompt by remember { mutableStateOf(false) }
var didExportFiles by remember { mutableStateOf(false) }
val screenPadding = 5.dp
val fillMaxWidth = .85f
if (uiState.loading) {
LoadingScreen()
return
}
fun exportAllConfigs() { val screenPadding = 5.dp
try { val fillMaxWidth = .85f
val files = uiState.tunnels.map { File(context.cacheDir, "${it.name}.conf") }
files.forEachIndexed { index, file -> fun exportAllConfigs() {
file.outputStream().use { it.write(uiState.tunnels[index].wgQuick.toByteArray()) } try {
} val files = tunnels.map { File(context.cacheDir, "${it.name}.conf") }
FileUtils.saveFilesToZip(context, files) files.forEachIndexed { index, file ->
didExportFiles = true file.outputStream().use {
showSnackbarMessage(Event.Message.ConfigsExported.message) it.write(tunnels[index].wgQuick.toByteArray())
} catch (e: Exception) { }
showSnackbarMessage(Event.Error.Exception(e).message) }
StorageUtil.saveFilesToZip(context, files)
didExportFiles = true
showSnackbarMessage(context.getString(R.string.exported_configs_message))
} catch (e : Exception) {
showSnackbarMessage(e.message!!)
}
} }
}
fun saveTrustedSSID() {
if (currentText.isNotEmpty()) { fun saveTrustedSSID() {
viewModel.onSaveTrustedSSID(currentText).let { if (currentText.isNotEmpty()) {
when(it) { scope.launch {
is Result.Success -> currentText = "" try {
is Result.Error -> showSnackbarMessage(it.error.message) viewModel.onSaveTrustedSSID(currentText)
} currentText = ""
} } catch (e : Exception) {
showSnackbarMessage(e.message ?: context.getString(R.string.unknown_error))
}
}
}
} }
}
fun openSettings() { fun isAllAutoTunnelPermissionsEnabled() : Boolean {
scope.launch { return(isBackgroundLocationGranted && fineLocationState.status.isGranted && !viewModel.isLocationServicesNeeded())
val intentSettings = Intent(ACTION_APPLICATION_DETAILS_SETTINGS)
intentSettings.data = Uri.fromParts("package", context.packageName, null)
context.startActivity(intentSettings)
} }
}
fun checkFineLocationGranted() {
isBackgroundLocationGranted = fun openSettings() {
if (!fineLocationState.status.isGranted) { scope.launch {
false val intentSettings =
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
intentSettings.data =
Uri.fromParts("package", context.packageName, null)
context.startActivity(intentSettings)
}
}
if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val backgroundLocationState =
rememberPermissionState(Manifest.permission.ACCESS_BACKGROUND_LOCATION)
if(!backgroundLocationState.status.isGranted) {
isBackgroundLocationGranted = false
} else { } else {
viewModel.setLocationDisclosureShown() isLocationDisclaimerNeeded = false
true isBackgroundLocationGranted = true
} }
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
if(WireGuardAutoTunnel.isRunningOnAndroidTv() && Build.VERSION.SDK_INT == Build.VERSION_CODES.Q){
checkFineLocationGranted()
} else {
val backgroundLocationState =
rememberPermissionState(Manifest.permission.ACCESS_BACKGROUND_LOCATION)
isBackgroundLocationGranted =
if (!backgroundLocationState.status.isGranted) {
false
} else {
SideEffect { viewModel.setLocationDisclosureShown() }
true
}
}
}
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) {
checkFineLocationGranted()
}
AnimatedVisibility(showLocationServicesAlertDialog) {
AlertDialog(
onDismissRequest = { showLocationServicesAlertDialog = false },
confirmButton = {
TextButton(
onClick = {
showLocationServicesAlertDialog = false
viewModel.toggleAutoTunnel()
}) {
Text(text = stringResource(R.string.okay))
}
},
dismissButton = {
TextButton(onClick = { showLocationServicesAlertDialog = false }) {
Text(text = stringResource(R.string.cancel))
}
},
title = { Text(text = stringResource(R.string.location_services_not_detected)) },
text = { Text(text = stringResource(R.string.location_services_missing_message)) })
} }
if (!uiState.isLocationDisclosureShown) { if(Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) {
Column( if(!fineLocationState.status.isGranted) {
horizontalAlignment = Alignment.CenterHorizontally, isBackgroundLocationGranted = false
verticalArrangement = Arrangement.Top, } else {
modifier = Modifier.fillMaxSize().verticalScroll(scrollState).padding(padding)) { isLocationDisclaimerNeeded = false
Icon( isBackgroundLocationGranted = true
Icons.Rounded.LocationOff,
contentDescription = stringResource(id = R.string.map),
modifier = Modifier.padding(30.dp).size(128.dp))
Text(
stringResource(R.string.prominent_background_location_title),
textAlign = TextAlign.Center,
modifier = Modifier.padding(30.dp),
fontSize = 20.sp)
Text(
stringResource(R.string.prominent_background_location_message),
textAlign = TextAlign.Center,
modifier = Modifier.padding(30.dp),
fontSize = 15.sp)
Row(
modifier =
if (WireGuardAutoTunnel.isRunningOnAndroidTv()) {
Modifier.fillMaxWidth().padding(10.dp)
} else {
Modifier.fillMaxWidth().padding(30.dp)
},
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceEvenly) {
TextButton(onClick = { viewModel.setLocationDisclosureShown() }) {
Text(stringResource(id = R.string.no_thanks))
}
TextButton(
modifier = Modifier.focusRequester(focusRequester),
onClick = {
openSettings()
viewModel.setLocationDisclosureShown()
}) {
Text(stringResource(id = R.string.turn_on))
}
}
} }
} }
if(showAuthPrompt) { if(isLocationDisclaimerNeeded) {
AuthorizationPrompt( Column(
onSuccess = { horizontalAlignment = Alignment.CenterHorizontally,
showAuthPrompt = false verticalArrangement = Arrangement.Top,
exportAllConfigs() modifier = Modifier
}, .fillMaxSize()
onError = { _ -> .verticalScroll(scrollState)
showAuthPrompt = false .padding(padding)
showSnackbarMessage(Event.Error.AuthenticationFailed.message) ) {
}, Icon(
onFailure = { Icons.Rounded.LocationOff,
showAuthPrompt = false contentDescription = stringResource(id = R.string.map),
showSnackbarMessage(Event.Error.AuthorizationFailed.message) modifier = Modifier
}) .padding(30.dp)
} .size(128.dp)
)
if (uiState.tunnels.isEmpty() && uiState.isLocationDisclosureShown) { Text(
Column( stringResource(R.string.prominent_background_location_title),
horizontalAlignment = Alignment.CenterHorizontally, textAlign = TextAlign.Center,
verticalArrangement = Arrangement.Center, modifier = Modifier.padding(30.dp),
modifier = Modifier.fillMaxSize().padding(padding)) { fontSize = 20.sp
Text( )
stringResource(R.string.one_tunnel_required), Text(
textAlign = TextAlign.Center, stringResource(R.string.prominent_background_location_message),
modifier = Modifier.padding(15.dp), textAlign = TextAlign.Center,
fontStyle = FontStyle.Italic) modifier = Modifier.padding(30.dp),
} fontSize = 15.sp
} )
if (uiState.isLocationDisclosureShown && uiState.tunnels.isNotEmpty()) { Row(
Column( modifier = if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) Modifier
horizontalAlignment = Alignment.CenterHorizontally, .fillMaxWidth()
verticalArrangement = Arrangement.Top, .padding(10.dp) else Modifier
modifier = .fillMaxWidth()
Modifier.fillMaxSize().verticalScroll(scrollState).clickable( .padding(30.dp),
indication = null, interactionSource = interactionSource) { verticalAlignment = Alignment.CenterVertically,
focusManager.clearFocus() horizontalArrangement = Arrangement.SpaceEvenly
) {
TextButton(onClick = {
isLocationDisclaimerNeeded = false
}) { }) {
Surface( Text(stringResource(id = R.string.no_thanks))
tonalElevation = 2.dp, }
shadowElevation = 2.dp, TextButton(modifier = Modifier.focusRequester(focusRequester), onClick = {
shape = RoundedCornerShape(12.dp), openSettings()
color = MaterialTheme.colorScheme.surface, }) {
modifier = Text(stringResource(id = R.string.turn_on))
(if (WireGuardAutoTunnel.isRunningOnAndroidTv()) { }
Modifier.height(IntrinsicSize.Min) }
.fillMaxWidth(fillMaxWidth) }
.padding(top = 10.dp) return
} else { }
Modifier.fillMaxWidth(fillMaxWidth).padding(top = 60.dp)
})
.padding(bottom = 10.dp)) {
if(showAuthPrompt) {
AuthorizationPrompt(onSuccess = {
showAuthPrompt = false
exportAllConfigs() },
onError = { error ->
showSnackbarMessage(error)
showAuthPrompt = false
},
onFailure = {
showAuthPrompt = false
showSnackbarMessage(context.getString(R.string.authentication_failed))
})
}
if (tunnels.isEmpty()) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
Text(
stringResource(R.string.one_tunnel_required),
textAlign = TextAlign.Center,
modifier = Modifier.padding(15.dp),
fontStyle = FontStyle.Italic
)
}
return
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top,
modifier = Modifier
.fillMaxSize()
.verticalScroll(scrollState)
.clickable(indication = null, interactionSource = interactionSource) {
focusManager.clearFocus()
}
) {
Surface(
tonalElevation = 2.dp,
shadowElevation = 2.dp,
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surface,
modifier = (if (WireGuardAutoTunnel.isRunningOnAndroidTv(context))
Modifier
.height(IntrinsicSize.Min)
.fillMaxWidth(fillMaxWidth)
.padding(top = 10.dp)
else Modifier
.fillMaxWidth(fillMaxWidth)
.padding(top = 60.dp)).padding(bottom = 25.dp)
) {
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Top,
modifier = Modifier.padding(15.dp)
) {
SectionTitle(title = stringResource(id = R.string.auto_tunneling), padding = screenPadding)
ConfigurationToggle(
stringResource(id = R.string.tunnel_on_wifi),
enabled = !(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled),
checked = settings.isTunnelOnWifiEnabled,
padding = screenPadding,
onCheckChanged = {
scope.launch {
viewModel.onToggleTunnelOnWifi()
}
},
modifier = Modifier.focusRequester(focusRequester)
)
AnimatedVisibility(visible = settings.isTunnelOnWifiEnabled) {
Column {
FlowRow(
modifier = Modifier.padding(screenPadding),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.SpaceEvenly
) {
trustedSSIDs.forEach { ssid ->
ClickableIconButton(
onIconClick = {
scope.launch {
viewModel.onDeleteTrustedSSID(ssid)
}
},
text = ssid,
icon = Icons.Filled.Close,
enabled = !(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled)
)
}
if(trustedSSIDs.isEmpty()) {
Text(stringResource(R.string.none), fontStyle = FontStyle.Italic, color = Color.Gray)
}
}
OutlinedTextField(
enabled = !(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled),
value = currentText,
onValueChange = { currentText = it },
label = { Text(stringResource(R.string.add_trusted_ssid)) },
modifier = Modifier
.padding(start = screenPadding, top = 5.dp, bottom = 10.dp)
.onFocusChanged {
if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
keyboardController?.hide()
}
},
maxLines = 1,
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.None,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = {
saveTrustedSSID()
}
),
trailingIcon = {
IconButton(onClick = { saveTrustedSSID() }) {
Icon(
imageVector = Icons.Outlined.Add,
contentDescription = if (currentText == "") stringResource(id = R.string.trusted_ssid_empty_description) else stringResource(
id = R.string.trusted_ssid_value_description
),
tint = if (currentText == "") Color.Transparent else MaterialTheme.colorScheme.primary
)
}
},
)
}
}
ConfigurationToggle(stringResource(R.string.tunnel_mobile_data),
enabled = !(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled),
checked = settings.isTunnelOnMobileDataEnabled,
padding = screenPadding,
onCheckChanged = {
scope.launch {
viewModel.onToggleTunnelOnMobileData()
}
}
)
ConfigurationToggle(stringResource(id = R.string.tunnel_on_ethernet),
enabled = !(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled),
checked = settings.isTunnelOnEthernetEnabled,
padding = screenPadding,
onCheckChanged = {
scope.launch {
viewModel.onToggleTunnelOnEthernet()
}
}
)
ConfigurationToggle(
stringResource(R.string.battery_saver),
enabled = !(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled),
checked = settings.isBatterySaverEnabled,
padding = screenPadding,
onCheckChanged = {
scope.launch {
viewModel.onToggleBatterySaver()
}
}
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxSize()
.padding(top = 5.dp),
horizontalArrangement = Arrangement.Center
) {
TextButton(
enabled = !settings.isAlwaysOnVpnEnabled,
onClick = {
//TODO fix logic for mobile only
if(!isAllAutoTunnelPermissionsEnabled() && settings.isTunnelOnWifiEnabled) {
val message = if(!isBackgroundLocationGranted) {
context.getString(R.string.background_location_required)
} else if(viewModel.isLocationServicesNeeded()) {
context.getString(R.string.location_services_required)
} else {
context.getString(R.string.precise_location_required)
}
showSnackbarMessage(message)
} else scope.launch {
viewModel.toggleAutoTunnel()
}
}) {
val autoTunnelButtonText = if(settings.isAutoTunnelEnabled) stringResource(R.string.disable_auto_tunnel)
else stringResource(id = R.string.enable_auto_tunnel)
Text(autoTunnelButtonText)
}
}
}
}
if(!WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
Surface(
tonalElevation = 2.dp,
shadowElevation = 2.dp,
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surface,
modifier = Modifier
.fillMaxWidth(fillMaxWidth)
.height(IntrinsicSize.Min)
.padding(bottom = 180.dp)
) {
Column( Column(
horizontalAlignment = Alignment.Start, horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Top, verticalArrangement = Arrangement.Top,
modifier = Modifier.padding(15.dp)) { modifier = Modifier.padding(15.dp)
SectionTitle( ) {
title = stringResource(id = R.string.auto_tunneling), SectionTitle(title = stringResource(id = R.string.other), padding = screenPadding)
padding = screenPadding) ConfigurationToggle(stringResource(R.string.always_on_vpn_support),
ConfigurationToggle( enabled = !settings.isAutoTunnelEnabled,
stringResource(id = R.string.tunnel_on_wifi), checked = settings.isAlwaysOnVpnEnabled,
enabled = padding = screenPadding,
!(uiState.settings.isAutoTunnelEnabled || onCheckChanged = {
uiState.settings.isAlwaysOnVpnEnabled), scope.launch {
checked = uiState.settings.isTunnelOnWifiEnabled, viewModel.onToggleAlwaysOnVPN()
padding = screenPadding,
onCheckChanged = { viewModel.onToggleTunnelOnWifi() },
modifier = if(uiState.settings.isAutoTunnelEnabled) Modifier else Modifier.focusRequester(focusRequester).focusProperties { down = focusRequester2 })
AnimatedVisibility(visible = uiState.settings.isTunnelOnWifiEnabled) {
Column {
FlowRow(
modifier = Modifier.padding(screenPadding).fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(5.dp)) {
uiState.settings.trustedNetworkSSIDs.forEach { ssid ->
ClickableIconButton(
onClick = { if(WireGuardAutoTunnel.isRunningOnAndroidTv()) {
viewModel.onDeleteTrustedSSID(ssid)
focusRequester2.requestFocus()
}},
onIconClick = { viewModel.onDeleteTrustedSSID(ssid) },
text = ssid,
icon = Icons.Filled.Close,
enabled =
!(uiState.settings.isAutoTunnelEnabled ||
uiState.settings.isAlwaysOnVpnEnabled))
}
if (uiState.settings.trustedNetworkSSIDs.isEmpty()) {
Text(
stringResource(R.string.none),
fontStyle = FontStyle.Italic,
color = Color.Gray)
}
}
OutlinedTextField(
enabled =
!(uiState.settings.isAutoTunnelEnabled ||
uiState.settings.isAlwaysOnVpnEnabled),
value = currentText,
onValueChange = { currentText = it },
label = { Text(stringResource(R.string.add_trusted_ssid)) },
modifier =
Modifier.padding(
start = screenPadding, top = 5.dp, bottom = 10.dp)
.focusRequester(focusRequester2)
,
maxLines = 1,
keyboardOptions =
KeyboardOptions(
capitalization = KeyboardCapitalization.None,
imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { saveTrustedSSID() }),
trailingIcon = {
if (currentText != "") {
IconButton(onClick = { saveTrustedSSID() }) {
Icon(
imageVector = Icons.Outlined.Add,
contentDescription =
if (currentText == "") {
stringResource(
id = R.string.trusted_ssid_empty_description)
} else {
stringResource(
id = R.string.trusted_ssid_value_description)
},
tint = MaterialTheme.colorScheme.primary)
}
}
})
}
}
ConfigurationToggle(
stringResource(R.string.tunnel_mobile_data),
enabled =
!(uiState.settings.isAutoTunnelEnabled ||
uiState.settings.isAlwaysOnVpnEnabled),
checked = uiState.settings.isTunnelOnMobileDataEnabled,
padding = screenPadding,
onCheckChanged = { viewModel.onToggleTunnelOnMobileData() })
ConfigurationToggle(
stringResource(id = R.string.tunnel_on_ethernet),
enabled =
!(uiState.settings.isAutoTunnelEnabled ||
uiState.settings.isAlwaysOnVpnEnabled),
checked = uiState.settings.isTunnelOnEthernetEnabled,
padding = screenPadding,
onCheckChanged = { viewModel.onToggleTunnelOnEthernet() })
ConfigurationToggle(
stringResource(R.string.battery_saver),
enabled =
!(uiState.settings.isAutoTunnelEnabled ||
uiState.settings.isAlwaysOnVpnEnabled),
checked = uiState.settings.isBatterySaverEnabled,
padding = screenPadding,
onCheckChanged = { viewModel.onToggleBatterySaver() })
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = (if(!uiState.settings.isAutoTunnelEnabled) Modifier else Modifier.focusRequester(focusRequester))
.fillMaxSize().padding(top = 5.dp),
horizontalArrangement = Arrangement.Center) {
TextButton(
enabled = !uiState.settings.isAlwaysOnVpnEnabled,
onClick = {
if (uiState.settings.isTunnelOnWifiEnabled && !uiState.settings.isAutoTunnelEnabled) {
when(false) {
isBackgroundLocationGranted ->
showSnackbarMessage(Event.Error.BackgroundLocationRequired.message)
fineLocationState.status.isGranted ->
showSnackbarMessage(Event.Error.PreciseLocationRequired.message)
viewModel.isLocationEnabled(context) ->
showLocationServicesAlertDialog = true
else -> {
viewModel.toggleAutoTunnel()
}
}
} else {
viewModel.toggleAutoTunnel()
}
}) {
val autoTunnelButtonText =
if (uiState.settings.isAutoTunnelEnabled) {
stringResource(R.string.disable_auto_tunnel)
} else {
stringResource(id = R.string.enable_auto_tunnel)
}
Text(autoTunnelButtonText)
}
}
}
}
if (WgQuickBackend.hasKernelSupport()) {
Surface(
tonalElevation = 2.dp,
shadowElevation = 2.dp,
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surface,
modifier = Modifier.fillMaxWidth(fillMaxWidth).padding(vertical = 10.dp)) {
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Top,
modifier = Modifier.padding(15.dp)) {
SectionTitle(
title = stringResource(id = R.string.kernel), padding = screenPadding)
ConfigurationToggle(
stringResource(R.string.use_kernel),
enabled =
!(uiState.settings.isAutoTunnelEnabled ||
uiState.settings.isAlwaysOnVpnEnabled ||
(uiState.vpnState.status == Tunnel.State.UP)),
checked = uiState.settings.isKernelEnabled,
padding = screenPadding,
onCheckChanged = { viewModel.onToggleKernelMode().let {
when(it) {
is Result.Error -> showSnackbarMessage(it.error.message)
is Result.Success -> {}
}
} })
}
}
}
if (!WireGuardAutoTunnel.isRunningOnAndroidTv()) {
Surface(
tonalElevation = 2.dp,
shadowElevation = 2.dp,
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surface,
modifier =
Modifier.fillMaxWidth(fillMaxWidth)
.padding(vertical = 10.dp)
.padding(bottom = 140.dp)) {
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Top,
modifier = Modifier.padding(15.dp)) {
SectionTitle(
title = stringResource(id = R.string.other), padding = screenPadding)
ConfigurationToggle(
stringResource(R.string.always_on_vpn_support),
enabled = !uiState.settings.isAutoTunnelEnabled,
checked = uiState.settings.isAlwaysOnVpnEnabled,
padding = screenPadding,
onCheckChanged = { viewModel.onToggleAlwaysOnVPN() })
ConfigurationToggle(
stringResource(R.string.enabled_app_shortcuts),
enabled = true,
checked = uiState.settings.isShortcutsEnabled,
padding = screenPadding,
onCheckChanged = { viewModel.onToggleShortcutsEnabled() })
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxSize().padding(top = 5.dp),
horizontalArrangement = Arrangement.Center) {
TextButton(
enabled = !didExportFiles, onClick = { showAuthPrompt = true }) {
Text(stringResource(R.string.export_configs))
}
} }
} }
)
ConfigurationToggle(stringResource(R.string.enabled_app_shortcuts),
enabled = true,
checked = settings.isShortcutsEnabled,
padding = screenPadding,
onCheckChanged = {
scope.launch {
viewModel.onToggleShortcutsEnabled()
}
}
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxSize()
.padding(top = 5.dp),
horizontalArrangement = Arrangement.Center
) {
TextButton(
enabled = !didExportFiles,
onClick = {
showAuthPrompt = true
}) {
Text(stringResource(R.string.export_configs))
}
}
} }
} }
if (WireGuardAutoTunnel.isRunningOnAndroidTv()) {
Spacer(modifier = Modifier.weight(.17f))
}
} }
} if(WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
Spacer(modifier = Modifier.weight(.17f))
}
}
} }
@@ -1,13 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.settings
import com.zaneschepke.wireguardautotunnel.data.model.Settings
import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnState
data class SettingsUiState(
val settings : Settings = Settings(),
val tunnels : List<TunnelConfig> = emptyList(),
val vpnState: VpnState = VpnState(),
val isLocationDisclosureShown : Boolean = true,
val loading : Boolean = true
)
@@ -3,171 +3,144 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.settings
import android.app.Application import android.app.Application
import android.content.Context import android.content.Context
import android.location.LocationManager import android.location.LocationManager
import androidx.core.location.LocationManagerCompat import android.os.Build
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.wireguard.android.util.RootShell import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa
import com.zaneschepke.wireguardautotunnel.data.datastore.DataStoreManager import com.zaneschepke.wireguardautotunnel.repository.TunnelConfigDao
import com.zaneschepke.wireguardautotunnel.data.model.Settings import com.zaneschepke.wireguardautotunnel.repository.model.Settings
import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepository import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.data.repository.TunnelConfigRepository
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService import com.zaneschepke.wireguardautotunnel.util.WgTunnelException
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.Event
import com.zaneschepke.wireguardautotunnel.util.Result
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.async
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class SettingsViewModel class SettingsViewModel @Inject constructor(private val application : Application,
@Inject private val tunnelRepo : TunnelConfigDao, private val settingsRepo : SettingsDoa
constructor(
private val application: Application,
private val tunnelConfigRepository: TunnelConfigRepository,
private val settingsRepository: SettingsRepository,
private val dataStoreManager: DataStoreManager,
private val rootShell: RootShell,
private val vpnService: VpnService
) : ViewModel() { ) : ViewModel() {
val uiState = combine( private val _trustedSSIDs = MutableStateFlow(emptyList<String>())
settingsRepository.getSettingsFlow(), val trustedSSIDs = _trustedSSIDs.asStateFlow()
tunnelConfigRepository.getTunnelConfigsFlow(), private val _settings = MutableStateFlow(Settings())
vpnService.vpnState, val settings get() = _settings.asStateFlow()
dataStoreManager.locationDisclosureFlow, val tunnels get() = tunnelRepo.getAllFlow()
){ settings, tunnels, tunnelState, locationDisclosure -> init {
SettingsUiState(settings, tunnels, tunnelState, locationDisclosure isLocationServicesEnabled()
?: false, false) viewModelScope.launch(Dispatchers.IO) {
}.stateIn(viewModelScope, settingsRepo.getAllFlow().filter { it.isNotEmpty() }.collect {
SharingStarted.WhileSubscribed(Constants.SUBSCRIPTION_TIMEOUT), SettingsUiState()) val settings = it.first()
_settings.emit(settings)
fun onSaveTrustedSSID(ssid: String) : Result<Unit>{ _trustedSSIDs.emit(settings.trustedNetworkSSIDs.toList())
val trimmed = ssid.trim() }
return if (!uiState.value.settings.trustedNetworkSSIDs.contains(trimmed)) {
uiState.value.settings.trustedNetworkSSIDs.add(trimmed)
saveSettings(uiState.value.settings)
Result.Success(Unit)
} else {
Result.Error(Event.Error.SsidConflict)
} }
} }
fun setLocationDisclosureShown() = viewModelScope.launch { suspend fun onSaveTrustedSSID(ssid: String) {
dataStoreManager.saveToDataStore(DataStoreManager.LOCATION_DISCLOSURE_SHOWN, true) val trimmed = ssid.trim()
if (!_settings.value.trustedNetworkSSIDs.contains(trimmed)) {
_settings.value.trustedNetworkSSIDs.add(trimmed)
settingsRepo.save(_settings.value)
} else {
throw WgTunnelException("SSID already exists.")
}
} }
fun onToggleTunnelOnMobileData() { suspend fun onToggleTunnelOnMobileData() {
saveSettings( settingsRepo.save(_settings.value.copy(
uiState.value.settings.copy( isTunnelOnMobileDataEnabled = !_settings.value.isTunnelOnMobileDataEnabled
isTunnelOnMobileDataEnabled = !uiState.value.settings.isTunnelOnMobileDataEnabled
)
)
}
fun onDeleteTrustedSSID(ssid: String) {
saveSettings(uiState.value.settings.copy(
trustedNetworkSSIDs = (uiState.value.settings.trustedNetworkSSIDs - ssid).toMutableList()
)) ))
} }
private suspend fun getDefaultTunnelOrFirst() : String { suspend fun onDeleteTrustedSSID(ssid: String) {
return uiState.value.settings.defaultTunnel ?: tunnelConfigRepository.getAll().first().toString() _settings.value.trustedNetworkSSIDs.remove(ssid)
settingsRepo.save(_settings.value)
} }
fun toggleAutoTunnel() = viewModelScope.launch { private fun emitFirstTunnelAsDefault() = viewModelScope.async {
val isAutoTunnelEnabled = uiState.value.settings.isAutoTunnelEnabled _settings.emit(_settings.value.copy(defaultTunnel = getFirstTunnelConfig().toString()))
var isAutoTunnelPaused = uiState.value.settings.isAutoTunnelPaused }
if (isAutoTunnelEnabled) { suspend fun toggleAutoTunnel() {
if(_settings.value.isAutoTunnelEnabled) {
ServiceManager.stopWatcherService(application) ServiceManager.stopWatcherService(application)
} else { } else {
ServiceManager.startWatcherService(application) if(_settings.value.defaultTunnel == null) {
isAutoTunnelPaused = false emitFirstTunnelAsDefault().await()
}
val defaultTunnel = _settings.value.defaultTunnel
ServiceManager.startWatcherService(application, defaultTunnel!!)
} }
saveSettings( settingsRepo.save(_settings.value.copy(
uiState.value.settings.copy( isAutoTunnelEnabled = !_settings.value.isAutoTunnelEnabled
isAutoTunnelEnabled = !isAutoTunnelEnabled, ))
isAutoTunnelPaused = isAutoTunnelPaused,
defaultTunnel = getDefaultTunnelOrFirst()
)
)
} }
private suspend fun getFirstTunnelConfig() : TunnelConfig {
return tunnelRepo.getAll().first()
}
fun onToggleAlwaysOnVPN() = viewModelScope.launch { suspend fun onToggleAlwaysOnVPN() {
val updatedSettings = uiState.value.settings.copy( if(_settings.value.defaultTunnel == null) {
isAlwaysOnVpnEnabled = !uiState.value.settings.isAlwaysOnVpnEnabled, emitFirstTunnelAsDefault().await()
defaultTunnel = getDefaultTunnelOrFirst() }
) val updatedSettings = _settings.value.copy(isAlwaysOnVpnEnabled = !_settings.value.isAlwaysOnVpnEnabled)
emitSettings(updatedSettings)
saveSettings(updatedSettings) saveSettings(updatedSettings)
} }
private fun saveSettings(settings: Settings) = viewModelScope.launch { private suspend fun emitSettings(settings: Settings) {
settingsRepository.save(settings) _settings.emit(
settings
)
} }
fun onToggleTunnelOnEthernet() { private suspend fun saveSettings(settings: Settings) {
saveSettings(uiState.value.settings.copy( settingsRepo.save(settings)
isTunnelOnEthernetEnabled = !uiState.value.settings.isTunnelOnEthernetEnabled }
suspend fun onToggleTunnelOnEthernet() {
if(_settings.value.defaultTunnel == null) {
emitFirstTunnelAsDefault().await()
}
_settings.emit(
_settings.value.copy(isTunnelOnEthernetEnabled = !_settings.value.isTunnelOnEthernetEnabled)
)
settingsRepo.save(_settings.value)
}
private fun isLocationServicesEnabled() : Boolean {
val locationManager =
application.getSystemService(Context.LOCATION_SERVICE) as LocationManager
return locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)
}
fun isLocationServicesNeeded() : Boolean {
return(!isLocationServicesEnabled() && Build.VERSION.SDK_INT > Build.VERSION_CODES.P)
}
suspend fun onToggleShortcutsEnabled() {
settingsRepo.save(_settings.value.copy(
isShortcutsEnabled = !_settings.value.isShortcutsEnabled
)) ))
} }
fun isLocationEnabled(context: Context): Boolean { suspend fun onToggleBatterySaver() {
val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager settingsRepo.save(_settings.value.copy(
return LocationManagerCompat.isLocationEnabled(locationManager) isBatterySaverEnabled = !_settings.value.isBatterySaverEnabled
))
} }
fun onToggleShortcutsEnabled() { suspend fun onToggleTunnelOnWifi() {
saveSettings( settingsRepo.save(_settings.value.copy(
uiState.value.settings.copy( isTunnelOnWifiEnabled = !_settings.value.isTunnelOnWifiEnabled
isShortcutsEnabled = !uiState.value.settings.isShortcutsEnabled ))
)
)
}
fun onToggleBatterySaver() {
saveSettings(
uiState.value.settings.copy(
isBatterySaverEnabled = !uiState.value.settings.isBatterySaverEnabled
)
)
}
private fun saveKernelMode(on: Boolean) {
saveSettings(
uiState.value.settings.copy(
isKernelEnabled = on
)
)
}
fun onToggleTunnelOnWifi() {
saveSettings(
uiState.value.settings.copy(
isTunnelOnWifiEnabled = !uiState.value.settings.isTunnelOnWifiEnabled
)
)
}
fun onToggleKernelMode() : Result<Unit> {
if (!uiState.value.settings.isKernelEnabled) {
try {
rootShell.start()
Timber.d("Root shell accepted!")
saveKernelMode(on = true)
} catch (e: RootShell.RootShellException) {
saveKernelMode(on = false)
return Result.Error(Event.Error.RootDenied)
}
} else {
saveKernelMode(on = false)
}
return Result.Success(Unit)
} }
} }
@@ -30,7 +30,6 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
@@ -40,189 +39,110 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat.startActivity import androidx.core.content.ContextCompat.startActivity
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.zaneschepke.wireguardautotunnel.BuildConfig import com.zaneschepke.wireguardautotunnel.BuildConfig
import com.zaneschepke.wireguardautotunnel.Constants
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.zaneschepke.wireguardautotunnel.ui.common.screen.LoadingScreen
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.Event
@Composable @Composable
fun SupportScreen( fun SupportScreen(padding : PaddingValues, focusRequester: FocusRequester) {
viewModel: SupportViewModel = hiltViewModel(),
padding: PaddingValues,
showSnackbarMessage: (String) -> Unit,
focusRequester: FocusRequester
) {
val context = LocalContext.current
val fillMaxWidth = .85f
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val context = LocalContext.current
val fillMaxWidth = .85f
fun openWebPage(url: String) { fun openWebPage(url: String) {
try { val webpage: Uri = Uri.parse(url)
val webpage: Uri = Uri.parse(url) val intent = Intent(Intent.ACTION_VIEW, webpage)
val intent = Intent(Intent.ACTION_VIEW, webpage) context.startActivity(intent)
context.startActivity(intent) }
} catch (e : Exception) {
showSnackbarMessage(Event.Error.Exception(e).message)
}
}
fun launchEmail() { fun launchEmail() {
try { val intent = Intent(Intent.ACTION_SEND).apply {
val intent = type = Constants.EMAIL_MIME_TYPE
Intent(Intent.ACTION_SEND).apply { putExtra(Intent.EXTRA_EMAIL, context.getString(R.string.my_email))
type = Constants.EMAIL_MIME_TYPE putExtra(Intent.EXTRA_SUBJECT, context.getString(R.string.email_subject))
putExtra(Intent.EXTRA_EMAIL, context.getString(R.string.my_email)) }
putExtra(Intent.EXTRA_SUBJECT, context.getString(R.string.email_subject)) startActivity(context,createChooser(intent, context.getString(R.string.email_chooser)),null)
} }
startActivity(context, createChooser(intent, context.getString(R.string.email_chooser)), null)
} catch (e : Exception) {
showSnackbarMessage(Event.Error.Exception(e).message)
}
}
if (uiState.loading) { Column(horizontalAlignment = Alignment.CenterHorizontally,
LoadingScreen() verticalArrangement = Arrangement.Top,
return modifier = Modifier
} .fillMaxSize()
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top,
modifier =
Modifier.fillMaxSize()
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.focusable() .focusable()
.padding(padding)) { .padding(padding)) {
Surface( Surface(
tonalElevation = 2.dp, tonalElevation = 2.dp,
shadowElevation = 2.dp, shadowElevation = 2.dp,
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surface, color = MaterialTheme.colorScheme.surface,
modifier = modifier = (if (WireGuardAutoTunnel.isRunningOnAndroidTv(context))
(if (WireGuardAutoTunnel.isRunningOnAndroidTv()) { Modifier
Modifier.height(IntrinsicSize.Min) .height(IntrinsicSize.Min)
.fillMaxWidth(fillMaxWidth) .fillMaxWidth(fillMaxWidth)
.padding(top = 10.dp) .padding(top = 10.dp)
} else { else Modifier
Modifier.fillMaxWidth(fillMaxWidth).padding(top = 20.dp) .fillMaxWidth(fillMaxWidth)
}) .padding(top = 20.dp)).padding(bottom = 25.dp)
.padding(bottom = 25.dp)) { ) {
Column(modifier = Modifier.padding(20.dp)) { Column(modifier = Modifier.padding(20.dp)) {
Text( Text(stringResource(R.string.thank_you), textAlign = TextAlign.Start, modifier = Modifier.padding(bottom = 20.dp), fontSize = 16.sp)
stringResource(R.string.thank_you), Text(stringResource(id = R.string.support_help_text), textAlign = TextAlign.Start, fontSize = 16.sp, modifier = Modifier.padding(bottom = 20.dp))
textAlign = TextAlign.Start, TextButton(onClick = { openWebPage(context.resources.getString(R.string.docs_url)) }, modifier = Modifier.padding(vertical = 5.dp).focusRequester(focusRequester)) {
fontWeight = FontWeight.Bold, Row(horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
modifier = Modifier.padding(bottom = 20.dp), Row {
fontSize = 16.sp)
Text(
stringResource(id = R.string.support_help_text),
textAlign = TextAlign.Start,
fontSize = 16.sp,
modifier = Modifier.padding(bottom = 20.dp))
TextButton(
onClick = { openWebPage(context.resources.getString(R.string.docs_url)) },
modifier = Modifier.padding(vertical = 5.dp).focusRequester(focusRequester)) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()) {
Row {
Icon(Icons.Rounded.Book, stringResource(id = R.string.docs)) Icon(Icons.Rounded.Book, stringResource(id = R.string.docs))
Text( Text(stringResource(id = R.string.docs_description), textAlign = TextAlign.Justify, modifier = Modifier.padding(start = 10.dp))
stringResource(id = R.string.docs_description),
textAlign = TextAlign.Justify,
modifier = Modifier.padding(start = 10.dp))
}
Icon(Icons.Rounded.ArrowForward, stringResource(id = R.string.go))
} }
} Icon(Icons.Rounded.ArrowForward, stringResource(id = R.string.go))
Divider(color = MaterialTheme.colorScheme.onBackground, thickness = 0.5.dp) }
TextButton( }
onClick = { openWebPage(context.resources.getString(R.string.discord_url)) }, Divider(color = MaterialTheme.colorScheme.onBackground, thickness = 0.5.dp)
modifier = Modifier.padding(vertical = 5.dp)) { TextButton(onClick = { openWebPage(context.resources.getString(R.string.discord_url)) }, modifier = Modifier.padding(vertical = 5.dp)) {
Row( Row(horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
horizontalArrangement = Arrangement.SpaceBetween, Row {
verticalAlignment = Alignment.CenterVertically, Icon(imageVector = ImageVector.vectorResource(R.drawable.discord), stringResource(
modifier = Modifier.fillMaxWidth()) { id = R.string.discord), Modifier.size(25.dp))
Row { Text(stringResource(id = R.string.discord_description), textAlign = TextAlign.Justify, modifier = Modifier.padding(start = 10.dp))
Icon(
imageVector = ImageVector.vectorResource(R.drawable.discord),
stringResource(id = R.string.discord),
Modifier.size(25.dp))
Text(
stringResource(id = R.string.discord_description),
textAlign = TextAlign.Justify,
modifier = Modifier.padding(start = 10.dp))
}
Icon(Icons.Rounded.ArrowForward, stringResource(id = R.string.go))
} }
} Icon(Icons.Rounded.ArrowForward, stringResource(id = R.string.go))
Divider(color = MaterialTheme.colorScheme.onBackground, thickness = 0.5.dp) }
TextButton( }
onClick = { openWebPage(context.resources.getString(R.string.github_url)) }, Divider(color = MaterialTheme.colorScheme.onBackground, thickness = 0.5.dp)
modifier = Modifier.padding(vertical = 5.dp)) { TextButton(onClick = { openWebPage(context.resources.getString(R.string.github_url)) }, modifier = Modifier.padding(vertical = 5.dp)) {
Row( Row(horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
horizontalArrangement = Arrangement.SpaceBetween, Row {
verticalAlignment = Alignment.CenterVertically, Icon(imageVector = ImageVector.vectorResource(R.drawable.github), stringResource(
modifier = Modifier.fillMaxWidth()) { id = R.string.github
Row { ), Modifier.size(25.dp))
Icon( Text("Open an issue", textAlign = TextAlign.Justify, modifier = Modifier.padding(start = 10.dp))
imageVector = ImageVector.vectorResource(R.drawable.github),
stringResource(id = R.string.github),
Modifier.size(25.dp))
Text(
"Open an issue",
textAlign = TextAlign.Justify,
modifier = Modifier.padding(start = 10.dp))
}
Icon(Icons.Rounded.ArrowForward, stringResource(id = R.string.go))
} }
} Icon(Icons.Rounded.ArrowForward, stringResource(id = R.string.go))
Divider(color = MaterialTheme.colorScheme.onBackground, thickness = 0.5.dp) }
TextButton( }
onClick = { launchEmail() }, modifier = Modifier.padding(vertical = 5.dp)) { Divider(color = MaterialTheme.colorScheme.onBackground, thickness = 0.5.dp)
Row( TextButton(onClick = { launchEmail() }, modifier = Modifier.padding(vertical = 5.dp)) {
horizontalArrangement = Arrangement.SpaceBetween, Row(horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
verticalAlignment = Alignment.CenterVertically, Row {
modifier = Modifier.fillMaxWidth()) {
Row {
Icon(Icons.Rounded.Mail, stringResource(id = R.string.email)) Icon(Icons.Rounded.Mail, stringResource(id = R.string.email))
Text( Text(stringResource(id = R.string.email_description), textAlign = TextAlign.Justify, modifier = Modifier.padding(start = 10.dp))
stringResource(id = R.string.email_description),
textAlign = TextAlign.Justify,
modifier = Modifier.padding(start = 10.dp))
}
Icon(Icons.Rounded.ArrowForward, stringResource(id = R.string.go))
} }
} Icon(Icons.Rounded.ArrowForward, stringResource(id = R.string.go))
}
}
} }
} }
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
Text( Text(stringResource(id = R.string.privacy_policy), style = TextStyle(textDecoration = TextDecoration.Underline),
stringResource(id = R.string.privacy_policy), modifier = Modifier.clickable {
style = TextStyle(textDecoration = TextDecoration.Underline), openWebPage(context.resources.getString(R.string.privacy_policy_url))
fontSize = 16.sp, })
modifier = Text("App version: ${BuildConfig.VERSION_NAME}", Modifier.padding(25.dp))
Modifier.clickable {
openWebPage(context.resources.getString(R.string.privacy_policy_url))
})
Row(
horizontalArrangement = Arrangement.spacedBy(25.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(25.dp)) {
Text("Version: ${BuildConfig.VERSION_NAME}", modifier = Modifier.focusable())
Text("Mode: ${if (uiState.settings.isKernelEnabled) "Kernel" else "Userspace" }")
}
} }
} }
@@ -1,8 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.support
import com.zaneschepke.wireguardautotunnel.data.model.Settings
data class SupportUiState(
val settings : Settings = Settings(),
val loading : Boolean = true
)
@@ -1,25 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.support
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepository
import com.zaneschepke.wireguardautotunnel.util.Constants
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject
@HiltViewModel
class SupportViewModel @Inject constructor(
private val settingsRepository: SettingsRepository
) : ViewModel() {
val uiState = settingsRepository.getSettingsFlow().map {
SupportUiState(it, false)
}.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(Constants.SUBSCRIPTION_TIMEOUT),
SupportUiState()
)
}
@@ -11,8 +11,7 @@ val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71) val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFFFFFFFF) val Pink40 = Color(0xFFFFFFFF)
// status colors //status colors
val brickRed = Color(0xFFCE4257) val brickRed = Color(0xFFCE4257)
val corn = Color(0xFFFBEC5D)
val pinkRed = Color(0xFFEF476F) val pinkRed = Color(0xFFEF476F)
val mint = Color(0xFF52B788) val mint = Color(0xFF52B788)
@@ -15,52 +15,51 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
private val DarkColorScheme = private val DarkColorScheme = darkColorScheme(
darkColorScheme( //primary = Purple80,
// primary = Purple80, primary = virdigris,
primary = virdigris, secondary = virdigris,
secondary = virdigris, // secondary = PurpleGrey80,
// secondary = PurpleGrey80, tertiary = virdigris
tertiary = virdigris //tertiary = Pink80
// tertiary = Pink80 )
)
private val LightColorScheme = private val LightColorScheme = lightColorScheme(
lightColorScheme( primary = Purple40,
primary = Purple40, secondary = PurpleGrey40,
secondary = PurpleGrey40, tertiary = Pink40
tertiary = Pink40
/* Other default colors to override /* Other default colors to override
background = Color(0xFFFFFBFE), background = Color(0xFFFFFBFE),
surface = Color(0xFFFFFBFE), surface = Color(0xFFFFFBFE),
onPrimary = Color.White, onPrimary = Color.White,
onSecondary = Color.White, onSecondary = Color.White,
onTertiary = Color.White, onTertiary = Color.White,
onBackground = Color(0xFF1C1B1F), onBackground = Color(0xFF1C1B1F),
onSurface = Color(0xFF1C1B1F), onSurface = Color(0xFF1C1B1F),
*/ */
) )
@Composable @Composable
fun WireguardAutoTunnelTheme( fun WireguardAutoTunnelTheme(
// force dark theme //force dark theme
darkTheme: Boolean = true, darkTheme : Boolean = true,
// darkTheme: Boolean = isSystemInDarkTheme(), //darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+ // Dynamic color is available on Android 12+
// turning off dynamic color for now //turning off dynamic color for now
dynamicColor: Boolean = false, dynamicColor: Boolean = false,
content: @Composable () -> Unit content: @Composable () -> Unit
) { ) {
val colorScheme =
when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme val colorScheme = when {
else -> LightColorScheme dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
} }
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
val view = LocalView.current val view = LocalView.current
if (!view.isInEditMode) { if (!view.isInEditMode) {
SideEffect { SideEffect {
@@ -7,30 +7,28 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
// Set of Material typography styles to start with // Set of Material typography styles to start with
val Typography = val Typography = Typography(
Typography( bodyLarge = TextStyle(
bodyLarge = fontFamily = FontFamily.Default,
TextStyle( fontWeight = FontWeight.Normal,
fontFamily = FontFamily.Default, fontSize = 16.sp,
fontWeight = FontWeight.Normal, lineHeight = 24.sp,
fontSize = 16.sp, letterSpacing = 0.5.sp
lineHeight = 24.sp,
letterSpacing = 0.5.sp
)
/* Other default text styles to override
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
*/
) )
/* Other default text styles to override
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
*/
)
@@ -1,90 +0,0 @@
package com.zaneschepke.wireguardautotunnel.util
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
sealed class Event {
abstract val message: String
sealed class Error : Event() {
data object None : Error() {
override val message: String
get() = WireGuardAutoTunnel.instance.getString(R.string.error_none)
}
data object SsidConflict : Error() {
override val message: String
get() = WireGuardAutoTunnel.instance.getString(R.string.error_ssid_exists)
}
data object RootDenied : Error() {
override val message: String
get() = WireGuardAutoTunnel.instance.getString(R.string.error_root_denied)
}
data class General(val customMessage: String) : Error() {
override val message: String
get() = customMessage
}
data class Exception(val exception : kotlin.Exception) : Error() {
override val message: String
get() = exception.message ?: WireGuardAutoTunnel.instance.getString(R.string.unknown_error)
}
data object InvalidQrCode : Error() {
override val message: String
get() = WireGuardAutoTunnel.instance.getString(R.string.error_invalid_code)
}
data object InvalidFileExtension : Error() {
override val message: String
get() = WireGuardAutoTunnel.instance.getString(R.string.error_file_extension)
}
data object FileReadFailed : Error() {
override val message: String
get() = WireGuardAutoTunnel.instance.getString(R.string.error_file_extension)
}
data object AuthenticationFailed : Error() {
override val message: String
get() = WireGuardAutoTunnel.instance.getString(R.string.error_authentication_failed)
}
data object AuthorizationFailed : Error() {
override val message: String
get() = WireGuardAutoTunnel.instance.getString(R.string.error_authorization_failed)
}
data object BackgroundLocationRequired : Error() {
override val message: String
get() = WireGuardAutoTunnel.instance.getString(R.string.background_location_required)
}
data object LocationServicesRequired : Error() {
override val message: String
get() = WireGuardAutoTunnel.instance.getString(R.string.location_services_required)
}
data object PreciseLocationRequired : Error() {
override val message: String
get() = WireGuardAutoTunnel.instance.getString(R.string.precise_location_required)
}
data object FileExplorerRequired : Error() {
override val message: String
get() = WireGuardAutoTunnel.instance.getString(R.string.error_no_file_explorer)
}
}
sealed class Message : Event() {
data object ConfigSaved: Message() {
override val message: String
get() = WireGuardAutoTunnel.instance.getString(R.string.config_changes_saved)
}
data object ConfigsExported: Message() {
override val message: String
get() = WireGuardAutoTunnel.instance.getString(R.string.exported_configs_message)
}
data object TunnelOffAction: Message() {
override val message: String
get() = WireGuardAutoTunnel.instance.getString(R.string.turn_off_tunnel)
}
data object TunnelOnAction: Message() {
override val message: String
get() = WireGuardAutoTunnel.instance.getString(R.string.turn_on_tunnel)
}
data object AutoTunnelOffAction: Message() {
override val message: String
get() = WireGuardAutoTunnel.instance.getString(R.string.turn_off_auto)
}
}
}
@@ -1,68 +0,0 @@
package com.zaneschepke.wireguardautotunnel.util
import android.content.BroadcastReceiver
import android.content.pm.PackageInfo
import com.wireguard.android.backend.Statistics
import com.wireguard.android.backend.Statistics.PeerStats
import com.wireguard.crypto.Key
import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.service.tunnel.HandshakeStatus
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import java.math.BigDecimal
import java.text.DecimalFormat
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
fun BroadcastReceiver.goAsync(
context: CoroutineContext = EmptyCoroutineContext,
block: suspend CoroutineScope.() -> Unit
) {
val pendingResult = goAsync()
@OptIn(DelicateCoroutinesApi::class) // Must run globally; there's no teardown callback.
GlobalScope.launch(context) {
try {
block()
} finally {
pendingResult.finish()
}
}
}
fun BigDecimal.toThreeDecimalPlaceString(): String {
val df = DecimalFormat("#.###")
return df.format(this)
}
fun <T> List<T>.update(index: Int, item: T): List<T> = toMutableList().apply { this[index] = item }
fun <T> List<T>.removeAt(index: Int): List<T> = toMutableList().apply { this.removeAt(index) }
typealias TunnelConfigs = List<TunnelConfig>
typealias Packages = List<PackageInfo>
fun Statistics.mapPeerStats(): Map<Key, PeerStats?> {
return this.peers().associateWith { key ->
(this.peer(key))
}
}
fun PeerStats.latestHandshakeSeconds() : Long? {
return NumberUtils.getSecondsBetweenTimestampAndNow(this.latestHandshakeEpochMillis)
}
fun PeerStats.handshakeStatus() : HandshakeStatus {
//TODO add never connected status after duration
return this.latestHandshakeSeconds().let {
when {
it == null -> HandshakeStatus.NOT_STARTED
it <= HandshakeStatus.STALE_TIME_LIMIT_SEC -> HandshakeStatus.HEALTHY
it > HandshakeStatus.STALE_TIME_LIMIT_SEC -> HandshakeStatus.STALE
else -> {
HandshakeStatus.UNKNOWN
}
}
}
}
@@ -6,23 +6,24 @@ import java.time.Instant
import kotlin.math.pow import kotlin.math.pow
object NumberUtils { object NumberUtils {
private const val BYTES_IN_KB = 1024.0 private const val BYTES_IN_KB = 1024.0
private val BYTES_IN_MB = BYTES_IN_KB.pow(2.0) private val BYTES_IN_MB = BYTES_IN_KB.pow(2.0)
private val keyValidationRegex = """^[A-Za-z0-9+/]{42}[AEIMQUYcgkosw480]=${'$'}""".toRegex() private val keyValidationRegex = """^[A-Za-z0-9+/]{42}[AEIMQUYcgkosw480]=${'$'}""".toRegex()
fun bytesToMB(bytes: Long): BigDecimal { fun bytesToMB(bytes : Long) : BigDecimal {
return bytes.toBigDecimal().divide(BYTES_IN_MB.toBigDecimal()) return bytes.toBigDecimal().divide(BYTES_IN_MB.toBigDecimal())
} }
fun isValidKey(key: String): Boolean { fun isValidKey(key : String) : Boolean {
return key.matches(keyValidationRegex) return key.matches(keyValidationRegex)
} }
fun generateRandomTunnelName(): String { fun generateRandomTunnelName() : String {
return "tunnel${(Math.random() * 100000).toInt()}" return "tunnel${(Math.random() * 100000).toInt()}"
} }
fun getSecondsBetweenTimestampAndNow(epoch: Long): Long? { fun getSecondsBetweenTimestampAndNow(epoch : Long) : Long? {
return if (epoch != 0L) { return if (epoch != 0L) {
val time = Instant.ofEpochMilli(epoch) val time = Instant.ofEpochMilli(epoch)
return Duration.between(time, Instant.now()).seconds return Duration.between(time, Instant.now()).seconds
@@ -1,16 +0,0 @@
package com.zaneschepke.wireguardautotunnel.util
import timber.log.Timber
sealed class Result<T> {
class Success<T>(val data: T): Result<T>()
class Error<T>(val error : Event.Error): Result<T>() {
init {
when(this.error) {
is Event.Error.Exception -> Timber.e(this.error.exception)
else -> Timber.e(this.error.message)
}
}
}
}
@@ -6,55 +6,43 @@ import android.os.Build
import android.os.Environment import android.os.Environment
import android.provider.MediaStore import android.provider.MediaStore
import android.provider.MediaStore.MediaColumns import android.provider.MediaStore.MediaColumns
import com.zaneschepke.wireguardautotunnel.Constants
import java.io.File import java.io.File
import java.io.OutputStream import java.io.OutputStream
import java.time.Instant import java.time.Instant
import java.util.zip.ZipEntry import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream import java.util.zip.ZipOutputStream
object FileUtils { object StorageUtil {
private const val ZIP_FILE_MIME_TYPE = "application/zip" private const val ZIP_FILE_MIME_TYPE = "application/zip"
private fun createDownloadsFileOutputStream(context: Context, fileName: String, mimeType : String = Constants.ALLOWED_FILE_TYPES) : OutputStream? {
private fun createDownloadsFileOutputStream(
context: Context,
fileName: String,
mimeType: String = Constants.ALLOWED_FILE_TYPES
): OutputStream? {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val resolver = context.contentResolver val resolver = context.contentResolver
val contentValues = val contentValues = ContentValues().apply {
ContentValues().apply { put(MediaColumns.DISPLAY_NAME, fileName)
put(MediaColumns.DISPLAY_NAME, fileName) put(MediaColumns.MIME_TYPE, mimeType)
put(MediaColumns.MIME_TYPE, mimeType) put(MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
put(MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS) }
}
val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues) val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues)
if (uri != null) { if (uri != null) {
return resolver.openOutputStream(uri) return resolver.openOutputStream(uri)
} }
} else { } else {
val target = val target = File(
File( Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), fileName
fileName )
)
return target.outputStream() return target.outputStream()
} }
return null return null
} }
fun saveFilesToZip( fun saveFilesToZip(context: Context, files : List<File>) {
context: Context, val zipOutputStream = createDownloadsFileOutputStream(context, "wg-export_${Instant.now().epochSecond}.zip", ZIP_FILE_MIME_TYPE)
files: List<File>
) {
val zipOutputStream = createDownloadsFileOutputStream(
context,
"wg-export_${Instant.now().epochSecond}.zip",
ZIP_FILE_MIME_TYPE
)
ZipOutputStream(zipOutputStream).use { zos -> ZipOutputStream(zipOutputStream).use { zos ->
files.forEach { file -> files.forEach { file ->
val entry = ZipEntry(file.name) val entry = ZipEntry( file.name)
zos.putNextEntry(entry) zos.putNextEntry(entry)
if (file.isFile) { if (file.isFile) {
file.inputStream().use { fis -> fis.copyTo(zos) } file.inputStream().use { fis -> fis.copyTo(zos) }
@@ -0,0 +1,15 @@
package com.zaneschepke.wireguardautotunnel.util
import com.wireguard.config.BadConfigException
class WgTunnelException(e: Exception) : Exception() {
constructor(message : String) : this(Exception(message))
override val message: String = generateExceptionMessage(e)
private fun generateExceptionMessage(e : Exception) : String {
return when(e) {
is BadConfigException -> "${e.section.name} ${e.location.name} ${e.reason.name}"
else -> e.message ?: "Unknown error occurred"
}
}
}
+9 -20
View File
@@ -9,14 +9,13 @@
<string name="github_url">https://github.com/zaneschepke/wgtunnel/issues</string> <string name="github_url">https://github.com/zaneschepke/wgtunnel/issues</string>
<string name="docs_url">https://zaneschepke.com/wgtunnel-docs/overview.html</string> <string name="docs_url">https://zaneschepke.com/wgtunnel-docs/overview.html</string>
<string name="privacy_policy_url">https://zaneschepke.com/wgtunnel-docs/privacypolicy.html</string> <string name="privacy_policy_url">https://zaneschepke.com/wgtunnel-docs/privacypolicy.html</string>
<string name="error_file_extension">File is not a .conf or .zip</string> <string name="file_extension_message">File is not a .conf or .zip</string>
<string name="turn_off_tunnel">Action requires tunnel off</string> <string name="turn_off_tunnel">Turn off tunnel before editing</string>
<string name="no_tunnels">No tunnels added yet!</string> <string name="no_tunnels">No tunnels added yet!</string>
<string name="tunnel_exists">Tunnel name already exists</string> <string name="tunnel_exists">Tunnel name already exists</string>
<string name="discord_url">https://discord.gg/rbRRNh6H7V</string> <string name="discord_url">https://discord.gg/rbRRNh6H7V</string>
<string name="watcher_notification_title">Watcher Service</string> <string name="watcher_notification_title">Watcher Service</string>
<string name="watcher_notification_text_active">Monitoring network state changes: active</string> <string name="watcher_notification_text">Monitoring network state changes</string>
<string name="watcher_notification_text_paused">Monitoring network state changes: paused</string>
<string name="tunnel_start_title">VPN Connected</string> <string name="tunnel_start_title">VPN Connected</string>
<string name="tunnel_start_text">Connected to tunnel -</string> <string name="tunnel_start_text">Connected to tunnel -</string>
<string name="vpn_permission_required">VPN permission is required for the app to work properly. If this permission is not launching, please disable \"Always-on VPN\" in your phone settings for the official WireGuard mobile app and try again.</string> <string name="vpn_permission_required">VPN permission is required for the app to work properly. If this permission is not launching, please disable \"Always-on VPN\" in your phone settings for the official WireGuard mobile app and try again.</string>
@@ -79,7 +78,7 @@
<string name="lost_connection_failure_message">Attempting to reconnect to server after more than one minute of no response.</string> <string name="lost_connection_failure_message">Attempting to reconnect to server after more than one minute of no response.</string>
<string name="always_on_vpn_support">Allow Always-On VPN </string> <string name="always_on_vpn_support">Allow Always-On VPN </string>
<string name="select_tunnel_message">Please select a tunnel first</string> <string name="select_tunnel_message">Please select a tunnel first</string>
<string name="location_services_not_detected">Location Services Not Detected</string> <string name="location_services_not_detected">Unable to detect Location Services which are required for this feature. Please enable Location Services.</string>
<string name="check_again">Check again</string> <string name="check_again">Check again</string>
<string name="detecting_location_services_disabled">Detecting Location Services disabled</string> <string name="detecting_location_services_disabled">Detecting Location Services disabled</string>
<string name="precise_location_message">This feature requires precise location to access Wi-Fi SSID name. Please enable precise location here or in the app settings.</string> <string name="precise_location_message">This feature requires precise location to access Wi-Fi SSID name. Please enable precise location here or in the app settings.</string>
@@ -97,6 +96,8 @@
<string name="none">No trusted wifi names</string> <string name="none">No trusted wifi names</string>
<string name="never">Never</string> <string name="never">Never</string>
<string name="stream_failed">Failed to open file stream.</string> <string name="stream_failed">Failed to open file stream.</string>
<string name="unknown_error_message">An unknown error occurred.</string>
<string name="no_file_app">No file app installed.</string>
<string name="other">Other</string> <string name="other">Other</string>
<string name="auto_tunneling">Auto-tunneling</string> <string name="auto_tunneling">Auto-tunneling</string>
<string name="select_tunnel">Select tunnel to use</string> <string name="select_tunnel">Select tunnel to use</string>
@@ -107,7 +108,6 @@
<string name="create_import">Create from scratch</string> <string name="create_import">Create from scratch</string>
<string name="set_primary">Set primary</string> <string name="set_primary">Set primary</string>
<string name="turn_off_auto">Action requires auto-tunnel disabled</string> <string name="turn_off_auto">Action requires auto-tunnel disabled</string>
<string name="turn_on_tunnel">Action requires active tunnel</string>
<string name="add_peer">Add peer</string> <string name="add_peer">Add peer</string>
<string name="info">Info</string> <string name="info">Info</string>
<string name="done">Done</string> <string name="done">Done</string>
@@ -128,8 +128,7 @@
<string name="cancel">Cancel</string> <string name="cancel">Cancel</string>
<string name="primary_tunnel_change">Primary tunnel change</string> <string name="primary_tunnel_change">Primary tunnel change</string>
<string name="primary_tunnel_change_question">Would you like to make this your primary tunnel?</string> <string name="primary_tunnel_change_question">Would you like to make this your primary tunnel?</string>
<string name="error_authentication_failed">Authentication failed</string> <string name="authentication_failed">Authentication failed</string>
<string name="error_authorization_failed">Failed to authorize</string>
<string name="enabled_app_shortcuts">Enable app shortcuts</string> <string name="enabled_app_shortcuts">Enable app shortcuts</string>
<string name="export_configs">Export configs</string> <string name="export_configs">Export configs</string>
<string name="battery_saver">Battery saver (beta)</string> <string name="battery_saver">Battery saver (beta)</string>
@@ -138,6 +137,7 @@
<string name="precise_location_required">Precise location required</string> <string name="precise_location_required">Precise location required</string>
<string name="unknown_error">Unknown error occurred</string> <string name="unknown_error">Unknown error occurred</string>
<string name="exported_configs_message">Exported configs to downloads</string> <string name="exported_configs_message">Exported configs to downloads</string>
<string name="no_file_explorer">No file explorer installed</string>
<string name="status">status</string> <string name="status">status</string>
<string name="tunnel_on_wifi">Tunnel on untrusted wifi</string> <string name="tunnel_on_wifi">Tunnel on untrusted wifi</string>
<string name="my_email">zanecschepke@gmail.com</string> <string name="my_email">zanecschepke@gmail.com</string>
@@ -152,16 +152,5 @@
<string name="email">Email</string> <string name="email">Email</string>
<string name="email_description">Send me an email</string> <string name="email_description">Send me an email</string>
<string name="support_help_text">If you are experiencing issues, have improvement ideas, or just want to engage, the following resources are available:</string> <string name="support_help_text">If you are experiencing issues, have improvement ideas, or just want to engage, the following resources are available:</string>
<string name="kernel">Kernel</string>
<string name="use_kernel">Use kernel module</string>
<string name="error_ssid_exists">SSID already exists</string>
<string name="error_root_denied">Root shell denied</string>
<string name="error_no_file_explorer">No file explorer installed</string>
<string name="error_no_scan">No code scanned</string>
<string name="error_invalid_code">Invalid QR code</string>
<string name="error_none">No error</string>
<string name="error_file_read">Failed to read file</string>
<string name="location_service_missing">Location Services Not Detected</string>
<string name="location_services_missing_message">The app is not detecting any location services enabled on your device. Depending on the device, this could cause the untrusted wifi feature to fail to read the wifi name. Would you like to continue anyways?</string>
<string name="auto_tunnel_title">Auto-tunnel Service</string>
</resources> </resources>
+1 -1
View File
@@ -12,5 +12,5 @@ plugins {
alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.hilt.android) apply false alias(libs.plugins.hilt.android) apply false
kotlin("plugin.serialization").version(libs.versions.kotlin).apply(false) kotlin("plugin.serialization").version(libs.versions.kotlin).apply(false)
alias(libs.plugins.ksp) apply false alias(libs.plugins.ksp) apply false
} }
+16 -23
View File
@@ -1,36 +1,29 @@
import org.gradle.api.invocation.Gradle import org.gradle.api.invocation.Gradle
object BuildHelper { object BuildHelper {
private fun getCurrentFlavor(gradle: Gradle): String { private fun getCurrentFlavor(gradle : Gradle): String {
val taskRequestsStr = gradle.startParameter.taskRequests.toString() val taskRequestsStr = gradle.startParameter.taskRequests.toString()
val pattern: java.util.regex.Pattern = val pattern: java.util.regex.Pattern = if (taskRequestsStr.contains("assemble")) {
if (taskRequestsStr.contains("assemble")) { java.util.regex.Pattern.compile("assemble(\\w+)(Release|Debug)")
java.util.regex.Pattern.compile("assemble(\\w+)(Release|Debug)") } else {
} else { java.util.regex.Pattern.compile("bundle(\\w+)(Release|Debug)")
java.util.regex.Pattern.compile("bundle(\\w+)(Release|Debug)") }
}
val matcher = pattern.matcher(taskRequestsStr) val matcher = pattern.matcher(taskRequestsStr)
val flavor = val flavor = if (matcher.find()) {
if (matcher.find()) { matcher.group(1).lowercase()
matcher.group(1).lowercase() } else {
} else { print("NO FLAVOR FOUND")
print("NO FLAVOR FOUND") ""
"" }
}
return flavor return flavor
} }
fun isGeneralFlavor(gradle: Gradle): Boolean { fun isGeneralFlavor(gradle : Gradle) : Boolean {
return getCurrentFlavor(gradle) == "general" return getCurrentFlavor(gradle) == "general"
} }
fun isReleaseBuild(gradle: Gradle) : Boolean {
fun isReleaseBuild(gradle: Gradle): Boolean { return (gradle.startParameter.taskNames.size > 0 && gradle.startParameter.taskNames[0].contains(
return ( "Release"))
gradle.startParameter.taskNames.size > 0 &&
gradle.startParameter.taskNames[0].contains(
"Release",
)
)
} }
} }
+2 -2
View File
@@ -1,7 +1,7 @@
object Constants { object Constants {
const val VERSION_NAME = "3.3.2" const val VERSION_NAME = "3.2.3"
const val JVM_TARGET = "17" const val JVM_TARGET = "17"
const val VERSION_CODE = 33200 const val VERSION_CODE = 32300
const val TARGET_SDK = 34 const val TARGET_SDK = 34
const val MIN_SDK = 26 const val MIN_SDK = 26
const val APP_ID = "com.zaneschepke.wireguardautotunnel" const val APP_ID = "com.zaneschepke.wireguardautotunnel"
@@ -1,5 +0,0 @@
Enhancements:
- Add basic WireGuard Kernel support
- Improved location disclosure flow
- Fix auto-tunnel permissions bug
- Various other UI bug fixes
@@ -1,2 +0,0 @@
Fixes:
- Android 14 foreground permissions
@@ -1,8 +0,0 @@
Enhancements:
- Refactor state management
- Improve AndroidTV navigation
- Improve auto-tunneling efficiency
- Improve navigation
- Auto-tunneling pause feature
- Many bugfixes
@@ -1,7 +0,0 @@
Enhancements:
- Refactor state management
- Improve AndroidTV navigation
- Improve auto-tunneling efficiency
- Improve navigation
- Auto-tunneling pause feature
- Many bugfixes
@@ -1,8 +0,0 @@
Enhancements:
- Refactor state management
- Improve AndroidTV navigation
- Improve auto-tunneling efficiency
- Improve navigation
- Auto-tunneling pause feature
- Fix foreground start auto-tunnel
- Many bugfixes

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