Compare commits

..

26 Commits

Author SHA1 Message Date
Zane Schepke 85a27f48a2 chore: release v4.1.8 2025-11-14 14:11:22 -05:00
Zane Schepke 1f978cdf96 fix: rapid network changes race in network monitoring 2025-11-14 14:06:59 -05:00
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
Zane Schepke 9b3283a2b1 chore: release 4.1.4 2025-11-04 20:20:41 -05:00
Zane Schepke 78def29980 fix: keep network monitor for full app lifecyle 2025-11-04 20:16:23 -05:00
Zane Schepke e83bbdf23a fix: tunnel service bind race 2025-11-04 19:59:30 -05:00
Zane Schepke 4beeb4e01e fix: network monitoring bug 2025-11-04 17:48:40 -05:00
55 changed files with 862 additions and 500 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,
)
@@ -21,6 +21,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import timber.log.Timber
class ServiceManager
@@ -137,17 +138,25 @@ constructor(
suspend fun startTunnelService(appMode: AppMode) =
tunnelMutex.withLock {
if (_tunnelService.value != null) return@withLock
val serviceClass =
when (appMode) {
AppMode.VPN,
AppMode.LOCK_DOWN -> VpnForegroundService::class.java
AppMode.KERNEL,
AppMode.PROXY -> TunnelForegroundService::class.java
}
val intent = Intent(context, serviceClass)
context.startForegroundService(intent)
context.bindService(intent, tunnelServiceConnection, Context.BIND_AUTO_CREATE)
if (_tunnelService.value != null) {
Timber.d("Service already exists, waiting for disconnect")
withTimeoutOrNull(2000L) { _tunnelService.first { it == null } }
?: Timber.w("Timeout waiting for existing service to disconnect")
}
if (_tunnelService.value == null) {
val serviceClass =
when (appMode) {
AppMode.VPN,
AppMode.LOCK_DOWN -> VpnForegroundService::class.java
AppMode.KERNEL,
AppMode.PROXY -> TunnelForegroundService::class.java
}
val intent = Intent(context, serviceClass)
context.startForegroundService(intent)
context.bindService(intent, tunnelServiceConnection, Context.BIND_AUTO_CREATE)
} else {
Timber.e("Service still not null after timeout")
}
}
suspend fun stopTunnelService() =
@@ -157,7 +166,7 @@ constructor(
try {
context.unbindService(tunnelServiceConnection)
} catch (e: Exception) {
Timber.e(e, "Failed to stop Tunnel Service")
Timber.e(e, "Failed to unbind Tunnel Service")
}
}
}
@@ -61,6 +61,10 @@ class AutoTunnelService : LifecycleService() {
private val autoTunnelStateFlow = MutableStateFlow(defaultState)
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)
@@ -89,8 +93,10 @@ class AutoTunnelService : LifecycleService() {
fun start() {
launchWatcherNotification()
startAutoTunnelStateJob()
startLocationPermissionsNotificationJob()
autoTunnelJob?.cancel()
autoTunnelJob = startAutoTunnelStateJob()
permissionsJob?.cancel()
permissionsJob = startLocationPermissionsNotificationJob()
}
fun stop() {
@@ -99,7 +105,6 @@ class AutoTunnelService : LifecycleService() {
override fun onDestroy() {
serviceManager.handleAutoTunnelServiceDestroy()
networkMonitor.destroy()
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
super.onDestroy()
}
@@ -130,7 +135,7 @@ class AutoTunnelService : LifecycleService() {
)
}
private fun startAutoTunnelStateJob() =
private fun startAutoTunnelStateJob(): Job =
lifecycleScope.launch(ioDispatcher) {
val networkFlow =
debouncedConnectivityStateFlow
@@ -208,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)
@@ -230,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) }
@@ -354,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 {
@@ -59,8 +59,8 @@ data class AutoTunnelState(
return DoNothing
}
private val ethernetActive: Boolean = networkState.activeNetwork is ActiveNetwork.Cellular
private val mobileDataActive: Boolean = networkState.activeNetwork is ActiveNetwork.Ethernet
private val ethernetActive: Boolean = networkState.activeNetwork is ActiveNetwork.Ethernet
private val mobileDataActive: Boolean = networkState.activeNetwork is ActiveNetwork.Cellular
private val wifiActive: Boolean = networkState.activeNetwork is ActiveNetwork.Wifi
private fun preferredMobileDataTunnel(): TunnelConfig? {
@@ -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.3"
const val VERSION_CODE = 40103
const val VERSION_NAME = "4.1.8"
const val VERSION_CODE = 40108
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:
- Auto tunnel network detection bugfix
- Tunnel notification sometimes don't start bugfix
@@ -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
@@ -0,0 +1,2 @@
What's new:
- Rapid network changes cause invalid network state bugfix
@@ -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" }
@@ -1,31 +1,29 @@
package com.zaneschepke.networkmonitor
import android.Manifest
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.location.LocationManager
import android.net.ConnectivityManager
import android.net.ConnectivityManager.NetworkCallback.FLAG_INCLUDE_LOCATION_INFO
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.net.wifi.WifiManager
import android.os.Build
import androidx.core.content.ContextCompat
import com.wireguard.android.util.RootShell
import com.zaneschepke.networkmonitor.AndroidNetworkMonitor.WifiDetectionMethod.*
import com.zaneschepke.networkmonitor.shizuku.ShizukuShell
import com.zaneschepke.networkmonitor.util.*
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.withTimeoutOrNull
import timber.log.Timber
import kotlin.concurrent.atomics.AtomicReference
import kotlin.concurrent.atomics.ExperimentalAtomicApi
class AndroidNetworkMonitor(
private val appContext: Context,
@@ -68,119 +66,117 @@ class AndroidNetworkMonitor(
private val locationManager =
appContext.getSystemService(Context.LOCATION_SERVICE) as LocationManager?
private val activeWifiNetworks =
ConcurrentHashMap<String, Pair<Network?, NetworkCapabilities?>>()
private val permissionsChangedFlow = MutableStateFlow(false)
private var permissionReceiver: BroadcastReceiver? = null
private var locationServicesReceiver: BroadcastReceiver? = null
private var airplaneReceiver: BroadcastReceiver? = null
private var defaultNetworkCallback: ConnectivityManager.NetworkCallback? = null
private var wifiInterfaceCallback: ConnectivityManager.NetworkCallback? = null
private var cellularInterfaceCallback: ConnectivityManager.NetworkCallback? = null
private var wifiCallback: ConnectivityManager.NetworkCallback? = null
private var cellularCallback: ConnectivityManager.NetworkCallback? = null
private var ethernetCallback: ConnectivityManager.NetworkCallback? = null
private val isAirplaneModeOn: Boolean
get() =
android.provider.Settings.Global.getInt(
appContext.contentResolver,
android.provider.Settings.Global.AIRPLANE_MODE_ON,
0,
) != 0
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,
changed ->
Pair(detectionMethod, changed)
}
.flatMapLatest { (detectionMethod, _) ->
createDefaultNetworkCallbackFlow(detectionMethod)
combine(configurationListener.detectionMethod, permissionsChangedFlow) { detectionMethod, _
->
detectionMethod
}
.flatMapLatest { detectionMethod ->
callbackFlow {
if (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && detectionMethod == DEFAULT
) {
defaultNetworkCallback =
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")
}
private fun isAndroidTv(): Boolean =
appContext.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK)
override fun onLost(network: Network) {
trySend(TransportEvent.Lost(network))
}
private fun hasRequiredLocationPermissions(): Boolean {
val fineLocationGranted =
ContextCompat.checkSelfPermission(
appContext,
Manifest.permission.ACCESS_FINE_LOCATION,
) == PackageManager.PERMISSION_GRANTED
val backgroundLocationGranted =
if (
(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) &&
// exclude Android TV on Q as background location is not required on this
// version
!(Build.VERSION.SDK_INT == Build.VERSION_CODES.Q && isAndroidTv())
) {
ContextCompat.checkSelfPermission(
appContext,
Manifest.permission.ACCESS_BACKGROUND_LOCATION,
) == PackageManager.PERMISSION_GRANTED
} else {
true // No need for ACCESS_BACKGROUND_LOCATION on Android P or Android TV on Q
}
return fineLocationGranted && backgroundLocationGranted
}
private fun createDefaultNetworkCallbackFlow(
detectionMethod: WifiDetectionMethod
): Flow<TransportEvent> = callbackFlow {
val onAvailable: (Network) -> Unit = { network ->
Timber.d("Network onAvailable: network=$network")
}
val onLost: (Network) -> Unit = { network ->
Timber.d("Network onLost: network=$network")
trySend(TransportEvent.Lost(network))
}
val onCapabilitiesChanged: (Network, NetworkCapabilities) -> Unit =
{ network, networkCapabilities ->
val isValidated =
networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
val hasInternet =
networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
Timber.d("onCapabilitiesChanged: network=$network, validated: $isValidated")
if (isValidated && hasInternet) {
val event =
when {
networkCapabilities.hasTransport(
NetworkCapabilities.TRANSPORT_WIFI
) -> {
activeWifiNetworks[network.toString()] =
Pair(network, networkCapabilities)
TransportEvent.CapabilitiesChanged(
network,
networkCapabilities,
detectionMethod,
)
override fun onCapabilitiesChanged(
network: Network,
caps: NetworkCapabilities,
) {
trySend(TransportEvent.CapabilitiesChanged(network, caps))
}
}
networkCapabilities.hasTransport(
NetworkCapabilities.TRANSPORT_CELLULAR
) -> {
activeWifiNetworks.clear()
TransportEvent.CapabilitiesChanged(network, networkCapabilities)
} else {
defaultNetworkCallback =
object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
Timber.d("Default onAvailable: $network")
}
override fun onLost(network: Network) {
trySend(TransportEvent.Lost(network))
}
override fun onCapabilitiesChanged(
network: Network,
caps: NetworkCapabilities,
) {
trySend(TransportEvent.CapabilitiesChanged(network, caps))
}
}
networkCapabilities.hasTransport(
NetworkCapabilities.TRANSPORT_ETHERNET
) -> {
activeWifiNetworks.clear()
TransportEvent.CapabilitiesChanged(network, networkCapabilities)
}
else -> TransportEvent.Unknown
}
trySend(event)
} else {
activeWifiNetworks.remove(network.toString())
trySend(TransportEvent.Lost(network))
}
connectivityManager?.registerDefaultNetworkCallback(defaultNetworkCallback!!)
trySend(
TransportEvent.Permissions(
Permissions(
locationManager?.isLocationServicesEnabled() ?: false,
appContext.hasRequiredLocationPermissions(),
)
)
)
awaitClose {
connectivityManager?.unregisterNetworkCallback(defaultNetworkCallback!!)
}
}
}
val callback: ConnectivityManager.NetworkCallback =
// 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, _
->
detectionMethod
}
.flatMapLatest { detectionMethod -> createWifiNetworkCallbackFlow(detectionMethod) }
private fun createWifiNetworkCallbackFlow(
detectionMethod: WifiDetectionMethod
): Flow<TransportEvent> = callbackFlow {
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))
}
val onCapabilitiesChanged: (Network, NetworkCapabilities) -> Unit = { network, caps ->
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) {
trySend(TransportEvent.CapabilitiesChanged(network, caps))
}
}
wifiCallback =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && detectionMethod == DEFAULT) {
object : ConnectivityManager.NetworkCallback(FLAG_INCLUDE_LOCATION_INFO) {
override fun onAvailable(network: Network) = onAvailable(network)
@@ -189,8 +185,8 @@ class AndroidNetworkMonitor(
override fun onCapabilitiesChanged(
network: Network,
networkCapabilities: NetworkCapabilities,
) = onCapabilitiesChanged(network, networkCapabilities)
caps: NetworkCapabilities,
) = onCapabilitiesChanged(network, caps)
}
} else {
object : ConnectivityManager.NetworkCallback() {
@@ -200,120 +196,133 @@ class AndroidNetworkMonitor(
override fun onCapabilitiesChanged(
network: Network,
networkCapabilities: NetworkCapabilities,
) = onCapabilitiesChanged(network, networkCapabilities)
caps: NetworkCapabilities,
) = onCapabilitiesChanged(network, caps)
}
}
defaultNetworkCallback = callback
connectivityManager?.registerDefaultNetworkCallback(defaultNetworkCallback!!)
trySend(
TransportEvent.Permissions(
permissions =
Permissions(
locationManager?.isLocationServicesEnabled() ?: false,
hasRequiredLocationPermissions(),
)
)
)
awaitClose {
runCatching { connectivityManager?.unregisterNetworkCallback(defaultNetworkCallback!!) }
.onFailure { Timber.e(it, "Error unregistering default network callback") }
}
}
private val wifiInterfaceFlow: Flow<Boolean> = callbackFlow {
val localCallback =
object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
Timber.d("Wi-Fi onAvailable: network=$network")
trySend(true)
}
override fun onLost(network: Network) {
Timber.d("Wi-Fi onLost: network=$network")
trySend(false)
}
}
wifiInterfaceCallback = localCallback
val request =
NetworkRequest.Builder().addTransportType(NetworkCapabilities.TRANSPORT_WIFI).build()
connectivityManager?.registerNetworkCallback(request, wifiInterfaceCallback!!)
@Suppress("DEPRECATION") val isWifiInitiallyOn = wifiManager?.isWifiEnabled == true
trySend(isWifiInitiallyOn)
awaitClose {
runCatching { connectivityManager?.unregisterNetworkCallback(wifiInterfaceCallback!!) }
.onFailure { Timber.e(it, "Error unregistering Wi-Fi interface callback") }
}
}
private val cellularInterfaceFlow: Flow<Boolean> = callbackFlow {
val localCallback =
object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
Timber.d("Cellular onAvailable: network=$network")
trySend(true)
}
override fun onLost(network: Network) {
Timber.d("Cellular onLost: network=$network")
trySend(false)
}
}
cellularInterfaceCallback = localCallback
val request =
NetworkRequest.Builder()
.addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR)
.apply { addTransportType(NetworkCapabilities.TRANSPORT_WIFI) }
.build()
connectivityManager?.registerNetworkCallback(request, cellularInterfaceCallback!!)
// initial state
val initialCellularNetwork = connectivityManager?.activeNetwork
val initialCapabilities =
connectivityManager?.getNetworkCapabilities(initialCellularNetwork)
val isCellularInitiallyOn =
initialCapabilities?.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == true
trySend(isCellularInitiallyOn)
connectivityManager?.registerNetworkCallback(request, wifiCallback!!)
awaitClose {
runCatching {
connectivityManager?.unregisterNetworkCallback(cellularInterfaceCallback!!)
}
.onFailure { Timber.e(it, "Error unregistering cellular interface callback") }
runCatching { connectivityManager?.unregisterNetworkCallback(wifiCallback!!) }
.onFailure { Timber.e(it, "Error unregistering WiFi network callback") }
}
}
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(isAirplaneModeOn)
}
}
private val cellularFlow: Flow<TransportEvent> = callbackFlow {
val onAvailable: (Network) -> Unit = { network ->
Timber.d("Cellular onAvailable: $network")
}
val onLost: (Network) -> Unit = { network ->
Timber.d("Cellular onLost: $network")
trySend(TransportEvent.Lost(network))
}
val onCapabilitiesChanged: (Network, NetworkCapabilities) -> Unit = { network, caps ->
Timber.d("Cellular onCapabilitiesChanged: $network")
trySend(TransportEvent.CapabilitiesChanged(network, caps))
}
cellularCallback =
object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) = onAvailable(network)
override fun onLost(network: Network) = onLost(network)
override fun onCapabilitiesChanged(network: Network, caps: NetworkCapabilities) =
onCapabilitiesChanged(network, caps)
}
val filter = IntentFilter(Intent.ACTION_AIRPLANE_MODE_CHANGED)
appContext.registerReceiver(receiver, filter)
val request =
NetworkRequest.Builder()
.apply { addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR) }
.build()
// initial state
trySend(isAirplaneModeOn)
connectivityManager?.registerNetworkCallback(request, cellularCallback!!)
trySend(TransportEvent.Unknown)
awaitClose {
runCatching { appContext.unregisterReceiver(receiver) }
.onFailure { Timber.e(it, "Error unregistering airplane mode receiver") }
runCatching { connectivityManager?.unregisterNetworkCallback(cellularCallback!!) }
.onFailure { Timber.e(it, "Error unregistering cellular network callback") }
}
}
private val ethernetFlow: Flow<TransportEvent> = callbackFlow {
val onAvailable: (Network) -> Unit = { network ->
Timber.d("Ethernet onAvailable: $network")
}
val onLost: (Network) -> Unit = { network ->
Timber.d("Ethernet onLost: $network")
trySend(TransportEvent.Lost(network))
}
val onCapabilitiesChanged: (Network, NetworkCapabilities) -> Unit = { network, caps ->
Timber.d("Ethernet onCapabilitiesChanged: $network")
trySend(TransportEvent.CapabilitiesChanged(network, caps))
}
ethernetCallback =
object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) = onAvailable(network)
override fun onLost(network: Network) = onLost(network)
override fun onCapabilitiesChanged(network: Network, caps: NetworkCapabilities) =
onCapabilitiesChanged(network, caps)
}
val request =
NetworkRequest.Builder()
.apply { addTransportType(NetworkCapabilities.TRANSPORT_ETHERNET) }
.build()
connectivityManager?.registerNetworkCallback(request, ethernetCallback!!)
trySend(TransportEvent.Unknown)
awaitClose {
runCatching { connectivityManager?.unregisterNetworkCallback(ethernetCallback!!) }
.onFailure { Timber.e(it, "Error unregistering ethernet network callback") }
}
}
private val wifiStateFlow: Flow<NetworkCapabilities?> =
wifiFlow
.map { event ->
when (event) {
is TransportEvent.CapabilitiesChanged -> event.networkCapabilities
is TransportEvent.Lost -> null
else -> null
}
}
.stateIn(applicationScope, SharingStarted.Eagerly, null)
private val cellularStateFlow: Flow<NetworkCapabilities?> =
cellularFlow
.map { event ->
when (event) {
is TransportEvent.CapabilitiesChanged -> event.networkCapabilities
is TransportEvent.Lost -> null
else -> null
}
}
.stateIn(applicationScope, SharingStarted.Eagerly, null)
private val ethernetStateFlow: Flow<NetworkCapabilities?> =
ethernetFlow
.map { event ->
when (event) {
is TransportEvent.CapabilitiesChanged -> event.networkCapabilities
is TransportEvent.Lost -> null
else -> null
}
}
.stateIn(applicationScope, SharingStarted.Eagerly, null)
private suspend fun getSsidByDetectionMethod(
detectionMethod: WifiDetectionMethod?,
networkCapabilities: NetworkCapabilities?,
@@ -345,114 +354,175 @@ class AndroidNetworkMonitor(
.also { Timber.d("Current SSID via ${method.name}: $it") }
}
override val connectivityStateFlow: SharedFlow<ConnectivityState> =
combine(
defaultNetworkFlow.scan(
ConnectivityState(
activeNetwork = ActiveNetwork.Disconnected,
locationPermissionsGranted = hasRequiredLocationPermissions(),
locationServicesEnabled =
locationManager?.isLocationServicesEnabled() ?: false,
)
) { previous, event ->
when (event) {
is TransportEvent.CapabilitiesChanged -> {
when {
event.networkCapabilities.hasTransport(
NetworkCapabilities.TRANSPORT_WIFI
) -> {
val ssid =
getSsidByDetectionMethod(
event.wifiDetectionMethod
?: WifiDetectionMethod.DEFAULT,
event.networkCapabilities,
)
// default network events don't contain detailed capability information of underlying networks,
// so we need to track separately
private data class NetworkData(
val defaultNetworkEvent: TransportEvent,
val wifiCapabilities: NetworkCapabilities?,
val cellularCaps: NetworkCapabilities?,
val ethernetCaps: NetworkCapabilities?,
)
previous.copy(
activeNetwork =
// combine our network flows to keep sync
private val networkFlows: Flow<NetworkData> =
combine(defaultNetworkFlow, wifiStateFlow, cellularStateFlow, ethernetStateFlow) {
defaultEvent,
wifiCaps,
cellularCaps,
ethernetCaps ->
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, FlowPreview::class)
override val connectivityStateFlow: SharedFlow<ConnectivityState> =
combine(networkFlows, airplaneModeFlow, configurationListener.detectionMethod) {
networkData,
isAirplaneOn,
detectionMethod ->
val defaultEvent = networkData.defaultNetworkEvent
val wifiCaps = networkData.wifiCapabilities
val cellularCaps = networkData.cellularCaps
val ethernetCaps = networkData.ethernetCaps
val permissions =
when (defaultEvent) {
is TransportEvent.Permissions -> defaultEvent.permissions
else ->
Permissions(
locationManager?.isLocationServicesEnabled() ?: false,
appContext.hasRequiredLocationPermissions(),
)
}
// determine default network capabilities
val defaultCaps =
when (defaultEvent) {
is TransportEvent.CapabilitiesChanged -> defaultEvent.networkCapabilities
else ->
connectivityManager?.activeNetwork?.let {
connectivityManager.getNetworkCapabilities(it)
}
}
?: return@combine ConnectivityState(
activeNetwork = ActiveNetwork.Disconnected,
locationPermissionsGranted = permissions.locationPermissionGranted,
locationServicesEnabled = permissions.locationServicesEnabled,
vpnState = VpnState.Inactive,
)
val vpnPreviouslyActive =
vpnActiveState.exchange(
defaultCaps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)
)
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
}
} else {
val fromCaps =
when {
ethernetCaps != null -> ActiveNetwork.Ethernet
wifiCaps != null -> {
val ssid =
getSsidByDetectionMethod(detectionMethod, wifiCaps)
ActiveNetwork.Wifi(
ssid = ssid,
securityType = wifiManager?.getCurrentSecurityType(),
ssid,
wifiManager?.getCurrentSecurityType(),
)
)
}
event.networkCapabilities.hasTransport(
NetworkCapabilities.TRANSPORT_CELLULAR
) -> {
activeWifiNetworks.clear()
previous.copy(activeNetwork = ActiveNetwork.Cellular)
}
event.networkCapabilities.hasTransport(
NetworkCapabilities.TRANSPORT_ETHERNET
) -> {
activeWifiNetworks.clear()
previous.copy(activeNetwork = ActiveNetwork.Ethernet)
}
else -> previous
}
cellularCaps != null && !isAirplaneOn ->
ActiveNetwork.Cellular
else -> null
}
fromCaps
?: if (!vpnPreviouslyActive) {
lastKnownActiveNetwork.value
} else {
ActiveNetwork.Disconnected
}
}
}
is TransportEvent.Lost ->
previous.copy(activeNetwork = ActiveNetwork.Disconnected)
is TransportEvent.Permissions -> {
previous.copy(
locationPermissionsGranted =
event.permissions.locationPermissionGranted,
locationServicesEnabled = event.permissions.locationServicesEnabled,
)
}
is TransportEvent.Available -> previous
is TransportEvent.Unknown -> previous
}
},
wifiInterfaceFlow,
airplaneModeFlow,
cellularInterfaceFlow,
) { defaultState, isWifiInterfaceOn, isAirplaneModeOn, isCellularInterfaceOn ->
val activeNetwork =
when {
// Wi-Fi interface disabled, force disconnected
!isWifiInterfaceOn && defaultState.activeNetwork is ActiveNetwork.Wifi ->
ActiveNetwork.Disconnected
// Cellular active when airplane mode on
isAirplaneModeOn && defaultState.activeNetwork is ActiveNetwork.Cellular ->
ActiveNetwork.Disconnected
// Cellular active when cellular interface disabled
!isCellularInterfaceOn &&
defaultState.activeNetwork is ActiveNetwork.Cellular ->
ActiveNetwork.Disconnected
else -> defaultState.activeNetwork
}
.also { network -> lastKnownActiveNetwork.value = network }
ConnectivityState(
activeNetwork = activeNetwork,
locationPermissionsGranted = defaultState.locationPermissionsGranted,
locationServicesEnabled = defaultState.locationServicesEnabled,
)
.also { Timber.i("Connectivity Status: $it") }
activeNetwork = activeNetwork,
locationPermissionsGranted = permissions.locationPermissionGranted,
locationServicesEnabled = permissions.locationServicesEnabled,
vpnState = vpnState,
)
}
.distinctUntilChanged()
.debounce { 300L }
.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)
val intent = Intent(action).apply { setPackage(appContext.packageName) }
Timber.d("Sending broadcast: $action")
appContext.sendBroadcast(intent)
}
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() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == actionPermissionCheck) {
val isGranted = hasRequiredLocationPermissions()
val isGranted = appContext.hasRequiredLocationPermissions()
Timber.d("Received permission check broadcast, isGranted: $isGranted")
if (
connectivityStateFlow.replayCache
@@ -462,16 +532,16 @@ class AndroidNetworkMonitor(
Timber.d(
"Location permissions have changed, canceling and restarting callback flow"
)
activeWifiNetworks.clear()
permissionsChangedFlow.update { !permissionsChangedFlow.value }
}
}
}
}
permissionReceiver?.let {
appContext.registerReceiver(it, IntentFilter(actionPermissionCheck), receiverFlags)
}
appContext.registerReceiver(
permissionReceiver,
IntentFilter(actionPermissionCheck),
localFlags,
)
locationServicesReceiver =
object : BroadcastReceiver() {
@@ -487,31 +557,44 @@ class AndroidNetworkMonitor(
Timber.d(
"Location services have changed, canceling and restarting callback flow"
)
activeWifiNetworks.clear()
permissionsChangedFlow.update { !permissionsChangedFlow.value }
}
}
}
}
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) }
wifiInterfaceCallback?.let { connectivityManager?.unregisterNetworkCallback(it) }
cellularInterfaceCallback?.let {
connectivityManager?.unregisterNetworkCallback(it)
}
wifiCallback?.let { connectivityManager?.unregisterNetworkCallback(it) }
cellularCallback?.let { connectivityManager?.unregisterNetworkCallback(it) }
ethernetCallback?.let { connectivityManager?.unregisterNetworkCallback(it) }
}
.onFailure { Timber.e(it, "Error during cleanup") }
Timber.d("NetworkMonitor cleaned up")
@@ -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
@@ -27,17 +28,20 @@ data class ConnectivityState(
}
}
data class Permissions(val locationServicesEnabled: Boolean, val locationPermissionGranted: Boolean)
sealed class ActiveNetwork {
data object Disconnected : ActiveNetwork()
data object Ethernet : ActiveNetwork()
data class Wifi(val ssid: String, val securityType: WifiSecurityType?) : ActiveNetwork()
data object Cellular : ActiveNetwork()
data class Wifi(val ssid: String, val securityType: WifiSecurityType? = null) : ActiveNetwork()
data object Ethernet : ActiveNetwork()
}
data class Permissions(
val locationServicesEnabled: Boolean = false,
val locationPermissionGranted: Boolean = false,
)
sealed interface VpnState {
object Inactive : VpnState
data class Active(val hasInternet: Boolean) : VpnState
}
@@ -1,10 +1,15 @@
package com.zaneschepke.networkmonitor.util
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.location.LocationManager
import android.net.NetworkCapabilities
import android.net.wifi.WifiInfo
import android.net.wifi.WifiManager
import android.os.Build
import android.provider.Settings
import androidx.core.content.ContextCompat
import com.wireguard.android.util.RootShell
import com.zaneschepke.networkmonitor.AndroidNetworkMonitor.Companion.ANDROID_UNKNOWN_SSID
import kotlinx.coroutines.Dispatchers
@@ -62,3 +67,29 @@ fun LocationManager.isLocationServicesEnabled(): Boolean {
false
}
}
fun Context.hasRequiredLocationPermissions(): Boolean {
val fineLocationGranted =
ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
val backgroundLocationGranted =
if (
(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) &&
// exclude Android TV on Q as background location is not required on this
// version
!(Build.VERSION.SDK_INT == Build.VERSION_CODES.Q &&
packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK))
) {
ContextCompat.checkSelfPermission(
this,
Manifest.permission.ACCESS_BACKGROUND_LOCATION,
) == PackageManager.PERMISSION_GRANTED
} else {
true // No need for ACCESS_BACKGROUND_LOCATION on Android P or Android TV on Q
}
return fineLocationGranted && backgroundLocationGranted
}
fun Context.isAirplaneModeOn(): Boolean {
return Settings.Global.getInt(contentResolver, Settings.Global.AIRPLANE_MODE_ON, 0) != 0
}