feat: add basic kernel support

Added basic kernel support to allow users to switch between userspace and kernel wireguard

Improved location disclosure flow to only show once once per app install

Fix airplane mode bug

Improve database migration testing

Fix auto-tunneling permission bug.

Lint

Closes #67
Closes #43
This commit is contained in:
Zane Schepke 2023-12-20 23:13:48 -05:00
parent 515e91d191
commit ffa7a207fb
86 changed files with 2930 additions and 1690 deletions

View File

@ -70,7 +70,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with: with:
# fix hardcode changelog file name # fix hardcode changelog file name
body_path: ${{ github.workspace }}/fastlane/metadata/android/en-US/changelogs/32300.txt body_path: ${{ github.workspace }}/fastlane/metadata/android/en-US/changelogs/32400.txt
tag_name: ${{ github.ref_name }} tag_name: ${{ github.ref_name }}
name: Release ${{ github.ref_name }} name: Release ${{ github.ref_name }}
draft: false draft: false

View File

@ -23,6 +23,10 @@ android {
arg("room.schemaLocation", "$projectDir/schemas") arg("room.schemaLocation", "$projectDir/schemas")
} }
sourceSets {
getByName("debug").assets.srcDirs(files("$projectDir/schemas")) // Room
}
resourceConfigurations.addAll(listOf("en")) resourceConfigurations.addAll(listOf("en"))
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@ -33,7 +37,8 @@ android {
signingConfigs { signingConfigs {
create(Constants.RELEASE) { create(Constants.RELEASE) {
val properties = Properties().apply { val properties =
Properties().apply {
// created local file for signing details // created local file for signing details
try { try {
load(file("signing.properties").reader()) load(file("signing.properties").reader())
@ -43,23 +48,40 @@ android {
} }
// try to get secrets from env first for pipeline build, then properties file for local build // try to get secrets from env first for pipeline build, then properties file for local build
storeFile = file(System.getenv().getOrDefault(Constants.KEY_STORE_PATH_VAR, properties.getProperty(Constants.KEY_STORE_PATH_VAR))) storeFile = file(
storePassword = System.getenv().getOrDefault(Constants.STORE_PASS_VAR, properties.getProperty(Constants.STORE_PASS_VAR)) System.getenv().getOrDefault(
keyAlias = System.getenv().getOrDefault(Constants.KEY_ALIAS_VAR, properties.getProperty(Constants.KEY_ALIAS_VAR)) Constants.KEY_STORE_PATH_VAR,
keyPassword = System.getenv().getOrDefault(Constants.KEY_PASS_VAR, properties.getProperty(Constants.KEY_PASS_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)
)
} }
} }
buildTypes { buildTypes {
// don't strip // don't strip
packaging.jniLibs.keepDebugSymbols.addAll(listOf("libwg-go.so", "libwg-quick.so", "libwg.so")) packaging.jniLibs.keepDebugSymbols.addAll(
listOf("libwg-go.so", "libwg-quick.so", "libwg.so")
)
applicationVariants.all { applicationVariants.all {
val variant = this val variant = this
variant.outputs variant.outputs
.map { it as com.android.build.gradle.internal.api.BaseVariantOutputImpl } .map { it as com.android.build.gradle.internal.api.BaseVariantOutputImpl }
.forEach { output -> .forEach { output ->
val outputFileName = "${Constants.APP_NAME}-${variant.flavorName}-${variant.buildType.name}-${variant.versionName}.apk" val outputFileName =
"${Constants.APP_NAME}-${variant.flavorName}-${variant.buildType.name}-${variant.versionName}.apk"
output.outputFileName = outputFileName output.outputFileName = outputFileName
} }
} }
@ -85,8 +107,7 @@ android {
} }
create("general") { create("general") {
dimension = Constants.TYPE dimension = Constants.TYPE
if (BuildHelper.isReleaseBuild(gradle) && BuildHelper.isGeneralFlavor(gradle)) if (BuildHelper.isReleaseBuild(gradle) && BuildHelper.isGeneralFlavor(gradle)) {
{
apply(plugin = "com.google.gms.google-services") apply(plugin = "com.google.gms.google-services")
apply(plugin = "com.google.firebase.crashlytics") apply(plugin = "com.google.firebase.crashlytics")
} }
@ -103,7 +124,6 @@ android {
buildFeatures { buildFeatures {
compose = true compose = true
buildConfig = true buildConfig = true
} }
composeOptions { composeOptions {
kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get() kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get()
@ -131,10 +151,12 @@ dependencies {
// test // test
testImplementation(libs.junit) testImplementation(libs.junit)
testImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test) androidTestImplementation(libs.androidx.compose.ui.test)
androidTestImplementation(libs.androidx.room.testing)
debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.manifest) debugImplementation(libs.androidx.compose.manifest)
@ -159,17 +181,17 @@ dependencies {
implementation(libs.accompanist.flowlayout) implementation(libs.accompanist.flowlayout)
implementation(libs.accompanist.drawablepainter) implementation(libs.accompanist.drawablepainter)
//room // storage
implementation(libs.androidx.room.runtime) implementation(libs.androidx.room.runtime)
ksp(libs.androidx.room.compiler) ksp(libs.androidx.room.compiler)
implementation(libs.androidx.room.ktx) implementation(libs.androidx.room.ktx)
implementation(libs.androidx.datastore.preferences)
// lifecycle // lifecycle
implementation(libs.lifecycle.runtime.compose) implementation(libs.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.process) implementation(libs.androidx.lifecycle.process)
// icons // icons
implementation(libs.material.icons.extended) implementation(libs.material.icons.extended)
// serialization // serialization

View File

@ -1 +1,5 @@
-dontwarn com.google.errorprone.annotations.** -dontwarn com.google.errorprone.annotations.**
-keepclassmembers class * extends androidx.datastore.preferences.protobuf.GeneratedMessageLite {
<fields>;
}

View File

@ -19,3 +19,6 @@
# If you keep the line number information, uncomment this to # If you keep the line number information, uncomment this to
# hide the original source file name. # hide the original source file name.
#-renamesourcefileattribute SourceFile #-renamesourcefileattribute SourceFile
-keepclassmembers class * extends androidx.datastore.preferences.protobuf.GeneratedMessageLite {
<fields>;
}

View File

@ -0,0 +1,154 @@
{
"formatVersion": 1,
"database": {
"version": 4,
"identityHash": "aee55639422df8dadfe74c3bad204477",
"entities": [
{
"tableName": "Settings",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `is_tunnel_enabled` INTEGER NOT NULL, `is_tunnel_on_mobile_data_enabled` INTEGER NOT NULL, `trusted_network_ssids` TEXT NOT NULL, `default_tunnel` TEXT, `is_always_on_vpn_enabled` INTEGER NOT NULL, `is_tunnel_on_ethernet_enabled` INTEGER NOT NULL, `is_shortcuts_enabled` INTEGER NOT NULL DEFAULT false, `is_battery_saver_enabled` INTEGER NOT NULL DEFAULT false, `is_tunnel_on_wifi_enabled` INTEGER NOT NULL DEFAULT false, `is_kernel_enabled` INTEGER NOT NULL DEFAULT false, `is_restore_on_boot_enabled` INTEGER NOT NULL DEFAULT false, `is_multi_tunnel_enabled` INTEGER NOT NULL DEFAULT false)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isAutoTunnelEnabled",
"columnName": "is_tunnel_enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isTunnelOnMobileDataEnabled",
"columnName": "is_tunnel_on_mobile_data_enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "trustedNetworkSSIDs",
"columnName": "trusted_network_ssids",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "defaultTunnel",
"columnName": "default_tunnel",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "isAlwaysOnVpnEnabled",
"columnName": "is_always_on_vpn_enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isTunnelOnEthernetEnabled",
"columnName": "is_tunnel_on_ethernet_enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isShortcutsEnabled",
"columnName": "is_shortcuts_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isBatterySaverEnabled",
"columnName": "is_battery_saver_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isTunnelOnWifiEnabled",
"columnName": "is_tunnel_on_wifi_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isKernelEnabled",
"columnName": "is_kernel_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isRestoreOnBootEnabled",
"columnName": "is_restore_on_boot_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isMultiTunnelEnabled",
"columnName": "is_multi_tunnel_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "TunnelConfig",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `wg_quick` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "wgQuick",
"columnName": "wg_quick",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_TunnelConfig_name",
"unique": true,
"columnNames": [
"name"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_TunnelConfig_name` ON `${TABLE_NAME}` (`name`)"
}
],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'aee55639422df8dadfe74c3bad204477')"
]
}
}

View File

@ -0,0 +1,63 @@
package com.zaneschepke.wireguardautotunnel
import androidx.room.testing.MigrationTestHelper
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.zaneschepke.wireguardautotunnel.repository.AppDatabase
import java.io.IOException
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class MigrationTest {
private val dbName = "migration-test"
@get:Rule
val helper: MigrationTestHelper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
AppDatabase::class.java
)
@Test
@Throws(IOException::class)
fun migrate2To3() {
helper.createDatabase(dbName, 3).apply {
// Database has schema version 1. Insert some data using SQL queries.
// You can't use DAO classes because they expect the latest schema.
execSQL(
"INSERT INTO Settings (is_tunnel_enabled, " +
"is_tunnel_on_mobile_data_enabled," +
"trusted_network_ssids," +
"default_tunnel, " +
"is_always_on_vpn_enabled," +
"is_tunnel_on_ethernet_enabled," +
"is_shortcuts_enabled," +
"is_battery_saver_enabled," +
"is_tunnel_on_wifi_enabled)" +
" VALUES (" +
"false," +
"false," +
"'[trustedSSID1,trustedSSID2]'," +
"'defaultTunnel'," +
"false," +
"false," +
"false," +
"false," +
"false)"
)
execSQL(
"INSERT INTO TunnelConfig (name, wg_quick)" +
" VALUES ('hello', 'hello')"
)
// Prepare for the next version.
close()
}
// Re-open the database with version 2 and provide
// MIGRATION_1_2 as the migration process.
helper.runMigrationsAndValidate(dbName, 4, true)
// MigrationTestHelper automatically verifies the schema changes,
// but you need to validate that the data was migrated properly.
}
}

View File

@ -17,6 +17,10 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE"/> <uses-permission android:name="android.permission.CHANGE_NETWORK_STATE"/>
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION"/> <uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION"/>
<!--foreground service exempt android 14-->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SYSTEM_EXEMPTED"/>
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION"/> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_REMOTE_MESSAGING"/> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_REMOTE_MESSAGING"/>
<!--foreground service permissions--> <!--foreground service permissions-->
@ -102,7 +106,7 @@
android:permission="android.permission.BIND_VPN_SERVICE" android:permission="android.permission.BIND_VPN_SERVICE"
android:enabled="true" android:enabled="true"
android:persistent="true" android:persistent="true"
android:foregroundServiceType="remoteMessaging" android:foregroundServiceType="systemExempted"
android:exported="false"> android:exported="false">
<intent-filter> <intent-filter>
<action android:name="android.net.VpnService"/> <action android:name="android.net.VpnService"/>
@ -115,8 +119,7 @@
android:enabled="true" android:enabled="true"
android:stopWithTask="false" android:stopWithTask="false"
android:persistent="true" android:persistent="true"
android:foregroundServiceType="location" android:foregroundServiceType="systemExempted"
android:permission=""
android:exported="false"> android:exported="false">
</service> </service>
<receiver android:enabled="true" android:name=".receiver.BootReceiver" <receiver android:enabled="true" android:name=".receiver.BootReceiver"

View File

@ -2,7 +2,7 @@ package com.zaneschepke.wireguardautotunnel
object Constants { object Constants {
const val MANUAL_TUNNEL_CONFIG_ID = "0" const val MANUAL_TUNNEL_CONFIG_ID = "0"
const val WATCHER_SERVICE_WAKE_LOCK_TIMEOUT = 10*60*1000L /*10 minute*/ const val WATCHER_SERVICE_WAKE_LOCK_TIMEOUT = 10 * 60 * 1000L // 10 minutes
const val VPN_CONNECTIVITY_CHECK_INTERVAL = 3000L const val VPN_CONNECTIVITY_CHECK_INTERVAL = 3000L
const val VPN_STATISTIC_CHECK_INTERVAL = 1000L const val VPN_STATISTIC_CHECK_INTERVAL = 1000L
const val TOGGLE_TUNNEL_DELAY = 500L const val TOGGLE_TUNNEL_DELAY = 500L
@ -17,4 +17,5 @@ object Constants {
const val GOOGLE_TV_EXPLORER_STUB = "com.google.android.tv.frameworkpackagestubs" const val GOOGLE_TV_EXPLORER_STUB = "com.google.android.tv.frameworkpackagestubs"
const val ANDROID_TV_EXPLORER_STUB = "com.android.tv.frameworkpackagestubs" const val ANDROID_TV_EXPLORER_STUB = "com.android.tv.frameworkpackagestubs"
const val EMAIL_MIME_TYPE = "message/rfc822" const val EMAIL_MIME_TYPE = "message/rfc822"
const val SYSTEM_EXEMPT_SERVICE_TYPE_ID = 1024
} }

View File

@ -1,14 +1,14 @@
package com.zaneschepke.wireguardautotunnel package com.zaneschepke.wireguardautotunnel
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import java.math.BigDecimal import java.math.BigDecimal
import java.text.DecimalFormat import java.text.DecimalFormat
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext import kotlin.coroutines.EmptyCoroutineContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
fun BroadcastReceiver.goAsync( fun BroadcastReceiver.goAsync(
context: CoroutineContext = EmptyCoroutineContext, context: CoroutineContext = EmptyCoroutineContext,

View File

@ -6,24 +6,36 @@ import android.content.pm.PackageManager
import androidx.lifecycle.ProcessLifecycleOwner import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa
import com.zaneschepke.wireguardautotunnel.repository.datastore.DataStoreManager
import com.zaneschepke.wireguardautotunnel.repository.model.Settings import com.zaneschepke.wireguardautotunnel.repository.model.Settings
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
import java.io.IOException
import javax.inject.Inject
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject
@HiltAndroidApp @HiltAndroidApp
class WireGuardAutoTunnel : Application() { class WireGuardAutoTunnel : Application() {
@Inject @Inject
lateinit var settingsRepo: SettingsDoa lateinit var settingsRepo: SettingsDoa
@Inject
lateinit var dataStoreManager: DataStoreManager
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
if(BuildConfig.DEBUG) { if (BuildConfig.DEBUG) Timber.plant(Timber.DebugTree())
Timber.plant(Timber.DebugTree())
}
initSettings() initSettings()
with(ProcessLifecycleOwner.get()) {
lifecycleScope.launch {
try {
// load preferences into memory
dataStoreManager.init()
} catch (e: IOException) {
Timber.e("Failed to load preferences")
}
}
}
} }
private fun initSettings() { private fun initSettings() {
@ -36,8 +48,6 @@ class WireGuardAutoTunnel : Application() {
} }
} }
companion object { companion object {
fun isRunningOnAndroidTv(context: Context): Boolean { fun isRunningOnAndroidTv(context: Context): Boolean {
return context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK) return context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK)

View File

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

View File

@ -0,0 +1,7 @@
package com.zaneschepke.wireguardautotunnel.module
import javax.inject.Qualifier
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class Kernel

View File

@ -1,18 +1,20 @@
package com.zaneschepke.wireguardautotunnel.module package com.zaneschepke.wireguardautotunnel.module
import android.content.Context
import com.zaneschepke.wireguardautotunnel.repository.AppDatabase import com.zaneschepke.wireguardautotunnel.repository.AppDatabase
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa
import com.zaneschepke.wireguardautotunnel.repository.TunnelConfigDao import com.zaneschepke.wireguardautotunnel.repository.TunnelConfigDao
import com.zaneschepke.wireguardautotunnel.repository.datastore.DataStoreManager
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton import javax.inject.Singleton
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
class RepositoryModule { class RepositoryModule {
@Singleton @Singleton
@Provides @Provides
fun provideSettingsRepository(appDatabase: AppDatabase): SettingsDoa { fun provideSettingsRepository(appDatabase: AppDatabase): SettingsDoa {
@ -24,4 +26,10 @@ class RepositoryModule {
fun provideTunnelConfigRepository(appDatabase: AppDatabase): TunnelConfigDao { fun provideTunnelConfigRepository(appDatabase: AppDatabase): TunnelConfigDao {
return appDatabase.tunnelConfigDoa() return appDatabase.tunnelConfigDoa()
} }
@Singleton
@Provides
fun providePreferencesDataStore(@ApplicationContext context: Context): DataStoreManager {
return DataStoreManager(context)
}
} }

View File

@ -15,7 +15,6 @@ import dagger.hilt.android.scopes.ServiceScoped
@Module @Module
@InstallIn(ServiceComponent::class) @InstallIn(ServiceComponent::class)
abstract class ServiceModule { abstract class ServiceModule {
@Binds @Binds
@ServiceScoped @ServiceScoped
abstract fun provideNotificationService(wireGuardNotification: WireGuardNotification): NotificationService abstract fun provideNotificationService(wireGuardNotification: WireGuardNotification): NotificationService

View File

@ -3,6 +3,10 @@ package com.zaneschepke.wireguardautotunnel.module
import android.content.Context import android.content.Context
import com.wireguard.android.backend.Backend import com.wireguard.android.backend.Backend
import com.wireguard.android.backend.GoBackend import com.wireguard.android.backend.GoBackend
import com.wireguard.android.backend.WgQuickBackend
import com.wireguard.android.util.RootShell
import com.wireguard.android.util.ToolsInstaller
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa
import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService
import com.zaneschepke.wireguardautotunnel.service.tunnel.WireGuardTunnel import com.zaneschepke.wireguardautotunnel.service.tunnel.WireGuardTunnel
import dagger.Module import dagger.Module
@ -15,16 +19,40 @@ import javax.inject.Singleton
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
class TunnelModule { class TunnelModule {
@Provides
@Singleton
fun provideRootShell(
@ApplicationContext context: Context
): RootShell {
return RootShell(context)
}
@Provides @Provides
@Singleton @Singleton
fun provideBackend(@ApplicationContext context : Context) : Backend { @Userspace
fun provideUserspaceBackend(
@ApplicationContext context: Context
): Backend {
return GoBackend(context) return GoBackend(context)
} }
@Provides @Provides
@Singleton @Singleton
fun provideVpnService(backend: Backend) : VpnService { @Kernel
return WireGuardTunnel(backend) fun provideKernelBackend(
@ApplicationContext context: Context,
rootShell: RootShell
): Backend {
return WgQuickBackend(context, rootShell, ToolsInstaller(context, rootShell))
}
@Provides
@Singleton
fun provideVpnService(
@Userspace userspaceBackend: Backend,
@Kernel kernelBackend: Backend,
settingsDoa: SettingsDoa
): VpnService {
return WireGuardTunnel(userspaceBackend, kernelBackend, settingsDoa)
} }
} }

View File

@ -0,0 +1,7 @@
package com.zaneschepke.wireguardautotunnel.module
import javax.inject.Qualifier
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class Userspace

View File

@ -7,16 +7,18 @@ import com.zaneschepke.wireguardautotunnel.goAsync
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.cancel
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.cancel
@AndroidEntryPoint @AndroidEntryPoint
class BootReceiver : BroadcastReceiver() { class BootReceiver : BroadcastReceiver() {
@Inject @Inject
lateinit var settingsRepo: SettingsDoa lateinit var settingsRepo: SettingsDoa
override fun onReceive(context: Context, intent: Intent) = goAsync { override fun onReceive(
context: Context,
intent: Intent
) = goAsync {
if (intent.action == Intent.ACTION_BOOT_COMPLETED) { if (intent.action == Intent.ACTION_BOOT_COMPLETED) {
try { try {
val settings = settingsRepo.getAll() val settings = settingsRepo.getAll()

View File

@ -8,16 +8,19 @@ import com.zaneschepke.wireguardautotunnel.goAsync
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class NotificationActionReceiver : BroadcastReceiver() { class NotificationActionReceiver : BroadcastReceiver() {
@Inject @Inject
lateinit var settingsRepo: SettingsDoa lateinit var settingsRepo: SettingsDoa
override fun onReceive(context: Context, intent: Intent?) = goAsync {
override fun onReceive(
context: Context,
intent: Intent?
) = goAsync {
try { try {
val settings = settingsRepo.getAll() val settings = settingsRepo.getAll()
if (settings.isNotEmpty()) { if (settings.isNotEmpty()) {

View File

@ -7,11 +7,20 @@ import androidx.room.TypeConverters
import com.zaneschepke.wireguardautotunnel.repository.model.Settings import com.zaneschepke.wireguardautotunnel.repository.model.Settings
import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig
@Database(entities = [Settings::class, TunnelConfig::class], version = 3, autoMigrations = [ @Database(
AutoMigration(from = 1, to = 2), AutoMigration(from = 2, to = 3) entities = [Settings::class, TunnelConfig::class],
], exportSchema = true) version = 4,
autoMigrations = [
AutoMigration(from = 1, to = 2), AutoMigration(from = 2, to = 3), AutoMigration(
from = 3,
to = 4
)
],
exportSchema = true
)
@TypeConverters(DatabaseListConverters::class) @TypeConverters(DatabaseListConverters::class)
abstract class AppDatabase : RoomDatabase() { abstract class AppDatabase : RoomDatabase() {
abstract fun settingDao(): SettingsDoa abstract fun settingDao(): SettingsDoa
abstract fun tunnelConfigDoa(): TunnelConfigDao abstract fun tunnelConfigDoa(): TunnelConfigDao
} }

View File

@ -9,6 +9,7 @@ class DatabaseListConverters {
fun listToString(value: MutableList<String>): String { fun listToString(value: MutableList<String>): String {
return Json.encodeToString(value) return Json.encodeToString(value)
} }
@TypeConverter @TypeConverter
fun stringToList(value: String): MutableList<String> { fun stringToList(value: String): MutableList<String> {
if (value.isEmpty()) return mutableListOf() if (value.isEmpty()) return mutableListOf()

View File

@ -10,7 +10,6 @@ import kotlinx.coroutines.flow.Flow
@Dao @Dao
interface SettingsDoa { interface SettingsDoa {
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun save(t: Settings) suspend fun save(t: Settings)

View File

@ -10,7 +10,6 @@ import kotlinx.coroutines.flow.Flow
@Dao @Dao
interface TunnelConfigDao { interface TunnelConfigDao {
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun save(t: TunnelConfig) suspend fun save(t: TunnelConfig)

View File

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

View File

@ -13,9 +13,30 @@ data class Settings(
@ColumnInfo(name = "default_tunnel") var defaultTunnel: String? = null, @ColumnInfo(name = "default_tunnel") var defaultTunnel: String? = null,
@ColumnInfo(name = "is_always_on_vpn_enabled") var isAlwaysOnVpnEnabled: Boolean = false, @ColumnInfo(name = "is_always_on_vpn_enabled") var isAlwaysOnVpnEnabled: Boolean = false,
@ColumnInfo(name = "is_tunnel_on_ethernet_enabled") var isTunnelOnEthernetEnabled: Boolean = false, @ColumnInfo(name = "is_tunnel_on_ethernet_enabled") var isTunnelOnEthernetEnabled: Boolean = false,
@ColumnInfo(name = "is_shortcuts_enabled", defaultValue = "false") var isShortcutsEnabled : Boolean = false, @ColumnInfo(
@ColumnInfo(name = "is_battery_saver_enabled", defaultValue = "false") var isBatterySaverEnabled : Boolean = false, name = "is_shortcuts_enabled",
@ColumnInfo(name = "is_tunnel_on_wifi_enabled", defaultValue = "false") var isTunnelOnWifiEnabled : Boolean = false, defaultValue = "false"
) var isShortcutsEnabled: Boolean = false,
@ColumnInfo(
name = "is_battery_saver_enabled",
defaultValue = "false"
) var isBatterySaverEnabled: Boolean = false,
@ColumnInfo(
name = "is_tunnel_on_wifi_enabled",
defaultValue = "false"
) var isTunnelOnWifiEnabled: Boolean = false,
@ColumnInfo(
name = "is_kernel_enabled",
defaultValue = "false"
) var isKernelEnabled: Boolean = false,
@ColumnInfo(
name = "is_restore_on_boot_enabled",
defaultValue = "false"
) var isRestoreOnBootEnabled: Boolean = false,
@ColumnInfo(
name = "is_multi_tunnel_enabled",
defaultValue = "false"
) var isMultiTunnelEnabled: Boolean = false
) { ) {
fun isTunnelConfigDefault(tunnelConfig: TunnelConfig): Boolean { fun isTunnelConfigDefault(tunnelConfig: TunnelConfig): Boolean {
return if (defaultTunnel != null) { return if (defaultTunnel != null) {

View File

@ -5,27 +5,26 @@ import androidx.room.Entity
import androidx.room.Index import androidx.room.Index
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import com.wireguard.config.Config import com.wireguard.config.Config
import java.io.InputStream
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import java.io.InputStream
@Entity(indices = [Index(value = ["name"], unique = true)]) @Entity(indices = [Index(value = ["name"], unique = true)])
@Serializable @Serializable
data class TunnelConfig( data class TunnelConfig(
@PrimaryKey(autoGenerate = true) val id: Int = 0, @PrimaryKey(autoGenerate = true) val id: Int = 0,
@ColumnInfo(name = "name") var name: String, @ColumnInfo(name = "name") var name: String,
@ColumnInfo(name = "wg_quick") var wgQuick : String, @ColumnInfo(name = "wg_quick") var wgQuick: String
) { ) {
override fun toString(): String { override fun toString(): String {
return Json.encodeToString(serializer(), this) return Json.encodeToString(serializer(), this)
} }
companion object { companion object {
fun from(string: String): TunnelConfig { fun from(string: String): TunnelConfig {
return Json.decodeFromString<TunnelConfig>(string) return Json.decodeFromString<TunnelConfig>(string)
} }
fun configFromQuick(wgQuick: String): Config { fun configFromQuick(wgQuick: String): Config {
val inputStream: InputStream = wgQuick.byteInputStream() val inputStream: InputStream = wgQuick.byteInputStream()
val reader = inputStream.bufferedReader(Charsets.UTF_8) val reader = inputStream.bufferedReader(Charsets.UTF_8)

View File

@ -6,9 +6,7 @@ import android.os.IBinder
import androidx.lifecycle.LifecycleService import androidx.lifecycle.LifecycleService
import timber.log.Timber import timber.log.Timber
open class ForegroundService : LifecycleService() { open class ForegroundService : LifecycleService() {
private var isServiceStarted = false private var isServiceStarted = false
override fun onBind(intent: Intent): IBinder? { override fun onBind(intent: Intent): IBinder? {
@ -17,7 +15,11 @@ open class ForegroundService : LifecycleService() {
return null 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) super.onStartCommand(intent, flags, startId)
Timber.d("onStartCommand executed with startId: $startId") Timber.d("onStartCommand executed with startId: $startId")
if (intent != null) { if (intent != null) {
@ -41,7 +43,6 @@ open class ForegroundService : LifecycleService() {
return START_STICKY return START_STICKY
} }
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
Timber.d("The service has been destroyed") Timber.d("The service has been destroyed")

View File

@ -16,15 +16,24 @@ object ServiceManager {
.getRunningServices(Integer.MAX_VALUE) .getRunningServices(Integer.MAX_VALUE)
.any { it.service.className == service.name } .any { it.service.className == service.name }
fun <T : Service> getServiceState(context: Context, cls : Class<T>): ServiceState { fun <T : Service> getServiceState(
context: Context,
cls: Class<T>
): ServiceState {
val isServiceRunning = context.isServiceRunning(cls) val isServiceRunning = context.isServiceRunning(cls)
return if (isServiceRunning) ServiceState.STARTED else ServiceState.STOPPED return if (isServiceRunning) ServiceState.STARTED else ServiceState.STOPPED
} }
private fun <T : Service> actionOnService(action: Action, context: Context, cls : Class<T>, extras : Map<String,String>? = null) { private fun <T : Service> actionOnService(
action: Action,
context: Context,
cls: Class<T>,
extras: Map<String, String>? = null
) {
if (getServiceState(context, cls) == ServiceState.STOPPED && action == Action.STOP) return if (getServiceState(context, cls) == ServiceState.STOPPED && action == Action.STOP) return
if (getServiceState(context, cls) == ServiceState.STARTED && action == Action.START) return if (getServiceState(context, cls) == ServiceState.STARTED && action == Action.START) return
val intent = Intent(context, cls).also { val intent =
Intent(context, cls).also {
it.action = action.name it.action = action.name
extras?.forEach { (k, v) -> extras?.forEach { (k, v) ->
it.putExtra(k, v) it.putExtra(k, v)
@ -36,9 +45,11 @@ object ServiceManager {
Action.START_FOREGROUND -> { Action.START_FOREGROUND -> {
context.startForegroundService(intent) context.startForegroundService(intent)
} }
Action.START -> { Action.START -> {
context.startService(intent) context.startService(intent)
} }
Action.STOP -> context.startService(intent) Action.STOP -> context.startService(intent)
} }
} catch (e: Exception) { } catch (e: Exception) {
@ -46,13 +57,18 @@ object ServiceManager {
} }
} }
fun startVpnService(context : Context, tunnelConfig : String) { fun startVpnService(
context: Context,
tunnelConfig: String
) {
actionOnService( actionOnService(
Action.START, Action.START,
context, context,
WireGuardTunnelService::class.java, WireGuardTunnelService::class.java,
mapOf(context.getString(R.string.tunnel_extras_key) to tunnelConfig)) mapOf(context.getString(R.string.tunnel_extras_key) to tunnelConfig)
)
} }
fun stopVpnService(context: Context) { fun stopVpnService(context: Context) {
actionOnService( actionOnService(
Action.STOP, Action.STOP,
@ -61,39 +77,68 @@ object ServiceManager {
) )
} }
fun startVpnServiceForeground(context : Context, tunnelConfig : String) { fun startVpnServiceForeground(
context: Context,
tunnelConfig: String
) {
actionOnService( actionOnService(
Action.START_FOREGROUND, Action.START_FOREGROUND,
context, context,
WireGuardTunnelService::class.java, WireGuardTunnelService::class.java,
mapOf(context.getString(R.string.tunnel_extras_key) to tunnelConfig)) mapOf(context.getString(R.string.tunnel_extras_key) to tunnelConfig)
)
} }
private fun startWatcherServiceForeground(context : Context, tunnelConfig : String) { private fun startWatcherServiceForeground(
context: Context,
tunnelConfig: String
) {
actionOnService( actionOnService(
Action.START, context, Action.START,
WireGuardConnectivityWatcherService::class.java, mapOf(context. context,
getString(R.string.tunnel_extras_key) to WireGuardConnectivityWatcherService::class.java,
tunnelConfig)) mapOf(
context
.getString(R.string.tunnel_extras_key) to
tunnelConfig
)
)
} }
fun startWatcherService(context : Context, tunnelConfig : String) { fun startWatcherService(
context: Context,
tunnelConfig: String
) {
actionOnService( actionOnService(
Action.START, context, Action.START,
WireGuardConnectivityWatcherService::class.java, mapOf(context. context,
getString(R.string.tunnel_extras_key) to WireGuardConnectivityWatcherService::class.java,
tunnelConfig)) mapOf(
context
.getString(R.string.tunnel_extras_key) to
tunnelConfig
)
)
} }
fun stopWatcherService(context: Context) { fun stopWatcherService(context: Context) {
actionOnService( actionOnService(
Action.STOP, context, Action.STOP,
WireGuardConnectivityWatcherService::class.java) context,
WireGuardConnectivityWatcherService::class.java
)
} }
fun toggleWatcherServiceForeground(context: Context, tunnelConfig : String) { fun toggleWatcherServiceForeground(
when(getServiceState( context, context: Context,
WireGuardConnectivityWatcherService::class.java,)) { tunnelConfig: String
) {
when (
getServiceState(
context,
WireGuardConnectivityWatcherService::class.java
)
) {
ServiceState.STARTED -> stopWatcherService(context) ServiceState.STARTED -> stopWatcherService(context)
ServiceState.STOPPED -> startWatcherServiceForeground(context, tunnelConfig) ServiceState.STOPPED -> startWatcherServiceForeground(context, tunnelConfig)
} }

View File

@ -7,6 +7,7 @@ import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.os.PowerManager import android.os.PowerManager
import android.os.SystemClock import android.os.SystemClock
import androidx.core.app.ServiceCompat
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.wireguard.android.backend.Tunnel import com.wireguard.android.backend.Tunnel
import com.zaneschepke.wireguardautotunnel.Constants import com.zaneschepke.wireguardautotunnel.Constants
@ -21,17 +22,16 @@ import com.zaneschepke.wireguardautotunnel.service.network.WifiService
import com.zaneschepke.wireguardautotunnel.service.notification.NotificationService import com.zaneschepke.wireguardautotunnel.service.notification.NotificationService
import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class WireGuardConnectivityWatcherService : ForegroundService() { class WireGuardConnectivityWatcherService : ForegroundService() {
private val foregroundId = 122 private val foregroundId = 122
@Inject @Inject
@ -64,16 +64,20 @@ class WireGuardConnectivityWatcherService : ForegroundService() {
private var wakeLock: PowerManager.WakeLock? = null private var wakeLock: PowerManager.WakeLock? = null
private val tag = this.javaClass.name private val tag = this.javaClass.name
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
lifecycleScope.launch(Dispatchers.Main) { lifecycleScope.launch(Dispatchers.Main) {
try {
launchWatcherNotification() launchWatcherNotification()
} catch (e: Exception) {
Timber.e("Failed to start watcher service, not enough permissions")
}
} }
} }
override fun startService(extras: Bundle?) { override fun startService(extras: Bundle?) {
super.startService(extras) super.startService(extras)
try {
launchWatcherNotification() launchWatcherNotification()
val tunnelId = extras?.getString(getString(R.string.tunnel_extras_key)) val tunnelId = extras?.getString(getString(R.string.tunnel_extras_key))
if (tunnelId != null) { if (tunnelId != null) {
@ -89,6 +93,9 @@ class WireGuardConnectivityWatcherService : ForegroundService() {
} else { } else {
stopService(extras) stopService(extras)
} }
} catch (e: Exception) {
Timber.e("Failed to launch watcher service, no permissions")
}
} }
override fun stopService(extras: Bundle?) { override fun stopService(extras: Bundle?) {
@ -103,21 +110,30 @@ class WireGuardConnectivityWatcherService : ForegroundService() {
} }
private fun launchWatcherNotification() { private fun launchWatcherNotification() {
val notification = notificationService.createNotification( val notification =
notificationService.createNotification(
channelId = getString(R.string.watcher_channel_id), channelId = getString(R.string.watcher_channel_id),
channelName = getString(R.string.watcher_channel_name), channelName = getString(R.string.watcher_channel_name),
description = getString(R.string.watcher_notification_text), description = getString(R.string.watcher_notification_text),
vibration = false vibration = false
) )
super.startForeground(foregroundId, notification) ServiceCompat.startForeground(
this,
foregroundId,
notification,
Constants.SYSTEM_EXEMPT_SERVICE_TYPE_ID
)
} }
// try to start task again if killed // try to start task again if killed
override fun onTaskRemoved(rootIntent: Intent) { override fun onTaskRemoved(rootIntent: Intent) {
Timber.d("Task Removed called") Timber.d("Task Removed called")
val restartServiceIntent = Intent(rootIntent) val restartServiceIntent = Intent(rootIntent)
val restartServicePendingIntent: PendingIntent = PendingIntent.getService( val restartServicePendingIntent: PendingIntent =
this, 1, restartServiceIntent, PendingIntent.getService(
this,
1,
restartServiceIntent,
PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE
) )
applicationContext.getSystemService(Context.ALARM_SERVICE) applicationContext.getSystemService(Context.ALARM_SERVICE)
@ -131,7 +147,8 @@ class WireGuardConnectivityWatcherService : ForegroundService() {
} }
private suspend fun initWakeLock() { private suspend fun initWakeLock() {
val isBatterySaverOn = withContext(lifecycleScope.coroutineContext) { val isBatterySaverOn =
withContext(lifecycleScope.coroutineContext) {
settingsRepo.getAll().firstOrNull()?.isBatterySaverEnabled ?: false settingsRepo.getAll().firstOrNull()?.isBatterySaverEnabled ?: false
} }
wakeLock = wakeLock =
@ -155,7 +172,8 @@ class WireGuardConnectivityWatcherService : ForegroundService() {
} }
private fun startWatcherJob() { private fun startWatcherJob() {
watcherJob = lifecycleScope.launch(Dispatchers.IO) { watcherJob =
lifecycleScope.launch(Dispatchers.IO) {
val settings = settingsRepo.getAll() val settings = settingsRepo.getAll()
if (settings.isNotEmpty()) { if (settings.isNotEmpty()) {
setting = settings[0] setting = settings[0]
@ -232,7 +250,9 @@ class WireGuardConnectivityWatcherService : ForegroundService() {
is NetworkStatus.CapabilitiesChanged -> { is NetworkStatus.CapabilitiesChanged -> {
Timber.d("Wifi capabilities changed") Timber.d("Wifi capabilities changed")
isWifiConnected = true isWifiConnected = true
currentNetworkSSID = wifiService.getNetworkName(it.networkCapabilities) ?: "" val ssid = wifiService.getNetworkName(it.networkCapabilities) ?: ""
Timber.d("Detected SSID: $ssid")
currentNetworkSSID = ssid
} }
is NetworkStatus.Unavailable -> { is NetworkStatus.Unavailable -> {
@ -246,46 +266,67 @@ class WireGuardConnectivityWatcherService : ForegroundService() {
private suspend fun manageVpn() { private suspend fun manageVpn() {
while (true) { while (true) {
when { when {
((isEthernetConnected && (
(
isEthernetConnected &&
setting.isTunnelOnEthernetEnabled && setting.isTunnelOnEthernetEnabled &&
vpnService.getState() == Tunnel.State.DOWN)) -> vpnService.getState() == Tunnel.State.DOWN
)
) ->
ServiceManager.startVpnService(this, tunnelConfig) ServiceManager.startVpnService(this, tunnelConfig)
(!isEthernetConnected && (
!isEthernetConnected &&
setting.isTunnelOnMobileDataEnabled && setting.isTunnelOnMobileDataEnabled &&
!isWifiConnected && !isWifiConnected &&
isMobileDataConnected && isMobileDataConnected &&
vpnService.getState() == Tunnel.State.DOWN) -> vpnService.getState() == Tunnel.State.DOWN
) ->
ServiceManager.startVpnService(this, tunnelConfig) ServiceManager.startVpnService(this, tunnelConfig)
(!isEthernetConnected && (
!isEthernetConnected &&
!setting.isTunnelOnMobileDataEnabled && !setting.isTunnelOnMobileDataEnabled &&
!isWifiConnected && !isWifiConnected &&
vpnService.getState() == Tunnel.State.UP) -> vpnService.getState() == Tunnel.State.UP
) ->
ServiceManager.stopVpnService(this) ServiceManager.stopVpnService(this)
(!isEthernetConnected && isWifiConnected && (
!isEthernetConnected && isWifiConnected &&
!setting.trustedNetworkSSIDs.contains(currentNetworkSSID) && !setting.trustedNetworkSSIDs.contains(currentNetworkSSID) &&
setting.isTunnelOnWifiEnabled && setting.isTunnelOnWifiEnabled &&
(vpnService.getState() != Tunnel.State.UP)) -> (vpnService.getState() != Tunnel.State.UP)
) ->
ServiceManager.startVpnService(this, tunnelConfig) ServiceManager.startVpnService(this, tunnelConfig)
(!isEthernetConnected && (isWifiConnected && (
setting.trustedNetworkSSIDs.contains(currentNetworkSSID)) && !isEthernetConnected && (
(vpnService.getState() == Tunnel.State.UP)) -> isWifiConnected &&
setting.trustedNetworkSSIDs.contains(currentNetworkSSID)
) &&
(vpnService.getState() == Tunnel.State.UP)
) ->
ServiceManager.stopVpnService(this) ServiceManager.stopVpnService(this)
(!isEthernetConnected && (isWifiConnected && (
!isEthernetConnected && (
isWifiConnected &&
!setting.isTunnelOnWifiEnabled && !setting.isTunnelOnWifiEnabled &&
(vpnService.getState() == Tunnel.State.UP))) -> (vpnService.getState() == Tunnel.State.UP)
)
) ->
ServiceManager.stopVpnService(this) ServiceManager.stopVpnService(this)
(!isEthernetConnected && !isWifiConnected && (
!isEthernetConnected && !isWifiConnected &&
!isMobileDataConnected && !isMobileDataConnected &&
(vpnService.getState() == Tunnel.State.UP)) -> (vpnService.getState() == Tunnel.State.UP)
) ->
ServiceManager.stopVpnService(this) ServiceManager.stopVpnService(this)
else -> { else -> {
Timber.d("Unknown case") // Do nothing
} }
} }
delay(Constants.VPN_CONNECTIVITY_CHECK_INTERVAL) delay(Constants.VPN_CONNECTIVITY_CHECK_INTERVAL)

View File

@ -3,7 +3,9 @@ package com.zaneschepke.wireguardautotunnel.service.foreground
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import androidx.core.app.ServiceCompat
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.zaneschepke.wireguardautotunnel.Constants
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.receiver.NotificationActionReceiver import com.zaneschepke.wireguardautotunnel.receiver.NotificationActionReceiver
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa
@ -12,15 +14,14 @@ import com.zaneschepke.wireguardautotunnel.service.notification.NotificationServ
import com.zaneschepke.wireguardautotunnel.service.tunnel.HandshakeStatus import com.zaneschepke.wireguardautotunnel.service.tunnel.HandshakeStatus
import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class WireGuardTunnelService : ForegroundService() { class WireGuardTunnelService : ForegroundService() {
private val foregroundId = 123 private val foregroundId = 123
@Inject @Inject
@ -48,7 +49,8 @@ class WireGuardTunnelService : ForegroundService() {
launchVpnStartingNotification() launchVpnStartingNotification()
val tunnelConfigString = extras?.getString(getString(R.string.tunnel_extras_key)) val tunnelConfigString = extras?.getString(getString(R.string.tunnel_extras_key))
cancelJob() cancelJob()
job = lifecycleScope.launch(Dispatchers.IO) { job =
lifecycleScope.launch(Dispatchers.IO) {
launch { launch {
if (tunnelConfigString != null) { if (tunnelConfigString != null) {
try { try {
@ -81,20 +83,26 @@ class WireGuardTunnelService : ForegroundService() {
} }
HandshakeStatus.NEVER_CONNECTED -> { HandshakeStatus.NEVER_CONNECTED -> {
if (!didShowFailedHandshakeNotification) { if (!didShowFailedHandshakeNotification) {
launchVpnConnectionFailedNotification(getString(R.string.initial_connection_failure_message)) launchVpnConnectionFailedNotification(
getString(R.string.initial_connection_failure_message)
)
didShowFailedHandshakeNotification = true didShowFailedHandshakeNotification = true
didShowConnected = false didShowConnected = false
} }
} }
HandshakeStatus.HEALTHY -> { HandshakeStatus.HEALTHY -> {
if (!didShowConnected) { if (!didShowConnected) {
launchVpnConnectedNotification() launchVpnConnectedNotification()
didShowConnected = true didShowConnected = true
} }
} }
HandshakeStatus.STALE -> {}
HandshakeStatus.UNHEALTHY -> { HandshakeStatus.UNHEALTHY -> {
if (!didShowFailedHandshakeNotification) { if (!didShowFailedHandshakeNotification) {
launchVpnConnectionFailedNotification(getString(R.string.lost_connection_failure_message)) launchVpnConnectionFailedNotification(
getString(R.string.lost_connection_failure_message)
)
didShowFailedHandshakeNotification = true didShowFailedHandshakeNotification = true
didShowConnected = false didShowConnected = false
} }
@ -115,7 +123,8 @@ class WireGuardTunnelService : ForegroundService() {
} }
private fun launchVpnConnectedNotification() { private fun launchVpnConnectedNotification() {
val notification = notificationService.createNotification( val notification =
notificationService.createNotification(
channelId = getString(R.string.vpn_channel_id), channelId = getString(R.string.vpn_channel_id),
channelName = getString(R.string.vpn_channel_name), channelName = getString(R.string.vpn_channel_name),
title = getString(R.string.tunnel_start_title), title = getString(R.string.tunnel_start_title),
@ -124,11 +133,17 @@ class WireGuardTunnelService : ForegroundService() {
showTimestamp = true, showTimestamp = true,
description = "${getString(R.string.tunnel_start_text)} $tunnelName" description = "${getString(R.string.tunnel_start_text)} $tunnelName"
) )
super.startForeground(foregroundId, notification) ServiceCompat.startForeground(
this,
foregroundId,
notification,
Constants.SYSTEM_EXEMPT_SERVICE_TYPE_ID
)
} }
private fun launchVpnStartingNotification() { private fun launchVpnStartingNotification() {
val notification = notificationService.createNotification( val notification =
notificationService.createNotification(
channelId = getString(R.string.vpn_channel_id), channelId = getString(R.string.vpn_channel_id),
channelName = getString(R.string.vpn_channel_name), channelName = getString(R.string.vpn_channel_name),
title = getString(R.string.vpn_starting), title = getString(R.string.vpn_starting),
@ -137,15 +152,26 @@ class WireGuardTunnelService : ForegroundService() {
showTimestamp = true, showTimestamp = true,
description = getString(R.string.attempt_connection) description = getString(R.string.attempt_connection)
) )
super.startForeground(foregroundId, notification) ServiceCompat.startForeground(
this,
foregroundId,
notification,
Constants.SYSTEM_EXEMPT_SERVICE_TYPE_ID
)
} }
private fun launchVpnConnectionFailedNotification(message: String) { private fun launchVpnConnectionFailedNotification(message: String) {
val notification = notificationService.createNotification( val notification =
notificationService.createNotification(
channelId = getString(R.string.vpn_channel_id), channelId = getString(R.string.vpn_channel_id),
channelName = getString(R.string.vpn_channel_name), channelName = getString(R.string.vpn_channel_name),
action = PendingIntent.getBroadcast(this,0, action =
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), actionText = getString(R.string.restart),
title = getString(R.string.vpn_connection_failed), title = getString(R.string.vpn_connection_failed),
onGoing = false, onGoing = false,
@ -156,7 +182,6 @@ class WireGuardTunnelService : ForegroundService() {
super.startForeground(foregroundId, notification) super.startForeground(foregroundId, notification)
} }
private fun cancelJob() { private fun cancelJob() {
if (this::job.isInitialized) { if (this::job.isInitialized) {
job.cancel() job.cancel()

View File

@ -14,16 +14,20 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
abstract class BaseNetworkService<T : BaseNetworkService<T>>(
abstract class BaseNetworkService<T : BaseNetworkService<T>>(val context: Context, networkCapability : Int) : NetworkService<T> { val context: Context,
networkCapability: Int
) : NetworkService<T> {
private val connectivityManager = private val connectivityManager =
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
private val wifiManager = private val wifiManager =
context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
override val networkStatus = callbackFlow { override val networkStatus =
val networkStatusCallback = when (Build.VERSION.SDK_INT) { callbackFlow {
val networkStatusCallback =
when (Build.VERSION.SDK_INT) {
in Build.VERSION_CODES.S..Int.MAX_VALUE -> { in Build.VERSION_CODES.S..Int.MAX_VALUE -> {
object : ConnectivityManager.NetworkCallback( object : ConnectivityManager.NetworkCallback(
FLAG_INCLUDE_LOCATION_INFO FLAG_INCLUDE_LOCATION_INFO
@ -40,14 +44,18 @@ abstract class BaseNetworkService<T : BaseNetworkService<T>>(val context: Contex
network: Network, network: Network,
networkCapabilities: NetworkCapabilities networkCapabilities: NetworkCapabilities
) { ) {
trySend(NetworkStatus.CapabilitiesChanged(network, networkCapabilities)) trySend(
NetworkStatus.CapabilitiesChanged(
network,
networkCapabilities
)
)
} }
} }
} }
else -> { else -> {
object : ConnectivityManager.NetworkCallback() { object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) { override fun onAvailable(network: Network) {
trySend(NetworkStatus.Available(network)) trySend(NetworkStatus.Available(network))
} }
@ -60,12 +68,18 @@ abstract class BaseNetworkService<T : BaseNetworkService<T>>(val context: Contex
network: Network, network: Network,
networkCapabilities: NetworkCapabilities networkCapabilities: NetworkCapabilities
) { ) {
trySend(NetworkStatus.CapabilitiesChanged(network, networkCapabilities)) trySend(
NetworkStatus.CapabilitiesChanged(
network,
networkCapabilities
)
)
} }
} }
} }
} }
val request = NetworkRequest.Builder() val request =
NetworkRequest.Builder()
.addTransportType(networkCapability) .addTransportType(networkCapability)
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.addCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) .addCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
@ -77,7 +91,6 @@ abstract class BaseNetworkService<T : BaseNetworkService<T>>(val context: Contex
} }
} }
override fun getNetworkName(networkCapabilities: NetworkCapabilities): String? { override fun getNetworkName(networkCapabilities: NetworkCapabilities): String? {
var ssid: String? = getWifiNameFromCapabilities(networkCapabilities) var ssid: String? = getWifiNameFromCapabilities(networkCapabilities)
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) { if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) {
@ -89,7 +102,6 @@ abstract class BaseNetworkService<T : BaseNetworkService<T>>(val context: Contex
return ssid?.trim('"') return ssid?.trim('"')
} }
companion object { companion object {
private fun getWifiNameFromCapabilities(networkCapabilities: NetworkCapabilities): String? { private fun getWifiNameFromCapabilities(networkCapabilities: NetworkCapabilities): String? {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
@ -107,11 +119,18 @@ abstract class BaseNetworkService<T : BaseNetworkService<T>>(val context: Contex
inline fun <Result> Flow<NetworkStatus>.map( inline fun <Result> Flow<NetworkStatus>.map(
crossinline onUnavailable: suspend (network: Network) -> Result, crossinline onUnavailable: suspend (network: Network) -> Result,
crossinline onAvailable: suspend (network: Network) -> Result, crossinline onAvailable: suspend (network: Network) -> Result,
crossinline onCapabilitiesChanged: suspend (network : Network, networkCapabilities : NetworkCapabilities) -> Result, crossinline onCapabilitiesChanged: suspend (
): Flow<Result> = map { status -> network: Network,
networkCapabilities: NetworkCapabilities
) -> Result
): Flow<Result> =
map { status ->
when (status) { when (status) {
is NetworkStatus.Unavailable -> onUnavailable(status.network) is NetworkStatus.Unavailable -> onUnavailable(status.network)
is NetworkStatus.Available -> onAvailable(status.network) is NetworkStatus.Available -> onAvailable(status.network)
is NetworkStatus.CapabilitiesChanged -> onCapabilitiesChanged(status.network, status.networkCapabilities) is NetworkStatus.CapabilitiesChanged -> onCapabilitiesChanged(
status.network,
status.networkCapabilities
)
} }
} }

View File

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

View File

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

View File

@ -5,5 +5,6 @@ import kotlinx.coroutines.flow.Flow
interface NetworkService<T> { interface NetworkService<T> {
fun getNetworkName(networkCapabilities: NetworkCapabilities): String? fun getNetworkName(networkCapabilities: NetworkCapabilities): String?
val networkStatus: Flow<NetworkStatus> val networkStatus: Flow<NetworkStatus>
} }

View File

@ -5,6 +5,9 @@ import android.net.NetworkCapabilities
sealed class NetworkStatus { sealed class NetworkStatus {
class Available(val network: Network) : NetworkStatus() class Available(val network: Network) : NetworkStatus()
class Unavailable(val network: Network) : NetworkStatus() class Unavailable(val network: Network) : NetworkStatus()
class CapabilitiesChanged(val network : Network, val networkCapabilities : NetworkCapabilities) : NetworkStatus()
class CapabilitiesChanged(val network: Network, val networkCapabilities: NetworkCapabilities) :
NetworkStatus()
} }

View File

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

View File

@ -12,9 +12,13 @@ import com.zaneschepke.wireguardautotunnel.ui.MainActivity
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject import javax.inject.Inject
class WireGuardNotification @Inject constructor(@ApplicationContext private val context: Context) : NotificationService { class WireGuardNotification
@Inject
private val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager constructor(
@ApplicationContext private val context: Context
) : NotificationService {
private val notificationManager =
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
override fun createNotification( override fun createNotification(
channelId: String, channelId: String,
@ -29,7 +33,8 @@ class WireGuardNotification @Inject constructor(@ApplicationContext private val
onGoing: Boolean, onGoing: Boolean,
lights: Boolean lights: Boolean
): Notification { ): Notification {
val channel = NotificationChannel( val channel =
NotificationChannel(
channelId, channelId,
channelName, channelName,
importance importance
@ -62,7 +67,8 @@ class WireGuardNotification @Inject constructor(@ApplicationContext private val
// TODO find a not deprecated way to do this // TODO find a not deprecated way to do this
it.addAction( it.addAction(
Notification.Action.Builder(0, actionText, action) Notification.Action.Builder(0, actionText, action)
.build()) .build()
)
it.setAutoCancel(true) it.setAutoCancel(true)
} }
it.setContentTitle(title) it.setContentTitle(title)

View File

@ -12,14 +12,13 @@ import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import com.zaneschepke.wireguardautotunnel.service.foreground.WireGuardTunnelService import com.zaneschepke.wireguardautotunnel.service.foreground.WireGuardTunnelService
import com.zaneschepke.wireguardautotunnel.util.WgTunnelException import com.zaneschepke.wireguardautotunnel.util.WgTunnelException
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class ShortcutsActivity : ComponentActivity() { class ShortcutsActivity : ComponentActivity() {
@Inject @Inject
lateinit var settingsRepo: SettingsDoa lateinit var settingsRepo: SettingsDoa
@ -38,13 +37,15 @@ class ShortcutsActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
if (intent.getStringExtra(CLASS_NAME_EXTRA_KEY) if (intent.getStringExtra(CLASS_NAME_EXTRA_KEY)
.equals(WireGuardTunnelService::class.java.simpleName)) { .equals(WireGuardTunnelService::class.java.simpleName)
) {
lifecycleScope.launch(Dispatchers.Main) { lifecycleScope.launch(Dispatchers.Main) {
val settings = getSettings() val settings = getSettings()
if (settings.isShortcutsEnabled) { if (settings.isShortcutsEnabled) {
try { try {
val tunnelName = intent.getStringExtra(TUNNEL_NAME_EXTRA_KEY) val tunnelName = intent.getStringExtra(TUNNEL_NAME_EXTRA_KEY)
val tunnelConfig = if(tunnelName != null) { val tunnelConfig =
if (tunnelName != null) {
tunnelConfigRepo.getAll().firstOrNull { it.name == tunnelName } tunnelConfigRepo.getAll().firstOrNull { it.name == tunnelName }
} else { } else {
if (settings.defaultTunnel == null) { if (settings.defaultTunnel == null) {
@ -56,8 +57,13 @@ class ShortcutsActivity : ComponentActivity() {
tunnelConfig ?: return@launch tunnelConfig ?: return@launch
attemptWatcherServiceToggle(tunnelConfig.toString()) attemptWatcherServiceToggle(tunnelConfig.toString())
when (intent.action) { when (intent.action) {
Action.STOP.name -> ServiceManager.stopVpnService(this@ShortcutsActivity) Action.STOP.name -> ServiceManager.stopVpnService(
Action.START.name -> ServiceManager.startVpnService(this@ShortcutsActivity, tunnelConfig.toString()) this@ShortcutsActivity
)
Action.START.name -> ServiceManager.startVpnService(
this@ShortcutsActivity,
tunnelConfig.toString()
)
} }
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e.message) Timber.e(e.message)
@ -76,6 +82,7 @@ class ShortcutsActivity : ComponentActivity() {
throw WgTunnelException("Settings empty") throw WgTunnelException("Settings empty")
} }
} }
companion object { companion object {
const val TUNNEL_NAME_EXTRA_KEY = "tunnelName" const val TUNNEL_NAME_EXTRA_KEY = "tunnelName"
const val CLASS_NAME_EXTRA_KEY = "className" const val CLASS_NAME_EXTRA_KEY = "className"

View File

@ -11,17 +11,16 @@ import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class TunnelControlTile : TileService() { class TunnelControlTile : TileService() {
@Inject @Inject
lateinit var settingsRepo: SettingsDoa lateinit var settingsRepo: SettingsDoa
@ -36,7 +35,8 @@ class TunnelControlTile : TileService() {
private lateinit var job: Job private lateinit var job: Job
override fun onStartListening() { override fun onStartListening() {
job = scope.launch { job =
scope.launch {
updateTileState() updateTileState()
} }
super.onStartListening() super.onStartListening()
@ -63,7 +63,10 @@ class TunnelControlTile : TileService() {
if (vpnService.getState() == Tunnel.State.UP) { if (vpnService.getState() == Tunnel.State.UP) {
ServiceManager.stopVpnService(this@TunnelControlTile) ServiceManager.stopVpnService(this@TunnelControlTile)
} else { } else {
ServiceManager.startVpnServiceForeground(this@TunnelControlTile, tunnel.toString()) ServiceManager.startVpnServiceForeground(
this@TunnelControlTile,
tunnel.toString()
)
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
@ -80,11 +83,13 @@ class TunnelControlTile : TileService() {
val settings = settingsRepo.getAll() val settings = settingsRepo.getAll()
if (settings.isNotEmpty()) { if (settings.isNotEmpty()) {
val setting = settings.first() val setting = settings.first()
tunnelConfig = if (setting.defaultTunnel != null) { tunnelConfig =
if (setting.defaultTunnel != null) {
TunnelConfig.from(setting.defaultTunnel!!) TunnelConfig.from(setting.defaultTunnel!!)
} else { } else {
val configs = configRepo.getAll() val configs = configRepo.getAll()
val config = if(configs.isNotEmpty()) { val config =
if (configs.isNotEmpty()) {
configs.first() configs.first()
} else { } else {
null null
@ -95,14 +100,16 @@ class TunnelControlTile : TileService() {
return tunnelConfig return tunnelConfig
} }
private fun attemptWatcherServiceToggle(tunnelConfig: String) { private fun attemptWatcherServiceToggle(tunnelConfig: String) {
scope.launch { scope.launch {
val settings = settingsRepo.getAll() val settings = settingsRepo.getAll()
if (settings.isNotEmpty()) { if (settings.isNotEmpty()) {
val setting = settings.first() val setting = settings.first()
if (setting.isAutoTunnelEnabled) { if (setting.isAutoTunnelEnabled) {
ServiceManager.toggleWatcherServiceForeground(this@TunnelControlTile, tunnelConfig) ServiceManager.toggleWatcherServiceForeground(
this@TunnelControlTile,
tunnelConfig
)
} }
} }
} }
@ -115,15 +122,19 @@ class TunnelControlTile : TileService() {
Tunnel.State.UP -> { Tunnel.State.UP -> {
qsTile.state = Tile.STATE_ACTIVE qsTile.state = Tile.STATE_ACTIVE
} }
Tunnel.State.DOWN -> { Tunnel.State.DOWN -> {
qsTile.state = Tile.STATE_INACTIVE qsTile.state = Tile.STATE_INACTIVE
} }
else -> { else -> {
qsTile.state = Tile.STATE_UNAVAILABLE qsTile.state = Tile.STATE_UNAVAILABLE
} }
} }
val config = determineTileTunnel() val config = determineTileTunnel()
setTileDescription(config?.name ?: this.resources.getString(R.string.no_tunnel_available)) setTileDescription(
config?.name ?: this.resources.getString(R.string.no_tunnel_available)
)
qsTile.updateTile() qsTile.updateTile()
} catch (e: Exception) { } catch (e: Exception) {
Timber.e("Unable to update tile state") Timber.e("Unable to update tile state")

View File

@ -2,13 +2,16 @@ package com.zaneschepke.wireguardautotunnel.service.tunnel
enum class HandshakeStatus { enum class HandshakeStatus {
HEALTHY, HEALTHY,
STALE,
UNHEALTHY, UNHEALTHY,
NEVER_CONNECTED, NEVER_CONNECTED,
NOT_STARTED; NOT_STARTED
;
companion object { companion object {
private const val WG_TYPICAL_HANDSHAKE_INTERVAL_WHEN_HEALTHY_SEC = 120 private const val WG_TYPICAL_HANDSHAKE_INTERVAL_WHEN_HEALTHY_SEC = 180
const val UNHEALTHY_TIME_LIMIT_SEC = WG_TYPICAL_HANDSHAKE_INTERVAL_WHEN_HEALTHY_SEC + 60 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 NEVER_CONNECTED_TO_UNHEALTHY_TIME_LIMIT_SEC = 30 const val NEVER_CONNECTED_TO_UNHEALTHY_TIME_LIMIT_SEC = 30
} }
} }

View File

@ -8,11 +8,14 @@ import kotlinx.coroutines.flow.SharedFlow
interface VpnService : Tunnel { interface VpnService : Tunnel {
suspend fun startTunnel(tunnelConfig: TunnelConfig): Tunnel.State suspend fun startTunnel(tunnelConfig: TunnelConfig): Tunnel.State
suspend fun stopTunnel() suspend fun stopTunnel()
val state: SharedFlow<Tunnel.State> val state: SharedFlow<Tunnel.State>
val tunnelName: SharedFlow<String> val tunnelName: SharedFlow<String>
val statistics: SharedFlow<Statistics> val statistics: SharedFlow<Statistics>
val lastHandshake: SharedFlow<Map<Key, Long>> val lastHandshake: SharedFlow<Map<Key, Long>>
val handshakeStatus: SharedFlow<HandshakeStatus> val handshakeStatus: SharedFlow<HandshakeStatus>
fun getState(): Tunnel.State fun getState(): Tunnel.State
} }

View File

@ -4,10 +4,15 @@ import com.wireguard.android.backend.Backend
import com.wireguard.android.backend.BackendException import com.wireguard.android.backend.BackendException
import com.wireguard.android.backend.Statistics import com.wireguard.android.backend.Statistics
import com.wireguard.android.backend.Tunnel import com.wireguard.android.backend.Tunnel
import com.wireguard.config.Config
import com.wireguard.crypto.Key import com.wireguard.crypto.Key
import com.zaneschepke.wireguardautotunnel.Constants import com.zaneschepke.wireguardautotunnel.Constants
import com.zaneschepke.wireguardautotunnel.module.Kernel
import com.zaneschepke.wireguardautotunnel.module.Userspace
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa
import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.util.NumberUtils import com.zaneschepke.wireguardautotunnel.util.NumberUtils
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@ -20,20 +25,28 @@ import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject
class WireGuardTunnel @Inject constructor(private val backend : Backend) : VpnService {
class WireGuardTunnel
@Inject
constructor(
@Userspace private val userspaceBackend: Backend,
@Kernel private val kernelBackend: Backend,
private val settingsRepo: SettingsDoa
) : VpnService {
private val _tunnelName = MutableStateFlow("") private val _tunnelName = MutableStateFlow("")
override val tunnelName get() = _tunnelName.asStateFlow() override val tunnelName get() = _tunnelName.asStateFlow()
private val _state = MutableSharedFlow<Tunnel.State>( private val _state =
MutableSharedFlow<Tunnel.State>(
onBufferOverflow = BufferOverflow.DROP_OLDEST, onBufferOverflow = BufferOverflow.DROP_OLDEST,
replay = 1) replay = 1
)
private val _handshakeStatus = MutableSharedFlow<HandshakeStatus>(replay = 1, private val _handshakeStatus =
onBufferOverflow = BufferOverflow.DROP_OLDEST) MutableSharedFlow<HandshakeStatus>(
replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
override val state get() = _state.asSharedFlow() override val state get() = _state.asSharedFlow()
private val _statistics = MutableSharedFlow<Statistics>(replay = 1) private val _statistics = MutableSharedFlow<Statistics>(replay = 1)
@ -49,14 +62,40 @@ class WireGuardTunnel @Inject constructor(private val backend : Backend) : VpnSe
private lateinit var statsJob: Job private lateinit var statsJob: Job
private var config: Config? = null
private var backend: Backend = userspaceBackend
private var backendIsUserspace = true
init {
scope.launch {
settingsRepo.getAllFlow().collect {
val settings = it.first()
if (settings.isKernelEnabled && backendIsUserspace) {
Timber.d("Setting kernel backend")
backend = kernelBackend
backendIsUserspace = false
} else if (!settings.isKernelEnabled && !backendIsUserspace) {
Timber.d("Setting userspace backend")
backend = userspaceBackend
backendIsUserspace = true
}
}
}
}
override suspend fun startTunnel(tunnelConfig: TunnelConfig): Tunnel.State { override suspend fun startTunnel(tunnelConfig: TunnelConfig): Tunnel.State {
return try { return try {
stopTunnelOnConfigChange(tunnelConfig) stopTunnelOnConfigChange(tunnelConfig)
emitTunnelName(tunnelConfig.name) emitTunnelName(tunnelConfig.name)
val config = TunnelConfig.configFromQuick(tunnelConfig.wgQuick) config = TunnelConfig.configFromQuick(tunnelConfig.wgQuick)
val state = backend.setState( val state =
this, Tunnel.State.UP, config) backend.setState(
this,
Tunnel.State.UP,
config
)
_state.emit(state) _state.emit(state)
state state
} catch (e: Exception) { } catch (e: Exception) {
@ -98,15 +137,17 @@ class WireGuardTunnel @Inject constructor(private val backend : Backend) : VpnSe
val tunnel = this val tunnel = this
_state.tryEmit(state) _state.tryEmit(state)
if (state == Tunnel.State.UP) { if (state == Tunnel.State.UP) {
statsJob = scope.launch { statsJob =
scope.launch {
val handshakeMap = HashMap<Key, Long>() val handshakeMap = HashMap<Key, Long>()
var neverHadHandshakeCounter = 0 var neverHadHandshakeCounter = 0
while (true) { while (true) {
val statistics = backend.getStatistics(tunnel) val statistics = backend.getStatistics(tunnel)
_statistics.emit(statistics) _statistics.emit(statistics)
statistics.peers().forEach { statistics.peers().forEach { key ->
val handshakeEpoch = statistics.peer(it)?.latestHandshakeEpochMillis ?: 0L val handshakeEpoch =
handshakeMap[it] = handshakeEpoch statistics.peer(key)?.latestHandshakeEpochMillis ?: 0L
handshakeMap[key] = handshakeEpoch
if (handshakeEpoch == 0L) { if (handshakeEpoch == 0L) {
if (neverHadHandshakeCounter >= HandshakeStatus.NEVER_CONNECTED_TO_UNHEALTHY_TIME_LIMIT_SEC) { if (neverHadHandshakeCounter >= HandshakeStatus.NEVER_CONNECTED_TO_UNHEALTHY_TIME_LIMIT_SEC) {
_handshakeStatus.emit(HandshakeStatus.NEVER_CONNECTED) _handshakeStatus.emit(HandshakeStatus.NEVER_CONNECTED)
@ -118,12 +159,18 @@ class WireGuardTunnel @Inject constructor(private val backend : Backend) : VpnSe
} }
return@forEach return@forEach
} }
if((NumberUtils.getSecondsBetweenTimestampAndNow(handshakeEpoch) ?: 0L) >= HandshakeStatus.UNHEALTHY_TIME_LIMIT_SEC) { // TODO one day make each peer have their own dedicated status
_handshakeStatus.emit(HandshakeStatus.UNHEALTHY) val lastHandshake = NumberUtils.getSecondsBetweenTimestampAndNow(
handshakeEpoch
)
if (lastHandshake != null) {
if (lastHandshake >= HandshakeStatus.STALE_TIME_LIMIT_SEC) {
_handshakeStatus.emit(HandshakeStatus.STALE)
} else { } else {
_handshakeStatus.emit(HandshakeStatus.HEALTHY) _handshakeStatus.emit(HandshakeStatus.HEALTHY)
} }
} }
}
_lastHandshake.emit(handshakeMap) _lastHandshake.emit(handshakeMap)
delay(Constants.VPN_STATISTIC_CHECK_INTERVAL) delay(Constants.VPN_STATISTIC_CHECK_INTERVAL)
} }
@ -137,6 +184,4 @@ class WireGuardTunnel @Inject constructor(private val backend : Backend) : VpnSe
_lastHandshake.tryEmit(emptyMap()) _lastHandshake.tryEmit(emptyMap())
} }
} }
} }

View File

@ -0,0 +1,8 @@
package com.zaneschepke.wireguardautotunnel.ui
import androidx.lifecycle.ViewModel
import javax.inject.Inject
class ActivityViewModel @Inject constructor() : ViewModel() {
// TODO move shared logic to shared viewmodel
}

View File

@ -12,7 +12,6 @@ import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.ExitTransition import androidx.compose.animation.ExitTransition
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideInHorizontally
@ -59,13 +58,14 @@ import timber.log.Timber
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
@OptIn(
@OptIn(ExperimentalAnimationApi::class,
ExperimentalPermissionsApi::class ExperimentalPermissionsApi::class
) )
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContent { setContent {
// TODO move shared logic to shared viewmodel
// val sharedViewModel = hiltViewModel<ActivityViewModel>()
val navController = rememberNavController() val navController = rememberNavController()
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
@ -84,54 +84,72 @@ class MainActivity : AppCompatActivity() {
} }
var vpnIntent by remember { mutableStateOf(GoBackend.VpnService.prepare(this)) } var vpnIntent by remember { mutableStateOf(GoBackend.VpnService.prepare(this)) }
val vpnActivityResultState = rememberLauncherForActivityResult( val vpnActivityResultState =
rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult(), ActivityResultContracts.StartActivityForResult(),
onResult = { onResult = {
val accepted = (it.resultCode == RESULT_OK) val accepted = (it.resultCode == RESULT_OK)
if (accepted) { if (accepted) {
vpnIntent = null vpnIntent = null
} }
}) }
)
LaunchedEffect(vpnIntent) { LaunchedEffect(vpnIntent) {
if (vpnIntent != null) { if (vpnIntent != null) {
vpnActivityResultState.launch(vpnIntent) vpnActivityResultState.launch(vpnIntent)
} else requestNotificationPermission() } else {
requestNotificationPermission()
}
} }
fun showSnackBarMessage(message: String) { fun showSnackBarMessage(message: String) {
lifecycleScope.launch(Dispatchers.Main) { lifecycleScope.launch(Dispatchers.Main) {
val result = snackbarHostState.showSnackbar( val result =
snackbarHostState.showSnackbar(
message = message, message = message,
actionLabel = applicationContext.getString(R.string.okay), actionLabel = applicationContext.getString(R.string.okay),
duration = SnackbarDuration.Short, duration = SnackbarDuration.Short
) )
when (result) { when (result) {
SnackbarResult.ActionPerformed -> { snackbarHostState.currentSnackbarData?.dismiss() } SnackbarResult.ActionPerformed -> {
SnackbarResult.Dismissed -> { snackbarHostState.currentSnackbarData?.dismiss() } snackbarHostState.currentSnackbarData?.dismiss()
}
SnackbarResult.Dismissed -> {
snackbarHostState.currentSnackbarData?.dismiss()
}
} }
} }
} }
Scaffold(snackbarHost = { Scaffold(
snackbarHost = {
SnackbarHost(snackbarHostState) { snackbarData: SnackbarData -> SnackbarHost(snackbarHostState) { snackbarData: SnackbarData ->
CustomSnackBar( CustomSnackBar(
snackbarData.visuals.message, snackbarData.visuals.message,
isRtl = false, isRtl = false,
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp) containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(
2.dp
)
) )
} }
}, },
modifier = Modifier.onKeyEvent { modifier =
Modifier.onKeyEvent {
if (it.nativeKeyEvent.action == KeyEvent.ACTION_UP) { if (it.nativeKeyEvent.action == KeyEvent.ACTION_UP) {
when (it.nativeKeyEvent.keyCode) { when (it.nativeKeyEvent.keyCode) {
KeyEvent.KEYCODE_DPAD_UP -> { KeyEvent.KEYCODE_DPAD_UP -> {
try { try {
focusRequester.requestFocus() focusRequester.requestFocus()
} catch (e: IllegalStateException) { } catch (e: IllegalStateException) {
Timber.e("No D-Pad focus request modifier added to element on screen") Timber.e(
"No D-Pad focus request modifier added to element on screen"
)
} }
false false
} else -> { }
else -> {
false false
} }
} }
@ -139,13 +157,13 @@ class MainActivity : AppCompatActivity() {
false false
} }
}, },
bottomBar = if (vpnIntent == null && notificationPermissionState.status.isGranted) { bottomBar =
if (vpnIntent == null && notificationPermissionState.status.isGranted) {
{ BottomNavBar(navController, Routes.navItems) } { BottomNavBar(navController, Routes.navItems) }
} else { } else {
{} {}
}, }
) ) { padding ->
{ padding ->
if (vpnIntent != null) { if (vpnIntent != null) {
PermissionRequestFailedScreen( PermissionRequestFailedScreen(
padding = padding, padding = padding,
@ -162,7 +180,11 @@ class MainActivity : AppCompatActivity() {
val intentSettings = val intentSettings =
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
intentSettings.data = intentSettings.data =
Uri.fromParts(Constants.URI_PACKAGE_SCHEME, this.packageName, null) Uri.fromParts(
Constants.URI_PACKAGE_SCHEME,
this.packageName,
null
)
startActivity(intentSettings) startActivity(intentSettings)
}, },
message = getString(R.string.notification_permission_required), message = getString(R.string.notification_permission_required),
@ -172,23 +194,36 @@ class MainActivity : AppCompatActivity() {
} }
NavHost(navController, startDestination = Routes.Main.name) { NavHost(navController, startDestination = Routes.Main.name) {
composable(Routes.Main.name, enterTransition = { composable(
Routes.Main.name,
enterTransition = {
when (initialState.destination.route) { when (initialState.destination.route) {
Routes.Settings.name, Routes.Support.name -> Routes.Settings.name, Routes.Support.name ->
slideInHorizontally( slideInHorizontally(
initialOffsetX = { -Constants.SLIDE_IN_TRANSITION_OFFSET }, initialOffsetX = {
animationSpec = tween(Constants.SLIDE_IN_ANIMATION_DURATION) -Constants.SLIDE_IN_TRANSITION_OFFSET
},
animationSpec = tween(
Constants.SLIDE_IN_ANIMATION_DURATION
)
) )
else -> { else -> {
fadeIn(animationSpec = tween(Constants.FADE_IN_ANIMATION_DURATION)) fadeIn(
animationSpec = tween(
Constants.FADE_IN_ANIMATION_DURATION
)
)
} }
} }
}, exitTransition = { },
exitTransition = {
ExitTransition.None ExitTransition.None
} }
) { ) {
MainScreen(padding = padding, showSnackbarMessage = { message -> showSnackBarMessage(message) }, navController = navController) MainScreen(padding = padding, showSnackbarMessage = { message ->
showSnackBarMessage(message)
}, navController = navController)
} }
composable(Routes.Settings.name, enterTransition = { composable(Routes.Settings.name, enterTransition = {
when (initialState.destination.route) { when (initialState.destination.route) {
@ -206,10 +241,16 @@ class MainActivity : AppCompatActivity() {
} }
else -> { else -> {
fadeIn(animationSpec = tween(Constants.FADE_IN_ANIMATION_DURATION)) fadeIn(
animationSpec = tween(Constants.FADE_IN_ANIMATION_DURATION)
)
} }
} }
}) { SettingsScreen(padding = padding, showSnackbarMessage = { message -> showSnackBarMessage(message) }, focusRequester = focusRequester) } }) {
SettingsScreen(padding = padding, showSnackbarMessage = { message ->
showSnackBarMessage(message)
}, focusRequester = focusRequester)
}
composable(Routes.Support.name, enterTransition = { composable(Routes.Support.name, enterTransition = {
when (initialState.destination.route) { when (initialState.destination.route) {
Routes.Settings.name, Routes.Main.name -> Routes.Settings.name, Routes.Main.name ->
@ -219,16 +260,26 @@ class MainActivity : AppCompatActivity() {
) )
else -> { else -> {
fadeIn(animationSpec = tween(Constants.FADE_IN_ANIMATION_DURATION)) fadeIn(
animationSpec = tween(Constants.FADE_IN_ANIMATION_DURATION)
)
} }
} }
}) { SupportScreen(padding = padding, focusRequester) } }) { SupportScreen(padding = padding, focusRequester = focusRequester) }
composable("${Routes.Config.name}/{id}", enterTransition = { composable("${Routes.Config.name}/{id}", enterTransition = {
fadeIn(animationSpec = tween(Constants.FADE_IN_ANIMATION_DURATION)) fadeIn(animationSpec = tween(Constants.FADE_IN_ANIMATION_DURATION))
}) { it -> }) {
val id = it.arguments?.getString("id") val id = it.arguments?.getString("id")
if (!id.isNullOrBlank()) { if (!id.isNullOrBlank()) {
ConfigScreen(navController = navController, id = id, showSnackbarMessage = { message -> showSnackBarMessage(message) }, focusRequester = focusRequester)} ConfigScreen(
navController = navController,
id = id,
showSnackbarMessage = { message ->
showSnackBarMessage(message)
},
focusRequester = focusRequester
)
}
} }
} }
} }

View File

@ -10,25 +10,26 @@ enum class Routes {
Main, Main,
Settings, Settings,
Support, Support,
Config; Config
;
companion object { companion object {
val navItems = listOf( val navItems =
listOf(
BottomNavItem( BottomNavItem(
name = "Tunnels", name = "Tunnels",
route = Main.name, route = Main.name,
icon = Icons.Rounded.Home, icon = Icons.Rounded.Home
), ),
BottomNavItem( BottomNavItem(
name = "Settings", name = "Settings",
route = Settings.name, route = Settings.name,
icon = Icons.Rounded.Settings, icon = Icons.Rounded.Settings
), ),
BottomNavItem( BottomNavItem(
name = "Support", name = "Support",
route = Support.name, route = Support.name,
icon = Icons.Rounded.QuestionMark, icon = Icons.Rounded.QuestionMark
) )
) )
} }

View File

@ -1,8 +1,10 @@
package com.zaneschepke.wireguardautotunnel.ui.common package com.zaneschepke.wireguardautotunnel.ui.common
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.Text import androidx.compose.material3.Text
@ -11,19 +13,27 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
@Composable @Composable
fun ClickableIconButton(onIconClick : () -> Unit, text : String, icon : ImageVector, enabled : Boolean) { fun ClickableIconButton(
TextButton(onClick = {}, onIconClick: () -> Unit,
text: String,
icon: ImageVector,
enabled: Boolean
) {
TextButton(
onClick = {},
enabled = enabled enabled = enabled
) { ) {
Text(text) Text(text, Modifier.weight(1f, false))
Spacer(modifier = Modifier.size(ButtonDefaults.IconSpacing)) Spacer(modifier = Modifier.size(ButtonDefaults.IconSpacing))
Icon( Icon(
imageVector = icon, imageVector = icon,
contentDescription = stringResource(R.string.delete), contentDescription = stringResource(R.string.delete),
modifier = Modifier.size(ButtonDefaults.IconSize).clickable { modifier =
Modifier.size(ButtonDefaults.IconSize).weight(1f, false).clickable {
if (enabled) { if (enabled) {
onIconClick() onIconClick()
} }

View File

@ -16,13 +16,21 @@ import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@Composable @Composable
fun PermissionRequestFailedScreen(padding : PaddingValues, onRequestAgain : () -> Unit, message : String, buttonText : String ) { fun PermissionRequestFailedScreen(
padding: PaddingValues,
onRequestAgain: () -> Unit,
message: String,
buttonText: String
) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
Column(horizontalAlignment = Alignment.CenterHorizontally, Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
modifier = Modifier modifier =
Modifier
.fillMaxSize() .fillMaxSize()
.padding(padding)) { .padding(padding)
) {
Text(message, textAlign = TextAlign.Center, modifier = Modifier.padding(15.dp)) Text(message, textAlign = TextAlign.Center, modifier = Modifier.padding(15.dp))
Button(onClick = { Button(onClick = {
scope.launch { scope.launch {

View File

@ -23,12 +23,18 @@ import com.zaneschepke.wireguardautotunnel.util.NumberUtils
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun RowListItem(icon : @Composable () -> Unit, text : String, onHold : () -> Unit, fun RowListItem(
onClick: () -> Unit, rowButton : @Composable () -> Unit, icon: @Composable () -> Unit,
expanded : Boolean, statistics: Statistics? text: String,
onHold: () -> Unit,
onClick: () -> Unit,
rowButton: @Composable () -> Unit,
expanded: Boolean,
statistics: Statistics?
) { ) {
Box( Box(
modifier = Modifier modifier =
Modifier
.animateContentSize() .animateContentSize()
.clip(RoundedCornerShape(30.dp)) .clip(RoundedCornerShape(30.dp))
.combinedClickable( .combinedClickable(
@ -42,13 +48,17 @@ fun RowListItem(icon : @Composable () -> Unit, text : String, onHold : () -> Uni
) { ) {
Column { Column {
Row( Row(
modifier = Modifier modifier =
Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 5.dp), .padding(horizontal = 14.dp, vertical = 5.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween horizontalArrangement = Arrangement.SpaceBetween
) { ) {
Row(verticalAlignment = Alignment.CenterVertically,) { Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(.60f)
) {
icon() icon()
Text(text) Text(text)
} }
@ -57,7 +67,8 @@ fun RowListItem(icon : @Composable () -> Unit, text : String, onHold : () -> Uni
if (expanded) { if (expanded) {
statistics?.peers()?.forEach { statistics?.peers()?.forEach {
Row( Row(
modifier = Modifier modifier =
Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(end = 10.dp, bottom = 10.dp, start = 10.dp), .padding(end = 10.dp, bottom = 10.dp, start = 10.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@ -67,8 +78,10 @@ fun RowListItem(icon : @Composable () -> Unit, text : String, onHold : () -> Uni
val peerTx = statistics.peer(it)!!.txBytes val peerTx = statistics.peer(it)!!.txBytes
val peerRx = statistics.peer(it)!!.rxBytes val peerRx = statistics.peer(it)!!.rxBytes
val peerId = it.toBase64().subSequence(0, 3).toString() + "***" val peerId = it.toBase64().subSequence(0, 3).toString() + "***"
val handshakeSec = NumberUtils.getSecondsBetweenTimestampAndNow(handshakeEpoch) val handshakeSec =
val handshake = if(handshakeSec == null) "never" else "$handshakeSec secs ago" NumberUtils.getSecondsBetweenTimestampAndNow(handshakeEpoch)
val handshake =
if (handshakeSec == null) "never" else "$handshakeSec secs ago"
val peerTxMB = NumberUtils.bytesToMB(peerTx).toThreeDecimalPlaceString() val peerTxMB = NumberUtils.bytesToMB(peerTx).toThreeDecimalPlaceString()
val peerRxMB = NumberUtils.bytesToMB(peerRx).toThreeDecimalPlaceString() val peerRxMB = NumberUtils.bytesToMB(peerRx).toThreeDecimalPlaceString()
val fontSize = 9.sp val fontSize = 9.sp

View File

@ -25,9 +25,7 @@ import androidx.compose.ui.text.input.KeyboardType
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
@Composable @Composable
fun SearchBar( fun SearchBar(onQuery: (queryString: String) -> Unit) {
onQuery : (queryString : String) -> Unit
) {
// Immediately update and keep track of query from text field changes. // Immediately update and keep track of query from text field changes.
var query: String by rememberSaveable { mutableStateOf("") } var query: String by rememberSaveable { mutableStateOf("") }
var showClearIcon by rememberSaveable { mutableStateOf(false) } var showClearIcon by rememberSaveable { mutableStateOf(false) }
@ -64,16 +62,18 @@ fun SearchBar(
} }
}, },
maxLines = 1, maxLines = 1,
colors = TextFieldDefaults.colors( colors =
TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent, focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent, unfocusedContainerColor = Color.Transparent,
disabledContainerColor = Color.Transparent, disabledContainerColor = Color.Transparent
), ),
placeholder = { Text(text = stringResource(R.string.hint_search_packages)) }, placeholder = { Text(text = stringResource(R.string.hint_search_packages)) },
textStyle = MaterialTheme.typography.bodySmall, textStyle = MaterialTheme.typography.bodySmall,
singleLine = true, singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text),
modifier = Modifier modifier =
Modifier
.fillMaxWidth() .fillMaxWidth()
.background(color = MaterialTheme.colorScheme.background, shape = RectangleShape) .background(color = MaterialTheme.colorScheme.background, shape = RectangleShape)
) )

View File

@ -10,8 +10,14 @@ import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardCapitalization
@Composable @Composable
fun fun ConfigurationTextBox(
ConfigurationTextBox(value : String, hint : String, onValueChange : (String) -> Unit, keyboardActions : KeyboardActions, label : String, modifier: Modifier) { value: String,
hint: String,
onValueChange: (String) -> Unit,
keyboardActions: KeyboardActions,
label: String,
modifier: Modifier
) {
OutlinedTextField( OutlinedTextField(
modifier = modifier, modifier = modifier,
value = value, value = value,
@ -24,10 +30,11 @@ fun
placeholder = { placeholder = {
Text(hint) Text(hint)
}, },
keyboardOptions = KeyboardOptions( keyboardOptions =
KeyboardOptions(
capitalization = KeyboardCapitalization.None, capitalization = KeyboardCapitalization.None,
imeAction = ImeAction.Done imeAction = ImeAction.Done
), ),
keyboardActions = keyboardActions, keyboardActions = keyboardActions
) )
} }

View File

@ -12,10 +12,17 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
@Composable @Composable
fun ConfigurationToggle(label : String, enabled : Boolean, checked : Boolean, padding : Dp, fun ConfigurationToggle(
onCheckChanged : () -> Unit, modifier : Modifier = Modifier) { label: String,
enabled: Boolean,
checked: Boolean,
padding: Dp,
onCheckChanged: () -> Unit,
modifier: Modifier = Modifier
) {
Row( Row(
modifier = Modifier modifier =
Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(padding), .padding(padding),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,

View File

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

View File

@ -11,36 +11,47 @@ import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
@Composable @Composable
fun AuthorizationPrompt(onSuccess : () -> Unit, onFailure : () -> Unit, onError : (String) -> Unit) { fun AuthorizationPrompt(
onSuccess: () -> Unit,
onFailure: () -> Unit,
onError: (String) -> Unit
) {
val context = LocalContext.current val context = LocalContext.current
val biometricManager = BiometricManager.from(context) val biometricManager = BiometricManager.from(context)
val bio = biometricManager.canAuthenticate(BIOMETRIC_WEAK or DEVICE_CREDENTIAL) val bio = biometricManager.canAuthenticate(BIOMETRIC_WEAK or DEVICE_CREDENTIAL)
val isBiometricAvailable = remember { val isBiometricAvailable =
remember {
when (bio) { when (bio) {
BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE -> { BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE -> {
onError("Biometrics not available") onError("Biometrics not available")
false false
} }
BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> { BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> {
onError("Biometrics not created") onError("Biometrics not created")
false false
} }
BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> { BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> {
onError("Biometric hardware not found") onError("Biometric hardware not found")
false false
} }
BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED -> { BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED -> {
onError("Biometric security update required") onError("Biometric security update required")
false false
} }
BiometricManager.BIOMETRIC_ERROR_UNSUPPORTED -> { BiometricManager.BIOMETRIC_ERROR_UNSUPPORTED -> {
onError("Biometrics not supported") onError("Biometrics not supported")
false false
} }
BiometricManager.BIOMETRIC_STATUS_UNKNOWN -> { BiometricManager.BIOMETRIC_STATUS_UNKNOWN -> {
onError("Biometrics status unknown") onError("Biometrics status unknown")
false false
} }
BiometricManager.BIOMETRIC_SUCCESS -> true BiometricManager.BIOMETRIC_SUCCESS -> true
else -> false else -> false
} }
@ -48,22 +59,29 @@ fun AuthorizationPrompt(onSuccess : () -> Unit, onFailure : () -> Unit, onError
if (isBiometricAvailable) { if (isBiometricAvailable) {
val executor = remember { ContextCompat.getMainExecutor(context) } val executor = remember { ContextCompat.getMainExecutor(context) }
val promptInfo = BiometricPrompt.PromptInfo.Builder() val promptInfo =
BiometricPrompt.PromptInfo.Builder()
.setAllowedAuthenticators(BIOMETRIC_WEAK or DEVICE_CREDENTIAL) .setAllowedAuthenticators(BIOMETRIC_WEAK or DEVICE_CREDENTIAL)
.setTitle("Biometric Authentication") .setTitle("Biometric Authentication")
.setSubtitle("Log in using your biometric credential") .setSubtitle("Log in using your biometric credential")
.build() .build()
val biometricPrompt = BiometricPrompt( val biometricPrompt =
BiometricPrompt(
context as FragmentActivity, context as FragmentActivity,
executor, executor,
object : BiometricPrompt.AuthenticationCallback() { object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { override fun onAuthenticationError(
errorCode: Int,
errString: CharSequence
) {
super.onAuthenticationError(errorCode, errString) super.onAuthenticationError(errorCode, errString)
onFailure() onFailure()
} }
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { override fun onAuthenticationSucceeded(
result: BiometricPrompt.AuthenticationResult
) {
super.onAuthenticationSucceeded(result) super.onAuthenticationSucceeded(result)
onSuccess() onSuccess()
} }

View File

@ -34,9 +34,12 @@ fun CustomSnackBar(
containerColor: Color = MaterialTheme.colorScheme.surface containerColor: Color = MaterialTheme.colorScheme.surface
) { ) {
val context = LocalContext.current val context = LocalContext.current
Snackbar(containerColor = containerColor, Snackbar(
modifier = Modifier.fillMaxWidth( containerColor = containerColor,
if(WireGuardAutoTunnel.isRunningOnAndroidTv(context)) 1/3f else 2/3f).padding(bottom = 100.dp), modifier =
Modifier.fillMaxWidth(
if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) 1 / 3f else 2 / 3f
).padding(bottom = 100.dp),
shape = RoundedCornerShape(16.dp) shape = RoundedCornerShape(16.dp)
) { ) {
CompositionLocalProvider( CompositionLocalProvider(

View File

@ -12,7 +12,10 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@Composable @Composable
fun SectionTitle(title : String, padding : Dp) { fun SectionTitle(
title: String,
padding: Dp
) {
Text( Text(
title, title,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,

View File

@ -8,7 +8,7 @@ data class InterfaceProxy(
var addresses: String = "", var addresses: String = "",
var dnsServers: String = "", var dnsServers: String = "",
var listenPort: String = "", var listenPort: String = "",
var mtu : String = "", var mtu: String = ""
) { ) {
companion object { companion object {
fun from(i: Interface): InterfaceProxy { fun from(i: Interface): InterfaceProxy {
@ -17,7 +17,12 @@ data class InterfaceProxy(
privateKey = i.keyPair.privateKey.toBase64().trim(), privateKey = i.keyPair.privateKey.toBase64().trim(),
addresses = i.addresses.joinToString(", ").trim(), addresses = i.addresses.joinToString(", ").trim(),
dnsServers = i.dnsServers.joinToString(", ").replace("/", "").trim(), dnsServers = i.dnsServers.joinToString(", ").replace("/", "").trim(),
listenPort = if(i.listenPort.isPresent) i.listenPort.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 "" mtu = if (i.mtu.isPresent) i.mtu.get().toString().trim() else ""
) )
} }

View File

@ -13,13 +13,30 @@ data class PeerProxy(
fun from(peer: Peer): PeerProxy { fun from(peer: Peer): PeerProxy {
return PeerProxy( return PeerProxy(
publicKey = peer.publicKey.toBase64(), publicKey = peer.publicKey.toBase64(),
preSharedKey = if(peer.preSharedKey.isPresent) peer.preSharedKey.get().toBase64().trim() else "", preSharedKey = if (peer.preSharedKey.isPresent) {
persistentKeepalive = if(peer.persistentKeepalive.isPresent) peer.persistentKeepalive.get().toString().trim() else "", peer.preSharedKey.get().toBase64()
endpoint = if(peer.endpoint.isPresent) peer.endpoint.get().toString().trim() else "", .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() allowedIps = peer.allowedIps.joinToString(", ").trim()
) )
} }
val IPV4_PUBLIC_NETWORKS = setOf(
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", "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", "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", "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",

View File

@ -86,7 +86,9 @@ import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class, @OptIn(
ExperimentalComposeUiApi::class,
ExperimentalMaterial3Api::class,
ExperimentalFoundationApi::class ExperimentalFoundationApi::class
) )
@Composable @Composable
@ -97,13 +99,11 @@ fun ConfigScreen(
showSnackbarMessage: (String) -> Unit, showSnackbarMessage: (String) -> Unit,
id: String id: String
) { ) {
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val clipboardManager: ClipboardManager = LocalClipboardManager.current val clipboardManager: ClipboardManager = LocalClipboardManager.current
val keyboardController = LocalSoftwareKeyboardController.current val keyboardController = LocalSoftwareKeyboardController.current
val tunnel by viewModel.tunnel.collectAsStateWithLifecycle(null) val tunnel by viewModel.tunnel.collectAsStateWithLifecycle(null)
val tunnelName = viewModel.tunnelName.collectAsStateWithLifecycle() val tunnelName = viewModel.tunnelName.collectAsStateWithLifecycle()
val packages by viewModel.packages.collectAsStateWithLifecycle() val packages by viewModel.packages.collectAsStateWithLifecycle()
@ -115,19 +115,22 @@ fun ConfigScreen(
var showApplicationsDialog by remember { mutableStateOf(false) } var showApplicationsDialog by remember { mutableStateOf(false) }
var showAuthPrompt by remember { mutableStateOf(false) } var showAuthPrompt by remember { mutableStateOf(false) }
var isAuthenticated by remember { mutableStateOf(false) } var isAuthenticated by remember { mutableStateOf(false) }
val baseTextBoxModifier = Modifier.onFocusChanged { val baseTextBoxModifier =
Modifier.onFocusChanged {
if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) { if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
keyboardController?.hide() keyboardController?.hide()
} }
} }
val keyboardActions = KeyboardActions( val keyboardActions =
KeyboardActions(
onDone = { onDone = {
keyboardController?.hide() keyboardController?.hide()
} }
) )
val keyboardOptions = KeyboardOptions( val keyboardOptions =
KeyboardOptions(
capitalization = KeyboardCapitalization.None, capitalization = KeyboardCapitalization.None,
imeAction = ImeAction.Done imeAction = ImeAction.Done
) )
@ -149,15 +152,19 @@ fun ConfigScreen(
val applicationButtonText = { val applicationButtonText = {
"Tunneling apps: " + "Tunneling apps: " +
if (isAllApplicationsEnabled) "all" if (isAllApplicationsEnabled) {
else "${checkedPackages.size} " + (if (include) "included" else "excluded") "all"
} else {
"${checkedPackages.size} " + (if (include) "included" else "excluded")
}
} }
if (showAuthPrompt) { if (showAuthPrompt) {
AuthorizationPrompt(onSuccess = { AuthorizationPrompt(
onSuccess = {
showAuthPrompt = false showAuthPrompt = false
isAuthenticated = true }, isAuthenticated = true
},
onError = { error -> onError = { error ->
showSnackbarMessage(error) showSnackbarMessage(error)
showAuthPrompt = false showAuthPrompt = false
@ -165,11 +172,13 @@ fun ConfigScreen(
onFailure = { onFailure = {
showAuthPrompt = false showAuthPrompt = false
showSnackbarMessage(context.getString(R.string.authentication_failed)) showSnackbarMessage(context.getString(R.string.authentication_failed))
}) }
)
} }
if (showApplicationsDialog) { if (showApplicationsDialog) {
val sortedPackages = remember(packages) { val sortedPackages =
remember(packages) {
packages.sortedBy { viewModel.getPackageLabel(it) } packages.sortedBy { viewModel.getPackageLabel(it) }
} }
AlertDialog(onDismissRequest = { AlertDialog(onDismissRequest = {
@ -180,7 +189,8 @@ fun ConfigScreen(
shadowElevation = 2.dp, shadowElevation = 2.dp,
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surface, color = MaterialTheme.colorScheme.surface,
modifier = Modifier modifier =
Modifier
.fillMaxWidth() .fillMaxWidth()
.fillMaxHeight(if (isAllApplicationsEnabled) 1 / 5f else 4 / 5f) .fillMaxHeight(if (isAllApplicationsEnabled) 1 / 5f else 4 / 5f)
) { ) {
@ -188,7 +198,8 @@ fun ConfigScreen(
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Row( Row(
modifier = Modifier modifier =
Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 7.dp), .padding(horizontal = 20.dp, vertical = 7.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@ -204,7 +215,8 @@ fun ConfigScreen(
} }
if (!isAllApplicationsEnabled) { if (!isAllApplicationsEnabled) {
Row( Row(
modifier = Modifier modifier =
Modifier
.fillMaxWidth() .fillMaxWidth()
.padding( .padding(
horizontal = 20.dp, horizontal = 20.dp,
@ -239,7 +251,8 @@ fun ConfigScreen(
} }
} }
Row( Row(
modifier = Modifier modifier =
Modifier
.fillMaxWidth() .fillMaxWidth()
.padding( .padding(
horizontal = 20.dp, horizontal = 20.dp,
@ -254,21 +267,25 @@ fun ConfigScreen(
LazyColumn( LazyColumn(
horizontalAlignment = Alignment.Start, horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Top, verticalArrangement = Arrangement.Top,
modifier = Modifier modifier =
Modifier
.fillMaxHeight(4 / 5f) .fillMaxHeight(4 / 5f)
) { ) {
items( items(
sortedPackages, sortedPackages,
key = { it.packageName }) { pack -> key = { it.packageName }
) { pack ->
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier modifier =
Modifier
.fillMaxSize() .fillMaxSize()
.padding(5.dp) .padding(5.dp)
) { ) {
Row( Row(
modifier = Modifier.fillMaxWidth( modifier =
Modifier.fillMaxWidth(
fillMaxWidth fillMaxWidth
) )
) { ) {
@ -278,11 +295,13 @@ fun ConfigScreen(
) )
if (drawable != null) { if (drawable != null) {
Image( Image(
painter = DrawablePainter( painter =
DrawablePainter(
drawable drawable
), ),
stringResource(id = R.string.icon), stringResource(id = R.string.icon),
modifier = Modifier.size( modifier =
Modifier.size(
50.dp, 50.dp,
50.dp 50.dp
) )
@ -291,7 +310,8 @@ fun ConfigScreen(
Icon( Icon(
Icons.Rounded.Android, Icons.Rounded.Android,
stringResource(id = R.string.edit), stringResource(id = R.string.edit),
modifier = Modifier.size( modifier =
Modifier.size(
50.dp, 50.dp,
50.dp 50.dp
) )
@ -306,11 +326,15 @@ fun ConfigScreen(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
checked = (checkedPackages.contains(pack.packageName)), checked = (checkedPackages.contains(pack.packageName)),
onCheckedChange = { onCheckedChange = {
if (it) viewModel.onAddCheckedPackage( if (it) {
pack.packageName viewModel.onAddCheckedPackage(
) else viewModel.onRemoveCheckedPackage(
pack.packageName pack.packageName
) )
} else {
viewModel.onRemoveCheckedPackage(
pack.packageName
)
}
} }
) )
} }
@ -319,7 +343,8 @@ fun ConfigScreen(
} }
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier modifier =
Modifier
.fillMaxSize() .fillMaxSize()
.padding(top = 5.dp), .padding(top = 5.dp),
horizontalArrangement = Arrangement.Center horizontalArrangement = Arrangement.Center
@ -327,7 +352,8 @@ fun ConfigScreen(
TextButton( TextButton(
onClick = { onClick = {
showApplicationsDialog = false showApplicationsDialog = false
}) { }
) {
Text(stringResource(R.string.done)) Text(stringResource(R.string.done))
} }
} }
@ -336,7 +362,6 @@ fun ConfigScreen(
} }
} }
if (tunnel != null) { if (tunnel != null) {
Scaffold( Scaffold(
floatingActionButtonPosition = FabPosition.End, floatingActionButtonPosition = FabPosition.End,
@ -345,16 +370,20 @@ fun ConfigScreen(
val hoverColor = MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp) val hoverColor = MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp)
var fobColor by remember { mutableStateOf(secondaryColor) } var fobColor by remember { mutableStateOf(secondaryColor) }
FloatingActionButton( FloatingActionButton(
modifier = Modifier.padding(bottom = 90.dp).onFocusChanged { modifier =
Modifier.padding(bottom = 90.dp).onFocusChanged {
if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) { if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
fobColor = if (it.isFocused) hoverColor else secondaryColor } fobColor = if (it.isFocused) hoverColor else secondaryColor
}
}, },
onClick = { onClick = {
scope.launch { scope.launch {
try { try {
viewModel.onSaveAllChanges() viewModel.onSaveAllChanges()
navController.navigate(Routes.Main.name) navController.navigate(Routes.Main.name)
showSnackbarMessage(context.resources.getString(R.string.config_changes_saved)) showSnackbarMessage(
context.resources.getString(R.string.config_changes_saved)
)
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e.message) Timber.e(e.message)
showSnackbarMessage(e.message!!) showSnackbarMessage(e.message!!)
@ -362,20 +391,22 @@ fun ConfigScreen(
} }
}, },
containerColor = fobColor, containerColor = fobColor,
shape = RoundedCornerShape(16.dp), shape = RoundedCornerShape(16.dp)
) { ) {
Icon( Icon(
imageVector = Icons.Rounded.Save, imageVector = Icons.Rounded.Save,
contentDescription = stringResource(id = R.string.save_changes), contentDescription = stringResource(id = R.string.save_changes),
tint = Color.DarkGray, tint = Color.DarkGray
) )
} }
}) { }
) {
Column { Column {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top, verticalArrangement = Arrangement.Top,
modifier = Modifier modifier =
Modifier
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.weight(1f, true) .weight(1f, true)
.fillMaxSize() .fillMaxSize()
@ -385,11 +416,16 @@ fun ConfigScreen(
shadowElevation = 2.dp, shadowElevation = 2.dp,
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surface, color = MaterialTheme.colorScheme.surface,
modifier = (if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) modifier =
(
if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
Modifier Modifier
.fillMaxHeight(fillMaxHeight) .fillMaxHeight(fillMaxHeight)
.fillMaxWidth(fillMaxWidth) .fillMaxWidth(fillMaxWidth)
else Modifier.fillMaxWidth(fillMaxWidth)).padding( } else {
Modifier.fillMaxWidth(fillMaxWidth)
}
).padding(
top = 50.dp, top = 50.dp,
bottom = 10.dp bottom = 10.dp
) )
@ -399,7 +435,10 @@ fun ConfigScreen(
verticalArrangement = Arrangement.Top, verticalArrangement = Arrangement.Top,
modifier = Modifier.padding(15.dp).focusGroup() modifier = Modifier.padding(15.dp).focusGroup()
) { ) {
SectionTitle(stringResource(R.string.interface_), padding = screenPadding) SectionTitle(
stringResource(R.string.interface_),
padding = screenPadding
)
ConfigurationTextBox( ConfigurationTextBox(
value = tunnelName.value, value = tunnelName.value,
onValueChange = { value -> onValueChange = { value ->
@ -408,10 +447,13 @@ fun ConfigScreen(
keyboardActions = keyboardActions, keyboardActions = keyboardActions,
label = stringResource(R.string.name), label = stringResource(R.string.name),
hint = stringResource(R.string.tunnel_name).lowercase(), hint = stringResource(R.string.tunnel_name).lowercase(),
modifier = baseTextBoxModifier.fillMaxWidth().focusRequester(focusRequester) modifier = baseTextBoxModifier.fillMaxWidth().focusRequester(
focusRequester
)
) )
OutlinedTextField( OutlinedTextField(
modifier = baseTextBoxModifier.fillMaxWidth().clickable { modifier =
baseTextBoxModifier.fillMaxWidth().clickable {
showAuthPrompt = true showAuthPrompt = true
}, },
value = proxyInterface.privateKey, value = proxyInterface.privateKey,
@ -425,7 +467,8 @@ fun ConfigScreen(
modifier = Modifier.focusRequester(FocusRequester.Default), modifier = Modifier.focusRequester(FocusRequester.Default),
onClick = { onClick = {
viewModel.generateKeyPair() viewModel.generateKeyPair()
}) { }
) {
Icon( Icon(
Icons.Rounded.Refresh, Icons.Rounded.Refresh,
stringResource(R.string.rotate_keys), stringResource(R.string.rotate_keys),
@ -440,7 +483,9 @@ fun ConfigScreen(
keyboardActions = keyboardActions keyboardActions = keyboardActions
) )
OutlinedTextField( OutlinedTextField(
modifier = baseTextBoxModifier.fillMaxWidth().focusRequester(FocusRequester.Default), modifier = baseTextBoxModifier.fillMaxWidth().focusRequester(
FocusRequester.Default
),
value = proxyInterface.publicKey, value = proxyInterface.publicKey,
enabled = false, enabled = false,
onValueChange = {}, onValueChange = {},
@ -448,8 +493,11 @@ fun ConfigScreen(
IconButton( IconButton(
modifier = Modifier.focusRequester(FocusRequester.Default), modifier = Modifier.focusRequester(FocusRequester.Default),
onClick = { onClick = {
clipboardManager.setText(AnnotatedString(proxyInterface.publicKey)) clipboardManager.setText(
}) { AnnotatedString(proxyInterface.publicKey)
)
}
) {
Icon( Icon(
Icons.Rounded.ContentCopy, Icons.Rounded.ContentCopy,
stringResource(R.string.copy_public_key), stringResource(R.string.copy_public_key),
@ -472,7 +520,8 @@ fun ConfigScreen(
keyboardActions = keyboardActions, keyboardActions = keyboardActions,
label = stringResource(R.string.addresses), label = stringResource(R.string.addresses),
hint = stringResource(R.string.comma_separated_list), hint = stringResource(R.string.comma_separated_list),
modifier = baseTextBoxModifier modifier =
baseTextBoxModifier
.fillMaxWidth(3 / 5f) .fillMaxWidth(3 / 5f)
.padding(end = 5.dp) .padding(end = 5.dp)
) )
@ -492,7 +541,8 @@ fun ConfigScreen(
keyboardActions = keyboardActions, keyboardActions = keyboardActions,
label = stringResource(R.string.dns_servers), label = stringResource(R.string.dns_servers),
hint = stringResource(R.string.comma_separated_list), hint = stringResource(R.string.comma_separated_list),
modifier = baseTextBoxModifier modifier =
baseTextBoxModifier
.fillMaxWidth(3 / 5f) .fillMaxWidth(3 / 5f)
.padding(end = 5.dp) .padding(end = 5.dp)
) )
@ -507,7 +557,8 @@ fun ConfigScreen(
} }
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier modifier =
Modifier
.fillMaxSize() .fillMaxSize()
.padding(top = 5.dp), .padding(top = 5.dp),
horizontalArrangement = Arrangement.Center horizontalArrangement = Arrangement.Center
@ -515,7 +566,8 @@ fun ConfigScreen(
TextButton( TextButton(
onClick = { onClick = {
showApplicationsDialog = true showApplicationsDialog = true
}) { }
) {
Text(applicationButtonText()) Text(applicationButtonText())
} }
} }
@ -527,11 +579,16 @@ fun ConfigScreen(
shadowElevation = 2.dp, shadowElevation = 2.dp,
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surface, color = MaterialTheme.colorScheme.surface,
modifier = (if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) modifier =
(
if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
Modifier Modifier
.fillMaxHeight(fillMaxHeight) .fillMaxHeight(fillMaxHeight)
.fillMaxWidth(fillMaxWidth) .fillMaxWidth(fillMaxWidth)
else Modifier.fillMaxWidth(fillMaxWidth)).padding( } else {
Modifier.fillMaxWidth(fillMaxWidth)
}
).padding(
top = 10.dp, top = 10.dp,
bottom = 10.dp bottom = 10.dp
) )
@ -539,18 +596,23 @@ fun ConfigScreen(
Column( Column(
horizontalAlignment = Alignment.Start, horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Top, verticalArrangement = Arrangement.Top,
modifier = Modifier modifier =
Modifier
.padding(horizontal = 15.dp) .padding(horizontal = 15.dp)
.padding(bottom = 10.dp) .padding(bottom = 10.dp)
) { ) {
Row( Row(
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier modifier =
Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 5.dp) .padding(horizontal = 5.dp)
) { ) {
SectionTitle(stringResource(R.string.peer), padding = screenPadding) SectionTitle(
stringResource(R.string.peer),
padding = screenPadding
)
IconButton( IconButton(
onClick = { onClick = {
viewModel.onDeletePeer(index) viewModel.onDeletePeer(index)
@ -593,10 +655,17 @@ fun ConfigScreen(
onValueChange = { value -> onValueChange = { value ->
viewModel.onPersistentKeepaliveChanged(index, value) viewModel.onPersistentKeepaliveChanged(index, value)
}, },
trailingIcon = { Text(stringResource(R.string.seconds), modifier = Modifier.padding(end = 10.dp)) }, trailingIcon = {
Text(
stringResource(R.string.seconds),
modifier = Modifier.padding(end = 10.dp)
)
},
label = { Text(stringResource(R.string.persistent_keepalive)) }, label = { Text(stringResource(R.string.persistent_keepalive)) },
singleLine = true, singleLine = true,
placeholder = { Text(stringResource(R.string.optional_no_recommend)) }, placeholder = {
Text(stringResource(R.string.optional_no_recommend))
},
keyboardOptions = keyboardOptions, keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions keyboardActions = keyboardActions
) )
@ -625,7 +694,9 @@ fun ConfigScreen(
}, },
label = { Text(stringResource(R.string.allowed_ips)) }, label = { Text(stringResource(R.string.allowed_ips)) },
singleLine = true, singleLine = true,
placeholder = { Text(stringResource(R.string.comma_separated_list)) }, placeholder = {
Text(stringResource(R.string.comma_separated_list))
},
keyboardOptions = keyboardOptions, keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions keyboardActions = keyboardActions
) )
@ -635,11 +706,11 @@ fun ConfigScreen(
Row( Row(
horizontalArrangement = Arrangement.SpaceEvenly, horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier modifier =
Modifier
.fillMaxSize() .fillMaxSize()
.padding(bottom = 140.dp) .padding(bottom = 140.dp)
) { ) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center horizontalArrangement = Arrangement.Center
@ -647,7 +718,8 @@ fun ConfigScreen(
TextButton( TextButton(
onClick = { onClick = {
viewModel.addEmptyPeer() viewModel.addEmptyPeer()
}) { }
) {
Text(stringResource(R.string.add_peer)) Text(stringResource(R.string.add_peer))
} }
} }

View File

@ -23,18 +23,20 @@ import com.zaneschepke.wireguardautotunnel.ui.models.PeerProxy
import com.zaneschepke.wireguardautotunnel.util.NumberUtils import com.zaneschepke.wireguardautotunnel.util.NumberUtils
import com.zaneschepke.wireguardautotunnel.util.WgTunnelException import com.zaneschepke.wireguardautotunnel.util.WgTunnelException
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel @HiltViewModel
class ConfigViewModel @Inject constructor(private val application : Application, class ConfigViewModel
@Inject
constructor(
private val application: Application,
private val tunnelRepo: TunnelConfigDao, private val tunnelRepo: TunnelConfigDao,
private val settingsRepo: SettingsDoa private val settingsRepo: SettingsDoa
) : ViewModel() { ) : ViewModel() {
private val _tunnel = MutableStateFlow<TunnelConfig?>(null) private val _tunnel = MutableStateFlow<TunnelConfig?>(null)
private val _tunnelName = MutableStateFlow("") private val _tunnelName = MutableStateFlow("")
val tunnelName get() = _tunnelName.asStateFlow() val tunnelName get() = _tunnelName.asStateFlow()
@ -58,13 +60,14 @@ class ConfigViewModel @Inject constructor(private val application : Application,
private val _isAllApplicationsEnabled = MutableStateFlow(false) private val _isAllApplicationsEnabled = MutableStateFlow(false)
val isAllApplicationsEnabled get() = _isAllApplicationsEnabled.asStateFlow() val isAllApplicationsEnabled get() = _isAllApplicationsEnabled.asStateFlow()
private val _isDefaultTunnel = MutableStateFlow(false) private val _isDefaultTunnel = MutableStateFlow(false)
val isDefaultTunnel = _isDefaultTunnel.asStateFlow()
private lateinit var tunnelConfig: TunnelConfig private lateinit var tunnelConfig: TunnelConfig
suspend fun onScreenLoad(id: String) { suspend fun onScreenLoad(id: String) {
if (id != Constants.MANUAL_TUNNEL_CONFIG_ID) { if (id != Constants.MANUAL_TUNNEL_CONFIG_ID) {
tunnelConfig = getTunnelConfigById(id) ?: throw WgTunnelException("Config not found") tunnelConfig = getTunnelConfigById(id) ?: throw WgTunnelException(
"Config not found"
)
emitScreenData() emitScreenData()
} else { } else {
emitEmptyScreenData() emitEmptyScreenData()
@ -84,7 +87,6 @@ class ConfigViewModel @Inject constructor(private val application : Application,
} }
} }
private suspend fun emitScreenData() { private suspend fun emitScreenData() {
emitTunnelConfig() emitTunnelConfig()
emitPeersFromConfig() emitPeersFromConfig()
@ -145,6 +147,7 @@ class ConfigViewModel @Inject constructor(private val application : Application,
fun onIncludeChange(include: Boolean) { fun onIncludeChange(include: Boolean) {
_include.value = include _include.value = include
} }
fun onAddCheckedPackage(packageName: String) { fun onAddCheckedPackage(packageName: String) {
_checkedPackages.value.add(packageName) _checkedPackages.value.add(packageName)
} }
@ -168,7 +171,10 @@ class ConfigViewModel @Inject constructor(private val application : Application,
} }
} }
private suspend fun determineAppInclusionState(excludedApps : Set<String>, includedApps : Set<String>) { private suspend fun determineAppInclusionState(
excludedApps: Set<String>,
includedApps: Set<String>
) {
if (excludedApps.isEmpty()) { if (excludedApps.isEmpty()) {
emitIncludedAppsExist() emitIncludedAppsExist()
emitCheckedApps(includedApps) emitCheckedApps(includedApps)
@ -207,7 +213,8 @@ class ConfigViewModel @Inject constructor(private val application : Application,
fun emitQueriedPackages(query: String) { fun emitQueriedPackages(query: String) {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
val packages = getAllInternetCapablePackages().filter { val packages =
getAllInternetCapablePackages().filter {
getPackageLabel(it).lowercase().contains(query.lowercase()) getPackageLabel(it).lowercase().contains(query.lowercase())
} }
_packages.emit(packages) _packages.emit(packages)
@ -218,14 +225,16 @@ class ConfigViewModel @Inject constructor(private val application : Application,
return packageInfo.applicationInfo.loadLabel(application.packageManager).toString() return packageInfo.applicationInfo.loadLabel(application.packageManager).toString()
} }
private fun getAllInternetCapablePackages(): List<PackageInfo> { private fun getAllInternetCapablePackages(): List<PackageInfo> {
return getPackagesHoldingPermissions(arrayOf(Manifest.permission.INTERNET)) return getPackagesHoldingPermissions(arrayOf(Manifest.permission.INTERNET))
} }
private fun getPackagesHoldingPermissions(permissions: Array<String>): List<PackageInfo> { private fun getPackagesHoldingPermissions(permissions: Array<String>): List<PackageInfo> {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
packageManager.getPackagesHoldingPermissions(permissions, PackageManager.PackageInfoFlags.of(0L)) packageManager.getPackagesHoldingPermissions(
permissions,
PackageManager.PackageInfoFlags.of(0L)
)
} else { } else {
packageManager.getPackagesHoldingPermissions(permissions, 0) packageManager.getPackagesHoldingPermissions(permissions, 0)
} }
@ -235,13 +244,10 @@ class ConfigViewModel @Inject constructor(private val application : Application,
return _isAllApplicationsEnabled.value return _isAllApplicationsEnabled.value
} }
private fun isIncludeApplicationsEnabled() : Boolean {
return _include.value
}
private suspend fun saveConfig(tunnelConfig: TunnelConfig) { private suspend fun saveConfig(tunnelConfig: TunnelConfig) {
tunnelRepo.save(tunnelConfig) tunnelRepo.save(tunnelConfig)
} }
private suspend fun updateTunnelConfig(tunnelConfig: TunnelConfig?) { private suspend fun updateTunnelConfig(tunnelConfig: TunnelConfig?) {
if (tunnelConfig != null) { if (tunnelConfig != null) {
saveConfig(tunnelConfig) saveConfig(tunnelConfig)
@ -255,82 +261,113 @@ class ConfigViewModel @Inject constructor(private val application : Application,
val setting = settings[0] val setting = settings[0]
if (setting.defaultTunnel != null) { if (setting.defaultTunnel != null) {
if (tunnelConfig.id == TunnelConfig.from(setting.defaultTunnel!!).id) { if (tunnelConfig.id == TunnelConfig.from(setting.defaultTunnel!!).id) {
settingsRepo.save(setting.copy( settingsRepo.save(
setting.copy(
defaultTunnel = tunnelConfig.toString() defaultTunnel = tunnelConfig.toString()
)) )
)
} }
} }
} }
} }
fun buildPeerListFromProxyPeers() : List<Peer> { private fun buildPeerListFromProxyPeers(): List<Peer> {
return _proxyPeers.value.map { return _proxyPeers.value.map {
val builder = Peer.Builder() val builder = Peer.Builder()
if (it.allowedIps.isNotEmpty()) builder.parseAllowedIPs(it.allowedIps.trim()) if (it.allowedIps.isNotEmpty()) builder.parseAllowedIPs(it.allowedIps.trim())
if (it.publicKey.isNotEmpty()) builder.parsePublicKey(it.publicKey.trim()) if (it.publicKey.isNotEmpty()) builder.parsePublicKey(it.publicKey.trim())
if (it.preSharedKey.isNotEmpty()) builder.parsePreSharedKey(it.preSharedKey.trim()) if (it.preSharedKey.isNotEmpty()) builder.parsePreSharedKey(it.preSharedKey.trim())
if (it.endpoint.isNotEmpty()) builder.parseEndpoint(it.endpoint.trim()) if (it.endpoint.isNotEmpty()) builder.parseEndpoint(it.endpoint.trim())
if (it.persistentKeepalive.isNotEmpty()) builder.parsePersistentKeepalive(it.persistentKeepalive.trim()) if (it.persistentKeepalive.isNotEmpty()) {
builder.parsePersistentKeepalive(
it.persistentKeepalive.trim()
)
}
builder.build() builder.build()
} }
} }
fun buildInterfaceListFromProxyInterface() : Interface { private fun buildInterfaceListFromProxyInterface(): Interface {
val builder = Interface.Builder() val builder = Interface.Builder()
builder.parsePrivateKey(_interface.value.privateKey.trim()) builder.parsePrivateKey(_interface.value.privateKey.trim())
builder.parseAddresses(_interface.value.addresses.trim()) builder.parseAddresses(_interface.value.addresses.trim())
builder.parseDnsServers(_interface.value.dnsServers.trim()) builder.parseDnsServers(_interface.value.dnsServers.trim())
if (_interface.value.mtu.isNotEmpty()) builder.parseMtu(_interface.value.mtu.trim()) if (_interface.value.mtu.isNotEmpty()) builder.parseMtu(_interface.value.mtu.trim())
if(_interface.value.listenPort.isNotEmpty()) builder.parseListenPort(_interface.value.listenPort.trim()) if (_interface.value.listenPort.isNotEmpty()) {
builder.parseListenPort(
_interface.value.listenPort.trim()
)
}
if (isAllApplicationsEnabled()) _checkedPackages.value.clear() if (isAllApplicationsEnabled()) _checkedPackages.value.clear()
if (_include.value) builder.includeApplications(_checkedPackages.value) if (_include.value) builder.includeApplications(_checkedPackages.value)
if (!_include.value) builder.excludeApplications(_checkedPackages.value) if (!_include.value) builder.excludeApplications(_checkedPackages.value)
return builder.build() return builder.build()
} }
suspend fun onSaveAllChanges() { suspend fun onSaveAllChanges() {
try { try {
val peerList = buildPeerListFromProxyPeers() val peerList = buildPeerListFromProxyPeers()
val wgInterface = buildInterfaceListFromProxyInterface() val wgInterface = buildInterfaceListFromProxyInterface()
val config = Config.Builder().addPeers(peerList).setInterface(wgInterface).build() val config = Config.Builder().addPeers(peerList).setInterface(wgInterface).build()
val tunnelConfig = _tunnel.value?.copy( val tunnelConfig =
_tunnel.value?.copy(
name = _tunnelName.value, name = _tunnelName.value,
wgQuick = config.toWgQuickString() wgQuick = config.toWgQuickString()
) )
updateTunnelConfig(tunnelConfig) updateTunnelConfig(tunnelConfig)
} catch (e: Exception) { } catch (e: Exception) {
throw WgTunnelException("Error: ${e.cause?.message?.lowercase() ?: "unknown error occurred"}") throw WgTunnelException(
"Error: ${e.cause?.message?.lowercase() ?: "unknown error occurred"}"
)
} }
} }
fun onPeerPublicKeyChange(index: Int, publicKey: String) { fun onPeerPublicKeyChange(
_proxyPeers.value[index] = _proxyPeers.value[index].copy( index: Int,
publicKey: String
) {
_proxyPeers.value[index] =
_proxyPeers.value[index].copy(
publicKey = publicKey publicKey = publicKey
) )
} }
fun onPreSharedKeyChange(index: Int, value: String) { fun onPreSharedKeyChange(
_proxyPeers.value[index] = _proxyPeers.value[index].copy( index: Int,
value: String
) {
_proxyPeers.value[index] =
_proxyPeers.value[index].copy(
preSharedKey = value preSharedKey = value
) )
} }
fun onEndpointChange(index: Int, value: String) { fun onEndpointChange(
_proxyPeers.value[index] = _proxyPeers.value[index].copy( index: Int,
value: String
) {
_proxyPeers.value[index] =
_proxyPeers.value[index].copy(
endpoint = value endpoint = value
) )
} }
fun onAllowedIpsChange(index: Int, value: String) { fun onAllowedIpsChange(
_proxyPeers.value[index] = _proxyPeers.value[index].copy( index: Int,
value: String
) {
_proxyPeers.value[index] =
_proxyPeers.value[index].copy(
allowedIps = value allowedIps = value
) )
} }
fun onPersistentKeepaliveChanged(index : Int, value : String) { fun onPersistentKeepaliveChanged(
_proxyPeers.value[index] = _proxyPeers.value[index].copy( index: Int,
value: String
) {
_proxyPeers.value[index] =
_proxyPeers.value[index].copy(
persistentKeepalive = value persistentKeepalive = value
) )
} }
@ -345,44 +382,51 @@ class ConfigViewModel @Inject constructor(private val application : Application,
fun generateKeyPair() { fun generateKeyPair() {
val keyPair = KeyPair() val keyPair = KeyPair()
_interface.value = _interface.value.copy( _interface.value =
_interface.value.copy(
privateKey = keyPair.privateKey.toBase64(), privateKey = keyPair.privateKey.toBase64(),
publicKey = keyPair.publicKey.toBase64() publicKey = keyPair.publicKey.toBase64()
) )
} }
fun onAddressesChanged(value: String) { fun onAddressesChanged(value: String) {
_interface.value = _interface.value.copy( _interface.value =
_interface.value.copy(
addresses = value addresses = value
) )
} }
fun onListenPortChanged(value: String) { fun onListenPortChanged(value: String) {
_interface.value = _interface.value.copy( _interface.value =
_interface.value.copy(
listenPort = value listenPort = value
) )
} }
fun onDnsServersChanged(value: String) { fun onDnsServersChanged(value: String) {
_interface.value = _interface.value.copy( _interface.value =
_interface.value.copy(
dnsServers = value dnsServers = value
) )
} }
fun onMtuChanged(value: String) { fun onMtuChanged(value: String) {
_interface.value = _interface.value.copy( _interface.value =
_interface.value.copy(
mtu = value mtu = value
) )
} }
private fun onInterfacePublicKeyChange(value: String) { private fun onInterfacePublicKeyChange(value: String) {
_interface.value = _interface.value.copy( _interface.value =
_interface.value.copy(
publicKey = value publicKey = value
) )
} }
fun onPrivateKeyChange(value: String) { fun onPrivateKeyChange(value: String) {
_interface.value = _interface.value.copy( _interface.value =
_interface.value.copy(
privateKey = value privateKey = value
) )
if (NumberUtils.isValidKey(value)) { if (NumberUtils.isValidKey(value)) {

View File

@ -88,6 +88,7 @@ import com.zaneschepke.wireguardautotunnel.ui.CaptureActivityPortrait
import com.zaneschepke.wireguardautotunnel.ui.Routes import com.zaneschepke.wireguardautotunnel.ui.Routes
import com.zaneschepke.wireguardautotunnel.ui.common.RowListItem import com.zaneschepke.wireguardautotunnel.ui.common.RowListItem
import com.zaneschepke.wireguardautotunnel.ui.theme.brickRed import com.zaneschepke.wireguardautotunnel.ui.theme.brickRed
import com.zaneschepke.wireguardautotunnel.ui.theme.corn
import com.zaneschepke.wireguardautotunnel.ui.theme.mint import com.zaneschepke.wireguardautotunnel.ui.theme.mint
import com.zaneschepke.wireguardautotunnel.util.WgTunnelException import com.zaneschepke.wireguardautotunnel.util.WgTunnelException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -102,7 +103,6 @@ fun MainScreen(
showSnackbarMessage: (String) -> Unit, showSnackbarMessage: (String) -> Unit,
navController: NavController navController: NavController
) { ) {
val haptic = LocalHapticFeedback.current val haptic = LocalHapticFeedback.current
val context = LocalContext.current val context = LocalContext.current
val isVisible = rememberSaveable { mutableStateOf(true) } val isVisible = rememberSaveable { mutableStateOf(true) }
@ -112,7 +112,9 @@ fun MainScreen(
var showBottomSheet by remember { mutableStateOf(false) } var showBottomSheet by remember { mutableStateOf(false) }
var showPrimaryChangeAlertDialog by remember { mutableStateOf(false) } var showPrimaryChangeAlertDialog by remember { mutableStateOf(false) }
val tunnels by viewModel.tunnels.collectAsStateWithLifecycle(mutableListOf()) val tunnels by viewModel.tunnels.collectAsStateWithLifecycle(mutableListOf())
val handshakeStatus by viewModel.handshakeStatus.collectAsStateWithLifecycle(HandshakeStatus.NOT_STARTED) val handshakeStatus by viewModel.handshakeStatus.collectAsStateWithLifecycle(
HandshakeStatus.NOT_STARTED
)
var selectedTunnel by remember { mutableStateOf<TunnelConfig?>(null) } var selectedTunnel by remember { mutableStateOf<TunnelConfig?>(null) }
val state by viewModel.state.collectAsStateWithLifecycle(Tunnel.State.DOWN) val state by viewModel.state.collectAsStateWithLifecycle(Tunnel.State.DOWN)
val tunnelName by viewModel.tunnelName.collectAsStateWithLifecycle("") val tunnelName by viewModel.tunnelName.collectAsStateWithLifecycle("")
@ -120,9 +122,13 @@ fun MainScreen(
val statistics by viewModel.statistics.collectAsStateWithLifecycle(null) val statistics by viewModel.statistics.collectAsStateWithLifecycle(null)
// Nested scroll for control FAB // Nested scroll for control FAB
val nestedScrollConnection = remember { val nestedScrollConnection =
remember {
object : NestedScrollConnection { object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { override fun onPreScroll(
available: Offset,
source: NestedScrollSource
): Offset {
// Hide FAB // Hide FAB
if (available.y < -1) { if (available.y < -1) {
isVisible.value = false isVisible.value = false
@ -136,26 +142,44 @@ fun MainScreen(
} }
} }
val tunnelFileImportResultLauncher = rememberLauncherForActivityResult(object : ActivityResultContracts.GetContent() { val tunnelFileImportResultLauncher =
override fun createIntent(context: Context, input: String): Intent { rememberLauncherForActivityResult(
object : ActivityResultContracts.GetContent() {
override fun createIntent(
context: Context,
input: String
): Intent {
val intent = super.createIntent(context, input) val intent = super.createIntent(context, input)
/* AndroidTV now comes with stubs that do nothing but display a Toast less helpful than /* AndroidTV now comes with stubs that do nothing but display a Toast less helpful than
* what we can do, so detect this and throw an exception that we can catch later. */ * what we can do, so detect this and throw an exception that we can catch later. */
val activitiesToResolveIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { val activitiesToResolveIntent =
context.packageManager.queryIntentActivities(intent, PackageManager.ResolveInfoFlags.of(PackageManager.MATCH_DEFAULT_ONLY.toLong())) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.packageManager.queryIntentActivities(
intent,
PackageManager.ResolveInfoFlags.of(
PackageManager.MATCH_DEFAULT_ONLY.toLong()
)
)
} else { } else {
context.packageManager.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY) context.packageManager.queryIntentActivities(
intent,
PackageManager.MATCH_DEFAULT_ONLY
)
} }
if (activitiesToResolveIntent.all { if (activitiesToResolveIntent.all {
val name = it.activityInfo.packageName val name = it.activityInfo.packageName
name.startsWith(Constants.GOOGLE_TV_EXPLORER_STUB) || name.startsWith(Constants.ANDROID_TV_EXPLORER_STUB) name.startsWith(Constants.GOOGLE_TV_EXPLORER_STUB) || name.startsWith(
}) { Constants.ANDROID_TV_EXPLORER_STUB
)
}
) {
throw WgTunnelException(context.getString(R.string.no_file_explorer)) throw WgTunnelException(context.getString(R.string.no_file_explorer))
} }
return intent return intent
} }
}) { data -> }
) { data ->
if (data == null) return@rememberLauncherForActivityResult if (data == null) return@rememberLauncherForActivityResult
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
try { try {
@ -166,7 +190,8 @@ fun MainScreen(
} }
} }
val scanLauncher = rememberLauncherForActivityResult( val scanLauncher =
rememberLauncherForActivityResult(
contract = ScanContract(), contract = ScanContract(),
onResult = { onResult = {
scope.launch { scope.launch {
@ -176,7 +201,9 @@ fun MainScreen(
when (e) { when (e) {
is WgTunnelException -> { is WgTunnelException -> {
showSnackbarMessage(e.message) showSnackbarMessage(e.message)
} else -> { }
else -> {
showSnackbarMessage("No QR code scanned") showSnackbarMessage("No QR code scanned")
} }
} }
@ -197,21 +224,22 @@ fun MainScreen(
showPrimaryChangeAlertDialog = false showPrimaryChangeAlertDialog = false
selectedTunnel = null selectedTunnel = null
} }
}) }) { Text(text = stringResource(R.string.okay)) }
{ Text(text = stringResource(R.string.okay)) }
}, },
dismissButton = { dismissButton = {
TextButton(onClick = { TextButton(onClick = {
showPrimaryChangeAlertDialog = false showPrimaryChangeAlertDialog = false
}) }) { Text(text = stringResource(R.string.cancel)) }
{ Text(text = stringResource(R.string.cancel)) }
}, },
title = { Text(text = stringResource(R.string.primary_tunnel_change)) }, title = { Text(text = stringResource(R.string.primary_tunnel_change)) },
text = { Text(text = stringResource(R.string.primary_tunnel_change_question)) } text = { Text(text = stringResource(R.string.primary_tunnel_change_question)) }
) )
} }
fun onTunnelToggle(checked : Boolean , tunnel : TunnelConfig) { fun onTunnelToggle(
checked: Boolean,
tunnel: TunnelConfig
) {
try { try {
if (checked) viewModel.onTunnelStart(tunnel) else viewModel.onTunnelStop() if (checked) viewModel.onTunnelStart(tunnel) else viewModel.onTunnelStop()
} catch (e: Exception) { } catch (e: Exception) {
@ -220,7 +248,8 @@ fun MainScreen(
} }
Scaffold( Scaffold(
modifier = Modifier.pointerInput(Unit) { modifier =
Modifier.pointerInput(Unit) {
detectTapGestures(onTap = { detectTapGestures(onTap = {
selectedTunnel = null selectedTunnel = null
}) })
@ -230,30 +259,30 @@ fun MainScreen(
AnimatedVisibility( AnimatedVisibility(
visible = isVisible.value, visible = isVisible.value,
enter = slideInVertically(initialOffsetY = { it * 2 }), enter = slideInVertically(initialOffsetY = { it * 2 }),
exit = slideOutVertically(targetOffsetY = { it * 2 }), exit = slideOutVertically(targetOffsetY = { it * 2 })
) { ) {
val secondaryColor = MaterialTheme.colorScheme.secondary val secondaryColor = MaterialTheme.colorScheme.secondary
val hoverColor = MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp) val hoverColor = MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp)
var fobColor by remember { mutableStateOf(secondaryColor) } var fobColor by remember { mutableStateOf(secondaryColor) }
FloatingActionButton( FloatingActionButton(
modifier = Modifier modifier =
Modifier
.padding(bottom = 90.dp) .padding(bottom = 90.dp)
.onFocusChanged { .onFocusChanged {
if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) { if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
fobColor = if (it.isFocused) hoverColor else secondaryColor fobColor = if (it.isFocused) hoverColor else secondaryColor
} }
} },
,
onClick = { onClick = {
showBottomSheet = true showBottomSheet = true
}, },
containerColor = fobColor, containerColor = fobColor,
shape = RoundedCornerShape(16.dp), shape = RoundedCornerShape(16.dp)
) { ) {
Icon( Icon(
imageVector = Icons.Rounded.Add, imageVector = Icons.Rounded.Add,
contentDescription = stringResource(id = R.string.add_tunnel), contentDescription = stringResource(id = R.string.add_tunnel),
tint = Color.DarkGray, tint = Color.DarkGray
) )
} }
} }
@ -263,7 +292,8 @@ fun MainScreen(
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
modifier = Modifier modifier =
Modifier
.fillMaxSize() .fillMaxSize()
.padding(padding) .padding(padding)
) { ) {
@ -279,7 +309,8 @@ fun MainScreen(
) { ) {
// Sheet content // Sheet content
Row( Row(
modifier = Modifier modifier =
Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable { .clickable {
showBottomSheet = false showBottomSheet = false
@ -303,7 +334,9 @@ fun MainScreen(
} }
if (!WireGuardAutoTunnel.isRunningOnAndroidTv(context)) { if (!WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
Divider() Divider()
Row(modifier = Modifier Row(
modifier =
Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable { .clickable {
scope.launch { scope.launch {
@ -313,7 +346,8 @@ fun MainScreen(
scanOptions.setOrientationLocked(true) scanOptions.setOrientationLocked(true)
scanOptions.setPrompt(context.getString(R.string.scanning_qr)) scanOptions.setPrompt(context.getString(R.string.scanning_qr))
scanOptions.setBeepEnabled(false) scanOptions.setBeepEnabled(false)
scanOptions.captureActivity = CaptureActivityPortrait::class.java scanOptions.captureActivity =
CaptureActivityPortrait::class.java
scanLauncher.launch(scanOptions) scanLauncher.launch(scanOptions)
} }
} }
@ -332,11 +366,14 @@ fun MainScreen(
} }
Divider() Divider()
Row( Row(
modifier = Modifier modifier =
Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable { .clickable {
showBottomSheet = false showBottomSheet = false
navController.navigate("${Routes.Config.name}/${Constants.MANUAL_TUNNEL_CONFIG_ID}") navController.navigate(
"${Routes.Config.name}/${Constants.MANUAL_TUNNEL_CONFIG_ID}"
)
} }
.padding(10.dp) .padding(10.dp)
) { ) {
@ -355,47 +392,67 @@ fun MainScreen(
Column( Column(
horizontalAlignment = Alignment.Start, horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Top, verticalArrangement = Arrangement.Top,
modifier = Modifier modifier =
Modifier
.fillMaxSize() .fillMaxSize()
.padding(padding) .padding(padding)
) { ) {
LazyColumn( LazyColumn(
modifier = Modifier modifier =
Modifier
.fillMaxSize() .fillMaxSize()
.nestedScroll(nestedScrollConnection), .padding(top = 10.dp)
.nestedScroll(nestedScrollConnection)
) { ) {
items(tunnels, key = { tunnel -> tunnel.id }) { tunnel -> items(tunnels, key = { tunnel -> tunnel.id }) { tunnel ->
val leadingIconColor = (if (tunnelName == tunnel.name) when (handshakeStatus) { val leadingIconColor = (
if (tunnelName == tunnel.name) {
when (handshakeStatus) {
HandshakeStatus.HEALTHY -> mint HandshakeStatus.HEALTHY -> mint
HandshakeStatus.UNHEALTHY -> brickRed HandshakeStatus.UNHEALTHY -> brickRed
HandshakeStatus.STALE -> corn
HandshakeStatus.NOT_STARTED -> Color.Gray HandshakeStatus.NOT_STARTED -> Color.Gray
HandshakeStatus.NEVER_CONNECTED -> brickRed HandshakeStatus.NEVER_CONNECTED -> brickRed
} else {Color.Gray}) }
} else {
Color.Gray
}
)
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
val expanded = remember { val expanded =
remember {
mutableStateOf(false) mutableStateOf(false)
} }
RowListItem(icon = { RowListItem(
if (settings.isTunnelConfigDefault(tunnel)) icon = {
if (settings.isTunnelConfigDefault(tunnel)) {
Icon( Icon(
Icons.Rounded.Star, stringResource(R.string.status), Icons.Rounded.Star,
stringResource(R.string.status),
tint = leadingIconColor, tint = leadingIconColor,
modifier = Modifier modifier =
Modifier
.padding(end = 10.dp) .padding(end = 10.dp)
.size(20.dp) .size(20.dp)
) )
else Icon( } else {
Icons.Rounded.Circle, stringResource(R.string.status), Icon(
Icons.Rounded.Circle,
stringResource(R.string.status),
tint = leadingIconColor, tint = leadingIconColor,
modifier = Modifier modifier =
Modifier
.padding(end = 15.dp) .padding(end = 15.dp)
.size(15.dp) .size(15.dp)
) )
}
}, },
text = tunnel.name, text = tunnel.name,
onHold = { onHold = {
if (state == Tunnel.State.UP && tunnel.name == tunnelName) { if ((state == Tunnel.State.UP) && (tunnel.name == tunnelName)) {
showSnackbarMessage(context.resources.getString(R.string.turn_off_tunnel)) showSnackbarMessage(
context.resources.getString(R.string.turn_off_tunnel)
)
return@RowListItem return@RowListItem
} }
haptic.performHapticFeedback(HapticFeedbackType.LongPress) haptic.performHapticFeedback(HapticFeedbackType.LongPress)
@ -414,25 +471,40 @@ fun MainScreen(
statistics = statistics, statistics = statistics,
expanded = expanded.value, expanded = expanded.value,
rowButton = { rowButton = {
if (tunnel.id == selectedTunnel?.id && !WireGuardAutoTunnel.isRunningOnAndroidTv(context)) { if (tunnel.id == selectedTunnel?.id && !WireGuardAutoTunnel.isRunningOnAndroidTv(
context
)
) {
Row { Row {
if (!settings.isTunnelConfigDefault(tunnel)) { if (!settings.isTunnelConfigDefault(tunnel)) {
IconButton(onClick = { IconButton(onClick = {
if (settings.isAutoTunnelEnabled) { if (settings.isAutoTunnelEnabled) {
showSnackbarMessage(context.resources.getString(R.string.turn_off_auto)) showSnackbarMessage(
} else showPrimaryChangeAlertDialog = true context.resources.getString(
R.string.turn_off_auto
)
)
} else {
showPrimaryChangeAlertDialog = true
}
}) { }) {
Icon(Icons.Rounded.Star, stringResource(id = R.string.set_primary)) Icon(
Icons.Rounded.Star,
stringResource(id = R.string.set_primary)
)
} }
} }
IconButton(onClick = { IconButton(onClick = {
navController.navigate("${Routes.Config.name}/${selectedTunnel?.id}") navController.navigate(
"${Routes.Config.name}/${selectedTunnel?.id}"
)
}) { }) {
Icon(Icons.Rounded.Edit, stringResource(id = R.string.edit)) Icon(Icons.Rounded.Edit, stringResource(id = R.string.edit))
} }
IconButton( IconButton(
modifier = Modifier.focusable(), modifier = Modifier.focusable(),
onClick = { viewModel.onDelete(tunnel) }) { onClick = { viewModel.onDelete(tunnel) }
) {
Icon( Icon(
Icons.Rounded.Delete, Icons.Rounded.Delete,
stringResource(id = R.string.delete) stringResource(id = R.string.delete)
@ -442,8 +514,10 @@ fun MainScreen(
} else { } else {
val checked = state == Tunnel.State.UP && tunnel.name == tunnelName val checked = state == Tunnel.State.UP && tunnel.name == tunnelName
if (!checked) expanded.value = false if (!checked) expanded.value = false
@Composable @Composable
fun TunnelSwitch() = Switch( fun TunnelSwitch() =
Switch(
modifier = Modifier.focusRequester(focusRequester), modifier = Modifier.focusRequester(focusRequester),
checked = checked, checked = checked,
onCheckedChange = { checked -> onCheckedChange = { checked ->
@ -456,10 +530,19 @@ fun MainScreen(
if (!settings.isTunnelConfigDefault(tunnel)) { if (!settings.isTunnelConfigDefault(tunnel)) {
IconButton(onClick = { IconButton(onClick = {
if (settings.isAutoTunnelEnabled) { if (settings.isAutoTunnelEnabled) {
showSnackbarMessage(context.resources.getString(R.string.turn_off_auto)) showSnackbarMessage(
} else showPrimaryChangeAlertDialog = true context.resources.getString(
R.string.turn_off_auto
)
)
} else {
showPrimaryChangeAlertDialog = true
}
}) { }) {
Icon(Icons.Rounded.Star, stringResource(id = R.string.set_primary)) Icon(
Icons.Rounded.Star,
stringResource(id = R.string.set_primary)
)
} }
} }
IconButton( IconButton(
@ -468,18 +551,21 @@ fun MainScreen(
if (state == Tunnel.State.UP && (tunnelName == tunnel.name)) { if (state == Tunnel.State.UP && (tunnelName == tunnel.name)) {
expanded.value = !expanded.value expanded.value = !expanded.value
} }
}) { }
) {
Icon(Icons.Rounded.Info, stringResource(R.string.info)) Icon(Icons.Rounded.Info, stringResource(R.string.info))
} }
IconButton(onClick = { IconButton(onClick = {
if (state == Tunnel.State.UP && tunnel.name == tunnelName) if (state == Tunnel.State.UP && tunnel.name == tunnelName) {
showSnackbarMessage( showSnackbarMessage(
context.resources.getString( context.resources.getString(
R.string.turn_off_tunnel R.string.turn_off_tunnel
) )
) )
else { } else {
navController.navigate("${Routes.Config.name}/${tunnel.id}") navController.navigate(
"${Routes.Config.name}/${tunnel.id}"
)
} }
}) { }) {
Icon( Icon(
@ -488,13 +574,13 @@ fun MainScreen(
) )
} }
IconButton(onClick = { IconButton(onClick = {
if (state == Tunnel.State.UP && tunnel.name == tunnelName) if (state == Tunnel.State.UP && tunnel.name == tunnelName) {
showSnackbarMessage( showSnackbarMessage(
context.resources.getString( context.resources.getString(
R.string.turn_off_tunnel R.string.turn_off_tunnel
) )
) )
else { } else {
viewModel.onDelete(tunnel) viewModel.onDelete(tunnel)
} }
}) { }) {
@ -509,7 +595,8 @@ fun MainScreen(
TunnelSwitch() TunnelSwitch()
} }
} }
}) }
)
} }
} }
} }

View File

@ -22,6 +22,9 @@ import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService
import com.zaneschepke.wireguardautotunnel.util.NumberUtils import com.zaneschepke.wireguardautotunnel.util.NumberUtils
import com.zaneschepke.wireguardautotunnel.util.WgTunnelException import com.zaneschepke.wireguardautotunnel.util.WgTunnelException
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import java.io.InputStream
import java.util.zip.ZipInputStream
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -29,19 +32,16 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.InputStream
import java.util.zip.ZipInputStream
import javax.inject.Inject
@HiltViewModel @HiltViewModel
class MainViewModel @Inject constructor( class MainViewModel
@Inject
constructor(
private val application: Application, private val application: Application,
private val tunnelRepo: TunnelConfigDao, private val tunnelRepo: TunnelConfigDao,
private val settingsRepo: SettingsDoa, private val settingsRepo: SettingsDoa,
private val vpnService: VpnService private val vpnService: VpnService
) : ViewModel() { ) : ViewModel() {
val tunnels get() = tunnelRepo.getAllFlow() val tunnels get() = tunnelRepo.getAllFlow()
val state get() = vpnService.state val state get() = vpnService.state
@ -62,7 +62,8 @@ class MainViewModel @Inject constructor(
} }
private fun validateWatcherServiceState(settings: Settings) { private fun validateWatcherServiceState(settings: Settings) {
val watcherState = ServiceManager.getServiceState( val watcherState =
ServiceManager.getServiceState(
application.applicationContext, application.applicationContext,
WireGuardConnectivityWatcherService::class.java WireGuardConnectivityWatcherService::class.java
) )
@ -74,7 +75,6 @@ class MainViewModel @Inject constructor(
} }
} }
fun onDelete(tunnel: TunnelConfig) { fun onDelete(tunnel: TunnelConfig) {
viewModelScope.launch { viewModelScope.launch {
if (tunnelRepo.count() == 1L) { if (tunnelRepo.count() == 1L) {
@ -106,7 +106,7 @@ class MainViewModel @Inject constructor(
private suspend fun stopActiveTunnel() { private suspend fun stopActiveTunnel() {
if (ServiceManager.getServiceState( if (ServiceManager.getServiceState(
application.applicationContext, application.applicationContext,
WireGuardTunnelService::class.java, WireGuardTunnelService::class.java
) == ServiceState.STARTED ) == ServiceState.STARTED
) { ) {
onTunnelStop() onTunnelStop()
@ -133,7 +133,10 @@ class MainViewModel @Inject constructor(
} }
} }
private suspend fun saveTunnelConfigFromStream(stream: InputStream, fileName: String) { private suspend fun saveTunnelConfigFromStream(
stream: InputStream,
fileName: String
) {
val bufferReader = stream.bufferedReader(charset = Charsets.UTF_8) val bufferReader = stream.bufferedReader(charset = Charsets.UTF_8)
val config = Config.parse(bufferReader) val config = Config.parse(bufferReader)
val tunnelName = getNameFromFileName(fileName) val tunnelName = getNameFromFileName(fileName)
@ -155,7 +158,9 @@ class MainViewModel @Inject constructor(
when (fileExtension) { when (fileExtension) {
Constants.CONF_FILE_EXTENSION -> saveTunnelFromConfUri(fileName, uri) Constants.CONF_FILE_EXTENSION -> saveTunnelFromConfUri(fileName, uri)
Constants.ZIP_FILE_EXTENSION -> saveTunnelsFromZipUri(uri) Constants.ZIP_FILE_EXTENSION -> saveTunnelsFromZipUri(uri)
else -> throw WgTunnelException(application.getString(R.string.file_extension_message)) else -> throw WgTunnelException(
application.getString(R.string.file_extension_message)
)
} }
} catch (e: Exception) { } catch (e: Exception) {
throw WgTunnelException(e) throw WgTunnelException(e)
@ -165,8 +170,10 @@ class MainViewModel @Inject constructor(
private suspend fun saveTunnelsFromZipUri(uri: Uri) { private suspend fun saveTunnelsFromZipUri(uri: Uri) {
ZipInputStream(getInputStreamFromUri(uri)).use { zip -> ZipInputStream(getInputStreamFromUri(uri)).use { zip ->
generateSequence { zip.nextEntry } generateSequence { zip.nextEntry }
.filterNot { it.isDirectory || .filterNot {
getFileExtensionFromFileName(it.name) != Constants.CONF_FILE_EXTENSION } it.isDirectory ||
getFileExtensionFromFileName(it.name) != Constants.CONF_FILE_EXTENSION
}
.forEach { .forEach {
val name = getNameFromFileName(it.name) val name = getNameFromFileName(it.name)
val config = Config.parse(zip) val config = Config.parse(zip)
@ -177,7 +184,10 @@ class MainViewModel @Inject constructor(
} }
} }
private suspend fun saveTunnelFromConfUri(name : String, uri: Uri) { private suspend fun saveTunnelFromConfUri(
name: String,
uri: Uri
) {
val stream = getInputStreamFromUri(uri) val stream = getInputStreamFromUri(uri)
saveTunnelConfigFromStream(stream, name) saveTunnelConfigFromStream(stream, name)
} }
@ -190,7 +200,10 @@ class MainViewModel @Inject constructor(
tunnelRepo.save(tunnelConfig) tunnelRepo.save(tunnelConfig)
} }
private fun getFileNameByCursor(context: Context, uri: Uri): String { private fun getFileNameByCursor(
context: Context,
uri: Uri
): String {
val cursor = context.contentResolver.query(uri, null, null, null, null) val cursor = context.contentResolver.query(uri, null, null, null, null)
if (cursor != null) { if (cursor != null) {
cursor.use { cursor.use {
@ -224,8 +237,10 @@ class MainViewModel @Inject constructor(
} }
} }
private fun getFileName(
private fun getFileName(context: Context, uri: Uri): String { context: Context,
uri: Uri
): String {
validateUriContentScheme(uri) validateUriContentScheme(uri)
return try { return try {
getFileNameByCursor(context, uri) getFileNameByCursor(context, uri)

View File

@ -38,6 +38,7 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@ -65,29 +66,32 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState import com.google.accompanist.permissions.rememberPermissionState
import com.wireguard.android.backend.Tunnel
import com.wireguard.android.backend.WgQuickBackend
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.zaneschepke.wireguardautotunnel.repository.datastore.DataStoreManager
import com.zaneschepke.wireguardautotunnel.ui.common.ClickableIconButton import com.zaneschepke.wireguardautotunnel.ui.common.ClickableIconButton
import com.zaneschepke.wireguardautotunnel.ui.common.config.ConfigurationToggle import com.zaneschepke.wireguardautotunnel.ui.common.config.ConfigurationToggle
import com.zaneschepke.wireguardautotunnel.ui.common.prompt.AuthorizationPrompt import com.zaneschepke.wireguardautotunnel.ui.common.prompt.AuthorizationPrompt
import com.zaneschepke.wireguardautotunnel.ui.common.text.SectionTitle import com.zaneschepke.wireguardautotunnel.ui.common.text.SectionTitle
import com.zaneschepke.wireguardautotunnel.util.StorageUtil import com.zaneschepke.wireguardautotunnel.util.FileUtils
import com.zaneschepke.wireguardautotunnel.util.WgTunnelException
import java.io.File
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.File
@OptIn( @OptIn(
ExperimentalPermissionsApi::class, ExperimentalPermissionsApi::class,
ExperimentalLayoutApi::class, ExperimentalComposeUiApi::class ExperimentalLayoutApi::class,
ExperimentalComposeUiApi::class
) )
@Composable @Composable
fun SettingsScreen( fun SettingsScreen(
viewModel: SettingsViewModel = hiltViewModel(), viewModel: SettingsViewModel = hiltViewModel(),
padding: PaddingValues, padding: PaddingValues,
showSnackbarMessage: (String) -> Unit, showSnackbarMessage: (String) -> Unit,
focusRequester: FocusRequester, focusRequester: FocusRequester
) { ) {
val scope = rememberCoroutineScope { Dispatchers.IO } val scope = rememberCoroutineScope { Dispatchers.IO }
val context = LocalContext.current val context = LocalContext.current
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
@ -100,16 +104,24 @@ fun SettingsScreen(
val fineLocationState = rememberPermissionState(Manifest.permission.ACCESS_FINE_LOCATION) val fineLocationState = rememberPermissionState(Manifest.permission.ACCESS_FINE_LOCATION)
var currentText by remember { mutableStateOf("") } var currentText by remember { mutableStateOf("") }
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
var isLocationDisclaimerNeeded by remember { mutableStateOf(true) }
var isBackgroundLocationGranted by remember { mutableStateOf(true) } var isBackgroundLocationGranted by remember { mutableStateOf(true) }
var showAuthPrompt by remember { mutableStateOf(false) } var showAuthPrompt by remember { mutableStateOf(false) }
var didExportFiles by remember { mutableStateOf(false) } var didExportFiles by remember { mutableStateOf(false) }
val isLocationDisclosureShown by viewModel.disclosureShown.collectAsStateWithLifecycle(
null
)
val vpnState = viewModel.vpnState.collectAsStateWithLifecycle(initialValue = Tunnel.State.DOWN)
val screenPadding = 5.dp val screenPadding = 5.dp
val fillMaxWidth = .85f val fillMaxWidth = .85f
fun setLocationDisclosureShown() = scope.launch {
viewModel.dataStoreManager.saveToDataStore(
DataStoreManager.LOCATION_DISCLOSURE_SHOWN,
true
)
}
fun exportAllConfigs() { fun exportAllConfigs() {
try { try {
val files = tunnels.map { File(context.cacheDir, "${it.name}.conf") } val files = tunnels.map { File(context.cacheDir, "${it.name}.conf") }
@ -118,7 +130,7 @@ fun SettingsScreen(
it.write(tunnels[index].wgQuick.toByteArray()) it.write(tunnels[index].wgQuick.toByteArray())
} }
} }
StorageUtil.saveFilesToZip(context, files) FileUtils.saveFilesToZip(context, files)
didExportFiles = true didExportFiles = true
showSnackbarMessage(context.getString(R.string.exported_configs_message)) showSnackbarMessage(context.getString(R.string.exported_configs_message))
} catch (e: Exception) { } catch (e: Exception) {
@ -126,7 +138,6 @@ fun SettingsScreen(
} }
} }
fun saveTrustedSSID() { fun saveTrustedSSID() {
if (currentText.isNotEmpty()) { if (currentText.isNotEmpty()) {
scope.launch { scope.launch {
@ -141,10 +152,13 @@ fun SettingsScreen(
} }
fun isAllAutoTunnelPermissionsEnabled(): Boolean { fun isAllAutoTunnelPermissionsEnabled(): Boolean {
return(isBackgroundLocationGranted && fineLocationState.status.isGranted && !viewModel.isLocationServicesNeeded()) return (
isBackgroundLocationGranted &&
fineLocationState.status.isGranted &&
!viewModel.isLocationServicesNeeded()
)
} }
fun openSettings() { fun openSettings() {
scope.launch { scope.launch {
val intentSettings = val intentSettings =
@ -158,11 +172,13 @@ fun SettingsScreen(
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val backgroundLocationState = val backgroundLocationState =
rememberPermissionState(Manifest.permission.ACCESS_BACKGROUND_LOCATION) rememberPermissionState(Manifest.permission.ACCESS_BACKGROUND_LOCATION)
if(!backgroundLocationState.status.isGranted) { isBackgroundLocationGranted = if (!backgroundLocationState.status.isGranted) {
isBackgroundLocationGranted = false false
} else { } else {
isLocationDisclaimerNeeded = false SideEffect {
isBackgroundLocationGranted = true setLocationDisclosureShown()
}
true
} }
} }
@ -170,16 +186,19 @@ fun SettingsScreen(
if (!fineLocationState.status.isGranted) { if (!fineLocationState.status.isGranted) {
isBackgroundLocationGranted = false isBackgroundLocationGranted = false
} else { } else {
isLocationDisclaimerNeeded = false SideEffect {
setLocationDisclosureShown()
}
isBackgroundLocationGranted = true isBackgroundLocationGranted = true
} }
} }
if(isLocationDisclaimerNeeded) { if (isLocationDisclosureShown != true) {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top, verticalArrangement = Arrangement.Top,
modifier = Modifier modifier =
Modifier
.fillMaxSize() .fillMaxSize()
.verticalScroll(scrollState) .verticalScroll(scrollState)
.padding(padding) .padding(padding)
@ -187,7 +206,8 @@ fun SettingsScreen(
Icon( Icon(
Icons.Rounded.LocationOff, Icons.Rounded.LocationOff,
contentDescription = stringResource(id = R.string.map), contentDescription = stringResource(id = R.string.map),
modifier = Modifier modifier =
Modifier
.padding(30.dp) .padding(30.dp)
.size(128.dp) .size(128.dp)
) )
@ -204,21 +224,27 @@ fun SettingsScreen(
fontSize = 15.sp fontSize = 15.sp
) )
Row( Row(
modifier = if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) Modifier modifier =
if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(10.dp) else Modifier .padding(10.dp)
} else {
Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(30.dp), .padding(30.dp)
},
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceEvenly horizontalArrangement = Arrangement.SpaceEvenly
) { ) {
TextButton(onClick = { TextButton(onClick = {
isLocationDisclaimerNeeded = false setLocationDisclosureShown()
}) { }) {
Text(stringResource(id = R.string.no_thanks)) Text(stringResource(id = R.string.no_thanks))
} }
TextButton(modifier = Modifier.focusRequester(focusRequester), onClick = { TextButton(modifier = Modifier.focusRequester(focusRequester), onClick = {
openSettings() openSettings()
setLocationDisclosureShown()
}) { }) {
Text(stringResource(id = R.string.turn_on)) Text(stringResource(id = R.string.turn_on))
} }
@ -227,12 +253,12 @@ fun SettingsScreen(
return return
} }
if (showAuthPrompt) { if (showAuthPrompt) {
AuthorizationPrompt(onSuccess = { AuthorizationPrompt(
onSuccess = {
showAuthPrompt = false showAuthPrompt = false
exportAllConfigs() }, exportAllConfigs()
},
onError = { error -> onError = { error ->
showSnackbarMessage(error) showSnackbarMessage(error)
showAuthPrompt = false showAuthPrompt = false
@ -240,14 +266,16 @@ fun SettingsScreen(
onFailure = { onFailure = {
showAuthPrompt = false showAuthPrompt = false
showSnackbarMessage(context.getString(R.string.authentication_failed)) showSnackbarMessage(context.getString(R.string.authentication_failed))
}) }
)
} }
if (tunnels.isEmpty()) { if (tunnels.isEmpty()) {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
modifier = Modifier modifier =
Modifier
.fillMaxSize() .fillMaxSize()
.padding(padding) .padding(padding)
) { ) {
@ -264,7 +292,8 @@ fun SettingsScreen(
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top, verticalArrangement = Arrangement.Top,
modifier = Modifier modifier =
Modifier
.fillMaxSize() .fillMaxSize()
.verticalScroll(scrollState) .verticalScroll(scrollState)
.clickable(indication = null, interactionSource = interactionSource) { .clickable(indication = null, interactionSource = interactionSource) {
@ -276,21 +305,29 @@ fun SettingsScreen(
shadowElevation = 2.dp, shadowElevation = 2.dp,
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surface, color = MaterialTheme.colorScheme.surface,
modifier = (if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) modifier =
(
if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
Modifier Modifier
.height(IntrinsicSize.Min) .height(IntrinsicSize.Min)
.fillMaxWidth(fillMaxWidth) .fillMaxWidth(fillMaxWidth)
.padding(top = 10.dp) .padding(top = 10.dp)
else Modifier } else {
Modifier
.fillMaxWidth(fillMaxWidth) .fillMaxWidth(fillMaxWidth)
.padding(top = 60.dp)).padding(bottom = 25.dp) .padding(top = 60.dp)
}
).padding(bottom = 10.dp)
) { ) {
Column( Column(
horizontalAlignment = Alignment.Start, horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Top, verticalArrangement = Arrangement.Top,
modifier = Modifier.padding(15.dp) modifier = Modifier.padding(15.dp)
) { ) {
SectionTitle(title = stringResource(id = R.string.auto_tunneling), padding = screenPadding) SectionTitle(
title = stringResource(id = R.string.auto_tunneling),
padding = screenPadding
)
ConfigurationToggle( ConfigurationToggle(
stringResource(id = R.string.tunnel_on_wifi), stringResource(id = R.string.tunnel_on_wifi),
enabled = !(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled), enabled = !(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled),
@ -306,9 +343,10 @@ fun SettingsScreen(
AnimatedVisibility(visible = settings.isTunnelOnWifiEnabled) { AnimatedVisibility(visible = settings.isTunnelOnWifiEnabled) {
Column { Column {
FlowRow( FlowRow(
modifier = Modifier.padding(screenPadding), modifier = Modifier
horizontalArrangement = Arrangement.spacedBy(8.dp), .padding(screenPadding)
verticalArrangement = Arrangement.SpaceEvenly .fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(5.dp)
) { ) {
trustedSSIDs.forEach { ssid -> trustedSSIDs.forEach { ssid ->
ClickableIconButton( ClickableIconButton(
@ -323,7 +361,11 @@ fun SettingsScreen(
) )
} }
if (trustedSSIDs.isEmpty()) { if (trustedSSIDs.isEmpty()) {
Text(stringResource(R.string.none), fontStyle = FontStyle.Italic, color = Color.Gray) Text(
stringResource(R.string.none),
fontStyle = FontStyle.Italic,
color = Color.Gray
)
} }
} }
OutlinedTextField( OutlinedTextField(
@ -331,7 +373,8 @@ fun SettingsScreen(
value = currentText, value = currentText,
onValueChange = { currentText = it }, onValueChange = { currentText = it },
label = { Text(stringResource(R.string.add_trusted_ssid)) }, label = { Text(stringResource(R.string.add_trusted_ssid)) },
modifier = Modifier modifier =
Modifier
.padding(start = screenPadding, top = 5.dp, bottom = 10.dp) .padding(start = screenPadding, top = 5.dp, bottom = 10.dp)
.onFocusChanged { .onFocusChanged {
if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) { if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
@ -339,30 +382,42 @@ fun SettingsScreen(
} }
}, },
maxLines = 1, maxLines = 1,
keyboardOptions = KeyboardOptions( keyboardOptions =
KeyboardOptions(
capitalization = KeyboardCapitalization.None, capitalization = KeyboardCapitalization.None,
imeAction = ImeAction.Done imeAction = ImeAction.Done
), ),
keyboardActions = KeyboardActions( keyboardActions =
KeyboardActions(
onDone = { onDone = {
saveTrustedSSID() saveTrustedSSID()
} }
), ),
trailingIcon = { trailingIcon = {
if (currentText != "") {
IconButton(onClick = { saveTrustedSSID() }) { IconButton(onClick = { saveTrustedSSID() }) {
Icon( Icon(
imageVector = Icons.Outlined.Add, imageVector = Icons.Outlined.Add,
contentDescription = if (currentText == "") stringResource(id = R.string.trusted_ssid_empty_description) else stringResource( contentDescription =
if (currentText == "") {
stringResource(
id = R.string.trusted_ssid_empty_description
)
} else {
stringResource(
id = R.string.trusted_ssid_value_description id = R.string.trusted_ssid_value_description
),
tint = if (currentText == "") Color.Transparent else MaterialTheme.colorScheme.primary
) )
}
}, },
tint = MaterialTheme.colorScheme.primary
) )
} }
} }
ConfigurationToggle(stringResource(R.string.tunnel_mobile_data), }
)
}
}
ConfigurationToggle(
stringResource(R.string.tunnel_mobile_data),
enabled = !(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled), enabled = !(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled),
checked = settings.isTunnelOnMobileDataEnabled, checked = settings.isTunnelOnMobileDataEnabled,
padding = screenPadding, padding = screenPadding,
@ -372,7 +427,8 @@ fun SettingsScreen(
} }
} }
) )
ConfigurationToggle(stringResource(id = R.string.tunnel_on_ethernet), ConfigurationToggle(
stringResource(id = R.string.tunnel_on_ethernet),
enabled = !(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled), enabled = !(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled),
checked = settings.isTunnelOnEthernetEnabled, checked = settings.isTunnelOnEthernetEnabled,
padding = screenPadding, padding = screenPadding,
@ -395,7 +451,8 @@ fun SettingsScreen(
) )
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier modifier =
Modifier
.fillMaxSize() .fillMaxSize()
.padding(top = 5.dp), .padding(top = 5.dp),
horizontalArrangement = Arrangement.Center horizontalArrangement = Arrangement.Center
@ -403,9 +460,9 @@ fun SettingsScreen(
TextButton( TextButton(
enabled = !settings.isAlwaysOnVpnEnabled, enabled = !settings.isAlwaysOnVpnEnabled,
onClick = { onClick = {
//TODO fix logic for mobile only
if (!isAllAutoTunnelPermissionsEnabled() && settings.isTunnelOnWifiEnabled) { if (!isAllAutoTunnelPermissionsEnabled() && settings.isTunnelOnWifiEnabled) {
val message = if(!isBackgroundLocationGranted) { val message =
if (!isBackgroundLocationGranted) {
context.getString(R.string.background_location_required) context.getString(R.string.background_location_required)
} else if (viewModel.isLocationServicesNeeded()) { } else if (viewModel.isLocationServicesNeeded()) {
context.getString(R.string.location_services_required) context.getString(R.string.location_services_required)
@ -413,17 +470,64 @@ fun SettingsScreen(
context.getString(R.string.precise_location_required) context.getString(R.string.precise_location_required)
} }
showSnackbarMessage(message) showSnackbarMessage(message)
} else scope.launch { } else {
scope.launch {
viewModel.toggleAutoTunnel() viewModel.toggleAutoTunnel()
} }
}) { }
val autoTunnelButtonText = if(settings.isAutoTunnelEnabled) stringResource(R.string.disable_auto_tunnel) }
else stringResource(id = R.string.enable_auto_tunnel) ) {
val autoTunnelButtonText =
if (settings.isAutoTunnelEnabled) {
stringResource(R.string.disable_auto_tunnel)
} else {
stringResource(id = R.string.enable_auto_tunnel)
}
Text(autoTunnelButtonText) Text(autoTunnelButtonText)
} }
} }
} }
} }
if (WgQuickBackend.hasKernelSupport()) {
Surface(
tonalElevation = 2.dp,
shadowElevation = 2.dp,
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surface,
modifier = Modifier
.fillMaxWidth(fillMaxWidth)
.padding(vertical = 10.dp)
) {
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Top,
modifier = Modifier.padding(15.dp)
) {
SectionTitle(
title = stringResource(id = R.string.kernel),
padding = screenPadding
)
ConfigurationToggle(
stringResource(R.string.use_kernel),
enabled = !(
settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled ||
(vpnState.value == Tunnel.State.UP)
),
checked = settings.isKernelEnabled,
padding = screenPadding,
onCheckChanged = {
scope.launch {
try {
viewModel.onToggleKernelMode()
} catch (e: WgTunnelException) {
showSnackbarMessage(e.message)
}
}
}
)
}
}
}
if (!WireGuardAutoTunnel.isRunningOnAndroidTv(context)) { if (!WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
Surface( Surface(
tonalElevation = 2.dp, tonalElevation = 2.dp,
@ -431,17 +535,20 @@ fun SettingsScreen(
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surface, color = MaterialTheme.colorScheme.surface,
modifier = Modifier modifier = Modifier
.fillMaxWidth(fillMaxWidth) .fillMaxWidth(fillMaxWidth).padding(vertical = 10.dp)
.height(IntrinsicSize.Min) .padding(bottom = 140.dp)
.padding(bottom = 180.dp)
) { ) {
Column( Column(
horizontalAlignment = Alignment.Start, horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Top, verticalArrangement = Arrangement.Top,
modifier = Modifier.padding(15.dp) modifier = Modifier.padding(15.dp)
) { ) {
SectionTitle(title = stringResource(id = R.string.other), padding = screenPadding) SectionTitle(
ConfigurationToggle(stringResource(R.string.always_on_vpn_support), title = stringResource(id = R.string.other),
padding = screenPadding
)
ConfigurationToggle(
stringResource(R.string.always_on_vpn_support),
enabled = !settings.isAutoTunnelEnabled, enabled = !settings.isAutoTunnelEnabled,
checked = settings.isAlwaysOnVpnEnabled, checked = settings.isAlwaysOnVpnEnabled,
padding = screenPadding, padding = screenPadding,
@ -451,7 +558,8 @@ fun SettingsScreen(
} }
} }
) )
ConfigurationToggle(stringResource(R.string.enabled_app_shortcuts), ConfigurationToggle(
stringResource(R.string.enabled_app_shortcuts),
enabled = true, enabled = true,
checked = settings.isShortcutsEnabled, checked = settings.isShortcutsEnabled,
padding = screenPadding, padding = screenPadding,
@ -463,7 +571,8 @@ fun SettingsScreen(
) )
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier modifier =
Modifier
.fillMaxSize() .fillMaxSize()
.padding(top = 5.dp), .padding(top = 5.dp),
horizontalArrangement = Arrangement.Center horizontalArrangement = Arrangement.Center
@ -472,7 +581,8 @@ fun SettingsScreen(
enabled = !didExportFiles, enabled = !didExportFiles,
onClick = { onClick = {
showAuthPrompt = true showAuthPrompt = true
}) { }
) {
Text(stringResource(R.string.export_configs)) Text(stringResource(R.string.export_configs))
} }
} }
@ -484,5 +594,3 @@ fun SettingsScreen(
} }
} }
} }

View File

@ -6,32 +6,45 @@ import android.location.LocationManager
import android.os.Build import android.os.Build
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.wireguard.android.util.RootShell
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa
import com.zaneschepke.wireguardautotunnel.repository.TunnelConfigDao import com.zaneschepke.wireguardautotunnel.repository.TunnelConfigDao
import com.zaneschepke.wireguardautotunnel.repository.datastore.DataStoreManager
import com.zaneschepke.wireguardautotunnel.repository.model.Settings import com.zaneschepke.wireguardautotunnel.repository.model.Settings
import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService
import com.zaneschepke.wireguardautotunnel.util.WgTunnelException import com.zaneschepke.wireguardautotunnel.util.WgTunnelException
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import timber.log.Timber
@HiltViewModel @HiltViewModel
class SettingsViewModel @Inject constructor(private val application : Application, class SettingsViewModel
private val tunnelRepo : TunnelConfigDao, private val settingsRepo : SettingsDoa @Inject
constructor(
private val application: Application,
private val tunnelRepo: TunnelConfigDao,
private val settingsRepo: SettingsDoa,
val dataStoreManager: DataStoreManager,
private val rootShell: RootShell,
private val vpnService: VpnService
) : ViewModel() { ) : ViewModel() {
private val _trustedSSIDs = MutableStateFlow(emptyList<String>()) private val _trustedSSIDs = MutableStateFlow(emptyList<String>())
val trustedSSIDs = _trustedSSIDs.asStateFlow() val trustedSSIDs = _trustedSSIDs.asStateFlow()
private val _settings = MutableStateFlow(Settings()) private val _settings = MutableStateFlow(Settings())
val settings get() = _settings.asStateFlow() val settings get() = _settings.asStateFlow()
val vpnState get() = vpnService.state
val tunnels get() = tunnelRepo.getAllFlow() val tunnels get() = tunnelRepo.getAllFlow()
val disclosureShown = dataStoreManager.locationDisclosureFlow
init { init {
isLocationServicesEnabled() isLocationServicesEnabled()
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
@ -42,7 +55,6 @@ class SettingsViewModel @Inject constructor(private val application : Applicatio
} }
} }
} }
suspend fun onSaveTrustedSSID(ssid: String) { suspend fun onSaveTrustedSSID(ssid: String) {
val trimmed = ssid.trim() val trimmed = ssid.trim()
if (!_settings.value.trustedNetworkSSIDs.contains(trimmed)) { if (!_settings.value.trustedNetworkSSIDs.contains(trimmed)) {
@ -54,9 +66,11 @@ class SettingsViewModel @Inject constructor(private val application : Applicatio
} }
suspend fun onToggleTunnelOnMobileData() { suspend fun onToggleTunnelOnMobileData() {
settingsRepo.save(_settings.value.copy( settingsRepo.save(
_settings.value.copy(
isTunnelOnMobileDataEnabled = !_settings.value.isTunnelOnMobileDataEnabled isTunnelOnMobileDataEnabled = !_settings.value.isTunnelOnMobileDataEnabled
)) )
)
} }
suspend fun onDeleteTrustedSSID(ssid: String) { suspend fun onDeleteTrustedSSID(ssid: String) {
@ -64,7 +78,8 @@ class SettingsViewModel @Inject constructor(private val application : Applicatio
settingsRepo.save(_settings.value) settingsRepo.save(_settings.value)
} }
private fun emitFirstTunnelAsDefault() = viewModelScope.async { private fun emitFirstTunnelAsDefault() =
viewModelScope.async {
_settings.emit(_settings.value.copy(defaultTunnel = getFirstTunnelConfig().toString())) _settings.emit(_settings.value.copy(defaultTunnel = getFirstTunnelConfig().toString()))
} }
@ -78,9 +93,11 @@ class SettingsViewModel @Inject constructor(private val application : Applicatio
val defaultTunnel = _settings.value.defaultTunnel val defaultTunnel = _settings.value.defaultTunnel
ServiceManager.startWatcherService(application, defaultTunnel!!) ServiceManager.startWatcherService(application, defaultTunnel!!)
} }
settingsRepo.save(_settings.value.copy( settingsRepo.save(
_settings.value.copy(
isAutoTunnelEnabled = !_settings.value.isAutoTunnelEnabled isAutoTunnelEnabled = !_settings.value.isAutoTunnelEnabled
)) )
)
} }
private suspend fun getFirstTunnelConfig(): TunnelConfig { private suspend fun getFirstTunnelConfig(): TunnelConfig {
@ -91,7 +108,10 @@ class SettingsViewModel @Inject constructor(private val application : Applicatio
if (_settings.value.defaultTunnel == null) { if (_settings.value.defaultTunnel == null) {
emitFirstTunnelAsDefault().await() emitFirstTunnelAsDefault().await()
} }
val updatedSettings = _settings.value.copy(isAlwaysOnVpnEnabled = !_settings.value.isAlwaysOnVpnEnabled) val updatedSettings =
_settings.value.copy(
isAlwaysOnVpnEnabled = !_settings.value.isAlwaysOnVpnEnabled
)
emitSettings(updatedSettings) emitSettings(updatedSettings)
saveSettings(updatedSettings) saveSettings(updatedSettings)
} }
@ -111,7 +131,9 @@ class SettingsViewModel @Inject constructor(private val application : Applicatio
emitFirstTunnelAsDefault().await() emitFirstTunnelAsDefault().await()
} }
_settings.emit( _settings.emit(
_settings.value.copy(isTunnelOnEthernetEnabled = !_settings.value.isTunnelOnEthernetEnabled) _settings.value.copy(
isTunnelOnEthernetEnabled = !_settings.value.isTunnelOnEthernetEnabled
)
) )
settingsRepo.save(_settings.value) settingsRepo.save(_settings.value)
} }
@ -127,20 +149,49 @@ class SettingsViewModel @Inject constructor(private val application : Applicatio
} }
suspend fun onToggleShortcutsEnabled() { suspend fun onToggleShortcutsEnabled() {
settingsRepo.save(_settings.value.copy( settingsRepo.save(
_settings.value.copy(
isShortcutsEnabled = !_settings.value.isShortcutsEnabled isShortcutsEnabled = !_settings.value.isShortcutsEnabled
)) )
)
} }
suspend fun onToggleBatterySaver() { suspend fun onToggleBatterySaver() {
settingsRepo.save(_settings.value.copy( settingsRepo.save(
_settings.value.copy(
isBatterySaverEnabled = !_settings.value.isBatterySaverEnabled isBatterySaverEnabled = !_settings.value.isBatterySaverEnabled
)) )
)
}
private suspend fun saveKernelMode(on: Boolean) {
settingsRepo.save(
_settings.value.copy(
isKernelEnabled = on
)
)
}
suspend fun onToggleKernelMode() {
if (!_settings.value.isKernelEnabled) {
try {
rootShell.start()
Timber.d("Root shell accepted!")
saveKernelMode(on = true)
} catch (e: RootShell.RootShellException) {
saveKernelMode(on = false)
throw WgTunnelException("Root shell denied!")
}
} else {
saveKernelMode(on = false)
}
} }
suspend fun onToggleTunnelOnWifi() { suspend fun onToggleTunnelOnWifi() {
settingsRepo.save(_settings.value.copy( settingsRepo.save(
_settings.value.copy(
isTunnelOnWifiEnabled = !_settings.value.isTunnelOnWifiEnabled isTunnelOnWifiEnabled = !_settings.value.isTunnelOnWifiEnabled
)) )
)
} }
} }

View File

@ -30,6 +30,7 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
@ -44,17 +45,25 @@ import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat.startActivity import androidx.core.content.ContextCompat.startActivity
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.zaneschepke.wireguardautotunnel.BuildConfig import com.zaneschepke.wireguardautotunnel.BuildConfig
import com.zaneschepke.wireguardautotunnel.Constants import com.zaneschepke.wireguardautotunnel.Constants
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.SettingsViewModel
@Composable @Composable
fun SupportScreen(padding : PaddingValues, focusRequester: FocusRequester) { fun SupportScreen(
viewModel: SettingsViewModel = hiltViewModel(),
padding: PaddingValues,
focusRequester: FocusRequester
) {
val context = LocalContext.current val context = LocalContext.current
val fillMaxWidth = .85f val fillMaxWidth = .85f
val settings by viewModel.settings.collectAsStateWithLifecycle()
fun openWebPage(url: String) { fun openWebPage(url: String) {
val webpage: Uri = Uri.parse(url) val webpage: Uri = Uri.parse(url)
val intent = Intent(Intent.ACTION_VIEW, webpage) val intent = Intent(Intent.ACTION_VIEW, webpage)
@ -62,76 +71,151 @@ fun SupportScreen(padding : PaddingValues, focusRequester: FocusRequester) {
} }
fun launchEmail() { fun launchEmail() {
val intent = Intent(Intent.ACTION_SEND).apply { val intent =
Intent(Intent.ACTION_SEND).apply {
type = Constants.EMAIL_MIME_TYPE type = Constants.EMAIL_MIME_TYPE
putExtra(Intent.EXTRA_EMAIL, context.getString(R.string.my_email)) putExtra(Intent.EXTRA_EMAIL, context.getString(R.string.my_email))
putExtra(Intent.EXTRA_SUBJECT, context.getString(R.string.email_subject)) putExtra(Intent.EXTRA_SUBJECT, context.getString(R.string.email_subject))
} }
startActivity(context,createChooser(intent, context.getString(R.string.email_chooser)),null) startActivity(
context,
createChooser(intent, context.getString(R.string.email_chooser)),
null
)
} }
Column(horizontalAlignment = Alignment.CenterHorizontally, Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top, verticalArrangement = Arrangement.Top,
modifier = Modifier modifier =
Modifier
.fillMaxSize() .fillMaxSize()
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.focusable() .focusable()
.padding(padding)) { .padding(padding)
) {
Surface( Surface(
tonalElevation = 2.dp, tonalElevation = 2.dp,
shadowElevation = 2.dp, shadowElevation = 2.dp,
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surface, color = MaterialTheme.colorScheme.surface,
modifier = (if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) modifier =
(
if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
Modifier Modifier
.height(IntrinsicSize.Min) .height(IntrinsicSize.Min)
.fillMaxWidth(fillMaxWidth) .fillMaxWidth(fillMaxWidth)
.padding(top = 10.dp) .padding(top = 10.dp)
else Modifier } else {
Modifier
.fillMaxWidth(fillMaxWidth) .fillMaxWidth(fillMaxWidth)
.padding(top = 20.dp)).padding(bottom = 25.dp) .padding(top = 20.dp)
}
).padding(bottom = 25.dp)
) { ) {
Column(modifier = Modifier.padding(20.dp)) { Column(modifier = Modifier.padding(20.dp)) {
Text(stringResource(R.string.thank_you), textAlign = TextAlign.Start, modifier = Modifier.padding(bottom = 20.dp), fontSize = 16.sp) Text(
Text(stringResource(id = R.string.support_help_text), textAlign = TextAlign.Start, fontSize = 16.sp, modifier = Modifier.padding(bottom = 20.dp)) stringResource(R.string.thank_you),
TextButton(onClick = { openWebPage(context.resources.getString(R.string.docs_url)) }, modifier = Modifier.padding(vertical = 5.dp).focusRequester(focusRequester)) { textAlign = TextAlign.Start,
Row(horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { 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 { Row {
Icon(Icons.Rounded.Book, stringResource(id = R.string.docs)) 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)) 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)) Icon(Icons.Rounded.ArrowForward, stringResource(id = R.string.go))
} }
} }
Divider(color = MaterialTheme.colorScheme.onBackground, thickness = 0.5.dp) 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)) { TextButton(
Row(horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { 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 { Row {
Icon(imageVector = ImageVector.vectorResource(R.drawable.discord), stringResource( Icon(
id = R.string.discord), Modifier.size(25.dp)) imageVector = ImageVector.vectorResource(R.drawable.discord),
Text(stringResource(id = R.string.discord_description), textAlign = TextAlign.Justify, modifier = Modifier.padding(start = 10.dp)) stringResource(
id = R.string.discord
),
Modifier.size(25.dp)
)
Text(
stringResource(id = R.string.discord_description),
textAlign = TextAlign.Justify,
modifier = Modifier.padding(start = 10.dp)
)
} }
Icon(Icons.Rounded.ArrowForward, stringResource(id = R.string.go)) Icon(Icons.Rounded.ArrowForward, stringResource(id = R.string.go))
} }
} }
Divider(color = MaterialTheme.colorScheme.onBackground, thickness = 0.5.dp) 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)) { TextButton(
Row(horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { 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 { Row {
Icon(imageVector = ImageVector.vectorResource(R.drawable.github), stringResource( Icon(
imageVector = ImageVector.vectorResource(R.drawable.github),
stringResource(
id = R.string.github id = R.string.github
), Modifier.size(25.dp)) ),
Text("Open an issue", textAlign = TextAlign.Justify, modifier = Modifier.padding(start = 10.dp)) Modifier.size(25.dp)
)
Text(
"Open an issue",
textAlign = TextAlign.Justify,
modifier = Modifier.padding(start = 10.dp)
)
} }
Icon(Icons.Rounded.ArrowForward, stringResource(id = R.string.go)) Icon(Icons.Rounded.ArrowForward, stringResource(id = R.string.go))
} }
} }
Divider(color = MaterialTheme.colorScheme.onBackground, thickness = 0.5.dp) Divider(color = MaterialTheme.colorScheme.onBackground, thickness = 0.5.dp)
TextButton(onClick = { launchEmail() }, modifier = Modifier.padding(vertical = 5.dp)) { TextButton(
Row(horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { onClick = { launchEmail() },
modifier = Modifier.padding(vertical = 5.dp)
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Row { Row {
Icon(Icons.Rounded.Mail, stringResource(id = R.string.email)) 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)) 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)) Icon(Icons.Rounded.ArrowForward, stringResource(id = R.string.go))
} }
@ -139,10 +223,22 @@ fun SupportScreen(padding : PaddingValues, focusRequester: FocusRequester) {
} }
} }
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
Text(stringResource(id = R.string.privacy_policy), style = TextStyle(textDecoration = TextDecoration.Underline), Text(
modifier = Modifier.clickable { 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)) openWebPage(context.resources.getString(R.string.privacy_policy_url))
}) }
Text("App version: ${BuildConfig.VERSION_NAME}", Modifier.padding(25.dp)) )
Row(
horizontalArrangement = Arrangement.spacedBy(25.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(25.dp)
) {
Text("Version: ${BuildConfig.VERSION_NAME}")
Text("Mode: ${if (settings.isKernelEnabled) "Kernel" else "Userspace" }")
}
} }
} }

View File

@ -0,0 +1,25 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.support
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa
import com.zaneschepke.wireguardautotunnel.repository.model.Settings
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
@HiltViewModel
class SupportViewModel @Inject constructor(
private val settingsRepo: SettingsDoa
) : ViewModel() {
private val _settings = MutableStateFlow(Settings())
val settings get() = _settings.asStateFlow()
init {
viewModelScope.launch(Dispatchers.IO) {
_settings.value = settingsRepo.getAll().first()
}
}
}

View File

@ -13,5 +13,6 @@ val Pink40 = Color(0xFFFFFFFF)
// status colors // status colors
val brickRed = Color(0xFFCE4257) val brickRed = Color(0xFFCE4257)
val corn = Color(0xFFFBEC5D)
val pinkRed = Color(0xFFEF476F) val pinkRed = Color(0xFFEF476F)
val mint = Color(0xFF52B788) val mint = Color(0xFF52B788)

View File

@ -15,7 +15,8 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
private val DarkColorScheme = darkColorScheme( private val DarkColorScheme =
darkColorScheme(
// primary = Purple80, // primary = Purple80,
primary = virdigris, primary = virdigris,
secondary = virdigris, secondary = virdigris,
@ -24,11 +25,11 @@ private val DarkColorScheme = darkColorScheme(
// tertiary = Pink80 // tertiary = Pink80
) )
private val LightColorScheme = lightColorScheme( private val LightColorScheme =
lightColorScheme(
primary = Purple40, primary = Purple40,
secondary = PurpleGrey40, secondary = PurpleGrey40,
tertiary = Pink40 tertiary = Pink40
/* Other default colors to override /* Other default colors to override
background = Color(0xFFFFFBFE), background = Color(0xFFFFFBFE),
surface = Color(0xFFFFFBFE), surface = Color(0xFFFFFBFE),
@ -50,8 +51,8 @@ fun WireguardAutoTunnelTheme(
dynamicColor: Boolean = false, dynamicColor: Boolean = false,
content: @Composable () -> Unit content: @Composable () -> Unit
) { ) {
val colorScheme =
val colorScheme = when { when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)

View File

@ -7,8 +7,10 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
// Set of Material typography styles to start with // Set of Material typography styles to start with
val Typography = Typography( val Typography =
bodyLarge = TextStyle( Typography(
bodyLarge =
TextStyle(
fontFamily = FontFamily.Default, fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
fontSize = 16.sp, fontSize = 16.sp,

View File

@ -13,23 +13,29 @@ import java.time.Instant
import java.util.zip.ZipEntry import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream import java.util.zip.ZipOutputStream
object StorageUtil { object FileUtils {
private const val ZIP_FILE_MIME_TYPE = "application/zip" private const val ZIP_FILE_MIME_TYPE = "application/zip"
private fun createDownloadsFileOutputStream(context: Context, fileName: String, mimeType : String = Constants.ALLOWED_FILE_TYPES) : OutputStream? {
private fun createDownloadsFileOutputStream(
context: Context,
fileName: String,
mimeType: String = Constants.ALLOWED_FILE_TYPES
): OutputStream? {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val resolver = context.contentResolver val resolver = context.contentResolver
val contentValues = ContentValues().apply { val contentValues =
ContentValues().apply {
put(MediaColumns.DISPLAY_NAME, fileName) put(MediaColumns.DISPLAY_NAME, fileName)
put(MediaColumns.MIME_TYPE, mimeType) put(MediaColumns.MIME_TYPE, mimeType)
put(MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS) put(MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
} }
val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues) val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues)
if (uri != null) { if (uri != null) {
return resolver.openOutputStream(uri) return resolver.openOutputStream(uri)
} }
} else { } else {
val target = File( val target =
File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
fileName fileName
) )
@ -38,8 +44,15 @@ object StorageUtil {
return null return null
} }
fun saveFilesToZip(context: Context, files : List<File>) { fun saveFilesToZip(
val zipOutputStream = createDownloadsFileOutputStream(context, "wg-export_${Instant.now().epochSecond}.zip", ZIP_FILE_MIME_TYPE) context: Context,
files: List<File>
) {
val zipOutputStream = createDownloadsFileOutputStream(
context,
"wg-export_${Instant.now().epochSecond}.zip",
ZIP_FILE_MIME_TYPE
)
ZipOutputStream(zipOutputStream).use { zos -> ZipOutputStream(zipOutputStream).use { zos ->
files.forEach { file -> files.forEach { file ->
val entry = ZipEntry(file.name) val entry = ZipEntry(file.name)

View File

@ -6,7 +6,6 @@ import java.time.Instant
import kotlin.math.pow import kotlin.math.pow
object NumberUtils { object NumberUtils {
private const val BYTES_IN_KB = 1024.0 private const val BYTES_IN_KB = 1024.0
private val BYTES_IN_MB = BYTES_IN_KB.pow(2.0) private val BYTES_IN_MB = BYTES_IN_KB.pow(2.0)
private val keyValidationRegex = """^[A-Za-z0-9+/]{42}[AEIMQUYcgkosw480]=${'$'}""".toRegex() private val keyValidationRegex = """^[A-Za-z0-9+/]{42}[AEIMQUYcgkosw480]=${'$'}""".toRegex()

View File

@ -6,6 +6,7 @@ class WgTunnelException(e: Exception) : Exception() {
constructor(message: String) : this(Exception(message)) constructor(message: String) : this(Exception(message))
override val message: String = generateExceptionMessage(e) override val message: String = generateExceptionMessage(e)
private fun generateExceptionMessage(e: Exception): String { private fun generateExceptionMessage(e: Exception): String {
return when (e) { return when (e) {
is BadConfigException -> "${e.section.name} ${e.location.name} ${e.reason.name}" is BadConfigException -> "${e.section.name} ${e.location.name} ${e.reason.name}"

View File

@ -152,5 +152,6 @@
<string name="email">Email</string> <string name="email">Email</string>
<string name="email_description">Send me an email</string> <string name="email_description">Send me an email</string>
<string name="support_help_text">If you are experiencing issues, have improvement ideas, or just want to engage, the following resources are available:</string> <string name="support_help_text">If you are experiencing issues, have improvement ideas, or just want to engage, the following resources are available:</string>
<string name="kernel">Kernel</string>
<string name="use_kernel">Use kernel module</string>
</resources> </resources>

View File

@ -3,14 +3,16 @@ import org.gradle.api.invocation.Gradle
object BuildHelper { object BuildHelper {
private fun getCurrentFlavor(gradle: Gradle): String { private fun getCurrentFlavor(gradle: Gradle): String {
val taskRequestsStr = gradle.startParameter.taskRequests.toString() val taskRequestsStr = gradle.startParameter.taskRequests.toString()
val pattern: java.util.regex.Pattern = if (taskRequestsStr.contains("assemble")) { val pattern: java.util.regex.Pattern =
if (taskRequestsStr.contains("assemble")) {
java.util.regex.Pattern.compile("assemble(\\w+)(Release|Debug)") java.util.regex.Pattern.compile("assemble(\\w+)(Release|Debug)")
} else { } else {
java.util.regex.Pattern.compile("bundle(\\w+)(Release|Debug)") java.util.regex.Pattern.compile("bundle(\\w+)(Release|Debug)")
} }
val matcher = pattern.matcher(taskRequestsStr) val matcher = pattern.matcher(taskRequestsStr)
val flavor = if (matcher.find()) { val flavor =
if (matcher.find()) {
matcher.group(1).lowercase() matcher.group(1).lowercase()
} else { } else {
print("NO FLAVOR FOUND") print("NO FLAVOR FOUND")
@ -22,8 +24,13 @@ object BuildHelper {
fun isGeneralFlavor(gradle: Gradle): Boolean { fun isGeneralFlavor(gradle: Gradle): Boolean {
return getCurrentFlavor(gradle) == "general" return getCurrentFlavor(gradle) == "general"
} }
fun isReleaseBuild(gradle: Gradle): Boolean { fun isReleaseBuild(gradle: Gradle): Boolean {
return (gradle.startParameter.taskNames.size > 0 && gradle.startParameter.taskNames[0].contains( return (
"Release")) gradle.startParameter.taskNames.size > 0 &&
gradle.startParameter.taskNames[0].contains(
"Release",
)
)
} }
} }

View File

@ -1,7 +1,7 @@
object Constants { object Constants {
const val VERSION_NAME = "3.2.3" const val VERSION_NAME = "3.2.4"
const val JVM_TARGET = "17" const val JVM_TARGET = "17"
const val VERSION_CODE = 32300 const val VERSION_CODE = 32400
const val TARGET_SDK = 34 const val TARGET_SDK = 34
const val MIN_SDK = 26 const val MIN_SDK = 26
const val APP_ID = "com.zaneschepke.wireguardautotunnel" const val APP_ID = "com.zaneschepke.wireguardautotunnel"

View File

@ -0,0 +1,5 @@
Enhancements:
- Add basic WireGuard Kernel support
- Improved location disclosure flow
- Fix auto-tunnel permissions bug
- Various other UI bug fixes

View File

@ -1,11 +1,12 @@
[versions] [versions]
accompanist = "0.32.0" accompanist = "0.32.0"
activityCompose = "1.8.1" activityCompose = "1.8.2"
androidx-junit = "1.1.5" androidx-junit = "1.1.5"
appcompat = "1.6.1" appcompat = "1.6.1"
biometricKtx = "1.2.0-alpha05" biometricKtx = "1.2.0-alpha05"
coreGoogleShortcuts = "1.1.0" coreGoogleShortcuts = "1.1.0"
coreKtx = "1.12.0" coreKtx = "1.12.0"
datastorePreferences = "1.0.0"
desugar_jdk_libs = "2.0.4" desugar_jdk_libs = "2.0.4"
espressoCore = "3.5.1" espressoCore = "3.5.1"
firebase-crashlytics-gradle = "2.9.9" firebase-crashlytics-gradle = "2.9.9"
@ -17,7 +18,7 @@ kotlinx-serialization-json = "1.6.2"
lifecycle-runtime-compose = "2.6.2" lifecycle-runtime-compose = "2.6.2"
material-icons-extended = "1.5.4" material-icons-extended = "1.5.4"
material3 = "1.1.2" material3 = "1.1.2"
navigationCompose = "2.7.5" navigationCompose = "2.7.6"
roomVersion = "2.6.1" roomVersion = "2.6.1"
timber = "5.0.1" timber = "5.0.1"
tunnel = "1.0.20230706" tunnel = "1.0.20230706"
@ -25,7 +26,7 @@ androidGradlePlugin = "8.2.0"
kotlin="1.9.10" kotlin="1.9.10"
ksp="1.9.10-1.0.13" ksp="1.9.10-1.0.13"
composeBom="2023.10.01" composeBom="2023.10.01"
firebaseBom= "32.6.0" firebaseBom= "32.7.0"
compose="1.5.4" compose="1.5.4"
crashlytics= "18.6.0" crashlytics= "18.6.0"
analytics="21.5.0" analytics="21.5.0"
@ -45,6 +46,7 @@ accompanist-systemuicontroller = { module = "com.google.accompanist:accompanist-
androidx-biometric-ktx = { module = "androidx.biometric:biometric-ktx", version.ref = "biometricKtx" } androidx-biometric-ktx = { module = "androidx.biometric:biometric-ktx", version.ref = "biometricKtx" }
androidx-core = { module = "androidx.core:core", version.ref = "coreKtx" } androidx-core = { module = "androidx.core:core", version.ref = "coreKtx" }
androidx-core-google-shortcuts = { module = "androidx.core:core-google-shortcuts", version.ref = "coreGoogleShortcuts" } androidx-core-google-shortcuts = { module = "androidx.core:core-google-shortcuts", version.ref = "coreGoogleShortcuts" }
androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" }
androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "lifecycle-runtime-compose" } androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "lifecycle-runtime-compose" }
androidx-lifecycle-service = { module = "androidx.lifecycle:lifecycle-service", version.ref = "lifecycle-runtime-compose" } androidx-lifecycle-service = { module = "androidx.lifecycle:lifecycle-service", version.ref = "lifecycle-runtime-compose" }
androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "roomVersion" } androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "roomVersion" }
@ -61,6 +63,7 @@ androidx-compose-ui-tooling-preview = { module="androidx.compose.ui:ui-tooling-p
androidx-compose-ui = { module="androidx.compose.ui:ui", version.ref="compose" } androidx-compose-ui = { module="androidx.compose.ui:ui", version.ref="compose" }
#hilt #hilt
androidx-room-testing = { module = "androidx.room:room-testing", version.ref = "roomVersion" }
desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" } desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" }
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hiltAndroid" } hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hiltAndroid" }
hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hiltAndroid" } hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hiltAndroid" }

View File

@ -1,47 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<span style="white-space: pre;">
Privacy Policy
==============
WG Tunnel provides an alternative Android client app for network tunnels using the WireGuard Protocol.
Information you provide
-----------------------
No information provided to the App is transmitted to me or anyone else.
The App does not collect information for purposes of our collection. Your
data is not collected.
Background Location
------------------------
This application does collect location information (specifically Wi-Fi ssid name) in the background
for the auto tunnel feature. This information is not stored or transmitted but is simple collected
by the app to determine whether or not to turn on the VPN.
Updates to this document
------------------------
I will keep this document up-to-date. Your continued use of WG Tunnel confirms
your acceptance of this Privacy Policy.
Contact Me
----------
If you have questions about this Privacy Policy, please contact me
zanecschepke@gmail.com or Discord (invite link on this repository).
Effective as of May 24, 2023
Updated May 24, 2023
</span>
</body>
</html>

View File

@ -15,4 +15,3 @@ dependencyResolutionManagement {
rootProject.name = "WG Tunnel" rootProject.name = "WG Tunnel"
include(":app") include(":app")