fix: tunnel disable frozen

Fixes a bug where after toggling a tunnel so many times it would eventually get stuck in the on position. This was also impacting auto-tunneling reliability.

Fixes a bug where clicking the email button on the support page would not populate the "to" email field.

Fixes a bug where you could not save a tunnel without having configured DNS.

Added a dialog to prompt user if they are deleting a tunnel.

Added battery optimization disable request when first launching auto-tunneling.

Format to kotlinlang standards.

Fix ci google play deploy.

Closes #63
This commit is contained in:
Zane Schepke 2024-01-06 04:06:21 -05:00
parent 7ec294b789
commit 5a15776bb3
92 changed files with 3454 additions and 2930 deletions

85
.editorconfig Normal file
View File

@ -0,0 +1,85 @@
[{*.kt,*.kts}]
indent_style = space
insert_final_newline = true
max_line_length = 100
indent_size = 4
ij_continuation_indent_size = 4
ij_java_names_count_to_use_import_on_demand = 9999
ij_kotlin_align_in_columns_case_branch = false
ij_kotlin_align_multiline_binary_operation = false
ij_kotlin_align_multiline_extends_list = false
ij_kotlin_align_multiline_method_parentheses = false
ij_kotlin_align_multiline_parameters = true
ij_kotlin_align_multiline_parameters_in_calls = false
ij_kotlin_allow_trailing_comma = true
ij_kotlin_allow_trailing_comma_on_call_site = true
ij_kotlin_assignment_wrap = normal
ij_kotlin_blank_lines_after_class_header = 0
ij_kotlin_blank_lines_around_block_when_branches = 0
ij_kotlin_blank_lines_before_declaration_with_comment_or_annotation_on_separate_line = 1
ij_kotlin_block_comment_at_first_column = true
ij_kotlin_call_parameters_new_line_after_left_paren = true
ij_kotlin_call_parameters_right_paren_on_new_line = false
ij_kotlin_call_parameters_wrap = on_every_item
ij_kotlin_catch_on_new_line = false
ij_kotlin_class_annotation_wrap = split_into_lines
ij_kotlin_code_style_defaults = KOTLIN_OFFICIAL
ij_kotlin_continuation_indent_for_chained_calls = true
ij_kotlin_continuation_indent_for_expression_bodies = true
ij_kotlin_continuation_indent_in_argument_lists = true
ij_kotlin_continuation_indent_in_elvis = false
ij_kotlin_continuation_indent_in_if_conditions = false
ij_kotlin_continuation_indent_in_parameter_lists = false
ij_kotlin_continuation_indent_in_supertype_lists = false
ij_kotlin_else_on_new_line = false
ij_kotlin_enum_constants_wrap = off
ij_kotlin_extends_list_wrap = normal
ij_kotlin_field_annotation_wrap = split_into_lines
ij_kotlin_finally_on_new_line = false
ij_kotlin_if_rparen_on_new_line = false
ij_kotlin_import_nested_classes = false
ij_kotlin_insert_whitespaces_in_simple_one_line_method = true
ij_kotlin_keep_blank_lines_before_right_brace = 2
ij_kotlin_keep_blank_lines_in_code = 2
ij_kotlin_keep_blank_lines_in_declarations = 2
ij_kotlin_keep_first_column_comment = true
ij_kotlin_keep_indents_on_empty_lines = false
ij_kotlin_keep_line_breaks = true
ij_kotlin_lbrace_on_next_line = false
ij_kotlin_line_comment_add_space = false
ij_kotlin_line_comment_at_first_column = true
ij_kotlin_method_annotation_wrap = split_into_lines
ij_kotlin_method_call_chain_wrap = normal
ij_kotlin_method_parameters_new_line_after_left_paren = true
ij_kotlin_method_parameters_right_paren_on_new_line = true
ij_kotlin_method_parameters_wrap = on_every_item
ij_kotlin_name_count_to_use_star_import = 9999
ij_kotlin_name_count_to_use_star_import_for_members = 9999
ij_kotlin_parameter_annotation_wrap = off
ij_kotlin_space_after_comma = true
ij_kotlin_space_after_extend_colon = true
ij_kotlin_space_after_type_colon = true
ij_kotlin_space_before_catch_parentheses = true
ij_kotlin_space_before_comma = false
ij_kotlin_space_before_extend_colon = true
ij_kotlin_space_before_for_parentheses = true
ij_kotlin_space_before_if_parentheses = true
ij_kotlin_space_before_lambda_arrow = true
ij_kotlin_space_before_type_colon = false
ij_kotlin_space_before_when_parentheses = true
ij_kotlin_space_before_while_parentheses = true
ij_kotlin_spaces_around_additive_operators = true
ij_kotlin_spaces_around_assignment_operators = true
ij_kotlin_spaces_around_equality_operators = true
ij_kotlin_spaces_around_function_type_arrow = true
ij_kotlin_spaces_around_logical_operators = true
ij_kotlin_spaces_around_multiplicative_operators = true
ij_kotlin_spaces_around_range = false
ij_kotlin_spaces_around_relational_operators = true
ij_kotlin_spaces_around_unary_operator = false
ij_kotlin_spaces_around_when_arrow = true
ij_kotlin_variable_annotation_wrap = off
ij_kotlin_while_on_new_line = false
ij_kotlin_wrap_elvis_expressions = 1
ij_kotlin_wrap_expression_body_functions = 1
ij_kotlin_wrap_first_method_in_call_chain = false

View File

@ -11,12 +11,14 @@ assignees: zaneschepke
A clear and concise description of what the bug is. A clear and concise description of what the bug is.
**Smartphone (please complete the following information):** **Smartphone (please complete the following information):**
- Device: [e.g. iPhone6]
- Android Version: [e.g. iOS8.1] - Device: [e.g. Pixel 4a]
- App Version [e.g. 22] - Android Version: [e.g. Android 13]
- App Version [e.g. 3.3.3]
**To Reproduce** **To Reproduce**
Steps to reproduce the behavior: Steps to reproduce the behavior:
1. Go to '...' 1. Go to '...'
2. Click on '....' 2. Click on '....'
3. Scroll down to '....' 3. Scroll down to '....'

2
.github/SUPPORT.md vendored
View File

@ -1,6 +1,6 @@
# Support # Support
If you are experiencing issues with the app, the following resources are available to help you. If you are experiencing issues with the app, the following resources are available to help you.
<ol> <ol>
<li> <li>

View File

@ -9,7 +9,7 @@ on:
jobs: jobs:
build: build:
name: Build Signed APK name: Build Signed APK
# change to macos because of hilt issues on ubuntu in gradle 8.3
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: env:
@ -70,7 +70,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with: with:
# fix hardcode changelog file name # fix hardcode changelog file name
body_path: ${{ github.workspace }}/fastlane/metadata/android/en-US/changelogs/33200.txt body_path: ${{ github.workspace }}/fastlane/metadata/android/en-US/changelogs/33300.txt
tag_name: ${{ github.ref_name }} tag_name: ${{ github.ref_name }}
name: Release ${{ github.ref_name }} name: Release ${{ github.ref_name }}
draft: false draft: false

View File

@ -28,7 +28,10 @@ WG Tunnel
<div align="left"> <div align="left">
This is an alternative Android Application for [WireGuard](https://www.wireguard.com/) with added features. Built using the [wireguard-android](https://github.com/WireGuard/wireguard-android) library and [Jetpack Compose](https://developer.android.com/jetpack/compose), this application was inspired by the official [WireGuard Android](https://github.com/WireGuard/wireguard-android) app. This is an alternative Android Application for [WireGuard](https://www.wireguard.com/) with added
features. Built using the [wireguard-android](https://github.com/WireGuard/wireguard-android)
library and [Jetpack Compose](https://developer.android.com/jetpack/compose), this application was
inspired by the official [WireGuard Android](https://github.com/WireGuard/wireguard-android) app.
</div> </div>
@ -47,7 +50,8 @@ This is an alternative Android Application for [WireGuard](https://www.wireguard
## Inspiration ## Inspiration
The original inspiration for this app came from the inconvenience of having to manually turn VPN off and on while on different networks. This app was created to offer a free solution to this problem. The original inspiration for this app came from the inconvenience of having to manually turn VPN off
and on while on different networks. This app was created to offer a free solution to this problem.
## Features ## Features
@ -63,9 +67,8 @@ The original inspiration for this app came from the inconvenience of having to m
* Automatic service restart after reboot * Automatic service restart after reboot
* Battery preservation measures * Battery preservation measures
## Building ## Building
``` ```
$ git clone https://github.com/zaneschepke/wgtunnel $ git clone https://github.com/zaneschepke/wgtunnel
$ cd wgtunnel $ cd wgtunnel

View File

@ -19,9 +19,7 @@ android {
versionCode = Constants.VERSION_CODE versionCode = Constants.VERSION_CODE
versionName = Constants.VERSION_NAME versionName = Constants.VERSION_NAME
ksp { ksp { arg("room.schemaLocation", "$projectDir/schemas") }
arg("room.schemaLocation", "$projectDir/schemas")
}
sourceSets { sourceSets {
getByName("debug").assets.srcDirs(files("$projectDir/schemas")) // Room getByName("debug").assets.srcDirs(files("$projectDir/schemas")) // Room
@ -30,9 +28,7 @@ android {
resourceConfigurations.addAll(listOf("en")) resourceConfigurations.addAll(listOf("en"))
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { vectorDrawables { useSupportLibrary = true }
useSupportLibrary = true
}
} }
signingConfigs { signingConfigs {
@ -47,32 +43,41 @@ android {
} }
} }
// try to get secrets from env first for pipeline build, then properties file for local build // try to get secrets from env first for pipeline build, then properties file for local
storeFile = file( // build
System.getenv().getOrDefault( storeFile =
Constants.KEY_STORE_PATH_VAR, file(
properties.getProperty(Constants.KEY_STORE_PATH_VAR) System.getenv()
.getOrDefault(
Constants.KEY_STORE_PATH_VAR,
properties.getProperty(Constants.KEY_STORE_PATH_VAR),
),
) )
) storePassword =
storePassword = System.getenv().getOrDefault( System.getenv()
Constants.STORE_PASS_VAR, .getOrDefault(
properties.getProperty(Constants.STORE_PASS_VAR) Constants.STORE_PASS_VAR,
) properties.getProperty(Constants.STORE_PASS_VAR),
keyAlias = System.getenv().getOrDefault( )
Constants.KEY_ALIAS_VAR, keyAlias =
properties.getProperty(Constants.KEY_ALIAS_VAR) System.getenv()
) .getOrDefault(
keyPassword = System.getenv().getOrDefault( Constants.KEY_ALIAS_VAR,
Constants.KEY_PASS_VAR, properties.getProperty(Constants.KEY_ALIAS_VAR),
properties.getProperty(Constants.KEY_PASS_VAR) )
) keyPassword =
System.getenv()
.getOrDefault(
Constants.KEY_PASS_VAR,
properties.getProperty(Constants.KEY_PASS_VAR),
)
} }
} }
buildTypes { buildTypes {
// don't strip // don't strip
packaging.jniLibs.keepDebugSymbols.addAll( packaging.jniLibs.keepDebugSymbols.addAll(
listOf("libwg-go.so", "libwg-quick.so", "libwg.so") listOf("libwg-go.so", "libwg-quick.so", "libwg.so"),
) )
applicationVariants.all { applicationVariants.all {
@ -91,13 +96,11 @@ android {
isShrinkResources = true isShrinkResources = true
proguardFiles( proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"), getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro" "proguard-rules.pro",
) )
signingConfig = signingConfigs.getByName(Constants.RELEASE) signingConfig = signingConfigs.getByName(Constants.RELEASE)
} }
debug { debug { isDebuggable = true }
isDebuggable = true
}
} }
flavorDimensions.add(Constants.TYPE) flavorDimensions.add(Constants.TYPE)
productFlavors { productFlavors {
@ -118,24 +121,17 @@ android {
targetCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17
isCoreLibraryDesugaringEnabled = true isCoreLibraryDesugaringEnabled = true
} }
kotlinOptions { kotlinOptions { jvmTarget = Constants.JVM_TARGET }
jvmTarget = Constants.JVM_TARGET
}
buildFeatures { buildFeatures {
compose = true compose = true
buildConfig = true buildConfig = true
} }
composeOptions { composeOptions { kotlinCompilerExtensionVersion = Constants.COMPOSE_COMPILER_EXTENSION_VERSION }
kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get() packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" } }
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
} }
val generalImplementation by configurations val generalImplementation by configurations
dependencies { dependencies {
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.ktx)

View File

@ -14,10 +14,11 @@ class MigrationTest {
private val dbName = "migration-test" private val dbName = "migration-test"
@get:Rule @get:Rule
val helper: MigrationTestHelper = MigrationTestHelper( val helper: MigrationTestHelper =
InstrumentationRegistry.getInstrumentation(), MigrationTestHelper(
AppDatabase::class.java InstrumentationRegistry.getInstrumentation(),
) AppDatabase::class.java,
)
@Test @Test
@Throws(IOException::class) @Throws(IOException::class)
@ -27,34 +28,33 @@ class MigrationTest {
// You can't use DAO classes because they expect the latest schema. // You can't use DAO classes because they expect the latest schema.
execSQL( execSQL(
"INSERT INTO Settings (is_tunnel_enabled," + "INSERT INTO Settings (is_tunnel_enabled," +
"is_tunnel_on_mobile_data_enabled," + "is_tunnel_on_mobile_data_enabled," +
"trusted_network_ssids," + "trusted_network_ssids," +
"default_tunnel," + "default_tunnel," +
"is_always_on_vpn_enabled," + "is_always_on_vpn_enabled," +
"is_tunnel_on_ethernet_enabled," + "is_tunnel_on_ethernet_enabled," +
"is_shortcuts_enabled," + "is_shortcuts_enabled," +
"is_battery_saver_enabled," + "is_battery_saver_enabled," +
"is_tunnel_on_wifi_enabled," + "is_tunnel_on_wifi_enabled," +
"is_kernel_enabled," + "is_kernel_enabled," +
"is_restore_on_boot_enabled," + "is_restore_on_boot_enabled," +
"is_multi_tunnel_enabled)" + "is_multi_tunnel_enabled)" +
" VALUES " + " VALUES " +
"('false'," + "('false'," +
"'false'," + "'false'," +
"'[trustedSSID1,trustedSSID2]'," + "'[trustedSSID1,trustedSSID2]'," +
"'defaultTunnel'," + "'defaultTunnel'," +
"'false'," + "'false'," +
"'false'," + "'false'," +
"'false'," + "'false'," +
"'false'," + "'false'," +
"'false'," + "'false'," +
"'false'," + "'false'," +
"'false'," + "'false'," +
"'false')" "'false')",
) )
execSQL( execSQL(
"INSERT INTO TunnelConfig (name, wg_quick)" + "INSERT INTO TunnelConfig (name, wg_quick)" + " VALUES ('hello', 'hello')",
" VALUES ('hello', 'hello')"
) )
// Prepare for the next version. // Prepare for the next version.
close() close()

View File

@ -1,56 +1,63 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" <uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" /> android:maxSdkVersion="32" />
<uses-permission <uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="32" android:maxSdkVersion="32"
tools:ignore="ScopedStorage" /> tools:ignore="ScopedStorage" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" <uses-permission
android:name="android.permission.ACCESS_WIFI_STATE"
android:maxSdkVersion="30" android:maxSdkVersion="30"
tools:ignore="LeanbackUsesWifi" /> tools:ignore="LeanbackUsesWifi" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE"/> <uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION"/> <uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<!--foreground service exempt android 14--> <!--foreground service exempt android 14-->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SYSTEM_EXEMPTED"/> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_SYSTEM_EXEMPTED" />
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/> <uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE"/> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<!--foreground service permissions--> <!--foreground service permissions-->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.WAKE_LOCK" /> <uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<!--start service on boot permission--> <!--start service on boot permission-->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!--android tv support--> <!--android tv support-->
<uses-feature android:name="android.software.leanback" <uses-feature
android:name="android.software.leanback"
android:required="false" /> android:required="false" />
<uses-feature android:name="android.hardware.touchscreen" <uses-feature
android:name="android.hardware.touchscreen"
android:required="false" /> android:required="false" />
<uses-feature <uses-feature
android:name="android.hardware.location.gps" android:name="android.hardware.location.gps"
android:required="false" /> android:required="false" />
<uses-feature <uses-feature
android:name="android.hardware.screen.portrait" android:name="android.hardware.screen.portrait"
android:required="false" /> android:required="false" />
<queries> <queries>
<intent> <intent>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
</intent> </intent>
</queries> </queries>
<application <application
android:allowBackup="true"
android:enableOnBackInvokedCallback="true"
android:name=".WireGuardAutoTunnel" android:name=".WireGuardAutoTunnel"
android:allowBackup="true"
android:banner="@mipmap/ic_banner"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
android:enableOnBackInvokedCallback="true"
android:fullBackupContent="@xml/backup_rules" android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:banner="@mipmap/ic_banner"
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
@ -62,11 +69,14 @@
android:theme="@style/Theme.WireguardAutoTunnel"> android:theme="@style/Theme.WireguardAutoTunnel">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" /> <category android:name="android.intent.category.LEANBACK_LAUNCHER" />
<action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES"/>
<action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES" />
</intent-filter> </intent-filter>
<meta-data android:name="android.app.shortcuts" <meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" /> android:resource="@xml/shortcuts" />
</activity> </activity>
<activity <activity
@ -76,65 +86,74 @@
android:theme="@style/zxing_CaptureTheme" android:theme="@style/zxing_CaptureTheme"
android:windowSoftInputMode="stateAlwaysHidden" /> android:windowSoftInputMode="stateAlwaysHidden" />
<activity <activity
android:finishOnTaskLaunch="true" android:name=".service.shortcut.ShortcutsActivity"
android:enabled="true" android:enabled="true"
android:exported="true" android:exported="true"
android:theme="@android:style/Theme.NoDisplay" android:finishOnTaskLaunch="true"
android:name=".service.shortcut.ShortcutsActivity"/> android:theme="@android:style/Theme.NoDisplay" />
<service <service
android:name=".service.foreground.ForegroundService" android:name=".service.foreground.ForegroundService"
android:enabled="true" android:enabled="true"
android:exported="false"
android:foregroundServiceType="systemExempted|specialUse" android:foregroundServiceType="systemExempted|specialUse"
tools:node="merge" tools:node="merge" />
android:exported="false">
</service>
<service <service
android:exported="true"
android:name=".service.tile.TunnelControlTile" android:name=".service.tile.TunnelControlTile"
android:exported="true"
android:icon="@drawable/shield" android:icon="@drawable/shield"
android:label="WG Tunnel" android:label="WG Tunnel"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"> android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<meta-data android:name="android.service.quicksettings.ACTIVE_TILE" <meta-data
android:name="android.service.quicksettings.ACTIVE_TILE"
android:value="true" /> android:value="true" />
<meta-data android:name="android.service.quicksettings.TOGGLEABLE_TILE" <meta-data
android:name="android.service.quicksettings.TOGGLEABLE_TILE"
android:value="true" /> android:value="true" />
<intent-filter> <intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" /> <action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter> </intent-filter>
</service> </service>
<service <service
android:name=".service.foreground.WireGuardTunnelService" android:name=".service.foreground.WireGuardTunnelService"
android:permission="android.permission.BIND_VPN_SERVICE"
android:enabled="true" android:enabled="true"
android:persistent="true" android:exported="false"
android:foregroundServiceType="systemExempted|specialUse" android:foregroundServiceType="systemExempted|specialUse"
tools:node="merge" android:permission="android.permission.BIND_VPN_SERVICE"
android:exported="false"> android:persistent="true"
tools:node="merge">
<intent-filter> <intent-filter>
<action android:name="android.net.VpnService"/> <action android:name="android.net.VpnService" />
</intent-filter> </intent-filter>
<meta-data android:name="android.net.VpnService.SUPPORTS_ALWAYS_ON" <meta-data
android:name="android.net.VpnService.SUPPORTS_ALWAYS_ON"
android:value="true" /> android:value="true" />
</service> </service>
<service <service
android:name=".service.foreground.WireGuardConnectivityWatcherService" android:name=".service.foreground.WireGuardConnectivityWatcherService"
android:enabled="true" android:enabled="true"
android:stopWithTask="false" android:exported="false"
android:persistent="true"
android:foregroundServiceType="systemExempted|specialUse" android:foregroundServiceType="systemExempted|specialUse"
tools:node="merge" android:persistent="true"
android:exported="false"> android:stopWithTask="false"
</service> tools:node="merge" />
<receiver android:enabled="true" android:name=".receiver.BootReceiver"
<receiver
android:name=".receiver.BootReceiver"
android:enabled="true"
android:exported="false"> android:exported="false">
<intent-filter> <intent-filter>
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<action android:name="android.intent.action.BOOT_COMPLETED" /> <action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.ACTION_BOOT_COMPLETED" /> <action android:name="android.intent.action.ACTION_BOOT_COMPLETED" />
<action android:name="android.intent.action.QUICKBOOT_POWERON" /> <action android:name="android.intent.action.QUICKBOOT_POWERON" />
<action android:name="com.htc.intent.action.QUICKBOOT_POWERON" /> <action android:name="com.htc.intent.action.QUICKBOOT_POWERON" />
</intent-filter> </intent-filter>
</receiver> </receiver>
<receiver android:exported="false" android:name=".receiver.NotificationActionReceiver"/> <receiver
android:name=".receiver.NotificationActionReceiver"
android:exported="false" />
</application> </application>
</manifest> </manifest>

View File

@ -4,61 +4,31 @@ import android.app.Application
import android.content.ComponentName import android.content.ComponentName
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.service.quicksettings.TileService import android.service.quicksettings.TileService
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.lifecycleScope
import com.zaneschepke.wireguardautotunnel.data.datastore.DataStoreManager
import com.zaneschepke.wireguardautotunnel.data.model.Settings
import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepository
import com.zaneschepke.wireguardautotunnel.service.tile.TunnelControlTile import com.zaneschepke.wireguardautotunnel.service.tile.TunnelControlTile
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import java.io.IOException
import javax.inject.Inject
@HiltAndroidApp @HiltAndroidApp
class WireGuardAutoTunnel : Application() { class WireGuardAutoTunnel : Application() {
@Inject
lateinit var settingsRepository: SettingsRepository
@Inject
lateinit var dataStoreManager: DataStoreManager
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
instance = this instance = this
if (BuildConfig.DEBUG) Timber.plant(Timber.DebugTree()) if (BuildConfig.DEBUG) Timber.plant(Timber.DebugTree())
initSettings()
with(ProcessLifecycleOwner.get()) {
lifecycleScope.launch {
try {
// load preferences into memory
dataStoreManager.init()
requestTileServiceStateUpdate()
} catch (e: IOException) {
Timber.e("Failed to load preferences")
}
}
}
}
private fun initSettings() {
with(ProcessLifecycleOwner.get()) {
lifecycleScope.launch {
if (settingsRepository.getAll().isEmpty()) {
settingsRepository.save(Settings())
}
}
}
} }
companion object { companion object {
lateinit var instance: WireGuardAutoTunnel private set lateinit var instance: WireGuardAutoTunnel
private set
fun isRunningOnAndroidTv(): Boolean { fun isRunningOnAndroidTv(): Boolean {
return instance.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK) return instance.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK)
} }
fun requestTileServiceStateUpdate() { fun requestTileServiceStateUpdate() {
TileService.requestListeningState(instance, ComponentName(instance, TunnelControlTile::class.java)) TileService.requestListeningState(
instance,
ComponentName(instance, TunnelControlTile::class.java),
)
} }
} }
} }

View File

@ -10,16 +10,20 @@ import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig
@Database( @Database(
entities = [Settings::class, TunnelConfig::class], entities = [Settings::class, TunnelConfig::class],
version = 5, version = 5,
autoMigrations = [ autoMigrations =
AutoMigration(from = 1, to = 2), AutoMigration(from = 2, to = 3), AutoMigration( [
from = 3, AutoMigration(from = 1, to = 2),
to = 4 AutoMigration(from = 2, to = 3),
),AutoMigration( AutoMigration(
from = 4, from = 3,
to = 5 to = 4,
) ),
], AutoMigration(
exportSchema = true from = 4,
to = 5,
),
],
exportSchema = true,
) )
@TypeConverters(DatabaseListConverters::class) @TypeConverters(DatabaseListConverters::class)
abstract class AppDatabase : RoomDatabase() { abstract class AppDatabase : RoomDatabase() {

View File

@ -10,27 +10,19 @@ import kotlinx.coroutines.flow.Flow
@Dao @Dao
interface SettingsDao { interface SettingsDao {
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun save(t: Settings)
suspend fun save(t: Settings)
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun saveAll(t: List<Settings>)
suspend fun saveAll(t: List<Settings>)
@Query("SELECT * FROM settings WHERE id=:id") @Query("SELECT * FROM settings WHERE id=:id") suspend fun getById(id: Long): Settings?
suspend fun getById(id: Long): Settings?
@Query("SELECT * FROM settings") @Query("SELECT * FROM settings") suspend fun getAll(): List<Settings>
suspend fun getAll(): List<Settings>
@Query("SELECT * FROM settings LIMIT 1") @Query("SELECT * FROM settings LIMIT 1") fun getSettingsFlow(): Flow<Settings>
fun getSettingsFlow(): Flow<Settings>
@Query("SELECT * FROM settings") @Query("SELECT * FROM settings") fun getAllFlow(): Flow<MutableList<Settings>>
fun getAllFlow(): Flow<MutableList<Settings>>
@Delete @Delete suspend fun delete(t: Settings)
suspend fun delete(t: Settings)
@Query("SELECT COUNT('id') FROM settings") @Query("SELECT COUNT('id') FROM settings") suspend fun count(): Long
suspend fun count(): Long
} }

View File

@ -10,24 +10,17 @@ import kotlinx.coroutines.flow.Flow
@Dao @Dao
interface TunnelConfigDao { interface TunnelConfigDao {
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun save(t: TunnelConfig)
suspend fun save(t: TunnelConfig)
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun saveAll(t: List<TunnelConfig>)
suspend fun saveAll(t: List<TunnelConfig>)
@Query("SELECT * FROM TunnelConfig WHERE id=:id") @Query("SELECT * FROM TunnelConfig WHERE id=:id") suspend fun getById(id: Long): TunnelConfig?
suspend fun getById(id: Long): TunnelConfig?
@Query("SELECT * FROM TunnelConfig") @Query("SELECT * FROM TunnelConfig") suspend fun getAll(): List<TunnelConfig>
suspend fun getAll(): List<TunnelConfig>
@Delete @Delete suspend fun delete(t: TunnelConfig)
suspend fun delete(t: TunnelConfig)
@Query("SELECT COUNT('id') FROM TunnelConfig") @Query("SELECT COUNT('id') FROM TunnelConfig") suspend fun count(): Long
suspend fun count(): Long
@Query("SELECT * FROM tunnelconfig") @Query("SELECT * FROM tunnelconfig") fun getAllFlow(): Flow<MutableList<TunnelConfig>>
fun getAllFlow(): Flow<MutableList<TunnelConfig>>
} }

View File

@ -1,4 +1,5 @@
package com.zaneschepke.wireguardautotunnel.data.datastore package com.zaneschepke.wireguardautotunnel.data.datastore
import android.content.Context import android.content.Context
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.booleanPreferencesKey
@ -11,29 +12,27 @@ import kotlinx.coroutines.flow.map
class DataStoreManager(private val context: Context) { class DataStoreManager(private val context: Context) {
companion object { companion object {
val LOCATION_DISCLOSURE_SHOWN = booleanPreferencesKey("LOCATION_DISCLOSURE_SHOWN") val LOCATION_DISCLOSURE_SHOWN = booleanPreferencesKey("LOCATION_DISCLOSURE_SHOWN")
val BATTERY_OPTIMIZE_DISABLE_SHOWN = booleanPreferencesKey("BATTERY_OPTIMIZE_DISABLE_SHOWN")
} }
// preferences // preferences
private val preferencesKey = "preferences" private val preferencesKey = "preferences"
private val Context.dataStore by preferencesDataStore( private val Context.dataStore by
name = preferencesKey preferencesDataStore(
) name = preferencesKey,
)
suspend fun init() { suspend fun init() {
context.dataStore.data.first() context.dataStore.data.first()
} }
suspend fun <T> saveToDataStore(key: Preferences.Key<T>, value: T) = suspend fun <T> saveToDataStore(key: Preferences.Key<T>, value: T) =
context.dataStore.edit { context.dataStore.edit { it[key] = value }
it[key] = value
}
fun <T> getFromStoreFlow(key: Preferences.Key<T>) = context.dataStore.data.map {
it[key]
}
suspend fun <T> getFromStore(key: Preferences.Key<T>) = context.dataStore.data.first { it.contains(key) }[key] fun <T> getFromStoreFlow(key: Preferences.Key<T>) = context.dataStore.data.map { it[key] }
val locationDisclosureFlow: Flow<Boolean?> = context.dataStore.data.map { suspend fun <T> getFromStore(key: Preferences.Key<T>) =
it[LOCATION_DISCLOSURE_SHOWN] context.dataStore.data.first { it.contains(key) }[key]
}
val preferencesFlow: Flow<Preferences?> = context.dataStore.data
} }

View File

@ -8,39 +8,49 @@ import androidx.room.PrimaryKey
data class Settings( data class Settings(
@PrimaryKey(autoGenerate = true) val id: Int = 0, @PrimaryKey(autoGenerate = true) val id: Int = 0,
@ColumnInfo(name = "is_tunnel_enabled") var isAutoTunnelEnabled: Boolean = false, @ColumnInfo(name = "is_tunnel_enabled") var isAutoTunnelEnabled: Boolean = false,
@ColumnInfo(name = "is_tunnel_on_mobile_data_enabled") var isTunnelOnMobileDataEnabled: Boolean = false, @ColumnInfo(name = "is_tunnel_on_mobile_data_enabled")
@ColumnInfo(name = "trusted_network_ssids") var trustedNetworkSSIDs: MutableList<String> = mutableListOf(), var isTunnelOnMobileDataEnabled: Boolean = false,
@ColumnInfo(name = "trusted_network_ssids")
var trustedNetworkSSIDs: MutableList<String> = mutableListOf(),
@ColumnInfo(name = "default_tunnel") var defaultTunnel: String? = null, @ColumnInfo(name = "default_tunnel") var defaultTunnel: String? = null,
@ColumnInfo(name = "is_always_on_vpn_enabled") var isAlwaysOnVpnEnabled: Boolean = false, @ColumnInfo(name = "is_always_on_vpn_enabled") var isAlwaysOnVpnEnabled: Boolean = false,
@ColumnInfo(name = "is_tunnel_on_ethernet_enabled") var isTunnelOnEthernetEnabled: Boolean = false, @ColumnInfo(name = "is_tunnel_on_ethernet_enabled")
var isTunnelOnEthernetEnabled: Boolean = false,
@ColumnInfo( @ColumnInfo(
name = "is_shortcuts_enabled", name = "is_shortcuts_enabled",
defaultValue = "false" defaultValue = "false",
) var isShortcutsEnabled: Boolean = false, )
var isShortcutsEnabled: Boolean = false,
@ColumnInfo( @ColumnInfo(
name = "is_battery_saver_enabled", name = "is_battery_saver_enabled",
defaultValue = "false" defaultValue = "false",
) var isBatterySaverEnabled: Boolean = false, )
var isBatterySaverEnabled: Boolean = false,
@ColumnInfo( @ColumnInfo(
name = "is_tunnel_on_wifi_enabled", name = "is_tunnel_on_wifi_enabled",
defaultValue = "false" defaultValue = "false",
) var isTunnelOnWifiEnabled: Boolean = false, )
var isTunnelOnWifiEnabled: Boolean = false,
@ColumnInfo( @ColumnInfo(
name = "is_kernel_enabled", name = "is_kernel_enabled",
defaultValue = "false" defaultValue = "false",
) var isKernelEnabled: Boolean = false, )
var isKernelEnabled: Boolean = false,
@ColumnInfo( @ColumnInfo(
name = "is_restore_on_boot_enabled", name = "is_restore_on_boot_enabled",
defaultValue = "false" defaultValue = "false",
) var isRestoreOnBootEnabled: Boolean = false, )
var isRestoreOnBootEnabled: Boolean = false,
@ColumnInfo( @ColumnInfo(
name = "is_multi_tunnel_enabled", name = "is_multi_tunnel_enabled",
defaultValue = "false" defaultValue = "false",
) var isMultiTunnelEnabled: Boolean = false, )
var isMultiTunnelEnabled: Boolean = false,
@ColumnInfo( @ColumnInfo(
name = "is_auto_tunnel_paused", name = "is_auto_tunnel_paused",
defaultValue = "false" defaultValue = "false",
) var isAutoTunnelPaused: Boolean = false, )
var isAutoTunnelPaused: Boolean = false,
) { ) {
fun isTunnelConfigDefault(tunnelConfig: TunnelConfig): Boolean { fun isTunnelConfigDefault(tunnelConfig: TunnelConfig): Boolean {
return if (defaultTunnel != null) { return if (defaultTunnel != null) {

View File

@ -4,9 +4,11 @@ import com.zaneschepke.wireguardautotunnel.data.model.Settings
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
interface SettingsRepository { interface SettingsRepository {
suspend fun save(settings : Settings) suspend fun save(settings: Settings)
fun getSettingsFlow() : Flow<Settings>
suspend fun getSettings() : Settings fun getSettingsFlow(): Flow<Settings>
suspend fun getAll() : List<Settings>
} suspend fun getSettings(): Settings
suspend fun getAll(): List<Settings>
}

View File

@ -21,4 +21,4 @@ class SettingsRepositoryImpl(private val settingsDoa: SettingsDao) : SettingsRep
override suspend fun getAll(): List<Settings> { override suspend fun getAll(): List<Settings> {
return settingsDoa.getAll() return settingsDoa.getAll()
} }
} }

View File

@ -6,9 +6,13 @@ import kotlinx.coroutines.flow.Flow
interface TunnelConfigRepository { interface TunnelConfigRepository {
fun getTunnelConfigsFlow() : Flow<TunnelConfigs> fun getTunnelConfigsFlow(): Flow<TunnelConfigs>
suspend fun getAll() : TunnelConfigs
suspend fun getAll(): TunnelConfigs
suspend fun save(tunnelConfig: TunnelConfig) suspend fun save(tunnelConfig: TunnelConfig)
suspend fun delete(tunnelConfig: TunnelConfig) suspend fun delete(tunnelConfig: TunnelConfig)
suspend fun count() : Int
} suspend fun count(): Int
}

View File

@ -5,7 +5,8 @@ import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.util.TunnelConfigs import com.zaneschepke.wireguardautotunnel.util.TunnelConfigs
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
class TunnelConfigRepositoryImpl(private val tunnelConfigDao: TunnelConfigDao) : TunnelConfigRepository { class TunnelConfigRepositoryImpl(private val tunnelConfigDao: TunnelConfigDao) :
TunnelConfigRepository {
override fun getTunnelConfigsFlow(): Flow<TunnelConfigs> { override fun getTunnelConfigsFlow(): Flow<TunnelConfigs> {
return tunnelConfigDao.getAllFlow() return tunnelConfigDao.getAllFlow()
} }
@ -25,4 +26,4 @@ class TunnelConfigRepositoryImpl(private val tunnelConfigDao: TunnelConfigDao) :
override suspend fun count(): Int { override suspend fun count(): Int {
return tunnelConfigDao.count().toInt() return tunnelConfigDao.count().toInt()
} }
} }

View File

@ -16,14 +16,12 @@ import javax.inject.Singleton
class DatabaseModule { class DatabaseModule {
@Provides @Provides
@Singleton @Singleton
fun provideDatabase( fun provideDatabase(@ApplicationContext context: Context): AppDatabase {
@ApplicationContext context: Context
): AppDatabase {
return Room.databaseBuilder( return Room.databaseBuilder(
context, context,
AppDatabase::class.java, AppDatabase::class.java,
context.getString(R.string.db_name) context.getString(R.string.db_name),
) )
.fallbackToDestructiveMigration() .fallbackToDestructiveMigration()
.build() .build()
} }

View File

@ -2,6 +2,4 @@ package com.zaneschepke.wireguardautotunnel.module
import javax.inject.Qualifier import javax.inject.Qualifier
@Qualifier @Qualifier @Retention(AnnotationRetention.BINARY) annotation class Kernel
@Retention(AnnotationRetention.BINARY)
annotation class Kernel

View File

@ -17,7 +17,9 @@ import dagger.hilt.android.scopes.ServiceScoped
abstract class ServiceModule { abstract class ServiceModule {
@Binds @Binds
@ServiceScoped @ServiceScoped
abstract fun provideNotificationService(wireGuardNotification: WireGuardNotification): NotificationService abstract fun provideNotificationService(
wireGuardNotification: WireGuardNotification
): NotificationService
@Binds @Binds
@ServiceScoped @ServiceScoped
@ -25,9 +27,13 @@ abstract class ServiceModule {
@Binds @Binds
@ServiceScoped @ServiceScoped
abstract fun provideMobileDataService(mobileDataService: MobileDataService): NetworkService<MobileDataService> abstract fun provideMobileDataService(
mobileDataService: MobileDataService
): NetworkService<MobileDataService>
@Binds @Binds
@ServiceScoped @ServiceScoped
abstract fun provideEthernetService(ethernetService: EthernetService): NetworkService<EthernetService> abstract fun provideEthernetService(
ethernetService: EthernetService
): NetworkService<EthernetService>
} }

View File

@ -21,28 +21,21 @@ import javax.inject.Singleton
class TunnelModule { class TunnelModule {
@Provides @Provides
@Singleton @Singleton
fun provideRootShell( fun provideRootShell(@ApplicationContext context: Context): RootShell {
@ApplicationContext context: Context
): RootShell {
return RootShell(context) return RootShell(context)
} }
@Provides @Provides
@Singleton @Singleton
@Userspace @Userspace
fun provideUserspaceBackend( fun provideUserspaceBackend(@ApplicationContext context: Context): Backend {
@ApplicationContext context: Context
): Backend {
return GoBackend(context) return GoBackend(context)
} }
@Provides @Provides
@Singleton @Singleton
@Kernel @Kernel
fun provideKernelBackend( fun provideKernelBackend(@ApplicationContext context: Context, rootShell: RootShell): Backend {
@ApplicationContext context: Context,
rootShell: RootShell
): Backend {
return WgQuickBackend(context, rootShell, ToolsInstaller(context, rootShell)) return WgQuickBackend(context, rootShell, ToolsInstaller(context, rootShell))
} }
@ -51,7 +44,7 @@ class TunnelModule {
fun provideVpnService( fun provideVpnService(
@Userspace userspaceBackend: Backend, @Userspace userspaceBackend: Backend,
@Kernel kernelBackend: Backend, @Kernel kernelBackend: Backend,
settingsRepository : SettingsRepository settingsRepository: SettingsRepository
): VpnService { ): VpnService {
return WireGuardTunnel(userspaceBackend, kernelBackend, settingsRepository) return WireGuardTunnel(userspaceBackend, kernelBackend, settingsRepository)
} }

View File

@ -2,6 +2,4 @@ package com.zaneschepke.wireguardautotunnel.module
import javax.inject.Qualifier import javax.inject.Qualifier
@Qualifier @Qualifier @Retention(AnnotationRetention.BINARY) annotation class Userspace
@Retention(AnnotationRetention.BINARY)
annotation class Userspace

View File

@ -9,17 +9,15 @@ import com.zaneschepke.wireguardautotunnel.util.goAsync
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class BootReceiver : BroadcastReceiver() { class BootReceiver : BroadcastReceiver() {
@Inject @Inject lateinit var settingsRepository: SettingsRepository
lateinit var settingsRepository: SettingsRepository
override fun onReceive(context: Context?, intent: Intent?) = goAsync { override fun onReceive(context: Context?, intent: Intent?) = goAsync {
if (Intent.ACTION_BOOT_COMPLETED != intent?.action) return@goAsync if (Intent.ACTION_BOOT_COMPLETED != intent?.action) return@goAsync
if(settingsRepository.getSettings().isAutoTunnelEnabled) { if (settingsRepository.getSettings().isAutoTunnelEnabled) {
ServiceManager.startWatcherServiceForeground(context!!) ServiceManager.startWatcherServiceForeground(context!!)
} }
} }
} }

View File

@ -14,13 +14,9 @@ import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class NotificationActionReceiver : BroadcastReceiver() { class NotificationActionReceiver : BroadcastReceiver() {
@Inject @Inject lateinit var settingsRepository: SettingsRepository
lateinit var settingsRepository: SettingsRepository
override fun onReceive( override fun onReceive(context: Context, intent: Intent?) = goAsync {
context: Context,
intent: Intent?
) = goAsync {
try { try {
val settings = settingsRepository.getSettings() val settings = settingsRepository.getSettings()
if (settings.defaultTunnel != null) { if (settings.defaultTunnel != null) {

View File

@ -15,18 +15,14 @@ open class ForegroundService : LifecycleService() {
return null return null
} }
override fun onStartCommand( override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
intent: Intent?,
flags: Int,
startId: Int
): Int {
super.onStartCommand(intent, flags, startId) super.onStartCommand(intent, flags, startId)
Timber.d("onStartCommand executed with startId: $startId") Timber.d("onStartCommand executed with startId: $startId")
if (intent != null) { if (intent != null) {
val action = intent.action val action = intent.action
Timber.d("using an intent with action $action")
when (action) { when (action) {
Action.START.name, Action.START_FOREGROUND.name -> startService(intent.extras) Action.START.name,
Action.START_FOREGROUND.name -> startService(intent.extras)
Action.STOP.name -> stopService(intent.extras) Action.STOP.name -> stopService(intent.extras)
"android.net.VpnService" -> { "android.net.VpnService" -> {
Timber.d("Always-on VPN starting service") Timber.d("Always-on VPN starting service")
@ -36,7 +32,7 @@ open class ForegroundService : LifecycleService() {
} }
} else { } else {
Timber.d( Timber.d(
"with a null intent. It has been probably restarted by the system." "with a null intent. It has been probably restarted by the system.",
) )
} }
// by returning this we make sure the service is restarted if the system kills the service // by returning this we make sure the service is restarted if the system kills the service

View File

@ -1,28 +1,27 @@
package com.zaneschepke.wireguardautotunnel.service.foreground package com.zaneschepke.wireguardautotunnel.service.foreground
import android.app.ActivityManager
import android.app.Service import android.app.Service
import android.content.Context import android.content.Context
import android.content.Context.ACTIVITY_SERVICE
import android.content.Intent import android.content.Intent
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import timber.log.Timber import timber.log.Timber
object ServiceManager { object ServiceManager {
@Suppress("DEPRECATION")
private // Deprecated for third party Services.
fun <T> Context.isServiceRunning(service: Class<T>) =
(getSystemService(ACTIVITY_SERVICE) as ActivityManager)
.getRunningServices(Integer.MAX_VALUE)
.any { it.service.className == service.name }
fun <T : Service> getServiceState( // private
context: Context, // fun <T> Context.isServiceRunning(service: Class<T>) =
cls: Class<T> // (getSystemService(ACTIVITY_SERVICE) as ActivityManager)
): ServiceState { // .runningAppProcesses.any {
val isServiceRunning = context.isServiceRunning(cls) // it.processName == service.name
return if (isServiceRunning) ServiceState.STARTED else ServiceState.STOPPED // }
} //
// fun <T : Service> getServiceState(
// context: Context,
// cls: Class<T>
// ): ServiceState {
// val isServiceRunning = context.isServiceRunning(cls)
// return if (isServiceRunning) ServiceState.STARTED else ServiceState.STOPPED
// }
private fun <T : Service> actionOnService( private fun <T : Service> actionOnService(
action: Action, action: Action,
@ -30,14 +29,10 @@ object ServiceManager {
cls: Class<T>, cls: Class<T>,
extras: Map<String, String>? = null extras: Map<String, String>? = null
) { ) {
if (getServiceState(context, cls) == ServiceState.STOPPED && action == Action.STOP) return
if (getServiceState(context, cls) == ServiceState.STARTED && action == Action.START) return
val intent = val intent =
Intent(context, cls).also { Intent(context, cls).also {
it.action = action.name it.action = action.name
extras?.forEach { (k, v) -> extras?.forEach { (k, v) -> it.putExtra(k, v) }
it.putExtra(k, v)
}
} }
intent.component?.javaClass intent.component?.javaClass
try { try {
@ -45,11 +40,9 @@ object ServiceManager {
Action.START_FOREGROUND -> { Action.START_FOREGROUND -> {
context.startForegroundService(intent) context.startForegroundService(intent)
} }
Action.START -> { Action.START -> {
context.startService(intent) context.startService(intent)
} }
Action.STOP -> context.startService(intent) Action.STOP -> context.startService(intent)
} }
} catch (e: Exception) { } catch (e: Exception) {
@ -57,35 +50,30 @@ object ServiceManager {
} }
} }
fun startVpnService( fun startVpnService(context: Context, tunnelConfig: String) {
context: Context,
tunnelConfig: String
) {
actionOnService( actionOnService(
Action.START, Action.START,
context, context,
WireGuardTunnelService::class.java, WireGuardTunnelService::class.java,
mapOf(context.getString(R.string.tunnel_extras_key) to tunnelConfig) mapOf(context.getString(R.string.tunnel_extras_key) to tunnelConfig),
) )
} }
fun stopVpnService(context: Context) { fun stopVpnService(context: Context) {
Timber.d("Stopping vpn service action")
actionOnService( actionOnService(
Action.STOP, Action.STOP,
context, context,
WireGuardTunnelService::class.java WireGuardTunnelService::class.java,
) )
} }
fun startVpnServiceForeground( fun startVpnServiceForeground(context: Context, tunnelConfig: String) {
context: Context,
tunnelConfig: String
) {
actionOnService( actionOnService(
Action.START_FOREGROUND, Action.START_FOREGROUND,
context, context,
WireGuardTunnelService::class.java, WireGuardTunnelService::class.java,
mapOf(context.getString(R.string.tunnel_extras_key) to tunnelConfig) mapOf(context.getString(R.string.tunnel_extras_key) to tunnelConfig),
) )
} }
@ -95,17 +83,15 @@ object ServiceManager {
actionOnService( actionOnService(
Action.START_FOREGROUND, Action.START_FOREGROUND,
context, context,
WireGuardConnectivityWatcherService::class.java WireGuardConnectivityWatcherService::class.java,
) )
} }
fun startWatcherService( fun startWatcherService(context: Context) {
context: Context
) {
actionOnService( actionOnService(
Action.START, Action.START,
context, context,
WireGuardConnectivityWatcherService::class.java WireGuardConnectivityWatcherService::class.java,
) )
} }
@ -113,7 +99,7 @@ object ServiceManager {
actionOnService( actionOnService(
Action.STOP, Action.STOP,
context, context,
WireGuardConnectivityWatcherService::class.java WireGuardConnectivityWatcherService::class.java,
) )
} }
} }

View File

@ -34,334 +34,361 @@ import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class WireGuardConnectivityWatcherService : ForegroundService() { class WireGuardConnectivityWatcherService : ForegroundService() {
private val foregroundId = 122 private val foregroundId = 122
@Inject lateinit var wifiService: NetworkService<WifiService> @Inject lateinit var wifiService: NetworkService<WifiService>
@Inject lateinit var mobileDataService: NetworkService<MobileDataService> @Inject lateinit var mobileDataService: NetworkService<MobileDataService>
@Inject lateinit var ethernetService: NetworkService<EthernetService> @Inject lateinit var ethernetService: NetworkService<EthernetService>
@Inject lateinit var settingsRepository: SettingsRepository @Inject lateinit var settingsRepository: SettingsRepository
@Inject lateinit var notificationService: NotificationService @Inject lateinit var notificationService: NotificationService
@Inject lateinit var vpnService: VpnService @Inject lateinit var vpnService: VpnService
private val networkEventsFlow = MutableStateFlow(WatcherState()) private val networkEventsFlow = MutableStateFlow(WatcherState())
data class WatcherState(
val isWifiConnected: Boolean = false,
val isVpnConnected : Boolean = false,
val isEthernetConnected: Boolean = false,
val isMobileDataConnected: Boolean = false,
val currentNetworkSSID: String = "",
val settings: Settings = Settings()
)
private lateinit var watcherJob: Job data class WatcherState(
val isWifiConnected: Boolean = false,
val isVpnConnected: Boolean = false,
val isEthernetConnected: Boolean = false,
val isMobileDataConnected: Boolean = false,
val currentNetworkSSID: String = "",
val settings: Settings = Settings()
)
private var wakeLock: PowerManager.WakeLock? = null private lateinit var watcherJob: Job
private val tag = this.javaClass.name
override fun onCreate() { private var wakeLock: PowerManager.WakeLock? = null
super.onCreate() private val tag = this.javaClass.name
lifecycleScope.launch(Dispatchers.Main) {
try { override fun onCreate() {
if(settingsRepository.getSettings().isAutoTunnelPaused) { super.onCreate()
launchWatcherPausedNotification() lifecycleScope.launch(Dispatchers.Main) {
} else launchWatcherNotification() try {
} catch (e: Exception) { if (settingsRepository.getSettings().isAutoTunnelPaused) {
Timber.e("Failed to start watcher service, not enough permissions") launchWatcherPausedNotification()
} } else launchWatcherNotification()
} catch (e: Exception) {
Timber.e("Failed to start watcher service, not enough permissions")
}
}
} }
}
override fun startService(extras: Bundle?) { override fun startService(extras: Bundle?) {
super.startService(extras) super.startService(extras)
try { try {
// we need this lock so our service gets not affected by Doze Mode // we need this lock so our service gets not affected by Doze Mode
lifecycleScope.launch { initWakeLock() } lifecycleScope.launch { initWakeLock() }
cancelWatcherJob() cancelWatcherJob()
startWatcherJob() startWatcherJob()
} catch (e: Exception) { } catch (e: Exception) {
Timber.e("Failed to launch watcher service, no permissions") Timber.e("Failed to launch watcher service, no permissions")
}
} }
}
override fun stopService(extras: Bundle?) { override fun stopService(extras: Bundle?) {
super.stopService(extras) super.stopService(extras)
wakeLock?.let { wakeLock?.let {
if (it.isHeld) { if (it.isHeld) {
it.release() it.release()
} }
}
cancelWatcherJob()
stopSelf()
} }
cancelWatcherJob()
stopSelf()
}
private fun launchWatcherNotification(description: String = getString(R.string.watcher_notification_text_active)) { private fun launchWatcherNotification(
val notification = description: String = getString(R.string.watcher_notification_text_active)
notificationService.createNotification( ) {
channelId = getString(R.string.watcher_channel_id), val notification =
channelName = getString(R.string.watcher_channel_name), notificationService.createNotification(
title = getString(R.string.auto_tunnel_title), channelId = getString(R.string.watcher_channel_id),
description = description) channelName = getString(R.string.watcher_channel_name),
ServiceCompat.startForeground( title = getString(R.string.auto_tunnel_title),
this, foregroundId, notification, Constants.SYSTEM_EXEMPT_SERVICE_TYPE_ID) description = description,
} )
ServiceCompat.startForeground(
this,
foregroundId,
notification,
Constants.SYSTEM_EXEMPT_SERVICE_TYPE_ID,
)
}
private fun launchWatcherPausedNotification() { private fun launchWatcherPausedNotification() {
launchWatcherNotification(getString(R.string.watcher_notification_text_paused)) launchWatcherNotification(getString(R.string.watcher_notification_text_paused))
} }
// TODO could this be restarting service in a bad state? // TODO could this be restarting service in a bad state?
// try to start task again if killed // try to start task again if killed
override fun onTaskRemoved(rootIntent: Intent) { override fun onTaskRemoved(rootIntent: Intent) {
Timber.d("Task Removed called") Timber.d("Task Removed called")
val restartServiceIntent = Intent(rootIntent) val restartServiceIntent = Intent(rootIntent)
val restartServicePendingIntent: PendingIntent = val restartServicePendingIntent: PendingIntent =
PendingIntent.getService( PendingIntent.getService(
this, this,
1, 1,
restartServiceIntent, restartServiceIntent,
PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE) PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE,
applicationContext.getSystemService(Context.ALARM_SERVICE) )
val alarmService: AlarmManager = applicationContext.getSystemService(Context.ALARM_SERVICE)
applicationContext.getSystemService(Context.ALARM_SERVICE) as AlarmManager val alarmService: AlarmManager =
alarmService.set( applicationContext.getSystemService(Context.ALARM_SERVICE) as AlarmManager
AlarmManager.ELAPSED_REALTIME, alarmService.set(
SystemClock.elapsedRealtime() + 1000, AlarmManager.ELAPSED_REALTIME,
restartServicePendingIntent) SystemClock.elapsedRealtime() + 1000,
} restartServicePendingIntent,
)
private suspend fun initWakeLock() {
val isBatterySaverOn =
withContext(lifecycleScope.coroutineContext) {
settingsRepository.getSettings().isBatterySaverEnabled
}
wakeLock =
(getSystemService(Context.POWER_SERVICE) as PowerManager).run {
newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "$tag::lock").apply {
if (isBatterySaverOn) {
Timber.d("Initiating wakelock with timeout")
acquire(Constants.BATTERY_SAVER_WATCHER_WAKE_LOCK_TIMEOUT)
} else {
Timber.d("Initiating wakelock with zero timeout")
acquire(Constants.DEFAULT_WATCHER_WAKE_LOCK_TIMEOUT)
}
}
}
}
private fun cancelWatcherJob() {
if (this::watcherJob.isInitialized) {
watcherJob.cancel()
} }
}
private fun startWatcherJob() { private suspend fun initWakeLock() {
watcherJob = val isBatterySaverOn =
lifecycleScope.launch(Dispatchers.IO) { withContext(lifecycleScope.coroutineContext) {
val setting = settingsRepository.getSettings() settingsRepository.getSettings().isBatterySaverEnabled
launch {
Timber.d("Starting wifi watcher")
watchForWifiConnectivityChanges()
}
if (setting.isTunnelOnMobileDataEnabled) {
launch {
Timber.d("Starting mobile data watcher")
watchForMobileDataConnectivityChanges()
} }
} wakeLock =
if (setting.isTunnelOnEthernetEnabled) { (getSystemService(Context.POWER_SERVICE) as PowerManager).run {
launch { newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "$tag::lock").apply {
Timber.d("Starting ethernet data watcher") if (isBatterySaverOn) {
watchForEthernetConnectivityChanges() Timber.d("Initiating wakelock with timeout")
acquire(Constants.BATTERY_SAVER_WATCHER_WAKE_LOCK_TIMEOUT)
} else {
Timber.d("Initiating wakelock with zero timeout")
acquire(Constants.DEFAULT_WATCHER_WAKE_LOCK_TIMEOUT)
}
}
} }
}
launch {
Timber.d("Starting vpn state watcher")
watchForVpnConnectivityChanges()
}
launch {
Timber.d("Starting settings watcher")
watchForSettingsChanges()
}
launch {
Timber.d("Starting management watcher")
manageVpn()
}
}
}
private suspend fun watchForMobileDataConnectivityChanges() {
mobileDataService.networkStatus.collect {
when (it) {
is NetworkStatus.Available -> {
Timber.d("Gained Mobile data connection")
networkEventsFlow.value = networkEventsFlow.value.copy(
isMobileDataConnected = true
)
}
is NetworkStatus.CapabilitiesChanged -> {
networkEventsFlow.value = networkEventsFlow.value.copy(
isMobileDataConnected = true
)
Timber.d("Mobile data capabilities changed")
}
is NetworkStatus.Unavailable -> {
networkEventsFlow.value = networkEventsFlow.value.copy(
isMobileDataConnected = false
)
Timber.d("Lost mobile data connection")
}
}
} }
}
private fun cancelWatcherJob() {
if (this::watcherJob.isInitialized) {
watcherJob.cancel()
}
}
private fun startWatcherJob() {
watcherJob =
lifecycleScope.launch(Dispatchers.IO) {
val setting = settingsRepository.getSettings()
launch {
Timber.d("Starting wifi watcher")
watchForWifiConnectivityChanges()
}
if (setting.isTunnelOnMobileDataEnabled) {
launch {
Timber.d("Starting mobile data watcher")
watchForMobileDataConnectivityChanges()
}
}
if (setting.isTunnelOnEthernetEnabled) {
launch {
Timber.d("Starting ethernet data watcher")
watchForEthernetConnectivityChanges()
}
}
launch {
Timber.d("Starting vpn state watcher")
watchForVpnConnectivityChanges()
}
launch {
Timber.d("Starting settings watcher")
watchForSettingsChanges()
}
launch {
Timber.d("Starting management watcher")
manageVpn()
}
}
}
private suspend fun watchForMobileDataConnectivityChanges() {
mobileDataService.networkStatus.collect {
when (it) {
is NetworkStatus.Available -> {
Timber.d("Gained Mobile data connection")
networkEventsFlow.value =
networkEventsFlow.value.copy(
isMobileDataConnected = true,
)
}
is NetworkStatus.CapabilitiesChanged -> {
networkEventsFlow.value =
networkEventsFlow.value.copy(
isMobileDataConnected = true,
)
Timber.d("Mobile data capabilities changed")
}
is NetworkStatus.Unavailable -> {
networkEventsFlow.value =
networkEventsFlow.value.copy(
isMobileDataConnected = false,
)
Timber.d("Lost mobile data connection")
}
}
}
}
private suspend fun watchForSettingsChanges() { private suspend fun watchForSettingsChanges() {
settingsRepository.getSettingsFlow().collect { settingsRepository.getSettingsFlow().collect {
if(networkEventsFlow.value.settings.isAutoTunnelPaused != it.isAutoTunnelPaused) { if (networkEventsFlow.value.settings.isAutoTunnelPaused != it.isAutoTunnelPaused) {
when(it.isAutoTunnelPaused) { when (it.isAutoTunnelPaused) {
true -> launchWatcherPausedNotification() true -> launchWatcherPausedNotification()
false -> launchWatcherNotification() false -> launchWatcherNotification()
} }
} }
networkEventsFlow.value = networkEventsFlow.value.copy( networkEventsFlow.value =
settings = it networkEventsFlow.value.copy(
) settings = it,
)
} }
} }
private suspend fun watchForVpnConnectivityChanges() { private suspend fun watchForVpnConnectivityChanges() {
vpnService.vpnState.collect { vpnService.vpnState.collect {
when(it.status) { when (it.status) {
Tunnel.State.DOWN -> networkEventsFlow.value = networkEventsFlow.value.copy( Tunnel.State.DOWN ->
isVpnConnected = false networkEventsFlow.value =
) networkEventsFlow.value.copy(
Tunnel.State.UP -> networkEventsFlow.value = networkEventsFlow.value.copy( isVpnConnected = false,
isVpnConnected = true )
) Tunnel.State.UP ->
networkEventsFlow.value =
networkEventsFlow.value.copy(
isVpnConnected = true,
)
else -> {} else -> {}
} }
} }
} }
private suspend fun watchForEthernetConnectivityChanges() { private suspend fun watchForEthernetConnectivityChanges() {
ethernetService.networkStatus.collect { ethernetService.networkStatus.collect {
when (it) { when (it) {
is NetworkStatus.Available -> { is NetworkStatus.Available -> {
Timber.d("Gained Ethernet connection") Timber.d("Gained Ethernet connection")
networkEventsFlow.value = networkEventsFlow.value.copy( networkEventsFlow.value =
isEthernetConnected = true networkEventsFlow.value.copy(
) isEthernetConnected = true,
} )
is NetworkStatus.CapabilitiesChanged -> {
Timber.d("Ethernet capabilities changed")
networkEventsFlow.value = networkEventsFlow.value.copy(
isEthernetConnected = true
)
}
is NetworkStatus.Unavailable -> {
networkEventsFlow.value = networkEventsFlow.value.copy(
isEthernetConnected = false
)
Timber.d("Lost Ethernet connection")
}
}
}
}
private suspend fun watchForWifiConnectivityChanges() {
wifiService.networkStatus.collect {
when (it) {
is NetworkStatus.Available -> {
Timber.d("Gained Wi-Fi connection")
networkEventsFlow.value = networkEventsFlow.value.copy(
isWifiConnected = true
)
}
is NetworkStatus.CapabilitiesChanged -> {
Timber.d("Wifi capabilities changed")
networkEventsFlow.value = networkEventsFlow.value.copy(
isWifiConnected = true
)
val ssid = wifiService.getNetworkName(it.networkCapabilities) ?: ""
Timber.d("Detected SSID: $ssid")
networkEventsFlow.value = networkEventsFlow.value.copy(
currentNetworkSSID = ssid
)
}
is NetworkStatus.Unavailable -> {
networkEventsFlow.value = networkEventsFlow.value.copy(
isWifiConnected = false
)
Timber.d("Lost Wi-Fi connection")
}
}
}
}
//TODO clean this up
private suspend fun manageVpn() {
networkEventsFlow.collectLatest {
Timber.i("New watcher state: $it")
if (!it.settings.isAutoTunnelPaused && it.settings.defaultTunnel != null) {
delay(Constants.TOGGLE_TUNNEL_DELAY)
when {
((it.isEthernetConnected &&
it.settings.isTunnelOnEthernetEnabled &&
!it.isVpnConnected)) -> {
ServiceManager.startVpnServiceForeground(this, it.settings.defaultTunnel!!)
Timber.i("Condition 1 met")
} }
(!it.isEthernetConnected && is NetworkStatus.CapabilitiesChanged -> {
it.settings.isTunnelOnMobileDataEnabled && Timber.d("Ethernet capabilities changed")
!it.isWifiConnected && networkEventsFlow.value =
it.isMobileDataConnected && networkEventsFlow.value.copy(
!it.isVpnConnected) -> { isEthernetConnected = true,
ServiceManager.startVpnServiceForeground(this, it.settings.defaultTunnel!!) )
Timber.i("Condition 2 met")
} }
(!it.isEthernetConnected && is NetworkStatus.Unavailable -> {
!it.settings.isTunnelOnMobileDataEnabled && networkEventsFlow.value =
!it.isWifiConnected && networkEventsFlow.value.copy(
it.isVpnConnected) -> { isEthernetConnected = false,
ServiceManager.stopVpnService(this) )
Timber.i("Condition 3 met") Timber.d("Lost Ethernet connection")
} }
(!it.isEthernetConnected && }
it.isWifiConnected && }
!it.settings.trustedNetworkSSIDs.contains(it.currentNetworkSSID) && }
it.settings.isTunnelOnWifiEnabled &&
(!it.isVpnConnected)) -> { private suspend fun watchForWifiConnectivityChanges() {
ServiceManager.startVpnServiceForeground(this, it.settings.defaultTunnel!!) wifiService.networkStatus.collect {
Timber.i("Condition 4 met") when (it) {
} is NetworkStatus.Available -> {
(!it.isEthernetConnected && Timber.d("Gained Wi-Fi connection")
(it.isWifiConnected && it.settings.trustedNetworkSSIDs.contains(it.currentNetworkSSID)) && networkEventsFlow.value =
(it.isVpnConnected)) -> { networkEventsFlow.value.copy(
ServiceManager.stopVpnService(this) isWifiConnected = true,
Timber.i("Condition 5 met") )
} }
(!it.isEthernetConnected && is NetworkStatus.CapabilitiesChanged -> {
(it.isWifiConnected && Timber.d("Wifi capabilities changed")
!it.settings.isTunnelOnWifiEnabled && networkEventsFlow.value =
(it.isVpnConnected))) -> { networkEventsFlow.value.copy(
ServiceManager.stopVpnService(this) isWifiConnected = true,
Timber.i("Condition 6 met") )
} val ssid = wifiService.getNetworkName(it.networkCapabilities) ?: ""
(!it.isEthernetConnected && Timber.d("Detected SSID: $ssid")
!it.isWifiConnected && networkEventsFlow.value =
!it.isMobileDataConnected && networkEventsFlow.value.copy(
(it.isVpnConnected)) -> { currentNetworkSSID = ssid,
ServiceManager.stopVpnService(this) )
Timber.i("Condition 7 met") }
} is NetworkStatus.Unavailable -> {
else -> { networkEventsFlow.value =
Timber.i("No condition met") networkEventsFlow.value.copy(
isWifiConnected = false,
)
Timber.d("Lost Wi-Fi connection")
}
}
}
}
// TODO clean this up
private suspend fun manageVpn() {
networkEventsFlow.collectLatest {
Timber.i("New watcher state: $it")
if (!it.settings.isAutoTunnelPaused && it.settings.defaultTunnel != null) {
delay(Constants.TOGGLE_TUNNEL_DELAY)
when {
((it.isEthernetConnected &&
it.settings.isTunnelOnEthernetEnabled &&
!it.isVpnConnected)) -> {
ServiceManager.startVpnServiceForeground(this, it.settings.defaultTunnel!!)
Timber.i("Condition 1 met")
}
(!it.isEthernetConnected &&
it.settings.isTunnelOnMobileDataEnabled &&
!it.isWifiConnected &&
it.isMobileDataConnected &&
!it.isVpnConnected) -> {
ServiceManager.startVpnServiceForeground(this, it.settings.defaultTunnel!!)
Timber.i("Condition 2 met")
}
(!it.isEthernetConnected &&
!it.settings.isTunnelOnMobileDataEnabled &&
!it.isWifiConnected &&
it.isVpnConnected) -> {
ServiceManager.stopVpnService(this)
Timber.i("Condition 3 met")
}
(!it.isEthernetConnected &&
it.isWifiConnected &&
!it.settings.trustedNetworkSSIDs.contains(it.currentNetworkSSID) &&
it.settings.isTunnelOnWifiEnabled &&
(!it.isVpnConnected)) -> {
ServiceManager.startVpnServiceForeground(this, it.settings.defaultTunnel!!)
Timber.i("Condition 4 met")
}
(!it.isEthernetConnected &&
(it.isWifiConnected &&
it.settings.trustedNetworkSSIDs.contains(it.currentNetworkSSID)) &&
(it.isVpnConnected)) -> {
ServiceManager.stopVpnService(this)
Timber.i("Condition 5 met")
}
(!it.isEthernetConnected &&
(it.isWifiConnected &&
!it.settings.isTunnelOnWifiEnabled &&
(it.isVpnConnected))) -> {
ServiceManager.stopVpnService(this)
Timber.i("Condition 6 met")
}
(!it.isEthernetConnected &&
!it.isWifiConnected &&
!it.isMobileDataConnected &&
(it.isVpnConnected)) -> {
ServiceManager.stopVpnService(this)
Timber.i("Condition 7 met")
}
else -> {
Timber.i("No condition met")
}
} }
} }
} }
} }
}
} }

View File

@ -28,17 +28,13 @@ import javax.inject.Inject
class WireGuardTunnelService : ForegroundService() { class WireGuardTunnelService : ForegroundService() {
private val foregroundId = 123 private val foregroundId = 123
@Inject @Inject lateinit var vpnService: VpnService
lateinit var vpnService: VpnService
@Inject @Inject lateinit var settingsRepository: SettingsRepository
lateinit var settingsRepository: SettingsRepository
@Inject @Inject lateinit var tunnelConfigRepository: TunnelConfigRepository
lateinit var tunnelConfigRepository: TunnelConfigRepository
@Inject @Inject lateinit var notificationService: NotificationService
lateinit var notificationService: NotificationService
private lateinit var job: Job private lateinit var job: Job
@ -48,7 +44,7 @@ class WireGuardTunnelService : ForegroundService() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
lifecycleScope.launch(Dispatchers.Main) { lifecycleScope.launch(Dispatchers.Main) {
if(tunnelConfigRepository.getAll().isNotEmpty()) { if (tunnelConfigRepository.getAll().isNotEmpty()) {
launchVpnNotification() launchVpnNotification()
} }
} }
@ -58,11 +54,10 @@ class WireGuardTunnelService : ForegroundService() {
super.startService(extras) super.startService(extras)
cancelJob() cancelJob()
val tunnelConfigString = extras?.getString(getString(R.string.tunnel_extras_key)) val tunnelConfigString = extras?.getString(getString(R.string.tunnel_extras_key))
val tunnelConfig = tunnelConfigString?.let { val tunnelConfig = tunnelConfigString?.let { TunnelConfig.from(it) }
TunnelConfig.from(it)
}
tunnelName = tunnelConfig?.name ?: "" tunnelName = tunnelConfig?.name ?: ""
job = lifecycleScope.launch(Dispatchers.IO) { job =
lifecycleScope.launch(Dispatchers.IO) {
launch { launch {
if (tunnelConfig != null) { if (tunnelConfig != null) {
try { try {
@ -77,22 +72,22 @@ class WireGuardTunnelService : ForegroundService() {
val settings = settingsRepository.getSettings() val settings = settingsRepository.getSettings()
val tunnels = tunnelConfigRepository.getAll() val tunnels = tunnelConfigRepository.getAll()
if (settings.isAlwaysOnVpnEnabled) { if (settings.isAlwaysOnVpnEnabled) {
val tunnel = if(settings.defaultTunnel != null) { val tunnel =
TunnelConfig.from(settings.defaultTunnel!!) if (settings.defaultTunnel != null) {
} else if(tunnels.isNotEmpty()) { TunnelConfig.from(settings.defaultTunnel!!)
tunnels.first() } else if (tunnels.isNotEmpty()) {
} else { tunnels.first()
null } else {
} null
if(tunnel != null) { }
if (tunnel != null) {
tunnelName = tunnel.name tunnelName = tunnel.name
vpnService.startTunnel(tunnel) vpnService.startTunnel(tunnel)
} }
} }
} }
} }
//TODO add failed to connect notification // TODO add failed to connect notification
launch { launch {
vpnService.vpnState.collect { state -> vpnService.vpnState.collect { state ->
state.statistics state.statistics
@ -101,14 +96,18 @@ class WireGuardTunnelService : ForegroundService() {
.let { statuses -> .let { statuses ->
when { when {
statuses?.all { it == HandshakeStatus.HEALTHY } == true -> { statuses?.all { it == HandshakeStatus.HEALTHY } == true -> {
if(!didShowConnected){ if (!didShowConnected) {
delay(Constants.VPN_CONNECTED_NOTIFICATION_DELAY) delay(Constants.VPN_CONNECTED_NOTIFICATION_DELAY)
launchVpnNotification(getString(R.string.tunnel_start_title),"${getString(R.string.tunnel_start_text)} $tunnelName") launchVpnNotification(
getString(R.string.tunnel_start_title),
"${getString(R.string.tunnel_start_text)} $tunnelName",
)
didShowConnected = true didShowConnected = true
} }
} }
statuses?.any { it == HandshakeStatus.STALE } == true -> {} statuses?.any { it == HandshakeStatus.STALE } == true -> {}
statuses?.all { it == HandshakeStatus.NOT_STARTED } == true -> {} statuses?.all { it == HandshakeStatus.NOT_STARTED } ==
true -> {}
else -> {} else -> {}
} }
} }
@ -127,7 +126,10 @@ class WireGuardTunnelService : ForegroundService() {
stopSelf() stopSelf()
} }
private fun launchVpnNotification(title : String = getString(R.string.vpn_starting),description : String = getString(R.string.attempt_connection)) { private fun launchVpnNotification(
title: String = getString(R.string.vpn_starting),
description: String = getString(R.string.attempt_connection)
) {
val notification = val notification =
notificationService.createNotification( notificationService.createNotification(
channelId = getString(R.string.vpn_channel_id), channelId = getString(R.string.vpn_channel_id),
@ -136,13 +138,13 @@ class WireGuardTunnelService : ForegroundService() {
onGoing = false, onGoing = false,
vibration = false, vibration = false,
showTimestamp = true, showTimestamp = true,
description = description description = description,
) )
ServiceCompat.startForeground( ServiceCompat.startForeground(
this, this,
foregroundId, foregroundId,
notification, notification,
Constants.SYSTEM_EXEMPT_SERVICE_TYPE_ID Constants.SYSTEM_EXEMPT_SERVICE_TYPE_ID,
) )
} }
@ -152,24 +154,24 @@ class WireGuardTunnelService : ForegroundService() {
channelId = getString(R.string.vpn_channel_id), channelId = getString(R.string.vpn_channel_id),
channelName = getString(R.string.vpn_channel_name), channelName = getString(R.string.vpn_channel_name),
action = action =
PendingIntent.getBroadcast( PendingIntent.getBroadcast(
this, this,
0, 0,
Intent(this, NotificationActionReceiver::class.java), Intent(this, NotificationActionReceiver::class.java),
PendingIntent.FLAG_IMMUTABLE PendingIntent.FLAG_IMMUTABLE,
), ),
actionText = getString(R.string.restart), actionText = getString(R.string.restart),
title = getString(R.string.vpn_connection_failed), title = getString(R.string.vpn_connection_failed),
onGoing = false, onGoing = false,
vibration = true, vibration = true,
showTimestamp = true, showTimestamp = true,
description = message description = message,
) )
ServiceCompat.startForeground( ServiceCompat.startForeground(
this, this,
foregroundId, foregroundId,
notification, notification,
Constants.SYSTEM_EXEMPT_SERVICE_TYPE_ID Constants.SYSTEM_EXEMPT_SERVICE_TYPE_ID,
) )
} }

View File

@ -24,72 +24,69 @@ abstract class BaseNetworkService<T : BaseNetworkService<T>>(
private val wifiManager = private val wifiManager =
context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
override val networkStatus = override val networkStatus = callbackFlow {
callbackFlow { val networkStatusCallback =
val networkStatusCallback = when (Build.VERSION.SDK_INT) {
when (Build.VERSION.SDK_INT) { in Build.VERSION_CODES.S..Int.MAX_VALUE -> {
in Build.VERSION_CODES.S..Int.MAX_VALUE -> { object :
object : ConnectivityManager.NetworkCallback( ConnectivityManager.NetworkCallback(
FLAG_INCLUDE_LOCATION_INFO FLAG_INCLUDE_LOCATION_INFO,
) { ) {
override fun onAvailable(network: Network) { override fun onAvailable(network: Network) {
trySend(NetworkStatus.Available(network)) trySend(NetworkStatus.Available(network))
}
override fun onLost(network: Network) {
trySend(NetworkStatus.Unavailable(network))
}
override fun onCapabilitiesChanged(
network: Network,
networkCapabilities: NetworkCapabilities
) {
trySend(
NetworkStatus.CapabilitiesChanged(
network,
networkCapabilities
)
)
}
} }
}
else -> { override fun onLost(network: Network) {
object : ConnectivityManager.NetworkCallback() { trySend(NetworkStatus.Unavailable(network))
override fun onAvailable(network: Network) { }
trySend(NetworkStatus.Available(network))
}
override fun onLost(network: Network) { override fun onCapabilitiesChanged(
trySend(NetworkStatus.Unavailable(network)) network: Network,
} networkCapabilities: NetworkCapabilities
) {
override fun onCapabilitiesChanged( trySend(
network: Network, NetworkStatus.CapabilitiesChanged(
networkCapabilities: NetworkCapabilities network,
) { networkCapabilities,
trySend( ),
NetworkStatus.CapabilitiesChanged( )
network,
networkCapabilities
)
)
}
} }
} }
} }
val request = else -> {
NetworkRequest.Builder() object : ConnectivityManager.NetworkCallback() {
.addTransportType(networkCapability) override fun onAvailable(network: Network) {
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) trySend(NetworkStatus.Available(network))
.addCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) }
.build()
connectivityManager.registerNetworkCallback(request, networkStatusCallback)
awaitClose { override fun onLost(network: Network) {
connectivityManager.unregisterNetworkCallback(networkStatusCallback) trySend(NetworkStatus.Unavailable(network))
}
override fun onCapabilitiesChanged(
network: Network,
networkCapabilities: NetworkCapabilities
) {
trySend(
NetworkStatus.CapabilitiesChanged(
network,
networkCapabilities,
),
)
}
}
}
} }
} val request =
NetworkRequest.Builder()
.addTransportType(networkCapability)
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.addCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
.build()
connectivityManager.registerNetworkCallback(request, networkStatusCallback)
awaitClose { connectivityManager.unregisterNetworkCallback(networkStatusCallback) }
}
override fun getNetworkName(networkCapabilities: NetworkCapabilities): String? { override fun getNetworkName(networkCapabilities: NetworkCapabilities): String? {
var ssid: String? = getWifiNameFromCapabilities(networkCapabilities) var ssid: String? = getWifiNameFromCapabilities(networkCapabilities)
@ -119,18 +116,16 @@ abstract class BaseNetworkService<T : BaseNetworkService<T>>(
inline fun <Result> Flow<NetworkStatus>.map( inline fun <Result> Flow<NetworkStatus>.map(
crossinline onUnavailable: suspend (network: Network) -> Result, crossinline onUnavailable: suspend (network: Network) -> Result,
crossinline onAvailable: suspend (network: Network) -> Result, crossinline onAvailable: suspend (network: Network) -> Result,
crossinline onCapabilitiesChanged: suspend ( crossinline onCapabilitiesChanged:
network: Network, suspend (network: Network, networkCapabilities: NetworkCapabilities) -> Result
networkCapabilities: NetworkCapabilities ): Flow<Result> = map { status ->
) -> Result when (status) {
): Flow<Result> = is NetworkStatus.Unavailable -> onUnavailable(status.network)
map { status -> is NetworkStatus.Available -> onAvailable(status.network)
when (status) { is NetworkStatus.CapabilitiesChanged ->
is NetworkStatus.Unavailable -> onUnavailable(status.network) onCapabilitiesChanged(
is NetworkStatus.Available -> onAvailable(status.network)
is NetworkStatus.CapabilitiesChanged -> onCapabilitiesChanged(
status.network, status.network,
status.networkCapabilities status.networkCapabilities,
) )
}
} }
}

View File

@ -5,9 +5,5 @@ import android.net.NetworkCapabilities
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject import javax.inject.Inject
class EthernetService class EthernetService @Inject constructor(@ApplicationContext context: Context) :
@Inject
constructor(
@ApplicationContext context: Context
) :
BaseNetworkService<EthernetService>(context, NetworkCapabilities.TRANSPORT_ETHERNET) BaseNetworkService<EthernetService>(context, NetworkCapabilities.TRANSPORT_ETHERNET)

View File

@ -5,9 +5,5 @@ import android.net.NetworkCapabilities
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject import javax.inject.Inject
class MobileDataService class MobileDataService @Inject constructor(@ApplicationContext context: Context) :
@Inject
constructor(
@ApplicationContext context: Context
) :
BaseNetworkService<MobileDataService>(context, NetworkCapabilities.TRANSPORT_CELLULAR) BaseNetworkService<MobileDataService>(context, NetworkCapabilities.TRANSPORT_CELLULAR)

View File

@ -5,9 +5,5 @@ import android.net.NetworkCapabilities
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject import javax.inject.Inject
class WifiService class WifiService @Inject constructor(@ApplicationContext context: Context) :
@Inject
constructor(
@ApplicationContext context: Context
) :
BaseNetworkService<WifiService>(context, NetworkCapabilities.TRANSPORT_WIFI) BaseNetworkService<WifiService>(context, NetworkCapabilities.TRANSPORT_WIFI)

View File

@ -3,7 +3,6 @@ package com.zaneschepke.wireguardautotunnel.service.notification
import android.app.Notification import android.app.Notification
import android.app.NotificationManager import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
import androidx.core.app.NotificationCompat
interface NotificationService { interface NotificationService {
fun createNotification( fun createNotification(

View File

@ -13,23 +13,21 @@ import com.zaneschepke.wireguardautotunnel.ui.MainActivity
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject import javax.inject.Inject
class WireGuardNotification class WireGuardNotification @Inject constructor(@ApplicationContext private val context: Context) :
@Inject NotificationService {
constructor(
@ApplicationContext private val context: Context
) : NotificationService {
private val notificationManager = private val notificationManager =
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
private val watcherBuilder: NotificationCompat.Builder = private val watcherBuilder: NotificationCompat.Builder =
NotificationCompat.Builder( NotificationCompat.Builder(
context, context,
context.getString(R.string.watcher_channel_id) context.getString(R.string.watcher_channel_id),
)
private val tunnelBuilder: NotificationCompat.Builder =
NotificationCompat.Builder(
context,
context.getString(R.string.vpn_channel_id),
) )
private val tunnelBuilder: NotificationCompat.Builder = NotificationCompat.Builder(
context,
context.getString(R.string.vpn_channel_id)
)
override fun createNotification( override fun createNotification(
channelId: String, channelId: String,
@ -47,17 +45,18 @@ constructor(
): Notification { ): Notification {
val channel = val channel =
NotificationChannel( NotificationChannel(
channelId, channelId,
channelName, channelName,
importance importance,
).let { )
it.description = title .let {
it.enableLights(lights) it.description = title
it.lightColor = Color.RED it.enableLights(lights)
it.enableVibration(vibration) it.lightColor = Color.RED
it.vibrationPattern = longArrayOf(100,200,300) it.enableVibration(vibration)
it it.vibrationPattern = longArrayOf(100, 200, 300)
} it
}
notificationManager.createNotificationChannel(channel) notificationManager.createNotificationChannel(channel)
val pendingIntent: PendingIntent = val pendingIntent: PendingIntent =
Intent(context, MainActivity::class.java).let { notificationIntent -> Intent(context, MainActivity::class.java).let { notificationIntent ->
@ -65,26 +64,26 @@ constructor(
context, context,
0, 0,
notificationIntent, notificationIntent,
PendingIntent.FLAG_IMMUTABLE PendingIntent.FLAG_IMMUTABLE,
) )
} }
val builder = when(channelId) { val builder =
context.getString(R.string.watcher_channel_id) -> watcherBuilder when (channelId) {
context.getString(R.string.vpn_channel_id) -> tunnelBuilder context.getString(R.string.watcher_channel_id) -> watcherBuilder
else -> { context.getString(R.string.vpn_channel_id) -> tunnelBuilder
NotificationCompat.Builder( else -> {
context, NotificationCompat.Builder(
channelId context,
) channelId,
)
}
} }
}
return builder.let { return builder.let {
if (action != null && actionText != null) { if (action != null && actionText != null) {
it.addAction( it.addAction(
NotificationCompat.Action.Builder(0, actionText, action) NotificationCompat.Action.Builder(0, actionText, action).build(),
.build()
) )
it.setAutoCancel(true) it.setAutoCancel(true)
} }

View File

@ -12,33 +12,34 @@ import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import com.zaneschepke.wireguardautotunnel.service.foreground.WireGuardTunnelService import com.zaneschepke.wireguardautotunnel.service.foreground.WireGuardTunnelService
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class ShortcutsActivity : ComponentActivity() { class ShortcutsActivity : ComponentActivity() {
@Inject @Inject lateinit var settingsRepository: SettingsRepository
lateinit var settingsRepository: SettingsRepository
@Inject @Inject lateinit var tunnelConfigRepository: TunnelConfigRepository
lateinit var tunnelConfigRepository: TunnelConfigRepository
private suspend fun toggleWatcherServicePause() { private suspend fun toggleWatcherServicePause() {
val settings = settingsRepository.getSettings() val settings = settingsRepository.getSettings()
if (settings.isAutoTunnelEnabled) { if (settings.isAutoTunnelEnabled) {
val pauseAutoTunnel = !settings.isAutoTunnelPaused val pauseAutoTunnel = !settings.isAutoTunnelPaused
settingsRepository.save(settings.copy( settingsRepository.save(
isAutoTunnelPaused = pauseAutoTunnel settings.copy(
)) isAutoTunnelPaused = pauseAutoTunnel,
} ),
)
} }
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(View(this)) setContentView(View(this))
if (intent.getStringExtra(CLASS_NAME_EXTRA_KEY) if (
intent
.getStringExtra(CLASS_NAME_EXTRA_KEY)
.equals(WireGuardTunnelService::class.java.simpleName) .equals(WireGuardTunnelService::class.java.simpleName)
) { ) {
lifecycleScope.launch(Dispatchers.Main) { lifecycleScope.launch(Dispatchers.Main) {
@ -48,7 +49,9 @@ class ShortcutsActivity : ComponentActivity() {
val tunnelName = intent.getStringExtra(TUNNEL_NAME_EXTRA_KEY) val tunnelName = intent.getStringExtra(TUNNEL_NAME_EXTRA_KEY)
val tunnelConfig = val tunnelConfig =
if (tunnelName != null) { if (tunnelName != null) {
tunnelConfigRepository.getAll().firstOrNull { it.name == tunnelName } tunnelConfigRepository.getAll().firstOrNull {
it.name == tunnelName
}
} else { } else {
if (settings.defaultTunnel == null) { if (settings.defaultTunnel == null) {
tunnelConfigRepository.getAll().first() tunnelConfigRepository.getAll().first()
@ -59,13 +62,15 @@ class ShortcutsActivity : ComponentActivity() {
tunnelConfig ?: return@launch tunnelConfig ?: return@launch
toggleWatcherServicePause() toggleWatcherServicePause()
when (intent.action) { when (intent.action) {
Action.STOP.name -> ServiceManager.stopVpnService( Action.STOP.name ->
this@ShortcutsActivity ServiceManager.stopVpnService(
) this@ShortcutsActivity,
Action.START.name -> ServiceManager.startVpnServiceForeground( )
this@ShortcutsActivity, Action.START.name ->
tunnelConfig.toString() ServiceManager.startVpnServiceForeground(
) this@ShortcutsActivity,
tunnelConfig.toString(),
)
} }
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e.message) Timber.e(e.message)

View File

@ -20,44 +20,43 @@ import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class TunnelControlTile() : TileService() { class TunnelControlTile() : TileService() {
@Inject @Inject lateinit var tunnelConfigRepository: TunnelConfigRepository
lateinit var tunnelConfigRepository: TunnelConfigRepository
@Inject @Inject lateinit var settingsRepository: SettingsRepository
lateinit var settingsRepository: SettingsRepository
@Inject @Inject lateinit var vpnService: VpnService
lateinit var vpnService: VpnService
private val scope = CoroutineScope(Dispatchers.IO) private val scope = CoroutineScope(Dispatchers.IO)
private var tunnelName : String? = null private var tunnelName: String? = null
override fun onStartListening() { override fun onStartListening() {
super.onStartListening() super.onStartListening()
Timber.d("On start listening called") Timber.d("On start listening called")
scope.launch { scope.launch {
vpnService.vpnState.collect { vpnService.vpnState.collect {
when(it.status) { when (it.status) {
Tunnel.State.UP -> setActive() Tunnel.State.UP -> setActive()
Tunnel.State.DOWN -> setInactive() Tunnel.State.DOWN -> setInactive()
else -> setInactive() else -> setInactive()
} }
val tunnels = tunnelConfigRepository.getAll() val tunnels = tunnelConfigRepository.getAll()
if(tunnels.isEmpty()) { if (tunnels.isEmpty()) {
setUnavailable() setUnavailable()
return@collect return@collect
} }
tunnelName = it.name.ifBlank { tunnelName =
val settings = settingsRepository.getSettings() it.name.ifBlank {
if (settings.defaultTunnel != null) { val settings = settingsRepository.getSettings()
TunnelConfig.from(settings.defaultTunnel!!).name if (settings.defaultTunnel != null) {
} else tunnels.firstOrNull()?.name TunnelConfig.from(settings.defaultTunnel!!).name
} } else tunnels.firstOrNull()?.name
}
setTileDescription(tunnelName ?: "") setTileDescription(tunnelName ?: "")
} }
} }
} }
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
scope.cancel() scope.cancel()
@ -73,14 +72,15 @@ class TunnelControlTile() : TileService() {
unlockAndRun { unlockAndRun {
scope.launch { scope.launch {
try { try {
val tunnelConfig = tunnelConfigRepository.getAll().first { it.name == tunnelName } val tunnelConfig =
tunnelConfigRepository.getAll().first { it.name == tunnelName }
toggleWatcherServicePause() toggleWatcherServicePause()
if (vpnService.getState() == Tunnel.State.UP) { if (vpnService.getState() == Tunnel.State.UP) {
ServiceManager.stopVpnService(this@TunnelControlTile) ServiceManager.stopVpnService(this@TunnelControlTile)
} else { } else {
ServiceManager.startVpnServiceForeground( ServiceManager.startVpnServiceForeground(
this@TunnelControlTile, this@TunnelControlTile,
tunnelConfig.toString() tunnelConfig.toString(),
) )
} }
} catch (e: Exception) { } catch (e: Exception) {
@ -97,9 +97,11 @@ class TunnelControlTile() : TileService() {
val settings = settingsRepository.getSettings() val settings = settingsRepository.getSettings()
if (settings.isAutoTunnelEnabled) { if (settings.isAutoTunnelEnabled) {
val pauseAutoTunnel = !settings.isAutoTunnelPaused val pauseAutoTunnel = !settings.isAutoTunnelPaused
settingsRepository.save(settings.copy( settingsRepository.save(
isAutoTunnelPaused = pauseAutoTunnel settings.copy(
)) isAutoTunnelPaused = pauseAutoTunnel,
),
)
} }
} }
} }

View File

@ -4,13 +4,13 @@ enum class HandshakeStatus {
HEALTHY, HEALTHY,
STALE, STALE,
UNKNOWN, UNKNOWN,
NOT_STARTED NOT_STARTED;
;
companion object { companion object {
private const val WG_TYPICAL_HANDSHAKE_INTERVAL_WHEN_HEALTHY_SEC = 180 private const val WG_TYPICAL_HANDSHAKE_INTERVAL_WHEN_HEALTHY_SEC = 180
const val STATUS_CHANGE_TIME_BUFFER = 30 const val STATUS_CHANGE_TIME_BUFFER = 30
const val STALE_TIME_LIMIT_SEC = WG_TYPICAL_HANDSHAKE_INTERVAL_WHEN_HEALTHY_SEC + STATUS_CHANGE_TIME_BUFFER const val STALE_TIME_LIMIT_SEC =
WG_TYPICAL_HANDSHAKE_INTERVAL_WHEN_HEALTHY_SEC + STATUS_CHANGE_TIME_BUFFER
const val NEVER_CONNECTED_TO_UNHEALTHY_TIME_LIMIT_SEC = 30 const val NEVER_CONNECTED_TO_UNHEALTHY_TIME_LIMIT_SEC = 30
} }
} }

View File

@ -4,7 +4,7 @@ import com.wireguard.android.backend.Statistics
import com.wireguard.android.backend.Tunnel import com.wireguard.android.backend.Tunnel
data class VpnState( data class VpnState(
val status : Tunnel.State = Tunnel.State.DOWN, val status: Tunnel.State = Tunnel.State.DOWN,
val name : String = "", val name: String = "",
val statistics : Statistics? = null val statistics: Statistics? = null
) )

View File

@ -67,7 +67,7 @@ constructor(
backend.setState( backend.setState(
this, this,
State.UP, State.UP,
config config,
) )
emitTunnelState(state) emitTunnelState(state)
state state
@ -80,24 +80,24 @@ constructor(
private fun emitTunnelState(state: State) { private fun emitTunnelState(state: State) {
_vpnState.tryEmit( _vpnState.tryEmit(
_vpnState.value.copy( _vpnState.value.copy(
status = state status = state,
) ),
) )
} }
private fun emitBackendStatistics(statistics: Statistics) { private fun emitBackendStatistics(statistics: Statistics) {
_vpnState.tryEmit( _vpnState.tryEmit(
_vpnState.value.copy( _vpnState.value.copy(
statistics = statistics statistics = statistics,
) ),
) )
} }
private suspend fun emitTunnelName(name: String) { private suspend fun emitTunnelName(name: String) {
_vpnState.emit( _vpnState.emit(
_vpnState.value.copy( _vpnState.value.copy(
name = name name = name,
) ),
) )
} }

View File

@ -6,8 +6,8 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class ActivityViewModel @Inject constructor( class ActivityViewModel
@Inject
constructor(
private val settingsRepo: SettingsDao, private val settingsRepo: SettingsDao,
) : ViewModel() { ) : ViewModel() {}
}

View File

@ -28,6 +28,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
@ -37,6 +38,9 @@ import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState import com.google.accompanist.permissions.rememberPermissionState
import com.wireguard.android.backend.GoBackend import com.wireguard.android.backend.GoBackend
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.zaneschepke.wireguardautotunnel.data.datastore.DataStoreManager
import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepository
import com.zaneschepke.wireguardautotunnel.ui.common.PermissionRequestFailedScreen import com.zaneschepke.wireguardautotunnel.ui.common.PermissionRequestFailedScreen
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.BottomNavBar import com.zaneschepke.wireguardautotunnel.ui.common.navigation.BottomNavBar
import com.zaneschepke.wireguardautotunnel.ui.common.prompt.CustomSnackBar import com.zaneschepke.wireguardautotunnel.ui.common.prompt.CustomSnackBar
@ -50,19 +54,40 @@ import com.zaneschepke.wireguardautotunnel.util.Constants
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber
import java.io.IOException
import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
@Inject
lateinit var dataStoreManager: DataStoreManager
@Inject lateinit var settingsRepository: SettingsRepository
@OptIn( @OptIn(
ExperimentalPermissionsApi::class ExperimentalPermissionsApi::class,
) )
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
// load preferences into memory and init data
lifecycleScope.launch {
try {
dataStoreManager.init()
if (settingsRepository.getAll().isEmpty()) {
settingsRepository.save(com.zaneschepke.wireguardautotunnel.data.model.Settings())
}
WireGuardAutoTunnel.requestTileServiceStateUpdate()
} catch (e: IOException) {
Timber.e("Failed to load preferences")
}
}
setContent { setContent {
// val activityViewModel = hiltViewModel<ActivityViewModel>() // val activityViewModel = hiltViewModel<ActivityViewModel>()
val navController = rememberNavController() val navController = rememberNavController()
val focusRequester = remember { FocusRequester()} val focusRequester = remember { FocusRequester() }
WireguardAutoTunnelTheme { WireguardAutoTunnelTheme {
TransparentSystemBars() TransparentSystemBars()
@ -73,7 +98,10 @@ class MainActivity : AppCompatActivity() {
rememberPermissionState(Manifest.permission.POST_NOTIFICATIONS) rememberPermissionState(Manifest.permission.POST_NOTIFICATIONS)
fun requestNotificationPermission() { fun requestNotificationPermission() {
if (!notificationPermissionState.status.isGranted && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if (
!notificationPermissionState.status.isGranted &&
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
) {
notificationPermissionState.launchPermissionRequest() notificationPermissionState.launchPermissionRequest()
} }
} }
@ -87,7 +115,7 @@ class MainActivity : AppCompatActivity() {
if (accepted) { if (accepted) {
vpnIntent = null vpnIntent = null
} }
} },
) )
LaunchedEffect(vpnIntent) { LaunchedEffect(vpnIntent) {
if (vpnIntent != null) { if (vpnIntent != null) {
@ -99,13 +127,15 @@ class MainActivity : AppCompatActivity() {
fun showSnackBarMessage(message: String) { fun showSnackBarMessage(message: String) {
lifecycleScope.launch(Dispatchers.Main) { lifecycleScope.launch(Dispatchers.Main) {
val result = snackbarHostState.showSnackbar( val result =
snackbarHostState.showSnackbar(
message = message, message = message,
actionLabel = applicationContext.getString(R.string.okay), actionLabel = applicationContext.getString(R.string.okay),
duration = SnackbarDuration.Short duration = SnackbarDuration.Short,
) )
when (result) { when (result) {
SnackbarResult.ActionPerformed, SnackbarResult.Dismissed -> { SnackbarResult.ActionPerformed,
SnackbarResult.Dismissed -> {
snackbarHostState.currentSnackbarData?.dismiss() snackbarHostState.currentSnackbarData?.dismiss()
} }
} }
@ -118,29 +148,36 @@ class MainActivity : AppCompatActivity() {
CustomSnackBar( CustomSnackBar(
snackbarData.visuals.message, snackbarData.visuals.message,
isRtl = false, isRtl = false,
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation( containerColor =
2.dp MaterialTheme.colorScheme.surfaceColorAtElevation(
) 2.dp,
),
) )
} }
}, },
modifier = Modifier.focusable().focusProperties { up = focusRequester }, modifier = Modifier.focusable().focusProperties { up = focusRequester },
bottomBar = bottomBar =
if (vpnIntent == null && notificationPermissionState.status.isGranted) { if (vpnIntent == null && notificationPermissionState.status.isGranted) {
{ BottomNavBar(navController, listOf( {
Screen.Main.navItem, BottomNavBar(
Screen.Settings.navItem, navController,
Screen.Support.navItem)) } listOf(
} else { Screen.Main.navItem,
{} Screen.Settings.navItem,
} Screen.Support.navItem,
),
)
}
} else {
{}
},
) { padding -> ) { padding ->
if (vpnIntent != null) { if (vpnIntent != null) {
PermissionRequestFailedScreen( PermissionRequestFailedScreen(
padding = padding, padding = padding,
onRequestAgain = { vpnActivityResultState.launch(vpnIntent) }, onRequestAgain = { vpnActivityResultState.launch(vpnIntent) },
message = getString(R.string.vpn_permission_required), message = getString(R.string.vpn_permission_required),
getString(R.string.retry) getString(R.string.retry),
) )
return@Scaffold return@Scaffold
} }
@ -154,12 +191,12 @@ class MainActivity : AppCompatActivity() {
Uri.fromParts( Uri.fromParts(
Constants.URI_PACKAGE_SCHEME, Constants.URI_PACKAGE_SCHEME,
this.packageName, this.packageName,
null null,
) )
startActivity(intentSettings) startActivity(intentSettings)
}, },
message = getString(R.string.notification_permission_required), message = getString(R.string.notification_permission_required),
getString(R.string.open_settings) getString(R.string.open_settings),
) )
return@Scaffold return@Scaffold
} }
@ -167,34 +204,42 @@ class MainActivity : AppCompatActivity() {
composable( composable(
Screen.Main.route, Screen.Main.route,
) { ) {
MainScreen(padding = padding, focusRequester = focusRequester, showSnackbarMessage = { message -> MainScreen(
showSnackBarMessage(message) padding = padding,
}, navController = navController) focusRequester = focusRequester,
showSnackbarMessage = { message -> showSnackBarMessage(message) },
navController = navController,
)
} }
composable(Screen.Settings.route, composable(
Screen.Settings.route,
) { ) {
SettingsScreen(padding = padding, showSnackbarMessage = { message -> SettingsScreen(
showSnackBarMessage(message) padding = padding,
}, focusRequester = focusRequester) showSnackbarMessage = { message -> showSnackBarMessage(message) },
focusRequester = focusRequester,
)
} }
composable(Screen.Support.route, composable(
Screen.Support.route,
) { ) {
SupportScreen(padding = padding, focusRequester = focusRequester, SupportScreen(
showSnackbarMessage = { message -> padding = padding,
showSnackBarMessage(message) focusRequester = focusRequester,
}) showSnackbarMessage = { message -> showSnackBarMessage(message) },
)
} }
composable("${Screen.Config.route}/{id}") { composable("${Screen.Config.route}/{id}") {
val id = it.arguments?.getString("id") val id = it.arguments?.getString("id")
if (!id.isNullOrBlank()) { if (!id.isNullOrBlank()) {
//https://dagger.dev/hilt/view-model#assisted-injection // https://dagger.dev/hilt/view-model#assisted-injection
ConfigScreen( ConfigScreen(
navController = navController, navController = navController,
id = id, id = id,
showSnackbarMessage = { message -> showSnackbarMessage = { message ->
showSnackBarMessage(message) showSnackBarMessage(message)
}, },
focusRequester = focusRequester focusRequester = focusRequester,
) )
} }
} }

View File

@ -6,28 +6,33 @@ import androidx.compose.material.icons.rounded.QuestionMark
import androidx.compose.material.icons.rounded.Settings import androidx.compose.material.icons.rounded.Settings
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.BottomNavItem import com.zaneschepke.wireguardautotunnel.ui.common.navigation.BottomNavItem
sealed class Screen(val route : String) { sealed class Screen(val route: String) {
data object Main: Screen("main") { data object Main : Screen("main") {
val navItem = BottomNavItem( val navItem =
name = "Tunnels", BottomNavItem(
route = route, name = "Tunnels",
icon = Icons.Rounded.Home route = route,
) icon = Icons.Rounded.Home,
)
} }
data object Settings: Screen("settings") {
val navItem = BottomNavItem(
name = "Settings",
route = route,
icon = Icons.Rounded.Settings
)
}
data object Support: Screen("support") {
val navItem = BottomNavItem(
name = "Support",
route = route,
icon = Icons.Rounded.QuestionMark
)
}
data object Config : Screen("config")
} data object Settings : Screen("settings") {
val navItem =
BottomNavItem(
name = "Settings",
route = route,
icon = Icons.Rounded.Settings,
)
}
data object Support : Screen("support") {
val navItem =
BottomNavItem(
name = "Support",
route = route,
icon = Icons.Rounded.QuestionMark,
)
}
data object Config : Screen("config")
}

View File

@ -23,7 +23,7 @@ fun ClickableIconButton(
) { ) {
TextButton( TextButton(
onClick = onClick, onClick = onClick,
enabled = enabled enabled = enabled,
) { ) {
Text(text, Modifier.weight(1f, false)) Text(text, Modifier.weight(1f, false))
Spacer(modifier = Modifier.size(ButtonDefaults.IconSpacing)) Spacer(modifier = Modifier.size(ButtonDefaults.IconSpacing))
@ -31,11 +31,11 @@ fun ClickableIconButton(
imageVector = icon, imageVector = icon,
contentDescription = stringResource(R.string.delete), contentDescription = stringResource(R.string.delete),
modifier = modifier =
Modifier.size(ButtonDefaults.IconSize).weight(1f, false).clickable { Modifier.size(ButtonDefaults.IconSize).weight(1f, false).clickable {
if (enabled) { if (enabled) {
onIconClick() onIconClick()
} }
} },
) )
} }
} }

View File

@ -26,17 +26,12 @@ fun PermissionRequestFailedScreen(
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
modifier = modifier = Modifier.fillMaxSize().padding(padding),
Modifier
.fillMaxSize()
.padding(padding)
) { ) {
Text(message, textAlign = TextAlign.Center, modifier = Modifier.padding(15.dp)) Text(message, textAlign = TextAlign.Center, modifier = Modifier.padding(15.dp))
Button(onClick = { Button(
scope.launch { onClick = { scope.launch { onRequestAgain() } },
onRequestAgain() ) {
}
}) {
Text(buttonText) Text(buttonText)
} }
} }

View File

@ -34,30 +34,22 @@ fun RowListItem(
) { ) {
Box( Box(
modifier = modifier =
Modifier Modifier.animateContentSize()
.animateContentSize() .clip(RoundedCornerShape(30.dp))
.clip(RoundedCornerShape(30.dp)) .combinedClickable(
.combinedClickable( onClick = { onClick() },
onClick = { onLongClick = { onHold() },
onClick() ),
},
onLongClick = {
onHold()
}
)
) { ) {
Column { Column {
Row( Row(
modifier = modifier = Modifier.fillMaxWidth().padding(horizontal = 15.dp, vertical = 5.dp),
Modifier
.fillMaxWidth()
.padding(horizontal = 15.dp, vertical = 5.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween horizontalArrangement = Arrangement.SpaceBetween,
) { ) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(.60f) modifier = Modifier.fillMaxWidth(.60f),
) { ) {
icon() icon()
Text(text) Text(text)
@ -68,11 +60,10 @@ fun RowListItem(
statistics?.peers()?.forEach { statistics?.peers()?.forEach {
Row( Row(
modifier = modifier =
Modifier Modifier.fillMaxWidth()
.fillMaxWidth() .padding(end = 10.dp, bottom = 10.dp, start = 10.dp),
.padding(end = 10.dp, bottom = 10.dp, start = 10.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceEvenly horizontalArrangement = Arrangement.SpaceEvenly,
) { ) {
val handshakeEpoch = statistics.peer(it)!!.latestHandshakeEpochMillis val handshakeEpoch = statistics.peer(it)!!.latestHandshakeEpochMillis
val peerTx = statistics.peer(it)!!.txBytes val peerTx = statistics.peer(it)!!.txBytes

View File

@ -47,7 +47,7 @@ fun SearchBar(onQuery: (queryString: String) -> Unit) {
Icon( Icon(
imageVector = Icons.Rounded.Search, imageVector = Icons.Rounded.Search,
tint = MaterialTheme.colorScheme.onBackground, tint = MaterialTheme.colorScheme.onBackground,
contentDescription = stringResource(id = R.string.search_icon) contentDescription = stringResource(id = R.string.search_icon),
) )
}, },
trailingIcon = { trailingIcon = {
@ -56,25 +56,24 @@ fun SearchBar(onQuery: (queryString: String) -> Unit) {
Icon( Icon(
imageVector = Icons.Rounded.Clear, imageVector = Icons.Rounded.Clear,
tint = MaterialTheme.colorScheme.onBackground, tint = MaterialTheme.colorScheme.onBackground,
contentDescription = stringResource(id = R.string.clear_icon) contentDescription = stringResource(id = R.string.clear_icon),
) )
} }
} }
}, },
maxLines = 1, maxLines = 1,
colors = colors =
TextFieldDefaults.colors( TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent, focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent, unfocusedContainerColor = Color.Transparent,
disabledContainerColor = Color.Transparent disabledContainerColor = Color.Transparent,
), ),
placeholder = { Text(text = stringResource(R.string.hint_search_packages)) }, placeholder = { Text(text = stringResource(R.string.hint_search_packages)) },
textStyle = MaterialTheme.typography.bodySmall, textStyle = MaterialTheme.typography.bodySmall,
singleLine = true, singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text),
modifier = modifier =
Modifier Modifier.fillMaxWidth()
.fillMaxWidth() .background(color = MaterialTheme.colorScheme.background, shape = RectangleShape),
.background(color = MaterialTheme.colorScheme.background, shape = RectangleShape)
) )
} }

View File

@ -22,19 +22,15 @@ fun ConfigurationTextBox(
modifier = modifier, modifier = modifier,
value = value, value = value,
singleLine = true, singleLine = true,
onValueChange = { onValueChange = { onValueChange(it) },
onValueChange(it)
},
label = { Text(label) }, label = { Text(label) },
maxLines = 1, maxLines = 1,
placeholder = { placeholder = { Text(hint) },
Text(hint)
},
keyboardOptions = keyboardOptions =
KeyboardOptions( KeyboardOptions(
capitalization = KeyboardCapitalization.None, capitalization = KeyboardCapitalization.None,
imeAction = ImeAction.Done imeAction = ImeAction.Done,
), ),
keyboardActions = keyboardActions keyboardActions = keyboardActions,
) )
} }

View File

@ -21,21 +21,16 @@ fun ConfigurationToggle(
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
Row( Row(
modifier = modifier = Modifier.fillMaxWidth().padding(padding),
Modifier
.fillMaxWidth()
.padding(padding),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween horizontalArrangement = Arrangement.SpaceBetween,
) { ) {
Text(label) Text(label)
Switch( Switch(
modifier = modifier, modifier = modifier,
enabled = enabled, enabled = enabled,
checked = checked, checked = checked,
onCheckedChange = { onCheckedChange = { onCheckChanged() },
onCheckChanged()
}
) )
} }
} }

View File

@ -11,14 +11,11 @@ import androidx.navigation.NavController
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.currentBackStackEntryAsState
@Composable @Composable
fun BottomNavBar( fun BottomNavBar(navController: NavController, bottomNavItems: List<BottomNavItem>) {
navController: NavController,
bottomNavItems: List<BottomNavItem>
) {
val backStackEntry = navController.currentBackStackEntryAsState() val backStackEntry = navController.currentBackStackEntryAsState()
NavigationBar( NavigationBar(
containerColor = MaterialTheme.colorScheme.background containerColor = MaterialTheme.colorScheme.background,
) { ) {
bottomNavItems.forEach { item -> bottomNavItems.forEach { item ->
val selected = item.route == backStackEntry.value?.destination?.route val selected = item.route == backStackEntry.value?.destination?.route
@ -29,15 +26,15 @@ fun BottomNavBar(
label = { label = {
Text( Text(
text = item.name, text = item.name,
fontWeight = FontWeight.SemiBold fontWeight = FontWeight.SemiBold,
) )
}, },
icon = { icon = {
Icon( Icon(
imageVector = item.icon, imageVector = item.icon,
contentDescription = "${item.name} Icon" contentDescription = "${item.name} Icon",
) )
} },
) )
} }
} }

View File

@ -11,51 +11,40 @@ import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
@Composable @Composable
fun AuthorizationPrompt( fun AuthorizationPrompt(onSuccess: () -> Unit, onFailure: () -> Unit, onError: (String) -> Unit) {
onSuccess: () -> Unit,
onFailure: () -> Unit,
onError: (String) -> Unit
) {
val context = LocalContext.current val context = LocalContext.current
val biometricManager = BiometricManager.from(context) val biometricManager = BiometricManager.from(context)
val bio = biometricManager.canAuthenticate(BIOMETRIC_WEAK or DEVICE_CREDENTIAL) val bio = biometricManager.canAuthenticate(BIOMETRIC_WEAK or DEVICE_CREDENTIAL)
val isBiometricAvailable = val isBiometricAvailable = remember {
remember { when (bio) {
when (bio) { BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE -> {
BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE -> { onError("Biometrics not available")
onError("Biometrics not available") false
false
}
BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> {
onError("Biometrics not created")
false
}
BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> {
onError("Biometric hardware not found")
false
}
BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED -> {
onError("Biometric security update required")
false
}
BiometricManager.BIOMETRIC_ERROR_UNSUPPORTED -> {
onError("Biometrics not supported")
false
}
BiometricManager.BIOMETRIC_STATUS_UNKNOWN -> {
onError("Biometrics status unknown")
false
}
BiometricManager.BIOMETRIC_SUCCESS -> true
else -> false
} }
BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> {
onError("Biometrics not created")
false
}
BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> {
onError("Biometric hardware not found")
false
}
BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED -> {
onError("Biometric security update required")
false
}
BiometricManager.BIOMETRIC_ERROR_UNSUPPORTED -> {
onError("Biometrics not supported")
false
}
BiometricManager.BIOMETRIC_STATUS_UNKNOWN -> {
onError("Biometrics status unknown")
false
}
BiometricManager.BIOMETRIC_SUCCESS -> true
else -> false
} }
}
if (isBiometricAvailable) { if (isBiometricAvailable) {
val executor = remember { ContextCompat.getMainExecutor(context) } val executor = remember { ContextCompat.getMainExecutor(context) }
@ -71,10 +60,7 @@ fun AuthorizationPrompt(
context as FragmentActivity, context as FragmentActivity,
executor, executor,
object : BiometricPrompt.AuthenticationCallback() { object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationError( override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
errorCode: Int,
errString: CharSequence
) {
super.onAuthenticationError(errorCode, errString) super.onAuthenticationError(errorCode, errString)
onFailure() onFailure()
} }
@ -90,7 +76,7 @@ fun AuthorizationPrompt(
super.onAuthenticationFailed() super.onAuthenticationFailed()
onFailure() onFailure()
} }
} },
) )
biometricPrompt.authenticate(promptInfo) biometricPrompt.authenticate(promptInfo)
} }

View File

@ -37,25 +37,25 @@ fun CustomSnackBar(
Snackbar( Snackbar(
containerColor = containerColor, containerColor = containerColor,
modifier = modifier =
Modifier.fillMaxWidth( Modifier.fillMaxWidth(
if (WireGuardAutoTunnel.isRunningOnAndroidTv()) 1 / 3f else 2 / 3f if (WireGuardAutoTunnel.isRunningOnAndroidTv()) 1 / 3f else 2 / 3f,
).padding(bottom = 100.dp), )
shape = RoundedCornerShape(16.dp) .padding(bottom = 100.dp),
shape = RoundedCornerShape(16.dp),
) { ) {
CompositionLocalProvider( CompositionLocalProvider(
LocalLayoutDirection provides LocalLayoutDirection provides if (isRtl) LayoutDirection.Rtl else LayoutDirection.Ltr,
if (isRtl) LayoutDirection.Rtl else LayoutDirection.Ltr
) { ) {
Row( Row(
modifier = Modifier.width(IntrinsicSize.Max).height(IntrinsicSize.Min), modifier = Modifier.width(IntrinsicSize.Max).height(IntrinsicSize.Min),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start horizontalArrangement = Arrangement.Start,
) { ) {
Icon( Icon(
Icons.Rounded.Info, Icons.Rounded.Info,
contentDescription = stringResource(R.string.info), contentDescription = stringResource(R.string.info),
tint = Color.White, tint = Color.White,
modifier = Modifier.padding(end = 10.dp) modifier = Modifier.padding(end = 10.dp),
) )
Text(message, color = Color.White, modifier = Modifier.padding(end = 5.dp)) Text(message, color = Color.White, modifier = Modifier.padding(end = 5.dp))
} }

View File

@ -13,10 +13,11 @@ import androidx.compose.ui.unit.dp
@Composable @Composable
fun LoadingScreen() { fun LoadingScreen() {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top, verticalArrangement = Arrangement.Top,
modifier = Modifier.fillMaxSize().focusable().padding()) { modifier = Modifier.fillMaxSize().focusable().padding(),
Column(modifier = Modifier.padding(120.dp)) { CircularProgressIndicator() } ) {
Column(modifier = Modifier.padding(120.dp)) { CircularProgressIndicator() }
} }
} }

View File

@ -12,14 +12,11 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@Composable @Composable
fun SectionTitle( fun SectionTitle(title: String, padding: Dp) {
title: String,
padding: Dp
) {
Text( Text(
title, title,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
style = TextStyle(fontSize = 18.sp, fontWeight = FontWeight.ExtraBold), style = TextStyle(fontSize = 18.sp, fontWeight = FontWeight.ExtraBold),
modifier = Modifier.padding(padding, bottom = 5.dp, top = 5.dp) modifier = Modifier.padding(padding, bottom = 5.dp, top = 5.dp),
) )
} }

View File

@ -17,13 +17,13 @@ data class InterfaceProxy(
privateKey = i.keyPair.privateKey.toBase64().trim(), privateKey = i.keyPair.privateKey.toBase64().trim(),
addresses = i.addresses.joinToString(", ").trim(), addresses = i.addresses.joinToString(", ").trim(),
dnsServers = i.dnsServers.joinToString(", ").replace("/", "").trim(), dnsServers = i.dnsServers.joinToString(", ").replace("/", "").trim(),
listenPort = if (i.listenPort.isPresent) { listenPort =
i.listenPort.get().toString() if (i.listenPort.isPresent) {
.trim() i.listenPort.get().toString().trim()
} else { } else {
"" ""
}, },
mtu = if (i.mtu.isPresent) i.mtu.get().toString().trim() else "" mtu = if (i.mtu.isPresent) i.mtu.get().toString().trim() else "",
) )
} }
} }

View File

@ -13,36 +13,60 @@ data class PeerProxy(
fun from(peer: Peer): PeerProxy { fun from(peer: Peer): PeerProxy {
return PeerProxy( return PeerProxy(
publicKey = peer.publicKey.toBase64(), publicKey = peer.publicKey.toBase64(),
preSharedKey = if (peer.preSharedKey.isPresent) { preSharedKey =
peer.preSharedKey.get().toBase64() if (peer.preSharedKey.isPresent) {
.trim() peer.preSharedKey.get().toBase64().trim()
} else { } else {
"" ""
}, },
persistentKeepalive = if (peer.persistentKeepalive.isPresent) { persistentKeepalive =
peer.persistentKeepalive.get() if (peer.persistentKeepalive.isPresent) {
.toString().trim() peer.persistentKeepalive.get().toString().trim()
} else { } else {
"" ""
}, },
endpoint = if (peer.endpoint.isPresent) { endpoint =
peer.endpoint.get().toString() if (peer.endpoint.isPresent) {
.trim() peer.endpoint.get().toString().trim()
} else { } else {
"" ""
}, },
allowedIps = peer.allowedIps.joinToString(", ").trim() allowedIps = peer.allowedIps.joinToString(", ").trim(),
) )
} }
val IPV4_PUBLIC_NETWORKS = val IPV4_PUBLIC_NETWORKS =
setOf( setOf(
"0.0.0.0/5", "8.0.0.0/7", "11.0.0.0/8", "12.0.0.0/6", "16.0.0.0/4", "32.0.0.0/3", "0.0.0.0/5",
"64.0.0.0/2", "128.0.0.0/3", "160.0.0.0/5", "168.0.0.0/6", "172.0.0.0/12", "8.0.0.0/7",
"172.32.0.0/11", "172.64.0.0/10", "172.128.0.0/9", "173.0.0.0/8", "174.0.0.0/7", "11.0.0.0/8",
"176.0.0.0/4", "192.0.0.0/9", "192.128.0.0/11", "192.160.0.0/13", "192.169.0.0/16", "12.0.0.0/6",
"192.170.0.0/15", "192.172.0.0/14", "192.176.0.0/12", "192.192.0.0/10", "16.0.0.0/4",
"193.0.0.0/8", "194.0.0.0/7", "196.0.0.0/6", "200.0.0.0/5", "208.0.0.0/4" "32.0.0.0/3",
"64.0.0.0/2",
"128.0.0.0/3",
"160.0.0.0/5",
"168.0.0.0/6",
"172.0.0.0/12",
"172.32.0.0/11",
"172.64.0.0/10",
"172.128.0.0/9",
"173.0.0.0/8",
"174.0.0.0/7",
"176.0.0.0/4",
"192.0.0.0/9",
"192.128.0.0/11",
"192.160.0.0/13",
"192.169.0.0/16",
"192.170.0.0/15",
"192.172.0.0/14",
"192.176.0.0/12",
"192.192.0.0/10",
"193.0.0.0/8",
"194.0.0.0/7",
"196.0.0.0/6",
"200.0.0.0/5",
"208.0.0.0/4",
) )
val IPV4_WILDCARD = setOf("0.0.0.0/0") val IPV4_WILDCARD = setOf("0.0.0.0/0")
} }

View File

@ -88,7 +88,8 @@ import kotlinx.coroutines.delay
@OptIn( @OptIn(
ExperimentalComposeUiApi::class, ExperimentalComposeUiApi::class,
ExperimentalMaterial3Api::class, ExperimentalMaterial3Api::class,
ExperimentalFoundationApi::class) ExperimentalFoundationApi::class,
)
@Composable @Composable
fun ConfigScreen( fun ConfigScreen(
viewModel: ConfigViewModel = hiltViewModel(), viewModel: ConfigViewModel = hiltViewModel(),
@ -97,347 +98,374 @@ fun ConfigScreen(
showSnackbarMessage: (String) -> Unit, showSnackbarMessage: (String) -> Unit,
id: String id: String
) { ) {
val context = LocalContext.current val context = LocalContext.current
val clipboardManager: ClipboardManager = LocalClipboardManager.current val clipboardManager: ClipboardManager = LocalClipboardManager.current
val keyboardController = LocalSoftwareKeyboardController.current val keyboardController = LocalSoftwareKeyboardController.current
var showApplicationsDialog by remember { mutableStateOf(false) } var showApplicationsDialog by remember { mutableStateOf(false) }
var showAuthPrompt by remember { mutableStateOf(false) } var showAuthPrompt by remember { mutableStateOf(false) }
var isAuthenticated by remember { mutableStateOf(false) } var isAuthenticated by remember { mutableStateOf(false) }
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
LaunchedEffect(Unit) { LaunchedEffect(Unit) { viewModel.init(id) }
viewModel.init(id)
}
LaunchedEffect(uiState.loading) { LaunchedEffect(uiState.loading) {
if(!uiState.loading && WireGuardAutoTunnel.isRunningOnAndroidTv()) { if (!uiState.loading && WireGuardAutoTunnel.isRunningOnAndroidTv()) {
delay(Constants.FOCUS_REQUEST_DELAY) delay(Constants.FOCUS_REQUEST_DELAY)
focusRequester.requestFocus() focusRequester.requestFocus()
}
}
if (uiState.loading) {
LoadingScreen()
return
}
val keyboardActions = KeyboardActions(onDone = { keyboardController?.hide() })
val keyboardOptions =
KeyboardOptions(imeAction = ImeAction.Done)
val fillMaxHeight = .85f
val fillMaxWidth = .85f
val screenPadding = 5.dp
val applicationButtonText = {
"Tunneling apps: " +
if (uiState.isAllApplicationsEnabled) {
"all"
} else {
"${uiState.checkedPackageNames.size} " + (if (uiState.include) "included" else "excluded")
} }
} }
if (showAuthPrompt) { if (uiState.loading) {
AuthorizationPrompt( LoadingScreen()
onSuccess = { return
showAuthPrompt = false }
isAuthenticated = true
},
onError = { error ->
showAuthPrompt = false
showSnackbarMessage(Event.Error.AuthenticationFailed.message)
},
onFailure = {
showAuthPrompt = false
showSnackbarMessage(Event.Error.AuthorizationFailed.message)
})
}
if (showApplicationsDialog) { val keyboardActions = KeyboardActions(onDone = { keyboardController?.hide() })
val sortedPackages =
remember(uiState.packages) { uiState.packages.sortedBy { viewModel.getPackageLabel(it) } } val keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done)
AlertDialog(onDismissRequest = { showApplicationsDialog = false }) {
Surface( val fillMaxHeight = .85f
tonalElevation = 2.dp, val fillMaxWidth = .85f
shadowElevation = 2.dp, val screenPadding = 5.dp
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surface, val applicationButtonText = {
modifier = "Tunneling apps: " +
Modifier.fillMaxWidth() if (uiState.isAllApplicationsEnabled) {
.fillMaxHeight(if (uiState.isAllApplicationsEnabled) 1 / 5f else 4 / 5f)) { "all"
Column(modifier = Modifier.fillMaxWidth()) { } else {
Row( "${uiState.checkedPackageNames.size} " +
modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 7.dp), (if (uiState.include) "included" else "excluded")
verticalAlignment = Alignment.CenterVertically, }
horizontalArrangement = Arrangement.SpaceBetween) { }
Text(stringResource(id = R.string.tunnel_all))
Switch( if (showAuthPrompt) {
checked = uiState.isAllApplicationsEnabled, AuthorizationPrompt(
onCheckedChange = { viewModel.onAllApplicationsChange(it) }) onSuccess = {
} showAuthPrompt = false
if (!uiState.isAllApplicationsEnabled) { isAuthenticated = true
Row( },
modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 7.dp), onError = { error ->
verticalAlignment = Alignment.CenterVertically, showAuthPrompt = false
horizontalArrangement = Arrangement.SpaceBetween) { showSnackbarMessage(Event.Error.AuthenticationFailed.message)
Row( },
verticalAlignment = Alignment.CenterVertically, onFailure = {
horizontalArrangement = Arrangement.SpaceBetween) { showAuthPrompt = false
Text(stringResource(id = R.string.include)) showSnackbarMessage(Event.Error.AuthorizationFailed.message)
Checkbox( },
checked = uiState.include, )
onCheckedChange = { viewModel.onIncludeChange(!uiState.include) }) }
}
Row( if (showApplicationsDialog) {
verticalAlignment = Alignment.CenterVertically, val sortedPackages =
horizontalArrangement = Arrangement.SpaceBetween) { remember(uiState.packages) {
Text(stringResource(id = R.string.exclude)) uiState.packages.sortedBy { viewModel.getPackageLabel(it) }
Checkbox( }
checked = !uiState.include, AlertDialog(onDismissRequest = { showApplicationsDialog = false }) {
onCheckedChange = { viewModel.onIncludeChange(!uiState.include) }) 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) },
)
} }
Row( if (!uiState.isAllApplicationsEnabled) {
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( Row(
modifier =
Modifier.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 7.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxSize().padding(5.dp)) { ) {
Row(modifier = Modifier.fillMaxWidth(fillMaxWidth)) { Row(
val drawable = verticalAlignment = Alignment.CenterVertically,
pack.applicationInfo?.loadIcon(context.packageManager) horizontalArrangement = Arrangement.SpaceBetween,
if (drawable != null) { ) {
Image( Text(stringResource(id = R.string.include))
painter = DrawablePainter(drawable), Checkbox(
stringResource(id = R.string.icon), checked = uiState.include,
modifier = Modifier.size(50.dp, 50.dp)) onCheckedChange = {
} else { viewModel.onIncludeChange(!uiState.include)
Icon( },
Icons.Rounded.Android, )
stringResource(id = R.string.edit),
modifier = Modifier.size(50.dp, 50.dp))
}
Text(
viewModel.getPackageLabel(pack),
modifier = Modifier.padding(5.dp))
}
Checkbox(
modifier = Modifier.fillMaxSize(),
checked =
(uiState.checkedPackageNames.contains(pack.packageName)),
onCheckedChange = {
if (it) {
viewModel.onAddCheckedPackage(pack.packageName)
} else {
viewModel.onRemoveCheckedPackage(pack.packageName)
}
})
} }
} Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(stringResource(id = R.string.exclude))
Checkbox(
checked = !uiState.include,
onCheckedChange = {
viewModel.onIncludeChange(!uiState.include)
},
)
}
}
Row(
modifier =
Modifier.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 7.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
SearchBar(viewModel::emitQueriedPackages)
}
Spacer(Modifier.padding(5.dp))
LazyColumn(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Top,
modifier = Modifier.fillMaxHeight(4 / 5f),
) {
items(sortedPackages, key = { it.packageName }) { pack ->
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxSize().padding(5.dp),
) {
Row(modifier = Modifier.fillMaxWidth(fillMaxWidth)) {
val drawable =
pack.applicationInfo?.loadIcon(context.packageManager)
if (drawable != null) {
Image(
painter = DrawablePainter(drawable),
stringResource(id = R.string.icon),
modifier = Modifier.size(50.dp, 50.dp),
)
} else {
Icon(
Icons.Rounded.Android,
stringResource(id = R.string.edit),
modifier = Modifier.size(50.dp, 50.dp),
)
}
Text(
viewModel.getPackageLabel(pack),
modifier = Modifier.padding(5.dp),
)
}
Checkbox(
modifier = Modifier.fillMaxSize(),
checked =
(uiState.checkedPackageNames.contains(
pack.packageName
)),
onCheckedChange = {
if (it) {
viewModel.onAddCheckedPackage(pack.packageName)
} else {
viewModel.onRemoveCheckedPackage(pack.packageName)
}
},
)
}
}
}
} }
} Row(
Row( verticalAlignment = Alignment.CenterVertically,
verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize().padding(top = 5.dp),
modifier = Modifier.fillMaxSize().padding(top = 5.dp), horizontalArrangement = Arrangement.Center,
horizontalArrangement = Arrangement.Center) { ) {
TextButton(onClick = { showApplicationsDialog = false }) { TextButton(onClick = { showApplicationsDialog = false }) {
Text(stringResource(R.string.done)) Text(stringResource(R.string.done))
}
} }
} }
} }
} }
} }
}
Scaffold( Scaffold(
floatingActionButtonPosition = FabPosition.End, floatingActionButtonPosition = FabPosition.End,
floatingActionButton = { floatingActionButton = {
val secondaryColor = MaterialTheme.colorScheme.secondary val secondaryColor = MaterialTheme.colorScheme.secondary
val hoverColor = MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp) val hoverColor = MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp)
var fobColor by remember { mutableStateOf(secondaryColor) } var fobColor by remember { mutableStateOf(secondaryColor) }
FloatingActionButton( FloatingActionButton(
modifier = modifier =
Modifier.padding(bottom = 90.dp).onFocusChanged { Modifier.padding(bottom = 90.dp).onFocusChanged {
if (WireGuardAutoTunnel.isRunningOnAndroidTv()) { if (WireGuardAutoTunnel.isRunningOnAndroidTv()) {
fobColor = if (it.isFocused) hoverColor else secondaryColor fobColor = if (it.isFocused) hoverColor else secondaryColor
}
},
onClick = {
viewModel.onSaveAllChanges().let {
when (it) {
is Result.Success -> {
showSnackbarMessage(it.data.message)
navController.navigate(Screen.Main.route)
}
is Result.Error -> showSnackbarMessage(it.error.message)
}
} }
}, },
onClick = { containerColor = fobColor,
viewModel.onSaveAllChanges().let { shape = RoundedCornerShape(16.dp),
when (it) { ) {
is Result.Success -> {
showSnackbarMessage(it.data.message)
navController.navigate(Screen.Main.route)
}
is Result.Error -> showSnackbarMessage(it.error.message)
}
}
},
containerColor = fobColor,
shape = RoundedCornerShape(16.dp)) {
Icon( Icon(
imageVector = Icons.Rounded.Save, imageVector = Icons.Rounded.Save,
contentDescription = stringResource(id = R.string.save_changes), contentDescription = stringResource(id = R.string.save_changes),
tint = Color.DarkGray) tint = Color.DarkGray,
} )
}) { }
Column { },
) {
Column {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top, verticalArrangement = Arrangement.Top,
modifier = modifier =
Modifier.verticalScroll(rememberScrollState()).weight(1f, true).fillMaxSize()) { Modifier.verticalScroll(rememberScrollState()).weight(1f, true).fillMaxSize(),
Surface( ) {
tonalElevation = 2.dp, Surface(
shadowElevation = 2.dp, tonalElevation = 2.dp,
shape = RoundedCornerShape(12.dp), shadowElevation = 2.dp,
color = MaterialTheme.colorScheme.surface, shape = RoundedCornerShape(12.dp),
modifier = color = MaterialTheme.colorScheme.surface,
(if (WireGuardAutoTunnel.isRunningOnAndroidTv()) { modifier =
(if (WireGuardAutoTunnel.isRunningOnAndroidTv()) {
Modifier.fillMaxHeight(fillMaxHeight).fillMaxWidth(fillMaxWidth) Modifier.fillMaxHeight(fillMaxHeight).fillMaxWidth(fillMaxWidth)
} else { } else {
Modifier.fillMaxWidth(fillMaxWidth) Modifier.fillMaxWidth(fillMaxWidth)
}) })
.padding(top = 50.dp, bottom = 10.dp)) { .padding(top = 50.dp, bottom = 10.dp),
Column( ) {
horizontalAlignment = Alignment.Start, Column(
verticalArrangement = Arrangement.Top, horizontalAlignment = Alignment.Start,
modifier = Modifier.padding(15.dp).focusGroup()) { verticalArrangement = Arrangement.Top,
SectionTitle( modifier = Modifier.padding(15.dp).focusGroup(),
stringResource(R.string.interface_), padding = screenPadding) ) {
ConfigurationTextBox( SectionTitle(
value = uiState.tunnelName, stringResource(R.string.interface_),
onValueChange = { value -> viewModel.onTunnelNameChange(value) }, padding = screenPadding,
keyboardActions = keyboardActions, )
label = stringResource(R.string.name), ConfigurationTextBox(
hint = stringResource(R.string.tunnel_name).lowercase(), value = uiState.tunnelName,
modifier = onValueChange = { value -> viewModel.onTunnelNameChange(value) },
Modifier keyboardActions = keyboardActions,
.fillMaxWidth() label = stringResource(R.string.name),
.focusRequester(focusRequester)) hint = stringResource(R.string.tunnel_name).lowercase(),
OutlinedTextField( modifier = Modifier.fillMaxWidth().focusRequester(focusRequester),
modifier = )
Modifier.fillMaxWidth().clickable { OutlinedTextField(
showAuthPrompt = true modifier = Modifier.fillMaxWidth().clickable { showAuthPrompt = true },
}, value = uiState.interfaceProxy.privateKey,
value = uiState.interfaceProxy.privateKey, visualTransformation =
visualTransformation = if ((id == Constants.MANUAL_TUNNEL_CONFIG_ID) || isAuthenticated)
if ((id == Constants.MANUAL_TUNNEL_CONFIG_ID) || VisualTransformation.None
isAuthenticated) else PasswordVisualTransformation(),
VisualTransformation.None enabled = (id == Constants.MANUAL_TUNNEL_CONFIG_ID) || isAuthenticated,
else PasswordVisualTransformation(), onValueChange = { value -> viewModel.onPrivateKeyChange(value) },
enabled = trailingIcon = {
(id == Constants.MANUAL_TUNNEL_CONFIG_ID) || isAuthenticated, IconButton(
onValueChange = { value -> viewModel.onPrivateKeyChange(value) }, modifier = Modifier.focusRequester(FocusRequester.Default),
trailingIcon = { onClick = { viewModel.generateKeyPair() },
IconButton( ) {
modifier = Modifier.focusRequester(FocusRequester.Default), Icon(
onClick = { viewModel.generateKeyPair() }) { Icons.Rounded.Refresh,
Icon( stringResource(R.string.rotate_keys),
Icons.Rounded.Refresh, tint = Color.White,
stringResource(R.string.rotate_keys), )
tint = Color.White) }
} },
}, label = { Text(stringResource(R.string.private_key)) },
label = { Text(stringResource(R.string.private_key)) }, singleLine = true,
singleLine = true, placeholder = { Text(stringResource(R.string.base64_key)) },
placeholder = { Text(stringResource(R.string.base64_key)) }, keyboardOptions = keyboardOptions,
keyboardOptions = keyboardOptions, keyboardActions = keyboardActions,
keyboardActions = keyboardActions) )
OutlinedTextField( OutlinedTextField(
modifier = modifier =
Modifier Modifier.fillMaxWidth().focusRequester(FocusRequester.Default),
.fillMaxWidth() value = uiState.interfaceProxy.publicKey,
.focusRequester(FocusRequester.Default), enabled = false,
value = uiState.interfaceProxy.publicKey, onValueChange = {},
enabled = false, trailingIcon = {
onValueChange = {}, IconButton(
trailingIcon = { modifier = Modifier.focusRequester(FocusRequester.Default),
IconButton( onClick = {
modifier = Modifier.focusRequester(FocusRequester.Default), clipboardManager.setText(
onClick = { AnnotatedString(uiState.interfaceProxy.publicKey),
clipboardManager.setText( )
AnnotatedString(uiState.interfaceProxy.publicKey))
}) {
Icon(
Icons.Rounded.ContentCopy,
stringResource(R.string.copy_public_key),
tint = Color.White)
}
},
label = { Text(stringResource(R.string.public_key)) },
singleLine = true,
placeholder = { Text(stringResource(R.string.base64_key)) },
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions)
Row(modifier = Modifier.fillMaxWidth()) {
ConfigurationTextBox(
value = uiState.interfaceProxy.addresses,
onValueChange = { value ->
viewModel.onAddressesChanged(value)
}, },
keyboardActions = keyboardActions, ) {
label = stringResource(R.string.addresses), Icon(
hint = stringResource(R.string.comma_separated_list), Icons.Rounded.ContentCopy,
modifier = stringResource(R.string.copy_public_key),
Modifier tint = Color.White,
.fillMaxWidth(3 / 5f) )
.padding(end = 5.dp)) }
ConfigurationTextBox( },
value = uiState.interfaceProxy.listenPort, label = { Text(stringResource(R.string.public_key)) },
onValueChange = { value -> singleLine = true,
viewModel.onListenPortChanged(value) placeholder = { Text(stringResource(R.string.base64_key)) },
}, keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions, keyboardActions = keyboardActions,
label = stringResource(R.string.listen_port), )
hint = stringResource(R.string.random), Row(modifier = Modifier.fillMaxWidth()) {
modifier = Modifier.width(IntrinsicSize.Min)) ConfigurationTextBox(
} value = uiState.interfaceProxy.addresses,
Row(modifier = Modifier.fillMaxWidth()) { onValueChange = { value -> viewModel.onAddressesChanged(value) },
ConfigurationTextBox( keyboardActions = keyboardActions,
value = uiState.interfaceProxy.dnsServers, label = stringResource(R.string.addresses),
onValueChange = { value -> hint = stringResource(R.string.comma_separated_list),
viewModel.onDnsServersChanged(value) modifier = Modifier.fillMaxWidth(3 / 5f).padding(end = 5.dp),
}, )
keyboardActions = keyboardActions, ConfigurationTextBox(
label = stringResource(R.string.dns_servers), value = uiState.interfaceProxy.listenPort,
hint = stringResource(R.string.comma_separated_list), onValueChange = { value -> viewModel.onListenPortChanged(value) },
modifier = keyboardActions = keyboardActions,
Modifier label = stringResource(R.string.listen_port),
.fillMaxWidth(3 / 5f) hint = stringResource(R.string.random),
.padding(end = 5.dp)) modifier = Modifier.width(IntrinsicSize.Min),
ConfigurationTextBox( )
value = uiState.interfaceProxy.mtu, }
onValueChange = { value -> viewModel.onMtuChanged(value) }, Row(modifier = Modifier.fillMaxWidth()) {
keyboardActions = keyboardActions, ConfigurationTextBox(
label = stringResource(R.string.mtu), value = uiState.interfaceProxy.dnsServers,
hint = stringResource(R.string.auto), onValueChange = { value -> viewModel.onDnsServersChanged(value) },
modifier = Modifier.width(IntrinsicSize.Min)) keyboardActions = keyboardActions,
} label = stringResource(R.string.dns_servers),
Row( hint = stringResource(R.string.comma_separated_list),
verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth(3 / 5f).padding(end = 5.dp),
modifier = Modifier.fillMaxSize().padding(top = 5.dp), )
horizontalArrangement = Arrangement.Center) { ConfigurationTextBox(
TextButton(onClick = { showApplicationsDialog = true }) { value = uiState.interfaceProxy.mtu,
Text(applicationButtonText()) onValueChange = { value -> viewModel.onMtuChanged(value) },
} keyboardActions = keyboardActions,
} label = stringResource(R.string.mtu),
hint = stringResource(R.string.auto),
modifier = Modifier.width(IntrinsicSize.Min),
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxSize().padding(top = 5.dp),
horizontalArrangement = Arrangement.Center,
) {
TextButton(onClick = { showApplicationsDialog = true }) {
Text(applicationButtonText())
} }
} }
uiState.proxyPeers.forEachIndexed { index, peer -> }
}
uiState.proxyPeers.forEachIndexed { index, peer ->
Surface( Surface(
tonalElevation = 2.dp, tonalElevation = 2.dp,
shadowElevation = 2.dp, shadowElevation = 2.dp,
@ -445,106 +473,118 @@ fun ConfigScreen(
color = MaterialTheme.colorScheme.surface, color = MaterialTheme.colorScheme.surface,
modifier = modifier =
(if (WireGuardAutoTunnel.isRunningOnAndroidTv()) { (if (WireGuardAutoTunnel.isRunningOnAndroidTv()) {
Modifier.fillMaxHeight(fillMaxHeight).fillMaxWidth(fillMaxWidth) Modifier.fillMaxHeight(fillMaxHeight).fillMaxWidth(fillMaxWidth)
} else { } else {
Modifier.fillMaxWidth(fillMaxWidth) Modifier.fillMaxWidth(fillMaxWidth)
}) })
.padding(top = 10.dp, bottom = 10.dp)) { .padding(top = 10.dp, bottom = 10.dp),
Column( ) {
horizontalAlignment = Alignment.Start, Column(
verticalArrangement = Arrangement.Top, horizontalAlignment = Alignment.Start,
modifier = verticalArrangement = Arrangement.Top,
Modifier.padding(horizontal = 15.dp).padding(bottom = 10.dp)) { modifier = Modifier.padding(horizontal = 15.dp).padding(bottom = 10.dp),
Row( ) {
horizontalArrangement = Arrangement.SpaceBetween, Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth().padding(horizontal = 5.dp)) { verticalAlignment = Alignment.CenterVertically,
SectionTitle( modifier = Modifier.fillMaxWidth().padding(horizontal = 5.dp),
stringResource(R.string.peer), padding = screenPadding) ) {
IconButton(onClick = { viewModel.onDeletePeer(index) }) { SectionTitle(
Icon(Icons.Rounded.Delete, stringResource(R.string.delete)) stringResource(R.string.peer),
} padding = screenPadding,
} )
IconButton(onClick = { viewModel.onDeletePeer(index) }) {
ConfigurationTextBox( Icon(Icons.Rounded.Delete, stringResource(R.string.delete))
value = peer.publicKey, }
onValueChange = { value ->
viewModel.onPeerPublicKeyChange(index, value)
},
keyboardActions = keyboardActions,
label = stringResource(R.string.public_key),
hint = stringResource(R.string.base64_key),
modifier = Modifier.fillMaxWidth())
ConfigurationTextBox(
value = peer.preSharedKey,
onValueChange = { value ->
viewModel.onPreSharedKeyChange(index, value)
},
keyboardActions = keyboardActions,
label = stringResource(R.string.preshared_key),
hint = stringResource(R.string.optional),
modifier = Modifier.fillMaxWidth())
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = peer.persistentKeepalive,
enabled = true,
onValueChange = { value ->
viewModel.onPersistentKeepaliveChanged(index, value)
},
trailingIcon = {
Text(
stringResource(R.string.seconds),
modifier = Modifier.padding(end = 10.dp))
},
label = { Text(stringResource(R.string.persistent_keepalive)) },
singleLine = true,
placeholder = {
Text(stringResource(R.string.optional_no_recommend))
},
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions)
ConfigurationTextBox(
value = peer.endpoint,
onValueChange = { value ->
viewModel.onEndpointChange(index, value)
},
keyboardActions = keyboardActions,
label = stringResource(R.string.endpoint),
hint = stringResource(R.string.endpoint).lowercase(),
modifier = Modifier.fillMaxWidth())
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = peer.allowedIps,
enabled = true,
onValueChange = { value ->
viewModel.onAllowedIpsChange(index, value)
},
label = { Text(stringResource(R.string.allowed_ips)) },
singleLine = true,
placeholder = {
Text(stringResource(R.string.comma_separated_list))
},
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions)
}
}
}
Row(
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxSize().padding(bottom = 140.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center) {
TextButton(onClick = { viewModel.addEmptyPeer() }) {
Text(stringResource(R.string.add_peer))
}
} }
}
ConfigurationTextBox(
value = peer.publicKey,
onValueChange = { value ->
viewModel.onPeerPublicKeyChange(index, value)
},
keyboardActions = keyboardActions,
label = stringResource(R.string.public_key),
hint = stringResource(R.string.base64_key),
modifier = Modifier.fillMaxWidth(),
)
ConfigurationTextBox(
value = peer.preSharedKey,
onValueChange = { value ->
viewModel.onPreSharedKeyChange(index, value)
},
keyboardActions = keyboardActions,
label = stringResource(R.string.preshared_key),
hint = stringResource(R.string.optional),
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = peer.persistentKeepalive,
enabled = true,
onValueChange = { value ->
viewModel.onPersistentKeepaliveChanged(index, value)
},
trailingIcon = {
Text(
stringResource(R.string.seconds),
modifier = Modifier.padding(end = 10.dp),
)
},
label = { Text(stringResource(R.string.persistent_keepalive)) },
singleLine = true,
placeholder = {
Text(stringResource(R.string.optional_no_recommend))
},
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
)
ConfigurationTextBox(
value = peer.endpoint,
onValueChange = { value ->
viewModel.onEndpointChange(index, value)
},
keyboardActions = keyboardActions,
label = stringResource(R.string.endpoint),
hint = stringResource(R.string.endpoint).lowercase(),
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = peer.allowedIps,
enabled = true,
onValueChange = { value ->
viewModel.onAllowedIpsChange(index, value)
},
label = { Text(stringResource(R.string.allowed_ips)) },
singleLine = true,
placeholder = {
Text(stringResource(R.string.comma_separated_list))
},
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
)
}
}
}
Row(
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxSize().padding(bottom = 140.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
) {
TextButton(onClick = { viewModel.addEmptyPeer() }) {
Text(stringResource(R.string.add_peer))
}
}
} }
if (WireGuardAutoTunnel.isRunningOnAndroidTv()) {
Spacer(modifier = Modifier.weight(.17f))
} }
} if (WireGuardAutoTunnel.isRunningOnAndroidTv()) {
Spacer(modifier = Modifier.weight(.17f))
}
} }
}
} }

View File

@ -11,8 +11,8 @@ data class ConfigUiState(
val packages: Packages = emptyList(), val packages: Packages = emptyList(),
val checkedPackageNames: List<String> = emptyList(), val checkedPackageNames: List<String> = emptyList(),
val include: Boolean = true, val include: Boolean = true,
val isAllApplicationsEnabled : Boolean = false, val isAllApplicationsEnabled: Boolean = false,
val loading: Boolean = true, val loading: Boolean = true,
val tunnel: TunnelConfig? = null, val tunnel: TunnelConfig? = null,
val tunnelName: String = "" val tunnelName: String = ""
) )

View File

@ -41,272 +41,312 @@ constructor(
private val settingsRepository: SettingsRepository, private val settingsRepository: SettingsRepository,
) : ViewModel() { ) : ViewModel() {
private val packageManager = application.packageManager private val packageManager = application.packageManager
private val _uiState = MutableStateFlow(ConfigUiState()) private val _uiState = MutableStateFlow(ConfigUiState())
val uiState = _uiState.asStateFlow() val uiState = _uiState.asStateFlow()
fun init(tunnelId : String) = viewModelScope.launch(Dispatchers.IO) { fun init(tunnelId: String) =
val packages = getQueriedPackages("") viewModelScope.launch(Dispatchers.IO) {
val state = if(tunnelId != Constants.MANUAL_TUNNEL_CONFIG_ID) { val packages = getQueriedPackages("")
val tunnelConfig = val state =
tunnelConfigRepository.getAll().firstOrNull { it.id.toString() == tunnelId } if (tunnelId != Constants.MANUAL_TUNNEL_CONFIG_ID) {
if (tunnelConfig != null) { val tunnelConfig =
val config = TunnelConfig.configFromQuick(tunnelConfig.wgQuick) tunnelConfigRepository.getAll().firstOrNull { it.id.toString() == tunnelId }
val proxyPeers = config.peers.map { PeerProxy.from(it) } if (tunnelConfig != null) {
val proxyInterface = InterfaceProxy.from(config.`interface`) val config = TunnelConfig.configFromQuick(tunnelConfig.wgQuick)
var include = true val proxyPeers = config.peers.map { PeerProxy.from(it) }
var isAllApplicationsEnabled = false val proxyInterface = InterfaceProxy.from(config.`interface`)
val checkedPackages = var include = true
if (config.`interface`.includedApplications.isNotEmpty()) { var isAllApplicationsEnabled = false
config.`interface`.includedApplications val checkedPackages =
} else if (config.`interface`.excludedApplications.isNotEmpty()) { if (config.`interface`.includedApplications.isNotEmpty()) {
include = false config.`interface`.includedApplications
config.`interface`.excludedApplications } else if (config.`interface`.excludedApplications.isNotEmpty()) {
include = false
config.`interface`.excludedApplications
} else {
isAllApplicationsEnabled = true
emptySet()
}
ConfigUiState(
proxyPeers,
proxyInterface,
packages,
checkedPackages.toList(),
include,
isAllApplicationsEnabled,
false,
tunnelConfig,
tunnelConfig.name,
)
} else { } else {
isAllApplicationsEnabled = true ConfigUiState(loading = false, packages = packages)
emptySet()
} }
ConfigUiState( } else {
proxyPeers, ConfigUiState(loading = false, packages = packages)
proxyInterface, }
packages, _uiState.value = state
checkedPackages.toList(), }
include,
isAllApplicationsEnabled, fun onTunnelNameChange(name: String) {
false, _uiState.value = _uiState.value.copy(tunnelName = name)
tunnelConfig, }
tunnelConfig.name)
} else { fun onIncludeChange(include: Boolean) {
ConfigUiState(loading = false, packages = packages) _uiState.value = _uiState.value.copy(include = include)
} }
fun onAddCheckedPackage(packageName: String) {
_uiState.value =
_uiState.value.copy(
checkedPackageNames = _uiState.value.checkedPackageNames + packageName
)
}
fun onAllApplicationsChange(isAllApplicationsEnabled: Boolean) {
_uiState.value = _uiState.value.copy(isAllApplicationsEnabled = isAllApplicationsEnabled)
}
fun onRemoveCheckedPackage(packageName: String) {
_uiState.value =
_uiState.value.copy(
checkedPackageNames = _uiState.value.checkedPackageNames - packageName
)
}
private fun getQueriedPackages(query: String): List<PackageInfo> {
return getAllInternetCapablePackages().filter {
getPackageLabel(it).lowercase().contains(query.lowercase())
}
}
fun getPackageLabel(packageInfo: PackageInfo): String {
return packageInfo.applicationInfo.loadLabel(application.packageManager).toString()
}
private fun getAllInternetCapablePackages(): List<PackageInfo> {
return getPackagesHoldingPermissions(arrayOf(Manifest.permission.INTERNET))
}
private fun getPackagesHoldingPermissions(permissions: Array<String>): List<PackageInfo> {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
packageManager.getPackagesHoldingPermissions(
permissions,
PackageManager.PackageInfoFlags.of(0L),
)
} else { } else {
ConfigUiState(loading = false, packages = packages) packageManager.getPackagesHoldingPermissions(permissions, 0)
} }
_uiState.value = state
} }
fun onTunnelNameChange(name: String) {
_uiState.value = _uiState.value.copy(tunnelName = name)
}
fun onIncludeChange(include: Boolean) { private fun isAllApplicationsEnabled(): Boolean {
_uiState.value = _uiState.value.copy(include = include) return _uiState.value.isAllApplicationsEnabled
}
fun onAddCheckedPackage(packageName: String) {
_uiState.value =
_uiState.value.copy(checkedPackageNames = _uiState.value.checkedPackageNames + packageName)
}
fun onAllApplicationsChange(isAllApplicationsEnabled: Boolean) {
_uiState.value = _uiState.value.copy(isAllApplicationsEnabled = isAllApplicationsEnabled)
}
fun onRemoveCheckedPackage(packageName: String) {
_uiState.value =
_uiState.value.copy(checkedPackageNames = _uiState.value.checkedPackageNames - packageName)
}
private fun getQueriedPackages(query: String): List<PackageInfo> {
return getAllInternetCapablePackages().filter {
getPackageLabel(it).lowercase().contains(query.lowercase())
} }
}
fun getPackageLabel(packageInfo: PackageInfo): String { private fun saveConfig(tunnelConfig: TunnelConfig) =
return packageInfo.applicationInfo.loadLabel(application.packageManager).toString() viewModelScope.launch { tunnelConfigRepository.save(tunnelConfig) }
}
private fun getAllInternetCapablePackages(): List<PackageInfo> { private fun updateTunnelConfig(tunnelConfig: TunnelConfig?) =
return getPackagesHoldingPermissions(arrayOf(Manifest.permission.INTERNET)) viewModelScope.launch {
} if (tunnelConfig != null) {
saveConfig(tunnelConfig).join()
private fun getPackagesHoldingPermissions(permissions: Array<String>): List<PackageInfo> { WireGuardAutoTunnel.requestTileServiceStateUpdate()
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { updateSettingsDefaultTunnel(tunnelConfig)
packageManager.getPackagesHoldingPermissions( }
permissions, PackageManager.PackageInfoFlags.of(0L))
} else {
packageManager.getPackagesHoldingPermissions(permissions, 0)
}
}
private fun isAllApplicationsEnabled(): Boolean {
return _uiState.value.isAllApplicationsEnabled
}
private fun saveConfig(tunnelConfig: TunnelConfig) =
viewModelScope.launch {
tunnelConfigRepository.save(tunnelConfig)
}
private fun updateTunnelConfig(tunnelConfig: TunnelConfig?) =
viewModelScope.launch {
if (tunnelConfig != null) {
saveConfig(tunnelConfig).join()
WireGuardAutoTunnel.requestTileServiceStateUpdate()
updateSettingsDefaultTunnel(tunnelConfig)
} }
}
private suspend fun updateSettingsDefaultTunnel(tunnelConfig: TunnelConfig) { private suspend fun updateSettingsDefaultTunnel(tunnelConfig: TunnelConfig) {
val settings = settingsRepository.getSettingsFlow().first() val settings = settingsRepository.getSettingsFlow().first()
if (settings.defaultTunnel != null) { if (settings.defaultTunnel != null) {
if (tunnelConfig.id == TunnelConfig.from(settings.defaultTunnel!!).id) { if (tunnelConfig.id == TunnelConfig.from(settings.defaultTunnel!!).id) {
settingsRepository.save(settings.copy(defaultTunnel = tunnelConfig.toString())) settingsRepository.save(settings.copy(defaultTunnel = tunnelConfig.toString()))
} }
}
}
private fun buildPeerListFromProxyPeers(): List<Peer> {
return _uiState.value.proxyPeers.map {
val builder = Peer.Builder()
if (it.allowedIps.isNotEmpty()) builder.parseAllowedIPs(it.allowedIps.trim())
if (it.publicKey.isNotEmpty()) builder.parsePublicKey(it.publicKey.trim())
if (it.preSharedKey.isNotEmpty()) builder.parsePreSharedKey(it.preSharedKey.trim())
if (it.endpoint.isNotEmpty()) builder.parseEndpoint(it.endpoint.trim())
if (it.persistentKeepalive.isNotEmpty()) {
builder.parsePersistentKeepalive(it.persistentKeepalive.trim())
}
builder.build()
}
}
private fun emptyCheckedPackagesList() {
_uiState.value = _uiState.value.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())
builder.parseDnsServers(_uiState.value.interfaceProxy.dnsServers.trim())
if (_uiState.value.interfaceProxy.mtu.isNotEmpty())
builder.parseMtu(_uiState.value.interfaceProxy.mtu.trim())
if (_uiState.value.interfaceProxy.listenPort.isNotEmpty()) {
builder.parseListenPort(_uiState.value.interfaceProxy.listenPort.trim())
}
if (isAllApplicationsEnabled()) emptyCheckedPackagesList()
if (_uiState.value.include) builder.includeApplications(_uiState.value.checkedPackageNames)
if (!_uiState.value.include) builder.excludeApplications(_uiState.value.checkedPackageNames)
return builder.build()
}
fun onSaveAllChanges(): Result<Event> {
return try {
val peerList = buildPeerListFromProxyPeers()
val wgInterface = buildInterfaceListFromProxyInterface()
val config = Config.Builder().addPeers(peerList).setInterface(wgInterface).build()
val tunnelConfig =
_uiState.value.tunnel?.copy(
name = _uiState.value.tunnelName, wgQuick = config.toWgQuickString())
updateTunnelConfig(tunnelConfig)
Result.Success(Event.Message.ConfigSaved)
} catch (e: Exception) {
Result.Error(Event.Error.Exception(e))
}
}
fun onPeerPublicKeyChange(index: Int, value: String) {
_uiState.value =
_uiState.value.copy(
proxyPeers =
_uiState.value.proxyPeers.update(
index, _uiState.value.proxyPeers[index].copy(publicKey = value)))
}
fun onPreSharedKeyChange(index: Int, value: String) {
_uiState.value =
_uiState.value.copy(
proxyPeers =
_uiState.value.proxyPeers.update(
index, _uiState.value.proxyPeers[index].copy(preSharedKey = value)))
}
fun onEndpointChange(index: Int, value: String) {
_uiState.value =
_uiState.value.copy(
proxyPeers =
_uiState.value.proxyPeers.update(
index, _uiState.value.proxyPeers[index].copy(endpoint = value)))
}
fun onAllowedIpsChange(index: Int, value: String) {
_uiState.value =
_uiState.value.copy(
proxyPeers =
_uiState.value.proxyPeers.update(
index, _uiState.value.proxyPeers[index].copy(allowedIps = value)))
}
fun onPersistentKeepaliveChanged(index: Int, value: String) {
_uiState.value =
_uiState.value.copy(
proxyPeers =
_uiState.value.proxyPeers.update(
index, _uiState.value.proxyPeers[index].copy(persistentKeepalive = value)))
}
fun onDeletePeer(index: Int) {
_uiState.value = _uiState.value.copy(
proxyPeers = _uiState.value.proxyPeers.removeAt(index)
)
}
fun addEmptyPeer() {
_uiState.value = _uiState.value.copy(proxyPeers = _uiState.value.proxyPeers + PeerProxy())
}
fun generateKeyPair() {
val keyPair = KeyPair()
_uiState.value =
_uiState.value.copy(
interfaceProxy =
_uiState.value.interfaceProxy.copy(
privateKey = keyPair.privateKey.toBase64(),
publicKey = keyPair.publicKey.toBase64()))
}
fun onAddressesChanged(value: String) {
_uiState.value =
_uiState.value.copy(interfaceProxy = _uiState.value.interfaceProxy.copy(addresses = value))
}
fun onListenPortChanged(value: String) {
_uiState.value =
_uiState.value.copy(interfaceProxy = _uiState.value.interfaceProxy.copy(listenPort = value))
}
fun onDnsServersChanged(value: String) {
_uiState.value =
_uiState.value.copy(interfaceProxy = _uiState.value.interfaceProxy.copy(dnsServers = value))
}
fun onMtuChanged(value: String) {
_uiState.value =
_uiState.value.copy(interfaceProxy = _uiState.value.interfaceProxy.copy(mtu = value))
}
private fun onInterfacePublicKeyChange(value: String) {
_uiState.value =
_uiState.value.copy(interfaceProxy = _uiState.value.interfaceProxy.copy(publicKey = value))
}
fun onPrivateKeyChange(value: String) {
_uiState.value =
_uiState.value.copy(interfaceProxy = _uiState.value.interfaceProxy.copy(privateKey = value))
if (NumberUtils.isValidKey(value)) {
val pair = KeyPair(Key.fromBase64(value))
onInterfacePublicKeyChange(pair.publicKey.toBase64())
} else {
onInterfacePublicKeyChange("")
}
}
fun emitQueriedPackages(query: String) {
val packages =
getAllInternetCapablePackages().filter {
getPackageLabel(it).lowercase().contains(query.lowercase())
} }
_uiState.value = _uiState.value.copy(packages = packages) }
}
private fun buildPeerListFromProxyPeers(): List<Peer> {
return _uiState.value.proxyPeers.map {
val builder = Peer.Builder()
if (it.allowedIps.isNotEmpty()) builder.parseAllowedIPs(it.allowedIps.trim())
if (it.publicKey.isNotEmpty()) builder.parsePublicKey(it.publicKey.trim())
if (it.preSharedKey.isNotEmpty()) builder.parsePreSharedKey(it.preSharedKey.trim())
if (it.endpoint.isNotEmpty()) builder.parseEndpoint(it.endpoint.trim())
if (it.persistentKeepalive.isNotEmpty()) {
builder.parsePersistentKeepalive(it.persistentKeepalive.trim())
}
builder.build()
}
}
private fun emptyCheckedPackagesList() {
_uiState.value = _uiState.value.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)
return builder.build()
}
fun onSaveAllChanges(): Result<Event> {
return try {
val peerList = buildPeerListFromProxyPeers()
val wgInterface = buildInterfaceListFromProxyInterface()
val config = Config.Builder().addPeers(peerList).setInterface(wgInterface).build()
val tunnelConfig =
_uiState.value.tunnel?.copy(
name = _uiState.value.tunnelName,
wgQuick = config.toWgQuickString(),
)
updateTunnelConfig(tunnelConfig)
Result.Success(Event.Message.ConfigSaved)
} catch (e: Exception) {
Result.Error(Event.Error.Exception(e))
}
}
fun onPeerPublicKeyChange(index: Int, value: String) {
_uiState.value =
_uiState.value.copy(
proxyPeers =
_uiState.value.proxyPeers.update(
index,
_uiState.value.proxyPeers[index].copy(publicKey = value),
),
)
}
fun onPreSharedKeyChange(index: Int, value: String) {
_uiState.value =
_uiState.value.copy(
proxyPeers =
_uiState.value.proxyPeers.update(
index,
_uiState.value.proxyPeers[index].copy(preSharedKey = value),
),
)
}
fun onEndpointChange(index: Int, value: String) {
_uiState.value =
_uiState.value.copy(
proxyPeers =
_uiState.value.proxyPeers.update(
index,
_uiState.value.proxyPeers[index].copy(endpoint = value),
),
)
}
fun onAllowedIpsChange(index: Int, value: String) {
_uiState.value =
_uiState.value.copy(
proxyPeers =
_uiState.value.proxyPeers.update(
index,
_uiState.value.proxyPeers[index].copy(allowedIps = value),
),
)
}
fun onPersistentKeepaliveChanged(index: Int, value: String) {
_uiState.value =
_uiState.value.copy(
proxyPeers =
_uiState.value.proxyPeers.update(
index,
_uiState.value.proxyPeers[index].copy(persistentKeepalive = value),
),
)
}
fun onDeletePeer(index: Int) {
_uiState.value =
_uiState.value.copy(
proxyPeers = _uiState.value.proxyPeers.removeAt(index),
)
}
fun addEmptyPeer() {
_uiState.value = _uiState.value.copy(proxyPeers = _uiState.value.proxyPeers + PeerProxy())
}
fun generateKeyPair() {
val keyPair = KeyPair()
_uiState.value =
_uiState.value.copy(
interfaceProxy =
_uiState.value.interfaceProxy.copy(
privateKey = keyPair.privateKey.toBase64(),
publicKey = keyPair.publicKey.toBase64(),
),
)
}
fun onAddressesChanged(value: String) {
_uiState.value =
_uiState.value.copy(
interfaceProxy = _uiState.value.interfaceProxy.copy(addresses = value)
)
}
fun onListenPortChanged(value: String) {
_uiState.value =
_uiState.value.copy(
interfaceProxy = _uiState.value.interfaceProxy.copy(listenPort = value)
)
}
fun onDnsServersChanged(value: String) {
_uiState.value =
_uiState.value.copy(
interfaceProxy = _uiState.value.interfaceProxy.copy(dnsServers = value)
)
}
fun onMtuChanged(value: String) {
_uiState.value =
_uiState.value.copy(interfaceProxy = _uiState.value.interfaceProxy.copy(mtu = value))
}
private fun onInterfacePublicKeyChange(value: String) {
_uiState.value =
_uiState.value.copy(
interfaceProxy = _uiState.value.interfaceProxy.copy(publicKey = value)
)
}
fun onPrivateKeyChange(value: String) {
_uiState.value =
_uiState.value.copy(
interfaceProxy = _uiState.value.interfaceProxy.copy(privateKey = value)
)
if (NumberUtils.isValidKey(value)) {
val pair = KeyPair(Key.fromBase64(value))
onInterfacePublicKeyChange(pair.publicKey.toBase64())
} else {
onInterfacePublicKeyChange("")
}
}
fun emitQueriedPackages(query: String) {
val packages =
getAllInternetCapablePackages().filter {
getPackageLabel(it).lowercase().contains(query.lowercase())
}
_uiState.value = _uiState.value.copy(packages = packages)
}
} }

View File

@ -107,6 +107,7 @@ import com.zaneschepke.wireguardautotunnel.util.mapPeerStats
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@ -118,370 +119,472 @@ fun MainScreen(
showSnackbarMessage: (String) -> Unit, showSnackbarMessage: (String) -> Unit,
navController: NavController navController: NavController
) { ) {
val haptic = LocalHapticFeedback.current val haptic = LocalHapticFeedback.current
val context = LocalContext.current val context = LocalContext.current
val isVisible = rememberSaveable { mutableStateOf(true) } val isVisible = rememberSaveable { mutableStateOf(true) }
val scope = rememberCoroutineScope { Dispatchers.IO } val scope = rememberCoroutineScope { Dispatchers.IO }
val sheetState = rememberModalBottomSheetState() val sheetState = rememberModalBottomSheetState()
var showBottomSheet by remember { mutableStateOf(false) } var showBottomSheet by remember { mutableStateOf(false) }
var showPrimaryChangeAlertDialog by remember { mutableStateOf(false) } var showPrimaryChangeAlertDialog by remember { mutableStateOf(false) }
var selectedTunnel by remember { mutableStateOf<TunnelConfig?>(null) } var showDeleteTunnelAlertDialog by remember { mutableStateOf(false) }
val uiState by viewModel.uiState.collectAsStateWithLifecycle() var selectedTunnel by remember { mutableStateOf<TunnelConfig?>(null) }
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
LaunchedEffect(uiState.loading) { LaunchedEffect(uiState.loading) {
if (!uiState.loading && WireGuardAutoTunnel.isRunningOnAndroidTv()) { if (!uiState.loading && WireGuardAutoTunnel.isRunningOnAndroidTv()) {
delay(Constants.FOCUS_REQUEST_DELAY) delay(Constants.FOCUS_REQUEST_DELAY)
focusRequester.requestFocus() focusRequester.requestFocus()
}
} }
}
if (uiState.loading) { if (uiState.loading) {
LoadingScreen() LoadingScreen()
return return
} }
val tunnelFileImportResultLauncher = val tunnelFileImportResultLauncher =
rememberLauncherForActivityResult( rememberLauncherForActivityResult(
object : ActivityResultContracts.GetContent() { object : ActivityResultContracts.GetContent() {
override fun createIntent(context: Context, input: String): Intent { override fun createIntent(context: Context, input: String): Intent {
val intent = super.createIntent(context, input) val intent = super.createIntent(context, input)
/* AndroidTV now comes with stubs that do nothing but display a Toast less helpful than /* AndroidTV now comes with stubs that do nothing but display a Toast less helpful than
* what we can do, so detect this and throw an exception that we can catch later. */ * what we can do, so detect this and throw an exception that we can catch later. */
val activitiesToResolveIntent = val activitiesToResolveIntent =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.packageManager.queryIntentActivities( context.packageManager.queryIntentActivities(
intent, intent,
PackageManager.ResolveInfoFlags.of( PackageManager.ResolveInfoFlags.of(
PackageManager.MATCH_DEFAULT_ONLY.toLong())) PackageManager.MATCH_DEFAULT_ONLY.toLong(),
} else { ),
context.packageManager.queryIntentActivities( )
intent, PackageManager.MATCH_DEFAULT_ONLY) } else {
} context.packageManager.queryIntentActivities(
if (activitiesToResolveIntent.all { intent,
val name = it.activityInfo.packageName PackageManager.MATCH_DEFAULT_ONLY,
name.startsWith(Constants.GOOGLE_TV_EXPLORER_STUB) || )
name.startsWith(Constants.ANDROID_TV_EXPLORER_STUB) }
}) { if (
showSnackbarMessage(Event.Error.FileExplorerRequired.message) activitiesToResolveIntent.all {
} val name = it.activityInfo.packageName
return intent name.startsWith(Constants.GOOGLE_TV_EXPLORER_STUB) ||
} name.startsWith(Constants.ANDROID_TV_EXPLORER_STUB)
}) { data -> }
) {
showSnackbarMessage(Event.Error.FileExplorerRequired.message)
}
return intent
}
},
) { data ->
if (data == null) return@rememberLauncherForActivityResult if (data == null) return@rememberLauncherForActivityResult
scope.launch { scope.launch {
viewModel.onTunnelFileSelected(data).let { viewModel.onTunnelFileSelected(data).let {
when (it) { when (it) {
is Result.Error -> showSnackbarMessage(it.error.message) is Result.Error -> showSnackbarMessage(it.error.message)
is Result.Success -> {} is Result.Success -> {}
}
} }
}
} }
} }
val scanLauncher = val scanLauncher =
rememberLauncherForActivityResult( rememberLauncherForActivityResult(
contract = ScanContract(), contract = ScanContract(),
onResult = { onResult = {
if (it.contents != null) { if (it.contents != null) {
scope.launch { scope.launch {
viewModel.onTunnelQrResult(it.contents).let { result -> viewModel.onTunnelQrResult(it.contents).let { result ->
when (result) { when (result) {
is Result.Success -> {} is Result.Success -> {}
is Result.Error -> showSnackbarMessage(result.error.message) is Result.Error -> showSnackbarMessage(result.error.message)
}
}
}
}
})
AnimatedVisibility(showPrimaryChangeAlertDialog) {
AlertDialog(
onDismissRequest = { showPrimaryChangeAlertDialog = false },
confirmButton = {
TextButton(
onClick = {
viewModel.onDefaultTunnelChange(selectedTunnel)
showPrimaryChangeAlertDialog = false
selectedTunnel = null
}) {
Text(text = stringResource(R.string.okay))
}
},
dismissButton = {
TextButton(onClick = { showPrimaryChangeAlertDialog = false }) {
Text(text = stringResource(R.string.cancel))
}
},
title = { Text(text = stringResource(R.string.primary_tunnel_change)) },
text = { Text(text = stringResource(R.string.primary_tunnel_change_question)) })
}
fun onTunnelToggle(checked: Boolean, tunnel: TunnelConfig) {
if (checked) viewModel.onTunnelStart(tunnel) else viewModel.onTunnelStop()
}
Scaffold(
modifier =
Modifier.pointerInput(Unit) {
detectTapGestures(
onTap = {
if (!WireGuardAutoTunnel.isRunningOnAndroidTv()) selectedTunnel = null
})
},
floatingActionButtonPosition = FabPosition.End,
topBar = {
if (uiState.settings.isAutoTunnelEnabled)
TopAppBar(
title = {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.requiredWidth(LocalConfiguration.current.screenWidthDp.dp).padding(end = 5.dp)) {
Row {
Icon(
Icons.Rounded.Bolt,
stringResource(id = R.string.auto),
modifier = Modifier.size(25.dp),
tint = if(uiState.settings.isAutoTunnelPaused) Color.Gray else mint)
Text(
"Auto-tunneling: ${if(uiState.settings.isAutoTunnelPaused) "paused" else "active" }",
style = typography.bodyLarge,
modifier = Modifier.padding(start = 10.dp))
}
if(uiState.settings.isAutoTunnelPaused) TextButton(
onClick = { viewModel.resumeAutoTunneling() },
modifier = Modifier.padding(end = 10.dp)) {
Text("Resume")
} else TextButton(
onClick = { viewModel.pauseAutoTunneling() },
modifier = Modifier.padding(end = 10.dp)) {
Text("Pause")
}
}
},
)
},
floatingActionButton = {
AnimatedVisibility(
visible = isVisible.value,
enter = slideInVertically(initialOffsetY = { it * 2 }),
exit = slideOutVertically(targetOffsetY = { it * 2 })) {
val secondaryColor = MaterialTheme.colorScheme.secondary
val hoverColor = MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp)
var fobColor by remember { mutableStateOf(secondaryColor) }
FloatingActionButton(
modifier =
(if (WireGuardAutoTunnel.isRunningOnAndroidTv() &&
uiState.tunnels.isEmpty())
Modifier.focusRequester(focusRequester)
else Modifier)
.padding(bottom = 90.dp)
.onFocusChanged {
if (WireGuardAutoTunnel.isRunningOnAndroidTv()) {
fobColor = if (it.isFocused) hoverColor else secondaryColor
} }
}, }
onClick = { showBottomSheet = true }, }
containerColor = fobColor, }
shape = RoundedCornerShape(16.dp)) { },
)
AnimatedVisibility(showPrimaryChangeAlertDialog) {
AlertDialog(
onDismissRequest = { showPrimaryChangeAlertDialog = false },
confirmButton = {
TextButton(
onClick = {
viewModel.onDefaultTunnelChange(selectedTunnel)
showPrimaryChangeAlertDialog = false
selectedTunnel = null
},
) {
Text(text = stringResource(R.string.okay))
}
},
dismissButton = {
TextButton(onClick = { showPrimaryChangeAlertDialog = false }) {
Text(text = stringResource(R.string.cancel))
}
},
title = { Text(text = stringResource(R.string.primary_tunnel_change)) },
text = { Text(text = stringResource(R.string.primary_tunnel_change_question)) },
)
}
AnimatedVisibility(showDeleteTunnelAlertDialog) {
AlertDialog(
onDismissRequest = { showDeleteTunnelAlertDialog = false },
confirmButton = {
TextButton(
onClick = {
selectedTunnel?.let { viewModel.onDelete(it) }
showDeleteTunnelAlertDialog = false
selectedTunnel = null
},
) {
Text(text = stringResource(R.string.yes))
}
},
dismissButton = {
TextButton(onClick = { showDeleteTunnelAlertDialog = false }) {
Text(text = stringResource(R.string.cancel))
}
},
title = { Text(text = stringResource(R.string.delete_tunnel)) },
text = { Text(text = stringResource(R.string.delete_tunnel_message)) },
)
}
fun onTunnelToggle(checked: Boolean, tunnel: TunnelConfig) {
if (checked) viewModel.onTunnelStart(tunnel) else viewModel.onTunnelStop()
}
Scaffold(
modifier =
Modifier.pointerInput(Unit) {
detectTapGestures(
onTap = {
if (!WireGuardAutoTunnel.isRunningOnAndroidTv()) selectedTunnel = null
},
)
},
floatingActionButtonPosition = FabPosition.End,
topBar = {
if (uiState.settings.isAutoTunnelEnabled)
TopAppBar(
title = {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier.requiredWidth(LocalConfiguration.current.screenWidthDp.dp)
.padding(end = 5.dp),
) {
Row {
Icon(
Icons.Rounded.Bolt,
stringResource(id = R.string.auto),
modifier = Modifier.size(25.dp),
tint =
if (uiState.settings.isAutoTunnelPaused) Color.Gray
else mint,
)
Text(
"Auto-tunneling: ${if (uiState.settings.isAutoTunnelPaused) "paused" else "active"}",
style = typography.bodyLarge,
modifier = Modifier.padding(start = 10.dp),
)
}
if (uiState.settings.isAutoTunnelPaused)
TextButton(
onClick = { viewModel.resumeAutoTunneling() },
modifier = Modifier.padding(end = 10.dp),
) {
Text("Resume")
}
else
TextButton(
onClick = { viewModel.pauseAutoTunneling() },
modifier = Modifier.padding(end = 10.dp),
) {
Text("Pause")
}
}
},
)
},
floatingActionButton = {
AnimatedVisibility(
visible = isVisible.value,
enter = slideInVertically(initialOffsetY = { it * 2 }),
exit = slideOutVertically(targetOffsetY = { it * 2 }),
) {
val secondaryColor = MaterialTheme.colorScheme.secondary
val hoverColor = MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp)
var fobColor by remember { mutableStateOf(secondaryColor) }
FloatingActionButton(
modifier =
(if (
WireGuardAutoTunnel.isRunningOnAndroidTv() &&
uiState.tunnels.isEmpty()
)
Modifier.focusRequester(focusRequester)
else Modifier)
.padding(bottom = 90.dp)
.onFocusChanged {
if (WireGuardAutoTunnel.isRunningOnAndroidTv()) {
fobColor = if (it.isFocused) hoverColor else secondaryColor
}
},
onClick = { showBottomSheet = true },
containerColor = fobColor,
shape = RoundedCornerShape(16.dp),
) {
Icon( Icon(
imageVector = Icons.Rounded.Add, imageVector = Icons.Rounded.Add,
contentDescription = stringResource(id = R.string.add_tunnel), contentDescription = stringResource(id = R.string.add_tunnel),
tint = Color.DarkGray) tint = Color.DarkGray,
} )
}
} }
}) { innerPadding -> },
) { innerPadding ->
AnimatedVisibility(uiState.tunnels.isEmpty(), exit = fadeOut(), enter = fadeIn()) { AnimatedVisibility(uiState.tunnels.isEmpty(), exit = fadeOut(), enter = fadeIn()) {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxSize().padding(padding)) { modifier = Modifier.fillMaxSize().padding(padding),
) {
Text(text = stringResource(R.string.no_tunnels), fontStyle = FontStyle.Italic) Text(text = stringResource(R.string.no_tunnels), fontStyle = FontStyle.Italic)
} }
} }
if (showBottomSheet) { if (showBottomSheet) {
ModalBottomSheet( ModalBottomSheet(
onDismissRequest = { showBottomSheet = false }, sheetState = sheetState) { onDismissRequest = { showBottomSheet = false },
sheetState = sheetState,
) {
// Sheet content // Sheet content
Row( Row(
modifier = modifier =
Modifier.fillMaxWidth() Modifier.fillMaxWidth()
.clickable { .clickable {
showBottomSheet = false showBottomSheet = false
tunnelFileImportResultLauncher.launch(Constants.ALLOWED_FILE_TYPES) tunnelFileImportResultLauncher.launch(Constants.ALLOWED_FILE_TYPES)
} }
.padding(10.dp)) { .padding(10.dp),
Icon( ) {
Icons.Filled.FileOpen, Icon(
contentDescription = stringResource(id = R.string.open_file), Icons.Filled.FileOpen,
modifier = Modifier.padding(10.dp)) contentDescription = stringResource(id = R.string.open_file),
Text( modifier = Modifier.padding(10.dp),
stringResource(id = R.string.add_tunnels_text), )
modifier = Modifier.padding(10.dp)) Text(
} stringResource(id = R.string.add_tunnels_text),
modifier = Modifier.padding(10.dp),
)
}
if (!WireGuardAutoTunnel.isRunningOnAndroidTv()) { if (!WireGuardAutoTunnel.isRunningOnAndroidTv()) {
Divider() Divider()
Row( Row(
modifier = modifier =
Modifier.fillMaxWidth() Modifier.fillMaxWidth()
.clickable { .clickable {
scope.launch { scope.launch {
showBottomSheet = false showBottomSheet = false
val scanOptions = ScanOptions() val scanOptions = ScanOptions()
scanOptions.setDesiredBarcodeFormats(ScanOptions.QR_CODE) scanOptions.setDesiredBarcodeFormats(ScanOptions.QR_CODE)
scanOptions.setOrientationLocked(true) scanOptions.setOrientationLocked(true)
scanOptions.setPrompt(context.getString(R.string.scanning_qr)) scanOptions.setPrompt(
scanOptions.setBeepEnabled(false) context.getString(R.string.scanning_qr)
scanOptions.captureActivity = CaptureActivityPortrait::class.java )
scanLauncher.launch(scanOptions) scanOptions.setBeepEnabled(false)
scanOptions.captureActivity =
CaptureActivityPortrait::class.java
scanLauncher.launch(scanOptions)
}
} }
} .padding(10.dp),
.padding(10.dp)) { ) {
Icon( Icon(
Icons.Filled.QrCode, Icons.Filled.QrCode,
contentDescription = stringResource(id = R.string.qr_scan), contentDescription = stringResource(id = R.string.qr_scan),
modifier = Modifier.padding(10.dp)) modifier = Modifier.padding(10.dp),
)
Text( Text(
stringResource(id = R.string.add_from_qr), stringResource(id = R.string.add_from_qr),
modifier = Modifier.padding(10.dp)) modifier = Modifier.padding(10.dp),
} )
}
} }
Divider() Divider()
Row( Row(
modifier = modifier =
Modifier.fillMaxWidth() Modifier.fillMaxWidth()
.clickable { .clickable {
showBottomSheet = false showBottomSheet = false
navController.navigate( navController.navigate(
"${Screen.Config.route}/${Constants.MANUAL_TUNNEL_CONFIG_ID}") "${Screen.Config.route}/${Constants.MANUAL_TUNNEL_CONFIG_ID}",
)
} }
.padding(10.dp)) { .padding(10.dp),
Icon( ) {
Icons.Filled.Create, Icon(
contentDescription = stringResource(id = R.string.create_import), Icons.Filled.Create,
modifier = Modifier.padding(10.dp)) contentDescription = stringResource(id = R.string.create_import),
Text( modifier = Modifier.padding(10.dp),
stringResource(id = R.string.create_import), )
modifier = Modifier.padding(10.dp)) Text(
} stringResource(id = R.string.create_import),
} modifier = Modifier.padding(10.dp),
)
}
}
} }
LazyColumn( LazyColumn(
horizontalAlignment = Alignment.Start, horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Top, verticalArrangement = Arrangement.Top,
modifier = modifier =
Modifier.fillMaxWidth().fillMaxHeight(.90f).overscroll(ScrollableDefaults.overscrollEffect()).padding(innerPadding), Modifier.fillMaxWidth()
.fillMaxHeight(.90f)
.overscroll(ScrollableDefaults.overscrollEffect())
.padding(innerPadding),
state = rememberLazyListState(0, uiState.tunnels.count()), state = rememberLazyListState(0, uiState.tunnels.count()),
userScrollEnabled = true, userScrollEnabled = true,
reverseLayout = true, reverseLayout = true,
flingBehavior = ScrollableDefaults.flingBehavior()) { flingBehavior = ScrollableDefaults.flingBehavior(),
items(uiState.tunnels, ) {
key = { tunnel -> tunnel.id }) { tunnel -> items(
val leadingIconColor = uiState.tunnels,
(if (uiState.vpnState.name == tunnel.name && key = { tunnel -> tunnel.id },
uiState.vpnState.status == Tunnel.State.UP) { ) { tunnel ->
uiState.vpnState.statistics val leadingIconColor =
?.mapPeerStats() (if (
?.map { it.value?.handshakeStatus() } uiState.vpnState.name == tunnel.name &&
.let { statuses -> uiState.vpnState.status == Tunnel.State.UP
) {
uiState.vpnState.statistics
?.mapPeerStats()
?.map { it.value?.handshakeStatus() }
.let { statuses ->
when { when {
statuses?.all { it == HandshakeStatus.HEALTHY } == true -> mint statuses?.all { it == HandshakeStatus.HEALTHY } == true -> mint
statuses?.any { it == HandshakeStatus.STALE } == true -> corn statuses?.any { it == HandshakeStatus.STALE } == true -> corn
statuses?.all { it == HandshakeStatus.NOT_STARTED } == true -> statuses?.all { it == HandshakeStatus.NOT_STARTED } == true ->
Color.Gray Color.Gray
else -> { else -> {
Color.Gray Color.Gray
} }
} }
} }
} else { } else {
Color.Gray Color.Gray
}) })
val expanded = remember { mutableStateOf(false) } val expanded = remember { mutableStateOf(false) }
RowListItem( RowListItem(
icon = { icon = {
if (uiState.settings.isTunnelConfigDefault(tunnel)) { if (uiState.settings.isTunnelConfigDefault(tunnel)) {
Icon( Icon(
Icons.Rounded.Star, Icons.Rounded.Star,
stringResource(R.string.status), stringResource(R.string.status),
tint = leadingIconColor, tint = leadingIconColor,
modifier = Modifier.padding(end = 10.dp).size(20.dp)) modifier = Modifier.padding(end = 10.dp).size(20.dp),
} else { )
} else {
Icon( Icon(
Icons.Rounded.Circle, Icons.Rounded.Circle,
stringResource(R.string.status), stringResource(R.string.status),
tint = leadingIconColor, tint = leadingIconColor,
modifier = Modifier.padding(end = 15.dp).size(15.dp)) modifier = Modifier.padding(end = 15.dp).size(15.dp),
} )
}, }
text = tunnel.name, },
onHold = { text = tunnel.name,
if ((uiState.vpnState.status == Tunnel.State.UP) && onHold = {
(tunnel.name == uiState.vpnState.name)) { if (
(uiState.vpnState.status == Tunnel.State.UP) &&
(tunnel.name == uiState.vpnState.name)
) {
showSnackbarMessage(Event.Message.TunnelOffAction.message) showSnackbarMessage(Event.Message.TunnelOffAction.message)
return@RowListItem return@RowListItem
} }
haptic.performHapticFeedback(HapticFeedbackType.LongPress) haptic.performHapticFeedback(HapticFeedbackType.LongPress)
selectedTunnel = tunnel selectedTunnel = tunnel
}, },
onClick = { onClick = {
if (!WireGuardAutoTunnel.isRunningOnAndroidTv()) { if (!WireGuardAutoTunnel.isRunningOnAndroidTv()) {
if (uiState.vpnState.status == Tunnel.State.UP && if (
(uiState.vpnState.name == tunnel.name)) { uiState.vpnState.status == Tunnel.State.UP &&
expanded.value = !expanded.value (uiState.vpnState.name == tunnel.name)
) {
expanded.value = !expanded.value
} }
} else { } else {
selectedTunnel = tunnel selectedTunnel = tunnel
focusRequester.requestFocus() focusRequester.requestFocus()
} }
}, },
statistics = uiState.vpnState.statistics, statistics = uiState.vpnState.statistics,
expanded = expanded.value, expanded = expanded.value,
rowButton = { rowButton = {
if (tunnel.id == selectedTunnel?.id && if (
!WireGuardAutoTunnel.isRunningOnAndroidTv()) { tunnel.id == selectedTunnel?.id &&
!WireGuardAutoTunnel.isRunningOnAndroidTv()
) {
Row { Row {
if (!uiState.settings.isTunnelConfigDefault(tunnel)) { if (!uiState.settings.isTunnelConfigDefault(tunnel)) {
IconButton(
onClick = {
if (
uiState.settings.isAutoTunnelEnabled &&
!uiState.settings.isAutoTunnelPaused
) {
showSnackbarMessage(
Event.Message.AutoTunnelOffAction.message,
)
} else {
showPrimaryChangeAlertDialog = true
}
},
) {
Icon(
Icons.Rounded.Star,
stringResource(id = R.string.set_primary),
)
}
}
IconButton( IconButton(
onClick = { onClick = {
if (uiState.settings.isAutoTunnelEnabled && !uiState.settings.isAutoTunnelPaused) { if (
showSnackbarMessage( uiState.settings.isAutoTunnelEnabled &&
Event.Message.AutoTunnelOffAction.message) uiState.settings.isTunnelConfigDefault(
} else { tunnel,
showPrimaryChangeAlertDialog = true ) &&
} !uiState.settings.isAutoTunnelPaused
}) { ) {
Icon( showSnackbarMessage(
Icons.Rounded.Star, Event.Message.AutoTunnelOffAction.message,
stringResource(id = R.string.set_primary)) )
} } else
} navController.navigate(
IconButton( "${Screen.Config.route}/${selectedTunnel?.id}",
onClick = { )
if (uiState.settings.isAutoTunnelEnabled && uiState.settings.isTunnelConfigDefault(tunnel) },
&& !uiState.settings.isAutoTunnelPaused) { ) {
showSnackbarMessage(
Event.Message.AutoTunnelOffAction.message)
} else navController.navigate(
"${Screen.Config.route}/${selectedTunnel?.id}")
}) {
Icon(Icons.Rounded.Edit, stringResource(id = R.string.edit)) Icon(Icons.Rounded.Edit, stringResource(id = R.string.edit))
} }
IconButton( IconButton(
modifier = Modifier.focusable(), modifier = Modifier.focusable(),
onClick = { viewModel.onDelete(tunnel) }) { onClick = { showDeleteTunnelAlertDialog = true },
) {
Icon(Icons.Rounded.Delete, stringResource(id = R.string.delete)) Icon(Icons.Rounded.Delete, stringResource(id = R.string.delete))
} }
} }
} else { } else {
val checked by remember { val checked by remember {
derivedStateOf { derivedStateOf {
(uiState.vpnState.status == Tunnel.State.UP && (uiState.vpnState.status == Tunnel.State.UP &&
tunnel.name == uiState.vpnState.name) tunnel.name == uiState.vpnState.name)
} }
} }
if (!checked) expanded.value = false if (!checked) expanded.value = false
@ -491,72 +594,94 @@ fun MainScreen(
modifier = Modifier.focusRequester(focusRequester), modifier = Modifier.focusRequester(focusRequester),
checked = checked, checked = checked,
onCheckedChange = { checked -> onCheckedChange = { checked ->
if (!checked) expanded.value = false if (!checked) expanded.value = false
onTunnelToggle(checked, tunnel) onTunnelToggle(checked, tunnel)
}) },
)
if (WireGuardAutoTunnel.isRunningOnAndroidTv()) { if (WireGuardAutoTunnel.isRunningOnAndroidTv()) {
Row { Row {
if (!uiState.settings.isTunnelConfigDefault(tunnel)) { if (!uiState.settings.isTunnelConfigDefault(tunnel)) {
IconButton( IconButton(
onClick = { onClick = {
if (uiState.settings.isAutoTunnelEnabled) { if (uiState.settings.isAutoTunnelEnabled) {
showSnackbarMessage( showSnackbarMessage(
Event.Message.AutoTunnelOffAction.message) Event.Message.AutoTunnelOffAction.message,
} else { )
selectedTunnel = tunnel } else {
showPrimaryChangeAlertDialog = true selectedTunnel = tunnel
showPrimaryChangeAlertDialog = true
}
},
) {
Icon(
Icons.Rounded.Star,
stringResource(id = R.string.set_primary),
)
} }
}) { }
IconButton(
modifier = Modifier.focusRequester(focusRequester),
onClick = {
if (
uiState.vpnState.status == Tunnel.State.UP &&
(uiState.vpnState.name == tunnel.name)
) {
expanded.value = !expanded.value
} else {
showSnackbarMessage(
Event.Message.TunnelOnAction.message
)
}
},
) {
Icon(Icons.Rounded.Info, stringResource(R.string.info))
}
IconButton(
onClick = {
if (
uiState.vpnState.status == Tunnel.State.UP &&
tunnel.name == uiState.vpnState.name
) {
showSnackbarMessage(
Event.Message.TunnelOffAction.message
)
} else {
navController.navigate(
"${Screen.Config.route}/${tunnel.id}",
)
}
},
) {
Icon(Icons.Rounded.Edit, stringResource(id = R.string.edit))
}
IconButton(
onClick = {
if (
uiState.vpnState.status == Tunnel.State.UP &&
tunnel.name == uiState.vpnState.name
) {
showSnackbarMessage(
Event.Message.TunnelOffAction.message
)
} else {
showDeleteTunnelAlertDialog = true
}
},
) {
Icon( Icon(
Icons.Rounded.Star, Icons.Rounded.Delete,
stringResource(id = R.string.set_primary)) stringResource(id = R.string.delete),
} )
}
TunnelSwitch()
} }
IconButton(
modifier = Modifier.focusRequester(focusRequester),
onClick = {
if (uiState.vpnState.status == Tunnel.State.UP &&
(uiState.vpnState.name == tunnel.name)) {
expanded.value = !expanded.value
} else {
showSnackbarMessage(Event.Message.TunnelOnAction.message)
}
}) {
Icon(Icons.Rounded.Info, stringResource(R.string.info))
}
IconButton(
onClick = {
if (uiState.vpnState.status == Tunnel.State.UP &&
tunnel.name == uiState.vpnState.name) {
showSnackbarMessage(Event.Message.TunnelOffAction.message)
} else {
navController.navigate(
"${Screen.Config.route}/${tunnel.id}")
}
}) {
Icon(Icons.Rounded.Edit, stringResource(id = R.string.edit))
}
IconButton(
onClick = {
if (uiState.vpnState.status == Tunnel.State.UP &&
tunnel.name == uiState.vpnState.name) {
showSnackbarMessage(Event.Message.TunnelOffAction.message)
} else {
viewModel.onDelete(tunnel)
}
}) {
Icon(
Icons.Rounded.Delete,
stringResource(id = R.string.delete))
}
TunnelSwitch()
}
} else { } else {
TunnelSwitch() TunnelSwitch()
} }
} }
}) },
} )
} }
} }
}
} }

View File

@ -5,8 +5,8 @@ import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnState
import com.zaneschepke.wireguardautotunnel.util.TunnelConfigs import com.zaneschepke.wireguardautotunnel.util.TunnelConfigs
data class MainUiState( data class MainUiState(
val settings : Settings = Settings(), val settings: Settings = Settings(),
val tunnels : TunnelConfigs = emptyList(), val tunnels: TunnelConfigs = emptyList(),
val vpnState: VpnState = VpnState(), val vpnState: VpnState = VpnState(),
val loading : Boolean = true val loading: Boolean = true
) )

View File

@ -14,11 +14,7 @@ import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepository import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepository
import com.zaneschepke.wireguardautotunnel.data.repository.TunnelConfigRepository import com.zaneschepke.wireguardautotunnel.data.repository.TunnelConfigRepository
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceState
import com.zaneschepke.wireguardautotunnel.service.foreground.WireGuardConnectivityWatcherService
import com.zaneschepke.wireguardautotunnel.service.foreground.WireGuardTunnelService
import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService
import com.zaneschepke.wireguardautotunnel.service.tunnel.WireGuardTunnel
import com.zaneschepke.wireguardautotunnel.util.Constants import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.Event import com.zaneschepke.wireguardautotunnel.util.Event
import com.zaneschepke.wireguardautotunnel.util.NumberUtils import com.zaneschepke.wireguardautotunnel.util.NumberUtils
@ -32,6 +28,7 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import timber.log.Timber
import java.io.InputStream import java.io.InputStream
import java.util.zip.ZipInputStream import java.util.zip.ZipInputStream
import javax.inject.Inject import javax.inject.Inject
@ -46,216 +43,225 @@ constructor(
private val vpnService: VpnService private val vpnService: VpnService
) : ViewModel() { ) : ViewModel() {
val uiState = val uiState =
combine( combine(
settingsRepository.getSettingsFlow(), settingsRepository.getSettingsFlow(),
tunnelConfigRepository.getTunnelConfigsFlow(), tunnelConfigRepository.getTunnelConfigsFlow(),
vpnService.vpnState, vpnService.vpnState,
) { settings, tunnels, vpnState -> ) { settings, tunnels, vpnState ->
validateWatcherServiceState(settings) validateWatcherServiceState(settings)
MainUiState(settings, tunnels, vpnState, false) MainUiState(settings, tunnels, vpnState, false)
} }
.stateIn( .stateIn(
viewModelScope, viewModelScope,
SharingStarted.WhileSubscribed(Constants.SUBSCRIPTION_TIMEOUT), SharingStarted.WhileSubscribed(Constants.SUBSCRIPTION_TIMEOUT),
MainUiState()) MainUiState(),
)
private fun validateWatcherServiceState(settings: Settings) = viewModelScope.launch(Dispatchers.IO) { private fun validateWatcherServiceState(settings: Settings) =
val watcherState = viewModelScope.launch(Dispatchers.IO) {
ServiceManager.getServiceState( if (settings.isAutoTunnelEnabled) {
application.applicationContext, WireGuardConnectivityWatcherService::class.java) ServiceManager.startWatcherService(application.applicationContext)
if (settings.isAutoTunnelEnabled && }
watcherState == ServiceState.STOPPED) {
ServiceManager.startWatcherService(application.applicationContext)
}
}
private fun stopWatcherService() = viewModelScope.launch(Dispatchers.IO) {
ServiceManager.stopWatcherService(application.applicationContext)
}
fun onDelete(tunnel: TunnelConfig) {
viewModelScope.launch(Dispatchers.IO) {
if (tunnelConfigRepository.count() == 1) {
stopWatcherService()
val settings = settingsRepository.getSettings()
settings.defaultTunnel = null
settings.isAutoTunnelEnabled = false
settings.isAlwaysOnVpnEnabled = false
saveSettings(settings)
}
tunnelConfigRepository.delete(tunnel)
WireGuardAutoTunnel.requestTileServiceStateUpdate()
}
}
fun onTunnelStart(tunnelConfig: TunnelConfig) = viewModelScope.launch(Dispatchers.IO) {
stopActiveTunnel().await()
startTunnel(tunnelConfig)
}
private fun startTunnel(tunnelConfig: TunnelConfig) = viewModelScope.launch(Dispatchers.IO) {
ServiceManager.startVpnService(application.applicationContext, tunnelConfig.toString())
}
private fun stopActiveTunnel() =
viewModelScope.async(Dispatchers.IO) {
if (ServiceManager.getServiceState(
application.applicationContext, WireGuardTunnelService::class.java) ==
ServiceState.STARTED) {
onTunnelStop()
delay(Constants.TOGGLE_TUNNEL_DELAY)
} }
}
fun onTunnelStop() = viewModelScope.launch(Dispatchers.IO) { private fun stopWatcherService() =
ServiceManager.stopVpnService(application.applicationContext) viewModelScope.launch(Dispatchers.IO) {
} ServiceManager.stopWatcherService(application.applicationContext)
}
private fun validateConfigString(config: String) { fun onDelete(tunnel: TunnelConfig) {
TunnelConfig.configFromQuick(config) viewModelScope.launch(Dispatchers.IO) {
} if (tunnelConfigRepository.count() == 1) {
stopWatcherService()
val settings = settingsRepository.getSettings()
settings.defaultTunnel = null
settings.isAutoTunnelEnabled = false
settings.isAlwaysOnVpnEnabled = false
saveSettings(settings)
}
tunnelConfigRepository.delete(tunnel)
WireGuardAutoTunnel.requestTileServiceStateUpdate()
}
}
suspend fun onTunnelQrResult(result: String) : Result<Unit> { fun onTunnelStart(tunnelConfig: TunnelConfig) =
viewModelScope.launch(Dispatchers.IO) {
Timber.d("On start called!")
stopActiveTunnel().await()
startTunnel(tunnelConfig)
}
private fun startTunnel(tunnelConfig: TunnelConfig) =
viewModelScope.launch(Dispatchers.IO) {
Timber.d("Start tunnel via manager")
ServiceManager.startVpnService(application.applicationContext, tunnelConfig.toString())
}
private fun stopActiveTunnel() =
viewModelScope.async(Dispatchers.IO) {
onTunnelStop()
delay(Constants.TOGGLE_TUNNEL_DELAY)
}
fun onTunnelStop() =
viewModelScope.launch(Dispatchers.IO) {
Timber.d("Stopping active tunnel")
ServiceManager.stopVpnService(application.applicationContext)
}
private fun validateConfigString(config: String) {
TunnelConfig.configFromQuick(config)
}
suspend fun onTunnelQrResult(result: String): Result<Unit> {
return try { return try {
validateConfigString(result) validateConfigString(result)
val tunnelConfig = val tunnelConfig =
TunnelConfig(name = NumberUtils.generateRandomTunnelName(), wgQuick = result) TunnelConfig(name = NumberUtils.generateRandomTunnelName(), wgQuick = result)
addTunnel(tunnelConfig) addTunnel(tunnelConfig)
Result.Success(Unit) Result.Success(Unit)
} catch (e: Exception) { } catch (e: Exception) {
Result.Error(Event.Error.InvalidQrCode) Result.Error(Event.Error.InvalidQrCode)
} }
} }
private suspend fun saveTunnelConfigFromStream(stream: InputStream, fileName: String) { private suspend fun saveTunnelConfigFromStream(stream: InputStream, fileName: String) {
val bufferReader = stream.bufferedReader(charset = Charsets.UTF_8) val bufferReader = stream.bufferedReader(charset = Charsets.UTF_8)
val config = Config.parse(bufferReader) val config = Config.parse(bufferReader)
val tunnelName = getNameFromFileName(fileName) val tunnelName = getNameFromFileName(fileName)
addTunnel(TunnelConfig(name = tunnelName, wgQuick = config.toWgQuickString())) addTunnel(TunnelConfig(name = tunnelName, wgQuick = config.toWgQuickString()))
withContext(Dispatchers.IO) { stream.close() } withContext(Dispatchers.IO) { stream.close() }
} }
private fun getInputStreamFromUri(uri: Uri): InputStream? { private fun getInputStreamFromUri(uri: Uri): InputStream? {
return application.applicationContext.contentResolver.openInputStream(uri) return application.applicationContext.contentResolver.openInputStream(uri)
} }
suspend fun onTunnelFileSelected(uri: Uri) : Result<Unit> { suspend fun onTunnelFileSelected(uri: Uri): Result<Unit> {
try { try {
if(isValidUriContentScheme(uri)){ if (isValidUriContentScheme(uri)) {
val fileName = getFileName(application.applicationContext, uri) val fileName = getFileName(application.applicationContext, uri)
when (getFileExtensionFromFileName(fileName)) { when (getFileExtensionFromFileName(fileName)) {
Constants.CONF_FILE_EXTENSION -> saveTunnelFromConfUri(fileName, uri).let { Constants.CONF_FILE_EXTENSION ->
when(it) { saveTunnelFromConfUri(fileName, uri).let {
is Result.Error -> return Result.Error(Event.Error.FileReadFailed) when (it) {
is Result.Success -> return it is Result.Error -> return Result.Error(Event.Error.FileReadFailed)
is Result.Success -> return it
}
} }
}
Constants.ZIP_FILE_EXTENSION -> saveTunnelsFromZipUri(uri) Constants.ZIP_FILE_EXTENSION -> saveTunnelsFromZipUri(uri)
else -> return Result.Error(Event.Error.InvalidFileExtension) else -> return Result.Error(Event.Error.InvalidFileExtension)
} }
return Result.Success(Unit) return Result.Success(Unit)
} else { } else {
return Result.Error(Event.Error.InvalidFileExtension) return Result.Error(Event.Error.InvalidFileExtension)
} }
} catch (e: Exception) { } catch (e: Exception) {
return Result.Error(Event.Error.FileReadFailed) return Result.Error(Event.Error.FileReadFailed)
} }
}
private suspend fun saveTunnelsFromZipUri(uri: Uri) {
ZipInputStream(getInputStreamFromUri(uri)).use { zip ->
generateSequence { zip.nextEntry }
.filterNot {
it.isDirectory || getFileExtensionFromFileName(it.name) != Constants.CONF_FILE_EXTENSION
}
.forEach {
val name = getNameFromFileName(it.name)
val config = Config.parse(zip)
viewModelScope.launch(Dispatchers.IO) {
addTunnel(TunnelConfig(name = name, wgQuick = config.toWgQuickString()))
}
}
}
}
private suspend fun saveTunnelFromConfUri(name: String, uri: Uri) : Result<Unit> {
val stream = getInputStreamFromUri(uri)
return if(stream != null) {
saveTunnelConfigFromStream(stream, name)
Result.Success(Unit)
} else {
Result.Error(Event.Error.FileReadFailed)
}
}
private suspend fun addTunnel(tunnelConfig: TunnelConfig) {
saveTunnel(tunnelConfig)
WireGuardAutoTunnel.requestTileServiceStateUpdate()
}
fun pauseAutoTunneling() = viewModelScope.launch {
settingsRepository.save(uiState.value.settings.copy(isAutoTunnelPaused = true))
} }
fun resumeAutoTunneling() = viewModelScope.launch { private suspend fun saveTunnelsFromZipUri(uri: Uri) {
settingsRepository.save(uiState.value.settings.copy(isAutoTunnelPaused = false)) ZipInputStream(getInputStreamFromUri(uri)).use { zip ->
generateSequence { zip.nextEntry }
.filterNot {
it.isDirectory ||
getFileExtensionFromFileName(it.name) != Constants.CONF_FILE_EXTENSION
}
.forEach {
val name = getNameFromFileName(it.name)
val config = Config.parse(zip)
viewModelScope.launch(Dispatchers.IO) {
addTunnel(TunnelConfig(name = name, wgQuick = config.toWgQuickString()))
}
}
}
} }
private suspend fun saveTunnel(tunnelConfig: TunnelConfig) { private suspend fun saveTunnelFromConfUri(name: String, uri: Uri): Result<Unit> {
tunnelConfigRepository.save(tunnelConfig) val stream = getInputStreamFromUri(uri)
} return if (stream != null) {
saveTunnelConfigFromStream(stream, name)
private fun getFileNameByCursor(context: Context, uri: Uri): String? { Result.Success(Unit)
context.contentResolver.query(uri, null, null, null, null)?.use { } else {
return getDisplayNameByCursor(it) Result.Error(Event.Error.FileReadFailed)
}
} }
return null
}
private fun getDisplayNameColumnIndex(cursor: Cursor): Int? { private suspend fun addTunnel(tunnelConfig: TunnelConfig) {
val columnIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) saveTunnel(tunnelConfig)
return if (columnIndex != -1) { WireGuardAutoTunnel.requestTileServiceStateUpdate()
return columnIndex
} else {
null
} }
}
private fun getDisplayNameByCursor(cursor: Cursor): String? { fun pauseAutoTunneling() =
return if (cursor.moveToFirst()) { viewModelScope.launch {
val index = getDisplayNameColumnIndex(cursor) settingsRepository.save(uiState.value.settings.copy(isAutoTunnelPaused = true))
if (index != null) { }
cursor.getString(index)
} else null
} else null
}
private fun isValidUriContentScheme(uri: Uri): Boolean { fun resumeAutoTunneling() =
return uri.scheme == Constants.URI_CONTENT_SCHEME viewModelScope.launch {
} settingsRepository.save(uiState.value.settings.copy(isAutoTunnelPaused = false))
private fun getFileName(context: Context, uri: Uri): String { }
private suspend fun saveTunnel(tunnelConfig: TunnelConfig) {
tunnelConfigRepository.save(tunnelConfig)
}
private fun getFileNameByCursor(context: Context, uri: Uri): String? {
context.contentResolver.query(uri, null, null, null, null)?.use {
return getDisplayNameByCursor(it)
}
return null
}
private fun getDisplayNameColumnIndex(cursor: Cursor): Int? {
val columnIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
return if (columnIndex != -1) {
return columnIndex
} else {
null
}
}
private fun getDisplayNameByCursor(cursor: Cursor): String? {
return if (cursor.moveToFirst()) {
val index = getDisplayNameColumnIndex(cursor)
if (index != null) {
cursor.getString(index)
} else null
} else null
}
private fun isValidUriContentScheme(uri: Uri): Boolean {
return uri.scheme == Constants.URI_CONTENT_SCHEME
}
private fun getFileName(context: Context, uri: Uri): String {
return getFileNameByCursor(context, uri) ?: NumberUtils.generateRandomTunnelName() return getFileNameByCursor(context, uri) ?: NumberUtils.generateRandomTunnelName()
}
private fun getNameFromFileName(fileName: String): String {
return fileName.substring(0, fileName.lastIndexOf('.'))
}
private fun getFileExtensionFromFileName(fileName: String): String {
return try {
fileName.substring(fileName.lastIndexOf('.'))
} catch (e: Exception) {
""
} }
}
private fun saveSettings(settings: Settings) = private fun getNameFromFileName(fileName: String): String {
viewModelScope.launch(Dispatchers.IO) { settingsRepository.save(settings) } return fileName.substring(0, fileName.lastIndexOf('.'))
fun onDefaultTunnelChange(selectedTunnel: TunnelConfig?) = viewModelScope.launch {
if (selectedTunnel != null) {
saveSettings(uiState.value.settings.copy(defaultTunnel = selectedTunnel.toString())).join()
WireGuardAutoTunnel.requestTileServiceStateUpdate()
} }
}
private fun getFileExtensionFromFileName(fileName: String): String {
return try {
fileName.substring(fileName.lastIndexOf('.'))
} catch (e: Exception) {
""
}
}
private fun saveSettings(settings: Settings) =
viewModelScope.launch(Dispatchers.IO) { settingsRepository.save(settings) }
fun onDefaultTunnelChange(selectedTunnel: TunnelConfig?) =
viewModelScope.launch {
if (selectedTunnel != null) {
saveSettings(uiState.value.settings.copy(defaultTunnel = selectedTunnel.toString()))
.join()
WireGuardAutoTunnel.requestTileServiceStateUpdate()
}
}
} }

View File

@ -1,10 +1,17 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.settings package com.zaneschepke.wireguardautotunnel.ui.screens.settings
import android.Manifest import android.Manifest
import android.app.Activity
import android.content.Context.POWER_SERVICE
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.PowerManager
import android.provider.Settings
import android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS import android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.ActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
@ -83,7 +90,8 @@ import java.io.File
@OptIn( @OptIn(
ExperimentalPermissionsApi::class, ExperimentalPermissionsApi::class,
ExperimentalLayoutApi::class) ExperimentalLayoutApi::class,
)
@Composable @Composable
fun SettingsScreen( fun SettingsScreen(
viewModel: SettingsViewModel = hiltViewModel(), viewModel: SettingsViewModel = hiltViewModel(),
@ -91,92 +99,127 @@ fun SettingsScreen(
showSnackbarMessage: (String) -> Unit, showSnackbarMessage: (String) -> Unit,
focusRequester: FocusRequester focusRequester: FocusRequester
) { ) {
val scope = rememberCoroutineScope { Dispatchers.IO } val scope = rememberCoroutineScope { Dispatchers.IO }
val context = LocalContext.current val context = LocalContext.current
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val fineLocationState = rememberPermissionState(Manifest.permission.ACCESS_FINE_LOCATION) val fineLocationState = rememberPermissionState(Manifest.permission.ACCESS_FINE_LOCATION)
var currentText by remember { mutableStateOf("") } var currentText by remember { mutableStateOf("") }
var isBackgroundLocationGranted by remember { mutableStateOf(true) } var isBackgroundLocationGranted by remember { mutableStateOf(true) }
var showLocationServicesAlertDialog by remember { mutableStateOf(false) } var showLocationServicesAlertDialog by remember { mutableStateOf(false) }
var didExportFiles by remember { mutableStateOf(false) } var didExportFiles by remember { mutableStateOf(false) }
var showAuthPrompt by remember { mutableStateOf(false) } var showAuthPrompt by remember { mutableStateOf(false) }
val focusRequester2 = remember { FocusRequester() } val focusRequester2 = remember { FocusRequester() }
val screenPadding = 5.dp val screenPadding = 5.dp
val fillMaxWidth = .85f val fillMaxWidth = .85f
if (uiState.loading) { if (uiState.loading) {
LoadingScreen() LoadingScreen()
return return
}
fun exportAllConfigs() {
try {
val files = uiState.tunnels.map { File(context.cacheDir, "${it.name}.conf") }
files.forEachIndexed { index, file ->
file.outputStream().use { it.write(uiState.tunnels[index].wgQuick.toByteArray()) }
}
FileUtils.saveFilesToZip(context, files)
didExportFiles = true
showSnackbarMessage(Event.Message.ConfigsExported.message)
} catch (e: Exception) {
showSnackbarMessage(Event.Error.Exception(e).message)
} }
}
fun saveTrustedSSID() { val startForResult =
if (currentText.isNotEmpty()) { rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
viewModel.onSaveTrustedSSID(currentText).let { result: ActivityResult ->
when(it) { if (result.resultCode == Activity.RESULT_OK) {
is Result.Success -> currentText = "" val intent = result.data
is Result.Error -> showSnackbarMessage(it.error.message) // Handle the Intent
} }
} viewModel.setBatteryOptimizeDisableShown()
}
}
fun openSettings() {
scope.launch {
val intentSettings = Intent(ACTION_APPLICATION_DETAILS_SETTINGS)
intentSettings.data = Uri.fromParts("package", context.packageName, null)
context.startActivity(intentSettings)
}
}
fun checkFineLocationGranted() {
isBackgroundLocationGranted =
if (!fineLocationState.status.isGranted) {
false
} else {
viewModel.setLocationDisclosureShown()
true
} }
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { fun exportAllConfigs() {
if(WireGuardAutoTunnel.isRunningOnAndroidTv() && Build.VERSION.SDK_INT == Build.VERSION_CODES.Q){ try {
checkFineLocationGranted() val files = uiState.tunnels.map { File(context.cacheDir, "${it.name}.conf") }
} else { files.forEachIndexed { index, file ->
val backgroundLocationState = file.outputStream().use { it.write(uiState.tunnels[index].wgQuick.toByteArray()) }
rememberPermissionState(Manifest.permission.ACCESS_BACKGROUND_LOCATION) }
isBackgroundLocationGranted = FileUtils.saveFilesToZip(context, files)
if (!backgroundLocationState.status.isGranted) { didExportFiles = true
false showSnackbarMessage(Event.Message.ConfigsExported.message)
} else { } catch (e: Exception) {
SideEffect { viewModel.setLocationDisclosureShown() } showSnackbarMessage(Event.Error.Exception(e).message)
true }
} }
}
}
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) { fun isBatteryOptimizationsDisabled(): Boolean {
checkFineLocationGranted() val pm = context.getSystemService(POWER_SERVICE) as PowerManager
} return pm.isIgnoringBatteryOptimizations(context.packageName)
}
fun requestBatteryOptimizationsDisabled() {
val intent =
Intent().apply {
this.action = Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS
data = Uri.fromParts("package", context.packageName, null)
}
startForResult.launch(intent)
}
fun handleAutoTunnelToggle() {
if (uiState.isBatteryOptimizeDisableShown || isBatteryOptimizationsDisabled()) {
viewModel.toggleAutoTunnel()
} else {
requestBatteryOptimizationsDisabled()
}
}
fun saveTrustedSSID() {
if (currentText.isNotEmpty()) {
viewModel.onSaveTrustedSSID(currentText).let {
when (it) {
is Result.Success -> currentText = ""
is Result.Error -> showSnackbarMessage(it.error.message)
}
}
}
}
fun openSettings() {
scope.launch {
val intentSettings = Intent(ACTION_APPLICATION_DETAILS_SETTINGS)
intentSettings.data = Uri.fromParts("package", context.packageName, null)
context.startActivity(intentSettings)
}
}
fun checkFineLocationGranted() {
isBackgroundLocationGranted =
if (!fineLocationState.status.isGranted) {
false
} else {
viewModel.setLocationDisclosureShown()
true
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
if (
WireGuardAutoTunnel.isRunningOnAndroidTv() &&
Build.VERSION.SDK_INT == Build.VERSION_CODES.Q
) {
checkFineLocationGranted()
} else {
val backgroundLocationState =
rememberPermissionState(Manifest.permission.ACCESS_BACKGROUND_LOCATION)
isBackgroundLocationGranted =
if (!backgroundLocationState.status.isGranted) {
false
} else {
SideEffect { viewModel.setLocationDisclosureShown() }
true
}
}
}
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) {
checkFineLocationGranted()
}
AnimatedVisibility(showLocationServicesAlertDialog) { AnimatedVisibility(showLocationServicesAlertDialog) {
AlertDialog( AlertDialog(
@ -185,8 +228,9 @@ fun SettingsScreen(
TextButton( TextButton(
onClick = { onClick = {
showLocationServicesAlertDialog = false showLocationServicesAlertDialog = false
viewModel.toggleAutoTunnel() handleAutoTunnelToggle()
}) { },
) {
Text(text = stringResource(R.string.okay)) Text(text = stringResource(R.string.okay))
} }
}, },
@ -196,253 +240,313 @@ fun SettingsScreen(
} }
}, },
title = { Text(text = stringResource(R.string.location_services_not_detected)) }, title = { Text(text = stringResource(R.string.location_services_not_detected)) },
text = { Text(text = stringResource(R.string.location_services_missing_message)) }) text = { Text(text = stringResource(R.string.location_services_missing_message)) },
)
} }
if (!uiState.isLocationDisclosureShown) { if (!uiState.isLocationDisclosureShown) {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top, verticalArrangement = Arrangement.Top,
modifier = Modifier.fillMaxSize().verticalScroll(scrollState).padding(padding)) { modifier = Modifier.fillMaxSize().verticalScroll(scrollState).padding(padding),
Icon( ) {
Icons.Rounded.LocationOff, Icon(
contentDescription = stringResource(id = R.string.map), Icons.Rounded.LocationOff,
modifier = Modifier.padding(30.dp).size(128.dp)) contentDescription = stringResource(id = R.string.map),
Text( modifier = Modifier.padding(30.dp).size(128.dp),
stringResource(R.string.prominent_background_location_title), )
textAlign = TextAlign.Center, Text(
modifier = Modifier.padding(30.dp), stringResource(R.string.prominent_background_location_title),
fontSize = 20.sp) textAlign = TextAlign.Center,
Text( modifier = Modifier.padding(30.dp),
stringResource(R.string.prominent_background_location_message), fontSize = 20.sp,
textAlign = TextAlign.Center, )
modifier = Modifier.padding(30.dp), Text(
fontSize = 15.sp) stringResource(R.string.prominent_background_location_message),
Row( textAlign = TextAlign.Center,
modifier = modifier = Modifier.padding(30.dp),
if (WireGuardAutoTunnel.isRunningOnAndroidTv()) { fontSize = 15.sp,
Modifier.fillMaxWidth().padding(10.dp) )
} else { Row(
Modifier.fillMaxWidth().padding(30.dp) modifier =
}, if (WireGuardAutoTunnel.isRunningOnAndroidTv()) {
verticalAlignment = Alignment.CenterVertically, Modifier.fillMaxWidth().padding(10.dp)
horizontalArrangement = Arrangement.SpaceEvenly) { } else {
Modifier.fillMaxWidth().padding(30.dp)
},
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceEvenly,
) {
TextButton(onClick = { viewModel.setLocationDisclosureShown() }) { TextButton(onClick = { viewModel.setLocationDisclosureShown() }) {
Text(stringResource(id = R.string.no_thanks)) Text(stringResource(id = R.string.no_thanks))
} }
TextButton( TextButton(
modifier = Modifier.focusRequester(focusRequester), modifier = Modifier.focusRequester(focusRequester),
onClick = { onClick = {
openSettings() openSettings()
viewModel.setLocationDisclosureShown() viewModel.setLocationDisclosureShown()
}) { },
Text(stringResource(id = R.string.turn_on)) ) {
} Text(stringResource(id = R.string.turn_on))
} }
}
} }
} }
if(showAuthPrompt) { if (showAuthPrompt) {
AuthorizationPrompt( AuthorizationPrompt(
onSuccess = { onSuccess = {
showAuthPrompt = false showAuthPrompt = false
exportAllConfigs() exportAllConfigs()
}, },
onError = { _ -> onError = { _ ->
showAuthPrompt = false showAuthPrompt = false
showSnackbarMessage(Event.Error.AuthenticationFailed.message) showSnackbarMessage(Event.Error.AuthenticationFailed.message)
}, },
onFailure = { onFailure = {
showAuthPrompt = false showAuthPrompt = false
showSnackbarMessage(Event.Error.AuthorizationFailed.message) showSnackbarMessage(Event.Error.AuthorizationFailed.message)
}) },
} )
}
if (uiState.tunnels.isEmpty() && uiState.isLocationDisclosureShown) { if (uiState.tunnels.isEmpty() && uiState.isLocationDisclosureShown) {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxSize().padding(padding)) { modifier = Modifier.fillMaxSize().padding(padding),
Text( ) {
stringResource(R.string.one_tunnel_required), Text(
textAlign = TextAlign.Center, stringResource(R.string.one_tunnel_required),
modifier = Modifier.padding(15.dp), textAlign = TextAlign.Center,
fontStyle = FontStyle.Italic) modifier = Modifier.padding(15.dp),
fontStyle = FontStyle.Italic,
)
} }
} }
if (uiState.isLocationDisclosureShown && uiState.tunnels.isNotEmpty()) { if (uiState.isLocationDisclosureShown && uiState.tunnels.isNotEmpty()) {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top, verticalArrangement = Arrangement.Top,
modifier = modifier =
Modifier.fillMaxSize().verticalScroll(scrollState).clickable( Modifier.fillMaxSize().verticalScroll(scrollState).clickable(
indication = null, interactionSource = interactionSource) { indication = null,
focusManager.clearFocus() interactionSource = interactionSource,
}) { ) {
Surface( focusManager.clearFocus()
tonalElevation = 2.dp, },
shadowElevation = 2.dp, ) {
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surface,
modifier =
(if (WireGuardAutoTunnel.isRunningOnAndroidTv()) {
Modifier.height(IntrinsicSize.Min)
.fillMaxWidth(fillMaxWidth)
.padding(top = 10.dp)
} else {
Modifier.fillMaxWidth(fillMaxWidth).padding(top = 60.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.isAutoTunnelEnabled ||
uiState.settings.isAlwaysOnVpnEnabled),
checked = uiState.settings.isTunnelOnWifiEnabled,
padding = screenPadding,
onCheckChanged = { viewModel.onToggleTunnelOnWifi() },
modifier = if(uiState.settings.isAutoTunnelEnabled) Modifier else Modifier.focusRequester(focusRequester).focusProperties { down = focusRequester2 })
AnimatedVisibility(visible = uiState.settings.isTunnelOnWifiEnabled) {
Column {
FlowRow(
modifier = Modifier.padding(screenPadding).fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(5.dp)) {
uiState.settings.trustedNetworkSSIDs.forEach { ssid ->
ClickableIconButton(
onClick = { if(WireGuardAutoTunnel.isRunningOnAndroidTv()) {
viewModel.onDeleteTrustedSSID(ssid)
focusRequester2.requestFocus()
}},
onIconClick = { viewModel.onDeleteTrustedSSID(ssid) },
text = ssid,
icon = Icons.Filled.Close,
enabled =
!(uiState.settings.isAutoTunnelEnabled ||
uiState.settings.isAlwaysOnVpnEnabled))
}
if (uiState.settings.trustedNetworkSSIDs.isEmpty()) {
Text(
stringResource(R.string.none),
fontStyle = FontStyle.Italic,
color = Color.Gray)
}
}
OutlinedTextField(
enabled =
!(uiState.settings.isAutoTunnelEnabled ||
uiState.settings.isAlwaysOnVpnEnabled),
value = currentText,
onValueChange = { currentText = it },
label = { Text(stringResource(R.string.add_trusted_ssid)) },
modifier =
Modifier.padding(
start = screenPadding, top = 5.dp, bottom = 10.dp)
.focusRequester(focusRequester2)
,
maxLines = 1,
keyboardOptions =
KeyboardOptions(
capitalization = KeyboardCapitalization.None,
imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { saveTrustedSSID() }),
trailingIcon = {
if (currentText != "") {
IconButton(onClick = { saveTrustedSSID() }) {
Icon(
imageVector = Icons.Outlined.Add,
contentDescription =
if (currentText == "") {
stringResource(
id = R.string.trusted_ssid_empty_description)
} else {
stringResource(
id = R.string.trusted_ssid_value_description)
},
tint = MaterialTheme.colorScheme.primary)
}
}
})
}
}
ConfigurationToggle(
stringResource(R.string.tunnel_mobile_data),
enabled =
!(uiState.settings.isAutoTunnelEnabled ||
uiState.settings.isAlwaysOnVpnEnabled),
checked = uiState.settings.isTunnelOnMobileDataEnabled,
padding = screenPadding,
onCheckChanged = { viewModel.onToggleTunnelOnMobileData() })
ConfigurationToggle(
stringResource(id = R.string.tunnel_on_ethernet),
enabled =
!(uiState.settings.isAutoTunnelEnabled ||
uiState.settings.isAlwaysOnVpnEnabled),
checked = uiState.settings.isTunnelOnEthernetEnabled,
padding = screenPadding,
onCheckChanged = { viewModel.onToggleTunnelOnEthernet() })
ConfigurationToggle(
stringResource(R.string.battery_saver),
enabled =
!(uiState.settings.isAutoTunnelEnabled ||
uiState.settings.isAlwaysOnVpnEnabled),
checked = uiState.settings.isBatterySaverEnabled,
padding = screenPadding,
onCheckChanged = { viewModel.onToggleBatterySaver() })
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = (if(!uiState.settings.isAutoTunnelEnabled) Modifier else Modifier.focusRequester(focusRequester))
.fillMaxSize().padding(top = 5.dp),
horizontalArrangement = Arrangement.Center) {
TextButton(
enabled = !uiState.settings.isAlwaysOnVpnEnabled,
onClick = {
if (uiState.settings.isTunnelOnWifiEnabled && !uiState.settings.isAutoTunnelEnabled) {
when(false) {
isBackgroundLocationGranted ->
showSnackbarMessage(Event.Error.BackgroundLocationRequired.message)
fineLocationState.status.isGranted ->
showSnackbarMessage(Event.Error.PreciseLocationRequired.message)
viewModel.isLocationEnabled(context) ->
showLocationServicesAlertDialog = true
else -> {
viewModel.toggleAutoTunnel()
}
}
} else {
viewModel.toggleAutoTunnel()
}
}) {
val autoTunnelButtonText =
if (uiState.settings.isAutoTunnelEnabled) {
stringResource(R.string.disable_auto_tunnel)
} else {
stringResource(id = R.string.enable_auto_tunnel)
}
Text(autoTunnelButtonText)
}
}
}
}
if (WgQuickBackend.hasKernelSupport()) {
Surface( Surface(
tonalElevation = 2.dp, tonalElevation = 2.dp,
shadowElevation = 2.dp, shadowElevation = 2.dp,
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surface, color = MaterialTheme.colorScheme.surface,
modifier = Modifier.fillMaxWidth(fillMaxWidth).padding(vertical = 10.dp)) { modifier =
Column( (if (WireGuardAutoTunnel.isRunningOnAndroidTv()) {
horizontalAlignment = Alignment.Start, Modifier.height(IntrinsicSize.Min)
verticalArrangement = Arrangement.Top, .fillMaxWidth(fillMaxWidth)
modifier = Modifier.padding(15.dp)) { .padding(top = 10.dp)
} else {
Modifier.fillMaxWidth(fillMaxWidth).padding(top = 60.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.isAutoTunnelEnabled ||
uiState.settings.isAlwaysOnVpnEnabled),
checked = uiState.settings.isTunnelOnWifiEnabled,
padding = screenPadding,
onCheckChanged = { viewModel.onToggleTunnelOnWifi() },
modifier =
if (uiState.settings.isAutoTunnelEnabled) Modifier
else
Modifier.focusRequester(focusRequester).focusProperties {
down = focusRequester2
},
)
AnimatedVisibility(visible = uiState.settings.isTunnelOnWifiEnabled) {
Column {
FlowRow(
modifier = Modifier.padding(screenPadding).fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(5.dp),
) {
uiState.settings.trustedNetworkSSIDs.forEach { ssid ->
ClickableIconButton(
onClick = {
if (WireGuardAutoTunnel.isRunningOnAndroidTv()) {
viewModel.onDeleteTrustedSSID(ssid)
focusRequester2.requestFocus()
}
},
onIconClick = { viewModel.onDeleteTrustedSSID(ssid) },
text = ssid,
icon = Icons.Filled.Close,
enabled =
!(uiState.settings.isAutoTunnelEnabled ||
uiState.settings.isAlwaysOnVpnEnabled),
)
}
if (uiState.settings.trustedNetworkSSIDs.isEmpty()) {
Text(
stringResource(R.string.none),
fontStyle = FontStyle.Italic,
color = Color.Gray,
)
}
}
OutlinedTextField(
enabled =
!(uiState.settings.isAutoTunnelEnabled ||
uiState.settings.isAlwaysOnVpnEnabled),
value = currentText,
onValueChange = { currentText = it },
label = { Text(stringResource(R.string.add_trusted_ssid)) },
modifier =
Modifier.padding(
start = screenPadding,
top = 5.dp,
bottom = 10.dp,
)
.focusRequester(focusRequester2),
maxLines = 1,
keyboardOptions =
KeyboardOptions(
capitalization = KeyboardCapitalization.None,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(onDone = { saveTrustedSSID() }),
trailingIcon = {
if (currentText != "") {
IconButton(onClick = { saveTrustedSSID() }) {
Icon(
imageVector = Icons.Outlined.Add,
contentDescription =
if (currentText == "") {
stringResource(
id =
R.string
.trusted_ssid_empty_description,
)
} else {
stringResource(
id =
R.string
.trusted_ssid_value_description,
)
},
tint = MaterialTheme.colorScheme.primary,
)
}
}
},
)
}
}
ConfigurationToggle(
stringResource(R.string.tunnel_mobile_data),
enabled =
!(uiState.settings.isAutoTunnelEnabled ||
uiState.settings.isAlwaysOnVpnEnabled),
checked = uiState.settings.isTunnelOnMobileDataEnabled,
padding = screenPadding,
onCheckChanged = { viewModel.onToggleTunnelOnMobileData() },
)
ConfigurationToggle(
stringResource(id = R.string.tunnel_on_ethernet),
enabled =
!(uiState.settings.isAutoTunnelEnabled ||
uiState.settings.isAlwaysOnVpnEnabled),
checked = uiState.settings.isTunnelOnEthernetEnabled,
padding = screenPadding,
onCheckChanged = { viewModel.onToggleTunnelOnEthernet() },
)
ConfigurationToggle(
stringResource(R.string.battery_saver),
enabled =
!(uiState.settings.isAutoTunnelEnabled ||
uiState.settings.isAlwaysOnVpnEnabled),
checked = uiState.settings.isBatterySaverEnabled,
padding = screenPadding,
onCheckChanged = { viewModel.onToggleBatterySaver() },
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
(if (!uiState.settings.isAutoTunnelEnabled) Modifier
else
Modifier.focusRequester(
focusRequester,
))
.fillMaxSize()
.padding(top = 5.dp),
horizontalArrangement = Arrangement.Center,
) {
TextButton(
enabled = !uiState.settings.isAlwaysOnVpnEnabled,
onClick = {
if (
uiState.settings.isTunnelOnWifiEnabled &&
!uiState.settings.isAutoTunnelEnabled
) {
when (false) {
isBackgroundLocationGranted ->
showSnackbarMessage(
Event.Error.BackgroundLocationRequired.message
)
fineLocationState.status.isGranted ->
showSnackbarMessage(
Event.Error.PreciseLocationRequired.message
)
viewModel.isLocationEnabled(context) ->
showLocationServicesAlertDialog = true
else -> {
handleAutoTunnelToggle()
}
}
} else {
handleAutoTunnelToggle()
}
},
) {
val autoTunnelButtonText =
if (uiState.settings.isAutoTunnelEnabled) {
stringResource(R.string.disable_auto_tunnel)
} else {
stringResource(id = R.string.enable_auto_tunnel)
}
Text(autoTunnelButtonText)
}
}
}
}
if (WgQuickBackend.hasKernelSupport()) {
Surface(
tonalElevation = 2.dp,
shadowElevation = 2.dp,
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surface,
modifier = Modifier.fillMaxWidth(fillMaxWidth).padding(vertical = 10.dp),
) {
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Top,
modifier = Modifier.padding(15.dp),
) {
SectionTitle( SectionTitle(
title = stringResource(id = R.string.kernel), padding = screenPadding) title = stringResource(id = R.string.kernel),
padding = screenPadding,
)
ConfigurationToggle( ConfigurationToggle(
stringResource(R.string.use_kernel), stringResource(R.string.use_kernel),
enabled = enabled =
@ -451,58 +555,70 @@ fun SettingsScreen(
(uiState.vpnState.status == Tunnel.State.UP)), (uiState.vpnState.status == Tunnel.State.UP)),
checked = uiState.settings.isKernelEnabled, checked = uiState.settings.isKernelEnabled,
padding = screenPadding, padding = screenPadding,
onCheckChanged = { viewModel.onToggleKernelMode().let { onCheckChanged = {
when(it) { viewModel.onToggleKernelMode().let {
is Result.Error -> showSnackbarMessage(it.error.message) when (it) {
is Result.Success -> {} is Result.Error -> showSnackbarMessage(it.error.message)
is Result.Success -> {}
}
} }
} }) },
} )
}
} }
} }
if (!WireGuardAutoTunnel.isRunningOnAndroidTv()) { if (!WireGuardAutoTunnel.isRunningOnAndroidTv()) {
Surface( Surface(
tonalElevation = 2.dp, tonalElevation = 2.dp,
shadowElevation = 2.dp, shadowElevation = 2.dp,
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surface, color = MaterialTheme.colorScheme.surface,
modifier = modifier =
Modifier.fillMaxWidth(fillMaxWidth) Modifier.fillMaxWidth(fillMaxWidth)
.padding(vertical = 10.dp) .padding(vertical = 10.dp)
.padding(bottom = 140.dp)) { .padding(bottom = 140.dp),
Column( ) {
horizontalAlignment = Alignment.Start, Column(
verticalArrangement = Arrangement.Top, horizontalAlignment = Alignment.Start,
modifier = Modifier.padding(15.dp)) { verticalArrangement = Arrangement.Top,
modifier = Modifier.padding(15.dp),
) {
SectionTitle( SectionTitle(
title = stringResource(id = R.string.other), padding = screenPadding) title = stringResource(id = R.string.other),
padding = screenPadding,
)
ConfigurationToggle( ConfigurationToggle(
stringResource(R.string.always_on_vpn_support), stringResource(R.string.always_on_vpn_support),
enabled = !uiState.settings.isAutoTunnelEnabled, enabled = !uiState.settings.isAutoTunnelEnabled,
checked = uiState.settings.isAlwaysOnVpnEnabled, checked = uiState.settings.isAlwaysOnVpnEnabled,
padding = screenPadding, padding = screenPadding,
onCheckChanged = { viewModel.onToggleAlwaysOnVPN() }) onCheckChanged = { viewModel.onToggleAlwaysOnVPN() },
)
ConfigurationToggle( ConfigurationToggle(
stringResource(R.string.enabled_app_shortcuts), stringResource(R.string.enabled_app_shortcuts),
enabled = true, enabled = true,
checked = uiState.settings.isShortcutsEnabled, checked = uiState.settings.isShortcutsEnabled,
padding = screenPadding, padding = screenPadding,
onCheckChanged = { viewModel.onToggleShortcutsEnabled() }) onCheckChanged = { viewModel.onToggleShortcutsEnabled() },
)
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxSize().padding(top = 5.dp), modifier = Modifier.fillMaxSize().padding(top = 5.dp),
horizontalArrangement = Arrangement.Center) { horizontalArrangement = Arrangement.Center,
TextButton( ) {
enabled = !didExportFiles, onClick = { showAuthPrompt = true }) { TextButton(
Text(stringResource(R.string.export_configs)) enabled = !didExportFiles,
} onClick = { showAuthPrompt = true },
) {
Text(stringResource(R.string.export_configs))
} }
} }
}
} }
} }
if (WireGuardAutoTunnel.isRunningOnAndroidTv()) { if (WireGuardAutoTunnel.isRunningOnAndroidTv()) {
Spacer(modifier = Modifier.weight(.17f)) Spacer(modifier = Modifier.weight(.17f))
} }
} }
} }
} }

View File

@ -5,9 +5,10 @@ import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnState import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnState
data class SettingsUiState( data class SettingsUiState(
val settings : Settings = Settings(), val settings: Settings = Settings(),
val tunnels : List<TunnelConfig> = emptyList(), val tunnels: List<TunnelConfig> = emptyList(),
val vpnState: VpnState = VpnState(), val vpnState: VpnState = VpnState(),
val isLocationDisclosureShown : Boolean = true, val isLocationDisclosureShown: Boolean = true,
val loading : Boolean = true val isBatteryOptimizeDisableShown: Boolean = false,
val loading: Boolean = true
) )

View File

@ -36,18 +36,29 @@ constructor(
private val vpnService: VpnService private val vpnService: VpnService
) : ViewModel() { ) : ViewModel() {
val uiState = combine( val uiState =
settingsRepository.getSettingsFlow(), combine(
tunnelConfigRepository.getTunnelConfigsFlow(), settingsRepository.getSettingsFlow(),
vpnService.vpnState, tunnelConfigRepository.getTunnelConfigsFlow(),
dataStoreManager.locationDisclosureFlow, vpnService.vpnState,
){ settings, tunnels, tunnelState, locationDisclosure -> dataStoreManager.preferencesFlow,
SettingsUiState(settings, tunnels, tunnelState, locationDisclosure ) { settings, tunnels, tunnelState, preferences ->
?: false, false) SettingsUiState(
}.stateIn(viewModelScope, settings,
SharingStarted.WhileSubscribed(Constants.SUBSCRIPTION_TIMEOUT), SettingsUiState()) tunnels,
tunnelState,
preferences?.get(DataStoreManager.LOCATION_DISCLOSURE_SHOWN) ?: false,
preferences?.get(DataStoreManager.BATTERY_OPTIMIZE_DISABLE_SHOWN) ?: false,
false
)
}
.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(Constants.SUBSCRIPTION_TIMEOUT),
SettingsUiState(),
)
fun onSaveTrustedSSID(ssid: String) : Result<Unit>{ fun onSaveTrustedSSID(ssid: String): Result<Unit> {
val trimmed = ssid.trim() val trimmed = ssid.trim()
return if (!uiState.value.settings.trustedNetworkSSIDs.contains(trimmed)) { return if (!uiState.value.settings.trustedNetworkSSIDs.contains(trimmed)) {
uiState.value.settings.trustedNetworkSSIDs.add(trimmed) uiState.value.settings.trustedNetworkSSIDs.add(trimmed)
@ -58,64 +69,77 @@ constructor(
} }
} }
fun setLocationDisclosureShown() = viewModelScope.launch { fun setLocationDisclosureShown() =
viewModelScope.launch {
dataStoreManager.saveToDataStore(DataStoreManager.LOCATION_DISCLOSURE_SHOWN, true) dataStoreManager.saveToDataStore(DataStoreManager.LOCATION_DISCLOSURE_SHOWN, true)
} }
fun setBatteryOptimizeDisableShown() =
viewModelScope.launch {
dataStoreManager.saveToDataStore(DataStoreManager.BATTERY_OPTIMIZE_DISABLE_SHOWN, true)
}
fun onToggleTunnelOnMobileData() { fun onToggleTunnelOnMobileData() {
saveSettings( saveSettings(
uiState.value.settings.copy( uiState.value.settings.copy(
isTunnelOnMobileDataEnabled = !uiState.value.settings.isTunnelOnMobileDataEnabled isTunnelOnMobileDataEnabled = !uiState.value.settings.isTunnelOnMobileDataEnabled,
) ),
) )
} }
fun onDeleteTrustedSSID(ssid: String) { fun onDeleteTrustedSSID(ssid: String) {
saveSettings(uiState.value.settings.copy(
trustedNetworkSSIDs = (uiState.value.settings.trustedNetworkSSIDs - ssid).toMutableList()
))
}
private suspend fun getDefaultTunnelOrFirst() : String {
return uiState.value.settings.defaultTunnel ?: tunnelConfigRepository.getAll().first().toString()
}
fun toggleAutoTunnel() = viewModelScope.launch {
val isAutoTunnelEnabled = uiState.value.settings.isAutoTunnelEnabled
var isAutoTunnelPaused = uiState.value.settings.isAutoTunnelPaused
if (isAutoTunnelEnabled) {
ServiceManager.stopWatcherService(application)
} else {
ServiceManager.startWatcherService(application)
isAutoTunnelPaused = false
}
saveSettings( saveSettings(
uiState.value.settings.copy( uiState.value.settings.copy(
isAutoTunnelEnabled = !isAutoTunnelEnabled, trustedNetworkSSIDs =
isAutoTunnelPaused = isAutoTunnelPaused, (uiState.value.settings.trustedNetworkSSIDs - ssid).toMutableList(),
defaultTunnel = getDefaultTunnelOrFirst() ),
)
) )
} }
private suspend fun getDefaultTunnelOrFirst(): String {
return uiState.value.settings.defaultTunnel
?: tunnelConfigRepository.getAll().first().toString()
}
fun onToggleAlwaysOnVPN() = viewModelScope.launch { fun toggleAutoTunnel() =
val updatedSettings = uiState.value.settings.copy( viewModelScope.launch {
isAlwaysOnVpnEnabled = !uiState.value.settings.isAlwaysOnVpnEnabled, val isAutoTunnelEnabled = uiState.value.settings.isAutoTunnelEnabled
defaultTunnel = getDefaultTunnelOrFirst() var isAutoTunnelPaused = uiState.value.settings.isAutoTunnelPaused
if (isAutoTunnelEnabled) {
ServiceManager.stopWatcherService(application)
} else {
ServiceManager.startWatcherService(application)
isAutoTunnelPaused = false
}
saveSettings(
uiState.value.settings.copy(
isAutoTunnelEnabled = !isAutoTunnelEnabled,
isAutoTunnelPaused = isAutoTunnelPaused,
defaultTunnel = getDefaultTunnelOrFirst(),
),
) )
saveSettings(updatedSettings) }
}
private fun saveSettings(settings: Settings) = viewModelScope.launch { fun onToggleAlwaysOnVPN() =
settingsRepository.save(settings) viewModelScope.launch {
} val updatedSettings =
uiState.value.settings.copy(
isAlwaysOnVpnEnabled = !uiState.value.settings.isAlwaysOnVpnEnabled,
defaultTunnel = getDefaultTunnelOrFirst(),
)
saveSettings(updatedSettings)
}
private fun saveSettings(settings: Settings) =
viewModelScope.launch { settingsRepository.save(settings) }
fun onToggleTunnelOnEthernet() { fun onToggleTunnelOnEthernet() {
saveSettings(uiState.value.settings.copy( saveSettings(
isTunnelOnEthernetEnabled = !uiState.value.settings.isTunnelOnEthernetEnabled uiState.value.settings.copy(
)) isTunnelOnEthernetEnabled = !uiState.value.settings.isTunnelOnEthernetEnabled,
),
)
} }
fun isLocationEnabled(context: Context): Boolean { fun isLocationEnabled(context: Context): Boolean {
@ -126,36 +150,36 @@ constructor(
fun onToggleShortcutsEnabled() { fun onToggleShortcutsEnabled() {
saveSettings( saveSettings(
uiState.value.settings.copy( uiState.value.settings.copy(
isShortcutsEnabled = !uiState.value.settings.isShortcutsEnabled isShortcutsEnabled = !uiState.value.settings.isShortcutsEnabled,
) ),
) )
} }
fun onToggleBatterySaver() { fun onToggleBatterySaver() {
saveSettings( saveSettings(
uiState.value.settings.copy( uiState.value.settings.copy(
isBatterySaverEnabled = !uiState.value.settings.isBatterySaverEnabled isBatterySaverEnabled = !uiState.value.settings.isBatterySaverEnabled,
) ),
) )
} }
private fun saveKernelMode(on: Boolean) { private fun saveKernelMode(on: Boolean) {
saveSettings( saveSettings(
uiState.value.settings.copy( uiState.value.settings.copy(
isKernelEnabled = on isKernelEnabled = on,
) ),
) )
} }
fun onToggleTunnelOnWifi() { fun onToggleTunnelOnWifi() {
saveSettings( saveSettings(
uiState.value.settings.copy( uiState.value.settings.copy(
isTunnelOnWifiEnabled = !uiState.value.settings.isTunnelOnWifiEnabled isTunnelOnWifiEnabled = !uiState.value.settings.isTunnelOnWifiEnabled,
) ),
) )
} }
fun onToggleKernelMode() : Result<Unit> { fun onToggleKernelMode(): Result<Unit> {
if (!uiState.value.settings.isKernelEnabled) { if (!uiState.value.settings.isKernelEnabled) {
try { try {
rootShell.start() rootShell.start()

View File

@ -62,39 +62,43 @@ fun SupportScreen(
showSnackbarMessage: (String) -> Unit, showSnackbarMessage: (String) -> Unit,
focusRequester: FocusRequester focusRequester: FocusRequester
) { ) {
val context = LocalContext.current val context = LocalContext.current
val fillMaxWidth = .85f val fillMaxWidth = .85f
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
fun openWebPage(url: String) { fun openWebPage(url: String) {
try { try {
val webpage: Uri = Uri.parse(url) val webpage: Uri = Uri.parse(url)
val intent = Intent(Intent.ACTION_VIEW, webpage) val intent = Intent(Intent.ACTION_VIEW, webpage)
context.startActivity(intent) context.startActivity(intent)
} catch (e : Exception) { } catch (e: Exception) {
showSnackbarMessage(Event.Error.Exception(e).message) showSnackbarMessage(Event.Error.Exception(e).message)
} }
} }
fun launchEmail() { fun launchEmail() {
try { try {
val intent = val intent =
Intent(Intent.ACTION_SEND).apply { Intent(Intent.ACTION_SENDTO).apply {
type = Constants.EMAIL_MIME_TYPE type = Constants.EMAIL_MIME_TYPE
putExtra(Intent.EXTRA_EMAIL, context.getString(R.string.my_email)) putExtra(Intent.EXTRA_EMAIL, arrayOf(context.getString(R.string.my_email)))
putExtra(Intent.EXTRA_SUBJECT, context.getString(R.string.email_subject)) putExtra(Intent.EXTRA_SUBJECT, context.getString(R.string.email_subject))
} }
startActivity(context, createChooser(intent, context.getString(R.string.email_chooser)), null) startActivity(
} catch (e : Exception) { context,
showSnackbarMessage(Event.Error.Exception(e).message) createChooser(intent, context.getString(R.string.email_chooser)),
} null,
} )
} catch (e: Exception) {
showSnackbarMessage(Event.Error.Exception(e).message)
}
}
if (uiState.loading) { if (uiState.loading) {
LoadingScreen() LoadingScreen()
return return
} }
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
@ -103,126 +107,147 @@ fun SupportScreen(
Modifier.fillMaxSize() Modifier.fillMaxSize()
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.focusable() .focusable()
.padding(padding)) { .padding(padding),
Surface( ) {
tonalElevation = 2.dp, Surface(
shadowElevation = 2.dp, tonalElevation = 2.dp,
shape = RoundedCornerShape(12.dp), shadowElevation = 2.dp,
color = MaterialTheme.colorScheme.surface, shape = RoundedCornerShape(12.dp),
modifier = color = MaterialTheme.colorScheme.surface,
(if (WireGuardAutoTunnel.isRunningOnAndroidTv()) { modifier =
(if (WireGuardAutoTunnel.isRunningOnAndroidTv()) {
Modifier.height(IntrinsicSize.Min) Modifier.height(IntrinsicSize.Min)
.fillMaxWidth(fillMaxWidth) .fillMaxWidth(fillMaxWidth)
.padding(top = 10.dp) .padding(top = 10.dp)
} else { } else {
Modifier.fillMaxWidth(fillMaxWidth).padding(top = 20.dp) Modifier.fillMaxWidth(fillMaxWidth).padding(top = 20.dp)
}) })
.padding(bottom = 25.dp)) { .padding(bottom = 25.dp),
Column(modifier = Modifier.padding(20.dp)) { ) {
Text( Column(modifier = Modifier.padding(20.dp)) {
stringResource(R.string.thank_you), Text(
textAlign = TextAlign.Start, stringResource(R.string.thank_you),
fontWeight = FontWeight.Bold, textAlign = TextAlign.Start,
modifier = Modifier.padding(bottom = 20.dp), fontWeight = FontWeight.Bold,
fontSize = 16.sp) modifier = Modifier.padding(bottom = 20.dp),
Text( fontSize = 16.sp,
stringResource(id = R.string.support_help_text), )
textAlign = TextAlign.Start, Text(
fontSize = 16.sp, stringResource(id = R.string.support_help_text),
modifier = Modifier.padding(bottom = 20.dp)) textAlign = TextAlign.Start,
TextButton( fontSize = 16.sp,
onClick = { openWebPage(context.resources.getString(R.string.docs_url)) }, modifier = Modifier.padding(bottom = 20.dp),
modifier = Modifier.padding(vertical = 5.dp).focusRequester(focusRequester)) { )
Row( TextButton(
horizontalArrangement = Arrangement.SpaceBetween, onClick = { openWebPage(context.resources.getString(R.string.docs_url)) },
verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 5.dp).focusRequester(focusRequester),
modifier = Modifier.fillMaxWidth()) { ) {
Row { Row(
Icon(Icons.Rounded.Book, stringResource(id = R.string.docs)) horizontalArrangement = Arrangement.SpaceBetween,
Text( verticalAlignment = Alignment.CenterVertically,
stringResource(id = R.string.docs_description), modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Justify, ) {
modifier = Modifier.padding(start = 10.dp)) Row {
} Icon(Icons.Rounded.Book, stringResource(id = R.string.docs))
Icon(Icons.Rounded.ArrowForward, stringResource(id = R.string.go)) Text(
} stringResource(id = R.string.docs_description),
} textAlign = TextAlign.Justify,
Divider(color = MaterialTheme.colorScheme.onBackground, thickness = 0.5.dp) modifier = Modifier.padding(start = 10.dp),
TextButton( )
onClick = { openWebPage(context.resources.getString(R.string.discord_url)) }, }
modifier = Modifier.padding(vertical = 5.dp)) { Icon(Icons.Rounded.ArrowForward, stringResource(id = R.string.go))
Row( }
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()) {
Row {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.discord),
stringResource(id = R.string.discord),
Modifier.size(25.dp))
Text(
stringResource(id = R.string.discord_description),
textAlign = TextAlign.Justify,
modifier = Modifier.padding(start = 10.dp))
}
Icon(Icons.Rounded.ArrowForward, stringResource(id = R.string.go))
}
}
Divider(color = MaterialTheme.colorScheme.onBackground, thickness = 0.5.dp)
TextButton(
onClick = { openWebPage(context.resources.getString(R.string.github_url)) },
modifier = Modifier.padding(vertical = 5.dp)) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()) {
Row {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.github),
stringResource(id = R.string.github),
Modifier.size(25.dp))
Text(
"Open an issue",
textAlign = TextAlign.Justify,
modifier = Modifier.padding(start = 10.dp))
}
Icon(Icons.Rounded.ArrowForward, stringResource(id = R.string.go))
}
}
Divider(color = MaterialTheme.colorScheme.onBackground, thickness = 0.5.dp)
TextButton(
onClick = { launchEmail() }, modifier = Modifier.padding(vertical = 5.dp)) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()) {
Row {
Icon(Icons.Rounded.Mail, stringResource(id = R.string.email))
Text(
stringResource(id = R.string.email_description),
textAlign = TextAlign.Justify,
modifier = Modifier.padding(start = 10.dp))
}
Icon(Icons.Rounded.ArrowForward, stringResource(id = R.string.go))
}
}
} }
} Divider(color = MaterialTheme.colorScheme.onBackground, thickness = 0.5.dp)
Spacer(modifier = Modifier.weight(1f)) TextButton(
Text( onClick = { openWebPage(context.resources.getString(R.string.discord_url)) },
stringResource(id = R.string.privacy_policy), modifier = Modifier.padding(vertical = 5.dp),
style = TextStyle(textDecoration = TextDecoration.Underline), ) {
fontSize = 16.sp, Row(
modifier = horizontalArrangement = Arrangement.SpaceBetween,
Modifier.clickable { verticalAlignment = Alignment.CenterVertically,
openWebPage(context.resources.getString(R.string.privacy_policy_url)) modifier = Modifier.fillMaxWidth(),
}) ) {
Row( Row {
horizontalArrangement = Arrangement.spacedBy(25.dp), Icon(
verticalAlignment = Alignment.CenterVertically, imageVector = ImageVector.vectorResource(R.drawable.discord),
modifier = Modifier.padding(25.dp)) { stringResource(id = R.string.discord),
Text("Version: ${BuildConfig.VERSION_NAME}", modifier = Modifier.focusable()) Modifier.size(25.dp),
Text("Mode: ${if (uiState.settings.isKernelEnabled) "Kernel" else "Userspace" }") )
} Text(
stringResource(id = R.string.discord_description),
textAlign = TextAlign.Justify,
modifier = Modifier.padding(start = 10.dp),
)
}
Icon(Icons.Rounded.ArrowForward, stringResource(id = R.string.go))
}
}
Divider(color = MaterialTheme.colorScheme.onBackground, thickness = 0.5.dp)
TextButton(
onClick = { openWebPage(context.resources.getString(R.string.github_url)) },
modifier = Modifier.padding(vertical = 5.dp),
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Row {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.github),
stringResource(id = R.string.github),
Modifier.size(25.dp),
)
Text(
"Open an issue",
textAlign = TextAlign.Justify,
modifier = Modifier.padding(start = 10.dp),
)
}
Icon(Icons.Rounded.ArrowForward, stringResource(id = R.string.go))
}
}
Divider(color = MaterialTheme.colorScheme.onBackground, thickness = 0.5.dp)
TextButton(
onClick = { launchEmail() },
modifier = Modifier.padding(vertical = 5.dp),
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Row {
Icon(Icons.Rounded.Mail, stringResource(id = R.string.email))
Text(
stringResource(id = R.string.email_description),
textAlign = TextAlign.Justify,
modifier = Modifier.padding(start = 10.dp),
)
}
Icon(Icons.Rounded.ArrowForward, stringResource(id = R.string.go))
}
}
}
} }
} Spacer(modifier = Modifier.weight(1f))
Text(
stringResource(id = R.string.privacy_policy),
style = TextStyle(textDecoration = TextDecoration.Underline),
fontSize = 16.sp,
modifier =
Modifier.clickable {
openWebPage(context.resources.getString(R.string.privacy_policy_url))
},
)
Row(
horizontalArrangement = Arrangement.spacedBy(25.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(25.dp),
) {
Text("Version: ${BuildConfig.VERSION_NAME}", modifier = Modifier.focusable())
Text("Mode: ${if (uiState.settings.isKernelEnabled) "Kernel" else "Userspace"}")
}
}
}

View File

@ -2,7 +2,4 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.support
import com.zaneschepke.wireguardautotunnel.data.model.Settings import com.zaneschepke.wireguardautotunnel.data.model.Settings
data class SupportUiState( data class SupportUiState(val settings: Settings = Settings(), val loading: Boolean = true)
val settings : Settings = Settings(),
val loading : Boolean = true
)

View File

@ -11,15 +11,16 @@ import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class SupportViewModel @Inject constructor( class SupportViewModel @Inject constructor(private val settingsRepository: SettingsRepository) :
private val settingsRepository: SettingsRepository ViewModel() {
) : ViewModel() {
val uiState = settingsRepository.getSettingsFlow().map { val uiState =
SupportUiState(it, false) settingsRepository
}.stateIn( .getSettingsFlow()
viewModelScope, .map { SupportUiState(it, false) }
SharingStarted.WhileSubscribed(Constants.SUBSCRIPTION_TIMEOUT), .stateIn(
SupportUiState() viewModelScope,
) SharingStarted.WhileSubscribed(Constants.SUBSCRIPTION_TIMEOUT),
SupportUiState(),
)
} }

View File

@ -21,7 +21,7 @@ private val DarkColorScheme =
primary = virdigris, primary = virdigris,
secondary = virdigris, secondary = virdigris,
// secondary = PurpleGrey80, // secondary = PurpleGrey80,
tertiary = virdigris tertiary = virdigris,
// tertiary = Pink80 // tertiary = Pink80
) )
@ -29,7 +29,7 @@ private val LightColorScheme =
lightColorScheme( lightColorScheme(
primary = Purple40, primary = Purple40,
secondary = PurpleGrey40, secondary = PurpleGrey40,
tertiary = Pink40 tertiary = Pink40,
/* Other default colors to override /* Other default colors to override
background = Color(0xFFFFFBFE), background = Color(0xFFFFFBFE),
surface = Color(0xFFFFFBFE), surface = Color(0xFFFFFBFE),
@ -57,7 +57,6 @@ fun WireguardAutoTunnelTheme(
val context = LocalContext.current val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
} }
darkTheme -> DarkColorScheme darkTheme -> DarkColorScheme
else -> LightColorScheme else -> LightColorScheme
} }
@ -68,14 +67,19 @@ fun WireguardAutoTunnelTheme(
WindowCompat.setDecorFitsSystemWindows(window, false) WindowCompat.setDecorFitsSystemWindows(window, false)
window.statusBarColor = Color.Transparent.toArgb() window.statusBarColor = Color.Transparent.toArgb()
window.navigationBarColor = Color.Transparent.toArgb() window.navigationBarColor = Color.Transparent.toArgb()
WindowCompat.getInsetsController(window, window.decorView).isAppearanceLightStatusBars = !darkTheme WindowCompat.getInsetsController(window, window.decorView).isAppearanceLightStatusBars =
WindowCompat.getInsetsController(window, window.decorView).isAppearanceLightNavigationBars = !darkTheme !darkTheme
WindowCompat.getInsetsController(
window,
window.decorView,
)
.isAppearanceLightNavigationBars = !darkTheme
} }
} }
MaterialTheme( MaterialTheme(
colorScheme = colorScheme, colorScheme = colorScheme,
typography = Typography, typography = Typography,
content = content content = content,
) )
} }

View File

@ -14,7 +14,7 @@ fun TransparentSystemBars() {
DisposableEffect(systemUiController, useDarkIcons) { DisposableEffect(systemUiController, useDarkIcons) {
systemUiController.setSystemBarsColor( systemUiController.setSystemBarsColor(
color = Color.Transparent, color = Color.Transparent,
darkIcons = useDarkIcons darkIcons = useDarkIcons,
) )
onDispose {} onDispose {}

View File

@ -10,13 +10,13 @@ import androidx.compose.ui.unit.sp
val Typography = val Typography =
Typography( Typography(
bodyLarge = bodyLarge =
TextStyle( TextStyle(
fontFamily = FontFamily.Default, fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
fontSize = 16.sp, fontSize = 16.sp,
lineHeight = 24.sp, lineHeight = 24.sp,
letterSpacing = 0.5.sp letterSpacing = 0.5.sp,
) ),
/* Other default text styles to override /* Other default text styles to override
titleLarge = TextStyle( titleLarge = TextStyle(
fontFamily = FontFamily.Default, fontFamily = FontFamily.Default,

View File

@ -12,79 +12,100 @@ sealed class Event {
override val message: String override val message: String
get() = WireGuardAutoTunnel.instance.getString(R.string.error_none) get() = WireGuardAutoTunnel.instance.getString(R.string.error_none)
} }
data object SsidConflict : Error() { data object SsidConflict : Error() {
override val message: String override val message: String
get() = WireGuardAutoTunnel.instance.getString(R.string.error_ssid_exists) get() = WireGuardAutoTunnel.instance.getString(R.string.error_ssid_exists)
} }
data object RootDenied : Error() { data object RootDenied : Error() {
override val message: String override val message: String
get() = WireGuardAutoTunnel.instance.getString(R.string.error_root_denied) get() = WireGuardAutoTunnel.instance.getString(R.string.error_root_denied)
} }
data class General(val customMessage: String) : Error() { data class General(val customMessage: String) : Error() {
override val message: String override val message: String
get() = customMessage get() = customMessage
} }
data class Exception(val exception : kotlin.Exception) : Error() {
data class Exception(val exception: kotlin.Exception) : Error() {
override val message: String override val message: String
get() = exception.message ?: WireGuardAutoTunnel.instance.getString(R.string.unknown_error) get() =
exception.message
?: WireGuardAutoTunnel.instance.getString(R.string.unknown_error)
} }
data object InvalidQrCode : Error() { data object InvalidQrCode : Error() {
override val message: String override val message: String
get() = WireGuardAutoTunnel.instance.getString(R.string.error_invalid_code) get() = WireGuardAutoTunnel.instance.getString(R.string.error_invalid_code)
} }
data object InvalidFileExtension : Error() { data object InvalidFileExtension : Error() {
override val message: String override val message: String
get() = WireGuardAutoTunnel.instance.getString(R.string.error_file_extension) get() = WireGuardAutoTunnel.instance.getString(R.string.error_file_extension)
} }
data object FileReadFailed : Error() { data object FileReadFailed : Error() {
override val message: String override val message: String
get() = WireGuardAutoTunnel.instance.getString(R.string.error_file_extension) get() = WireGuardAutoTunnel.instance.getString(R.string.error_file_extension)
} }
data object AuthenticationFailed : Error() { data object AuthenticationFailed : Error() {
override val message: String override val message: String
get() = WireGuardAutoTunnel.instance.getString(R.string.error_authentication_failed) get() = WireGuardAutoTunnel.instance.getString(R.string.error_authentication_failed)
} }
data object AuthorizationFailed : Error() { data object AuthorizationFailed : Error() {
override val message: String override val message: String
get() = WireGuardAutoTunnel.instance.getString(R.string.error_authorization_failed) get() = WireGuardAutoTunnel.instance.getString(R.string.error_authorization_failed)
} }
data object BackgroundLocationRequired : Error() { data object BackgroundLocationRequired : Error() {
override val message: String override val message: String
get() = WireGuardAutoTunnel.instance.getString(R.string.background_location_required) get() =
WireGuardAutoTunnel.instance.getString(R.string.background_location_required)
} }
data object LocationServicesRequired : Error() { data object LocationServicesRequired : Error() {
override val message: String override val message: String
get() = WireGuardAutoTunnel.instance.getString(R.string.location_services_required) get() = WireGuardAutoTunnel.instance.getString(R.string.location_services_required)
} }
data object PreciseLocationRequired : Error() { data object PreciseLocationRequired : Error() {
override val message: String override val message: String
get() = WireGuardAutoTunnel.instance.getString(R.string.precise_location_required) get() = WireGuardAutoTunnel.instance.getString(R.string.precise_location_required)
} }
data object FileExplorerRequired : Error() { data object FileExplorerRequired : Error() {
override val message: String override val message: String
get() = WireGuardAutoTunnel.instance.getString(R.string.error_no_file_explorer) get() = WireGuardAutoTunnel.instance.getString(R.string.error_no_file_explorer)
} }
} }
sealed class Message : Event() { sealed class Message : Event() {
data object ConfigSaved: Message() { data object ConfigSaved : Message() {
override val message: String override val message: String
get() = WireGuardAutoTunnel.instance.getString(R.string.config_changes_saved) get() = WireGuardAutoTunnel.instance.getString(R.string.config_changes_saved)
} }
data object ConfigsExported: Message() {
data object ConfigsExported : Message() {
override val message: String override val message: String
get() = WireGuardAutoTunnel.instance.getString(R.string.exported_configs_message) get() = WireGuardAutoTunnel.instance.getString(R.string.exported_configs_message)
} }
data object TunnelOffAction: Message() {
data object TunnelOffAction : Message() {
override val message: String override val message: String
get() = WireGuardAutoTunnel.instance.getString(R.string.turn_off_tunnel) get() = WireGuardAutoTunnel.instance.getString(R.string.turn_off_tunnel)
} }
data object TunnelOnAction: Message() {
data object TunnelOnAction : Message() {
override val message: String override val message: String
get() = WireGuardAutoTunnel.instance.getString(R.string.turn_on_tunnel) get() = WireGuardAutoTunnel.instance.getString(R.string.turn_on_tunnel)
} }
data object AutoTunnelOffAction: Message() {
data object AutoTunnelOffAction : Message() {
override val message: String override val message: String
get() = WireGuardAutoTunnel.instance.getString(R.string.turn_off_auto) get() = WireGuardAutoTunnel.instance.getString(R.string.turn_off_auto)
} }
} }
} }

View File

@ -37,23 +37,23 @@ fun BigDecimal.toThreeDecimalPlaceString(): String {
} }
fun <T> List<T>.update(index: Int, item: T): List<T> = toMutableList().apply { this[index] = item } fun <T> List<T>.update(index: Int, item: T): List<T> = toMutableList().apply { this[index] = item }
fun <T> List<T>.removeAt(index: Int): List<T> = toMutableList().apply { this.removeAt(index) } fun <T> List<T>.removeAt(index: Int): List<T> = toMutableList().apply { this.removeAt(index) }
typealias TunnelConfigs = List<TunnelConfig> typealias TunnelConfigs = List<TunnelConfig>
typealias Packages = List<PackageInfo> typealias Packages = List<PackageInfo>
fun Statistics.mapPeerStats(): Map<Key, PeerStats?> { fun Statistics.mapPeerStats(): Map<Key, PeerStats?> {
return this.peers().associateWith { key -> return this.peers().associateWith { key -> (this.peer(key)) }
(this.peer(key))
}
} }
fun PeerStats.latestHandshakeSeconds() : Long? { fun PeerStats.latestHandshakeSeconds(): Long? {
return NumberUtils.getSecondsBetweenTimestampAndNow(this.latestHandshakeEpochMillis) return NumberUtils.getSecondsBetweenTimestampAndNow(this.latestHandshakeEpochMillis)
} }
fun PeerStats.handshakeStatus() : HandshakeStatus { fun PeerStats.handshakeStatus(): HandshakeStatus {
//TODO add never connected status after duration // TODO add never connected status after duration
return this.latestHandshakeSeconds().let { return this.latestHandshakeSeconds().let {
when { when {
it == null -> HandshakeStatus.NOT_STARTED it == null -> HandshakeStatus.NOT_STARTED
@ -65,4 +65,3 @@ fun PeerStats.handshakeStatus() : HandshakeStatus {
} }
} }
} }

View File

@ -36,22 +36,20 @@ object FileUtils {
val target = val target =
File( File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
fileName fileName,
) )
return target.outputStream() return target.outputStream()
} }
return null return null
} }
fun saveFilesToZip( fun saveFilesToZip(context: Context, files: List<File>) {
context: Context, val zipOutputStream =
files: List<File> createDownloadsFileOutputStream(
) { context,
val zipOutputStream = createDownloadsFileOutputStream( "wg-export_${Instant.now().epochSecond}.zip",
context, ZIP_FILE_MIME_TYPE,
"wg-export_${Instant.now().epochSecond}.zip", )
ZIP_FILE_MIME_TYPE
)
ZipOutputStream(zipOutputStream).use { zos -> ZipOutputStream(zipOutputStream).use { zos ->
files.forEach { file -> files.forEach { file ->
val entry = ZipEntry(file.name) val entry = ZipEntry(file.name)

View File

@ -2,15 +2,15 @@ package com.zaneschepke.wireguardautotunnel.util
import timber.log.Timber import timber.log.Timber
sealed class Result<T> { sealed class Result<T> {
class Success<T>(val data: T): Result<T>() class Success<T>(val data: T) : Result<T>()
class Error<T>(val error : Event.Error): Result<T>() {
class Error<T>(val error: Event.Error) : Result<T>() {
init { init {
when(this.error) { when (this.error) {
is Event.Error.Exception -> Timber.e(this.error.exception) is Event.Error.Exception -> Timber.e(this.error.exception)
else -> Timber.e(this.error.message) else -> Timber.e(this.error.message)
} }
} }
} }
} }

View File

@ -3,8 +3,8 @@
android:height="800dp" android:height="800dp"
android:viewportWidth="256" android:viewportWidth="256"
android:viewportHeight="256"> android:viewportHeight="256">
<path <path
android:pathData="M216.86,45.1C200.29,37.34 182.57,31.71 164.04,28.5C161.77,32.61 159.11,38.15 157.28,42.55C137.58,39.58 118.07,39.58 98.74,42.55C96.91,38.15 94.19,32.61 91.9,28.5C73.35,31.71 55.61,37.36 39.04,45.14C5.62,95.65 -3.44,144.9 1.09,193.46C23.26,210.01 44.74,220.07 65.86,226.65C71.08,219.47 75.73,211.84 79.74,203.8C72.1,200.9 64.79,197.32 57.89,193.17C59.72,191.81 61.51,190.39 63.24,188.93C105.37,208.63 151.13,208.63 192.75,188.93C194.51,190.39 196.3,191.81 198.11,193.17C191.18,197.34 183.85,200.92 176.22,203.82C180.23,211.84 184.86,219.49 190.1,226.67C211.24,220.09 232.74,210.03 254.91,193.46C260.23,137.17 245.83,88.37 216.86,45.1ZM85.47,163.59C72.83,163.59 62.46,151.79 62.46,137.41C62.46,123.04 72.61,111.21 85.47,111.21C98.34,111.21 108.71,123.02 108.49,137.41C108.51,151.79 98.34,163.59 85.47,163.59ZM170.53,163.59C157.88,163.59 147.51,151.79 147.51,137.41C147.51,123.04 157.66,111.21 170.53,111.21C183.39,111.21 193.76,123.02 193.54,137.41C193.54,151.79 183.39,163.59 170.53,163.59Z" android:fillColor="#5865F2"
android:fillColor="#5865F2" android:fillType="nonZero"
android:fillType="nonZero"/> android:pathData="M216.86,45.1C200.29,37.34 182.57,31.71 164.04,28.5C161.77,32.61 159.11,38.15 157.28,42.55C137.58,39.58 118.07,39.58 98.74,42.55C96.91,38.15 94.19,32.61 91.9,28.5C73.35,31.71 55.61,37.36 39.04,45.14C5.62,95.65 -3.44,144.9 1.09,193.46C23.26,210.01 44.74,220.07 65.86,226.65C71.08,219.47 75.73,211.84 79.74,203.8C72.1,200.9 64.79,197.32 57.89,193.17C59.72,191.81 61.51,190.39 63.24,188.93C105.37,208.63 151.13,208.63 192.75,188.93C194.51,190.39 196.3,191.81 198.11,193.17C191.18,197.34 183.85,200.92 176.22,203.82C180.23,211.84 184.86,219.49 190.1,226.67C211.24,220.09 232.74,210.03 254.91,193.46C260.23,137.17 245.83,88.37 216.86,45.1ZM85.47,163.59C72.83,163.59 62.46,151.79 62.46,137.41C62.46,123.04 72.61,111.21 85.47,111.21C98.34,111.21 108.71,123.02 108.49,137.41C108.51,151.79 98.34,163.59 85.47,163.59ZM170.53,163.59C157.88,163.59 147.51,151.79 147.51,137.41C147.51,123.04 157.66,111.21 170.53,111.21C183.39,111.21 193.76,123.02 193.54,137.41C193.54,151.79 183.39,163.59 170.53,163.59Z" />
</vector> </vector>

View File

@ -3,10 +3,10 @@
android:height="800dp" android:height="800dp"
android:viewportWidth="20" android:viewportWidth="20"
android:viewportHeight="20"> android:viewportHeight="20">
<path <path
android:pathData="M10,0C15.523,0 20,4.59 20,10.253C20,14.782 17.138,18.624 13.167,19.981C12.66,20.082 12.48,19.762 12.48,19.489C12.48,19.151 12.492,18.047 12.492,16.675C12.492,15.719 12.172,15.095 11.813,14.777C14.04,14.523 16.38,13.656 16.38,9.718C16.38,8.598 15.992,7.684 15.35,6.966C15.454,6.707 15.797,5.664 15.252,4.252C15.252,4.252 14.414,3.977 12.505,5.303C11.706,5.076 10.85,4.962 10,4.958C9.15,4.962 8.295,5.076 7.497,5.303C5.586,3.977 4.746,4.252 4.746,4.252C4.203,5.664 4.546,6.707 4.649,6.966C4.01,7.684 3.619,8.598 3.619,9.718C3.619,13.646 5.954,14.526 8.175,14.785C7.889,15.041 7.63,15.493 7.54,16.156C6.97,16.418 5.522,16.871 4.63,15.304C4.63,15.304 4.101,14.319 3.097,14.247C3.097,14.247 2.122,14.234 3.029,14.87C3.029,14.87 3.684,15.185 4.139,16.37C4.139,16.37 4.726,18.2 7.508,17.58C7.513,18.437 7.522,19.245 7.522,19.489C7.522,19.76 7.338,20.077 6.839,19.982C2.865,18.627 0,14.783 0,10.253C0,4.59 4.478,0 10,0" android:fillColor="#000000"
android:strokeWidth="1" android:fillType="evenOdd"
android:fillColor="#000000" android:pathData="M10,0C15.523,0 20,4.59 20,10.253C20,14.782 17.138,18.624 13.167,19.981C12.66,20.082 12.48,19.762 12.48,19.489C12.48,19.151 12.492,18.047 12.492,16.675C12.492,15.719 12.172,15.095 11.813,14.777C14.04,14.523 16.38,13.656 16.38,9.718C16.38,8.598 15.992,7.684 15.35,6.966C15.454,6.707 15.797,5.664 15.252,4.252C15.252,4.252 14.414,3.977 12.505,5.303C11.706,5.076 10.85,4.962 10,4.958C9.15,4.962 8.295,5.076 7.497,5.303C5.586,3.977 4.746,4.252 4.746,4.252C4.203,5.664 4.546,6.707 4.649,6.966C4.01,7.684 3.619,8.598 3.619,9.718C3.619,13.646 5.954,14.526 8.175,14.785C7.889,15.041 7.63,15.493 7.54,16.156C6.97,16.418 5.522,16.871 4.63,15.304C4.63,15.304 4.101,14.319 3.097,14.247C3.097,14.247 2.122,14.234 3.029,14.87C3.029,14.87 3.684,15.185 4.139,16.37C4.139,16.37 4.726,18.2 7.508,17.58C7.513,18.437 7.522,19.245 7.522,19.489C7.522,19.76 7.338,20.077 6.839,19.982C2.865,18.627 0,14.783 0,10.253C0,4.59 4.478,0 10,0"
android:fillType="evenOdd" android:strokeWidth="1"
android:strokeColor="#00000000"/> android:strokeColor="#00000000" />
</vector> </vector>

View File

@ -1,5 +1,10 @@
<vector android:height="24dp" android:tint="#000000" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:viewportHeight="24" android:viewportWidth="24" android:width="24dp"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android"> android:height="24dp"
<path android:fillColor="@android:color/white" android:pathData="M12,1L3,5v6c0,5.55 3.84,10.74 9,12 5.16,-1.26 9,-6.45 9,-12V5l-9,-4z"/> android:tint="#000000"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M12,1L3,5v6c0,5.55 3.84,10.74 9,12 5.16,-1.26 9,-6.45 9,-12V5l-9,-4z" />
</vector> </vector>

View File

@ -1,5 +1,10 @@
<vector android:height="24dp" android:tint="#000000" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:viewportHeight="24" android:viewportWidth="24" android:width="24dp"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android"> android:height="24dp"
<path android:fillColor="@android:color/white" android:pathData="M20.83,18H21v-4h2v-4H12.83L20.83,18zM19.78,22.61l1.41,-1.41L2.81,2.81L1.39,4.22l2.59,2.59C2.2,7.85 1,9.79 1,12c0,3.31 2.69,6 6,6c2.21,0 4.15,-1.2 5.18,-2.99L19.78,22.61zM8.99,11.82C9,11.88 9,11.94 9,12c0,1.1 -0.9,2 -2,2s-2,-0.9 -2,-2s0.9,-2 2,-2c0.06,0 0.12,0 0.18,0.01L8.99,11.82z"/> android:tint="#000000"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M20.83,18H21v-4h2v-4H12.83L20.83,18zM19.78,22.61l1.41,-1.41L2.81,2.81L1.39,4.22l2.59,2.59C2.2,7.85 1,9.79 1,12c0,3.31 2.69,6 6,6c2.21,0 4.15,-1.2 5.18,-2.99L19.78,22.61zM8.99,11.82C9,11.88 9,11.94 9,12c0,1.1 -0.9,2 -2,2s-2,-0.9 -2,-2s0.9,-2 2,-2c0.06,0 0.12,0 0.18,0.01L8.99,11.82z" />
</vector> </vector>

View File

@ -1,5 +1,10 @@
<vector android:height="24dp" android:tint="#000000" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:viewportHeight="24" android:viewportWidth="24" android:width="24dp"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android"> android:height="24dp"
<path android:fillColor="@android:color/white" android:pathData="M12.65,10C11.83,7.67 9.61,6 7,6c-3.31,0 -6,2.69 -6,6s2.69,6 6,6c2.61,0 4.83,-1.67 5.65,-4H17v4h4v-4h2v-4H12.65zM7,14c-1.1,0 -2,-0.9 -2,-2s0.9,-2 2,-2 2,0.9 2,2 -0.9,2 -2,2z"/> android:tint="#000000"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M12.65,10C11.83,7.67 9.61,6 7,6c-3.31,0 -6,2.69 -6,6s2.69,6 6,6c2.61,0 4.83,-1.67 5.65,-4H17v4h4v-4h2v-4H12.65zM7,14c-1.1,0 -2,-0.9 -2,-2s0.9,-2 2,-2 2,0.9 2,2 -0.9,2 -2,2z" />
</vector> </vector>

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_banner_background"/> <background android:drawable="@color/ic_banner_background" />
<foreground android:drawable="@mipmap/ic_banner_foreground"/> <foreground android:drawable="@mipmap/ic_banner_foreground" />
</adaptive-icon> </adaptive-icon>

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/> <background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground"/> <foreground android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon> </adaptive-icon>

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/> <background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground"/> <foreground android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon> </adaptive-icon>

View File

@ -164,4 +164,7 @@
<string name="location_service_missing">Location Services Not Detected</string> <string name="location_service_missing">Location Services Not Detected</string>
<string name="location_services_missing_message">The app is not detecting any location services enabled on your device. Depending on the device, this could cause the untrusted wifi feature to fail to read the wifi name. Would you like to continue anyways?</string> <string name="location_services_missing_message">The app is not detecting any location services enabled on your device. Depending on the device, this could cause the untrusted wifi feature to fail to read the wifi name. Would you like to continue anyways?</string>
<string name="auto_tunnel_title">Auto-tunnel Service</string> <string name="auto_tunnel_title">Auto-tunnel Service</string>
<string name="delete_tunnel">Delete tunnel</string>
<string name="delete_tunnel_message">Are you sure you would like to delete this tunnel?</string>
<string name="yes">Yes</string>
</resources> </resources>

View File

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<style name="Theme.WireguardAutoTunnel" parent="@style/Theme.AppCompat.NoActionBar"> <style name="Theme.WireguardAutoTunnel" parent="@style/Theme.AppCompat.NoActionBar">
<item name="android:windowBackground">@color/black_background</item> <item name="android:windowBackground">@color/black_background</item>
</style> </style>

View File

@ -1,31 +1,35 @@
<shortcuts xmlns:android="http://schemas.android.com/apk/res/android"> <shortcuts xmlns:android="http://schemas.android.com/apk/res/android">
<shortcut <shortcut
android:shortcutId="defaultOn1"
android:enabled="true" android:enabled="true"
android:icon="@drawable/vpn_on" android:icon="@drawable/vpn_on"
android:shortcutShortLabel="@string/vpn_on" android:shortcutDisabledMessage="@string/vpn_on"
android:shortcutId="defaultOn1"
android:shortcutLongLabel="@string/default_vpn_on" android:shortcutLongLabel="@string/default_vpn_on"
android:shortcutDisabledMessage="@string/vpn_on"> android:shortcutShortLabel="@string/vpn_on">
<intent <intent
android:action="START" android:action="START"
android:targetPackage="com.zaneschepke.wireguardautotunnel" android:targetClass="com.zaneschepke.wireguardautotunnel.service.shortcut.ShortcutsActivity"
android:targetClass="com.zaneschepke.wireguardautotunnel.service.shortcut.ShortcutsActivity"> android:targetPackage="com.zaneschepke.wireguardautotunnel">
<extra android:name="className" android:value="WireGuardTunnelService" /> <extra
android:name="className"
android:value="WireGuardTunnelService" />
</intent> </intent>
<capability-binding android:key="actions.intent.START" /> <capability-binding android:key="actions.intent.START" />
</shortcut> </shortcut>
<shortcut <shortcut
android:shortcutId="defaultOff1"
android:enabled="true" android:enabled="true"
android:icon="@drawable/vpn_off" android:icon="@drawable/vpn_off"
android:shortcutShortLabel="@string/vpn_off" android:shortcutDisabledMessage="@string/vpn_off"
android:shortcutId="defaultOff1"
android:shortcutLongLabel="@string/default_vpn_off" android:shortcutLongLabel="@string/default_vpn_off"
android:shortcutDisabledMessage="@string/vpn_off"> android:shortcutShortLabel="@string/vpn_off">
<intent <intent
android:action="STOP" android:action="STOP"
android:targetPackage="com.zaneschepke.wireguardautotunnel" android:targetClass="com.zaneschepke.wireguardautotunnel.service.shortcut.ShortcutsActivity"
android:targetClass="com.zaneschepke.wireguardautotunnel.service.shortcut.ShortcutsActivity"> android:targetPackage="com.zaneschepke.wireguardautotunnel">
<extra android:name="className" android:value="WireGuardTunnelService" /> <extra
android:name="className"
android:value="WireGuardTunnelService" />
</intent> </intent>
<capability-binding android:key="actions.intent.STOP" /> <capability-binding android:key="actions.intent.STOP" />
</shortcut> </shortcut>

View File

@ -26,11 +26,9 @@ object BuildHelper {
} }
fun isReleaseBuild(gradle: Gradle): Boolean { fun isReleaseBuild(gradle: Gradle): Boolean {
return ( return (gradle.startParameter.taskNames.size > 0 &&
gradle.startParameter.taskNames.size > 0 && gradle.startParameter.taskNames[0].contains(
gradle.startParameter.taskNames[0].contains( "Release",
"Release", ))
)
)
} }
} }

View File

@ -1,11 +1,13 @@
object Constants { object Constants {
const val VERSION_NAME = "3.3.2" const val VERSION_NAME = "3.3.3"
const val JVM_TARGET = "17" const val JVM_TARGET = "17"
const val VERSION_CODE = 33200 const val VERSION_CODE = 33300
const val TARGET_SDK = 34 const val TARGET_SDK = 34
const val MIN_SDK = 26 const val MIN_SDK = 26
const val APP_ID = "com.zaneschepke.wireguardautotunnel" const val APP_ID = "com.zaneschepke.wireguardautotunnel"
const val APP_NAME = "wgtunnel" const val APP_NAME = "wgtunnel"
const val COMPOSE_COMPILER_EXTENSION_VERSION = "1.5.7"
const val STORE_PASS_VAR = "SIGNING_STORE_PASSWORD" const val STORE_PASS_VAR = "SIGNING_STORE_PASSWORD"
const val KEY_ALIAS_VAR = "SIGNING_KEY_ALIAS" const val KEY_ALIAS_VAR = "SIGNING_KEY_ALIAS"

View File

@ -5,13 +5,13 @@ platform :android do
desc "Deploy a beta version to the Google Play" desc "Deploy a beta version to the Google Play"
lane :beta do lane :beta do
gradle(task: "clean bundleGeneralRelease") gradle(task: "clean bundleGeneralRelease")
upload_to_play_store(track: 'beta') upload_to_play_store(track: 'beta', skip_upload_apk: true)
end end
desc "Deploy a new version to the Google Play" desc "Deploy a new version to the Google Play"
lane :production do lane :production do
gradle(task: "clean bundleGeneralRelease") gradle(task: "clean bundleGeneralRelease")
upload_to_play_store upload_to_play_store(skip_upload_apk: true)
end end
end end

View File

@ -0,0 +1,7 @@
Enhancements:
- Added delete tunnel confirmation
- Added battery background permission
Fixes:
- Tunnel disable frozen bug
- Email to field bug
- Config edit empty DNS

View File

@ -11,7 +11,7 @@ desugar_jdk_libs = "2.0.4"
espressoCore = "3.5.1" espressoCore = "3.5.1"
firebase-crashlytics-gradle = "2.9.9" firebase-crashlytics-gradle = "2.9.9"
google-services = "4.4.0" google-services = "4.4.0"
hiltAndroid = "2.49" hiltAndroid = "2.50"
hiltNavigationCompose = "1.1.0" hiltNavigationCompose = "1.1.0"
junit = "4.13.2" junit = "4.13.2"
kotlinx-serialization-json = "1.6.2" kotlinx-serialization-json = "1.6.2"
@ -22,15 +22,14 @@ navigationCompose = "2.7.6"
roomVersion = "2.6.1" roomVersion = "2.6.1"
timber = "5.0.1" timber = "5.0.1"
tunnel = "1.0.20230706" tunnel = "1.0.20230706"
androidGradlePlugin = "8.2.0" androidGradlePlugin = "8.2.1"
kotlin="1.9.10" kotlin = "1.9.21"
ksp="1.9.10-1.0.13" ksp = "1.9.21-1.0.16"
composeBom="2023.10.01" composeBom = "2023.10.01"
firebaseBom= "32.7.0" firebaseBom = "32.7.0"
compose="1.5.4" compose = "1.5.4"
crashlytics= "18.6.0" crashlytics = "18.6.0"
analytics="21.5.0" analytics = "21.5.0"
composeCompiler="1.5.3"
zxingAndroidEmbedded = "4.3.0" zxingAndroidEmbedded = "4.3.0"
zxingCore = "3.5.2" zxingCore = "3.5.2"
@ -54,13 +53,13 @@ androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "roomVers
androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "roomVersion" } androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "roomVersion" }
#compose #compose
androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref="composeBom" } androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" }
androidx-compose-ui-test = { module="androidx.compose.ui:ui-test-junit4", version.ref="compose" } androidx-compose-ui-test = { module = "androidx.compose.ui:ui-test-junit4", version.ref = "compose" }
androidx-compose-ui-tooling = { module="androidx.compose.ui:ui-tooling", version.ref="compose" } androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose" }
androidx-compose-manifest = { module="androidx.compose.ui:ui-test-manifest", version.ref="compose" } androidx-compose-manifest = { module = "androidx.compose.ui:ui-test-manifest", version.ref = "compose" }
androidx-compose-ui-graphics = { module="androidx.compose.ui:ui-graphics", version.ref="compose" } androidx-compose-ui-graphics = { module = "androidx.compose.ui:ui-graphics", version.ref = "compose" }
androidx-compose-ui-tooling-preview = { module="androidx.compose.ui:ui-tooling-preview", version.ref="compose" } androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "compose" }
androidx-compose-ui = { module="androidx.compose.ui:ui", version.ref="compose" } androidx-compose-ui = { module = "androidx.compose.ui:ui", version.ref = "compose" }
#hilt #hilt
androidx-room-testing = { module = "androidx.room:room-testing", version.ref = "roomVersion" } androidx-room-testing = { module = "androidx.room:room-testing", version.ref = "roomVersion" }
@ -84,14 +83,13 @@ lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-com
material-icons-extended = { module = "androidx.compose.material:material-icons-extended", version.ref = "material-icons-extended" } material-icons-extended = { module = "androidx.compose.material:material-icons-extended", version.ref = "material-icons-extended" }
timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" } timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" }
tunnel = { module = "com.wireguard.android:tunnel", version.ref = "tunnel" } tunnel = { module = "com.wireguard.android:tunnel", version.ref = "tunnel" }
#firebase #firebase
google-firebase-crashlytics-ktx = { module = "com.google.firebase:firebase-crashlytics-ktx", version.ref = "crashlytics" } google-firebase-crashlytics-ktx = { module = "com.google.firebase:firebase-crashlytics-ktx", version.ref = "crashlytics" }
google-firebase-analytics-ktx = { module = "com.google.firebase:firebase-analytics-ktx", version.ref = "analytics" } google-firebase-analytics-ktx = { module = "com.google.firebase:firebase-analytics-ktx", version.ref = "analytics" }
firebase-crashlytics-gradle = { module = "com.google.firebase:firebase-crashlytics-gradle", version.ref = "firebase-crashlytics-gradle" } firebase-crashlytics-gradle = { module = "com.google.firebase:firebase-crashlytics-gradle", version.ref = "firebase-crashlytics-gradle" }
firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebaseBom"} firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebaseBom" }
google-services = { module = "com.google.gms:google-services", version.ref = "google-services" } google-services = { module = "com.google.gms:google-services", version.ref = "google-services" }
zxing-core = { module = "com.google.zxing:core", version.ref = "zxingCore" } zxing-core = { module = "com.google.zxing:core", version.ref = "zxingCore" }
@ -101,4 +99,4 @@ zxing-android-embedded = { module = "com.journeyapps:zxing-android-embedded", ve
android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "hiltAndroid" } hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "hiltAndroid" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }

View File

@ -5,6 +5,7 @@ pluginManagement {
gradlePluginPortal() gradlePluginPortal()
} }
} }
dependencyResolutionManagement { dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories { repositories {
@ -14,4 +15,5 @@ dependencyResolutionManagement {
} }
rootProject.name = "WG Tunnel" rootProject.name = "WG Tunnel"
include(":app") include(":app")