fix: mobile data tunneling

Fixes a bug where mobile data tunneling was not working properly in certain scenarios.

Fixes an issue where the new floating action button was not working correctly on AndroidTV.

Improved local logging.

Additional refactors and optimizations.
This commit is contained in:
Zane Schepke 2024-05-30 23:10:28 -04:00
parent 57bb3f5e74
commit 54d9653f04
69 changed files with 1764 additions and 967 deletions

View File

@ -4,7 +4,7 @@
We as individuals involved in this project, pledge to participate in this
community in a respectful, constructive, and civil manner as we work towards a common goal
of delivering free, open source, and value adding software for all.
of delivering free, open source, and value adding software for all.
## Standard

View File

@ -2,7 +2,7 @@ name: Issue Updates Workflow
on:
issues:
types: [opened, closed, reopened]
types: [ opened, closed, reopened ]
jobs:

View File

@ -2,7 +2,7 @@ name: Release Updates Workflow
on:
release:
types: [published]
types: [ published ]
jobs:

View File

@ -22,7 +22,8 @@ WG Tunnel
<div align="left">
This is an alternative Android Application for [WireGuard](https://www.wireguard.com/) and [AmneziaWG](https://docs.amnezia.org/documentation/amnezia-wg/) with added
This is an alternative Android Application for [WireGuard](https://www.wireguard.com/)
and [AmneziaWG](https://docs.amnezia.org/documentation/amnezia-wg/) with added
features. Built using the [wireguard-android](https://github.com/WireGuard/wireguard-android)
library and [Jetpack Compose](https://developer.android.com/jetpack/compose), this application was
inspired by the official [WireGuard Android](https://github.com/WireGuard/wireguard-android) app.
@ -73,18 +74,21 @@ The repository for these docs can be found [here](https://github.com/zaneschepke
## Contributing
Any contributions in the form of feedback, issues, code, or translations are welcome and much appreciated!
Any contributions in the form of feedback, issues, code, or translations are welcome and much
appreciated!
Please read the [code of conduct](https://github.com/zaneschepke/wgtunnel?tab=coc-ov-file#contributor-code-of-conduct) before contributing.
Please read
the [code of conduct](https://github.com/zaneschepke/wgtunnel?tab=coc-ov-file#contributor-code-of-conduct)
before contributing.
## Translation
This app is using [Weblate](https://weblate.org) to assist with translations.
This app is using [Weblate](https://weblate.org) to assist with translations.
Help translate WG Tunnel into your language at [Hosted Weblate](https://hosted.weblate.org/engage/wg-tunnel/).\
Help translate WG Tunnel into your language
at [Hosted Weblate](https://hosted.weblate.org/engage/wg-tunnel/).\
[![Translation status](https://hosted.weblate.org/widgets/wg-tunnel/-/multi-auto.svg)](https://hosted.weblate.org/engage/wg-tunnel/)
## Building
```

View File

@ -203,7 +203,6 @@ dependencies {
// barcode scanning
implementation(libs.zxing.android.embedded)
implementation(libs.zxing.core)
// bio
implementation(libs.androidx.biometric.ktx)

View File

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

View File

@ -21,6 +21,4 @@
#-renamesourcefileattribute SourceFile
-keepclassmembers class * extends androidx.datastore.preferences.protobuf.GeneratedMessageLite {
<fields>;
}
}

View File

@ -3,35 +3,59 @@ package com.zaneschepke.wireguardautotunnel
import android.app.Application
import android.content.ComponentName
import android.content.pm.PackageManager
import android.os.StrictMode
import android.os.StrictMode.ThreadPolicy
import android.service.quicksettings.TileService
import com.zaneschepke.logcatter.LocalLogCollector
import com.zaneschepke.wireguardautotunnel.module.ApplicationScope
import com.zaneschepke.wireguardautotunnel.module.IoDispatcher
import com.zaneschepke.wireguardautotunnel.service.tile.AutoTunnelControlTile
import com.zaneschepke.wireguardautotunnel.service.tile.TunnelControlTile
import com.zaneschepke.wireguardautotunnel.util.ReleaseTree
import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.cancel
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import timber.log.Timber
import xyz.teamgravity.pin_lock_compose.PinManager
import javax.inject.Inject
@HiltAndroidApp
class WireGuardAutoTunnel : Application() {
@Inject
lateinit var localLogCollector: LocalLogCollector
@Inject
@ApplicationScope
lateinit var applicationScope: CoroutineScope
@Inject
@IoDispatcher
lateinit var ioDispatcher: CoroutineDispatcher
override fun onCreate() {
super.onCreate()
instance = this
if (BuildConfig.DEBUG) Timber.plant(Timber.DebugTree()) else Timber.plant(ReleaseTree())
PinManager.initialize(this)
}
override fun onLowMemory() {
super.onLowMemory()
applicationScope.cancel("onLowMemory() called by system")
applicationScope = MainScope()
if (BuildConfig.DEBUG) {
Timber.plant(Timber.DebugTree())
StrictMode.setThreadPolicy(
ThreadPolicy.Builder()
.detectDiskReads()
.detectDiskWrites()
.detectNetwork()
.penaltyLog()
.build(),
)
} else Timber.plant(ReleaseTree())
applicationScope.launch(ioDispatcher) {
PinManager.initialize(this@WireGuardAutoTunnel)
if (!isRunningOnAndroidTv()) localLogCollector.start()
}
}
companion object {
var applicationScope = MainScope()
lateinit var instance: WireGuardAutoTunnel
private set

View File

@ -33,7 +33,7 @@ import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig
to = 7,
spec = RemoveLegacySettingColumnsMigration::class,
),
AutoMigration(7, 8)
AutoMigration(7, 8),
],
exportSchema = true,
)

View File

@ -21,7 +21,7 @@ interface TunnelConfigDao {
suspend fun getById(id: Long): TunnelConfig?
@Query("SELECT * FROM TunnelConfig WHERE name=:name")
suspend fun getByName(name: String) : TunnelConfig?
suspend fun getByName(name: String): TunnelConfig?
@Query("SELECT * FROM TunnelConfig")
suspend fun getAll(): TunnelConfigs
@ -36,10 +36,10 @@ interface TunnelConfigDao {
suspend fun findByTunnelNetworkName(name: String): TunnelConfigs
@Query("UPDATE TunnelConfig SET is_primary_tunnel = 0 WHERE is_primary_tunnel =1")
fun resetPrimaryTunnel()
suspend fun resetPrimaryTunnel()
@Query("UPDATE TunnelConfig SET is_mobile_data_tunnel = 0 WHERE is_mobile_data_tunnel =1")
fun resetMobileDataTunnel()
suspend fun resetMobileDataTunnel()
@Query("SELECT * FROM TUNNELCONFIG WHERE is_primary_tunnel=1")
suspend fun findByPrimary(): TunnelConfigs

View File

@ -7,14 +7,20 @@ import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.zaneschepke.wireguardautotunnel.module.IoDispatcher
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.io.IOException
class DataStoreManager(private val context: Context) {
class DataStoreManager(
private val context: Context,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher
) {
companion object {
val LOCATION_DISCLOSURE_SHOWN = booleanPreferencesKey("LOCATION_DISCLOSURE_SHOWN")
val BATTERY_OPTIMIZE_DISABLE_SHOWN = booleanPreferencesKey("BATTERY_OPTIMIZE_DISABLE_SHOWN")
@ -32,20 +38,24 @@ class DataStoreManager(private val context: Context) {
)
suspend fun init() {
try {
context.dataStore.data.first()
} catch (e: IOException) {
Timber.e(e)
withContext(ioDispatcher) {
try {
context.dataStore.data.first()
} catch (e: IOException) {
Timber.e(e)
}
}
}
suspend fun <T> saveToDataStore(key: Preferences.Key<T>, value: T) {
try {
context.dataStore.edit { it[key] = value }
} catch (e: IOException) {
Timber.e(e)
} catch (e: Exception) {
Timber.e(e)
withContext(ioDispatcher) {
try {
context.dataStore.edit { it[key] = value }
} catch (e: IOException) {
Timber.e(e)
} catch (e: Exception) {
Timber.e(e)
}
}
}
@ -53,11 +63,13 @@ class DataStoreManager(private val context: Context) {
fun <T> getFromStoreFlow(key: Preferences.Key<T>) = context.dataStore.data.map { it[key] }
suspend fun <T> getFromStore(key: Preferences.Key<T>): T? {
return try {
context.dataStore.data.map { it[key] }.first()
} catch (e: IOException) {
Timber.e(e)
null
return withContext(ioDispatcher) {
try {
context.dataStore.data.map { it[key] }.first()
} catch (e: IOException) {
Timber.e(e)
null
}
}
}

View File

@ -40,12 +40,14 @@ data class TunnelConfig(
Config.parse(it)
}
}
fun configFromAmQuick(amQuick: String) : org.amnezia.awg.config.Config {
fun configFromAmQuick(amQuick: String): org.amnezia.awg.config.Config {
val inputStream: InputStream = amQuick.byteInputStream()
return inputStream.bufferedReader(Charsets.UTF_8).use {
org.amnezia.awg.config.Config.parse(it)
}
}
const val AM_QUICK_DEFAULT = ""
}
}

View File

@ -22,7 +22,7 @@ interface TunnelConfigRepository {
suspend fun count(): Int
suspend fun findByTunnelName(name : String) : TunnelConfig?
suspend fun findByTunnelName(name: String): TunnelConfig?
suspend fun findByTunnelNetworksName(name: String): TunnelConfigs

View File

@ -0,0 +1,30 @@
package com.zaneschepke.wireguardautotunnel.module
import android.content.Context
import com.zaneschepke.logcatter.LocalLogCollector
import com.zaneschepke.logcatter.LogcatHelper
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
class AppModule {
@Singleton
@ApplicationScope
@Provides
fun providesApplicationScope(@DefaultDispatcher defaultDispatcher: CoroutineDispatcher): CoroutineScope =
CoroutineScope(SupervisorJob() + defaultDispatcher)
@Singleton
@Provides
fun provideLogCollect(@ApplicationContext context: Context): LocalLogCollector {
return LogcatHelper.init(context = context)
}
}

View File

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

View File

@ -0,0 +1,27 @@
package com.zaneschepke.wireguardautotunnel.module
import javax.inject.Qualifier
@Retention(AnnotationRetention.RUNTIME)
@Qualifier
annotation class DefaultDispatcher
@Retention(AnnotationRetention.RUNTIME)
@Qualifier
annotation class IoDispatcher
@Retention(AnnotationRetention.RUNTIME)
@Qualifier
annotation class MainDispatcher
@Retention(AnnotationRetention.BINARY)
@Qualifier
annotation class MainImmediateDispatcher
@Retention(AnnotationRetention.RUNTIME)
@Qualifier
annotation class ApplicationScope
@Retention(AnnotationRetention.RUNTIME)
@Qualifier
annotation class ServiceScope

View File

@ -0,0 +1,28 @@
package com.zaneschepke.wireguardautotunnel.module
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
@Module
@InstallIn(SingletonComponent::class)
object CoroutinesDispatchersModule {
@DefaultDispatcher
@Provides
fun providesDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Default
@IoDispatcher
@Provides
fun providesIoDispatcher(): CoroutineDispatcher = Dispatchers.IO
@MainDispatcher
@Provides
fun providesMainDispatcher(): CoroutineDispatcher = Dispatchers.Main
@MainImmediateDispatcher
@Provides
fun providesMainImmediateDispatcher(): CoroutineDispatcher = Dispatchers.Main.immediate
}

View File

@ -1,30 +0,0 @@
package com.zaneschepke.wireguardautotunnel.module
import android.content.Context
import androidx.room.Room
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.data.AppDatabase
import com.zaneschepke.wireguardautotunnel.data.DatabaseCallback
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
class DatabaseModule {
@Provides
@Singleton
fun provideDatabase(@ApplicationContext context: Context): AppDatabase {
return Room.databaseBuilder(
context,
AppDatabase::class.java,
context.getString(R.string.db_name),
)
.fallbackToDestructiveMigration()
.addCallback(DatabaseCallback())
.build()
}
}

View File

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

View File

@ -1,7 +1,10 @@
package com.zaneschepke.wireguardautotunnel.module
import android.content.Context
import androidx.room.Room
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.data.AppDatabase
import com.zaneschepke.wireguardautotunnel.data.DatabaseCallback
import com.zaneschepke.wireguardautotunnel.data.SettingsDao
import com.zaneschepke.wireguardautotunnel.data.TunnelConfigDao
import com.zaneschepke.wireguardautotunnel.data.datastore.DataStoreManager
@ -18,11 +21,25 @@ import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineDispatcher
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
class RepositoryModule {
@Provides
@Singleton
fun provideDatabase(@ApplicationContext context: Context): AppDatabase {
return Room.databaseBuilder(
context,
AppDatabase::class.java,
context.getString(R.string.db_name),
)
.fallbackToDestructiveMigration()
.addCallback(DatabaseCallback())
.build()
}
@Singleton
@Provides
fun provideSettingsDoa(appDatabase: AppDatabase): SettingsDao {
@ -49,8 +66,11 @@ class RepositoryModule {
@Singleton
@Provides
fun providePreferencesDataStore(@ApplicationContext context: Context): DataStoreManager {
return DataStoreManager(context)
fun providePreferencesDataStore(
@ApplicationContext context: Context,
@IoDispatcher ioDispatcher: CoroutineDispatcher
): DataStoreManager {
return DataStoreManager(context, ioDispatcher)
}
@Provides

View File

@ -15,6 +15,8 @@ import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import javax.inject.Singleton
@Module
@ -42,7 +44,7 @@ class TunnelModule {
@Provides
@Singleton
fun provideAmneziaBackend(@ApplicationContext context: Context) : org.amnezia.awg.backend.Backend {
fun provideAmneziaBackend(@ApplicationContext context: Context): org.amnezia.awg.backend.Backend {
return org.amnezia.awg.backend.GoBackend(context)
}
@ -52,14 +54,26 @@ class TunnelModule {
amneziaBackend: org.amnezia.awg.backend.Backend,
@Userspace userspaceBackend: Backend,
@Kernel kernelBackend: Backend,
appDataRepository: AppDataRepository
appDataRepository: AppDataRepository,
@ApplicationScope applicationScope: CoroutineScope,
@IoDispatcher ioDispatcher: CoroutineDispatcher
): VpnService {
return WireGuardTunnel(amneziaBackend,userspaceBackend, kernelBackend, appDataRepository)
return WireGuardTunnel(
amneziaBackend,
userspaceBackend,
kernelBackend,
appDataRepository,
applicationScope,
ioDispatcher,
)
}
@Provides
@Singleton
fun provideServiceManager(appDataRepository: AppDataRepository): ServiceManager {
return ServiceManager(appDataRepository)
fun provideServiceManager(
appDataRepository: AppDataRepository,
@IoDispatcher ioDispatcher: CoroutineDispatcher
): ServiceManager {
return ServiceManager(appDataRepository, ioDispatcher)
}
}

View File

@ -0,0 +1,25 @@
package com.zaneschepke.wireguardautotunnel.module
import android.content.Context
import com.zaneschepke.wireguardautotunnel.util.FileUtils
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.components.ViewModelComponent
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.scopes.ViewModelScoped
import kotlinx.coroutines.CoroutineDispatcher
@Module
@InstallIn(ViewModelComponent::class)
class ViewModelModule {
@ViewModelScoped
@Provides
fun provideFileUtils(
@ApplicationContext context: Context,
@IoDispatcher ioDispatcher: CoroutineDispatcher
): FileUtils {
return FileUtils(context, ioDispatcher)
}
}

View File

@ -24,6 +24,7 @@ open class ForegroundService : LifecycleService() {
when (action) {
Action.START.name,
Action.START_FOREGROUND.name -> startService(intent.extras)
Action.STOP.name, Action.STOP_FOREGROUND.name -> stopService()
Constants.ALWAYS_ON_VPN_ACTION -> {
Timber.i("Always-on VPN starting service")

View File

@ -4,10 +4,16 @@ import android.app.Service
import android.content.Context
import android.content.Intent
import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.module.IoDispatcher
import com.zaneschepke.wireguardautotunnel.util.Constants
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import timber.log.Timber
class ServiceManager(private val appDataRepository: AppDataRepository) {
class ServiceManager(
private val appDataRepository: AppDataRepository,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher
) {
private fun <T : Service> actionOnService(
action: Action,
@ -23,7 +29,10 @@ class ServiceManager(private val appDataRepository: AppDataRepository) {
intent.component?.javaClass
try {
when (action) {
Action.START_FOREGROUND, Action.STOP_FOREGROUND -> context.startForegroundService(intent)
Action.START_FOREGROUND, Action.STOP_FOREGROUND -> context.startForegroundService(
intent,
)
Action.START, Action.STOP -> context.startService(intent)
}
} catch (e: Exception) {
@ -46,23 +55,27 @@ class ServiceManager(private val appDataRepository: AppDataRepository) {
}
suspend fun stopVpnServiceForeground(context: Context, isManualStop: Boolean = false) {
if (isManualStop) onManualStop()
Timber.i("Stopping vpn service")
actionOnService(
Action.STOP_FOREGROUND,
context,
WireGuardTunnelService::class.java,
)
withContext(ioDispatcher) {
if (isManualStop) onManualStop()
Timber.i("Stopping vpn service")
actionOnService(
Action.STOP_FOREGROUND,
context,
WireGuardTunnelService::class.java,
)
}
}
suspend fun stopVpnService(context: Context, isManualStop: Boolean = false) {
if (isManualStop) onManualStop()
Timber.i("Stopping vpn service")
actionOnService(
Action.STOP,
context,
WireGuardTunnelService::class.java,
)
withContext(ioDispatcher) {
if (isManualStop) onManualStop()
Timber.i("Stopping vpn service")
actionOnService(
Action.STOP,
context,
WireGuardTunnelService::class.java,
)
}
}
private suspend fun onManualStop() {
@ -80,13 +93,15 @@ class ServiceManager(private val appDataRepository: AppDataRepository) {
tunnelId: Int? = null,
isManualStart: Boolean = false
) {
if (isManualStart) onManualStart(tunnelId)
actionOnService(
Action.START_FOREGROUND,
context,
WireGuardTunnelService::class.java,
tunnelId?.let { mapOf(Constants.TUNNEL_EXTRA_KEY to it) },
)
withContext(ioDispatcher) {
if (isManualStart) onManualStart(tunnelId)
actionOnService(
Action.START_FOREGROUND,
context,
WireGuardTunnelService::class.java,
tunnelId?.let { mapOf(Constants.TUNNEL_EXTRA_KEY to it) },
)
}
}
fun startWatcherServiceForeground(

View File

@ -21,13 +21,6 @@ data class WatcherState(
isMobileDataConnected)
}
fun isTunnelOnMobileDataPreferredConditionMet(): Boolean {
return (!isEthernetConnected &&
settings.isTunnelOnMobileDataEnabled &&
!isWifiConnected &&
isMobileDataConnected)
}
fun isTunnelOffOnMobileDataConditionMet(): Boolean {
return (!isEthernetConnected &&
!settings.isTunnelOnMobileDataEnabled &&

View File

@ -8,6 +8,8 @@ import androidx.lifecycle.lifecycleScope
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig
import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.module.IoDispatcher
import com.zaneschepke.wireguardautotunnel.module.MainImmediateDispatcher
import com.zaneschepke.wireguardautotunnel.service.network.EthernetService
import com.zaneschepke.wireguardautotunnel.service.network.MobileDataService
import com.zaneschepke.wireguardautotunnel.service.network.NetworkService
@ -19,13 +21,14 @@ import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService
import com.zaneschepke.wireguardautotunnel.util.Constants
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.net.InetAddress
import javax.inject.Inject
@ -56,6 +59,14 @@ class WireGuardConnectivityWatcherService : ForegroundService() {
@Inject
lateinit var serviceManager: ServiceManager
@Inject
@IoDispatcher
lateinit var ioDispatcher: CoroutineDispatcher
@Inject
@MainImmediateDispatcher
lateinit var mainImmediateDispatcher: CoroutineDispatcher
private val networkEventsFlow = MutableStateFlow(WatcherState())
private var watcherJob: Job? = null
@ -65,7 +76,7 @@ class WireGuardConnectivityWatcherService : ForegroundService() {
override fun onCreate() {
super.onCreate()
lifecycleScope.launch(Dispatchers.Main) {
lifecycleScope.launch(mainImmediateDispatcher) {
try {
if (appDataRepository.settings.getSettings().isAutoTunnelPaused) {
launchWatcherPausedNotification()
@ -138,14 +149,14 @@ class WireGuardConnectivityWatcherService : ForegroundService() {
private fun cancelWatcherJob() {
try {
watcherJob?.cancel()
} catch (e : CancellationException) {
} catch (e: CancellationException) {
Timber.i("Watcher job cancelled")
}
}
private fun startWatcherJob() {
watcherJob =
lifecycleScope.launch(Dispatchers.IO) {
lifecycleScope.launch {
val setting = appDataRepository.settings.getSettings()
launch {
Timber.i("Starting wifi watcher")
@ -182,69 +193,74 @@ class WireGuardConnectivityWatcherService : ForegroundService() {
}
private suspend fun watchForMobileDataConnectivityChanges() {
mobileDataService.networkStatus.collect { status ->
when (status) {
is NetworkStatus.Available -> {
Timber.i("Gained Mobile data connection")
networkEventsFlow.update {
it.copy(
isMobileDataConnected = true,
)
withContext(ioDispatcher) {
mobileDataService.networkStatus.collect { status ->
when (status) {
is NetworkStatus.Available -> {
Timber.i("Gained Mobile data connection")
networkEventsFlow.update {
it.copy(
isMobileDataConnected = true,
)
}
}
}
is NetworkStatus.CapabilitiesChanged -> {
networkEventsFlow.update {
it.copy(
isMobileDataConnected = true,
)
is NetworkStatus.CapabilitiesChanged -> {
networkEventsFlow.update {
it.copy(
isMobileDataConnected = true,
)
}
Timber.i("Mobile data capabilities changed")
}
Timber.i("Mobile data capabilities changed")
}
is NetworkStatus.Unavailable -> {
networkEventsFlow.update {
it.copy(
isMobileDataConnected = false,
)
is NetworkStatus.Unavailable -> {
networkEventsFlow.update {
it.copy(
isMobileDataConnected = false,
)
}
Timber.i("Lost mobile data connection")
}
Timber.i("Lost mobile data connection")
}
}
}
}
private suspend fun watchForPingFailure() {
try {
do {
if (vpnService.vpnState.value.status == TunnelState.UP) {
val tunnelConfig = vpnService.vpnState.value.tunnelConfig
tunnelConfig?.let {
val config = TunnelConfig.configFromWgQuick(it.wgQuick)
val results = config.peers.map { peer ->
val host = if (peer.endpoint.isPresent &&
peer.endpoint.get().resolved.isPresent)
peer.endpoint.get().resolved.get().host
else Constants.DEFAULT_PING_IP
Timber.i("Checking reachability of: $host")
val reachable = InetAddress.getByName(host)
.isReachable(Constants.PING_TIMEOUT.toInt())
Timber.i("Result: reachable - $reachable")
reachable
}
if (results.contains(false)) {
Timber.i("Restarting VPN for ping failure")
serviceManager.stopVpnServiceForeground(this)
delay(Constants.VPN_RESTART_DELAY)
serviceManager.startVpnServiceForeground(this, it.id)
delay(Constants.PING_COOLDOWN)
val context = this
withContext(ioDispatcher) {
try {
do {
if (vpnService.vpnState.value.status == TunnelState.UP) {
val tunnelConfig = vpnService.vpnState.value.tunnelConfig
tunnelConfig?.let {
val config = TunnelConfig.configFromWgQuick(it.wgQuick)
val results = config.peers.map { peer ->
val host = if (peer.endpoint.isPresent &&
peer.endpoint.get().resolved.isPresent)
peer.endpoint.get().resolved.get().host
else Constants.DEFAULT_PING_IP
Timber.i("Checking reachability of: $host")
val reachable = InetAddress.getByName(host)
.isReachable(Constants.PING_TIMEOUT.toInt())
Timber.i("Result: reachable - $reachable")
reachable
}
if (results.contains(false)) {
Timber.i("Restarting VPN for ping failure")
serviceManager.stopVpnServiceForeground(context)
delay(Constants.VPN_RESTART_DELAY)
serviceManager.startVpnServiceForeground(context, it.id)
delay(Constants.PING_COOLDOWN)
}
}
}
}
delay(Constants.PING_INTERVAL)
} while (true)
} catch (e: Exception) {
Timber.e(e)
delay(Constants.PING_INTERVAL)
} while (true)
} catch (e: Exception) {
Timber.e(e)
}
}
}
@ -265,77 +281,82 @@ class WireGuardConnectivityWatcherService : ForegroundService() {
}
private suspend fun watchForEthernetConnectivityChanges() {
ethernetService.networkStatus.collect { status ->
when (status) {
is NetworkStatus.Available -> {
Timber.i("Gained Ethernet connection")
networkEventsFlow.update {
it.copy(
isEthernetConnected = true,
)
withContext(ioDispatcher) {
ethernetService.networkStatus.collect { status ->
when (status) {
is NetworkStatus.Available -> {
Timber.i("Gained Ethernet connection")
networkEventsFlow.update {
it.copy(
isEthernetConnected = true,
)
}
}
}
is NetworkStatus.CapabilitiesChanged -> {
Timber.i("Ethernet capabilities changed")
networkEventsFlow.update {
it.copy(
isEthernetConnected = true,
)
is NetworkStatus.CapabilitiesChanged -> {
Timber.i("Ethernet capabilities changed")
networkEventsFlow.update {
it.copy(
isEthernetConnected = true,
)
}
}
}
is NetworkStatus.Unavailable -> {
networkEventsFlow.update {
it.copy(
isEthernetConnected = false,
)
is NetworkStatus.Unavailable -> {
networkEventsFlow.update {
it.copy(
isEthernetConnected = false,
)
}
Timber.i("Lost Ethernet connection")
}
Timber.i("Lost Ethernet connection")
}
}
}
}
private suspend fun watchForWifiConnectivityChanges() {
wifiService.networkStatus.collect { status ->
when (status) {
is NetworkStatus.Available -> {
Timber.i("Gained Wi-Fi connection")
networkEventsFlow.update {
it.copy(
isWifiConnected = true,
)
}
}
is NetworkStatus.CapabilitiesChanged -> {
Timber.i("Wifi capabilities changed")
networkEventsFlow.update {
it.copy(
isWifiConnected = true,
)
}
val ssid = wifiService.getNetworkName(status.networkCapabilities)
ssid?.let { name ->
if(name.contains(Constants.UNREADABLE_SSID)) {
Timber.w("SSID unreadable: missing permissions")
} else Timber.i("Detected valid SSID")
appDataRepository.appState.setCurrentSsid(name)
withContext(ioDispatcher) {
wifiService.networkStatus.collect { status ->
when (status) {
is NetworkStatus.Available -> {
Timber.i("Gained Wi-Fi connection")
networkEventsFlow.update {
it.copy(
currentNetworkSSID = name,
isWifiConnected = true,
)
}
} ?: Timber.w("Failed to read ssid")
}
is NetworkStatus.Unavailable -> {
networkEventsFlow.update {
it.copy(
isWifiConnected = false,
)
}
Timber.i("Lost Wi-Fi connection")
is NetworkStatus.CapabilitiesChanged -> {
Timber.i("Wifi capabilities changed")
networkEventsFlow.update {
it.copy(
isWifiConnected = true,
)
}
val ssid = wifiService.getNetworkName(status.networkCapabilities)
ssid?.let { name ->
if (name.contains(Constants.UNREADABLE_SSID)) {
Timber.w("SSID unreadable: missing permissions")
} else Timber.i("Detected valid SSID")
appDataRepository.appState.setCurrentSsid(name)
networkEventsFlow.update {
it.copy(
currentNetworkSSID = name,
)
}
} ?: Timber.w("Failed to read ssid")
}
is NetworkStatus.Unavailable -> {
networkEventsFlow.update {
it.copy(
isWifiConnected = false,
)
}
Timber.i("Lost Wi-Fi connection")
}
}
}
}
@ -349,78 +370,89 @@ class WireGuardConnectivityWatcherService : ForegroundService() {
return appDataRepository.tunnels.findByTunnelNetworksName(ssid).firstOrNull()
}
private fun isTunnelDown() : Boolean {
private fun isTunnelDown(): Boolean {
return vpnService.vpnState.value.status == TunnelState.DOWN
}
private suspend fun manageVpn() {
networkEventsFlow.collectLatest { watcherState ->
val autoTunnel = "Auto-tunnel watcher"
if (!watcherState.settings.isAutoTunnelPaused) {
//delay for rapid network state changes and then collect latest
delay(Constants.WATCHER_COLLECTION_DELAY)
val tunnelConfig = vpnService.vpnState.value.tunnelConfig
when {
watcherState.isEthernetConditionMet() -> {
Timber.i("$autoTunnel - tunnel on on ethernet condition met")
if(isTunnelDown()) serviceManager.startVpnServiceForeground(this)
}
val context = this
withContext(ioDispatcher) {
networkEventsFlow.collectLatest { watcherState ->
val autoTunnel = "Auto-tunnel watcher"
if (!watcherState.settings.isAutoTunnelPaused) {
//delay for rapid network state changes and then collect latest
delay(Constants.WATCHER_COLLECTION_DELAY)
val tunnelConfig = vpnService.vpnState.value.tunnelConfig
when {
watcherState.isEthernetConditionMet() -> {
Timber.i("$autoTunnel - tunnel on on ethernet condition met")
if (isTunnelDown()) serviceManager.startVpnServiceForeground(context)
}
watcherState.isMobileDataConditionMet() -> {
Timber.i("$autoTunnel - tunnel on on mobile data condition met")
if(isTunnelDown()) serviceManager.startVpnServiceForeground(this, getMobileDataTunnel()?.id)
}
watcherState.isTunnelOnMobileDataPreferredConditionMet() -> {
if(tunnelConfig?.isMobileDataTunnel == false) {
getMobileDataTunnel()?.let {
watcherState.isMobileDataConditionMet() -> {
Timber.i("$autoTunnel - tunnel on on mobile data condition met")
val mobileDataTunnel = getMobileDataTunnel()
val tunnel =
mobileDataTunnel ?: appDataRepository.getPrimaryOrFirstTunnel()
if (isTunnelDown()) return@collectLatest serviceManager.startVpnServiceForeground(
context,
tunnel?.id,
)
if (tunnelConfig?.isMobileDataTunnel == false && mobileDataTunnel != null) {
Timber.i("$autoTunnel - tunnel connected on mobile data is not preferred condition met, switching to preferred")
if(isTunnelDown()) serviceManager.startVpnServiceForeground(
this,
getMobileDataTunnel()?.id,
serviceManager.startVpnServiceForeground(
context,
mobileDataTunnel.id,
)
}
}
}
watcherState.isTunnelOffOnMobileDataConditionMet() -> {
Timber.i("$autoTunnel - tunnel off on mobile data met, turning vpn off")
if(!isTunnelDown()) serviceManager.stopVpnServiceForeground(this)
}
watcherState.isUntrustedWifiConditionMet() -> {
if(tunnelConfig?.tunnelNetworks?.contains(watcherState.currentNetworkSSID) == false ||
tunnelConfig == null) {
Timber.i("$autoTunnel - tunnel on ssid not associated with current tunnel condition met")
getSsidTunnel(watcherState.currentNetworkSSID)?.let {
Timber.i("Found tunnel associated with this SSID, bringing tunnel up")
if(isTunnelDown()) serviceManager.startVpnServiceForeground(this, it.id)
} ?: suspend {
Timber.i("No tunnel associated with this SSID, using defaults")
if (appDataRepository.getPrimaryOrFirstTunnel()?.name != vpnService.name) {
if(isTunnelDown()) serviceManager.startVpnServiceForeground(this)
}
}.invoke()
watcherState.isTunnelOffOnMobileDataConditionMet() -> {
Timber.i("$autoTunnel - tunnel off on mobile data met, turning vpn off")
if (!isTunnelDown()) serviceManager.stopVpnServiceForeground(context)
}
}
watcherState.isTrustedWifiConditionMet() -> {
Timber.i("$autoTunnel - tunnel off on trusted wifi condition met, turning vpn off")
if(!isTunnelDown()) serviceManager.stopVpnServiceForeground(this)
}
watcherState.isUntrustedWifiConditionMet() -> {
if (tunnelConfig?.tunnelNetworks?.contains(watcherState.currentNetworkSSID) == false ||
tunnelConfig == null) {
Timber.i("$autoTunnel - tunnel on ssid not associated with current tunnel condition met")
getSsidTunnel(watcherState.currentNetworkSSID)?.let {
Timber.i("Found tunnel associated with this SSID, bringing tunnel up")
if (isTunnelDown()) serviceManager.startVpnServiceForeground(
context,
it.id,
)
} ?: suspend {
Timber.i("No tunnel associated with this SSID, using defaults")
val default = appDataRepository.getPrimaryOrFirstTunnel()
if (default?.name != vpnService.name) {
default?.let {
serviceManager.startVpnServiceForeground(context, it.id)
}
watcherState.isTunnelOffOnWifiConditionMet() -> {
Timber.i("$autoTunnel - tunnel off on wifi condition met, turning vpn off")
if(!isTunnelDown()) serviceManager.stopVpnServiceForeground(this)
}
}
}.invoke()
}
}
watcherState.isTunnelOffOnNoConnectivityMet() -> {
Timber.i("$autoTunnel - tunnel off on no connectivity met, turning vpn off")
if(!isTunnelDown()) serviceManager.stopVpnServiceForeground(this)
}
watcherState.isTrustedWifiConditionMet() -> {
Timber.i("$autoTunnel - tunnel off on trusted wifi condition met, turning vpn off")
if (!isTunnelDown()) serviceManager.stopVpnServiceForeground(context)
}
else -> {
Timber.i("$autoTunnel - no condition met")
watcherState.isTunnelOffOnWifiConditionMet() -> {
Timber.i("$autoTunnel - tunnel off on wifi condition met, turning vpn off")
if (!isTunnelDown()) serviceManager.stopVpnServiceForeground(context)
}
watcherState.isTunnelOffOnNoConnectivityMet() -> {
Timber.i("$autoTunnel - tunnel off on no connectivity met, turning vpn off")
if (!isTunnelDown()) serviceManager.stopVpnServiceForeground(context)
}
else -> {
Timber.i("$autoTunnel - no condition met")
}
}
}
}

View File

@ -7,6 +7,8 @@ import androidx.core.app.ServiceCompat
import androidx.lifecycle.lifecycleScope
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.module.IoDispatcher
import com.zaneschepke.wireguardautotunnel.module.MainImmediateDispatcher
import com.zaneschepke.wireguardautotunnel.receiver.NotificationActionReceiver
import com.zaneschepke.wireguardautotunnel.service.notification.NotificationService
import com.zaneschepke.wireguardautotunnel.service.tunnel.HandshakeStatus
@ -17,10 +19,11 @@ import com.zaneschepke.wireguardautotunnel.util.handshakeStatus
import com.zaneschepke.wireguardautotunnel.util.mapPeerStats
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
import javax.inject.Inject
@ -37,13 +40,21 @@ class WireGuardTunnelService : ForegroundService() {
@Inject
lateinit var notificationService: NotificationService
@Inject
@MainImmediateDispatcher
lateinit var mainImmediateDispatcher: CoroutineDispatcher
@Inject
@IoDispatcher
lateinit var ioDispatcher: CoroutineDispatcher
private var job: Job? = null
private var didShowConnected = false
override fun onCreate() {
super.onCreate()
lifecycleScope.launch(Dispatchers.Main) {
lifecycleScope.launch(mainImmediateDispatcher) {
//TODO fix this to not launch if AOVPN
if (appDataRepository.tunnels.count() != 0) {
launchVpnNotification()
@ -55,7 +66,7 @@ class WireGuardTunnelService : ForegroundService() {
super.startService(extras)
cancelJob()
job =
lifecycleScope.launch(Dispatchers.IO) {
lifecycleScope.launch {
launch {
val tunnelId = extras?.getInt(Constants.TUNNEL_EXTRA_KEY)
if (vpnService.getState() == TunnelState.UP) {
@ -75,39 +86,41 @@ class WireGuardTunnelService : ForegroundService() {
//TODO improve tunnel notifications
private suspend fun handshakeNotifications() {
var tunnelName: String? = null
vpnService.vpnState.collect { state ->
withContext(ioDispatcher) {
var tunnelName: String? = null
vpnService.vpnState.collect { state ->
state.statistics
?.mapPeerStats()
?.map { it.value?.handshakeStatus() }
.let { statuses ->
when {
statuses?.all { it == HandshakeStatus.HEALTHY } == true -> {
if (!didShowConnected) {
delay(Constants.VPN_CONNECTED_NOTIFICATION_DELAY)
tunnelName = state.tunnelConfig?.name
launchVpnNotification(
getString(R.string.tunnel_start_title),
"${getString(R.string.tunnel_start_text)} - $tunnelName",
)
didShowConnected = true
?.mapPeerStats()
?.map { it.value?.handshakeStatus() }
.let { statuses ->
when {
statuses?.all { it == HandshakeStatus.HEALTHY } == true -> {
if (!didShowConnected) {
delay(Constants.VPN_CONNECTED_NOTIFICATION_DELAY)
tunnelName = state.tunnelConfig?.name
launchVpnNotification(
getString(R.string.tunnel_start_title),
"${getString(R.string.tunnel_start_text)} - $tunnelName",
)
didShowConnected = true
}
}
}
statuses?.any { it == HandshakeStatus.STALE } == true -> {}
statuses?.all { it == HandshakeStatus.NOT_STARTED } ==
true -> {
}
statuses?.any { it == HandshakeStatus.STALE } == true -> {}
statuses?.all { it == HandshakeStatus.NOT_STARTED } ==
true -> {
}
else -> {}
else -> {}
}
}
if (state.status == TunnelState.UP && state.tunnelConfig?.name != tunnelName) {
tunnelName = state.tunnelConfig?.name
launchVpnNotification(
getString(R.string.tunnel_start_title),
"${getString(R.string.tunnel_start_text)} - $tunnelName",
)
}
if (state.status == TunnelState.UP && state.tunnelConfig?.name != tunnelName) {
tunnelName = state.tunnelConfig?.name
launchVpnNotification(
getString(R.string.tunnel_start_title),
"${getString(R.string.tunnel_start_text)} - $tunnelName",
)
}
}
}
@ -121,7 +134,7 @@ class WireGuardTunnelService : ForegroundService() {
override fun stopService() {
super.stopService()
lifecycleScope.launch(Dispatchers.IO) {
lifecycleScope.launch {
vpnService.stopTunnel()
didShowConnected = false
}
@ -181,7 +194,7 @@ class WireGuardTunnelService : ForegroundService() {
private fun cancelJob() {
try {
job?.cancel()
} catch (e : CancellationException) {
} catch (e: CancellationException) {
Timber.i("Tunnel job cancelled")
}
}

View File

@ -2,14 +2,14 @@ package com.zaneschepke.wireguardautotunnel.service.shortcut
import android.os.Bundle
import androidx.activity.ComponentActivity
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.module.ApplicationScope
import com.zaneschepke.wireguardautotunnel.service.foreground.Action
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import com.zaneschepke.wireguardautotunnel.service.foreground.WireGuardConnectivityWatcherService
import com.zaneschepke.wireguardautotunnel.service.foreground.WireGuardTunnelService
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
@ -22,9 +22,13 @@ class ShortcutsActivity : ComponentActivity() {
@Inject
lateinit var serviceManager: ServiceManager
@Inject
@ApplicationScope
lateinit var applicationScope: CoroutineScope
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
WireGuardAutoTunnel.applicationScope.launch(Dispatchers.IO) {
applicationScope.launch {
val settings = appDataRepository.settings.getSettings()
if (settings.isShortcutsEnabled) {
when (intent.getStringExtra(CLASS_NAME_EXTRA_KEY)) {

View File

@ -6,12 +6,11 @@ import android.service.quicksettings.TileService
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig
import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.module.ApplicationScope
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@ -25,29 +24,30 @@ class AutoTunnelControlTile : TileService() {
@Inject
lateinit var serviceManager: ServiceManager
private val scope = CoroutineScope(Dispatchers.IO)
@Inject
@ApplicationScope
lateinit var applicationScope: CoroutineScope
private var manualStartConfig: TunnelConfig? = null
override fun onStartListening() {
super.onStartListening()
scope.launch {
appDataRepository.settings.getSettingsFlow().collectLatest {
when (it.isAutoTunnelEnabled) {
true -> {
if (it.isAutoTunnelPaused) {
setInactive()
setTileDescription(this@AutoTunnelControlTile.getString(R.string.paused))
} else {
setActive()
setTileDescription(this@AutoTunnelControlTile.getString(R.string.active))
}
applicationScope.launch {
val settings = appDataRepository.settings.getSettings()
when (settings.isAutoTunnelEnabled) {
true -> {
if (settings.isAutoTunnelPaused) {
setInactive()
setTileDescription(this@AutoTunnelControlTile.getString(R.string.paused))
} else {
setActive()
setTileDescription(this@AutoTunnelControlTile.getString(R.string.active))
}
}
false -> {
setTileDescription(this@AutoTunnelControlTile.getString(R.string.disabled))
setUnavailable()
}
false -> {
setTileDescription(this@AutoTunnelControlTile.getString(R.string.disabled))
setUnavailable()
}
}
}
@ -58,20 +58,10 @@ class AutoTunnelControlTile : TileService() {
onStartListening()
}
override fun onDestroy() {
super.onDestroy()
scope.cancel()
}
override fun onTileRemoved() {
super.onTileRemoved()
scope.cancel()
}
override fun onClick() {
super.onClick()
unlockAndRun {
scope.launch {
applicationScope.launch {
try {
appDataRepository.toggleWatcherServicePause()
} catch (e: Exception) {

View File

@ -5,12 +5,13 @@ import android.service.quicksettings.Tile
import android.service.quicksettings.TileService
import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig
import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.module.ApplicationScope
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelState
import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import timber.log.Timber
@ -28,14 +29,19 @@ class TunnelControlTile : TileService() {
@Inject
lateinit var serviceManager: ServiceManager
private val scope = CoroutineScope(Dispatchers.IO)
@Inject
@ApplicationScope
lateinit var applicationScope: CoroutineScope
private var manualStartConfig: TunnelConfig? = null
private var job: Job? = null;
override fun onStartListening() {
super.onStartListening()
Timber.d("On start listening called")
scope.launch {
//TODO Fix this
if (job == null || job?.isCancelled == true) job = applicationScope.launch {
vpnService.vpnState.collect { it ->
when (it.status) {
TunnelState.UP -> {
@ -52,22 +58,13 @@ class TunnelControlTile : TileService() {
setTileDescription(it.name)
} ?: setUnavailable()
}
else -> setInactive()
}
}
}
}
override fun onDestroy() {
super.onDestroy()
scope.cancel()
}
override fun onTileRemoved() {
super.onTileRemoved()
scope.cancel()
}
override fun onTileAdded() {
super.onTileAdded()
onStartListening()
@ -76,7 +73,7 @@ class TunnelControlTile : TileService() {
override fun onClick() {
super.onClick()
unlockAndRun {
scope.launch {
applicationScope.launch {
try {
if (vpnService.getState() == TunnelState.UP) {
serviceManager.stopVpnServiceForeground(

View File

@ -7,16 +7,16 @@ enum class TunnelState {
DOWN,
TOGGLE;
fun toWgState() : Tunnel.State {
return when(this) {
fun toWgState(): Tunnel.State {
return when (this) {
UP -> Tunnel.State.UP
DOWN -> Tunnel.State.DOWN
TOGGLE -> Tunnel.State.TOGGLE
}
}
fun toAmState() : org.amnezia.awg.backend.Tunnel.State {
return when(this) {
fun toAmState(): org.amnezia.awg.backend.Tunnel.State {
return when (this) {
UP -> org.amnezia.awg.backend.Tunnel.State.UP
DOWN -> org.amnezia.awg.backend.Tunnel.State.DOWN
TOGGLE -> org.amnezia.awg.backend.Tunnel.State.TOGGLE
@ -24,15 +24,16 @@ enum class TunnelState {
}
companion object {
fun from(state: Tunnel.State) : TunnelState {
return when(state) {
fun from(state: Tunnel.State): TunnelState {
return when (state) {
Tunnel.State.DOWN -> DOWN
Tunnel.State.TOGGLE -> TOGGLE
Tunnel.State.UP -> UP
}
}
fun from(state: org.amnezia.awg.backend.Tunnel.State) : TunnelState {
return when(state) {
fun from(state: org.amnezia.awg.backend.Tunnel.State): TunnelState {
return when (state) {
org.amnezia.awg.backend.Tunnel.State.DOWN -> DOWN
org.amnezia.awg.backend.Tunnel.State.TOGGLE -> TOGGLE
org.amnezia.awg.backend.Tunnel.State.UP -> UP

View File

@ -6,6 +6,8 @@ import com.wireguard.android.backend.Tunnel.State
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig
import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.module.ApplicationScope
import com.zaneschepke.wireguardautotunnel.module.IoDispatcher
import com.zaneschepke.wireguardautotunnel.module.Kernel
import com.zaneschepke.wireguardautotunnel.module.Userspace
import com.zaneschepke.wireguardautotunnel.service.tunnel.statistics.AmneziaStatistics
@ -13,14 +15,15 @@ import com.zaneschepke.wireguardautotunnel.service.tunnel.statistics.TunnelStati
import com.zaneschepke.wireguardautotunnel.service.tunnel.statistics.WireGuardStatistics
import com.zaneschepke.wireguardautotunnel.util.Constants
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.amnezia.awg.backend.Tunnel
import timber.log.Timber
import javax.inject.Inject
@ -28,15 +31,16 @@ import javax.inject.Inject
class WireGuardTunnel
@Inject
constructor(
private val userspaceAmneziaBackend : org.amnezia.awg.backend.Backend,
private val userspaceAmneziaBackend: org.amnezia.awg.backend.Backend,
@Userspace private val userspaceBackend: Backend,
@Kernel private val kernelBackend: Backend,
private val appDataRepository: AppDataRepository,
@ApplicationScope private val applicationScope: CoroutineScope,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher
) : VpnService {
private val _vpnState = MutableStateFlow(VpnState())
override val vpnState: StateFlow<VpnState> = _vpnState.asStateFlow()
private val scope = CoroutineScope(Dispatchers.IO)
private var statsJob: Job? = null
@ -47,20 +51,20 @@ constructor(
private var backendIsAmneziaUserspace = false
init {
scope.launch {
applicationScope.launch(ioDispatcher) {
appDataRepository.settings.getSettingsFlow().collect {
if (it.isKernelEnabled && (backendIsWgUserspace || backendIsAmneziaUserspace)) {
Timber.d("Setting kernel backend")
Timber.i("Setting kernel backend")
backend = kernelBackend
backendIsWgUserspace = false
backendIsAmneziaUserspace = false
} else if (!it.isKernelEnabled && !it.isAmneziaEnabled && !backendIsWgUserspace) {
Timber.d("Setting WireGuard userspace backend")
Timber.i("Setting WireGuard userspace backend")
backend = userspaceBackend
backendIsWgUserspace = true
backendIsAmneziaUserspace = false
} else if (it.isAmneziaEnabled && !backendIsAmneziaUserspace) {
Timber.d("Setting Amnezia userspace backend")
Timber.i("Setting Amnezia userspace backend")
backendIsAmneziaUserspace = true
backendIsWgUserspace = false
}
@ -68,11 +72,11 @@ constructor(
}
}
private fun setState(tunnelConfig: TunnelConfig?, tunnelState: TunnelState) : TunnelState {
return if(backendIsAmneziaUserspace) {
private fun setState(tunnelConfig: TunnelConfig?, tunnelState: TunnelState): TunnelState {
return if (backendIsAmneziaUserspace) {
Timber.i("Using Amnezia backend")
val config = tunnelConfig?.let {
if(it.amQuick != "") TunnelConfig.configFromAmQuick(it.amQuick) else {
if (it.amQuick != "") TunnelConfig.configFromAmQuick(it.amQuick) else {
Timber.w("Using backwards compatible wg config, amnezia specific config not found.")
TunnelConfig.configFromAmQuick(it.wgQuick)
}
@ -92,20 +96,22 @@ constructor(
}
override suspend fun startTunnel(tunnelConfig: TunnelConfig?): TunnelState {
return try {
//TODO we need better error handling here
val config = tunnelConfig ?: appDataRepository.getPrimaryOrFirstTunnel()
if (config != null) {
emitTunnelConfig(config)
setState(config, TunnelState.UP)
} else throw Exception("No tunnels")
} catch (e: BackendException) {
Timber.e("Failed to start tunnel with error: ${e.message}")
TunnelState.from(State.DOWN)
return withContext(ioDispatcher) {
try {
//TODO we need better error handling here
val config = tunnelConfig ?: appDataRepository.getPrimaryOrFirstTunnel()
if (config != null) {
emitTunnelConfig(config)
setState(config, TunnelState.UP)
} else throw Exception("No tunnels")
} catch (e: BackendException) {
Timber.e("Failed to start tunnel with error: ${e.message}")
TunnelState.from(State.DOWN)
}
}
}
private fun emitTunnelState(state : TunnelState) {
private fun emitTunnelState(state: TunnelState) {
_vpnState.tryEmit(
_vpnState.value.copy(
status = state,
@ -134,21 +140,23 @@ constructor(
}
override suspend fun stopTunnel() {
try {
if (getState() == TunnelState.UP) {
val state = setState(null, TunnelState.DOWN)
resetVpnState()
emitTunnelState(state)
withContext(ioDispatcher) {
try {
if (getState() == TunnelState.UP) {
val state = setState(null, TunnelState.DOWN)
resetVpnState()
emitTunnelState(state)
}
} catch (e: BackendException) {
Timber.e("Failed to stop wireguard tunnel with error: ${e.message}")
} catch (e: org.amnezia.awg.backend.BackendException) {
Timber.e("Failed to stop amnezia tunnel with error: ${e.message}")
}
} catch (e: BackendException) {
Timber.e("Failed to stop wireguard tunnel with error: ${e.message}")
} catch (e: org.amnezia.awg.backend.BackendException) {
Timber.e("Failed to stop amnezia tunnel with error: ${e.message}")
}
}
override fun getState(): TunnelState {
return if(backendIsAmneziaUserspace) TunnelState.from(userspaceAmneziaBackend.getState(this))
return if (backendIsAmneziaUserspace) TunnelState.from(userspaceAmneziaBackend.getState(this))
else TunnelState.from(backend.getState(this))
}
@ -162,31 +170,31 @@ constructor(
}
private fun handleStateChange(state: TunnelState) {
val tunnel = this
emitTunnelState(state)
WireGuardAutoTunnel.requestTunnelTileServiceStateUpdate()
if (state == TunnelState.UP) {
statsJob =
scope.launch {
while (true) {
if(backendIsAmneziaUserspace) {
emitBackendStatistics(AmneziaStatistics(userspaceAmneziaBackend.getStatistics(tunnel)))
} else {
emitBackendStatistics(WireGuardStatistics(backend.getStatistics(tunnel)))
}
delay(Constants.VPN_STATISTIC_CHECK_INTERVAL)
}
}
statsJob = startTunnelStatisticsJob()
}
if (state == TunnelState.DOWN) {
try {
statsJob?.cancel()
} catch (e : CancellationException) {
} catch (e: CancellationException) {
Timber.i("Stats job cancelled")
}
}
}
private fun startTunnelStatisticsJob() = applicationScope.launch(ioDispatcher) {
while (true) {
if (backendIsAmneziaUserspace) {
emitBackendStatistics(AmneziaStatistics(userspaceAmneziaBackend.getStatistics(this@WireGuardTunnel)))
} else {
emitBackendStatistics(WireGuardStatistics(backend.getStatistics(this@WireGuardTunnel)))
}
delay(Constants.VPN_STATISTIC_CHECK_INTERVAL)
}
}
override fun onStateChange(state: State) {
handleStateChange(TunnelState.from(state))
}

View File

@ -11,7 +11,7 @@ class AmneziaStatistics(private val statistics: Statistics) : TunnelStatistics()
PeerStats(
rxBytes = stats.rxBytes,
txBytes = stats.txBytes,
latestHandshakeEpochMillis = stats.latestHandshakeEpochMillis
latestHandshakeEpochMillis = stats.latestHandshakeEpochMillis,
)
}
}

View File

@ -2,17 +2,17 @@ package com.zaneschepke.wireguardautotunnel.service.tunnel.statistics
import org.amnezia.awg.crypto.Key
abstract class TunnelStatistics {
abstract class TunnelStatistics {
@JvmRecord
data class PeerStats(val rxBytes: Long, val txBytes: Long, val latestHandshakeEpochMillis: Long)
abstract fun peerStats(peer: Key): PeerStats?
abstract fun isTunnelStale() : Boolean
abstract fun isTunnelStale(): Boolean
abstract fun getPeers(): Array<Key>
abstract fun rx() : Long
abstract fun rx(): Long
abstract fun tx() : Long
abstract fun tx(): Long
}

View File

@ -3,7 +3,7 @@ package com.zaneschepke.wireguardautotunnel.service.tunnel.statistics
import com.wireguard.android.backend.Statistics
import org.amnezia.awg.crypto.Key
class WireGuardStatistics(private val statistics: Statistics) : TunnelStatistics() {
class WireGuardStatistics(private val statistics: Statistics) : TunnelStatistics() {
override fun peerStats(peer: Key): PeerStats? {
val key = com.wireguard.crypto.Key.fromBase64(peer.toBase64())
val peerStats = statistics.peer(key)
@ -11,7 +11,7 @@ class WireGuardStatistics(private val statistics: Statistics) : TunnelStatistics
PeerStats(
txBytes = peerStats.txBytes,
rxBytes = peerStats.rxBytes,
latestHandshakeEpochMillis = peerStats.latestHandshakeEpochMillis
latestHandshakeEpochMillis = peerStats.latestHandshakeEpochMillis,
)
}
}

View File

@ -4,25 +4,16 @@ import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.widget.Toast
import androidx.compose.runtime.mutableStateListOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.wireguard.android.backend.GoBackend
import com.zaneschepke.logcatter.Logcatter
import com.zaneschepke.logcatter.model.LogMessage
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.FileUtils
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import timber.log.Timber
import java.time.Instant
import javax.inject.Inject
@HiltViewModel
@ -50,7 +41,7 @@ constructor() : ViewModel() {
private fun requestPermissions() {
_appUiState.update {
it.copy(
requestPermissions = true
requestPermissions = true,
)
}
}
@ -58,12 +49,12 @@ constructor() : ViewModel() {
fun permissionsRequested() {
_appUiState.update {
it.copy(
requestPermissions = false
requestPermissions = false,
)
}
}
fun openWebPage(url: String, context : Context) {
fun openWebPage(url: String, context: Context) {
try {
val webpage: Uri = Uri.parse(url)
val intent = Intent(Intent.ACTION_VIEW, webpage).apply {
@ -79,7 +70,7 @@ constructor() : ViewModel() {
fun onVpnPermissionAccepted() {
_appUiState.update {
it.copy(
vpnPermissionAccepted = true
vpnPermissionAccepted = true,
)
}
}
@ -122,33 +113,6 @@ constructor() : ViewModel() {
}
}
val logs = mutableStateListOf<LogMessage>()
fun readLogCatOutput() =
viewModelScope.launch(viewModelScope.coroutineContext + Dispatchers.IO) {
launch {
Logcatter.logs(callback = {
logs.add(it)
if (logs.size > Constants.LOG_BUFFER_SIZE) {
logs.removeRange(0, (logs.size - Constants.LOG_BUFFER_SIZE).toInt())
}
})
}
}
fun clearLogs() {
logs.clear()
Logcatter.clear()
}
fun saveLogsToFile(context: Context) {
val fileName = "${Constants.BASE_LOG_FILE_NAME}-${Instant.now().epochSecond}.txt"
val content = logs.joinToString(separator = "\n")
FileUtils.saveFileToDownloads(context.applicationContext, content, fileName)
Toast.makeText(context, context.getString(R.string.logs_saved), Toast.LENGTH_SHORT)
.show()
}
fun setNotificationPermissionAccepted(accepted: Boolean) {
_appUiState.update {
it.copy(

View File

@ -143,7 +143,7 @@ class MainActivity : AppCompatActivity() {
if (!appUiState.vpnPermissionAccepted) {
return@LaunchedEffect appViewModel.vpnIntent?.let {
vpnActivityResultState.launch(
it
it,
)
}!!
}
@ -155,7 +155,6 @@ class MainActivity : AppCompatActivity() {
appViewModel.setNotificationPermissionAccepted(
notificationPermissionState?.status?.isGranted ?: true,
)
if (!WireGuardAutoTunnel.isRunningOnAndroidTv()) appViewModel.readLogCatOutput()
}
LaunchedEffect(appUiState.snackbarMessageConsumed) {
@ -237,30 +236,33 @@ class MainActivity : AppCompatActivity() {
)
}
composable(Screen.Support.Logs.route) {
LogsScreen(appViewModel)
LogsScreen()
}
//TODO fix navigation for amnezia
composable("${Screen.Config.route}/{id}?configType={configType}", arguments =
listOf(
navArgument("id") {
type = NavType.StringType
defaultValue = "0"
},
navArgument("configType") {
type = NavType.StringType
defaultValue = ConfigType.WIREGUARD.name
}
)
composable(
"${Screen.Config.route}/{id}?configType={configType}",
arguments =
listOf(
navArgument("id") {
type = NavType.StringType
defaultValue = "0"
},
navArgument("configType") {
type = NavType.StringType
defaultValue = ConfigType.WIREGUARD.name
},
),
) {
val id = it.arguments?.getString("id")
val configType = ConfigType.valueOf( it.arguments?.getString("configType") ?: ConfigType.WIREGUARD.name)
val configType = ConfigType.valueOf(
it.arguments?.getString("configType") ?: ConfigType.WIREGUARD.name,
)
if (!id.isNullOrBlank()) {
ConfigScreen(
navController = navController,
tunnelId = id,
appViewModel = appViewModel,
focusRequester = focusRequester,
configType = configType
configType = configType,
)
}
}

View File

@ -15,6 +15,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.zaneschepke.wireguardautotunnel.service.tunnel.statistics.TunnelStatistics
@ -52,9 +53,10 @@ fun RowListItem(
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(13 / 20f),
) {
icon()
Text(text)
Text(text, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
rowButton()
}

View File

@ -28,12 +28,15 @@ fun ConfigurationToggle(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(label, textAlign = TextAlign.Start, modifier = Modifier
.weight(
weight = 1.0f,
fill = false,
),
softWrap = true)
Text(
label, textAlign = TextAlign.Start,
modifier = Modifier
.weight(
weight = 1.0f,
fill = false,
),
softWrap = true,
)
Switch(
modifier = modifier,
enabled = enabled,

View File

@ -483,7 +483,7 @@ fun ConfigScreen(
modifier = Modifier.width(IntrinsicSize.Min),
)
}
if(configType == ConfigType.AMNEZIA) {
if (configType == ConfigType.AMNEZIA) {
ConfigurationTextBox(
value = uiState.interfaceProxy.junkPacketCount,
onValueChange = { value -> viewModel.onJunkPacketCountChanged(value) },
@ -496,7 +496,11 @@ fun ConfigScreen(
)
ConfigurationTextBox(
value = uiState.interfaceProxy.junkPacketMinSize,
onValueChange = { value -> viewModel.onJunkPacketMinSizeChanged(value) },
onValueChange = { value ->
viewModel.onJunkPacketMinSizeChanged(
value,
)
},
keyboardActions = keyboardActions,
label = stringResource(R.string.junk_packet_minimum_size),
hint = stringResource(R.string.junk_packet_minimum_size).lowercase(),
@ -506,7 +510,11 @@ fun ConfigScreen(
)
ConfigurationTextBox(
value = uiState.interfaceProxy.junkPacketMaxSize,
onValueChange = { value -> viewModel.onJunkPacketMaxSizeChanged(value) },
onValueChange = { value ->
viewModel.onJunkPacketMaxSizeChanged(
value,
)
},
keyboardActions = keyboardActions,
label = stringResource(R.string.junk_packet_maximum_size),
hint = stringResource(R.string.junk_packet_maximum_size).lowercase(),
@ -516,7 +524,11 @@ fun ConfigScreen(
)
ConfigurationTextBox(
value = uiState.interfaceProxy.initPacketJunkSize,
onValueChange = { value -> viewModel.onInitPacketJunkSizeChanged(value) },
onValueChange = { value ->
viewModel.onInitPacketJunkSizeChanged(
value,
)
},
keyboardActions = keyboardActions,
label = stringResource(R.string.init_packet_junk_size),
hint = stringResource(R.string.init_packet_junk_size).lowercase(),
@ -546,7 +558,11 @@ fun ConfigScreen(
)
ConfigurationTextBox(
value = uiState.interfaceProxy.responsePacketMagicHeader,
onValueChange = { value -> viewModel.onResponsePacketMagicHeader(value) },
onValueChange = { value ->
viewModel.onResponsePacketMagicHeader(
value,
)
},
keyboardActions = keyboardActions,
label = stringResource(R.string.response_packet_magic_header),
hint = stringResource(R.string.response_packet_magic_header).lowercase(),
@ -556,7 +572,11 @@ fun ConfigScreen(
)
ConfigurationTextBox(
value = uiState.interfaceProxy.underloadPacketMagicHeader,
onValueChange = { value -> viewModel.onUnderloadPacketMagicHeader(value) },
onValueChange = { value ->
viewModel.onUnderloadPacketMagicHeader(
value,
)
},
keyboardActions = keyboardActions,
label = stringResource(R.string.underload_packet_magic_header),
hint = stringResource(R.string.underload_packet_magic_header).lowercase(),
@ -566,7 +586,11 @@ fun ConfigScreen(
)
ConfigurationTextBox(
value = uiState.interfaceProxy.transportPacketMagicHeader,
onValueChange = { value -> viewModel.onTransportPacketMagicHeader(value) },
onValueChange = { value ->
viewModel.onTransportPacketMagicHeader(
value,
)
},
keyboardActions = keyboardActions,
label = stringResource(R.string.transport_packet_magic_header),
hint = stringResource(R.string.transport_packet_magic_header).lowercase(),

View File

@ -19,7 +19,7 @@ data class ConfigUiState(
val isAmneziaEnabled: Boolean = false
) {
companion object {
fun from(config : Config) : ConfigUiState {
fun from(config: Config): ConfigUiState {
val proxyPeers = config.peers.map { PeerProxy.from(it) }
val proxyInterface = InterfaceProxy.from(config.`interface`)
var include = true
@ -43,7 +43,8 @@ data class ConfigUiState(
isAllApplicationsEnabled,
)
}
fun from(config: org.amnezia.awg.config.Config) : ConfigUiState {
fun from(config: org.amnezia.awg.config.Config): ConfigUiState {
//TODO update with new values
val proxyPeers = config.peers.map { PeerProxy.from(it) }
val proxyInterface = InterfaceProxy.from(config.`interface`)

View File

@ -16,6 +16,7 @@ import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig
import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepository
import com.zaneschepke.wireguardautotunnel.module.IoDispatcher
import com.zaneschepke.wireguardautotunnel.ui.screens.config.model.PeerProxy
import com.zaneschepke.wireguardautotunnel.ui.screens.main.ConfigType
import com.zaneschepke.wireguardautotunnel.util.Constants
@ -25,7 +26,7 @@ import com.zaneschepke.wireguardautotunnel.util.WgTunnelExceptions
import com.zaneschepke.wireguardautotunnel.util.removeAt
import com.zaneschepke.wireguardautotunnel.util.update
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
@ -38,7 +39,8 @@ class ConfigViewModel
@Inject
constructor(
private val settingsRepository: SettingsRepository,
private val appDataRepository: AppDataRepository
private val appDataRepository: AppDataRepository,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher
) : ViewModel() {
private val packageManager = WireGuardAutoTunnel.instance.packageManager
@ -47,7 +49,7 @@ constructor(
val uiState = _uiState.asStateFlow()
fun init(tunnelId: String) =
viewModelScope.launch(Dispatchers.IO) {
viewModelScope.launch(ioDispatcher) {
val packages = getQueriedPackages("")
val state =
if (tunnelId != Constants.MANUAL_TUNNEL_CONFIG_ID) {
@ -56,15 +58,16 @@ constructor(
.firstOrNull { it.id.toString() == tunnelId }
val isAmneziaEnabled = settingsRepository.getSettings().isAmneziaEnabled
if (tunnelConfig != null) {
(if(isAmneziaEnabled) {
val amConfig = if(tunnelConfig.amQuick == "") tunnelConfig.wgQuick else tunnelConfig.amQuick
(if (isAmneziaEnabled) {
val amConfig =
if (tunnelConfig.amQuick == "") tunnelConfig.wgQuick else tunnelConfig.amQuick
ConfigUiState.from(TunnelConfig.configFromAmQuick(amConfig))
} else ConfigUiState.from(TunnelConfig.configFromWgQuick(tunnelConfig.wgQuick))).copy(
packages = packages,
loading = false,
tunnel = tunnelConfig,
tunnelName = tunnelConfig.name,
isAmneziaEnabled = isAmneziaEnabled
isAmneziaEnabled = isAmneziaEnabled,
)
} else {
ConfigUiState(loading = false, packages = packages)
@ -206,64 +209,82 @@ constructor(
if (isAllApplicationsEnabled()) emptyCheckedPackagesList()
if (_uiState.value.include) builder.includeApplications(_uiState.value.checkedPackageNames)
if (!_uiState.value.include) builder.excludeApplications(_uiState.value.checkedPackageNames)
if(_uiState.value.interfaceProxy.junkPacketCount.isNotEmpty()) {
if (_uiState.value.interfaceProxy.junkPacketCount.isNotEmpty()) {
builder.setJunkPacketCount(_uiState.value.interfaceProxy.junkPacketCount.trim().toInt())
}
if(_uiState.value.interfaceProxy.junkPacketMinSize.isNotEmpty()) {
builder.setJunkPacketMinSize(_uiState.value.interfaceProxy.junkPacketMinSize.trim().toInt())
if (_uiState.value.interfaceProxy.junkPacketMinSize.isNotEmpty()) {
builder.setJunkPacketMinSize(
_uiState.value.interfaceProxy.junkPacketMinSize.trim().toInt(),
)
}
if(_uiState.value.interfaceProxy.junkPacketMaxSize.isNotEmpty()) {
builder.setJunkPacketMaxSize(_uiState.value.interfaceProxy.junkPacketMaxSize.trim().toInt())
if (_uiState.value.interfaceProxy.junkPacketMaxSize.isNotEmpty()) {
builder.setJunkPacketMaxSize(
_uiState.value.interfaceProxy.junkPacketMaxSize.trim().toInt(),
)
}
if(_uiState.value.interfaceProxy.initPacketJunkSize.isNotEmpty()) {
builder.setInitPacketJunkSize(_uiState.value.interfaceProxy.initPacketJunkSize.trim().toInt())
if (_uiState.value.interfaceProxy.initPacketJunkSize.isNotEmpty()) {
builder.setInitPacketJunkSize(
_uiState.value.interfaceProxy.initPacketJunkSize.trim().toInt(),
)
}
if(_uiState.value.interfaceProxy.responsePacketJunkSize.isNotEmpty()) {
builder.setResponsePacketJunkSize(_uiState.value.interfaceProxy.responsePacketJunkSize.trim().toInt())
if (_uiState.value.interfaceProxy.responsePacketJunkSize.isNotEmpty()) {
builder.setResponsePacketJunkSize(
_uiState.value.interfaceProxy.responsePacketJunkSize.trim().toInt(),
)
}
if(_uiState.value.interfaceProxy.initPacketMagicHeader.isNotEmpty()) {
builder.setInitPacketMagicHeader(_uiState.value.interfaceProxy.initPacketMagicHeader.trim().toLong())
if (_uiState.value.interfaceProxy.initPacketMagicHeader.isNotEmpty()) {
builder.setInitPacketMagicHeader(
_uiState.value.interfaceProxy.initPacketMagicHeader.trim().toLong(),
)
}
if(_uiState.value.interfaceProxy.responsePacketMagicHeader.isNotEmpty()) {
builder.setResponsePacketMagicHeader(_uiState.value.interfaceProxy.responsePacketMagicHeader.trim().toLong())
if (_uiState.value.interfaceProxy.responsePacketMagicHeader.isNotEmpty()) {
builder.setResponsePacketMagicHeader(
_uiState.value.interfaceProxy.responsePacketMagicHeader.trim().toLong(),
)
}
if(_uiState.value.interfaceProxy.transportPacketMagicHeader.isNotEmpty()) {
builder.setTransportPacketMagicHeader(_uiState.value.interfaceProxy.transportPacketMagicHeader.trim().toLong())
if (_uiState.value.interfaceProxy.transportPacketMagicHeader.isNotEmpty()) {
builder.setTransportPacketMagicHeader(
_uiState.value.interfaceProxy.transportPacketMagicHeader.trim().toLong(),
)
}
if(_uiState.value.interfaceProxy.underloadPacketMagicHeader.isNotEmpty()) {
builder.setUnderloadPacketMagicHeader(_uiState.value.interfaceProxy.underloadPacketMagicHeader.trim().toLong())
if (_uiState.value.interfaceProxy.underloadPacketMagicHeader.isNotEmpty()) {
builder.setUnderloadPacketMagicHeader(
_uiState.value.interfaceProxy.underloadPacketMagicHeader.trim().toLong(),
)
}
return builder.build()
}
private fun buildConfig() : Config {
private fun buildConfig(): Config {
val peerList = buildPeerListFromProxyPeers()
val wgInterface = buildInterfaceListFromProxyInterface()
return Config.Builder().addPeers(peerList).setInterface(wgInterface).build()
return Config.Builder().addPeers(peerList).setInterface(wgInterface).build()
}
private fun buildAmConfig() : org.amnezia.awg.config.Config {
private fun buildAmConfig(): org.amnezia.awg.config.Config {
val peerList = buildAmPeerListFromProxyPeers()
val amInterface = buildAmInterfaceListFromProxyInterface()
return org.amnezia.awg.config.Config.Builder().addPeers(peerList).setInterface(amInterface).build()
return org.amnezia.awg.config.Config.Builder().addPeers(peerList).setInterface(amInterface)
.build()
}
fun onSaveAllChanges(configType: ConfigType): Result<Unit> {
return try {
val wgQuick = buildConfig().toWgQuickString()
val amQuick = if(configType == ConfigType.AMNEZIA) {
val amQuick = if (configType == ConfigType.AMNEZIA) {
buildAmConfig().toAwgQuickString()
} else TunnelConfig.AM_QUICK_DEFAULT
val tunnelConfig = when (uiState.value.tunnel) {
null -> TunnelConfig(
name = _uiState.value.tunnelName,
wgQuick = wgQuick,
amQuick = amQuick
amQuick = amQuick,
)
else -> uiState.value.tunnel!!.copy(
name = _uiState.value.tunnelName,
wgQuick = wgQuick,
amQuick = amQuick
amQuick = amQuick,
)
}
updateTunnelConfig(tunnelConfig)
@ -430,14 +451,15 @@ constructor(
fun onJunkPacketCountChanged(value: String) {
_uiState.update {
it.copy(
interfaceProxy = _uiState.value.interfaceProxy.copy(junkPacketCount = value)
interfaceProxy = _uiState.value.interfaceProxy.copy(junkPacketCount = value),
)
}
}
fun onJunkPacketMinSizeChanged(value: String) {
_uiState.update {
it.copy(
interfaceProxy = _uiState.value.interfaceProxy.copy(junkPacketMinSize = value)
interfaceProxy = _uiState.value.interfaceProxy.copy(junkPacketMinSize = value),
)
}
}
@ -445,7 +467,7 @@ constructor(
fun onJunkPacketMaxSizeChanged(value: String) {
_uiState.update {
it.copy(
interfaceProxy = _uiState.value.interfaceProxy.copy(junkPacketMaxSize = value)
interfaceProxy = _uiState.value.interfaceProxy.copy(junkPacketMaxSize = value),
)
}
}
@ -453,7 +475,7 @@ constructor(
fun onInitPacketJunkSizeChanged(value: String) {
_uiState.update {
it.copy(
interfaceProxy = _uiState.value.interfaceProxy.copy(initPacketJunkSize = value)
interfaceProxy = _uiState.value.interfaceProxy.copy(initPacketJunkSize = value),
)
}
}
@ -461,7 +483,7 @@ constructor(
fun onResponsePacketJunkSize(value: String) {
_uiState.update {
it.copy(
interfaceProxy = _uiState.value.interfaceProxy.copy(responsePacketJunkSize = value)
interfaceProxy = _uiState.value.interfaceProxy.copy(responsePacketJunkSize = value),
)
}
}
@ -469,7 +491,7 @@ constructor(
fun onInitPacketMagicHeader(value: String) {
_uiState.update {
it.copy(
interfaceProxy = _uiState.value.interfaceProxy.copy(initPacketMagicHeader = value)
interfaceProxy = _uiState.value.interfaceProxy.copy(initPacketMagicHeader = value),
)
}
}
@ -477,7 +499,7 @@ constructor(
fun onResponsePacketMagicHeader(value: String) {
_uiState.update {
it.copy(
interfaceProxy = _uiState.value.interfaceProxy.copy(responsePacketMagicHeader = value)
interfaceProxy = _uiState.value.interfaceProxy.copy(responsePacketMagicHeader = value),
)
}
}
@ -485,7 +507,7 @@ constructor(
fun onTransportPacketMagicHeader(value: String) {
_uiState.update {
it.copy(
interfaceProxy = _uiState.value.interfaceProxy.copy(transportPacketMagicHeader = value)
interfaceProxy = _uiState.value.interfaceProxy.copy(transportPacketMagicHeader = value),
)
}
}
@ -493,7 +515,7 @@ constructor(
fun onUnderloadPacketMagicHeader(value: String) {
_uiState.update {
it.copy(
interfaceProxy = _uiState.value.interfaceProxy.copy(underloadPacketMagicHeader = value)
interfaceProxy = _uiState.value.interfaceProxy.copy(underloadPacketMagicHeader = value),
)
}
}

View File

@ -35,7 +35,8 @@ data class InterfaceProxy(
mtu = if (i.mtu.isPresent) i.mtu.get().toString().trim() else "",
)
}
fun from(i: org.amnezia.awg.config.Interface) : InterfaceProxy {
fun from(i: org.amnezia.awg.config.Interface): InterfaceProxy {
return InterfaceProxy(
publicKey = i.keyPair.publicKey.toBase64().trim(),
privateKey = i.keyPair.privateKey.toBase64().trim(),
@ -48,15 +49,24 @@ data class InterfaceProxy(
""
},
mtu = if (i.mtu.isPresent) i.mtu.get().toString().trim() else "",
junkPacketCount = if(i.junkPacketCount.isPresent) i.junkPacketCount.get().toString() else "",
junkPacketMinSize = if(i.junkPacketMinSize.isPresent) i.junkPacketMinSize.get().toString() else "",
junkPacketMaxSize = if(i.junkPacketMaxSize.isPresent) i.junkPacketMaxSize.get().toString() else "",
initPacketJunkSize = if(i.initPacketJunkSize.isPresent) i.initPacketJunkSize.get().toString() else "",
responsePacketJunkSize = if(i.responsePacketJunkSize.isPresent) i.responsePacketJunkSize.get().toString() else "",
initPacketMagicHeader = if(i.initPacketMagicHeader.isPresent) i.initPacketMagicHeader.get().toString() else "",
responsePacketMagicHeader = if(i.responsePacketMagicHeader.isPresent) i.responsePacketMagicHeader.get().toString() else "",
transportPacketMagicHeader = if(i.transportPacketMagicHeader.isPresent) i.transportPacketMagicHeader.get().toString() else "",
underloadPacketMagicHeader = if(i.underloadPacketMagicHeader.isPresent) i.underloadPacketMagicHeader.get().toString() else "",
junkPacketCount = if (i.junkPacketCount.isPresent) i.junkPacketCount.get()
.toString() else "",
junkPacketMinSize = if (i.junkPacketMinSize.isPresent) i.junkPacketMinSize.get()
.toString() else "",
junkPacketMaxSize = if (i.junkPacketMaxSize.isPresent) i.junkPacketMaxSize.get()
.toString() else "",
initPacketJunkSize = if (i.initPacketJunkSize.isPresent) i.initPacketJunkSize.get()
.toString() else "",
responsePacketJunkSize = if (i.responsePacketJunkSize.isPresent) i.responsePacketJunkSize.get()
.toString() else "",
initPacketMagicHeader = if (i.initPacketMagicHeader.isPresent) i.initPacketMagicHeader.get()
.toString() else "",
responsePacketMagicHeader = if (i.responsePacketMagicHeader.isPresent) i.responsePacketMagicHeader.get()
.toString() else "",
transportPacketMagicHeader = if (i.transportPacketMagicHeader.isPresent) i.transportPacketMagicHeader.get()
.toString() else "",
underloadPacketMagicHeader = if (i.underloadPacketMagicHeader.isPresent) i.underloadPacketMagicHeader.get()
.toString() else "",
)
}
}

View File

@ -35,7 +35,7 @@ data class PeerProxy(
)
}
fun from(peer: org.amnezia.awg.config.Peer) : PeerProxy {
fun from(peer: org.amnezia.awg.config.Peer): PeerProxy {
return PeerProxy(
publicKey = peer.publicKey.toBase64(),
preSharedKey =

View File

@ -14,6 +14,7 @@ import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.focusable
import androidx.compose.foundation.gestures.ScrollableDefaults
import androidx.compose.foundation.gestures.detectTapGestures
@ -69,7 +70,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
@ -111,8 +111,6 @@ import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.getMessage
import com.zaneschepke.wireguardautotunnel.util.handshakeStatus
import com.zaneschepke.wireguardautotunnel.util.mapPeerStats
import com.zaneschepke.wireguardautotunnel.util.truncateWithEllipsis
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@ -128,7 +126,7 @@ fun MainScreen(
val haptic = LocalHapticFeedback.current
val context = LocalContext.current
val isVisible = rememberSaveable { mutableStateOf(true) }
val scope = rememberCoroutineScope { Dispatchers.IO }
val scope = rememberCoroutineScope()
val sheetState = rememberModalBottomSheetState()
var showBottomSheet by remember { mutableStateOf(false) }
@ -212,8 +210,8 @@ fun MainScreen(
onResult = {
if (it.contents != null) {
scope.launch {
viewModel.onTunnelQrResult(it.contents, configType).onFailure {
appViewModel.showSnackbarMessage(it.getMessage(context))
viewModel.onTunnelQrResult(it.contents, configType).onFailure { error ->
appViewModel.showSnackbarMessage(error.getMessage(context))
}
}
}
@ -246,7 +244,9 @@ fun MainScreen(
fun onTunnelToggle(checked: Boolean, tunnel: TunnelConfig) {
if (appViewModel.isRequiredPermissionGranted()) {
if (checked) viewModel.onTunnelStart(tunnel, context) else viewModel.onTunnelStop(context)
if (checked) viewModel.onTunnelStart(tunnel, context) else viewModel.onTunnelStop(
context,
)
}
}
@ -270,7 +270,7 @@ fun MainScreen(
Scaffold(
modifier =
Modifier.pointerInput(Unit) {
if(uiState.tunnels.isNotEmpty()) {
if (uiState.tunnels.isNotEmpty()) {
detectTapGestures(
onTap = {
selectedTunnel = null
@ -285,31 +285,25 @@ fun MainScreen(
visible = isVisible.value,
enter = slideInVertically(initialOffsetY = { it * 2 }),
exit = slideOutVertically(targetOffsetY = { it * 2 }),
modifier = Modifier
.focusRequester(focusRequester)
.focusGroup(),
) {
val secondaryColor = MaterialTheme.colorScheme.secondary
val hoverColor = MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp)
var fobColor by remember { mutableStateOf(secondaryColor) }
val tvFobColor = MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp)
val fobColor =
if (WireGuardAutoTunnel.isRunningOnAndroidTv()) tvFobColor else secondaryColor
val fobIconColor =
if (WireGuardAutoTunnel.isRunningOnAndroidTv()) Color.White else MaterialTheme.colorScheme.background
MultiFloatingActionButton(
modifier =
(if (
WireGuardAutoTunnel.isRunningOnAndroidTv() &&
uiState.tunnels.isEmpty()
)
Modifier.focusRequester(focusRequester)
else Modifier)
.onFocusChanged {
if (WireGuardAutoTunnel.isRunningOnAndroidTv()) {
fobColor = if (it.isFocused) hoverColor else secondaryColor
}
},
fabIcon = FabIcon(
iconRes = R.drawable.add,
iconResAfterRotate = R.drawable.close,
iconRotate = 180f
iconRotate = 180f,
),
fabOption = FabOption(
iconTint = MaterialTheme.colorScheme.background,
backgroundTint = MaterialTheme.colorScheme.primary,
iconTint = fobIconColor,
backgroundTint = fobColor,
),
itemsMultiFab = listOf(
MultiFabItem(
@ -318,24 +312,39 @@ fun MainScreen(
stringResource(id = R.string.amnezia),
color = Color.White,
textAlign = TextAlign.Center,
modifier = Modifier.padding(end = 10.dp)
modifier = Modifier.padding(end = 10.dp),
)
},
modifier = Modifier
.size(40.dp),
icon = R.drawable.add,
value = ConfigType.AMNEZIA.name,
miniFabOption = FabOption(
backgroundTint = fobColor,
fobIconColor,
),
),
MultiFabItem(
label = {
Text(stringResource(id = R.string.wireguard), color = Color.White, textAlign = TextAlign.Center, modifier = Modifier.padding(end = 10.dp))
Text(
stringResource(id = R.string.wireguard),
color = Color.White,
textAlign = TextAlign.Center,
modifier = Modifier.padding(end = 10.dp),
)
},
icon = R.drawable.add,
value = ConfigType.WIREGUARD.name
value = ConfigType.WIREGUARD.name,
miniFabOption = FabOption(
backgroundTint = fobColor,
fobIconColor,
),
),
),
onFabItemClicked = {
showBottomSheet = true
configType = ConfigType.valueOf(it.value)
},
},
shape = RoundedCornerShape(16.dp),
)
}
@ -343,7 +352,10 @@ fun MainScreen(
) {
if (showBottomSheet) {
ModalBottomSheet(
onDismissRequest = { showBottomSheet = false },
onDismissRequest = {
showBottomSheet = false
},
sheetState = sheetState,
) {
// Sheet content
@ -432,34 +444,48 @@ fun MainScreen(
flingBehavior = ScrollableDefaults.flingBehavior(),
) {
item {
val gettingStarted = buildAnnotatedString {
append(stringResource(id = R.string.see_the))
append(" ")
pushStringAnnotation(tag = "gettingStarted", annotation = stringResource(id = R.string.getting_started_url))
withStyle(style = SpanStyle(color = MaterialTheme.colorScheme.primary)) {
append(stringResource(id = R.string.getting_started_guide))
}
pop()
append(" ")
append(stringResource(R.string.unsure_how))
append(".")
}
AnimatedVisibility(
uiState.tunnels.isEmpty(), exit = fadeOut(), enter = fadeIn()) {
uiState.tunnels.isEmpty(), exit = fadeOut(), enter = fadeIn(),
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier.padding(top = 100.dp)
modifier = Modifier
.padding(top = 100.dp)
.fillMaxSize(),
) {
Text(text = stringResource(R.string.no_tunnels), fontStyle = FontStyle.Italic)
ClickableText(
modifier = Modifier.padding(vertical = 10.dp, horizontal = 24.dp),
text = gettingStarted,
style = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center),
) {
gettingStarted.getStringAnnotations(tag = "gettingStarted", it, it).firstOrNull()?.let { annotation ->
appViewModel.openWebPage(annotation.item, context)
val gettingStarted = buildAnnotatedString {
append(stringResource(id = R.string.see_the))
append(" ")
pushStringAnnotation(
tag = "gettingStarted",
annotation = stringResource(id = R.string.getting_started_url),
)
withStyle(style = SpanStyle(color = MaterialTheme.colorScheme.primary)) {
append(stringResource(id = R.string.getting_started_guide))
}
pop()
append(" ")
append(stringResource(R.string.unsure_how))
append(".")
}
Text(
text = stringResource(R.string.no_tunnels),
fontStyle = FontStyle.Italic,
)
ClickableText(
modifier = Modifier
.padding(vertical = 10.dp, horizontal = 24.dp),
text = gettingStarted,
style = MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
),
) {
gettingStarted.getStringAnnotations(tag = "gettingStarted", it, it)
.firstOrNull()?.let { annotation ->
appViewModel.openWebPage(annotation.item, context)
}
}
}
}
@ -562,7 +588,7 @@ fun MainScreen(
.size(if (icon == circleIcon) 15.dp else 20.dp),
)
},
text = tunnel.name.truncateWithEllipsis(Constants.ALLOWED_DISPLAY_NAME_LENGTH),
text = tunnel.name,
onHold = {
if (
(uiState.vpnState.status == TunnelState.UP) &&

View File

@ -11,6 +11,7 @@ import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.zaneschepke.wireguardautotunnel.data.domain.Settings
import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig
import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.module.IoDispatcher
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService
import com.zaneschepke.wireguardautotunnel.util.Constants
@ -18,7 +19,7 @@ import com.zaneschepke.wireguardautotunnel.util.NumberUtils
import com.zaneschepke.wireguardautotunnel.util.WgTunnelExceptions
import com.zaneschepke.wireguardautotunnel.util.toWgQuickString
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
@ -35,7 +36,8 @@ class MainViewModel
constructor(
private val appDataRepository: AppDataRepository,
private val serviceManager: ServiceManager,
val vpnService: VpnService
val vpnService: VpnService,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher
) : ViewModel() {
val uiState =
@ -52,13 +54,12 @@ constructor(
MainUiState(),
)
private fun stopWatcherService(context: Context) =
viewModelScope.launch(Dispatchers.IO) {
serviceManager.stopWatcherService(context)
}
private fun stopWatcherService(context: Context) {
serviceManager.stopWatcherService(context)
}
fun onDelete(tunnel: TunnelConfig, context: Context) {
viewModelScope.launch(Dispatchers.IO) {
viewModelScope.launch {
val settings = appDataRepository.settings.getSettings()
val isPrimary = tunnel.isPrimaryTunnel
if (appDataRepository.tunnels.count() == 1 || isPrimary) {
@ -80,7 +81,7 @@ constructor(
}
fun onTunnelStart(tunnelConfig: TunnelConfig, context: Context) =
viewModelScope.launch(Dispatchers.IO) {
viewModelScope.launch {
Timber.d("On start called!")
serviceManager.startVpnService(
context,
@ -91,41 +92,42 @@ constructor(
fun onTunnelStop(context: Context) =
viewModelScope.launch(Dispatchers.IO) {
viewModelScope.launch {
Timber.i("Stopping active tunnel")
serviceManager.stopVpnService(context, isManualStop = true)
}
private fun validateConfigString(config: String, configType: ConfigType) {
when(configType) {
when (configType) {
ConfigType.AMNEZIA -> TunnelConfig.configFromAmQuick(config)
ConfigType.WIREGUARD -> TunnelConfig.configFromWgQuick(config)
}
}
private fun generateQrCodeDefaultName(config : String, configType: ConfigType) : String {
private fun generateQrCodeDefaultName(config: String, configType: ConfigType): String {
return try {
when(configType) {
when (configType) {
ConfigType.AMNEZIA -> {
TunnelConfig.configFromAmQuick(config).peers[0].endpoint.get().host
}
ConfigType.WIREGUARD -> {
TunnelConfig.configFromWgQuick(config).peers[0].endpoint.get().host
}
}
} catch (e : Exception) {
} catch (e: Exception) {
Timber.e(e)
NumberUtils.generateRandomTunnelName()
}
}
private fun generateQrCodeTunnelName(config : String, configType: ConfigType) : String {
private fun generateQrCodeTunnelName(config: String, configType: ConfigType): String {
var defaultName = generateQrCodeDefaultName(config, configType)
val lines = config.lines().toMutableList()
val linesIterator = lines.iterator()
while(linesIterator.hasNext()) {
while (linesIterator.hasNext()) {
val next = linesIterator.next()
if(next.contains(Constants.QR_CODE_NAME_PROPERTY)) {
if (next.contains(Constants.QR_CODE_NAME_PROPERTY)) {
defaultName = next.substringAfter(Constants.QR_CODE_NAME_PROPERTY).trim()
break
}
@ -134,121 +136,177 @@ constructor(
}
suspend fun onTunnelQrResult(result: String, configType: ConfigType): Result<Unit> {
return try {
validateConfigString(result, configType)
val tunnelName = makeTunnelNameUnique(generateQrCodeTunnelName(result, configType))
val tunnelConfig = when(configType) {
ConfigType.AMNEZIA ->{
TunnelConfig(name = tunnelName, amQuick = result,
wgQuick = TunnelConfig.configFromAmQuick(result).toWgQuickString())
return withContext(ioDispatcher) {
try {
validateConfigString(result, configType)
val tunnelName = makeTunnelNameUnique(generateQrCodeTunnelName(result, configType))
val tunnelConfig = when (configType) {
ConfigType.AMNEZIA -> {
TunnelConfig(
name = tunnelName, amQuick = result,
wgQuick = TunnelConfig.configFromAmQuick(result).toWgQuickString(),
)
}
ConfigType.WIREGUARD -> TunnelConfig(name = tunnelName, wgQuick = result)
}
ConfigType.WIREGUARD -> TunnelConfig(name = tunnelName, wgQuick = result)
addTunnel(tunnelConfig)
Result.success(Unit)
} catch (e: Exception) {
Timber.e(e)
Result.failure(WgTunnelExceptions.InvalidQrCode())
}
addTunnel(tunnelConfig)
Result.success(Unit)
} catch (e: Exception) {
Timber.e(e)
Result.failure(WgTunnelExceptions.InvalidQrCode())
}
}
private suspend fun makeTunnelNameUnique(name : String) : String {
val tunnels = appDataRepository.tunnels.getAll()
var tunnelName = name
var num = 1
while (tunnels.any { it.name == tunnelName }) {
tunnelName = name + "(${num})"
num++
private suspend fun makeTunnelNameUnique(name: String): String {
return withContext(ioDispatcher) {
val tunnels = appDataRepository.tunnels.getAll()
var tunnelName = name
var num = 1
while (tunnels.any { it.name == tunnelName }) {
tunnelName = name + "(${num})"
num++
}
tunnelName
}
return tunnelName
}
private suspend fun saveTunnelConfigFromStream(stream: InputStream, fileName: String, type: ConfigType) {
var amQuick : String? = null
private fun saveTunnelConfigFromStream(
stream: InputStream,
fileName: String,
type: ConfigType
) {
var amQuick: String? = null
val wgQuick = stream.use {
when(type) {
when (type) {
ConfigType.AMNEZIA -> {
val config = org.amnezia.awg.config.Config.parse(it)
amQuick = config.toAwgQuickString()
config.toWgQuickString()
}
ConfigType.WIREGUARD -> {
Config.parse(it).toWgQuickString()
}
}
}
val tunnelName = makeTunnelNameUnique(getNameFromFileName(fileName))
addTunnel(TunnelConfig(name = tunnelName, wgQuick = wgQuick, amQuick = amQuick ?: TunnelConfig.AM_QUICK_DEFAULT))
viewModelScope.launch {
val tunnelName = makeTunnelNameUnique(getNameFromFileName(fileName))
addTunnel(
TunnelConfig(
name = tunnelName,
wgQuick = wgQuick,
amQuick = amQuick ?: TunnelConfig.AM_QUICK_DEFAULT,
),
)
}
}
private fun getInputStreamFromUri(uri: Uri, context: Context): InputStream? {
return context.applicationContext.contentResolver.openInputStream(uri)
}
suspend fun onTunnelFileSelected(uri: Uri, configType: ConfigType, context: Context): Result<Unit> {
return try {
if (isValidUriContentScheme(uri)) {
val fileName = getFileName(context, uri)
return when (getFileExtensionFromFileName(fileName)) {
Constants.CONF_FILE_EXTENSION ->
saveTunnelFromConfUri(fileName, uri, configType, context)
Constants.ZIP_FILE_EXTENSION -> saveTunnelsFromZipUri(uri, configType, context)
else -> Result.failure(WgTunnelExceptions.InvalidFileExtension())
suspend fun onTunnelFileSelected(
uri: Uri,
configType: ConfigType,
context: Context
): Result<Unit> {
return withContext(ioDispatcher) {
try {
if (isValidUriContentScheme(uri)) {
val fileName = getFileName(context, uri)
return@withContext when (getFileExtensionFromFileName(fileName)) {
Constants.CONF_FILE_EXTENSION ->
saveTunnelFromConfUri(fileName, uri, configType, context)
Constants.ZIP_FILE_EXTENSION -> saveTunnelsFromZipUri(
uri,
configType,
context,
)
else -> Result.failure(WgTunnelExceptions.InvalidFileExtension())
}
} else {
Result.failure(WgTunnelExceptions.InvalidFileExtension())
}
} else {
Result.failure(WgTunnelExceptions.InvalidFileExtension())
} catch (e: Exception) {
Timber.e(e)
Result.failure(WgTunnelExceptions.FileReadFailed())
}
} catch (e: Exception) {
Timber.e(e)
Result.failure(WgTunnelExceptions.FileReadFailed())
}
}
private suspend fun saveTunnelsFromZipUri(uri: Uri, configType: ConfigType, context: Context) : Result<Unit> {
return ZipInputStream(getInputStreamFromUri(uri, context)).use { zip ->
generateSequence { zip.nextEntry }
.filterNot {
it.isDirectory ||
getFileExtensionFromFileName(it.name) != Constants.CONF_FILE_EXTENSION
}
.forEach {
val name = getNameFromFileName(it.name)
withContext(viewModelScope.coroutineContext + Dispatchers.IO) {
try {
var amQuick : String? = null
val wgQuick =
when(configType) {
ConfigType.AMNEZIA -> {
val config = org.amnezia.awg.config.Config.parse(zip)
amQuick = config.toAwgQuickString()
config.toWgQuickString()
private suspend fun saveTunnelsFromZipUri(
uri: Uri,
configType: ConfigType,
context: Context
): Result<Unit> {
return withContext(ioDispatcher) {
ZipInputStream(getInputStreamFromUri(uri, context)).use { zip ->
generateSequence { zip.nextEntry }
.filterNot {
it.isDirectory ||
getFileExtensionFromFileName(it.name) != Constants.CONF_FILE_EXTENSION
}
.forEach {
val name = getNameFromFileName(it.name)
withContext(viewModelScope.coroutineContext) {
try {
var amQuick: String? = null
val wgQuick =
when (configType) {
ConfigType.AMNEZIA -> {
val config = org.amnezia.awg.config.Config.parse(zip)
amQuick = config.toAwgQuickString()
config.toWgQuickString()
}
ConfigType.WIREGUARD -> {
Config.parse(zip).toWgQuickString()
}
}
ConfigType.WIREGUARD -> {
Config.parse(zip).toWgQuickString()
}
}
addTunnel(TunnelConfig(name = makeTunnelNameUnique(name), wgQuick = wgQuick, amQuick = amQuick ?: TunnelConfig.AM_QUICK_DEFAULT))
Result.success(Unit)
} catch (e : Exception) {
Result.failure(WgTunnelExceptions.FileReadFailed())
addTunnel(
TunnelConfig(
name = makeTunnelNameUnique(name),
wgQuick = wgQuick,
amQuick = amQuick ?: TunnelConfig.AM_QUICK_DEFAULT,
),
)
Result.success(Unit)
} catch (e: Exception) {
Result.failure(WgTunnelExceptions.FileReadFailed())
}
}
}
Result.success(Unit)
}
}
}
private suspend fun saveTunnelFromConfUri(
name: String,
uri: Uri,
configType: ConfigType,
context: Context
): Result<Unit> {
return withContext(ioDispatcher) {
val stream = getInputStreamFromUri(uri, context)
return@withContext if (stream != null) {
try {
saveTunnelConfigFromStream(stream, name, configType)
} catch (e: Exception) {
return@withContext Result.failure(WgTunnelExceptions.ConfigParseError())
}
Result.success(Unit)
Result.success(Unit)
} else {
Result.failure(WgTunnelExceptions.FileReadFailed())
}
}
}
private suspend fun saveTunnelFromConfUri(name: String, uri: Uri, configType: ConfigType, context: Context): Result<Unit> {
val stream = getInputStreamFromUri(uri, context)
return if (stream != null) {
saveTunnelConfigFromStream(stream, name, configType)
Result.success(Unit)
} else {
Result.failure(WgTunnelExceptions.FileReadFailed())
}
}
private suspend fun addTunnel(tunnelConfig: TunnelConfig) {
private fun addTunnel(tunnelConfig: TunnelConfig) = viewModelScope.launch {
val firstTunnel = appDataRepository.tunnels.count() == 0
saveTunnel(tunnelConfig)
if (firstTunnel) WireGuardAutoTunnel.requestTunnelTileServiceStateUpdate()
@ -266,7 +324,7 @@ constructor(
WireGuardAutoTunnel.requestAutoTunnelTileServiceUpdate()
}
private suspend fun saveTunnel(tunnelConfig: TunnelConfig) {
private fun saveTunnel(tunnelConfig: TunnelConfig) = viewModelScope.launch {
appDataRepository.tunnels.save(tunnelConfig)
}
@ -317,7 +375,7 @@ constructor(
}
private fun saveSettings(settings: Settings) =
viewModelScope.launch(Dispatchers.IO) { appDataRepository.settings.save(settings) }
viewModelScope.launch { appDataRepository.settings.save(settings) }
fun onCopyTunnel(tunnel: TunnelConfig?) = viewModelScope.launch {

View File

@ -1,7 +1,11 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.options
import android.annotation.SuppressLint
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@ -12,6 +16,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
@ -39,7 +44,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
@ -114,59 +118,75 @@ fun OptionsScreen(
Scaffold(
floatingActionButton = {
val secondaryColor = MaterialTheme.colorScheme.secondary
val hoverColor = MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp)
var fobColor by remember { mutableStateOf(secondaryColor) }
MultiFloatingActionButton(
modifier =
(if (
WireGuardAutoTunnel.isRunningOnAndroidTv()
)
Modifier.focusRequester(focusRequester)
else Modifier)
.onFocusChanged {
if (WireGuardAutoTunnel.isRunningOnAndroidTv()) {
fobColor = if (it.isFocused) hoverColor else secondaryColor
}
val tvFobColor = MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp)
val fobColor =
if (WireGuardAutoTunnel.isRunningOnAndroidTv()) tvFobColor else secondaryColor
val fobIconColor =
if (WireGuardAutoTunnel.isRunningOnAndroidTv()) Color.White else MaterialTheme.colorScheme.background
AnimatedVisibility(
visible = true,
enter = slideInVertically(initialOffsetY = { it * 2 }),
exit = slideOutVertically(targetOffsetY = { it * 2 }),
modifier = Modifier
.focusRequester(focusRequester)
.focusGroup(),
) {
MultiFloatingActionButton(
fabIcon = FabIcon(
iconRes = R.drawable.edit,
iconResAfterRotate = R.drawable.close,
iconRotate = 180f,
),
fabOption = FabOption(
iconTint = fobIconColor,
backgroundTint = fobColor,
),
itemsMultiFab = listOf(
MultiFabItem(
label = {
Text(
stringResource(id = R.string.amnezia),
color = Color.White,
textAlign = TextAlign.Center,
modifier = Modifier.padding(end = 10.dp),
)
},
modifier = Modifier
.size(40.dp),
icon = R.drawable.edit,
value = ConfigType.AMNEZIA.name,
miniFabOption = FabOption(
backgroundTint = fobColor,
fobIconColor,
),
),
MultiFabItem(
label = {
Text(
stringResource(id = R.string.wireguard),
color = Color.White,
textAlign = TextAlign.Center,
modifier = Modifier.padding(end = 10.dp),
)
},
icon = R.drawable.edit,
value = ConfigType.WIREGUARD.name,
miniFabOption = FabOption(
backgroundTint = fobColor,
fobIconColor,
),
),
),
onFabItemClicked = {
val configType = ConfigType.valueOf(it.value)
navController.navigate(
"${Screen.Config.route}/${tunnelId}?configType=${configType.name}",
)
},
fabIcon = FabIcon(
iconRes = R.drawable.edit,
iconResAfterRotate = R.drawable.close,
iconRotate = 180f
),
fabOption = FabOption(
iconTint = MaterialTheme.colorScheme.background,
backgroundTint = MaterialTheme.colorScheme.primary,
),
itemsMultiFab = listOf(
MultiFabItem(
label = {
Text(
stringResource(id = R.string.amnezia),
color = Color.White,
textAlign = TextAlign.Center,
modifier = Modifier.padding(end = 10.dp)
)
},
icon = R.drawable.edit,
value = ConfigType.AMNEZIA.name,
),
MultiFabItem(
label = {
Text(stringResource(id = R.string.wireguard), color = Color.White, textAlign = TextAlign.Center, modifier = Modifier.padding(end = 10.dp))
},
icon = R.drawable.edit,
value = ConfigType.WIREGUARD.name
),
),
onFabItemClicked = {
val configType = ConfigType.valueOf(it.value)
navController.navigate(
"${Screen.Config.route}/${tunnelId}?configType=${configType.name}",
)
},
shape = RoundedCornerShape(16.dp),
)
}
shape = RoundedCornerShape(16.dp),
)
}
},
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,

View File

@ -9,7 +9,6 @@ import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.WgTunnelExceptions
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
@ -45,12 +44,12 @@ constructor(
fun init(tunnelId: String) {
_optionState.update {
it.copy(
id = tunnelId
id = tunnelId,
)
}
}
fun onDeleteRunSSID(ssid: String) = viewModelScope.launch(Dispatchers.IO) {
fun onDeleteRunSSID(ssid: String) = viewModelScope.launch {
uiState.value.tunnel?.let {
appDataRepository.tunnels.save(
tunnelConfig = it.copy(
@ -60,7 +59,7 @@ constructor(
}
}
private fun saveTunnel(tunnelConfig: TunnelConfig?) = viewModelScope.launch(Dispatchers.IO) {
private fun saveTunnel(tunnelConfig: TunnelConfig?) = viewModelScope.launch {
tunnelConfig?.let {
appDataRepository.tunnels.save(it)
}
@ -81,7 +80,7 @@ constructor(
}
}
fun onToggleIsMobileDataTunnel() = viewModelScope.launch(Dispatchers.IO) {
fun onToggleIsMobileDataTunnel() = viewModelScope.launch {
uiState.value.tunnel?.let {
if (it.isMobileDataTunnel) {
appDataRepository.tunnels.updateMobileDataTunnel(null)
@ -89,7 +88,7 @@ constructor(
}
}
fun onTogglePrimaryTunnel() = viewModelScope.launch(Dispatchers.IO) {
fun onTogglePrimaryTunnel() = viewModelScope.launch {
if (uiState.value.tunnel != null) {
appDataRepository.tunnels.updatePrimaryTunnel(
when (uiState.value.isDefaultTunnel) {

View File

@ -81,9 +81,7 @@ import com.zaneschepke.wireguardautotunnel.ui.common.ClickableIconButton
import com.zaneschepke.wireguardautotunnel.ui.common.config.ConfigurationToggle
import com.zaneschepke.wireguardautotunnel.ui.common.prompt.AuthorizationPrompt
import com.zaneschepke.wireguardautotunnel.ui.common.text.SectionTitle
import com.zaneschepke.wireguardautotunnel.util.FileUtils
import com.zaneschepke.wireguardautotunnel.util.getMessage
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import timber.log.Timber
import xyz.teamgravity.pin_lock_compose.PinManager
@ -100,9 +98,9 @@ fun SettingsScreen(
navController: NavController,
focusRequester: FocusRequester,
) {
val scope = rememberCoroutineScope { Dispatchers.IO }
val context = LocalContext.current
val focusManager = LocalFocusManager.current
val scope = rememberCoroutineScope()
val scrollState = rememberScrollState()
val interactionSource = remember { MutableInteractionSource() }
val pinExists = remember { mutableStateOf(PinManager.pinExists()) }
@ -137,18 +135,22 @@ fun SettingsScreen(
}
file
}
val amFiles = uiState.tunnels.mapNotNull { config -> if(config.amQuick != TunnelConfig.AM_QUICK_DEFAULT) {
val file = File(context.cacheDir, "${config.name}-am.conf")
file.outputStream().use {
it.write(config.amQuick.toByteArray())
val amFiles = uiState.tunnels.mapNotNull { config ->
if (config.amQuick != TunnelConfig.AM_QUICK_DEFAULT) {
val file = File(context.cacheDir, "${config.name}-am.conf")
file.outputStream().use {
it.write(config.amQuick.toByteArray())
}
file
} else null
}
scope.launch {
viewModel.onExportTunnels(wgFiles + amFiles).onFailure {
appViewModel.showSnackbarMessage(it.getMessage(context))
}.onSuccess {
didExportFiles = true
appViewModel.showSnackbarMessage(context.getString(R.string.exported_configs_message))
}
file
} else null }
FileUtils.saveFilesToZip(context, wgFiles + amFiles).onFailure {
appViewModel.showSnackbarMessage(it.getMessage(context))
}.onSuccess {
didExportFiles = true
appViewModel.showSnackbarMessage(context.getString(R.string.exported_configs_message))
}
} catch (e: Exception) {
Timber.e(e)
@ -190,11 +192,9 @@ fun SettingsScreen(
}
fun openSettings() {
scope.launch {
val intentSettings = Intent(ACTION_APPLICATION_DETAILS_SETTINGS)
intentSettings.data = Uri.fromParts("package", context.packageName, null)
context.startActivity(intentSettings)
}
val intentSettings = Intent(ACTION_APPLICATION_DETAILS_SETTINGS)
intentSettings.data = Uri.fromParts("package", context.packageName, null)
context.startActivity(intentSettings)
}
fun checkFineLocationGranted() {

View File

@ -12,6 +12,7 @@ import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.FileUtils
import com.zaneschepke.wireguardautotunnel.util.WgTunnelExceptions
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
@ -19,6 +20,7 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import timber.log.Timber
import java.io.File
import javax.inject.Inject
@HiltViewModel
@ -28,6 +30,7 @@ constructor(
private val appDataRepository: AppDataRepository,
private val serviceManager: ServiceManager,
private val rootShell: RootShell,
private val fileUtils: FileUtils,
vpnService: VpnService
) : ViewModel() {
@ -90,6 +93,10 @@ constructor(
)
}
suspend fun onExportTunnels(files: List<File>): Result<Unit> {
return fileUtils.saveFilesToZip(files)
}
fun onToggleAutoTunnel(context: Context) =
viewModelScope.launch {
val isAutoTunnelEnabled = uiState.value.settings.isAutoTunnelEnabled
@ -160,7 +167,7 @@ constructor(
}
fun onToggleAmnezia() = viewModelScope.launch {
if(uiState.value.settings.isKernelEnabled) {
if (uiState.value.settings.isKernelEnabled) {
saveKernelMode(false)
}
saveAmneziaMode(!uiState.value.settings.isAmneziaEnabled)
@ -169,8 +176,8 @@ constructor(
private fun saveAmneziaMode(on: Boolean) {
saveSettings(
uiState.value.settings.copy(
isAmneziaEnabled = on
)
isAmneziaEnabled = on,
),
)
}

View File

@ -107,7 +107,12 @@ fun SupportScreen(
modifier = Modifier.padding(bottom = 20.dp),
)
TextButton(
onClick = { appViewModel.openWebPage(context.resources.getString(R.string.docs_url), context) },
onClick = {
appViewModel.openWebPage(
context.resources.getString(R.string.docs_url),
context,
)
},
modifier = Modifier
.padding(vertical = 5.dp)
.focusRequester(focusRequester),
@ -129,7 +134,7 @@ fun SupportScreen(
weight = 1.0f,
fill = false,
),
softWrap = true
softWrap = true,
)
}
Icon(
@ -143,7 +148,12 @@ fun SupportScreen(
color = MaterialTheme.colorScheme.onBackground,
)
TextButton(
onClick = { appViewModel.openWebPage(context.resources.getString(R.string.telegram_url), context) },
onClick = {
appViewModel.openWebPage(
context.resources.getString(R.string.telegram_url),
context,
)
},
modifier = Modifier.padding(vertical = 5.dp),
) {
Row(
@ -175,7 +185,12 @@ fun SupportScreen(
color = MaterialTheme.colorScheme.onBackground,
)
TextButton(
onClick = { appViewModel.openWebPage(context.resources.getString(R.string.github_url), context) },
onClick = {
appViewModel.openWebPage(
context.resources.getString(R.string.github_url),
context,
)
},
modifier = Modifier.padding(vertical = 5.dp),
) {
Row(
@ -269,7 +284,10 @@ fun SupportScreen(
fontSize = 16.sp,
modifier =
Modifier.clickable {
appViewModel.openWebPage(context.resources.getString(R.string.privacy_policy_url), context)
appViewModel.openWebPage(
context.resources.getString(R.string.privacy_policy_url),
context,
)
},
)
Row(
@ -285,7 +303,7 @@ fun SupportScreen(
val mode = buildAnnotatedString {
append(stringResource(R.string.mode))
append(": ")
when(uiState.settings.isKernelEnabled){
when (uiState.settings.isKernelEnabled) {
true -> append(stringResource(id = R.string.kernel))
false -> append(stringResource(id = R.string.userspace))
}

View File

@ -1,6 +1,7 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.support.logs
import android.annotation.SuppressLint
import android.widget.Toast
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
@ -8,7 +9,7 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
@ -32,17 +33,17 @@ import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.zaneschepke.wireguardautotunnel.ui.AppViewModel
import androidx.hilt.navigation.compose.hiltViewModel
import com.zaneschepke.logcatter.model.LogMessage
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.common.text.LogTypeLabel
import kotlinx.coroutines.launch
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@Composable
fun LogsScreen(appViewModel: AppViewModel) {
fun LogsScreen(viewModel: LogsViewModel = hiltViewModel()) {
val logs = remember {
appViewModel.logs
}
val logs = viewModel.logs
val context = LocalContext.current
@ -60,7 +61,15 @@ fun LogsScreen(appViewModel: AppViewModel) {
floatingActionButton = {
FloatingActionButton(
onClick = {
appViewModel.saveLogsToFile(context)
scope.launch {
viewModel.saveLogsToFile().onSuccess {
Toast.makeText(
context,
context.getString(R.string.logs_saved),
Toast.LENGTH_SHORT,
).show()
}
}
},
shape = RoundedCornerShape(16.dp),
containerColor = MaterialTheme.colorScheme.primary,
@ -82,7 +91,11 @@ fun LogsScreen(appViewModel: AppViewModel) {
.fillMaxSize()
.padding(horizontal = 24.dp),
) {
items(logs) {
itemsIndexed(
logs,
key = { index, _ -> index },
contentType = { _: Int, _: LogMessage -> null },
) { _, it ->
Row(
horizontalArrangement = Arrangement.spacedBy(5.dp, Alignment.Start),
verticalAlignment = Alignment.Top,

View File

@ -0,0 +1,55 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.support.logs
import androidx.compose.runtime.mutableStateListOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.zaneschepke.logcatter.LocalLogCollector
import com.zaneschepke.logcatter.model.LogMessage
import com.zaneschepke.wireguardautotunnel.module.IoDispatcher
import com.zaneschepke.wireguardautotunnel.module.MainDispatcher
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.FileUtils
import com.zaneschepke.wireguardautotunnel.util.chunked
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.time.Duration
import java.time.Instant
import javax.inject.Inject
@HiltViewModel
class LogsViewModel
@Inject constructor(
private val localLogCollector: LocalLogCollector,
private val fileUtils: FileUtils,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
@MainDispatcher private val mainDispatcher: CoroutineDispatcher
) : ViewModel() {
val logs = mutableStateListOf<LogMessage>()
init {
viewModelScope.launch(ioDispatcher) {
localLogCollector.bufferedLogs.chunked(500, Duration.ofSeconds(1)).collect {
withContext(mainDispatcher) {
logs.addAll(it)
}
if (logs.size > Constants.LOG_BUFFER_SIZE) {
withContext(mainDispatcher) {
logs.removeRange(0, (logs.size - Constants.LOG_BUFFER_SIZE).toInt())
}
}
}
}
}
suspend fun saveLogsToFile(): Result<Unit> {
val file = localLogCollector.getLogFile().getOrElse {
return Result.failure(it)
}
val fileContent = fileUtils.readBytesFromFile(file)
val fileName = "${Constants.BASE_LOG_FILE_NAME}-${Instant.now().epochSecond}.txt"
return fileUtils.saveByteArrayToDownloads(fileContent, fileName)
}
}

View File

@ -16,10 +16,11 @@ object Constants {
const val URI_CONTENT_SCHEME = "content"
const val ALLOWED_FILE_TYPES = "*/*"
const val TEXT_MIME_TYPE = "text/plain"
const val ZIP_FILE_MIME_TYPE = "application/zip"
const val GOOGLE_TV_EXPLORER_STUB = "com.google.android.tv.frameworkpackagestubs"
const val ANDROID_TV_EXPLORER_STUB = "com.android.tv.frameworkpackagestubs"
const val ALWAYS_ON_VPN_ACTION = "android.net.VpnService"
const val EMAIL_MIME_TYPE = "message/rfc822"
const val EMAIL_MIME_TYPE = "plain/text"
const val SYSTEM_EXEMPT_SERVICE_TYPE_ID = 1024
const val SUBSCRIPTION_TIMEOUT = 5_000L

View File

@ -9,13 +9,27 @@ import com.zaneschepke.wireguardautotunnel.service.tunnel.HandshakeStatus
import com.zaneschepke.wireguardautotunnel.service.tunnel.statistics.TunnelStatistics
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.ObsoleteCoroutinesApi
import kotlinx.coroutines.channels.ClosedReceiveChannelException
import kotlinx.coroutines.channels.ReceiveChannel
import kotlinx.coroutines.channels.produce
import kotlinx.coroutines.channels.ticker
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.selects.whileSelect
import org.amnezia.awg.config.Config
import timber.log.Timber
import java.math.BigDecimal
import java.text.DecimalFormat
import java.time.Duration
import java.util.concurrent.ConcurrentLinkedQueue
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
import kotlin.coroutines.cancellation.CancellationException
fun BroadcastReceiver.goAsync(
context: CoroutineContext = EmptyCoroutineContext,
@ -32,12 +46,6 @@ fun BroadcastReceiver.goAsync(
}
}
fun String.truncateWithEllipsis(allowedLength: Int): String {
return if (this.length > allowedLength + 3) {
this.substring(0, allowedLength) + "***"
} else this
}
fun BigDecimal.toThreeDecimalPlaceString(): String {
val df = DecimalFormat("#.###")
return df.format(this)
@ -73,14 +81,14 @@ fun TunnelStatistics.PeerStats.handshakeStatus(): HandshakeStatus {
}
}
fun Config.toWgQuickString() : String {
fun Config.toWgQuickString(): String {
val amQuick = toAwgQuickString()
val lines = amQuick.lines().toMutableList()
val linesIterator = lines.iterator()
while(linesIterator.hasNext()) {
while (linesIterator.hasNext()) {
val next = linesIterator.next()
Constants.amneziaProperties.forEach {
if(next.startsWith(it, ignoreCase = true)) {
if (next.startsWith(it, ignoreCase = true)) {
linesIterator.remove()
}
}
@ -88,9 +96,73 @@ fun Config.toWgQuickString() : String {
return lines.joinToString(System.lineSeparator())
}
fun Throwable.getMessage(context: Context) : String {
return when(this) {
fun Throwable.getMessage(context: Context): String {
return when (this) {
is WgTunnelExceptions -> this.getMessage(context)
else -> this.message ?: StringValue.StringResource(R.string.unknown_error).asString(context)
}
}
/**
* Chunks based on a time or size threshold.
*
* Borrowed from this [Stack Overflow question](https://stackoverflow.com/questions/51022533/kotlin-chunk-sequence-based-on-size-and-time).
*/
@OptIn(ObsoleteCoroutinesApi::class, ExperimentalCoroutinesApi::class)
fun <T> ReceiveChannel<T>.chunked(scope: CoroutineScope, size: Int, time: Duration) =
scope.produce<List<T>> {
while (true) { // this loop goes over each chunk
val chunk = ConcurrentLinkedQueue<T>() // current chunk
val ticker = ticker(time.toMillis()) // time-limit for this chunk
try {
whileSelect {
ticker.onReceive {
false // done with chunk when timer ticks, takes priority over received elements
}
this@chunked.onReceive {
chunk += it
chunk.size < size // continue whileSelect if chunk is not full
}
}
} catch (e: ClosedReceiveChannelException) {
Timber.e(e)
return@produce
} finally {
ticker.cancel()
if (chunk.isNotEmpty()) {
send(chunk.toList())
}
}
}
}
@OptIn(DelicateCoroutinesApi::class, ExperimentalCoroutinesApi::class)
fun <T> Flow<T>.chunked(size: Int, time: Duration) = channelFlow {
coroutineScope {
val channel = asChannel(this@chunked).chunked(this, size, time)
try {
while (!channel.isClosedForReceive) {
send(channel.receive())
}
} catch (e: ClosedReceiveChannelException) {
// Channel was closed by the flow completing, nothing to do
Timber.w(e)
} catch (e: CancellationException) {
channel.cancel(e)
throw e
} catch (e: Exception) {
channel.cancel(CancellationException("Closing channel due to flow exception", e))
throw e
}
}
}
@ExperimentalCoroutinesApi
fun <T> CoroutineScope.asChannel(flow: Flow<T>): ReceiveChannel<T> = produce {
flow.collect { value ->
channel.send(value)
}
}

View File

@ -6,20 +6,102 @@ import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import android.provider.MediaStore.MediaColumns
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.OutputStream
import java.time.Instant
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
object FileUtils {
private const val ZIP_FILE_MIME_TYPE = "application/zip"
class FileUtils(
private val context: Context,
private val ioDispatcher: CoroutineDispatcher,
) {
suspend fun readBytesFromFile(file: File): ByteArray {
return withContext(ioDispatcher) {
FileInputStream(file).use {
it.readBytes()
}
}
}
suspend fun readTextFromFileName(fileName: String): String {
return withContext(ioDispatcher) {
context.assets.open(fileName).use { stream ->
stream.bufferedReader(Charsets.UTF_8).use {
it.readText()
}
}
}
}
suspend fun saveByteArrayToDownloads(content: ByteArray, fileName: String): Result<Unit> {
return withContext(ioDispatcher) {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val contentValues =
ContentValues().apply {
put(MediaColumns.DISPLAY_NAME, fileName)
put(MediaColumns.MIME_TYPE, Constants.TEXT_MIME_TYPE)
put(MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
}
val resolver = context.contentResolver
val uri =
resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues)
if (uri != null) {
resolver.openOutputStream(uri).use { output ->
output?.write(content)
}
}
} else {
val target =
File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
fileName,
)
FileOutputStream(target).use { output ->
output.write(content)
}
}
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)
}
}
}
suspend fun saveFilesToZip(files: List<File>): Result<Unit> {
return withContext(ioDispatcher) {
try {
val zipOutputStream =
createDownloadsFileOutputStream(
"wg-export_${Instant.now().epochSecond}.zip",
Constants.ZIP_FILE_MIME_TYPE,
)
ZipOutputStream(zipOutputStream).use { zos ->
files.forEach { file ->
val entry = ZipEntry(file.name)
zos.putNextEntry(entry)
if (file.isFile) {
file.inputStream().use { fis -> fis.copyTo(zos) }
}
}
return@withContext Result.success(Unit)
}
} catch (e: Exception) {
Timber.e(e)
Result.failure(WgTunnelExceptions.ConfigExportFailed())
}
}
}
//TODO issue with android 9
private fun createDownloadsFileOutputStream(
context: Context,
fileName: String,
mimeType: String = Constants.ALLOWED_FILE_TYPES
): OutputStream? {
@ -45,53 +127,4 @@ object FileUtils {
}
return null
}
fun saveFileToDownloads(context: Context, content: String, fileName: String) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val contentValues = ContentValues().apply {
put(MediaColumns.DISPLAY_NAME, fileName)
put(MediaColumns.MIME_TYPE, Constants.TEXT_MIME_TYPE)
put(MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
}
val resolver = context.contentResolver
val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues)
if (uri != null) {
resolver.openOutputStream(uri).use { output ->
output?.write(content.toByteArray())
}
}
} else {
val target = File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
fileName,
)
FileOutputStream(target).use { output ->
output.write(content.toByteArray())
}
}
}
fun saveFilesToZip(context: Context, files: List<File>) : Result<Unit> {
return try {
val zipOutputStream =
createDownloadsFileOutputStream(
context,
"wg-export_${Instant.now().epochSecond}.zip",
ZIP_FILE_MIME_TYPE,
)
ZipOutputStream(zipOutputStream).use { zos ->
files.forEach { file ->
val entry = ZipEntry(file.name)
zos.putNextEntry(entry)
if (file.isFile) {
file.inputStream().use { fis -> fis.copyTo(zos) }
}
}
return Result.success(Unit)
}
} catch (e : Exception) {
Timber.e(e)
Result.failure(WgTunnelExceptions.ConfigExportFailed())
}
}
}

View File

@ -4,121 +4,127 @@ import android.content.Context
import com.zaneschepke.wireguardautotunnel.R
sealed class WgTunnelExceptions : Exception() {
abstract fun getMessage(context: Context) : String
data class General(private val userMessage : StringValue) : WgTunnelExceptions() {
override fun getMessage(context: Context) : String {
abstract fun getMessage(context: Context): String
data class General(private val userMessage: StringValue) : WgTunnelExceptions() {
override fun getMessage(context: Context): String {
return userMessage.asString(context)
}
}
data class SsidConflict(private val userMessage : StringValue = StringValue.StringResource(R.string.error_ssid_exists)) : WgTunnelExceptions() {
override fun getMessage(context: Context) : String {
data class SsidConflict(private val userMessage: StringValue = StringValue.StringResource(R.string.error_ssid_exists)) :
WgTunnelExceptions() {
override fun getMessage(context: Context): String {
return userMessage.asString(context)
}
}
data class ConfigExportFailed(private val userMessage : StringValue = StringValue.StringResource(R.string.export_configs_failed)) : WgTunnelExceptions() {
override fun getMessage(context: Context) : String {
data class ConfigExportFailed(
private val userMessage: StringValue = StringValue.StringResource(
R.string.export_configs_failed,
)
) : WgTunnelExceptions() {
override fun getMessage(context: Context): String {
return userMessage.asString(context)
}
}
data class ConfigParseError(private val appendMessage : StringValue = StringValue.Empty) : WgTunnelExceptions() {
override fun getMessage(context: Context) : String {
data class ConfigParseError(private val appendMessage: StringValue = StringValue.Empty) :
WgTunnelExceptions() {
override fun getMessage(context: Context): String {
return StringValue.StringResource(R.string.config_parse_error).asString(context) + (
if (appendMessage != StringValue.Empty) ": ${appendMessage.asString(context)}" else "")
}
}
data class RootDenied(private val userMessage : StringValue = StringValue.StringResource(R.string.error_root_denied)) : WgTunnelExceptions() {
override fun getMessage(context: Context) : String {
data class RootDenied(private val userMessage: StringValue = StringValue.StringResource(R.string.error_root_denied)) :
WgTunnelExceptions() {
override fun getMessage(context: Context): String {
return userMessage.asString(context)
}
}
data class InvalidQrCode(private val userMessage : StringValue = StringValue.StringResource(R.string.error_invalid_code)) : WgTunnelExceptions() {
override fun getMessage(context: Context) : String {
data class InvalidQrCode(private val userMessage: StringValue = StringValue.StringResource(R.string.error_invalid_code)) :
WgTunnelExceptions() {
override fun getMessage(context: Context): String {
return userMessage.asString(context)
}
}
data class InvalidFileExtension(private val userMessage : StringValue = StringValue.StringResource(R.string.error_file_extension)) : WgTunnelExceptions() {
override fun getMessage(context: Context) : String {
data class InvalidFileExtension(
private val userMessage: StringValue = StringValue.StringResource(
R.string.error_file_extension,
)
) : WgTunnelExceptions() {
override fun getMessage(context: Context): String {
return userMessage.asString(context)
}
}
data class FileReadFailed(private val userMessage : StringValue = StringValue.StringResource(R.string.error_file_format)) : WgTunnelExceptions() {
override fun getMessage(context: Context) : String {
data class FileReadFailed(private val userMessage: StringValue = StringValue.StringResource(R.string.error_file_format)) :
WgTunnelExceptions() {
override fun getMessage(context: Context): String {
return userMessage.asString(context)
}
}
data class AuthenticationFailed(private val userMessage : StringValue = StringValue.StringResource(R.string.error_authentication_failed)) : WgTunnelExceptions() {
override fun getMessage(context: Context) : String {
data class AuthenticationFailed(
private val userMessage: StringValue = StringValue.StringResource(
R.string.error_authentication_failed,
)
) : WgTunnelExceptions() {
override fun getMessage(context: Context): String {
return userMessage.asString(context)
}
}
data class AuthorizationFailed(private val userMessage : StringValue = StringValue.StringResource(R.string.error_authorization_failed)) : WgTunnelExceptions() {
override fun getMessage(context: Context) : String {
data class AuthorizationFailed(
private val userMessage: StringValue = StringValue.StringResource(
R.string.error_authorization_failed,
)
) : WgTunnelExceptions() {
override fun getMessage(context: Context): String {
return userMessage.asString(context)
}
}
data class BackgroundLocationRequired(private val userMessage : StringValue = StringValue.StringResource(R.string.background_location_required)) : WgTunnelExceptions() {
override fun getMessage(context: Context) : String {
data class BackgroundLocationRequired(
private val userMessage: StringValue = StringValue.StringResource(
R.string.background_location_required,
)
) : WgTunnelExceptions() {
override fun getMessage(context: Context): String {
return userMessage.asString(context)
}
}
data class LocationServicesRequired(private val userMessage : StringValue = StringValue.StringResource(R.string.location_services_required)) : WgTunnelExceptions() {
override fun getMessage(context: Context) : String {
data class LocationServicesRequired(
private val userMessage: StringValue = StringValue.StringResource(
R.string.location_services_required,
)
) : WgTunnelExceptions() {
override fun getMessage(context: Context): String {
return userMessage.asString(context)
}
}
data class PreciseLocationRequired(private val userMessage : StringValue = StringValue.StringResource(R.string.precise_location_required)) : WgTunnelExceptions() {
override fun getMessage(context: Context) : String {
data class PreciseLocationRequired(
private val userMessage: StringValue = StringValue.StringResource(
R.string.precise_location_required,
)
) : WgTunnelExceptions() {
override fun getMessage(context: Context): String {
return userMessage.asString(context)
}
}
data class FileExplorerRequired (private val userMessage : StringValue = StringValue.StringResource(R.string.error_no_file_explorer)) : WgTunnelExceptions() {
override fun getMessage(context: Context) : String {
data class FileExplorerRequired(
private val userMessage: StringValue = StringValue.StringResource(
R.string.error_no_file_explorer,
)
) : WgTunnelExceptions() {
override fun getMessage(context: Context): String {
return userMessage.asString(context)
}
}
// 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

@ -3,7 +3,7 @@
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:pathData="M440,520L200,520v-80h240v-240h80v240h240v80L520,520v240h-80v-240Z"
android:fillColor="#e8eaed"/>
<path
android:fillColor="#e8eaed"
android:pathData="M440,520L200,520v-80h240v-240h80v240h240v80L520,520v240h-80v-240Z" />
</vector>

View File

@ -3,7 +3,7 @@
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:pathData="m256,760 l-56,-56 224,-224 -224,-224 56,-56 224,224 224,-224 56,56 -224,224 224,224 -56,56 -224,-224 -224,224Z"
android:fillColor="#e8eaed"/>
<path
android:fillColor="#e8eaed"
android:pathData="m256,760 l-56,-56 224,-224 -224,-224 56,-56 224,224 224,-224 56,56 -224,224 224,224 -56,56 -224,-224 -224,224Z" />
</vector>

View File

@ -3,7 +3,7 @@
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:pathData="M200,760h57l391,-391 -57,-57 -391,391v57ZM120,840v-170l528,-527q12,-11 26.5,-17t30.5,-6q16,0 31,6t26,18l55,56q12,11 17.5,26t5.5,30q0,16 -5.5,30.5T817,313L290,840L120,840ZM760,256 L704,200 760,256ZM619,341 L591,312 648,369 619,341Z"
android:fillColor="#e8eaed"/>
<path
android:fillColor="#e8eaed"
android:pathData="M200,760h57l391,-391 -57,-57 -391,391v57ZM120,840v-170l528,-527q12,-11 26.5,-17t30.5,-6q16,0 31,6t26,18l55,56q12,11 17.5,26t5.5,30q0,16 -5.5,30.5T817,313L290,840L120,840ZM760,256 L704,200 760,256ZM619,341 L591,312 648,369 619,341Z" />
</vector>

View File

@ -3,7 +3,7 @@
android:height="50dp"
android:viewportWidth="50"
android:viewportHeight="50">
<path
android:fillColor="#FF000000"
android:pathData="M25,2c12.703,0 23,10.297 23,23S37.703,48 25,48S2,37.703 2,25S12.297,2 25,2zM32.934,34.375c0.423,-1.298 2.405,-14.234 2.65,-16.783c0.074,-0.772 -0.17,-1.285 -0.648,-1.514c-0.578,-0.278 -1.434,-0.139 -2.427,0.219c-1.362,0.491 -18.774,7.884 -19.78,8.312c-0.954,0.405 -1.856,0.847 -1.856,1.487c0,0.45 0.267,0.703 1.003,0.966c0.766,0.273 2.695,0.858 3.834,1.172c1.097,0.303 2.346,0.04 3.046,-0.395c0.742,-0.461 9.305,-6.191 9.92,-6.693c0.614,-0.502 1.104,0.141 0.602,0.644c-0.502,0.502 -6.38,6.207 -7.155,6.997c-0.941,0.959 -0.273,1.953 0.358,2.351c0.721,0.454 5.906,3.932 6.687,4.49c0.781,0.558 1.573,0.811 2.298,0.811C32.191,36.439 32.573,35.484 32.934,34.375z"/>
<path
android:fillColor="#FF000000"
android:pathData="M25,2c12.703,0 23,10.297 23,23S37.703,48 25,48S2,37.703 2,25S12.297,2 25,2zM32.934,34.375c0.423,-1.298 2.405,-14.234 2.65,-16.783c0.074,-0.772 -0.17,-1.285 -0.648,-1.514c-0.578,-0.278 -1.434,-0.139 -2.427,0.219c-1.362,0.491 -18.774,7.884 -19.78,8.312c-0.954,0.405 -1.856,0.847 -1.856,1.487c0,0.45 0.267,0.703 1.003,0.966c0.766,0.273 2.695,0.858 3.834,1.172c1.097,0.303 2.346,0.04 3.046,-0.395c0.742,-0.461 9.305,-6.191 9.92,-6.693c0.614,-0.502 1.104,0.141 0.602,0.644c-0.502,0.502 -6.38,6.207 -7.155,6.997c-0.941,0.959 -0.273,1.953 0.358,2.351c0.721,0.454 5.906,3.932 6.687,4.49c0.781,0.558 1.573,0.811 2.298,0.811C32.191,36.439 32.573,35.484 32.934,34.375z" />
</vector>

View File

@ -1,7 +1,7 @@
object Constants {
const val VERSION_NAME = "3.4.4"
const val VERSION_NAME = "3.4.5"
const val JVM_TARGET = "17"
const val VERSION_CODE = 34400
const val VERSION_CODE = 34500
const val TARGET_SDK = 34
const val MIN_SDK = 26
const val APP_ID = "com.zaneschepke.wireguardautotunnel"

View File

@ -0,0 +1,5 @@
What's new:
- Additional language support
- Auto-tunneling mobile data bug fix
- AndroidTV floating action button fix
- Other optimizations and enhancements

View File

@ -16,19 +16,18 @@ junit = "4.13.2"
kotlinx-serialization-json = "1.6.3"
lifecycle-runtime-compose = "2.7.0"
material3 = "1.2.1"
multifabVersion = "1.0.9"
multifabVersion = "1.1.0"
navigationCompose = "2.7.7"
pinLockCompose = "1.0.3"
roomVersion = "2.6.1"
timber = "5.0.1"
tunnel = "1.0.20230706"
androidGradlePlugin = "8.4.0"
androidGradlePlugin = "8.4.1"
kotlin = "1.9.23"
ksp = "1.9.23-1.0.19"
composeBom = "2024.05.00"
compose = "1.6.7"
zxingAndroidEmbedded = "4.3.0"
zxingCore = "3.5.3"
#plugins
gradlePlugins-kotlinxSerialization = "1.9.23"
@ -89,7 +88,6 @@ timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" }
tunnel = { module = "com.wireguard.android:tunnel", version.ref = "tunnel" }
zaneschepke-multifab = { module = "com.zaneschepke:multifab", version.ref = "multifabVersion" }
zxing-core = { module = "com.google.zxing:core", version.ref = "zxingCore" }
zxing-android-embedded = { module = "com.journeyapps:zxing-android-embedded", version.ref = "zxingAndroidEmbedded" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" }

View File

@ -1,7 +1,7 @@
#Wed Oct 11 22:39:21 EDT 2023
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
distributionSha256Sum=9631d53cf3e74bfa726893aee1f8994fee4e060c401335946dba2156f440f24c
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME

View File

@ -40,4 +40,7 @@ dependencies {
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
// logging
implementation(libs.timber)
}

View File

@ -0,0 +1,13 @@
package com.zaneschepke.logcatter
import com.zaneschepke.logcatter.model.LogMessage
import kotlinx.coroutines.flow.Flow
import java.io.File
interface LocalLogCollector {
fun start(onLogMessage: ((message: LogMessage) -> Unit)? = null)
fun stop()
suspend fun getLogFile(): Result<File>
val bufferedLogs: Flow<LogMessage>
}

View File

@ -1,34 +1,271 @@
package com.zaneschepke.logcatter
import android.content.Context
import com.zaneschepke.logcatter.model.LogLevel
import com.zaneschepke.logcatter.model.LogMessage
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.io.BufferedReader
import java.io.File
import java.io.FileNotFoundException
import java.io.FileOutputStream
import java.io.IOException
import java.io.InputStreamReader
import java.nio.file.Files
import java.nio.file.Paths
import java.nio.file.StandardOpenOption
object Logcatter {
object LogcatHelper {
private const val MAX_FILE_SIZE = 2097152L // 2MB
private const val MAX_FOLDER_SIZE = 10485760L // 10MB
private val findKeyRegex = """[A-Za-z0-9+/]{42}[AEIMQUYcgkosw480]=""".toRegex()
private val findIpv6AddressRegex = """(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))""".toRegex()
private val findIpv6AddressRegex =
"""(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))""".toRegex()
private val findIpv4AddressRegex = """((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.?\b){4}""".toRegex()
private val findTunnelNameRegex = """(?<=tunnel ).*?(?= UP| DOWN)""".toRegex()
private const val CHORE = "Choreographer"
private object LogcatHelperInit {
var maxFileSize: Long = MAX_FILE_SIZE
var maxFolderSize: Long = MAX_FOLDER_SIZE
var pID: Int = 0
var publicAppDirectory = ""
var logcatPath = ""
}
fun logs(callback: (input: LogMessage) -> Unit, obfuscator: (log : String) -> String = { log -> this.obfuscator(log)}){
clear()
Runtime.getRuntime().exec("logcat -v epoch")
.inputStream
.bufferedReader()
.useLines { lines ->
lines.forEach { callback(LogMessage.from(obfuscator(it))) }
fun init(
maxFileSize: Long = MAX_FILE_SIZE,
maxFolderSize: Long = MAX_FOLDER_SIZE,
context: Context
): LocalLogCollector {
if (maxFileSize > maxFolderSize) {
throw IllegalStateException("maxFileSize must be less than maxFolderSize")
}
synchronized(LogcatHelperInit) {
LogcatHelperInit.maxFileSize = maxFileSize
LogcatHelperInit.maxFolderSize = maxFolderSize
LogcatHelperInit.pID = android.os.Process.myPid()
context.getExternalFilesDir(null)?.let {
LogcatHelperInit.publicAppDirectory = it.absolutePath
LogcatHelperInit.logcatPath =
LogcatHelperInit.publicAppDirectory + File.separator + "logs"
val logDirectory = File(LogcatHelperInit.logcatPath)
if (!logDirectory.exists()) {
logDirectory.mkdir()
}
}
return Logcat
}
}
private fun obfuscator(log : String) : String {
return findKeyRegex.replace(log, "<crypto-key>").let { first ->
findIpv6AddressRegex.replace(first, "<ipv6-address>").let { second ->
findTunnelNameRegex.replace(second, "<tunnel>")
}
}.let{ last -> findIpv4AddressRegex.replace(last,"<ipv4-address>") }
}
internal object Logcat : LocalLogCollector {
fun clear() {
Runtime.getRuntime().exec("logcat -c")
private var logcatReader: LogcatReader? = null
override fun start(onLogMessage: ((message: LogMessage) -> Unit)?) {
logcatReader ?: run {
logcatReader = LogcatReader(
LogcatHelperInit.pID.toString(),
LogcatHelperInit.logcatPath,
onLogMessage,
)
}
logcatReader?.let { logReader ->
if (!logReader.isAlive) logReader.start()
}
}
override fun stop() {
logcatReader?.stopLogs()
logcatReader = null
}
private fun mergeLogsApi26(sourceDir: String, outputFile: File) {
val outputFilePath = Paths.get(outputFile.absolutePath)
val logcatPath = Paths.get(sourceDir)
Files.list(logcatPath).use {
it.sorted { o1, o2 ->
Files.getLastModifiedTime(o1).compareTo(Files.getLastModifiedTime(o2))
}
.flatMap(Files::lines).use { lines ->
lines.forEach { line ->
Files.write(
outputFilePath,
(line + System.lineSeparator()).toByteArray(),
StandardOpenOption.CREATE,
StandardOpenOption.APPEND,
)
}
}
}
}
override suspend fun getLogFile(): Result<File> {
stop()
return withContext(Dispatchers.IO) {
try {
val outputDir =
File(LogcatHelperInit.publicAppDirectory + File.separator + "output")
val outputFile = File(outputDir.absolutePath + File.separator + "logs.txt")
if (!outputDir.exists()) outputDir.mkdir()
if (outputFile.exists()) outputFile.delete()
mergeLogsApi26(LogcatHelperInit.logcatPath, outputFile)
Result.success(outputFile)
} catch (e: Exception) {
Result.failure(e)
} finally {
start()
}
}
}
private val _bufferedLogs = MutableSharedFlow<LogMessage>(
replay = 10_000,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
override val bufferedLogs: Flow<LogMessage> = _bufferedLogs.asSharedFlow()
private class LogcatReader(
pID: String,
private val logcatPath: String,
private val callback: ((input: LogMessage) -> Unit)?,
) : Thread() {
private var logcatProc: Process? = null
private var reader: BufferedReader? = null
private var mRunning = true
private var command = ""
private var clearLogCommand = ""
private var outputStream: FileOutputStream? = null
init {
try {
outputStream = FileOutputStream(createLogFile(logcatPath))
} catch (e: FileNotFoundException) {
Timber.e(e)
}
command = "logcat -v epoch | grep \"($pID)\""
clearLogCommand = "logcat -c"
}
fun stopLogs() {
mRunning = false
}
fun clear() {
Runtime.getRuntime().exec(clearLogCommand)
}
private fun obfuscator(log: String): String {
return findKeyRegex.replace(log, "<crypto-key>").let { first ->
findIpv6AddressRegex.replace(first, "<ipv6-address>").let { second ->
findTunnelNameRegex.replace(second, "<tunnel>")
}
}.let { last -> findIpv4AddressRegex.replace(last, "<ipv4-address>") }
}
override fun run() {
if (outputStream == null) return
try {
clear()
logcatProc = Runtime.getRuntime().exec(command)
reader = BufferedReader(InputStreamReader(logcatProc!!.inputStream), 1024)
var line: String? = null
while (mRunning && run {
line = reader!!.readLine()
line
} != null
) {
if (!mRunning) {
break
}
if (line!!.isEmpty()) {
continue
}
if (outputStream!!.channel.size() >= LogcatHelperInit.maxFileSize) {
outputStream!!.close()
outputStream = FileOutputStream(createLogFile(logcatPath))
}
if (getFolderSize(logcatPath) >= LogcatHelperInit.maxFolderSize) {
deleteOldestFile(logcatPath)
}
line?.let { text ->
val obfuscated = obfuscator(text)
outputStream!!.write((obfuscated + System.lineSeparator()).toByteArray())
try {
val logMessage = LogMessage.from(obfuscated)
when (logMessage.level) {
LogLevel.VERBOSE -> Unit
else -> {
if (!logMessage.tag.contains(CHORE)) {
_bufferedLogs.tryEmit(logMessage)
}
}
}
callback?.let {
it(logMessage)
}
} catch (e: Exception) {
Timber.e(e)
}
}
}
} catch (e: IOException) {
Timber.e(e)
} finally {
logcatProc?.destroy()
logcatProc = null
try {
reader?.close()
outputStream?.close()
reader = null
outputStream = null
} catch (e: IOException) {
Timber.e(e)
}
}
}
private fun getFolderSize(path: String): Long {
File(path).run {
var size = 0L
if (this.isDirectory && this.listFiles() != null) {
for (file in this.listFiles()!!) {
size += getFolderSize(file.absolutePath)
}
} else {
size = this.length()
}
return size
}
}
private fun createLogFile(dir: String): File {
return File(dir, "logcat_" + System.currentTimeMillis() + ".txt")
}
private fun deleteOldestFile(path: String) {
val directory = File(path)
if (directory.isDirectory) {
directory.listFiles()?.toMutableList()?.run {
this.sortBy { it.lastModified() }
this.first().delete()
}
}
}
}
}
}

View File

@ -3,12 +3,12 @@ package com.zaneschepke.logcatter.model
import java.time.Instant
data class LogMessage(
val time: Instant,
val time: String,
val pid: String,
val tid: String,
val level: LogLevel,
val tag: String,
val message: String
val message: String,
) {
override fun toString(): String {
return "$time $pid $tid $level $tag message= $message"
@ -16,21 +16,22 @@ data class LogMessage(
companion object {
fun from(logcatLine: String): LogMessage {
return if (logcatLine.contains("---------")) LogMessage(
Instant.now(),
"0",
"0",
LogLevel.VERBOSE,
"System",
logcatLine,
)
else {
//TODO improve this
return if (logcatLine.contains("---------")) {
LogMessage(
Instant.now().toString(),
"0",
"0",
LogLevel.VERBOSE,
"System",
logcatLine,
)
} else {
// TODO improve this
val parts = logcatLine.trim().split(" ").filter { it.isNotEmpty() }
val epochParts = parts[0].split(".").map { it.toLong() }
val message = parts.subList(5, parts.size).joinToString(" ")
LogMessage(
Instant.ofEpochSecond(epochParts[0], epochParts[1]),
Instant.ofEpochSecond(epochParts[0], epochParts[1]).toString(),
parts[1],
parts[2],
LogLevel.fromSignifier(parts[3]),