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 }}
with:
# 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 }}
name: Release ${{ github.ref_name }}
draft: false

View File

@ -23,6 +23,10 @@ android {
arg("room.schemaLocation", "$projectDir/schemas")
}
sourceSets {
getByName("debug").assets.srcDirs(files("$projectDir/schemas")) // Room
}
resourceConfigurations.addAll(listOf("en"))
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@ -33,33 +37,51 @@ android {
signingConfigs {
create(Constants.RELEASE) {
val properties = Properties().apply {
//created local file for signing details
try {
load(file("signing.properties").reader())
} catch (_ : Exception) {
load(file("signing_template.properties").reader())
val properties =
Properties().apply {
// created local file for signing details
try {
load(file("signing.properties").reader())
} catch (_: Exception) {
load(file("signing_template.properties").reader())
}
}
}
//try to get secrets from env first for pipeline build, then properties file for local build
storeFile = file(System.getenv().getOrDefault(Constants.KEY_STORE_PATH_VAR, properties.getProperty(Constants.KEY_STORE_PATH_VAR)))
storePassword = System.getenv().getOrDefault(Constants.STORE_PASS_VAR, properties.getProperty(Constants.STORE_PASS_VAR))
keyAlias = System.getenv().getOrDefault(Constants.KEY_ALIAS_VAR, properties.getProperty(Constants.KEY_ALIAS_VAR))
keyPassword = System.getenv().getOrDefault(Constants.KEY_PASS_VAR, properties.getProperty(Constants.KEY_PASS_VAR))
// try to get secrets from env first for pipeline build, then properties file for local build
storeFile = file(
System.getenv().getOrDefault(
Constants.KEY_STORE_PATH_VAR,
properties.getProperty(Constants.KEY_STORE_PATH_VAR)
)
)
storePassword = System.getenv().getOrDefault(
Constants.STORE_PASS_VAR,
properties.getProperty(Constants.STORE_PASS_VAR)
)
keyAlias = System.getenv().getOrDefault(
Constants.KEY_ALIAS_VAR,
properties.getProperty(Constants.KEY_ALIAS_VAR)
)
keyPassword = System.getenv().getOrDefault(
Constants.KEY_PASS_VAR,
properties.getProperty(Constants.KEY_PASS_VAR)
)
}
}
buildTypes {
//don't strip
packaging.jniLibs.keepDebugSymbols.addAll(listOf("libwg-go.so", "libwg-quick.so", "libwg.so"))
// don't strip
packaging.jniLibs.keepDebugSymbols.addAll(
listOf("libwg-go.so", "libwg-quick.so", "libwg.so")
)
applicationVariants.all {
val variant = this
variant.outputs
.map { it as com.android.build.gradle.internal.api.BaseVariantOutputImpl }
.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
}
}
@ -85,8 +107,7 @@ android {
}
create("general") {
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.firebase.crashlytics")
}
@ -103,7 +124,6 @@ android {
buildFeatures {
compose = true
buildConfig = true
}
composeOptions {
kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get()
@ -129,20 +149,22 @@ dependencies {
implementation(libs.androidx.material3)
implementation(libs.androidx.appcompat)
//test
// test
testImplementation(libs.junit)
testImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test)
androidTestImplementation(libs.androidx.room.testing)
debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.manifest)
//wg
// wg
implementation(libs.tunnel)
coreLibraryDesugaring(libs.desugar.jdk.libs)
//logging
// logging
implementation(libs.timber)
// compose navigation
@ -153,41 +175,41 @@ dependencies {
implementation(libs.hilt.android)
ksp(libs.hilt.android.compiler)
//accompanist
// accompanist
implementation(libs.accompanist.systemuicontroller)
implementation(libs.accompanist.permissions)
implementation(libs.accompanist.flowlayout)
implementation(libs.accompanist.drawablepainter)
//room
// storage
implementation(libs.androidx.room.runtime)
ksp(libs.androidx.room.compiler)
implementation(libs.androidx.room.ktx)
implementation(libs.androidx.datastore.preferences)
//lifecycle
// lifecycle
implementation(libs.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.process)
//icons
// icons
implementation(libs.material.icons.extended)
//serialization
// serialization
implementation(libs.kotlinx.serialization.json)
//firebase crashlytics
// firebase crashlytics
generalImplementation(platform(libs.firebase.bom))
generalImplementation(libs.google.firebase.crashlytics.ktx)
generalImplementation(libs.google.firebase.analytics.ktx)
//barcode scanning
// barcode scanning
implementation(libs.zxing.android.embedded)
implementation(libs.zxing.core)
//bio
// bio
implementation(libs.androidx.biometric.ktx)
//shortcuts
// shortcuts
implementation(libs.androidx.core)
implementation(libs.androidx.core.google.shortcuts)
}
}

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
# hide the original source file name.
#-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

@ -19,4 +19,4 @@ class ExampleInstrumentedTest {
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.zaneschepke.wireguardautotunnel", appContext.packageName)
}
}
}

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

View File

@ -2,7 +2,7 @@ package com.zaneschepke.wireguardautotunnel
object Constants {
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_STATISTIC_CHECK_INTERVAL = 1000L
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 ANDROID_TV_EXPLORER_STUB = "com.android.tv.frameworkpackagestubs"
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
import android.content.BroadcastReceiver
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import java.math.BigDecimal
import java.text.DecimalFormat
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
fun BroadcastReceiver.goAsync(
context: CoroutineContext = EmptyCoroutineContext,
@ -25,7 +25,7 @@ fun BroadcastReceiver.goAsync(
}
}
fun BigDecimal.toThreeDecimalPlaceString() : String {
fun BigDecimal.toThreeDecimalPlaceString(): String {
val df = DecimalFormat("#.###")
return df.format(this)
}

View File

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

View File

@ -16,11 +16,15 @@ import javax.inject.Singleton
class DatabaseModule {
@Provides
@Singleton
fun provideDatabase(@ApplicationContext context : Context) : AppDatabase {
fun provideDatabase(
@ApplicationContext context: Context
): AppDatabase {
return Room.databaseBuilder(
context,
AppDatabase::class.java, context.getString(R.string.db_name))
AppDatabase::class.java,
context.getString(R.string.db_name)
)
.fallbackToDestructiveMigration()
.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,27 +1,35 @@
package com.zaneschepke.wireguardautotunnel.module
import android.content.Context
import com.zaneschepke.wireguardautotunnel.repository.AppDatabase
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa
import com.zaneschepke.wireguardautotunnel.repository.TunnelConfigDao
import com.zaneschepke.wireguardautotunnel.repository.datastore.DataStoreManager
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
class RepositoryModule {
@Singleton
@Provides
fun provideSettingsRepository(appDatabase: AppDatabase) : SettingsDoa {
fun provideSettingsRepository(appDatabase: AppDatabase): SettingsDoa {
return appDatabase.settingDao()
}
@Singleton
@Provides
fun provideTunnelConfigRepository(appDatabase: AppDatabase) : TunnelConfigDao {
fun provideTunnelConfigRepository(appDatabase: AppDatabase): TunnelConfigDao {
return appDatabase.tunnelConfigDoa()
}
}
@Singleton
@Provides
fun providePreferencesDataStore(@ApplicationContext context: Context): DataStoreManager {
return DataStoreManager(context)
}
}

View File

@ -15,20 +15,19 @@ import dagger.hilt.android.scopes.ServiceScoped
@Module
@InstallIn(ServiceComponent::class)
abstract class ServiceModule {
@Binds
@ServiceScoped
abstract fun provideNotificationService(wireGuardNotification: WireGuardNotification): NotificationService
@Binds
@ServiceScoped
abstract fun provideNotificationService(wireGuardNotification: WireGuardNotification) : NotificationService
abstract fun provideWifiService(wifiService: WifiService): NetworkService<WifiService>
@Binds
@ServiceScoped
abstract fun provideWifiService(wifiService: WifiService) : NetworkService<WifiService>
abstract fun provideMobileDataService(mobileDataService: MobileDataService): NetworkService<MobileDataService>
@Binds
@ServiceScoped
abstract fun provideMobileDataService(mobileDataService : MobileDataService) : NetworkService<MobileDataService>
@Binds
@ServiceScoped
abstract fun provideEthernetService(ethernetService: EthernetService) : NetworkService<EthernetService>
}
abstract fun provideEthernetService(ethernetService: EthernetService): NetworkService<EthernetService>
}

View File

@ -3,6 +3,10 @@ package com.zaneschepke.wireguardautotunnel.module
import android.content.Context
import com.wireguard.android.backend.Backend
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.WireGuardTunnel
import dagger.Module
@ -15,16 +19,40 @@ import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
class TunnelModule {
@Provides
@Singleton
fun provideRootShell(
@ApplicationContext context: Context
): RootShell {
return RootShell(context)
}
@Provides
@Singleton
fun provideBackend(@ApplicationContext context : Context) : Backend {
@Userspace
fun provideUserspaceBackend(
@ApplicationContext context: Context
): Backend {
return GoBackend(context)
}
@Provides
@Singleton
fun provideVpnService(backend: Backend) : VpnService {
return WireGuardTunnel(backend)
@Kernel
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.service.foreground.ServiceManager
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.cancel
import javax.inject.Inject
import kotlinx.coroutines.cancel
@AndroidEntryPoint
class BootReceiver : BroadcastReceiver() {
@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) {
try {
val settings = settingsRepo.getAll()
@ -31,4 +33,4 @@ class BootReceiver : BroadcastReceiver() {
}
}
}
}
}

View File

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

View File

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

View File

@ -9,15 +9,16 @@ class DatabaseListConverters {
fun listToString(value: MutableList<String>): String {
return Json.encodeToString(value)
}
@TypeConverter
fun stringToList(value: String): MutableList<String> {
if(value.isEmpty()) return mutableListOf()
if (value.isEmpty()) return mutableListOf()
return try {
Json.decodeFromString<MutableList<String>>(value)
} catch (e : Exception) {
} catch (e: Exception) {
val list = value.split(",").toMutableList()
val json = listToString(list)
Json.decodeFromString<MutableList<String>>(json)
}
}
}
}

View File

@ -10,7 +10,6 @@ import kotlinx.coroutines.flow.Flow
@Dao
interface SettingsDoa {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun save(t: Settings)
@ -31,4 +30,4 @@ interface SettingsDoa {
@Query("SELECT COUNT('id') FROM settings")
suspend fun count(): Long
}
}

View File

@ -9,8 +9,7 @@ import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig
import kotlinx.coroutines.flow.Flow
@Dao
interface TunnelConfigDao{
interface TunnelConfigDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun save(t: TunnelConfig)
@ -31,4 +30,4 @@ interface TunnelConfigDao{
@Query("SELECT * FROM tunnelconfig")
fun getAllFlow(): Flow<MutableList<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

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

View File

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

View File

@ -4,4 +4,4 @@ enum class Action {
START,
START_FOREGROUND,
STOP
}
}

View File

@ -6,9 +6,7 @@ import android.os.IBinder
import androidx.lifecycle.LifecycleService
import timber.log.Timber
open class ForegroundService : LifecycleService() {
private var isServiceStarted = false
override fun onBind(intent: Intent): IBinder? {
@ -17,7 +15,11 @@ open class ForegroundService : LifecycleService() {
return null
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
override fun onStartCommand(
intent: Intent?,
flags: Int,
startId: Int
): Int {
super.onStartCommand(intent, flags, startId)
Timber.d("onStartCommand executed with startId: $startId")
if (intent != null) {
@ -41,19 +43,18 @@ open class ForegroundService : LifecycleService() {
return START_STICKY
}
override fun onDestroy() {
super.onDestroy()
Timber.d("The service has been destroyed")
}
protected open fun startService(extras : Bundle?) {
protected open fun startService(extras: Bundle?) {
if (isServiceStarted) return
Timber.d("Starting ${this.javaClass.simpleName}")
isServiceStarted = true
}
protected open fun stopService(extras : Bundle?) {
protected open fun stopService(extras: Bundle?) {
Timber.d("Stopping ${this.javaClass.simpleName}")
try {
stopForeground(STOP_FOREGROUND_REMOVE)
@ -63,4 +64,4 @@ open class ForegroundService : LifecycleService() {
}
isServiceStarted = false
}
}
}

View File

@ -16,44 +16,60 @@ object ServiceManager {
.getRunningServices(Integer.MAX_VALUE)
.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)
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.STARTED && action == Action.START) return
val intent = Intent(context, cls).also {
it.action = action.name
extras?.forEach {(k, v) ->
it.putExtra(k, v)
val intent =
Intent(context, cls).also {
it.action = action.name
extras?.forEach { (k, v) ->
it.putExtra(k, v)
}
}
}
intent.component?.javaClass
try {
when(action) {
when (action) {
Action.START_FOREGROUND -> {
context.startForegroundService(intent)
}
Action.START -> {
context.startService(intent)
}
Action.STOP -> context.startService(intent)
}
} catch (e : Exception) {
} catch (e: Exception) {
Timber.e(e.message)
}
}
fun startVpnService(context : Context, tunnelConfig : String) {
fun startVpnService(
context: Context,
tunnelConfig: String
) {
actionOnService(
Action.START,
context,
WireGuardTunnelService::class.java,
mapOf(context.getString(R.string.tunnel_extras_key) to tunnelConfig))
mapOf(context.getString(R.string.tunnel_extras_key) to tunnelConfig)
)
}
fun stopVpnService(context : Context) {
fun stopVpnService(context: Context) {
actionOnService(
Action.STOP,
context,
@ -61,41 +77,70 @@ object ServiceManager {
)
}
fun startVpnServiceForeground(context : Context, tunnelConfig : String) {
fun startVpnServiceForeground(
context: Context,
tunnelConfig: String
) {
actionOnService(
Action.START_FOREGROUND,
context,
WireGuardTunnelService::class.java,
mapOf(context.getString(R.string.tunnel_extras_key) to tunnelConfig))
mapOf(context.getString(R.string.tunnel_extras_key) to tunnelConfig)
)
}
private fun startWatcherServiceForeground(context : Context, tunnelConfig : String) {
private fun startWatcherServiceForeground(
context: Context,
tunnelConfig: String
) {
actionOnService(
Action.START, context,
WireGuardConnectivityWatcherService::class.java, mapOf(context.
getString(R.string.tunnel_extras_key) to
tunnelConfig))
Action.START,
context,
WireGuardConnectivityWatcherService::class.java,
mapOf(
context
.getString(R.string.tunnel_extras_key) to
tunnelConfig
)
)
}
fun startWatcherService(context : Context, tunnelConfig : String) {
fun startWatcherService(
context: Context,
tunnelConfig: String
) {
actionOnService(
Action.START, context,
WireGuardConnectivityWatcherService::class.java, mapOf(context.
getString(R.string.tunnel_extras_key) to
tunnelConfig))
Action.START,
context,
WireGuardConnectivityWatcherService::class.java,
mapOf(
context
.getString(R.string.tunnel_extras_key) to
tunnelConfig
)
)
}
fun stopWatcherService(context : Context) {
fun stopWatcherService(context: Context) {
actionOnService(
Action.STOP, context,
WireGuardConnectivityWatcherService::class.java)
Action.STOP,
context,
WireGuardConnectivityWatcherService::class.java
)
}
fun toggleWatcherServiceForeground(context: Context, tunnelConfig : String) {
when(getServiceState( context,
WireGuardConnectivityWatcherService::class.java,)) {
fun toggleWatcherServiceForeground(
context: Context,
tunnelConfig: String
) {
when (
getServiceState(
context,
WireGuardConnectivityWatcherService::class.java
)
) {
ServiceState.STARTED -> stopWatcherService(context)
ServiceState.STOPPED -> startWatcherServiceForeground(context, tunnelConfig)
}
}
}
}

View File

@ -3,4 +3,4 @@ package com.zaneschepke.wireguardautotunnel.service.foreground
enum class ServiceState {
STARTED,
STOPPED,
}
}

View File

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

View File

@ -3,7 +3,9 @@ package com.zaneschepke.wireguardautotunnel.service.foreground
import android.app.PendingIntent
import android.content.Intent
import android.os.Bundle
import androidx.core.app.ServiceCompat
import androidx.lifecycle.lifecycleScope
import com.zaneschepke.wireguardautotunnel.Constants
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.receiver.NotificationActionReceiver
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa
@ -12,29 +14,28 @@ import com.zaneschepke.wireguardautotunnel.service.notification.NotificationServ
import com.zaneschepke.wireguardautotunnel.service.tunnel.HandshakeStatus
import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@AndroidEntryPoint
class WireGuardTunnelService : ForegroundService() {
private val foregroundId = 123
@Inject
lateinit var vpnService : VpnService
lateinit var vpnService: VpnService
@Inject
lateinit var settingsRepo: SettingsDoa
@Inject
lateinit var notificationService : NotificationService
lateinit var notificationService: NotificationService
private lateinit var job : Job
private lateinit var job: Job
private var tunnelName : String = ""
private var tunnelName: String = ""
override fun onCreate() {
super.onCreate()
@ -43,69 +44,76 @@ class WireGuardTunnelService : ForegroundService() {
}
}
override fun startService(extras : Bundle?) {
override fun startService(extras: Bundle?) {
super.startService(extras)
launchVpnStartingNotification()
val tunnelConfigString = extras?.getString(getString(R.string.tunnel_extras_key))
cancelJob()
job = lifecycleScope.launch(Dispatchers.IO) {
launch {
if(tunnelConfigString != null) {
try {
val tunnelConfig = TunnelConfig.from(tunnelConfigString)
tunnelName = tunnelConfig.name
vpnService.startTunnel(tunnelConfig)
} catch (e : Exception) {
Timber.e("Problem starting tunnel: ${e.message}")
stopService(extras)
}
} else {
Timber.d("Tunnel config null, starting default tunnel")
val settings = settingsRepo.getAll()
if(settings.isNotEmpty()) {
val setting = settings[0]
if(setting.defaultTunnel != null && setting.isAlwaysOnVpnEnabled) {
val tunnelConfig = TunnelConfig.from(setting.defaultTunnel!!)
job =
lifecycleScope.launch(Dispatchers.IO) {
launch {
if (tunnelConfigString != null) {
try {
val tunnelConfig = TunnelConfig.from(tunnelConfigString)
tunnelName = tunnelConfig.name
vpnService.startTunnel(tunnelConfig)
} catch (e: Exception) {
Timber.e("Problem starting tunnel: ${e.message}")
stopService(extras)
}
} else {
Timber.d("Tunnel config null, starting default tunnel")
val settings = settingsRepo.getAll()
if (settings.isNotEmpty()) {
val setting = settings[0]
if (setting.defaultTunnel != null && setting.isAlwaysOnVpnEnabled) {
val tunnelConfig = TunnelConfig.from(setting.defaultTunnel!!)
tunnelName = tunnelConfig.name
vpnService.startTunnel(tunnelConfig)
}
}
}
}
}
launch {
var didShowConnected = false
var didShowFailedHandshakeNotification = false
vpnService.handshakeStatus.collect {
when(it) {
HandshakeStatus.NOT_STARTED -> {
}
HandshakeStatus.NEVER_CONNECTED -> {
if(!didShowFailedHandshakeNotification) {
launchVpnConnectionFailedNotification(getString(R.string.initial_connection_failure_message))
didShowFailedHandshakeNotification = true
didShowConnected = false
launch {
var didShowConnected = false
var didShowFailedHandshakeNotification = false
vpnService.handshakeStatus.collect {
when (it) {
HandshakeStatus.NOT_STARTED -> {
}
}
HandshakeStatus.HEALTHY -> {
if(!didShowConnected) {
launchVpnConnectedNotification()
didShowConnected = true
HandshakeStatus.NEVER_CONNECTED -> {
if (!didShowFailedHandshakeNotification) {
launchVpnConnectionFailedNotification(
getString(R.string.initial_connection_failure_message)
)
didShowFailedHandshakeNotification = true
didShowConnected = false
}
}
}
HandshakeStatus.UNHEALTHY -> {
if(!didShowFailedHandshakeNotification) {
launchVpnConnectionFailedNotification(getString(R.string.lost_connection_failure_message))
didShowFailedHandshakeNotification = true
didShowConnected = false
HandshakeStatus.HEALTHY -> {
if (!didShowConnected) {
launchVpnConnectedNotification()
didShowConnected = true
}
}
HandshakeStatus.STALE -> {}
HandshakeStatus.UNHEALTHY -> {
if (!didShowFailedHandshakeNotification) {
launchVpnConnectionFailedNotification(
getString(R.string.lost_connection_failure_message)
)
didShowFailedHandshakeNotification = true
didShowConnected = false
}
}
}
}
}
}
}
}
override fun stopService(extras : Bundle?) {
override fun stopService(extras: Bundle?) {
super.stopService(extras)
lifecycleScope.launch(Dispatchers.IO) {
vpnService.stopTunnel()
@ -115,51 +123,68 @@ class WireGuardTunnelService : ForegroundService() {
}
private fun launchVpnConnectedNotification() {
val notification = notificationService.createNotification(
channelId = getString(R.string.vpn_channel_id),
channelName = getString(R.string.vpn_channel_name),
title = getString(R.string.tunnel_start_title),
onGoing = false,
vibration = false,
showTimestamp = true,
description = "${getString(R.string.tunnel_start_text)} $tunnelName"
val notification =
notificationService.createNotification(
channelId = getString(R.string.vpn_channel_id),
channelName = getString(R.string.vpn_channel_name),
title = getString(R.string.tunnel_start_title),
onGoing = false,
vibration = false,
showTimestamp = true,
description = "${getString(R.string.tunnel_start_text)} $tunnelName"
)
ServiceCompat.startForeground(
this,
foregroundId,
notification,
Constants.SYSTEM_EXEMPT_SERVICE_TYPE_ID
)
super.startForeground(foregroundId, notification)
}
private fun launchVpnStartingNotification() {
val notification = notificationService.createNotification(
channelId = getString(R.string.vpn_channel_id),
channelName = getString(R.string.vpn_channel_name),
title = getString(R.string.vpn_starting),
onGoing = false,
vibration = false,
showTimestamp = true,
description = getString(R.string.attempt_connection)
val notification =
notificationService.createNotification(
channelId = getString(R.string.vpn_channel_id),
channelName = getString(R.string.vpn_channel_name),
title = getString(R.string.vpn_starting),
onGoing = false,
vibration = false,
showTimestamp = true,
description = getString(R.string.attempt_connection)
)
ServiceCompat.startForeground(
this,
foregroundId,
notification,
Constants.SYSTEM_EXEMPT_SERVICE_TYPE_ID
)
super.startForeground(foregroundId, notification)
}
private fun launchVpnConnectionFailedNotification(message : String) {
val notification = notificationService.createNotification(
channelId = getString(R.string.vpn_channel_id),
channelName = getString(R.string.vpn_channel_name),
action = PendingIntent.getBroadcast(this,0,
Intent(this, NotificationActionReceiver::class.java),PendingIntent.FLAG_IMMUTABLE),
actionText = getString(R.string.restart),
title = getString(R.string.vpn_connection_failed),
onGoing = false,
vibration = true,
showTimestamp = true,
description = message
)
private fun launchVpnConnectionFailedNotification(message: String) {
val notification =
notificationService.createNotification(
channelId = getString(R.string.vpn_channel_id),
channelName = getString(R.string.vpn_channel_name),
action =
PendingIntent.getBroadcast(
this,
0,
Intent(this, NotificationActionReceiver::class.java),
PendingIntent.FLAG_IMMUTABLE
),
actionText = getString(R.string.restart),
title = getString(R.string.vpn_connection_failed),
onGoing = false,
vibration = true,
showTimestamp = true,
description = message
)
super.startForeground(foregroundId, notification)
}
private fun cancelJob() {
if(this::job.isInitialized) {
if (this::job.isInitialized) {
job.cancel()
}
}
}
}

View File

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

View File

@ -5,6 +5,9 @@ import android.net.NetworkCapabilities
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
class EthernetService @Inject constructor(@ApplicationContext context: Context) :
BaseNetworkService<EthernetService>(context, NetworkCapabilities.TRANSPORT_ETHERNET) {
}
class EthernetService
@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 javax.inject.Inject
class MobileDataService @Inject constructor(@ApplicationContext context: Context) :
BaseNetworkService<MobileDataService>(context, NetworkCapabilities.TRANSPORT_CELLULAR) {
}
class MobileDataService
@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> {
fun getNetworkName(networkCapabilities: NetworkCapabilities): String?
val networkStatus: Flow<NetworkStatus>
}

View File

@ -4,7 +4,10 @@ import android.net.Network
import android.net.NetworkCapabilities
sealed class NetworkStatus {
class Available(val network : Network) : NetworkStatus()
class Unavailable(val network : Network) : NetworkStatus()
class CapabilitiesChanged(val network : Network, val networkCapabilities : NetworkCapabilities) : NetworkStatus()
class Available(val network: Network) : NetworkStatus()
class Unavailable(val network: Network) : 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 javax.inject.Inject
class WifiService @Inject constructor(@ApplicationContext context: Context) :
BaseNetworkService<WifiService>(context, NetworkCapabilities.TRANSPORT_WIFI) {
}
class WifiService
@Inject
constructor(
@ApplicationContext context: Context
) :
BaseNetworkService<WifiService>(context, NetworkCapabilities.TRANSPORT_WIFI)

View File

@ -12,10 +12,10 @@ interface NotificationService {
action: PendingIntent? = null,
actionText: String? = null,
description: String,
showTimestamp : Boolean = false,
showTimestamp: Boolean = false,
importance: Int = NotificationManager.IMPORTANCE_HIGH,
vibration: Boolean = false,
onGoing: Boolean = true,
lights: Boolean = true
): Notification
}
}

View File

@ -12,9 +12,13 @@ import com.zaneschepke.wireguardautotunnel.ui.MainActivity
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
class WireGuardNotification @Inject constructor(@ApplicationContext private val context: Context) : NotificationService {
private val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
class WireGuardNotification
@Inject
constructor(
@ApplicationContext private val context: Context
) : NotificationService {
private val notificationManager =
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
override fun createNotification(
channelId: String,
@ -29,18 +33,19 @@ class WireGuardNotification @Inject constructor(@ApplicationContext private val
onGoing: Boolean,
lights: Boolean
): Notification {
val channel = NotificationChannel(
channelId,
channelName,
importance
).let {
it.description = title
it.enableLights(lights)
it.lightColor = Color.RED
it.enableVibration(vibration)
it.vibrationPattern = longArrayOf(100, 200, 300, 400, 500, 400, 300, 200, 400)
it
}
val channel =
NotificationChannel(
channelId,
channelName,
importance
).let {
it.description = title
it.enableLights(lights)
it.lightColor = Color.RED
it.enableVibration(vibration)
it.vibrationPattern = longArrayOf(100, 200, 300, 400, 500, 400, 300, 200, 400)
it
}
notificationManager.createNotificationChannel(channel)
val pendingIntent: PendingIntent =
Intent(context, MainActivity::class.java).let { notificationIntent ->
@ -58,14 +63,15 @@ class WireGuardNotification @Inject constructor(@ApplicationContext private val
channelId
)
return builder.let {
if(action != null && actionText != null) {
//TODO find a not deprecated way to do this
if (action != null && actionText != null) {
// TODO find a not deprecated way to do this
it.addAction(
Notification.Action.Builder(0, actionText, action)
.build())
it.setAutoCancel(true)
.build()
)
it.setAutoCancel(true)
}
it.setContentTitle(title)
it.setContentTitle(title)
.setContentText(description)
.setContentIntent(pendingIntent)
.setOngoing(onGoing)
@ -74,4 +80,4 @@ class WireGuardNotification @Inject constructor(@ApplicationContext private val
.build()
}
}
}
}

View File

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

View File

@ -11,34 +11,34 @@ import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@AndroidEntryPoint
class TunnelControlTile : TileService() {
@Inject
lateinit var settingsRepo: SettingsDoa
@Inject
lateinit var settingsRepo : SettingsDoa
lateinit var configRepo: TunnelConfigDao
@Inject
lateinit var configRepo : TunnelConfigDao
@Inject
lateinit var vpnService : VpnService
lateinit var vpnService: VpnService
private val scope = CoroutineScope(Dispatchers.Main)
private lateinit var job : Job
private lateinit var job: Job
override fun onStartListening() {
job = scope.launch {
updateTileState()
}
job =
scope.launch {
updateTileState()
}
super.onStartListening()
}
@ -58,15 +58,18 @@ class TunnelControlTile : TileService() {
scope.launch {
try {
val tunnel = determineTileTunnel()
if(tunnel != null) {
if (tunnel != null) {
attemptWatcherServiceToggle(tunnel.toString())
if(vpnService.getState() == Tunnel.State.UP) {
if (vpnService.getState() == Tunnel.State.UP) {
ServiceManager.stopVpnService(this@TunnelControlTile)
} else {
ServiceManager.startVpnServiceForeground(this@TunnelControlTile, tunnel.toString())
ServiceManager.startVpnServiceForeground(
this@TunnelControlTile,
tunnel.toString()
)
}
}
} catch (e : Exception) {
} catch (e: Exception) {
Timber.e(e.message)
} finally {
cancel()
@ -75,34 +78,38 @@ class TunnelControlTile : TileService() {
}
}
private suspend fun determineTileTunnel() : TunnelConfig? {
var tunnelConfig : TunnelConfig? = null
private suspend fun determineTileTunnel(): TunnelConfig? {
var tunnelConfig: TunnelConfig? = null
val settings = settingsRepo.getAll()
if (settings.isNotEmpty()) {
val setting = settings.first()
tunnelConfig = if (setting.defaultTunnel != null) {
TunnelConfig.from(setting.defaultTunnel!!)
} else {
val configs = configRepo.getAll()
val config = if(configs.isNotEmpty()) {
configs.first()
tunnelConfig =
if (setting.defaultTunnel != null) {
TunnelConfig.from(setting.defaultTunnel!!)
} else {
null
val configs = configRepo.getAll()
val config =
if (configs.isNotEmpty()) {
configs.first()
} else {
null
}
config
}
config
}
}
return tunnelConfig
}
private fun attemptWatcherServiceToggle(tunnelConfig : String) {
private fun attemptWatcherServiceToggle(tunnelConfig: String) {
scope.launch {
val settings = settingsRepo.getAll()
if (settings.isNotEmpty()) {
val setting = settings.first()
if(setting.isAutoTunnelEnabled) {
ServiceManager.toggleWatcherServiceForeground(this@TunnelControlTile, tunnelConfig)
if (setting.isAutoTunnelEnabled) {
ServiceManager.toggleWatcherServiceForeground(
this@TunnelControlTile,
tunnelConfig
)
}
}
}
@ -111,27 +118,31 @@ class TunnelControlTile : TileService() {
private suspend fun updateTileState() {
vpnService.state.collect {
try {
when(it) {
when (it) {
Tunnel.State.UP -> {
qsTile.state = Tile.STATE_ACTIVE
}
Tunnel.State.DOWN -> {
qsTile.state = Tile.STATE_INACTIVE
}
else -> {
qsTile.state = Tile.STATE_UNAVAILABLE
}
}
val config = determineTileTunnel()
setTileDescription(config?.name ?: this.resources.getString(R.string.no_tunnel_available))
setTileDescription(
config?.name ?: this.resources.getString(R.string.no_tunnel_available)
)
qsTile.updateTile()
} catch (e : Exception) {
} catch (e: Exception) {
Timber.e("Unable to update tile state")
}
}
}
private fun setTileDescription(description : String) {
private fun setTileDescription(description: String) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
qsTile.subtitle = description
}
@ -141,8 +152,8 @@ class TunnelControlTile : TileService() {
}
private fun cancelJob() {
if(this::job.isInitialized) {
if (this::job.isInitialized) {
job.cancel()
}
}
}
}

View File

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

View File

@ -7,12 +7,15 @@ import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig
import kotlinx.coroutines.flow.SharedFlow
interface VpnService : Tunnel {
suspend fun startTunnel(tunnelConfig : TunnelConfig) : Tunnel.State
suspend fun startTunnel(tunnelConfig: TunnelConfig): Tunnel.State
suspend fun stopTunnel()
val state : SharedFlow<Tunnel.State>
val tunnelName : SharedFlow<String>
val statistics : SharedFlow<Statistics>
val lastHandshake : SharedFlow<Map<Key,Long>>
val handshakeStatus : SharedFlow<HandshakeStatus>
fun getState() : Tunnel.State
}
val state: SharedFlow<Tunnel.State>
val tunnelName: SharedFlow<String>
val statistics: SharedFlow<Statistics>
val lastHandshake: SharedFlow<Map<Key, Long>>
val handshakeStatus: SharedFlow<HandshakeStatus>
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.Statistics
import com.wireguard.android.backend.Tunnel
import com.wireguard.config.Config
import com.wireguard.crypto.Key
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.util.NumberUtils
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@ -20,20 +25,28 @@ import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
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("")
override val tunnelName get() = _tunnelName.asStateFlow()
private val _state = MutableSharedFlow<Tunnel.State>(
onBufferOverflow = BufferOverflow.DROP_OLDEST,
replay = 1)
private val _state =
MutableSharedFlow<Tunnel.State>(
onBufferOverflow = BufferOverflow.DROP_OLDEST,
replay = 1
)
private val _handshakeStatus = MutableSharedFlow<HandshakeStatus>(replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST)
private val _handshakeStatus =
MutableSharedFlow<HandshakeStatus>(
replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
override val state get() = _state.asSharedFlow()
private val _statistics = MutableSharedFlow<Statistics>(replay = 1)
@ -47,30 +60,56 @@ class WireGuardTunnel @Inject constructor(private val backend : Backend) : VpnSe
private val scope = CoroutineScope(Dispatchers.IO)
private lateinit var statsJob : Job
private lateinit var statsJob: Job
private var config: Config? = null
override suspend fun startTunnel(tunnelConfig: TunnelConfig) : Tunnel.State{
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 {
return try {
stopTunnelOnConfigChange(tunnelConfig)
emitTunnelName(tunnelConfig.name)
val config = TunnelConfig.configFromQuick(tunnelConfig.wgQuick)
val state = backend.setState(
this, Tunnel.State.UP, config)
config = TunnelConfig.configFromQuick(tunnelConfig.wgQuick)
val state =
backend.setState(
this,
Tunnel.State.UP,
config
)
_state.emit(state)
state
} catch (e : Exception) {
} catch (e: Exception) {
Timber.e("Failed to start tunnel with error: ${e.message}")
Tunnel.State.DOWN
}
}
private suspend fun emitTunnelName(name : String) {
private suspend fun emitTunnelName(name: String) {
_tunnelName.emit(name)
}
private suspend fun stopTunnelOnConfigChange(tunnelConfig: TunnelConfig) {
if(getState() == Tunnel.State.UP && _tunnelName.value != tunnelConfig.name) {
if (getState() == Tunnel.State.UP && _tunnelName.value != tunnelConfig.name) {
stopTunnel()
}
}
@ -81,11 +120,11 @@ class WireGuardTunnel @Inject constructor(private val backend : Backend) : VpnSe
override suspend fun stopTunnel() {
try {
if(getState() == Tunnel.State.UP) {
if (getState() == Tunnel.State.UP) {
val state = backend.setState(this, Tunnel.State.DOWN, null)
_state.emit(state)
}
} catch (e : BackendException) {
} catch (e: BackendException) {
Timber.e("Failed to stop tunnel with error: ${e.message}")
}
}
@ -94,49 +133,55 @@ class WireGuardTunnel @Inject constructor(private val backend : Backend) : VpnSe
return backend.getState(this)
}
override fun onStateChange(state : Tunnel.State) {
override fun onStateChange(state: Tunnel.State) {
val tunnel = this
_state.tryEmit(state)
if(state == Tunnel.State.UP) {
statsJob = scope.launch {
val handshakeMap = HashMap<Key, Long>()
var neverHadHandshakeCounter = 0
while (true) {
val statistics = backend.getStatistics(tunnel)
_statistics.emit(statistics)
statistics.peers().forEach {
val handshakeEpoch = statistics.peer(it)?.latestHandshakeEpochMillis ?: 0L
handshakeMap[it] = handshakeEpoch
if(handshakeEpoch == 0L) {
if(neverHadHandshakeCounter >= HandshakeStatus.NEVER_CONNECTED_TO_UNHEALTHY_TIME_LIMIT_SEC) {
_handshakeStatus.emit(HandshakeStatus.NEVER_CONNECTED)
} else {
_handshakeStatus.emit(HandshakeStatus.NOT_STARTED)
if (state == Tunnel.State.UP) {
statsJob =
scope.launch {
val handshakeMap = HashMap<Key, Long>()
var neverHadHandshakeCounter = 0
while (true) {
val statistics = backend.getStatistics(tunnel)
_statistics.emit(statistics)
statistics.peers().forEach { key ->
val handshakeEpoch =
statistics.peer(key)?.latestHandshakeEpochMillis ?: 0L
handshakeMap[key] = handshakeEpoch
if (handshakeEpoch == 0L) {
if (neverHadHandshakeCounter >= HandshakeStatus.NEVER_CONNECTED_TO_UNHEALTHY_TIME_LIMIT_SEC) {
_handshakeStatus.emit(HandshakeStatus.NEVER_CONNECTED)
} else {
_handshakeStatus.emit(HandshakeStatus.NOT_STARTED)
}
if (neverHadHandshakeCounter <= HandshakeStatus.NEVER_CONNECTED_TO_UNHEALTHY_TIME_LIMIT_SEC) {
neverHadHandshakeCounter += (1 * Constants.VPN_STATISTIC_CHECK_INTERVAL / 1000).toInt()
}
return@forEach
}
if(neverHadHandshakeCounter <= HandshakeStatus.NEVER_CONNECTED_TO_UNHEALTHY_TIME_LIMIT_SEC) {
neverHadHandshakeCounter += (1 * Constants.VPN_STATISTIC_CHECK_INTERVAL/1000).toInt()
// TODO one day make each peer have their own dedicated status
val lastHandshake = NumberUtils.getSecondsBetweenTimestampAndNow(
handshakeEpoch
)
if (lastHandshake != null) {
if (lastHandshake >= HandshakeStatus.STALE_TIME_LIMIT_SEC) {
_handshakeStatus.emit(HandshakeStatus.STALE)
} else {
_handshakeStatus.emit(HandshakeStatus.HEALTHY)
}
}
return@forEach
}
if((NumberUtils.getSecondsBetweenTimestampAndNow(handshakeEpoch) ?: 0L) >= HandshakeStatus.UNHEALTHY_TIME_LIMIT_SEC) {
_handshakeStatus.emit(HandshakeStatus.UNHEALTHY)
} else {
_handshakeStatus.emit(HandshakeStatus.HEALTHY)
}
_lastHandshake.emit(handshakeMap)
delay(Constants.VPN_STATISTIC_CHECK_INTERVAL)
}
_lastHandshake.emit(handshakeMap)
delay(Constants.VPN_STATISTIC_CHECK_INTERVAL)
}
}
}
if(state == Tunnel.State.DOWN) {
if(this::statsJob.isInitialized) {
if (state == Tunnel.State.DOWN) {
if (this::statsJob.isInitialized) {
statsJob.cancel()
}
_handshakeStatus.tryEmit(HandshakeStatus.NOT_STARTED)
_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

@ -2,4 +2,4 @@ package com.zaneschepke.wireguardautotunnel.ui
import com.journeyapps.barcodescanner.CaptureActivity
class CaptureActivityPortrait : CaptureActivity()
class CaptureActivityPortrait : CaptureActivity()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -34,11 +34,14 @@ fun CustomSnackBar(
containerColor: Color = MaterialTheme.colorScheme.surface
) {
val context = LocalContext.current
Snackbar(containerColor = containerColor,
modifier = Modifier.fillMaxWidth(
if(WireGuardAutoTunnel.isRunningOnAndroidTv(context)) 1/3f else 2/3f).padding(bottom = 100.dp),
Snackbar(
containerColor = containerColor,
modifier =
Modifier.fillMaxWidth(
if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) 1 / 3f else 2 / 3f
).padding(bottom = 100.dp),
shape = RoundedCornerShape(16.dp)
) {
) {
CompositionLocalProvider(
LocalLayoutDirection provides
if (isRtl) LayoutDirection.Rtl else LayoutDirection.Ltr
@ -58,4 +61,4 @@ fun CustomSnackBar(
}
}
}
}
}

View File

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

View File

@ -3,23 +3,28 @@ package com.zaneschepke.wireguardautotunnel.ui.models
import com.wireguard.config.Interface
data class InterfaceProxy(
var privateKey : String = "",
var publicKey : String = "",
var addresses : String = "",
var dnsServers : String = "",
var listenPort : String = "",
var mtu : String = "",
){
var privateKey: String = "",
var publicKey: String = "",
var addresses: String = "",
var dnsServers: String = "",
var listenPort: String = "",
var mtu: String = ""
) {
companion object {
fun from(i : Interface) : InterfaceProxy {
fun from(i: Interface): InterfaceProxy {
return InterfaceProxy(
publicKey = i.keyPair.publicKey.toBase64().trim(),
privateKey = i.keyPair.privateKey.toBase64().trim(),
addresses = i.addresses.joinToString(", ").trim(),
dnsServers = i.dnsServers.joinToString(", ").replace("/", "").trim(),
listenPort = if(i.listenPort.isPresent) i.listenPort.get().toString().trim() else "",
mtu = if(i.mtu.isPresent) i.mtu.get().toString().trim() else ""
listenPort = if (i.listenPort.isPresent) {
i.listenPort.get().toString()
.trim()
} else {
""
},
mtu = if (i.mtu.isPresent) i.mtu.get().toString().trim() else ""
)
}
}
}
}

View File

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

View File

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

View File

@ -23,18 +23,20 @@ import com.zaneschepke.wireguardautotunnel.ui.models.PeerProxy
import com.zaneschepke.wireguardautotunnel.util.NumberUtils
import com.zaneschepke.wireguardautotunnel.util.WgTunnelException
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
import javax.inject.Inject
@HiltViewModel
class ConfigViewModel @Inject constructor(private val application : Application,
private val tunnelRepo : TunnelConfigDao,
private val settingsRepo : SettingsDoa
class ConfigViewModel
@Inject
constructor(
private val application: Application,
private val tunnelRepo: TunnelConfigDao,
private val settingsRepo: SettingsDoa
) : ViewModel() {
private val _tunnel = MutableStateFlow<TunnelConfig?>(null)
private val _tunnelName = MutableStateFlow("")
val tunnelName get() = _tunnelName.asStateFlow()
@ -58,13 +60,14 @@ class ConfigViewModel @Inject constructor(private val application : Application,
private val _isAllApplicationsEnabled = MutableStateFlow(false)
val isAllApplicationsEnabled get() = _isAllApplicationsEnabled.asStateFlow()
private val _isDefaultTunnel = MutableStateFlow(false)
val isDefaultTunnel = _isDefaultTunnel.asStateFlow()
private lateinit var tunnelConfig: TunnelConfig
suspend fun onScreenLoad(id : String) {
if(id != Constants.MANUAL_TUNNEL_CONFIG_ID) {
tunnelConfig = getTunnelConfigById(id) ?: throw WgTunnelException("Config not found")
suspend fun onScreenLoad(id: String) {
if (id != Constants.MANUAL_TUNNEL_CONFIG_ID) {
tunnelConfig = getTunnelConfigById(id) ?: throw WgTunnelException(
"Config not found"
)
emitScreenData()
} else {
emitEmptyScreenData()
@ -84,7 +87,6 @@ class ConfigViewModel @Inject constructor(private val application : Application,
}
}
private suspend fun emitScreenData() {
emitTunnelConfig()
emitPeersFromConfig()
@ -97,7 +99,7 @@ class ConfigViewModel @Inject constructor(private val application : Application,
private suspend fun emitDefaultTunnelStatus() {
val settings = settingsRepo.getAll()
if(settings.isNotEmpty()) {
if (settings.isNotEmpty()) {
_isDefaultTunnel.value = settings.first().isTunnelConfigDefault(tunnelConfig)
}
}
@ -109,7 +111,7 @@ class ConfigViewModel @Inject constructor(private val application : Application,
private fun emitPeersFromConfig() {
val config = TunnelConfig.configFromQuick(tunnelConfig.wgQuick)
config.peers.forEach{
config.peers.forEach {
_proxyPeers.value.add(PeerProxy.from(it))
}
}
@ -122,10 +124,10 @@ class ConfigViewModel @Inject constructor(private val application : Application,
_interface.value = interfaceProxy
}
private suspend fun getTunnelConfigById(id : String) : TunnelConfig? {
private suspend fun getTunnelConfigById(id: String): TunnelConfig? {
return try {
tunnelRepo.getById(id.toLong())
} catch (_ : Exception) {
} catch (_: Exception) {
null
}
}
@ -134,30 +136,31 @@ class ConfigViewModel @Inject constructor(private val application : Application,
_tunnel.emit(tunnelConfig)
}
private suspend fun emitTunnelConfigName() {
private suspend fun emitTunnelConfigName() {
_tunnelName.emit(tunnelConfig.name)
}
fun onTunnelNameChange(name : String) {
fun onTunnelNameChange(name: String) {
_tunnelName.value = name
}
fun onIncludeChange(include : Boolean) {
fun onIncludeChange(include: Boolean) {
_include.value = include
}
fun onAddCheckedPackage(packageName : String) {
fun onAddCheckedPackage(packageName: String) {
_checkedPackages.value.add(packageName)
}
fun onAllApplicationsChange(isAllApplicationsEnabled : Boolean) {
fun onAllApplicationsChange(isAllApplicationsEnabled: Boolean) {
_isAllApplicationsEnabled.value = isAllApplicationsEnabled
}
fun onRemoveCheckedPackage(packageName : String) {
fun onRemoveCheckedPackage(packageName: String) {
_checkedPackages.value.remove(packageName)
}
private suspend fun emitSplitTunnelConfiguration(config : Config) {
private suspend fun emitSplitTunnelConfiguration(config: Config) {
val excludedApps = config.`interface`.excludedApplications
val includedApps = config.`interface`.includedApplications
if (excludedApps.isNotEmpty() || includedApps.isNotEmpty()) {
@ -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()) {
emitIncludedAppsExist()
emitCheckedApps(includedApps)
@ -186,7 +192,7 @@ class ConfigViewModel @Inject constructor(private val application : Application,
_include.emit(false)
}
private suspend fun emitCheckedApps(apps : Set<String>) {
private suspend fun emitCheckedApps(apps: Set<String>) {
_checkedPackages.emit(apps.toMutableStateList())
}
@ -205,45 +211,45 @@ class ConfigViewModel @Inject constructor(private val application : Application,
}
}
fun emitQueriedPackages(query : String) {
fun emitQueriedPackages(query: String) {
viewModelScope.launch(Dispatchers.IO) {
val packages = getAllInternetCapablePackages().filter {
getPackageLabel(it).lowercase().contains(query.lowercase())
}
val packages =
getAllInternetCapablePackages().filter {
getPackageLabel(it).lowercase().contains(query.lowercase())
}
_packages.emit(packages)
}
}
fun getPackageLabel(packageInfo : PackageInfo) : String {
fun getPackageLabel(packageInfo: PackageInfo): String {
return packageInfo.applicationInfo.loadLabel(application.packageManager).toString()
}
private fun getAllInternetCapablePackages() : List<PackageInfo> {
private fun getAllInternetCapablePackages(): List<PackageInfo> {
return getPackagesHoldingPermissions(arrayOf(Manifest.permission.INTERNET))
}
private fun getPackagesHoldingPermissions(permissions: Array<String>): List<PackageInfo> {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
packageManager.getPackagesHoldingPermissions(permissions, PackageManager.PackageInfoFlags.of(0L))
packageManager.getPackagesHoldingPermissions(
permissions,
PackageManager.PackageInfoFlags.of(0L)
)
} else {
packageManager.getPackagesHoldingPermissions(permissions, 0)
}
}
private fun isAllApplicationsEnabled() : Boolean {
private fun isAllApplicationsEnabled(): Boolean {
return _isAllApplicationsEnabled.value
}
private fun isIncludeApplicationsEnabled() : Boolean {
return _include.value
}
private suspend fun saveConfig(tunnelConfig: TunnelConfig) {
tunnelRepo.save(tunnelConfig)
}
private suspend fun updateTunnelConfig(tunnelConfig: TunnelConfig?) {
if(tunnelConfig != null) {
if (tunnelConfig != null) {
saveConfig(tunnelConfig)
updateSettingsDefaultTunnel(tunnelConfig)
}
@ -251,88 +257,119 @@ class ConfigViewModel @Inject constructor(private val application : Application,
private suspend fun updateSettingsDefaultTunnel(tunnelConfig: TunnelConfig) {
val settings = settingsRepo.getAll()
if(settings.isNotEmpty()) {
if (settings.isNotEmpty()) {
val setting = settings[0]
if(setting.defaultTunnel != null) {
if(tunnelConfig.id == TunnelConfig.from(setting.defaultTunnel!!).id) {
settingsRepo.save(setting.copy(
defaultTunnel = tunnelConfig.toString()
))
if (setting.defaultTunnel != null) {
if (tunnelConfig.id == TunnelConfig.from(setting.defaultTunnel!!).id) {
settingsRepo.save(
setting.copy(
defaultTunnel = tunnelConfig.toString()
)
)
}
}
}
}
fun buildPeerListFromProxyPeers() : List<Peer> {
private fun buildPeerListFromProxyPeers(): List<Peer> {
return _proxyPeers.value.map {
val builder = Peer.Builder()
if (it.allowedIps.isNotEmpty()) builder.parseAllowedIPs(it.allowedIps.trim())
if (it.publicKey.isNotEmpty()) builder.parsePublicKey(it.publicKey.trim())
if (it.preSharedKey.isNotEmpty()) builder.parsePreSharedKey(it.preSharedKey.trim())
if (it.endpoint.isNotEmpty()) builder.parseEndpoint(it.endpoint.trim())
if (it.persistentKeepalive.isNotEmpty()) builder.parsePersistentKeepalive(it.persistentKeepalive.trim())
if (it.persistentKeepalive.isNotEmpty()) {
builder.parsePersistentKeepalive(
it.persistentKeepalive.trim()
)
}
builder.build()
}
}
fun buildInterfaceListFromProxyInterface() : Interface {
private fun buildInterfaceListFromProxyInterface(): Interface {
val builder = Interface.Builder()
builder.parsePrivateKey(_interface.value.privateKey.trim())
builder.parseAddresses(_interface.value.addresses.trim())
builder.parseDnsServers(_interface.value.dnsServers.trim())
if(_interface.value.mtu.isNotEmpty()) builder.parseMtu(_interface.value.mtu.trim())
if(_interface.value.listenPort.isNotEmpty()) builder.parseListenPort(_interface.value.listenPort.trim())
if(isAllApplicationsEnabled()) _checkedPackages.value.clear()
if(_include.value) builder.includeApplications(_checkedPackages.value)
if(!_include.value) builder.excludeApplications(_checkedPackages.value)
if (_interface.value.mtu.isNotEmpty()) builder.parseMtu(_interface.value.mtu.trim())
if (_interface.value.listenPort.isNotEmpty()) {
builder.parseListenPort(
_interface.value.listenPort.trim()
)
}
if (isAllApplicationsEnabled()) _checkedPackages.value.clear()
if (_include.value) builder.includeApplications(_checkedPackages.value)
if (!_include.value) builder.excludeApplications(_checkedPackages.value)
return builder.build()
}
suspend fun onSaveAllChanges() {
try {
val peerList = buildPeerListFromProxyPeers()
val wgInterface = buildInterfaceListFromProxyInterface()
val config = Config.Builder().addPeers(peerList).setInterface(wgInterface).build()
val tunnelConfig = _tunnel.value?.copy(
name = _tunnelName.value,
wgQuick = config.toWgQuickString()
)
val tunnelConfig =
_tunnel.value?.copy(
name = _tunnelName.value,
wgQuick = config.toWgQuickString()
)
updateTunnelConfig(tunnelConfig)
} catch (e : Exception) {
throw WgTunnelException("Error: ${e.cause?.message?.lowercase() ?: "unknown error occurred"}")
} catch (e: Exception) {
throw WgTunnelException(
"Error: ${e.cause?.message?.lowercase() ?: "unknown error occurred"}"
)
}
}
fun onPeerPublicKeyChange(index: Int, publicKey: String) {
_proxyPeers.value[index] = _proxyPeers.value[index].copy(
publicKey = publicKey
)
fun onPeerPublicKeyChange(
index: Int,
publicKey: String
) {
_proxyPeers.value[index] =
_proxyPeers.value[index].copy(
publicKey = publicKey
)
}
fun onPreSharedKeyChange(index: Int, value: String) {
_proxyPeers.value[index] = _proxyPeers.value[index].copy(
preSharedKey = value
)
fun onPreSharedKeyChange(
index: Int,
value: String
) {
_proxyPeers.value[index] =
_proxyPeers.value[index].copy(
preSharedKey = value
)
}
fun onEndpointChange(index: Int, value: String) {
_proxyPeers.value[index] = _proxyPeers.value[index].copy(
endpoint = value
)
fun onEndpointChange(
index: Int,
value: String
) {
_proxyPeers.value[index] =
_proxyPeers.value[index].copy(
endpoint = value
)
}
fun onAllowedIpsChange(index: Int, value: String) {
_proxyPeers.value[index] = _proxyPeers.value[index].copy(
allowedIps = value
)
fun onAllowedIpsChange(
index: Int,
value: String
) {
_proxyPeers.value[index] =
_proxyPeers.value[index].copy(
allowedIps = value
)
}
fun onPersistentKeepaliveChanged(index : Int, value : String) {
_proxyPeers.value[index] = _proxyPeers.value[index].copy(
persistentKeepalive = value
)
fun onPersistentKeepaliveChanged(
index: Int,
value: String
) {
_proxyPeers.value[index] =
_proxyPeers.value[index].copy(
persistentKeepalive = value
)
}
fun onDeletePeer(index: Int) {
@ -345,51 +382,58 @@ class ConfigViewModel @Inject constructor(private val application : Application,
fun generateKeyPair() {
val keyPair = KeyPair()
_interface.value = _interface.value.copy(
privateKey = keyPair.privateKey.toBase64(),
publicKey = keyPair.publicKey.toBase64()
)
_interface.value =
_interface.value.copy(
privateKey = keyPair.privateKey.toBase64(),
publicKey = keyPair.publicKey.toBase64()
)
}
fun onAddressesChanged(value: String) {
_interface.value = _interface.value.copy(
addresses = value
)
_interface.value =
_interface.value.copy(
addresses = value
)
}
fun onListenPortChanged(value: String) {
_interface.value = _interface.value.copy(
listenPort = value
)
_interface.value =
_interface.value.copy(
listenPort = value
)
}
fun onDnsServersChanged(value: String) {
_interface.value = _interface.value.copy(
dnsServers = value
)
_interface.value =
_interface.value.copy(
dnsServers = value
)
}
fun onMtuChanged(value: String) {
_interface.value = _interface.value.copy(
mtu = value
)
_interface.value =
_interface.value.copy(
mtu = value
)
}
private fun onInterfacePublicKeyChange(value : String) {
_interface.value = _interface.value.copy(
publicKey = value
)
private fun onInterfacePublicKeyChange(value: String) {
_interface.value =
_interface.value.copy(
publicKey = value
)
}
fun onPrivateKeyChange(value: String) {
_interface.value = _interface.value.copy(
privateKey = value
)
if(NumberUtils.isValidKey(value)) {
_interface.value =
_interface.value.copy(
privateKey = value
)
if (NumberUtils.isValidKey(value)) {
val pair = KeyPair(Key.fromBase64(value))
onInterfacePublicKeyChange(pair.publicKey.toBase64())
} else {
onInterfacePublicKeyChange("")
}
}
}
}

View File

@ -88,6 +88,7 @@ import com.zaneschepke.wireguardautotunnel.ui.CaptureActivityPortrait
import com.zaneschepke.wireguardautotunnel.ui.Routes
import com.zaneschepke.wireguardautotunnel.ui.common.RowListItem
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.util.WgTunnelException
import kotlinx.coroutines.Dispatchers
@ -102,7 +103,6 @@ fun MainScreen(
showSnackbarMessage: (String) -> Unit,
navController: NavController
) {
val haptic = LocalHapticFeedback.current
val context = LocalContext.current
val isVisible = rememberSaveable { mutableStateOf(true) }
@ -112,7 +112,9 @@ fun MainScreen(
var showBottomSheet by remember { mutableStateOf(false) }
var showPrimaryChangeAlertDialog by remember { mutableStateOf(false) }
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) }
val state by viewModel.state.collectAsStateWithLifecycle(Tunnel.State.DOWN)
val tunnelName by viewModel.tunnelName.collectAsStateWithLifecycle("")
@ -120,75 +122,100 @@ fun MainScreen(
val statistics by viewModel.statistics.collectAsStateWithLifecycle(null)
// Nested scroll for control FAB
val nestedScrollConnection = remember {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
// Hide FAB
if (available.y < -1) {
isVisible.value = false
val nestedScrollConnection =
remember {
object : NestedScrollConnection {
override fun onPreScroll(
available: Offset,
source: NestedScrollSource
): Offset {
// Hide FAB
if (available.y < -1) {
isVisible.value = false
}
// Show FAB
if (available.y > 1) {
isVisible.value = true
}
return Offset.Zero
}
// Show FAB
if (available.y > 1) {
isVisible.value = true
}
}
val tunnelFileImportResultLauncher =
rememberLauncherForActivityResult(
object : ActivityResultContracts.GetContent() {
override fun createIntent(
context: Context,
input: String
): Intent {
val intent = super.createIntent(context, input)
/* AndroidTV now comes with stubs that do nothing but display a Toast less helpful than
* what we can do, so detect this and throw an exception that we can catch later. */
val activitiesToResolveIntent =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.packageManager.queryIntentActivities(
intent,
PackageManager.ResolveInfoFlags.of(
PackageManager.MATCH_DEFAULT_ONLY.toLong()
)
)
} else {
context.packageManager.queryIntentActivities(
intent,
PackageManager.MATCH_DEFAULT_ONLY
)
}
if (activitiesToResolveIntent.all {
val name = it.activityInfo.packageName
name.startsWith(Constants.GOOGLE_TV_EXPLORER_STUB) || name.startsWith(
Constants.ANDROID_TV_EXPLORER_STUB
)
}
) {
throw WgTunnelException(context.getString(R.string.no_file_explorer))
}
return intent
}
return Offset.Zero
}
}
}
val tunnelFileImportResultLauncher = rememberLauncherForActivityResult(object : ActivityResultContracts.GetContent() {
override fun createIntent(context: Context, input: String): Intent {
val intent = super.createIntent(context, input)
/* AndroidTV now comes with stubs that do nothing but display a Toast less helpful than
* what we can do, so detect this and throw an exception that we can catch later. */
val activitiesToResolveIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.packageManager.queryIntentActivities(intent, PackageManager.ResolveInfoFlags.of(PackageManager.MATCH_DEFAULT_ONLY.toLong()))
} else {
context.packageManager.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY)
}
if (activitiesToResolveIntent.all {
val name = it.activityInfo.packageName
name.startsWith(Constants.GOOGLE_TV_EXPLORER_STUB) || name.startsWith(Constants.ANDROID_TV_EXPLORER_STUB)
}) {
throw WgTunnelException(context.getString(R.string.no_file_explorer))
}
return intent
}
}) { data ->
if (data == null) return@rememberLauncherForActivityResult
scope.launch(Dispatchers.IO) {
try {
viewModel.onTunnelFileSelected(data)
} catch (e : WgTunnelException) {
showSnackbarMessage(e.message)
}
}
}
val scanLauncher = rememberLauncherForActivityResult(
contract = ScanContract(),
onResult = {
scope.launch {
) { data ->
if (data == null) return@rememberLauncherForActivityResult
scope.launch(Dispatchers.IO) {
try {
viewModel.onTunnelQrResult(it.contents)
} catch (e: Exception) {
when(e) {
is WgTunnelException -> {
showSnackbarMessage(e.message)
} else -> {
showSnackbarMessage("No QR code scanned")
viewModel.onTunnelFileSelected(data)
} catch (e: WgTunnelException) {
showSnackbarMessage(e.message)
}
}
}
val scanLauncher =
rememberLauncherForActivityResult(
contract = ScanContract(),
onResult = {
scope.launch {
try {
viewModel.onTunnelQrResult(it.contents)
} catch (e: Exception) {
when (e) {
is WgTunnelException -> {
showSnackbarMessage(e.message)
}
else -> {
showSnackbarMessage("No QR code scanned")
}
}
}
}
}
}
)
)
if(showPrimaryChangeAlertDialog) {
if (showPrimaryChangeAlertDialog) {
AlertDialog(
onDismissRequest = {
showPrimaryChangeAlertDialog = false
showPrimaryChangeAlertDialog = false
},
confirmButton = {
TextButton(onClick = {
@ -197,30 +224,32 @@ fun MainScreen(
showPrimaryChangeAlertDialog = false
selectedTunnel = null
}
})
{ Text(text = stringResource(R.string.okay)) }
}) { Text(text = stringResource(R.string.okay)) }
},
dismissButton = {
TextButton(onClick = {
showPrimaryChangeAlertDialog = false
})
{ Text(text = stringResource(R.string.cancel)) }
}) { Text(text = stringResource(R.string.cancel)) }
},
title = { Text(text = stringResource(R.string.primary_tunnel_change)) },
text = { Text(text = stringResource(R.string.primary_tunnel_change_question)) }
)
}
fun onTunnelToggle(checked : Boolean , tunnel : TunnelConfig) {
fun onTunnelToggle(
checked: Boolean,
tunnel: TunnelConfig
) {
try {
if (checked) viewModel.onTunnelStart(tunnel) else viewModel.onTunnelStop()
} catch (e : Exception) {
} catch (e: Exception) {
showSnackbarMessage(e.message!!)
}
}
Scaffold(
modifier = Modifier.pointerInput(Unit) {
modifier =
Modifier.pointerInput(Unit) {
detectTapGestures(onTap = {
selectedTunnel = null
})
@ -230,30 +259,30 @@ fun MainScreen(
AnimatedVisibility(
visible = isVisible.value,
enter = slideInVertically(initialOffsetY = { it * 2 }),
exit = slideOutVertically(targetOffsetY = { it * 2 }),
exit = slideOutVertically(targetOffsetY = { it * 2 })
) {
val secondaryColor = MaterialTheme.colorScheme.secondary
val hoverColor = MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp)
var fobColor by remember { mutableStateOf(secondaryColor) }
FloatingActionButton(
modifier = Modifier
modifier =
Modifier
.padding(bottom = 90.dp)
.onFocusChanged {
if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
fobColor = if (it.isFocused) hoverColor else secondaryColor
}
}
,
},
onClick = {
showBottomSheet = true
},
containerColor = fobColor,
shape = RoundedCornerShape(16.dp),
shape = RoundedCornerShape(16.dp)
) {
Icon(
imageVector = Icons.Rounded.Add,
contentDescription = stringResource(id = R.string.add_tunnel),
tint = Color.DarkGray,
tint = Color.DarkGray
)
}
}
@ -263,7 +292,8 @@ fun MainScreen(
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier
modifier =
Modifier
.fillMaxSize()
.padding(padding)
) {
@ -279,7 +309,8 @@ fun MainScreen(
) {
// Sheet content
Row(
modifier = Modifier
modifier =
Modifier
.fillMaxWidth()
.clickable {
showBottomSheet = false
@ -301,23 +332,26 @@ fun MainScreen(
modifier = Modifier.padding(10.dp)
)
}
if(!WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
if (!WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
Divider()
Row(modifier = Modifier
.fillMaxWidth()
.clickable {
scope.launch {
showBottomSheet = false
val scanOptions = ScanOptions()
scanOptions.setDesiredBarcodeFormats(ScanOptions.QR_CODE)
scanOptions.setOrientationLocked(true)
scanOptions.setPrompt(context.getString(R.string.scanning_qr))
scanOptions.setBeepEnabled(false)
scanOptions.captureActivity = CaptureActivityPortrait::class.java
scanLauncher.launch(scanOptions)
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable {
scope.launch {
showBottomSheet = false
val scanOptions = ScanOptions()
scanOptions.setDesiredBarcodeFormats(ScanOptions.QR_CODE)
scanOptions.setOrientationLocked(true)
scanOptions.setPrompt(context.getString(R.string.scanning_qr))
scanOptions.setBeepEnabled(false)
scanOptions.captureActivity =
CaptureActivityPortrait::class.java
scanLauncher.launch(scanOptions)
}
}
}
.padding(10.dp)
.padding(10.dp)
) {
Icon(
Icons.Filled.QrCode,
@ -332,11 +366,14 @@ fun MainScreen(
}
Divider()
Row(
modifier = Modifier
modifier =
Modifier
.fillMaxWidth()
.clickable {
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)
) {
@ -355,47 +392,67 @@ fun MainScreen(
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Top,
modifier = Modifier
modifier =
Modifier
.fillMaxSize()
.padding(padding)
) {
LazyColumn(
modifier = Modifier
modifier =
Modifier
.fillMaxSize()
.nestedScroll(nestedScrollConnection),
.padding(top = 10.dp)
.nestedScroll(nestedScrollConnection)
) {
items(tunnels, key = { tunnel -> tunnel.id }) { tunnel ->
val leadingIconColor = (if (tunnelName == tunnel.name) when (handshakeStatus) {
HandshakeStatus.HEALTHY -> mint
HandshakeStatus.UNHEALTHY -> brickRed
HandshakeStatus.NOT_STARTED -> Color.Gray
HandshakeStatus.NEVER_CONNECTED -> brickRed
} else {Color.Gray})
val focusRequester = remember { FocusRequester() }
val expanded = remember {
mutableStateOf(false)
}
RowListItem(icon = {
if (settings.isTunnelConfigDefault(tunnel))
Icon(
Icons.Rounded.Star, stringResource(R.string.status),
tint = leadingIconColor,
modifier = Modifier
.padding(end = 10.dp)
.size(20.dp)
val leadingIconColor = (
if (tunnelName == tunnel.name) {
when (handshakeStatus) {
HandshakeStatus.HEALTHY -> mint
HandshakeStatus.UNHEALTHY -> brickRed
HandshakeStatus.STALE -> corn
HandshakeStatus.NOT_STARTED -> Color.Gray
HandshakeStatus.NEVER_CONNECTED -> brickRed
}
} else {
Color.Gray
}
)
else Icon(
Icons.Rounded.Circle, stringResource(R.string.status),
tint = leadingIconColor,
modifier = Modifier
.padding(end = 15.dp)
.size(15.dp)
)
},
val focusRequester = remember { FocusRequester() }
val expanded =
remember {
mutableStateOf(false)
}
RowListItem(
icon = {
if (settings.isTunnelConfigDefault(tunnel)) {
Icon(
Icons.Rounded.Star,
stringResource(R.string.status),
tint = leadingIconColor,
modifier =
Modifier
.padding(end = 10.dp)
.size(20.dp)
)
} else {
Icon(
Icons.Rounded.Circle,
stringResource(R.string.status),
tint = leadingIconColor,
modifier =
Modifier
.padding(end = 15.dp)
.size(15.dp)
)
}
},
text = tunnel.name,
onHold = {
if (state == Tunnel.State.UP && tunnel.name == tunnelName) {
showSnackbarMessage(context.resources.getString(R.string.turn_off_tunnel))
if ((state == Tunnel.State.UP) && (tunnel.name == tunnelName)) {
showSnackbarMessage(
context.resources.getString(R.string.turn_off_tunnel)
)
return@RowListItem
}
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
@ -403,7 +460,7 @@ fun MainScreen(
},
onClick = {
if (!WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
if(state == Tunnel.State.UP && (tunnelName == tunnel.name) ) {
if (state == Tunnel.State.UP && (tunnelName == tunnel.name)) {
expanded.value = !expanded.value
}
} else {
@ -414,25 +471,40 @@ fun MainScreen(
statistics = statistics,
expanded = expanded.value,
rowButton = {
if (tunnel.id == selectedTunnel?.id && !WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
if (tunnel.id == selectedTunnel?.id && !WireGuardAutoTunnel.isRunningOnAndroidTv(
context
)
) {
Row {
if(!settings.isTunnelConfigDefault(tunnel)) {
if (!settings.isTunnelConfigDefault(tunnel)) {
IconButton(onClick = {
if(settings.isAutoTunnelEnabled) {
showSnackbarMessage(context.resources.getString(R.string.turn_off_auto))
} else showPrimaryChangeAlertDialog = true
if (settings.isAutoTunnelEnabled) {
showSnackbarMessage(
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 = {
navController.navigate("${Routes.Config.name}/${selectedTunnel?.id}")
navController.navigate(
"${Routes.Config.name}/${selectedTunnel?.id}"
)
}) {
Icon(Icons.Rounded.Edit, stringResource(id = R.string.edit))
}
IconButton(
modifier = Modifier.focusable(),
onClick = { viewModel.onDelete(tunnel) }) {
onClick = { viewModel.onDelete(tunnel) }
) {
Icon(
Icons.Rounded.Delete,
stringResource(id = R.string.delete)
@ -441,45 +513,59 @@ fun MainScreen(
}
} else {
val checked = state == Tunnel.State.UP && tunnel.name == tunnelName
if(!checked) expanded.value = false
if (!checked) expanded.value = false
@Composable
fun TunnelSwitch() = Switch(
modifier = Modifier.focusRequester(focusRequester),
checked = checked,
onCheckedChange = { checked ->
if(!checked) expanded.value = false
onTunnelToggle(checked, tunnel)
}
)
fun TunnelSwitch() =
Switch(
modifier = Modifier.focusRequester(focusRequester),
checked = checked,
onCheckedChange = { checked ->
if (!checked) expanded.value = false
onTunnelToggle(checked, tunnel)
}
)
if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
Row {
if(!settings.isTunnelConfigDefault(tunnel)) {
if (!settings.isTunnelConfigDefault(tunnel)) {
IconButton(onClick = {
if(settings.isAutoTunnelEnabled) {
showSnackbarMessage(context.resources.getString(R.string.turn_off_auto))
} else showPrimaryChangeAlertDialog = true
if (settings.isAutoTunnelEnabled) {
showSnackbarMessage(
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(
modifier = Modifier.focusRequester(focusRequester),
onClick = {
if(state == Tunnel.State.UP && (tunnelName == tunnel.name) ) {
if (state == Tunnel.State.UP && (tunnelName == tunnel.name)) {
expanded.value = !expanded.value
}
}) {
}
) {
Icon(Icons.Rounded.Info, stringResource(R.string.info))
}
IconButton(onClick = {
if (state == Tunnel.State.UP && tunnel.name == tunnelName)
if (state == Tunnel.State.UP && tunnel.name == tunnelName) {
showSnackbarMessage(
context.resources.getString(
R.string.turn_off_tunnel
)
)
else {
navController.navigate("${Routes.Config.name}/${tunnel.id}")
} else {
navController.navigate(
"${Routes.Config.name}/${tunnel.id}"
)
}
}) {
Icon(
@ -488,13 +574,13 @@ fun MainScreen(
)
}
IconButton(onClick = {
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
)
)
else {
} else {
viewModel.onDelete(tunnel)
}
}) {
@ -509,7 +595,8 @@ fun MainScreen(
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.WgTunnelException
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.delay
import kotlinx.coroutines.flow.MutableStateFlow
@ -29,19 +32,16 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.InputStream
import java.util.zip.ZipInputStream
import javax.inject.Inject
@HiltViewModel
class MainViewModel @Inject constructor(
class MainViewModel
@Inject
constructor(
private val application: Application,
private val tunnelRepo: TunnelConfigDao,
private val settingsRepo: SettingsDoa,
private val vpnService: VpnService
) : ViewModel() {
val tunnels get() = tunnelRepo.getAllFlow()
val state get() = vpnService.state
@ -62,10 +62,11 @@ class MainViewModel @Inject constructor(
}
private fun validateWatcherServiceState(settings: Settings) {
val watcherState = ServiceManager.getServiceState(
application.applicationContext,
WireGuardConnectivityWatcherService::class.java
)
val watcherState =
ServiceManager.getServiceState(
application.applicationContext,
WireGuardConnectivityWatcherService::class.java
)
if (settings.isAutoTunnelEnabled && watcherState == ServiceState.STOPPED && settings.defaultTunnel != null) {
ServiceManager.startWatcherService(
application.applicationContext,
@ -74,7 +75,6 @@ class MainViewModel @Inject constructor(
}
}
fun onDelete(tunnel: TunnelConfig) {
viewModelScope.launch {
if (tunnelRepo.count() == 1L) {
@ -106,7 +106,7 @@ class MainViewModel @Inject constructor(
private suspend fun stopActiveTunnel() {
if (ServiceManager.getServiceState(
application.applicationContext,
WireGuardTunnelService::class.java,
WireGuardTunnelService::class.java
) == ServiceState.STARTED
) {
onTunnelStop()
@ -128,12 +128,15 @@ class MainViewModel @Inject constructor(
val tunnelConfig =
TunnelConfig(name = NumberUtils.generateRandomTunnelName(), wgQuick = result)
addTunnel(tunnelConfig)
} catch (e : Exception) {
} catch (e: Exception) {
throw WgTunnelException(e)
}
}
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 config = Config.parse(bufferReader)
val tunnelName = getNameFromFileName(fileName)
@ -152,10 +155,12 @@ class MainViewModel @Inject constructor(
try {
val fileName = getFileName(application.applicationContext, uri)
val fileExtension = getFileExtensionFromFileName(fileName)
when(fileExtension){
when (fileExtension) {
Constants.CONF_FILE_EXTENSION -> saveTunnelFromConfUri(fileName, uri)
Constants.ZIP_FILE_EXTENSION -> saveTunnelsFromZipUri(uri)
else -> throw WgTunnelException(application.getString(R.string.file_extension_message))
else -> throw WgTunnelException(
application.getString(R.string.file_extension_message)
)
}
} catch (e: Exception) {
throw WgTunnelException(e)
@ -165,19 +170,24 @@ class MainViewModel @Inject constructor(
private suspend fun saveTunnelsFromZipUri(uri: Uri) {
ZipInputStream(getInputStreamFromUri(uri)).use { zip ->
generateSequence { zip.nextEntry }
.filterNot { it.isDirectory ||
getFileExtensionFromFileName(it.name) != Constants.CONF_FILE_EXTENSION }
.filterNot {
it.isDirectory ||
getFileExtensionFromFileName(it.name) != Constants.CONF_FILE_EXTENSION
}
.forEach {
val name = getNameFromFileName(it.name)
val config = Config.parse(zip)
viewModelScope.launch(Dispatchers.IO) {
addTunnel(TunnelConfig(name = name, wgQuick = config.toWgQuickString()))
}
}
}
}
}
private suspend fun saveTunnelFromConfUri(name : String, uri: Uri) {
private suspend fun saveTunnelFromConfUri(
name: String,
uri: Uri
) {
val stream = getInputStreamFromUri(uri)
saveTunnelConfigFromStream(stream, name)
}
@ -190,7 +200,10 @@ class MainViewModel @Inject constructor(
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)
if (cursor != null) {
cursor.use {
@ -224,8 +237,10 @@ class MainViewModel @Inject constructor(
}
}
private fun getFileName(context: Context, uri: Uri): String {
private fun getFileName(
context: Context,
uri: Uri
): String {
validateUriContentScheme(uri)
return try {
getFileNameByCursor(context, uri)
@ -256,4 +271,4 @@ class MainViewModel @Inject constructor(
settingsRepo.save(_settings.value)
}
}
}
}

View File

@ -38,6 +38,7 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
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.isGranted
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.WireGuardAutoTunnel
import com.zaneschepke.wireguardautotunnel.repository.datastore.DataStoreManager
import com.zaneschepke.wireguardautotunnel.ui.common.ClickableIconButton
import com.zaneschepke.wireguardautotunnel.ui.common.config.ConfigurationToggle
import com.zaneschepke.wireguardautotunnel.ui.common.prompt.AuthorizationPrompt
import com.zaneschepke.wireguardautotunnel.ui.common.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.launch
import java.io.File
@OptIn(
ExperimentalPermissionsApi::class,
ExperimentalLayoutApi::class, ExperimentalComposeUiApi::class
ExperimentalLayoutApi::class,
ExperimentalComposeUiApi::class
)
@Composable
fun SettingsScreen(
viewModel: SettingsViewModel = hiltViewModel(),
padding: PaddingValues,
showSnackbarMessage: (String) -> Unit,
focusRequester: FocusRequester,
focusRequester: FocusRequester
) {
val scope = rememberCoroutineScope { Dispatchers.IO }
val context = LocalContext.current
val focusManager = LocalFocusManager.current
@ -100,16 +104,24 @@ fun SettingsScreen(
val fineLocationState = rememberPermissionState(Manifest.permission.ACCESS_FINE_LOCATION)
var currentText by remember { mutableStateOf("") }
val scrollState = rememberScrollState()
var isLocationDisclaimerNeeded by remember { mutableStateOf(true) }
var isBackgroundLocationGranted by remember { mutableStateOf(true) }
var showAuthPrompt by remember { mutableStateOf(false) }
var didExportFiles by remember { mutableStateOf(false) }
val isLocationDisclosureShown by viewModel.disclosureShown.collectAsStateWithLifecycle(
null
)
val vpnState = viewModel.vpnState.collectAsStateWithLifecycle(initialValue = Tunnel.State.DOWN)
val screenPadding = 5.dp
val fillMaxWidth = .85f
fun setLocationDisclosureShown() = scope.launch {
viewModel.dataStoreManager.saveToDataStore(
DataStoreManager.LOCATION_DISCLOSURE_SHOWN,
true
)
}
fun exportAllConfigs() {
try {
val files = tunnels.map { File(context.cacheDir, "${it.name}.conf") }
@ -118,33 +130,35 @@ fun SettingsScreen(
it.write(tunnels[index].wgQuick.toByteArray())
}
}
StorageUtil.saveFilesToZip(context, files)
FileUtils.saveFilesToZip(context, files)
didExportFiles = true
showSnackbarMessage(context.getString(R.string.exported_configs_message))
} catch (e : Exception) {
} catch (e: Exception) {
showSnackbarMessage(e.message!!)
}
}
fun saveTrustedSSID() {
if (currentText.isNotEmpty()) {
scope.launch {
try {
viewModel.onSaveTrustedSSID(currentText)
currentText = ""
} catch (e : Exception) {
} catch (e: Exception) {
showSnackbarMessage(e.message ?: context.getString(R.string.unknown_error))
}
}
}
}
fun isAllAutoTunnelPermissionsEnabled() : Boolean {
return(isBackgroundLocationGranted && fineLocationState.status.isGranted && !viewModel.isLocationServicesNeeded())
fun isAllAutoTunnelPermissionsEnabled(): Boolean {
return (
isBackgroundLocationGranted &&
fineLocationState.status.isGranted &&
!viewModel.isLocationServicesNeeded()
)
}
fun openSettings() {
scope.launch {
val intentSettings =
@ -155,334 +169,428 @@ fun SettingsScreen(
}
}
if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val backgroundLocationState =
rememberPermissionState(Manifest.permission.ACCESS_BACKGROUND_LOCATION)
if(!backgroundLocationState.status.isGranted) {
isBackgroundLocationGranted = if (!backgroundLocationState.status.isGranted) {
false
} else {
SideEffect {
setLocationDisclosureShown()
}
true
}
}
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) {
if (!fineLocationState.status.isGranted) {
isBackgroundLocationGranted = false
} else {
isLocationDisclaimerNeeded = false
SideEffect {
setLocationDisclosureShown()
}
isBackgroundLocationGranted = true
}
}
if(Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) {
if(!fineLocationState.status.isGranted) {
isBackgroundLocationGranted = false
} else {
isLocationDisclaimerNeeded = false
isBackgroundLocationGranted = true
if (isLocationDisclosureShown != true) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top,
modifier =
Modifier
.fillMaxSize()
.verticalScroll(scrollState)
.padding(padding)
) {
Icon(
Icons.Rounded.LocationOff,
contentDescription = stringResource(id = R.string.map),
modifier =
Modifier
.padding(30.dp)
.size(128.dp)
)
Text(
stringResource(R.string.prominent_background_location_title),
textAlign = TextAlign.Center,
modifier = Modifier.padding(30.dp),
fontSize = 20.sp
)
Text(
stringResource(R.string.prominent_background_location_message),
textAlign = TextAlign.Center,
modifier = Modifier.padding(30.dp),
fontSize = 15.sp
)
Row(
modifier =
if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
Modifier
.fillMaxWidth()
.padding(10.dp)
} else {
Modifier
.fillMaxWidth()
.padding(30.dp)
},
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceEvenly
) {
TextButton(onClick = {
setLocationDisclosureShown()
}) {
Text(stringResource(id = R.string.no_thanks))
}
TextButton(modifier = Modifier.focusRequester(focusRequester), onClick = {
openSettings()
setLocationDisclosureShown()
}) {
Text(stringResource(id = R.string.turn_on))
}
}
}
return
}
if (showAuthPrompt) {
AuthorizationPrompt(
onSuccess = {
showAuthPrompt = false
exportAllConfigs()
},
onError = { error ->
showSnackbarMessage(error)
showAuthPrompt = false
},
onFailure = {
showAuthPrompt = false
showSnackbarMessage(context.getString(R.string.authentication_failed))
}
)
}
if (tunnels.isEmpty()) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier =
Modifier
.fillMaxSize()
.padding(padding)
) {
Text(
stringResource(R.string.one_tunnel_required),
textAlign = TextAlign.Center,
modifier = Modifier.padding(15.dp),
fontStyle = FontStyle.Italic
)
}
return
}
}
if(isLocationDisclaimerNeeded) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top,
modifier = Modifier
modifier =
Modifier
.fillMaxSize()
.verticalScroll(scrollState)
.padding(padding)
.clickable(indication = null, interactionSource = interactionSource) {
focusManager.clearFocus()
}
) {
Icon(
Icons.Rounded.LocationOff,
contentDescription = stringResource(id = R.string.map),
modifier = Modifier
.padding(30.dp)
.size(128.dp)
)
Text(
stringResource(R.string.prominent_background_location_title),
textAlign = TextAlign.Center,
modifier = Modifier.padding(30.dp),
fontSize = 20.sp
)
Text(
stringResource(R.string.prominent_background_location_message),
textAlign = TextAlign.Center,
modifier = Modifier.padding(30.dp),
fontSize = 15.sp
)
Row(
modifier = if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) Modifier
.fillMaxWidth()
.padding(10.dp) else Modifier
.fillMaxWidth()
.padding(30.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceEvenly
) {
TextButton(onClick = {
isLocationDisclaimerNeeded = false
}) {
Text(stringResource(id = R.string.no_thanks))
}
TextButton(modifier = Modifier.focusRequester(focusRequester), onClick = {
openSettings()
}) {
Text(stringResource(id = R.string.turn_on))
}
}
}
return
}
if(showAuthPrompt) {
AuthorizationPrompt(onSuccess = {
showAuthPrompt = false
exportAllConfigs() },
onError = { error ->
showSnackbarMessage(error)
showAuthPrompt = false
},
onFailure = {
showAuthPrompt = false
showSnackbarMessage(context.getString(R.string.authentication_failed))
})
}
if (tunnels.isEmpty()) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
Text(
stringResource(R.string.one_tunnel_required),
textAlign = TextAlign.Center,
modifier = Modifier.padding(15.dp),
fontStyle = FontStyle.Italic
)
}
return
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top,
modifier = Modifier
.fillMaxSize()
.verticalScroll(scrollState)
.clickable(indication = null, interactionSource = interactionSource) {
focusManager.clearFocus()
}
) {
Surface(
tonalElevation = 2.dp,
shadowElevation = 2.dp,
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surface,
modifier = (if (WireGuardAutoTunnel.isRunningOnAndroidTv(context))
Modifier
.height(IntrinsicSize.Min)
.fillMaxWidth(fillMaxWidth)
.padding(top = 10.dp)
else Modifier
.fillMaxWidth(fillMaxWidth)
.padding(top = 60.dp)).padding(bottom = 25.dp)
) {
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Top,
modifier = Modifier.padding(15.dp)
) {
SectionTitle(title = stringResource(id = R.string.auto_tunneling), padding = screenPadding)
ConfigurationToggle(
stringResource(id = R.string.tunnel_on_wifi),
enabled = !(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled),
checked = settings.isTunnelOnWifiEnabled,
padding = screenPadding,
onCheckChanged = {
scope.launch {
viewModel.onToggleTunnelOnWifi()
}
},
modifier = Modifier.focusRequester(focusRequester)
)
AnimatedVisibility(visible = settings.isTunnelOnWifiEnabled) {
Column {
FlowRow(
modifier = Modifier.padding(screenPadding),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.SpaceEvenly
) {
trustedSSIDs.forEach { ssid ->
ClickableIconButton(
onIconClick = {
scope.launch {
viewModel.onDeleteTrustedSSID(ssid)
}
},
text = ssid,
icon = Icons.Filled.Close,
enabled = !(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled)
)
}
if(trustedSSIDs.isEmpty()) {
Text(stringResource(R.string.none), fontStyle = FontStyle.Italic, color = Color.Gray)
}
}
OutlinedTextField(
enabled = !(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled),
value = currentText,
onValueChange = { currentText = it },
label = { Text(stringResource(R.string.add_trusted_ssid)) },
modifier = Modifier
.padding(start = screenPadding, top = 5.dp, bottom = 10.dp)
.onFocusChanged {
if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
keyboardController?.hide()
}
},
maxLines = 1,
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.None,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = {
saveTrustedSSID()
}
),
trailingIcon = {
IconButton(onClick = { saveTrustedSSID() }) {
Icon(
imageVector = Icons.Outlined.Add,
contentDescription = if (currentText == "") stringResource(id = R.string.trusted_ssid_empty_description) else stringResource(
id = R.string.trusted_ssid_value_description
),
tint = if (currentText == "") Color.Transparent else MaterialTheme.colorScheme.primary
)
}
},
)
}
}
ConfigurationToggle(stringResource(R.string.tunnel_mobile_data),
enabled = !(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled),
checked = settings.isTunnelOnMobileDataEnabled,
padding = screenPadding,
onCheckChanged = {
scope.launch {
viewModel.onToggleTunnelOnMobileData()
}
}
)
ConfigurationToggle(stringResource(id = R.string.tunnel_on_ethernet),
enabled = !(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled),
checked = settings.isTunnelOnEthernetEnabled,
padding = screenPadding,
onCheckChanged = {
scope.launch {
viewModel.onToggleTunnelOnEthernet()
}
}
)
ConfigurationToggle(
stringResource(R.string.battery_saver),
enabled = !(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled),
checked = settings.isBatterySaverEnabled,
padding = screenPadding,
onCheckChanged = {
scope.launch {
viewModel.onToggleBatterySaver()
}
}
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxSize()
.padding(top = 5.dp),
horizontalArrangement = Arrangement.Center
) {
TextButton(
enabled = !settings.isAlwaysOnVpnEnabled,
onClick = {
//TODO fix logic for mobile only
if(!isAllAutoTunnelPermissionsEnabled() && settings.isTunnelOnWifiEnabled) {
val message = if(!isBackgroundLocationGranted) {
context.getString(R.string.background_location_required)
} else if(viewModel.isLocationServicesNeeded()) {
context.getString(R.string.location_services_required)
} else {
context.getString(R.string.precise_location_required)
}
showSnackbarMessage(message)
} else scope.launch {
viewModel.toggleAutoTunnel()
}
}) {
val autoTunnelButtonText = if(settings.isAutoTunnelEnabled) stringResource(R.string.disable_auto_tunnel)
else stringResource(id = R.string.enable_auto_tunnel)
Text(autoTunnelButtonText)
}
}
}
}
if(!WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
Surface(
tonalElevation = 2.dp,
shadowElevation = 2.dp,
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surface,
modifier = Modifier
.fillMaxWidth(fillMaxWidth)
.height(IntrinsicSize.Min)
.padding(bottom = 180.dp)
modifier =
(
if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
Modifier
.height(IntrinsicSize.Min)
.fillMaxWidth(fillMaxWidth)
.padding(top = 10.dp)
} else {
Modifier
.fillMaxWidth(fillMaxWidth)
.padding(top = 60.dp)
}
).padding(bottom = 10.dp)
) {
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Top,
modifier = Modifier.padding(15.dp)
) {
SectionTitle(title = stringResource(id = R.string.other), padding = screenPadding)
ConfigurationToggle(stringResource(R.string.always_on_vpn_support),
enabled = !settings.isAutoTunnelEnabled,
checked = settings.isAlwaysOnVpnEnabled,
SectionTitle(
title = stringResource(id = R.string.auto_tunneling),
padding = screenPadding
)
ConfigurationToggle(
stringResource(id = R.string.tunnel_on_wifi),
enabled = !(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled),
checked = settings.isTunnelOnWifiEnabled,
padding = screenPadding,
onCheckChanged = {
scope.launch {
viewModel.onToggleAlwaysOnVPN()
viewModel.onToggleTunnelOnWifi()
}
},
modifier = Modifier.focusRequester(focusRequester)
)
AnimatedVisibility(visible = settings.isTunnelOnWifiEnabled) {
Column {
FlowRow(
modifier = Modifier
.padding(screenPadding)
.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(5.dp)
) {
trustedSSIDs.forEach { ssid ->
ClickableIconButton(
onIconClick = {
scope.launch {
viewModel.onDeleteTrustedSSID(ssid)
}
},
text = ssid,
icon = Icons.Filled.Close,
enabled = !(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled)
)
}
if (trustedSSIDs.isEmpty()) {
Text(
stringResource(R.string.none),
fontStyle = FontStyle.Italic,
color = Color.Gray
)
}
}
OutlinedTextField(
enabled = !(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled),
value = currentText,
onValueChange = { currentText = it },
label = { Text(stringResource(R.string.add_trusted_ssid)) },
modifier =
Modifier
.padding(start = screenPadding, top = 5.dp, bottom = 10.dp)
.onFocusChanged {
if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
keyboardController?.hide()
}
},
maxLines = 1,
keyboardOptions =
KeyboardOptions(
capitalization = KeyboardCapitalization.None,
imeAction = ImeAction.Done
),
keyboardActions =
KeyboardActions(
onDone = {
saveTrustedSSID()
}
),
trailingIcon = {
if (currentText != "") {
IconButton(onClick = { saveTrustedSSID() }) {
Icon(
imageVector = Icons.Outlined.Add,
contentDescription =
if (currentText == "") {
stringResource(
id = R.string.trusted_ssid_empty_description
)
} else {
stringResource(
id = R.string.trusted_ssid_value_description
)
},
tint = MaterialTheme.colorScheme.primary
)
}
}
}
)
}
}
ConfigurationToggle(
stringResource(R.string.tunnel_mobile_data),
enabled = !(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled),
checked = settings.isTunnelOnMobileDataEnabled,
padding = screenPadding,
onCheckChanged = {
scope.launch {
viewModel.onToggleTunnelOnMobileData()
}
}
)
ConfigurationToggle(stringResource(R.string.enabled_app_shortcuts),
enabled = true,
checked = settings.isShortcutsEnabled,
ConfigurationToggle(
stringResource(id = R.string.tunnel_on_ethernet),
enabled = !(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled),
checked = settings.isTunnelOnEthernetEnabled,
padding = screenPadding,
onCheckChanged = {
scope.launch {
viewModel.onToggleShortcutsEnabled()
viewModel.onToggleTunnelOnEthernet()
}
}
)
ConfigurationToggle(
stringResource(R.string.battery_saver),
enabled = !(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled),
checked = settings.isBatterySaverEnabled,
padding = screenPadding,
onCheckChanged = {
scope.launch {
viewModel.onToggleBatterySaver()
}
}
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
modifier =
Modifier
.fillMaxSize()
.padding(top = 5.dp),
horizontalArrangement = Arrangement.Center
) {
TextButton(
enabled = !didExportFiles,
enabled = !settings.isAlwaysOnVpnEnabled,
onClick = {
showAuthPrompt = true
}) {
Text(stringResource(R.string.export_configs))
if (!isAllAutoTunnelPermissionsEnabled() && settings.isTunnelOnWifiEnabled) {
val message =
if (!isBackgroundLocationGranted) {
context.getString(R.string.background_location_required)
} else if (viewModel.isLocationServicesNeeded()) {
context.getString(R.string.location_services_required)
} else {
context.getString(R.string.precise_location_required)
}
showSnackbarMessage(message)
} else {
scope.launch {
viewModel.toggleAutoTunnel()
}
}
}
) {
val autoTunnelButtonText =
if (settings.isAutoTunnelEnabled) {
stringResource(R.string.disable_auto_tunnel)
} else {
stringResource(id = R.string.enable_auto_tunnel)
}
Text(autoTunnelButtonText)
}
}
}
}
}
if(WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
Spacer(modifier = Modifier.weight(.17f))
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)) {
Surface(
tonalElevation = 2.dp,
shadowElevation = 2.dp,
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surface,
modifier = Modifier
.fillMaxWidth(fillMaxWidth).padding(vertical = 10.dp)
.padding(bottom = 140.dp)
) {
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Top,
modifier = Modifier.padding(15.dp)
) {
SectionTitle(
title = stringResource(id = R.string.other),
padding = screenPadding
)
ConfigurationToggle(
stringResource(R.string.always_on_vpn_support),
enabled = !settings.isAutoTunnelEnabled,
checked = settings.isAlwaysOnVpnEnabled,
padding = screenPadding,
onCheckChanged = {
scope.launch {
viewModel.onToggleAlwaysOnVPN()
}
}
)
ConfigurationToggle(
stringResource(R.string.enabled_app_shortcuts),
enabled = true,
checked = settings.isShortcutsEnabled,
padding = screenPadding,
onCheckChanged = {
scope.launch {
viewModel.onToggleShortcutsEnabled()
}
}
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.fillMaxSize()
.padding(top = 5.dp),
horizontalArrangement = Arrangement.Center
) {
TextButton(
enabled = !didExportFiles,
onClick = {
showAuthPrompt = true
}
) {
Text(stringResource(R.string.export_configs))
}
}
}
}
}
if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
Spacer(modifier = Modifier.weight(.17f))
}
}
}
}

View File

@ -6,32 +6,45 @@ import android.location.LocationManager
import android.os.Build
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.wireguard.android.util.RootShell
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa
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.TunnelConfig
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService
import com.zaneschepke.wireguardautotunnel.util.WgTunnelException
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
import javax.inject.Inject
import timber.log.Timber
@HiltViewModel
class SettingsViewModel @Inject constructor(private val application : Application,
private val tunnelRepo : TunnelConfigDao, private val settingsRepo : SettingsDoa
class SettingsViewModel
@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() {
private val _trustedSSIDs = MutableStateFlow(emptyList<String>())
val trustedSSIDs = _trustedSSIDs.asStateFlow()
private val _settings = MutableStateFlow(Settings())
val settings get() = _settings.asStateFlow()
val vpnState get() = vpnService.state
val tunnels get() = tunnelRepo.getAllFlow()
val disclosureShown = dataStoreManager.locationDisclosureFlow
init {
isLocationServicesEnabled()
viewModelScope.launch(Dispatchers.IO) {
@ -42,7 +55,6 @@ class SettingsViewModel @Inject constructor(private val application : Applicatio
}
}
}
suspend fun onSaveTrustedSSID(ssid: String) {
val trimmed = ssid.trim()
if (!_settings.value.trustedNetworkSSIDs.contains(trimmed)) {
@ -54,9 +66,11 @@ class SettingsViewModel @Inject constructor(private val application : Applicatio
}
suspend fun onToggleTunnelOnMobileData() {
settingsRepo.save(_settings.value.copy(
isTunnelOnMobileDataEnabled = !_settings.value.isTunnelOnMobileDataEnabled
))
settingsRepo.save(
_settings.value.copy(
isTunnelOnMobileDataEnabled = !_settings.value.isTunnelOnMobileDataEnabled
)
)
}
suspend fun onDeleteTrustedSSID(ssid: String) {
@ -64,34 +78,40 @@ class SettingsViewModel @Inject constructor(private val application : Applicatio
settingsRepo.save(_settings.value)
}
private fun emitFirstTunnelAsDefault() = viewModelScope.async {
_settings.emit(_settings.value.copy(defaultTunnel = getFirstTunnelConfig().toString()))
}
private fun emitFirstTunnelAsDefault() =
viewModelScope.async {
_settings.emit(_settings.value.copy(defaultTunnel = getFirstTunnelConfig().toString()))
}
suspend fun toggleAutoTunnel() {
if(_settings.value.isAutoTunnelEnabled) {
if (_settings.value.isAutoTunnelEnabled) {
ServiceManager.stopWatcherService(application)
} else {
if(_settings.value.defaultTunnel == null) {
if (_settings.value.defaultTunnel == null) {
emitFirstTunnelAsDefault().await()
}
val defaultTunnel = _settings.value.defaultTunnel
ServiceManager.startWatcherService(application, defaultTunnel!!)
}
settingsRepo.save(_settings.value.copy(
isAutoTunnelEnabled = !_settings.value.isAutoTunnelEnabled
))
settingsRepo.save(
_settings.value.copy(
isAutoTunnelEnabled = !_settings.value.isAutoTunnelEnabled
)
)
}
private suspend fun getFirstTunnelConfig() : TunnelConfig {
private suspend fun getFirstTunnelConfig(): TunnelConfig {
return tunnelRepo.getAll().first()
}
suspend fun onToggleAlwaysOnVPN() {
if(_settings.value.defaultTunnel == null) {
if (_settings.value.defaultTunnel == null) {
emitFirstTunnelAsDefault().await()
}
val updatedSettings = _settings.value.copy(isAlwaysOnVpnEnabled = !_settings.value.isAlwaysOnVpnEnabled)
val updatedSettings =
_settings.value.copy(
isAlwaysOnVpnEnabled = !_settings.value.isAlwaysOnVpnEnabled
)
emitSettings(updatedSettings)
saveSettings(updatedSettings)
}
@ -107,40 +127,71 @@ class SettingsViewModel @Inject constructor(private val application : Applicatio
}
suspend fun onToggleTunnelOnEthernet() {
if(_settings.value.defaultTunnel == null) {
if (_settings.value.defaultTunnel == null) {
emitFirstTunnelAsDefault().await()
}
_settings.emit(
_settings.value.copy(isTunnelOnEthernetEnabled = !_settings.value.isTunnelOnEthernetEnabled)
_settings.value.copy(
isTunnelOnEthernetEnabled = !_settings.value.isTunnelOnEthernetEnabled
)
)
settingsRepo.save(_settings.value)
}
private fun isLocationServicesEnabled() : Boolean {
private fun isLocationServicesEnabled(): Boolean {
val locationManager =
application.getSystemService(Context.LOCATION_SERVICE) as LocationManager
return locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)
}
fun isLocationServicesNeeded() : Boolean {
return(!isLocationServicesEnabled() && Build.VERSION.SDK_INT > Build.VERSION_CODES.P)
fun isLocationServicesNeeded(): Boolean {
return (!isLocationServicesEnabled() && Build.VERSION.SDK_INT > Build.VERSION_CODES.P)
}
suspend fun onToggleShortcutsEnabled() {
settingsRepo.save(_settings.value.copy(
isShortcutsEnabled = !_settings.value.isShortcutsEnabled
))
settingsRepo.save(
_settings.value.copy(
isShortcutsEnabled = !_settings.value.isShortcutsEnabled
)
)
}
suspend fun onToggleBatterySaver() {
settingsRepo.save(_settings.value.copy(
isBatterySaverEnabled = !_settings.value.isBatterySaverEnabled
))
settingsRepo.save(
_settings.value.copy(
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() {
settingsRepo.save(_settings.value.copy(
isTunnelOnWifiEnabled = !_settings.value.isTunnelOnWifiEnabled
))
settingsRepo.save(
_settings.value.copy(
isTunnelOnWifiEnabled = !_settings.value.isTunnelOnWifiEnabled
)
)
}
}
}

View File

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

@ -11,7 +11,8 @@ val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFFFFFFFF)
//status colors
// status colors
val brickRed = Color(0xFFCE4257)
val corn = Color(0xFFFBEC5D)
val pinkRed = Color(0xFFEF476F)
val mint = Color(0xFF52B788)

View File

@ -15,51 +15,52 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
private val DarkColorScheme = darkColorScheme(
//primary = Purple80,
primary = virdigris,
secondary = virdigris,
// secondary = PurpleGrey80,
tertiary = virdigris
//tertiary = Pink80
)
private val DarkColorScheme =
darkColorScheme(
// primary = Purple80,
primary = virdigris,
secondary = virdigris,
// secondary = PurpleGrey80,
tertiary = virdigris
// tertiary = Pink80
)
private val LightColorScheme = lightColorScheme(
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40
/* Other default colors to override
background = Color(0xFFFFFBFE),
surface = Color(0xFFFFFBFE),
onPrimary = Color.White,
onSecondary = Color.White,
onTertiary = Color.White,
onBackground = Color(0xFF1C1B1F),
onSurface = Color(0xFF1C1B1F),
*/
)
private val LightColorScheme =
lightColorScheme(
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40
/* Other default colors to override
background = Color(0xFFFFFBFE),
surface = Color(0xFFFFFBFE),
onPrimary = Color.White,
onSecondary = Color.White,
onTertiary = Color.White,
onBackground = Color(0xFF1C1B1F),
onSurface = Color(0xFF1C1B1F),
*/
)
@Composable
fun WireguardAutoTunnelTheme(
//force dark theme
darkTheme : Boolean = true,
//darkTheme: Boolean = isSystemInDarkTheme(),
// force dark theme
darkTheme: Boolean = true,
// darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+
//turning off dynamic color for now
// turning off dynamic color for now
dynamicColor: Boolean = false,
content: @Composable () -> Unit
) {
val colorScheme =
when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
@ -77,4 +78,4 @@ fun WireguardAutoTunnelTheme(
typography = Typography,
content = content
)
}
}

View File

@ -19,4 +19,4 @@ fun TransparentSystemBars() {
onDispose {}
}
}
}

View File

@ -7,28 +7,30 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
// Set of Material typography styles to start with
val Typography = Typography(
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
val Typography =
Typography(
bodyLarge =
TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
)
/* Other default text styles to override
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
*/
)
/* Other default text styles to override
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
*/
)

View File

@ -13,36 +13,49 @@ import java.time.Instant
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
object StorageUtil {
object FileUtils {
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) {
val resolver = context.contentResolver
val contentValues = ContentValues().apply {
put(MediaColumns.DISPLAY_NAME, fileName)
put(MediaColumns.MIME_TYPE, mimeType)
put(MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
}
val contentValues =
ContentValues().apply {
put(MediaColumns.DISPLAY_NAME, fileName)
put(MediaColumns.MIME_TYPE, mimeType)
put(MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
}
val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues)
if (uri != null) {
return resolver.openOutputStream(uri)
}
} else {
val target = File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
fileName
)
val target =
File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
fileName
)
return target.outputStream()
}
return null
}
fun saveFilesToZip(context: Context, files : List<File>) {
val zipOutputStream = createDownloadsFileOutputStream(context, "wg-export_${Instant.now().epochSecond}.zip", ZIP_FILE_MIME_TYPE)
fun saveFilesToZip(
context: Context,
files: List<File>
) {
val zipOutputStream = createDownloadsFileOutputStream(
context,
"wg-export_${Instant.now().epochSecond}.zip",
ZIP_FILE_MIME_TYPE
)
ZipOutputStream(zipOutputStream).use { zos ->
files.forEach { file ->
val entry = ZipEntry( file.name)
val entry = ZipEntry(file.name)
zos.putNextEntry(entry)
if (file.isFile) {
file.inputStream().use { fis -> fis.copyTo(zos) }
@ -50,4 +63,4 @@ object StorageUtil {
}
}
}
}
}

View File

@ -6,24 +6,23 @@ import java.time.Instant
import kotlin.math.pow
object NumberUtils {
private const val BYTES_IN_KB = 1024.0
private val BYTES_IN_MB = BYTES_IN_KB.pow(2.0)
private val keyValidationRegex = """^[A-Za-z0-9+/]{42}[AEIMQUYcgkosw480]=${'$'}""".toRegex()
fun bytesToMB(bytes : Long) : BigDecimal {
fun bytesToMB(bytes: Long): BigDecimal {
return bytes.toBigDecimal().divide(BYTES_IN_MB.toBigDecimal())
}
fun isValidKey(key : String) : Boolean {
fun isValidKey(key: String): Boolean {
return key.matches(keyValidationRegex)
}
fun generateRandomTunnelName() : String {
fun generateRandomTunnelName(): String {
return "tunnel${(Math.random() * 100000).toInt()}"
}
fun getSecondsBetweenTimestampAndNow(epoch : Long) : Long? {
fun getSecondsBetweenTimestampAndNow(epoch: Long): Long? {
return if (epoch != 0L) {
val time = Instant.ofEpochMilli(epoch)
return Duration.between(time, Instant.now()).seconds
@ -31,4 +30,4 @@ object NumberUtils {
null
}
}
}
}

View File

@ -3,13 +3,14 @@ package com.zaneschepke.wireguardautotunnel.util
import com.wireguard.config.BadConfigException
class WgTunnelException(e: Exception) : Exception() {
constructor(message : String) : this(Exception(message))
constructor(message: String) : this(Exception(message))
override val message: String = generateExceptionMessage(e)
private fun generateExceptionMessage(e : Exception) : String {
return when(e) {
private fun generateExceptionMessage(e: Exception): String {
return when (e) {
is BadConfigException -> "${e.section.name} ${e.location.name} ${e.reason.name}"
else -> e.message ?: "Unknown error occurred"
}
}
}
}

View File

@ -152,5 +152,6 @@
<string name="email">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="kernel">Kernel</string>
<string name="use_kernel">Use kernel module</string>
</resources>

View File

@ -13,4 +13,4 @@ class ExampleUnitTest {
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}
}

View File

@ -12,5 +12,5 @@ plugins {
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.hilt.android) apply false
kotlin("plugin.serialization").version(libs.versions.kotlin).apply(false)
alias(libs.plugins.ksp) apply false
alias(libs.plugins.ksp) apply false
}

View File

@ -5,4 +5,4 @@ plugins {
repositories {
google()
mavenCentral()
}
}

View File

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

View File

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

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]
accompanist = "0.32.0"
activityCompose = "1.8.1"
activityCompose = "1.8.2"
androidx-junit = "1.1.5"
appcompat = "1.6.1"
biometricKtx = "1.2.0-alpha05"
coreGoogleShortcuts = "1.1.0"
coreKtx = "1.12.0"
datastorePreferences = "1.0.0"
desugar_jdk_libs = "2.0.4"
espressoCore = "3.5.1"
firebase-crashlytics-gradle = "2.9.9"
@ -17,7 +18,7 @@ kotlinx-serialization-json = "1.6.2"
lifecycle-runtime-compose = "2.6.2"
material-icons-extended = "1.5.4"
material3 = "1.1.2"
navigationCompose = "2.7.5"
navigationCompose = "2.7.6"
roomVersion = "2.6.1"
timber = "5.0.1"
tunnel = "1.0.20230706"
@ -25,7 +26,7 @@ androidGradlePlugin = "8.2.0"
kotlin="1.9.10"
ksp="1.9.10-1.0.13"
composeBom="2023.10.01"
firebaseBom= "32.6.0"
firebaseBom= "32.7.0"
compose="1.5.4"
crashlytics= "18.6.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-core = { module = "androidx.core:core", version.ref = "coreKtx" }
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-service = { module = "androidx.lifecycle:lifecycle-service", version.ref = "lifecycle-runtime-compose" }
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" }
#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" }
hilt-android = { module = "com.google.dagger:hilt-android", 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"
include(":app")