Compare commits

...

14 Commits

Author SHA1 Message Date
zaneschepke 0a9773d202 chore: release 5.0.6 2026-06-25 13:10:15 -04:00
zaneschepke 3cb4480a65 fix: android 17 local devices/network permission requirement
closes #1299
2026-06-25 04:41:18 -04:00
zaneschepke a7f3255a76 refactor: remove legacy round icons 2026-06-25 02:32:03 -04:00
zaneschepke 7d7b99f448 fix: quick tile logo for samsung OneUI
#1301
2026-06-25 01:12:40 -04:00
zaneschepke 74e9e462bb fix: app shortcuts crash
closes #1302
2026-06-24 02:11:02 -04:00
zaneschepke 619e3c1cde chore: release 5.0.5 2026-06-23 12:51:01 -04:00
zaneschepke 77f8a8215b fix: improve mobile network detection for dual sim setups 2026-06-23 11:18:31 -04:00
zaneschepke 8772036dd7 build: fix localization string mismatch 2026-06-23 10:57:11 -04:00
zaneschepke 63625ccbd7 refactor: service manager to use new user start function 2026-06-23 10:46:08 -04:00
zaneschepke 9ac7ae77b3 fix: improve always on vpn reliability
#1289
2026-06-23 10:38:28 -04:00
zaneschepke e062fbb34d fix: vpnservice not cleaned up properly in certain scenarios 2026-06-23 09:40:44 -04:00
alexandervlpl 16d5586433 feat: config import via wg:// deep links (#1213)
Co-authored-by: zaneschepke <dev@zaneschepke.com>
2026-06-22 11:40:00 -04:00
zaneschepke 48a3ad64f4 chore: release 5.0.4 2026-06-20 13:13:19 -04:00
zaneschepke e5796d641d fix: auto tunnel rapid toggle bug
Improve notification efficiency
#1288
2026-06-20 12:33:25 -04:00
37 changed files with 604 additions and 263 deletions
+9 -3
View File
@@ -53,7 +53,6 @@
android:fullBackupContent="@xml/backup_rules" android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.App.Start" android:theme="@style/Theme.App.Start"
tools:targetApi="tiramisu"> tools:targetApi="tiramisu">
@@ -74,6 +73,13 @@
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
</intent-filter> </intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="wg" />
</intent-filter>
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<action android:name="android.intent.action.SHOW_APP_INFO" /> <action android:name="android.intent.action.SHOW_APP_INFO" />
@@ -171,7 +177,7 @@
<service <service
android:name=".service.tile.TunnelControlTile" android:name=".service.tile.TunnelControlTile"
android:exported="true" android:exported="true"
android:icon="@drawable/ic_notification" android:icon="@drawable/ic_qs_logo"
android:label="@string/tunnel_control" android:label="@string/tunnel_control"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"> android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<meta-data <meta-data
@@ -188,7 +194,7 @@
<service <service
android:name=".service.tile.AutoTunnelControlTile" android:name=".service.tile.AutoTunnelControlTile"
android:exported="true" android:exported="true"
android:icon="@drawable/ic_notification" android:icon="@drawable/ic_qs_logo"
android:label="@string/auto_tunnel" android:label="@string/auto_tunnel"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"> android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<meta-data <meta-data
@@ -1,12 +1,14 @@
package com.zaneschepke.wireguardautotunnel package com.zaneschepke.wireguardautotunnel
import ProxySettingsScreen import ProxySettingsScreen
import android.Manifest
import android.content.Intent import android.content.Intent
import android.graphics.Color import android.graphics.Color
import android.net.Uri import android.net.Uri
import android.net.VpnService import android.net.VpnService
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.provider.Settings
import android.view.WindowManager import android.view.WindowManager
import androidx.activity.SystemBarStyle import androidx.activity.SystemBarStyle
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
@@ -31,15 +33,10 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.CheckCircleOutline import androidx.compose.material.icons.outlined.CheckCircleOutline
import androidx.compose.material.icons.outlined.Error
import androidx.compose.material.icons.outlined.ErrorOutline import androidx.compose.material.icons.outlined.ErrorOutline
import androidx.compose.material.icons.outlined.FavoriteBorder import androidx.compose.material.icons.outlined.FavoriteBorder
import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.Info
import androidx.compose.material.icons.outlined.Warning
import androidx.compose.material.icons.outlined.WarningAmber import androidx.compose.material.icons.outlined.WarningAmber
import androidx.compose.material.icons.rounded.Error
import androidx.compose.material.icons.rounded.Info
import androidx.compose.material.icons.rounded.Warning
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -55,6 +52,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -66,6 +64,7 @@ import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
import androidx.core.app.ActivityCompat
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
@@ -87,6 +86,8 @@ import com.zaneschepke.wireguardautotunnel.domain.sideeffect.GlobalSideEffect
import com.zaneschepke.wireguardautotunnel.ui.LocalIsAndroidTV import com.zaneschepke.wireguardautotunnel.ui.LocalIsAndroidTV
import com.zaneschepke.wireguardautotunnel.ui.LocalNavController import com.zaneschepke.wireguardautotunnel.ui.LocalNavController
import com.zaneschepke.wireguardautotunnel.ui.common.banner.AppAlertBanner import com.zaneschepke.wireguardautotunnel.ui.common.banner.AppAlertBanner
import com.zaneschepke.wireguardautotunnel.ui.common.dialog.InfoDialog
import com.zaneschepke.wireguardautotunnel.ui.common.dialog.LocalNetworkPermissionDialog
import com.zaneschepke.wireguardautotunnel.ui.common.dialog.VpnDeniedDialog import com.zaneschepke.wireguardautotunnel.ui.common.dialog.VpnDeniedDialog
import com.zaneschepke.wireguardautotunnel.ui.navigation.Route import com.zaneschepke.wireguardautotunnel.ui.navigation.Route
import com.zaneschepke.wireguardautotunnel.ui.navigation.SecureRoute import com.zaneschepke.wireguardautotunnel.ui.navigation.SecureRoute
@@ -136,6 +137,7 @@ import com.zaneschepke.wireguardautotunnel.util.extensions.installApk
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
import com.zaneschepke.wireguardautotunnel.util.extensions.openWebUrl import com.zaneschepke.wireguardautotunnel.util.extensions.openWebUrl
import com.zaneschepke.wireguardautotunnel.util.extensions.restartApp import com.zaneschepke.wireguardautotunnel.util.extensions.restartApp
import com.zaneschepke.wireguardautotunnel.util.permission.LocalNetworkPermissionHelper
import com.zaneschepke.wireguardautotunnel.viewmodel.ConfigEditViewModel import com.zaneschepke.wireguardautotunnel.viewmodel.ConfigEditViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.SharedAppViewModel import com.zaneschepke.wireguardautotunnel.viewmodel.SharedAppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.SplitTunnelViewModel import com.zaneschepke.wireguardautotunnel.viewmodel.SplitTunnelViewModel
@@ -181,7 +183,8 @@ class MainActivity : AppCompatActivity() {
roomBackup = RoomBackup(this).database(appDatabase).enableLogDebug(true).maxFileCount(5) roomBackup = RoomBackup(this).database(appDatabase).enableLogDebug(true).maxFileCount(5)
handleIncomingIntent(intent) handleConfigFileIntent(intent)
handleWgDeepLinkIntent(intent)
installSplashScreen().apply { installSplashScreen().apply {
setKeepOnScreenCondition { !viewModel.container.stateFlow.value.isAppLoaded } setKeepOnScreenCondition { !viewModel.container.stateFlow.value.isAppLoaded }
@@ -205,6 +208,68 @@ class MainActivity : AppCompatActivity() {
var requestingTunnelMode by remember { var requestingTunnelMode by remember {
mutableStateOf<Pair<TunnelMode?, TunnelConfig?>>(Pair(null, null)) mutableStateOf<Pair<TunnelMode?, TunnelConfig?>>(Pair(null, null))
} }
var showLocalNetworkRationale by remember { mutableStateOf(false) }
var hasPromptedLocalNetwork by rememberSaveable { mutableStateOf(false) }
val localNetworkPermissionLauncher =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission()
) { isGranted ->
if (!isGranted) {
val canAskAgain =
ActivityCompat.shouldShowRequestPermissionRationale(
this,
Manifest.permission.ACCESS_LOCAL_NETWORK,
)
if (!canAskAgain) {
val intent =
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.fromParts("package", packageName, null)
}
startActivity(intent)
} else {
toaster.show(
message =
context.getString(R.string.local_network_permission_denied),
type = ToastType.Warning,
duration = 6000.milliseconds,
)
}
}
}
LaunchedEffect(uiState.isAppLoaded) {
if (
uiState.isAppLoaded &&
!hasPromptedLocalNetwork &&
LocalNetworkPermissionHelper.shouldRequestPermission() &&
!LocalNetworkPermissionHelper.isPermissionGranted(context)
) {
hasPromptedLocalNetwork = true
showLocalNetworkRationale = true
}
}
if (showLocalNetworkRationale) {
LocalNetworkPermissionDialog(
onDismiss = {
showLocalNetworkRationale = false
toaster.show(
message = context.getString(R.string.local_network_permission_denied),
type = ToastType.Warning,
duration = 6000.milliseconds,
)
},
onContinue = {
showLocalNetworkRationale = false
localNetworkPermissionLauncher.launch(
Manifest.permission.ACCESS_LOCAL_NETWORK
)
},
)
}
val startingStack = buildList { val startingStack = buildList {
add(Route.Tunnels) add(Route.Tunnels)
@@ -295,6 +360,17 @@ class MainActivity : AppCompatActivity() {
}, },
) )
uiState.pendingWgImportUrl?.let { url ->
val host = Uri.parse(url).host ?: url
InfoDialog(
onDismiss = { viewModel.dismissWgImport() },
onAttest = { viewModel.importFromUrl(url) },
title = stringResource(R.string.add_from_url),
body = { Text(stringResource(R.string.wg_url_confirm_message, host)) },
confirmText = stringResource(R.string.okay),
)
}
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
if (uiState.shouldShowDonationSnackbar && !uiState.alreadyDonated) { if (uiState.shouldShowDonationSnackbar && !uiState.alreadyDonated) {
viewModel.setShouldShowDonationSnackbar(false) viewModel.setShouldShowDonationSnackbar(false)
@@ -597,6 +673,21 @@ class MainActivity : AppCompatActivity() {
} }
} }
private fun handleWgDeepLinkIntent(intent: Intent) {
if (intent.action == Intent.ACTION_VIEW) {
val uri = intent.data ?: return
if (uri.scheme == "wg") {
val httpsUrl = uri.toString().replaceFirst("wg://", "https://")
viewModel.promptWgImport(httpsUrl)
}
}
}
override fun onResume() {
super.onResume()
networkMonitor.checkPermissionsAndUpdateState()
}
fun performBackup(encrypt: Boolean = false, password: String? = null) { fun performBackup(encrypt: Boolean = false, password: String? = null) {
roomBackup roomBackup
.backupLocation(RoomBackup.BACKUP_FILE_LOCATION_CUSTOM_DIALOG) .backupLocation(RoomBackup.BACKUP_FILE_LOCATION_CUSTOM_DIALOG)
@@ -673,18 +764,14 @@ class MainActivity : AppCompatActivity() {
.restore() .restore()
} }
override fun onResume() {
super.onResume()
networkMonitor.checkPermissionsAndUpdateState()
}
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent) super.onNewIntent(intent)
setIntent(intent) setIntent(intent)
handleIncomingIntent(intent) handleConfigFileIntent(intent)
handleWgDeepLinkIntent(intent)
} }
private fun handleIncomingIntent(intent: Intent?) { private fun handleConfigFileIntent(intent: Intent?) {
intent ?: return intent ?: return
when (intent.action) { when (intent.action) {
Intent.ACTION_VIEW, Intent.ACTION_VIEW,
@@ -51,6 +51,13 @@ class WireGuardAutoTunnel : Application(), KoinComponent {
private val backend: Backend by inject() private val backend: Backend by inject()
private val alwaysOnCallback =
object : VpnService.AlwaysOnCallback {
override fun alwaysOnTriggered() {
applicationScope.launch { tunnelCoordinator.startDefault() }
}
}
@OptIn(KoinViewModelScopeApi::class) @OptIn(KoinViewModelScopeApi::class)
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
@@ -86,13 +93,7 @@ class WireGuardAutoTunnel : Application(), KoinComponent {
Timber.plant(ReleaseTree()) Timber.plant(ReleaseTree())
} }
backend.setAlwaysOnCallback( backend.setAlwaysOnCallback(alwaysOnCallback)
object : VpnService.AlwaysOnCallback {
override fun alwaysOnTriggered() {
applicationScope.launch { tunnelCoordinator.startDefault() }
}
}
)
val dispatcher = get<TunnelEventDispatcher>() val dispatcher = get<TunnelEventDispatcher>()
val coordinator = get<TunnelCoordinator>() val coordinator = get<TunnelCoordinator>()
@@ -117,7 +117,7 @@ class TunnelCoordinator(
// enforce single tunnel, for now // enforce single tunnel, for now
if (backendStatus.value.activeTunnels.isNotEmpty()) { if (backendStatus.value.activeTunnels.isNotEmpty()) {
stopActiveTunnelsInternal() stopActiveTunnelsInternal(source)
} }
startTunnelInternal(config, source) startTunnelInternal(config, source)
@@ -131,7 +131,13 @@ class TunnelCoordinator(
stopTunnelInternal(id, source) stopTunnelInternal(id, source)
} }
suspend fun stopActiveTunnels() = tunnelMutex.withLock { stopActiveTunnelsInternal() } suspend fun stopActiveTunnels(source: TunnelActionSource = TunnelActionSource.USER) =
tunnelMutex.withLock {
if (source == TunnelActionSource.USER) {
_userOverrideFlow.tryEmit(Unit)
}
stopActiveTunnelsInternal(source)
}
private suspend fun startTunnelInternal( private suspend fun startTunnelInternal(
tunnelConfig: TunnelConfig, tunnelConfig: TunnelConfig,
@@ -218,7 +224,7 @@ class TunnelCoordinator(
_actions.emit(TunnelActionEvent.Stopped(tunnelId = id, source = source)) _actions.emit(TunnelActionEvent.Stopped(tunnelId = id, source = source))
} }
stopActiveTunnelsInternal() stopActiveTunnelsInternal(source)
return@withLock return@withLock
} }
@@ -243,7 +249,15 @@ class TunnelCoordinator(
.onFailure { _errors.emit(TunnelErrorEvent.from(it, id)) } .onFailure { _errors.emit(TunnelErrorEvent.from(it, id)) }
} }
private suspend fun stopActiveTunnelsInternal() { private suspend fun stopActiveTunnelsInternal(
source: TunnelActionSource = TunnelActionSource.USER
) {
val active = tunnelProvider.backendStatus.value.activeTunnels
active.keys.forEach { id ->
_actions.emit(TunnelActionEvent.Stopped(tunnelId = id, source = source))
}
tunnelProvider.stopActiveTunnels() tunnelProvider.stopActiveTunnels()
} }
} }
@@ -19,9 +19,8 @@ class ShortcutsActivity : ComponentActivity() {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
applicationScope.launch { finish()
shortcutCoordinator.handle(intent)
finish() applicationScope.launch { shortcutCoordinator.handle(intent) }
}
} }
} }
@@ -4,14 +4,11 @@ import com.zaneschepke.tunnel.Tunnel
import com.zaneschepke.tunnel.backend.Backend import com.zaneschepke.tunnel.backend.Backend
import com.zaneschepke.tunnel.model.BackendMode import com.zaneschepke.tunnel.model.BackendMode
import com.zaneschepke.tunnel.state.BackendStatus import com.zaneschepke.tunnel.state.BackendStatus
import com.zaneschepke.wireguardautotunnel.domain.events.BackendCoreException
import com.zaneschepke.wireguardautotunnel.domain.events.BackendMessage
import com.zaneschepke.wireguardautotunnel.domain.model.LockdownSettings import com.zaneschepke.wireguardautotunnel.domain.model.LockdownSettings
import kotlin.concurrent.atomics.ExperimentalAtomicApi import kotlin.concurrent.atomics.ExperimentalAtomicApi
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
@@ -52,10 +49,4 @@ class TunnelBackendProvider(
override suspend fun disableLockDown(): Result<Unit> { override suspend fun disableLockDown(): Result<Unit> {
return backend.disableKillSwitch() return backend.disableKillSwitch()
} }
@OptIn(ExperimentalCoroutinesApi::class)
private val localErrorEvents = MutableSharedFlow<Pair<String?, BackendCoreException>>()
@OptIn(ExperimentalCoroutinesApi::class)
private val localMessageEvents = MutableSharedFlow<Pair<String?, BackendMessage>>()
} }
@@ -27,13 +27,14 @@ import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.extensions.to import com.zaneschepke.wireguardautotunnel.util.extensions.to
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.mapNotNull
@@ -77,13 +78,16 @@ class AutoTunnelService : LifecycleService() {
@Volatile private var hasUserOverride = false @Volatile private var hasUserOverride = false
private var lastNetworkFingerprint: AutoTunnelState.NetworkFingerprint? = null private var lastNetworkFingerprint: AutoTunnelState.NetworkFingerprint? = null
@OptIn(FlowPreview::class)
private val autoTunnelStateFlow: Flow<AutoTunnelState> by lazy { private val autoTunnelStateFlow: Flow<AutoTunnelState> by lazy {
val networkFlow = networkEngine.stableState.mapNotNull { it?.state?.toDomain() } val networkFlow = networkEngine.stableState.mapNotNull { it?.state?.toDomain() }
val settingsFlow = combineSettings() val settingsFlow = combineSettings()
val backendFlow = val backendFlow =
tunnelCoordinator.backendStatus.distinctUntilChangedBy { it.activeTunnels.keys.toSet() } tunnelCoordinator.backendStatus
.distinctUntilChanged { old, new -> old.activeTunnels == new.activeTunnels }
.debounce(300L.milliseconds)
combine(networkFlow, settingsFlow, backendFlow) { network, settings, backend -> combine(networkFlow, settingsFlow, backendFlow) { network, settings, backend ->
AutoTunnelState( AutoTunnelState(
@@ -0,0 +1,75 @@
package com.zaneschepke.wireguardautotunnel.ui.common.dialog
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.R
@Composable
fun LocalNetworkPermissionDialog(onDismiss: () -> Unit, onContinue: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(text = stringResource(R.string.local_network_permission_title)) },
text = {
Column {
Text(
text = stringResource(R.string.local_network_permission_intro),
style = MaterialTheme.typography.bodyMedium,
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.local_network_permission_issues_intro),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.local_network_permission_feature_tunnels),
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = stringResource(R.string.local_network_permission_feature_autotunnel),
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = stringResource(R.string.local_network_permission_feature_proxy),
style = MaterialTheme.typography.bodyMedium,
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.local_network_permission_nearby_devices),
style = MaterialTheme.typography.bodyMedium,
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.local_network_permission_recommendation),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
)
}
},
confirmButton = {
TextButton(onClick = onContinue) { Text(text = stringResource(R.string._continue)) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(text = stringResource(R.string.not_now)) }
},
)
}
@@ -18,5 +18,6 @@ data class GlobalAppUiState(
val selectedTunnelCount: Int = 0, val selectedTunnelCount: Int = 0,
val alreadyDonated: Boolean = false, val alreadyDonated: Boolean = false,
val isPinVerified: Boolean = false, val isPinVerified: Boolean = false,
val pendingWgImportUrl: String? = null,
val isScreenRecordingProtectionEnabled: Boolean = false, val isScreenRecordingProtectionEnabled: Boolean = false,
) )
@@ -0,0 +1,13 @@
package com.zaneschepke.wireguardautotunnel.util.extensions
import io.ktor.client.statement.HttpResponse
import io.ktor.client.statement.bodyAsText
suspend fun HttpResponse.isHtmlResponse(): Boolean {
val contentType = headers["Content-Type"] ?: ""
if (contentType.contains("text/html", ignoreCase = true)) return true
val bodyStart = bodyAsText().trimStart()
return bodyStart.startsWith("<!DOCTYPE", ignoreCase = true) ||
bodyStart.startsWith("<html", ignoreCase = true)
}
@@ -0,0 +1,23 @@
package com.zaneschepke.wireguardautotunnel.util.permission
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.content.ContextCompat
object LocalNetworkPermissionHelper {
fun shouldRequestPermission(): Boolean {
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN
}
fun isPermissionGranted(context: Context): Boolean {
return if (shouldRequestPermission()) {
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_LOCAL_NETWORK) ==
PackageManager.PERMISSION_GRANTED
} else {
true
}
}
}
@@ -29,6 +29,7 @@ import com.zaneschepke.wireguardautotunnel.util.StringValue
import com.zaneschepke.wireguardautotunnel.util.extensions.QuickConfig import com.zaneschepke.wireguardautotunnel.util.extensions.QuickConfig
import com.zaneschepke.wireguardautotunnel.util.extensions.TunnelName import com.zaneschepke.wireguardautotunnel.util.extensions.TunnelName
import com.zaneschepke.wireguardautotunnel.util.extensions.asStringValue import com.zaneschepke.wireguardautotunnel.util.extensions.asStringValue
import com.zaneschepke.wireguardautotunnel.util.extensions.isHtmlResponse
import com.zaneschepke.wireguardautotunnel.util.extensions.saveTunnelsUniquely import com.zaneschepke.wireguardautotunnel.util.extensions.saveTunnelsUniquely
import com.zaneschepke.wireguardautotunnel.util.network.NetworkUtils import com.zaneschepke.wireguardautotunnel.util.network.NetworkUtils
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
@@ -274,17 +275,30 @@ class SharedAppViewModel(
fun importFromQr(conf: String) = intent { importFromClipboard(conf) } fun importFromQr(conf: String) = intent { importFromClipboard(conf) }
fun promptWgImport(url: String) = intent { reduce { state.copy(pendingWgImportUrl = url) } }
fun dismissWgImport() = intent { reduce { state.copy(pendingWgImportUrl = null) } }
fun importFromUrl(url: String) = intent { fun importFromUrl(url: String) = intent {
reduce { state.copy(pendingWgImportUrl = null) }
try { try {
httpClient.prepareGet(url).execute { response -> httpClient.prepareGet(url).execute { response ->
if (response.status.value in 200..299) { if (response.status.value !in 200..299) {
val body = response.bodyAsText() throw IOException("Server returned error: ${response.status.value}")
importFromClipboard(body)
} else {
throw IOException(
"Failed to download file with error status: ${response.status.value}"
)
} }
if (response.isHtmlResponse()) {
postSideEffect(
GlobalSideEffect.Snackbar(
StringValue.StringResource(R.string.error_invalid_config_url),
ToastType.Error,
)
)
return@execute
}
val body = response.bodyAsText()
importFromClipboard(body)
} }
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e) Timber.e(e)
+20
View File
@@ -0,0 +1,20 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="1024"
android:viewportHeight="1024">
<group
android:scaleX="1.18"
android:scaleY="1.18"
android:pivotX="512"
android:pivotY="512"
android:translateX="-45"
android:translateY="-45">
<path
android:pathData="M779.7,207.8C782.5,207.8 785.4,207.8 788.2,207.8C851.8,207.6 851.8,207.6 871.1,225.5C882.4,237.3 887.6,250.9 887.5,267.1C886.9,284.9 879,300.9 872,317C871,319.2 870.1,321.4 869.1,323.7C867.7,327.1 866.2,330.6 864.7,334.1C861,342.7 857.3,351.4 853.7,360.1C847.1,375.7 840.5,391.3 833.8,406.9C831.1,413.1 828.5,419.2 825.8,425.4C822.9,432.3 819.9,439.2 817,446C804.9,474 804.9,474 793.3,502.2C791,507.8 788.5,513.3 785.9,518.7C782,527 778.5,535.5 775,544C772,551 769,557.9 766.1,564.9C765.6,566 765.1,567.2 764.6,568.4C763.5,570.7 762.5,573.1 761.5,575.5C760,579 758.5,582.6 757,586.1C751.8,598.2 746.6,610.4 741.2,622.4C737.3,631.3 733.4,640.3 729.5,649.2C725.8,657.9 722,666.6 718,675.2C715.7,680.4 713.5,685.6 711.4,690.8C708.5,697.8 705.4,704.7 702.1,711.6C700.8,714.5 699.4,717.4 698,720.4C685.7,746.7 672.9,772.5 643.9,783.1C639.6,784.4 635.5,784.7 631,785C630.2,785.1 629.5,785.1 628.7,785.2C610.7,785.8 596.7,779.5 583.5,767.8C569.4,754.5 562,735.7 554.3,718.3C552.1,713.5 549.9,708.7 547.5,704.1C543.9,696.9 540.8,689.6 537.6,682.2C534.6,675 531.4,667.8 528.3,660.6C522.3,646.9 516.3,633.3 510.4,619.6C508.5,615.1 506.5,610.6 504.6,606.1C494.1,582.1 494.1,582.1 489.6,571.2C488,567.4 486.4,563.6 484.6,559.8C481.8,553.6 479.2,547.4 476.6,541.1C472.4,531.2 472.4,531.2 468,521.5C461.3,507.3 455.5,492.6 449.5,478.1C444.9,466.9 440.2,455.8 435.3,444.7C431.5,436.1 427.7,427.4 424.1,418.8C423.8,418.1 423.5,417.4 423.2,416.6C420,409.1 416.8,401.6 413.7,394C413.4,393.3 413.2,392.7 412.9,392C411.6,389 410.4,386 409.2,383C406.8,377.1 404.3,371.4 401.6,365.7C397.9,357.9 394.6,349.9 391.3,341.9C390.4,339.6 389.5,337.4 388.6,335.2C388,333.7 387.3,332.2 386.7,330.7C384.7,325.7 382.6,320.7 380.6,315.8C380,314.5 379.5,313.2 378.9,311.9C377.9,309.5 376.9,307.1 375.9,304.7C368.4,286.7 361.9,264.8 369.7,245.7C373.8,237.4 377.9,230 385,224C385.6,223.5 386.2,223 386.8,222.4C397,213.9 412.5,209.7 425.8,210.1C442.3,212.1 455.2,220.4 466,233C471.9,241.6 476.5,250.7 480.9,260.1C481.5,261.3 482.2,262.6 482.8,263.9C487.3,273.5 491.7,283.1 496,292.8C497,294.9 497.9,297.1 498.9,299.2C503.6,309.6 508.1,320.1 512.5,330.6C515.1,336.6 517.7,342.5 520.5,348.4C524.1,356.1 527.4,363.9 530.6,371.8C531.7,374.4 532.8,377 533.9,379.7C534.3,380.6 534.3,380.6 534.7,381.6C536.3,385.3 537.8,388.9 539.5,392.5C541.5,396.7 543.3,401 545.1,405.3C545.4,406 545.6,406.6 545.9,407.3C547,409.9 548.1,412.6 549.2,415.2C552.5,423.2 555.9,431.1 559.6,438.9C561.7,443.4 563.7,447.9 565.5,452.5C567.6,457.7 569.8,462.9 572.3,467.9C575.9,475.7 579.2,483.5 582.5,491.4C585.8,499.2 589.1,507 592.5,514.7C617.2,571.5 617.2,571.5 625.1,591.7C625.8,593.9 625.8,593.9 627,595C627.3,594.3 627.6,593.5 627.9,592.7C632.8,580 637.7,567.3 643.4,554.9C646.9,547.2 650.2,539.5 653.4,531.8C653.7,531.1 654,530.4 654.3,529.7C657.2,522.9 660,516 662.9,509.1C663.3,508 663.8,506.9 664.2,505.8C665,503.9 665.9,501.9 666.7,499.9C668.9,494.5 671.3,489.1 673.8,483.7C675.7,479.4 677.6,475 679.5,470.7C679.9,469.7 680.3,468.7 680.8,467.7C682.2,464.5 683.6,461.2 685,458C686,455.6 687.1,453.3 688.1,450.9C689.7,447.1 691.3,443.4 693,439.6C695.8,433.2 698.5,426.8 701.3,420.3C714.6,389.7 714.6,389.7 727.2,358.9C729.7,352.7 732.4,346.8 735.3,340.8C736.9,337.4 738.2,333.8 739.6,330.3C740.1,329.1 740.5,327.9 741,326.7C741.3,325.8 741.6,324.9 742,324C741.2,324 741.2,324 740.3,324C726.5,324.1 712.7,324.1 698.9,324.1C692.2,324.1 685.5,324.1 678.9,324.2C672.4,324.2 665.9,324.2 659.5,324.2C657,324.2 654.6,324.2 652.1,324.2C640.5,324.3 629,324.2 617.4,323.1C616.6,323.1 615.8,323 614.9,322.9C597,321 581.4,314.3 569,301C566.5,297.8 564.7,294.6 563,291C562.5,290 562,289 561.5,288C556.5,276.1 555.8,261.8 559.6,249.4C562,243.5 565.3,238.2 569,233C569.7,232 569.7,232 570.4,231C581.2,217.3 599.1,212.3 615.8,210.2C637.1,208 659,208.7 680.4,208.5C683.5,208.4 686.5,208.4 689.5,208.4C719.6,208.1 749.6,207.9 779.7,207.8Z"
android:fillColor="#FFFFFF"/>
<path
android:pathData="M263.3,223.5C276.3,234.5 282.6,248.9 289.6,264C290.9,266.8 292.2,269.5 293.5,272.2C303,291.5 311.4,311.3 320,331C320.3,331.7 320.6,332.5 321,333.2C323.4,338.9 325.9,344.5 328.3,350.2C332.2,359.1 336.1,368.1 340,377C346.2,391.1 352.3,405.2 358.4,419.3C360.8,424.9 363.3,430.5 365.7,436.1C366.5,438 367.3,439.9 368.1,441.7C370.6,447.4 373,453 375.5,458.7C382.5,474.8 389.4,491 396.4,507.2C398.1,511.3 399.9,515.5 401.7,519.6C408.7,536 415.8,552.5 422.7,568.9C424,572 425.3,575.2 426.7,578.3C433,593.2 439.2,608.1 445.5,623C447.5,627.7 449.4,632.4 451.4,637C453.8,642.9 456.3,648.7 458.7,654.6C459.9,657.5 461.2,660.4 462.4,663.3C467.1,674.6 471.8,685.9 476.4,697.3C477.1,698.8 477.1,698.8 477.7,700.4C484.3,716.8 488.7,735.5 482,752.6C479.3,758.3 475.9,763.2 472,768C471.6,768.6 471.1,769.2 470.7,769.8C464.5,777.8 455.6,782.4 446,785C445,785.3 444,785.7 443,786C431.6,787 420.6,786.5 410,782C408.7,781.5 408.7,781.5 407.4,781C380.7,769.4 369.2,736.1 358.2,711.6C356.7,708.3 355.1,705 353.6,701.7C340.8,674.4 329,646.7 317.5,618.9C316,615.2 314.3,611.5 312.7,607.9C309.7,601.4 306.9,594.9 304.1,588.4C303.9,587.8 303.6,587.2 303.3,586.5C299.7,578 296.1,569.4 292.6,560.8C291,556.9 289.2,553 287.4,549.1C284.7,543 282,536.9 279.3,530.8C278.7,529.3 278.1,527.9 277.4,526.5C276.2,523.6 274.9,520.8 273.7,517.9C272.3,514.6 270.8,511.3 269.4,508C264.2,496.1 259.1,484.2 254.1,472.3C250.4,463.6 246.7,455 242.9,446.3C242.5,445.4 242.2,444.5 241.8,443.6C240,439.6 238.3,435.6 236.5,431.6C234.9,427.8 233.2,424.1 231.6,420.4C231.4,419.7 231.1,419.1 230.8,418.5C226.1,407.6 221.5,396.7 217,385.8C214.5,379.6 211.9,373.5 209.2,367.5C207.4,363.7 205.8,359.9 204.1,356.1C203.8,355.2 203.4,354.4 203,353.5C199.1,344.2 195.2,334.9 191.3,325.6C191,324.9 190.7,324.1 190.3,323.3C187.1,315.5 183.8,307.6 180.7,299.8C180.2,298.6 179.7,297.4 179.2,296.2C177.6,291.9 176.2,287.5 175,283C174.8,282.4 174.7,281.8 174.5,281.2C171.2,266.5 173.7,250.9 181.4,238.2C191.3,223.5 203.1,215.7 220.6,212.3C236.3,210.9 250.7,213.5 263.3,223.5Z"
android:fillColor="#FFFFFF"/>
</group>
</vector>
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<monochrome android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

+1
View File
@@ -138,6 +138,7 @@
<string name="config_error">Ungültige Konfiguration</string> <string name="config_error">Ungültige Konfiguration</string>
<string name="join_matrix">Matrix-Community beitreten</string> <string name="join_matrix">Matrix-Community beitreten</string>
<string name="error_download_failed">Download der Konfiguration fehlgeschlagen</string> <string name="error_download_failed">Download der Konfiguration fehlgeschlagen</string>
<string name="wg_url_confirm_message">Möchtest du wirklich Tunnel von %1$s hinzufügen? Verbinde dich niemals mit einem nicht vertrauenswürdigen VPN!</string>
<string name="add_from_url">Von URL hinzufügen</string> <string name="add_from_url">Von URL hinzufügen</string>
<string name="export_logs">Gespeicherte Logs exportieren</string> <string name="export_logs">Gespeicherte Logs exportieren</string>
<string name="app_permission_title">Steuere Tunnel und Auto-Tunnel Funktionen.</string> <string name="app_permission_title">Steuere Tunnel und Auto-Tunnel Funktionen.</string>
+1
View File
@@ -155,6 +155,7 @@
<string name="delete">Удалить</string> <string name="delete">Удалить</string>
<string name="export_failed">Экспорт не выполнен</string> <string name="export_failed">Экспорт не выполнен</string>
<string name="error_download_failed">Невозможно скачать конфигурацию</string> <string name="error_download_failed">Невозможно скачать конфигурацию</string>
<string name="wg_url_confirm_message">Добавить туннели от %1$s? Никогда не подключайтесь к неизвестному VPN!</string>
<string name="select_all">Выбрать все</string> <string name="select_all">Выбрать все</string>
<string name="export_success">Экспорт успешно выполнен</string> <string name="export_success">Экспорт успешно выполнен</string>
<string name="check_for_update">Проверить обновление</string> <string name="check_for_update">Проверить обновление</string>
+19
View File
@@ -153,6 +153,7 @@
<string name="add_from_url">Add from URL</string> <string name="add_from_url">Add from URL</string>
<string name="enter_config_url">Enter config URL</string> <string name="enter_config_url">Enter config URL</string>
<string name="error_download_failed">Failed to download config</string> <string name="error_download_failed">Failed to download config</string>
<string name="wg_url_confirm_message">Are you sure you want to add tunnels from %1$s? Never connect to an untrusted VPN!</string>
<string name="save">Save</string> <string name="save">Save</string>
<string name="search">Search</string> <string name="search">Search</string>
<string name="select">Select</string> <string name="select">Select</string>
@@ -535,4 +536,22 @@
<string name="hide_password">Hide password</string> <string name="hide_password">Hide password</string>
<string name="restore_failed_wrong_password">Restore failed. Wrong password</string> <string name="restore_failed_wrong_password">Restore failed. Wrong password</string>
<string name="restore_failed_invalid_file">Restore failed. Select a valid backup file (.sqlite3 or .sqlite3.aes)</string> <string name="restore_failed_invalid_file">Restore failed. Select a valid backup file (.sqlite3 or .sqlite3.aes)</string>
<string name="error_invalid_config_url">This link returned an invalid config file. Make sure you are using a direct download link</string>
<string name="local_network_permission_title">Local Network Access Needed</string>
<string name="local_network_permission_intro">WG Tunnel needs access to your local network for several features to work properly.</string>
<string name="local_network_permission_issues_intro">Without this permission, you may experience issues with:</string>
<string name="local_network_permission_feature_tunnels">- Connecting to certain tunnels</string>
<string name="local_network_permission_feature_autotunnel">- Auto-tunneling and split tunneling features</string>
<string name="local_network_permission_feature_proxy">- Local proxy and bypass functionality</string>
<string name="local_network_permission_recommendation">Granting this permission is strongly recommended.</string>
<string name="local_network_permission_nearby_devices">Note: Android labels this permission as “nearby devices”.</string>
<string name="not_now">Not now</string>
<string name="local_network_permission_denied">Local network access denied. Some features may not work properly</string>
</resources> </resources>
-1
View File
@@ -1,5 +1,4 @@
import com.ncorti.ktfmt.gradle.tasks.KtfmtFormatTask import com.ncorti.ktfmt.gradle.tasks.KtfmtFormatTask
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins { plugins {
alias(libs.plugins.android.application) apply false alias(libs.plugins.android.application) apply false
+2 -2
View File
@@ -1,6 +1,6 @@
object Constants { object Constants {
const val VERSION_NAME = "5.0.3" const val VERSION_NAME = "5.0.6"
const val VERSION_CODE = 50003 const val VERSION_CODE = 50006
const val TARGET_SDK = 37 const val TARGET_SDK = 37
const val MIN_SDK = 26 const val MIN_SDK = 26
@@ -0,0 +1,2 @@
What's new:
- Bugfix for auto tunnel rapid toggle
@@ -0,0 +1,5 @@
What's new:
- Bugfix for certain scenarios that were not cleaning up the vpn service fully
- Allows tunnel imports via wg:// url deep links
- Improvements to Always-On VPN reliability
- Improved cellular connectivity detection for dual sims
@@ -0,0 +1,3 @@
What's new:
- Bugfix for Android 17 local network permission requirement
- Bugfix for app shortcuts causing crash
@@ -102,6 +102,8 @@ class AndroidNetworkMonitor(
private var ethernetCallback: ConnectivityManager.NetworkCallback? = null private var ethernetCallback: ConnectivityManager.NetworkCallback? = null
private val airplaneModeState = MutableStateFlow(appContext.isAirplaneModeOn()) private val airplaneModeState = MutableStateFlow(appContext.isAirplaneModeOn())
private val activeCellularNetworks =
MutableStateFlow<Map<Network, NetworkCapabilities>>(emptyMap())
private val airplaneModeFlow: Flow<Boolean> = airplaneModeState.asStateFlow() private val airplaneModeFlow: Flow<Boolean> = airplaneModeState.asStateFlow()
// tracking to prevent races that occur when VPN is first activated and to prevent redundant // tracking to prevent races that occur when VPN is first activated and to prevent redundant
@@ -317,14 +319,19 @@ class AndroidNetworkMonitor(
private val cellularFlow: Flow<TransportEvent> = callbackFlow { private val cellularFlow: Flow<TransportEvent> = callbackFlow {
val onAvailable: (Network) -> Unit = { network -> val onAvailable: (Network) -> Unit = { network ->
Timber.d("Cellular onAvailable: $network") Timber.d("Cellular onAvailable: $network")
// Defensive cleanup
activeCellularNetworks.update { it - network }
} }
val onLost: (Network) -> Unit = { network -> val onLost: (Network) -> Unit = { network ->
Timber.d("Cellular onLost: $network") Timber.d("Cellular onLost: $network")
activeCellularNetworks.update { it - network }
trySend(TransportEvent.Lost(network)) trySend(TransportEvent.Lost(network))
} }
val onCapabilitiesChanged: (Network, NetworkCapabilities) -> Unit = { network, caps -> val onCapabilitiesChanged: (Network, NetworkCapabilities) -> Unit = { network, caps ->
Timber.d("Cellular onCapabilitiesChanged: $network") if (caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) {
trySend(TransportEvent.CapabilitiesChanged(network, caps)) activeCellularNetworks.update { it + (network to caps) }
trySend(TransportEvent.CapabilitiesChanged(network, caps))
}
} }
cellularCallback = cellularCallback =
@@ -339,13 +346,10 @@ class AndroidNetworkMonitor(
val request = val request =
NetworkRequest.Builder() NetworkRequest.Builder()
.apply { addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR) } .addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR)
.build() .build()
connectivityManager?.registerNetworkCallback(request, cellularCallback!!) connectivityManager?.registerNetworkCallback(request, cellularCallback!!)
trySend(TransportEvent.Unknown) trySend(TransportEvent.Unknown)
awaitClose { awaitClose {
runCatching { connectivityManager?.unregisterNetworkCallback(cellularCallback!!) } runCatching { connectivityManager?.unregisterNetworkCallback(cellularCallback!!) }
.onFailure { Timber.e(it, "Error unregistering cellular network callback") } .onFailure { Timber.e(it, "Error unregistering cellular network callback") }
@@ -438,6 +442,26 @@ class AndroidNetworkMonitor(
.also { Timber.d("Current SSID via ${method.name}: $it") } .also { Timber.d("Current SSID via ${method.name}: $it") }
} }
private fun hasGoodCellularNetwork(): Boolean =
activeCellularNetworks.value.values.any { caps ->
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) &&
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) &&
(Build.VERSION.SDK_INT < Build.VERSION_CODES.P ||
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_SUSPENDED))
}
private fun getGoodCellularNetwork(): Network? =
activeCellularNetworks.value.entries
.firstOrNull { (_, caps) ->
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) &&
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) &&
(Build.VERSION.SDK_INT < Build.VERSION_CODES.P ||
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_SUSPENDED))
}
?.key
// default network events don't contain detailed capability information of underlying networks, // default network events don't contain detailed capability information of underlying networks,
// so we need to track separately // so we need to track separately
private data class NetworkData( private data class NetworkData(
@@ -577,21 +601,13 @@ class AndroidNetworkMonitor(
} }
// only count cellular as connected if validated AND not in airplane mode // only count cellular as connected if validated AND not in airplane mode
!isAirplaneOn && !isAirplaneOn && hasGoodCellularNetwork() -> {
networkData.cellularEvent is TransportEvent.CapabilitiesChanged && val goodNetwork = getGoodCellularNetwork()
networkData.cellularEvent.networkCapabilities?.let { caps -> if (goodNetwork != null) {
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) && ActiveNetwork.Cellular(goodNetwork)
caps.hasCapability( } else {
NetworkCapabilities.NET_CAPABILITY_INTERNET ActiveNetwork.Disconnected()
) && }
caps.hasCapability(
NetworkCapabilities.NET_CAPABILITY_VALIDATED
) &&
caps.hasCapability(
NetworkCapabilities.NET_CAPABILITY_NOT_SUSPENDED
)
} == true -> {
ActiveNetwork.Cellular(networkData.cellularEvent.network)
} }
else -> ActiveNetwork.Disconnected() else -> ActiveNetwork.Disconnected()
+1
View File
@@ -1,6 +1,7 @@
<?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">
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
<!--foreground service special use for non VPN service tunnels, android 14--> <!--foreground service special use for non VPN service tunnels, android 14-->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<!--foreground service special use for VPN service tunnels, android 14--> <!--foreground service special use for VPN service tunnels, android 14-->
@@ -23,10 +23,8 @@ import com.zaneschepke.tunnel.state.KillSwitchState
import com.zaneschepke.tunnel.util.RootShell import com.zaneschepke.tunnel.util.RootShell
import com.zaneschepke.tunnel.util.RootShellException import com.zaneschepke.tunnel.util.RootShellException
import com.zaneschepke.tunnel.util.buildResolvedPeers import com.zaneschepke.tunnel.util.buildResolvedPeers
import com.zaneschepke.tunnel.util.isLastTunnelOfServiceType
import com.zaneschepke.tunnel.util.toHostMap import com.zaneschepke.tunnel.util.toHostMap
import com.zaneschepke.wireguardautotunnel.parser.ActiveConfig import com.zaneschepke.wireguardautotunnel.parser.ActiveConfig
import java.lang.ref.WeakReference
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@@ -92,8 +90,6 @@ class TunnelBackend(
NETWORK_CHANGE_RESET, NETWORK_CHANGE_RESET,
} }
private var dnsConfigJob: Job? = null
private val statusCallback = StatusCallback { handle, code -> private val statusCallback = StatusCallback { handle, code ->
val state = Tunnel.State.fromNative(code) ?: return@StatusCallback val state = Tunnel.State.fromNative(code) ?: return@StatusCallback
val tunnelId = byHandle[handle] ?: return@StatusCallback val tunnelId = byHandle[handle] ?: return@StatusCallback
@@ -107,7 +103,7 @@ class TunnelBackend(
tunnelMutex.withLock { tunnelMutex.withLock {
runCatching { runCatching {
if (_status.value.activeTunnels.containsKey(tunnel.id)) { if (_status.value.activeTunnels.containsKey(tunnel.id)) {
Timber.d("Tunnel ${tunnel.id} already running — ignoring start") Timber.w("Tunnel ${tunnel.id} already running")
return@runCatching return@runCatching
} }
@@ -130,74 +126,68 @@ class TunnelBackend(
if (scriptsEnabled) if (scriptsEnabled)
mode.config.`interface`.preUp?.let { runScripts(it, tunnel.id) } mode.config.`interface`.preUp?.let { runScripts(it, tunnel.id) }
val fd = setupServiceForMode(tunnel, mode) setupServiceForMode(tunnel, mode)
if (hasDynamicEndpoints(mode)) { if (hasDynamicEndpoints(mode)) {
pendingResolutionJobs[tunnel.id] = startTunnelBootstrapJob(tunnel, mode, fd) pendingResolutionJobs[tunnel.id] = startTunnelBootstrapJob(tunnel, mode)
} else { } else {
val result = engine.start(tunnel, mode, fd) val result = engine.start(tunnel, mode)
onEngineStartResult(tunnel.id, result) onEngineStartResult(tunnel.id, result)
if (scriptsEnabled) { if (scriptsEnabled) {
mode.config.`interface`.postUp?.let { runScripts(it, tunnel.id) } mode.config.`interface`.postUp?.let { runScripts(it, tunnel.id) }
} }
tunnelJobs[tunnel.id] = startTunnelJobs(result.handle, tunnel, mode) tunnelJobs[tunnel.id] = startTunnelJobs(result.handle, tunnel, mode)
} }
} }
.onFailure { cleanup(tunnel.id) } .onFailure { cleanup(tunnel.id) }
} }
private fun startTunnelBootstrapJob(tunnel: Tunnel, mode: BackendMode, fd: Int?) = private fun startTunnelBootstrapJob(tunnel: Tunnel, mode: BackendMode) = scope.launch {
scope.launch { try {
try { updateTunnelBootstrapState(tunnel.id, BootstrapState.ResolvingDns)
updateTunnelBootstrapState(tunnel.id, BootstrapState.ResolvingDns)
val resultMap = endpointResolver.resolvePeers(mode) val resultMap = endpointResolver.resolvePeers(mode)
ensureActive() ensureActive()
val networkHasIpv6 = stableNetworkEngine.stableState.value?.state?.hasIpv6 ?: false val networkHasIpv6 = stableNetworkEngine.stableState.value?.state?.hasIpv6 ?: false
val hostMap = val hostMap =
resultMap.toHostMap( resultMap.toHostMap(
preferIpv6 = preferIpv6 = tunnel.ipStrategy is Tunnel.IpStrategy.PreferIpv6 && networkHasIpv6
tunnel.ipStrategy is Tunnel.IpStrategy.PreferIpv6 && networkHasIpv6 )
) val resolvedPeers = mode.config.buildResolvedPeers(hostMap)
val resolvedPeers = mode.config.buildResolvedPeers(hostMap)
updateTunnelBootstrapState(tunnel.id, BootstrapState.Complete) updateTunnelBootstrapState(tunnel.id, BootstrapState.Complete)
val resolvedConfig = mode.config.copy(peers = resolvedPeers) val resolvedConfig = mode.config.copy(peers = resolvedPeers)
val updatedMode = val updatedMode =
when (mode) { when (mode) {
is BackendMode.Vpn -> mode.copy(config = resolvedConfig) is BackendMode.Vpn -> mode.copy(config = resolvedConfig)
is BackendMode.Proxy.Standard -> mode.copy(config = resolvedConfig) is BackendMode.Proxy.Standard -> mode.copy(config = resolvedConfig)
is BackendMode.Proxy.KillSwitchPrimary -> mode.copy(config = resolvedConfig) is BackendMode.Proxy.KillSwitchPrimary -> mode.copy(config = resolvedConfig)
}
val result = engine.start(tunnel, updatedMode, fd)
onEngineStartResult(tunnel.id, result)
val scriptsEnabled = tunnel.scriptsEnabled
if (scriptsEnabled) {
mode.config.`interface`.postUp?.let { runScripts(it, tunnel.id) }
} }
tunnelJobs[tunnel.id] = startTunnelJobs(result.handle, tunnel, updatedMode) val result = engine.start(tunnel, updatedMode)
} catch (t: Throwable) { onEngineStartResult(tunnel.id, result)
if (t is kotlinx.coroutines.CancellationException) {
Timber.d("Bootstrap job cancelled for tunnel ${tunnel.id}") val scriptsEnabled = tunnel.scriptsEnabled
throw t if (scriptsEnabled) {
} else { mode.config.`interface`.postUp?.let { runScripts(it, tunnel.id) }
Timber.e(t, "Tunnel bootstrap failed for ${tunnel.id}")
}
cleanup(tunnel.id)
} finally {
pendingResolutionJobs.remove(tunnel.id)
} }
}
private suspend fun setupServiceForMode(tunnel: Tunnel, mode: BackendMode): Int? { tunnelJobs[tunnel.id] = startTunnelJobs(result.handle, tunnel, updatedMode)
var fd: Int? = null } catch (t: Throwable) {
if (t is kotlinx.coroutines.CancellationException) {
Timber.d("Bootstrap job cancelled for tunnel ${tunnel.id}")
} else {
Timber.e(t, "Tunnel bootstrap failed for ${tunnel.id}")
cleanup(tunnel.id)
}
if (t is kotlinx.coroutines.CancellationException) throw t
}
}
private suspend fun setupServiceForMode(tunnel: Tunnel, mode: BackendMode) {
when (mode) { when (mode) {
is BackendMode.Proxy.KillSwitchPrimary -> { is BackendMode.Proxy.KillSwitchPrimary -> {
serviceHolder.ensureVpnProtectorRegistered() serviceHolder.ensureVpnProtectorRegistered()
@@ -207,10 +197,9 @@ class TunnelBackend(
} }
is BackendMode.Vpn -> { is BackendMode.Vpn -> {
val service = serviceHolder.ensureVpnProtectorRegistered() val service = serviceHolder.ensureVpnProtectorRegistered()
fd = service.createTunInterface(tunnel, mode.config)?.detachFd() service.createTunInterface(tunnel, mode.config)
} }
} }
return fd
} }
private fun onEngineStartResult(tunnelId: Int, result: EngineStartResult) { private fun onEngineStartResult(tunnelId: Int, result: EngineStartResult) {
@@ -221,13 +210,27 @@ class TunnelBackend(
byTunnelId[tunnelId] = result.handle byTunnelId[tunnelId] = result.handle
} }
private fun cleanup(tunnelId: Int) { private suspend fun cleanup(tunnelId: Int) {
pendingResolutionJobs.remove(tunnelId)?.cancel() pendingResolutionJobs.remove(tunnelId)?.cancel()
tunnelJobs.remove(tunnelId)?.cancel() tunnelJobs.remove(tunnelId)?.cancel()
val activeTunnels = _status.value.activeTunnels
val vpnTypeCount = activeTunnels.values.count { it.mode is BackendMode.Vpn }
val proxyTypeCount = activeTunnels.values.count { it.mode is BackendMode.Proxy.Standard }
removeActiveTunnel(tunnelId) removeActiveTunnel(tunnelId)
byTunnelId[tunnelId]?.let { byHandle.remove(it) } byTunnelId[tunnelId]?.let { byHandle.remove(it) }
byTunnelId.remove(tunnelId) byTunnelId.remove(tunnelId)
peerUpdateMutexes.remove(tunnelId) peerUpdateMutexes.remove(tunnelId)
if (vpnTypeCount == 1) {
serviceHolder.stopVpnService()
}
if (proxyTypeCount == 1) {
serviceHolder.stopTunnelService()
}
} }
private suspend fun runScripts(commands: List<String>, tunnelId: Int) { private suspend fun runScripts(commands: List<String>, tunnelId: Int) {
@@ -246,29 +249,20 @@ class TunnelBackend(
} }
override fun setAlwaysOnCallback(alwaysOnCallback: VpnService.AlwaysOnCallback) { override fun setAlwaysOnCallback(alwaysOnCallback: VpnService.AlwaysOnCallback) {
ServiceHolder.alwaysOnCallback = WeakReference(alwaysOnCallback) ServiceHolder.alwaysOnCallback = alwaysOnCallback
} }
override suspend fun stop(id: Int): Result<Unit> = tunnelMutex.withLock { override suspend fun stop(id: Int): Result<Unit> = tunnelMutex.withLock {
runCatching { runCatching {
val activeTun = _status.value.activeTunnels[id] ?: return@runCatching val activeTun = _status.value.activeTunnels[id] ?: return@runCatching
val mode = activeTun.mode ?: return@runCatching
updateTunnelTransportState(id, Tunnel.State.Stopping) updateTunnelTransportState(id, Tunnel.State.Stopping)
val isLast = _status.value.activeTunnels.size == 1
val isLastOfServiceType = _status.value.isLastTunnelOfServiceType(id)
try { try {
stopTunnelInternal(id, activeTun) stopTunnelInternal(id, activeTun)
} finally { } finally {
applicationProvider.refreshTile(serviceHolder.context) applicationProvider.refreshTile(serviceHolder.context)
if (isLast) VpnBackend.setStatusCallback(null) if (_status.value.activeTunnels.isEmpty()) {
if (isLastOfServiceType) { VpnBackend.setStatusCallback(null)
when (mode) {
is BackendMode.Proxy.KillSwitchPrimary,
is BackendMode.Vpn -> serviceHolder.stopVpnService()
is BackendMode.Proxy.Standard -> serviceHolder.stopTunnelService()
}
} }
} }
} }
@@ -277,8 +271,6 @@ class TunnelBackend(
private suspend fun stopTunnelInternal(tunnelId: Int, activeTunnel: ActiveTunnel) { private suspend fun stopTunnelInternal(tunnelId: Int, activeTunnel: ActiveTunnel) {
updateTunnelTransportState(tunnelId, Tunnel.State.Stopping) updateTunnelTransportState(tunnelId, Tunnel.State.Stopping)
pendingResolutionJobs.remove(tunnelId)?.cancel()
val handle = byTunnelId[tunnelId] val handle = byTunnelId[tunnelId]
if (handle == null) { if (handle == null) {
@@ -8,7 +8,7 @@ import com.zaneschepke.wireguardautotunnel.parser.PeerSection
internal interface TunnelEngine { internal interface TunnelEngine {
suspend fun start(tunnel: Tunnel, mode: BackendMode, fd: Int?): EngineStartResult suspend fun start(tunnel: Tunnel, mode: BackendMode): EngineStartResult
suspend fun stop(handle: Int, mode: BackendMode) suspend fun stop(handle: Int, mode: BackendMode)
@@ -17,7 +17,7 @@ import java.util.UUID
internal class WireGuardTunnelEngine(private val serviceHolder: ServiceHolder) : TunnelEngine { internal class WireGuardTunnelEngine(private val serviceHolder: ServiceHolder) : TunnelEngine {
override suspend fun start(tunnel: Tunnel, mode: BackendMode, fd: Int?): EngineStartResult { override suspend fun start(tunnel: Tunnel, mode: BackendMode): EngineStartResult {
val ifName = WGT_INTERFACE_PREFIX + tunnel.id val ifName = WGT_INTERFACE_PREFIX + tunnel.id
@@ -56,7 +56,8 @@ internal class WireGuardTunnelEngine(private val serviceHolder: ServiceHolder) :
startProxyTunnel(ifName, mode.config, proxyConfig, false) startProxyTunnel(ifName, mode.config, proxyConfig, false)
} }
is BackendMode.Vpn -> { is BackendMode.Vpn -> {
startVpnTunnel(ifName, mode.config, fd) val service = serviceHolder.getVpnService()
startVpnTunnel(ifName, mode.config, service.detachVpnTunnelFd())
} }
} }
@@ -0,0 +1,13 @@
package com.zaneschepke.tunnel.service
import com.zaneschepke.tunnel.model.BackendMode
import com.zaneschepke.tunnel.state.BackendStatus
fun BackendStatus.toNotificationComparisonKey(): Any =
activeTunnels.mapValues { (_, tunnel) ->
Triple(
tunnel.transportState,
tunnel.bootstrapState,
tunnel.mode is BackendMode.Vpn || tunnel.mode is BackendMode.Proxy.KillSwitchPrimary,
)
} to (activeTunnels.keys to (killSwitch.enabled to dnsMode))
@@ -4,7 +4,6 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import com.zaneschepke.tunnel.ProxyBackend import com.zaneschepke.tunnel.ProxyBackend
import com.zaneschepke.tunnel.util.BackendException import com.zaneschepke.tunnel.util.BackendException
import java.lang.ref.WeakReference
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -50,7 +49,7 @@ internal class ServiceHolder(val context: Context) {
} }
if (_vpnService.value == null) { if (_vpnService.value == null) {
context.startService(Intent(context, VpnService::class.java)) VpnService.start(context, VpnService::class.java)
} }
return try { return try {
@@ -76,16 +75,22 @@ internal class ServiceHolder(val context: Context) {
suspend fun stopVpnService() { suspend fun stopVpnService() {
val service = _vpnService.value ?: return val service = _vpnService.value ?: return
clearVpnService() try {
service.shutdown() service.shutdown()
withTimeoutOrNull(1_000L.milliseconds) { vpnServiceFlow.first { it == null } } withTimeoutOrNull(1_500L.milliseconds) { vpnServiceFlow.first { it == null } }
} finally {
clearVpnService()
}
} }
suspend fun stopTunnelService() { suspend fun stopTunnelService() {
val service = _tunnelService.value ?: return val service = _tunnelService.value ?: return
clearTunnelService() try {
service.shutdown() service.shutdown()
withTimeoutOrNull(1_000L.milliseconds) { tunnelServiceFlow.first { it == null } } withTimeoutOrNull(1_500L.milliseconds) { tunnelServiceFlow.first { it == null } }
} finally {
clearTunnelService()
}
} }
/** /**
@@ -104,6 +109,6 @@ internal class ServiceHolder(val context: Context) {
const val SPECIAL_USE_SERVICE_TYPE_ID = 1 shl 30 const val SPECIAL_USE_SERVICE_TYPE_ID = 1 shl 30
const val DEFAULT_MTU = 1280 const val DEFAULT_MTU = 1280
// for consumer to set AOVPN callback // for consumer to set AOVPN callback
var alwaysOnCallback: WeakReference<VpnService.AlwaysOnCallback>? = null var alwaysOnCallback: VpnService.AlwaysOnCallback? = null
} }
} }
@@ -15,7 +15,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.java.KoinJavaComponent.inject import org.koin.java.KoinJavaComponent.inject
import timber.log.Timber import timber.log.Timber
@@ -51,7 +51,7 @@ class TunnelService : LifecycleService() {
(intent.component!!.packageName != packageName) (intent.component!!.packageName != packageName)
) { ) {
Timber.d("TunnelService started by system") Timber.d("TunnelService started by system")
alwaysOnCallback?.get()?.alwaysOnTriggered() alwaysOnCallback?.alwaysOnTriggered()
} }
return START_STICKY return START_STICKY
@@ -61,12 +61,11 @@ class TunnelService : LifecycleService() {
private fun observeProxyPersistentNotification() { private fun observeProxyPersistentNotification() {
lifecycleScope.launch { lifecycleScope.launch {
backend.status backend.status
.distinctUntilChanged { old, new -> old.activeTunnels == new.activeTunnels } .distinctUntilChangedBy { it.toNotificationComparisonKey() }
.debounce(1_000.milliseconds) .debounce(700.milliseconds)
.collect { status -> .collect { status ->
val notification = val notification =
backend.applicationProvider.buildProxyPersistentNotification(status) backend.applicationProvider.buildProxyPersistentNotification(status)
notificationManager.notify( notificationManager.notify(
backend.applicationProvider.proxyNotificationId, backend.applicationProvider.proxyNotificationId,
notification, notification,
@@ -10,7 +10,7 @@ import com.zaneschepke.tunnel.backend.Backend
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
import timber.log.Timber import timber.log.Timber
@@ -43,8 +43,8 @@ class VpnCompanionService : LifecycleService() {
private fun observeVpnPersistentNotification() { private fun observeVpnPersistentNotification() {
lifecycleScope.launch { lifecycleScope.launch {
backend.status backend.status
.distinctUntilChanged { old, new -> old.activeTunnels == new.activeTunnels } .distinctUntilChangedBy { it.toNotificationComparisonKey() }
.debounce(1000.milliseconds) .debounce(700.milliseconds)
.collect { status -> .collect { status ->
val notification = val notification =
backend.applicationProvider.buildVpnPersistentNotification(status) backend.applicationProvider.buildVpnPersistentNotification(status)
@@ -1,5 +1,6 @@
package com.zaneschepke.tunnel.service package com.zaneschepke.tunnel.service
import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.TrafficStats import android.net.TrafficStats
import android.os.Build import android.os.Build
@@ -39,10 +40,9 @@ class VpnService : android.net.VpnService(), KillSwitch, SocketProtector {
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val shutdownScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val shutdownScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@Volatile private var userActivatedShutdown = false
private var hevBridgeJob: Job? = null private var hevBridgeJob: Job? = null
@Volatile private var fd: ParcelFileDescriptor? = null @Volatile private var hevBridgeFd: ParcelFileDescriptor? = null
@Volatile private var vpnTunFd: ParcelFileDescriptor? = null
override fun onCreate() { override fun onCreate() {
serviceHolder.set(this) serviceHolder.set(this)
@@ -58,31 +58,21 @@ class VpnService : android.net.VpnService(), KillSwitch, SocketProtector {
// Stop the companion foreground service alongside the VPN teardown // Stop the companion foreground service alongside the VPN teardown
stopService(Intent(this, VpnCompanionService::class.java)) stopService(Intent(this, VpnCompanionService::class.java))
closeVpnTunnelFd()
disableKillSwitch() disableKillSwitch()
hevBridgeJob?.cancel() hevBridgeJob?.cancel()
serviceScope.cancel() serviceScope.cancel()
stopHevSocks5Bridge() stopHevSocks5Bridge()
if (!userActivatedShutdown) {
Timber.d("Service being killed by system, clean up tunnels")
shutdownScope.launch { backend.stopAllActiveTunnels() }
}
} finally { } finally {
super.onDestroy() super.onDestroy()
} }
} }
@OptIn(ExperimentalAtomicApi::class)
fun shutdown() {
userActivatedShutdown = true
stopSelf()
}
override fun onRevoke() { override fun onRevoke() {
Timber.w("VPN privilege revoked by system") Timber.w("VPN revoked by user via system settings")
userActivatedShutdown = false
disableKillSwitch() disableKillSwitch()
stopHevSocks5Bridge() stopHevSocks5Bridge()
serviceScope.launch { backend.stopAllActiveTunnels() } shutdownScope.launch { backend.stopAllActiveTunnels() }
stopSelf() stopSelf()
super.onRevoke() super.onRevoke()
} }
@@ -90,21 +80,40 @@ class VpnService : android.net.VpnService(), KillSwitch, SocketProtector {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
serviceHolder.set(this) serviceHolder.set(this)
// Ensure the companion service is up immediately to provide foreground process
bootKeepaliveService() bootKeepaliveService()
// Service restarted by system or Always-on VPN started // system recovery restart
if ( if (intent == null) {
intent == null || return START_STICKY
intent.component == null ||
(intent.component!!.packageName != packageName)
) {
Timber.d("VpnService started by system (Always-On trigger)")
alwaysOnCallback?.get()?.alwaysOnTriggered()
} }
val isUserLaunch = intent.getBooleanExtra(getUserLaunchExtraKey(this), false)
val platformSaysAlwaysOn =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
isAlwaysOn
} else {
false
}
val isAlwaysOnTrigger =
!isUserLaunch && (intent.action == SERVICE_INTERFACE || platformSaysAlwaysOn)
if (isAlwaysOnTrigger) {
Timber.d("VpnService started by system (Always-On trigger)")
alwaysOnCallback?.alwaysOnTriggered()
}
return START_STICKY return START_STICKY
} }
fun shutdown() {
// have to close fds before we can trigger service shutdown
closeVpnTunnelFd()
disableKillSwitch()
stopSelf()
}
private fun bootKeepaliveService() { private fun bootKeepaliveService() {
try { try {
val intent = Intent(this, VpnCompanionService::class.java) val intent = Intent(this, VpnCompanionService::class.java)
@@ -119,7 +128,7 @@ class VpnService : android.net.VpnService(), KillSwitch, SocketProtector {
val job = serviceScope.launch { val job = serviceScope.launch {
TrafficStats.setThreadStatsTag(HEV_BRIDGE_TRAFFIC_TAG) TrafficStats.setThreadStatsTag(HEV_BRIDGE_TRAFFIC_TAG)
try { try {
val vpnFd = fd ?: throw IOException("No VPN interface fd available") val vpnFd = hevBridgeFd ?: throw IOException("No VPN interface fd available")
repeat(60) { attempt -> repeat(60) { attempt ->
try { try {
@@ -176,15 +185,15 @@ class VpnService : android.net.VpnService(), KillSwitch, SocketProtector {
} }
private fun disableKillSwitch() { private fun disableKillSwitch() {
fd?.close() hevBridgeFd?.close()
fd = null hevBridgeFd = null
} }
override fun setKillSwitch(config: KillSwitchConfig?) { override fun setKillSwitch(config: KillSwitchConfig?) {
if (config == null) return disableKillSwitch() if (config == null) return disableKillSwitch()
fd?.close() hevBridgeFd?.close()
val intent = backend.applicationProvider.createVpnConfigurePendingIntent(this@VpnService) val intent = backend.applicationProvider.createVpnConfigurePendingIntent(this@VpnService)
fd = hevBridgeFd =
Builder() Builder()
.apply { .apply {
setSession(LOCKDOWN_SESSION_NAME) setSession(LOCKDOWN_SESSION_NAME)
@@ -211,76 +220,94 @@ class VpnService : android.net.VpnService(), KillSwitch, SocketProtector {
.establish() .establish()
} }
fun createTunInterface(tunnel: Tunnel, config: Config): ParcelFileDescriptor? { fun createTunInterface(tunnel: Tunnel, config: Config) {
val intent = backend.applicationProvider.createVpnConfigurePendingIntent(this@VpnService) val intent = backend.applicationProvider.createVpnConfigurePendingIntent(this@VpnService)
return Builder() vpnTunFd?.close()
.apply { vpnTunFd = null
setSession(tunnel.name) vpnTunFd =
setConfigureIntent(intent) Builder()
setMtu(config.`interface`.mtu ?: DEFAULT_MTU) .apply {
setBlocking(true) setSession(tunnel.name)
setUnderlyingNetworks(null) setConfigureIntent(intent)
setMtu(config.`interface`.mtu ?: DEFAULT_MTU)
setBlocking(true)
setUnderlyingNetworks(null)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
setMetered(tunnel.isMetered) setMetered(tunnel.isMetered)
} }
config.`interface`.includedApplications?.forEach { addAllowedApplication(it) } config.`interface`.includedApplications?.forEach { addAllowedApplication(it) }
config.`interface`.excludedApplications?.forEach { addDisallowedApplication(it) } config.`interface`.excludedApplications?.forEach {
addDisallowedApplication(it)
var hasIpv4 = false }
var hasIpv6 = false
var sawDefaultRoute = false var hasIpv4 = false
var hasIpv6 = false
// Parse interface addresses var sawDefaultRoute = false
config.`interface`.address?.split(",")?.forEach { rawAddress ->
val (address, prefixLength) = rawAddress.parseInetNetwork() // Parse interface addresses
addAddress(address, prefixLength) config.`interface`.address?.split(",")?.forEach { rawAddress ->
if (address is Inet4Address) hasIpv4 = true else hasIpv6 = true val (address, prefixLength) = rawAddress.parseInetNetwork()
} addAddress(address, prefixLength)
if (address is Inet4Address) hasIpv4 = true else hasIpv6 = true
// Parse peer routes }
config.peers.forEach { peer ->
peer.allowedIPs // Parse peer routes
?.split(",") config.peers.forEach { peer ->
?.map { it.trim() } peer.allowedIPs
?.filter { it.isNotEmpty() } ?.split(",")
?.forEach { entry -> ?.map { it.trim() }
val (address, prefix) = entry.parseInetNetwork() ?.filter { it.isNotEmpty() }
addRoute(address, prefix) ?.forEach { entry ->
val (address, prefix) = entry.parseInetNetwork()
if (prefix == 0) { addRoute(address, prefix)
sawDefaultRoute = true
} if (prefix == 0) {
if (address is Inet4Address) hasIpv4 = true else hasIpv6 = true sawDefaultRoute = true
} }
} if (address is Inet4Address) hasIpv4 = true else hasIpv6 = true
}
// "Kill-switch" semantics (mirrors wireguard-android) }
val isKillSwitchRouting = sawDefaultRoute && config.peers.size == 1
// "Kill-switch" semantics (mirrors wireguard-android)
if (!isKillSwitchRouting) { val isKillSwitchRouting = sawDefaultRoute && config.peers.size == 1
allowFamily(OsConstants.AF_INET)
allowFamily(OsConstants.AF_INET6) if (!isKillSwitchRouting) {
} allowFamily(OsConstants.AF_INET)
allowFamily(OsConstants.AF_INET6)
// Only add DNS servers whose family is supported }
config.`interface`.dns?.let { rawDns ->
val dnsConfig = rawDns.parseDns() // Only add DNS servers whose family is supported
dnsConfig.dnsServers.forEach { dnsServer -> config.`interface`.dns?.let { rawDns ->
val isIpv6 = dnsServer is Inet6Address val dnsConfig = rawDns.parseDns()
if ((isIpv6 && hasIpv6) || (!isIpv6 && hasIpv4)) { dnsConfig.dnsServers.forEach { dnsServer ->
addDnsServer(dnsServer) val isIpv6 = dnsServer is Inet6Address
} else { if ((isIpv6 && hasIpv6) || (!isIpv6 && hasIpv4)) {
Timber.w( addDnsServer(dnsServer)
"Dropped DNS server $dnsServer: IP family not allowed by interface/routes" } else {
) Timber.w(
} "Dropped DNS server $dnsServer: IP family not allowed by interface/routes"
)
}
}
dnsConfig.searchDomains.forEach { addSearchDomain(it) }
} }
dnsConfig.searchDomains.forEach { addSearchDomain(it) }
} }
} .establish()
.establish() }
fun detachVpnTunnelFd(): Int? {
val tunFd = vpnTunFd
vpnTunFd = null
return tunFd?.detachFd()
}
fun closeVpnTunnelFd() {
try {
vpnTunFd?.close()
} catch (_: Exception) {}
vpnTunFd = null
} }
override fun startHevSocks5Bridge(port: Int, pass: String) { override fun startHevSocks5Bridge(port: Int, pass: String) {
@@ -317,6 +344,21 @@ class VpnService : android.net.VpnService(), KillSwitch, SocketProtector {
} }
companion object { companion object {
private fun getUserLaunchExtraKey(context: Context): String {
return "${context.packageName}.EXTRA_IS_USER_LAUNCH"
}
@JvmStatic
fun start(context: Context, serviceClass: Class<out VpnService>) {
val intent =
Intent(context, serviceClass).apply {
action = SERVICE_INTERFACE
putExtra(getUserLaunchExtraKey(context), true)
}
context.startService(intent)
}
private const val LOCKDOWN_SESSION_NAME = "Lockdown" private const val LOCKDOWN_SESSION_NAME = "Lockdown"
private const val LOCALHOST = "127.0.0.1" private const val LOCALHOST = "127.0.0.1"
private const val IPV4_INTERFACE_ADDRESS = "10.0.0.1" private const val IPV4_INTERFACE_ADDRESS = "10.0.0.1"