feat: androidtv navigation, auto-tunneling pause

Improved AndroidTV navigation to be less clunky and more streamlined

Added auto-tunneling pause feature to UI to allow of quick auto tunneling pauses.
App shortcuts and quick tile also override auto tunneling by engaging pause for temporary override of VPN purposes.

Fixed bug where auto start on reboot was not working on older devices and AndroidTV.

Fixed bug where location services is prefenting some flavors of Android from using auto-tunneling.

Fixed bug where location permissions were not being detected correctly on AndroidTV versions.

Fixed bug where quick tile could become out of sync.

Improved notifications to show proper state of auto-tunneling and vpn.

Removed excessive vibration from notifications.

Improved error handling.

Closes #75
Closes #73
Closes #61
Closes #53
Closes #30
This commit is contained in:
Zane Schepke 2023-12-24 18:09:23 -05:00
parent f0ec661223
commit aeb4a13389
69 changed files with 3184 additions and 3328 deletions

View File

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

View File

@ -15,6 +15,7 @@ android {
defaultConfig { defaultConfig {
applicationId = Constants.APP_ID applicationId = Constants.APP_ID
minSdk = Constants.MIN_SDK minSdk = Constants.MIN_SDK
compileSdk = Constants.COMPILE_SDK
targetSdk = Constants.TARGET_SDK targetSdk = Constants.TARGET_SDK
versionCode = Constants.VERSION_CODE versionCode = Constants.VERSION_CODE
versionName = Constants.VERSION_NAME versionName = Constants.VERSION_NAME

View File

@ -2,4 +2,4 @@
-keepclassmembers class * extends androidx.datastore.preferences.protobuf.GeneratedMessageLite { -keepclassmembers class * extends androidx.datastore.preferences.protobuf.GeneratedMessageLite {
<fields>; <fields>;
} }

View File

@ -22,3 +22,5 @@
-keepclassmembers class * extends androidx.datastore.preferences.protobuf.GeneratedMessageLite { -keepclassmembers class * extends androidx.datastore.preferences.protobuf.GeneratedMessageLite {
<fields>; <fields>;
} }

View File

@ -0,0 +1,161 @@
{
"formatVersion": 1,
"database": {
"version": 5,
"identityHash": "bc15003a44746e18b9c260ec49737089",
"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, `is_auto_tunnel_paused` 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"
},
{
"fieldPath": "isAutoTunnelPaused",
"columnName": "is_auto_tunnel_paused",
"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, 'bc15003a44746e18b9c260ec49737089')"
]
}
}

View File

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

View File

@ -126,9 +126,13 @@
android:exported="false"> android:exported="false">
</service> </service>
<receiver android:enabled="true" android:name=".receiver.BootReceiver" <receiver android:enabled="true" android:name=".receiver.BootReceiver"
android:exported="true"> android:exported="false">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED"/> <category android:name="android.intent.category.DEFAULT" />
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.ACTION_BOOT_COMPLETED" />
<action android:name="android.intent.action.QUICKBOOT_POWERON" />
<action android:name="com.htc.intent.action.QUICKBOOT_POWERON" />
</intent-filter> </intent-filter>
</receiver> </receiver>
<receiver android:exported="false" android:name=".receiver.NotificationActionReceiver"/> <receiver android:exported="false" android:name=".receiver.NotificationActionReceiver"/>

View File

@ -1,31 +0,0 @@
package com.zaneschepke.wireguardautotunnel
import android.content.BroadcastReceiver
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,
block: suspend CoroutineScope.() -> Unit
) {
val pendingResult = goAsync()
@OptIn(DelicateCoroutinesApi::class) // Must run globally; there's no teardown callback.
GlobalScope.launch(context) {
try {
block()
} finally {
pendingResult.finish()
}
}
}
fun BigDecimal.toThreeDecimalPlaceString(): String {
val df = DecimalFormat("#.###")
return df.format(this)
}

View File

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

View File

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

View File

@ -1,4 +1,4 @@
package com.zaneschepke.wireguardautotunnel.repository package com.zaneschepke.wireguardautotunnel.data
import androidx.room.TypeConverter import androidx.room.TypeConverter
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString

View File

@ -1,15 +1,15 @@
package com.zaneschepke.wireguardautotunnel.repository package com.zaneschepke.wireguardautotunnel.data
import androidx.room.Dao import androidx.room.Dao
import androidx.room.Delete import androidx.room.Delete
import androidx.room.Insert import androidx.room.Insert
import androidx.room.OnConflictStrategy import androidx.room.OnConflictStrategy
import androidx.room.Query import androidx.room.Query
import com.zaneschepke.wireguardautotunnel.repository.model.Settings import com.zaneschepke.wireguardautotunnel.data.model.Settings
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@Dao @Dao
interface SettingsDoa { interface SettingsDao {
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun save(t: Settings) suspend fun save(t: Settings)
@ -22,6 +22,9 @@ interface SettingsDoa {
@Query("SELECT * FROM settings") @Query("SELECT * FROM settings")
suspend fun getAll(): List<Settings> suspend fun getAll(): List<Settings>
@Query("SELECT * FROM settings LIMIT 1")
fun getSettingsFlow(): Flow<Settings>
@Query("SELECT * FROM settings") @Query("SELECT * FROM settings")
fun getAllFlow(): Flow<MutableList<Settings>> fun getAllFlow(): Flow<MutableList<Settings>>

View File

@ -1,11 +1,11 @@
package com.zaneschepke.wireguardautotunnel.repository package com.zaneschepke.wireguardautotunnel.data
import androidx.room.Dao import androidx.room.Dao
import androidx.room.Delete import androidx.room.Delete
import androidx.room.Insert import androidx.room.Insert
import androidx.room.OnConflictStrategy import androidx.room.OnConflictStrategy
import androidx.room.Query import androidx.room.Query
import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@Dao @Dao

View File

@ -1,4 +1,4 @@
package com.zaneschepke.wireguardautotunnel.repository.datastore package com.zaneschepke.wireguardautotunnel.data.datastore
import android.content.Context import android.content.Context
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.booleanPreferencesKey
@ -27,12 +27,12 @@ class DataStoreManager(private val context: Context) {
context.dataStore.edit { context.dataStore.edit {
it[key] = value it[key] = value
} }
fun <T> getFromStoreFlow(key: Preferences.Key<T>) = context.dataStore.data.map {
fun <T> getFromStore(key: Preferences.Key<T>) =
context.dataStore.data.map {
it[key] it[key]
} }
suspend fun <T> getFromStore(key: Preferences.Key<T>) = context.dataStore.data.first { it.contains(key) }[key]
val locationDisclosureFlow: Flow<Boolean?> = context.dataStore.data.map { val locationDisclosureFlow: Flow<Boolean?> = context.dataStore.data.map {
it[LOCATION_DISCLOSURE_SHOWN] it[LOCATION_DISCLOSURE_SHOWN]
} }

View File

@ -1,4 +1,4 @@
package com.zaneschepke.wireguardautotunnel.repository.model package com.zaneschepke.wireguardautotunnel.data.model
import androidx.room.ColumnInfo import androidx.room.ColumnInfo
import androidx.room.Entity import androidx.room.Entity
@ -36,7 +36,11 @@ data class Settings(
@ColumnInfo( @ColumnInfo(
name = "is_multi_tunnel_enabled", name = "is_multi_tunnel_enabled",
defaultValue = "false" defaultValue = "false"
) var isMultiTunnelEnabled: Boolean = false ) var isMultiTunnelEnabled: Boolean = false,
@ColumnInfo(
name = "is_auto_tunnel_paused",
defaultValue = "false"
) var isAutoTunnelPaused: Boolean = false,
) { ) {
fun isTunnelConfigDefault(tunnelConfig: TunnelConfig): Boolean { fun isTunnelConfigDefault(tunnelConfig: TunnelConfig): Boolean {
return if (defaultTunnel != null) { return if (defaultTunnel != null) {

View File

@ -1,13 +1,13 @@
package com.zaneschepke.wireguardautotunnel.repository.model package com.zaneschepke.wireguardautotunnel.data.model
import androidx.room.ColumnInfo import androidx.room.ColumnInfo
import androidx.room.Entity import androidx.room.Entity
import androidx.room.Index import androidx.room.Index
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import com.wireguard.config.Config import com.wireguard.config.Config
import java.io.InputStream
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import java.io.InputStream
@Entity(indices = [Index(value = ["name"], unique = true)]) @Entity(indices = [Index(value = ["name"], unique = true)])
@Serializable @Serializable

View File

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

View File

@ -0,0 +1,24 @@
package com.zaneschepke.wireguardautotunnel.data.repository
import com.zaneschepke.wireguardautotunnel.data.SettingsDao
import com.zaneschepke.wireguardautotunnel.data.model.Settings
import kotlinx.coroutines.flow.Flow
class SettingsRepositoryImpl(private val settingsDoa: SettingsDao) : SettingsRepository {
override suspend fun save(settings: Settings) {
settingsDoa.save(settings)
}
override fun getSettingsFlow(): Flow<Settings> {
return settingsDoa.getSettingsFlow()
}
override suspend fun getSettings(): Settings {
return settingsDoa.getAll().firstOrNull() ?: Settings()
}
override suspend fun getAll(): List<Settings> {
return settingsDoa.getAll()
}
}

View File

@ -0,0 +1,14 @@
package com.zaneschepke.wireguardautotunnel.data.repository
import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.util.TunnelConfigs
import kotlinx.coroutines.flow.Flow
interface TunnelConfigRepository {
fun getTunnelConfigsFlow() : Flow<TunnelConfigs>
suspend fun getAll() : TunnelConfigs
suspend fun save(tunnelConfig: TunnelConfig)
suspend fun delete(tunnelConfig: TunnelConfig)
suspend fun count() : Int
}

View File

@ -0,0 +1,28 @@
package com.zaneschepke.wireguardautotunnel.data.repository
import com.zaneschepke.wireguardautotunnel.data.TunnelConfigDao
import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.util.TunnelConfigs
import kotlinx.coroutines.flow.Flow
class TunnelConfigRepositoryImpl(private val tunnelConfigDao: TunnelConfigDao) : TunnelConfigRepository {
override fun getTunnelConfigsFlow(): Flow<TunnelConfigs> {
return tunnelConfigDao.getAllFlow()
}
override suspend fun getAll(): TunnelConfigs {
return tunnelConfigDao.getAll()
}
override suspend fun save(tunnelConfig: TunnelConfig) {
tunnelConfigDao.save(tunnelConfig)
}
override suspend fun delete(tunnelConfig: TunnelConfig) {
tunnelConfigDao.delete(tunnelConfig)
}
override suspend fun count(): Int {
return tunnelConfigDao.count().toInt()
}
}

View File

@ -3,7 +3,7 @@ package com.zaneschepke.wireguardautotunnel.module
import android.content.Context import android.content.Context
import androidx.room.Room import androidx.room.Room
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.repository.AppDatabase import com.zaneschepke.wireguardautotunnel.data.AppDatabase
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn

View File

@ -1,10 +1,14 @@
package com.zaneschepke.wireguardautotunnel.module package com.zaneschepke.wireguardautotunnel.module
import android.content.Context import android.content.Context
import com.zaneschepke.wireguardautotunnel.repository.AppDatabase import com.zaneschepke.wireguardautotunnel.data.AppDatabase
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa import com.zaneschepke.wireguardautotunnel.data.SettingsDao
import com.zaneschepke.wireguardautotunnel.repository.TunnelConfigDao import com.zaneschepke.wireguardautotunnel.data.TunnelConfigDao
import com.zaneschepke.wireguardautotunnel.repository.datastore.DataStoreManager import com.zaneschepke.wireguardautotunnel.data.datastore.DataStoreManager
import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepository
import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepositoryImpl
import com.zaneschepke.wireguardautotunnel.data.repository.TunnelConfigRepository
import com.zaneschepke.wireguardautotunnel.data.repository.TunnelConfigRepositoryImpl
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
@ -17,16 +21,28 @@ import javax.inject.Singleton
class RepositoryModule { class RepositoryModule {
@Singleton @Singleton
@Provides @Provides
fun provideSettingsRepository(appDatabase: AppDatabase): SettingsDoa { fun provideSettingsDoa(appDatabase: AppDatabase): SettingsDao {
return appDatabase.settingDao() return appDatabase.settingDao()
} }
@Singleton @Singleton
@Provides @Provides
fun provideTunnelConfigRepository(appDatabase: AppDatabase): TunnelConfigDao { fun provideTunnelConfigDoa(appDatabase: AppDatabase): TunnelConfigDao {
return appDatabase.tunnelConfigDoa() return appDatabase.tunnelConfigDoa()
} }
@Singleton
@Provides
fun provideTunnelConfigRepository(tunnelConfigDao: TunnelConfigDao): TunnelConfigRepository {
return TunnelConfigRepositoryImpl(tunnelConfigDao)
}
@Singleton
@Provides
fun provideSettingsRepository(settingsDao: SettingsDao): SettingsRepository {
return SettingsRepositoryImpl(settingsDao)
}
@Singleton @Singleton
@Provides @Provides
fun providePreferencesDataStore(@ApplicationContext context: Context): DataStoreManager { fun providePreferencesDataStore(@ApplicationContext context: Context): DataStoreManager {

View File

@ -6,7 +6,7 @@ import com.wireguard.android.backend.GoBackend
import com.wireguard.android.backend.WgQuickBackend import com.wireguard.android.backend.WgQuickBackend
import com.wireguard.android.util.RootShell import com.wireguard.android.util.RootShell
import com.wireguard.android.util.ToolsInstaller import com.wireguard.android.util.ToolsInstaller
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepository
import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService
import com.zaneschepke.wireguardautotunnel.service.tunnel.WireGuardTunnel import com.zaneschepke.wireguardautotunnel.service.tunnel.WireGuardTunnel
import dagger.Module import dagger.Module
@ -51,8 +51,8 @@ class TunnelModule {
fun provideVpnService( fun provideVpnService(
@Userspace userspaceBackend: Backend, @Userspace userspaceBackend: Backend,
@Kernel kernelBackend: Backend, @Kernel kernelBackend: Backend,
settingsDoa: SettingsDoa settingsRepository : SettingsRepository
): VpnService { ): VpnService {
return WireGuardTunnel(userspaceBackend, kernelBackend, settingsDoa) return WireGuardTunnel(userspaceBackend, kernelBackend, settingsRepository)
} }
} }

View File

@ -3,34 +3,23 @@ package com.zaneschepke.wireguardautotunnel.receiver
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import com.zaneschepke.wireguardautotunnel.goAsync import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepository
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import com.zaneschepke.wireguardautotunnel.util.goAsync
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.cancel
@AndroidEntryPoint @AndroidEntryPoint
class BootReceiver : BroadcastReceiver() { class BootReceiver : BroadcastReceiver() {
@Inject
lateinit var settingsRepo: SettingsDoa
override fun onReceive( @Inject
context: Context, lateinit var settingsRepository: SettingsRepository
intent: Intent override fun onReceive(context: Context?, intent: Intent?) = goAsync {
) = goAsync { if (Intent.ACTION_BOOT_COMPLETED != intent?.action) return@goAsync
if (intent.action == Intent.ACTION_BOOT_COMPLETED) { if(settingsRepository.getSettings().isAutoTunnelEnabled) {
try { ServiceManager.startWatcherServiceForeground(context!!)
val settings = settingsRepo.getAll()
if (settings.isNotEmpty()) {
val setting = settings.first()
if (setting.isAutoTunnelEnabled && setting.defaultTunnel != null) {
ServiceManager.startWatcherService(context, setting.defaultTunnel!!)
}
}
} finally {
cancel()
}
} }
} }
} }

View File

@ -3,33 +3,30 @@ package com.zaneschepke.wireguardautotunnel.receiver
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import com.zaneschepke.wireguardautotunnel.Constants import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepository
import com.zaneschepke.wireguardautotunnel.goAsync
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.goAsync
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class NotificationActionReceiver : BroadcastReceiver() { class NotificationActionReceiver : BroadcastReceiver() {
@Inject @Inject
lateinit var settingsRepo: SettingsDoa lateinit var settingsRepository: SettingsRepository
override fun onReceive( override fun onReceive(
context: Context, context: Context,
intent: Intent? intent: Intent?
) = goAsync { ) = goAsync {
try { try {
val settings = settingsRepo.getAll() val settings = settingsRepository.getSettings()
if (settings.isNotEmpty()) { if (settings.defaultTunnel != null) {
val setting = settings.first() ServiceManager.stopVpnService(context)
if (setting.defaultTunnel != null) { delay(Constants.TOGGLE_TUNNEL_DELAY)
ServiceManager.stopVpnService(context) ServiceManager.startVpnService(context, settings.defaultTunnel.toString())
delay(Constants.TOGGLE_TUNNEL_DELAY)
ServiceManager.startVpnService(context, setting.defaultTunnel.toString())
}
} }
} finally { } finally {
cancel() cancel()

View File

@ -89,35 +89,23 @@ object ServiceManager {
) )
} }
private fun startWatcherServiceForeground( fun startWatcherServiceForeground(
context: Context, context: Context,
tunnelConfig: String
) { ) {
actionOnService( actionOnService(
Action.START, Action.START_FOREGROUND,
context, context,
WireGuardConnectivityWatcherService::class.java, WireGuardConnectivityWatcherService::class.java
mapOf(
context
.getString(R.string.tunnel_extras_key) to
tunnelConfig
)
) )
} }
fun startWatcherService( fun startWatcherService(
context: Context, context: Context
tunnelConfig: String
) { ) {
actionOnService( actionOnService(
Action.START, Action.START,
context, context,
WireGuardConnectivityWatcherService::class.java, WireGuardConnectivityWatcherService::class.java
mapOf(
context
.getString(R.string.tunnel_extras_key) to
tunnelConfig
)
) )
} }
@ -128,19 +116,4 @@ object ServiceManager {
WireGuardConnectivityWatcherService::class.java 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

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

View File

@ -5,20 +5,24 @@ import android.content.Intent
import android.os.Bundle import android.os.Bundle
import androidx.core.app.ServiceCompat import androidx.core.app.ServiceCompat
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.zaneschepke.wireguardautotunnel.Constants
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepository
import com.zaneschepke.wireguardautotunnel.data.repository.TunnelConfigRepository
import com.zaneschepke.wireguardautotunnel.receiver.NotificationActionReceiver import com.zaneschepke.wireguardautotunnel.receiver.NotificationActionReceiver
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa
import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.service.notification.NotificationService import com.zaneschepke.wireguardautotunnel.service.notification.NotificationService
import com.zaneschepke.wireguardautotunnel.service.tunnel.HandshakeStatus import com.zaneschepke.wireguardautotunnel.service.tunnel.HandshakeStatus
import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.handshakeStatus
import com.zaneschepke.wireguardautotunnel.util.mapPeerStats
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class WireGuardTunnelService : ForegroundService() { class WireGuardTunnelService : ForegroundService() {
@ -28,7 +32,10 @@ class WireGuardTunnelService : ForegroundService() {
lateinit var vpnService: VpnService lateinit var vpnService: VpnService
@Inject @Inject
lateinit var settingsRepo: SettingsDoa lateinit var settingsRepository: SettingsRepository
@Inject
lateinit var tunnelConfigRepository: TunnelConfigRepository
@Inject @Inject
lateinit var notificationService: NotificationService lateinit var notificationService: NotificationService
@ -36,26 +43,29 @@ class WireGuardTunnelService : ForegroundService() {
private lateinit var job: Job private lateinit var job: Job
private var tunnelName: String = "" private var tunnelName: String = ""
private var didShowConnected = false
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
lifecycleScope.launch(Dispatchers.Main) { lifecycleScope.launch(Dispatchers.Main) {
launchVpnStartingNotification() if(tunnelConfigRepository.getAll().isNotEmpty()) {
launchVpnNotification()
}
} }
} }
override fun startService(extras: Bundle?) { override fun startService(extras: Bundle?) {
super.startService(extras) super.startService(extras)
// TODO fix grapheneOS calls always-on on install
launchVpnStartingNotification()
val tunnelConfigString = extras?.getString(getString(R.string.tunnel_extras_key))
cancelJob() cancelJob()
job = val tunnelConfigString = extras?.getString(getString(R.string.tunnel_extras_key))
lifecycleScope.launch(Dispatchers.IO) { val tunnelConfig = tunnelConfigString?.let {
TunnelConfig.from(it)
}
tunnelName = tunnelConfig?.name ?: ""
job = lifecycleScope.launch(Dispatchers.IO) {
launch { launch {
if (tunnelConfigString != null) { if (tunnelConfig != null) {
try { try {
val tunnelConfig = TunnelConfig.from(tunnelConfigString)
tunnelName = tunnelConfig.name tunnelName = tunnelConfig.name
vpnService.startTunnel(tunnelConfig) vpnService.startTunnel(tunnelConfig)
} catch (e: Exception) { } catch (e: Exception) {
@ -63,52 +73,45 @@ class WireGuardTunnelService : ForegroundService() {
stopService(extras) stopService(extras)
} }
} else { } else {
Timber.d("Tunnel config null, starting default tunnel") Timber.d("Tunnel config null, starting default tunnel or first")
val settings = settingsRepo.getAll() val settings = settingsRepository.getSettings()
if (settings.isNotEmpty()) { val tunnels = tunnelConfigRepository.getAll()
val setting = settings[0] if (settings.isAlwaysOnVpnEnabled) {
if (setting.defaultTunnel != null && setting.isAlwaysOnVpnEnabled) { val tunnel = if(settings.defaultTunnel != null) {
val tunnelConfig = TunnelConfig.from(setting.defaultTunnel!!) TunnelConfig.from(settings.defaultTunnel!!)
tunnelName = tunnelConfig.name } else if(tunnels.isNotEmpty()) {
vpnService.startTunnel(tunnelConfig) tunnels.first()
} else {
null
} }
if(tunnel != null) {
tunnelName = tunnel.name
vpnService.startTunnel(tunnel)
}
} }
} }
} }
//TODO add failed to connect notification
launch { launch {
var didShowConnected = false vpnService.vpnState.collect { state ->
var didShowFailedHandshakeNotification = false state.statistics
vpnService.handshakeStatus.collect { ?.mapPeerStats()
when (it) { ?.map { it.value?.handshakeStatus() }
HandshakeStatus.NOT_STARTED -> { .let { statuses ->
} when {
HandshakeStatus.NEVER_CONNECTED -> { statuses?.all { it == HandshakeStatus.HEALTHY } == true -> {
if (!didShowFailedHandshakeNotification) { if(!didShowConnected){
launchVpnConnectionFailedNotification( delay(Constants.VPN_CONNECTED_NOTIFICATION_DELAY)
getString(R.string.initial_connection_failure_message) launchVpnNotification(getString(R.string.tunnel_start_title),"${getString(R.string.tunnel_start_text)} $tunnelName")
) didShowConnected = true
didShowFailedHandshakeNotification = true }
didShowConnected = false }
statuses?.any { it == HandshakeStatus.STALE } == true -> {}
statuses?.all { it == HandshakeStatus.NOT_STARTED } == true -> {}
else -> {}
} }
} }
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
}
}
}
} }
} }
} }
@ -118,40 +121,22 @@ class WireGuardTunnelService : ForegroundService() {
super.stopService(extras) super.stopService(extras)
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
vpnService.stopTunnel() vpnService.stopTunnel()
didShowConnected = false
} }
cancelJob() cancelJob()
stopSelf() stopSelf()
} }
private fun launchVpnConnectedNotification() { private fun launchVpnNotification(title : String = getString(R.string.vpn_starting),description : String = getString(R.string.attempt_connection)) {
val notification = val notification =
notificationService.createNotification( notificationService.createNotification(
channelId = getString(R.string.vpn_channel_id), channelId = getString(R.string.vpn_channel_id),
channelName = getString(R.string.vpn_channel_name), channelName = getString(R.string.vpn_channel_name),
title = getString(R.string.tunnel_start_title), title = title,
onGoing = false, onGoing = false,
vibration = false, vibration = false,
showTimestamp = true, showTimestamp = true,
description = "${getString(R.string.tunnel_start_text)} $tunnelName" description = description
)
ServiceCompat.startForeground(
this,
foregroundId,
notification,
Constants.SYSTEM_EXEMPT_SERVICE_TYPE_ID
)
}
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)
) )
ServiceCompat.startForeground( ServiceCompat.startForeground(
this, this,

View File

@ -3,6 +3,7 @@ package com.zaneschepke.wireguardautotunnel.service.notification
import android.app.Notification import android.app.Notification
import android.app.NotificationManager import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
import androidx.core.app.NotificationCompat
interface NotificationService { interface NotificationService {
fun createNotification( fun createNotification(
@ -16,6 +17,7 @@ interface NotificationService {
importance: Int = NotificationManager.IMPORTANCE_HIGH, importance: Int = NotificationManager.IMPORTANCE_HIGH,
vibration: Boolean = false, vibration: Boolean = false,
onGoing: Boolean = true, onGoing: Boolean = true,
lights: Boolean = true lights: Boolean = true,
onlyAlertOnce: Boolean = true,
): Notification ): Notification
} }

View File

@ -7,6 +7,7 @@ import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.Color import android.graphics.Color
import androidx.core.app.NotificationCompat
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.MainActivity import com.zaneschepke.wireguardautotunnel.ui.MainActivity
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
@ -20,6 +21,16 @@ constructor(
private val notificationManager = private val notificationManager =
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
private val watcherBuilder: NotificationCompat.Builder =
NotificationCompat.Builder(
context,
context.getString(R.string.watcher_channel_id)
)
private val tunnelBuilder: NotificationCompat.Builder = NotificationCompat.Builder(
context,
context.getString(R.string.vpn_channel_id)
)
override fun createNotification( override fun createNotification(
channelId: String, channelId: String,
channelName: String, channelName: String,
@ -31,7 +42,8 @@ constructor(
importance: Int, importance: Int,
vibration: Boolean, vibration: Boolean,
onGoing: Boolean, onGoing: Boolean,
lights: Boolean lights: Boolean,
onlyAlertOnce: Boolean,
): Notification { ): Notification {
val channel = val channel =
NotificationChannel( NotificationChannel(
@ -43,7 +55,7 @@ constructor(
it.enableLights(lights) it.enableLights(lights)
it.lightColor = Color.RED it.lightColor = Color.RED
it.enableVibration(vibration) it.enableVibration(vibration)
it.vibrationPattern = longArrayOf(100, 200, 300, 400, 500, 400, 300, 200, 400) it.vibrationPattern = longArrayOf(100,200,300)
it it
} }
notificationManager.createNotificationChannel(channel) notificationManager.createNotificationChannel(channel)
@ -57,24 +69,31 @@ constructor(
) )
} }
val builder: Notification.Builder = val builder = when(channelId) {
Notification.Builder( context.getString(R.string.watcher_channel_id) -> watcherBuilder
context, context.getString(R.string.vpn_channel_id) -> tunnelBuilder
channelId else -> {
) NotificationCompat.Builder(
context,
channelId
)
}
}
return builder.let { return builder.let {
if (action != null && actionText != null) { if (action != null && actionText != null) {
// TODO find a not deprecated way to do this
it.addAction( it.addAction(
Notification.Action.Builder(0, actionText, action) NotificationCompat.Action.Builder(0, actionText, action)
.build() .build()
) )
it.setAutoCancel(true) it.setAutoCancel(true)
} }
it.setContentTitle(title) it.setContentTitle(title)
.setContentText(description) .setContentText(description)
.setOnlyAlertOnce(onlyAlertOnce)
.setContentIntent(pendingIntent) .setContentIntent(pendingIntent)
.setOngoing(onGoing) .setOngoing(onGoing)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setShowWhen(showTimestamp) .setShowWhen(showTimestamp)
.setSmallIcon(R.mipmap.ic_launcher_foreground) .setSmallIcon(R.mipmap.ic_launcher_foreground)
.build() .build()

View File

@ -1,61 +1,63 @@
package com.zaneschepke.wireguardautotunnel.service.shortcut package com.zaneschepke.wireguardautotunnel.service.shortcut
import android.os.Bundle import android.os.Bundle
import android.view.View
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.repository.TunnelConfigDao import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepository
import com.zaneschepke.wireguardautotunnel.repository.model.Settings import com.zaneschepke.wireguardautotunnel.data.repository.TunnelConfigRepository
import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.service.foreground.Action import com.zaneschepke.wireguardautotunnel.service.foreground.Action
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import com.zaneschepke.wireguardautotunnel.service.foreground.WireGuardTunnelService import com.zaneschepke.wireguardautotunnel.service.foreground.WireGuardTunnelService
import com.zaneschepke.wireguardautotunnel.util.WgTunnelException
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class ShortcutsActivity : ComponentActivity() { class ShortcutsActivity : ComponentActivity() {
@Inject @Inject
lateinit var settingsRepo: SettingsDoa lateinit var settingsRepository: SettingsRepository
@Inject @Inject
lateinit var tunnelConfigRepo: TunnelConfigDao lateinit var tunnelConfigRepository: TunnelConfigRepository
private fun attemptWatcherServiceToggle(tunnelConfig: String) { private suspend fun toggleWatcherServicePause() {
lifecycleScope.launch(Dispatchers.Main) { val settings = settingsRepository.getSettings()
val settings = getSettings()
if (settings.isAutoTunnelEnabled) { if (settings.isAutoTunnelEnabled) {
ServiceManager.toggleWatcherServiceForeground(this@ShortcutsActivity, tunnelConfig) val pauseAutoTunnel = !settings.isAutoTunnelPaused
settingsRepository.save(settings.copy(
isAutoTunnelPaused = pauseAutoTunnel
))
} }
} }
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(View(this))
if (intent.getStringExtra(CLASS_NAME_EXTRA_KEY) if (intent.getStringExtra(CLASS_NAME_EXTRA_KEY)
.equals(WireGuardTunnelService::class.java.simpleName) .equals(WireGuardTunnelService::class.java.simpleName)
) { ) {
lifecycleScope.launch(Dispatchers.Main) { lifecycleScope.launch(Dispatchers.Main) {
val settings = getSettings() val settings = settingsRepository.getSettings()
if (settings.isShortcutsEnabled) { if (settings.isShortcutsEnabled) {
try { try {
val tunnelName = intent.getStringExtra(TUNNEL_NAME_EXTRA_KEY) val tunnelName = intent.getStringExtra(TUNNEL_NAME_EXTRA_KEY)
val tunnelConfig = val tunnelConfig =
if (tunnelName != null) { if (tunnelName != null) {
tunnelConfigRepo.getAll().firstOrNull { it.name == tunnelName } tunnelConfigRepository.getAll().firstOrNull { it.name == tunnelName }
} else { } else {
if (settings.defaultTunnel == null) { if (settings.defaultTunnel == null) {
tunnelConfigRepo.getAll().first() tunnelConfigRepository.getAll().first()
} else { } else {
TunnelConfig.from(settings.defaultTunnel!!) TunnelConfig.from(settings.defaultTunnel!!)
} }
} }
tunnelConfig ?: return@launch tunnelConfig ?: return@launch
attemptWatcherServiceToggle(tunnelConfig.toString()) toggleWatcherServicePause()
when (intent.action) { when (intent.action) {
Action.STOP.name -> ServiceManager.stopVpnService( Action.STOP.name -> ServiceManager.stopVpnService(
this@ShortcutsActivity this@ShortcutsActivity
@ -67,6 +69,7 @@ class ShortcutsActivity : ComponentActivity() {
} }
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e.message) Timber.e(e.message)
finish()
} }
} }
} }
@ -74,15 +77,6 @@ class ShortcutsActivity : ComponentActivity() {
finish() finish()
} }
private suspend fun getSettings(): Settings {
val settings = settingsRepo.getAll()
return if (settings.isNotEmpty()) {
settings.first()
} else {
throw WgTunnelException("Settings empty")
}
}
companion object { companion object {
const val TUNNEL_NAME_EXTRA_KEY = "tunnelName" const val TUNNEL_NAME_EXTRA_KEY = "tunnelName"
const val CLASS_NAME_EXTRA_KEY = "className" const val CLASS_NAME_EXTRA_KEY = "className"

View File

@ -4,51 +4,67 @@ import android.os.Build
import android.service.quicksettings.Tile import android.service.quicksettings.Tile
import android.service.quicksettings.TileService import android.service.quicksettings.TileService
import com.wireguard.android.backend.Tunnel import com.wireguard.android.backend.Tunnel
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepository
import com.zaneschepke.wireguardautotunnel.repository.TunnelConfigDao import com.zaneschepke.wireguardautotunnel.data.repository.TunnelConfigRepository
import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class TunnelControlTile : TileService() { class TunnelControlTile() : TileService() {
@Inject
lateinit var settingsRepo: SettingsDoa
@Inject @Inject
lateinit var configRepo: TunnelConfigDao lateinit var tunnelConfigRepository: TunnelConfigRepository
@Inject
lateinit var settingsRepository: SettingsRepository
@Inject @Inject
lateinit var vpnService: VpnService lateinit var vpnService: VpnService
private val scope = CoroutineScope(Dispatchers.Main) private val scope = CoroutineScope(Dispatchers.IO)
private lateinit var job: Job private var tunnelName : String? = null
override fun onStartListening() { override fun onStartListening() {
job =
scope.launch {
updateTileState()
}
super.onStartListening() super.onStartListening()
Timber.d("On start listening called")
scope.launch {
vpnService.vpnState.collect {
when(it.status) {
Tunnel.State.UP -> setActive()
Tunnel.State.DOWN -> setInactive()
else -> setInactive()
}
val tunnels = tunnelConfigRepository.getAll()
if(tunnels.isEmpty()) {
setUnavailable()
return@collect
}
tunnelName = it.name.ifBlank {
val settings = settingsRepository.getSettings()
if (settings.defaultTunnel != null) {
TunnelConfig.from(settings.defaultTunnel!!).name
} else tunnels.firstOrNull()?.name
}
setTileDescription(tunnelName ?: "")
}
}
}
override fun onDestroy() {
super.onDestroy()
scope.cancel()
} }
override fun onTileRemoved() { override fun onTileRemoved() {
super.onTileRemoved() super.onTileRemoved()
cancelJob()
}
override fun onDestroy() {
super.onDestroy()
scope.cancel() scope.cancel()
} }
@ -57,17 +73,15 @@ class TunnelControlTile : TileService() {
unlockAndRun { unlockAndRun {
scope.launch { scope.launch {
try { try {
val tunnel = determineTileTunnel() val tunnelConfig = tunnelConfigRepository.getAll().first { it.name == tunnelName }
if (tunnel != null) { toggleWatcherServicePause()
attemptWatcherServiceToggle(tunnel.toString()) if (vpnService.getState() == Tunnel.State.UP) {
if (vpnService.getState() == Tunnel.State.UP) { ServiceManager.stopVpnService(this@TunnelControlTile)
ServiceManager.stopVpnService(this@TunnelControlTile) } else {
} else { ServiceManager.startVpnServiceForeground(
ServiceManager.startVpnServiceForeground( this@TunnelControlTile,
this@TunnelControlTile, tunnelConfig.toString()
tunnel.toString() )
)
}
} }
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e.message) Timber.e(e.message)
@ -78,68 +92,31 @@ class TunnelControlTile : TileService() {
} }
} }
private suspend fun determineTileTunnel(): TunnelConfig? { private fun toggleWatcherServicePause() {
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()
} else {
null
}
config
}
}
return tunnelConfig
}
private fun attemptWatcherServiceToggle(tunnelConfig: String) {
scope.launch { scope.launch {
val settings = settingsRepo.getAll() val settings = settingsRepository.getSettings()
if (settings.isNotEmpty()) { if (settings.isAutoTunnelEnabled) {
val setting = settings.first() val pauseAutoTunnel = !settings.isAutoTunnelPaused
if (setting.isAutoTunnelEnabled) { settingsRepository.save(settings.copy(
ServiceManager.toggleWatcherServiceForeground( isAutoTunnelPaused = pauseAutoTunnel
this@TunnelControlTile, ))
tunnelConfig
)
}
} }
} }
} }
private suspend fun updateTileState() { private fun setActive() {
vpnService.state.collect { qsTile.state = Tile.STATE_ACTIVE
try { qsTile.updateTile()
when (it) { }
Tunnel.State.UP -> {
qsTile.state = Tile.STATE_ACTIVE
}
Tunnel.State.DOWN -> { private fun setInactive() {
qsTile.state = Tile.STATE_INACTIVE qsTile.state = Tile.STATE_INACTIVE
} qsTile.updateTile()
}
else -> { private fun setUnavailable() {
qsTile.state = Tile.STATE_UNAVAILABLE qsTile.state = Tile.STATE_UNAVAILABLE
} qsTile.updateTile()
}
val config = determineTileTunnel()
setTileDescription(
config?.name ?: this.resources.getString(R.string.no_tunnel_available)
)
qsTile.updateTile()
} catch (e: Exception) {
Timber.e("Unable to update tile state")
}
}
} }
private fun setTileDescription(description: String) { private fun setTileDescription(description: String) {
@ -149,11 +126,6 @@ class TunnelControlTile : TileService() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
qsTile.stateDescription = description qsTile.stateDescription = description
} }
} qsTile.updateTile()
private fun cancelJob() {
if (this::job.isInitialized) {
job.cancel()
}
} }
} }

View File

@ -3,8 +3,7 @@ package com.zaneschepke.wireguardautotunnel.service.tunnel
enum class HandshakeStatus { enum class HandshakeStatus {
HEALTHY, HEALTHY,
STALE, STALE,
UNHEALTHY, UNKNOWN,
NEVER_CONNECTED,
NOT_STARTED NOT_STARTED
; ;

View File

@ -1,21 +1,15 @@
package com.zaneschepke.wireguardautotunnel.service.tunnel package com.zaneschepke.wireguardautotunnel.service.tunnel
import com.wireguard.android.backend.Statistics
import com.wireguard.android.backend.Tunnel import com.wireguard.android.backend.Tunnel
import com.wireguard.crypto.Key import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.SharedFlow
interface VpnService : Tunnel { interface VpnService : Tunnel {
suspend fun startTunnel(tunnelConfig: TunnelConfig): Tunnel.State suspend fun startTunnel(tunnelConfig: TunnelConfig): Tunnel.State
suspend fun stopTunnel() suspend fun stopTunnel()
val state: SharedFlow<Tunnel.State> val vpnState: StateFlow<VpnState>
val tunnelName: SharedFlow<String>
val statistics: SharedFlow<Statistics>
val lastHandshake: SharedFlow<Map<Key, Long>>
val handshakeStatus: SharedFlow<HandshakeStatus>
fun getState(): Tunnel.State fun getState(): Tunnel.State
} }

View File

@ -0,0 +1,10 @@
package com.zaneschepke.wireguardautotunnel.service.tunnel
import com.wireguard.android.backend.Statistics
import com.wireguard.android.backend.Tunnel
data class VpnState(
val status : Tunnel.State = Tunnel.State.DOWN,
val name : String = "",
val statistics : Statistics? = null
)

View File

@ -3,60 +3,34 @@ package com.zaneschepke.wireguardautotunnel.service.tunnel
import com.wireguard.android.backend.Backend import com.wireguard.android.backend.Backend
import com.wireguard.android.backend.BackendException import com.wireguard.android.backend.BackendException
import com.wireguard.android.backend.Statistics import com.wireguard.android.backend.Statistics
import com.wireguard.android.backend.Tunnel import com.wireguard.android.backend.Tunnel.State
import com.wireguard.config.Config import com.wireguard.config.Config
import com.wireguard.crypto.Key import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.zaneschepke.wireguardautotunnel.Constants import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepository
import com.zaneschepke.wireguardautotunnel.module.Kernel import com.zaneschepke.wireguardautotunnel.module.Kernel
import com.zaneschepke.wireguardautotunnel.module.Userspace import com.zaneschepke.wireguardautotunnel.module.Userspace
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.util.NumberUtils
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject
class WireGuardTunnel class WireGuardTunnel
@Inject @Inject
constructor( constructor(
@Userspace private val userspaceBackend: Backend, @Userspace private val userspaceBackend: Backend,
@Kernel private val kernelBackend: Backend, @Kernel private val kernelBackend: Backend,
private val settingsRepo: SettingsDoa private val settingsRepository: SettingsRepository
) : VpnService { ) : VpnService {
private val _tunnelName = MutableStateFlow("") private val _vpnState = MutableStateFlow(VpnState())
override val tunnelName get() = _tunnelName.asStateFlow() override val vpnState: StateFlow<VpnState> = _vpnState.asStateFlow()
private val _state =
MutableSharedFlow<Tunnel.State>(
onBufferOverflow = BufferOverflow.DROP_OLDEST,
replay = 1
)
private val _handshakeStatus =
MutableSharedFlow<HandshakeStatus>(
replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
override val state get() = _state.asSharedFlow()
private val _statistics = MutableSharedFlow<Statistics>(replay = 1)
override val statistics get() = _statistics.asSharedFlow()
private val _lastHandshake = MutableSharedFlow<Map<Key, Long>>(replay = 1)
override val lastHandshake get() = _lastHandshake.asSharedFlow()
override val handshakeStatus: SharedFlow<HandshakeStatus>
get() = _handshakeStatus.asSharedFlow()
private val scope = CoroutineScope(Dispatchers.IO) private val scope = CoroutineScope(Dispatchers.IO)
@ -70,13 +44,12 @@ constructor(
init { init {
scope.launch { scope.launch {
settingsRepo.getAllFlow().collect { settingsRepository.getSettingsFlow().collect {
val settings = it.first() if (it.isKernelEnabled && backendIsUserspace) {
if (settings.isKernelEnabled && backendIsUserspace) {
Timber.d("Setting kernel backend") Timber.d("Setting kernel backend")
backend = kernelBackend backend = kernelBackend
backendIsUserspace = false backendIsUserspace = false
} else if (!settings.isKernelEnabled && !backendIsUserspace) { } else if (!it.isKernelEnabled && !backendIsUserspace) {
Timber.d("Setting userspace backend") Timber.d("Setting userspace backend")
backend = userspaceBackend backend = userspaceBackend
backendIsUserspace = true backendIsUserspace = true
@ -85,7 +58,7 @@ constructor(
} }
} }
override suspend fun startTunnel(tunnelConfig: TunnelConfig): Tunnel.State { override suspend fun startTunnel(tunnelConfig: TunnelConfig): State {
return try { return try {
stopTunnelOnConfigChange(tunnelConfig) stopTunnelOnConfigChange(tunnelConfig)
emitTunnelName(tunnelConfig.name) emitTunnelName(tunnelConfig.name)
@ -93,95 +66,84 @@ constructor(
val state = val state =
backend.setState( backend.setState(
this, this,
Tunnel.State.UP, State.UP,
config config
) )
_state.emit(state) emitTunnelState(state)
state state
} catch (e: Exception) { } catch (e: Exception) {
Timber.e("Failed to start tunnel with error: ${e.message}") Timber.e("Failed to start tunnel with error: ${e.message}")
Tunnel.State.DOWN State.DOWN
} }
} }
private fun emitTunnelState(state: State) {
_vpnState.tryEmit(
_vpnState.value.copy(
status = state
)
)
}
private fun emitBackendStatistics(statistics: Statistics) {
_vpnState.tryEmit(
_vpnState.value.copy(
statistics = statistics
)
)
}
private suspend fun emitTunnelName(name: String) { private suspend fun emitTunnelName(name: String) {
_tunnelName.emit(name) _vpnState.emit(
_vpnState.value.copy(
name = name
)
)
} }
private suspend fun stopTunnelOnConfigChange(tunnelConfig: TunnelConfig) { private suspend fun stopTunnelOnConfigChange(tunnelConfig: TunnelConfig) {
if (getState() == Tunnel.State.UP && _tunnelName.value != tunnelConfig.name) { if (getState() == State.UP && _vpnState.value.name != tunnelConfig.name) {
stopTunnel() stopTunnel()
} }
} }
override fun getName(): String { override fun getName(): String {
return _tunnelName.value return _vpnState.value.name
} }
override suspend fun stopTunnel() { override suspend fun stopTunnel() {
try { try {
if (getState() == Tunnel.State.UP) { if (getState() == State.UP) {
val state = backend.setState(this, Tunnel.State.DOWN, null) val state = backend.setState(this, State.DOWN, null)
_state.emit(state) emitTunnelState(state)
} }
} catch (e: BackendException) { } catch (e: BackendException) {
Timber.e("Failed to stop tunnel with error: ${e.message}") Timber.e("Failed to stop tunnel with error: ${e.message}")
} }
} }
override fun getState(): Tunnel.State { override fun getState(): State {
return backend.getState(this) return backend.getState(this)
} }
override fun onStateChange(state: Tunnel.State) { override fun onStateChange(state: State) {
val tunnel = this val tunnel = this
_state.tryEmit(state) emitTunnelState(state)
if (state == Tunnel.State.UP) { WireGuardAutoTunnel.requestTileServiceStateUpdate()
if (state == State.UP) {
statsJob = statsJob =
scope.launch { scope.launch {
val handshakeMap = HashMap<Key, Long>()
var neverHadHandshakeCounter = 0
while (true) { while (true) {
val statistics = backend.getStatistics(tunnel) val statistics = backend.getStatistics(tunnel)
_statistics.emit(statistics) emitBackendStatistics(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
}
// 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)
}
}
}
_lastHandshake.emit(handshakeMap)
delay(Constants.VPN_STATISTIC_CHECK_INTERVAL) delay(Constants.VPN_STATISTIC_CHECK_INTERVAL)
} }
} }
} }
if (state == Tunnel.State.DOWN) { if (state == State.DOWN) {
if (this::statsJob.isInitialized) { if (this::statsJob.isInitialized) {
statsJob.cancel() statsJob.cancel()
} }
_handshakeStatus.tryEmit(HandshakeStatus.NOT_STARTED)
_lastHandshake.tryEmit(emptyMap())
} }
} }
} }

View File

@ -1,8 +1,13 @@
package com.zaneschepke.wireguardautotunnel.ui package com.zaneschepke.wireguardautotunnel.ui
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import com.zaneschepke.wireguardautotunnel.data.SettingsDao
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject import javax.inject.Inject
class ActivityViewModel @Inject constructor() : ViewModel() { @HiltViewModel
// TODO move shared logic to shared viewmodel class ActivityViewModel @Inject constructor(
private val settingsRepo: SettingsDao,
) : ViewModel() {
} }

View File

@ -6,15 +6,11 @@ import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.provider.Settings import android.provider.Settings
import android.view.KeyEvent
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.ExitTransition import androidx.compose.foundation.focusable
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.slideInHorizontally
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarData import androidx.compose.material3.SnackbarData
@ -30,7 +26,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
@ -40,7 +36,6 @@ import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState import com.google.accompanist.permissions.rememberPermissionState
import com.wireguard.android.backend.GoBackend import com.wireguard.android.backend.GoBackend
import com.zaneschepke.wireguardautotunnel.Constants
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.common.PermissionRequestFailedScreen import com.zaneschepke.wireguardautotunnel.ui.common.PermissionRequestFailedScreen
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.BottomNavBar import com.zaneschepke.wireguardautotunnel.ui.common.navigation.BottomNavBar
@ -51,10 +46,10 @@ import com.zaneschepke.wireguardautotunnel.ui.screens.settings.SettingsScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.support.SupportScreen import com.zaneschepke.wireguardautotunnel.ui.screens.support.SupportScreen
import com.zaneschepke.wireguardautotunnel.ui.theme.TransparentSystemBars import com.zaneschepke.wireguardautotunnel.ui.theme.TransparentSystemBars
import com.zaneschepke.wireguardautotunnel.ui.theme.WireguardAutoTunnelTheme import com.zaneschepke.wireguardautotunnel.ui.theme.WireguardAutoTunnelTheme
import com.zaneschepke.wireguardautotunnel.util.Constants
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
@ -64,10 +59,10 @@ class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContent { setContent {
// TODO move shared logic to shared viewmodel // val activityViewModel = hiltViewModel<ActivityViewModel>()
// val sharedViewModel = hiltViewModel<ActivityViewModel>()
val navController = rememberNavController() val navController = rememberNavController()
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester()}
WireguardAutoTunnelTheme { WireguardAutoTunnelTheme {
TransparentSystemBars() TransparentSystemBars()
@ -104,18 +99,13 @@ class MainActivity : AppCompatActivity() {
fun showSnackBarMessage(message: String) { fun showSnackBarMessage(message: String) {
lifecycleScope.launch(Dispatchers.Main) { lifecycleScope.launch(Dispatchers.Main) {
val result = val result = snackbarHostState.showSnackbar(
snackbarHostState.showSnackbar(
message = message, message = message,
actionLabel = applicationContext.getString(R.string.okay), actionLabel = applicationContext.getString(R.string.okay),
duration = SnackbarDuration.Short duration = SnackbarDuration.Short
) )
when (result) { when (result) {
SnackbarResult.ActionPerformed -> { SnackbarResult.ActionPerformed, SnackbarResult.Dismissed -> {
snackbarHostState.currentSnackbarData?.dismiss()
}
SnackbarResult.Dismissed -> {
snackbarHostState.currentSnackbarData?.dismiss() snackbarHostState.currentSnackbarData?.dismiss()
} }
} }
@ -134,32 +124,13 @@ class MainActivity : AppCompatActivity() {
) )
} }
}, },
modifier = modifier = Modifier.focusable().focusProperties { up = focusRequester },
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"
)
}
false
}
else -> {
false
}
}
} else {
false
}
},
bottomBar = bottomBar =
if (vpnIntent == null && notificationPermissionState.status.isGranted) { if (vpnIntent == null && notificationPermissionState.status.isGranted) {
{ BottomNavBar(navController, Routes.navItems) } { BottomNavBar(navController, listOf(
Screen.Main.navItem,
Screen.Settings.navItem,
Screen.Support.navItem)) }
} else { } else {
{} {}
} }
@ -192,85 +163,31 @@ class MainActivity : AppCompatActivity() {
) )
return@Scaffold return@Scaffold
} }
NavHost(navController, startDestination = Screen.Main.route) {
NavHost(navController, startDestination = Routes.Main.name) {
composable( composable(
Routes.Main.name, Screen.Main.route,
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
)
)
}
}
},
exitTransition = {
ExitTransition.None
}
) { ) {
MainScreen(padding = padding, showSnackbarMessage = { message -> MainScreen(padding = padding, focusRequester = focusRequester, showSnackbarMessage = { message ->
showSnackBarMessage(message) showSnackBarMessage(message)
}, navController = navController) }, navController = navController)
} }
composable(Routes.Settings.name, enterTransition = { composable(Screen.Settings.route,
when (initialState.destination.route) { ) {
Routes.Main.name ->
slideInHorizontally(
initialOffsetX = { Constants.SLIDE_IN_TRANSITION_OFFSET },
animationSpec = tween(Constants.SLIDE_IN_ANIMATION_DURATION)
)
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)
)
}
}
}) {
SettingsScreen(padding = padding, showSnackbarMessage = { message -> SettingsScreen(padding = padding, showSnackbarMessage = { message ->
showSnackBarMessage(message) showSnackBarMessage(message)
}, focusRequester = focusRequester) }, focusRequester = focusRequester)
} }
composable(Routes.Support.name, enterTransition = { composable(Screen.Support.route,
when (initialState.destination.route) { ) {
Routes.Settings.name, Routes.Main.name -> SupportScreen(padding = padding, focusRequester = focusRequester,
slideInHorizontally( showSnackbarMessage = { message ->
initialOffsetX = { Constants.SLIDE_IN_ANIMATION_DURATION }, showSnackBarMessage(message)
animationSpec = tween(Constants.SLIDE_IN_ANIMATION_DURATION) })
) }
composable("${Screen.Config.route}/{id}") {
else -> {
fadeIn(
animationSpec = tween(Constants.FADE_IN_ANIMATION_DURATION)
)
}
}
}) { SupportScreen(padding = padding, focusRequester = focusRequester) }
composable("${Routes.Config.name}/{id}", enterTransition = {
fadeIn(animationSpec = tween(Constants.FADE_IN_ANIMATION_DURATION))
}) {
val id = it.arguments?.getString("id") val id = it.arguments?.getString("id")
if (!id.isNullOrBlank()) { if (!id.isNullOrBlank()) {
//https://dagger.dev/hilt/view-model#assisted-injection
ConfigScreen( ConfigScreen(
navController = navController, navController = navController,
id = id, id = id,

View File

@ -1,36 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Home
import androidx.compose.material.icons.rounded.QuestionMark
import androidx.compose.material.icons.rounded.Settings
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.BottomNavItem
enum class Routes {
Main,
Settings,
Support,
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
)
)
}
}

View File

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

View File

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

View File

@ -18,8 +18,8 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.wireguard.android.backend.Statistics import com.wireguard.android.backend.Statistics
import com.zaneschepke.wireguardautotunnel.toThreeDecimalPlaceString
import com.zaneschepke.wireguardautotunnel.util.NumberUtils import com.zaneschepke.wireguardautotunnel.util.NumberUtils
import com.zaneschepke.wireguardautotunnel.util.toThreeDecimalPlaceString
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
@ -51,7 +51,7 @@ fun RowListItem(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 5.dp), .padding(horizontal = 15.dp, vertical = 5.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween horizontalArrangement = Arrangement.SpaceBetween
) { ) {

View File

@ -38,7 +38,7 @@ fun CustomSnackBar(
containerColor = containerColor, containerColor = containerColor,
modifier = modifier =
Modifier.fillMaxWidth( Modifier.fillMaxWidth(
if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) 1 / 3f else 2 / 3f if (WireGuardAutoTunnel.isRunningOnAndroidTv()) 1 / 3f else 2 / 3f
).padding(bottom = 100.dp), ).padding(bottom = 100.dp),
shape = RoundedCornerShape(16.dp) shape = RoundedCornerShape(16.dp)
) { ) {

View File

@ -0,0 +1,22 @@
package com.zaneschepke.wireguardautotunnel.ui.common.screen
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun LoadingScreen() {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top,
modifier = Modifier.fillMaxSize().focusable().padding()) {
Column(modifier = Modifier.padding(120.dp)) { CircularProgressIndicator() }
}
}

View File

@ -0,0 +1,18 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.config
import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.ui.models.InterfaceProxy
import com.zaneschepke.wireguardautotunnel.ui.models.PeerProxy
import com.zaneschepke.wireguardautotunnel.util.Packages
data class ConfigUiState(
val proxyPeers: List<PeerProxy> = arrayListOf(PeerProxy()),
val interfaceProxy: InterfaceProxy = InterfaceProxy(),
val packages: Packages = emptyList(),
val checkedPackageNames: List<String> = emptyList(),
val include: Boolean = true,
val isAllApplicationsEnabled : Boolean = false,
val loading: Boolean = true,
val tunnel: TunnelConfig? = null,
val tunnelName: String = ""
)

View File

@ -5,8 +5,6 @@ import android.app.Application
import android.content.pm.PackageInfo import android.content.pm.PackageInfo
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.toMutableStateList
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.wireguard.config.Config import com.wireguard.config.Config
@ -14,426 +12,301 @@ import com.wireguard.config.Interface
import com.wireguard.config.Peer import com.wireguard.config.Peer
import com.wireguard.crypto.Key import com.wireguard.crypto.Key
import com.wireguard.crypto.KeyPair import com.wireguard.crypto.KeyPair
import com.zaneschepke.wireguardautotunnel.Constants import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.repository.TunnelConfigDao import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepository
import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig import com.zaneschepke.wireguardautotunnel.data.repository.TunnelConfigRepository
import com.zaneschepke.wireguardautotunnel.ui.models.InterfaceProxy import com.zaneschepke.wireguardautotunnel.ui.models.InterfaceProxy
import com.zaneschepke.wireguardautotunnel.ui.models.PeerProxy import com.zaneschepke.wireguardautotunnel.ui.models.PeerProxy
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.Event
import com.zaneschepke.wireguardautotunnel.util.NumberUtils import com.zaneschepke.wireguardautotunnel.util.NumberUtils
import com.zaneschepke.wireguardautotunnel.util.WgTunnelException import com.zaneschepke.wireguardautotunnel.util.Result
import com.zaneschepke.wireguardautotunnel.util.removeAt
import com.zaneschepke.wireguardautotunnel.util.update
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel @HiltViewModel
class ConfigViewModel class ConfigViewModel
@Inject @Inject
constructor( constructor(
private val application: Application, private val application: Application,
private val tunnelRepo: TunnelConfigDao, private val tunnelConfigRepository: TunnelConfigRepository,
private val settingsRepo: SettingsDoa private val settingsRepository: SettingsRepository,
) : ViewModel() { ) : ViewModel() {
private val _tunnel = MutableStateFlow<TunnelConfig?>(null)
private val _tunnelName = MutableStateFlow("")
val tunnelName get() = _tunnelName.asStateFlow()
val tunnel get() = _tunnel.asStateFlow()
private var _proxyPeers = MutableStateFlow(mutableStateListOf<PeerProxy>()) private val packageManager = application.packageManager
val proxyPeers get() = _proxyPeers.asStateFlow()
private var _interface = MutableStateFlow(InterfaceProxy()) private val _uiState = MutableStateFlow(ConfigUiState())
val interfaceProxy = _interface.asStateFlow() val uiState = _uiState.asStateFlow()
private val _packages = MutableStateFlow(emptyList<PackageInfo>()) fun init(tunnelId : String) = viewModelScope.launch(Dispatchers.IO) {
val packages get() = _packages.asStateFlow() val packages = getQueriedPackages("")
private val packageManager = application.packageManager val state = if(tunnelId != Constants.MANUAL_TUNNEL_CONFIG_ID) {
private val _checkedPackages = MutableStateFlow(mutableStateListOf<String>())
val checkedPackages get() = _checkedPackages.asStateFlow()
private val _include = MutableStateFlow(true)
val include get() = _include.asStateFlow()
private val _isAllApplicationsEnabled = MutableStateFlow(false)
val isAllApplicationsEnabled get() = _isAllApplicationsEnabled.asStateFlow()
private val _isDefaultTunnel = MutableStateFlow(false)
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"
)
emitScreenData()
} else {
emitEmptyScreenData()
}
}
private fun emitEmptyScreenData() {
tunnelConfig = TunnelConfig(name = NumberUtils.generateRandomTunnelName(), wgQuick = "")
viewModelScope.launch {
emitTunnelConfig()
emitPeerProxy(PeerProxy())
emitInterfaceProxy(InterfaceProxy())
emitTunnelConfigName()
emitDefaultTunnelStatus()
emitQueriedPackages("")
emitTunnelAllApplicationsEnabled()
}
}
private suspend fun emitScreenData() {
emitTunnelConfig()
emitPeersFromConfig()
emitInterfaceFromConfig()
emitTunnelConfigName()
emitDefaultTunnelStatus()
emitQueriedPackages("")
emitCurrentPackageConfigurations()
}
private suspend fun emitDefaultTunnelStatus() {
val settings = settingsRepo.getAll()
if (settings.isNotEmpty()) {
_isDefaultTunnel.value = settings.first().isTunnelConfigDefault(tunnelConfig)
}
}
private fun emitInterfaceFromConfig() {
val config = TunnelConfig.configFromQuick(tunnelConfig.wgQuick)
_interface.value = InterfaceProxy.from(config.`interface`)
}
private fun emitPeersFromConfig() {
val config = TunnelConfig.configFromQuick(tunnelConfig.wgQuick)
config.peers.forEach {
_proxyPeers.value.add(PeerProxy.from(it))
}
}
private fun emitPeerProxy(peerProxy: PeerProxy) {
_proxyPeers.value.add(peerProxy)
}
private fun emitInterfaceProxy(interfaceProxy: InterfaceProxy) {
_interface.value = interfaceProxy
}
private suspend fun getTunnelConfigById(id: String): TunnelConfig? {
return try {
tunnelRepo.getById(id.toLong())
} catch (_: Exception) {
null
}
}
private suspend fun emitTunnelConfig() {
_tunnel.emit(tunnelConfig)
}
private suspend fun emitTunnelConfigName() {
_tunnelName.emit(tunnelConfig.name)
}
fun onTunnelNameChange(name: String) {
_tunnelName.value = name
}
fun onIncludeChange(include: Boolean) {
_include.value = include
}
fun onAddCheckedPackage(packageName: String) {
_checkedPackages.value.add(packageName)
}
fun onAllApplicationsChange(isAllApplicationsEnabled: Boolean) {
_isAllApplicationsEnabled.value = isAllApplicationsEnabled
}
fun onRemoveCheckedPackage(packageName: String) {
_checkedPackages.value.remove(packageName)
}
private suspend fun emitSplitTunnelConfiguration(config: Config) {
val excludedApps = config.`interface`.excludedApplications
val includedApps = config.`interface`.includedApplications
if (excludedApps.isNotEmpty() || includedApps.isNotEmpty()) {
emitTunnelAllApplicationsDisabled()
determineAppInclusionState(excludedApps, includedApps)
} else {
emitTunnelAllApplicationsEnabled()
}
}
private suspend fun determineAppInclusionState(
excludedApps: Set<String>,
includedApps: Set<String>
) {
if (excludedApps.isEmpty()) {
emitIncludedAppsExist()
emitCheckedApps(includedApps)
} else {
emitExcludedAppsExist()
emitCheckedApps(excludedApps)
}
}
private suspend fun emitIncludedAppsExist() {
_include.emit(true)
}
private suspend fun emitExcludedAppsExist() {
_include.emit(false)
}
private suspend fun emitCheckedApps(apps: Set<String>) {
_checkedPackages.emit(apps.toMutableStateList())
}
private suspend fun emitTunnelAllApplicationsEnabled() {
_isAllApplicationsEnabled.emit(true)
}
private suspend fun emitTunnelAllApplicationsDisabled() {
_isAllApplicationsEnabled.emit(false)
}
private fun emitCurrentPackageConfigurations() {
viewModelScope.launch(Dispatchers.IO) {
val config = TunnelConfig.configFromQuick(tunnelConfig.wgQuick)
emitSplitTunnelConfiguration(config)
}
}
fun emitQueriedPackages(query: String) {
viewModelScope.launch(Dispatchers.IO) {
val packages =
getAllInternetCapablePackages().filter {
getPackageLabel(it).lowercase().contains(query.lowercase())
}
_packages.emit(packages)
}
}
fun getPackageLabel(packageInfo: PackageInfo): String {
return packageInfo.applicationInfo.loadLabel(application.packageManager).toString()
}
private fun getAllInternetCapablePackages(): List<PackageInfo> {
return getPackagesHoldingPermissions(arrayOf(Manifest.permission.INTERNET))
}
private fun getPackagesHoldingPermissions(permissions: Array<String>): List<PackageInfo> {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
packageManager.getPackagesHoldingPermissions(
permissions,
PackageManager.PackageInfoFlags.of(0L)
)
} else {
packageManager.getPackagesHoldingPermissions(permissions, 0)
}
}
private fun isAllApplicationsEnabled(): Boolean {
return _isAllApplicationsEnabled.value
}
private suspend fun saveConfig(tunnelConfig: TunnelConfig) {
tunnelRepo.save(tunnelConfig)
}
private suspend fun updateTunnelConfig(tunnelConfig: TunnelConfig?) {
if (tunnelConfig != null) {
saveConfig(tunnelConfig)
updateSettingsDefaultTunnel(tunnelConfig)
}
}
private suspend fun updateSettingsDefaultTunnel(tunnelConfig: TunnelConfig) {
val settings = settingsRepo.getAll()
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()
)
)
}
}
}
}
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()
)
}
builder.build()
}
}
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)
return builder.build()
}
suspend fun onSaveAllChanges() {
try {
val peerList = buildPeerListFromProxyPeers()
val wgInterface = buildInterfaceListFromProxyInterface()
val config = Config.Builder().addPeers(peerList).setInterface(wgInterface).build()
val tunnelConfig = val tunnelConfig =
_tunnel.value?.copy( tunnelConfigRepository.getAll().firstOrNull { it.id.toString() == tunnelId }
name = _tunnelName.value, if (tunnelConfig != null) {
wgQuick = config.toWgQuickString() val config = TunnelConfig.configFromQuick(tunnelConfig.wgQuick)
) val proxyPeers = config.peers.map { PeerProxy.from(it) }
updateTunnelConfig(tunnelConfig) val proxyInterface = InterfaceProxy.from(config.`interface`)
} catch (e: Exception) { var include = true
throw WgTunnelException( var isAllApplicationsEnabled = false
"Error: ${e.cause?.message?.lowercase() ?: "unknown error occurred"}" val checkedPackages =
) if (config.`interface`.includedApplications.isNotEmpty()) {
} config.`interface`.includedApplications
} } else if (config.`interface`.excludedApplications.isNotEmpty()) {
include = false
fun onPeerPublicKeyChange( config.`interface`.excludedApplications
index: Int, } else {
publicKey: String isAllApplicationsEnabled = true
) { emptySet()
_proxyPeers.value[index] = }
_proxyPeers.value[index].copy( ConfigUiState(
publicKey = publicKey proxyPeers,
) proxyInterface,
} packages,
checkedPackages.toList(),
fun onPreSharedKeyChange( include,
index: Int, isAllApplicationsEnabled,
value: String false,
) { tunnelConfig,
_proxyPeers.value[index] = tunnelConfig.name)
_proxyPeers.value[index].copy( } else {
preSharedKey = value ConfigUiState(loading = false, packages = packages)
) }
}
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 onPersistentKeepaliveChanged(
index: Int,
value: String
) {
_proxyPeers.value[index] =
_proxyPeers.value[index].copy(
persistentKeepalive = value
)
}
fun onDeletePeer(index: Int) {
proxyPeers.value.removeAt(index)
}
fun addEmptyPeer() {
_proxyPeers.value.add(PeerProxy())
}
fun generateKeyPair() {
val keyPair = KeyPair()
_interface.value =
_interface.value.copy(
privateKey = keyPair.privateKey.toBase64(),
publicKey = keyPair.publicKey.toBase64()
)
}
fun onAddressesChanged(value: String) {
_interface.value =
_interface.value.copy(
addresses = value
)
}
fun onListenPortChanged(value: String) {
_interface.value =
_interface.value.copy(
listenPort = value
)
}
fun onDnsServersChanged(value: String) {
_interface.value =
_interface.value.copy(
dnsServers = value
)
}
fun onMtuChanged(value: String) {
_interface.value =
_interface.value.copy(
mtu = 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)) {
val pair = KeyPair(Key.fromBase64(value))
onInterfacePublicKeyChange(pair.publicKey.toBase64())
} else { } else {
onInterfacePublicKeyChange("") ConfigUiState(loading = false, packages = packages)
} }
_uiState.value = state
} }
fun onTunnelNameChange(name: String) {
_uiState.value = _uiState.value.copy(tunnelName = name)
}
fun onIncludeChange(include: Boolean) {
_uiState.value = _uiState.value.copy(include = include)
}
fun onAddCheckedPackage(packageName: String) {
_uiState.value =
_uiState.value.copy(checkedPackageNames = _uiState.value.checkedPackageNames + packageName)
}
fun onAllApplicationsChange(isAllApplicationsEnabled: Boolean) {
_uiState.value = _uiState.value.copy(isAllApplicationsEnabled = isAllApplicationsEnabled)
}
fun onRemoveCheckedPackage(packageName: String) {
_uiState.value =
_uiState.value.copy(checkedPackageNames = _uiState.value.checkedPackageNames - packageName)
}
private fun getQueriedPackages(query: String): List<PackageInfo> {
return getAllInternetCapablePackages().filter {
getPackageLabel(it).lowercase().contains(query.lowercase())
}
}
fun getPackageLabel(packageInfo: PackageInfo): String {
return packageInfo.applicationInfo.loadLabel(application.packageManager).toString()
}
private fun getAllInternetCapablePackages(): List<PackageInfo> {
return getPackagesHoldingPermissions(arrayOf(Manifest.permission.INTERNET))
}
private fun getPackagesHoldingPermissions(permissions: Array<String>): List<PackageInfo> {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
packageManager.getPackagesHoldingPermissions(
permissions, PackageManager.PackageInfoFlags.of(0L))
} else {
packageManager.getPackagesHoldingPermissions(permissions, 0)
}
}
private fun isAllApplicationsEnabled(): Boolean {
return _uiState.value.isAllApplicationsEnabled
}
private fun saveConfig(tunnelConfig: TunnelConfig) =
viewModelScope.launch {
tunnelConfigRepository.save(tunnelConfig)
}
private fun updateTunnelConfig(tunnelConfig: TunnelConfig?) =
viewModelScope.launch {
if (tunnelConfig != null) {
saveConfig(tunnelConfig).join()
WireGuardAutoTunnel.requestTileServiceStateUpdate()
updateSettingsDefaultTunnel(tunnelConfig)
}
}
private suspend fun updateSettingsDefaultTunnel(tunnelConfig: TunnelConfig) {
val settings = settingsRepository.getSettingsFlow().first()
if (settings.defaultTunnel != null) {
if (tunnelConfig.id == TunnelConfig.from(settings.defaultTunnel!!).id) {
settingsRepository.save(settings.copy(defaultTunnel = tunnelConfig.toString()))
}
}
}
private fun buildPeerListFromProxyPeers(): List<Peer> {
return _uiState.value.proxyPeers.map {
val builder = Peer.Builder()
if (it.allowedIps.isNotEmpty()) builder.parseAllowedIPs(it.allowedIps.trim())
if (it.publicKey.isNotEmpty()) builder.parsePublicKey(it.publicKey.trim())
if (it.preSharedKey.isNotEmpty()) builder.parsePreSharedKey(it.preSharedKey.trim())
if (it.endpoint.isNotEmpty()) builder.parseEndpoint(it.endpoint.trim())
if (it.persistentKeepalive.isNotEmpty()) {
builder.parsePersistentKeepalive(it.persistentKeepalive.trim())
}
builder.build()
}
}
private fun emptyCheckedPackagesList() {
_uiState.value = _uiState.value.copy(checkedPackageNames = emptyList())
}
private fun buildInterfaceListFromProxyInterface(): Interface {
val builder = Interface.Builder()
builder.parsePrivateKey(_uiState.value.interfaceProxy.privateKey.trim())
builder.parseAddresses(_uiState.value.interfaceProxy.addresses.trim())
builder.parseDnsServers(_uiState.value.interfaceProxy.dnsServers.trim())
if (_uiState.value.interfaceProxy.mtu.isNotEmpty())
builder.parseMtu(_uiState.value.interfaceProxy.mtu.trim())
if (_uiState.value.interfaceProxy.listenPort.isNotEmpty()) {
builder.parseListenPort(_uiState.value.interfaceProxy.listenPort.trim())
}
if (isAllApplicationsEnabled()) emptyCheckedPackagesList()
if (_uiState.value.include) builder.includeApplications(_uiState.value.checkedPackageNames)
if (!_uiState.value.include) builder.excludeApplications(_uiState.value.checkedPackageNames)
return builder.build()
}
fun onSaveAllChanges(): Result<Event> {
return try {
val peerList = buildPeerListFromProxyPeers()
val wgInterface = buildInterfaceListFromProxyInterface()
val config = Config.Builder().addPeers(peerList).setInterface(wgInterface).build()
val tunnelConfig =
_uiState.value.tunnel?.copy(
name = _uiState.value.tunnelName, wgQuick = config.toWgQuickString())
updateTunnelConfig(tunnelConfig)
Result.Success(Event.Message.ConfigSaved)
} catch (e: Exception) {
Result.Error(Event.Error.Exception(e))
}
}
fun onPeerPublicKeyChange(index: Int, value: String) {
_uiState.value =
_uiState.value.copy(
proxyPeers =
_uiState.value.proxyPeers.update(
index, _uiState.value.proxyPeers[index].copy(publicKey = value)))
}
fun onPreSharedKeyChange(index: Int, value: String) {
_uiState.value =
_uiState.value.copy(
proxyPeers =
_uiState.value.proxyPeers.update(
index, _uiState.value.proxyPeers[index].copy(preSharedKey = value)))
}
fun onEndpointChange(index: Int, value: String) {
_uiState.value =
_uiState.value.copy(
proxyPeers =
_uiState.value.proxyPeers.update(
index, _uiState.value.proxyPeers[index].copy(endpoint = value)))
}
fun onAllowedIpsChange(index: Int, value: String) {
_uiState.value =
_uiState.value.copy(
proxyPeers =
_uiState.value.proxyPeers.update(
index, _uiState.value.proxyPeers[index].copy(allowedIps = value)))
}
fun onPersistentKeepaliveChanged(index: Int, value: String) {
_uiState.value =
_uiState.value.copy(
proxyPeers =
_uiState.value.proxyPeers.update(
index, _uiState.value.proxyPeers[index].copy(persistentKeepalive = value)))
}
fun onDeletePeer(index: Int) {
_uiState.value = _uiState.value.copy(
proxyPeers = _uiState.value.proxyPeers.removeAt(index)
)
}
fun addEmptyPeer() {
_uiState.value = _uiState.value.copy(proxyPeers = _uiState.value.proxyPeers + PeerProxy())
}
fun generateKeyPair() {
val keyPair = KeyPair()
_uiState.value =
_uiState.value.copy(
interfaceProxy =
_uiState.value.interfaceProxy.copy(
privateKey = keyPair.privateKey.toBase64(),
publicKey = keyPair.publicKey.toBase64()))
}
fun onAddressesChanged(value: String) {
_uiState.value =
_uiState.value.copy(interfaceProxy = _uiState.value.interfaceProxy.copy(addresses = value))
}
fun onListenPortChanged(value: String) {
_uiState.value =
_uiState.value.copy(interfaceProxy = _uiState.value.interfaceProxy.copy(listenPort = value))
}
fun onDnsServersChanged(value: String) {
_uiState.value =
_uiState.value.copy(interfaceProxy = _uiState.value.interfaceProxy.copy(dnsServers = value))
}
fun onMtuChanged(value: String) {
_uiState.value =
_uiState.value.copy(interfaceProxy = _uiState.value.interfaceProxy.copy(mtu = value))
}
private fun onInterfacePublicKeyChange(value: String) {
_uiState.value =
_uiState.value.copy(interfaceProxy = _uiState.value.interfaceProxy.copy(publicKey = value))
}
fun onPrivateKeyChange(value: String) {
_uiState.value =
_uiState.value.copy(interfaceProxy = _uiState.value.interfaceProxy.copy(privateKey = value))
if (NumberUtils.isValidKey(value)) {
val pair = KeyPair(Key.fromBase64(value))
onInterfacePublicKeyChange(pair.publicKey.toBase64())
} else {
onInterfacePublicKeyChange("")
}
}
fun emitQueriedPackages(query: String) {
val packages =
getAllInternetCapablePackages().filter {
getPackageLabel(it).lowercase().contains(query.lowercase())
}
_uiState.value = _uiState.value.copy(packages = packages)
}
} }

View File

@ -8,27 +8,36 @@ import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable import androidx.compose.foundation.focusable
import androidx.compose.foundation.gestures.ScrollableDefaults
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredWidth
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.overscroll
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Create import androidx.compose.material.icons.filled.Create
import androidx.compose.material.icons.filled.FileOpen import androidx.compose.material.icons.filled.FileOpen
import androidx.compose.material.icons.filled.QrCode import androidx.compose.material.icons.filled.QrCode
import androidx.compose.material.icons.rounded.Add import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.Bolt
import androidx.compose.material.icons.rounded.Circle import androidx.compose.material.icons.rounded.Circle
import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.Edit import androidx.compose.material.icons.rounded.Edit
@ -42,14 +51,18 @@ import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MaterialTheme.typography
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@ -61,13 +74,10 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@ -79,526 +89,474 @@ import androidx.navigation.NavController
import com.journeyapps.barcodescanner.ScanContract import com.journeyapps.barcodescanner.ScanContract
import com.journeyapps.barcodescanner.ScanOptions import com.journeyapps.barcodescanner.ScanOptions
import com.wireguard.android.backend.Tunnel import com.wireguard.android.backend.Tunnel
import com.zaneschepke.wireguardautotunnel.Constants
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.service.tunnel.HandshakeStatus import com.zaneschepke.wireguardautotunnel.service.tunnel.HandshakeStatus
import com.zaneschepke.wireguardautotunnel.ui.CaptureActivityPortrait import com.zaneschepke.wireguardautotunnel.ui.CaptureActivityPortrait
import com.zaneschepke.wireguardautotunnel.ui.Routes import com.zaneschepke.wireguardautotunnel.ui.Screen
import com.zaneschepke.wireguardautotunnel.ui.common.RowListItem import com.zaneschepke.wireguardautotunnel.ui.common.RowListItem
import com.zaneschepke.wireguardautotunnel.ui.theme.brickRed import com.zaneschepke.wireguardautotunnel.ui.common.screen.LoadingScreen
import com.zaneschepke.wireguardautotunnel.ui.theme.corn import com.zaneschepke.wireguardautotunnel.ui.theme.corn
import com.zaneschepke.wireguardautotunnel.ui.theme.mint import com.zaneschepke.wireguardautotunnel.ui.theme.mint
import com.zaneschepke.wireguardautotunnel.util.WgTunnelException import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.Event
import com.zaneschepke.wireguardautotunnel.util.Result
import com.zaneschepke.wireguardautotunnel.util.handshakeStatus
import com.zaneschepke.wireguardautotunnel.util.mapPeerStats
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable @Composable
fun MainScreen( fun MainScreen(
viewModel: MainViewModel = hiltViewModel(), viewModel: MainViewModel = hiltViewModel(),
padding: PaddingValues, padding: PaddingValues,
focusRequester: FocusRequester,
showSnackbarMessage: (String) -> Unit, showSnackbarMessage: (String) -> Unit,
navController: NavController navController: NavController
) { ) {
val haptic = LocalHapticFeedback.current val haptic = LocalHapticFeedback.current
val context = LocalContext.current val context = LocalContext.current
val isVisible = rememberSaveable { mutableStateOf(true) } val isVisible = rememberSaveable { mutableStateOf(true) }
val scope = rememberCoroutineScope { Dispatchers.IO } val scope = rememberCoroutineScope { Dispatchers.IO }
val sheetState = rememberModalBottomSheetState() val sheetState = rememberModalBottomSheetState()
var showBottomSheet by remember { mutableStateOf(false) } var showBottomSheet by remember { mutableStateOf(false) }
var showPrimaryChangeAlertDialog by remember { mutableStateOf(false) }
val tunnels by viewModel.tunnels.collectAsStateWithLifecycle(mutableListOf())
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("")
val settings by viewModel.settings.collectAsStateWithLifecycle()
val statistics by viewModel.statistics.collectAsStateWithLifecycle(null)
// Nested scroll for control FAB var showPrimaryChangeAlertDialog by remember { mutableStateOf(false) }
val nestedScrollConnection = var selectedTunnel by remember { mutableStateOf<TunnelConfig?>(null) }
remember { val uiState by viewModel.uiState.collectAsStateWithLifecycle()
object : NestedScrollConnection {
override fun onPreScroll( LaunchedEffect(uiState.loading) {
available: Offset, if (!uiState.loading && WireGuardAutoTunnel.isRunningOnAndroidTv()) {
source: NestedScrollSource delay(Constants.FOCUS_REQUEST_DELAY)
): Offset { focusRequester.requestFocus()
// Hide FAB }
if (available.y < -1) { }
isVisible.value = false
} if (uiState.loading) {
// Show FAB LoadingScreen()
if (available.y > 1) { return
isVisible.value = true }
}
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)
}) {
showSnackbarMessage(Event.Error.FileExplorerRequired.message)
}
return intent
} }
} }) { data ->
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 if (data == null) return@rememberLauncherForActivityResult
scope.launch(Dispatchers.IO) { scope.launch {
try { viewModel.onTunnelFileSelected(data).let {
viewModel.onTunnelFileSelected(data) when (it) {
} catch (e: WgTunnelException) { is Result.Error -> showSnackbarMessage(it.error.message)
showSnackbarMessage(e.message) is Result.Success -> {}
} }
}
} }
} }
val scanLauncher =
val scanLauncher = rememberLauncherForActivityResult(
rememberLauncherForActivityResult( contract = ScanContract(),
contract = ScanContract(), onResult = {
onResult = { if (it.contents != null) {
scope.launch { scope.launch {
try { viewModel.onTunnelQrResult(it.contents).let { result ->
viewModel.onTunnelQrResult(it.contents) when (result) {
} catch (e: Exception) { is Result.Success -> {}
when (e) { is Result.Error -> showSnackbarMessage(result.error.message)
is WgTunnelException -> { }
showSnackbarMessage(e.message)
}
else -> {
showSnackbarMessage("No QR code scanned")
}
}
}
} }
}
} }
) })
if (showPrimaryChangeAlertDialog) { AnimatedVisibility(showPrimaryChangeAlertDialog) {
AlertDialog( AlertDialog(
onDismissRequest = { onDismissRequest = { showPrimaryChangeAlertDialog = false },
confirmButton = {
TextButton(
onClick = {
viewModel.onDefaultTunnelChange(selectedTunnel)
showPrimaryChangeAlertDialog = false showPrimaryChangeAlertDialog = false
},
confirmButton = {
TextButton(onClick = {
scope.launch {
viewModel.onDefaultTunnelChange(selectedTunnel)
showPrimaryChangeAlertDialog = false
selectedTunnel = null
}
}) { Text(text = stringResource(R.string.okay)) }
},
dismissButton = {
TextButton(onClick = {
showPrimaryChangeAlertDialog = false
}) { Text(text = stringResource(R.string.cancel)) }
},
title = { Text(text = stringResource(R.string.primary_tunnel_change)) },
text = { Text(text = stringResource(R.string.primary_tunnel_change_question)) }
)
}
fun onTunnelToggle(
checked: Boolean,
tunnel: TunnelConfig
) {
try {
if (checked) viewModel.onTunnelStart(tunnel) else viewModel.onTunnelStop()
} catch (e: Exception) {
showSnackbarMessage(e.message!!)
}
}
Scaffold(
modifier =
Modifier.pointerInput(Unit) {
detectTapGestures(onTap = {
selectedTunnel = null selectedTunnel = null
}) }) {
Text(text = stringResource(R.string.okay))
}
}, },
floatingActionButtonPosition = FabPosition.End, dismissButton = {
floatingActionButton = { TextButton(onClick = { showPrimaryChangeAlertDialog = false }) {
AnimatedVisibility( Text(text = stringResource(R.string.cancel))
visible = isVisible.value, }
enter = slideInVertically(initialOffsetY = { it * 2 }), },
exit = slideOutVertically(targetOffsetY = { it * 2 }) title = { Text(text = stringResource(R.string.primary_tunnel_change)) },
) { text = { Text(text = stringResource(R.string.primary_tunnel_change_question)) })
val secondaryColor = MaterialTheme.colorScheme.secondary }
val hoverColor = MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp)
var fobColor by remember { mutableStateOf(secondaryColor) } fun onTunnelToggle(checked: Boolean, tunnel: TunnelConfig) {
FloatingActionButton( if (checked) viewModel.onTunnelStart(tunnel) else viewModel.onTunnelStop()
modifier = }
Modifier
.padding(bottom = 90.dp) Scaffold(
.onFocusChanged { modifier =
if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) { Modifier.pointerInput(Unit) {
fobColor = if (it.isFocused) hoverColor else secondaryColor detectTapGestures(
onTap = {
if (!WireGuardAutoTunnel.isRunningOnAndroidTv()) selectedTunnel = null
})
},
floatingActionButtonPosition = FabPosition.End,
topBar = {
if (uiState.settings.isAutoTunnelEnabled)
TopAppBar(
title = {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.requiredWidth(LocalConfiguration.current.screenWidthDp.dp).padding(end = 5.dp)) {
Row {
Icon(
Icons.Rounded.Bolt,
stringResource(id = R.string.auto),
modifier = Modifier.size(25.dp),
tint = if(uiState.settings.isAutoTunnelPaused) Color.Gray else mint)
Text(
"Auto-tunneling: ${if(uiState.settings.isAutoTunnelPaused) "paused" else "active" }",
style = typography.bodyLarge,
modifier = Modifier.padding(start = 10.dp))
}
if(uiState.settings.isAutoTunnelPaused) TextButton(
onClick = { viewModel.resumeAutoTunneling() },
modifier = Modifier.padding(end = 10.dp)) {
Text("Resume")
} else TextButton(
onClick = { viewModel.pauseAutoTunneling() },
modifier = Modifier.padding(end = 10.dp)) {
Text("Pause")
}
}
},
)
},
floatingActionButton = {
AnimatedVisibility(
visible = isVisible.value,
enter = slideInVertically(initialOffsetY = { it * 2 }),
exit = slideOutVertically(targetOffsetY = { it * 2 })) {
val secondaryColor = MaterialTheme.colorScheme.secondary
val hoverColor = MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp)
var fobColor by remember { mutableStateOf(secondaryColor) }
FloatingActionButton(
modifier =
(if (WireGuardAutoTunnel.isRunningOnAndroidTv() &&
uiState.tunnels.isEmpty())
Modifier.focusRequester(focusRequester)
else Modifier)
.padding(bottom = 90.dp)
.onFocusChanged {
if (WireGuardAutoTunnel.isRunningOnAndroidTv()) {
fobColor = if (it.isFocused) hoverColor else secondaryColor
} }
}, },
onClick = { onClick = { showBottomSheet = true },
showBottomSheet = true containerColor = fobColor,
}, shape = RoundedCornerShape(16.dp)) {
containerColor = fobColor,
shape = RoundedCornerShape(16.dp)
) {
Icon( Icon(
imageVector = Icons.Rounded.Add, imageVector = Icons.Rounded.Add,
contentDescription = stringResource(id = R.string.add_tunnel), contentDescription = stringResource(id = R.string.add_tunnel),
tint = Color.DarkGray tint = Color.DarkGray)
) }
}
} }
} }) { innerPadding ->
) { AnimatedVisibility(uiState.tunnels.isEmpty(), exit = fadeOut(), enter = fadeIn()) {
if (tunnels.isEmpty()) { Column(
Column( horizontalAlignment = Alignment.CenterHorizontally,
horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center,
verticalArrangement = Arrangement.Center, modifier = Modifier.fillMaxSize().padding(padding)) {
modifier =
Modifier
.fillMaxSize()
.padding(padding)
) {
Text(text = stringResource(R.string.no_tunnels), fontStyle = FontStyle.Italic) Text(text = stringResource(R.string.no_tunnels), fontStyle = FontStyle.Italic)
} }
} }
if (showBottomSheet) { if (showBottomSheet) {
ModalBottomSheet( ModalBottomSheet(
onDismissRequest = { onDismissRequest = { showBottomSheet = false }, sheetState = sheetState) {
showBottomSheet = false
},
sheetState = sheetState
) {
// Sheet content // Sheet content
Row( Row(
modifier = modifier =
Modifier Modifier.fillMaxWidth()
.fillMaxWidth()
.clickable {
showBottomSheet = false
try {
tunnelFileImportResultLauncher.launch(Constants.ALLOWED_FILE_TYPES)
} catch (e: Exception) {
showSnackbarMessage(e.message!!)
}
}
.padding(10.dp)
) {
Icon(
Icons.Filled.FileOpen,
contentDescription = stringResource(id = R.string.open_file),
modifier = Modifier.padding(10.dp)
)
Text(
stringResource(id = R.string.add_tunnels_text),
modifier = Modifier.padding(10.dp)
)
}
if (!WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
Divider()
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable { .clickable {
scope.launch { showBottomSheet = false
showBottomSheet = false tunnelFileImportResultLauncher.launch(Constants.ALLOWED_FILE_TYPES)
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.FileOpen,
contentDescription = stringResource(id = R.string.open_file),
modifier = Modifier.padding(10.dp))
Text(
stringResource(id = R.string.add_tunnels_text),
modifier = Modifier.padding(10.dp))
}
if (!WireGuardAutoTunnel.isRunningOnAndroidTv()) {
Divider()
Row(
modifier =
Modifier.fillMaxWidth()
.clickable {
scope.launch {
showBottomSheet = false
val scanOptions = ScanOptions()
scanOptions.setDesiredBarcodeFormats(ScanOptions.QR_CODE)
scanOptions.setOrientationLocked(true)
scanOptions.setPrompt(context.getString(R.string.scanning_qr))
scanOptions.setBeepEnabled(false)
scanOptions.captureActivity = CaptureActivityPortrait::class.java
scanLauncher.launch(scanOptions)
}
}
.padding(10.dp)) {
Icon( Icon(
Icons.Filled.QrCode, Icons.Filled.QrCode,
contentDescription = stringResource(id = R.string.qr_scan), contentDescription = stringResource(id = R.string.qr_scan),
modifier = Modifier.padding(10.dp) modifier = Modifier.padding(10.dp))
)
Text( Text(
stringResource(id = R.string.add_from_qr), stringResource(id = R.string.add_from_qr),
modifier = Modifier.padding(10.dp) modifier = Modifier.padding(10.dp))
) }
}
} }
Divider() Divider()
Row( Row(
modifier = modifier =
Modifier Modifier.fillMaxWidth()
.fillMaxWidth() .clickable {
.clickable { showBottomSheet = false
showBottomSheet = false navController.navigate(
navController.navigate( "${Screen.Config.route}/${Constants.MANUAL_TUNNEL_CONFIG_ID}")
"${Routes.Config.name}/${Constants.MANUAL_TUNNEL_CONFIG_ID}" }
) .padding(10.dp)) {
} Icon(
.padding(10.dp) Icons.Filled.Create,
) { contentDescription = stringResource(id = R.string.create_import),
Icon( modifier = Modifier.padding(10.dp))
Icons.Filled.Create, Text(
contentDescription = stringResource(id = R.string.create_import), stringResource(id = R.string.create_import),
modifier = Modifier.padding(10.dp) modifier = Modifier.padding(10.dp))
) }
Text( }
stringResource(id = R.string.create_import),
modifier = Modifier.padding(10.dp)
)
}
}
} }
Column(
LazyColumn(
horizontalAlignment = Alignment.Start, horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Top, verticalArrangement = Arrangement.Top,
modifier = modifier =
Modifier Modifier.fillMaxWidth().fillMaxHeight(.90f).overscroll(ScrollableDefaults.overscrollEffect()).padding(innerPadding),
.fillMaxSize() state = rememberLazyListState(0, uiState.tunnels.count()),
.padding(padding) userScrollEnabled = true,
) { reverseLayout = true,
LazyColumn( flingBehavior = ScrollableDefaults.flingBehavior()) {
modifier = items(uiState.tunnels,
Modifier key = { tunnel -> tunnel.id }) { tunnel ->
.fillMaxSize() val leadingIconColor =
.padding(top = 10.dp) (if (uiState.vpnState.name == tunnel.name &&
.nestedScroll(nestedScrollConnection) uiState.vpnState.status == Tunnel.State.UP) {
) { uiState.vpnState.statistics
items(tunnels, key = { tunnel -> tunnel.id }) { tunnel -> ?.mapPeerStats()
val leadingIconColor = ( ?.map { it.value?.handshakeStatus() }
if (tunnelName == tunnel.name) { .let { statuses ->
when (handshakeStatus) { when {
HandshakeStatus.HEALTHY -> mint statuses?.all { it == HandshakeStatus.HEALTHY } == true -> mint
HandshakeStatus.UNHEALTHY -> brickRed statuses?.any { it == HandshakeStatus.STALE } == true -> corn
HandshakeStatus.STALE -> corn statuses?.all { it == HandshakeStatus.NOT_STARTED } == true ->
HandshakeStatus.NOT_STARTED -> Color.Gray Color.Gray
HandshakeStatus.NEVER_CONNECTED -> brickRed else -> {
Color.Gray
}
} }
} else { }
Color.Gray } else {
} Color.Gray
) })
val focusRequester = remember { FocusRequester() } val expanded = remember { mutableStateOf(false) }
val expanded =
remember {
mutableStateOf(false)
}
RowListItem( RowListItem(
icon = { icon = {
if (settings.isTunnelConfigDefault(tunnel)) { if (uiState.settings.isTunnelConfigDefault(tunnel)) {
Icon( Icon(
Icons.Rounded.Star, Icons.Rounded.Star,
stringResource(R.string.status), stringResource(R.string.status),
tint = leadingIconColor, tint = leadingIconColor,
modifier = modifier = Modifier.padding(end = 10.dp).size(20.dp))
Modifier } else {
.padding(end = 10.dp) Icon(
.size(20.dp) Icons.Rounded.Circle,
) stringResource(R.string.status),
} else { tint = leadingIconColor,
Icon( modifier = Modifier.padding(end = 15.dp).size(15.dp))
Icons.Rounded.Circle, }
stringResource(R.string.status),
tint = leadingIconColor,
modifier =
Modifier
.padding(end = 15.dp)
.size(15.dp)
)
}
}, },
text = tunnel.name, text = tunnel.name,
onHold = { onHold = {
if ((state == Tunnel.State.UP) && (tunnel.name == tunnelName)) { if ((uiState.vpnState.status == Tunnel.State.UP) &&
showSnackbarMessage( (tunnel.name == uiState.vpnState.name)) {
context.resources.getString(R.string.turn_off_tunnel) showSnackbarMessage(Event.Message.TunnelOffAction.message)
) return@RowListItem
return@RowListItem }
} haptic.performHapticFeedback(HapticFeedbackType.LongPress)
haptic.performHapticFeedback(HapticFeedbackType.LongPress) selectedTunnel = tunnel
selectedTunnel = tunnel
}, },
onClick = { onClick = {
if (!WireGuardAutoTunnel.isRunningOnAndroidTv(context)) { if (!WireGuardAutoTunnel.isRunningOnAndroidTv()) {
if (state == Tunnel.State.UP && (tunnelName == tunnel.name)) { if (uiState.vpnState.status == Tunnel.State.UP &&
expanded.value = !expanded.value (uiState.vpnState.name == tunnel.name)) {
} expanded.value = !expanded.value
} else {
selectedTunnel = tunnel
focusRequester.requestFocus()
} }
} else {
selectedTunnel = tunnel
focusRequester.requestFocus()
}
}, },
statistics = statistics, statistics = uiState.vpnState.statistics,
expanded = expanded.value, expanded = expanded.value,
rowButton = { rowButton = {
if (tunnel.id == selectedTunnel?.id && !WireGuardAutoTunnel.isRunningOnAndroidTv( if (tunnel.id == selectedTunnel?.id &&
context !WireGuardAutoTunnel.isRunningOnAndroidTv()) {
) Row {
) { if (!uiState.settings.isTunnelConfigDefault(tunnel)) {
Row { IconButton(
if (!settings.isTunnelConfigDefault(tunnel)) { onClick = {
IconButton(onClick = { if (uiState.settings.isAutoTunnelEnabled && !uiState.settings.isAutoTunnelPaused) {
if (settings.isAutoTunnelEnabled) { showSnackbarMessage(
showSnackbarMessage( Event.Message.AutoTunnelOffAction.message)
context.resources.getString( } else {
R.string.turn_off_auto showPrimaryChangeAlertDialog = true
) }
)
} else {
showPrimaryChangeAlertDialog = true
}
}) {
Icon(
Icons.Rounded.Star,
stringResource(id = R.string.set_primary)
)
}
}
IconButton(onClick = {
navController.navigate(
"${Routes.Config.name}/${selectedTunnel?.id}"
)
}) { }) {
Icon(Icons.Rounded.Edit, stringResource(id = R.string.edit)) Icon(
Icons.Rounded.Star,
stringResource(id = R.string.set_primary))
} }
IconButton( }
modifier = Modifier.focusable(), IconButton(
onClick = { viewModel.onDelete(tunnel) } onClick = {
) { if (uiState.settings.isAutoTunnelEnabled && uiState.settings.isTunnelConfigDefault(tunnel)
Icon( && !uiState.settings.isAutoTunnelPaused) {
Icons.Rounded.Delete, showSnackbarMessage(
stringResource(id = R.string.delete) Event.Message.AutoTunnelOffAction.message)
) } else navController.navigate(
} "${Screen.Config.route}/${selectedTunnel?.id}")
} }) {
} else { Icon(Icons.Rounded.Edit, stringResource(id = R.string.edit))
val checked = state == Tunnel.State.UP && tunnel.name == tunnelName }
if (!checked) expanded.value = false IconButton(
modifier = Modifier.focusable(),
@Composable onClick = { viewModel.onDelete(tunnel) }) {
fun TunnelSwitch() = Icon(Icons.Rounded.Delete, stringResource(id = R.string.delete))
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)) {
IconButton(onClick = {
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)
)
}
}
IconButton(
modifier = Modifier.focusRequester(focusRequester),
onClick = {
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) {
showSnackbarMessage(
context.resources.getString(
R.string.turn_off_tunnel
)
)
} else {
navController.navigate(
"${Routes.Config.name}/${tunnel.id}"
)
}
}) {
Icon(
Icons.Rounded.Edit,
stringResource(id = R.string.edit)
)
}
IconButton(onClick = {
if (state == Tunnel.State.UP && tunnel.name == tunnelName) {
showSnackbarMessage(
context.resources.getString(
R.string.turn_off_tunnel
)
)
} else {
viewModel.onDelete(tunnel)
}
}) {
Icon(
Icons.Rounded.Delete,
stringResource(id = R.string.delete)
)
}
TunnelSwitch()
}
} else {
TunnelSwitch()
}
} }
} } else {
) val checked by remember {
} derivedStateOf {
(uiState.vpnState.status == Tunnel.State.UP &&
tunnel.name == uiState.vpnState.name)
}
}
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)
})
if (WireGuardAutoTunnel.isRunningOnAndroidTv()) {
Row {
if (!uiState.settings.isTunnelConfigDefault(tunnel)) {
IconButton(
onClick = {
if (uiState.settings.isAutoTunnelEnabled) {
showSnackbarMessage(
Event.Message.AutoTunnelOffAction.message)
} else {
selectedTunnel = tunnel
showPrimaryChangeAlertDialog = true
}
}) {
Icon(
Icons.Rounded.Star,
stringResource(id = R.string.set_primary))
}
}
IconButton(
modifier = Modifier.focusRequester(focusRequester),
onClick = {
if (uiState.vpnState.status == Tunnel.State.UP &&
(uiState.vpnState.name == tunnel.name)) {
expanded.value = !expanded.value
} else {
showSnackbarMessage(Event.Message.TunnelOnAction.message)
}
}) {
Icon(Icons.Rounded.Info, stringResource(R.string.info))
}
IconButton(
onClick = {
if (uiState.vpnState.status == Tunnel.State.UP &&
tunnel.name == uiState.vpnState.name) {
showSnackbarMessage(Event.Message.TunnelOffAction.message)
} else {
navController.navigate(
"${Screen.Config.route}/${tunnel.id}")
}
}) {
Icon(Icons.Rounded.Edit, stringResource(id = R.string.edit))
}
IconButton(
onClick = {
if (uiState.vpnState.status == Tunnel.State.UP &&
tunnel.name == uiState.vpnState.name) {
showSnackbarMessage(Event.Message.TunnelOffAction.message)
} else {
viewModel.onDelete(tunnel)
}
}) {
Icon(
Icons.Rounded.Delete,
stringResource(id = R.string.delete))
}
TunnelSwitch()
}
} else {
TunnelSwitch()
}
}
})
}
} }
} }
}
} }

View File

@ -0,0 +1,12 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main
import com.zaneschepke.wireguardautotunnel.data.model.Settings
import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnState
import com.zaneschepke.wireguardautotunnel.util.TunnelConfigs
data class MainUiState(
val settings : Settings = Settings(),
val tunnels : TunnelConfigs = emptyList(),
val vpnState: VpnState = VpnState(),
val loading : Boolean = true
)

View File

@ -8,267 +8,254 @@ import android.provider.OpenableColumns
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.wireguard.config.Config import com.wireguard.config.Config
import com.zaneschepke.wireguardautotunnel.Constants import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.data.model.Settings
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.repository.TunnelConfigDao import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepository
import com.zaneschepke.wireguardautotunnel.repository.model.Settings import com.zaneschepke.wireguardautotunnel.data.repository.TunnelConfigRepository
import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceState import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceState
import com.zaneschepke.wireguardautotunnel.service.foreground.WireGuardConnectivityWatcherService import com.zaneschepke.wireguardautotunnel.service.foreground.WireGuardConnectivityWatcherService
import com.zaneschepke.wireguardautotunnel.service.foreground.WireGuardTunnelService import com.zaneschepke.wireguardautotunnel.service.foreground.WireGuardTunnelService
import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService
import com.zaneschepke.wireguardautotunnel.service.tunnel.WireGuardTunnel
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.Event
import com.zaneschepke.wireguardautotunnel.util.NumberUtils import com.zaneschepke.wireguardautotunnel.util.NumberUtils
import com.zaneschepke.wireguardautotunnel.util.WgTunnelException import com.zaneschepke.wireguardautotunnel.util.Result
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.InputStream import java.io.InputStream
import java.util.zip.ZipInputStream import java.util.zip.ZipInputStream
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@HiltViewModel @HiltViewModel
class MainViewModel class MainViewModel
@Inject @Inject
constructor( constructor(
private val application: Application, private val application: Application,
private val tunnelRepo: TunnelConfigDao, private val tunnelConfigRepository: TunnelConfigRepository,
private val settingsRepo: SettingsDoa, private val settingsRepository: SettingsRepository,
private val vpnService: VpnService private val vpnService: VpnService
) : ViewModel() { ) : ViewModel() {
val tunnels get() = tunnelRepo.getAllFlow()
val state get() = vpnService.state
val handshakeStatus get() = vpnService.handshakeStatus val uiState =
val tunnelName get() = vpnService.tunnelName combine(
private val _settings = MutableStateFlow(Settings()) settingsRepository.getSettingsFlow(),
val settings get() = _settings.asStateFlow() tunnelConfigRepository.getTunnelConfigsFlow(),
val statistics get() = vpnService.statistics vpnService.vpnState,
) { settings, tunnels, vpnState ->
validateWatcherServiceState(settings)
MainUiState(settings, tunnels, vpnState, false)
}
.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(Constants.SUBSCRIPTION_TIMEOUT),
MainUiState())
init { private fun validateWatcherServiceState(settings: Settings) = viewModelScope.launch(Dispatchers.IO) {
viewModelScope.launch(Dispatchers.IO) { val watcherState =
settingsRepo.getAllFlow().filter { it.isNotEmpty() }.collect { ServiceManager.getServiceState(
val settings = it.first() application.applicationContext, WireGuardConnectivityWatcherService::class.java)
validateWatcherServiceState(settings) if (settings.isAutoTunnelEnabled &&
_settings.emit(settings) watcherState == ServiceState.STOPPED) {
} ServiceManager.startWatcherService(application.applicationContext)
}
} }
}
private fun validateWatcherServiceState(settings: Settings) { private fun stopWatcherService() = viewModelScope.launch(Dispatchers.IO) {
val watcherState = ServiceManager.stopWatcherService(application.applicationContext)
ServiceManager.getServiceState(
application.applicationContext,
WireGuardConnectivityWatcherService::class.java
)
if (settings.isAutoTunnelEnabled && watcherState == ServiceState.STOPPED && settings.defaultTunnel != null) {
ServiceManager.startWatcherService(
application.applicationContext,
settings.defaultTunnel!!
)
}
} }
fun onDelete(tunnel: TunnelConfig) {
fun onDelete(tunnel: TunnelConfig) { viewModelScope.launch(Dispatchers.IO) {
viewModelScope.launch { if (tunnelConfigRepository.count() == 1) {
if (tunnelRepo.count() == 1L) { stopWatcherService()
ServiceManager.stopWatcherService(application.applicationContext) val settings = settingsRepository.getSettings()
val settings = settingsRepo.getAll() settings.defaultTunnel = null
if (settings.isNotEmpty()) { settings.isAutoTunnelEnabled = false
val setting = settings[0] settings.isAlwaysOnVpnEnabled = false
setting.defaultTunnel = null saveSettings(settings)
setting.isAutoTunnelEnabled = false }
setting.isAlwaysOnVpnEnabled = false tunnelConfigRepository.delete(tunnel)
settingsRepo.save(setting) WireGuardAutoTunnel.requestTileServiceStateUpdate()
}
}
tunnelRepo.delete(tunnel)
}
} }
}
fun onTunnelStart(tunnelConfig: TunnelConfig) { fun onTunnelStart(tunnelConfig: TunnelConfig) = viewModelScope.launch(Dispatchers.IO) {
viewModelScope.launch { stopActiveTunnel().await()
stopActiveTunnel() startTunnel(tunnelConfig)
startTunnel(tunnelConfig) }
}
}
private fun startTunnel(tunnelConfig: TunnelConfig) { private fun startTunnel(tunnelConfig: TunnelConfig) = viewModelScope.launch(Dispatchers.IO) {
ServiceManager.startVpnService(application.applicationContext, tunnelConfig.toString()) ServiceManager.startVpnService(application.applicationContext, tunnelConfig.toString())
} }
private suspend fun stopActiveTunnel() { private fun stopActiveTunnel() =
viewModelScope.async(Dispatchers.IO) {
if (ServiceManager.getServiceState( if (ServiceManager.getServiceState(
application.applicationContext, application.applicationContext, WireGuardTunnelService::class.java) ==
WireGuardTunnelService::class.java ServiceState.STARTED) {
) == ServiceState.STARTED onTunnelStop()
) { delay(Constants.TOGGLE_TUNNEL_DELAY)
onTunnelStop()
delay(Constants.TOGGLE_TUNNEL_DELAY)
} }
} }
fun onTunnelStop() { fun onTunnelStop() = viewModelScope.launch(Dispatchers.IO) {
ServiceManager.stopVpnService(application.applicationContext) ServiceManager.stopVpnService(application.applicationContext)
} }
private fun validateConfigString(config: String) { private fun validateConfigString(config: String) {
TunnelConfig.configFromQuick(config) TunnelConfig.configFromQuick(config)
} }
suspend fun onTunnelQrResult(result: String) { suspend fun onTunnelQrResult(result: String) : Result<Unit> {
try { return try {
validateConfigString(result) validateConfigString(result)
val tunnelConfig = val tunnelConfig =
TunnelConfig(name = NumberUtils.generateRandomTunnelName(), wgQuick = result) TunnelConfig(name = NumberUtils.generateRandomTunnelName(), wgQuick = result)
addTunnel(tunnelConfig) addTunnel(tunnelConfig)
Result.Success(Unit)
} catch (e: Exception) { } catch (e: Exception) {
throw WgTunnelException(e) Result.Error(Event.Error.InvalidQrCode)
} }
} }
private suspend fun saveTunnelConfigFromStream( private suspend fun saveTunnelConfigFromStream(stream: InputStream, fileName: String) {
stream: InputStream, val bufferReader = stream.bufferedReader(charset = Charsets.UTF_8)
fileName: String val config = Config.parse(bufferReader)
) { val tunnelName = getNameFromFileName(fileName)
val bufferReader = stream.bufferedReader(charset = Charsets.UTF_8) addTunnel(TunnelConfig(name = tunnelName, wgQuick = config.toWgQuickString()))
val config = Config.parse(bufferReader) withContext(Dispatchers.IO) { stream.close() }
val tunnelName = getNameFromFileName(fileName) }
addTunnel(TunnelConfig(name = tunnelName, wgQuick = config.toWgQuickString()))
withContext(Dispatchers.IO) {
stream.close()
}
}
private fun getInputStreamFromUri(uri: Uri): InputStream { private fun getInputStreamFromUri(uri: Uri): InputStream? {
return application.applicationContext.contentResolver.openInputStream(uri) return application.applicationContext.contentResolver.openInputStream(uri)
?: throw WgTunnelException(application.getString(R.string.stream_failed)) }
}
suspend fun onTunnelFileSelected(uri: Uri) { suspend fun onTunnelFileSelected(uri: Uri) : Result<Unit> {
try { try {
val fileName = getFileName(application.applicationContext, uri) if(isValidUriContentScheme(uri)){
val fileExtension = getFileExtensionFromFileName(fileName) val fileName = getFileName(application.applicationContext, uri)
when (fileExtension) { when (getFileExtensionFromFileName(fileName)) {
Constants.CONF_FILE_EXTENSION -> saveTunnelFromConfUri(fileName, uri) Constants.CONF_FILE_EXTENSION -> saveTunnelFromConfUri(fileName, uri).let {
Constants.ZIP_FILE_EXTENSION -> saveTunnelsFromZipUri(uri) when(it) {
else -> throw WgTunnelException( is Result.Error -> return Result.Error(Event.Error.FileReadFailed)
application.getString(R.string.file_extension_message) is Result.Success -> return it
) }
}
} catch (e: Exception) {
throw WgTunnelException(e)
}
}
private suspend fun saveTunnelsFromZipUri(uri: Uri) {
ZipInputStream(getInputStreamFromUri(uri)).use { zip ->
generateSequence { zip.nextEntry }
.filterNot {
it.isDirectory ||
getFileExtensionFromFileName(it.name) != Constants.CONF_FILE_EXTENSION
}
.forEach {
val name = getNameFromFileName(it.name)
val config = Config.parse(zip)
viewModelScope.launch(Dispatchers.IO) {
addTunnel(TunnelConfig(name = name, wgQuick = config.toWgQuickString()))
} }
Constants.ZIP_FILE_EXTENSION -> saveTunnelsFromZipUri(uri)
else -> return Result.Error(Event.Error.InvalidFileExtension)
} }
} return Result.Success(Unit)
} } else {
return Result.Error(Event.Error.InvalidFileExtension)
private suspend fun saveTunnelFromConfUri(
name: String,
uri: Uri
) {
val stream = getInputStreamFromUri(uri)
saveTunnelConfigFromStream(stream, name)
}
private suspend fun addTunnel(tunnelConfig: TunnelConfig) {
saveTunnel(tunnelConfig)
}
private suspend fun saveTunnel(tunnelConfig: TunnelConfig) {
tunnelRepo.save(tunnelConfig)
}
private fun getFileNameByCursor(
context: Context,
uri: Uri
): String {
val cursor = context.contentResolver.query(uri, null, null, null, null)
if (cursor != null) {
cursor.use {
return getDisplayNameByCursor(it)
} }
} else {
throw WgTunnelException("Failed to initialize cursor")
}
}
private fun getDisplayNameColumnIndex(cursor: Cursor): Int {
val columnIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (columnIndex == -1) {
throw WgTunnelException("Cursor out of bounds")
}
return columnIndex
}
private fun getDisplayNameByCursor(cursor: Cursor): String {
if (cursor.moveToFirst()) {
val index = getDisplayNameColumnIndex(cursor)
return cursor.getString(index)
} else {
throw WgTunnelException("Cursor failed to move to first")
}
}
private fun validateUriContentScheme(uri: Uri) {
if (uri.scheme != Constants.URI_CONTENT_SCHEME) {
throw WgTunnelException(application.getString(R.string.file_extension_message))
}
}
private fun getFileName(
context: Context,
uri: Uri
): String {
validateUriContentScheme(uri)
return try {
getFileNameByCursor(context, uri)
} catch (_: Exception) {
NumberUtils.generateRandomTunnelName()
}
}
private fun getNameFromFileName(fileName: String): String {
return fileName.substring(0, fileName.lastIndexOf('.'))
}
private fun getFileExtensionFromFileName(fileName: String): String {
return try {
fileName.substring(fileName.lastIndexOf('.'))
} catch (e: Exception) { } catch (e: Exception) {
"" return Result.Error(Event.Error.FileReadFailed)
} }
}
private suspend fun saveTunnelsFromZipUri(uri: Uri) {
ZipInputStream(getInputStreamFromUri(uri)).use { zip ->
generateSequence { zip.nextEntry }
.filterNot {
it.isDirectory || getFileExtensionFromFileName(it.name) != Constants.CONF_FILE_EXTENSION
}
.forEach {
val name = getNameFromFileName(it.name)
val config = Config.parse(zip)
viewModelScope.launch(Dispatchers.IO) {
addTunnel(TunnelConfig(name = name, wgQuick = config.toWgQuickString()))
}
}
}
}
private suspend fun saveTunnelFromConfUri(name: String, uri: Uri) : Result<Unit> {
val stream = getInputStreamFromUri(uri)
return if(stream != null) {
saveTunnelConfigFromStream(stream, name)
Result.Success(Unit)
} else {
Result.Error(Event.Error.FileReadFailed)
}
}
private suspend fun addTunnel(tunnelConfig: TunnelConfig) {
saveTunnel(tunnelConfig)
WireGuardAutoTunnel.requestTileServiceStateUpdate()
}
fun pauseAutoTunneling() = viewModelScope.launch {
settingsRepository.save(uiState.value.settings.copy(isAutoTunnelPaused = true))
} }
suspend fun onDefaultTunnelChange(selectedTunnel: TunnelConfig?) { fun resumeAutoTunneling() = viewModelScope.launch {
if (selectedTunnel != null) { settingsRepository.save(uiState.value.settings.copy(isAutoTunnelPaused = false))
_settings.emit(
_settings.value.copy(
defaultTunnel = selectedTunnel.toString()
)
)
settingsRepo.save(_settings.value)
}
} }
private suspend fun saveTunnel(tunnelConfig: TunnelConfig) {
tunnelConfigRepository.save(tunnelConfig)
}
private fun getFileNameByCursor(context: Context, uri: Uri): String? {
context.contentResolver.query(uri, null, null, null, null)?.use {
return getDisplayNameByCursor(it)
}
return null
}
private fun getDisplayNameColumnIndex(cursor: Cursor): Int? {
val columnIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
return if (columnIndex != -1) {
return columnIndex
} else {
null
}
}
private fun getDisplayNameByCursor(cursor: Cursor): String? {
return if (cursor.moveToFirst()) {
val index = getDisplayNameColumnIndex(cursor)
if (index != null) {
cursor.getString(index)
} else null
} else null
}
private fun isValidUriContentScheme(uri: Uri): Boolean {
return uri.scheme == Constants.URI_CONTENT_SCHEME
}
private fun getFileName(context: Context, uri: Uri): String {
return getFileNameByCursor(context, uri) ?: NumberUtils.generateRandomTunnelName()
}
private fun getNameFromFileName(fileName: String): String {
return fileName.substring(0, fileName.lastIndexOf('.'))
}
private fun getFileExtensionFromFileName(fileName: String): String {
return try {
fileName.substring(fileName.lastIndexOf('.'))
} catch (e: Exception) {
""
}
}
private fun saveSettings(settings: Settings) =
viewModelScope.launch(Dispatchers.IO) { settingsRepository.save(settings) }
fun onDefaultTunnelChange(selectedTunnel: TunnelConfig?) = viewModelScope.launch {
if (selectedTunnel != null) {
saveSettings(uiState.value.settings.copy(defaultTunnel = selectedTunnel.toString())).join()
WireGuardAutoTunnel.requestTileServiceStateUpdate()
}
}
} }

View File

@ -4,7 +4,7 @@ import android.Manifest
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.provider.Settings import android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
@ -30,6 +30,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.outlined.Add import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.rounded.LocationOff import androidx.compose.material.icons.rounded.LocationOff
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -45,15 +46,13 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
@ -70,21 +69,21 @@ import com.wireguard.android.backend.Tunnel
import com.wireguard.android.backend.WgQuickBackend import com.wireguard.android.backend.WgQuickBackend
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.zaneschepke.wireguardautotunnel.repository.datastore.DataStoreManager
import com.zaneschepke.wireguardautotunnel.ui.common.ClickableIconButton import com.zaneschepke.wireguardautotunnel.ui.common.ClickableIconButton
import com.zaneschepke.wireguardautotunnel.ui.common.config.ConfigurationToggle import com.zaneschepke.wireguardautotunnel.ui.common.config.ConfigurationToggle
import com.zaneschepke.wireguardautotunnel.ui.common.prompt.AuthorizationPrompt import com.zaneschepke.wireguardautotunnel.ui.common.prompt.AuthorizationPrompt
import com.zaneschepke.wireguardautotunnel.ui.common.screen.LoadingScreen
import com.zaneschepke.wireguardautotunnel.ui.common.text.SectionTitle import com.zaneschepke.wireguardautotunnel.ui.common.text.SectionTitle
import com.zaneschepke.wireguardautotunnel.util.Event
import com.zaneschepke.wireguardautotunnel.util.FileUtils import com.zaneschepke.wireguardautotunnel.util.FileUtils
import com.zaneschepke.wireguardautotunnel.util.WgTunnelException import com.zaneschepke.wireguardautotunnel.util.Result
import java.io.File
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.File
@OptIn( @OptIn(
ExperimentalPermissionsApi::class, ExperimentalPermissionsApi::class,
ExperimentalLayoutApi::class, ExperimentalLayoutApi::class)
ExperimentalComposeUiApi::class
)
@Composable @Composable
fun SettingsScreen( fun SettingsScreen(
viewModel: SettingsViewModel = hiltViewModel(), viewModel: SettingsViewModel = hiltViewModel(),
@ -92,505 +91,418 @@ fun SettingsScreen(
showSnackbarMessage: (String) -> Unit, showSnackbarMessage: (String) -> Unit,
focusRequester: FocusRequester focusRequester: FocusRequester
) { ) {
val scope = rememberCoroutineScope { Dispatchers.IO } val scope = rememberCoroutineScope { Dispatchers.IO }
val context = LocalContext.current val context = LocalContext.current
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val keyboardController = LocalSoftwareKeyboardController.current val scrollState = rememberScrollState()
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
val settings by viewModel.settings.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val trustedSSIDs by viewModel.trustedSSIDs.collectAsStateWithLifecycle()
val tunnels by viewModel.tunnels.collectAsStateWithLifecycle(mutableListOf())
val fineLocationState = rememberPermissionState(Manifest.permission.ACCESS_FINE_LOCATION)
var currentText by remember { mutableStateOf("") }
val scrollState = rememberScrollState()
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 fineLocationState = rememberPermissionState(Manifest.permission.ACCESS_FINE_LOCATION)
val fillMaxWidth = .85f var currentText by remember { mutableStateOf("") }
var isBackgroundLocationGranted by remember { mutableStateOf(true) }
var showLocationServicesAlertDialog by remember { mutableStateOf(false) }
var didExportFiles by remember { mutableStateOf(false) }
var showAuthPrompt by remember { mutableStateOf(false) }
val focusRequester2 = remember { FocusRequester() }
fun setLocationDisclosureShown() = scope.launch { val screenPadding = 5.dp
viewModel.dataStoreManager.saveToDataStore( val fillMaxWidth = .85f
DataStoreManager.LOCATION_DISCLOSURE_SHOWN,
true if (uiState.loading) {
) LoadingScreen()
return
}
fun exportAllConfigs() {
try {
val files = uiState.tunnels.map { File(context.cacheDir, "${it.name}.conf") }
files.forEachIndexed { index, file ->
file.outputStream().use { it.write(uiState.tunnels[index].wgQuick.toByteArray()) }
}
FileUtils.saveFilesToZip(context, files)
didExportFiles = true
showSnackbarMessage(Event.Message.ConfigsExported.message)
} catch (e: Exception) {
showSnackbarMessage(Event.Error.Exception(e).message)
} }
}
fun exportAllConfigs() { fun saveTrustedSSID() {
try { if (currentText.isNotEmpty()) {
val files = tunnels.map { File(context.cacheDir, "${it.name}.conf") } viewModel.onSaveTrustedSSID(currentText).let {
files.forEachIndexed { index, file -> when(it) {
file.outputStream().use { is Result.Success -> currentText = ""
it.write(tunnels[index].wgQuick.toByteArray()) is Result.Error -> showSnackbarMessage(it.error.message)
} }
} }
FileUtils.saveFilesToZip(context, files)
didExportFiles = true
showSnackbarMessage(context.getString(R.string.exported_configs_message))
} catch (e: Exception) {
showSnackbarMessage(e.message!!)
}
} }
}
fun saveTrustedSSID() { fun openSettings() {
if (currentText.isNotEmpty()) { scope.launch {
scope.launch { val intentSettings = Intent(ACTION_APPLICATION_DETAILS_SETTINGS)
try { intentSettings.data = Uri.fromParts("package", context.packageName, null)
viewModel.onSaveTrustedSSID(currentText) context.startActivity(intentSettings)
currentText = ""
} catch (e: Exception) {
showSnackbarMessage(e.message ?: context.getString(R.string.unknown_error))
}
}
}
} }
}
fun isAllAutoTunnelPermissionsEnabled(): Boolean { fun checkFineLocationGranted() {
return ( isBackgroundLocationGranted =
isBackgroundLocationGranted &&
fineLocationState.status.isGranted &&
!viewModel.isLocationServicesNeeded()
)
}
fun openSettings() {
scope.launch {
val intentSettings =
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
intentSettings.data =
Uri.fromParts("package", context.packageName, null)
context.startActivity(intentSettings)
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val backgroundLocationState =
rememberPermissionState(Manifest.permission.ACCESS_BACKGROUND_LOCATION)
isBackgroundLocationGranted = if (!backgroundLocationState.status.isGranted) {
false
} else {
SideEffect {
setLocationDisclosureShown()
}
true
}
}
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) {
if (!fineLocationState.status.isGranted) { if (!fineLocationState.status.isGranted) {
isBackgroundLocationGranted = false false
} else { } else {
SideEffect { viewModel.setLocationDisclosureShown()
setLocationDisclosureShown() true
}
isBackgroundLocationGranted = true
} }
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
if(WireGuardAutoTunnel.isRunningOnAndroidTv() && Build.VERSION.SDK_INT == Build.VERSION_CODES.Q){
checkFineLocationGranted()
} else {
val backgroundLocationState =
rememberPermissionState(Manifest.permission.ACCESS_BACKGROUND_LOCATION)
isBackgroundLocationGranted =
if (!backgroundLocationState.status.isGranted) {
false
} else {
SideEffect { viewModel.setLocationDisclosureShown() }
true
}
}
}
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) {
checkFineLocationGranted()
}
AnimatedVisibility(showLocationServicesAlertDialog) {
AlertDialog(
onDismissRequest = { showLocationServicesAlertDialog = false },
confirmButton = {
TextButton(
onClick = {
showLocationServicesAlertDialog = false
viewModel.toggleAutoTunnel()
}) {
Text(text = stringResource(R.string.okay))
}
},
dismissButton = {
TextButton(onClick = { showLocationServicesAlertDialog = false }) {
Text(text = stringResource(R.string.cancel))
}
},
title = { Text(text = stringResource(R.string.location_services_not_detected)) },
text = { Text(text = stringResource(R.string.location_services_missing_message)) })
} }
if (isLocationDisclosureShown != true) { if (!uiState.isLocationDisclosureShown) {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top, verticalArrangement = Arrangement.Top,
modifier = modifier = Modifier.fillMaxSize().verticalScroll(scrollState).padding(padding)) {
Modifier Icon(
.fillMaxSize() Icons.Rounded.LocationOff,
.verticalScroll(scrollState) contentDescription = stringResource(id = R.string.map),
.padding(padding) modifier = Modifier.padding(30.dp).size(128.dp))
) { Text(
Icon( stringResource(R.string.prominent_background_location_title),
Icons.Rounded.LocationOff, textAlign = TextAlign.Center,
contentDescription = stringResource(id = R.string.map), modifier = Modifier.padding(30.dp),
modifier = fontSize = 20.sp)
Modifier Text(
.padding(30.dp) stringResource(R.string.prominent_background_location_message),
.size(128.dp) textAlign = TextAlign.Center,
) modifier = Modifier.padding(30.dp),
Text( fontSize = 15.sp)
stringResource(R.string.prominent_background_location_title), Row(
textAlign = TextAlign.Center, modifier =
modifier = Modifier.padding(30.dp), if (WireGuardAutoTunnel.isRunningOnAndroidTv()) {
fontSize = 20.sp Modifier.fillMaxWidth().padding(10.dp)
) } else {
Text( Modifier.fillMaxWidth().padding(30.dp)
stringResource(R.string.prominent_background_location_message), },
textAlign = TextAlign.Center, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(30.dp), horizontalArrangement = Arrangement.SpaceEvenly) {
fontSize = 15.sp TextButton(onClick = { viewModel.setLocationDisclosureShown() }) {
) Text(stringResource(id = R.string.no_thanks))
Row( }
modifier = TextButton(
if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) { modifier = Modifier.focusRequester(focusRequester),
Modifier onClick = {
.fillMaxWidth() openSettings()
.padding(10.dp) viewModel.setLocationDisclosureShown()
} else {
Modifier
.fillMaxWidth()
.padding(30.dp)
},
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceEvenly
) {
TextButton(onClick = {
setLocationDisclosureShown()
}) { }) {
Text(stringResource(id = R.string.no_thanks)) Text(stringResource(id = R.string.turn_on))
} }
TextButton(modifier = Modifier.focusRequester(focusRequester), onClick = { }
openSettings() }
setLocationDisclosureShown() }
}) {
Text(stringResource(id = R.string.turn_on)) if(showAuthPrompt) {
AuthorizationPrompt(
onSuccess = {
showAuthPrompt = false
exportAllConfigs()
},
onError = { _ ->
showAuthPrompt = false
showSnackbarMessage(Event.Error.AuthenticationFailed.message)
},
onFailure = {
showAuthPrompt = false
showSnackbarMessage(Event.Error.AuthorizationFailed.message)
})
}
if (uiState.tunnels.isEmpty() && uiState.isLocationDisclosureShown) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxSize().padding(padding)) {
Text(
stringResource(R.string.one_tunnel_required),
textAlign = TextAlign.Center,
modifier = Modifier.padding(15.dp),
fontStyle = FontStyle.Italic)
}
}
if (uiState.isLocationDisclosureShown && uiState.tunnels.isNotEmpty()) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top,
modifier =
Modifier.fillMaxSize().verticalScroll(scrollState).clickable(
indication = null, interactionSource = interactionSource) {
focusManager.clearFocus()
}) {
Surface(
tonalElevation = 2.dp,
shadowElevation = 2.dp,
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surface,
modifier =
(if (WireGuardAutoTunnel.isRunningOnAndroidTv()) {
Modifier.height(IntrinsicSize.Min)
.fillMaxWidth(fillMaxWidth)
.padding(top = 10.dp)
} else {
Modifier.fillMaxWidth(fillMaxWidth).padding(top = 60.dp)
})
.padding(bottom = 10.dp)) {
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Top,
modifier = Modifier.padding(15.dp)) {
SectionTitle(
title = stringResource(id = R.string.auto_tunneling),
padding = screenPadding)
ConfigurationToggle(
stringResource(id = R.string.tunnel_on_wifi),
enabled =
!(uiState.settings.isAutoTunnelEnabled ||
uiState.settings.isAlwaysOnVpnEnabled),
checked = uiState.settings.isTunnelOnWifiEnabled,
padding = screenPadding,
onCheckChanged = { viewModel.onToggleTunnelOnWifi() },
modifier = if(uiState.settings.isAutoTunnelEnabled) Modifier else Modifier.focusRequester(focusRequester).focusProperties { down = focusRequester2 })
AnimatedVisibility(visible = uiState.settings.isTunnelOnWifiEnabled) {
Column {
FlowRow(
modifier = Modifier.padding(screenPadding).fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(5.dp)) {
uiState.settings.trustedNetworkSSIDs.forEach { ssid ->
ClickableIconButton(
onClick = { if(WireGuardAutoTunnel.isRunningOnAndroidTv()) {
viewModel.onDeleteTrustedSSID(ssid)
focusRequester2.requestFocus()
}},
onIconClick = { viewModel.onDeleteTrustedSSID(ssid) },
text = ssid,
icon = Icons.Filled.Close,
enabled =
!(uiState.settings.isAutoTunnelEnabled ||
uiState.settings.isAlwaysOnVpnEnabled))
}
if (uiState.settings.trustedNetworkSSIDs.isEmpty()) {
Text(
stringResource(R.string.none),
fontStyle = FontStyle.Italic,
color = Color.Gray)
}
}
OutlinedTextField(
enabled =
!(uiState.settings.isAutoTunnelEnabled ||
uiState.settings.isAlwaysOnVpnEnabled),
value = currentText,
onValueChange = { currentText = it },
label = { Text(stringResource(R.string.add_trusted_ssid)) },
modifier =
Modifier.padding(
start = screenPadding, top = 5.dp, bottom = 10.dp)
.focusRequester(focusRequester2)
,
maxLines = 1,
keyboardOptions =
KeyboardOptions(
capitalization = KeyboardCapitalization.None,
imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { saveTrustedSSID() }),
trailingIcon = {
if (currentText != "") {
IconButton(onClick = { saveTrustedSSID() }) {
Icon(
imageVector = Icons.Outlined.Add,
contentDescription =
if (currentText == "") {
stringResource(
id = R.string.trusted_ssid_empty_description)
} else {
stringResource(
id = R.string.trusted_ssid_value_description)
},
tint = MaterialTheme.colorScheme.primary)
}
}
})
}
}
ConfigurationToggle(
stringResource(R.string.tunnel_mobile_data),
enabled =
!(uiState.settings.isAutoTunnelEnabled ||
uiState.settings.isAlwaysOnVpnEnabled),
checked = uiState.settings.isTunnelOnMobileDataEnabled,
padding = screenPadding,
onCheckChanged = { viewModel.onToggleTunnelOnMobileData() })
ConfigurationToggle(
stringResource(id = R.string.tunnel_on_ethernet),
enabled =
!(uiState.settings.isAutoTunnelEnabled ||
uiState.settings.isAlwaysOnVpnEnabled),
checked = uiState.settings.isTunnelOnEthernetEnabled,
padding = screenPadding,
onCheckChanged = { viewModel.onToggleTunnelOnEthernet() })
ConfigurationToggle(
stringResource(R.string.battery_saver),
enabled =
!(uiState.settings.isAutoTunnelEnabled ||
uiState.settings.isAlwaysOnVpnEnabled),
checked = uiState.settings.isBatterySaverEnabled,
padding = screenPadding,
onCheckChanged = { viewModel.onToggleBatterySaver() })
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = (if(!uiState.settings.isAutoTunnelEnabled) Modifier else Modifier.focusRequester(focusRequester))
.fillMaxSize().padding(top = 5.dp),
horizontalArrangement = Arrangement.Center) {
TextButton(
enabled = !uiState.settings.isAlwaysOnVpnEnabled,
onClick = {
if (uiState.settings.isTunnelOnWifiEnabled && !uiState.settings.isAutoTunnelEnabled) {
when(false) {
isBackgroundLocationGranted ->
showSnackbarMessage(Event.Error.BackgroundLocationRequired.message)
fineLocationState.status.isGranted ->
showSnackbarMessage(Event.Error.PreciseLocationRequired.message)
viewModel.isLocationEnabled(context) ->
showLocationServicesAlertDialog = true
else -> {
viewModel.toggleAutoTunnel()
}
}
} else {
viewModel.toggleAutoTunnel()
}
}) {
val autoTunnelButtonText =
if (uiState.settings.isAutoTunnelEnabled) {
stringResource(R.string.disable_auto_tunnel)
} else {
stringResource(id = R.string.enable_auto_tunnel)
}
Text(autoTunnelButtonText)
}
}
} }
}
if (WgQuickBackend.hasKernelSupport()) {
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 =
!(uiState.settings.isAutoTunnelEnabled ||
uiState.settings.isAlwaysOnVpnEnabled ||
(uiState.vpnState.status == Tunnel.State.UP)),
checked = uiState.settings.isKernelEnabled,
padding = screenPadding,
onCheckChanged = { viewModel.onToggleKernelMode().let {
when(it) {
is Result.Error -> showSnackbarMessage(it.error.message)
is Result.Success -> {}
}
} })
}
} }
} }
return if (!WireGuardAutoTunnel.isRunningOnAndroidTv()) {
}
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( Surface(
tonalElevation = 2.dp, tonalElevation = 2.dp,
shadowElevation = 2.dp, shadowElevation = 2.dp,
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surface, color = MaterialTheme.colorScheme.surface,
modifier = modifier =
( Modifier.fillMaxWidth(fillMaxWidth)
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.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)
.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(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 = {
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 (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) .padding(vertical = 10.dp)
) { .padding(bottom = 140.dp)) {
Column( Column(
horizontalAlignment = Alignment.Start, horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Top, verticalArrangement = Arrangement.Top,
modifier = Modifier.padding(15.dp) modifier = Modifier.padding(15.dp)) {
) {
SectionTitle( SectionTitle(
title = stringResource(id = R.string.kernel), title = stringResource(id = R.string.other), padding = screenPadding)
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( ConfigurationToggle(
stringResource(R.string.always_on_vpn_support), stringResource(R.string.always_on_vpn_support),
enabled = !settings.isAutoTunnelEnabled, enabled = !uiState.settings.isAutoTunnelEnabled,
checked = settings.isAlwaysOnVpnEnabled, checked = uiState.settings.isAlwaysOnVpnEnabled,
padding = screenPadding, padding = screenPadding,
onCheckChanged = { onCheckChanged = { viewModel.onToggleAlwaysOnVPN() })
scope.launch {
viewModel.onToggleAlwaysOnVPN()
}
}
)
ConfigurationToggle( ConfigurationToggle(
stringResource(R.string.enabled_app_shortcuts), stringResource(R.string.enabled_app_shortcuts),
enabled = true, enabled = true,
checked = settings.isShortcutsEnabled, checked = uiState.settings.isShortcutsEnabled,
padding = screenPadding, padding = screenPadding,
onCheckChanged = { onCheckChanged = { viewModel.onToggleShortcutsEnabled() })
scope.launch {
viewModel.onToggleShortcutsEnabled()
}
}
)
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = modifier = Modifier.fillMaxSize().padding(top = 5.dp),
Modifier horizontalArrangement = Arrangement.Center) {
.fillMaxSize() TextButton(
.padding(top = 5.dp), enabled = !didExportFiles, onClick = { showAuthPrompt = true }) {
horizontalArrangement = Arrangement.Center Text(stringResource(R.string.export_configs))
) { }
TextButton(
enabled = !didExportFiles,
onClick = {
showAuthPrompt = true
}
) {
Text(stringResource(R.string.export_configs))
} }
} }
}
} }
} }
if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) { if (WireGuardAutoTunnel.isRunningOnAndroidTv()) {
Spacer(modifier = Modifier.weight(.17f)) Spacer(modifier = Modifier.weight(.17f))
} }
} }
} }
}

View File

@ -0,0 +1,13 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.settings
import com.zaneschepke.wireguardautotunnel.data.model.Settings
import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnState
data class SettingsUiState(
val settings : Settings = Settings(),
val tunnels : List<TunnelConfig> = emptyList(),
val vpnState: VpnState = VpnState(),
val isLocationDisclosureShown : Boolean = true,
val loading : Boolean = true
)

View File

@ -3,195 +3,171 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.settings
import android.app.Application import android.app.Application
import android.content.Context import android.content.Context
import android.location.LocationManager import android.location.LocationManager
import android.os.Build import androidx.core.location.LocationManagerCompat
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.wireguard.android.util.RootShell import com.wireguard.android.util.RootShell
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa import com.zaneschepke.wireguardautotunnel.data.datastore.DataStoreManager
import com.zaneschepke.wireguardautotunnel.repository.TunnelConfigDao import com.zaneschepke.wireguardautotunnel.data.model.Settings
import com.zaneschepke.wireguardautotunnel.repository.datastore.DataStoreManager import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepository
import com.zaneschepke.wireguardautotunnel.repository.model.Settings import com.zaneschepke.wireguardautotunnel.data.repository.TunnelConfigRepository
import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService
import com.zaneschepke.wireguardautotunnel.util.WgTunnelException import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.Event
import com.zaneschepke.wireguardautotunnel.util.Result
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.async import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject
@HiltViewModel @HiltViewModel
class SettingsViewModel class SettingsViewModel
@Inject @Inject
constructor( constructor(
private val application: Application, private val application: Application,
private val tunnelRepo: TunnelConfigDao, private val tunnelConfigRepository: TunnelConfigRepository,
private val settingsRepo: SettingsDoa, private val settingsRepository: SettingsRepository,
val dataStoreManager: DataStoreManager, private val dataStoreManager: DataStoreManager,
private val rootShell: RootShell, private val rootShell: RootShell,
private val vpnService: VpnService private val vpnService: VpnService
) : ViewModel() { ) : ViewModel() {
private val _trustedSSIDs = MutableStateFlow(emptyList<String>()) val uiState = combine(
val trustedSSIDs = _trustedSSIDs.asStateFlow() settingsRepository.getSettingsFlow(),
private val _settings = MutableStateFlow(Settings()) tunnelConfigRepository.getTunnelConfigsFlow(),
val settings get() = _settings.asStateFlow() vpnService.vpnState,
val vpnState get() = vpnService.state dataStoreManager.locationDisclosureFlow,
val tunnels get() = tunnelRepo.getAllFlow() ){ settings, tunnels, tunnelState, locationDisclosure ->
val disclosureShown = dataStoreManager.locationDisclosureFlow SettingsUiState(settings, tunnels, tunnelState, locationDisclosure
?: false, false)
}.stateIn(viewModelScope,
SharingStarted.WhileSubscribed(Constants.SUBSCRIPTION_TIMEOUT), SettingsUiState())
init { fun onSaveTrustedSSID(ssid: String) : Result<Unit>{
isLocationServicesEnabled()
viewModelScope.launch(Dispatchers.IO) {
settingsRepo.getAllFlow().filter { it.isNotEmpty() }.collect {
val settings = it.first()
_settings.emit(settings)
_trustedSSIDs.emit(settings.trustedNetworkSSIDs.toList())
}
}
}
suspend fun onSaveTrustedSSID(ssid: String) {
val trimmed = ssid.trim() val trimmed = ssid.trim()
if (!_settings.value.trustedNetworkSSIDs.contains(trimmed)) { return if (!uiState.value.settings.trustedNetworkSSIDs.contains(trimmed)) {
_settings.value.trustedNetworkSSIDs.add(trimmed) uiState.value.settings.trustedNetworkSSIDs.add(trimmed)
settingsRepo.save(_settings.value) saveSettings(uiState.value.settings)
Result.Success(Unit)
} else { } else {
throw WgTunnelException("SSID already exists.") Result.Error(Event.Error.SsidConflict)
} }
} }
suspend fun onToggleTunnelOnMobileData() { fun setLocationDisclosureShown() = viewModelScope.launch {
settingsRepo.save( dataStoreManager.saveToDataStore(DataStoreManager.LOCATION_DISCLOSURE_SHOWN, true)
_settings.value.copy( }
isTunnelOnMobileDataEnabled = !_settings.value.isTunnelOnMobileDataEnabled
fun onToggleTunnelOnMobileData() {
saveSettings(
uiState.value.settings.copy(
isTunnelOnMobileDataEnabled = !uiState.value.settings.isTunnelOnMobileDataEnabled
) )
) )
} }
suspend fun onDeleteTrustedSSID(ssid: String) { fun onDeleteTrustedSSID(ssid: String) {
_settings.value.trustedNetworkSSIDs.remove(ssid) saveSettings(uiState.value.settings.copy(
settingsRepo.save(_settings.value) trustedNetworkSSIDs = (uiState.value.settings.trustedNetworkSSIDs - ssid).toMutableList()
))
} }
private fun emitFirstTunnelAsDefault() = private suspend fun getDefaultTunnelOrFirst() : String {
viewModelScope.async { return uiState.value.settings.defaultTunnel ?: tunnelConfigRepository.getAll().first().toString()
_settings.emit(_settings.value.copy(defaultTunnel = getFirstTunnelConfig().toString())) }
}
suspend fun toggleAutoTunnel() { fun toggleAutoTunnel() = viewModelScope.launch {
if (_settings.value.isAutoTunnelEnabled) { val isAutoTunnelEnabled = uiState.value.settings.isAutoTunnelEnabled
var isAutoTunnelPaused = uiState.value.settings.isAutoTunnelPaused
if (isAutoTunnelEnabled) {
ServiceManager.stopWatcherService(application) ServiceManager.stopWatcherService(application)
} else { } else {
if (_settings.value.defaultTunnel == null) { ServiceManager.startWatcherService(application)
emitFirstTunnelAsDefault().await() isAutoTunnelPaused = false
}
val defaultTunnel = _settings.value.defaultTunnel
ServiceManager.startWatcherService(application, defaultTunnel!!)
} }
settingsRepo.save( saveSettings(
_settings.value.copy( uiState.value.settings.copy(
isAutoTunnelEnabled = !_settings.value.isAutoTunnelEnabled isAutoTunnelEnabled = !isAutoTunnelEnabled,
isAutoTunnelPaused = isAutoTunnelPaused,
defaultTunnel = getDefaultTunnelOrFirst()
) )
) )
} }
private suspend fun getFirstTunnelConfig(): TunnelConfig {
return tunnelRepo.getAll().first()
}
suspend fun onToggleAlwaysOnVPN() { fun onToggleAlwaysOnVPN() = viewModelScope.launch {
if (_settings.value.defaultTunnel == null) { val updatedSettings = uiState.value.settings.copy(
emitFirstTunnelAsDefault().await() isAlwaysOnVpnEnabled = !uiState.value.settings.isAlwaysOnVpnEnabled,
} defaultTunnel = getDefaultTunnelOrFirst()
val updatedSettings =
_settings.value.copy(
isAlwaysOnVpnEnabled = !_settings.value.isAlwaysOnVpnEnabled
) )
emitSettings(updatedSettings)
saveSettings(updatedSettings) saveSettings(updatedSettings)
} }
private suspend fun emitSettings(settings: Settings) { private fun saveSettings(settings: Settings) = viewModelScope.launch {
_settings.emit( settingsRepository.save(settings)
settings
)
} }
private suspend fun saveSettings(settings: Settings) { fun onToggleTunnelOnEthernet() {
settingsRepo.save(settings) saveSettings(uiState.value.settings.copy(
isTunnelOnEthernetEnabled = !uiState.value.settings.isTunnelOnEthernetEnabled
))
} }
suspend fun onToggleTunnelOnEthernet() { fun isLocationEnabled(context: Context): Boolean {
if (_settings.value.defaultTunnel == null) { val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
emitFirstTunnelAsDefault().await() return LocationManagerCompat.isLocationEnabled(locationManager)
}
_settings.emit(
_settings.value.copy(
isTunnelOnEthernetEnabled = !_settings.value.isTunnelOnEthernetEnabled
)
)
settingsRepo.save(_settings.value)
} }
private fun isLocationServicesEnabled(): Boolean { fun onToggleShortcutsEnabled() {
val locationManager = saveSettings(
application.getSystemService(Context.LOCATION_SERVICE) as LocationManager uiState.value.settings.copy(
return locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER) isShortcutsEnabled = !uiState.value.settings.isShortcutsEnabled
}
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
) )
) )
} }
suspend fun onToggleBatterySaver() { fun onToggleBatterySaver() {
settingsRepo.save( saveSettings(
_settings.value.copy( uiState.value.settings.copy(
isBatterySaverEnabled = !_settings.value.isBatterySaverEnabled isBatterySaverEnabled = !uiState.value.settings.isBatterySaverEnabled
) )
) )
} }
private suspend fun saveKernelMode(on: Boolean) { private fun saveKernelMode(on: Boolean) {
settingsRepo.save( saveSettings(
_settings.value.copy( uiState.value.settings.copy(
isKernelEnabled = on isKernelEnabled = on
) )
) )
} }
suspend fun onToggleKernelMode() { fun onToggleTunnelOnWifi() {
if (!_settings.value.isKernelEnabled) { saveSettings(
uiState.value.settings.copy(
isTunnelOnWifiEnabled = !uiState.value.settings.isTunnelOnWifiEnabled
)
)
}
fun onToggleKernelMode() : Result<Unit> {
if (!uiState.value.settings.isKernelEnabled) {
try { try {
rootShell.start() rootShell.start()
Timber.d("Root shell accepted!") Timber.d("Root shell accepted!")
saveKernelMode(on = true) saveKernelMode(on = true)
} catch (e: RootShell.RootShellException) { } catch (e: RootShell.RootShellException) {
saveKernelMode(on = false) saveKernelMode(on = false)
throw WgTunnelException("Root shell denied!") return Result.Error(Event.Error.RootDenied)
} }
} else { } else {
saveKernelMode(on = false) saveKernelMode(on = false)
} }
} return Result.Success(Unit)
suspend fun onToggleTunnelOnWifi() {
settingsRepo.save(
_settings.value.copy(
isTunnelOnWifiEnabled = !_settings.value.isTunnelOnWifiEnabled
)
)
} }
} }

View File

@ -40,6 +40,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -48,197 +49,180 @@ import androidx.core.content.ContextCompat.startActivity
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.zaneschepke.wireguardautotunnel.BuildConfig import com.zaneschepke.wireguardautotunnel.BuildConfig
import com.zaneschepke.wireguardautotunnel.Constants
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.SettingsViewModel import com.zaneschepke.wireguardautotunnel.ui.common.screen.LoadingScreen
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.Event
@Composable @Composable
fun SupportScreen( fun SupportScreen(
viewModel: SettingsViewModel = hiltViewModel(), viewModel: SupportViewModel = hiltViewModel(),
padding: PaddingValues, padding: PaddingValues,
showSnackbarMessage: (String) -> Unit,
focusRequester: FocusRequester focusRequester: FocusRequester
) { ) {
val context = LocalContext.current val context = LocalContext.current
val fillMaxWidth = .85f val fillMaxWidth = .85f
val settings by viewModel.settings.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
fun openWebPage(url: String) { fun openWebPage(url: String) {
val webpage: Uri = Uri.parse(url) try {
val intent = Intent(Intent.ACTION_VIEW, webpage) val webpage: Uri = Uri.parse(url)
context.startActivity(intent) val intent = Intent(Intent.ACTION_VIEW, webpage)
} context.startActivity(intent)
} catch (e : Exception) {
showSnackbarMessage(Event.Error.Exception(e).message)
}
}
fun launchEmail() { fun launchEmail() {
val intent = try {
Intent(Intent.ACTION_SEND).apply { val intent =
type = Constants.EMAIL_MIME_TYPE Intent(Intent.ACTION_SEND).apply {
putExtra(Intent.EXTRA_EMAIL, context.getString(R.string.my_email)) type = Constants.EMAIL_MIME_TYPE
putExtra(Intent.EXTRA_SUBJECT, context.getString(R.string.email_subject)) putExtra(Intent.EXTRA_EMAIL, context.getString(R.string.my_email))
} putExtra(Intent.EXTRA_SUBJECT, context.getString(R.string.email_subject))
startActivity( }
context, startActivity(context, createChooser(intent, context.getString(R.string.email_chooser)), null)
createChooser(intent, context.getString(R.string.email_chooser)), } catch (e : Exception) {
null showSnackbarMessage(Event.Error.Exception(e).message)
) }
} }
if (uiState.loading) {
LoadingScreen()
return
}
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top, verticalArrangement = Arrangement.Top,
modifier = modifier =
Modifier Modifier.fillMaxSize()
.fillMaxSize() .verticalScroll(rememberScrollState())
.verticalScroll(rememberScrollState()) .focusable()
.focusable() .padding(padding)) {
.padding(padding) Surface(
) { tonalElevation = 2.dp,
Surface( shadowElevation = 2.dp,
tonalElevation = 2.dp, shape = RoundedCornerShape(12.dp),
shadowElevation = 2.dp, color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(12.dp), modifier =
color = MaterialTheme.colorScheme.surface, (if (WireGuardAutoTunnel.isRunningOnAndroidTv()) {
modifier = Modifier.height(IntrinsicSize.Min)
(
if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
Modifier
.height(IntrinsicSize.Min)
.fillMaxWidth(fillMaxWidth) .fillMaxWidth(fillMaxWidth)
.padding(top = 10.dp) .padding(top = 10.dp)
} else { } else {
Modifier Modifier.fillMaxWidth(fillMaxWidth).padding(top = 20.dp)
.fillMaxWidth(fillMaxWidth) })
.padding(top = 20.dp) .padding(bottom = 25.dp)) {
} Column(modifier = Modifier.padding(20.dp)) {
).padding(bottom = 25.dp) Text(
) { stringResource(R.string.thank_you),
Column(modifier = Modifier.padding(20.dp)) { textAlign = TextAlign.Start,
Text( fontWeight = FontWeight.Bold,
stringResource(R.string.thank_you), modifier = Modifier.padding(bottom = 20.dp),
textAlign = TextAlign.Start, fontSize = 16.sp)
modifier = Modifier.padding(bottom = 20.dp), Text(
fontSize = 16.sp stringResource(id = R.string.support_help_text),
) textAlign = TextAlign.Start,
Text( fontSize = 16.sp,
stringResource(id = R.string.support_help_text), modifier = Modifier.padding(bottom = 20.dp))
textAlign = TextAlign.Start, TextButton(
fontSize = 16.sp, onClick = { openWebPage(context.resources.getString(R.string.docs_url)) },
modifier = Modifier.padding(bottom = 20.dp) modifier = Modifier.padding(vertical = 5.dp).focusRequester(focusRequester)) {
) Row(
TextButton(onClick = { horizontalArrangement = Arrangement.SpaceBetween,
openWebPage(context.resources.getString(R.string.docs_url)) verticalAlignment = Alignment.CenterVertically,
}, modifier = Modifier.padding(vertical = 5.dp).focusRequester(focusRequester)) { modifier = Modifier.fillMaxWidth()) {
Row( Row {
horizontalArrangement = Arrangement.SpaceBetween, Icon(Icons.Rounded.Book, stringResource(id = R.string.docs))
verticalAlignment = Alignment.CenterVertically, Text(
modifier = Modifier.fillMaxWidth() stringResource(id = R.string.docs_description),
) { textAlign = TextAlign.Justify,
Row { modifier = Modifier.padding(start = 10.dp))
Icon(Icons.Rounded.Book, stringResource(id = R.string.docs)) }
Text( Icon(Icons.Rounded.ArrowForward, stringResource(id = R.string.go))
stringResource(id = R.string.docs_description), }
textAlign = TextAlign.Justify, }
modifier = Modifier.padding(start = 10.dp) Divider(color = MaterialTheme.colorScheme.onBackground, thickness = 0.5.dp)
) TextButton(
} onClick = { openWebPage(context.resources.getString(R.string.discord_url)) },
Icon(Icons.Rounded.ArrowForward, stringResource(id = R.string.go)) modifier = Modifier.padding(vertical = 5.dp)) {
} Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()) {
Row {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.discord),
stringResource(id = R.string.discord),
Modifier.size(25.dp))
Text(
stringResource(id = R.string.discord_description),
textAlign = TextAlign.Justify,
modifier = Modifier.padding(start = 10.dp))
}
Icon(Icons.Rounded.ArrowForward, stringResource(id = R.string.go))
}
}
Divider(color = MaterialTheme.colorScheme.onBackground, thickness = 0.5.dp)
TextButton(
onClick = { openWebPage(context.resources.getString(R.string.github_url)) },
modifier = Modifier.padding(vertical = 5.dp)) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()) {
Row {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.github),
stringResource(id = R.string.github),
Modifier.size(25.dp))
Text(
"Open an issue",
textAlign = TextAlign.Justify,
modifier = Modifier.padding(start = 10.dp))
}
Icon(Icons.Rounded.ArrowForward, stringResource(id = R.string.go))
}
}
Divider(color = MaterialTheme.colorScheme.onBackground, thickness = 0.5.dp)
TextButton(
onClick = { launchEmail() }, modifier = Modifier.padding(vertical = 5.dp)) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()) {
Row {
Icon(Icons.Rounded.Mail, stringResource(id = R.string.email))
Text(
stringResource(id = R.string.email_description),
textAlign = TextAlign.Justify,
modifier = Modifier.padding(start = 10.dp))
}
Icon(Icons.Rounded.ArrowForward, stringResource(id = R.string.go))
}
}
} }
Divider(color = MaterialTheme.colorScheme.onBackground, thickness = 0.5.dp) }
TextButton( Spacer(modifier = Modifier.weight(1f))
onClick = { openWebPage(context.resources.getString(R.string.discord_url)) }, Text(
modifier = Modifier.padding(vertical = 5.dp) stringResource(id = R.string.privacy_policy),
) { style = TextStyle(textDecoration = TextDecoration.Underline),
Row( fontSize = 16.sp,
horizontalArrangement = Arrangement.SpaceBetween, modifier =
verticalAlignment = Alignment.CenterVertically, Modifier.clickable {
modifier = Modifier.fillMaxWidth() openWebPage(context.resources.getString(R.string.privacy_policy_url))
) { })
Row { Row(
Icon( horizontalArrangement = Arrangement.spacedBy(25.dp),
imageVector = ImageVector.vectorResource(R.drawable.discord), verticalAlignment = Alignment.CenterVertically,
stringResource( modifier = Modifier.padding(25.dp)) {
id = R.string.discord Text("Version: ${BuildConfig.VERSION_NAME}", modifier = Modifier.focusable())
), Text("Mode: ${if (uiState.settings.isKernelEnabled) "Kernel" else "Userspace" }")
Modifier.size(25.dp) }
)
Text(
stringResource(id = R.string.discord_description),
textAlign = TextAlign.Justify,
modifier = Modifier.padding(start = 10.dp)
)
}
Icon(Icons.Rounded.ArrowForward, stringResource(id = R.string.go))
}
}
Divider(color = MaterialTheme.colorScheme.onBackground, thickness = 0.5.dp)
TextButton(
onClick = { openWebPage(context.resources.getString(R.string.github_url)) },
modifier = Modifier.padding(vertical = 5.dp)
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Row {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.github),
stringResource(
id = R.string.github
),
Modifier.size(25.dp)
)
Text(
"Open an issue",
textAlign = TextAlign.Justify,
modifier = Modifier.padding(start = 10.dp)
)
}
Icon(Icons.Rounded.ArrowForward, stringResource(id = R.string.go))
}
}
Divider(color = MaterialTheme.colorScheme.onBackground, thickness = 0.5.dp)
TextButton(
onClick = { launchEmail() },
modifier = Modifier.padding(vertical = 5.dp)
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Row {
Icon(Icons.Rounded.Mail, stringResource(id = R.string.email))
Text(
stringResource(id = R.string.email_description),
textAlign = TextAlign.Justify,
modifier = Modifier.padding(start = 10.dp)
)
}
Icon(Icons.Rounded.ArrowForward, stringResource(id = R.string.go))
}
}
}
} }
Spacer(modifier = Modifier.weight(1f)) }
Text(
stringResource(id = R.string.privacy_policy),
style = TextStyle(textDecoration = TextDecoration.Underline),
fontSize = 16.sp,
modifier =
Modifier.clickable {
openWebPage(context.resources.getString(R.string.privacy_policy_url))
}
)
Row(
horizontalArrangement = Arrangement.spacedBy(25.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(25.dp)
) {
Text("Version: ${BuildConfig.VERSION_NAME}")
Text("Mode: ${if (settings.isKernelEnabled) "Kernel" else "Userspace" }")
}
}
}

View File

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

View File

@ -2,24 +2,24 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.support
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepository
import com.zaneschepke.wireguardautotunnel.repository.model.Settings import com.zaneschepke.wireguardautotunnel.util.Constants
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
@HiltViewModel @HiltViewModel
class SupportViewModel @Inject constructor( class SupportViewModel @Inject constructor(
private val settingsRepo: SettingsDoa private val settingsRepository: SettingsRepository
) : ViewModel() { ) : ViewModel() {
private val _settings = MutableStateFlow(Settings())
val settings get() = _settings.asStateFlow() val uiState = settingsRepository.getSettingsFlow().map {
init { SupportUiState(it, false)
viewModelScope.launch(Dispatchers.IO) { }.stateIn(
_settings.value = settingsRepo.getAll().first() viewModelScope,
} SharingStarted.WhileSubscribed(Constants.SUBSCRIPTION_TIMEOUT),
} SupportUiState()
)
} }

View File

@ -1,14 +1,12 @@
package com.zaneschepke.wireguardautotunnel package com.zaneschepke.wireguardautotunnel.util
object Constants { object Constants {
const val MANUAL_TUNNEL_CONFIG_ID = "0" const val MANUAL_TUNNEL_CONFIG_ID = "0"
const val WATCHER_SERVICE_WAKE_LOCK_TIMEOUT = 10 * 60 * 1000L // 10 minutes const val BATTERY_SAVER_WATCHER_WAKE_LOCK_TIMEOUT = 10 * 60 * 1000L // 10 minutes
const val VPN_CONNECTIVITY_CHECK_INTERVAL = 3000L const val DEFAULT_WATCHER_WAKE_LOCK_TIMEOUT = 30 * 60 * 1000L // 30 minutes
const val VPN_STATISTIC_CHECK_INTERVAL = 1000L const val VPN_STATISTIC_CHECK_INTERVAL = 1000L
const val TOGGLE_TUNNEL_DELAY = 500L const val VPN_CONNECTED_NOTIFICATION_DELAY = 3000L
const val FADE_IN_ANIMATION_DURATION = 1000 const val TOGGLE_TUNNEL_DELAY = 300L
const val SLIDE_IN_ANIMATION_DURATION = 500
const val SLIDE_IN_TRANSITION_OFFSET = 1000
const val CONF_FILE_EXTENSION = ".conf" const val CONF_FILE_EXTENSION = ".conf"
const val ZIP_FILE_EXTENSION = ".zip" const val ZIP_FILE_EXTENSION = ".zip"
const val URI_CONTENT_SCHEME = "content" const val URI_CONTENT_SCHEME = "content"
@ -18,4 +16,7 @@ object Constants {
const val ANDROID_TV_EXPLORER_STUB = "com.android.tv.frameworkpackagestubs" const val ANDROID_TV_EXPLORER_STUB = "com.android.tv.frameworkpackagestubs"
const val EMAIL_MIME_TYPE = "message/rfc822" const val EMAIL_MIME_TYPE = "message/rfc822"
const val SYSTEM_EXEMPT_SERVICE_TYPE_ID = 1024 const val SYSTEM_EXEMPT_SERVICE_TYPE_ID = 1024
const val SUBSCRIPTION_TIMEOUT = 5_000L
const val FOCUS_REQUEST_DELAY = 500L
} }

View File

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

View File

@ -0,0 +1,68 @@
package com.zaneschepke.wireguardautotunnel.util
import android.content.BroadcastReceiver
import android.content.pm.PackageInfo
import com.wireguard.android.backend.Statistics
import com.wireguard.android.backend.Statistics.PeerStats
import com.wireguard.crypto.Key
import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.service.tunnel.HandshakeStatus
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
fun BroadcastReceiver.goAsync(
context: CoroutineContext = EmptyCoroutineContext,
block: suspend CoroutineScope.() -> Unit
) {
val pendingResult = goAsync()
@OptIn(DelicateCoroutinesApi::class) // Must run globally; there's no teardown callback.
GlobalScope.launch(context) {
try {
block()
} finally {
pendingResult.finish()
}
}
}
fun BigDecimal.toThreeDecimalPlaceString(): String {
val df = DecimalFormat("#.###")
return df.format(this)
}
fun <T> List<T>.update(index: Int, item: T): List<T> = toMutableList().apply { this[index] = item }
fun <T> List<T>.removeAt(index: Int): List<T> = toMutableList().apply { this.removeAt(index) }
typealias TunnelConfigs = List<TunnelConfig>
typealias Packages = List<PackageInfo>
fun Statistics.mapPeerStats(): Map<Key, PeerStats?> {
return this.peers().associateWith { key ->
(this.peer(key))
}
}
fun PeerStats.latestHandshakeSeconds() : Long? {
return NumberUtils.getSecondsBetweenTimestampAndNow(this.latestHandshakeEpochMillis)
}
fun PeerStats.handshakeStatus() : HandshakeStatus {
//TODO add never connected status after duration
return this.latestHandshakeSeconds().let {
when {
it == null -> HandshakeStatus.NOT_STARTED
it <= HandshakeStatus.STALE_TIME_LIMIT_SEC -> HandshakeStatus.HEALTHY
it > HandshakeStatus.STALE_TIME_LIMIT_SEC -> HandshakeStatus.STALE
else -> {
HandshakeStatus.UNKNOWN
}
}
}
}

View File

@ -6,7 +6,6 @@ import android.os.Build
import android.os.Environment import android.os.Environment
import android.provider.MediaStore import android.provider.MediaStore
import android.provider.MediaStore.MediaColumns import android.provider.MediaStore.MediaColumns
import com.zaneschepke.wireguardautotunnel.Constants
import java.io.File import java.io.File
import java.io.OutputStream import java.io.OutputStream
import java.time.Instant import java.time.Instant

View File

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

View File

@ -1,16 +0,0 @@
package com.zaneschepke.wireguardautotunnel.util
import com.wireguard.config.BadConfigException
class WgTunnelException(e: Exception) : Exception() {
constructor(message: String) : this(Exception(message))
override val message: String = generateExceptionMessage(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

@ -9,13 +9,14 @@
<string name="github_url">https://github.com/zaneschepke/wgtunnel/issues</string> <string name="github_url">https://github.com/zaneschepke/wgtunnel/issues</string>
<string name="docs_url">https://zaneschepke.com/wgtunnel-docs/overview.html</string> <string name="docs_url">https://zaneschepke.com/wgtunnel-docs/overview.html</string>
<string name="privacy_policy_url">https://zaneschepke.com/wgtunnel-docs/privacypolicy.html</string> <string name="privacy_policy_url">https://zaneschepke.com/wgtunnel-docs/privacypolicy.html</string>
<string name="file_extension_message">File is not a .conf or .zip</string> <string name="error_file_extension">File is not a .conf or .zip</string>
<string name="turn_off_tunnel">Turn off tunnel before editing</string> <string name="turn_off_tunnel">Action requires tunnel off</string>
<string name="no_tunnels">No tunnels added yet!</string> <string name="no_tunnels">No tunnels added yet!</string>
<string name="tunnel_exists">Tunnel name already exists</string> <string name="tunnel_exists">Tunnel name already exists</string>
<string name="discord_url">https://discord.gg/rbRRNh6H7V</string> <string name="discord_url">https://discord.gg/rbRRNh6H7V</string>
<string name="watcher_notification_title">Watcher Service</string> <string name="watcher_notification_title">Watcher Service</string>
<string name="watcher_notification_text">Monitoring network state changes</string> <string name="watcher_notification_text_active">Monitoring network state changes: active</string>
<string name="watcher_notification_text_paused">Monitoring network state changes: paused</string>
<string name="tunnel_start_title">VPN Connected</string> <string name="tunnel_start_title">VPN Connected</string>
<string name="tunnel_start_text">Connected to tunnel -</string> <string name="tunnel_start_text">Connected to tunnel -</string>
<string name="vpn_permission_required">VPN permission is required for the app to work properly. If this permission is not launching, please disable \"Always-on VPN\" in your phone settings for the official WireGuard mobile app and try again.</string> <string name="vpn_permission_required">VPN permission is required for the app to work properly. If this permission is not launching, please disable \"Always-on VPN\" in your phone settings for the official WireGuard mobile app and try again.</string>
@ -78,7 +79,7 @@
<string name="lost_connection_failure_message">Attempting to reconnect to server after more than one minute of no response.</string> <string name="lost_connection_failure_message">Attempting to reconnect to server after more than one minute of no response.</string>
<string name="always_on_vpn_support">Allow Always-On VPN </string> <string name="always_on_vpn_support">Allow Always-On VPN </string>
<string name="select_tunnel_message">Please select a tunnel first</string> <string name="select_tunnel_message">Please select a tunnel first</string>
<string name="location_services_not_detected">Unable to detect Location Services which are required for this feature. Please enable Location Services.</string> <string name="location_services_not_detected">Location Services Not Detected</string>
<string name="check_again">Check again</string> <string name="check_again">Check again</string>
<string name="detecting_location_services_disabled">Detecting Location Services disabled</string> <string name="detecting_location_services_disabled">Detecting Location Services disabled</string>
<string name="precise_location_message">This feature requires precise location to access Wi-Fi SSID name. Please enable precise location here or in the app settings.</string> <string name="precise_location_message">This feature requires precise location to access Wi-Fi SSID name. Please enable precise location here or in the app settings.</string>
@ -96,8 +97,6 @@
<string name="none">No trusted wifi names</string> <string name="none">No trusted wifi names</string>
<string name="never">Never</string> <string name="never">Never</string>
<string name="stream_failed">Failed to open file stream.</string> <string name="stream_failed">Failed to open file stream.</string>
<string name="unknown_error_message">An unknown error occurred.</string>
<string name="no_file_app">No file app installed.</string>
<string name="other">Other</string> <string name="other">Other</string>
<string name="auto_tunneling">Auto-tunneling</string> <string name="auto_tunneling">Auto-tunneling</string>
<string name="select_tunnel">Select tunnel to use</string> <string name="select_tunnel">Select tunnel to use</string>
@ -108,6 +107,7 @@
<string name="create_import">Create from scratch</string> <string name="create_import">Create from scratch</string>
<string name="set_primary">Set primary</string> <string name="set_primary">Set primary</string>
<string name="turn_off_auto">Action requires auto-tunnel disabled</string> <string name="turn_off_auto">Action requires auto-tunnel disabled</string>
<string name="turn_on_tunnel">Action requires active tunnel</string>
<string name="add_peer">Add peer</string> <string name="add_peer">Add peer</string>
<string name="info">Info</string> <string name="info">Info</string>
<string name="done">Done</string> <string name="done">Done</string>
@ -128,7 +128,8 @@
<string name="cancel">Cancel</string> <string name="cancel">Cancel</string>
<string name="primary_tunnel_change">Primary tunnel change</string> <string name="primary_tunnel_change">Primary tunnel change</string>
<string name="primary_tunnel_change_question">Would you like to make this your primary tunnel?</string> <string name="primary_tunnel_change_question">Would you like to make this your primary tunnel?</string>
<string name="authentication_failed">Authentication failed</string> <string name="error_authentication_failed">Authentication failed</string>
<string name="error_authorization_failed">Failed to authorize</string>
<string name="enabled_app_shortcuts">Enable app shortcuts</string> <string name="enabled_app_shortcuts">Enable app shortcuts</string>
<string name="export_configs">Export configs</string> <string name="export_configs">Export configs</string>
<string name="battery_saver">Battery saver (beta)</string> <string name="battery_saver">Battery saver (beta)</string>
@ -137,7 +138,6 @@
<string name="precise_location_required">Precise location required</string> <string name="precise_location_required">Precise location required</string>
<string name="unknown_error">Unknown error occurred</string> <string name="unknown_error">Unknown error occurred</string>
<string name="exported_configs_message">Exported configs to downloads</string> <string name="exported_configs_message">Exported configs to downloads</string>
<string name="no_file_explorer">No file explorer installed</string>
<string name="status">status</string> <string name="status">status</string>
<string name="tunnel_on_wifi">Tunnel on untrusted wifi</string> <string name="tunnel_on_wifi">Tunnel on untrusted wifi</string>
<string name="my_email">zanecschepke@gmail.com</string> <string name="my_email">zanecschepke@gmail.com</string>
@ -154,4 +154,14 @@
<string name="support_help_text">If you are experiencing issues, have improvement ideas, or just want to engage, the following resources are available:</string> <string name="support_help_text">If you are experiencing issues, have improvement ideas, or just want to engage, the following resources are available:</string>
<string name="kernel">Kernel</string> <string name="kernel">Kernel</string>
<string name="use_kernel">Use kernel module</string> <string name="use_kernel">Use kernel module</string>
<string name="error_ssid_exists">SSID already exists</string>
<string name="error_root_denied">Root shell denied</string>
<string name="error_no_file_explorer">No file explorer installed</string>
<string name="error_no_scan">No code scanned</string>
<string name="error_invalid_code">Invalid QR code</string>
<string name="error_none">No error</string>
<string name="error_file_read">Failed to read file</string>
<string name="location_service_missing">Location Services Not Detected</string>
<string name="location_services_missing_message">The app is not detecting any location services enabled on your device. Depending on the device, this could cause the untrusted wifi feature to fail to read the wifi name. Would you like to continue anyways?</string>
<string name="auto_tunnel_title">Auto-tunnel Service</string>
</resources> </resources>

View File

@ -1,8 +1,9 @@
object Constants { object Constants {
const val VERSION_NAME = "3.2.5" const val VERSION_NAME = "3.3.0"
const val JVM_TARGET = "17" const val JVM_TARGET = "17"
const val VERSION_CODE = 32500 const val VERSION_CODE = 33000
const val TARGET_SDK = 34 const val TARGET_SDK = 28
const val COMPILE_SDK = 34
const val MIN_SDK = 26 const val MIN_SDK = 26
const val APP_ID = "com.zaneschepke.wireguardautotunnel" const val APP_ID = "com.zaneschepke.wireguardautotunnel"
const val APP_NAME = "wgtunnel" const val APP_NAME = "wgtunnel"

View File

@ -0,0 +1,8 @@
Enhancements:
- Refactor state management
- Improve AndroidTV navigation
- Improve auto-tunneling efficiency
- Improve navigation
- Auto-tunneling pause feature
- Many bugfixes