Compare commits

..

8 Commits

Author SHA1 Message Date
dependabot[bot] 5e7379f94b chore(deps): bump hiltAndroid from 2.55 to 2.56
Bumps `hiltAndroid` from 2.55 to 2.56.

Updates `com.google.dagger:hilt-android` from 2.55 to 2.56
- [Release notes](https://github.com/google/dagger/releases)
- [Changelog](https://github.com/google/dagger/blob/master/CHANGELOG.md)
- [Commits](https://github.com/google/dagger/compare/dagger-2.55...dagger-2.56)

Updates `com.google.dagger:hilt-android-compiler` from 2.55 to 2.56
- [Release notes](https://github.com/google/dagger/releases)
- [Changelog](https://github.com/google/dagger/blob/master/CHANGELOG.md)
- [Commits](https://github.com/google/dagger/compare/dagger-2.55...dagger-2.56)

Updates `com.google.dagger.hilt.android` from 2.55 to 2.56
- [Release notes](https://github.com/google/dagger/releases)
- [Changelog](https://github.com/google/dagger/blob/master/CHANGELOG.md)
- [Commits](https://github.com/google/dagger/compare/dagger-2.55...dagger-2.56)

---
updated-dependencies:
- dependency-name: com.google.dagger:hilt-android
  dependency-type: direct:production
  update-type: version-update:semver-minor
- dependency-name: com.google.dagger:hilt-android-compiler
  dependency-type: direct:production
  update-type: version-update:semver-minor
- dependency-name: com.google.dagger.hilt.android
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-20 13:34:19 +00:00
Zane Schepke c71c4e5b29 chore: bump version and notes 2025-03-19 22:35:21 -04:00
Zane Schepke 7f0fea3766 fix: improve wifi monitoring to better handle permission changes 2025-03-19 21:51:54 -04:00
Zane Schepke 53c19762ef fix: attempt to improve tile sync 2025-03-16 23:12:39 -04:00
Zane Schepke c98fa04f73 fix: auto tunnel and tunnel regressions 2025-03-16 20:10:44 -04:00
Zane Schepke aba0f7d4d3 chore: bump deps 2025-03-16 02:05:55 -04:00
Zane Schepke fa517b2124 fix: race conditions (#621) 2025-03-16 02:04:09 -04:00
Zane Schepke d7e2648393 docs: update readme links 2025-03-15 19:22:34 -04:00
28 changed files with 640 additions and 398 deletions
+7 -7
View File
@@ -4,7 +4,7 @@ WG Tunnel
<div align="center"> <div align="center">
An alternative Android client app for [WireGuard®](https://www.wireguard.com/) An alternative Android client app for [WireGuard](https://www.wireguard.com/)
and [AmneziaWG](https://docs.amnezia.org/documentation/amnezia-wg/) and [AmneziaWG](https://docs.amnezia.org/documentation/amnezia-wg/)
<br /> <br />
<br /> <br />
@@ -23,14 +23,14 @@ and [AmneziaWG](https://docs.amnezia.org/documentation/amnezia-wg/)
[![Google Play](https://img.shields.io/badge/Google_Play-414141?style=for-the-badge&logo=google-play&logoColor=white)](https://play.google.com/store/apps/details?id=com.zaneschepke.wireguardautotunnel) [![Google Play](https://img.shields.io/badge/Google_Play-414141?style=for-the-badge&logo=google-play&logoColor=white)](https://play.google.com/store/apps/details?id=com.zaneschepke.wireguardautotunnel)
[![F-Droid](https://img.shields.io/static/v1?style=for-the-badge&message=F-Droid&color=1976D2&logo=F-Droid&logoColor=FFFFFF&label=)](https://f-droid.org/packages/com.zaneschepke.wireguardautotunnel/) [![F-Droid](https://img.shields.io/static/v1?style=for-the-badge&message=F-Droid&color=1976D2&logo=F-Droid&logoColor=FFFFFF&label=)](https://f-droid.org/packages/com.zaneschepke.wireguardautotunnel/)
[![Personal](https://img.shields.io/static/v1?style=for-the-badge&message=Personal&color=1976D2&logo=F-Droid&logoColor=FFFFFF&label=)](https://github.com/zaneschepke/fdroid) [![Personal](https://img.shields.io/static/v1?style=for-the-badge&message=Personal&color=1976D2&logo=F-Droid&logoColor=FFFFFF&label=)](https://github.com/zaneschepke/fdroid)
[![Obtainium](https://img.shields.io/badge/Obtainium-414141?style=for-the-badge&logo=Obtainium&logoColor=white)](https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22com.zaneschepke.wireguardautotunnel%22%2C%22url%22%3A%22https%3A%2F%2Fgithub.com%2Fzaneschepke%2Fwgtunnel%22%2C%22author%22%3A%22zaneschepke%22%2C%22name%22%3A%22WG%20Tunnel%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22includePrereleases%5C%22%3Afalse%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22filterReleaseNotesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22verifyLatestTag%5C%22%3Atrue%2C%5C%22sortMethodChoice%5C%22%3A%5C%22date%5C%22%2C%5C%22useLatestAssetDateAsReleaseDate%5C%22%3Afalse%2C%5C%22releaseTitleAsVersion%5C%22%3Afalse%2C%5C%22trackOnly%5C%22%3Afalse%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%5C%22%2C%5C%22versionDetection%5C%22%3Atrue%2C%5C%22releaseDateAsVersion%5C%22%3Afalse%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22WG%20Tunnel%5C%22%2C%5C%22appAuthor%5C%22%3A%5C%22Zane%20Schepke%5C%22%2C%5C%22shizukuPretendToBeGooglePlay%5C%22%3Afalse%2C%5C%22allowInsecure%5C%22%3Afalse%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22%5C%22%2C%5C%22refreshBeforeDownload%5C%22%3Afalse%7D%22%2C%22overrideSource%22%3Anull%7D)
</div> </div>
<div align="center"> <div align="center">
[![Discord](https://img.shields.io/badge/Discord-%235865F2.svg?style=for-the-badge&logo=discord&logoColor=white)](https://discord.gg/rbRRNh6H7V) [<img src="https://img.shields.io/badge/Telegram-26A5E4.svg?style=for-the-badge&logo=Telegram&logoColor=white">](https://t.me/wgtunnel)
[![Telegram](https://img.shields.io/badge/Telegram-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/wgtunnel) [<img src="https://img.shields.io/badge/Matrix-000000.svg?style=for-the-badge&logo=Matrix&logoColor=white">](https://matrix.to/#/#wg-tunnel:matrix.zaneschepke.com)
</div> </div>
<details open="open"> <details open="open">
@@ -49,7 +49,7 @@ and [AmneziaWG](https://docs.amnezia.org/documentation/amnezia-wg/)
<div style="text-align: left;"> <div style="text-align: left;">
## About ## About
Inspired by the official [wireguard-android](https://github.com/WireGuard/wireguard-android) app, WG Tunnel was created to address features and support missing from the official app. This app combines support for both [WireGuard®](https://www.wireguard.com/) Inspired by the official [wireguard-android](https://github.com/WireGuard/wireguard-android) app, WG Tunnel was created to address features and support missing from the official app. This app combines support for both [WireGuard](https://www.wireguard.com/)
and [AmneziaWG](https://docs.amnezia.org/documentation/amnezia-wg/), with its primary feature of auto-tunneling (on-demand tunneling). and [AmneziaWG](https://docs.amnezia.org/documentation/amnezia-wg/), with its primary feature of auto-tunneling (on-demand tunneling).
</div> </div>
@@ -61,14 +61,14 @@ and [AmneziaWG](https://docs.amnezia.org/documentation/amnezia-wg/), with its pr
Thank you to the following: Thank you to the following:
- All of the users that have helped contribute to the project with ideas, translations, feedback, bug reports, testing, and donations. - All of the users that have helped contribute to the project with ideas, translations, feedback, bug reports, testing, and donations.
- [WireGuard®](https://www.wireguard.com/) - © Jason A. Donenfeld (https://github.com/WireGuard/wireguard-android) - [WireGuard](https://www.wireguard.com/) - Jason A. Donenfeld (https://github.com/WireGuard/wireguard-android)
- [AmneziaWG](https://docs.amnezia.org/documentation/amnezia-wg/) - Amnezia Team (https://github.com/amnezia-vpn/amneziawg-android) - [AmneziaWG](https://docs.amnezia.org/documentation/amnezia-wg/) - Amnezia Team (https://github.com/amnezia-vpn/amneziawg-android)
## Screenshots ## Screenshots
</div> </div>
<div style="display: flex; flex-wrap: wrap; justify-content: center; gap: 10px;"> <div style="display: flex; flex-wrap: wrap; justify-content: left; gap: 10px;">
<img label="Main" src="fastlane/metadata/android/en-US/images/phoneScreenshots/main_screen.png" width="200" /> <img label="Main" src="fastlane/metadata/android/en-US/images/phoneScreenshots/main_screen.png" width="200" />
<img label="Settings" src="fastlane/metadata/android/en-US/images/phoneScreenshots/settings_screen.png" width="200" /> <img label="Settings" src="fastlane/metadata/android/en-US/images/phoneScreenshots/settings_screen.png" width="200" />
<img label="Auto" src="fastlane/metadata/android/en-US/images/phoneScreenshots/auto_screen.png" width="200" /> <img label="Auto" src="fastlane/metadata/android/en-US/images/phoneScreenshots/auto_screen.png" width="200" />
@@ -1,6 +1,8 @@
package com.zaneschepke.wireguardautotunnel package com.zaneschepke.wireguardautotunnel
import android.Manifest
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Color import android.graphics.Color
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
@@ -32,12 +34,14 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import androidx.navigation.toRoute import androidx.navigation.toRoute
import com.zaneschepke.networkmonitor.NetworkMonitor
import com.zaneschepke.wireguardautotunnel.core.shortcut.ShortcutManager import com.zaneschepke.wireguardautotunnel.core.shortcut.ShortcutManager
import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager
import com.zaneschepke.wireguardautotunnel.domain.repository.AppStateRepository import com.zaneschepke.wireguardautotunnel.domain.repository.AppStateRepository
@@ -68,6 +72,7 @@ import com.zaneschepke.wireguardautotunnel.ui.theme.WireguardAutoTunnelTheme
import com.zaneschepke.wireguardautotunnel.util.Constants import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
import kotlin.system.exitProcess import kotlin.system.exitProcess
@@ -83,6 +88,11 @@ class MainActivity : AppCompatActivity() {
@Inject @Inject
lateinit var shortcutManager: ShortcutManager lateinit var shortcutManager: ShortcutManager
@Inject
lateinit var networkMonitor: NetworkMonitor
private var lastLocationPermissionState: Boolean? = null
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge( enableEdgeToEdge(
statusBarStyle = SystemBarStyle.Companion.auto(Color.TRANSPARENT, Color.TRANSPARENT), statusBarStyle = SystemBarStyle.Companion.auto(Color.TRANSPARENT, Color.TRANSPARENT),
@@ -256,4 +266,22 @@ class MainActivity : AppCompatActivity() {
} }
} }
} }
override fun onResume() {
super.onResume()
checkPermissionAndNotify()
}
private fun checkPermissionAndNotify() {
val hasLocation = ContextCompat.checkSelfPermission(
this,
Manifest.permission.ACCESS_FINE_LOCATION,
) == PackageManager.PERMISSION_GRANTED
if (lastLocationPermissionState != hasLocation) {
Timber.d("Location permission changed to: $hasLocation")
if (hasLocation) {
networkMonitor.sendLocationPermissionsGrantedBroadcast()
}
lastLocationPermissionState = hasLocation
}
}
} }
@@ -4,8 +4,6 @@ import android.app.Service
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import com.zaneschepke.wireguardautotunnel.core.service.autotunnel.AutoTunnelService import com.zaneschepke.wireguardautotunnel.core.service.autotunnel.AutoTunnelService
import com.zaneschepke.wireguardautotunnel.core.service.tile.AutoTunnelControlTile
import com.zaneschepke.wireguardautotunnel.core.service.tile.TunnelControlTile
import com.zaneschepke.wireguardautotunnel.di.ApplicationScope import com.zaneschepke.wireguardautotunnel.di.ApplicationScope
import com.zaneschepke.wireguardautotunnel.di.IoDispatcher import com.zaneschepke.wireguardautotunnel.di.IoDispatcher
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
@@ -20,7 +18,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
import timber.log.Timber import timber.log.Timber
@@ -36,8 +33,6 @@ class ServiceManager @Inject constructor(
var autoTunnelService = CompletableDeferred<AutoTunnelService>() var autoTunnelService = CompletableDeferred<AutoTunnelService>()
var backgroundService = CompletableDeferred<TunnelForegroundService>() var backgroundService = CompletableDeferred<TunnelForegroundService>()
var autoTunnelTile = CompletableDeferred<AutoTunnelControlTile>()
var tunnelControlTile = CompletableDeferred<TunnelControlTile>()
private fun <T : Service> startService(cls: Class<T>, background: Boolean) { private fun <T : Service> startService(cls: Class<T>, background: Boolean) {
runCatching { runCatching {
@@ -61,19 +56,16 @@ class ServiceManager @Inject constructor(
runCatching { runCatching {
autoTunnelService = CompletableDeferred() autoTunnelService = CompletableDeferred()
startService(AutoTunnelService::class.java, background) startService(AutoTunnelService::class.java, background)
val service = withTimeoutOrNull(SERVICE_START_TIMEOUT) { autoTunnelService.await() }
?: throw IllegalStateException("AutoTunnelService start timed out")
service.start()
_autoTunnelActive.update { true } _autoTunnelActive.update { true }
updateAutoTunnelTile()
}.onFailure { }.onFailure {
Timber.e(it) Timber.e(it)
_autoTunnelActive.update { false } _autoTunnelActive.update { false }
} }
} }
updateAutoTunnelTile()
} }
fun startBackgroundService(tunnelConf: TunnelConf) { fun startTunnelForegroundService(tunnelConf: TunnelConf) {
applicationScope.launch(ioDispatcher) { applicationScope.launch(ioDispatcher) {
if (backgroundService.isCompleted) return@launch if (backgroundService.isCompleted) return@launch
runCatching { runCatching {
@@ -88,7 +80,19 @@ class ServiceManager @Inject constructor(
} }
} }
fun stopBackgroundService() { fun updateTunnelForegroundServiceNotification(tunnelConf: TunnelConf) {
applicationScope.launch(ioDispatcher) {
if (!backgroundService.isCompleted) return@launch
runCatching {
val service = backgroundService.await()
service.start(tunnelConf)
}.onFailure {
Timber.e(it)
}
}
}
fun stopTunnelForegroundService() {
applicationScope.launch(ioDispatcher) { applicationScope.launch(ioDispatcher) {
if (!backgroundService.isCompleted) return@launch if (!backgroundService.isCompleted) return@launch
runCatching { runCatching {
@@ -107,34 +111,12 @@ class ServiceManager @Inject constructor(
} }
} }
suspend fun updateAutoTunnelTile() { fun updateAutoTunnelTile() {
withContext(ioDispatcher) { context.requestAutoTunnelTileServiceUpdate()
runCatching {
val service = withTimeoutOrNull(SERVICE_START_TIMEOUT) { autoTunnelTile.await() }
?: run {
context.requestAutoTunnelTileServiceUpdate()
return@withContext
}
service.updateTileState()
}.onFailure {
Timber.e(it)
}
}
} }
suspend fun updateTunnelTile() { fun updateTunnelTile() {
withContext(ioDispatcher) { context.requestTunnelTileServiceStateUpdate()
runCatching {
val service = withTimeoutOrNull(SERVICE_START_TIMEOUT) { tunnelControlTile.await() }
?: run {
context.requestTunnelTileServiceStateUpdate()
return@withContext
}
service.updateTileState()
}.onFailure {
Timber.e(it)
}
}
} }
fun stopAutoTunnel() { fun stopAutoTunnel() {
@@ -49,12 +49,12 @@ class TunnelForegroundService : LifecycleService() {
} }
fun stop() { fun stop() {
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf() stopSelf()
} }
override fun onDestroy() { override fun onDestroy() {
serviceManager.backgroundService = CompletableDeferred() serviceManager.backgroundService = CompletableDeferred()
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
super.onDestroy() super.onDestroy()
} }
@@ -94,9 +94,11 @@ class AutoTunnelService : LifecycleService() {
} }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId)
Timber.d("onStartCommand executed with startId: $startId") Timber.d("onStartCommand executed with startId: $startId")
serviceManager.autoTunnelService.complete(this) serviceManager.autoTunnelService.complete(this)
return super.onStartCommand(intent, flags, startId) start()
return START_STICKY
} }
fun start() { fun start() {
@@ -178,8 +180,8 @@ class AutoTunnelService : LifecycleService() {
combineSettings(), combineSettings(),
appDataRepository.get().settings.flow appDataRepository.get().settings.flow
.distinctUntilChanged { old, new -> old.isKernelEnabled == new.isKernelEnabled } // Only emit when isKernelEnabled changes .distinctUntilChanged { old, new -> old.isKernelEnabled == new.isKernelEnabled } // Only emit when isKernelEnabled changes
.flatMapLatest { settings -> .flatMapLatest {
networkMonitor.getNetworkStatusFlow(true, settings.isKernelEnabled) networkMonitor.networkStatusFlow
.flowOn(ioDispatcher) .flowOn(ioDispatcher)
.map { buildNetworkState(it) } .map { buildNetworkState(it) }
} }
@@ -4,57 +4,61 @@ import android.content.Intent
import android.os.IBinder import android.os.IBinder
import android.service.quicksettings.Tile import android.service.quicksettings.Tile
import android.service.quicksettings.TileService import android.service.quicksettings.TileService
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry
import androidx.lifecycle.lifecycleScope
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.di.ApplicationScope
import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class AutoTunnelControlTile : TileService() { class AutoTunnelControlTile : TileService(), LifecycleOwner {
@Inject @Inject
lateinit var appDataRepository: AppDataRepository lateinit var appDataRepository: AppDataRepository
@Inject @Inject
lateinit var serviceManager: ServiceManager lateinit var serviceManager: ServiceManager
@Inject private val lifecycleRegistry: LifecycleRegistry = LifecycleRegistry(this)
@ApplicationScope
lateinit var applicationScope: CoroutineScope
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
serviceManager.autoTunnelTile.complete(this) lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE)
} }
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
serviceManager.autoTunnelTile = CompletableDeferred() lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY)
} }
override fun onStartListening() { override fun onStartListening() {
super.onStartListening() super.onStartListening()
serviceManager.autoTunnelTile.complete(this) Timber.d("Start listening called for auto tunnel tile")
applicationScope.launch { lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START)
if (appDataRepository.tunnels.getAll().isEmpty()) return@launch setUnavailable() lifecycleScope.launch {
updateTileState() serviceManager.autoTunnelActive.collect {
if (it) setActive() else setInactive()
}
} }
} lifecycleScope.launch {
appDataRepository.tunnels.flow.collect {
fun updateTileState() { if (it.isEmpty()) {
serviceManager.autoTunnelActive.value.let { setUnavailable()
if (it) setActive() else setInactive() } else {
if (qsTile.state == Tile.STATE_ACTIVE) setInactive()
}
}
} }
} }
override fun onClick() { override fun onClick() {
super.onClick() super.onClick()
unlockAndRun { unlockAndRun {
applicationScope.launch { lifecycleScope.launch {
if (serviceManager.autoTunnelActive.value) { if (serviceManager.autoTunnelActive.value) {
serviceManager.stopAutoTunnel() serviceManager.stopAutoTunnel()
setInactive() setInactive()
@@ -97,4 +101,7 @@ class AutoTunnelControlTile : TileService() {
qsTile.updateTile() qsTile.updateTile()
} }
} }
override val lifecycle: Lifecycle
get() = lifecycleRegistry
} }
@@ -5,58 +5,63 @@ import android.os.Build
import android.os.IBinder import android.os.IBinder
import android.service.quicksettings.Tile import android.service.quicksettings.Tile
import android.service.quicksettings.TileService import android.service.quicksettings.TileService
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry
import androidx.lifecycle.lifecycleScope
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager
import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager
import com.zaneschepke.wireguardautotunnel.di.ApplicationScope
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class TunnelControlTile : TileService() { class TunnelControlTile : TileService(), LifecycleOwner {
@Inject @Inject
lateinit var appDataRepository: AppDataRepository lateinit var appDataRepository: AppDataRepository
@Inject
@ApplicationScope
lateinit var applicationScope: CoroutineScope
@Inject @Inject
lateinit var serviceManager: ServiceManager lateinit var serviceManager: ServiceManager
@Inject @Inject
lateinit var tunnelManager: TunnelManager lateinit var tunnelManager: TunnelManager
private val lifecycleRegistry: LifecycleRegistry = LifecycleRegistry(this)
private var isCollecting = false
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
serviceManager.tunnelControlTile.complete(this) lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE)
} }
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
serviceManager.tunnelControlTile = CompletableDeferred() lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY)
} }
override fun onStartListening() { override fun onStartListening() {
super.onStartListening() super.onStartListening()
Timber.d("Start listening called") lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START)
serviceManager.tunnelControlTile.complete(this) Timber.d("Start listening called for tunnel tile")
applicationScope.launch { if (isCollecting) return
updateTileState() isCollecting = true
lifecycleScope.launch {
tunnelManager.activeTunnels.collect {
updateTileState()
}
} }
} }
fun updateTileState() = applicationScope.launch { private fun updateTileState() = lifecycleScope.launch {
val tunnels = appDataRepository.tunnels.getAll() val tunnels = appDataRepository.tunnels.getAll()
if (tunnels.isEmpty()) return@launch setUnavailable() if (tunnels.isEmpty()) return@launch setUnavailable()
with(tunnelManager.activeTunnels.value) { with(tunnelManager.activeTunnels.value) {
if (isNotEmpty()) if (size == 1) { if (isNotEmpty()) if (size == 1) {
tunnels.firstOrNull { it.id == keys.first() }?.let { return@launch updateTile(it.tunName, true) } tunnels.firstOrNull { it.id == keys.first().id }?.let { return@launch updateTile(it.tunName, true) }
} else { } else {
return@launch updateTile(getString(R.string.multiple), true) return@launch updateTile(getString(R.string.multiple), true)
} }
@@ -69,7 +74,7 @@ class TunnelControlTile : TileService() {
override fun onClick() { override fun onClick() {
super.onClick() super.onClick()
unlockAndRun { unlockAndRun {
applicationScope.launch { lifecycleScope.launch {
if (tunnelManager.activeTunnels.value.isNotEmpty()) return@launch tunnelManager.stopTunnel() if (tunnelManager.activeTunnels.value.isNotEmpty()) return@launch tunnelManager.stopTunnel()
appDataRepository.getStartTunnelConfig()?.let { appDataRepository.getStartTunnelConfig()?.let {
tunnelManager.startTunnel(it) tunnelManager.startTunnel(it)
@@ -132,4 +137,6 @@ class TunnelControlTile : TileService() {
Timber.e(it) Timber.e(it)
} }
} }
override val lifecycle: Lifecycle
get() = lifecycleRegistry
} }
@@ -1,5 +1,6 @@
package com.zaneschepke.wireguardautotunnel.core.tunnel package com.zaneschepke.wireguardautotunnel.core.tunnel
import com.wireguard.android.backend.BackendException
import com.wireguard.android.backend.Tunnel import com.wireguard.android.backend.Tunnel
import com.zaneschepke.networkmonitor.NetworkMonitor import com.zaneschepke.networkmonitor.NetworkMonitor
import com.zaneschepke.networkmonitor.NetworkStatus import com.zaneschepke.networkmonitor.NetworkStatus
@@ -8,12 +9,10 @@ import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.zaneschepke.wireguardautotunnel.core.notification.NotificationManager import com.zaneschepke.wireguardautotunnel.core.notification.NotificationManager
import com.zaneschepke.wireguardautotunnel.core.notification.WireGuardNotification import com.zaneschepke.wireguardautotunnel.core.notification.WireGuardNotification
import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager
import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelProvider.Companion.CHECK_INTERVAL
import com.zaneschepke.wireguardautotunnel.di.ApplicationScope import com.zaneschepke.wireguardautotunnel.di.ApplicationScope
import com.zaneschepke.wireguardautotunnel.di.IoDispatcher import com.zaneschepke.wireguardautotunnel.di.IoDispatcher
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.domain.enums.BackendError import com.zaneschepke.wireguardautotunnel.domain.enums.BackendError
import com.zaneschepke.wireguardautotunnel.domain.enums.BackendState
import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelStatus import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelStatus
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState
@@ -23,23 +22,23 @@ import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.StringValue import com.zaneschepke.wireguardautotunnel.util.StringValue
import com.zaneschepke.wireguardautotunnel.util.extensions.asTunnelState import com.zaneschepke.wireguardautotunnel.util.extensions.asTunnelState
import com.zaneschepke.wireguardautotunnel.util.extensions.cancelWithMessage import com.zaneschepke.wireguardautotunnel.util.extensions.cancelWithMessage
import com.zaneschepke.wireguardautotunnel.util.extensions.toBackendError
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import timber.log.Timber import timber.log.Timber
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean
open class BaseTunnel( abstract class BaseTunnel(
@IoDispatcher private val ioDispatcher: CoroutineDispatcher, @IoDispatcher private val ioDispatcher: CoroutineDispatcher,
@ApplicationScope private val applicationScope: CoroutineScope, @ApplicationScope private val applicationScope: CoroutineScope,
private val networkMonitor: NetworkMonitor, private val networkMonitor: NetworkMonitor,
@@ -48,167 +47,71 @@ open class BaseTunnel(
private val notificationManager: NotificationManager, private val notificationManager: NotificationManager,
) : TunnelProvider { ) : TunnelProvider {
internal val tunnels = MutableStateFlow<List<TunnelConf>>(emptyList()) companion object {
const val CHECK_INTERVAL = 1000L
}
private val _tunnelStates = MutableStateFlow<Map<Int, TunnelState>>(emptyMap()) protected val activeTuns = MutableStateFlow<Map<TunnelConf, TunnelState>>(emptyMap())
override val activeTunnels = activeTuns.asStateFlow()
private val tunnelJobs = ConcurrentHashMap<Int, Job>() private val tunnelJobs = ConcurrentHashMap<Int, MutableList<Job>>()
private val isNetworkAvailable = AtomicBoolean(false) protected val mutex = Mutex()
private val isNetworkConnected = MutableStateFlow(true)
init { init {
applicationScope.launch(ioDispatcher) { applicationScope.launch(ioDispatcher) {
launch { startNetworkJob() } launch { monitorNetworkStatus() }
launch { monitorTunnelConfigChanges() } launch { monitorTunnelConfigChanges() }
tunnels.collect { tuns ->
val previousTunIds = tunnelJobs.keys.toSet()
val currentTunIds = tuns.map { it.id }.toSet()
val newTuns = tuns.filter { it.id !in previousTunIds }
val removedTunIds = previousTunIds - currentTunIds
newTuns.forEach { tun ->
Timber.d("Starting tunnel jobs for tun ${tun.name} (ID: ${tun.id})")
tunnelJobs[tun.id] = startTunnelJobs(tun)
}
removedTunIds.forEach { tunId ->
tunnelJobs[tunId]?.cancelWithMessage("Canceling tunnel jobs for tunnel ID: $tunId")
tunnelJobs.remove(tunId)
_tunnelStates.update { it - tunId }
serviceManager.updateTunnelTile()
}
}
} }
} }
private fun startTunnelJobs(tunnel: TunnelConf) = applicationScope.launch(ioDispatcher) { private fun startTunnelJobs(tunnel: TunnelConf): Job {
launch { startTunnelStatisticsJob(tunnel) } return applicationScope.launch(ioDispatcher) {
if (tunnel.isPingEnabled) launch { startPingJob(tunnel) } val jobs = mutableListOf<Job>()
} jobs += launch { updateTunnelStatistics(tunnel) }
if (tunnel.isPingEnabled) jobs += launch { monitorTunnelPing(tunnel) }
private fun updateTunnelState(tunnelId: Int, newState: TunnelStatus) { jobs.forEach { it.join() }
Timber.d("Updating tunnel state for ID $tunnelId to $newState")
_tunnelStates.update { current ->
val currentState = current[tunnelId]
val updatedState = currentState?.copy(state = newState) ?: TunnelState(state = newState)
val newMap = current + (tunnelId to updatedState)
Timber.d("New tunnel states: $newMap")
newMap
} }
} }
internal fun beforeStartTunnel(tunnelConf: TunnelConf) { private suspend fun updateTunnelStatistics(tunnel: TunnelConf) {
tunnelConf.setStateChangeCallback { state -> while (true) {
Timber.d("New tunnel state $state")
when (state) {
is Tunnel.State -> updateTunnelState(tunnelConf.id, state.asTunnelState())
is org.amnezia.awg.backend.Tunnel.State -> updateTunnelState(tunnelConf.id, state.asTunnelState())
}
applicationScope.launch(ioDispatcher) {
serviceManager.updateTunnelTile()
}
}
}
override fun startTunnel(tunnelConf: TunnelConf) {
applicationScope.launch(ioDispatcher) {
serviceManager.startBackgroundService(tunnelConf)
appDataRepository.tunnels.save(tunnelConf.copy(isActive = true))
addToActiveTunnels(tunnelConf)
}
}
override fun stopTunnel(tunnelConf: TunnelConf?) {
// Default empty implementation; subclasses override
}
override suspend fun bounceTunnel(tunnelConf: TunnelConf) {
stopTunnel(tunnelConf)
delay(1000)
startTunnel(tunnelConf)
}
override suspend fun setBackendState(backendState: BackendState, allowedIps: Collection<String>) {
// Default empty implementation
}
override suspend fun runningTunnelNames(): Set<String> {
return emptySet()
}
override fun getStatistics(tunnelConf: TunnelConf): TunnelStatistics {
throw NotImplementedError("Get statistics not implemented in base class")
}
override val activeTunnels: StateFlow<Map<Int, TunnelState>>
get() = _tunnelStates.asStateFlow()
internal suspend fun onTunnelStop(tunnelConf: TunnelConf) {
appDataRepository.tunnels.save(tunnelConf.copy(isActive = false))
removeFromActiveTunnels(tunnelConf)
if (tunnels.value.isEmpty()) serviceManager.stopBackgroundService()
}
internal fun stopAllTunnels() {
tunnels.value.forEach {
stopTunnel(it)
}
}
private fun addToActiveTunnels(conf: TunnelConf) {
tunnels.update {
it.toMutableList().apply {
add(conf)
}
}
}
private fun removeFromActiveTunnels(conf: TunnelConf) {
tunnels.update {
it.toMutableList().apply {
remove(conf)
}
}
}
private suspend fun startNetworkJob() = coroutineScope {
networkMonitor.getNetworkStatusFlow(includeWifiSsid = false, useRootShell = false)
.flowOn(ioDispatcher).collect {
isNetworkAvailable.set(it !is NetworkStatus.Disconnected)
}
}
private suspend fun startPingJob(tunnel: TunnelConf) = coroutineScope {
while (isActive) {
runCatching { runCatching {
if (isNetworkAvailable.get() && tunnel.isActive) { val stats = getStatistics(tunnel)
updateTunnelState(tunnel, stats = stats)
}.onFailure { e ->
Timber.e(e, "Failed to update stats for ${tunnel.tunName}")
}
delay(CHECK_INTERVAL)
}
}
private suspend fun monitorTunnelPing(tunnel: TunnelConf) {
while (true) {
runCatching {
if (isNetworkConnected.value && tunnel.isActive) {
val pingSuccess = tunnel.isTunnelPingable(ioDispatcher) val pingSuccess = tunnel.isTunnelPingable(ioDispatcher)
handlePingResult(tunnel, pingSuccess) if (!pingSuccess) bounceTunnel(tunnel)
} }
delay(tunnel.pingInterval ?: Constants.PING_INTERVAL) }.onFailure { e ->
Timber.e(e, "Ping failed for ${tunnel.tunName}")
} }
delay(tunnel.pingInterval ?: Constants.PING_INTERVAL)
} }
} }
private suspend fun handlePingResult(tunnel: TunnelConf, pingSuccess: Boolean) { protected fun handleBackendThrowable(throwable: Throwable) {
if (!pingSuccess) { val backendError = when (throwable) {
if (isNetworkAvailable.get()) { is BackendException -> throwable.toBackendError()
Timber.i("Ping result: target was not reachable, bouncing the tunnel") is org.amnezia.awg.backend.BackendException -> throwable.toBackendError()
bounceTunnel(tunnel) else -> BackendError.Unknown
delay(tunnel.pingCooldown ?: Constants.PING_COOLDOWN)
} else {
Timber.i("Ping result: target was not reachable, but no network available")
}
} else {
Timber.i("Ping result: all ping targets were reached successfully")
} }
}
internal fun handleBackendThrowable(backendError: BackendError) {
val message = when (backendError) { val message = when (backendError) {
BackendError.Config -> StringValue.StringResource(R.string.start_failed_config) BackendError.Config -> StringValue.StringResource(R.string.start_failed_config)
BackendError.DNS -> StringValue.StringResource(R.string.dns_error) BackendError.DNS -> StringValue.StringResource(R.string.dns_error)
BackendError.Unauthorized -> StringValue.StringResource(R.string.unauthorized) BackendError.Unauthorized -> StringValue.StringResource(R.string.unauthorized)
BackendError.Unknown -> StringValue.StringResource(R.string.unknown_error)
} }
if (WireGuardAutoTunnel.isForeground()) { if (WireGuardAutoTunnel.isForeground()) {
SnackbarController.showMessage(message) SnackbarController.showMessage(message)
@@ -224,33 +127,133 @@ open class BaseTunnel(
} }
} }
private suspend fun monitorTunnelConfigChanges() = coroutineScope { protected fun updateTunnelState(tunnelConf: TunnelConf, state: TunnelStatus? = null, stats: TunnelStatistics? = null) {
appDataRepository.tunnels.flow.collect { storageTuns -> applicationScope.launch(ioDispatcher) {
storageTuns.forEach { storageTun -> mutex.withLock {
val currentTun = tunnels.value.firstOrNull { it.id == storageTun.id } activeTuns.update { current ->
if (currentTun != null) { val originalConf = current.getKeyById(tunnelConf.id) ?: tunnelConf
if (!currentTun.isQuickConfigMatching(storageTun)) { val existingState = current.getValueById(tunnelConf.id) ?: TunnelState()
Timber.d("Tunnel config changed for ID $storageTun, bouncing tunnel") val newState = state ?: existingState.state
bounceTunnel(storageTun) if (newState == TunnelStatus.DOWN) {
// Remove tunnel from activeTunnels when it goes DOWN
Timber.d("Removing tunnel ${tunnelConf.id} from activeTunnels as state is DOWN")
current - originalConf
} else if (existingState.state == newState && stats == null) {
Timber.d("Skipping redundant state update for ${tunnelConf.id}: $newState")
current
} else {
val updated = existingState.copy(
state = newState,
statistics = stats ?: existingState.statistics,
)
current + (originalConf to updated)
} }
} }
} }
} }
} }
private suspend fun startTunnelStatisticsJob(tunnel: TunnelConf) = coroutineScope { protected suspend fun configureTunnel(tunnelConf: TunnelConf) {
while (this.isActive) { // setup state change callback
runCatching { tunnelConf.setStateChangeCallback { state ->
val stats = getStatistics(tunnel) Timber.d("State change callback triggered for tunnel ${tunnelConf.id}: ${tunnelConf.tunName} with state $state at ${System.currentTimeMillis()}")
_tunnelStates.update { currentStates -> when (state) {
val updatedState = currentStates[tunnel.id]?.copy(statistics = stats) is Tunnel.State -> updateTunnelState(tunnelConf, state.asTunnelState())
?: TunnelState(statistics = stats) is org.amnezia.awg.backend.Tunnel.State -> updateTunnelState(tunnelConf, state.asTunnelState())
currentStates + (tunnel.id to updatedState) }
applicationScope.launch(ioDispatcher) { serviceManager.updateTunnelTile() }
}
activeTuns.update { current ->
current.filter { it.key != tunnelConf } + (tunnelConf to TunnelState())
}
}
protected suspend fun onStartSuccess(tunnelConf: TunnelConf) {
val tunnelCopy = tunnelConf.copyWithCallback(isActive = true)
// start service
if (activeTuns.value.isEmpty()) {
serviceManager.startTunnelForegroundService(tunnelCopy)
} else {
serviceManager.updateTunnelForegroundServiceNotification(tunnelCopy)
}
// save active
appDataRepository.tunnels.save(tunnelCopy)
// start tunnel jobs
tunnelJobs[tunnelCopy.id] = mutableListOf(startTunnelJobs(tunnelConf))
}
override fun startTunnel(tunnelConf: TunnelConf) {
throw NotImplementedError("Must be implemented by subclass")
}
override fun stopTunnel(tunnelConf: TunnelConf?) {
tunnelConf?.let {
applicationScope.launch(ioDispatcher) {
mutex.withLock {
removeActiveTunnel(tunnelConf)
tunnelJobs[tunnelConf.id]?.forEach { it.cancelWithMessage("Cancel tunnel job") }
tunnelJobs.remove(tunnelConf.id)
val lockedConf = it.copyWithCallback(isActive = false)
appDataRepository.tunnels.save(lockedConf)
// TODO improve to handle multiple tunnels
if (activeTuns.value.isEmpty()) {
Timber.d("No tunnels active, stopping background service")
serviceManager.stopTunnelForegroundService()
} else {
Timber.d("Other tunnels still active, updating service notification")
val nextActive = activeTuns.value.keys.firstOrNull()
if (nextActive != null) {
Timber.d("Next active tunnel: ${nextActive.id}")
serviceManager.updateTunnelForegroundServiceNotification(nextActive)
}
}
} }
delay(CHECK_INTERVAL)
}.onFailure { exception ->
Timber.e(exception, "Failed to update tunnel statistics for ${tunnel.tunName}")
} }
} }
} }
private fun removeActiveTunnel(tunnelConf: TunnelConf) {
activeTuns.update { current ->
current.toMutableMap().apply { remove(tunnelConf) }
}
}
override suspend fun bounceTunnel(tunnelConf: TunnelConf) {
stopTunnel(tunnelConf)
delay(1000)
startTunnel(tunnelConf)
}
private suspend fun monitorNetworkStatus() {
networkMonitor.networkStatusFlow
.flowOn(ioDispatcher)
.collectLatest { status ->
val isAvailable = status !is NetworkStatus.Disconnected
isNetworkConnected.value = isAvailable
Timber.d("Network status: $isAvailable")
}
}
private suspend fun monitorTunnelConfigChanges() {
appDataRepository.tunnels.flow.collectLatest { storedTunnels ->
mutex.withLock {
storedTunnels.forEach { stored ->
val current = activeTuns.value.keys.find { it.id == stored.id }
if (current != null && !current.isQuickConfigMatching(stored)) {
Timber.d("Config changed for ${stored.id}, bouncing")
bounceTunnel(stored)
}
}
}
}
}
override fun getStatistics(tunnelConf: TunnelConf): TunnelStatistics? {
throw NotImplementedError("Must be implemented by subclass")
}
override suspend fun runningTunnelNames(): Set<String> = activeTuns.value.keys.map { it.tunName }.toSet()
} }
@@ -0,0 +1,25 @@
package com.zaneschepke.wireguardautotunnel.core.tunnel
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState
fun Map<TunnelConf, TunnelState>.allDown(): Boolean {
return this.all { it.value.state.isDown() }
}
fun Map<TunnelConf, TunnelState>.hasActive(): Boolean {
return this.any { it.value.state.isUp() }
}
fun Map<TunnelConf, TunnelState>.getValueById(id: Int): TunnelState? {
val key = this.keys.find { it.id == id }
return key?.let { this@getValueById[it] }
}
fun Map<TunnelConf, TunnelState>.getKeyById(id: Int): TunnelConf? {
return this.keys.find { it.id == id }
}
fun Map<TunnelConf, TunnelState>.isUp(tunnelConf: TunnelConf): Boolean {
return this.getValueById(tunnelConf.id)?.state?.isUp() ?: false
}
@@ -1,7 +1,6 @@
package com.zaneschepke.wireguardautotunnel.core.tunnel package com.zaneschepke.wireguardautotunnel.core.tunnel
import com.wireguard.android.backend.Backend import com.wireguard.android.backend.Backend
import com.wireguard.android.backend.BackendException
import com.wireguard.android.backend.Tunnel import com.wireguard.android.backend.Tunnel
import com.zaneschepke.networkmonitor.NetworkMonitor import com.zaneschepke.networkmonitor.NetworkMonitor
import com.zaneschepke.wireguardautotunnel.core.notification.NotificationManager import com.zaneschepke.wireguardautotunnel.core.notification.NotificationManager
@@ -10,13 +9,15 @@ import com.zaneschepke.wireguardautotunnel.di.ApplicationScope
import com.zaneschepke.wireguardautotunnel.di.IoDispatcher import com.zaneschepke.wireguardautotunnel.di.IoDispatcher
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.domain.enums.BackendState import com.zaneschepke.wireguardautotunnel.domain.enums.BackendState
import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelStatus
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.domain.state.TunnelStatistics import com.zaneschepke.wireguardautotunnel.domain.state.TunnelStatistics
import com.zaneschepke.wireguardautotunnel.domain.state.WireGuardStatistics import com.zaneschepke.wireguardautotunnel.domain.state.WireGuardStatistics
import com.zaneschepke.wireguardautotunnel.util.extensions.toBackendError import com.zaneschepke.wireguardautotunnel.util.extensions.asTunnelState
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.withLock
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@@ -31,40 +32,60 @@ class KernelTunnel @Inject constructor(
) : BaseTunnel(ioDispatcher, applicationScope, networkMonitor, appDataRepository, serviceManager, notificationManager) { ) : BaseTunnel(ioDispatcher, applicationScope, networkMonitor, appDataRepository, serviceManager, notificationManager) {
override fun startTunnel(tunnelConf: TunnelConf) { override fun startTunnel(tunnelConf: TunnelConf) {
Timber.d("Starting tunnel ${tunnelConf.id} kernel") Timber.i("Starting tunnel ${tunnelConf.id} kernel")
applicationScope.launch(ioDispatcher) { applicationScope.launch(ioDispatcher) {
if (tunnels.value.any { it.id == tunnelConf.id }) return@launch Timber.w("Tunnel already running")
runCatching { runCatching {
Timber.d("Setting backend state UP") // tunnel already active
super.beforeStartTunnel(tunnelConf) if (activeTuns.value.any { it.key.id == tunnelConf.id }) return@launch
backend.setState(tunnelConf, Tunnel.State.UP, tunnelConf.toWgConfig())
Timber.d("Calling super.startTunnel") mutex.withLock {
super.startTunnel(tunnelConf) updateTunnelState(tunnelConf, TunnelStatus.STARTING)
}.onFailure {
Timber.e(it, "Failed to start tunnel ${tunnelConf.id} kernel") // configure state callback and add to tunnels
onTunnelStop(tunnelConf) configureTunnel(tunnelConf)
if (it is BackendException) {
handleBackendThrowable(it.toBackendError()) updateTunnelState(tunnelConf, backend.setState(tunnelConf, Tunnel.State.UP, tunnelConf.toWgConfig()).asTunnelState())
} else {
Timber.e(it) // run some actions after start success
onStartSuccess(tunnelConf)
} }
}.onFailure { exception ->
Timber.e(exception, "Failed to start tunnel ${tunnelConf.id} kernel")
stopTunnel(tunnelConf)
handleBackendThrowable(exception)
}.onSuccess {
Timber.i("Tunnel ${tunnelConf.id} started successfully")
} }
} }
} }
override fun getStatistics(tunnelConf: TunnelConf): TunnelStatistics { override fun getStatistics(tunnelConf: TunnelConf): TunnelStatistics? {
return WireGuardStatistics(backend.getStatistics(tunnelConf)) return try {
WireGuardStatistics(backend.getStatistics(tunnelConf))
} catch (e: Exception) {
Timber.e(e)
null
}
} }
override fun stopTunnel(tunnelConf: TunnelConf?) { override fun stopTunnel(tunnelConf: TunnelConf?) {
applicationScope.launch(ioDispatcher) { applicationScope.launch(ioDispatcher) {
runCatching { runCatching {
tunnels.value.firstOrNull { it.id == tunnelConf?.id }?.let { val originalTunnel = activeTuns.value.keys.find { it.id == tunnelConf?.id }
backend.setState(it, Tunnel.State.DOWN, it.toWgConfig()) if (originalTunnel != null) {
onTunnelStop(it) Timber.i("Stopping tunnel ${originalTunnel.id} kernel")
} ?: stopAllTunnels() mutex.withLock {
}.onFailure { updateTunnelState(originalTunnel, backend.setState(originalTunnel, Tunnel.State.DOWN, originalTunnel.toWgConfig()).asTunnelState())
Timber.e(it) super.stopTunnel(originalTunnel)
}
} else {
Timber.w("Tunnel not found in startedTunnels, stopping all tunnels")
activeTuns.value.keys.forEach { config ->
stopTunnel(config)
}
}
}.onFailure { e ->
Timber.e(e, "Failed to stop tunnel ${tunnelConf?.id}")
} }
} }
} }
@@ -76,7 +76,7 @@ class TunnelManager @Inject constructor(
return tunnelProviderFlow.value.runningTunnelNames() return tunnelProviderFlow.value.runningTunnelNames()
} }
override fun getStatistics(tunnelConf: TunnelConf): TunnelStatistics { override fun getStatistics(tunnelConf: TunnelConf): TunnelStatistics? {
return tunnelProviderFlow.value.getStatistics(tunnelConf) return tunnelProviderFlow.value.getStatistics(tunnelConf)
} }
@@ -84,7 +84,7 @@ class TunnelManager @Inject constructor(
val settings = appDataRepository.settings.get() val settings = appDataRepository.settings.get()
if (settings.isRestoreOnBootEnabled) { if (settings.isRestoreOnBootEnabled) {
val previouslyActiveTuns = appDataRepository.tunnels.getActive() val previouslyActiveTuns = appDataRepository.tunnels.getActive()
val tunsToStart = previouslyActiveTuns.filterNot { tun -> activeTunnels.value.any { tun.id == it.key } } val tunsToStart = previouslyActiveTuns.filterNot { tun -> activeTunnels.value.any { tun.id == it.key.id } }
if (settings.isKernelEnabled) { if (settings.isKernelEnabled) {
return@launch tunsToStart.forEach { return@launch tunsToStart.forEach {
startTunnel(it) startTunnel(it)
@@ -7,15 +7,11 @@ import com.zaneschepke.wireguardautotunnel.domain.state.TunnelStatistics
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
interface TunnelProvider { interface TunnelProvider {
companion object {
const val CHECK_INTERVAL = 1000L
}
fun startTunnel(tunnelConf: TunnelConf) fun startTunnel(tunnelConf: TunnelConf)
fun stopTunnel(tunnelConf: TunnelConf? = null) fun stopTunnel(tunnelConf: TunnelConf? = null)
suspend fun bounceTunnel(tunnelConf: TunnelConf) suspend fun bounceTunnel(tunnelConf: TunnelConf)
suspend fun setBackendState(backendState: BackendState, allowedIps: Collection<String>) suspend fun setBackendState(backendState: BackendState, allowedIps: Collection<String>)
suspend fun runningTunnelNames(): Set<String> suspend fun runningTunnelNames(): Set<String>
fun getStatistics(tunnelConf: TunnelConf): TunnelStatistics fun getStatistics(tunnelConf: TunnelConf): TunnelStatistics?
val activeTunnels: StateFlow<Map<Int, TunnelState>> val activeTunnels: StateFlow<Map<TunnelConf, TunnelState>>
} }
@@ -1,6 +1,5 @@
package com.zaneschepke.wireguardautotunnel.core.tunnel package com.zaneschepke.wireguardautotunnel.core.tunnel
import com.wireguard.android.backend.BackendException
import com.zaneschepke.networkmonitor.NetworkMonitor import com.zaneschepke.networkmonitor.NetworkMonitor
import com.zaneschepke.wireguardautotunnel.core.notification.NotificationManager import com.zaneschepke.wireguardautotunnel.core.notification.NotificationManager
import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager
@@ -8,14 +7,17 @@ import com.zaneschepke.wireguardautotunnel.di.ApplicationScope
import com.zaneschepke.wireguardautotunnel.di.IoDispatcher import com.zaneschepke.wireguardautotunnel.di.IoDispatcher
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.domain.enums.BackendState import com.zaneschepke.wireguardautotunnel.domain.enums.BackendState
import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelStatus
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.domain.state.AmneziaStatistics import com.zaneschepke.wireguardautotunnel.domain.state.AmneziaStatistics
import com.zaneschepke.wireguardautotunnel.domain.state.TunnelStatistics import com.zaneschepke.wireguardautotunnel.domain.state.TunnelStatistics
import com.zaneschepke.wireguardautotunnel.util.extensions.asAmBackendState import com.zaneschepke.wireguardautotunnel.util.extensions.asAmBackendState
import com.zaneschepke.wireguardautotunnel.util.extensions.toBackendError import com.zaneschepke.wireguardautotunnel.util.extensions.asTunnelState
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.withLock
import org.amnezia.awg.backend.Backend import org.amnezia.awg.backend.Backend
import org.amnezia.awg.backend.Tunnel import org.amnezia.awg.backend.Tunnel
import timber.log.Timber import timber.log.Timber
@@ -32,27 +34,41 @@ class UserspaceTunnel @Inject constructor(
) : BaseTunnel(ioDispatcher, applicationScope, networkMonitor, appDataRepository, serviceManager, notificationManager) { ) : BaseTunnel(ioDispatcher, applicationScope, networkMonitor, appDataRepository, serviceManager, notificationManager) {
override fun startTunnel(tunnelConf: TunnelConf) { override fun startTunnel(tunnelConf: TunnelConf) {
Timber.i("Starting tunnel ${tunnelConf.id} userspace")
applicationScope.launch(ioDispatcher) { applicationScope.launch(ioDispatcher) {
Timber.d("Starting tunnel ${tunnelConf.id} userspace")
if (tunnels.value.any { it.id == tunnelConf.id }) return@launch Timber.w("Tunnel already running")
if (tunnels.value.isNotEmpty()) {
Timber.d("Stopping all tunnels")
stopAllTunnels()
}
runCatching { runCatching {
Timber.d("Setting backend state UP") // tunnel already active
super.beforeStartTunnel(tunnelConf) if (activeTuns.value.any { it.key.id == tunnelConf.id }) return@launch
backend.setState(tunnelConf, Tunnel.State.UP, tunnelConf.toAmConfig())
Timber.d("Calling super.startTunnel") // stop any active tunnels that aren't this one, userspace only
super.startTunnel(tunnelConf) stopActiveTunnels()
}.onFailure {
Timber.e(it, "Failed to start tunnel ${tunnelConf.id} userspace") mutex.withLock {
onTunnelStop(tunnelConf) updateTunnelState(tunnelConf, TunnelStatus.STARTING)
if (it is BackendException) {
handleBackendThrowable(it.toBackendError()) // configure state callback and add to tunnels
} else { configureTunnel(tunnelConf)
Timber.e(it)
updateTunnelState(tunnelConf, backend.setState(tunnelConf, Tunnel.State.UP, tunnelConf.toAmConfig()).asTunnelState())
// run some actions after start success
onStartSuccess(tunnelConf)
} }
}.onFailure { exception ->
Timber.e(exception, "Failed to start tunnel ${tunnelConf.id} userspace")
stopTunnel(tunnelConf)
handleBackendThrowable(exception)
}.onSuccess {
Timber.i("Tunnel ${tunnelConf.id} started successfully")
}
}
}
private suspend fun stopActiveTunnels() {
activeTunnels.value.forEach { (config, state) ->
if (state.state.isUp()) {
stopTunnel(config)
delay(300)
} }
} }
} }
@@ -60,17 +76,27 @@ class UserspaceTunnel @Inject constructor(
override fun stopTunnel(tunnelConf: TunnelConf?) { override fun stopTunnel(tunnelConf: TunnelConf?) {
applicationScope.launch(ioDispatcher) { applicationScope.launch(ioDispatcher) {
runCatching { runCatching {
tunnels.value.firstOrNull { it.id == tunnelConf?.id }?.let { val originalTunnel = activeTuns.value.keys.find { it.id == tunnelConf?.id }
backend.setState(it, Tunnel.State.DOWN, it.toAmConfig()) if (originalTunnel != null) {
onTunnelStop(it) Timber.i("Stopping tunnel ${originalTunnel.id} userspace")
} ?: stopAllTunnels() mutex.withLock {
}.onFailure { updateTunnelState(originalTunnel, backend.setState(originalTunnel, Tunnel.State.DOWN, originalTunnel.toAmConfig()).asTunnelState())
Timber.e(it) super.stopTunnel(originalTunnel)
}
} else {
Timber.w("Tunnel not found in startedTunnels, stopping all tunnels")
activeTuns.value.keys.forEach { config ->
stopTunnel(config)
}
}
}.onFailure { e ->
Timber.e(e, "Failed to stop tunnel ${tunnelConf?.id}")
} }
} }
} }
override suspend fun setBackendState(backendState: BackendState, allowedIps: Collection<String>) { override suspend fun setBackendState(backendState: BackendState, allowedIps: Collection<String>) {
Timber.d("Setting backend state: $backendState with allowedIps: $allowedIps")
backend.setBackendState(backendState.asAmBackendState(), allowedIps) backend.setBackendState(backendState.asAmBackendState(), allowedIps)
} }
@@ -78,7 +104,12 @@ class UserspaceTunnel @Inject constructor(
return backend.runningTunnelNames return backend.runningTunnelNames
} }
override fun getStatistics(tunnelConf: TunnelConf): TunnelStatistics { override fun getStatistics(tunnelConf: TunnelConf): TunnelStatistics? {
return AmneziaStatistics(backend.getStatistics(tunnelConf)) return try {
AmneziaStatistics(backend.getStatistics(tunnelConf))
} catch (e: Exception) {
Timber.e(e, "Failed to get stats for ${tunnelConf.tunName}")
null
}
} }
} }
@@ -13,6 +13,7 @@ import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager
import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelProvider import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelProvider
import com.zaneschepke.wireguardautotunnel.core.tunnel.UserspaceTunnel import com.zaneschepke.wireguardautotunnel.core.tunnel.UserspaceTunnel
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.domain.repository.AppSettingRepository
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
@@ -20,6 +21,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.runBlocking
import org.amnezia.awg.backend.Backend import org.amnezia.awg.backend.Backend
import org.amnezia.awg.backend.GoBackend import org.amnezia.awg.backend.GoBackend
import org.amnezia.awg.backend.RootTunnelActionHandler import org.amnezia.awg.backend.RootTunnelActionHandler
@@ -101,8 +103,8 @@ class TunnelModule {
@Provides @Provides
@Singleton @Singleton
fun provideNetworkMonitor(@ApplicationContext context: Context): NetworkMonitor { fun provideNetworkMonitor(@ApplicationContext context: Context, settingsRepository: AppSettingRepository): NetworkMonitor {
return AndroidNetworkMonitor(context) return AndroidNetworkMonitor(context) { runBlocking { settingsRepository.get().isWifiNameByShellEnabled } }
} }
@Singleton @Singleton
@@ -1,16 +1,19 @@
package com.zaneschepke.wireguardautotunnel.domain.entity package com.zaneschepke.wireguardautotunnel.domain.entity
import com.wireguard.android.backend.Tunnel
import com.wireguard.config.Config import com.wireguard.config.Config
import com.zaneschepke.wireguardautotunnel.util.Constants import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.extensions.isReachable import com.zaneschepke.wireguardautotunnel.util.extensions.isReachable
import com.zaneschepke.wireguardautotunnel.util.extensions.toWgQuickString import com.zaneschepke.wireguardautotunnel.util.extensions.toWgQuickString
import kotlinx.coroutines.withContext import kotlinx.coroutines.runBlocking
import kotlinx.serialization.Transient import kotlinx.coroutines.sync.Mutex
import org.amnezia.awg.backend.Tunnel import kotlinx.coroutines.sync.withLock
import timber.log.Timber
import java.io.InputStream import java.io.InputStream
import java.net.InetAddress import java.net.InetAddress
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.nio.charset.StandardCharsets
data class TunnelConf( data class TunnelConf(
val id: Int = 0, val id: Int = 0,
@@ -29,10 +32,39 @@ data class TunnelConf(
val isIpv4Preferred: Boolean = true, val isIpv4Preferred: Boolean = true,
@Transient @Transient
private var stateChangeCallback: ((Any) -> Unit)? = null, private var stateChangeCallback: ((Any) -> Unit)? = null,
) : Tunnel, com.wireguard.android.backend.Tunnel { ) : Tunnel, org.amnezia.awg.backend.Tunnel {
fun setStateChangeCallback(callback: (Any) -> Unit) { private val callbackMutex = Mutex()
stateChangeCallback = callback
suspend fun setStateChangeCallback(callback: (Any) -> Unit) {
callbackMutex.withLock {
stateChangeCallback = callback
}
}
fun copyWithCallback(
id: Int = this.id,
tunName: String = this.tunName,
wgQuick: String = this.wgQuick,
tunnelNetworks: List<String> = this.tunnelNetworks,
isMobileDataTunnel: Boolean = this.isMobileDataTunnel,
isPrimaryTunnel: Boolean = this.isPrimaryTunnel,
amQuick: String = this.amQuick,
isActive: Boolean = this.isActive,
isPingEnabled: Boolean = this.isPingEnabled,
pingInterval: Long? = this.pingInterval,
pingCooldown: Long? = this.pingCooldown,
pingIp: String? = this.pingIp,
isEthernetTunnel: Boolean = this.isEthernetTunnel,
isIpv4Preferred: Boolean = this.isIpv4Preferred,
): TunnelConf {
return TunnelConf(
id, tunName, wgQuick, tunnelNetworks, isMobileDataTunnel, isPrimaryTunnel,
amQuick, isActive, isPingEnabled, pingInterval, pingCooldown, pingIp,
isEthernetTunnel, isIpv4Preferred,
).apply {
stateChangeCallback = this@TunnelConf.stateChangeCallback
}
} }
fun toAmConfig(): org.amnezia.awg.config.Config { fun toAmConfig(): org.amnezia.awg.config.Config {
@@ -43,25 +75,40 @@ data class TunnelConf(
return configFromWgQuick(wgQuick) return configFromWgQuick(wgQuick)
} }
override fun getName(): String { override fun getName(): String = tunName
return tunName
}
override fun isIpv4ResolutionPreferred(): Boolean { override fun isIpv4ResolutionPreferred(): Boolean = isIpv4Preferred
return isIpv4Preferred
}
override fun onStateChange(newState: com.wireguard.android.backend.Tunnel.State) { override fun onStateChange(newState: org.amnezia.awg.backend.Tunnel.State) {
stateChangeCallback?.invoke(newState) Timber.d("onStateChange called for tunnel $id: $tunName with state $newState")
runBlocking {
callbackMutex.withLock {
if (stateChangeCallback != null) {
Timber.d("Invoking stateChangeCallback for tunnel $id: $tunName with state $newState")
stateChangeCallback?.invoke(newState)
} else {
Timber.w("No stateChangeCallback set for tunnel $id: $tunName")
}
}
}
} }
override fun onStateChange(newState: Tunnel.State) { override fun onStateChange(newState: Tunnel.State) {
stateChangeCallback?.invoke(newState) Timber.d("onStateChange called for tunnel $id: $tunName with state $newState")
runBlocking {
callbackMutex.withLock {
if (stateChangeCallback != null) {
Timber.d("Invoking stateChangeCallback for tunnel $id: $tunName with state $newState")
stateChangeCallback?.invoke(newState)
} else {
Timber.w("No stateChangeCallback set for tunnel $id: $tunName")
}
}
}
} }
fun isQuickConfigMatching(updatedConf: TunnelConf): Boolean { fun isQuickConfigMatching(updatedConf: TunnelConf): Boolean {
return updatedConf.wgQuick == wgQuick || return updatedConf.wgQuick == wgQuick || updatedConf.amQuick == amQuick
updatedConf.amQuick == amQuick
} }
fun isPingConfigMatching(updatedConf: TunnelConf): Boolean { fun isPingConfigMatching(updatedConf: TunnelConf): Boolean {
@@ -78,7 +125,6 @@ data class TunnelConf(
return@withContext InetAddress.getByName(pingIp) return@withContext InetAddress.getByName(pingIp)
.isReachable(Constants.PING_TIMEOUT.toInt()) .isReachable(Constants.PING_TIMEOUT.toInt())
} }
Timber.i("Pinging all peers")
config.peers.map { peer -> config.peers.map { peer ->
peer.isReachable(isIpv4Preferred) peer.isReachable(isIpv4Preferred)
}.all { true } }.all { true }
@@ -88,14 +134,14 @@ data class TunnelConf(
companion object { companion object {
fun configFromWgQuick(wgQuick: String): Config { fun configFromWgQuick(wgQuick: String): Config {
val inputStream: InputStream = wgQuick.byteInputStream() val inputStream: InputStream = wgQuick.byteInputStream()
return inputStream.bufferedReader(Charsets.UTF_8).use { return inputStream.bufferedReader(StandardCharsets.UTF_8).use {
Config.parse(it) 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() val inputStream: InputStream = amQuick.byteInputStream()
return inputStream.bufferedReader(Charsets.UTF_8).use { return inputStream.bufferedReader(StandardCharsets.UTF_8).use {
org.amnezia.awg.config.Config.parse(it) org.amnezia.awg.config.Config.parse(it)
} }
} }
@@ -4,4 +4,5 @@ sealed class BackendError() {
data object DNS : BackendError() data object DNS : BackendError()
data object Unauthorized : BackendError() data object Unauthorized : BackendError()
data object Config : BackendError() data object Config : BackendError()
data object Unknown : BackendError()
} }
@@ -3,6 +3,8 @@ package com.zaneschepke.wireguardautotunnel.domain.enums
enum class TunnelStatus { enum class TunnelStatus {
UP, UP,
DOWN, DOWN,
STARTING,
STOPPING,
; ;
fun isDown(): Boolean { fun isDown(): Boolean {
@@ -1,5 +1,8 @@
package com.zaneschepke.wireguardautotunnel.domain.state package com.zaneschepke.wireguardautotunnel.domain.state
import com.zaneschepke.wireguardautotunnel.core.tunnel.allDown
import com.zaneschepke.wireguardautotunnel.core.tunnel.hasActive
import com.zaneschepke.wireguardautotunnel.core.tunnel.isUp
import com.zaneschepke.wireguardautotunnel.domain.events.KillSwitchEvent import com.zaneschepke.wireguardautotunnel.domain.events.KillSwitchEvent
import com.zaneschepke.wireguardautotunnel.domain.entity.AppSettings import com.zaneschepke.wireguardautotunnel.domain.entity.AppSettings
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
@@ -7,7 +10,7 @@ import com.zaneschepke.wireguardautotunnel.domain.events.AutoTunnelEvent
import com.zaneschepke.wireguardautotunnel.util.extensions.isMatchingToWildcardList import com.zaneschepke.wireguardautotunnel.util.extensions.isMatchingToWildcardList
data class AutoTunnelState( data class AutoTunnelState(
val activeTunnels: Map<Int, TunnelState> = emptyMap(), val activeTunnels: Map<TunnelConf, TunnelState> = emptyMap(),
val networkState: NetworkState = NetworkState(), val networkState: NetworkState = NetworkState(),
val settings: AppSettings = AppSettings(), val settings: AppSettings = AppSettings(),
val tunnels: List<TunnelConf> = emptyList(), val tunnels: List<TunnelConf> = emptyList(),
@@ -20,12 +23,12 @@ data class AutoTunnelState(
private fun isMobileTunnelDataChangeNeeded(): Boolean { private fun isMobileTunnelDataChangeNeeded(): Boolean {
val preferredTunnel = preferredMobileDataTunnel() val preferredTunnel = preferredMobileDataTunnel()
return preferredTunnel != null && return preferredTunnel != null &&
activeTunnels.isNotEmpty() && !activeTunnels.any { it.key == preferredTunnel.id } activeTunnels.isNotEmpty() && !activeTunnels.isUp(preferredTunnel)
} }
private fun isEthernetTunnelChangeNeeded(): Boolean { private fun isEthernetTunnelChangeNeeded(): Boolean {
val preferredTunnel = preferredEthernetTunnel() val preferredTunnel = preferredEthernetTunnel()
return preferredTunnel != null && activeTunnels.isNotEmpty() && !activeTunnels.any { it.key == preferredTunnel.id } return preferredTunnel != null && activeTunnels.isNotEmpty() && !activeTunnels.isUp(preferredTunnel)
} }
private fun preferredMobileDataTunnel(): TunnelConf? { private fun preferredMobileDataTunnel(): TunnelConf? {
@@ -45,11 +48,11 @@ data class AutoTunnelState(
} }
private fun startOnEthernet(): Boolean { private fun startOnEthernet(): Boolean {
return networkState.isEthernetConnected && settings.isTunnelOnEthernetEnabled && activeTunnels.isEmpty() return networkState.isEthernetConnected && settings.isTunnelOnEthernetEnabled && activeTunnels.allDown()
} }
private fun stopOnEthernet(): Boolean { private fun stopOnEthernet(): Boolean {
return networkState.isEthernetConnected && !settings.isTunnelOnEthernetEnabled && activeTunnels.isNotEmpty() return networkState.isEthernetConnected && !settings.isTunnelOnEthernetEnabled && activeTunnels.hasActive()
} }
// TODO test removed kill switch state check // TODO test removed kill switch state check
@@ -67,11 +70,11 @@ data class AutoTunnelState(
} }
private fun stopOnMobileData(): Boolean { private fun stopOnMobileData(): Boolean {
return isMobileDataActive() && !settings.isTunnelOnMobileDataEnabled && activeTunnels.isNotEmpty() return isMobileDataActive() && !settings.isTunnelOnMobileDataEnabled && activeTunnels.hasActive()
} }
private fun startOnMobileData(): Boolean { private fun startOnMobileData(): Boolean {
return isMobileDataActive() && settings.isTunnelOnMobileDataEnabled && activeTunnels.isEmpty() return isMobileDataActive() && settings.isTunnelOnMobileDataEnabled && activeTunnels.allDown()
} }
private fun changeOnMobileData(): Boolean { private fun changeOnMobileData(): Boolean {
@@ -83,24 +86,24 @@ data class AutoTunnelState(
} }
private fun stopOnWifi(): Boolean { private fun stopOnWifi(): Boolean {
return isWifiActive() && !settings.isTunnelOnWifiEnabled && activeTunnels.isNotEmpty() return isWifiActive() && !settings.isTunnelOnWifiEnabled && activeTunnels.hasActive()
} }
private fun stopOnTrustedWifi(): Boolean { private fun stopOnTrustedWifi(): Boolean {
return isWifiActive() && settings.isTunnelOnWifiEnabled && activeTunnels.isNotEmpty() && isCurrentSSIDTrusted() return isWifiActive() && settings.isTunnelOnWifiEnabled && activeTunnels.hasActive() && isCurrentSSIDTrusted()
} }
private fun startOnUntrustedWifi(): Boolean { private fun startOnUntrustedWifi(): Boolean {
return isWifiActive() && settings.isTunnelOnWifiEnabled && activeTunnels.isEmpty() && !isCurrentSSIDTrusted() return isWifiActive() && settings.isTunnelOnWifiEnabled && activeTunnels.allDown() && !isCurrentSSIDTrusted()
} }
private fun changeOnUntrustedWifi(): Boolean { private fun changeOnUntrustedWifi(): Boolean {
return isWifiActive() && settings.isTunnelOnWifiEnabled && activeTunnels.isNotEmpty() && !isCurrentSSIDTrusted() && !isWifiTunnelPreferred() return isWifiActive() && settings.isTunnelOnWifiEnabled && activeTunnels.hasActive() && !isCurrentSSIDTrusted() && !isWifiTunnelPreferred()
} }
private fun isWifiTunnelPreferred(): Boolean { private fun isWifiTunnelPreferred(): Boolean {
val preferred = preferredWifiTunnel() val preferred = preferredWifiTunnel()
return activeTunnels.any { it.key == preferred?.id } return preferred?.let { activeTunnels.isUp(it) } ?: true
} }
fun asAutoTunnelEvent(): AutoTunnelEvent { fun asAutoTunnelEvent(): AutoTunnelEvent {
@@ -38,6 +38,7 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.core.tunnel.getValueById
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
@@ -227,7 +228,7 @@ fun MainScreen(viewModel: MainViewModel = hiltViewModel(), uiState: AppUiState)
key = { tunnel -> tunnel.id }, key = { tunnel -> tunnel.id },
) { tunnel -> ) { tunnel ->
val expanded = uiState.generalState.isTunnelStatsExpanded val expanded = uiState.generalState.isTunnelStatsExpanded
val tunnelState = activeTunnels.getOrDefault(tunnel.id, TunnelState()) val tunnelState = activeTunnels.getValueById(tunnel.id) ?: TunnelState()
TunnelRowItem( TunnelRowItem(
tunnelState.state.isUp(), tunnelState.state.isUp(),
expanded, expanded,
@@ -32,6 +32,7 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.core.tunnel.isUp
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.ui.Route import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch
@@ -195,7 +196,7 @@ fun OptionsScreen(tunnelConf: TunnelConf, appUiState: AppUiState, viewModel: Tun
trailing = { trailing = {
ScaledSwitch( ScaledSwitch(
checked = tunnelConf.isPingEnabled, checked = tunnelConf.isPingEnabled,
enabled = !appUiState.activeTunnels.containsKey(tunnelConf.id), enabled = !appUiState.activeTunnels.isUp(tunnelConf),
onClick = { onPingToggle() }, onClick = { onPingToggle() },
) )
}, },
@@ -8,7 +8,7 @@ import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState
data class AppUiState( data class AppUiState(
val appSettings: AppSettings = AppSettings(), val appSettings: AppSettings = AppSettings(),
val tunnels: List<TunnelConf> = emptyList(), val tunnels: List<TunnelConf> = emptyList(),
val activeTunnels: Map<Int, TunnelState> = emptyMap(), val activeTunnels: Map<TunnelConf, TunnelState> = emptyMap(),
val generalState: GeneralState = GeneralState(), val generalState: GeneralState = GeneralState(),
val autoTunnelActive: Boolean = false, val autoTunnelActive: Boolean = false,
) )
@@ -147,6 +147,8 @@ constructor(
appDataRepository.appState.setLocalLogsEnabled(toggledOn) appDataRepository.appState.setLocalLogsEnabled(toggledOn)
if (!toggledOn) { if (!toggledOn) {
logReader.stop() logReader.stop()
} else {
logReader.start()
} }
} }
} }
@@ -275,13 +277,13 @@ constructor(
} }
} }
suspend fun requestRoot(): Result<Unit> { private suspend fun requestRoot(): Result<Unit> {
return withContext(ioDispatcher) { return withContext(ioDispatcher) {
runCatching { runCatching {
rootShell.get().start() rootShell.get().start()
SnackbarController.Companion.showMessage(StringValue.StringResource(R.string.root_accepted)) SnackbarController.showMessage(StringValue.StringResource(R.string.root_accepted))
}.onFailure { }.onFailure {
SnackbarController.Companion.showMessage(StringValue.StringResource(R.string.error_root_denied)) SnackbarController.showMessage(StringValue.StringResource(R.string.error_root_denied))
} }
} }
} }
@@ -351,7 +353,7 @@ constructor(
runCatching { runCatching {
val amConfig = tunnelConfig.toAmConfig() val amConfig = tunnelConfig.toAmConfig()
val wgConfig = tunnelConfig.toWgConfig() val wgConfig = tunnelConfig.toWgConfig()
val proxy = InterfaceProxy.Companion.from(amConfig.`interface`) val proxy = InterfaceProxy.from(amConfig.`interface`)
if (proxy.includedApplications.isEmpty() && proxy.excludedApplications.isEmpty()) return@launch if (proxy.includedApplications.isEmpty() && proxy.excludedApplications.isEmpty()) return@launch
if (proxy.includedApplications.retainAll(packages.toSet()) || proxy.excludedApplications.retainAll(packages.toSet())) { if (proxy.includedApplications.retainAll(packages.toSet()) || proxy.excludedApplications.retainAll(packages.toSet())) {
updateTunnelConfig(tunnelConfig, amConfig = amConfig, wgConfig = wgConfig, `interface` = proxy) updateTunnelConfig(tunnelConfig, amConfig = amConfig, wgConfig = wgConfig, `interface` = proxy)
+2 -2
View File
@@ -1,7 +1,7 @@
object Constants { object Constants {
const val VERSION_NAME = "3.7.1" const val VERSION_NAME = "3.7.2"
const val JVM_TARGET = "17" const val JVM_TARGET = "17"
const val VERSION_CODE = 37100 const val VERSION_CODE = 37200
const val TARGET_SDK = 35 const val TARGET_SDK = 35
const val MIN_SDK = 26 const val MIN_SDK = 26
const val APP_ID = "com.zaneschepke.wireguardautotunnel" const val APP_ID = "com.zaneschepke.wireguardautotunnel"
@@ -0,0 +1,5 @@
What's new:
- Auto tunnel regression fix
- Tile sync improvements
- Optimize wifi name querying
- Improve network monitoring permission checks
+3 -3
View File
@@ -9,13 +9,13 @@ coreKtx = "1.15.0"
datastorePreferences = "1.1.3" datastorePreferences = "1.1.3"
desugar_jdk_libs = "2.1.5" desugar_jdk_libs = "2.1.5"
espressoCore = "3.6.1" espressoCore = "3.6.1"
hiltAndroid = "2.55" hiltAndroid = "2.56"
hiltCompiler = "1.2.0" hiltCompiler = "1.2.0"
junit = "4.13.2" junit = "4.13.2"
kotlinx-serialization-json = "1.8.0" kotlinx-serialization-json = "1.8.0"
lifecycle-runtime-compose = "2.8.7" lifecycle-runtime-compose = "2.8.7"
material3 = "1.3.1" material3 = "1.3.1"
navigationCompose = "2.8.8" navigationCompose = "2.8.9"
pinLockCompose = "1.0.4" pinLockCompose = "1.0.4"
roomVersion = "2.6.1" roomVersion = "2.6.1"
timber = "5.0.1" timber = "5.0.1"
@@ -23,7 +23,7 @@ tunnel = "1.2.7"
androidGradlePlugin = "8.8.0-alpha05" androidGradlePlugin = "8.8.0-alpha05"
kotlin = "2.1.10" kotlin = "2.1.10"
ksp = "2.1.10-1.0.31" ksp = "2.1.10-1.0.31"
composeBom = "2025.02.00" composeBom = "2025.03.00"
compose = "1.7.8" compose = "1.7.8"
workRuntimeKtxVersion = "2.10.0" workRuntimeKtxVersion = "2.10.0"
zxingAndroidEmbedded = "4.3.0" zxingAndroidEmbedded = "4.3.0"
+8 -6
View File
@@ -1,9 +1,11 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools"
> >
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" /> <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION"
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/> tools:targetApi="29" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
</manifest> </manifest>
@@ -1,74 +1,139 @@
package com.zaneschepke.networkmonitor package com.zaneschepke.networkmonitor
import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.location.LocationManager
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.net.Network import android.net.Network
import android.net.NetworkCapabilities import android.net.NetworkCapabilities
import android.net.NetworkRequest import android.net.NetworkRequest
import android.net.wifi.WifiManager import android.net.wifi.WifiManager
import android.os.Build
import com.wireguard.android.util.RootShell import com.wireguard.android.util.RootShell
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.runBlocking
import timber.log.Timber import timber.log.Timber
class AndroidNetworkMonitor( class AndroidNetworkMonitor(
context: Context, context: Context,
private val useRootShellCallback: suspend () -> Boolean,
) : NetworkMonitor { ) : NetworkMonitor {
companion object {
const val LOCATION_GRANTED = "LOCATION_PERMISSIONS_GRANTED"
const val LOCATION_SERVICES_FILTER = "android.location.PROVIDERS_CHANGED"
}
private val appContext = context.applicationContext private val appContext = context.applicationContext
private val packageName = appContext.packageName
private val connectivityManager = appContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager private val connectivityManager = appContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
private val wifiManager = appContext.getSystemService(Context.WIFI_SERVICE) as WifiManager? private val wifiManager = appContext.getSystemService(Context.WIFI_SERVICE) as WifiManager?
private val locationManager = appContext.getSystemService(Context.LOCATION_SERVICE) as LocationManager
private val rootShell = RootShell(context) private val rootShell = RootShell(context)
private var includeWifiSsid = false @get:Synchronized @set:Synchronized
private var useRootShell = false var currentSsid: String? = null
@get:Synchronized @set:Synchronized
var wifiConnected = false
data class WifiState(val connected: Boolean = false, val ssid: String? = null) data class WifiState(val connected: Boolean = false, val ssid: String? = null)
data class TransportState(val connected: Boolean = false) data class TransportState(val connected: Boolean = false)
private val wifiFlow: Flow<WifiState> = callbackFlow { private val wifiFlow: Flow<WifiState> = callbackFlow {
var currentSsid: String? = null
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
fun getWifiSsid(): String? { fun getWifiSsid(): String? {
if (!includeWifiSsid || wifiManager == null) return null return if (runBlocking { useRootShellCallback() }) {
return if (useRootShell) {
rootShell.getCurrentWifiName() rootShell.getCurrentWifiName()
} else { } else {
wifiManager.connectionInfo?.ssid?.trim('"')?.takeIf { if (wifiManager == null) return null
it != "<unknown>" && it.isNotEmpty() try {
wifiManager.connectionInfo?.ssid?.trim('"')?.takeIf { it.isNotEmpty() }
} catch (e: Exception) {
Timber.e(e)
null
} }
} }
} }
fun handleUnknownWifi() {
val newSsid = getWifiSsid()
// Only update if new SSID is valid; preserve existing valid SSID otherwise
if (newSsid != null && newSsid != WifiManager.UNKNOWN_SSID) {
currentSsid = newSsid
trySend(WifiState(connected = wifiConnected, ssid = currentSsid))
} else if (currentSsid == null || currentSsid == WifiManager.UNKNOWN_SSID) {
currentSsid = newSsid
trySend(WifiState(connected = wifiConnected, ssid = currentSsid))
}
Timber.d("handleUnknownWifi: currentSsid=$currentSsid")
}
val locationPermissionReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
Timber.d("locationPermissionReceiver received intent with action: ${intent.action}")
if (intent.action == "$packageName.$LOCATION_GRANTED") {
Timber.d("Received update: Precise and all-the-time location permissions are enabled")
handleUnknownWifi()
}
}
}
val locationServicesReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == LOCATION_SERVICES_FILTER) {
val isGpsEnabled = locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER)
val isNetworkEnabled = locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)
val isLocationServicesEnabled = isGpsEnabled || isNetworkEnabled
Timber.d("Location Services state changed. Enabled: $isLocationServicesEnabled, GPS: $isGpsEnabled, Network: $isNetworkEnabled")
if (isLocationServicesEnabled) handleUnknownWifi()
}
}
}
// Use RECEIVER_NOT_EXPORTED for Android 14+ compatibility
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
Context.RECEIVER_EXPORTED
} else {
0
}
appContext.registerReceiver(
locationPermissionReceiver,
IntentFilter("$packageName.$LOCATION_GRANTED"),
flags,
)
appContext.registerReceiver(
locationServicesReceiver,
IntentFilter(LOCATION_SERVICES_FILTER),
flags,
)
val callback = object : ConnectivityManager.NetworkCallback() { val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) { override fun onAvailable(network: Network) {
Timber.d("Wi-Fi onAvailable: network=$network") Timber.d("Wi-Fi onAvailable: network=$network")
val capabilities = connectivityManager.getNetworkCapabilities(network) currentSsid = getWifiSsid()
val connected = capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true wifiConnected = true
if (connected) { trySend(WifiState(connected = true, ssid = currentSsid))
val ssid = getWifiSsid()
currentSsid = ssid
trySend(WifiState(connected = true, ssid = ssid))
}
} }
override fun onLost(network: Network) { override fun onLost(network: Network) {
Timber.d("Wi-Fi onLost: network=$network") Timber.d("Wi-Fi onLost: network=$network")
currentSsid = null currentSsid = null
wifiConnected = false
trySend(WifiState(connected = false, ssid = null)) trySend(WifiState(connected = false, ssid = null))
} }
override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) { override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) {
val connected = networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) Timber.d("Wi-Fi onCapabilitiesChanged: network=$network, networkCapabilities=$networkCapabilities")
val ssid = if (connected) getWifiSsid() else null
if (ssid != currentSsid) {
currentSsid = ssid
trySend(WifiState(connected = connected, ssid = ssid))
}
} }
} }
@@ -80,7 +145,11 @@ class AndroidNetworkMonitor(
connectivityManager.registerNetworkCallback(request, callback) connectivityManager.registerNetworkCallback(request, callback)
trySend(WifiState()) trySend(WifiState())
awaitClose { connectivityManager.unregisterNetworkCallback(callback) } awaitClose {
connectivityManager.unregisterNetworkCallback(callback)
appContext.unregisterReceiver(locationPermissionReceiver)
appContext.unregisterReceiver(locationServicesReceiver)
}
} }
private val cellularFlow: Flow<TransportState> = callbackFlow { private val cellularFlow: Flow<TransportState> = callbackFlow {
@@ -104,7 +173,9 @@ class AndroidNetworkMonitor(
connectivityManager.registerNetworkCallback(request, callback) connectivityManager.registerNetworkCallback(request, callback)
trySend(TransportState()) trySend(TransportState())
awaitClose { connectivityManager.unregisterNetworkCallback(callback) } awaitClose {
connectivityManager.unregisterNetworkCallback(callback)
}
} }
private val ethernetFlow: Flow<TransportState> = callbackFlow { private val ethernetFlow: Flow<TransportState> = callbackFlow {
@@ -131,21 +202,24 @@ class AndroidNetworkMonitor(
awaitClose { connectivityManager.unregisterNetworkCallback(callback) } awaitClose { connectivityManager.unregisterNetworkCallback(callback) }
} }
override fun getNetworkStatusFlow(includeWifiSsid: Boolean, useRootShell: Boolean): Flow<NetworkStatus> { override val networkStatusFlow = combine(wifiFlow, cellularFlow, ethernetFlow) { wifi, cellular, ethernet ->
this.includeWifiSsid = includeWifiSsid val hasAnyConnection = wifi.connected || cellular.connected || ethernet.connected
this.useRootShell = useRootShell if (hasAnyConnection) {
return combine(wifiFlow, cellularFlow, ethernetFlow) { wifi, cellular, ethernet -> NetworkStatus.Connected(
val hasAnyConnection = wifi.connected || cellular.connected || ethernet.connected wifiSsid = wifi.ssid,
if (hasAnyConnection) { wifiConnected = wifi.connected,
NetworkStatus.Connected( cellularConnected = cellular.connected,
wifiSsid = wifi.ssid, ethernetConnected = ethernet.connected,
wifiConnected = wifi.connected, )
cellularConnected = cellular.connected, } else {
ethernetConnected = ethernet.connected, NetworkStatus.Disconnected
) }.also { Timber.d("NetworkStatus: $it") }
} else { }.distinctUntilChanged()
NetworkStatus.Disconnected
}.also { Timber.d("NetworkStatus: $it") } override fun sendLocationPermissionsGrantedBroadcast() {
}.distinctUntilChanged() val action = "$packageName.$LOCATION_GRANTED"
val intent = Intent(action)
Timber.d("Sending broadcast: $action")
appContext.sendBroadcast(intent)
} }
} }
@@ -3,5 +3,6 @@ package com.zaneschepke.networkmonitor
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
interface NetworkMonitor { interface NetworkMonitor {
fun getNetworkStatusFlow(includeWifiSsid: Boolean, useRootShell: Boolean): Flow<NetworkStatus> val networkStatusFlow: Flow<NetworkStatus>
fun sendLocationPermissionsGrantedBroadcast()
} }