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.
**Smartphone (please complete the following information):**
- Device: [e.g. iPhone6]
- Android Version: [e.g. iOS8.1]
- App Version [e.g. 22]
- Device: [e.g. Pixel 4a]
- Android Version: [e.g. Android 13]
- App Version [e.g. 3.3.3]
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'

2
.github/SUPPORT.md vendored
View File

@ -1,6 +1,6 @@
# 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>
<li>

View File

@ -9,7 +9,7 @@ on:
jobs:
build:
name: Build Signed APK
# change to macos because of hilt issues on ubuntu in gradle 8.3
runs-on: ubuntu-latest
env:
@ -70,7 +70,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
# 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 }}
name: Release ${{ github.ref_name }}
draft: false

View File

@ -28,7 +28,10 @@ WG Tunnel
<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>
@ -47,7 +50,8 @@ This is an alternative Android Application for [WireGuard](https://www.wireguard
## 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
@ -63,9 +67,8 @@ The original inspiration for this app came from the inconvenience of having to m
* Automatic service restart after reboot
* Battery preservation measures
## Building
```
$ git clone https://github.com/zaneschepke/wgtunnel
$ cd wgtunnel

View File

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

View File

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

View File

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

View File

@ -4,61 +4,31 @@ import android.app.Application
import android.content.ComponentName
import android.content.pm.PackageManager
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 dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.launch
import timber.log.Timber
import java.io.IOException
import javax.inject.Inject
@HiltAndroidApp
class WireGuardAutoTunnel : Application() {
@Inject
lateinit var settingsRepository: SettingsRepository
@Inject
lateinit var dataStoreManager: DataStoreManager
override fun onCreate() {
super.onCreate()
instance = this
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 {
lateinit var instance: WireGuardAutoTunnel private set
lateinit var instance: WireGuardAutoTunnel
private set
fun isRunningOnAndroidTv(): Boolean {
return instance.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK)
}
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(
entities = [Settings::class, TunnelConfig::class],
version = 5,
autoMigrations = [
AutoMigration(from = 1, to = 2), AutoMigration(from = 2, to = 3), AutoMigration(
from = 3,
to = 4
),AutoMigration(
from = 4,
to = 5
)
],
exportSchema = true
autoMigrations =
[
AutoMigration(from = 1, to = 2),
AutoMigration(from = 2, to = 3),
AutoMigration(
from = 3,
to = 4,
),
AutoMigration(
from = 4,
to = 5,
),
],
exportSchema = true,
)
@TypeConverters(DatabaseListConverters::class)
abstract class AppDatabase : RoomDatabase() {

View File

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

View File

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

View File

@ -1,4 +1,5 @@
package com.zaneschepke.wireguardautotunnel.data.datastore
import android.content.Context
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
@ -11,29 +12,27 @@ import kotlinx.coroutines.flow.map
class DataStoreManager(private val context: Context) {
companion object {
val LOCATION_DISCLOSURE_SHOWN = booleanPreferencesKey("LOCATION_DISCLOSURE_SHOWN")
val BATTERY_OPTIMIZE_DISABLE_SHOWN = booleanPreferencesKey("BATTERY_OPTIMIZE_DISABLE_SHOWN")
}
// preferences
private val preferencesKey = "preferences"
private val Context.dataStore by preferencesDataStore(
name = preferencesKey
)
private val Context.dataStore by
preferencesDataStore(
name = preferencesKey,
)
suspend fun init() {
context.dataStore.data.first()
}
suspend fun <T> saveToDataStore(key: Preferences.Key<T>, value: T) =
context.dataStore.edit {
it[key] = value
}
fun <T> getFromStoreFlow(key: Preferences.Key<T>) = context.dataStore.data.map {
it[key]
}
context.dataStore.edit { it[key] = value }
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 {
it[LOCATION_DISCLOSURE_SHOWN]
}
suspend fun <T> getFromStore(key: Preferences.Key<T>) =
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(
@PrimaryKey(autoGenerate = true) val id: Int = 0,
@ColumnInfo(name = "is_tunnel_enabled") var isAutoTunnelEnabled: Boolean = false,
@ColumnInfo(name = "is_tunnel_on_mobile_data_enabled") var isTunnelOnMobileDataEnabled: Boolean = false,
@ColumnInfo(name = "trusted_network_ssids") var trustedNetworkSSIDs: MutableList<String> = mutableListOf(),
@ColumnInfo(name = "is_tunnel_on_mobile_data_enabled")
var isTunnelOnMobileDataEnabled: Boolean = false,
@ColumnInfo(name = "trusted_network_ssids")
var trustedNetworkSSIDs: MutableList<String> = mutableListOf(),
@ColumnInfo(name = "default_tunnel") var defaultTunnel: String? = null,
@ColumnInfo(name = "is_always_on_vpn_enabled") var isAlwaysOnVpnEnabled: Boolean = false,
@ColumnInfo(name = "is_tunnel_on_ethernet_enabled") var isTunnelOnEthernetEnabled: Boolean = false,
@ColumnInfo(name = "is_tunnel_on_ethernet_enabled")
var isTunnelOnEthernetEnabled: Boolean = false,
@ColumnInfo(
name = "is_shortcuts_enabled",
defaultValue = "false"
) var isShortcutsEnabled: Boolean = false,
defaultValue = "false",
)
var isShortcutsEnabled: Boolean = false,
@ColumnInfo(
name = "is_battery_saver_enabled",
defaultValue = "false"
) var isBatterySaverEnabled: Boolean = false,
defaultValue = "false",
)
var isBatterySaverEnabled: Boolean = false,
@ColumnInfo(
name = "is_tunnel_on_wifi_enabled",
defaultValue = "false"
) var isTunnelOnWifiEnabled: Boolean = false,
defaultValue = "false",
)
var isTunnelOnWifiEnabled: Boolean = false,
@ColumnInfo(
name = "is_kernel_enabled",
defaultValue = "false"
) var isKernelEnabled: Boolean = false,
defaultValue = "false",
)
var isKernelEnabled: Boolean = false,
@ColumnInfo(
name = "is_restore_on_boot_enabled",
defaultValue = "false"
) var isRestoreOnBootEnabled: Boolean = false,
name = "is_restore_on_boot_enabled",
defaultValue = "false",
)
var isRestoreOnBootEnabled: Boolean = false,
@ColumnInfo(
name = "is_multi_tunnel_enabled",
defaultValue = "false"
) var isMultiTunnelEnabled: Boolean = false,
name = "is_multi_tunnel_enabled",
defaultValue = "false",
)
var isMultiTunnelEnabled: Boolean = false,
@ColumnInfo(
name = "is_auto_tunnel_paused",
defaultValue = "false"
) var isAutoTunnelPaused: Boolean = false,
defaultValue = "false",
)
var isAutoTunnelPaused: Boolean = false,
) {
fun isTunnelConfigDefault(tunnelConfig: TunnelConfig): Boolean {
return if (defaultTunnel != null) {

View File

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

View File

@ -6,9 +6,13 @@ import kotlinx.coroutines.flow.Flow
interface TunnelConfigRepository {
fun getTunnelConfigsFlow() : Flow<TunnelConfigs>
suspend fun getAll() : TunnelConfigs
fun getTunnelConfigsFlow(): Flow<TunnelConfigs>
suspend fun getAll(): TunnelConfigs
suspend fun save(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 kotlinx.coroutines.flow.Flow
class TunnelConfigRepositoryImpl(private val tunnelConfigDao: TunnelConfigDao) : TunnelConfigRepository {
class TunnelConfigRepositoryImpl(private val tunnelConfigDao: TunnelConfigDao) :
TunnelConfigRepository {
override fun getTunnelConfigsFlow(): Flow<TunnelConfigs> {
return tunnelConfigDao.getAllFlow()
}
@ -25,4 +26,4 @@ class TunnelConfigRepositoryImpl(private val tunnelConfigDao: TunnelConfigDao) :
override suspend fun count(): Int {
return tunnelConfigDao.count().toInt()
}
}
}

View File

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

View File

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

View File

@ -17,7 +17,9 @@ import dagger.hilt.android.scopes.ServiceScoped
abstract class ServiceModule {
@Binds
@ServiceScoped
abstract fun provideNotificationService(wireGuardNotification: WireGuardNotification): NotificationService
abstract fun provideNotificationService(
wireGuardNotification: WireGuardNotification
): NotificationService
@Binds
@ServiceScoped
@ -25,9 +27,13 @@ abstract class ServiceModule {
@Binds
@ServiceScoped
abstract fun provideMobileDataService(mobileDataService: MobileDataService): NetworkService<MobileDataService>
abstract fun provideMobileDataService(
mobileDataService: MobileDataService
): NetworkService<MobileDataService>
@Binds
@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 {
@Provides
@Singleton
fun provideRootShell(
@ApplicationContext context: Context
): RootShell {
fun provideRootShell(@ApplicationContext context: Context): RootShell {
return RootShell(context)
}
@Provides
@Singleton
@Userspace
fun provideUserspaceBackend(
@ApplicationContext context: Context
): Backend {
fun provideUserspaceBackend(@ApplicationContext context: Context): Backend {
return GoBackend(context)
}
@Provides
@Singleton
@Kernel
fun provideKernelBackend(
@ApplicationContext context: Context,
rootShell: RootShell
): Backend {
fun provideKernelBackend(@ApplicationContext context: Context, rootShell: RootShell): Backend {
return WgQuickBackend(context, rootShell, ToolsInstaller(context, rootShell))
}
@ -51,7 +44,7 @@ class TunnelModule {
fun provideVpnService(
@Userspace userspaceBackend: Backend,
@Kernel kernelBackend: Backend,
settingsRepository : SettingsRepository
settingsRepository: SettingsRepository
): VpnService {
return WireGuardTunnel(userspaceBackend, kernelBackend, settingsRepository)
}

View File

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

View File

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

View File

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

View File

@ -15,18 +15,14 @@ open class ForegroundService : LifecycleService() {
return null
}
override fun onStartCommand(
intent: Intent?,
flags: Int,
startId: Int
): Int {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId)
Timber.d("onStartCommand executed with startId: $startId")
if (intent != null) {
val action = intent.action
Timber.d("using an intent with action $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)
"android.net.VpnService" -> {
Timber.d("Always-on VPN starting service")
@ -36,7 +32,7 @@ open class ForegroundService : LifecycleService() {
}
} else {
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

View File

@ -1,28 +1,27 @@
package com.zaneschepke.wireguardautotunnel.service.foreground
import android.app.ActivityManager
import android.app.Service
import android.content.Context
import android.content.Context.ACTIVITY_SERVICE
import android.content.Intent
import com.zaneschepke.wireguardautotunnel.R
import timber.log.Timber
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(
context: Context,
cls: Class<T>
): ServiceState {
val isServiceRunning = context.isServiceRunning(cls)
return if (isServiceRunning) ServiceState.STARTED else ServiceState.STOPPED
}
// private
// fun <T> Context.isServiceRunning(service: Class<T>) =
// (getSystemService(ACTIVITY_SERVICE) as ActivityManager)
// .runningAppProcesses.any {
// it.processName == service.name
// }
//
// 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(
action: Action,
@ -30,14 +29,10 @@ object ServiceManager {
cls: Class<T>,
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 =
Intent(context, cls).also {
it.action = action.name
extras?.forEach { (k, v) ->
it.putExtra(k, v)
}
extras?.forEach { (k, v) -> it.putExtra(k, v) }
}
intent.component?.javaClass
try {
@ -45,11 +40,9 @@ object ServiceManager {
Action.START_FOREGROUND -> {
context.startForegroundService(intent)
}
Action.START -> {
context.startService(intent)
}
Action.STOP -> context.startService(intent)
}
} catch (e: Exception) {
@ -57,35 +50,30 @@ object ServiceManager {
}
}
fun startVpnService(
context: Context,
tunnelConfig: String
) {
fun startVpnService(context: Context, tunnelConfig: String) {
actionOnService(
Action.START,
context,
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) {
Timber.d("Stopping vpn service action")
actionOnService(
Action.STOP,
context,
WireGuardTunnelService::class.java
WireGuardTunnelService::class.java,
)
}
fun startVpnServiceForeground(
context: Context,
tunnelConfig: String
) {
fun startVpnServiceForeground(context: Context, tunnelConfig: String) {
actionOnService(
Action.START_FOREGROUND,
context,
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(
Action.START_FOREGROUND,
context,
WireGuardConnectivityWatcherService::class.java
WireGuardConnectivityWatcherService::class.java,
)
}
fun startWatcherService(
context: Context
) {
fun startWatcherService(context: Context) {
actionOnService(
Action.START,
context,
WireGuardConnectivityWatcherService::class.java
WireGuardConnectivityWatcherService::class.java,
)
}
@ -113,7 +99,7 @@ object ServiceManager {
actionOnService(
Action.STOP,
context,
WireGuardConnectivityWatcherService::class.java
WireGuardConnectivityWatcherService::class.java,
)
}
}

View File

@ -34,334 +34,361 @@ import javax.inject.Inject
@AndroidEntryPoint
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())
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 val networkEventsFlow = MutableStateFlow(WatcherState())
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 val tag = this.javaClass.name
private lateinit var watcherJob: Job
override fun onCreate() {
super.onCreate()
lifecycleScope.launch(Dispatchers.Main) {
try {
if(settingsRepository.getSettings().isAutoTunnelPaused) {
launchWatcherPausedNotification()
} else launchWatcherNotification()
} catch (e: Exception) {
Timber.e("Failed to start watcher service, not enough permissions")
}
private var wakeLock: PowerManager.WakeLock? = null
private val tag = this.javaClass.name
override fun onCreate() {
super.onCreate()
lifecycleScope.launch(Dispatchers.Main) {
try {
if (settingsRepository.getSettings().isAutoTunnelPaused) {
launchWatcherPausedNotification()
} else launchWatcherNotification()
} catch (e: Exception) {
Timber.e("Failed to start watcher service, not enough permissions")
}
}
}
}
override fun startService(extras: Bundle?) {
super.startService(extras)
try {
// we need this lock so our service gets not affected by Doze Mode
lifecycleScope.launch { initWakeLock() }
cancelWatcherJob()
startWatcherJob()
} catch (e: Exception) {
Timber.e("Failed to launch watcher service, no permissions")
override fun startService(extras: Bundle?) {
super.startService(extras)
try {
// we need this lock so our service gets not affected by Doze Mode
lifecycleScope.launch { initWakeLock() }
cancelWatcherJob()
startWatcherJob()
} catch (e: Exception) {
Timber.e("Failed to launch watcher service, no permissions")
}
}
}
override fun stopService(extras: Bundle?) {
super.stopService(extras)
wakeLock?.let {
if (it.isHeld) {
it.release()
}
override fun stopService(extras: Bundle?) {
super.stopService(extras)
wakeLock?.let {
if (it.isHeld) {
it.release()
}
}
cancelWatcherJob()
stopSelf()
}
cancelWatcherJob()
stopSelf()
}
private fun launchWatcherNotification(description: String = getString(R.string.watcher_notification_text_active)) {
val notification =
notificationService.createNotification(
channelId = getString(R.string.watcher_channel_id),
channelName = getString(R.string.watcher_channel_name),
title = getString(R.string.auto_tunnel_title),
description = description)
ServiceCompat.startForeground(
this, foregroundId, notification, Constants.SYSTEM_EXEMPT_SERVICE_TYPE_ID)
}
private fun launchWatcherNotification(
description: String = getString(R.string.watcher_notification_text_active)
) {
val notification =
notificationService.createNotification(
channelId = getString(R.string.watcher_channel_id),
channelName = getString(R.string.watcher_channel_name),
title = getString(R.string.auto_tunnel_title),
description = description,
)
ServiceCompat.startForeground(
this,
foregroundId,
notification,
Constants.SYSTEM_EXEMPT_SERVICE_TYPE_ID,
)
}
private fun launchWatcherPausedNotification() {
launchWatcherNotification(getString(R.string.watcher_notification_text_paused))
}
// TODO could this be restarting service in a bad state?
// try to start task again if killed
override fun onTaskRemoved(rootIntent: Intent) {
Timber.d("Task Removed called")
val restartServiceIntent = Intent(rootIntent)
val restartServicePendingIntent: PendingIntent =
PendingIntent.getService(
this,
1,
restartServiceIntent,
PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE)
applicationContext.getSystemService(Context.ALARM_SERVICE)
val alarmService: AlarmManager =
applicationContext.getSystemService(Context.ALARM_SERVICE) as AlarmManager
alarmService.set(
AlarmManager.ELAPSED_REALTIME,
SystemClock.elapsedRealtime() + 1000,
restartServicePendingIntent)
}
private suspend fun initWakeLock() {
val isBatterySaverOn =
withContext(lifecycleScope.coroutineContext) {
settingsRepository.getSettings().isBatterySaverEnabled
}
wakeLock =
(getSystemService(Context.POWER_SERVICE) as PowerManager).run {
newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "$tag::lock").apply {
if (isBatterySaverOn) {
Timber.d("Initiating wakelock with timeout")
acquire(Constants.BATTERY_SAVER_WATCHER_WAKE_LOCK_TIMEOUT)
} else {
Timber.d("Initiating wakelock with zero timeout")
acquire(Constants.DEFAULT_WATCHER_WAKE_LOCK_TIMEOUT)
}
}
}
}
private fun cancelWatcherJob() {
if (this::watcherJob.isInitialized) {
watcherJob.cancel()
// TODO could this be restarting service in a bad state?
// try to start task again if killed
override fun onTaskRemoved(rootIntent: Intent) {
Timber.d("Task Removed called")
val restartServiceIntent = Intent(rootIntent)
val restartServicePendingIntent: PendingIntent =
PendingIntent.getService(
this,
1,
restartServiceIntent,
PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE,
)
applicationContext.getSystemService(Context.ALARM_SERVICE)
val alarmService: AlarmManager =
applicationContext.getSystemService(Context.ALARM_SERVICE) as AlarmManager
alarmService.set(
AlarmManager.ELAPSED_REALTIME,
SystemClock.elapsedRealtime() + 1000,
restartServicePendingIntent,
)
}
}
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()
private suspend fun initWakeLock() {
val isBatterySaverOn =
withContext(lifecycleScope.coroutineContext) {
settingsRepository.getSettings().isBatterySaverEnabled
}
}
if (setting.isTunnelOnEthernetEnabled) {
launch {
Timber.d("Starting ethernet data watcher")
watchForEthernetConnectivityChanges()
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)
}
}
}
}
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() {
settingsRepository.getSettingsFlow().collect {
if(networkEventsFlow.value.settings.isAutoTunnelPaused != it.isAutoTunnelPaused) {
when(it.isAutoTunnelPaused) {
if (networkEventsFlow.value.settings.isAutoTunnelPaused != it.isAutoTunnelPaused) {
when (it.isAutoTunnelPaused) {
true -> launchWatcherPausedNotification()
false -> launchWatcherNotification()
}
}
networkEventsFlow.value = networkEventsFlow.value.copy(
settings = it
)
networkEventsFlow.value =
networkEventsFlow.value.copy(
settings = it,
)
}
}
private suspend fun watchForVpnConnectivityChanges() {
vpnService.vpnState.collect {
when(it.status) {
Tunnel.State.DOWN -> networkEventsFlow.value = networkEventsFlow.value.copy(
isVpnConnected = false
)
Tunnel.State.UP -> networkEventsFlow.value = networkEventsFlow.value.copy(
isVpnConnected = true
)
when (it.status) {
Tunnel.State.DOWN ->
networkEventsFlow.value =
networkEventsFlow.value.copy(
isVpnConnected = false,
)
Tunnel.State.UP ->
networkEventsFlow.value =
networkEventsFlow.value.copy(
isVpnConnected = true,
)
else -> {}
}
}
}
private suspend fun watchForEthernetConnectivityChanges() {
ethernetService.networkStatus.collect {
when (it) {
is NetworkStatus.Available -> {
Timber.d("Gained Ethernet connection")
networkEventsFlow.value = 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")
private suspend fun watchForEthernetConnectivityChanges() {
ethernetService.networkStatus.collect {
when (it) {
is NetworkStatus.Available -> {
Timber.d("Gained Ethernet connection")
networkEventsFlow.value =
networkEventsFlow.value.copy(
isEthernetConnected = true,
)
}
(!it.isEthernetConnected &&
it.settings.isTunnelOnMobileDataEnabled &&
!it.isWifiConnected &&
it.isMobileDataConnected &&
!it.isVpnConnected) -> {
ServiceManager.startVpnServiceForeground(this, it.settings.defaultTunnel!!)
Timber.i("Condition 2 met")
is NetworkStatus.CapabilitiesChanged -> {
Timber.d("Ethernet capabilities changed")
networkEventsFlow.value =
networkEventsFlow.value.copy(
isEthernetConnected = true,
)
}
(!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")
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 &&
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() {
private val foregroundId = 123
@Inject
lateinit var vpnService: VpnService
@Inject lateinit var vpnService: VpnService
@Inject
lateinit var settingsRepository: SettingsRepository
@Inject lateinit var settingsRepository: SettingsRepository
@Inject
lateinit var tunnelConfigRepository: TunnelConfigRepository
@Inject lateinit var tunnelConfigRepository: TunnelConfigRepository
@Inject
lateinit var notificationService: NotificationService
@Inject lateinit var notificationService: NotificationService
private lateinit var job: Job
@ -48,7 +44,7 @@ class WireGuardTunnelService : ForegroundService() {
override fun onCreate() {
super.onCreate()
lifecycleScope.launch(Dispatchers.Main) {
if(tunnelConfigRepository.getAll().isNotEmpty()) {
if (tunnelConfigRepository.getAll().isNotEmpty()) {
launchVpnNotification()
}
}
@ -58,11 +54,10 @@ class WireGuardTunnelService : ForegroundService() {
super.startService(extras)
cancelJob()
val tunnelConfigString = extras?.getString(getString(R.string.tunnel_extras_key))
val tunnelConfig = tunnelConfigString?.let {
TunnelConfig.from(it)
}
val tunnelConfig = tunnelConfigString?.let { TunnelConfig.from(it) }
tunnelName = tunnelConfig?.name ?: ""
job = lifecycleScope.launch(Dispatchers.IO) {
job =
lifecycleScope.launch(Dispatchers.IO) {
launch {
if (tunnelConfig != null) {
try {
@ -77,22 +72,22 @@ class WireGuardTunnelService : ForegroundService() {
val settings = settingsRepository.getSettings()
val tunnels = tunnelConfigRepository.getAll()
if (settings.isAlwaysOnVpnEnabled) {
val tunnel = if(settings.defaultTunnel != null) {
TunnelConfig.from(settings.defaultTunnel!!)
} else if(tunnels.isNotEmpty()) {
tunnels.first()
} else {
null
}
if(tunnel != null) {
val tunnel =
if (settings.defaultTunnel != null) {
TunnelConfig.from(settings.defaultTunnel!!)
} else if (tunnels.isNotEmpty()) {
tunnels.first()
} else {
null
}
if (tunnel != null) {
tunnelName = tunnel.name
vpnService.startTunnel(tunnel)
}
}
}
}
//TODO add failed to connect notification
// TODO add failed to connect notification
launch {
vpnService.vpnState.collect { state ->
state.statistics
@ -101,14 +96,18 @@ class WireGuardTunnelService : ForegroundService() {
.let { statuses ->
when {
statuses?.all { it == HandshakeStatus.HEALTHY } == true -> {
if(!didShowConnected){
if (!didShowConnected) {
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
}
}
statuses?.any { it == HandshakeStatus.STALE } == true -> {}
statuses?.all { it == HandshakeStatus.NOT_STARTED } == true -> {}
statuses?.all { it == HandshakeStatus.NOT_STARTED } ==
true -> {}
else -> {}
}
}
@ -127,7 +126,10 @@ class WireGuardTunnelService : ForegroundService() {
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 =
notificationService.createNotification(
channelId = getString(R.string.vpn_channel_id),
@ -136,13 +138,13 @@ class WireGuardTunnelService : ForegroundService() {
onGoing = false,
vibration = false,
showTimestamp = true,
description = description
description = description,
)
ServiceCompat.startForeground(
this,
foregroundId,
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),
channelName = getString(R.string.vpn_channel_name),
action =
PendingIntent.getBroadcast(
this,
0,
Intent(this, NotificationActionReceiver::class.java),
PendingIntent.FLAG_IMMUTABLE
),
PendingIntent.getBroadcast(
this,
0,
Intent(this, NotificationActionReceiver::class.java),
PendingIntent.FLAG_IMMUTABLE,
),
actionText = getString(R.string.restart),
title = getString(R.string.vpn_connection_failed),
onGoing = false,
vibration = true,
showTimestamp = true,
description = message
description = message,
)
ServiceCompat.startForeground(
this,
foregroundId,
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 =
context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
override val networkStatus =
callbackFlow {
val networkStatusCallback =
when (Build.VERSION.SDK_INT) {
in Build.VERSION_CODES.S..Int.MAX_VALUE -> {
object : ConnectivityManager.NetworkCallback(
FLAG_INCLUDE_LOCATION_INFO
override val networkStatus = callbackFlow {
val networkStatusCallback =
when (Build.VERSION.SDK_INT) {
in Build.VERSION_CODES.S..Int.MAX_VALUE -> {
object :
ConnectivityManager.NetworkCallback(
FLAG_INCLUDE_LOCATION_INFO,
) {
override fun onAvailable(network: Network) {
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
)
)
}
override fun onAvailable(network: Network) {
trySend(NetworkStatus.Available(network))
}
}
else -> {
object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
trySend(NetworkStatus.Available(network))
}
override fun onLost(network: Network) {
trySend(NetworkStatus.Unavailable(network))
}
override fun onLost(network: Network) {
trySend(NetworkStatus.Unavailable(network))
}
override fun onCapabilitiesChanged(
network: Network,
networkCapabilities: NetworkCapabilities
) {
trySend(
NetworkStatus.CapabilitiesChanged(
network,
networkCapabilities
)
)
}
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)
else -> {
object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
trySend(NetworkStatus.Available(network))
}
awaitClose {
connectivityManager.unregisterNetworkCallback(networkStatusCallback)
override fun onLost(network: Network) {
trySend(NetworkStatus.Unavailable(network))
}
override fun onCapabilitiesChanged(
network: Network,
networkCapabilities: NetworkCapabilities
) {
trySend(
NetworkStatus.CapabilitiesChanged(
network,
networkCapabilities,
),
)
}
}
}
}
}
val request =
NetworkRequest.Builder()
.addTransportType(networkCapability)
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.addCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
.build()
connectivityManager.registerNetworkCallback(request, networkStatusCallback)
awaitClose { connectivityManager.unregisterNetworkCallback(networkStatusCallback) }
}
override fun getNetworkName(networkCapabilities: NetworkCapabilities): String? {
var ssid: String? = getWifiNameFromCapabilities(networkCapabilities)
@ -119,18 +116,16 @@ abstract class BaseNetworkService<T : BaseNetworkService<T>>(
inline fun <Result> Flow<NetworkStatus>.map(
crossinline onUnavailable: suspend (network: Network) -> Result,
crossinline onAvailable: suspend (network: Network) -> Result,
crossinline onCapabilitiesChanged: suspend (
network: Network,
networkCapabilities: NetworkCapabilities
) -> Result
): Flow<Result> =
map { status ->
when (status) {
is NetworkStatus.Unavailable -> onUnavailable(status.network)
is NetworkStatus.Available -> onAvailable(status.network)
is NetworkStatus.CapabilitiesChanged -> onCapabilitiesChanged(
crossinline onCapabilitiesChanged:
suspend (network: Network, networkCapabilities: NetworkCapabilities) -> Result
): Flow<Result> = map { status ->
when (status) {
is NetworkStatus.Unavailable -> onUnavailable(status.network)
is NetworkStatus.Available -> onAvailable(status.network)
is NetworkStatus.CapabilitiesChanged ->
onCapabilitiesChanged(
status.network,
status.networkCapabilities
status.networkCapabilities,
)
}
}
}

View File

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

View File

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

View File

@ -5,9 +5,5 @@ import android.net.NetworkCapabilities
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
class WifiService
@Inject
constructor(
@ApplicationContext context: Context
) :
class WifiService @Inject constructor(@ApplicationContext context: Context) :
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.NotificationManager
import android.app.PendingIntent
import androidx.core.app.NotificationCompat
interface NotificationService {
fun createNotification(

View File

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

View File

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

View File

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

View File

@ -4,13 +4,13 @@ enum class HandshakeStatus {
HEALTHY,
STALE,
UNKNOWN,
NOT_STARTED
;
NOT_STARTED;
companion object {
private const val WG_TYPICAL_HANDSHAKE_INTERVAL_WHEN_HEALTHY_SEC = 180
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
}
}

View File

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

View File

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

View File

@ -6,8 +6,8 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
@HiltViewModel
class ActivityViewModel @Inject constructor(
class ActivityViewModel
@Inject
constructor(
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.focusProperties
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
@ -37,6 +38,9 @@ import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.wireguard.android.backend.GoBackend
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.navigation.BottomNavBar
import com.zaneschepke.wireguardautotunnel.ui.common.prompt.CustomSnackBar
@ -50,19 +54,40 @@ import com.zaneschepke.wireguardautotunnel.util.Constants
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import timber.log.Timber
import java.io.IOException
import javax.inject.Inject
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
@Inject
lateinit var dataStoreManager: DataStoreManager
@Inject lateinit var settingsRepository: SettingsRepository
@OptIn(
ExperimentalPermissionsApi::class
ExperimentalPermissionsApi::class,
)
override fun onCreate(savedInstanceState: Bundle?) {
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 {
// val activityViewModel = hiltViewModel<ActivityViewModel>()
// val activityViewModel = hiltViewModel<ActivityViewModel>()
val navController = rememberNavController()
val focusRequester = remember { FocusRequester()}
val focusRequester = remember { FocusRequester() }
WireguardAutoTunnelTheme {
TransparentSystemBars()
@ -73,7 +98,10 @@ class MainActivity : AppCompatActivity() {
rememberPermissionState(Manifest.permission.POST_NOTIFICATIONS)
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()
}
}
@ -87,7 +115,7 @@ class MainActivity : AppCompatActivity() {
if (accepted) {
vpnIntent = null
}
}
},
)
LaunchedEffect(vpnIntent) {
if (vpnIntent != null) {
@ -99,13 +127,15 @@ class MainActivity : AppCompatActivity() {
fun showSnackBarMessage(message: String) {
lifecycleScope.launch(Dispatchers.Main) {
val result = snackbarHostState.showSnackbar(
val result =
snackbarHostState.showSnackbar(
message = message,
actionLabel = applicationContext.getString(R.string.okay),
duration = SnackbarDuration.Short
duration = SnackbarDuration.Short,
)
when (result) {
SnackbarResult.ActionPerformed, SnackbarResult.Dismissed -> {
SnackbarResult.ActionPerformed,
SnackbarResult.Dismissed -> {
snackbarHostState.currentSnackbarData?.dismiss()
}
}
@ -118,29 +148,36 @@ class MainActivity : AppCompatActivity() {
CustomSnackBar(
snackbarData.visuals.message,
isRtl = false,
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(
2.dp
)
containerColor =
MaterialTheme.colorScheme.surfaceColorAtElevation(
2.dp,
),
)
}
},
modifier = Modifier.focusable().focusProperties { up = focusRequester },
bottomBar =
if (vpnIntent == null && notificationPermissionState.status.isGranted) {
{ BottomNavBar(navController, listOf(
Screen.Main.navItem,
Screen.Settings.navItem,
Screen.Support.navItem)) }
} else {
{}
}
if (vpnIntent == null && notificationPermissionState.status.isGranted) {
{
BottomNavBar(
navController,
listOf(
Screen.Main.navItem,
Screen.Settings.navItem,
Screen.Support.navItem,
),
)
}
} else {
{}
},
) { padding ->
if (vpnIntent != null) {
PermissionRequestFailedScreen(
padding = padding,
onRequestAgain = { vpnActivityResultState.launch(vpnIntent) },
message = getString(R.string.vpn_permission_required),
getString(R.string.retry)
getString(R.string.retry),
)
return@Scaffold
}
@ -154,12 +191,12 @@ class MainActivity : AppCompatActivity() {
Uri.fromParts(
Constants.URI_PACKAGE_SCHEME,
this.packageName,
null
null,
)
startActivity(intentSettings)
},
message = getString(R.string.notification_permission_required),
getString(R.string.open_settings)
getString(R.string.open_settings),
)
return@Scaffold
}
@ -167,34 +204,42 @@ class MainActivity : AppCompatActivity() {
composable(
Screen.Main.route,
) {
MainScreen(padding = padding, focusRequester = focusRequester, showSnackbarMessage = { message ->
showSnackBarMessage(message)
}, navController = navController)
MainScreen(
padding = padding,
focusRequester = focusRequester,
showSnackbarMessage = { message -> showSnackBarMessage(message) },
navController = navController,
)
}
composable(Screen.Settings.route,
composable(
Screen.Settings.route,
) {
SettingsScreen(padding = padding, showSnackbarMessage = { message ->
showSnackBarMessage(message)
}, focusRequester = focusRequester)
SettingsScreen(
padding = padding,
showSnackbarMessage = { message -> showSnackBarMessage(message) },
focusRequester = focusRequester,
)
}
composable(Screen.Support.route,
composable(
Screen.Support.route,
) {
SupportScreen(padding = padding, focusRequester = focusRequester,
showSnackbarMessage = { message ->
showSnackBarMessage(message)
})
SupportScreen(
padding = padding,
focusRequester = focusRequester,
showSnackbarMessage = { message -> showSnackBarMessage(message) },
)
}
composable("${Screen.Config.route}/{id}") {
val id = it.arguments?.getString("id")
if (!id.isNullOrBlank()) {
//https://dagger.dev/hilt/view-model#assisted-injection
// https://dagger.dev/hilt/view-model#assisted-injection
ConfigScreen(
navController = navController,
id = id,
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 com.zaneschepke.wireguardautotunnel.ui.common.navigation.BottomNavItem
sealed class Screen(val route : String) {
data object Main: Screen("main") {
val navItem = BottomNavItem(
name = "Tunnels",
route = route,
icon = Icons.Rounded.Home
)
sealed class Screen(val route: String) {
data object Main : Screen("main") {
val navItem =
BottomNavItem(
name = "Tunnels",
route = route,
icon = Icons.Rounded.Home,
)
}
data object Settings: Screen("settings") {
val navItem = BottomNavItem(
name = "Settings",
route = route,
icon = Icons.Rounded.Settings
)
}
data object Support: Screen("support") {
val navItem = BottomNavItem(
name = "Support",
route = route,
icon = Icons.Rounded.QuestionMark
)
}
data object Config : Screen("config")
}
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(
onClick = onClick,
enabled = enabled
enabled = enabled,
) {
Text(text, Modifier.weight(1f, false))
Spacer(modifier = Modifier.size(ButtonDefaults.IconSpacing))
@ -31,11 +31,11 @@ fun ClickableIconButton(
imageVector = icon,
contentDescription = stringResource(R.string.delete),
modifier =
Modifier.size(ButtonDefaults.IconSize).weight(1f, false).clickable {
if (enabled) {
onIconClick()
}
}
Modifier.size(ButtonDefaults.IconSize).weight(1f, false).clickable {
if (enabled) {
onIconClick()
}
},
)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -11,14 +11,11 @@ import androidx.navigation.NavController
import androidx.navigation.compose.currentBackStackEntryAsState
@Composable
fun BottomNavBar(
navController: NavController,
bottomNavItems: List<BottomNavItem>
) {
fun BottomNavBar(navController: NavController, bottomNavItems: List<BottomNavItem>) {
val backStackEntry = navController.currentBackStackEntryAsState()
NavigationBar(
containerColor = MaterialTheme.colorScheme.background
containerColor = MaterialTheme.colorScheme.background,
) {
bottomNavItems.forEach { item ->
val selected = item.route == backStackEntry.value?.destination?.route
@ -29,15 +26,15 @@ fun BottomNavBar(
label = {
Text(
text = item.name,
fontWeight = FontWeight.SemiBold
fontWeight = FontWeight.SemiBold,
)
},
icon = {
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
@Composable
fun AuthorizationPrompt(
onSuccess: () -> Unit,
onFailure: () -> Unit,
onError: (String) -> Unit
) {
fun AuthorizationPrompt(onSuccess: () -> Unit, onFailure: () -> Unit, onError: (String) -> Unit) {
val context = LocalContext.current
val biometricManager = BiometricManager.from(context)
val bio = biometricManager.canAuthenticate(BIOMETRIC_WEAK or DEVICE_CREDENTIAL)
val isBiometricAvailable =
remember {
when (bio) {
BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE -> {
onError("Biometrics not available")
false
}
BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> {
onError("Biometrics not created")
false
}
BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> {
onError("Biometric hardware not found")
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
val isBiometricAvailable = remember {
when (bio) {
BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE -> {
onError("Biometrics not available")
false
}
BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> {
onError("Biometrics not created")
false
}
BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> {
onError("Biometric hardware not found")
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) {
val executor = remember { ContextCompat.getMainExecutor(context) }
@ -71,10 +60,7 @@ fun AuthorizationPrompt(
context as FragmentActivity,
executor,
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationError(
errorCode: Int,
errString: CharSequence
) {
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
super.onAuthenticationError(errorCode, errString)
onFailure()
}
@ -90,7 +76,7 @@ fun AuthorizationPrompt(
super.onAuthenticationFailed()
onFailure()
}
}
},
)
biometricPrompt.authenticate(promptInfo)
}

View File

@ -37,25 +37,25 @@ fun CustomSnackBar(
Snackbar(
containerColor = containerColor,
modifier =
Modifier.fillMaxWidth(
if (WireGuardAutoTunnel.isRunningOnAndroidTv()) 1 / 3f else 2 / 3f
).padding(bottom = 100.dp),
shape = RoundedCornerShape(16.dp)
Modifier.fillMaxWidth(
if (WireGuardAutoTunnel.isRunningOnAndroidTv()) 1 / 3f else 2 / 3f,
)
.padding(bottom = 100.dp),
shape = RoundedCornerShape(16.dp),
) {
CompositionLocalProvider(
LocalLayoutDirection provides
if (isRtl) LayoutDirection.Rtl else LayoutDirection.Ltr
LocalLayoutDirection provides if (isRtl) LayoutDirection.Rtl else LayoutDirection.Ltr,
) {
Row(
modifier = Modifier.width(IntrinsicSize.Max).height(IntrinsicSize.Min),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start
horizontalArrangement = Arrangement.Start,
) {
Icon(
Icons.Rounded.Info,
contentDescription = stringResource(R.string.info),
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))
}

View File

@ -13,10 +13,11 @@ import androidx.compose.ui.unit.dp
@Composable
fun LoadingScreen() {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top,
modifier = Modifier.fillMaxSize().focusable().padding()) {
Column(modifier = Modifier.padding(120.dp)) { CircularProgressIndicator() }
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top,
modifier = Modifier.fillMaxSize().focusable().padding(),
) {
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
@Composable
fun SectionTitle(
title: String,
padding: Dp
) {
fun SectionTitle(title: String, padding: Dp) {
Text(
title,
textAlign = TextAlign.Center,
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(),
addresses = i.addresses.joinToString(", ").trim(),
dnsServers = i.dnsServers.joinToString(", ").replace("/", "").trim(),
listenPort = if (i.listenPort.isPresent) {
i.listenPort.get().toString()
.trim()
} else {
""
},
mtu = if (i.mtu.isPresent) i.mtu.get().toString().trim() else ""
listenPort =
if (i.listenPort.isPresent) {
i.listenPort.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 {
return PeerProxy(
publicKey = peer.publicKey.toBase64(),
preSharedKey = if (peer.preSharedKey.isPresent) {
peer.preSharedKey.get().toBase64()
.trim()
} else {
""
},
persistentKeepalive = if (peer.persistentKeepalive.isPresent) {
peer.persistentKeepalive.get()
.toString().trim()
} else {
""
},
endpoint = if (peer.endpoint.isPresent) {
peer.endpoint.get().toString()
.trim()
} else {
""
},
allowedIps = peer.allowedIps.joinToString(", ").trim()
preSharedKey =
if (peer.preSharedKey.isPresent) {
peer.preSharedKey.get().toBase64().trim()
} else {
""
},
persistentKeepalive =
if (peer.persistentKeepalive.isPresent) {
peer.persistentKeepalive.get().toString().trim()
} else {
""
},
endpoint =
if (peer.endpoint.isPresent) {
peer.endpoint.get().toString().trim()
} else {
""
},
allowedIps = peer.allowedIps.joinToString(", ").trim(),
)
}
val IPV4_PUBLIC_NETWORKS =
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",
"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"
"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",
"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")
}

View File

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

View File

@ -41,272 +41,312 @@ constructor(
private val settingsRepository: SettingsRepository,
) : ViewModel() {
private val packageManager = application.packageManager
private val packageManager = application.packageManager
private val _uiState = MutableStateFlow(ConfigUiState())
val uiState = _uiState.asStateFlow()
private val _uiState = MutableStateFlow(ConfigUiState())
val uiState = _uiState.asStateFlow()
fun init(tunnelId : String) = viewModelScope.launch(Dispatchers.IO) {
val packages = getQueriedPackages("")
val state = if(tunnelId != Constants.MANUAL_TUNNEL_CONFIG_ID) {
val tunnelConfig =
tunnelConfigRepository.getAll().firstOrNull { it.id.toString() == tunnelId }
if (tunnelConfig != null) {
val config = TunnelConfig.configFromQuick(tunnelConfig.wgQuick)
val proxyPeers = config.peers.map { PeerProxy.from(it) }
val proxyInterface = InterfaceProxy.from(config.`interface`)
var include = true
var isAllApplicationsEnabled = false
val checkedPackages =
if (config.`interface`.includedApplications.isNotEmpty()) {
config.`interface`.includedApplications
} else if (config.`interface`.excludedApplications.isNotEmpty()) {
include = false
config.`interface`.excludedApplications
fun init(tunnelId: String) =
viewModelScope.launch(Dispatchers.IO) {
val packages = getQueriedPackages("")
val state =
if (tunnelId != Constants.MANUAL_TUNNEL_CONFIG_ID) {
val tunnelConfig =
tunnelConfigRepository.getAll().firstOrNull { it.id.toString() == tunnelId }
if (tunnelConfig != null) {
val config = TunnelConfig.configFromQuick(tunnelConfig.wgQuick)
val proxyPeers = config.peers.map { PeerProxy.from(it) }
val proxyInterface = InterfaceProxy.from(config.`interface`)
var include = true
var isAllApplicationsEnabled = false
val checkedPackages =
if (config.`interface`.includedApplications.isNotEmpty()) {
config.`interface`.includedApplications
} 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 {
isAllApplicationsEnabled = true
emptySet()
ConfigUiState(loading = false, packages = packages)
}
ConfigUiState(
proxyPeers,
proxyInterface,
packages,
checkedPackages.toList(),
include,
isAllApplicationsEnabled,
false,
tunnelConfig,
tunnelConfig.name)
} else {
ConfigUiState(loading = false, packages = packages)
}
} else {
ConfigUiState(loading = false, packages = packages)
}
_uiState.value = state
}
fun onTunnelNameChange(name: String) {
_uiState.value = _uiState.value.copy(tunnelName = name)
}
fun onIncludeChange(include: Boolean) {
_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 {
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) {
_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())
private fun isAllApplicationsEnabled(): Boolean {
return _uiState.value.isAllApplicationsEnabled
}
}
fun getPackageLabel(packageInfo: PackageInfo): String {
return packageInfo.applicationInfo.loadLabel(application.packageManager).toString()
}
private fun saveConfig(tunnelConfig: TunnelConfig) =
viewModelScope.launch { tunnelConfigRepository.save(tunnelConfig) }
private fun getAllInternetCapablePackages(): List<PackageInfo> {
return getPackagesHoldingPermissions(arrayOf(Manifest.permission.INTERNET))
}
private fun getPackagesHoldingPermissions(permissions: Array<String>): List<PackageInfo> {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
packageManager.getPackagesHoldingPermissions(
permissions, PackageManager.PackageInfoFlags.of(0L))
} else {
packageManager.getPackagesHoldingPermissions(permissions, 0)
}
}
private fun isAllApplicationsEnabled(): Boolean {
return _uiState.value.isAllApplicationsEnabled
}
private fun saveConfig(tunnelConfig: TunnelConfig) =
viewModelScope.launch {
tunnelConfigRepository.save(tunnelConfig)
}
private fun updateTunnelConfig(tunnelConfig: TunnelConfig?) =
viewModelScope.launch {
if (tunnelConfig != null) {
saveConfig(tunnelConfig).join()
WireGuardAutoTunnel.requestTileServiceStateUpdate()
updateSettingsDefaultTunnel(tunnelConfig)
private fun updateTunnelConfig(tunnelConfig: TunnelConfig?) =
viewModelScope.launch {
if (tunnelConfig != null) {
saveConfig(tunnelConfig).join()
WireGuardAutoTunnel.requestTileServiceStateUpdate()
updateSettingsDefaultTunnel(tunnelConfig)
}
}
}
private suspend fun updateSettingsDefaultTunnel(tunnelConfig: TunnelConfig) {
val settings = settingsRepository.getSettingsFlow().first()
if (settings.defaultTunnel != null) {
if (tunnelConfig.id == TunnelConfig.from(settings.defaultTunnel!!).id) {
settingsRepository.save(settings.copy(defaultTunnel = tunnelConfig.toString()))
}
}
}
private fun buildPeerListFromProxyPeers(): List<Peer> {
return _uiState.value.proxyPeers.map {
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())
private suspend fun updateSettingsDefaultTunnel(tunnelConfig: TunnelConfig) {
val settings = settingsRepository.getSettingsFlow().first()
if (settings.defaultTunnel != null) {
if (tunnelConfig.id == TunnelConfig.from(settings.defaultTunnel!!).id) {
settingsRepository.save(settings.copy(defaultTunnel = tunnelConfig.toString()))
}
}
_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.delay
import kotlinx.coroutines.launch
import timber.log.Timber
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@ -118,370 +119,472 @@ fun MainScreen(
showSnackbarMessage: (String) -> Unit,
navController: NavController
) {
val haptic = LocalHapticFeedback.current
val context = LocalContext.current
val isVisible = rememberSaveable { mutableStateOf(true) }
val scope = rememberCoroutineScope { Dispatchers.IO }
val haptic = LocalHapticFeedback.current
val context = LocalContext.current
val isVisible = rememberSaveable { mutableStateOf(true) }
val scope = rememberCoroutineScope { Dispatchers.IO }
val sheetState = rememberModalBottomSheetState()
var showBottomSheet by remember { mutableStateOf(false) }
val sheetState = rememberModalBottomSheetState()
var showBottomSheet by remember { mutableStateOf(false) }
var showPrimaryChangeAlertDialog by remember { mutableStateOf(false) }
var selectedTunnel by remember { mutableStateOf<TunnelConfig?>(null) }
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
var showPrimaryChangeAlertDialog by remember { mutableStateOf(false) }
var showDeleteTunnelAlertDialog by remember { mutableStateOf(false) }
var selectedTunnel by remember { mutableStateOf<TunnelConfig?>(null) }
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
LaunchedEffect(uiState.loading) {
if (!uiState.loading && WireGuardAutoTunnel.isRunningOnAndroidTv()) {
delay(Constants.FOCUS_REQUEST_DELAY)
focusRequester.requestFocus()
LaunchedEffect(uiState.loading) {
if (!uiState.loading && WireGuardAutoTunnel.isRunningOnAndroidTv()) {
delay(Constants.FOCUS_REQUEST_DELAY)
focusRequester.requestFocus()
}
}
}
if (uiState.loading) {
LoadingScreen()
return
}
if (uiState.loading) {
LoadingScreen()
return
}
val tunnelFileImportResultLauncher =
rememberLauncherForActivityResult(
object : ActivityResultContracts.GetContent() {
override fun createIntent(context: Context, input: String): Intent {
val intent = super.createIntent(context, input)
val tunnelFileImportResultLauncher =
rememberLauncherForActivityResult(
object : ActivityResultContracts.GetContent() {
override fun createIntent(context: Context, input: String): Intent {
val intent = super.createIntent(context, input)
/* AndroidTV now comes with stubs that do nothing but display a Toast less helpful than
* what we can do, so detect this and throw an exception that we can catch later. */
val activitiesToResolveIntent =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.packageManager.queryIntentActivities(
intent,
PackageManager.ResolveInfoFlags.of(
PackageManager.MATCH_DEFAULT_ONLY.toLong()))
} else {
context.packageManager.queryIntentActivities(
intent, PackageManager.MATCH_DEFAULT_ONLY)
}
if (activitiesToResolveIntent.all {
val name = it.activityInfo.packageName
name.startsWith(Constants.GOOGLE_TV_EXPLORER_STUB) ||
name.startsWith(Constants.ANDROID_TV_EXPLORER_STUB)
}) {
showSnackbarMessage(Event.Error.FileExplorerRequired.message)
}
return intent
}
}) { data ->
/* AndroidTV now comes with stubs that do nothing but display a Toast less helpful than
* what we can do, so detect this and throw an exception that we can catch later. */
val activitiesToResolveIntent =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.packageManager.queryIntentActivities(
intent,
PackageManager.ResolveInfoFlags.of(
PackageManager.MATCH_DEFAULT_ONLY.toLong(),
),
)
} else {
context.packageManager.queryIntentActivities(
intent,
PackageManager.MATCH_DEFAULT_ONLY,
)
}
if (
activitiesToResolveIntent.all {
val name = it.activityInfo.packageName
name.startsWith(Constants.GOOGLE_TV_EXPLORER_STUB) ||
name.startsWith(Constants.ANDROID_TV_EXPLORER_STUB)
}
) {
showSnackbarMessage(Event.Error.FileExplorerRequired.message)
}
return intent
}
},
) { data ->
if (data == null) return@rememberLauncherForActivityResult
scope.launch {
viewModel.onTunnelFileSelected(data).let {
when (it) {
is Result.Error -> showSnackbarMessage(it.error.message)
is Result.Success -> {}
viewModel.onTunnelFileSelected(data).let {
when (it) {
is Result.Error -> showSnackbarMessage(it.error.message)
is Result.Success -> {}
}
}
}
}
}
val scanLauncher =
rememberLauncherForActivityResult(
contract = ScanContract(),
onResult = {
if (it.contents != null) {
scope.launch {
viewModel.onTunnelQrResult(it.contents).let { result ->
when (result) {
is Result.Success -> {}
is Result.Error -> showSnackbarMessage(result.error.message)
}
}
}
}
})
AnimatedVisibility(showPrimaryChangeAlertDialog) {
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
}
val scanLauncher =
rememberLauncherForActivityResult(
contract = ScanContract(),
onResult = {
if (it.contents != null) {
scope.launch {
viewModel.onTunnelQrResult(it.contents).let { result ->
when (result) {
is Result.Success -> {}
is Result.Error -> showSnackbarMessage(result.error.message)
}
},
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(
imageVector = Icons.Rounded.Add,
contentDescription = stringResource(id = R.string.add_tunnel),
tint = Color.DarkGray)
}
tint = Color.DarkGray,
)
}
}
}) { innerPadding ->
},
) { innerPadding ->
AnimatedVisibility(uiState.tunnels.isEmpty(), exit = fadeOut(), enter = fadeIn()) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxSize().padding(padding)) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxSize().padding(padding),
) {
Text(text = stringResource(R.string.no_tunnels), fontStyle = FontStyle.Italic)
}
}
}
if (showBottomSheet) {
ModalBottomSheet(
onDismissRequest = { showBottomSheet = false }, sheetState = sheetState) {
ModalBottomSheet(
onDismissRequest = { showBottomSheet = false },
sheetState = sheetState,
) {
// Sheet content
Row(
modifier =
Modifier.fillMaxWidth()
.clickable {
showBottomSheet = false
tunnelFileImportResultLauncher.launch(Constants.ALLOWED_FILE_TYPES)
showBottomSheet = false
tunnelFileImportResultLauncher.launch(Constants.ALLOWED_FILE_TYPES)
}
.padding(10.dp)) {
Icon(
Icons.Filled.FileOpen,
contentDescription = stringResource(id = R.string.open_file),
modifier = Modifier.padding(10.dp))
Text(
stringResource(id = R.string.add_tunnels_text),
modifier = Modifier.padding(10.dp))
}
.padding(10.dp),
) {
Icon(
Icons.Filled.FileOpen,
contentDescription = stringResource(id = R.string.open_file),
modifier = Modifier.padding(10.dp),
)
Text(
stringResource(id = R.string.add_tunnels_text),
modifier = Modifier.padding(10.dp),
)
}
if (!WireGuardAutoTunnel.isRunningOnAndroidTv()) {
Divider()
Row(
modifier =
Modifier.fillMaxWidth()
.clickable {
scope.launch {
showBottomSheet = false
val scanOptions = ScanOptions()
scanOptions.setDesiredBarcodeFormats(ScanOptions.QR_CODE)
scanOptions.setOrientationLocked(true)
scanOptions.setPrompt(context.getString(R.string.scanning_qr))
scanOptions.setBeepEnabled(false)
scanOptions.captureActivity = CaptureActivityPortrait::class.java
scanLauncher.launch(scanOptions)
Divider()
Row(
modifier =
Modifier.fillMaxWidth()
.clickable {
scope.launch {
showBottomSheet = false
val scanOptions = ScanOptions()
scanOptions.setDesiredBarcodeFormats(ScanOptions.QR_CODE)
scanOptions.setOrientationLocked(true)
scanOptions.setPrompt(
context.getString(R.string.scanning_qr)
)
scanOptions.setBeepEnabled(false)
scanOptions.captureActivity =
CaptureActivityPortrait::class.java
scanLauncher.launch(scanOptions)
}
}
}
.padding(10.dp)) {
.padding(10.dp),
) {
Icon(
Icons.Filled.QrCode,
contentDescription = stringResource(id = R.string.qr_scan),
modifier = Modifier.padding(10.dp))
modifier = Modifier.padding(10.dp),
)
Text(
stringResource(id = R.string.add_from_qr),
modifier = Modifier.padding(10.dp))
}
modifier = Modifier.padding(10.dp),
)
}
}
Divider()
Row(
modifier =
Modifier.fillMaxWidth()
.clickable {
showBottomSheet = false
navController.navigate(
"${Screen.Config.route}/${Constants.MANUAL_TUNNEL_CONFIG_ID}")
showBottomSheet = false
navController.navigate(
"${Screen.Config.route}/${Constants.MANUAL_TUNNEL_CONFIG_ID}",
)
}
.padding(10.dp)) {
Icon(
Icons.Filled.Create,
contentDescription = stringResource(id = R.string.create_import),
modifier = Modifier.padding(10.dp))
Text(
stringResource(id = R.string.create_import),
modifier = Modifier.padding(10.dp))
}
}
.padding(10.dp),
) {
Icon(
Icons.Filled.Create,
contentDescription = stringResource(id = R.string.create_import),
modifier = Modifier.padding(10.dp),
)
Text(
stringResource(id = R.string.create_import),
modifier = Modifier.padding(10.dp),
)
}
}
}
LazyColumn(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Top,
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()),
userScrollEnabled = true,
reverseLayout = true,
flingBehavior = ScrollableDefaults.flingBehavior()) {
items(uiState.tunnels,
key = { tunnel -> tunnel.id }) { tunnel ->
val leadingIconColor =
(if (uiState.vpnState.name == tunnel.name &&
uiState.vpnState.status == Tunnel.State.UP) {
uiState.vpnState.statistics
?.mapPeerStats()
?.map { it.value?.handshakeStatus() }
.let { statuses ->
flingBehavior = ScrollableDefaults.flingBehavior(),
) {
items(
uiState.tunnels,
key = { tunnel -> tunnel.id },
) { tunnel ->
val leadingIconColor =
(if (
uiState.vpnState.name == tunnel.name &&
uiState.vpnState.status == Tunnel.State.UP
) {
uiState.vpnState.statistics
?.mapPeerStats()
?.map { it.value?.handshakeStatus() }
.let { statuses ->
when {
statuses?.all { it == HandshakeStatus.HEALTHY } == true -> mint
statuses?.any { it == HandshakeStatus.STALE } == true -> corn
statuses?.all { it == HandshakeStatus.NOT_STARTED } == true ->
Color.Gray
else -> {
Color.Gray
}
statuses?.all { it == HandshakeStatus.HEALTHY } == true -> mint
statuses?.any { it == HandshakeStatus.STALE } == true -> corn
statuses?.all { it == HandshakeStatus.NOT_STARTED } == true ->
Color.Gray
else -> {
Color.Gray
}
}
}
} else {
Color.Gray
})
val expanded = remember { mutableStateOf(false) }
RowListItem(
icon = {
if (uiState.settings.isTunnelConfigDefault(tunnel)) {
}
} else {
Color.Gray
})
val expanded = remember { mutableStateOf(false) }
RowListItem(
icon = {
if (uiState.settings.isTunnelConfigDefault(tunnel)) {
Icon(
Icons.Rounded.Star,
stringResource(R.string.status),
tint = leadingIconColor,
modifier = Modifier.padding(end = 10.dp).size(20.dp))
} else {
modifier = Modifier.padding(end = 10.dp).size(20.dp),
)
} else {
Icon(
Icons.Rounded.Circle,
stringResource(R.string.status),
tint = leadingIconColor,
modifier = Modifier.padding(end = 15.dp).size(15.dp))
}
},
text = tunnel.name,
onHold = {
if ((uiState.vpnState.status == Tunnel.State.UP) &&
(tunnel.name == uiState.vpnState.name)) {
modifier = Modifier.padding(end = 15.dp).size(15.dp),
)
}
},
text = tunnel.name,
onHold = {
if (
(uiState.vpnState.status == Tunnel.State.UP) &&
(tunnel.name == uiState.vpnState.name)
) {
showSnackbarMessage(Event.Message.TunnelOffAction.message)
return@RowListItem
}
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
selectedTunnel = tunnel
},
onClick = {
if (!WireGuardAutoTunnel.isRunningOnAndroidTv()) {
if (uiState.vpnState.status == Tunnel.State.UP &&
(uiState.vpnState.name == tunnel.name)) {
expanded.value = !expanded.value
}
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
selectedTunnel = tunnel
},
onClick = {
if (!WireGuardAutoTunnel.isRunningOnAndroidTv()) {
if (
uiState.vpnState.status == Tunnel.State.UP &&
(uiState.vpnState.name == tunnel.name)
) {
expanded.value = !expanded.value
}
} else {
} else {
selectedTunnel = tunnel
focusRequester.requestFocus()
}
},
statistics = uiState.vpnState.statistics,
expanded = expanded.value,
rowButton = {
if (tunnel.id == selectedTunnel?.id &&
!WireGuardAutoTunnel.isRunningOnAndroidTv()) {
}
},
statistics = uiState.vpnState.statistics,
expanded = expanded.value,
rowButton = {
if (
tunnel.id == selectedTunnel?.id &&
!WireGuardAutoTunnel.isRunningOnAndroidTv()
) {
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(
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(
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}")
}) {
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))
}
IconButton(
modifier = Modifier.focusable(),
onClick = { viewModel.onDelete(tunnel) }) {
}
IconButton(
modifier = Modifier.focusable(),
onClick = { showDeleteTunnelAlertDialog = true },
) {
Icon(Icons.Rounded.Delete, stringResource(id = R.string.delete))
}
}
}
} else {
} else {
val checked by remember {
derivedStateOf {
(uiState.vpnState.status == Tunnel.State.UP &&
tunnel.name == uiState.vpnState.name)
}
derivedStateOf {
(uiState.vpnState.status == Tunnel.State.UP &&
tunnel.name == uiState.vpnState.name)
}
}
if (!checked) expanded.value = false
@ -491,72 +594,94 @@ fun MainScreen(
modifier = Modifier.focusRequester(focusRequester),
checked = checked,
onCheckedChange = { checked ->
if (!checked) expanded.value = false
onTunnelToggle(checked, tunnel)
})
if (!checked) expanded.value = false
onTunnelToggle(checked, tunnel)
},
)
if (WireGuardAutoTunnel.isRunningOnAndroidTv()) {
Row {
if (!uiState.settings.isTunnelConfigDefault(tunnel)) {
IconButton(
onClick = {
if (uiState.settings.isAutoTunnelEnabled) {
showSnackbarMessage(
Event.Message.AutoTunnelOffAction.message)
} else {
selectedTunnel = tunnel
showPrimaryChangeAlertDialog = true
Row {
if (!uiState.settings.isTunnelConfigDefault(tunnel)) {
IconButton(
onClick = {
if (uiState.settings.isAutoTunnelEnabled) {
showSnackbarMessage(
Event.Message.AutoTunnelOffAction.message,
)
} else {
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(
Icons.Rounded.Star,
stringResource(id = R.string.set_primary))
}
Icons.Rounded.Delete,
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 {
TunnelSwitch()
TunnelSwitch()
}
}
})
}
}
},
)
}
}
}
}
}

View File

@ -5,8 +5,8 @@ import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnState
import com.zaneschepke.wireguardautotunnel.util.TunnelConfigs
data class MainUiState(
val settings : Settings = Settings(),
val tunnels : TunnelConfigs = emptyList(),
val settings: Settings = Settings(),
val tunnels: TunnelConfigs = emptyList(),
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.TunnelConfigRepository
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.WireGuardTunnel
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.Event
import com.zaneschepke.wireguardautotunnel.util.NumberUtils
@ -32,6 +28,7 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.io.InputStream
import java.util.zip.ZipInputStream
import javax.inject.Inject
@ -46,216 +43,225 @@ constructor(
private val vpnService: VpnService
) : ViewModel() {
val uiState =
combine(
settingsRepository.getSettingsFlow(),
tunnelConfigRepository.getTunnelConfigsFlow(),
vpnService.vpnState,
) { settings, tunnels, vpnState ->
validateWatcherServiceState(settings)
MainUiState(settings, tunnels, vpnState, false)
}
.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(Constants.SUBSCRIPTION_TIMEOUT),
MainUiState())
val uiState =
combine(
settingsRepository.getSettingsFlow(),
tunnelConfigRepository.getTunnelConfigsFlow(),
vpnService.vpnState,
) { settings, tunnels, vpnState ->
validateWatcherServiceState(settings)
MainUiState(settings, tunnels, vpnState, false)
}
.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(Constants.SUBSCRIPTION_TIMEOUT),
MainUiState(),
)
private fun validateWatcherServiceState(settings: Settings) = viewModelScope.launch(Dispatchers.IO) {
val watcherState =
ServiceManager.getServiceState(
application.applicationContext, WireGuardConnectivityWatcherService::class.java)
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)
private fun validateWatcherServiceState(settings: Settings) =
viewModelScope.launch(Dispatchers.IO) {
if (settings.isAutoTunnelEnabled) {
ServiceManager.startWatcherService(application.applicationContext)
}
}
}
fun onTunnelStop() = viewModelScope.launch(Dispatchers.IO) {
ServiceManager.stopVpnService(application.applicationContext)
}
private fun stopWatcherService() =
viewModelScope.launch(Dispatchers.IO) {
ServiceManager.stopWatcherService(application.applicationContext)
}
private fun validateConfigString(config: String) {
TunnelConfig.configFromQuick(config)
}
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()
}
}
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 {
validateConfigString(result)
val tunnelConfig =
TunnelConfig(name = NumberUtils.generateRandomTunnelName(), wgQuick = result)
addTunnel(tunnelConfig)
validateConfigString(result)
val tunnelConfig =
TunnelConfig(name = NumberUtils.generateRandomTunnelName(), wgQuick = result)
addTunnel(tunnelConfig)
Result.Success(Unit)
} catch (e: Exception) {
Result.Error(Event.Error.InvalidQrCode)
}
}
}
private suspend fun saveTunnelConfigFromStream(stream: InputStream, fileName: String) {
val bufferReader = stream.bufferedReader(charset = Charsets.UTF_8)
val config = Config.parse(bufferReader)
val tunnelName = getNameFromFileName(fileName)
addTunnel(TunnelConfig(name = tunnelName, wgQuick = config.toWgQuickString()))
withContext(Dispatchers.IO) { stream.close() }
}
private suspend fun saveTunnelConfigFromStream(stream: InputStream, fileName: String) {
val bufferReader = stream.bufferedReader(charset = Charsets.UTF_8)
val config = Config.parse(bufferReader)
val tunnelName = getNameFromFileName(fileName)
addTunnel(TunnelConfig(name = tunnelName, wgQuick = config.toWgQuickString()))
withContext(Dispatchers.IO) { stream.close() }
}
private fun getInputStreamFromUri(uri: Uri): InputStream? {
return application.applicationContext.contentResolver.openInputStream(uri)
}
private fun getInputStreamFromUri(uri: Uri): InputStream? {
return application.applicationContext.contentResolver.openInputStream(uri)
}
suspend fun onTunnelFileSelected(uri: Uri) : Result<Unit> {
suspend fun onTunnelFileSelected(uri: Uri): Result<Unit> {
try {
if(isValidUriContentScheme(uri)){
if (isValidUriContentScheme(uri)) {
val fileName = getFileName(application.applicationContext, uri)
when (getFileExtensionFromFileName(fileName)) {
Constants.CONF_FILE_EXTENSION -> saveTunnelFromConfUri(fileName, uri).let {
when(it) {
is Result.Error -> return Result.Error(Event.Error.FileReadFailed)
is Result.Success -> return it
Constants.CONF_FILE_EXTENSION ->
saveTunnelFromConfUri(fileName, uri).let {
when (it) {
is Result.Error -> return Result.Error(Event.Error.FileReadFailed)
is Result.Success -> return it
}
}
}
Constants.ZIP_FILE_EXTENSION -> saveTunnelsFromZipUri(uri)
else -> return Result.Error(Event.Error.InvalidFileExtension)
}
return Result.Success(Unit)
} else {
return Result.Error(Event.Error.InvalidFileExtension)
} else {
return Result.Error(Event.Error.InvalidFileExtension)
}
} catch (e: Exception) {
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 {
settingsRepository.save(uiState.value.settings.copy(isAutoTunnelPaused = false))
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 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)
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)
}
}
return null
}
private fun getDisplayNameColumnIndex(cursor: Cursor): Int? {
val columnIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
return if (columnIndex != -1) {
return columnIndex
} else {
null
private suspend fun addTunnel(tunnelConfig: TunnelConfig) {
saveTunnel(tunnelConfig)
WireGuardAutoTunnel.requestTileServiceStateUpdate()
}
}
private fun getDisplayNameByCursor(cursor: Cursor): String? {
return if (cursor.moveToFirst()) {
val index = getDisplayNameColumnIndex(cursor)
if (index != null) {
cursor.getString(index)
} else null
} else null
}
fun pauseAutoTunneling() =
viewModelScope.launch {
settingsRepository.save(uiState.value.settings.copy(isAutoTunnelPaused = true))
}
private fun isValidUriContentScheme(uri: Uri): Boolean {
return uri.scheme == Constants.URI_CONTENT_SCHEME
}
private fun getFileName(context: Context, uri: Uri): String {
fun resumeAutoTunneling() =
viewModelScope.launch {
settingsRepository.save(uiState.value.settings.copy(isAutoTunnelPaused = false))
}
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()
}
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) =
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()
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) =
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
import android.Manifest
import android.app.Activity
import android.content.Context.POWER_SERVICE
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.PowerManager
import android.provider.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.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
@ -83,7 +90,8 @@ import java.io.File
@OptIn(
ExperimentalPermissionsApi::class,
ExperimentalLayoutApi::class)
ExperimentalLayoutApi::class,
)
@Composable
fun SettingsScreen(
viewModel: SettingsViewModel = hiltViewModel(),
@ -91,92 +99,127 @@ fun SettingsScreen(
showSnackbarMessage: (String) -> Unit,
focusRequester: FocusRequester
) {
val scope = rememberCoroutineScope { Dispatchers.IO }
val context = LocalContext.current
val focusManager = LocalFocusManager.current
val scrollState = rememberScrollState()
val interactionSource = remember { MutableInteractionSource() }
val scope = rememberCoroutineScope { Dispatchers.IO }
val context = LocalContext.current
val focusManager = LocalFocusManager.current
val scrollState = rememberScrollState()
val interactionSource = remember { MutableInteractionSource() }
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val fineLocationState = rememberPermissionState(Manifest.permission.ACCESS_FINE_LOCATION)
var currentText by remember { mutableStateOf("") }
var isBackgroundLocationGranted by remember { mutableStateOf(true) }
val fineLocationState = rememberPermissionState(Manifest.permission.ACCESS_FINE_LOCATION)
var currentText by remember { mutableStateOf("") }
var isBackgroundLocationGranted by remember { mutableStateOf(true) }
var showLocationServicesAlertDialog by remember { mutableStateOf(false) }
var didExportFiles by remember { mutableStateOf(false) }
var showAuthPrompt by remember { mutableStateOf(false) }
var didExportFiles by remember { mutableStateOf(false) }
var showAuthPrompt by remember { mutableStateOf(false) }
val focusRequester2 = remember { FocusRequester() }
val screenPadding = 5.dp
val fillMaxWidth = .85f
val screenPadding = 5.dp
val fillMaxWidth = .85f
if (uiState.loading) {
LoadingScreen()
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)
if (uiState.loading) {
LoadingScreen()
return
}
}
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
val startForResult =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
result: ActivityResult ->
if (result.resultCode == Activity.RESULT_OK) {
val intent = result.data
// Handle the Intent
}
viewModel.setBatteryOptimizeDisableShown()
}
}
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
}
}
}
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)
}
}
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) {
checkFineLocationGranted()
}
fun isBatteryOptimizationsDisabled(): Boolean {
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) {
AlertDialog(
@ -185,8 +228,9 @@ fun SettingsScreen(
TextButton(
onClick = {
showLocationServicesAlertDialog = false
viewModel.toggleAutoTunnel()
}) {
handleAutoTunnelToggle()
},
) {
Text(text = stringResource(R.string.okay))
}
},
@ -196,253 +240,313 @@ fun SettingsScreen(
}
},
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) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top,
modifier = Modifier.fillMaxSize().verticalScroll(scrollState).padding(padding)) {
Icon(
Icons.Rounded.LocationOff,
contentDescription = stringResource(id = R.string.map),
modifier = Modifier.padding(30.dp).size(128.dp))
Text(
stringResource(R.string.prominent_background_location_title),
textAlign = TextAlign.Center,
modifier = Modifier.padding(30.dp),
fontSize = 20.sp)
Text(
stringResource(R.string.prominent_background_location_message),
textAlign = TextAlign.Center,
modifier = Modifier.padding(30.dp),
fontSize = 15.sp)
Row(
modifier =
if (WireGuardAutoTunnel.isRunningOnAndroidTv()) {
Modifier.fillMaxWidth().padding(10.dp)
} else {
Modifier.fillMaxWidth().padding(30.dp)
},
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceEvenly) {
if (!uiState.isLocationDisclosureShown) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top,
modifier = Modifier.fillMaxSize().verticalScroll(scrollState).padding(padding),
) {
Icon(
Icons.Rounded.LocationOff,
contentDescription = stringResource(id = R.string.map),
modifier = Modifier.padding(30.dp).size(128.dp),
)
Text(
stringResource(R.string.prominent_background_location_title),
textAlign = TextAlign.Center,
modifier = Modifier.padding(30.dp),
fontSize = 20.sp,
)
Text(
stringResource(R.string.prominent_background_location_message),
textAlign = TextAlign.Center,
modifier = Modifier.padding(30.dp),
fontSize = 15.sp,
)
Row(
modifier =
if (WireGuardAutoTunnel.isRunningOnAndroidTv()) {
Modifier.fillMaxWidth().padding(10.dp)
} else {
Modifier.fillMaxWidth().padding(30.dp)
},
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceEvenly,
) {
TextButton(onClick = { viewModel.setLocationDisclosureShown() }) {
Text(stringResource(id = R.string.no_thanks))
Text(stringResource(id = R.string.no_thanks))
}
TextButton(
modifier = Modifier.focusRequester(focusRequester),
onClick = {
openSettings()
viewModel.setLocationDisclosureShown()
}) {
Text(stringResource(id = R.string.turn_on))
}
}
openSettings()
viewModel.setLocationDisclosureShown()
},
) {
Text(stringResource(id = R.string.turn_on))
}
}
}
}
}
if(showAuthPrompt) {
AuthorizationPrompt(
onSuccess = {
showAuthPrompt = false
exportAllConfigs()
},
onError = { _ ->
showAuthPrompt = false
showSnackbarMessage(Event.Error.AuthenticationFailed.message)
},
onFailure = {
showAuthPrompt = false
showSnackbarMessage(Event.Error.AuthorizationFailed.message)
})
}
if (showAuthPrompt) {
AuthorizationPrompt(
onSuccess = {
showAuthPrompt = false
exportAllConfigs()
},
onError = { _ ->
showAuthPrompt = false
showSnackbarMessage(Event.Error.AuthenticationFailed.message)
},
onFailure = {
showAuthPrompt = false
showSnackbarMessage(Event.Error.AuthorizationFailed.message)
},
)
}
if (uiState.tunnels.isEmpty() && uiState.isLocationDisclosureShown) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxSize().padding(padding)) {
Text(
stringResource(R.string.one_tunnel_required),
textAlign = TextAlign.Center,
modifier = Modifier.padding(15.dp),
fontStyle = FontStyle.Italic)
if (uiState.tunnels.isEmpty() && uiState.isLocationDisclosureShown) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxSize().padding(padding),
) {
Text(
stringResource(R.string.one_tunnel_required),
textAlign = TextAlign.Center,
modifier = Modifier.padding(15.dp),
fontStyle = FontStyle.Italic,
)
}
}
if (uiState.isLocationDisclosureShown && uiState.tunnels.isNotEmpty()) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top,
modifier =
Modifier.fillMaxSize().verticalScroll(scrollState).clickable(
indication = null, interactionSource = interactionSource) {
focusManager.clearFocus()
}) {
Surface(
tonalElevation = 2.dp,
shadowElevation = 2.dp,
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surface,
modifier =
(if (WireGuardAutoTunnel.isRunningOnAndroidTv()) {
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()) {
}
if (uiState.isLocationDisclosureShown && uiState.tunnels.isNotEmpty()) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top,
modifier =
Modifier.fillMaxSize().verticalScroll(scrollState).clickable(
indication = null,
interactionSource = interactionSource,
) {
focusManager.clearFocus()
},
) {
Surface(
tonalElevation = 2.dp,
shadowElevation = 2.dp,
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surface,
modifier = Modifier.fillMaxWidth(fillMaxWidth).padding(vertical = 10.dp)) {
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Top,
modifier = Modifier.padding(15.dp)) {
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 -> {
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(
title = stringResource(id = R.string.kernel), padding = screenPadding)
title = stringResource(id = R.string.kernel),
padding = screenPadding,
)
ConfigurationToggle(
stringResource(R.string.use_kernel),
enabled =
@ -451,58 +555,70 @@ fun SettingsScreen(
(uiState.vpnState.status == Tunnel.State.UP)),
checked = uiState.settings.isKernelEnabled,
padding = screenPadding,
onCheckChanged = { viewModel.onToggleKernelMode().let {
when(it) {
is Result.Error -> showSnackbarMessage(it.error.message)
is Result.Success -> {}
onCheckChanged = {
viewModel.onToggleKernelMode().let {
when (it) {
is Result.Error -> showSnackbarMessage(it.error.message)
is Result.Success -> {}
}
}
} })
}
},
)
}
}
}
if (!WireGuardAutoTunnel.isRunningOnAndroidTv()) {
Surface(
tonalElevation = 2.dp,
shadowElevation = 2.dp,
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surface,
modifier =
Modifier.fillMaxWidth(fillMaxWidth)
.padding(vertical = 10.dp)
.padding(bottom = 140.dp)) {
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Top,
modifier = Modifier.padding(15.dp)) {
}
if (!WireGuardAutoTunnel.isRunningOnAndroidTv()) {
Surface(
tonalElevation = 2.dp,
shadowElevation = 2.dp,
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surface,
modifier =
Modifier.fillMaxWidth(fillMaxWidth)
.padding(vertical = 10.dp)
.padding(bottom = 140.dp),
) {
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Top,
modifier = Modifier.padding(15.dp),
) {
SectionTitle(
title = stringResource(id = R.string.other), padding = screenPadding)
title = stringResource(id = R.string.other),
padding = screenPadding,
)
ConfigurationToggle(
stringResource(R.string.always_on_vpn_support),
enabled = !uiState.settings.isAutoTunnelEnabled,
checked = uiState.settings.isAlwaysOnVpnEnabled,
padding = screenPadding,
onCheckChanged = { viewModel.onToggleAlwaysOnVPN() })
onCheckChanged = { viewModel.onToggleAlwaysOnVPN() },
)
ConfigurationToggle(
stringResource(R.string.enabled_app_shortcuts),
enabled = true,
checked = uiState.settings.isShortcutsEnabled,
padding = screenPadding,
onCheckChanged = { viewModel.onToggleShortcutsEnabled() })
onCheckChanged = { viewModel.onToggleShortcutsEnabled() },
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxSize().padding(top = 5.dp),
horizontalArrangement = Arrangement.Center) {
TextButton(
enabled = !didExportFiles, onClick = { showAuthPrompt = true }) {
Text(stringResource(R.string.export_configs))
}
horizontalArrangement = Arrangement.Center,
) {
TextButton(
enabled = !didExportFiles,
onClick = { showAuthPrompt = true },
) {
Text(stringResource(R.string.export_configs))
}
}
}
}
}
}
if (WireGuardAutoTunnel.isRunningOnAndroidTv()) {
Spacer(modifier = Modifier.weight(.17f))
}
}
if (WireGuardAutoTunnel.isRunningOnAndroidTv()) {
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
data class SettingsUiState(
val settings : Settings = Settings(),
val tunnels : List<TunnelConfig> = emptyList(),
val settings: Settings = Settings(),
val tunnels: List<TunnelConfig> = emptyList(),
val vpnState: VpnState = VpnState(),
val isLocationDisclosureShown : Boolean = true,
val loading : Boolean = true
val isLocationDisclosureShown: Boolean = true,
val isBatteryOptimizeDisableShown: Boolean = false,
val loading: Boolean = true
)

View File

@ -36,18 +36,29 @@ constructor(
private val vpnService: VpnService
) : ViewModel() {
val uiState = combine(
settingsRepository.getSettingsFlow(),
tunnelConfigRepository.getTunnelConfigsFlow(),
vpnService.vpnState,
dataStoreManager.locationDisclosureFlow,
){ settings, tunnels, tunnelState, locationDisclosure ->
SettingsUiState(settings, tunnels, tunnelState, locationDisclosure
?: false, false)
}.stateIn(viewModelScope,
SharingStarted.WhileSubscribed(Constants.SUBSCRIPTION_TIMEOUT), SettingsUiState())
val uiState =
combine(
settingsRepository.getSettingsFlow(),
tunnelConfigRepository.getTunnelConfigsFlow(),
vpnService.vpnState,
dataStoreManager.preferencesFlow,
) { settings, tunnels, tunnelState, preferences ->
SettingsUiState(
settings,
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()
return if (!uiState.value.settings.trustedNetworkSSIDs.contains(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)
}
}
fun setBatteryOptimizeDisableShown() =
viewModelScope.launch {
dataStoreManager.saveToDataStore(DataStoreManager.BATTERY_OPTIMIZE_DISABLE_SHOWN, true)
}
fun onToggleTunnelOnMobileData() {
saveSettings(
uiState.value.settings.copy(
isTunnelOnMobileDataEnabled = !uiState.value.settings.isTunnelOnMobileDataEnabled
)
isTunnelOnMobileDataEnabled = !uiState.value.settings.isTunnelOnMobileDataEnabled,
),
)
}
fun onDeleteTrustedSSID(ssid: String) {
saveSettings(uiState.value.settings.copy(
trustedNetworkSSIDs = (uiState.value.settings.trustedNetworkSSIDs - ssid).toMutableList()
))
}
private suspend fun getDefaultTunnelOrFirst() : String {
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(
uiState.value.settings.copy(
isAutoTunnelEnabled = !isAutoTunnelEnabled,
isAutoTunnelPaused = isAutoTunnelPaused,
defaultTunnel = getDefaultTunnelOrFirst()
)
trustedNetworkSSIDs =
(uiState.value.settings.trustedNetworkSSIDs - ssid).toMutableList(),
),
)
}
private suspend fun getDefaultTunnelOrFirst(): String {
return uiState.value.settings.defaultTunnel
?: tunnelConfigRepository.getAll().first().toString()
}
fun onToggleAlwaysOnVPN() = viewModelScope.launch {
val updatedSettings = uiState.value.settings.copy(
isAlwaysOnVpnEnabled = !uiState.value.settings.isAlwaysOnVpnEnabled,
defaultTunnel = getDefaultTunnelOrFirst()
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(
uiState.value.settings.copy(
isAutoTunnelEnabled = !isAutoTunnelEnabled,
isAutoTunnelPaused = isAutoTunnelPaused,
defaultTunnel = getDefaultTunnelOrFirst(),
),
)
saveSettings(updatedSettings)
}
}
private fun saveSettings(settings: Settings) = viewModelScope.launch {
settingsRepository.save(settings)
}
fun onToggleAlwaysOnVPN() =
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() {
saveSettings(uiState.value.settings.copy(
isTunnelOnEthernetEnabled = !uiState.value.settings.isTunnelOnEthernetEnabled
))
saveSettings(
uiState.value.settings.copy(
isTunnelOnEthernetEnabled = !uiState.value.settings.isTunnelOnEthernetEnabled,
),
)
}
fun isLocationEnabled(context: Context): Boolean {
@ -126,36 +150,36 @@ constructor(
fun onToggleShortcutsEnabled() {
saveSettings(
uiState.value.settings.copy(
isShortcutsEnabled = !uiState.value.settings.isShortcutsEnabled
)
isShortcutsEnabled = !uiState.value.settings.isShortcutsEnabled,
),
)
}
fun onToggleBatterySaver() {
saveSettings(
uiState.value.settings.copy(
isBatterySaverEnabled = !uiState.value.settings.isBatterySaverEnabled
)
isBatterySaverEnabled = !uiState.value.settings.isBatterySaverEnabled,
),
)
}
private fun saveKernelMode(on: Boolean) {
saveSettings(
uiState.value.settings.copy(
isKernelEnabled = on
)
isKernelEnabled = on,
),
)
}
fun onToggleTunnelOnWifi() {
saveSettings(
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) {
try {
rootShell.start()

View File

@ -62,39 +62,43 @@ fun SupportScreen(
showSnackbarMessage: (String) -> Unit,
focusRequester: FocusRequester
) {
val context = LocalContext.current
val fillMaxWidth = .85f
val context = LocalContext.current
val fillMaxWidth = .85f
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
fun openWebPage(url: String) {
try {
val webpage: Uri = Uri.parse(url)
val intent = Intent(Intent.ACTION_VIEW, webpage)
context.startActivity(intent)
} catch (e : Exception) {
showSnackbarMessage(Event.Error.Exception(e).message)
}
}
fun openWebPage(url: String) {
try {
val webpage: Uri = Uri.parse(url)
val intent = Intent(Intent.ACTION_VIEW, webpage)
context.startActivity(intent)
} catch (e: Exception) {
showSnackbarMessage(Event.Error.Exception(e).message)
}
}
fun launchEmail() {
try {
val intent =
Intent(Intent.ACTION_SEND).apply {
type = Constants.EMAIL_MIME_TYPE
putExtra(Intent.EXTRA_EMAIL, context.getString(R.string.my_email))
putExtra(Intent.EXTRA_SUBJECT, context.getString(R.string.email_subject))
}
startActivity(context, createChooser(intent, context.getString(R.string.email_chooser)), null)
} catch (e : Exception) {
showSnackbarMessage(Event.Error.Exception(e).message)
}
}
fun launchEmail() {
try {
val intent =
Intent(Intent.ACTION_SENDTO).apply {
type = Constants.EMAIL_MIME_TYPE
putExtra(Intent.EXTRA_EMAIL, arrayOf(context.getString(R.string.my_email)))
putExtra(Intent.EXTRA_SUBJECT, context.getString(R.string.email_subject))
}
startActivity(
context,
createChooser(intent, context.getString(R.string.email_chooser)),
null,
)
} catch (e: Exception) {
showSnackbarMessage(Event.Error.Exception(e).message)
}
}
if (uiState.loading) {
LoadingScreen()
return
}
if (uiState.loading) {
LoadingScreen()
return
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
@ -103,126 +107,147 @@ fun SupportScreen(
Modifier.fillMaxSize()
.verticalScroll(rememberScrollState())
.focusable()
.padding(padding)) {
Surface(
tonalElevation = 2.dp,
shadowElevation = 2.dp,
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surface,
modifier =
(if (WireGuardAutoTunnel.isRunningOnAndroidTv()) {
.padding(padding),
) {
Surface(
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 {
} else {
Modifier.fillMaxWidth(fillMaxWidth).padding(top = 20.dp)
})
.padding(bottom = 25.dp)) {
Column(modifier = Modifier.padding(20.dp)) {
Text(
stringResource(R.string.thank_you),
textAlign = TextAlign.Start,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 20.dp),
fontSize = 16.sp)
Text(
stringResource(id = R.string.support_help_text),
textAlign = TextAlign.Start,
fontSize = 16.sp,
modifier = Modifier.padding(bottom = 20.dp))
TextButton(
onClick = { openWebPage(context.resources.getString(R.string.docs_url)) },
modifier = Modifier.padding(vertical = 5.dp).focusRequester(focusRequester)) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()) {
Row {
Icon(Icons.Rounded.Book, stringResource(id = R.string.docs))
Text(
stringResource(id = R.string.docs_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.discord_url)) },
modifier = Modifier.padding(vertical = 5.dp)) {
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))
}
}
})
.padding(bottom = 25.dp),
) {
Column(modifier = Modifier.padding(20.dp)) {
Text(
stringResource(R.string.thank_you),
textAlign = TextAlign.Start,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 20.dp),
fontSize = 16.sp,
)
Text(
stringResource(id = R.string.support_help_text),
textAlign = TextAlign.Start,
fontSize = 16.sp,
modifier = Modifier.padding(bottom = 20.dp),
)
TextButton(
onClick = { openWebPage(context.resources.getString(R.string.docs_url)) },
modifier = Modifier.padding(vertical = 5.dp).focusRequester(focusRequester),
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Row {
Icon(Icons.Rounded.Book, stringResource(id = R.string.docs))
Text(
stringResource(id = R.string.docs_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" }")
}
Divider(color = MaterialTheme.colorScheme.onBackground, thickness = 0.5.dp)
TextButton(
onClick = { openWebPage(context.resources.getString(R.string.discord_url)) },
modifier = Modifier.padding(vertical = 5.dp),
) {
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))
}
}
}
}
}
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
data class SupportUiState(
val settings : Settings = Settings(),
val loading : Boolean = true
)
data class SupportUiState(val settings: Settings = Settings(), val loading: Boolean = true)

View File

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

View File

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

View File

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

View File

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

View File

@ -12,79 +12,100 @@ sealed class Event {
override val message: String
get() = WireGuardAutoTunnel.instance.getString(R.string.error_none)
}
data object SsidConflict : Error() {
override val message: String
get() = WireGuardAutoTunnel.instance.getString(R.string.error_ssid_exists)
}
data object RootDenied : Error() {
override val message: String
get() = WireGuardAutoTunnel.instance.getString(R.string.error_root_denied)
}
data class General(val customMessage: String) : Error() {
override val message: String
get() = customMessage
}
data class Exception(val exception : kotlin.Exception) : Error() {
data class Exception(val exception: kotlin.Exception) : Error() {
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() {
override val message: String
get() = WireGuardAutoTunnel.instance.getString(R.string.error_invalid_code)
}
data object InvalidFileExtension : Error() {
override val message: String
get() = WireGuardAutoTunnel.instance.getString(R.string.error_file_extension)
}
data object FileReadFailed : Error() {
override val message: String
get() = WireGuardAutoTunnel.instance.getString(R.string.error_file_extension)
}
data object AuthenticationFailed : Error() {
override val message: String
get() = WireGuardAutoTunnel.instance.getString(R.string.error_authentication_failed)
}
data object AuthorizationFailed : Error() {
override val message: String
get() = WireGuardAutoTunnel.instance.getString(R.string.error_authorization_failed)
}
data object BackgroundLocationRequired : Error() {
override val message: String
get() = WireGuardAutoTunnel.instance.getString(R.string.background_location_required)
get() =
WireGuardAutoTunnel.instance.getString(R.string.background_location_required)
}
data object LocationServicesRequired : Error() {
override val message: String
get() = WireGuardAutoTunnel.instance.getString(R.string.location_services_required)
}
data object PreciseLocationRequired : Error() {
override val message: String
get() = WireGuardAutoTunnel.instance.getString(R.string.precise_location_required)
}
data object FileExplorerRequired : Error() {
override val message: String
get() = WireGuardAutoTunnel.instance.getString(R.string.error_no_file_explorer)
}
}
sealed class Message : Event() {
data object ConfigSaved: Message() {
data object ConfigSaved : Message() {
override val message: String
get() = WireGuardAutoTunnel.instance.getString(R.string.config_changes_saved)
}
data object ConfigsExported: Message() {
data object ConfigsExported : Message() {
override val message: String
get() = WireGuardAutoTunnel.instance.getString(R.string.exported_configs_message)
}
data object TunnelOffAction: Message() {
data object TunnelOffAction : Message() {
override val message: String
get() = WireGuardAutoTunnel.instance.getString(R.string.turn_off_tunnel)
}
data object TunnelOnAction: Message() {
data object TunnelOnAction : Message() {
override val message: String
get() = WireGuardAutoTunnel.instance.getString(R.string.turn_on_tunnel)
}
data object AutoTunnelOffAction: Message() {
data object AutoTunnelOffAction : Message() {
override val message: String
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>.removeAt(index: Int): List<T> = toMutableList().apply { this.removeAt(index) }
typealias TunnelConfigs = List<TunnelConfig>
typealias Packages = List<PackageInfo>
fun Statistics.mapPeerStats(): Map<Key, PeerStats?> {
return this.peers().associateWith { key ->
(this.peer(key))
}
return this.peers().associateWith { key -> (this.peer(key)) }
}
fun PeerStats.latestHandshakeSeconds() : Long? {
fun PeerStats.latestHandshakeSeconds(): Long? {
return NumberUtils.getSecondsBetweenTimestampAndNow(this.latestHandshakeEpochMillis)
}
fun PeerStats.handshakeStatus() : HandshakeStatus {
//TODO add never connected status after duration
fun PeerStats.handshakeStatus(): HandshakeStatus {
// TODO add never connected status after duration
return this.latestHandshakeSeconds().let {
when {
it == null -> HandshakeStatus.NOT_STARTED
@ -65,4 +65,3 @@ fun PeerStats.handshakeStatus() : HandshakeStatus {
}
}
}

View File

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

View File

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

View File

@ -3,8 +3,8 @@
android:height="800dp"
android:viewportWidth="256"
android:viewportHeight="256">
<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:fillType="nonZero"/>
<path
android:fillColor="#5865F2"
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>

View File

@ -3,10 +3,10 @@
android:height="800dp"
android:viewportWidth="20"
android:viewportHeight="20">
<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:strokeWidth="1"
android:fillColor="#000000"
android:fillType="evenOdd"
android:strokeColor="#00000000"/>
<path
android:fillColor="#000000"
android:fillType="evenOdd"
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:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>

View File

@ -1,5 +1,10 @@
<vector android:height="24dp" android:tint="#000000"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<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 xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
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>

View File

@ -1,5 +1,10 @@
<vector android:height="24dp" android:tint="#000000"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<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 xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
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>

View File

@ -1,5 +1,10 @@
<vector android:height="24dp" android:tint="#000000"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<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 xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
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>

View File

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

View File

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

View File

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

View File

@ -164,4 +164,7 @@
<string name="location_service_missing">Location Services Not Detected</string>
<string name="location_services_missing_message">The app is not detecting any location services enabled on your device. Depending on the device, this could cause the untrusted wifi feature to fail to read the wifi name. Would you like to continue anyways?</string>
<string name="auto_tunnel_title">Auto-tunnel Service</string>
<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>

View File

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

View File

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

View File

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

View File

@ -1,11 +1,13 @@
object Constants {
const val VERSION_NAME = "3.3.2"
const val VERSION_NAME = "3.3.3"
const val JVM_TARGET = "17"
const val VERSION_CODE = 33200
const val VERSION_CODE = 33300
const val TARGET_SDK = 34
const val MIN_SDK = 26
const val APP_ID = "com.zaneschepke.wireguardautotunnel"
const val APP_NAME = "wgtunnel"
const val COMPOSE_COMPILER_EXTENSION_VERSION = "1.5.7"
const val STORE_PASS_VAR = "SIGNING_STORE_PASSWORD"
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"
lane :beta do
gradle(task: "clean bundleGeneralRelease")
upload_to_play_store(track: 'beta')
upload_to_play_store(track: 'beta', skip_upload_apk: true)
end
desc "Deploy a new version to the Google Play"
lane :production do
gradle(task: "clean bundleGeneralRelease")
upload_to_play_store
upload_to_play_store(skip_upload_apk: true)
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"
firebase-crashlytics-gradle = "2.9.9"
google-services = "4.4.0"
hiltAndroid = "2.49"
hiltAndroid = "2.50"
hiltNavigationCompose = "1.1.0"
junit = "4.13.2"
kotlinx-serialization-json = "1.6.2"
@ -22,15 +22,14 @@ navigationCompose = "2.7.6"
roomVersion = "2.6.1"
timber = "5.0.1"
tunnel = "1.0.20230706"
androidGradlePlugin = "8.2.0"
kotlin="1.9.10"
ksp="1.9.10-1.0.13"
composeBom="2023.10.01"
firebaseBom= "32.7.0"
compose="1.5.4"
crashlytics= "18.6.0"
analytics="21.5.0"
composeCompiler="1.5.3"
androidGradlePlugin = "8.2.1"
kotlin = "1.9.21"
ksp = "1.9.21-1.0.16"
composeBom = "2023.10.01"
firebaseBom = "32.7.0"
compose = "1.5.4"
crashlytics = "18.6.0"
analytics = "21.5.0"
zxingAndroidEmbedded = "4.3.0"
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" }
#compose
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-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-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 = { module="androidx.compose.ui:ui", version.ref="compose" }
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-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-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 = { module = "androidx.compose.ui:ui", version.ref = "compose" }
#hilt
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" }
timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" }
tunnel = { module = "com.wireguard.android:tunnel", version.ref = "tunnel" }
#firebase
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" }
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" }
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" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
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()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
@ -14,4 +15,5 @@ dependencyResolutionManagement {
}
rootProject.name = "WG Tunnel"
include(":app")