Compare commits

..

39 Commits

Author SHA1 Message Date
Zane Schepke 47f619ff07 add release notes and docs 2024-09-22 22:51:14 -04:00
Zane Schepke 791b532d85 fix: nav crash bug on scratch config 2024-09-22 17:57:13 -04:00
Zane Schepke 57d096ebb1 cd: fix nightly 2024-09-22 03:18:55 -04:00
Zane Schepke cfb4f85ded migrate to typed navigation 2024-09-22 03:07:02 -04:00
Zane Schepke d330fa4c28 fix: auto tunnel overrides 2024-09-22 00:24:38 -04:00
GitHub Actions a362327fa3 Automated build update 2024-09-21 03:30:33 +00:00
Zane Schepke 9c72390398 fix: split tunneling selection
Minor improvements to split tunneling selection

Add check to cleanup any uninstalled apps from included/excluded packages
Closes #236
2024-09-20 23:23:27 -04:00
GitHub Actions d1d59aaf5c Automated build update 2024-09-21 02:03:14 +00:00
Zane Schepke 03fdb82c24 fix: kernel mode and slowness
Switch to amenzia backend by default.

Revert to wg official kernel backend as Amnezia's does not work.
2024-09-20 21:53:55 -04:00
GitHub Actions 4355e2e9ce Automated build update 2024-09-20 23:55:41 +00:00
Zane Schepke d7b4fbecb9 fix: improve am config edit and import
Fixes bugs with new ip config fields

Improves performance of config edit screen

fixes nightly run workflows
2024-09-20 19:48:13 -04:00
Zane Schepke 7fee0b3768 cd: fix nightly 2024-09-20 02:14:06 -04:00
Zane Schepke bd76ee5392 chore: bump versions
fix kernel mode enable bug
2024-09-19 23:18:57 -04:00
Zane Schepke 911ed140e0 fix: restart tunnel on boot with aovpn
closes #292

also fixed bug where pausing auto tunnel did not pause pinger
2024-09-19 19:09:33 -04:00
Zane Schepke a9d7648425 fix: androidtv ping settings bug
save for android tv will not work when you submit with keyboard

closes #355
2024-09-19 17:44:17 -04:00
Zane Schepke 230f505806 cd: optimize nightly
Add check to only run nightly if changes in the past 24 hours
2024-09-19 16:45:31 -04:00
GitHub Actions e58dad00ea Automated build update 2024-09-19 03:30:00 +00:00
GitHub Actions 8ddf5cf537 Automated build update 2024-09-18 03:29:16 +00:00
GitHub Actions d86fa782cc Automated build update 2024-09-17 03:29:40 +00:00
GitHub Actions fe7b9072d6 Automated build update 2024-09-16 05:42:20 +00:00
Zane Schepke 9895ed1061 fix: versionCode increment 2024-09-16 01:35:00 -04:00
Zane Schepke 34066ea182 fix: version bump in cd 2024-09-16 01:14:28 -04:00
Zane Schepke b99b116fdb fix: android tv auto tunnel bug
Also fixed bug that could cause need to click start twice on mobile when starting auto tunneling

Fix nightly cd to bump app versionCode
2024-09-16 01:01:13 -04:00
Zane Schepke 9d42e7299b fix: wg version 2024-09-15 00:41:53 -04:00
Zane Schepke b782cdeb35 Merge branch 'main' of https://github.com/zaneschepke/wgtunnel 2024-09-15 00:39:20 -04:00
Zane Schepke e40515c138 chore: bump version 2024-09-15 00:39:13 -04:00
dependabot[bot] d62df80682 build(deps): bump actions/upload-artifact from 4.3.6 to 4.4.0 (#340)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-15 00:35:39 -04:00
Zane Schepke 71284b5a9c bump deps 2024-09-15 00:34:38 -04:00
Zane Schepke 0a730b7a1a add wildcards and live auto tunnel changes
add custom ping settings per tunnel

fix pin lock screen on light mode fix
closes #350

add allowance for auto tunnel changes while active

add trusted ssid wildcard support
closes #351

fix tunnel notification not disappearing after backgrounded start
closes #348

fix restart on reboot if auto tunnel is enabled
closes #335

allow for auto tunnling for restart on ping fail while using always on vpn
2024-09-15 00:30:41 -04:00
Zane Schepke 753d7eb22a Merge branch 'main' of https://github.com/zaneschepke/wgtunnel 2024-09-07 00:03:40 -04:00
Roy Orbitson 6e961e0994 Preserve DNS search domains (#344) 2024-09-06 23:01:59 -04:00
Zane Schepke 6bea44269f bump gradle 2024-09-06 22:19:22 -04:00
Zane Schepke b2a2b9fcf4 fix: android nightly workflow
bump deps
2024-08-27 01:12:34 -04:00
Zane Schepke 543a61efe0 add package build types 2024-08-25 22:34:28 -04:00
Zane Schepke 688fad770c chore: typo fix readme 2024-08-17 23:12:58 -04:00
Zane Schepke e87dd8d3ce chore: update README.md 2024-08-17 23:12:03 -04:00
Zane Schepke 30851a7d7b fix: tile control and kernel sync (#320)
increase auto tunnel delay to 3 seconds

optimize stats job by killing it when app is backgrounded

fix tunnel launch from background

add restart of services and tunnels after update
2024-08-17 21:29:31 -04:00
Zane Schepke 3f4673b2a7 fix: improve navigation animation speed
Fixes possible crashes on slow androidTVs

Closes #49
2024-08-17 00:43:57 -04:00
Zane Schepke 528a1f84e4 fix: minor ui changes 2024-08-16 22:13:31 -04:00
85 changed files with 2889 additions and 2455 deletions
@@ -1,4 +1,4 @@
name: Issue Updates Workflow
name: on-issue
on:
issues:
@@ -7,8 +7,8 @@ on:
jobs:
build:
name: Build
on-issue:
name: On new issue
runs-on: ubuntu-latest
steps:
- name: Send Telegram Message
@@ -1,4 +1,4 @@
name: Release Updates Workflow
name: on-publish
on:
release:
@@ -7,8 +7,8 @@ on:
jobs:
build:
name: Build
on-publish:
name: On publish
runs-on: ubuntu-latest
steps:
- name: Send Telegram Message
+40 -7
View File
@@ -2,7 +2,7 @@ name: release-android
on:
schedule:
- cron: "4 3 * * *"
- cron: "4 3 * * *"
workflow_dispatch:
inputs:
track:
@@ -14,7 +14,7 @@ on:
- alpha
- beta
- production
default: alpha
default: none
required: true
release_type:
type: choice
@@ -30,13 +30,30 @@ on:
description: "Tag name for release"
required: false
default: nightly
workflow_call:
jobs:
build:
name: Build Signed APK
if: ${{ inputs.release_type != 'none' }}
check_date:
runs-on: ubuntu-latest
name: Check latest commit
outputs:
should_run: ${{ steps.should_run.outputs.should_run }}
steps:
- uses: actions/checkout@v4
- name: print latest_commit
run: echo ${{ github.sha }}
- id: should_run
continue-on-error: true
name: check latest commit is less than a day
if: ${{ github.event_name == 'schedule' }}
run: test -z $(git rev-list --after="23 hours" ${{ github.sha }}) && echo "::set-output name=should_run::false"
build:
needs: check_date
if: |
github.event_name != 'schedule' ||
(needs.check_date.outputs.should_run == 'true' && github.event_name == 'schedule')
name: Build Signed APK
runs-on: ubuntu-latest
env:
SIGNING_KEY_ALIAS: ${{ secrets.SIGNING_KEY_ALIAS }}
SIGNING_KEY_PASSWORD: ${{ secrets.SIGNING_KEY_PASSWORD }}
@@ -110,9 +127,24 @@ jobs:
version_code=$(grep "VERSION_CODE" buildSrc/src/main/kotlin/Constants.kt | awk '{print $5}' | tr -d '\n')
echo "VERSION_CODE=$version_code" >> $GITHUB_ENV
- name: Commit and push versionCode changes
if: ${{ inputs.release_type == '' || inputs.release_type == 'nightly' || inputs.release_type == 'prerelease' }}
run: |
git config --global user.name 'GitHub Actions'
git config --global user.email 'actions@github.com'
git add versionCode.txt
git commit -m "Automated build update"
- name: Push changes
if: ${{ inputs.release_type == '' || inputs.release_type == 'nightly' || inputs.release_type == 'prerelease' }}
uses: ad-m/github-push-action@master
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
branch: ${{ github.ref }}
# Save the APK after the Build job is complete to publish it as a Github release in the next job
- name: Upload APK
uses: actions/upload-artifact@v4.3.6
uses: actions/upload-artifact@v4.4.0
with:
name: wgtunnel
path: ${{ env.APK_PATH }}
@@ -149,6 +181,7 @@ jobs:
run: |
echo "RELEASE_NOTES=Nightly build for the latest development version of the app." >> $GITHUB_ENV
gh release delete nightly --yes || true
git push origin :nightly || true
- name: On prerelease release notes
if: ${{ inputs.release_type == 'prerelease' }}
+2 -2
View File
@@ -55,13 +55,13 @@ and on while on different networks. This app was created to offer a free solutio
* Split tunneling by application with search
* WireGuard support for kernel and userspace modes
* Amnezia support for userspace mode for DPI/censorship protection
* Pre/Post Up/Down scripts support for all modes on a rooted device
* Always-On VPN support
* Export Amnezia and WireGuard tunnels to zip
* Quick tile support for tunnel toggling, auto-tunneling
* Static shortcuts support for tunnel toggling, auto-tunneling
* Intent automation support for all tunnels
* Automatic auto-tunneling service restart after reboot
* Automatic tunnel restart after reboot
* Automatic auto-tunneling service and/or tunnel restart after reboot or app update
* Battery preservation measures
* Restart tunnel on ping failure (beta)
+44 -14
View File
@@ -8,6 +8,21 @@ plugins {
alias(libs.plugins.grgit)
}
val versionFile = file("$rootDir/versionCode.txt")
val versionCodeIncrement = with(getBuildTaskName().lowercase()) {
when {
this.contains(Constants.NIGHTLY) || this.contains(Constants.PRERELEASE) -> {
if (versionFile.exists()) {
versionFile.readText().toInt() + 1
} else {
1
}
}
else -> 0
}
}
android {
namespace = Constants.APP_ID
compileSdk = Constants.TARGET_SDK
@@ -20,7 +35,7 @@ android {
applicationId = Constants.APP_ID
minSdk = Constants.MIN_SDK
targetSdk = Constants.TARGET_SDK
versionCode = determineVersionCode()
versionCode = Constants.VERSION_CODE + versionCodeIncrement
versionName = determineVersionName()
ksp { arg("room.schemaLocation", "$projectDir/schemas") }
@@ -58,14 +73,25 @@ android {
)
signingConfig = signingConfigs.getByName(Constants.RELEASE)
}
debug { isDebuggable = true }
debug {
applicationIdSuffix = ".debug"
versionNameSuffix = "-debug"
resValue("string", "app_name", "WG Tunnel - Debug")
isDebuggable = true
}
create(Constants.PRERELEASE) {
initWith(buildTypes.getByName(Constants.RELEASE))
applicationIdSuffix = ".prerelease"
versionNameSuffix = "-pre"
resValue("string", "app_name", "WG Tunnel - Pre")
}
create(Constants.NIGHTLY) {
initWith(buildTypes.getByName(Constants.RELEASE))
applicationIdSuffix = ".nightly"
versionNameSuffix = "-nightly"
resValue("string", "app_name", "WG Tunnel - Nightly")
}
applicationVariants.all {
@@ -146,8 +172,6 @@ dependencies {
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.zaneschepke.multifab)
// hilt
implementation(libs.hilt.android)
ksp(libs.hilt.android.compiler)
@@ -188,16 +212,6 @@ dependencies {
implementation(libs.androidx.core.splashscreen)
}
fun determineVersionCode(): Int {
return with(getBuildTaskName().lowercase()) {
when {
contains(Constants.NIGHTLY) -> Constants.VERSION_CODE + Constants.NIGHTLY_CODE
contains(Constants.PRERELEASE) -> Constants.VERSION_CODE + Constants.PRERELEASE_CODE
else -> Constants.VERSION_CODE
}
}
}
fun determineVersionName(): String {
return with(getBuildTaskName().lowercase()) {
when {
@@ -208,3 +222,19 @@ fun determineVersionName(): String {
}
}
}
val incrementVersionCode by tasks.registering {
doLast {
val versionFile = file("$rootDir/versionCode.txt")
if (versionFile.exists()) {
versionFile.writeText(versionCodeIncrement.toString())
println("Incremented versionCode to $versionCodeIncrement")
}
}
}
tasks.whenTaskAdded {
if (name.startsWith("assemble") && !name.lowercase().contains("debug")) {
dependsOn(incrementVersionCode)
}
}
@@ -0,0 +1,225 @@
{
"formatVersion": 1,
"database": {
"version": 10,
"identityHash": "c8621055524f90b4d1972f6171f59e80",
"entities": [
{
"tableName": "Settings",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `is_tunnel_enabled` INTEGER NOT NULL, `is_tunnel_on_mobile_data_enabled` INTEGER NOT NULL, `trusted_network_ssids` TEXT NOT NULL, `is_always_on_vpn_enabled` INTEGER NOT NULL, `is_tunnel_on_ethernet_enabled` INTEGER NOT NULL, `is_shortcuts_enabled` INTEGER NOT NULL DEFAULT false, `is_tunnel_on_wifi_enabled` INTEGER NOT NULL DEFAULT false, `is_kernel_enabled` INTEGER NOT NULL DEFAULT false, `is_restore_on_boot_enabled` INTEGER NOT NULL DEFAULT false, `is_multi_tunnel_enabled` INTEGER NOT NULL DEFAULT false, `is_auto_tunnel_paused` INTEGER NOT NULL DEFAULT false, `is_ping_enabled` INTEGER NOT NULL DEFAULT false, `is_amnezia_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": "isAlwaysOnVpnEnabled",
"columnName": "is_always_on_vpn_enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isTunnelOnEthernetEnabled",
"columnName": "is_tunnel_on_ethernet_enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isShortcutsEnabled",
"columnName": "is_shortcuts_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isTunnelOnWifiEnabled",
"columnName": "is_tunnel_on_wifi_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isKernelEnabled",
"columnName": "is_kernel_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isRestoreOnBootEnabled",
"columnName": "is_restore_on_boot_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isMultiTunnelEnabled",
"columnName": "is_multi_tunnel_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isAutoTunnelPaused",
"columnName": "is_auto_tunnel_paused",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isPingEnabled",
"columnName": "is_ping_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isAmneziaEnabled",
"columnName": "is_amnezia_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
}
],
"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, `tunnel_networks` TEXT NOT NULL DEFAULT '', `is_mobile_data_tunnel` INTEGER NOT NULL DEFAULT false, `is_primary_tunnel` INTEGER NOT NULL DEFAULT false, `am_quick` TEXT NOT NULL DEFAULT '', `is_Active` INTEGER NOT NULL DEFAULT false, `is_ping_enabled` INTEGER NOT NULL DEFAULT false, `ping_interval` INTEGER DEFAULT null, `ping_cooldown` INTEGER DEFAULT null, `ping_ip` TEXT DEFAULT null)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "wgQuick",
"columnName": "wg_quick",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "tunnelNetworks",
"columnName": "tunnel_networks",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "isMobileDataTunnel",
"columnName": "is_mobile_data_tunnel",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isPrimaryTunnel",
"columnName": "is_primary_tunnel",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "amQuick",
"columnName": "am_quick",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "isActive",
"columnName": "is_Active",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isPingEnabled",
"columnName": "is_ping_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "pingInterval",
"columnName": "ping_interval",
"affinity": "INTEGER",
"notNull": false,
"defaultValue": "null"
},
{
"fieldPath": "pingCooldown",
"columnName": "ping_cooldown",
"affinity": "INTEGER",
"notNull": false,
"defaultValue": "null"
},
{
"fieldPath": "pingIp",
"columnName": "ping_ip",
"affinity": "TEXT",
"notNull": false,
"defaultValue": "null"
}
],
"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, 'c8621055524f90b4d1972f6171f59e80')"
]
}
}
+17 -6
View File
@@ -103,12 +103,6 @@
android:launchMode="singleInstance"
android:theme="@android:style/Theme.NoDisplay" />
<service
android:name=".service.foreground.ForegroundService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="systemExempted"
tools:node="merge" />
<service
android:name=".service.tile.TunnelControlTile"
android:exported="true"
@@ -167,6 +161,16 @@
android:stopWithTask="false"
tools:node="merge" />
<service
android:name=".service.foreground.TunnelBackgroundService"
android:exported="false"
android:foregroundServiceType="systemExempted"
android:permission="android.permission.BIND_VPN_SERVICE">
<intent-filter>
<action android:name="android.net.VpnService" />
</intent-filter>
</service>
<receiver
android:name=".receiver.BootReceiver"
android:enabled="true"
@@ -184,6 +188,13 @@
android:name=".receiver.BackgroundActionReceiver"
android:enabled="true"
android:exported="false"/>
<receiver
android:name=".receiver.AppUpdateReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
<receiver
android:name=".receiver.KernelReceiver"
android:exported="false"
@@ -3,10 +3,15 @@ package com.zaneschepke.wireguardautotunnel
import android.app.Application
import android.os.StrictMode
import android.os.StrictMode.ThreadPolicy
import com.zaneschepke.logcatter.LocalLogCollector
import com.zaneschepke.wireguardautotunnel.module.ApplicationScope
import com.zaneschepke.wireguardautotunnel.module.IoDispatcher
import com.zaneschepke.wireguardautotunnel.util.ReleaseTree
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@@ -17,6 +22,13 @@ class WireGuardAutoTunnel : Application() {
@ApplicationScope
lateinit var applicationScope: CoroutineScope
@Inject
lateinit var localLogCollector: LocalLogCollector
@Inject
@IoDispatcher
lateinit var ioDispatcher: CoroutineDispatcher
override fun onCreate() {
super.onCreate()
instance = this
@@ -33,6 +45,11 @@ class WireGuardAutoTunnel : Application() {
} else {
Timber.plant(ReleaseTree())
}
if (!isRunningOnTv()) {
applicationScope.launch(ioDispatcher) {
localLogCollector.start()
}
}
}
companion object {
@@ -11,7 +11,7 @@ import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig
@Database(
entities = [Settings::class, TunnelConfig::class],
version = 9,
version = 10,
autoMigrations =
[
AutoMigration(from = 1, to = 2),
@@ -35,6 +35,7 @@ import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig
),
AutoMigration(7, 8),
AutoMigration(8, 9),
AutoMigration(9, 10),
],
exportSchema = true,
)
@@ -4,7 +4,6 @@ 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.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.zaneschepke.wireguardautotunnel.module.IoDispatcher
@@ -24,7 +23,6 @@ class DataStoreManager(
companion object {
val LOCATION_DISCLOSURE_SHOWN = booleanPreferencesKey("LOCATION_DISCLOSURE_SHOWN")
val BATTERY_OPTIMIZE_DISABLE_SHOWN = booleanPreferencesKey("BATTERY_OPTIMIZE_DISABLE_SHOWN")
val LAST_ACTIVE_TUNNEL = intPreferencesKey("LAST_ACTIVE_TUNNEL")
val CURRENT_SSID = stringPreferencesKey("CURRENT_SSID")
val IS_PIN_LOCK_ENABLED = booleanPreferencesKey("PIN_LOCK_ENABLED")
}
@@ -58,6 +56,18 @@ class DataStoreManager(
}
}
suspend fun <T> removeFromDataStore(key: Preferences.Key<T>) {
withContext(ioDispatcher) {
try {
context.dataStore.edit { it.remove(key) }
} catch (e: IOException) {
Timber.e(e)
} catch (e: Exception) {
Timber.e(e)
}
}
}
fun <T> getFromStoreFlow(key: Preferences.Key<T>) = context.dataStore.data.map { it[key] }
suspend fun <T> getFromStore(key: Preferences.Key<T>): T? {
@@ -4,7 +4,6 @@ data class GeneralState(
val isLocationDisclosureShown: Boolean = LOCATION_DISCLOSURE_SHOWN_DEFAULT,
val isBatteryOptimizationDisableShown: Boolean = BATTERY_OPTIMIZATION_DISABLE_SHOWN_DEFAULT,
val isPinLockEnabled: Boolean = PIN_LOCK_ENABLED_DEFAULT,
val lastActiveTunnelId: Int? = null,
) {
companion object {
const val LOCATION_DISCLOSURE_SHOWN_DEFAULT = false
@@ -37,11 +37,33 @@ data class TunnelConfig(
defaultValue = "false",
)
val isActive: Boolean = false,
@ColumnInfo(
name = "is_ping_enabled",
defaultValue = "false",
)
val isPingEnabled: Boolean = false,
@ColumnInfo(
name = "ping_interval",
defaultValue = "null",
)
val pingInterval: Long? = null,
@ColumnInfo(
name = "ping_cooldown",
defaultValue = "null",
)
val pingCooldown: Long? = null,
@ColumnInfo(
name = "ping_ip",
defaultValue = "null",
)
var pingIp: String? = null,
) {
fun toAmConfig(): org.amnezia.awg.config.Config {
return configFromAmQuick(if (amQuick != "") amQuick else wgQuick)
}
companion object {
fun findDefault(tunnels: List<TunnelConfig>): TunnelConfig? {
return tunnels.find { it.isPrimaryTunnel } ?: tunnels.firstOrNull()
}
fun configFromWgQuick(wgQuick: String): Config {
val inputStream: InputStream = wgQuick.byteInputStream()
@@ -15,8 +15,9 @@ constructor(
}
override suspend fun getStartTunnelConfig(): TunnelConfig? {
return appState.getLastActiveTunnelId()?.let {
tunnels.getById(it)
} ?: getPrimaryOrFirstTunnel()
tunnels.getActive().let {
if (it.isNotEmpty()) return it.first()
return getPrimaryOrFirstTunnel()
}
}
}
@@ -16,10 +16,6 @@ interface AppStateRepository {
suspend fun setBatteryOptimizationDisableShown(shown: Boolean)
suspend fun getLastActiveTunnelId(): Int?
suspend fun setLastActiveTunnelId(id: Int)
suspend fun getCurrentSsid(): String?
suspend fun setCurrentSsid(ssid: String)
@@ -2,65 +2,47 @@ package com.zaneschepke.wireguardautotunnel.data.repository
import com.zaneschepke.wireguardautotunnel.data.datastore.DataStoreManager
import com.zaneschepke.wireguardautotunnel.data.domain.GeneralState
import com.zaneschepke.wireguardautotunnel.module.IoDispatcher
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import timber.log.Timber
class DataStoreAppStateRepository(
private val dataStoreManager: DataStoreManager,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) :
AppStateRepository {
override suspend fun isLocationDisclosureShown(): Boolean {
return withContext(ioDispatcher) {
dataStoreManager.getFromStore(DataStoreManager.LOCATION_DISCLOSURE_SHOWN)
?: GeneralState.LOCATION_DISCLOSURE_SHOWN_DEFAULT
}
return dataStoreManager.getFromStore(DataStoreManager.LOCATION_DISCLOSURE_SHOWN)
?: GeneralState.LOCATION_DISCLOSURE_SHOWN_DEFAULT
}
override suspend fun setLocationDisclosureShown(shown: Boolean) {
withContext(ioDispatcher) { dataStoreManager.saveToDataStore(DataStoreManager.LOCATION_DISCLOSURE_SHOWN, shown) }
dataStoreManager.saveToDataStore(DataStoreManager.LOCATION_DISCLOSURE_SHOWN, shown)
}
override suspend fun isPinLockEnabled(): Boolean {
return withContext(ioDispatcher) {
dataStoreManager.getFromStore(DataStoreManager.IS_PIN_LOCK_ENABLED)
?: GeneralState.PIN_LOCK_ENABLED_DEFAULT
}
return dataStoreManager.getFromStore(DataStoreManager.IS_PIN_LOCK_ENABLED)
?: GeneralState.PIN_LOCK_ENABLED_DEFAULT
}
override suspend fun setPinLockEnabled(enabled: Boolean) {
withContext(ioDispatcher) { dataStoreManager.saveToDataStore(DataStoreManager.IS_PIN_LOCK_ENABLED, enabled) }
dataStoreManager.saveToDataStore(DataStoreManager.IS_PIN_LOCK_ENABLED, enabled)
}
override suspend fun isBatteryOptimizationDisableShown(): Boolean {
return withContext(ioDispatcher) {
dataStoreManager.getFromStore(DataStoreManager.BATTERY_OPTIMIZE_DISABLE_SHOWN)
?: GeneralState.BATTERY_OPTIMIZATION_DISABLE_SHOWN_DEFAULT
}
return dataStoreManager.getFromStore(DataStoreManager.BATTERY_OPTIMIZE_DISABLE_SHOWN)
?: GeneralState.BATTERY_OPTIMIZATION_DISABLE_SHOWN_DEFAULT
}
override suspend fun setBatteryOptimizationDisableShown(shown: Boolean) {
withContext(ioDispatcher) { dataStoreManager.saveToDataStore(DataStoreManager.BATTERY_OPTIMIZE_DISABLE_SHOWN, shown) }
}
override suspend fun getLastActiveTunnelId(): Int? {
return withContext(ioDispatcher) { dataStoreManager.getFromStore(DataStoreManager.LAST_ACTIVE_TUNNEL) }
}
override suspend fun setLastActiveTunnelId(id: Int) {
return withContext(ioDispatcher) { dataStoreManager.saveToDataStore(DataStoreManager.LAST_ACTIVE_TUNNEL, id) }
dataStoreManager.saveToDataStore(DataStoreManager.BATTERY_OPTIMIZE_DISABLE_SHOWN, shown)
}
override suspend fun getCurrentSsid(): String? {
return withContext(ioDispatcher) { dataStoreManager.getFromStore(DataStoreManager.CURRENT_SSID) }
return dataStoreManager.getFromStore(DataStoreManager.CURRENT_SSID)
}
override suspend fun setCurrentSsid(ssid: String) {
withContext(ioDispatcher) { dataStoreManager.saveToDataStore(DataStoreManager.CURRENT_SSID, ssid) }
dataStoreManager.saveToDataStore(DataStoreManager.CURRENT_SSID, ssid)
}
override val generalStateFlow: Flow<GeneralState> =
@@ -77,7 +59,6 @@ class DataStoreAppStateRepository(
isPinLockEnabled =
pref[DataStoreManager.IS_PIN_LOCK_ENABLED]
?: GeneralState.PIN_LOCK_ENABLED_DEFAULT,
lastActiveTunnelId = pref[DataStoreManager.LAST_ACTIVE_TUNNEL],
)
} catch (e: IllegalArgumentException) {
Timber.e(e)
@@ -0,0 +1,22 @@
package com.zaneschepke.wireguardautotunnel.module
import android.content.Context
import androidx.navigation.NavHostController
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.NavigationService
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.components.ActivityRetainedComponent
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.scopes.ActivityRetainedScoped
@Module
@InstallIn(ActivityRetainedComponent::class)
object NavigationModule {
@Provides
@ActivityRetainedScoped
fun provideNestedNavController(@ApplicationContext context: Context): NavHostController {
return NavigationService(context).navController
}
}
@@ -72,8 +72,8 @@ class RepositoryModule {
@Provides
@Singleton
fun provideGeneralStateRepository(dataStoreManager: DataStoreManager, @IoDispatcher ioDispatcher: CoroutineDispatcher): AppStateRepository {
return DataStoreAppStateRepository(dataStoreManager, ioDispatcher)
fun provideGeneralStateRepository(dataStoreManager: DataStoreManager): AppStateRepository {
return DataStoreAppStateRepository(dataStoreManager)
}
@Provides
@@ -8,7 +8,7 @@ import com.wireguard.android.backend.WgQuickBackend
import com.wireguard.android.util.RootShell
import com.wireguard.android.util.ToolsInstaller
import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import com.zaneschepke.wireguardautotunnel.data.repository.TunnelConfigRepository
import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelService
import com.zaneschepke.wireguardautotunnel.service.tunnel.WireGuardTunnel
import dagger.Module
@@ -60,25 +60,19 @@ class TunnelModule {
@Singleton
fun provideVpnService(
amneziaBackend: Provider<org.amnezia.awg.backend.Backend>,
@Userspace userspaceBackend: Provider<Backend>,
@Kernel kernelBackend: Provider<Backend>,
appDataRepository: AppDataRepository,
tunnelConfigRepository: TunnelConfigRepository,
@ApplicationScope applicationScope: CoroutineScope,
@IoDispatcher ioDispatcher: CoroutineDispatcher,
): TunnelService {
return WireGuardTunnel(
amneziaBackend,
userspaceBackend,
tunnelConfigRepository,
kernelBackend,
appDataRepository,
applicationScope,
ioDispatcher,
)
}
@Provides
@Singleton
fun provideServiceManager(): ServiceManager {
return ServiceManager()
}
}
@@ -0,0 +1,44 @@
package com.zaneschepke.wireguardautotunnel.receiver
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.module.ApplicationScope
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelService
import com.zaneschepke.wireguardautotunnel.util.extensions.startTunnelBackground
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@AndroidEntryPoint
class AppUpdateReceiver : BroadcastReceiver() {
@Inject
@ApplicationScope
lateinit var applicationScope: CoroutineScope
@Inject
lateinit var appDataRepository: AppDataRepository
@Inject
lateinit var tunnelService: TunnelService
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != Intent.ACTION_MY_PACKAGE_REPLACED) return
applicationScope.launch {
val settings = appDataRepository.settings.getSettings()
if (settings.isAutoTunnelEnabled) {
Timber.i("Restarting services after upgrade")
ServiceManager.startWatcherServiceForeground(context)
}
if (!settings.isAutoTunnelEnabled || settings.isAutoTunnelPaused) {
val tunnels = appDataRepository.tunnels.getAll().filter { it.isActive }
if (tunnels.isNotEmpty()) context.startTunnelBackground(tunnels.first().id)
}
}
}
}
@@ -5,10 +5,12 @@ import android.content.Context
import android.content.Intent
import com.zaneschepke.wireguardautotunnel.data.repository.TunnelConfigRepository
import com.zaneschepke.wireguardautotunnel.module.ApplicationScope
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelService
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Provider
@@ -30,9 +32,11 @@ class BackgroundActionReceiver : BroadcastReceiver() {
if (id == 0) return
when (intent.action) {
ACTION_CONNECT -> {
Timber.d("Connect actions")
applicationScope.launch {
val tunnel = tunnelConfigRepository.getById(id)
tunnel?.let {
ServiceManager.startTunnelBackgroundService(context)
tunnelService.get().startTunnel(it)
}
}
@@ -41,6 +45,7 @@ class BackgroundActionReceiver : BroadcastReceiver() {
applicationScope.launch {
val tunnel = tunnelConfigRepository.getById(id)
tunnel?.let {
ServiceManager.stopTunnelBackgroundService(context)
tunnelService.get().stopTunnel(it)
}
}
@@ -7,6 +7,8 @@ import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.module.ApplicationScope
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelService
import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelState
import com.zaneschepke.wireguardautotunnel.util.extensions.startTunnelBackground
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@@ -22,9 +24,6 @@ class BootReceiver : BroadcastReceiver() {
@Inject
lateinit var tunnelService: Provider<TunnelService>
@Inject
lateinit var serviceManager: ServiceManager
@Inject
@ApplicationScope
lateinit var applicationScope: CoroutineScope
@@ -32,16 +31,20 @@ class BootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (Intent.ACTION_BOOT_COMPLETED != intent.action) return
applicationScope.launch {
val settings = appDataRepository.settings.getSettings()
if (settings.isRestoreOnBootEnabled) {
appDataRepository.getStartTunnelConfig()?.let {
tunnelService.get().startTunnel(it)
with(appDataRepository.settings.getSettings()) {
if (isRestoreOnBootEnabled) {
val activeTunnels = appDataRepository.tunnels.getActive()
val tunState = tunnelService.get().vpnState.value.status
if (activeTunnels.isNotEmpty() && tunState != TunnelState.UP) {
Timber.i("Starting previously active tunnel")
context.startTunnelBackground(activeTunnels.first().id)
}
if (isAutoTunnelEnabled) {
Timber.i("Starting watcher service from boot")
ServiceManager.startWatcherServiceForeground(context)
}
}
}
if (settings.isAutoTunnelEnabled) {
Timber.i("Starting watcher service from boot")
serviceManager.startWatcherServiceForeground(context)
}
}
}
}
@@ -1,11 +1,14 @@
package com.zaneschepke.wireguardautotunnel.service.foreground
import android.content.Context
import android.os.Bundle
import android.content.Intent
import android.os.IBinder
import android.os.PowerManager
import androidx.core.app.ServiceCompat
import androidx.lifecycle.LifecycleService
import androidx.lifecycle.lifecycleScope
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.data.domain.Settings
import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig
import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.module.IoDispatcher
@@ -19,11 +22,18 @@ import com.zaneschepke.wireguardautotunnel.service.notification.NotificationServ
import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelService
import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelState
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.extensions.cancelWithMessage
import com.zaneschepke.wireguardautotunnel.util.extensions.isMatchingToWildcardList
import com.zaneschepke.wireguardautotunnel.util.extensions.isReachable
import com.zaneschepke.wireguardautotunnel.util.extensions.onNotRunning
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -33,7 +43,7 @@ import javax.inject.Inject
import javax.inject.Provider
@AndroidEntryPoint
class AutoTunnelService : ForegroundService() {
class AutoTunnelService : LifecycleService() {
private val foregroundId = 122
@Inject
@@ -62,11 +72,17 @@ class AutoTunnelService : ForegroundService() {
@MainImmediateDispatcher
lateinit var mainImmediateDispatcher: CoroutineDispatcher
private val networkEventsFlow = MutableStateFlow(AutoTunnelState())
private val autoTunnelStateFlow = MutableStateFlow(AutoTunnelState())
private var wakeLock: PowerManager.WakeLock? = null
private val tag = this.javaClass.name
private var wifiJob: Job? = null
private var mobileDataJob: Job? = null
private var ethernetJob: Job? = null
private var pingJob: Job? = null
private var networkEventJob: Job? = null
@get:Synchronized @set:Synchronized
private var running: Boolean = false
override fun onCreate() {
@@ -80,6 +96,26 @@ class AutoTunnelService : ForegroundService() {
}
}
override fun onBind(intent: Intent): IBinder? {
super.onBind(intent)
// We don't provide binding, so return null
return null
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Timber.d("onStartCommand executed with startId: $startId")
if (intent != null) {
val action = intent.action
when (action) {
Action.START.name,
Action.START_FOREGROUND.name,
-> startService()
Action.STOP.name, Action.STOP_FOREGROUND.name -> stopService()
}
}
return super.onStartCommand(intent, flags, startId)
}
private suspend fun launchNotification() {
if (appDataRepository.settings.getSettings().isAutoTunnelPaused) {
launchWatcherPausedNotification()
@@ -88,27 +124,34 @@ class AutoTunnelService : ForegroundService() {
}
}
override fun startService(extras: Bundle?) {
super.startService(extras)
private fun startService() {
if (running) return
running = true
kotlin.runCatching {
lifecycleScope.launch(mainImmediateDispatcher) {
launchNotification()
initWakeLock()
}
startWatcherJob()
startSettingsJob()
startVpnStateJob()
}.onFailure {
Timber.e(it)
}
}
override fun stopService() {
super.stopService()
private fun stopService() {
wakeLock?.let {
if (it.isHeld) {
it.release()
}
}
stopSelf()
}
override fun onDestroy() {
cancelAndResetNetworkJobs()
cancelAndResetPingJob()
super.onDestroy()
}
private fun launchWatcherNotification(description: String = getString(R.string.watcher_notification_text_active)) {
@@ -134,6 +177,7 @@ class AutoTunnelService : ForegroundService() {
private fun initWakeLock() {
wakeLock =
(getSystemService(Context.POWER_SERVICE) as PowerManager).run {
val tag = this.javaClass.name
newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "$tag::lock").apply {
try {
Timber.i("Initiating wakelock with 10 min timeout")
@@ -145,48 +189,42 @@ class AutoTunnelService : ForegroundService() {
}
}
private fun startWatcherJob() = lifecycleScope.launch {
val setting = appDataRepository.settings.getSettings()
launch {
Timber.i("Starting wifi watcher")
watchForWifiConnectivityChanges()
}
if (setting.isTunnelOnMobileDataEnabled) {
launch {
Timber.i("Starting mobile data watcher")
watchForMobileDataConnectivityChanges()
}
}
if (setting.isTunnelOnEthernetEnabled) {
launch {
Timber.i("Starting ethernet data watcher")
watchForEthernetConnectivityChanges()
}
}
launch {
Timber.i("Starting settings watcher")
watchForSettingsChanges()
}
if (setting.isPingEnabled) {
launch {
Timber.i("Starting ping watcher")
watchForPingFailure()
}
}
launch {
Timber.i("Starting management watcher")
manageVpn()
}
running = true
private fun startSettingsJob() = lifecycleScope.launch {
watchForSettingsChanges()
}
private fun startVpnStateJob() = lifecycleScope.launch {
watchForVpnStateChanges()
}
private fun startWifiJob() = lifecycleScope.launch {
watchForWifiConnectivityChanges()
}
private fun startMobileDataJob() = lifecycleScope.launch {
watchForMobileDataConnectivityChanges()
}
private fun startEthernetJob() = lifecycleScope.launch {
watchForEthernetConnectivityChanges()
}
private fun startPingJob() = lifecycleScope.launch {
watchForPingFailure()
}
private fun startNetworkEventJob() = lifecycleScope.launch {
handleNetworkEventChanges()
}
private suspend fun watchForMobileDataConnectivityChanges() {
withContext(ioDispatcher) {
Timber.i("Starting mobile data watcher")
mobileDataService.networkStatus.collect { status ->
when (status) {
is NetworkStatus.Available -> {
Timber.i("Gained Mobile data connection")
networkEventsFlow.update {
autoTunnelStateFlow.update {
it.copy(
isMobileDataConnected = true,
)
@@ -194,7 +232,7 @@ class AutoTunnelService : ForegroundService() {
}
is NetworkStatus.CapabilitiesChanged -> {
networkEventsFlow.update {
autoTunnelStateFlow.update {
it.copy(
isMobileDataConnected = true,
)
@@ -203,7 +241,7 @@ class AutoTunnelService : ForegroundService() {
}
is NetworkStatus.Unavailable -> {
networkEventsFlow.update {
autoTunnelStateFlow.update {
it.copy(
isMobileDataConnected = false,
)
@@ -217,92 +255,165 @@ class AutoTunnelService : ForegroundService() {
private suspend fun watchForPingFailure() {
withContext(ioDispatcher) {
try {
Timber.i("Starting ping watcher")
runCatching {
do {
if (tunnelService.get().vpnState.value.status == TunnelState.UP) {
val tunnelConfig = tunnelService.get().vpnState.value.tunnelConfig
tunnelConfig?.let {
val config = TunnelConfig.configFromWgQuick(it.wgQuick)
val results =
val vpnState = tunnelService.get().vpnState.value
val settings = appDataRepository.settings.getSettings()
if (vpnState.status == TunnelState.UP && !settings.isAutoTunnelPaused) {
if (vpnState.tunnelConfig != null) {
val config = TunnelConfig.configFromWgQuick(vpnState.tunnelConfig.wgQuick)
val results = if (vpnState.tunnelConfig.pingIp != null) {
Timber.d("Pinging custom ip : ${vpnState.tunnelConfig.pingIp}")
listOf(InetAddress.getByName(vpnState.tunnelConfig.pingIp).isReachable(Constants.PING_TIMEOUT.toInt()))
} else {
Timber.d("Pinging all peers")
config.peers.map { peer ->
val host =
if (peer.endpoint.isPresent &&
peer.endpoint.get().resolved.isPresent
) {
peer.endpoint.get().resolved.get().host
} else {
Constants.DEFAULT_PING_IP
}
Timber.i("Checking reachability of: $host")
val reachable =
InetAddress.getByName(host)
.isReachable(Constants.PING_TIMEOUT.toInt())
Timber.i("Result: reachable - $reachable")
reachable
peer.isReachable()
}
}
Timber.i("Ping results reachable: $results")
if (results.contains(false)) {
Timber.i("Restarting VPN for ping failure")
tunnelService.get().stopTunnel(it)
delay(Constants.VPN_RESTART_DELAY)
tunnelService.get().startTunnel(it)
delay(Constants.PING_COOLDOWN)
val cooldown = vpnState.tunnelConfig.pingCooldown
tunnelService.get().bounceTunnel(vpnState.tunnelConfig)
delay(cooldown ?: Constants.PING_COOLDOWN)
continue
}
}
}
delay(Constants.PING_INTERVAL)
delay(vpnState.tunnelConfig?.pingInterval ?: Constants.PING_INTERVAL)
} while (true)
} catch (e: Exception) {
Timber.e(e)
}.onFailure {
Timber.e(it)
}
}
}
private fun onAutoTunnelPause(paused: Boolean) {
if (autoTunnelStateFlow.value.settings.isAutoTunnelPaused
!= paused
) {
when (paused) {
true -> launchWatcherPausedNotification()
false -> launchWatcherNotification()
}
}
}
private suspend fun watchForSettingsChanges() {
appDataRepository.settings.getSettingsFlow().collect { settings ->
if (networkEventsFlow.value.settings.isAutoTunnelPaused
!= settings.isAutoTunnelPaused
) {
when (settings.isAutoTunnelPaused) {
true -> launchWatcherPausedNotification()
false -> launchWatcherNotification()
Timber.i("Starting settings watcher")
withContext(ioDispatcher) {
appDataRepository.settings.getSettingsFlow().combine(
// ignore isActive changes to allow manual tunnel overrides
appDataRepository.tunnels.getTunnelConfigsFlow().distinctUntilChanged { old, new ->
old.map { it.isActive } != new.map { it.isActive }
},
) { settings, tunnels ->
autoTunnelStateFlow.value.copy(
settings = settings,
tunnels = tunnels,
)
}.collect {
onAutoTunnelPause(it.settings.isAutoTunnelPaused)
manageJobsBySettings(it.settings)
autoTunnelStateFlow.emit(it)
}
}
}
private suspend fun watchForVpnStateChanges() {
Timber.i("Starting vpn state watcher")
withContext(ioDispatcher) {
tunnelService.get().vpnState.collect { state ->
state.tunnelConfig?.let {
val settings = appDataRepository.settings.getSettings()
if (it.isPingEnabled && !settings.isPingEnabled) {
pingJob.onNotRunning { pingJob = startPingJob() }
}
if (!it.isPingEnabled && !settings.isPingEnabled) {
cancelAndResetPingJob()
}
}
}
networkEventsFlow.update {
it.copy(
settings = settings,
)
}
}
private fun manageJobsBySettings(settings: Settings) {
with(settings) {
if (isPingEnabled) {
pingJob.onNotRunning { pingJob = startPingJob() }
} else {
cancelAndResetPingJob()
}
if (isTunnelOnWifiEnabled || isTunnelOnEthernetEnabled || isTunnelOnMobileDataEnabled) {
startNetworkJobs()
} else {
cancelAndResetNetworkJobs()
}
}
}
private fun startNetworkJobs() {
wifiJob.onNotRunning {
Timber.i("Wifi job starting")
wifiJob = startWifiJob()
}
ethernetJob.onNotRunning {
ethernetJob = startEthernetJob()
Timber.i("Ethernet job starting")
}
mobileDataJob.onNotRunning {
mobileDataJob = startMobileDataJob()
Timber.i("Mobile data job starting")
}
networkEventJob.onNotRunning {
Timber.i("Network event job starting")
networkEventJob = startNetworkEventJob()
}
}
private fun cancelAndResetPingJob() {
pingJob?.cancelWithMessage("Ping job canceled")
pingJob = null
}
private fun cancelAndResetNetworkJobs() {
networkEventJob?.cancelWithMessage("Network event job canceled")
wifiJob?.cancelWithMessage("Wifi job canceled")
ethernetJob?.cancelWithMessage("Ethernet job canceled")
mobileDataJob?.cancelWithMessage("Mobile data job canceled")
networkEventJob = null
wifiJob = null
ethernetJob = null
mobileDataJob = null
}
private fun updateEthernet(connected: Boolean) {
autoTunnelStateFlow.update {
it.copy(
isEthernetConnected = connected,
)
}
}
private suspend fun watchForEthernetConnectivityChanges() {
withContext(ioDispatcher) {
Timber.i("Starting ethernet data watcher")
ethernetService.networkStatus.collect { status ->
when (status) {
is NetworkStatus.Available -> {
Timber.i("Gained Ethernet connection")
networkEventsFlow.update {
it.copy(
isEthernetConnected = true,
)
}
updateEthernet(true)
}
is NetworkStatus.CapabilitiesChanged -> {
Timber.i("Ethernet capabilities changed")
networkEventsFlow.update {
it.copy(
isEthernetConnected = true,
)
}
updateEthernet(true)
}
is NetworkStatus.Unavailable -> {
networkEventsFlow.update {
it.copy(
isEthernetConnected = false,
)
}
updateEthernet(false)
Timber.i("Lost Ethernet connection")
}
}
@@ -312,11 +423,12 @@ class AutoTunnelService : ForegroundService() {
private suspend fun watchForWifiConnectivityChanges() {
withContext(ioDispatcher) {
Timber.i("Starting wifi watcher")
wifiService.networkStatus.collect { status ->
when (status) {
is NetworkStatus.Available -> {
Timber.i("Gained Wi-Fi connection")
networkEventsFlow.update {
autoTunnelStateFlow.update {
it.copy(
isWifiConnected = true,
)
@@ -325,7 +437,7 @@ class AutoTunnelService : ForegroundService() {
is NetworkStatus.CapabilitiesChanged -> {
Timber.i("Wifi capabilities changed")
networkEventsFlow.update {
autoTunnelStateFlow.update {
it.copy(
isWifiConnected = true,
)
@@ -338,7 +450,7 @@ class AutoTunnelService : ForegroundService() {
Timber.i("Detected valid SSID")
}
appDataRepository.appState.setCurrentSsid(name)
networkEventsFlow.update {
autoTunnelStateFlow.update {
it.copy(
currentNetworkSSID = name,
)
@@ -347,7 +459,7 @@ class AutoTunnelService : ForegroundService() {
}
is NetworkStatus.Unavailable -> {
networkEventsFlow.update {
autoTunnelStateFlow.update {
it.copy(
isWifiConnected = false,
)
@@ -363,17 +475,14 @@ class AutoTunnelService : ForegroundService() {
return appDataRepository.tunnels.findByMobileDataTunnel().firstOrNull()
}
private suspend fun getSsidTunnel(ssid: String): TunnelConfig? {
return appDataRepository.tunnels.findByTunnelNetworksName(ssid).firstOrNull()
}
private fun isTunnelDown(): Boolean {
return tunnelService.get().vpnState.value.status == TunnelState.DOWN
}
private suspend fun manageVpn() {
private suspend fun handleNetworkEventChanges() {
withContext(ioDispatcher) {
networkEventsFlow.collectLatest { watcherState ->
Timber.i("Starting network event watcher")
autoTunnelStateFlow.collectLatest { watcherState ->
val autoTunnel = "Auto-tunnel watcher"
if (!watcherState.settings.isAutoTunnelPaused) {
// delay for rapid network state changes and then collect latest
@@ -412,13 +521,14 @@ class AutoTunnelService : ForegroundService() {
}
watcherState.isUntrustedWifiConditionMet() -> {
if (activeTunnel?.tunnelNetworks?.contains(watcherState.currentNetworkSSID) == false ||
activeTunnel == null
Timber.i("Untrusted wifi condition met")
if (activeTunnel?.tunnelNetworks?.isMatchingToWildcardList(watcherState.currentNetworkSSID) == false ||
activeTunnel == null || isTunnelDown()
) {
Timber.i(
"$autoTunnel - tunnel on ssid not associated with current tunnel condition met",
)
getSsidTunnel(watcherState.currentNetworkSSID)?.let {
watcherState.tunnels.firstOrNull { it.tunnelNetworks.isMatchingToWildcardList(watcherState.currentNetworkSSID) }?.let {
Timber.i("Found tunnel associated with this SSID, bringing tunnel up: ${it.name}")
if (isTunnelDown() || activeTunnel?.id != it.id) {
tunnelService.get().startTunnel(it)
@@ -1,6 +1,8 @@
package com.zaneschepke.wireguardautotunnel.service.foreground
import com.zaneschepke.wireguardautotunnel.data.domain.Settings
import com.zaneschepke.wireguardautotunnel.util.extensions.TunnelConfigs
import com.zaneschepke.wireguardautotunnel.util.extensions.isMatchingToWildcardList
data class AutoTunnelState(
val isWifiConnected: Boolean = false,
@@ -8,6 +10,7 @@ data class AutoTunnelState(
val isMobileDataConnected: Boolean = false,
val currentNetworkSSID: String = "",
val settings: Settings = Settings(),
val tunnels: TunnelConfigs = emptyList(),
) {
fun isEthernetConditionMet(): Boolean {
return (
@@ -38,7 +41,7 @@ data class AutoTunnelState(
return (
!isEthernetConnected &&
isWifiConnected &&
!settings.trustedNetworkSSIDs.contains(currentNetworkSSID) &&
!settings.trustedNetworkSSIDs.isMatchingToWildcardList(currentNetworkSSID) &&
settings.isTunnelOnWifiEnabled
)
}
@@ -48,7 +51,7 @@ data class AutoTunnelState(
!isEthernetConnected &&
(
isWifiConnected &&
settings.trustedNetworkSSIDs.contains(currentNetworkSSID)
settings.trustedNetworkSSIDs.isMatchingToWildcardList(currentNetworkSSID)
)
)
}
@@ -1,57 +0,0 @@
package com.zaneschepke.wireguardautotunnel.service.foreground
import android.content.Intent
import android.os.Bundle
import android.os.IBinder
import androidx.lifecycle.LifecycleService
import com.zaneschepke.wireguardautotunnel.util.Constants
import timber.log.Timber
open class ForegroundService : LifecycleService() {
private var isServiceStarted = false
override fun onBind(intent: Intent): IBinder? {
super.onBind(intent)
// We don't provide binding, so return null
return null
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId)
Timber.d("onStartCommand executed with startId: $startId")
if (intent != null) {
val action = intent.action
when (action) {
Action.START.name,
Action.START_FOREGROUND.name,
-> startService(intent.extras)
Action.STOP.name, Action.STOP_FOREGROUND.name -> stopService()
Constants.ALWAYS_ON_VPN_ACTION -> {
Timber.i("Always-on VPN starting service")
startService(intent.extras)
}
else -> Timber.d("This should never happen. No action in the received intent")
}
} else {
Timber.d(
"with a null intent. It has been probably restarted by the system.",
)
}
return START_STICKY
}
protected open fun startService(extras: Bundle?) {
if (isServiceStarted) return
Timber.d("Starting ${this.javaClass.simpleName}")
isServiceStarted = true
}
protected open fun stopService() {
Timber.d("Stopping ${this.javaClass.simpleName}")
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
isServiceStarted = false
}
}
@@ -3,10 +3,12 @@ package com.zaneschepke.wireguardautotunnel.service.foreground
import android.app.Service
import android.content.Context
import android.content.Intent
import android.net.VpnService
import timber.log.Timber
class ServiceManager {
object ServiceManager {
private fun <T : Service> actionOnService(action: Action, context: Context, cls: Class<T>, extras: Map<String, Int>? = null) {
if (VpnService.prepare(context) != null) return
val intent =
Intent(context, cls).also {
it.action = action.name
@@ -50,4 +52,20 @@ class ServiceManager {
AutoTunnelService::class.java,
)
}
fun startTunnelBackgroundService(context: Context) {
actionOnService(
Action.START_FOREGROUND,
context,
TunnelBackgroundService::class.java,
)
}
fun stopTunnelBackgroundService(context: Context) {
actionOnService(
Action.STOP,
context,
TunnelBackgroundService::class.java,
)
}
}
@@ -0,0 +1,61 @@
package com.zaneschepke.wireguardautotunnel.service.foreground
import android.app.Notification
import android.content.Intent
import android.os.IBinder
import androidx.lifecycle.LifecycleService
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.service.notification.NotificationService
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
@AndroidEntryPoint
class TunnelBackgroundService : LifecycleService() {
@Inject
lateinit var notificationService: NotificationService
private val foregroundId = 123
override fun onCreate() {
super.onCreate()
startForeground(foregroundId, createNotification())
}
override fun onBind(intent: Intent): IBinder? {
super.onBind(intent)
// We don't provide binding, so return null
return null
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent != null) {
val action = intent.action
when (action) {
Action.START.name,
Action.START_FOREGROUND.name,
-> startService()
Action.STOP.name, Action.STOP_FOREGROUND.name -> stopService()
}
}
return super.onStartCommand(intent, flags, startId)
}
private fun startService() {
startForeground(foregroundId, createNotification())
}
private fun stopService() {
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
private fun createNotification(): Notification {
return notificationService.createNotification(
getString(R.string.vpn_channel_id),
getString(R.string.vpn_channel_name),
getString(R.string.tunnel_start_text),
description = "",
)
}
}
@@ -7,6 +7,8 @@ import com.zaneschepke.wireguardautotunnel.module.ApplicationScope
import com.zaneschepke.wireguardautotunnel.service.foreground.Action
import com.zaneschepke.wireguardautotunnel.service.foreground.AutoTunnelService
import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelService
import com.zaneschepke.wireguardautotunnel.util.extensions.startTunnelBackground
import com.zaneschepke.wireguardautotunnel.util.extensions.stopTunnelBackground
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@@ -42,8 +44,8 @@ class ShortcutsActivity : ComponentActivity() {
Timber.d("Shortcut action on name: ${tunnelConfig?.name}")
tunnelConfig?.let {
when (intent.action) {
Action.START.name -> tunnelService.get().startTunnel(it)
Action.STOP.name -> tunnelService.get().stopTunnel(it)
Action.START.name -> this@ShortcutsActivity.startTunnelBackground(it.id)
Action.STOP.name -> this@ShortcutsActivity.stopTunnelBackground(it.id)
else -> Unit
}
}
@@ -12,7 +12,6 @@ import androidx.lifecycle.lifecycleScope
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.module.ApplicationScope
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@@ -24,9 +23,6 @@ class AutoTunnelControlTile : TileService(), LifecycleOwner {
@Inject
lateinit var appDataRepository: AppDataRepository
@Inject
lateinit var serviceManager: ServiceManager
@Inject
@ApplicationScope
lateinit var applicationScope: CoroutineScope
@@ -68,6 +68,7 @@ class TunnelControlTile : TileService(), LifecycleOwner {
override fun onClick() {
super.onClick()
unlockAndRun {
Timber.d("Click")
lifecycleScope.launch {
val context = this@TunnelControlTile
val lastActive = appDataRepository.getStartTunnelConfig()
@@ -9,9 +9,14 @@ interface TunnelService : Tunnel, org.amnezia.awg.backend.Tunnel {
suspend fun stopTunnel(tunnelConfig: TunnelConfig): Result<TunnelState>
suspend fun bounceTunnel(tunnelConfig: TunnelConfig): Result<TunnelState>
val vpnState: StateFlow<VpnState>
suspend fun runningTunnelNames(): Set<String>
suspend fun getState(): TunnelState
fun cancelStatsJob()
fun startStatsJob()
}
@@ -2,26 +2,24 @@ package com.zaneschepke.wireguardautotunnel.service.tunnel
import com.wireguard.android.backend.Backend
import com.wireguard.android.backend.Tunnel.State
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig
import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.data.repository.TunnelConfigRepository
import com.zaneschepke.wireguardautotunnel.module.ApplicationScope
import com.zaneschepke.wireguardautotunnel.module.IoDispatcher
import com.zaneschepke.wireguardautotunnel.module.Kernel
import com.zaneschepke.wireguardautotunnel.module.Userspace
import com.zaneschepke.wireguardautotunnel.service.tunnel.statistics.AmneziaStatistics
import com.zaneschepke.wireguardautotunnel.service.tunnel.statistics.TunnelStatistics
import com.zaneschepke.wireguardautotunnel.service.tunnel.statistics.WireGuardStatistics
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.extensions.requestTunnelTileServiceStateUpdate
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.amnezia.awg.backend.Tunnel
@@ -33,14 +31,30 @@ class WireGuardTunnel
@Inject
constructor(
private val amneziaBackend: Provider<org.amnezia.awg.backend.Backend>,
@Userspace private val userspaceBackend: Provider<Backend>,
tunnelConfigRepository: TunnelConfigRepository,
@Kernel private val kernelBackend: Provider<Backend>,
private val appDataRepository: AppDataRepository,
@ApplicationScope private val applicationScope: CoroutineScope,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) : TunnelService {
private val _vpnState = MutableStateFlow(VpnState())
override val vpnState: StateFlow<VpnState> = _vpnState.asStateFlow()
override val vpnState: StateFlow<VpnState> = _vpnState.combine(
tunnelConfigRepository.getTunnelConfigsFlow(),
) {
vpnState, tunnels ->
vpnState.copy(
tunnelConfig = tunnels.firstOrNull { it.id == vpnState.tunnelConfig?.id },
)
}.stateIn(applicationScope, SharingStarted.Lazily, VpnState())
private var statsJob: Job? = null
private suspend fun backend(): Any {
val settings = appDataRepository.settings.getSettings()
if (settings.isKernelEnabled) return kernelBackend.get()
return amneziaBackend.get()
}
override suspend fun runningTunnelNames(): Set<String> {
return when (val backend = backend()) {
@@ -50,14 +64,21 @@ constructor(
}
}
private var statsJob: Job? = null
private suspend fun setState(tunnelConfig: TunnelConfig, tunnelState: TunnelState): Result<TunnelState> {
return runCatching {
when (val backend = backend()) {
is Backend -> backend.setState(this, tunnelState.toWgState(), TunnelConfig.configFromWgQuick(tunnelConfig.wgQuick)).let { TunnelState.from(it) }
is org.amnezia.awg.backend.Backend -> backend.setState(this, tunnelState.toAmState(), TunnelConfig.configFromAmQuick(tunnelConfig.amQuick)).let {
TunnelState.from(it)
is org.amnezia.awg.backend.Backend -> {
val config = if (tunnelConfig.amQuick.isBlank()) {
TunnelConfig.configFromAmQuick(
tunnelConfig.wgQuick,
)
} else {
TunnelConfig.configFromAmQuick(tunnelConfig.amQuick)
}
backend.setState(this, tunnelState.toAmState(), config).let {
TunnelState.from(it)
}
}
else -> throw NotImplementedError()
}
@@ -66,44 +87,76 @@ constructor(
}
}
private suspend fun backend(): Any {
val settings = appDataRepository.settings.getSettings()
if (settings.isKernelEnabled) return kernelBackend.get()
if (settings.isAmneziaEnabled) return amneziaBackend.get()
return userspaceBackend.get()
}
override suspend fun startTunnel(tunnelConfig: TunnelConfig): Result<TunnelState> {
return withContext(ioDispatcher) {
if (_vpnState.value.status == TunnelState.UP) vpnState.value.tunnelConfig?.let { stopTunnel(it) }
appDataRepository.tunnels.save(tunnelConfig.copy(isActive = true))
appDataRepository.appState.setLastActiveTunnelId(tunnelConfig.id)
emitTunnelConfig(tunnelConfig)
onBeforeStart(tunnelConfig)
setState(tunnelConfig, TunnelState.UP).onSuccess {
emitTunnelState(it)
WireGuardAutoTunnel.instance.requestTunnelTileServiceStateUpdate()
}.onFailure {
appDataRepository.tunnels.save(tunnelConfig.copy(isActive = false))
WireGuardAutoTunnel.instance.requestTunnelTileServiceStateUpdate()
Timber.e(it)
onStartFailed()
}
}
}
override suspend fun stopTunnel(tunnelConfig: TunnelConfig): Result<TunnelState> {
return withContext(ioDispatcher) {
appDataRepository.tunnels.save(tunnelConfig.copy(isActive = false))
onBeforeStop(tunnelConfig)
setState(tunnelConfig, TunnelState.DOWN).onSuccess {
emitTunnelState(it)
resetBackendStatistics()
WireGuardAutoTunnel.instance.requestTunnelTileServiceStateUpdate()
}.onFailure {
Timber.e(it)
appDataRepository.tunnels.save(tunnelConfig.copy(isActive = true))
WireGuardAutoTunnel.instance.requestTunnelTileServiceStateUpdate()
onStopFailed()
}
}
}
// use this when we just want to bounce tunnel and not change tunnelConfig active state
override suspend fun bounceTunnel(tunnelConfig: TunnelConfig): Result<TunnelState> {
toggleTunnel(tunnelConfig)
delay(VPN_RESTART_DELAY)
return toggleTunnel(tunnelConfig)
}
private suspend fun toggleTunnel(tunnelConfig: TunnelConfig): Result<TunnelState> {
return withContext(ioDispatcher) {
setState(tunnelConfig, TunnelState.TOGGLE).onSuccess {
emitTunnelState(it)
resetBackendStatistics()
}.onFailure {
Timber.e(it)
}
}
}
private suspend fun onStopFailed() {
_vpnState.value.tunnelConfig?.let {
appDataRepository.tunnels.save(it.copy(isActive = true))
}
}
private suspend fun onStartFailed() {
_vpnState.value.tunnelConfig?.let {
appDataRepository.tunnels.save(it.copy(isActive = false))
}
cancelStatsJob()
resetBackendStatistics()
}
private suspend fun onBeforeStart(tunnelConfig: TunnelConfig) {
if (_vpnState.value.status == TunnelState.UP) vpnState.value.tunnelConfig?.let { stopTunnel(it) }
resetBackendStatistics()
appDataRepository.tunnels.save(tunnelConfig.copy(isActive = true))
emitVpnStateConfig(tunnelConfig)
startStatsJob()
}
private suspend fun onBeforeStop(tunnelConfig: TunnelConfig) {
cancelStatsJob()
resetBackendStatistics()
appDataRepository.tunnels.save(tunnelConfig.copy(isActive = false))
}
private fun emitTunnelState(state: TunnelState) {
_vpnState.tryEmit(
_vpnState.value.copy(
@@ -120,7 +173,7 @@ constructor(
)
}
private fun emitTunnelConfig(tunnelConfig: TunnelConfig?) {
private fun emitVpnStateConfig(tunnelConfig: TunnelConfig) {
_vpnState.tryEmit(
_vpnState.value.copy(
tunnelConfig = tunnelConfig,
@@ -144,31 +197,21 @@ constructor(
}
}
override fun cancelStatsJob() {
statsJob?.cancel()
}
override fun startStatsJob() {
statsJob = startTunnelStatisticsJob()
}
override fun getName(): String {
return _vpnState.value.tunnelConfig?.name ?: ""
}
override fun onStateChange(newState: Tunnel.State) {
handleStateChange(TunnelState.from(newState))
}
private fun handleStateChange(state: TunnelState) {
emitTunnelState(state)
WireGuardAutoTunnel.instance.requestTunnelTileServiceStateUpdate()
if (state == TunnelState.UP) {
statsJob = startTunnelStatisticsJob()
}
if (state == TunnelState.DOWN) {
try {
statsJob?.cancel()
} catch (e: CancellationException) {
Timber.i("Stats job cancelled")
}
}
}
private fun startTunnelStatisticsJob() = applicationScope.launch(ioDispatcher) {
val backend = backend()
delay(STATS_START_DELAY)
while (true) {
when (backend) {
is Backend -> emitBackendStatistics(
@@ -182,11 +225,21 @@ constructor(
)
}
}
delay(Constants.VPN_STATISTIC_CHECK_INTERVAL)
delay(VPN_STATISTIC_CHECK_INTERVAL)
}
}
override fun onStateChange(newState: Tunnel.State) {
emitTunnelState(TunnelState.from(newState))
}
override fun onStateChange(state: State) {
handleStateChange(TunnelState.from(state))
emitTunnelState(TunnelState.from(state))
}
companion object {
const val STATS_START_DELAY = 5_000L
const val VPN_STATISTIC_CHECK_INTERVAL = 1_000L
const val VPN_RESTART_DELAY = 1_000L
}
}
@@ -1,8 +1,13 @@
package com.zaneschepke.wireguardautotunnel.ui
import com.zaneschepke.wireguardautotunnel.data.domain.GeneralState
import com.zaneschepke.wireguardautotunnel.data.domain.Settings
import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig
import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnState
data class AppUiState(
val snackbarMessage: String = "",
val snackbarMessageConsumed: Boolean = true,
val notificationPermissionAccepted: Boolean = false,
val requestPermissions: Boolean = false,
val settings: Settings = Settings(),
val tunnels: List<TunnelConfig> = emptyList(),
val vpnState: VpnState = VpnState(),
val generalState: GeneralState = GeneralState(),
)
@@ -2,12 +2,20 @@ package com.zaneschepke.wireguardautotunnel.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.navigation.NavHostController
import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.module.IoDispatcher
import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelService
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.extensions.TunnelConfigs
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
import xyz.teamgravity.pin_lock_compose.PinManager
import javax.inject.Inject
@@ -16,33 +24,42 @@ class AppViewModel
@Inject
constructor(
private val appDataRepository: AppDataRepository,
tunnelService: TunnelService,
val navHostController: NavHostController,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) : ViewModel() {
private val _appUiState =
MutableStateFlow(
AppUiState(),
private val _appUiState = MutableStateFlow(AppUiState())
val uiState =
combine(
appDataRepository.settings.getSettingsFlow(),
appDataRepository.tunnels.getTunnelConfigsFlow(),
tunnelService.vpnState,
appDataRepository.appState.generalStateFlow,
) { settings, tunnels, tunnelState, generalState ->
AppUiState(
settings,
tunnels,
tunnelState,
generalState,
)
}
.stateIn(
viewModelScope + ioDispatcher,
SharingStarted.WhileSubscribed(Constants.SUBSCRIPTION_TIMEOUT),
_appUiState.value,
)
fun setTunnels(tunnels: TunnelConfigs) = viewModelScope.launch(ioDispatcher) {
_appUiState.emit(
_appUiState.value.copy(
tunnels = tunnels,
),
)
val appUiState = _appUiState.asStateFlow()
fun showSnackbarMessage(message: String) {
_appUiState.update {
it.copy(
snackbarMessage = message,
snackbarMessageConsumed = false,
)
}
}
fun snackbarMessageConsumed() {
_appUiState.update {
it.copy(
snackbarMessage = "",
snackbarMessageConsumed = true,
)
}
}
fun onPinLockDisabled() = viewModelScope.launch {
fun onPinLockDisabled() = viewModelScope.launch(ioDispatcher) {
PinManager.clearPin()
appDataRepository.appState.setPinLockEnabled(false)
}
@@ -5,16 +5,21 @@ import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
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 androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarData
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Surface
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -24,23 +29,25 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import androidx.navigation.toRoute
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.data.repository.AppStateRepository
import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepository
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelService
import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelState
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.BottomNavBar
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.BottomNavItem
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.isCurrentRoute
import com.zaneschepke.wireguardautotunnel.ui.common.prompt.CustomSnackBar
import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarControllerProvider
import com.zaneschepke.wireguardautotunnel.ui.screens.config.ConfigScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.main.ConfigType
import com.zaneschepke.wireguardautotunnel.ui.screens.main.MainScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.options.OptionsScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.pinlock.PinLockScreen
@@ -48,10 +55,9 @@ import com.zaneschepke.wireguardautotunnel.ui.screens.settings.SettingsScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.support.SupportScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.support.logs.LogsScreen
import com.zaneschepke.wireguardautotunnel.ui.theme.WireguardAutoTunnelTheme
import com.zaneschepke.wireguardautotunnel.util.StringValue
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.extensions.requestTunnelTileServiceStateUpdate
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import javax.inject.Inject
@AndroidEntryPoint
@@ -60,180 +66,149 @@ class MainActivity : AppCompatActivity() {
lateinit var appStateRepository: AppStateRepository
@Inject
lateinit var settingsRepository: SettingsRepository
@Inject
lateinit var serviceManager: ServiceManager
lateinit var tunnelService: TunnelService
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val isPinLockEnabled = intent.extras?.getBoolean(SplashActivity.IS_PIN_LOCK_ENABLED_KEY)
enableEdgeToEdge(navigationBarStyle = SystemBarStyle.dark(Color.Transparent.toArgb()))
lifecycleScope.launch {
val settings = settingsRepository.getSettings()
if (settings.isAutoTunnelEnabled) {
serviceManager.startWatcherService(application.applicationContext)
}
}
enableEdgeToEdge(
navigationBarStyle = SystemBarStyle.auto(
lightScrim = Color.Transparent.toArgb(),
darkScrim = Color.Transparent.toArgb(),
),
)
setContent {
val appViewModel = hiltViewModel<AppViewModel>()
val appUiState by appViewModel.appUiState.collectAsStateWithLifecycle()
val navController = rememberNavController()
val appUiState by appViewModel.uiState.collectAsStateWithLifecycle(lifecycle = this.lifecycle)
val navController = appViewModel.navHostController
val navBackStackEntry by navController.currentBackStackEntryAsState()
val snackbarHostState = remember { SnackbarHostState() }
fun showSnackBarMessage(message: StringValue) {
lifecycleScope.launch(Dispatchers.Main) {
val result =
snackbarHostState.showSnackbar(
message = message.asString(this@MainActivity),
duration = SnackbarDuration.Short,
)
when (result) {
SnackbarResult.ActionPerformed,
SnackbarResult.Dismissed,
-> {
snackbarHostState.currentSnackbarData?.dismiss()
}
}
LaunchedEffect(appUiState.vpnState.status) {
val context = this@MainActivity
when (appUiState.vpnState.status) {
TunnelState.DOWN -> ServiceManager.stopTunnelBackgroundService(context)
else -> Unit
}
context.requestTunnelTileServiceStateUpdate()
}
WireguardAutoTunnelTheme {
LaunchedEffect(appUiState.snackbarMessageConsumed) {
if (!appUiState.snackbarMessageConsumed) {
showSnackBarMessage(StringValue.DynamicString(appUiState.snackbarMessage))
appViewModel.snackbarMessageConsumed()
}
}
val focusRequester = remember { FocusRequester() }
Scaffold(
snackbarHost = {
SnackbarHost(snackbarHostState) { snackbarData: SnackbarData ->
CustomSnackBar(
snackbarData.visuals.message,
isRtl = false,
containerColor =
MaterialTheme.colorScheme.surfaceColorAtElevation(
2.dp,
),
)
}
},
// TODO refactor
modifier =
Modifier
.focusable()
.focusProperties {
when (navBackStackEntry?.destination?.route) {
Screen.Lock.route -> Unit
else -> up = focusRequester
SnackbarControllerProvider { host ->
WireguardAutoTunnelTheme {
val focusRequester = remember { FocusRequester() }
Scaffold(
snackbarHost = {
SnackbarHost(host) { snackbarData: SnackbarData ->
CustomSnackBar(
snackbarData.visuals.message,
isRtl = false,
containerColor =
MaterialTheme.colorScheme.surfaceColorAtElevation(
2.dp,
),
)
}
},
bottomBar = {
BottomNavBar(
navController,
listOf(
Screen.Main.navItem,
Screen.Settings.navItem,
Screen.Support.navItem,
),
)
},
) { padding ->
NavHost(
navController,
startDestination = (if (isPinLockEnabled == true) Screen.Lock.route else Screen.Main.route),
containerColor = MaterialTheme.colorScheme.background,
modifier =
Modifier
.padding(padding)
.fillMaxSize(),
) {
composable(
Screen.Main.route,
) {
MainScreen(
focusRequester = focusRequester,
appViewModel = appViewModel,
navController = navController,
.focusable()
.focusProperties {
if (navBackStackEntry?.isCurrentRoute(Route.Lock) == true) {
Unit
} else {
up = focusRequester
}
},
bottomBar = {
BottomNavBar(
navController,
listOf(
BottomNavItem(
name = stringResource(R.string.tunnels),
route = Route.Main,
icon = Icons.Rounded.Home,
),
BottomNavItem(
name = stringResource(R.string.settings),
route = Route.Settings,
icon = Icons.Rounded.Settings,
),
BottomNavItem(
name = stringResource(R.string.support),
route = Route.Support,
icon = Icons.Rounded.QuestionMark,
),
),
)
}
composable(
Screen.Settings.route,
) {
SettingsScreen(
appViewModel = appViewModel,
navController = navController,
focusRequester = focusRequester,
)
}
composable(
Screen.Support.route,
) {
SupportScreen(
focusRequester = focusRequester,
navController = navController,
)
}
composable(Screen.Support.Logs.route) {
LogsScreen()
}
composable(
"${Screen.Config.route}/{id}?configType={configType}",
arguments =
listOf(
navArgument("id") {
type = NavType.StringType
defaultValue = "0"
},
navArgument("configType") {
type = NavType.StringType
defaultValue = ConfigType.WIREGUARD.name
},
),
) {
val id = it.arguments?.getString("id")
val configType =
ConfigType.valueOf(
it.arguments?.getString("configType") ?: ConfigType.WIREGUARD.name,
)
if (!id.isNullOrBlank()) {
ConfigScreen(
navController = navController,
tunnelId = id,
appViewModel = appViewModel,
focusRequester = focusRequester,
configType = configType,
)
},
) { padding ->
Surface(modifier = Modifier.fillMaxSize().padding(padding)) {
NavHost(
navController,
enterTransition = { fadeIn(tween(Constants.TRANSITION_ANIMATION_TIME)) },
exitTransition = { fadeOut(tween(Constants.TRANSITION_ANIMATION_TIME)) },
startDestination = (if (isPinLockEnabled == true) Route.Lock else Route.Main),
) {
composable<Route.Main> {
MainScreen(
focusRequester = focusRequester,
uiState = appUiState,
navController = navController,
)
}
composable<Route.Settings> {
SettingsScreen(
appViewModel = appViewModel,
uiState = appUiState,
navController = navController,
focusRequester = focusRequester,
)
}
composable<Route.Support> {
SupportScreen(
focusRequester = focusRequester,
navController = navController,
appUiState = appUiState,
)
}
composable<Route.Logs> {
LogsScreen()
}
composable<Route.Config> {
val args = it.toRoute<Route.Config>()
ConfigScreen(
focusRequester = focusRequester,
tunnelId = args.id,
)
}
composable<Route.Option> {
val args = it.toRoute<Route.Option>()
OptionsScreen(
navController = navController,
tunnelId = args.id,
focusRequester = focusRequester,
appUiState = appUiState,
)
}
composable<Route.Lock> {
PinLockScreen(
navController = navController,
appViewModel = appViewModel,
)
}
}
}
composable("${Screen.Option.route}/{id}") {
val id = it.arguments?.getString("id")
if (!id.isNullOrBlank()) {
OptionsScreen(
navController = navController,
tunnelId = id,
appViewModel = appViewModel,
focusRequester = focusRequester,
)
}
}
composable(Screen.Lock.route) {
PinLockScreen(
navController = navController,
appViewModel = appViewModel,
)
}
}
}
}
}
}
override fun onDestroy() {
super.onDestroy()
tunnelService.cancelStatsJob()
}
}
@@ -0,0 +1,30 @@
package com.zaneschepke.wireguardautotunnel.ui
import kotlinx.serialization.Serializable
sealed class Route {
@Serializable
data object Support : Route()
@Serializable
data object Settings : Route()
@Serializable
data object Main : Route()
@Serializable
data class Option(
val id: Int,
) : Route()
@Serializable
data object Lock : Route()
@Serializable
data class Config(
val id: Int,
) : Route()
@Serializable
data object Logs : Route()
}
@@ -1,46 +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.R
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.BottomNavItem
sealed class Screen(val route: String) {
data object Main : Screen("main") {
val navItem =
BottomNavItem(
name = WireGuardAutoTunnel.instance.getString(R.string.tunnels),
route = route,
icon = Icons.Rounded.Home,
)
}
data object Settings : Screen("settings") {
val navItem =
BottomNavItem(
name = WireGuardAutoTunnel.instance.getString(R.string.settings),
route = route,
icon = Icons.Rounded.Settings,
)
}
data object Support : Screen("support") {
val navItem =
BottomNavItem(
name = WireGuardAutoTunnel.instance.getString(R.string.support),
route = route,
icon = Icons.Rounded.QuestionMark,
)
data object Logs : Screen("support/logs")
}
data object Config : Screen("config")
data object Lock : Screen("lock")
data object Option : Screen("option")
}
@@ -5,25 +5,21 @@ import android.content.Intent
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.viewModels
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.zaneschepke.logcatter.LocalLogCollector
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.data.repository.AppStateRepository
import com.zaneschepke.wireguardautotunnel.module.ApplicationScope
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelService
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelState
import com.zaneschepke.wireguardautotunnel.util.extensions.requestAutoTunnelTileServiceUpdate
import com.zaneschepke.wireguardautotunnel.util.extensions.requestTunnelTileServiceStateUpdate
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import timber.log.Timber
import xyz.teamgravity.pin_lock_compose.PinManager
import javax.inject.Inject
import javax.inject.Provider
@@ -40,12 +36,7 @@ class SplashActivity : ComponentActivity() {
@Inject
lateinit var tunnelService: Provider<TunnelService>
@Inject
lateinit var localLogCollector: LocalLogCollector
@Inject
@ApplicationScope
lateinit var applicationScope: CoroutineScope
private val appViewModel: AppViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
@@ -54,30 +45,30 @@ class SplashActivity : ComponentActivity() {
}
super.onCreate(savedInstanceState)
applicationScope.launch {
if (!this@SplashActivity.isRunningOnTv()) localLogCollector.start()
}
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.CREATED) {
val pinLockEnabled = appStateRepository.isPinLockEnabled()
if (pinLockEnabled) {
PinManager.initialize(WireGuardAutoTunnel.instance)
}
// TODO eventually make this support multi-tunnel
Timber.d("Check for active tunnels")
val settings = appDataRepository.settings.getSettings()
if (settings.isKernelEnabled) {
// delay in case state change is underway while app is opened
delay(Constants.FOCUS_REQUEST_DELAY)
val activeTunnels = appDataRepository.tunnels.getActive()
Timber.d("Kernel mode enabled, seeing if we need to start a tunnel")
activeTunnels.firstOrNull()?.let {
Timber.d("Trying to start active kernel tunnel: ${it.name}")
tunnelService.get().startTunnel(it)
val pinLockEnabled = async {
appStateRepository.isPinLockEnabled().also {
if (it) PinManager.initialize(WireGuardAutoTunnel.instance)
}
}
requestTunnelTileServiceStateUpdate()
}.await()
async {
val settings = appDataRepository.settings.getSettings()
if (settings.isAutoTunnelEnabled) ServiceManager.startWatcherService(application.applicationContext)
if (tunnelService.get().getState() == TunnelState.UP) tunnelService.get().startStatsJob()
val activeTunnels = appDataRepository.tunnels.getActive()
if (activeTunnels.isNotEmpty() &&
tunnelService.get().getState() == TunnelState.DOWN
) {
tunnelService.get().startTunnel(activeTunnels.first())
}
}.await()
async {
val tunnels = appDataRepository.tunnels.getAll()
appViewModel.setTunnels(tunnels)
}.await()
requestAutoTunnelTileServiceUpdate()
val intent =
@@ -12,7 +12,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
@Composable
fun ClickableIconButton(onClick: () -> Unit, onIconClick: () -> Unit, text: String, icon: ImageVector, enabled: Boolean) {
fun ClickableIconButton(onClick: () -> Unit, onIconClick: () -> Unit, text: String, icon: ImageVector, enabled: Boolean = true) {
TextButton(
onClick = onClick,
enabled = enabled,
@@ -1,5 +1,6 @@
package com.zaneschepke.wireguardautotunnel.ui.common.config
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.OutlinedTextField
@@ -14,23 +15,29 @@ fun ConfigurationTextBox(
value: String,
hint: String,
onValueChange: (String) -> Unit,
keyboardActions: KeyboardActions,
keyboardActions: KeyboardActions = KeyboardActions(),
label: String,
modifier: Modifier,
isError: Boolean = false,
keyboardOptions: KeyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.None,
imeAction = ImeAction.Done,
),
trailing: @Composable () -> Unit = {},
interactionSource: MutableInteractionSource? = null,
) {
OutlinedTextField(
isError = isError,
modifier = modifier,
value = value,
singleLine = true,
interactionSource = interactionSource,
onValueChange = { onValueChange(it) },
label = { Text(label) },
maxLines = 1,
placeholder = { Text(hint) },
keyboardOptions =
KeyboardOptions(
capitalization = KeyboardCapitalization.None,
imeAction = ImeAction.Done,
),
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
trailingIcon = trailing,
)
}
@@ -13,7 +13,14 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
@Composable
fun ConfigurationToggle(label: String, enabled: Boolean, checked: Boolean, padding: Dp, onCheckChanged: () -> Unit, modifier: Modifier = Modifier) {
fun ConfigurationToggle(
label: String,
enabled: Boolean = true,
checked: Boolean,
padding: Dp,
onCheckChanged: (checked: Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier =
Modifier
@@ -37,7 +44,7 @@ fun ConfigurationToggle(label: String, enabled: Boolean, checked: Boolean, paddi
modifier = modifier,
enabled = enabled,
checked = checked,
onCheckedChange = { onCheckChanged() },
onCheckedChange = { onCheckChanged(it) },
)
}
}
@@ -0,0 +1,85 @@
package com.zaneschepke.wireguardautotunnel.ui.common.config
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Save
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import com.zaneschepke.wireguardautotunnel.R
@Composable
fun SubmitConfigurationTextBox(
value: String?,
label: String,
hint: String,
focusRequester: FocusRequester,
isErrorValue: (value: String?) -> Boolean,
onSubmit: (value: String) -> Unit,
keyboardOptions: KeyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.None,
imeAction = ImeAction.Done,
),
) {
val focusManager = LocalFocusManager.current
val interactionSource = remember { MutableInteractionSource() }
val isFocused by interactionSource.collectIsFocusedAsState()
val keyboardController = LocalSoftwareKeyboardController.current
var stateValue by remember { mutableStateOf(value ?: "") }
OutlinedTextField(
isError = isErrorValue(stateValue),
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester),
value = stateValue,
singleLine = true,
interactionSource = interactionSource,
onValueChange = { stateValue = it },
label = { Text(label) },
maxLines = 1,
placeholder = { Text(hint) },
keyboardOptions = keyboardOptions,
keyboardActions = KeyboardActions(
onDone = {
onSubmit(stateValue)
keyboardController?.hide()
},
),
trailingIcon = {
if (!isErrorValue(stateValue) && isFocused) {
IconButton(onClick = {
onSubmit(stateValue)
keyboardController?.hide()
focusManager.clearFocus()
}) {
Icon(
imageVector = Icons.Outlined.Save,
contentDescription = stringResource(R.string.save_changes),
tint = MaterialTheme.colorScheme.primary,
)
}
}
},
)
}
@@ -10,37 +10,34 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.navigation.NavController
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.compose.currentBackStackEntryAsState
import com.zaneschepke.wireguardautotunnel.ui.Screen
@Composable
fun BottomNavBar(navController: NavController, bottomNavItems: List<BottomNavItem>) {
val backStackEntry = navController.currentBackStackEntryAsState()
var showBottomBar by rememberSaveable { mutableStateOf(true) }
val navBackStackEntry by navController.currentBackStackEntryAsState()
// TODO find a better way to hide nav bar
showBottomBar =
when (navBackStackEntry?.destination?.route) {
Screen.Lock.route -> false
else -> true
}
showBottomBar = bottomNavItems.firstOrNull {
navBackStackEntry?.destination?.hierarchy?.any { dest ->
bottomNavItems.map { dest.hasRoute(route = it.route::class) }.contains(true)
} == true
} != null
NavigationBar(
containerColor = if (!showBottomBar) Color.Transparent else MaterialTheme.colorScheme.background,
) {
if (showBottomBar) {
if (showBottomBar) {
NavigationBar(
containerColor = MaterialTheme.colorScheme.surface,
) {
bottomNavItems.forEach { item ->
val selected = item.route == backStackEntry.value?.destination?.route
val selected = navBackStackEntry.isCurrentRoute(item.route)
NavigationBarItem(
selected = selected,
onClick = {
if (selected) return@NavigationBarItem
navController.navigate(item.route) {
// Pop up to the start destination of the graph to
// avoid building up a large stack of destinations
@@ -1,9 +1,10 @@
package com.zaneschepke.wireguardautotunnel.ui.common.navigation
import androidx.compose.ui.graphics.vector.ImageVector
import com.zaneschepke.wireguardautotunnel.ui.Route
data class BottomNavItem(
val name: String,
val route: String,
val route: Route,
val icon: ImageVector,
)
@@ -0,0 +1,12 @@
package com.zaneschepke.wireguardautotunnel.ui.common.navigation
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavDestination.Companion.hierarchy
import com.zaneschepke.wireguardautotunnel.ui.Route
fun NavBackStackEntry?.isCurrentRoute(route: Route): Boolean {
return this?.destination?.hierarchy?.any {
it.hasRoute(route = route::class)
} == true
}
@@ -0,0 +1,15 @@
package com.zaneschepke.wireguardautotunnel.ui.common.navigation
import android.content.Context
import androidx.navigation.NavHostController
import androidx.navigation.compose.ComposeNavigator
import androidx.navigation.compose.DialogNavigator
class NavigationService constructor(
context: Context,
) {
val navController = NavHostController(context).apply {
navigatorProvider.addNavigator(ComposeNavigator())
navigatorProvider.addNavigator(DialogNavigator())
}
}
@@ -0,0 +1,108 @@
package com.zaneschepke.wireguardautotunnel.ui.common.snackbar
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.platform.LocalContext
import com.zaneschepke.wireguardautotunnel.util.StringValue
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import kotlin.coroutines.EmptyCoroutineContext
private val LocalSnackbarController = staticCompositionLocalOf {
SnackbarController(
host = SnackbarHostState(),
scope = CoroutineScope(EmptyCoroutineContext),
)
}
private val channel = Channel<SnackbarChannelMessage>(capacity = 1)
@Composable
fun SnackbarControllerProvider(content: @Composable (snackbarHost: SnackbarHostState) -> Unit) {
val snackHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
val snackController = remember(scope) { SnackbarController(snackHostState, scope) }
val context = LocalContext.current
DisposableEffect(snackController, scope) {
val job = scope.launch {
for (payload in channel) {
snackController.showMessage(
message = payload.message.asString(context),
duration = payload.duration,
action = payload.action,
)
}
}
onDispose {
job.cancel()
}
}
CompositionLocalProvider(LocalSnackbarController provides snackController) {
content(
snackHostState,
)
}
}
@Immutable
class SnackbarController(
private val host: SnackbarHostState,
private val scope: CoroutineScope,
) {
companion object {
val current
@Composable
@ReadOnlyComposable
get() = LocalSnackbarController.current
fun showMessage(message: StringValue, action: SnackbarAction? = null, duration: SnackbarDuration = SnackbarDuration.Short) {
channel.trySend(
SnackbarChannelMessage(
message = message,
duration = duration,
action = action,
),
)
}
}
fun showMessage(message: String, action: SnackbarAction? = null, duration: SnackbarDuration = SnackbarDuration.Short) {
scope.launch {
/**
* note: uncomment this line if you want snackbar to be displayed immediately,
* rather than being enqueued and waiting [duration] * current_queue_size
*/
host.currentSnackbarData?.dismiss()
val result =
host.showSnackbar(
message = message,
actionLabel = action?.title,
duration = duration,
)
if (result == SnackbarResult.ActionPerformed) {
action?.onActionPress?.invoke()
}
}
}
}
data class SnackbarChannelMessage(
val message: StringValue,
val action: SnackbarAction?,
val duration: SnackbarDuration = SnackbarDuration.Short,
)
data class SnackbarAction(val title: String, val onActionPress: () -> Unit)
@@ -1,36 +1,27 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.config
import android.annotation.SuppressLint
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Android
import androidx.compose.material.icons.rounded.ContentCopy
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material.icons.rounded.Save
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FabPosition
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
@@ -39,10 +30,8 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -53,7 +42,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
@@ -67,60 +55,54 @@ import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import com.google.accompanist.drawablepainter.DrawablePainter
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.AppViewModel
import com.zaneschepke.wireguardautotunnel.ui.Screen
import com.zaneschepke.wireguardautotunnel.ui.common.SearchBar
import com.zaneschepke.wireguardautotunnel.ui.common.config.ConfigurationTextBox
import com.zaneschepke.wireguardautotunnel.ui.common.config.ConfigurationToggle
import com.zaneschepke.wireguardautotunnel.ui.common.prompt.AuthorizationPrompt
import com.zaneschepke.wireguardautotunnel.ui.common.screen.LoadingScreen
import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController
import com.zaneschepke.wireguardautotunnel.ui.common.text.SectionTitle
import com.zaneschepke.wireguardautotunnel.ui.screens.config.components.ApplicationSelectionDialog
import com.zaneschepke.wireguardautotunnel.ui.screens.main.ConfigType
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.extensions.getMessage
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
import kotlinx.coroutines.delay
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@OptIn(
ExperimentalMaterial3Api::class,
)
@Composable
fun ConfigScreen(
viewModel: ConfigViewModel = hiltViewModel(),
focusRequester: FocusRequester,
navController: NavController,
appViewModel: AppViewModel,
tunnelId: String,
configType: ConfigType,
) {
fun ConfigScreen(tunnelId: Int, focusRequester: FocusRequester) {
val viewModel = hiltViewModel<ConfigViewModel, ConfigViewModel.ConfigViewModelFactory> { factory ->
factory.create(tunnelId)
}
val context = LocalContext.current
val snackbar = SnackbarController.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) }
var configType by remember { mutableStateOf(ConfigType.WIREGUARD) }
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
LaunchedEffect(Unit) { viewModel.init(tunnelId) }
LaunchedEffect(uiState.loading) {
LaunchedEffect(Unit) {
if (!uiState.loading && context.isRunningOnTv()) {
delay(Constants.FOCUS_REQUEST_DELAY)
focusRequester.requestFocus()
kotlin.runCatching {
focusRequester.requestFocus()
}.onFailure {
delay(Constants.FOCUS_REQUEST_DELAY)
focusRequester.requestFocus()
}
}
}
if (uiState.loading) {
LoadingScreen()
return
LaunchedEffect(Unit) {
delay(2_000L)
viewModel.cleanUpUninstalledApps()
}
val keyboardActions = KeyboardActions(onDone = { keyboardController?.hide() })
val keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done)
val fillMaxHeight = .85f
@@ -155,13 +137,13 @@ fun ConfigScreen(
},
onError = {
showAuthPrompt = false
appViewModel.showSnackbarMessage(
snackbar.showMessage(
context.getString(R.string.error_authentication_failed),
)
},
onFailure = {
showAuthPrompt = false
appViewModel.showSnackbarMessage(
snackbar.showMessage(
context.getString(R.string.error_authorization_failed),
)
},
@@ -169,182 +151,19 @@ fun ConfigScreen(
}
if (showApplicationsDialog) {
val sortedPackages =
remember(uiState.packages) {
uiState.packages.sortedBy { viewModel.getPackageLabel(it) }
}
BasicAlertDialog(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 {
val icon = Icons.Rounded.Android
Icon(
icon,
icon.name,
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))
}
}
}
}
ApplicationSelectionDialog(viewModel, uiState) {
showApplicationsDialog = false
}
}
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.onFocusChanged {
if (context.isRunningOnTv()) {
fobColor = if (it.isFocused) hoverColor else secondaryColor
}
},
onClick = {
viewModel.onSaveAllChanges(configType).onSuccess {
appViewModel.showSnackbarMessage(
context.getString(R.string.config_changes_saved),
)
navController.navigate(Screen.Main.route)
}.onFailure {
appViewModel.showSnackbarMessage(it.getMessage(context))
}
viewModel.onSaveAllChanges()
},
containerColor = fobColor,
containerColor = MaterialTheme.colorScheme.primary,
shape = RoundedCornerShape(16.dp),
) {
Icon(
@@ -394,9 +213,16 @@ fun ConfigScreen(
stringResource(R.string.interface_),
padding = screenPadding,
)
ConfigurationToggle(
stringResource(id = R.string.show_amnezia_properties),
checked = configType == ConfigType.AMNEZIA,
padding = screenPadding,
onCheckChanged = { configType = if (it) ConfigType.AMNEZIA else ConfigType.WIREGUARD },
modifier = Modifier.focusRequester(focusRequester),
)
ConfigurationTextBox(
value = uiState.tunnelName,
onValueChange = { value -> viewModel.onTunnelNameChange(value) },
onValueChange = viewModel::onTunnelNameChange,
keyboardActions = keyboardActions,
label = stringResource(R.string.name),
hint = stringResource(R.string.tunnel_name).lowercase(),
@@ -412,12 +238,12 @@ fun ConfigScreen(
.clickable { showAuthPrompt = true },
value = uiState.interfaceProxy.privateKey,
visualTransformation =
if ((tunnelId == Constants.MANUAL_TUNNEL_CONFIG_ID) || isAuthenticated) {
if ((tunnelId == Constants.MANUAL_TUNNEL_CONFIG_ID.toInt()) || isAuthenticated) {
VisualTransformation.None
} else {
PasswordVisualTransformation()
},
enabled = (tunnelId == Constants.MANUAL_TUNNEL_CONFIG_ID) || isAuthenticated,
enabled = (tunnelId == Constants.MANUAL_TUNNEL_CONFIG_ID.toInt()) || isAuthenticated,
onValueChange = { value -> viewModel.onPrivateKeyChange(value) },
trailingIcon = {
IconButton(
@@ -467,31 +293,29 @@ fun ConfigScreen(
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),
)
}
ConfigurationTextBox(
value = uiState.interfaceProxy.addresses,
onValueChange = viewModel::onAddressesChanged,
keyboardActions = keyboardActions,
label = stringResource(R.string.addresses),
hint = stringResource(R.string.comma_separated_list),
modifier =
Modifier
.fillMaxWidth()
.padding(end = 5.dp),
)
ConfigurationTextBox(
value = uiState.interfaceProxy.listenPort,
onValueChange = viewModel::onListenPortChanged,
keyboardActions = keyboardActions,
label = stringResource(R.string.listen_port),
hint = stringResource(R.string.random),
modifier = Modifier.fillMaxWidth(),
)
Row(modifier = Modifier.fillMaxWidth()) {
ConfigurationTextBox(
value = uiState.interfaceProxy.dnsServers,
onValueChange = { value -> viewModel.onDnsServersChanged(value) },
onValueChange = viewModel::onDnsServersChanged,
keyboardActions = keyboardActions,
label = stringResource(R.string.dns_servers),
hint = stringResource(R.string.comma_separated_list),
@@ -502,7 +326,7 @@ fun ConfigScreen(
)
ConfigurationTextBox(
value = uiState.interfaceProxy.mtu,
onValueChange = { value -> viewModel.onMtuChanged(value) },
onValueChange = viewModel::onMtuChanged,
keyboardActions = keyboardActions,
label = stringResource(R.string.mtu),
hint = stringResource(R.string.auto),
@@ -512,10 +336,7 @@ fun ConfigScreen(
if (configType == ConfigType.AMNEZIA) {
ConfigurationTextBox(
value = uiState.interfaceProxy.junkPacketCount,
onValueChange = {
value ->
viewModel.onJunkPacketCountChanged(value)
},
onValueChange = viewModel::onJunkPacketCountChanged,
keyboardActions = keyboardActions,
label = stringResource(R.string.junk_packet_count),
hint = stringResource(R.string.junk_packet_count).lowercase(),
@@ -526,11 +347,7 @@ fun ConfigScreen(
)
ConfigurationTextBox(
value = uiState.interfaceProxy.junkPacketMinSize,
onValueChange = { value ->
viewModel.onJunkPacketMinSizeChanged(
value,
)
},
onValueChange = viewModel::onJunkPacketMinSizeChanged,
keyboardActions = keyboardActions,
label = stringResource(R.string.junk_packet_minimum_size),
hint =
@@ -544,11 +361,7 @@ fun ConfigScreen(
)
ConfigurationTextBox(
value = uiState.interfaceProxy.junkPacketMaxSize,
onValueChange = { value ->
viewModel.onJunkPacketMaxSizeChanged(
value,
)
},
onValueChange = viewModel::onJunkPacketMaxSizeChanged,
keyboardActions = keyboardActions,
label = stringResource(R.string.junk_packet_maximum_size),
hint =
@@ -562,11 +375,7 @@ fun ConfigScreen(
)
ConfigurationTextBox(
value = uiState.interfaceProxy.initPacketJunkSize,
onValueChange = { value ->
viewModel.onInitPacketJunkSizeChanged(
value,
)
},
onValueChange = viewModel::onInitPacketJunkSizeChanged,
keyboardActions = keyboardActions,
label = stringResource(R.string.init_packet_junk_size),
hint = stringResource(R.string.init_packet_junk_size).lowercase(),
@@ -577,10 +386,7 @@ fun ConfigScreen(
)
ConfigurationTextBox(
value = uiState.interfaceProxy.responsePacketJunkSize,
onValueChange = {
value ->
viewModel.onResponsePacketJunkSize(value)
},
onValueChange = viewModel::onResponsePacketJunkSize,
keyboardActions = keyboardActions,
label = stringResource(R.string.response_packet_junk_size),
hint =
@@ -594,10 +400,7 @@ fun ConfigScreen(
)
ConfigurationTextBox(
value = uiState.interfaceProxy.initPacketMagicHeader,
onValueChange = {
value ->
viewModel.onInitPacketMagicHeader(value)
},
onValueChange = viewModel::onInitPacketMagicHeader,
keyboardActions = keyboardActions,
label = stringResource(R.string.init_packet_magic_header),
hint =
@@ -611,11 +414,7 @@ fun ConfigScreen(
)
ConfigurationTextBox(
value = uiState.interfaceProxy.responsePacketMagicHeader,
onValueChange = { value ->
viewModel.onResponsePacketMagicHeader(
value,
)
},
onValueChange = viewModel::onResponsePacketMagicHeader,
keyboardActions = keyboardActions,
label = stringResource(R.string.response_packet_magic_header),
hint =
@@ -629,11 +428,7 @@ fun ConfigScreen(
)
ConfigurationTextBox(
value = uiState.interfaceProxy.underloadPacketMagicHeader,
onValueChange = { value ->
viewModel.onUnderloadPacketMagicHeader(
value,
)
},
onValueChange = viewModel::onUnderloadPacketMagicHeader,
keyboardActions = keyboardActions,
label = stringResource(R.string.underload_packet_magic_header),
hint =
@@ -647,11 +442,7 @@ fun ConfigScreen(
)
ConfigurationTextBox(
value = uiState.interfaceProxy.transportPacketMagicHeader,
onValueChange = { value ->
viewModel.onTransportPacketMagicHeader(
value,
)
},
onValueChange = viewModel::onTransportPacketMagicHeader,
keyboardActions = keyboardActions,
label = stringResource(R.string.transport_packet_magic_header),
hint =
@@ -809,9 +600,6 @@ fun ConfigScreen(
}
}
}
if (context.isRunningOnTv()) {
Spacer(modifier = Modifier.weight(.17f))
}
}
}
}
@@ -15,7 +15,7 @@ data class ConfigUiState(
val isAllApplicationsEnabled: Boolean = false,
val loading: Boolean = true,
val tunnel: TunnelConfig? = null,
val tunnelName: String = "",
var tunnelName: String = "",
val isAmneziaEnabled: Boolean = false,
) {
companion object {
@@ -45,7 +45,6 @@ data class ConfigUiState(
}
fun from(config: org.amnezia.awg.config.Config): ConfigUiState {
// TODO update with new values
val proxyPeers = config.peers.map { PeerProxy.from(it) }
val proxyInterface = InterfaceProxy.from(config.`interface`)
var include = true
@@ -69,5 +68,13 @@ data class ConfigUiState(
isAllApplicationsEnabled,
)
}
fun from(tunnel: TunnelConfig): ConfigUiState {
val config = tunnel.toAmConfig()
return from(config).copy(
tunnelName = tunnel.name,
tunnel = tunnel,
)
}
}
}
@@ -6,6 +6,7 @@ import android.content.pm.PackageManager
import android.os.Build
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.navigation.NavHostController
import com.wireguard.config.Config
import com.wireguard.config.Interface
import com.wireguard.config.Peer
@@ -15,100 +16,116 @@ import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig
import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepository
import com.zaneschepke.wireguardautotunnel.module.IoDispatcher
import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController
import com.zaneschepke.wireguardautotunnel.ui.screens.config.model.PeerProxy
import com.zaneschepke.wireguardautotunnel.ui.screens.main.ConfigType
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.NumberUtils
import com.zaneschepke.wireguardautotunnel.util.StringValue
import com.zaneschepke.wireguardautotunnel.util.WgTunnelExceptions
import com.zaneschepke.wireguardautotunnel.util.extensions.removeAt
import com.zaneschepke.wireguardautotunnel.util.extensions.update
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
import timber.log.Timber
import javax.inject.Inject
@HiltViewModel
@HiltViewModel(assistedFactory = ConfigViewModel.ConfigViewModelFactory::class)
class ConfigViewModel
@Inject
@AssistedInject
constructor(
private val settingsRepository: SettingsRepository,
private val appDataRepository: AppDataRepository,
private val navController: NavHostController,
@Assisted val id: Int,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) : ViewModel() {
private val packageManager = WireGuardAutoTunnel.instance.packageManager
private val _uiState = MutableStateFlow(ConfigUiState())
val uiState = _uiState.asStateFlow()
fun init(tunnelId: String) = viewModelScope.launch(ioDispatcher) {
val packages = getQueriedPackages("")
val state =
if (tunnelId != Constants.MANUAL_TUNNEL_CONFIG_ID) {
val tunnelConfig =
appDataRepository.tunnels.getAll()
.firstOrNull { it.id.toString() == tunnelId }
val isAmneziaEnabled = settingsRepository.getSettings().isAmneziaEnabled
if (tunnelConfig != null) {
(
if (isAmneziaEnabled) {
val amConfig =
if (tunnelConfig.amQuick == "") tunnelConfig.wgQuick else tunnelConfig.amQuick
ConfigUiState.from(TunnelConfig.configFromAmQuick(amConfig))
} else {
ConfigUiState.from(
TunnelConfig.configFromWgQuick(tunnelConfig.wgQuick),
)
}
).copy(
packages = packages,
loading = false,
tunnel = tunnelConfig,
tunnelName = tunnelConfig.name,
isAmneziaEnabled = isAmneziaEnabled,
)
} else {
ConfigUiState(loading = false, packages = packages)
}
} else {
ConfigUiState(loading = false, packages = packages)
}
_uiState.value = state
}
val uiState = _uiState.onStart {
appDataRepository.tunnels.getById(id)?.let {
val packages = getQueriedPackages()
_uiState.value = ConfigUiState.from(it).copy(
packages = packages,
)
}
}.stateIn(
viewModelScope + ioDispatcher,
SharingStarted.WhileSubscribed(Constants.SUBSCRIPTION_TIMEOUT),
ConfigUiState(),
)
fun onTunnelNameChange(name: String) {
_uiState.value = _uiState.value.copy(tunnelName = name)
_uiState.update {
it.copy(tunnelName = name)
}
}
fun onIncludeChange(include: Boolean) {
_uiState.value = _uiState.value.copy(include = include)
_uiState.update {
it.copy(include = include)
}
}
fun cleanUpUninstalledApps() = viewModelScope.launch(ioDispatcher) {
uiState.value.tunnel?.let {
val config = it.toAmConfig()
val packages = getQueriedPackages()
val packageSet = packages.map { pack -> pack.packageName }.toSet()
val includedApps = config.`interface`.includedApplications.toMutableList()
val excludedApps = config.`interface`.excludedApplications.toMutableList()
if (includedApps.isEmpty() && excludedApps.isEmpty()) return@launch
if (includedApps.retainAll(packageSet) || excludedApps.retainAll(packageSet)) {
Timber.i("Removing split tunnel package name that no longer exists on the device")
_uiState.update { state ->
state.copy(
checkedPackageNames = if (_uiState.value.include) includedApps else excludedApps,
)
}
val wgQuick = buildConfig().toWgQuickString(true)
val amQuick = buildAmConfig().toAwgQuickString(true)
saveConfig(
it.copy(
amQuick = amQuick,
wgQuick = wgQuick,
),
)
}
}
}
fun onAddCheckedPackage(packageName: String) {
_uiState.value =
_uiState.value.copy(
checkedPackageNames = _uiState.value.checkedPackageNames + packageName,
_uiState.update {
it.copy(
checkedPackageNames = it.checkedPackageNames + packageName,
)
}
}
fun onAllApplicationsChange(isAllApplicationsEnabled: Boolean) {
_uiState.value = _uiState.value.copy(isAllApplicationsEnabled = isAllApplicationsEnabled)
_uiState.update {
it.copy(isAllApplicationsEnabled = isAllApplicationsEnabled)
}
}
fun onRemoveCheckedPackage(packageName: String) {
_uiState.value =
_uiState.value.copy(
checkedPackageNames = _uiState.value.checkedPackageNames - packageName,
_uiState.update {
it.copy(
checkedPackageNames = it.checkedPackageNames - packageName,
)
}
}
private fun getQueriedPackages(query: String): List<PackageInfo> {
private fun getQueriedPackages(query: String = ""): List<PackageInfo> {
return getAllInternetCapablePackages().filter {
getPackageLabel(it).lowercase().contains(query.lowercase())
}
@@ -137,7 +154,9 @@ constructor(
return _uiState.value.isAllApplicationsEnabled
}
private fun saveConfig(tunnelConfig: TunnelConfig) = viewModelScope.launch { appDataRepository.tunnels.save(tunnelConfig) }
private fun saveConfig(tunnelConfig: TunnelConfig) = viewModelScope.launch {
appDataRepository.tunnels.save(tunnelConfig)
}
private fun updateTunnelConfig(tunnelConfig: TunnelConfig?) = viewModelScope.launch {
if (tunnelConfig != null) {
@@ -174,105 +193,113 @@ constructor(
}
private fun emptyCheckedPackagesList() {
_uiState.value = _uiState.value.copy(checkedPackageNames = emptyList())
_uiState.update {
it.copy(checkedPackageNames = emptyList())
}
}
private fun buildInterfaceListFromProxyInterface(): Interface {
val builder = Interface.Builder()
builder.parsePrivateKey(_uiState.value.interfaceProxy.privateKey.trim())
builder.parseAddresses(_uiState.value.interfaceProxy.addresses.trim())
if (_uiState.value.interfaceProxy.dnsServers.isNotEmpty()) {
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,
)
with(_uiState.value.interfaceProxy) {
builder.parsePrivateKey(this.privateKey.trim())
builder.parseAddresses(this.addresses.trim())
if (this.dnsServers.isNotEmpty()) {
builder.parseDnsServers(this.dnsServers.trim())
}
if (this.mtu.isNotEmpty()) {
builder.parseMtu(this.mtu.trim())
}
if (this.listenPort.isNotEmpty()) {
builder.parseListenPort(this.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()
}
private fun buildAmInterfaceListFromProxyInterface(): org.amnezia.awg.config.Interface {
val builder = org.amnezia.awg.config.Interface.Builder()
builder.parsePrivateKey(_uiState.value.interfaceProxy.privateKey.trim())
builder.parseAddresses(_uiState.value.interfaceProxy.addresses.trim())
if (_uiState.value.interfaceProxy.dnsServers.isNotEmpty()) {
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,
)
}
if (_uiState.value.interfaceProxy.junkPacketCount.isNotEmpty()) {
builder.setJunkPacketCount(
_uiState.value.interfaceProxy.junkPacketCount.trim().toInt(),
)
}
if (_uiState.value.interfaceProxy.junkPacketMinSize.isNotEmpty()) {
builder.setJunkPacketMinSize(
_uiState.value.interfaceProxy.junkPacketMinSize.trim().toInt(),
)
}
if (_uiState.value.interfaceProxy.junkPacketMaxSize.isNotEmpty()) {
builder.setJunkPacketMaxSize(
_uiState.value.interfaceProxy.junkPacketMaxSize.trim().toInt(),
)
}
if (_uiState.value.interfaceProxy.initPacketJunkSize.isNotEmpty()) {
builder.setInitPacketJunkSize(
_uiState.value.interfaceProxy.initPacketJunkSize.trim().toInt(),
)
}
if (_uiState.value.interfaceProxy.responsePacketJunkSize.isNotEmpty()) {
builder.setResponsePacketJunkSize(
_uiState.value.interfaceProxy.responsePacketJunkSize.trim().toInt(),
)
}
if (_uiState.value.interfaceProxy.initPacketMagicHeader.isNotEmpty()) {
builder.setInitPacketMagicHeader(
_uiState.value.interfaceProxy.initPacketMagicHeader.trim().toLong(),
)
}
if (_uiState.value.interfaceProxy.responsePacketMagicHeader.isNotEmpty()) {
builder.setResponsePacketMagicHeader(
_uiState.value.interfaceProxy.responsePacketMagicHeader.trim().toLong(),
)
}
if (_uiState.value.interfaceProxy.transportPacketMagicHeader.isNotEmpty()) {
builder.setTransportPacketMagicHeader(
_uiState.value.interfaceProxy.transportPacketMagicHeader.trim().toLong(),
)
}
if (_uiState.value.interfaceProxy.underloadPacketMagicHeader.isNotEmpty()) {
builder.setUnderloadPacketMagicHeader(
_uiState.value.interfaceProxy.underloadPacketMagicHeader.trim().toLong(),
)
with(_uiState.value.interfaceProxy) {
builder.parsePrivateKey(this.privateKey.trim())
builder.parseAddresses(this.addresses.trim())
if (this.dnsServers.isNotEmpty()) {
builder.parseDnsServers(this.dnsServers.trim())
}
if (this.mtu.isNotEmpty()) {
builder.parseMtu(this.mtu.trim())
}
if (this.listenPort.isNotEmpty()) {
builder.parseListenPort(this.listenPort.trim())
}
if (isAllApplicationsEnabled()) emptyCheckedPackagesList()
if (_uiState.value.include) {
builder.includeApplications(
_uiState.value.checkedPackageNames,
)
}
if (!_uiState.value.include) {
builder.excludeApplications(
_uiState.value.checkedPackageNames,
)
}
if (this.junkPacketCount.isNotEmpty()) {
builder.setJunkPacketCount(
this.junkPacketCount.trim().toInt(),
)
}
if (this.junkPacketMinSize.isNotEmpty()) {
builder.setJunkPacketMinSize(
this.junkPacketMinSize.trim().toInt(),
)
}
if (this.junkPacketMaxSize.isNotEmpty()) {
builder.setJunkPacketMaxSize(
this.junkPacketMaxSize.trim().toInt(),
)
}
if (this.initPacketJunkSize.isNotEmpty()) {
builder.setInitPacketJunkSize(
this.initPacketJunkSize.trim().toInt(),
)
}
if (this.responsePacketJunkSize.isNotEmpty()) {
builder.setResponsePacketJunkSize(
this.responsePacketJunkSize.trim().toInt(),
)
}
if (this.initPacketMagicHeader.isNotEmpty()) {
builder.setInitPacketMagicHeader(
this.initPacketMagicHeader.trim().toLong(),
)
}
if (this.responsePacketMagicHeader.isNotEmpty()) {
builder.setResponsePacketMagicHeader(
this.responsePacketMagicHeader.trim().toLong(),
)
}
if (this.transportPacketMagicHeader.isNotEmpty()) {
builder.setTransportPacketMagicHeader(
this.transportPacketMagicHeader.trim().toLong(),
)
}
if (this.underloadPacketMagicHeader.isNotEmpty()) {
builder.setUnderloadPacketMagicHeader(
this.underloadPacketMagicHeader.trim().toLong(),
)
}
}
return builder.build()
}
@@ -291,41 +318,33 @@ constructor(
.build()
}
fun onSaveAllChanges(configType: ConfigType): Result<Unit> {
return try {
fun onSaveAllChanges() = viewModelScope.launch {
kotlin.runCatching {
val wgQuick = buildConfig().toWgQuickString(true)
val amQuick =
if (configType == ConfigType.AMNEZIA) {
buildAmConfig().toAwgQuickString()
} else {
TunnelConfig.AM_QUICK_DEFAULT
}
val tunnelConfig =
when (uiState.value.tunnel) {
null ->
TunnelConfig(
name = _uiState.value.tunnelName,
wgQuick = wgQuick,
amQuick = amQuick,
)
else ->
uiState.value.tunnel!!.copy(
name = _uiState.value.tunnelName,
wgQuick = wgQuick,
amQuick = amQuick,
)
}
val amQuick = buildAmConfig().toAwgQuickString(true)
val tunnelConfig = uiState.value.tunnel?.copy(
name = _uiState.value.tunnelName,
amQuick = amQuick,
wgQuick = wgQuick,
) ?: TunnelConfig(
name = _uiState.value.tunnelName,
wgQuick = wgQuick,
amQuick = amQuick,
)
updateTunnelConfig(tunnelConfig)
Result.success(Unit)
} catch (e: Exception) {
Timber.e(e)
val message = e.message?.substringAfter(":", missingDelimiterValue = "")
val stringValue =
message?.let {
StringValue.DynamicString(message)
} ?: StringValue.StringResource(R.string.unknown_error)
Result.failure(WgTunnelExceptions.ConfigParseError(stringValue))
SnackbarController.showMessage(
StringValue.StringResource(R.string.config_changes_saved),
)
navController.navigate(Route.Main)
}.onFailure {
Timber.e(it)
val message = it.message?.substringAfter(":", missingDelimiterValue = "")
val stringValue = if (message.isNullOrBlank()) {
StringValue.StringResource(R.string.unknown_error)
} else {
StringValue.DynamicString(message)
}
SnackbarController.showMessage(stringValue)
}
}
@@ -408,7 +427,7 @@ constructor(
_uiState.update {
it.copy(
interfaceProxy =
_uiState.value.interfaceProxy.copy(
it.interfaceProxy.copy(
privateKey = keyPair.privateKey.toBase64(),
publicKey = keyPair.publicKey.toBase64(),
),
@@ -419,7 +438,7 @@ constructor(
fun onAddressesChanged(value: String) {
_uiState.update {
it.copy(
interfaceProxy = _uiState.value.interfaceProxy.copy(addresses = value),
interfaceProxy = it.interfaceProxy.copy(addresses = value),
)
}
}
@@ -427,7 +446,7 @@ constructor(
fun onListenPortChanged(value: String) {
_uiState.update {
it.copy(
interfaceProxy = _uiState.value.interfaceProxy.copy(listenPort = value),
interfaceProxy = it.interfaceProxy.copy(listenPort = value),
)
}
}
@@ -435,21 +454,21 @@ constructor(
fun onDnsServersChanged(value: String) {
_uiState.update {
it.copy(
interfaceProxy = _uiState.value.interfaceProxy.copy(dnsServers = value),
interfaceProxy = it.interfaceProxy.copy(dnsServers = value),
)
}
}
fun onMtuChanged(value: String) {
_uiState.update {
it.copy(interfaceProxy = _uiState.value.interfaceProxy.copy(mtu = value))
it.copy(interfaceProxy = it.interfaceProxy.copy(mtu = value))
}
}
private fun onInterfacePublicKeyChange(value: String) {
_uiState.update {
it.copy(
interfaceProxy = _uiState.value.interfaceProxy.copy(publicKey = value),
interfaceProxy = it.interfaceProxy.copy(publicKey = value),
)
}
}
@@ -457,7 +476,7 @@ constructor(
fun onPrivateKeyChange(value: String) {
_uiState.update {
it.copy(
interfaceProxy = _uiState.value.interfaceProxy.copy(privateKey = value),
interfaceProxy = it.interfaceProxy.copy(privateKey = value),
)
}
if (NumberUtils.isValidKey(value)) {
@@ -479,7 +498,7 @@ constructor(
fun onJunkPacketCountChanged(value: String) {
_uiState.update {
it.copy(
interfaceProxy = _uiState.value.interfaceProxy.copy(junkPacketCount = value),
interfaceProxy = it.interfaceProxy.copy(junkPacketCount = value),
)
}
}
@@ -487,7 +506,7 @@ constructor(
fun onJunkPacketMinSizeChanged(value: String) {
_uiState.update {
it.copy(
interfaceProxy = _uiState.value.interfaceProxy.copy(junkPacketMinSize = value),
interfaceProxy = it.interfaceProxy.copy(junkPacketMinSize = value),
)
}
}
@@ -495,7 +514,7 @@ constructor(
fun onJunkPacketMaxSizeChanged(value: String) {
_uiState.update {
it.copy(
interfaceProxy = _uiState.value.interfaceProxy.copy(junkPacketMaxSize = value),
interfaceProxy = it.interfaceProxy.copy(junkPacketMaxSize = value),
)
}
}
@@ -503,7 +522,7 @@ constructor(
fun onInitPacketJunkSizeChanged(value: String) {
_uiState.update {
it.copy(
interfaceProxy = _uiState.value.interfaceProxy.copy(initPacketJunkSize = value),
interfaceProxy = it.interfaceProxy.copy(initPacketJunkSize = value),
)
}
}
@@ -512,7 +531,7 @@ constructor(
_uiState.update {
it.copy(
interfaceProxy =
_uiState.value.interfaceProxy.copy(
it.interfaceProxy.copy(
responsePacketJunkSize = value,
),
)
@@ -523,7 +542,7 @@ constructor(
_uiState.update {
it.copy(
interfaceProxy =
_uiState.value.interfaceProxy.copy(
it.interfaceProxy.copy(
initPacketMagicHeader = value,
),
)
@@ -534,7 +553,7 @@ constructor(
_uiState.update {
it.copy(
interfaceProxy =
_uiState.value.interfaceProxy.copy(
it.interfaceProxy.copy(
responsePacketMagicHeader = value,
),
)
@@ -545,7 +564,7 @@ constructor(
_uiState.update {
it.copy(
interfaceProxy =
_uiState.value.interfaceProxy.copy(
it.interfaceProxy.copy(
transportPacketMagicHeader = value,
),
)
@@ -556,10 +575,15 @@ constructor(
_uiState.update {
it.copy(
interfaceProxy =
_uiState.value.interfaceProxy.copy(
it.interfaceProxy.copy(
underloadPacketMagicHeader = value,
),
)
}
}
@AssistedFactory
interface ConfigViewModelFactory {
fun create(id: Int): ConfigViewModel
}
}
@@ -0,0 +1,199 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.config.components
import android.content.pm.PackageInfo
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Android
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.google.accompanist.drawablepainter.DrawablePainter
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.common.SearchBar
import com.zaneschepke.wireguardautotunnel.ui.screens.config.ConfigUiState
import com.zaneschepke.wireguardautotunnel.ui.screens.config.ConfigViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ApplicationSelectionDialog(viewModel: ConfigViewModel, uiState: ConfigUiState, onDismiss: () -> Unit) {
val context = LocalContext.current
val licenseComparator = compareBy<PackageInfo> { viewModel.getPackageLabel(it) }
val sortedPackages = remember(uiState.packages, licenseComparator) {
uiState.packages.sortedWith(licenseComparator)
}
BasicAlertDialog(
onDismissRequest = { onDismiss() },
) {
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,
)
}
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(19 / 22f),
) {
items(sortedPackages, key = { it.packageName }) { pack ->
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier =
Modifier
.fillMaxSize()
.padding(5.dp).padding(end = 25.dp),
) {
Row(modifier = Modifier.fillMaxWidth().padding(start = 5.dp)) {
val drawable =
pack.applicationInfo?.loadIcon(context.packageManager)
val iconSize = 35.dp
if (drawable != null) {
Image(
painter = DrawablePainter(drawable),
stringResource(id = R.string.icon),
modifier = Modifier.size(iconSize),
)
} else {
val icon = Icons.Rounded.Android
Icon(
icon,
icon.name,
modifier = Modifier.size(iconSize),
)
}
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 = { onDismiss() }) {
Text(stringResource(R.string.done))
}
}
}
}
}
}
@@ -25,7 +25,10 @@ data class InterfaceProxy(
publicKey = i.keyPair.publicKey.toBase64().trim(),
privateKey = i.keyPair.privateKey.toBase64().trim(),
addresses = i.addresses.joinToString(", ").trim(),
dnsServers = i.dnsServers.joinToString(", ").replace("/", "").trim(),
dnsServers = listOf(
i.dnsServers.joinToString(", ").replace("/", "").trim(),
i.dnsSearchDomains.joinToString(", ").trim(),
).filter { it.length > 0 }.joinToString(", "),
listenPort =
if (i.listenPort.isPresent) {
i.listenPort.get().toString().trim()
@@ -4,9 +4,6 @@ import android.annotation.SuppressLint
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity.RESULT_OK
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.focusable
import androidx.compose.foundation.gestures.ScrollableDefaults
@@ -21,6 +18,7 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.overscroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.rounded.Bolt
import androidx.compose.material.icons.rounded.Circle
import androidx.compose.material.icons.rounded.CopyAll
@@ -32,6 +30,7 @@ import androidx.compose.material.icons.rounded.Star
import androidx.compose.material3.FabPosition
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
@@ -41,7 +40,6 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@@ -61,7 +59,6 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import com.journeyapps.barcodescanner.ScanContract
import com.journeyapps.barcodescanner.ScanOptions
@@ -70,49 +67,40 @@ import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig
import com.zaneschepke.wireguardautotunnel.service.tunnel.HandshakeStatus
import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelState
import com.zaneschepke.wireguardautotunnel.ui.AppViewModel
import com.zaneschepke.wireguardautotunnel.ui.Screen
import com.zaneschepke.wireguardautotunnel.ui.AppUiState
import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.common.RowListItem
import com.zaneschepke.wireguardautotunnel.ui.common.dialog.InfoDialog
import com.zaneschepke.wireguardautotunnel.ui.common.functions.rememberFileImportLauncherForResult
import com.zaneschepke.wireguardautotunnel.ui.common.screen.LoadingScreen
import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController
import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.GettingStartedLabel
import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.ScrollDismissMultiFab
import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.ScrollDismissFab
import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.TunnelImportSheet
import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.VpnDeniedDialog
import com.zaneschepke.wireguardautotunnel.ui.theme.corn
import com.zaneschepke.wireguardautotunnel.ui.theme.mint
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.extensions.getMessage
import com.zaneschepke.wireguardautotunnel.util.extensions.handshakeStatus
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
import com.zaneschepke.wireguardautotunnel.util.extensions.mapPeerStats
import com.zaneschepke.wireguardautotunnel.util.extensions.openWebUrl
import com.zaneschepke.wireguardautotunnel.util.extensions.startTunnelBackground
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import timber.log.Timber
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun MainScreen(
viewModel: MainViewModel = hiltViewModel(),
appViewModel: AppViewModel,
focusRequester: FocusRequester,
navController: NavController,
) {
fun MainScreen(viewModel: MainViewModel = hiltViewModel(), uiState: AppUiState, focusRequester: FocusRequester, navController: NavController) {
val haptic = LocalHapticFeedback.current
val context = LocalContext.current
val scope = rememberCoroutineScope()
val snackbar = SnackbarController.current
var showBottomSheet by remember { mutableStateOf(false) }
var configType by remember { mutableStateOf(ConfigType.WIREGUARD) }
var showVpnPermissionDialog by remember { mutableStateOf(false) }
val isVisible = rememberSaveable { mutableStateOf(true) }
var showDeleteTunnelAlertDialog by remember { mutableStateOf(false) }
var selectedTunnel by remember { mutableStateOf<TunnelConfig?>(null) }
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val nestedScrollConnection =
remember {
@@ -147,20 +135,21 @@ fun MainScreen(
LaunchedEffect(Unit) {
if (context.isRunningOnTv()) {
delay(Constants.FOCUS_REQUEST_DELAY)
focusRequester.requestFocus()
kotlin.runCatching {
focusRequester.requestFocus()
}.onFailure {
delay(Constants.FOCUS_REQUEST_DELAY)
focusRequester.requestFocus()
}
}
}
val tunnelFileImportResultLauncher = rememberFileImportLauncherForResult(onNoFileExplorer = {
appViewModel.showSnackbarMessage(
snackbar.showMessage(
context.getString(R.string.error_no_file_explorer),
)
}, onData = { data ->
scope.launch {
viewModel.onTunnelFileSelected(data, configType, context).onFailure {
appViewModel.showSnackbarMessage(it.getMessage(context))
}
}
viewModel.onTunnelFileSelected(data, context)
})
val scanLauncher =
@@ -168,11 +157,7 @@ fun MainScreen(
contract = ScanContract(),
onResult = {
if (it.contents != null) {
scope.launch {
viewModel.onTunnelQrResult(it.contents, configType).onFailure { error ->
appViewModel.showSnackbarMessage(error.getMessage(context))
}
}
viewModel.onTunnelQrResult(it.contents)
}
},
)
@@ -207,10 +192,6 @@ fun MainScreen(
}
}
if (uiState.loading) {
return LoadingScreen()
}
fun launchQrScanner() {
val scanOptions = ScanOptions()
scanOptions.setDesiredBarcodeFormats(ScanOptions.QR_CODE)
@@ -235,9 +216,15 @@ fun MainScreen(
},
floatingActionButtonPosition = FabPosition.End,
floatingActionButton = {
ScrollDismissMultiFab(R.drawable.add, focusRequester, isVisible = isVisible.value, onFabItemClicked = {
ScrollDismissFab(icon = {
val icon = Icons.Filled.Add
Icon(
imageVector = icon,
contentDescription = icon.name,
tint = MaterialTheme.colorScheme.onPrimary,
)
}, focusRequester, isVisible = isVisible.value, onClick = {
showBottomSheet = true
configType = ConfigType.valueOf(it.value)
})
},
) {
@@ -248,7 +235,7 @@ fun MainScreen(
onQrClick = { launchQrScanner() },
onManualImportClick = {
navController.navigate(
"${Screen.Config.route}/${Constants.MANUAL_TUNNEL_CONFIG_ID}?configType=$configType",
Route.Config(Constants.MANUAL_TUNNEL_CONFIG_ID),
)
},
)
@@ -265,12 +252,8 @@ fun MainScreen(
reverseLayout = false,
flingBehavior = ScrollableDefaults.flingBehavior(),
) {
item {
AnimatedVisibility(
uiState.tunnels.isEmpty(),
exit = fadeOut(),
enter = fadeIn(),
) {
if (uiState.tunnels.isEmpty()) {
item {
GettingStartedLabel(onClick = { context.openWebUrl(it) })
}
}
@@ -343,20 +326,22 @@ fun MainScreen(
uiState.tunnels,
key = { tunnel -> tunnel.id },
) { tunnel ->
val isActive = uiState.tunnels.any { it.id == tunnel.id && it.isActive }
val isActive = uiState.tunnels.any {
it.id == tunnel.id &&
it.isActive
}
val leadingIconColor =
(
if (
isActive
isActive && uiState.vpnState.statistics != null
) {
uiState.vpnState.statistics
?.mapPeerStats()
?.map { it.value?.handshakeStatus() }
uiState.vpnState.statistics.mapPeerStats()
.map { it.value?.handshakeStatus() }
.let { statuses ->
when {
statuses?.all { it == HandshakeStatus.HEALTHY } == true -> mint
statuses?.any { it == HandshakeStatus.STALE } == true -> corn
statuses?.all { it == HandshakeStatus.NOT_STARTED } == true ->
statuses.all { it == HandshakeStatus.HEALTHY } -> mint
statuses.any { it == HandshakeStatus.STALE } -> corn
statuses.all { it == HandshakeStatus.NOT_STARTED } ->
Color.Gray
else -> {
@@ -396,15 +381,6 @@ fun MainScreen(
},
text = tunnel.name,
onHold = {
if (
(uiState.vpnState.status == TunnelState.UP) &&
(tunnel.name == uiState.vpnState.tunnelConfig?.name)
) {
appViewModel.showSnackbarMessage(
context.getString(R.string.turn_off_tunnel),
)
return@RowListItem
}
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
selectedTunnel = tunnel
},
@@ -431,16 +407,9 @@ fun MainScreen(
Row {
IconButton(
onClick = {
if (
uiState.settings.isAutoTunnelEnabled &&
!uiState.settings.isAutoTunnelPaused
) {
appViewModel.showSnackbarMessage(
context.getString(R.string.turn_off_tunnel),
)
} else {
selectedTunnel?.let {
navController.navigate(
"${Screen.Option.route}/${selectedTunnel?.id}",
Route.Option(it.id),
)
}
},
@@ -459,6 +428,7 @@ fun MainScreen(
Icon(icon, icon.name)
}
IconButton(
enabled = !isActive,
modifier = Modifier.focusable(),
onClick = { showDeleteTunnelAlertDialog = true },
) {
@@ -483,14 +453,10 @@ fun MainScreen(
Row {
IconButton(
onClick = {
if (uiState.settings.isAutoTunnelEnabled && !uiState.settings.isAutoTunnelPaused) {
appViewModel.showSnackbarMessage(
context.getString(R.string.turn_off_auto),
)
} else {
selectedTunnel = tunnel
selectedTunnel = tunnel
selectedTunnel?.let {
navController.navigate(
"${Screen.Option.route}/${selectedTunnel?.id}",
Route.Option(it.id),
)
}
},
@@ -510,7 +476,7 @@ fun MainScreen(
) {
expanded.value = !expanded.value
} else {
appViewModel.showSnackbarMessage(
snackbar.showMessage(
context.getString(R.string.turn_on_tunnel),
)
}
@@ -531,7 +497,7 @@ fun MainScreen(
uiState.vpnState.status == TunnelState.UP &&
tunnel.name == uiState.vpnState.tunnelConfig?.name
) {
appViewModel.showSnackbarMessage(
snackbar.showMessage(
context.getString(R.string.turn_off_tunnel),
)
} else {
@@ -1,12 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main
import com.zaneschepke.wireguardautotunnel.data.domain.Settings
import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnState
import com.zaneschepke.wireguardautotunnel.util.extensions.TunnelConfigs
data class MainUiState(
val settings: Settings = Settings(),
val tunnels: TunnelConfigs = emptyList(),
val vpnState: VpnState = VpnState(),
val loading: Boolean = true,
)
@@ -6,22 +6,22 @@ import android.net.Uri
import android.provider.OpenableColumns
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.wireguard.config.Config
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.data.domain.Settings
import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig
import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.module.IoDispatcher
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelService
import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.FileReadException
import com.zaneschepke.wireguardautotunnel.util.InvalidFileExtensionException
import com.zaneschepke.wireguardautotunnel.util.NumberUtils
import com.zaneschepke.wireguardautotunnel.util.WgTunnelExceptions
import com.zaneschepke.wireguardautotunnel.util.StringValue
import com.zaneschepke.wireguardautotunnel.util.extensions.toWgQuickString
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
@@ -34,26 +34,12 @@ class MainViewModel
@Inject
constructor(
private val appDataRepository: AppDataRepository,
private val serviceManager: ServiceManager,
val tunnelService: TunnelService,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) : ViewModel() {
val uiState =
combine(
appDataRepository.settings.getSettingsFlow(),
appDataRepository.tunnels.getTunnelConfigsFlow(),
tunnelService.vpnState,
) { settings, tunnels, vpnState ->
MainUiState(settings, tunnels, vpnState, false)
}
.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(Constants.SUBSCRIPTION_TIMEOUT),
MainUiState(),
)
private fun stopWatcherService(context: Context) {
serviceManager.stopWatcherService(context)
ServiceManager.stopWatcherService(context)
}
fun onDelete(tunnel: TunnelConfig, context: Context) {
@@ -87,32 +73,17 @@ constructor(
tunnelService.stopTunnel(tunnel)
}
private fun validateConfigString(config: String, configType: ConfigType) {
when (configType) {
ConfigType.AMNEZIA -> TunnelConfig.configFromAmQuick(config)
ConfigType.WIREGUARD -> TunnelConfig.configFromWgQuick(config)
}
}
private fun generateQrCodeDefaultName(config: String, configType: ConfigType): String {
private fun generateQrCodeDefaultName(config: String): String {
return try {
when (configType) {
ConfigType.AMNEZIA -> {
TunnelConfig.configFromAmQuick(config).peers[0].endpoint.get().host
}
ConfigType.WIREGUARD -> {
TunnelConfig.configFromWgQuick(config).peers[0].endpoint.get().host
}
}
TunnelConfig.configFromAmQuick(config).peers[0].endpoint.get().host
} catch (e: Exception) {
Timber.e(e)
NumberUtils.generateRandomTunnelName()
}
}
private fun generateQrCodeTunnelName(config: String, configType: ConfigType): String {
var defaultName = generateQrCodeDefaultName(config, configType)
private fun generateQrCodeTunnelName(config: String): String {
var defaultName = generateQrCodeDefaultName(config)
val lines = config.lines().toMutableList()
val linesIterator = lines.iterator()
while (linesIterator.hasNext()) {
@@ -125,37 +96,18 @@ constructor(
return defaultName
}
suspend fun onTunnelQrResult(result: String, configType: ConfigType): Result<Unit> {
return withContext(ioDispatcher) {
try {
validateConfigString(result, configType)
val tunnelName =
makeTunnelNameUnique(generateQrCodeTunnelName(result, configType))
val tunnelConfig =
when (configType) {
ConfigType.AMNEZIA -> {
TunnelConfig(
name = tunnelName,
amQuick = result,
wgQuick =
TunnelConfig.configFromAmQuick(
result,
).toWgQuickString(),
)
}
fun onTunnelQrResult(result: String) = viewModelScope.launch(ioDispatcher) {
kotlin.runCatching {
val amConfig = TunnelConfig.configFromAmQuick(result)
val amQuick = amConfig.toAwgQuickString(true)
val wgQuick = amConfig.toWgQuickString()
ConfigType.WIREGUARD ->
TunnelConfig(
name = tunnelName,
wgQuick = result,
)
}
addTunnel(tunnelConfig)
Result.success(Unit)
} catch (e: Exception) {
Timber.e(e)
Result.failure(WgTunnelExceptions.InvalidQrCode())
}
val tunnelName = makeTunnelNameUnique(generateQrCodeTunnelName(result))
val tunnelConfig = TunnelConfig(name = tunnelName, wgQuick = wgQuick, amQuick = amQuick)
saveTunnel(tunnelConfig)
}.onFailure {
Timber.e(it)
SnackbarController.showMessage(StringValue.StringResource(R.string.error_invalid_code))
}
}
@@ -172,141 +124,83 @@ constructor(
}
}
private fun saveTunnelConfigFromStream(stream: InputStream, fileName: String, type: ConfigType) {
var amQuick: String? = null
val wgQuick =
stream.use {
when (type) {
ConfigType.AMNEZIA -> {
val config = org.amnezia.awg.config.Config.parse(it)
amQuick = config.toAwgQuickString()
config.toWgQuickString()
}
ConfigType.WIREGUARD -> {
Config.parse(it).toWgQuickString(true)
}
}
}
viewModelScope.launch {
val tunnelName = makeTunnelNameUnique(getNameFromFileName(fileName))
addTunnel(
TunnelConfig(
name = tunnelName,
wgQuick = wgQuick,
amQuick = amQuick ?: TunnelConfig.AM_QUICK_DEFAULT,
),
)
}
private suspend fun saveTunnelConfigFromStream(stream: InputStream, fileName: String) {
val amConfig = stream.use { org.amnezia.awg.config.Config.parse(it) }
val tunnelName = makeTunnelNameUnique(getNameFromFileName(fileName))
saveTunnel(
TunnelConfig(
name = tunnelName,
wgQuick = amConfig.toWgQuickString(),
amQuick = amConfig.toAwgQuickString(true),
),
)
}
private fun getInputStreamFromUri(uri: Uri, context: Context): InputStream? {
return context.applicationContext.contentResolver.openInputStream(uri)
}
suspend fun onTunnelFileSelected(uri: Uri, configType: ConfigType, context: Context): Result<Unit> {
return withContext(ioDispatcher) {
try {
if (isValidUriContentScheme(uri)) {
val fileName = getFileName(context, uri)
return@withContext when (getFileExtensionFromFileName(fileName)) {
Constants.CONF_FILE_EXTENSION ->
saveTunnelFromConfUri(fileName, uri, configType, context)
Constants.ZIP_FILE_EXTENSION ->
saveTunnelsFromZipUri(
uri,
configType,
context,
)
else -> Result.failure(WgTunnelExceptions.InvalidFileExtension())
}
} else {
Result.failure(WgTunnelExceptions.InvalidFileExtension())
}
} catch (e: Exception) {
Timber.e(e)
Result.failure(WgTunnelExceptions.FileReadFailed())
fun onTunnelFileSelected(uri: Uri, context: Context) = viewModelScope.launch(ioDispatcher) {
kotlin.runCatching {
if (!isValidUriContentScheme(uri)) throw InvalidFileExtensionException
val fileName = getFileName(context, uri)
when (getFileExtensionFromFileName(fileName)) {
Constants.CONF_FILE_EXTENSION ->
saveTunnelFromConfUri(fileName, uri, context)
Constants.ZIP_FILE_EXTENSION ->
saveTunnelsFromZipUri(
uri,
context,
)
else -> throw InvalidFileExtensionException
}
}
}
private suspend fun saveTunnelsFromZipUri(uri: Uri, configType: ConfigType, context: Context): Result<Unit> {
return withContext(ioDispatcher) {
ZipInputStream(getInputStreamFromUri(uri, context)).use { zip ->
generateSequence { zip.nextEntry }
.filterNot {
it.isDirectory ||
getFileExtensionFromFileName(it.name) != Constants.CONF_FILE_EXTENSION
}
.forEach {
val name = getNameFromFileName(it.name)
withContext(viewModelScope.coroutineContext) {
try {
var amQuick: String? = null
val wgQuick =
when (configType) {
ConfigType.AMNEZIA -> {
val config =
org.amnezia.awg.config.Config.parse(
zip,
)
amQuick = config.toAwgQuickString()
config.toWgQuickString()
}
ConfigType.WIREGUARD -> {
Config.parse(zip).toWgQuickString(true)
}
}
addTunnel(
TunnelConfig(
name = makeTunnelNameUnique(name),
wgQuick = wgQuick,
amQuick = amQuick ?: TunnelConfig.AM_QUICK_DEFAULT,
),
)
Result.success(Unit)
} catch (e: Exception) {
Result.failure(WgTunnelExceptions.FileReadFailed())
}
}
}
Result.success(Unit)
}
}
}
private suspend fun saveTunnelFromConfUri(name: String, uri: Uri, configType: ConfigType, context: Context): Result<Unit> {
return withContext(ioDispatcher) {
val stream = getInputStreamFromUri(uri, context)
return@withContext if (stream != null) {
try {
saveTunnelConfigFromStream(stream, name, configType)
} catch (e: Exception) {
return@withContext Result.failure(WgTunnelExceptions.ConfigParseError())
}
Result.success(Unit)
}.onFailure {
Timber.e(it)
if (it is InvalidFileExtensionException) {
SnackbarController.showMessage(StringValue.StringResource(R.string.error_file_extension))
} else {
Result.failure(WgTunnelExceptions.FileReadFailed())
SnackbarController.showMessage(StringValue.StringResource(R.string.error_file_format))
}
}
}
private fun addTunnel(tunnelConfig: TunnelConfig) = viewModelScope.launch {
saveTunnel(tunnelConfig)
private suspend fun saveTunnelsFromZipUri(uri: Uri, context: Context) {
ZipInputStream(getInputStreamFromUri(uri, context)).use { zip ->
generateSequence { zip.nextEntry }
.filterNot {
it.isDirectory ||
getFileExtensionFromFileName(it.name) != Constants.CONF_FILE_EXTENSION
}
.forEach { entry ->
val name = getNameFromFileName(entry.name)
val amConf = org.amnezia.awg.config.Config.parse(zip.bufferedReader())
saveTunnel(
TunnelConfig(
name = makeTunnelNameUnique(name),
wgQuick = amConf.toWgQuickString(),
amQuick = amConf.toAwgQuickString(true),
),
)
}
}
}
private suspend fun saveTunnelFromConfUri(name: String, uri: Uri, context: Context) {
val stream = getInputStreamFromUri(uri, context) ?: throw FileReadException
saveTunnelConfigFromStream(stream, name)
}
fun pauseAutoTunneling() = viewModelScope.launch {
val settings = appDataRepository.settings.getSettings()
appDataRepository.settings.save(
uiState.value.settings.copy(isAutoTunnelPaused = true),
settings.copy(isAutoTunnelPaused = true),
)
}
fun resumeAutoTunneling() = viewModelScope.launch {
val settings = appDataRepository.settings.getSettings()
appDataRepository.settings.save(
uiState.value.settings.copy(isAutoTunnelPaused = false),
settings.copy(isAutoTunnelPaused = false),
)
}
@@ -315,32 +209,23 @@ constructor(
}
private fun getFileNameByCursor(context: Context, uri: Uri): String? {
context.contentResolver.query(uri, null, null, null, null)?.use {
return getDisplayNameByCursor(it)
return context.contentResolver.query(uri, null, null, null, null)?.use {
getDisplayNameByCursor(it)
}
return null
}
private fun getDisplayNameColumnIndex(cursor: Cursor): Int? {
val columnIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
return if (columnIndex != -1) {
return columnIndex
} else {
null
}
if (columnIndex == -1) return null
return columnIndex
}
private fun getDisplayNameByCursor(cursor: Cursor): String? {
return if (cursor.moveToFirst()) {
val index = getDisplayNameColumnIndex(cursor)
if (index != null) {
cursor.getString(index)
} else {
null
}
} else {
null
}
val move = cursor.moveToFirst()
if (!move) return null
val index = getDisplayNameColumnIndex(cursor)
if (index == null) return index
return cursor.getString(index)
}
private fun isValidUriContentScheme(uri: Uri): Boolean {
@@ -1,41 +1,20 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main.components
import androidx.annotation.DrawableRes
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.iamageo.multifablibrary.FabIcon
import com.iamageo.multifablibrary.FabOption
import com.iamageo.multifablibrary.MultiFabItem
import com.iamageo.multifablibrary.MultiFloatingActionButton
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.screens.main.ConfigType
@Composable
fun ScrollDismissMultiFab(
@DrawableRes res: Int,
focusRequester: FocusRequester,
isVisible: Boolean,
onFabItemClicked: (fabItem: MultiFabItem) -> Unit,
) {
// Nested scroll for control FAB
val context = LocalContext.current
fun ScrollDismissFab(icon: @Composable () -> Unit, focusRequester: FocusRequester, isVisible: Boolean, onClick: () -> Unit) {
AnimatedVisibility(
visible = isVisible,
enter = slideInVertically(initialOffsetY = { it * 2 }),
@@ -45,64 +24,14 @@ fun ScrollDismissMultiFab(
.focusRequester(focusRequester)
.focusGroup(),
) {
val fobColor = MaterialTheme.colorScheme.secondary
val fobIconColor = MaterialTheme.colorScheme.background
MultiFloatingActionButton(
fabIcon =
FabIcon(
iconRes = res,
iconResAfterRotate = R.drawable.close,
iconRotate = 180f,
),
fabOption =
FabOption(
iconTint = fobIconColor,
backgroundTint = fobColor,
),
itemsMultiFab =
listOf(
MultiFabItem(
label = {
Text(
stringResource(id = R.string.amnezia),
color = MaterialTheme.colorScheme.onBackground,
textAlign = TextAlign.Center,
modifier = Modifier.padding(end = 10.dp),
)
},
modifier =
Modifier
.size(40.dp),
icon = res,
value = ConfigType.AMNEZIA.name,
miniFabOption =
FabOption(
backgroundTint = fobColor,
fobIconColor,
),
),
MultiFabItem(
label = {
Text(
stringResource(id = R.string.wireguard),
color = MaterialTheme.colorScheme.onBackground,
textAlign = TextAlign.Center,
modifier = Modifier.padding(end = 10.dp),
)
},
icon = res,
value = ConfigType.WIREGUARD.name,
miniFabOption =
FabOption(
backgroundTint = fobColor,
fobIconColor,
),
),
),
onFabItemClicked = {
onFabItemClicked(it)
FloatingActionButton(
onClick = {
onClick()
},
shape = RoundedCornerShape(16.dp),
)
containerColor = MaterialTheme.colorScheme.primary,
) {
icon()
}
}
}
@@ -19,6 +19,7 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -32,7 +33,6 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -44,23 +44,24 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.AppViewModel
import com.zaneschepke.wireguardautotunnel.ui.Screen
import com.zaneschepke.wireguardautotunnel.ui.AppUiState
import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.common.ClickableIconButton
import com.zaneschepke.wireguardautotunnel.ui.common.config.ConfigurationToggle
import com.zaneschepke.wireguardautotunnel.ui.common.config.SubmitConfigurationTextBox
import com.zaneschepke.wireguardautotunnel.ui.common.text.SectionTitle
import com.zaneschepke.wireguardautotunnel.ui.screens.main.ConfigType
import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.ScrollDismissMultiFab
import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.ScrollDismissFab
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.WildcardSupportingLabel
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.extensions.getMessage
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
import com.zaneschepke.wireguardautotunnel.util.extensions.isValidIpv4orIpv6Address
import com.zaneschepke.wireguardautotunnel.util.extensions.openWebUrl
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@OptIn(ExperimentalLayoutApi::class)
@@ -68,16 +69,15 @@ import kotlinx.coroutines.launch
fun OptionsScreen(
optionsViewModel: OptionsViewModel = hiltViewModel(),
navController: NavController,
appViewModel: AppViewModel,
focusRequester: FocusRequester,
tunnelId: String,
appUiState: AppUiState,
tunnelId: Int,
) {
val scrollState = rememberScrollState()
val uiState by optionsViewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
val config = appUiState.tunnels.first { it.id == tunnelId }
val interactionSource = remember { MutableInteractionSource() }
val scope = rememberCoroutineScope()
val focusManager = LocalFocusManager.current
val screenPadding = 5.dp
val fillMaxWidth = .85f
@@ -85,31 +85,36 @@ fun OptionsScreen(
var currentText by remember { mutableStateOf("") }
LaunchedEffect(Unit) {
optionsViewModel.init(tunnelId)
if (context.isRunningOnTv()) {
delay(Constants.FOCUS_REQUEST_DELAY)
focusRequester.requestFocus()
kotlin.runCatching {
focusRequester.requestFocus()
}.onFailure {
delay(Constants.FOCUS_REQUEST_DELAY)
focusRequester.requestFocus()
}
}
}
fun saveTrustedSSID() {
if (currentText.isNotEmpty()) {
scope.launch {
optionsViewModel.onSaveRunSSID(currentText).onSuccess {
currentText = ""
}.onFailure {
appViewModel.showSnackbarMessage(it.getMessage(context))
}
}
optionsViewModel.onSaveRunSSID(currentText, config)
currentText = ""
}
}
Scaffold(
floatingActionButton = {
ScrollDismissMultiFab(R.drawable.edit, focusRequester, isVisible = true, onFabItemClicked = {
val configType = ConfigType.valueOf(it.value)
ScrollDismissFab(icon = {
val icon = Icons.Filled.Edit
Icon(
imageVector = icon,
contentDescription = icon.name,
tint = MaterialTheme.colorScheme.onPrimary,
)
}, focusRequester, isVisible = true, onClick = {
navController.navigate(
"${Screen.Config.route}/$tunnelId?configType=${configType.name}",
Route.Config(config.id),
)
})
},
@@ -160,12 +165,12 @@ fun OptionsScreen(
ConfigurationToggle(
stringResource(R.string.set_primary_tunnel),
enabled = true,
checked = uiState.isDefaultTunnel,
checked = config.isPrimaryTunnel,
modifier =
Modifier
.focusRequester(focusRequester),
padding = screenPadding,
onCheckChanged = { optionsViewModel.onTogglePrimaryTunnel() },
onCheckChanged = { optionsViewModel.onTogglePrimaryTunnel(config) },
)
}
}
@@ -201,9 +206,9 @@ fun OptionsScreen(
ConfigurationToggle(
stringResource(R.string.mobile_data_tunnel),
enabled = true,
checked = uiState.tunnel?.isMobileDataTunnel == true,
checked = config.isMobileDataTunnel,
padding = screenPadding,
onCheckChanged = { optionsViewModel.onToggleIsMobileDataTunnel() },
onCheckChanged = { optionsViewModel.onToggleIsMobileDataTunnel(config) },
)
Column {
FlowRow(
@@ -213,24 +218,24 @@ fun OptionsScreen(
.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(5.dp),
) {
uiState.tunnel?.tunnelNetworks?.forEach { ssid ->
config.tunnelNetworks.forEach { ssid ->
ClickableIconButton(
onClick = {
if (context.isRunningOnTv()) {
focusRequester.requestFocus()
optionsViewModel.onDeleteRunSSID(ssid)
optionsViewModel.onDeleteRunSSID(ssid, config)
}
},
onIconClick = {
if (context.isRunningOnTv()) focusRequester.requestFocus()
optionsViewModel.onDeleteRunSSID(ssid)
optionsViewModel.onDeleteRunSSID(ssid, config)
},
text = ssid,
icon = Icons.Filled.Close,
enabled = true,
)
}
if (uiState.tunnel == null || uiState.tunnel?.tunnelNetworks?.isEmpty() == true) {
if (config.tunnelNetworks.isEmpty()) {
Text(
stringResource(R.string.no_wifi_names_configured),
fontStyle = FontStyle.Italic,
@@ -243,6 +248,7 @@ fun OptionsScreen(
value = currentText,
onValueChange = { currentText = it },
label = { Text(stringResource(id = R.string.use_tunnel_on_wifi_name)) },
supportingText = { WildcardSupportingLabel { context.openWebUrl(it) } },
modifier =
Modifier
.padding(
@@ -262,26 +268,68 @@ fun OptionsScreen(
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,
)
},
contentDescription = stringResource(R.string.save_changes),
tint = MaterialTheme.colorScheme.primary,
)
}
}
},
)
ConfigurationToggle(
stringResource(R.string.restart_on_ping),
enabled = !appUiState.settings.isPingEnabled,
checked = config.isPingEnabled || appUiState.settings.isPingEnabled,
padding = screenPadding,
onCheckChanged = { optionsViewModel.onToggleRestartOnPing(config) },
)
if (config.isPingEnabled || appUiState.settings.isPingEnabled) {
SubmitConfigurationTextBox(
config.pingIp,
stringResource(R.string.set_custom_ping_ip),
stringResource(R.string.default_ping_ip),
focusRequester,
isErrorValue = { !it.isNullOrBlank() && !it.isValidIpv4orIpv6Address() },
onSubmit = {
optionsViewModel.saveTunnelChanges(
config.copy(pingIp = it.ifBlank { null }),
)
},
)
fun isSecondsError(seconds: String?): Boolean {
return seconds?.let { value -> if (value.isBlank()) false else value.toLong() >= Long.MAX_VALUE / 1000 } ?: false
}
SubmitConfigurationTextBox(
config.pingInterval?.let { (it / 1000).toString() },
stringResource(R.string.set_custom_ping_internal),
"(${stringResource(R.string.optional_default)} ${Constants.PING_INTERVAL / 1000})",
focusRequester,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Done,
),
isErrorValue = ::isSecondsError,
onSubmit = {
optionsViewModel.saveTunnelChanges(
config.copy(pingInterval = if (it.isBlank()) null else it.toLong() * 1000),
)
},
)
SubmitConfigurationTextBox(
config.pingCooldown?.let { (it / 1000).toString() },
stringResource(R.string.set_custom_ping_cooldown),
"(${stringResource(R.string.optional_default)} ${Constants.PING_COOLDOWN / 1000})",
focusRequester,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
),
isErrorValue = ::isSecondsError,
onSubmit = {
optionsViewModel.saveTunnelChanges(
config.copy(pingCooldown = if (it.isBlank()) null else it.toLong() * 1000),
)
},
)
}
}
}
}
@@ -1,9 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.options
import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig
data class OptionsUiState(
val id: String? = null,
val tunnel: TunnelConfig? = null,
val isDefaultTunnel: Boolean = false,
)
@@ -1,20 +1,14 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.options
import androidx.compose.ui.util.fastFirstOrNull
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig
import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.WgTunnelExceptions
import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController
import com.zaneschepke.wireguardautotunnel.util.StringValue
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
@HiltViewModel
@@ -23,86 +17,63 @@ class OptionsViewModel
constructor(
private val appDataRepository: AppDataRepository,
) : ViewModel() {
private val _optionState = MutableStateFlow(OptionsUiState())
val uiState =
combine(
appDataRepository.tunnels.getTunnelConfigsFlow(),
_optionState,
) { tunnels, optionState ->
if (optionState.id != null) {
val tunnelConfig = tunnels.fastFirstOrNull { it.id.toString() == optionState.id }
val isPrimaryTunnel = tunnelConfig?.isPrimaryTunnel == true
OptionsUiState(optionState.id, tunnelConfig, isPrimaryTunnel)
} else {
OptionsUiState()
}
}.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(Constants.SUBSCRIPTION_TIMEOUT),
OptionsUiState(),
fun onDeleteRunSSID(ssid: String, tunnelConfig: TunnelConfig) = viewModelScope.launch {
appDataRepository.tunnels.save(
tunnelConfig =
tunnelConfig.copy(
tunnelNetworks = (tunnelConfig.tunnelNetworks - ssid).toMutableList(),
),
)
fun init(tunnelId: String) {
_optionState.update {
it.copy(
id = tunnelId,
)
}
}
fun onDeleteRunSSID(ssid: String) = viewModelScope.launch {
uiState.value.tunnel?.let {
appDataRepository.tunnels.save(
tunnelConfig =
it.copy(
tunnelNetworks = (uiState.value.tunnel!!.tunnelNetworks - ssid).toMutableList(),
fun saveTunnelChanges(tunnelConfig: TunnelConfig) = viewModelScope.launch {
appDataRepository.tunnels.save(tunnelConfig)
}
fun onSaveRunSSID(ssid: String, tunnelConfig: TunnelConfig) = viewModelScope.launch {
val trimmed = ssid.trim()
val tunnelsWithName = appDataRepository.tunnels.findByTunnelNetworksName(trimmed)
if (!tunnelConfig.tunnelNetworks.contains(trimmed) &&
tunnelsWithName.isEmpty()
) {
saveTunnelChanges(
tunnelConfig.copy(
tunnelNetworks = (tunnelConfig.tunnelNetworks + ssid).toMutableList(),
),
)
} else {
SnackbarController.showMessage(
StringValue.StringResource(
R.string.error_ssid_exists,
),
)
}
}
private fun saveTunnel(tunnelConfig: TunnelConfig?) = viewModelScope.launch {
tunnelConfig?.let {
appDataRepository.tunnels.save(it)
}
}
suspend fun onSaveRunSSID(ssid: String): Result<Unit> {
val trimmed = ssid.trim()
val tunnelsWithName =
withContext(viewModelScope.coroutineContext) {
appDataRepository.tunnels.findByTunnelNetworksName(trimmed)
}
return if (uiState.value.tunnel?.tunnelNetworks?.contains(trimmed) != true &&
tunnelsWithName.isEmpty()
) {
uiState.value.tunnel?.tunnelNetworks?.add(trimmed)
saveTunnel(uiState.value.tunnel)
Result.success(Unit)
fun onToggleIsMobileDataTunnel(tunnelConfig: TunnelConfig) = viewModelScope.launch {
if (tunnelConfig.isMobileDataTunnel) {
appDataRepository.tunnels.updateMobileDataTunnel(null)
} else {
Result.failure(WgTunnelExceptions.SsidConflict())
appDataRepository.tunnels.updateMobileDataTunnel(tunnelConfig)
}
}
fun onToggleIsMobileDataTunnel() = viewModelScope.launch {
uiState.value.tunnel?.let {
if (it.isMobileDataTunnel) {
appDataRepository.tunnels.updateMobileDataTunnel(null)
} else {
appDataRepository.tunnels.updateMobileDataTunnel(it)
}
}
fun onTogglePrimaryTunnel(tunnelConfig: TunnelConfig) = viewModelScope.launch {
appDataRepository.tunnels.updatePrimaryTunnel(
when (tunnelConfig.isPrimaryTunnel) {
true -> null
false -> tunnelConfig
},
)
}
fun onTogglePrimaryTunnel() = viewModelScope.launch {
if (uiState.value.tunnel != null) {
appDataRepository.tunnels.updatePrimaryTunnel(
when (uiState.value.isDefaultTunnel) {
true -> null
false -> uiState.value.tunnel
},
)
}
fun onToggleRestartOnPing(tunnelConfig: TunnelConfig) = viewModelScope.launch {
appDataRepository.tunnels.save(
tunnelConfig.copy(
isPingEnabled = !tunnelConfig.isPingEnabled,
),
)
}
}
@@ -8,7 +8,8 @@ import androidx.compose.ui.res.stringResource
import androidx.navigation.NavController
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.AppViewModel
import com.zaneschepke.wireguardautotunnel.ui.Screen
import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController
import com.zaneschepke.wireguardautotunnel.util.StringValue
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
import xyz.teamgravity.pin_lock_compose.PinLock
@@ -16,9 +17,11 @@ import xyz.teamgravity.pin_lock_compose.PinLock
@Composable
fun PinLockScreen(navController: NavController, appViewModel: AppViewModel) {
val context = LocalContext.current
val snackbar = SnackbarController.current
PinLock(
title = { pinExists ->
Text(
color = MaterialTheme.colorScheme.onSecondary,
text =
if (pinExists) {
stringResource(id = R.string.enter_pin)
@@ -29,27 +32,27 @@ fun PinLockScreen(navController: NavController, appViewModel: AppViewModel) {
},
)
},
color = MaterialTheme.colorScheme.surface,
color = MaterialTheme.colorScheme.secondary,
onPinCorrect = {
// pin is correct, navigate or hide pin lock
if (context.isRunningOnTv()) {
navController.navigate(Screen.Main.route)
navController.navigate(Route.Main)
} else {
val isPopped = navController.popBackStack()
if (!isPopped) {
navController.navigate(Screen.Main.route)
navController.navigate(Route.Main)
}
}
},
onPinIncorrect = {
// pin is incorrect, show error
appViewModel.showSnackbarMessage(
snackbar.showMessage(
StringValue.StringResource(R.string.incorrect_pin).asString(context),
)
},
onPinCreated = {
// pin created for the first time, navigate or hide pin lock
appViewModel.showSnackbarMessage(
snackbar.showMessage(
StringValue.StringResource(R.string.pin_created).asString(context),
)
appViewModel.onPinLockEnabled()
@@ -5,6 +5,7 @@ import android.app.Activity
import android.content.Context.POWER_SERVICE
import android.content.Intent
import android.net.Uri
import android.net.VpnService
import android.os.Build
import android.os.PowerManager
import android.provider.Settings
@@ -12,7 +13,6 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.ActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity.RESULT_OK
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
@@ -46,7 +46,6 @@ import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -67,26 +66,25 @@ import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig
import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelState
import com.zaneschepke.wireguardautotunnel.ui.AppUiState
import com.zaneschepke.wireguardautotunnel.ui.AppViewModel
import com.zaneschepke.wireguardautotunnel.ui.Screen
import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.common.ClickableIconButton
import com.zaneschepke.wireguardautotunnel.ui.common.config.ConfigurationToggle
import com.zaneschepke.wireguardautotunnel.ui.common.prompt.AuthorizationPrompt
import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController
import com.zaneschepke.wireguardautotunnel.ui.common.text.SectionTitle
import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.VpnDeniedDialog
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.BackgroundLocationDialog
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.BackgroundLocationDisclosure
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.LocationServicesDialog
import com.zaneschepke.wireguardautotunnel.util.extensions.getMessage
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.WildcardSupportingLabel
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
import com.zaneschepke.wireguardautotunnel.util.extensions.launchAppSettings
import com.zaneschepke.wireguardautotunnel.util.extensions.openWebUrl
import com.zaneschepke.wireguardautotunnel.util.extensions.showToast
import kotlinx.coroutines.launch
import timber.log.Timber
import xyz.teamgravity.pin_lock_compose.PinManager
import java.io.File
@OptIn(
ExperimentalPermissionsApi::class,
@@ -96,16 +94,17 @@ import java.io.File
fun SettingsScreen(
viewModel: SettingsViewModel = hiltViewModel(),
appViewModel: AppViewModel,
uiState: AppUiState,
navController: NavController,
focusRequester: FocusRequester,
) {
val context = LocalContext.current
val focusManager = LocalFocusManager.current
val scope = rememberCoroutineScope()
val snackbar = SnackbarController.current
val scrollState = rememberScrollState()
val interactionSource = remember { MutableInteractionSource() }
val isRunningOnTv = context.isRunningOnTv()
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val kernelSupport by viewModel.kernelSupport.collectAsStateWithLifecycle()
val fineLocationState = rememberPermissionState(Manifest.permission.ACCESS_FINE_LOCATION)
@@ -113,7 +112,7 @@ fun SettingsScreen(
var isBackgroundLocationGranted by remember { mutableStateOf(true) }
var showVpnPermissionDialog by remember { mutableStateOf(false) }
var showLocationServicesAlertDialog by remember { mutableStateOf(false) }
var didExportFiles by remember { mutableStateOf(false) }
val didExportFiles by remember { mutableStateOf(false) }
var showAuthPrompt by remember { mutableStateOf(false) }
var showLocationDialog by remember { mutableStateOf(false) }
@@ -124,12 +123,9 @@ fun SettingsScreen(
viewModel.checkKernelSupport()
}
val notificationPermissionState =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
rememberPermissionState(Manifest.permission.POST_NOTIFICATIONS)
} else {
null
}
LaunchedEffect(uiState.settings.trustedNetworkSSIDs) {
currentText = ""
}
val startForResult =
rememberLauncherForActivityResult(
@@ -155,43 +151,6 @@ fun SettingsScreen(
},
)
fun exportAllConfigs() {
try {
val wgFiles =
uiState.tunnels.map { config ->
val file = File(context.cacheDir, "${config.name}-wg.conf")
file.outputStream().use {
it.write(config.wgQuick.toByteArray())
}
file
}
val amFiles =
uiState.tunnels.mapNotNull { config ->
if (config.amQuick != TunnelConfig.AM_QUICK_DEFAULT) {
val file = File(context.cacheDir, "${config.name}-am.conf")
file.outputStream().use {
it.write(config.amQuick.toByteArray())
}
file
} else {
null
}
}
scope.launch {
viewModel.onExportTunnels(wgFiles + amFiles).onFailure {
appViewModel.showSnackbarMessage(it.getMessage(context))
}.onSuccess {
didExportFiles = true
appViewModel.showSnackbarMessage(
context.getString(R.string.exported_configs_message),
)
}
}
} catch (e: Exception) {
Timber.e(e)
}
}
fun isBatteryOptimizationsDisabled(): Boolean {
val pm = context.getSystemService(POWER_SERVICE) as PowerManager
return pm.isIgnoringBatteryOptimizations(context.packageName)
@@ -200,22 +159,20 @@ fun SettingsScreen(
fun requestBatteryOptimizationsDisabled() {
val intent =
Intent().apply {
this.action = Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS
data = Uri.fromParts("package", context.packageName, null)
action = Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS
data = Uri.parse("package:${context.packageName}")
}
startForResult.launch(intent)
}
fun handleAutoTunnelToggle() {
if (!uiState.isBatteryOptimizeDisableShown || !isBatteryOptimizationsDisabled()) return requestBatteryOptimizationsDisabled()
if (notificationPermissionState != null && !notificationPermissionState.status.isGranted) {
appViewModel.showSnackbarMessage(
context.getString(R.string.notification_permission_required),
)
return notificationPermissionState.launchPermissionRequest()
if (!uiState.generalState.isBatteryOptimizationDisableShown &&
!isBatteryOptimizationsDisabled() && !context.isRunningOnTv()
) {
return requestBatteryOptimizationsDisabled()
}
val intent = if (!uiState.settings.isKernelEnabled) {
com.wireguard.android.backend.GoBackend.VpnService.prepare(context)
VpnService.prepare(context)
} else {
null
}
@@ -225,11 +182,7 @@ fun SettingsScreen(
fun saveTrustedSSID() {
if (currentText.isNotEmpty()) {
viewModel.onSaveTrustedSSID(currentText).onSuccess {
currentText = ""
}.onFailure {
appViewModel.showSnackbarMessage(it.getMessage(context))
}
viewModel.onSaveTrustedSSID(currentText)
}
}
@@ -243,13 +196,9 @@ fun SettingsScreen(
}
}
fun onRootDenied() = appViewModel.showSnackbarMessage(context.getString(R.string.error_root_denied))
fun onRootAccepted() = appViewModel.showSnackbarMessage(context.getString(R.string.root_accepted))
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
if (
context.isRunningOnTv() &&
isRunningOnTv &&
Build.VERSION.SDK_INT == Build.VERSION_CODES.Q
) {
checkFineLocationGranted()
@@ -269,7 +218,7 @@ fun SettingsScreen(
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) {
checkFineLocationGranted()
}
if(!uiState.isLocationDisclosureShown) {
if (!uiState.generalState.isLocationDisclosureShown) {
BackgroundLocationDisclosure(
onDismiss = { viewModel.setLocationDisclosureShown() },
onAttest = {
@@ -282,7 +231,6 @@ fun SettingsScreen(
return
}
BackgroundLocationDialog(
showLocationDialog,
onDismiss = { showLocationDialog = false },
@@ -301,404 +249,359 @@ fun SettingsScreen(
AuthorizationPrompt(
onSuccess = {
showAuthPrompt = false
exportAllConfigs()
viewModel.exportAllConfigs()
},
onError = { _ ->
showAuthPrompt = false
appViewModel.showSnackbarMessage(
snackbar.showMessage(
context.getString(R.string.error_authentication_failed),
)
},
onFailure = {
showAuthPrompt = false
appViewModel.showSnackbarMessage(
snackbar.showMessage(
context.getString(R.string.error_authorization_failed),
)
},
)
}
if (uiState.isLocationDisclosureShown) {
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 (context.isRunningOnTv()) {
Modifier
.height(IntrinsicSize.Min)
.fillMaxWidth(fillMaxWidth)
.padding(top = 10.dp)
} else {
Modifier
.fillMaxWidth(fillMaxWidth)
.padding(top = 20.dp)
}
)
.padding(bottom = 10.dp),
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top,
modifier =
Modifier
.fillMaxSize()
.verticalScroll(scrollState)
.clickable(
indication = null,
interactionSource = interactionSource,
) {
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 =
!(
uiState.settings.isAutoTunnelEnabled ||
uiState.settings.isAlwaysOnVpnEnabled
),
checked = uiState.settings.isTunnelOnWifiEnabled,
padding = screenPadding,
onCheckChanged = { viewModel.onToggleTunnelOnWifi() },
modifier =
if (uiState.settings.isAutoTunnelEnabled) {
Modifier
} else {
Modifier
.focusRequester(focusRequester)
},
)
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 (context.isRunningOnTv()) {
focusRequester.requestFocus()
viewModel.onDeleteTrustedSSID(ssid)
}
},
onIconClick = {
if (context.isRunningOnTv()) focusRequester.requestFocus()
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,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
)
focusManager.clearFocus()
},
) {
Surface(
tonalElevation = 2.dp,
shadowElevation = 2.dp,
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surface,
modifier =
(
if (isRunningOnTv) {
Modifier
.height(IntrinsicSize.Min)
.fillMaxWidth(fillMaxWidth)
.padding(top = 10.dp)
} else {
Modifier
.fillMaxWidth(fillMaxWidth)
.padding(top = 20.dp)
}
)
.padding(bottom = 10.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 = !uiState.settings.isAlwaysOnVpnEnabled,
checked = uiState.settings.isTunnelOnWifiEnabled,
padding = screenPadding,
onCheckChanged = { checked ->
if (!checked) viewModel.onToggleTunnelOnWifi()
if (checked) {
when (false) {
isBackgroundLocationGranted -> showLocationDialog = true
fineLocationState.status.isGranted -> showLocationDialog = true
viewModel.isLocationEnabled(context) ->
showLocationServicesAlertDialog = true
else -> {
viewModel.onToggleTunnelOnWifi()
}
}
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,
),
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,
)
}
},
modifier =
if (uiState.settings.isAutoTunnelEnabled) {
Modifier
} else {
Modifier
.focusRequester(focusRequester)
},
)
if (uiState.settings.isTunnelOnWifiEnabled) {
Column {
FlowRow(
modifier =
Modifier
.padding(screenPadding)
.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(5.dp),
) {
uiState.settings.trustedNetworkSSIDs.forEach { ssid ->
ClickableIconButton(
onClick = {
if (isRunningOnTv) {
focusRequester.requestFocus()
viewModel.onDeleteTrustedSSID(ssid)
}
},
onIconClick = {
if (isRunningOnTv) focusRequester.requestFocus()
viewModel.onDeleteTrustedSSID(ssid)
},
text = ssid,
icon = Icons.Filled.Close,
)
}
if (uiState.settings.trustedNetworkSSIDs.isEmpty()) {
Text(
stringResource(R.string.none),
fontStyle = FontStyle.Italic,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
OutlinedTextField(
value = currentText,
onValueChange = { currentText = it },
label = { Text(stringResource(R.string.add_trusted_ssid)) },
modifier =
Modifier
.padding(
start = screenPadding,
top = 5.dp,
bottom = 10.dp,
),
supportingText = { WildcardSupportingLabel { context.openWebUrl(it) } },
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.isAlwaysOnVpnEnabled,
checked = uiState.settings.isTunnelOnMobileDataEnabled,
padding = screenPadding,
onCheckChanged = { viewModel.onToggleTunnelOnMobileData() },
)
ConfigurationToggle(
stringResource(id = R.string.tunnel_on_ethernet),
enabled = !uiState.settings.isAlwaysOnVpnEnabled,
checked = uiState.settings.isTunnelOnEthernetEnabled,
padding = screenPadding,
onCheckChanged = { viewModel.onToggleTunnelOnEthernet() },
)
ConfigurationToggle(
stringResource(R.string.restart_on_ping),
checked = uiState.settings.isPingEnabled,
padding = screenPadding,
onCheckChanged = { viewModel.onToggleRestartOnPing() },
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
(
if (!uiState.settings.isAutoTunnelEnabled) {
Modifier
} else {
Modifier.focusRequester(
focusRequester,
)
}
)
.fillMaxSize()
.padding(top = 5.dp),
horizontalArrangement = Arrangement.Center,
) {
TextButton(
onClick = {
if (uiState.tunnels.isEmpty()) return@TextButton context.showToast(R.string.tunnel_required)
handleAutoTunnelToggle()
},
) {
val autoTunnelButtonText =
if (uiState.settings.isAutoTunnelEnabled) {
stringResource(R.string.disable_auto_tunnel)
} else {
stringResource(id = R.string.enable_auto_tunnel)
}
Text(autoTunnelButtonText)
}
}
}
}
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.backend),
padding = screenPadding,
)
ConfigurationToggle(
stringResource(R.string.use_kernel),
enabled =
!(
uiState.settings.isAutoTunnelEnabled ||
uiState.settings.isAlwaysOnVpnEnabled ||
(uiState.vpnState.status == TunnelState.UP) ||
!kernelSupport
),
checked = uiState.settings.isKernelEnabled,
padding = screenPadding,
onCheckChanged = {
viewModel.onToggleKernelMode()
},
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.fillMaxSize()
.padding(top = 5.dp),
horizontalArrangement = Arrangement.Center,
) {
TextButton(
onClick = {
viewModel.onRequestRoot()
},
) {
Text(stringResource(R.string.request_root))
}
}
}
}
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 = 10.dp),
) {
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Top,
modifier = Modifier.padding(15.dp),
) {
SectionTitle(
title = stringResource(id = R.string.other),
padding = screenPadding,
)
if (!isRunningOnTv) {
ConfigurationToggle(
stringResource(R.string.tunnel_mobile_data),
enabled =
!(
uiState.settings.isAutoTunnelEnabled ||
uiState.settings.isAlwaysOnVpnEnabled
stringResource(R.string.always_on_vpn_support),
enabled = !(
uiState.settings.isTunnelOnWifiEnabled ||
uiState.settings.isTunnelOnWifiEnabled ||
uiState.settings.isTunnelOnMobileDataEnabled
),
checked = uiState.settings.isTunnelOnMobileDataEnabled,
checked = uiState.settings.isAlwaysOnVpnEnabled,
padding = screenPadding,
onCheckChanged = { viewModel.onToggleTunnelOnMobileData() },
onCheckChanged = { viewModel.onToggleAlwaysOnVPN() },
)
ConfigurationToggle(
stringResource(id = R.string.tunnel_on_ethernet),
enabled =
!(
uiState.settings.isAutoTunnelEnabled ||
uiState.settings.isAlwaysOnVpnEnabled
),
checked = uiState.settings.isTunnelOnEthernetEnabled,
stringResource(R.string.enabled_app_shortcuts),
enabled = true,
checked = uiState.settings.isShortcutsEnabled,
padding = screenPadding,
onCheckChanged = { viewModel.onToggleTunnelOnEthernet() },
)
ConfigurationToggle(
stringResource(R.string.restart_on_ping),
enabled =
!(
uiState.settings.isAutoTunnelEnabled ||
uiState.settings.isAlwaysOnVpnEnabled
),
checked = uiState.settings.isPingEnabled,
padding = screenPadding,
onCheckChanged = { viewModel.onToggleRestartOnPing() },
onCheckChanged = { viewModel.onToggleShortcutsEnabled() },
)
}
ConfigurationToggle(
stringResource(R.string.restart_at_boot),
enabled = true,
checked = uiState.settings.isRestoreOnBootEnabled,
padding = screenPadding,
onCheckChanged = {
viewModel.onToggleRestartAtBoot()
},
)
ConfigurationToggle(
stringResource(R.string.enable_app_lock),
enabled = true,
checked = uiState.generalState.isPinLockEnabled,
padding = screenPadding,
onCheckChanged = {
if (uiState.generalState.isPinLockEnabled) {
appViewModel.onPinLockDisabled()
} else {
// TODO may want to show a dialog before proceeding in the future
PinManager.initialize(WireGuardAutoTunnel.instance)
navController.navigate(Route.Lock)
}
},
)
if (!isRunningOnTv) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
(
if (!uiState.settings.isAutoTunnelEnabled) {
Modifier
} else {
Modifier.focusRequester(
focusRequester,
)
}
)
Modifier
.fillMaxSize()
.padding(top = 5.dp),
horizontalArrangement = Arrangement.Center,
) {
TextButton(
enabled = !uiState.settings.isAlwaysOnVpnEnabled,
enabled = !didExportFiles,
onClick = {
if (uiState.tunnels.isEmpty()) return@TextButton context.showToast(R.string.tunnel_required)
if (
uiState.settings.isTunnelOnWifiEnabled &&
!uiState.settings.isAutoTunnelEnabled
) {
when (false) {
isBackgroundLocationGranted -> showLocationDialog = true
fineLocationState.status.isGranted -> showLocationDialog = true
viewModel.isLocationEnabled(context) ->
showLocationServicesAlertDialog = true
else -> {
handleAutoTunnelToggle()
}
}
} else {
handleAutoTunnelToggle()
}
showAuthPrompt = true
},
) {
val autoTunnelButtonText =
if (uiState.settings.isAutoTunnelEnabled) {
stringResource(R.string.disable_auto_tunnel)
} else {
stringResource(id = R.string.enable_auto_tunnel)
}
Text(autoTunnelButtonText)
}
}
}
}
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.backend),
padding = screenPadding,
)
ConfigurationToggle(
stringResource(R.string.use_amnezia),
enabled =
!(
uiState.settings.isAutoTunnelEnabled ||
uiState.settings.isAlwaysOnVpnEnabled ||
(uiState.vpnState.status == TunnelState.UP) || uiState.settings.isKernelEnabled
),
checked = uiState.settings.isAmneziaEnabled,
padding = screenPadding,
onCheckChanged = {
viewModel.onToggleAmnezia()
},
)
if (kernelSupport) {
ConfigurationToggle(
stringResource(R.string.use_kernel),
enabled =
!(
uiState.settings.isAutoTunnelEnabled ||
uiState.settings.isAlwaysOnVpnEnabled ||
(uiState.vpnState.status == TunnelState.UP)
),
checked = uiState.settings.isKernelEnabled,
padding = screenPadding,
onCheckChanged = {
scope.launch {
viewModel.onToggleKernelMode({ onRootAccepted() }, { onRootDenied() })
}
},
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.fillMaxSize()
.padding(top = 5.dp),
horizontalArrangement = Arrangement.Center,
) {
TextButton(
onClick = {
viewModel.requestRoot({ onRootAccepted() }, { onRootDenied() })
},
) {
Text(stringResource(R.string.request_root))
}
}
}
}
}
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,
)
if (!context.isRunningOnTv()) {
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() },
)
}
ConfigurationToggle(
stringResource(R.string.restart_at_boot),
enabled = true,
checked = uiState.settings.isRestoreOnBootEnabled,
padding = screenPadding,
onCheckChanged = {
viewModel.onToggleRestartAtBoot()
},
)
ConfigurationToggle(
stringResource(R.string.enable_app_lock),
enabled = true,
checked = uiState.isPinLockEnabled,
padding = screenPadding,
onCheckChanged = {
if (uiState.isPinLockEnabled) {
appViewModel.onPinLockDisabled()
} else {
// TODO may want to show a dialog before proceeding in the future
PinManager.initialize(WireGuardAutoTunnel.instance)
navController.navigate(Screen.Lock.route)
}
},
)
if (!context.isRunningOnTv()) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.fillMaxSize()
.padding(top = 5.dp),
horizontalArrangement = Arrangement.Center,
) {
TextButton(
enabled = !didExportFiles,
onClick = {
if (uiState.tunnels.isEmpty()) return@TextButton context.showToast(R.string.tunnel_required)
showAuthPrompt = true
},
) {
Text(stringResource(R.string.export_configs))
}
Text(stringResource(R.string.export_configs))
}
}
}
@@ -1,14 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.settings
import com.zaneschepke.wireguardautotunnel.data.domain.Settings
import com.zaneschepke.wireguardautotunnel.data.domain.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 isBatteryOptimizeDisableShown: Boolean = false,
val isPinLockEnabled: Boolean = false,
)
@@ -7,25 +7,23 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.wireguard.android.backend.WgQuickBackend
import com.wireguard.android.util.RootShell
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.data.domain.Settings
import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.module.IoDispatcher
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelService
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController
import com.zaneschepke.wireguardautotunnel.util.FileUtils
import com.zaneschepke.wireguardautotunnel.util.WgTunnelExceptions
import com.zaneschepke.wireguardautotunnel.util.StringValue
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.io.File
import javax.inject.Inject
import javax.inject.Provider
@@ -35,45 +33,29 @@ class SettingsViewModel
@Inject
constructor(
private val appDataRepository: AppDataRepository,
private val serviceManager: ServiceManager,
private val rootShell: Provider<RootShell>,
private val fileUtils: FileUtils,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
tunnelService: TunnelService,
) : ViewModel() {
private val _kernelSupport = MutableStateFlow(false)
val kernelSupport = _kernelSupport.asStateFlow()
private val settings = appDataRepository.settings.getSettingsFlow()
.stateIn(viewModelScope, SharingStarted.Eagerly, Settings())
val uiState =
combine(
appDataRepository.settings.getSettingsFlow(),
appDataRepository.tunnels.getTunnelConfigsFlow(),
tunnelService.vpnState,
appDataRepository.appState.generalStateFlow,
) { settings, tunnels, tunnelState, generalState ->
SettingsUiState(
settings,
tunnels,
tunnelState,
generalState.isLocationDisclosureShown,
generalState.isBatteryOptimizationDisableShown,
generalState.isPinLockEnabled,
)
}
.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(Constants.SUBSCRIPTION_TIMEOUT),
SettingsUiState(),
)
fun onSaveTrustedSSID(ssid: String): Result<Unit> {
fun onSaveTrustedSSID(ssid: String) = viewModelScope.launch {
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.failure(WgTunnelExceptions.SsidConflict())
with(settings.value) {
if (!trustedNetworkSSIDs.contains(trimmed)) {
this.trustedNetworkSSIDs.add(ssid)
appDataRepository.settings.save(this)
} else {
SnackbarController.showMessage(
StringValue.StringResource(
R.string.error_ssid_exists,
),
)
}
}
}
@@ -85,61 +67,70 @@ constructor(
appDataRepository.appState.setBatteryOptimizationDisableShown(true)
}
fun onToggleTunnelOnMobileData() {
saveSettings(
uiState.value.settings.copy(
isTunnelOnMobileDataEnabled = !uiState.value.settings.isTunnelOnMobileDataEnabled,
),
)
fun onToggleTunnelOnMobileData() = viewModelScope.launch {
with(settings.value) {
appDataRepository.settings.save(
copy(
isTunnelOnMobileDataEnabled = !this.isTunnelOnMobileDataEnabled,
),
)
}
}
fun onDeleteTrustedSSID(ssid: String) {
saveSettings(
uiState.value.settings.copy(
trustedNetworkSSIDs =
(uiState.value.settings.trustedNetworkSSIDs - ssid).toMutableList(),
),
)
fun onDeleteTrustedSSID(ssid: String) = viewModelScope.launch {
with(settings.value) {
appDataRepository.settings.save(
copy(
trustedNetworkSSIDs = (this.trustedNetworkSSIDs - ssid).toMutableList(),
),
)
}
}
suspend fun onExportTunnels(files: List<File>): Result<Unit> {
return fileUtils.saveFilesToZip(files)
private fun exportTunnels(files: List<File>) = viewModelScope.launch {
fileUtils.saveFilesToZip(files).onSuccess {
SnackbarController.showMessage(StringValue.StringResource(R.string.exported_configs_message))
}.onFailure {
SnackbarController.showMessage(StringValue.StringResource(R.string.export_configs_failed))
}
}
fun onToggleAutoTunnel(context: Context) = viewModelScope.launch {
val isAutoTunnelEnabled = uiState.value.settings.isAutoTunnelEnabled
var isAutoTunnelPaused = uiState.value.settings.isAutoTunnelPaused
if (isAutoTunnelEnabled) {
serviceManager.stopWatcherService(context)
} else {
serviceManager.startWatcherService(context)
isAutoTunnelPaused = false
with(settings.value) {
var isAutoTunnelPaused = this.isAutoTunnelPaused
if (isAutoTunnelEnabled) {
ServiceManager.stopWatcherService(context)
} else {
ServiceManager.startWatcherService(context)
isAutoTunnelPaused = false
}
appDataRepository.settings.save(
copy(
isAutoTunnelEnabled = !isAutoTunnelEnabled,
isAutoTunnelPaused = isAutoTunnelPaused,
),
)
}
saveSettings(
uiState.value.settings.copy(
isAutoTunnelEnabled = !isAutoTunnelEnabled,
isAutoTunnelPaused = isAutoTunnelPaused,
),
)
}
fun onToggleAlwaysOnVPN() = viewModelScope.launch {
saveSettings(
uiState.value.settings.copy(
isAlwaysOnVpnEnabled = !uiState.value.settings.isAlwaysOnVpnEnabled,
),
)
with(settings.value) {
appDataRepository.settings.save(
copy(
isAlwaysOnVpnEnabled = !isAlwaysOnVpnEnabled,
),
)
}
}
private fun saveSettings(settings: Settings) = viewModelScope.launch { appDataRepository.settings.save(settings) }
fun onToggleTunnelOnEthernet() {
saveSettings(
uiState.value.settings.copy(
isTunnelOnEthernetEnabled = !uiState.value.settings.isTunnelOnEthernetEnabled,
),
)
fun onToggleTunnelOnEthernet() = viewModelScope.launch {
with(settings.value) {
appDataRepository.settings.save(
copy(
isTunnelOnEthernetEnabled = !isTunnelOnEthernetEnabled,
),
)
}
}
fun isLocationEnabled(context: Context): Boolean {
@@ -150,73 +141,74 @@ constructor(
return LocationManagerCompat.isLocationEnabled(locationManager)
}
fun onToggleShortcutsEnabled() {
saveSettings(
uiState.value.settings.copy(
isShortcutsEnabled = !uiState.value.settings.isShortcutsEnabled,
),
)
fun onToggleShortcutsEnabled() = viewModelScope.launch {
with(settings.value) {
appDataRepository.settings.save(
this.copy(
isShortcutsEnabled = !isShortcutsEnabled,
),
)
}
}
private fun saveKernelMode(enabled: Boolean) {
saveSettings(
uiState.value.settings.copy(
isKernelEnabled = enabled,
),
)
private fun saveKernelMode(enabled: Boolean) = viewModelScope.launch {
with(settings.value) {
appDataRepository.settings.save(
this.copy(
isKernelEnabled = enabled,
),
)
}
}
fun onToggleTunnelOnWifi() {
saveSettings(
uiState.value.settings.copy(
isTunnelOnWifiEnabled = !uiState.value.settings.isTunnelOnWifiEnabled,
),
)
fun onToggleTunnelOnWifi() = viewModelScope.launch {
with(settings.value) {
appDataRepository.settings.save(
copy(
isTunnelOnWifiEnabled = !isTunnelOnWifiEnabled,
),
)
}
}
fun onToggleAmnezia() = viewModelScope.launch {
if (uiState.value.settings.isKernelEnabled) {
saveKernelMode(false)
with(settings.value) {
if (isKernelEnabled) {
saveKernelMode(false)
}
appDataRepository.settings.save(
copy(
isAmneziaEnabled = !isAmneziaEnabled,
),
)
}
saveAmneziaMode(!uiState.value.settings.isAmneziaEnabled)
}
private fun saveAmneziaMode(on: Boolean) {
saveSettings(
uiState.value.settings.copy(
isAmneziaEnabled = on,
),
)
}
fun onToggleKernelMode(onSuccess: () -> Unit, onFailure: () -> Unit) = viewModelScope.launch {
if (!uiState.value.settings.isKernelEnabled) {
requestRoot(
{
onSuccess()
saveSettings(
uiState.value.settings.copy(
fun onToggleKernelMode() = viewModelScope.launch {
with(settings.value) {
if (!isKernelEnabled) {
requestRoot().onSuccess {
appDataRepository.settings.save(
copy(
isKernelEnabled = true,
isAmneziaEnabled = false,
),
)
},
{
onFailure()
saveKernelMode(enabled = false)
},
)
} else {
saveKernelMode(enabled = false)
}
} else {
saveKernelMode(enabled = false)
}
}
}
fun onToggleRestartOnPing() = viewModelScope.launch {
saveSettings(
uiState.value.settings.copy(
isPingEnabled = !uiState.value.settings.isPingEnabled,
),
)
with(settings.value) {
appDataRepository.settings.save(
copy(
isPingEnabled = !isPingEnabled,
),
)
}
}
fun checkKernelSupport() = viewModelScope.launch {
@@ -230,22 +222,36 @@ constructor(
}
fun onToggleRestartAtBoot() = viewModelScope.launch {
saveSettings(
uiState.value.settings.copy(
isRestoreOnBootEnabled = !uiState.value.settings.isRestoreOnBootEnabled,
),
)
with(settings.value) {
appDataRepository.settings.save(
copy(
isRestoreOnBootEnabled = !isRestoreOnBootEnabled,
),
)
}
}
fun requestRoot(onSuccess: () -> Unit, onFailure: () -> Unit) = viewModelScope.launch(ioDispatcher) {
private suspend fun requestRoot(): Result<Unit> {
return withContext(ioDispatcher) {
kotlin.runCatching {
rootShell.get().start()
SnackbarController.showMessage(StringValue.StringResource(R.string.root_accepted))
}.onFailure {
SnackbarController.showMessage(StringValue.StringResource(R.string.error_root_denied))
}
}
}
fun onRequestRoot() = viewModelScope.launch {
requestRoot()
}
fun exportAllConfigs() = viewModelScope.launch {
kotlin.runCatching {
rootShell.get().start()
Timber.i("Root shell accepted!")
onSuccess()
}.onFailure {
onFailure()
}.onSuccess {
onSuccess()
val tunnels = appDataRepository.tunnels.getAll()
val wgFiles = fileUtils.createWgFiles(tunnels)
val amFiles = fileUtils.createAmFiles(tunnels)
exportTunnels(wgFiles + amFiles)
}
}
}
@@ -28,12 +28,7 @@ import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
@Composable
fun BackgroundLocationDisclosure(
onDismiss: () -> Unit,
onAttest: () -> Unit,
scrollState: ScrollState,
focusRequester: FocusRequester,
) {
fun BackgroundLocationDisclosure(onDismiss: () -> Unit, onAttest: () -> Unit, scrollState: ScrollState, focusRequester: FocusRequester) {
val context = LocalContext.current
Column(
horizontalAlignment = Alignment.CenterHorizontally,
@@ -0,0 +1,44 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.settings.components
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.withStyle
import com.zaneschepke.wireguardautotunnel.R
@Composable
fun WildcardSupportingLabel(onClick: (url: String) -> Unit) {
// TODO update link when docs are fully updated
val gettingStarted =
buildAnnotatedString {
pushStringAnnotation(
tag = "details",
annotation = stringResource(id = R.string.docs_wildcards),
)
withStyle(
style = SpanStyle(color = MaterialTheme.colorScheme.primary),
) {
append(stringResource(id = R.string.wildcard_supported))
}
pop()
}
ClickableText(
text = gettingStarted,
style =
MaterialTheme.typography.bodySmall.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Start,
fontStyle = FontStyle.Italic,
),
) {
gettingStarted.getStringAnnotations(tag = "details", it, it)
.firstOrNull()?.let { annotation ->
onClick(annotation.item)
}
}
}
@@ -27,7 +27,6 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
@@ -43,23 +42,20 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import com.zaneschepke.wireguardautotunnel.BuildConfig
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.Screen
import com.zaneschepke.wireguardautotunnel.ui.AppUiState
import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
import com.zaneschepke.wireguardautotunnel.util.extensions.launchSupportEmail
import com.zaneschepke.wireguardautotunnel.util.extensions.openWebUrl
@Composable
fun SupportScreen(viewModel: SupportViewModel = hiltViewModel(), navController: NavController, focusRequester: FocusRequester) {
fun SupportScreen(navController: NavController, focusRequester: FocusRequester, appUiState: AppUiState) {
val context = LocalContext.current
val fillMaxWidth = .85f
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top,
@@ -248,7 +244,7 @@ fun SupportScreen(viewModel: SupportViewModel = hiltViewModel(), navController:
color = MaterialTheme.colorScheme.onBackground,
)
TextButton(
onClick = { navController.navigate(Screen.Support.Logs.route) },
onClick = { navController.navigate(Route.Logs) },
modifier = Modifier.padding(vertical = 5.dp),
) {
Row(
@@ -301,7 +297,7 @@ fun SupportScreen(viewModel: SupportViewModel = hiltViewModel(), navController:
buildAnnotatedString {
append(stringResource(R.string.mode))
append(": ")
when (uiState.settings.isKernelEnabled) {
when (appUiState.settings.isKernelEnabled) {
true -> append(stringResource(id = R.string.kernel))
false -> append(stringResource(id = R.string.userspace))
}
@@ -1,5 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.support
import com.zaneschepke.wireguardautotunnel.data.domain.Settings
data class SupportUiState(val settings: Settings = Settings())
@@ -1,27 +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(settingsRepository: SettingsRepository) :
ViewModel() {
val uiState =
settingsRepository
.getSettingsFlow()
.map { SupportUiState(it) }
.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(Constants.SUBSCRIPTION_TIMEOUT),
SupportUiState(),
)
}
@@ -20,9 +20,10 @@ private val DarkColorScheme =
darkColorScheme(
// primary = Purple80,
primary = virdigris,
secondary = virdigris,
secondary = PurpleGrey40,
// secondary = PurpleGrey80,
tertiary = virdigris,
tertiary = Pink40,
surfaceTint = Pink80,
// tertiary = Pink80
)
@@ -31,6 +32,7 @@ private val LightColorScheme =
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40,
surfaceTint = Pink80,
/* Other default colors to override
background = Color(0xFFFFFBFE),
surface = Color(0xFFFFFBFE),
@@ -4,10 +4,10 @@ object Constants {
const val BASE_LOG_FILE_NAME = "wg_tunnel_logs"
const val LOG_BUFFER_SIZE = 3_000L
const val MANUAL_TUNNEL_CONFIG_ID = "0"
const val MANUAL_TUNNEL_CONFIG_ID = 0
const val BATTERY_SAVER_WATCHER_WAKE_LOCK_TIMEOUT = 10 * 60 * 1_000L // 10 minutes
const val VPN_STATISTIC_CHECK_INTERVAL = 1_000L
const val WATCHER_COLLECTION_DELAY = 1_000L
const val WATCHER_COLLECTION_DELAY = 3_000L
const val CONF_FILE_EXTENSION = ".conf"
const val ZIP_FILE_EXTENSION = ".zip"
const val URI_CONTENT_SCHEME = "content"
@@ -16,7 +16,6 @@ object Constants {
const val ZIP_FILE_MIME_TYPE = "application/zip"
const val GOOGLE_TV_EXPLORER_STUB = "com.google.android.tv.frameworkpackagestubs"
const val ANDROID_TV_EXPLORER_STUB = "com.android.tv.frameworkpackagestubs"
const val ALWAYS_ON_VPN_ACTION = "android.net.VpnService"
const val VPN_SETTINGS_PACKAGE = "android.net.vpn.SETTINGS"
const val EMAIL_MIME_TYPE = "plain/text"
const val SYSTEM_EXEMPT_SERVICE_TYPE_ID = 1024
@@ -24,14 +23,15 @@ object Constants {
const val SUBSCRIPTION_TIMEOUT = 5_000L
const val FOCUS_REQUEST_DELAY = 500L
const val TRANSITION_ANIMATION_TIME = 200
const val DEFAULT_PING_IP = "1.1.1.1"
const val PING_TIMEOUT = 5_000L
const val VPN_RESTART_DELAY = 1_000L
const val PING_INTERVAL = 60_000L
const val PING_COOLDOWN = PING_INTERVAL * 60 // one hour
const val UNREADABLE_SSID = "<unknown ssid>"
val amneziaProperties = listOf("Jc", "Jmin", "Jmax", "S1", "S2", "H1", "H2", "H3", "H4")
val amProperties = listOf("Jc", "Jmin", "Jmax", "S1", "S2", "H1", "H2", "H3", "H4")
const val QR_CODE_NAME_PROPERTY = "# Name ="
}
@@ -0,0 +1,13 @@
package com.zaneschepke.wireguardautotunnel.util
object InvalidFileExtensionException : Exception() {
private fun readResolve(): Any = InvalidFileExtensionException
}
object FileReadException : Exception() {
private fun readResolve(): Any = FileReadException
}
object ConfigExportException : Exception() {
private fun readResolve(): Any = ConfigExportException
}
@@ -6,6 +6,8 @@ import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import android.provider.MediaStore.MediaColumns
import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig
import com.zaneschepke.wireguardautotunnel.util.extensions.TunnelConfigs
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import timber.log.Timber
@@ -39,6 +41,26 @@ class FileUtils(
}
}
fun createWgFiles(tunnels: TunnelConfigs): List<File> {
return tunnels.map { config ->
val file = File(context.cacheDir, "${config.name}-wg.conf")
file.outputStream().use {
it.write(config.wgQuick.toByteArray())
}
file
}
}
fun createAmFiles(tunnels: TunnelConfigs): List<File> {
return tunnels.filter { it.amQuick != TunnelConfig.AM_QUICK_DEFAULT }.map { config ->
val file = File(context.cacheDir, "${config.name}-am.conf")
file.outputStream().use {
it.write(config.amQuick.toByteArray())
}
file
}
}
suspend fun saveByteArrayToDownloads(content: ByteArray, fileName: String): Result<Unit> {
return withContext(ioDispatcher) {
try {
@@ -96,7 +118,7 @@ class FileUtils(
}
} catch (e: Exception) {
Timber.e(e)
Result.failure(WgTunnelExceptions.ConfigExportFailed())
Result.failure(ConfigExportException)
}
}
}
@@ -1,159 +0,0 @@
package com.zaneschepke.wireguardautotunnel.util
import android.content.Context
import com.zaneschepke.wireguardautotunnel.R
sealed class WgTunnelExceptions : Exception() {
abstract fun getMessage(context: Context): String
data class General(private val userMessage: StringValue) : WgTunnelExceptions() {
override fun getMessage(context: Context): String {
return userMessage.asString(context)
}
}
data class SsidConflict(
private val userMessage: StringValue =
StringValue.StringResource(
R.string.error_ssid_exists,
),
) :
WgTunnelExceptions() {
override fun getMessage(context: Context): String {
return userMessage.asString(context)
}
}
data class ConfigExportFailed(
private val userMessage: StringValue =
StringValue.StringResource(
R.string.export_configs_failed,
),
) : WgTunnelExceptions() {
override fun getMessage(context: Context): String {
return userMessage.asString(context)
}
}
data class ConfigParseError(private val appendMessage: StringValue = StringValue.Empty) :
WgTunnelExceptions() {
override fun getMessage(context: Context): String {
return StringValue.StringResource(R.string.config_parse_error).asString(context) + (
if (appendMessage != StringValue.Empty) ": ${appendMessage.asString(context)}" else ""
)
}
}
data class RootDenied(
private val userMessage: StringValue =
StringValue.StringResource(
R.string.error_root_denied,
),
) :
WgTunnelExceptions() {
override fun getMessage(context: Context): String {
return userMessage.asString(context)
}
}
data class InvalidQrCode(
private val userMessage: StringValue =
StringValue.StringResource(
R.string.error_invalid_code,
),
) :
WgTunnelExceptions() {
override fun getMessage(context: Context): String {
return userMessage.asString(context)
}
}
data class InvalidFileExtension(
private val userMessage: StringValue =
StringValue.StringResource(
R.string.error_file_extension,
),
) : WgTunnelExceptions() {
override fun getMessage(context: Context): String {
return userMessage.asString(context)
}
}
data class FileReadFailed(
private val userMessage: StringValue =
StringValue.StringResource(
R.string.error_file_format,
),
) :
WgTunnelExceptions() {
override fun getMessage(context: Context): String {
return userMessage.asString(context)
}
}
data class AuthenticationFailed(
private val userMessage: StringValue =
StringValue.StringResource(
R.string.error_authentication_failed,
),
) : WgTunnelExceptions() {
override fun getMessage(context: Context): String {
return userMessage.asString(context)
}
}
data class AuthorizationFailed(
private val userMessage: StringValue =
StringValue.StringResource(
R.string.error_authorization_failed,
),
) : WgTunnelExceptions() {
override fun getMessage(context: Context): String {
return userMessage.asString(context)
}
}
data class BackgroundLocationRequired(
private val userMessage: StringValue =
StringValue.StringResource(
R.string.background_location_required,
),
) : WgTunnelExceptions() {
override fun getMessage(context: Context): String {
return userMessage.asString(context)
}
}
data class LocationServicesRequired(
private val userMessage: StringValue =
StringValue.StringResource(
R.string.location_services_required,
),
) : WgTunnelExceptions() {
override fun getMessage(context: Context): String {
return userMessage.asString(context)
}
}
data class PreciseLocationRequired(
private val userMessage: StringValue =
StringValue.StringResource(
R.string.precise_location_required,
),
) : WgTunnelExceptions() {
override fun getMessage(context: Context): String {
return userMessage.asString(context)
}
}
data class FileExplorerRequired(
private val userMessage: StringValue =
StringValue.StringResource(
R.string.error_no_file_explorer,
),
) : WgTunnelExceptions() {
override fun getMessage(context: Context): String {
return userMessage.asString(context)
}
}
}
@@ -3,6 +3,7 @@ package com.zaneschepke.wireguardautotunnel.util.extensions
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.ObsoleteCoroutinesApi
import kotlinx.coroutines.channels.ClosedReceiveChannelException
import kotlinx.coroutines.channels.ReceiveChannel
@@ -76,3 +77,16 @@ fun <T> CoroutineScope.asChannel(flow: Flow<T>): ReceiveChannel<T> = produce {
channel.send(value)
}
}
fun Job?.onNotRunning(callback: () -> Unit) {
if (this == null || this.isCompleted || this.isCompleted) {
callback.invoke()
}
}
fun Job.cancelWithMessage(message: String) {
kotlin.runCatching {
this.cancel()
Timber.i(message)
}
}
@@ -1,11 +1,7 @@
package com.zaneschepke.wireguardautotunnel.util.extensions
import android.content.Context
import android.content.pm.PackageInfo
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig
import com.zaneschepke.wireguardautotunnel.util.StringValue
import com.zaneschepke.wireguardautotunnel.util.WgTunnelExceptions
import java.math.BigDecimal
import java.text.DecimalFormat
@@ -21,10 +17,3 @@ fun <T> List<T>.removeAt(index: Int): List<T> = toMutableList().apply { this.rem
typealias TunnelConfigs = List<TunnelConfig>
typealias Packages = List<PackageInfo>
fun Throwable.getMessage(context: Context): String {
return when (this) {
is WgTunnelExceptions -> this.getMessage(context)
else -> this.message ?: StringValue.StringResource(R.string.unknown_error).asString(context)
}
}
@@ -0,0 +1,30 @@
package com.zaneschepke.wireguardautotunnel.util.extensions
import timber.log.Timber
import java.util.regex.Pattern
fun String.isValidIpv4orIpv6Address(): Boolean {
val ipv4Pattern = Pattern.compile(
"^(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\$",
)
val ipv6Pattern = Pattern.compile(
"^([0-9A-Fa-f]{1,4}:){7}[0-9A-Fa-f]{1,4}\$",
)
return ipv4Pattern.matcher(this).matches() || ipv6Pattern.matcher(this).matches()
}
fun List<String>.isMatchingToWildcardList(value: String): Boolean {
val excludeValues = this.filter { it.startsWith("!") }.map { it.removePrefix("!").toRegexWithWildcards() }
Timber.d("Excluded values: $excludeValues")
val includedValues = this.filter { !it.startsWith("!") }.map { it.toRegexWithWildcards() }
Timber.d("Included values: $includedValues")
val matches = includedValues.filter { it.matches(value) }
val excludedMatches = excludeValues.filter { it.matches(value) }
Timber.d("Excluded matches: $excludedMatches")
Timber.d("Matches: $matches")
return matches.isNotEmpty() && excludedMatches.isEmpty()
}
fun String.toRegexWithWildcards(): Regex {
return this.replace("*", ".*").replace("?", ".").toRegex()
}
@@ -1,10 +1,13 @@
package com.zaneschepke.wireguardautotunnel.util.extensions
import com.wireguard.config.Peer
import com.zaneschepke.wireguardautotunnel.service.tunnel.HandshakeStatus
import com.zaneschepke.wireguardautotunnel.service.tunnel.statistics.TunnelStatistics
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.NumberUtils
import org.amnezia.awg.config.Config
import timber.log.Timber
import java.net.InetAddress
fun TunnelStatistics.mapPeerStats(): Map<org.amnezia.awg.crypto.Key, TunnelStatistics.PeerStats?> {
return this.getPeers().associateWith { key -> (this.peerStats(key)) }
@@ -28,13 +31,30 @@ fun TunnelStatistics.PeerStats.handshakeStatus(): HandshakeStatus {
}
}
fun Peer.isReachable(): Boolean {
val host =
if (this.endpoint.isPresent &&
this.endpoint.get().resolved.isPresent
) {
this.endpoint.get().resolved.get().host
} else {
Constants.DEFAULT_PING_IP
}
Timber.i("Checking reachability of peer: $host")
val reachable =
InetAddress.getByName(host)
.isReachable(Constants.PING_TIMEOUT.toInt())
Timber.i("Result: reachable - $reachable")
return reachable
}
fun Config.toWgQuickString(): String {
val amQuick = toAwgQuickString()
val amQuick = toAwgQuickString(true)
val lines = amQuick.lines().toMutableList()
val linesIterator = lines.iterator()
while (linesIterator.hasNext()) {
val next = linesIterator.next()
Constants.amneziaProperties.forEach {
Constants.amProperties.forEach {
if (next.startsWith(it, ignoreCase = true)) {
linesIterator.remove()
}
+11 -1
View File
@@ -6,8 +6,10 @@
<string name="watcher_channel_name">Watcher Notification Channel</string>
<string name="github_url" translatable="false">https://github.com/zaneschepke/wgtunnel/issues</string>
<string name="docs_url" translatable="false">https://zaneschepke.com/wgtunnel-docs/overview.html</string>
<string name="docs_features" translatable="false">https://zaneschepke.com/wgtunnel-docs/features.html</string>
<string name="privacy_policy_url" translatable="false">https://zaneschepke.com/wgtunnel-docs/privacypolicy.html</string>
<string name="error_file_extension">File is not a .conf or .zip</string>
<string name="docs_wildcards" translatable="false" >https://zaneschepke.com/wgtunnel-docs/features.html#wildcard-wi-fi-name-support</string>
<string name="error_file_extension">File is not a .conf or .zip</string>
<string name="turn_off_tunnel">Action requires tunnel off</string>
<string name="no_tunnels">No tunnels added yet!</string>
<string name="discord_url" translatable="false">https://discord.gg/rbRRNh6H7V</string>
@@ -186,4 +188,12 @@
<string name="app_settings">app settings</string>
<string name="background_location_message2">to make sure these permissions are enabled.</string>
<string name="root_accepted">Root shell accepted</string>
<string name="set_custom_ping_ip">Set custom ping ip</string>
<string name="default_ping_ip">(optional, defaults to peers)</string>
<string name="set_custom_ping_internal">Ping interval (sec)</string>
<string name="optional_default">"optional, default: "</string>
<string name="set_custom_ping_cooldown">Ping restart cooldown (sec)</string>
<string name="wildcard_supported">Learn about supported wildcards.</string>
<string name="details">details</string>
<string name="show_amnezia_properties">Show Amnezia properties</string>
</resources>
+2 -6
View File
@@ -1,7 +1,7 @@
object Constants {
const val VERSION_NAME = "3.5.0"
const val VERSION_NAME = "3.5.2"
const val JVM_TARGET = "17"
const val VERSION_CODE = 35001
const val VERSION_CODE = 35200
const val TARGET_SDK = 34
const val MIN_SDK = 26
const val APP_ID = "com.zaneschepke.wireguardautotunnel"
@@ -15,9 +15,5 @@ object Constants {
const val RELEASE = "release"
const val NIGHTLY = "nightly"
const val PRERELEASE = "prerelease"
const val DEBUG = "debug"
const val TYPE = "type"
const val NIGHTLY_CODE = 42
const val PRERELEASE_CODE = 53
}
@@ -0,0 +1,5 @@
What's new:
- Fixes for tunnels not launching from background
- Add support for restart services after update
- UI animation speed improvements
- Other optimizations
@@ -0,0 +1,5 @@
What's new:
- Added wildcard support for wifi names
- Fix slowness on mobile data
- Various bug fixes and improvements
- UI optimizations
+13 -15
View File
@@ -1,32 +1,31 @@
[versions]
accompanist = "0.34.0"
activityCompose = "1.9.1"
amneziawgAndroid = "1.2.1"
accompanist = "0.36.0"
activityCompose = "1.9.2"
amneziawgAndroid = "1.2.2"
androidx-junit = "1.2.1"
appcompat = "1.7.0"
biometricKtx = "1.2.0-alpha05"
coreGoogleShortcuts = "1.1.0"
coreKtx = "1.13.1"
datastorePreferences = "1.1.1"
desugar_jdk_libs = "2.0.4"
desugar_jdk_libs = "2.1.2"
espressoCore = "3.6.1"
hiltAndroid = "2.52"
hiltNavigationCompose = "1.2.0"
junit = "4.13.2"
kotlinx-serialization-json = "1.7.1"
lifecycle-runtime-compose = "2.8.4"
material3 = "1.2.1"
multifabVersion = "1.1.0"
navigationCompose = "2.7.7"
kotlinx-serialization-json = "1.7.3"
lifecycle-runtime-compose = "2.8.6"
material3 = "1.3.0"
navigationCompose = "2.8.1"
pinLockCompose = "1.0.3"
roomVersion = "2.6.1"
timber = "5.0.1"
tunnel = "1.2.1"
androidGradlePlugin = "8.7.0-alpha07"
kotlin = "2.0.10"
ksp = "2.0.10-1.0.24"
composeBom = "2024.06.00"
compose = "1.6.8"
androidGradlePlugin = "8.6.1"
kotlin = "2.0.20"
ksp = "2.0.20-1.0.25"
composeBom = "2024.09.02"
compose = "1.7.2"
zxingAndroidEmbedded = "4.3.0"
coreSplashscreen = "1.0.1"
gradlePlugins-grgit = "5.2.2"
@@ -90,7 +89,6 @@ pin-lock-compose = { module = "com.zaneschepke:pin_lock_compose", version.ref =
timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" }
tunnel = { module = "com.zaneschepke:wireguard-android", version.ref = "tunnel" }
zaneschepke-multifab = { module = "com.zaneschepke:multifab", version.ref = "multifabVersion" }
zxing-android-embedded = { module = "com.journeyapps:zxing-android-embedded", version.ref = "zxingAndroidEmbedded" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
+2 -2
View File
@@ -1,8 +1,8 @@
#Wed Oct 11 22:39:21 EDT 2023
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
distributionSha256Sum=544c35d6bd849ae8a5ed0bcea39ba677dc40f49df7d1835561582da2009b961d
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
distributionSha256Sum=d725d707bfabd4dfdc958c624003b3c80accc03f7037b5122c4b1d0ef15cecab
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
@@ -5,7 +5,7 @@ import kotlinx.coroutines.flow.Flow
import java.io.File
interface LocalLogCollector {
fun start(onLogMessage: ((message: LogMessage) -> Unit)? = null)
suspend fun start(onLogMessage: ((message: LogMessage) -> Unit)? = null)
fun stop()
@@ -69,7 +69,7 @@ object LogcatUtil {
internal object Logcat : LocalLogCollector {
private var logcatReader: LogcatReader? = null
override fun start(onLogMessage: ((message: LogMessage) -> Unit)?) {
override suspend fun start(onLogMessage: ((message: LogMessage) -> Unit)?) {
logcatReader ?: run {
logcatReader =
LogcatReader(
@@ -78,9 +78,7 @@ object LogcatUtil {
onLogMessage,
)
}
logcatReader?.let { logReader ->
if (!logReader.isAlive) logReader.start()
}
logcatReader?.run()
}
override fun stop() {
@@ -142,7 +140,7 @@ object LogcatUtil {
pID: String,
private val logcatPath: String,
private val callback: ((input: LogMessage) -> Unit)?,
) : Thread() {
) {
private var logcatProc: Process? = null
private var reader: BufferedReader? = null
private var mRunning = true
@@ -177,7 +175,7 @@ object LogcatUtil {
}.let { last -> findIpv4AddressRegex.replace(last, "<ipv4-address>") }
}
override fun run() {
fun run() {
if (outputStream == null) return
try {
clear()
+1
View File
@@ -0,0 +1 @@
9