Compare commits

..

20 Commits

Author SHA1 Message Date
Zane Schepke 4f816fa175 chore: release v4.1.7 2025-11-12 15:12:53 -05:00
Zane Schepke ee4ac4e968 fix: improve qr device support and scanner
#844
closes #1040
2025-11-12 14:13:35 -05:00
Zane Schepke ff53454966 fix: underlying network detection race
#1052
2025-11-12 11:59:20 -05:00
Zane Schepke 22c17ef66b fix: tile update crash when triggerd from non-user profile 2025-11-11 17:43:30 -05:00
Zane Schepke 7a60b90d2b fix: qr scanning scanning can cause crash 2025-11-11 17:29:05 -05:00
Zane Schepke 5fd3f89a59 feat: show tunnel uptime, improve duration display
closes #820
2025-11-11 16:20:08 -05:00
Zane Schepke 9510f43252 fix: global overrides regression, support prompt bug 2025-11-10 20:51:41 -05:00
Zane Schepke 064aa6aa74 fix: error notification bug 2025-11-10 00:56:56 -05:00
Zane Schepke 0c09add0e4 chore: add custom funding link 2025-11-09 12:42:21 -05:00
Zane Schepke fd0fd33f71 chore: release v4.1.6 2025-11-08 20:23:09 -05:00
Zane Schepke aaeb251bbf chore: shorten ur short description 2025-11-08 20:11:48 -05:00
Zane Schepke e563608e49 chore: bump deps 2025-11-08 20:06:09 -05:00
Zane Schepke 584f0386b6 fix: network monitor ignoring valid states for underlying networks 2025-11-08 14:00:14 -05:00
Zane Schepke cf49c34bff ci: simplify publish 2025-11-08 00:43:47 -05:00
Zane Schepke a0f89d40f5 chore: DE short description length too long 2025-11-08 00:17:29 -05:00
Zane Schepke 4da05e23f1 chore: release v4.1.5 2025-11-07 23:58:45 -05:00
Zane Schepke 6749719e21 chore: bump deps, update app description 2025-11-07 23:50:07 -05:00
Zane Schepke 1c160ff5f9 fix: network monitor should ignore default network VPN events
#1038
2025-11-07 21:54:16 -05:00
Zane Schepke 861440b7db fix: disable metered option for Android 9 and lower
closes #1044

#1031
2025-11-07 20:49:32 -05:00
Zane Schepke bdb0d27b53 ci: add aab build workflow 2025-11-05 00:47:46 -05:00
50 changed files with 582 additions and 287 deletions
+1
View File
@@ -1,3 +1,4 @@
ko_fi: zaneschepke
liberapay: zaneschepke
github: zaneschepke
custom: ["https://wgtunnel.com/donate/"]
+130
View File
@@ -0,0 +1,130 @@
name: build-aab
permissions:
contents: read
on:
workflow_dispatch:
inputs:
build_type:
type: choice
description: "Build type"
required: true
default: release
options:
- release
flavor:
type: choice
description: "Product flavor"
required: true
default: google
options:
- google
secrets:
SIGNING_KEY_ALIAS:
required: false
SIGNING_KEY_PASSWORD:
required: false
SIGNING_STORE_PASSWORD:
required: false
SERVICE_ACCOUNT_JSON:
required: false
KEYSTORE:
required: false
workflow_call:
inputs:
build_type:
type: string
description: "Build type"
required: true
default: release
flavor:
type: string
description: "Product flavor"
required: false
default: google
secrets:
SIGNING_KEY_ALIAS:
required: false
SIGNING_KEY_PASSWORD:
required: false
SIGNING_STORE_PASSWORD:
required: false
SERVICE_ACCOUNT_JSON:
required: false
KEYSTORE:
required: false
env:
UPLOAD_DIR_ANDROID: android_artifacts
jobs:
build:
runs-on: ubuntu-latest
env:
SIGNING_KEY_ALIAS: ${{ secrets.SIGNING_KEY_ALIAS }}
SIGNING_KEY_PASSWORD: ${{ secrets.SIGNING_KEY_PASSWORD }}
SIGNING_STORE_PASSWORD: ${{ secrets.SIGNING_STORE_PASSWORD }}
KEY_STORE_FILE: 'android_keystore.jks'
KEY_STORE_LOCATION: ${{ github.workspace }}/app/keystore/
outputs:
UPLOAD_DIR_ANDROID: ${{ env.UPLOAD_DIR_ANDROID }}
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Set up JDK 17
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: '17'
cache: gradle
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Decode Keystore
id: decode_keystore
uses: timheuer/base64-to-file@v1.2
with:
fileName: ${{ env.KEY_STORE_FILE }}
fileDir: ${{ env.KEY_STORE_LOCATION }}
encodedString: ${{ secrets.KEYSTORE }}
- name: Create keystore path env var
if: ${{ inputs.build_type != 'debug' }}
run: |
store_path=${{ env.KEY_STORE_LOCATION }}${{ env.KEY_STORE_FILE }}
echo "KEY_STORE_PATH=$store_path" >> $GITHUB_ENV
- name: Build AAB (noSplits=true)
run: |
flavor=${{ inputs.flavor }}
build_type=${{ inputs.build_type }}
case $build_type in
"release")
./gradlew :app:bundle${flavor^}Release \
-PnoSplits=true \
--info
;;
esac
- name: Get release AAB path
id: aab-path
run: |
AAB_PATH=$(find app/build/outputs/bundle -iname "*google*release*.aab" -type f | head -1)
if [ -z "$AAB_PATH" ]; then
echo "Error: AAB not found!" >&2
exit 1
fi
echo "Found AAB: $AAB_PATH"
echo "path=$AAB_PATH" >> $GITHUB_OUTPUT
- name: Upload AAB Artifact
uses: actions/upload-artifact@v5
with:
name: google-play-aab
path: ${{ steps.aab-path.outputs.path }}
retention-days: 7
if-no-files-found: error
+25 -15
View File
@@ -32,14 +32,6 @@ on:
description: "Tag name for release"
required: false
default: 1.1.1
flavor:
type: choice
description: "Product flavor"
required: true
default: standalone
options:
- fdroid
- standalone
workflow_call:
inputs:
flavor:
@@ -51,7 +43,11 @@ on:
jobs:
build-fdroid:
if: ${{ github.event_name == 'push' || inputs.release_type == 'release' || inputs.flavor == 'fdroid' }}
if: >-
${{
github.event_name == 'push' ||
inputs.release_type != 'none'
}}
uses: ./.github/workflows/build.yml
secrets: inherit
with:
@@ -59,16 +55,26 @@ jobs:
flavor: fdroid
build-standalone:
if: ${{ github.event_name == 'push' || inputs.release_type == 'release' || inputs.release_type == 'debug' || inputs.flavor == 'standalone' }}
if: >-
${{
github.event_name == 'push' ||
inputs.release_type != 'none'
}}
uses: ./.github/workflows/build.yml
secrets: inherit
with:
build_type: ${{ github.event_name == 'push' && 'release' || inputs.release_type }}
flavor: standalone
publish:
publish-github:
if: >-
${{
github.event_name == 'push' ||
inputs.release_type != 'none'
}}
needs:
- build-standalone
- build-fdroid
- build-standalone
name: publish-github
runs-on: ubuntu-latest
steps:
@@ -118,7 +124,7 @@ jobs:
- name: Set version release notes
if: ${{ github.event_name == 'push' || inputs.release_type == 'release' }}
run: |
VERSION_CODE=$(grep "const val VERSION_CODE" buildSrc/src/main/kotlin/Constants.kt | awk '{print $5}')
VERSION_CODE=$(sed -nE 's/.*const val VERSION_CODE[[:space:]]*=[[:space:]]*([0-9]+).*/\1/p' buildSrc/src/main/kotlin/Constants.kt)
RELEASE_NOTES="$(cat ${{ github.workspace }}/fastlane/metadata/android/en-US/changelogs/${VERSION_CODE}.txt || echo "No changelog found for ${VERSION_CODE}")"
echo "RELEASE_NOTES<<EOF" >> $GITHUB_ENV
echo "$RELEASE_NOTES" >> $GITHUB_ENV
@@ -166,9 +172,13 @@ jobs:
publish-fdroid-public:
runs-on: ubuntu-latest
if: >-
${{
github.event_name == 'push' ||
inputs.release_type != 'none'
}}
needs:
- build-fdroid
if: ${{ github.event_name == 'push' || inputs.release_type == 'release' }}
- publish-github
steps:
- name: Dispatch update for fdroid repo
uses: peter-evans/repository-dispatch@v4
+2
View File
@@ -136,6 +136,8 @@ android {
licensee {
allowedLicenses().forEach { allow(it) }
allowedLicenseUrls().forEach { allowUrl(it) }
// foss, but missing license
ignoreDependencies("com.github.T8RIN.QuickieExtended")
}
android.applicationVariants.all {
+5 -5
View File
@@ -59,10 +59,7 @@
android:supportsRtl="true"
android:theme="@style/Theme.App.Start"
tools:targetApi="tiramisu">
<activity
android:name="com.journeyapps.barcodescanner.CaptureActivity"
android:screenOrientation="portrait"
tools:replace="screenOrientation" />
<activity
android:name=".MainActivity"
android:exported="true"
@@ -198,7 +195,10 @@
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.QUICKBOOT_POWERON" />
<action android:name="com.htc.intent.action.QUICKBOOT_POWERON" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
<receiver
@@ -279,7 +279,7 @@ class MainActivity : AppCompatActivity() {
append(context.getString(R.string.donation_prompt_suffix))
}
LaunchedEffect(uiState.shouldShowDonationSnackbar) {
LaunchedEffect(Unit) {
if (
uiState.shouldShowDonationSnackbar && !uiState.settings.alreadyDonated
) {
@@ -34,6 +34,7 @@ class RestartReceiver : BroadcastReceiver() {
tunnelManager.handleReboot()
}
Intent.ACTION_MY_PACKAGE_REPLACED -> {
Timber.i("Restoring state on package upgrade")
tunnelManager.handleRestore()
logReader.deleteAndClearLogs()
appStateRepository.setShouldShowDonationSnackbar(true)
@@ -32,7 +32,7 @@ constructor(
description =
StringValue.StringResource(
R.string.tunnel_error_template,
error.toStringValue(),
error.stringRes,
),
groupKey = NotificationManager.VPN_GROUP_KEY,
)
@@ -63,6 +63,7 @@ class AutoTunnelService : LifecycleService() {
private var autoTunnelJob: Job? = null
private var permissionsJob: Job? = null
private var autoTunnelFailoverJob: Job? = null
class LocalBinder(service: AutoTunnelService) : Binder() {
private val serviceRef = WeakReference(service)
@@ -212,6 +213,7 @@ class AutoTunnelService : LifecycleService() {
handleAutoTunnelEvent(autoTunnelStateFlow.value.determineAutoTunnelEvent(change))
// re-evaluate network state after a short duration to prevent missed state changes
reevaluationJob = launch {
val snapshotNetwork = autoTunnelStateFlow.value.networkState
delay(REEVALUATE_CHECK_DELAY)
@@ -234,7 +236,7 @@ class AutoTunnelService : LifecycleService() {
return combine(
settingsRepository.flow.map { it.appMode }.distinctUntilChanged(),
autoTunnelRepository.get().flow,
tunnelsRepository.flow.map { tunnels ->
tunnelsRepository.userTunnelsFlow.map { tunnels ->
// isActive is ignored for equality checks so user can manually toggle off
// tunnel with auto-tunnel
tunnels.map { it.copy(isActive = false) }
@@ -358,6 +360,7 @@ class AutoTunnelService : LifecycleService() {
}
}
// restart network flow on debounce changes
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
private val debouncedConnectivityStateFlow: Flow<ConnectivityState> by lazy {
autoTunnelRepository
@@ -31,7 +31,7 @@ fun MutableStateFlow<Map<TunnelConfig, TunnelState>>.exists(id: Int): Boolean {
}
fun MutableStateFlow<Map<TunnelConfig, TunnelState>>.isUp(id: Int): Boolean {
return this.value.any { it.key.id == id && it.value.status == TunnelStatus.Up }
return this.value.any { it.key.id == id && it.value.status is TunnelStatus.Up }
}
fun MutableStateFlow<Map<TunnelConfig, TunnelState>>.isStarting(id: Int): Boolean {
@@ -322,11 +322,11 @@ constructor(
withContext(ioDispatcher) {
val settings = settingsRepository.getGeneralSettings()
val autoTunnelSettings = autoTunnelSettingsRepository.getAutoTunnelSettings()
val tunnels = tunnelsRepository.getAll()
val tunnels = tunnelsRepository.userTunnelsFlow.firstOrNull()
if (autoTunnelSettings.isAutoTunnelEnabled)
return@withContext restoreAutoTunnel(autoTunnelSettings)
if (settings.appMode == AppMode.LOCK_DOWN) handleLockDownModeInit()
if (tunnels.any { it.isActive }) {
if (tunnels?.any { it.isActive } == true) {
if (settings.appMode == AppMode.VPN && !serviceManager.hasVpnPermission())
return@withContext localErrorEvents.emit(null to NotAuthorized())
when (settings.appMode) {
@@ -350,7 +350,7 @@ constructor(
withContext(ioDispatcher) {
val settings = settingsRepository.getGeneralSettings()
val autoTunnelSettings = autoTunnelSettingsRepository.getAutoTunnelSettings()
val defaultTunnel = tunnelsRepository.getStartTunnel()
val defaultTunnel = tunnelsRepository.getDefaultTunnel()
if (autoTunnelSettings.startOnBoot)
return@withContext restoreAutoTunnel(autoTunnelSettings)
if (settings.isRestoreOnBootEnabled) {
@@ -247,7 +247,7 @@ constructor(
}
// Wait for the tunnel to be fully active
tunStateFlow.filter { state -> state?.status == TunnelStatus.Up }.first()
tunStateFlow.filter { state -> state?.status is TunnelStatus.Up }.first()
// small delay to make sure tunnel is fully up before we actively monitor
delay(3_000L)
@@ -90,7 +90,7 @@ constructor(
} catch (e: BackendException) {
throw e.toBackendCoreException()
// TODO this should be mapped to BackendException in the lib
} catch (e: IOException) {
} catch (_: IOException) {
throw VpnUnauthorized()
}
}
@@ -50,23 +50,27 @@ interface TunnelConfigDao {
@Query(
"""
SELECT * FROM tunnel_config
ORDER BY
CASE WHEN is_primary_tunnel = 1 THEN 0 ELSE 1 END,
position ASC
LIMIT 1"""
SELECT * FROM tunnel_config
WHERE name != '${TunnelConfig.GLOBAL_CONFIG_NAME}'
ORDER BY
CASE WHEN is_primary_tunnel = 1 THEN 0 ELSE 1 END,
position ASC
LIMIT 1
"""
)
suspend fun getDefaultTunnel(): TunnelConfig?
@Query(
"""
SELECT * FROM tunnel_config
ORDER BY
CASE WHEN is_Active = 1 THEN 0
WHEN is_primary_tunnel = 1 THEN 1
ELSE 2 END,
position ASC
LIMIT 1"""
SELECT * FROM tunnel_config
WHERE name != '${TunnelConfig.GLOBAL_CONFIG_NAME}'
ORDER BY
CASE WHEN is_Active = 1 THEN 0
WHEN is_primary_tunnel = 1 THEN 1
ELSE 2 END,
position ASC
LIMIT 1
"""
)
suspend fun getStartTunnel(): TunnelConfig?
@@ -55,6 +55,8 @@ class DataStoreAppStateRepository(
pref[DataStoreManager.locationDisclosureShown] ?: false,
isBatteryOptimizationDisableShown =
pref[DataStoreManager.batteryDisableShown] ?: false,
shouldShowDonationSnackbar =
pref[DataStoreManager.shouldShowDonationSnackbar] ?: false,
)
} catch (e: IllegalArgumentException) {
Timber.e(e)
@@ -2,7 +2,7 @@ package com.zaneschepke.wireguardautotunnel.domain.enums
sealed class TunnelStatus {
data object Up : TunnelStatus()
data class Up(val startTime: Long) : TunnelStatus()
data object Down : TunnelStatus()
@@ -15,11 +15,11 @@ sealed class TunnelStatus {
}
fun isUp(): Boolean {
return this == Up
return this is Up
}
fun isUpOrStarting(): Boolean {
return this == Up || this == Starting
return this is Up || this == Starting
}
fun isDownOrStopping(): Boolean {
@@ -12,6 +12,8 @@ data class TunnelState(
) {
fun health(): Health {
if (status !is TunnelStatus.Up) return Health.UNKNOWN
val uptime = uptime()
val now = System.currentTimeMillis()
if (pingStates == null && logHealthState == null && statistics == null)
@@ -37,13 +39,21 @@ data class TunnelState(
// Stats health if no logs or pings
statistics?.let { stats ->
if (stats.isTunnelStale()) return Health.STALE
if (stats.rx() == 0L) return Health.UNKNOWN
val rx = stats.rx()
if (uptime >= STATS_HEALTH_SUCCESS_TIMEOUT_MS && rx == 0L) return Health.UNHEALTHY
if (rx == 0L) return Health.UNKNOWN
return Health.HEALTHY
}
return Health.UNKNOWN
}
fun uptime(): Long {
val up = status as? TunnelStatus.Up ?: return 0L
if (up.startTime == 0L) return 0L
return System.currentTimeMillis() - up.startTime
}
enum class Health {
UNKNOWN,
UNHEALTHY,
@@ -53,5 +63,6 @@ data class TunnelState(
companion object {
const val LOG_HEALTH_SUCCESS_TIMEOUT_MS = 2 * 60 * 1000L // 2 minutes
const val STATS_HEALTH_SUCCESS_TIMEOUT_MS = 15 * 1000L // 15 sec
}
}
@@ -1,8 +1,6 @@
package com.zaneschepke.wireguardautotunnel.ui.common.snackbar
import android.R.attr.padding
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Favorite
@@ -1,5 +1,6 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.settings.lockdown
import android.os.Build
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
@@ -103,12 +104,14 @@ fun LockdownSettingsScreen(viewModel: LockdownViewModel = hiltViewModel()) {
trailing = { ThemedSwitch(checked = bypassLan, onClick = { bypassLan = it }) },
onClick = { bypassLan = !bypassLan },
)
SurfaceRow(
leading = { Icon(Icons.Outlined.DataUsage, contentDescription = null) },
title = stringResource(R.string.metered_tunnel),
trailing = { ThemedSwitch(checked = metered, onClick = { metered = it }) },
onClick = { metered = !metered },
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
SurfaceRow(
leading = { Icon(Icons.Outlined.DataUsage, contentDescription = null) },
title = stringResource(R.string.metered_tunnel),
trailing = { ThemedSwitch(checked = metered, onClick = { metered = it }) },
onClick = { metered = !metered },
)
}
SurfaceRow(
leading = {
Icon(ImageVector.vectorResource(R.drawable.host), contentDescription = null)
@@ -12,9 +12,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.journeyapps.barcodescanner.ScanContract
import com.journeyapps.barcodescanner.ScanOptions
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.LocalNavController
import com.zaneschepke.wireguardautotunnel.ui.LocalSharedVm
@@ -29,7 +26,10 @@ import com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.components.UrlImpo
import com.zaneschepke.wireguardautotunnel.ui.sideeffect.LocalSideEffect
import com.zaneschepke.wireguardautotunnel.util.FileUtils
import com.zaneschepke.wireguardautotunnel.util.StringValue
import io.github.g00fy2.quickie.QRResult
import io.github.g00fy2.quickie.ScanQRCode
import org.orbitmvi.orbit.compose.collectSideEffect
import timber.log.Timber
@Composable
fun TunnelsScreen() {
@@ -65,14 +65,26 @@ fun TunnelsScreen() {
onData = { data -> viewModel.importFromUri(data) },
)
val scanLauncher =
rememberLauncherForActivityResult(
contract = ScanContract(),
onResult = { result ->
if (result != null && result.contents.isNotEmpty())
viewModel.importFromQr(result.contents)
},
)
val scanQrCodeLauncher =
rememberLauncherForActivityResult(ScanQRCode()) { result ->
when (result) {
is QRResult.QRError -> {
Timber.e(result.exception, "QR Code")
}
QRResult.QRMissingPermission -> {
viewModel.showSnackMessage(
StringValue.StringResource(R.string.camera_permission_required)
)
}
is QRResult.QRSuccess -> {
result.content.rawValue?.let { viewModel.importFromQr(it) }
?: viewModel.showSnackMessage(
StringValue.StringResource(R.string.config_error)
)
}
QRResult.QRUserCanceled -> Unit
}
}
val requestPermissionLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted
@@ -83,9 +95,7 @@ fun TunnelsScreen() {
)
return@rememberLauncherForActivityResult
}
scanLauncher.launch(
ScanOptions().setDesiredBarcodeFormats(ScanOptions.QR_CODE).setBeepEnabled(false)
)
scanQrCodeLauncher.launch(null)
}
if (showDeleteModal) {
@@ -17,11 +17,11 @@ import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.R
@Composable
fun GettingStartedLabel(onClick: (url: String) -> Unit) {
fun GettingStartedLabel(onClick: (url: String) -> Unit, modifier: Modifier = Modifier) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier.padding(top = 100.dp).fillMaxSize(),
modifier = modifier.padding(top = 100.dp).fillMaxSize(),
) {
val url = stringResource(id = R.string.docs_url)
val gettingStarted = buildAnnotatedString {
@@ -59,7 +59,12 @@ fun TunnelList(
flingBehavior = ScrollableDefaults.flingBehavior(),
) {
if (sharedState.tunnels.isEmpty()) {
item { GettingStartedLabel(onClick = { context.openWebUrl(it) }) }
item {
GettingStartedLabel(
onClick = { context.openWebUrl(it) },
modifier = Modifier.animateItem(),
)
}
}
items(sharedState.tunnels, key = { it.id }) { tunnel ->
val tunnelState =
@@ -81,6 +86,7 @@ fun TunnelList(
}
SurfaceRow(
modifier = Modifier.animateItem(),
leading = {
Icon(
Icons.Rounded.Circle,
@@ -19,8 +19,10 @@ import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState
import com.zaneschepke.wireguardautotunnel.ui.common.label.lowercaseLabel
import com.zaneschepke.wireguardautotunnel.util.NumberUtils
import com.zaneschepke.wireguardautotunnel.util.extensions.abbreviateKey
import com.zaneschepke.wireguardautotunnel.util.extensions.localizedDuration
import com.zaneschepke.wireguardautotunnel.util.extensions.millisAgo
import java.util.Locale
import kotlinx.coroutines.delay
@Composable
@@ -33,6 +35,7 @@ fun TunnelStatisticsRow(
val context = LocalContext.current
val textStyle = MaterialTheme.typography.bodySmall
val textColor = MaterialTheme.colorScheme.outline
val locale = remember { Locale.getDefault() }
// needs to be set as peer stats for duplicates return as a single set of stats
val peers by
@@ -65,6 +68,19 @@ fun TunnelStatisticsRow(
verticalArrangement = Arrangement.spacedBy(10.dp),
horizontalAlignment = Alignment.Start,
) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
"uptime: ${tunnelState.uptime().localizedDuration(locale)}",
style = textStyle,
color = textColor,
)
}
}
peers.forEach { peerBase64 ->
key(peerBase64) {
val peerStats = remember(stats, peerBase64) { stats.peerStats(peerBase64) }
@@ -88,11 +104,7 @@ fun TunnelStatisticsRow(
derivedStateOf {
stats.latestHandshakeEpochMillis.let { lastHandshake ->
if (lastHandshake == 0L) null
else
NumberUtils.getSecondsBetween(
lastHandshake,
currentTimeMillis,
)
else lastHandshake.millisAgo().localizedDuration(locale)
}
}
}
@@ -105,9 +117,10 @@ fun TunnelStatisticsRow(
val lastPingedSeconds by
remember(pingState, currentTimeMillis) {
derivedStateOf {
pingState?.lastSuccessfulPingMillis?.let { lastPing ->
NumberUtils.getSecondsBetween(lastPing, currentTimeMillis)
}
pingState
?.lastSuccessfulPingMillis
?.millisAgo()
?.localizedDuration(locale)
}
}
@@ -156,7 +169,7 @@ fun TunnelStatisticsRow(
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
"$handshakeText: ${handshake?.let { lowercaseLabel(stringResource(R.string.sec_ago_template, it.toString())) } ?: neverText}",
"$handshakeText: ${handshake?.let { lowercaseLabel(it) } ?: neverText}",
style = textStyle,
color = textColor,
)
@@ -219,12 +232,7 @@ fun TunnelStatisticsRow(
stringResource(
R.string.ping_success_template,
lastPingedSeconds?.let { sec ->
lowercaseLabel(
stringResource(
R.string.sec_ago_template,
sec.toString(),
)
)
lowercaseLabel(sec)
} ?: neverText,
)
)
@@ -1,5 +1,6 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.settings
import android.os.Build
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
@@ -148,36 +149,38 @@ fun TunnelSettingsScreen(viewModel: TunnelViewModel) {
},
onClick = { viewModel.setIpv4Preferred(!tunnel.isIpv4Preferred) },
)
SurfaceRow(
leading = {
Icon(
Icons.Outlined.DataUsage,
contentDescription = null,
tint =
if (sharedUiState.proxyEnabled) Disabled
else MaterialTheme.colorScheme.onSurface,
)
},
title = stringResource(R.string.metered_tunnel),
enabled = !sharedUiState.proxyEnabled,
description =
if (sharedUiState.proxyEnabled) {
{
DescriptionText(
stringResource(R.string.unavailable_in_mode),
disabled = true,
)
}
} else null,
trailing = {
ThemedSwitch(
checked = tunnel.isMetered,
onClick = { viewModel.setMetered(it) },
enabled = !sharedUiState.proxyEnabled,
)
},
onClick = { viewModel.setMetered(!tunnel.isMetered) },
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
SurfaceRow(
leading = {
Icon(
Icons.Outlined.DataUsage,
contentDescription = null,
tint =
if (sharedUiState.proxyEnabled) Disabled
else MaterialTheme.colorScheme.onSurface,
)
},
title = stringResource(R.string.metered_tunnel),
enabled = !sharedUiState.proxyEnabled,
description =
if (sharedUiState.proxyEnabled) {
{
DescriptionText(
stringResource(R.string.unavailable_in_mode),
disabled = true,
)
}
} else null,
trailing = {
ThemedSwitch(
checked = tunnel.isMetered,
onClick = { viewModel.setMetered(it) },
enabled = !sharedUiState.proxyEnabled,
)
},
onClick = { viewModel.setMetered(!tunnel.isMetered) },
)
}
}
}
}
@@ -2,8 +2,6 @@ package com.zaneschepke.wireguardautotunnel.util
import com.vdurmont.semver4j.Semver
import java.math.BigDecimal
import java.time.Duration
import java.time.Instant
import kotlin.math.pow
import timber.log.Timber
@@ -28,16 +26,6 @@ object NumberUtils {
return (Math.random() * 100000).toInt()
}
fun getSecondsBetween(start: Long, end: Long): Long? {
return if (start != 0L && end != 0L) {
val startInstant = Instant.ofEpochMilli(start)
val endInstant = Instant.ofEpochMilli(end)
return Duration.between(startInstant, endInstant).seconds
} else {
null
}
}
fun compareVersions(newVersion: String, currentVersion: String): Int {
try {
val newSemver = Semver(newVersion, Semver.SemverType.LOOSE)
@@ -186,13 +186,23 @@ fun Context.launchAppSettings() {
}
}
fun Context.requestTunnelTileServiceStateUpdate() {
TileService.requestListeningState(this, ComponentName(this, TunnelControlTile::class.java))
}
fun Context.requestTunnelTileServiceStateUpdate() =
runCatching {
TileService.requestListeningState(
this,
ComponentName(this, TunnelControlTile::class.java),
)
}
.onFailure { Timber.w(it) }
fun Context.requestAutoTunnelTileServiceUpdate() {
TileService.requestListeningState(this, ComponentName(this, AutoTunnelControlTile::class.java))
}
fun Context.requestAutoTunnelTileServiceUpdate() =
runCatching {
TileService.requestListeningState(
this,
ComponentName(this, AutoTunnelControlTile::class.java),
)
}
.onFailure { Timber.w(it) }
fun Context.getAllInternetCapablePackages(): List<PackageInfo> {
val permissions = arrayOf(Manifest.permission.INTERNET)
@@ -77,7 +77,7 @@ fun BackendMode.asAmBackendMode(): Backend.BackendMode {
fun Tunnel.State.asTunnelState(): TunnelStatus {
return when (this) {
Tunnel.State.DOWN -> TunnelStatus.Down
Tunnel.State.UP -> TunnelStatus.Up
Tunnel.State.UP -> TunnelStatus.Up(System.currentTimeMillis())
}
}
@@ -114,6 +114,6 @@ fun org.amnezia.awg.backend.BackendException.toBackendCoreException(): BackendCo
fun com.wireguard.android.backend.Tunnel.State.asTunnelState(): TunnelStatus {
return when (this) {
com.wireguard.android.backend.Tunnel.State.DOWN -> TunnelStatus.Down
com.wireguard.android.backend.Tunnel.State.UP -> TunnelStatus.Up
com.wireguard.android.backend.Tunnel.State.UP -> TunnelStatus.Up(System.currentTimeMillis())
}
}
@@ -1,6 +1,9 @@
package com.zaneschepke.wireguardautotunnel.util.extensions
import android.content.Context
import android.icu.text.MeasureFormat
import android.icu.util.Measure
import android.icu.util.MeasureUnit
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Lock
import androidx.compose.material.icons.outlined.Terminal
@@ -18,6 +21,8 @@ import com.zaneschepke.wireguardautotunnel.ui.theme.AlertRed
import com.zaneschepke.wireguardautotunnel.ui.theme.CoolGray
import com.zaneschepke.wireguardautotunnel.ui.theme.SilverTree
import com.zaneschepke.wireguardautotunnel.ui.theme.Straw
import java.util.Locale
import kotlin.time.Duration.Companion.milliseconds
fun WifiDetectionMethod.asTitleString(context: Context): String {
return when (this) {
@@ -82,3 +87,35 @@ fun TunnelState.Health.asColor(): Color {
TunnelState.Health.STALE -> Straw
}
}
fun Long.localizedDuration(locale: Locale = Locale.getDefault()): String {
require(this >= 0L) { "Duration cannot be negative" }
val duration = this.milliseconds
if (duration < 1000.milliseconds) {
return MeasureFormat.getInstance(locale, MeasureFormat.FormatWidth.SHORT)
.format(Measure(0, MeasureUnit.SECOND))
}
val totalSeconds = duration.inWholeSeconds
val days = totalSeconds / 86_400
val hours = (totalSeconds % 86_400) / 3_600
val minutes = (totalSeconds % 3_600) / 60
val seconds = totalSeconds % 60
val measures = buildList {
if (days > 0) add(Measure(days, MeasureUnit.DAY))
if (hours > 0) add(Measure(hours, MeasureUnit.HOUR))
if (minutes > 0) add(Measure(minutes, MeasureUnit.MINUTE))
if (seconds > 0) add(Measure(seconds, MeasureUnit.SECOND))
}
return MeasureFormat.getInstance(locale, MeasureFormat.FormatWidth.SHORT)
.formatMeasures(*measures.toTypedArray())
}
fun Long.millisAgo(): Long {
return System.currentTimeMillis() - this
}
-1
View File
@@ -302,7 +302,6 @@
<string name="set_custom_ping_target">Vlastní cíl pingu (volitelné)</string>
<string name="tunnel_ping_interval">Interval pingování tunelu</string>
<string name="ping_timeout">Časový limit pingování tunelu</string>
<string name="sec_ago_template">Před %1$s s</string>
<string name="latency_template">Latence: %1$s</string>
<string name="ping_target_template">Cíl pingu: %1$s</string>
<string name="backup_success">Úspěšně zazálohováno. %1$s</string>
-1
View File
@@ -247,7 +247,6 @@
<string name="display_detailed_ping_stats">Detaillierte Ping-Statistiken anzeigen</string>
<string name="reachable_template">Erreichbar: %1$s</string>
<string name="ping_success_template">Letzter erfolgreicher Ping: %1$s</string>
<string name="sec_ago_template">vor %1$s Sek</string>
<string name="latency_template">Latenz: %1$s</string>
<string name="jitter_template">Jitter: %1$s</string>
<string name="packets_sent_template">Versendete Pakete: %1$s</string>
-1
View File
@@ -247,7 +247,6 @@
<string name="display_detailed_ping_stats">Mostrar estadísticas detalladas del ping</string>
<string name="reachable_template">Alcanzable: %1$s</string>
<string name="ping_success_template">Último ping recibido: %1$s</string>
<string name="sec_ago_template">hace %1$s segundos</string>
<string name="latency_template">Latencia: %1$s</string>
<string name="jitter_template">Jitter: %1$s</string>
<string name="packets_sent_template">Paquetes enviados: %1$s</string>
-1
View File
@@ -263,7 +263,6 @@
<string name="pinger_bounce_successful">Tunneli taaskäivitamine pingija poolt õnnestus.</string>
<string name="reachable_template">Leitav: %1$s</string>
<string name="ping_success_template">Viimane õnnestunud ping: %1$s</string>
<string name="sec_ago_template">%1$s sekundi eest</string>
<string name="dns_provider">Nimelahenduse pakkuja</string>
<string name="dns_protocol">Nimelahenduse protokoll</string>
<string name="system">Süsteemne</string>
-1
View File
@@ -252,7 +252,6 @@
<string name="packets_sent_template">Pacchetti inviati: %1$s</string>
<string name="jitter_template">Jitter: %1$s</string>
<string name="latency_template">Latenza: %1$s</string>
<string name="sec_ago_template">%1$s sec fa</string>
<string name="ping_success_template">Ultimo ping riuscito: %1$s</string>
<string name="reachable_template">Raggiungibile: %1$s</string>
<string name="current_template">Corrente: %1$s</string>
-1
View File
@@ -248,7 +248,6 @@
<string name="display_detailed_ping_stats">Wyświetl szczegółowe statystyki pingowania</string>
<string name="reachable_template">Osiągalny: %1$s</string>
<string name="ping_success_template">Ostatni udany ping: %1$s</string>
<string name="sec_ago_template">%1$s sek. temu</string>
<string name="latency_template">Opóźnienie: %1$s</string>
<string name="jitter_template">Jitter: %1$s</string>
<string name="packets_sent_template">Pakiety wysłane: %1$s</string>
-1
View File
@@ -240,7 +240,6 @@
<string name="backup_application">Резервирование данных</string>
<string name="restore_application">Восстановление данных</string>
<string name="tunnel_monitoring">Отслеживание туннеля</string>
<string name="sec_ago_template">%1$s сек.</string>
<string name="latency_template">Задержка: %1$s</string>
<string name="packets_sent_template">Отправлено пакетов: %1$s</string>
<string name="packet_loss_template">Потеряно пакетов: %.2f%%</string>
-1
View File
@@ -247,7 +247,6 @@
<string name="display_detailed_ping_stats">پِنگ کے تفصیلی اعدادوشمار دکھائیں</string>
<string name="reachable_template">قابل رسائی: %1$s</string>
<string name="ping_success_template">آخری کامیاب پِنگ: %1$s</string>
<string name="sec_ago_template">%1$s سیکنڈ پہلے</string>
<string name="latency_template">تاخیر: %1$s</string>
<string name="jitter_template">ہلچل: %1$s</string>
<string name="packets_sent_template">پیکٹ بھیجے گئے: %1$s</string>
@@ -247,7 +247,6 @@
<string name="display_detailed_ping_stats">展示详细的 ping 数据</string>
<string name="reachable_template">可抵达:%1$s</string>
<string name="ping_success_template">上次成功的 ping 操作:%1$s</string>
<string name="sec_ago_template">%1$s 秒前</string>
<string name="latency_template">延迟:%1$s</string>
<string name="jitter_template">抖动:%1$s</string>
<string name="packets_sent_template">已发送数据包:%1$s</string>
@@ -205,7 +205,6 @@
<string name="jitter_template">抖動: %1$s</string>
<string name="warning">警告</string>
<string name="ip_or_hostname">IP 或主機名稱</string>
<string name="sec_ago_template">%1$s 秒前</string>
<string name="restarting_app">正在重啟應用程式以應用變更…</string>
<string name="packets_sent_template">已發送封包: %1$s</string>
<string name="packet_loss_template">丟失封包: %.2f%%</string>
+2 -3
View File
@@ -110,7 +110,7 @@
<string name="root_accepted">Root shell accepted</string>
<string name="show_amnezia_properties">Show Amnezia properties</string>
<string name="never">Never</string>
<string name="handshake">Handshake</string>
<string name="handshake">Last handshake</string>
<string name="logs">Logs</string>
<string name="appearance">Appearance</string>
<string name="notifications">Notifications</string>
@@ -273,7 +273,6 @@
<string name="display_detailed_ping_stats">Display detailed ping stats</string>
<string name="reachable_template">Reachable: %1$s</string>
<string name="ping_success_template">Last successful ping: %1$s</string>
<string name="sec_ago_template">%1$s seconds ago</string>
<string name="latency_template">Latency: %1$s</string>
<string name="jitter_template">Jitter: %1$s</string>
<string name="packets_sent_template">Packets sent: %1$s</string>
@@ -434,7 +433,7 @@
<string name="info">Info</string>
<string name="already_donated">Already donated</string>
<string name="already_donated_description">Disables future donation prompts</string>
<string name="donation_prompt_prefix">Thanks for using WG Tunnel! If you can, please consider</string>
<string name="donation_prompt_prefix">Thanks for using WG Tunnel! If you are able, please consider</string>
<string name="donation_prompt_link">supporting the project</string>
<string name="donation_prompt_suffix">to keep it free and improving.</string>
</resources>
+2 -2
View File
@@ -1,6 +1,6 @@
object Constants {
const val VERSION_NAME = "4.1.4"
const val VERSION_CODE = 40104
const val VERSION_NAME = "4.1.7"
const val VERSION_CODE = 40107
const val TARGET_SDK = 36
const val MIN_SDK = 26
const val APP_ID = "com.zaneschepke.wireguardautotunnel"
+1 -1
View File
@@ -20,7 +20,7 @@ fun allowedLicenses(): List<String> {
}
fun allowedLicenseUrls(): List<String> {
return listOf("https://github.com/journeyapps/zxing-android-embedded/blob/master/COPYING",
return listOf("https://jsoup.org/license", "http://opensource.org/licenses/bsd-license.php", "https://github.com/journeyapps/zxing-android-embedded/blob/master/COPYING",
"https://github.com/RikkaApps/Shizuku-API/blob/master/LICENSE", "https://github.com/rafi0101/Android-Room-Database-Backup/blob/master/LICENSE",
"https://opensource.org/license/mit")
}
@@ -1 +1 @@
Ein WireGuard- und AmneziaWG-VPN-Client mit automatischem Tunneling, Sperrung und Proxying.
Ein WireGuard & AmneziaWG VPN-Client mit Auto-Tunnel, Sperre & Proxy.
@@ -0,0 +1,3 @@
What's new:
- Fixes crash on older Android versions where metered tunnel override is unavailable
- Fixes auto-tunnel network monitor incorrectly detecting VPN changes
@@ -0,0 +1,3 @@
What's new:
- Auto-tunnel regression bugfix
- Resource usage bugfix for kill switch mode
@@ -0,0 +1,6 @@
What's new:
- Improved QR scanning and device support
- Display tunnel uptime
- Fixes quick tile crash bug when running app in multiple profiles
- Fixes global overrides regression causing unexpected tunnel start errors
- Fixes network detection race while VPN is active
@@ -1,15 +1,13 @@
- Tunnel Import Methods: Easily add tunnels using .conf files, ZIP archives, manual entry, or QR code scanning.
- Auto-Tunneling: Automatically activate tunnels based on Wi-Fi SSID, Ethernet connections, or mobile data networks.
- Split Tunneling: Flexible support for routing specific apps or traffic through the VPN.
- WireGuard Modes: Full compatibility with WireGuard in both kernel and userspace implementations.
- AmneziaWG Integration: Userspace mode for AmneziaWG, providing robust censorship evasion.
- Always-On VPN: Ensures continuous protection with Android's Always-On VPN feature.
- Quick Controls: Quick Settings tile and home screen shortcuts for easy VPN toggling.
- Automation Support: Intent-based automation for controlling tunnels.
- Auto-Restore: Seamlessly restores auto-tunneling and active tunnels after device restarts or app updates.
- Proxying Options: Built-in HTTP and SOCKS5 proxy support within tunnels.
- Lockdown Mode: Custom kill switch for maximum leak prevention and security.
- Dynamic DNS Handling: Detects and updates DNS changes without tunnel restarts.
- Monitoring Tools: Advanced tunnel monitoring features for tunnel performance monitoring.
- Android TV Support: Android TV support for secure streaming and browsing.
- Advanced DNS: DNS over HTTPS support for tunnel endpoint resolutions.
WG Tunnel is a WireGuard VPN client that strikes the balance between simplicity and robustness, making it the ideal client for casual and power users alike.
Whether you simply want to automate when you're connected to your VPN or you're a power user with advanced privacy use cases, WG Tunnel has you covered.
- **Auto-Tunneling:** Automatically activate tunnels based on Wi-Fi SSID, Ethernet connections, or mobile data networks.
- **Split Tunneling:** Flexible support for routing specific apps or traffic through the VPN.
- **App Modes:** Support for multiple tunnel modes, including standard VPN, kernel, lockdown (custom kill switch), and proxy modes.
- **AmneziaWG Integration:** Full support for AmneziaWG, providing robust censorship evasion.
- **Proxying Options:** Built-in HTTP and SOCKS5 proxy support allowing third-party apps to tunnel their traffic.
- **Quick Controls:** Quick Settings tile and home screen shortcuts for easy toggling actions.
- **Automation Support:** Intent-based automation for controlling tunnels and auto-tunneling.
- **Dynamic DNS Handling:** Detects and updates DNS changes without tunnel restarts.
- **Monitoring Tools:** Advanced tunnel monitoring features for tunnel performance monitoring.
- **Android TV Support:** Android TV support for nearly all app features.
@@ -1 +1 @@
آٹو ٹنلنگ، لاک ڈاؤن اور پراکسینگ کے ساتھ ایک وائرس گارڈ اور یمنیزیا ویپیاین کلائنٹ۔
وائرس گارڈ اور یمنیزیا وی پی این کلائنٹ۔ آٹو ٹنلنگ، لاک ڈاؤن، پراکسی۔
+13 -13
View File
@@ -1,28 +1,28 @@
[versions]
accompanist = "0.37.3"
activityCompose = "1.11.0"
amneziawgAndroid = "2.2.1"
amneziawgAndroid = "2.2.3"
androidx-junit = "1.3.0"
icmp4a = "1.0.0"
ipaddress = "5.5.1"
leakcanaryAndroid = "3.0-alpha-8"
orbitCompose = "10.0.0"
orbitCompose = "11.0.0"
roomdatabasebackup = "1.1.0"
shizuku = "13.1.5"
appcompat = "1.7.1"
coreKtx = "1.17.0"
datastorePreferences = "1.2.0-beta01"
datastorePreferences = "1.2.0-rc01"
desugar_jdk_libs = "2.1.5"
espressoCore = "3.7.0"
hiltAndroid = "2.57.2"
hiltCompiler = "1.3.0"
hiltNavigationCompose = "1.3.0"
navigation3 = "1.0.0-beta01"
navigation3 = "1.0.0-rc01"
junit = "4.13.2"
kotlinx-serialization-json = "1.9.0"
ktorClientCore = "3.3.1"
ktorClientCore = "3.3.2"
lifecycle-runtime-compose = "2.9.4"
material3 = "1.5.0-alpha07"
material3 = "1.5.0-alpha08"
pinLockCompose = "1.0.5"
qrose = "1.0.1"
roomVersion = "2.8.3"
@@ -32,20 +32,20 @@ timber = "5.0.1"
tunnel = "1.4.0"
androidGradlePlugin = "8.12.3"
kotlin = "2.2.21"
ksp = "2.3.0"
composeBom = "2025.10.01"
ksp = "2.3.1"
composeBom = "2025.11.00"
compose = "1.9.4"
icons = "1.7.8"
workRuntimeKtxVersion = "2.11.0"
zxingAndroidEmbedded = "4.3.0"
coreSplashscreen = "1.0.1"
quickieFoss = "1.15.7"
coreSplashscreen = "1.2.0"
gradlePlugins-grgit = "5.3.3"
reorderable = "3.0.0"
material = "1.13.0"
storage = "1.6.0"
ktfmt = "0.25.0"
licensee = "1.14.1"
lifecycleViewmodelNavigation3 = "2.10.0-beta01"
lifecycleViewmodelNavigation3 = "2.10.0-rc01"
[bundles]
# Core AndroidX foundations
@@ -94,7 +94,7 @@ wireguard-tunnel = ["tunnel", "amneziawg-android"]
shizuku = ["shizuku-api", "shizuku-provider"]
# UI utilities
ui-utilities = ["pin-lock-compose", "qrose", "reorderable", "zxing-android-embedded", "androidx-core-splashscreen"]
ui-utilities = ["pin-lock-compose", "qrose", "reorderable", "quickie-foss", "androidx-core-splashscreen"]
# Misc utilities
misc-utilities = ["semver4j", "icmp4a", "slf4j-android", "timber"]
@@ -184,7 +184,7 @@ qrose = { module = "io.github.alexzhirkevich:qrose", version.ref = "qrose" }
semver4j = { module = "com.vdurmont:semver4j", version.ref = "semver4j" }
slf4j-android = { module = "org.slf4j:slf4j-android", version.ref = "slf4jAndroid" }
timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" }
zxing-android-embedded = { module = "com.journeyapps:zxing-android-embedded", version.ref = "zxingAndroidEmbedded" }
quickie-foss = { module = "com.github.T8RIN.QuickieExtended:quickie-foss", version.ref = "quickieFoss" }
desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" }
reorderable = { module = "sh.calvin.reorderable:reorderable", version.ref = "reorderable" }
@@ -15,6 +15,8 @@ import com.wireguard.android.util.RootShell
import com.zaneschepke.networkmonitor.AndroidNetworkMonitor.WifiDetectionMethod.*
import com.zaneschepke.networkmonitor.shizuku.ShizukuShell
import com.zaneschepke.networkmonitor.util.*
import kotlin.concurrent.atomics.AtomicReference
import kotlin.concurrent.atomics.ExperimentalAtomicApi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.awaitClose
@@ -67,11 +69,17 @@ class AndroidNetworkMonitor(
private var permissionReceiver: BroadcastReceiver? = null
private var locationServicesReceiver: BroadcastReceiver? = null
private var airplaneReceiver: BroadcastReceiver? = null
private var defaultNetworkCallback: ConnectivityManager.NetworkCallback? = null
private var wifiCallback: ConnectivityManager.NetworkCallback? = null
private var cellularCallback: ConnectivityManager.NetworkCallback? = null
private var ethernetCallback: ConnectivityManager.NetworkCallback? = null
private val airplaneModeState = MutableStateFlow(appContext.isAirplaneModeOn())
private val airplaneModeFlow: Flow<Boolean> = airplaneModeState.asStateFlow()
// recreate defaultNetwork flow on permission/detection method changes to get newly available
// network info
@OptIn(ExperimentalCoroutinesApi::class)
private val defaultNetworkFlow: Flow<TransportEvent> =
combine(configurationListener.detectionMethod, permissionsChangedFlow) { detectionMethod, _
@@ -87,6 +95,8 @@ class AndroidNetworkMonitor(
object :
ConnectivityManager.NetworkCallback(FLAG_INCLUDE_LOCATION_INFO) {
override fun onAvailable(network: Network) {
// ignore onAvailable has it doesn't contain detailed network
// information in capabilities
Timber.d("Default onAvailable: $network")
}
@@ -137,6 +147,8 @@ class AndroidNetworkMonitor(
}
}
// recreate Wi-Fi flow on permission/detection method changes to get newly available network
// info
@OptIn(ExperimentalCoroutinesApi::class)
private val wifiFlow: Flow<TransportEvent> =
combine(configurationListener.detectionMethod, permissionsChangedFlow) { detectionMethod, _
@@ -148,7 +160,11 @@ class AndroidNetworkMonitor(
private fun createWifiNetworkCallbackFlow(
detectionMethod: WifiDetectionMethod
): Flow<TransportEvent> = callbackFlow {
val onAvailable: (Network) -> Unit = { network -> Timber.d("WiFi onAvailable: $network") }
val onAvailable: (Network) -> Unit = { network ->
// ignore onAvailable has it doesn't contain detailed network information in
// capabilities
Timber.d("WiFi onAvailable: $network")
}
val onLost: (Network) -> Unit = { network ->
Timber.d("WiFi onLost: $network")
trySend(TransportEvent.Lost(network))
@@ -185,7 +201,14 @@ class AndroidNetworkMonitor(
}
val request =
NetworkRequest.Builder().addTransportType(NetworkCapabilities.TRANSPORT_WIFI).build()
NetworkRequest.Builder()
.apply {
addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
// remove so we can detect underlying network info on VPN
removeCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
}
.build()
connectivityManager?.registerNetworkCallback(request, wifiCallback!!)
awaitClose {
@@ -219,8 +242,13 @@ class AndroidNetworkMonitor(
val request =
NetworkRequest.Builder()
.addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR)
.apply {
addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR)
// remove so we can detect underlying network info on VPN
removeCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
}
.build()
connectivityManager?.registerNetworkCallback(request, cellularCallback!!)
trySend(TransportEvent.Unknown)
@@ -256,8 +284,13 @@ class AndroidNetworkMonitor(
val request =
NetworkRequest.Builder()
.addTransportType(NetworkCapabilities.TRANSPORT_ETHERNET)
.apply {
addTransportType(NetworkCapabilities.TRANSPORT_ETHERNET)
// remove so we can detect underlying network info on VPN
removeCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
}
.build()
connectivityManager?.registerNetworkCallback(request, ethernetCallback!!)
trySend(TransportEvent.Unknown)
@@ -268,29 +301,6 @@ class AndroidNetworkMonitor(
}
}
private val airplaneModeFlow: Flow<Boolean> = callbackFlow {
val receiver =
object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == Intent.ACTION_AIRPLANE_MODE_CHANGED) {
Timber.d("Received airplane mode changed broadcast")
trySend(appContext.isAirplaneModeOn())
}
}
}
val filter = IntentFilter(Intent.ACTION_AIRPLANE_MODE_CHANGED)
appContext.registerReceiver(receiver, filter)
// initial state
trySend(appContext.isAirplaneModeOn())
awaitClose {
runCatching { appContext.unregisterReceiver(receiver) }
.onFailure { Timber.e(it, "Error unregistering airplane mode receiver") }
}
}
private val wifiStateFlow: Flow<NetworkCapabilities?> =
wifiFlow
.map { event ->
@@ -306,14 +316,7 @@ class AndroidNetworkMonitor(
cellularFlow
.map { event ->
when (event) {
is TransportEvent.CapabilitiesChanged ->
if (
event.networkCapabilities.hasCapability(
NetworkCapabilities.NET_CAPABILITY_INTERNET
)
)
event.networkCapabilities
else null
is TransportEvent.CapabilitiesChanged -> event.networkCapabilities
is TransportEvent.Lost -> null
else -> null
}
@@ -324,14 +327,7 @@ class AndroidNetworkMonitor(
ethernetFlow
.map { event ->
when (event) {
is TransportEvent.CapabilitiesChanged ->
if (
event.networkCapabilities.hasCapability(
NetworkCapabilities.NET_CAPABILITY_INTERNET
)
)
event.networkCapabilities
else null
is TransportEvent.CapabilitiesChanged -> event.networkCapabilities
is TransportEvent.Lost -> null
else -> null
}
@@ -369,13 +365,16 @@ class AndroidNetworkMonitor(
.also { Timber.d("Current SSID via ${method.name}: $it") }
}
// default network events don't contain detailed capability information of underlying networks,
// so we need to track separately
private data class NetworkData(
val defaultEvent: TransportEvent,
val wifiCaps: NetworkCapabilities?,
val defaultNetworkEvent: TransportEvent,
val wifiCapabilities: NetworkCapabilities?,
val cellularCaps: NetworkCapabilities?,
val ethernetCaps: NetworkCapabilities?,
)
// combine our network flows to keep sync
private val networkFlows: Flow<NetworkData> =
combine(defaultNetworkFlow, wifiStateFlow, cellularStateFlow, ethernetStateFlow) {
defaultEvent,
@@ -385,13 +384,18 @@ class AndroidNetworkMonitor(
NetworkData(defaultEvent, wifiCaps, cellularCaps, ethernetCaps)
}
// tracking to prevent races that occur when VPN is first activated
private val lastKnownActiveNetwork = MutableStateFlow<ActiveNetwork>(ActiveNetwork.Disconnected)
@OptIn(ExperimentalAtomicApi::class) private val vpnActiveState = AtomicReference(false)
@OptIn(ExperimentalCoroutinesApi::class, ExperimentalAtomicApi::class)
override val connectivityStateFlow: SharedFlow<ConnectivityState> =
combine(networkFlows, airplaneModeFlow, configurationListener.detectionMethod) {
networkData,
isAirplaneOn,
detectionMethod ->
val defaultEvent = networkData.defaultEvent
val wifiCaps = networkData.wifiCaps
val defaultEvent = networkData.defaultNetworkEvent
val wifiCaps = networkData.wifiCapabilities
val cellularCaps = networkData.cellularCaps
val ethernetCaps = networkData.ethernetCaps
@@ -405,68 +409,104 @@ class AndroidNetworkMonitor(
)
}
// determine default network capabilities
val defaultCaps =
when (defaultEvent) {
is TransportEvent.CapabilitiesChanged -> defaultEvent.networkCapabilities
else ->
connectivityManager?.getNetworkCapabilities(
connectivityManager.activeNetwork
)
connectivityManager?.activeNetwork?.let {
connectivityManager.getNetworkCapabilities(it)
}
}
?: return@combine ConnectivityState(
ActiveNetwork.Disconnected,
permissions.locationServicesEnabled,
permissions.locationPermissionGranted,
activeNetwork = ActiveNetwork.Disconnected,
locationPermissionsGranted = permissions.locationPermissionGranted,
locationServicesEnabled = permissions.locationServicesEnabled,
vpnState = VpnState.Inactive,
)
val isValidated =
defaultCaps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
val hasInternet =
defaultCaps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
if (!isValidated || !hasInternet) {
return@combine ConnectivityState(
ActiveNetwork.Disconnected,
permissions.locationServicesEnabled,
permissions.locationPermissionGranted,
val vpnPreviouslyActive =
vpnActiveState.exchange(
defaultCaps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)
)
} else {
val activeNetwork: ActiveNetwork =
if (defaultCaps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) {
// Ignore VPN, determine underlying
when {
wifiCaps != null -> {
val ssid = getSsidByDetectionMethod(detectionMethod, wifiCaps)
ActiveNetwork.Wifi(ssid, wifiManager?.getCurrentSecurityType())
val isVpnActive = vpnActiveState.load()
// determine vpn state
val vpnState: VpnState =
if (!isVpnActive) {
VpnState.Inactive
} else {
VpnState.Active(
hasInternet =
defaultCaps.hasCapability(
NetworkCapabilities.NET_CAPABILITY_VALIDATED
)
)
}
val activeNetwork: ActiveNetwork =
run {
if (!isVpnActive) {
when {
defaultCaps.hasTransport(
NetworkCapabilities.TRANSPORT_ETHERNET
) -> ActiveNetwork.Ethernet
defaultCaps.hasTransport(
NetworkCapabilities.TRANSPORT_WIFI
) -> {
val ssid =
getSsidByDetectionMethod(detectionMethod, defaultCaps)
ActiveNetwork.Wifi(
ssid,
wifiManager?.getCurrentSecurityType(),
)
}
defaultCaps.hasTransport(
NetworkCapabilities.TRANSPORT_CELLULAR
) && !isAirplaneOn -> ActiveNetwork.Cellular
else -> ActiveNetwork.Disconnected
}
ethernetCaps != null -> ActiveNetwork.Ethernet
cellularCaps != null && !isAirplaneOn -> ActiveNetwork.Cellular
else -> ActiveNetwork.Disconnected
}
} else {
when {
defaultCaps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> {
val ssid =
getSsidByDetectionMethod(detectionMethod, defaultCaps)
ActiveNetwork.Wifi(ssid, wifiManager?.getCurrentSecurityType())
}
defaultCaps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) &&
!isAirplaneOn -> ActiveNetwork.Cellular
defaultCaps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) ->
ActiveNetwork.Ethernet
else -> ActiveNetwork.Disconnected
} else {
val fromCaps =
when {
ethernetCaps != null -> ActiveNetwork.Ethernet
wifiCaps != null -> {
val ssid =
getSsidByDetectionMethod(detectionMethod, wifiCaps)
ActiveNetwork.Wifi(
ssid,
wifiManager?.getCurrentSecurityType(),
)
}
cellularCaps != null && !isAirplaneOn ->
ActiveNetwork.Cellular
else -> null
}
fromCaps
?: if (!vpnPreviouslyActive) {
lastKnownActiveNetwork.value
} else {
ActiveNetwork.Disconnected
}
}
}
ConnectivityState(
activeNetwork,
permissions.locationServicesEnabled,
permissions.locationPermissionGranted,
)
}
.also { network -> lastKnownActiveNetwork.value = network }
ConnectivityState(
activeNetwork = activeNetwork,
locationPermissionsGranted = permissions.locationPermissionGranted,
locationServicesEnabled = permissions.locationServicesEnabled,
vpnState = vpnState,
)
}
.distinctUntilChanged()
.shareIn(applicationScope, SharingStarted.Eagerly, replay = 1)
// utility to send local broadcast to trigger a recheck of location permissions onResume,
// especially for getting SSID
// that we did not have permission to read before, will trigger a recreation of the Wi-Fi flows
// if permission was changed
override fun checkPermissionsAndUpdateState() {
val action = actionPermissionCheck
val intent = Intent(action)
@@ -475,12 +515,18 @@ class AndroidNetworkMonitor(
}
init {
val receiverFlags =
val exportedFlags =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
Context.RECEIVER_EXPORTED
} else {
0
}
val localFlags =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
Context.RECEIVER_NOT_EXPORTED
} else {
0
}
permissionReceiver =
object : BroadcastReceiver() {
@@ -501,10 +547,11 @@ class AndroidNetworkMonitor(
}
}
}
permissionReceiver?.let {
appContext.registerReceiver(it, IntentFilter(actionPermissionCheck), receiverFlags)
}
appContext.registerReceiver(
permissionReceiver,
IntentFilter(actionPermissionCheck),
localFlags,
)
locationServicesReceiver =
object : BroadcastReceiver() {
@@ -525,19 +572,34 @@ class AndroidNetworkMonitor(
}
}
}
locationServicesReceiver?.let {
appContext.registerReceiver(
locationServicesReceiver,
IntentFilter(LOCATION_SERVICES_FILTER),
receiverFlags,
)
}
appContext.registerReceiver(
locationServicesReceiver,
IntentFilter(LOCATION_SERVICES_FILTER),
exportedFlags,
)
airplaneReceiver =
object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == Intent.ACTION_AIRPLANE_MODE_CHANGED) {
Timber.d("Received airplane mode changed broadcast")
airplaneModeState.update { appContext.isAirplaneModeOn() }
}
}
}
appContext.registerReceiver(
airplaneReceiver,
IntentFilter(Intent.ACTION_AIRPLANE_MODE_CHANGED),
exportedFlags,
)
airplaneModeState.update { appContext.isAirplaneModeOn() }
}
override fun destroy() {
runCatching {
permissionReceiver?.let { appContext.unregisterReceiver(it) }
locationServicesReceiver?.let { appContext.unregisterReceiver(it) }
airplaneReceiver?.let { appContext.unregisterReceiver(it) }
defaultNetworkCallback?.let { connectivityManager?.unregisterNetworkCallback(it) }
wifiCallback?.let { connectivityManager?.unregisterNetworkCallback(it) }
@@ -6,6 +6,7 @@ data class ConnectivityState(
val activeNetwork: ActiveNetwork,
val locationPermissionsGranted: Boolean,
val locationServicesEnabled: Boolean,
val vpnState: VpnState,
) {
fun hasInternet(): Boolean = activeNetwork !is ActiveNetwork.Disconnected
@@ -38,3 +39,9 @@ sealed class ActiveNetwork {
data object Ethernet : ActiveNetwork()
}
sealed interface VpnState {
object Inactive : VpnState
data class Active(val hasInternet: Boolean) : VpnState
}