Compare commits

..

19 Commits

Author SHA1 Message Date
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
Zane Schepke 4bcd810b38 chore: release 4.1.3 2025-11-04 03:57:24 -05:00
Zane Schepke e71174995b fix: tab back navigation bug 2025-11-04 03:39:23 -05:00
Zane Schepke f256a32bda fix: restore proper metered tunnel default
closes #1035
2025-11-04 03:03:24 -05:00
Zane Schepke c49666303a fix: network monitor changes for Android 10 2025-11-04 02:00:58 -05:00
Zane Schepke 3a9b435e50 fix: default wifi method needs flag 2025-11-03 11:52:34 -05:00
Zane Schepke 0993f60977 fix: auto tunnel service binder 2025-11-03 10:55:57 -05:00
Zane Schepke 3d88feb97c fix: r8 ip parsing bug
closes #1031
2025-11-03 09:45:56 -05:00
Zane Schepke f61e6d6c6e fix: network detection bug
closes #1032
2025-11-03 08:20:35 -05:00
Zane Schepke df864ade95 fix: binder leak 2025-11-03 02:24:19 -05:00
Zane Schepke 0abe3f67ef chore: fix fastlane deploy 2025-11-02 03:30:16 -05:00
33 changed files with 1359 additions and 591 deletions
+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
+1 -1
View File
@@ -118,7 +118,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 -F'"' '{print $2}')
VERSION_CODE=$(grep "const val VERSION_CODE" buildSrc/src/main/kotlin/Constants.kt | awk '{print $5}')
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
+4 -2
View File
@@ -30,10 +30,10 @@ android {
splits {
abi {
isEnable = true
isEnable = !project.hasProperty("noSplits")
reset()
include("armeabi-v7a", "arm64-v8a")
isUniversalApk = true
isUniversalApk = !project.hasProperty("noSplits")
}
}
@@ -242,6 +242,8 @@ dependencies {
debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.manifest)
debugImplementation(libs.leakcanary.android)
// Room database backup
implementation(libs.roomdatabasebackup) {
exclude(group = "org.reactivestreams", module = "reactive-streams")
@@ -0,0 +1,523 @@
{
"formatVersion": 1,
"database": {
"version": 29,
"identityHash": "345471c118dee1b7688afa81d835e62c",
"entities": [
{
"tableName": "tunnel_config",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `wg_quick` TEXT NOT NULL, `tunnel_networks` TEXT NOT NULL DEFAULT '', `is_mobile_data_tunnel` INTEGER NOT NULL DEFAULT false, `is_primary_tunnel` INTEGER NOT NULL DEFAULT false, `am_quick` TEXT NOT NULL DEFAULT '', `is_Active` INTEGER NOT NULL DEFAULT false, `restart_on_ping_failure` INTEGER NOT NULL DEFAULT false, `ping_target` TEXT DEFAULT null, `is_ethernet_tunnel` INTEGER NOT NULL DEFAULT false, `is_ipv4_preferred` INTEGER NOT NULL DEFAULT true, `position` INTEGER NOT NULL DEFAULT 0, `auto_tunnel_apps` TEXT NOT NULL DEFAULT '[]', `is_metered` INTEGER NOT NULL DEFAULT false)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "wgQuick",
"columnName": "wg_quick",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "tunnelNetworks",
"columnName": "tunnel_networks",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "isMobileDataTunnel",
"columnName": "is_mobile_data_tunnel",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isPrimaryTunnel",
"columnName": "is_primary_tunnel",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "amQuick",
"columnName": "am_quick",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "isActive",
"columnName": "is_Active",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "restartOnPingFailure",
"columnName": "restart_on_ping_failure",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "pingTarget",
"columnName": "ping_target",
"affinity": "TEXT",
"defaultValue": "null"
},
{
"fieldPath": "isEthernetTunnel",
"columnName": "is_ethernet_tunnel",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isIpv4Preferred",
"columnName": "is_ipv4_preferred",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "true"
},
{
"fieldPath": "position",
"columnName": "position",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "autoTunnelApps",
"columnName": "auto_tunnel_apps",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "'[]'"
},
{
"fieldPath": "isMetered",
"columnName": "is_metered",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_tunnel_config_name",
"unique": true,
"columnNames": [
"name"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_tunnel_config_name` ON `${TABLE_NAME}` (`name`)"
}
]
},
{
"tableName": "proxy_settings",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `socks5_proxy_enabled` INTEGER NOT NULL DEFAULT 0, `socks5_proxy_bind_address` TEXT, `http_proxy_enable` INTEGER NOT NULL DEFAULT 0, `http_proxy_bind_address` TEXT, `proxy_username` TEXT, `proxy_password` TEXT)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "socks5ProxyEnabled",
"columnName": "socks5_proxy_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "socks5ProxyBindAddress",
"columnName": "socks5_proxy_bind_address",
"affinity": "TEXT"
},
{
"fieldPath": "httpProxyEnabled",
"columnName": "http_proxy_enable",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "httpProxyBindAddress",
"columnName": "http_proxy_bind_address",
"affinity": "TEXT"
},
{
"fieldPath": "proxyUsername",
"columnName": "proxy_username",
"affinity": "TEXT"
},
{
"fieldPath": "proxyPassword",
"columnName": "proxy_password",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
},
{
"tableName": "general_settings",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `is_shortcuts_enabled` INTEGER NOT NULL DEFAULT 0, `is_restore_on_boot_enabled` INTEGER NOT NULL DEFAULT 0, `is_multi_tunnel_enabled` INTEGER NOT NULL DEFAULT 0, `global_split_tunnel_enabled` INTEGER NOT NULL DEFAULT 0, `app_mode` INTEGER NOT NULL DEFAULT 0, `theme` TEXT NOT NULL DEFAULT 'AUTOMATIC', `locale` TEXT, `remote_key` TEXT, `is_remote_control_enabled` INTEGER NOT NULL DEFAULT 0, `is_pin_lock_enabled` INTEGER NOT NULL DEFAULT 0, `is_always_on_vpn_enabled` INTEGER NOT NULL DEFAULT 0, `already_donated` INTEGER NOT NULL DEFAULT 0)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isShortcutsEnabled",
"columnName": "is_shortcuts_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "isRestoreOnBootEnabled",
"columnName": "is_restore_on_boot_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "isMultiTunnelEnabled",
"columnName": "is_multi_tunnel_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "isGlobalSplitTunnelEnabled",
"columnName": "global_split_tunnel_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "appMode",
"columnName": "app_mode",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "theme",
"columnName": "theme",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "'AUTOMATIC'"
},
{
"fieldPath": "locale",
"columnName": "locale",
"affinity": "TEXT"
},
{
"fieldPath": "remoteKey",
"columnName": "remote_key",
"affinity": "TEXT"
},
{
"fieldPath": "isRemoteControlEnabled",
"columnName": "is_remote_control_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "isPinLockEnabled",
"columnName": "is_pin_lock_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "isAlwaysOnVpnEnabled",
"columnName": "is_always_on_vpn_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "alreadyDonated",
"columnName": "already_donated",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
},
{
"tableName": "auto_tunnel_settings",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `is_tunnel_enabled` INTEGER NOT NULL DEFAULT 0, `is_tunnel_on_mobile_data_enabled` INTEGER NOT NULL DEFAULT 0, `trusted_network_ssids` TEXT NOT NULL DEFAULT '', `is_tunnel_on_ethernet_enabled` INTEGER NOT NULL DEFAULT 0, `is_tunnel_on_wifi_enabled` INTEGER NOT NULL DEFAULT 0, `is_wildcards_enabled` INTEGER NOT NULL DEFAULT 0, `is_stop_on_no_internet_enabled` INTEGER NOT NULL DEFAULT 0, `debounce_delay_seconds` INTEGER NOT NULL DEFAULT 3, `is_tunnel_on_unsecure_enabled` INTEGER NOT NULL DEFAULT 0, `wifi_detection_method` INTEGER NOT NULL DEFAULT 0, `start_on_boot` INTEGER NOT NULL DEFAULT 0)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isAutoTunnelEnabled",
"columnName": "is_tunnel_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "isTunnelOnMobileDataEnabled",
"columnName": "is_tunnel_on_mobile_data_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "trustedNetworkSSIDs",
"columnName": "trusted_network_ssids",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "isTunnelOnEthernetEnabled",
"columnName": "is_tunnel_on_ethernet_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "isTunnelOnWifiEnabled",
"columnName": "is_tunnel_on_wifi_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "isWildcardsEnabled",
"columnName": "is_wildcards_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "isStopOnNoInternetEnabled",
"columnName": "is_stop_on_no_internet_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "debounceDelaySeconds",
"columnName": "debounce_delay_seconds",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "3"
},
{
"fieldPath": "isTunnelOnUnsecureEnabled",
"columnName": "is_tunnel_on_unsecure_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "wifiDetectionMethod",
"columnName": "wifi_detection_method",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "startOnBoot",
"columnName": "start_on_boot",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
},
{
"tableName": "monitoring_settings",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `is_ping_enabled` INTEGER NOT NULL DEFAULT 0, `is_ping_monitoring_enabled` INTEGER NOT NULL DEFAULT 1, `tunnel_ping_interval_sec` INTEGER NOT NULL DEFAULT 30, `tunnel_ping_attempts` INTEGER NOT NULL DEFAULT 3, `tunnel_ping_timeout_sec` INTEGER, `show_detailed_ping_stats` INTEGER NOT NULL DEFAULT 0, `is_local_logs_enabled` INTEGER NOT NULL DEFAULT 0)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isPingEnabled",
"columnName": "is_ping_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "isPingMonitoringEnabled",
"columnName": "is_ping_monitoring_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "1"
},
{
"fieldPath": "tunnelPingIntervalSeconds",
"columnName": "tunnel_ping_interval_sec",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "30"
},
{
"fieldPath": "tunnelPingAttempts",
"columnName": "tunnel_ping_attempts",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "3"
},
{
"fieldPath": "tunnelPingTimeoutSeconds",
"columnName": "tunnel_ping_timeout_sec",
"affinity": "INTEGER"
},
{
"fieldPath": "showDetailedPingStats",
"columnName": "show_detailed_ping_stats",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "isLocalLogsEnabled",
"columnName": "is_local_logs_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
},
{
"tableName": "dns_settings",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `dns_protocol` INTEGER NOT NULL DEFAULT 0, `dns_endpoint` TEXT, `global_tunnel_dns_enabled` INTEGER NOT NULL DEFAULT 0)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "dnsProtocol",
"columnName": "dns_protocol",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "dnsEndpoint",
"columnName": "dns_endpoint",
"affinity": "TEXT"
},
{
"fieldPath": "isGlobalTunnelDnsEnabled",
"columnName": "global_tunnel_dns_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
},
{
"tableName": "lockdown_settings",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `bypass_lan` INTEGER NOT NULL DEFAULT 0, `metered` INTEGER NOT NULL DEFAULT 0, `dual_stack` INTEGER NOT NULL DEFAULT 0)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bypassLan",
"columnName": "bypass_lan",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "metered",
"columnName": "metered",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "dualStack",
"columnName": "dual_stack",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '345471c118dee1b7688afa81d835e62c')"
]
}
}
@@ -40,6 +40,7 @@ import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
import com.zaneschepke.networkmonitor.NetworkMonitor
import com.zaneschepke.wireguardautotunnel.data.AppDatabase
import com.zaneschepke.wireguardautotunnel.data.DataStoreManager.Companion.shouldShowDonationSnackbar
import com.zaneschepke.wireguardautotunnel.data.model.AppMode
@@ -111,6 +112,7 @@ class MainActivity : AppCompatActivity() {
@Inject lateinit var appStateRepository: AppStateRepository
@Inject lateinit var tunnelRepository: TunnelRepository
@Inject lateinit var appDatabase: AppDatabase
@Inject lateinit var networkMonitor: NetworkMonitor
private lateinit var roomBackup: RoomBackup
@@ -520,6 +522,7 @@ class MainActivity : AppCompatActivity() {
override fun onResume() {
super.onResume()
networkMonitor.checkPermissionsAndUpdateState()
WireGuardAutoTunnel.setUiActive(true)
}
@@ -1,5 +1,11 @@
package com.zaneschepke.wireguardautotunnel.core.service
import android.os.Binder
import java.lang.ref.WeakReference
class LocalBinder(val service: TunnelService) : Binder()
class LocalBinder(service: TunnelService) : Binder() {
private val serviceRef = WeakReference(service)
val service: TunnelService?
get() = serviceRef.get()
}
@@ -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")
}
}
}
@@ -24,11 +24,12 @@ import com.zaneschepke.wireguardautotunnel.domain.repository.AutoTunnelSettingsR
import com.zaneschepke.wireguardautotunnel.domain.repository.GeneralSettingRepository
import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository
import com.zaneschepke.wireguardautotunnel.domain.state.AutoTunnelState
import com.zaneschepke.wireguardautotunnel.domain.state.NetworkState
import com.zaneschepke.wireguardautotunnel.domain.state.toDomain
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.extensions.to
import com.zaneschepke.wireguardautotunnel.util.extensions.toMillis
import dagger.hilt.android.AndroidEntryPoint
import java.lang.ref.WeakReference
import javax.inject.Inject
import javax.inject.Provider
import kotlinx.coroutines.*
@@ -60,7 +61,15 @@ class AutoTunnelService : LifecycleService() {
private val autoTunnelStateFlow = MutableStateFlow(defaultState)
class LocalBinder(val service: AutoTunnelService) : Binder()
private var autoTunnelJob: Job? = null
private var permissionsJob: Job? = null
class LocalBinder(service: AutoTunnelService) : Binder() {
private val serviceRef = WeakReference(service)
val service: AutoTunnelService?
get() = serviceRef.get()
}
private val binder = LocalBinder(this)
@@ -83,8 +92,10 @@ class AutoTunnelService : LifecycleService() {
fun start() {
launchWatcherNotification()
startAutoTunnelStateJob()
startLocationPermissionsNotificationJob()
autoTunnelJob?.cancel()
autoTunnelJob = startAutoTunnelStateJob()
permissionsJob?.cancel()
permissionsJob = startLocationPermissionsNotificationJob()
}
fun stop() {
@@ -93,7 +104,6 @@ class AutoTunnelService : LifecycleService() {
override fun onDestroy() {
serviceManager.handleAutoTunnelServiceDestroy()
networkMonitor.destroy()
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
super.onDestroy()
}
@@ -124,12 +134,12 @@ class AutoTunnelService : LifecycleService() {
)
}
private fun startAutoTunnelStateJob() =
private fun startAutoTunnelStateJob(): Job =
lifecycleScope.launch(ioDispatcher) {
val networkFlow =
debouncedConnectivityStateFlow
.flowOn(ioDispatcher)
.map(NetworkState::from)
.map { it.toDomain() }
.map(::NetworkChange)
.distinctUntilChanged()
@@ -266,8 +276,8 @@ class AutoTunnelService : LifecycleService() {
.map {
NetworkPermissionState(
it.settings.wifiDetectionMethod.to(),
it.networkState.locationServicesEnabled == true,
it.networkState.locationPermissionGranted == true,
it.networkState.locationServicesEnabled,
it.networkState.locationPermissionGranted,
(it.tunnels.any { tunnel -> tunnel.tunnelNetworks.isNotEmpty() } ||
it.settings.trustedNetworkSSIDs.isNotEmpty()),
)
@@ -95,26 +95,7 @@ constructor(
val connectivityStateFlow = networkMonitor.connectivityStateFlow.stateIn(this)
val isNetworkConnected = connectivityStateFlow.map { it.hasConnectivity() }.stateIn(this)
data class NetworkChangeKey(
val ethernetConnected: Boolean,
val wifiConnected: Boolean,
val cellularConnected: Boolean,
val wifiSsid: String?,
)
connectivityStateFlow
.map {
NetworkChangeKey(
ethernetConnected = it.ethernetConnected,
wifiConnected = it.wifiState.connected,
cellularConnected = it.cellularConnected,
wifiSsid = if (it.wifiState.connected) it.wifiState.ssid else null,
)
}
.distinctUntilChanged()
.stateIn(this)
val isNetworkConnected = connectivityStateFlow.map { it.hasInternet() }.stateIn(this)
combine(
settingsRepository.flow.distinctUntilChangedBy { it.appMode },
@@ -17,7 +17,7 @@ import com.zaneschepke.wireguardautotunnel.data.entity.*
DnsSettings::class,
LockdownSettings::class,
],
version = 28,
version = 29,
autoMigrations =
[
AutoMigration(from = 1, to = 2),
@@ -28,7 +28,7 @@ data class TunnelConfig(
@ColumnInfo(name = "position", defaultValue = "0") val position: Int = 0,
@ColumnInfo(name = "auto_tunnel_apps", defaultValue = "[]")
val autoTunnelApps: Set<String> = emptySet(),
@ColumnInfo(name = "is_metered", defaultValue = "true") val isMetered: Boolean = true,
@ColumnInfo(name = "is_metered", defaultValue = "false") val isMetered: Boolean = false,
) {
companion object {
const val GLOBAL_CONFIG_NAME = "4675ab06-903a-438b-8485-6ea4187a9512"
@@ -411,3 +411,56 @@ val MIGRATION_25_26 =
db.execSQL("ALTER TABLE `general_settings_new` RENAME TO `general_settings`")
}
}
val MIGRATION_28_29 =
object : Migration(28, 29) {
override fun migrate(database: SupportSQLiteDatabase) {
// Migrate tunnel_config table
database.execSQL(
"""
CREATE TABLE IF NOT EXISTS `tunnel_config_new` (
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` TEXT NOT NULL,
`wg_quick` TEXT NOT NULL,
`tunnel_networks` TEXT NOT NULL DEFAULT '',
`is_mobile_data_tunnel` INTEGER NOT NULL DEFAULT false,
`is_primary_tunnel` INTEGER NOT NULL DEFAULT false,
`am_quick` TEXT NOT NULL DEFAULT '',
`is_Active` INTEGER NOT NULL DEFAULT false,
`restart_on_ping_failure` INTEGER NOT NULL DEFAULT false,
`ping_target` TEXT DEFAULT null,
`is_ethernet_tunnel` INTEGER NOT NULL DEFAULT false,
`is_ipv4_preferred` INTEGER NOT NULL DEFAULT true,
`position` INTEGER NOT NULL DEFAULT 0,
`auto_tunnel_apps` TEXT NOT NULL DEFAULT '[]',
`is_metered` INTEGER NOT NULL DEFAULT false
)
"""
.trimIndent()
)
database.execSQL(
"""
INSERT INTO `tunnel_config_new` (
`id`, `name`, `wg_quick`, `tunnel_networks`, `is_mobile_data_tunnel`,
`is_primary_tunnel`, `am_quick`, `is_Active`, `restart_on_ping_failure`,
`ping_target`, `is_ethernet_tunnel`, `is_ipv4_preferred`, `position`,
`auto_tunnel_apps`, `is_metered`
)
SELECT
`id`, `name`, `wg_quick`, `tunnel_networks`, `is_mobile_data_tunnel`,
`is_primary_tunnel`, `am_quick`, `is_Active`, `restart_on_ping_failure`,
`ping_target`, `is_ethernet_tunnel`, `is_ipv4_preferred`, `position`,
`auto_tunnel_apps`, 0 AS `is_metered`
FROM `tunnel_config`
"""
.trimIndent()
)
database.execSQL("DROP TABLE `tunnel_config`")
database.execSQL("ALTER TABLE `tunnel_config_new` RENAME TO `tunnel_config`")
database.execSQL(
"CREATE UNIQUE INDEX IF NOT EXISTS `index_tunnel_config_name` ON `tunnel_config` (`name`)"
)
}
}
@@ -9,6 +9,7 @@ import com.zaneschepke.wireguardautotunnel.data.DatabaseCallback
import com.zaneschepke.wireguardautotunnel.data.dao.*
import com.zaneschepke.wireguardautotunnel.data.migrations.MIGRATION_23_24
import com.zaneschepke.wireguardautotunnel.data.migrations.MIGRATION_25_26
import com.zaneschepke.wireguardautotunnel.data.migrations.MIGRATION_28_29
import com.zaneschepke.wireguardautotunnel.data.network.GitHubApi
import com.zaneschepke.wireguardautotunnel.data.network.KtorClient
import com.zaneschepke.wireguardautotunnel.data.network.KtorGitHubApi
@@ -56,8 +57,11 @@ class RepositoryModule {
AppDatabase::class.java,
context.getString(R.string.db_name),
)
.addMigrations(MIGRATION_23_24(dataStoreManager.dataStore))
.addMigrations(MIGRATION_25_26)
.addMigrations(
MIGRATION_23_24(dataStoreManager.dataStore),
MIGRATION_25_26,
MIGRATION_28_29,
)
.fallbackToDestructiveMigration(true)
.addCallback(callback)
.build()
@@ -1,8 +0,0 @@
package com.zaneschepke.wireguardautotunnel.domain.enums
enum class NetworkType {
WIFI,
ETHERNET,
MOBILE_DATA,
NONE,
}
@@ -27,7 +27,7 @@ data class TunnelConfig(
val isIpv4Preferred: Boolean = true,
val position: Int = 0,
val autoTunnelApps: Set<String> = setOf(),
val isMetered: Boolean = true,
val isMetered: Boolean = false,
) {
override fun equals(other: Any?): Boolean {
@@ -25,29 +25,27 @@ data class AutoTunnelState(
is NetworkChange,
is SettingsChange -> {
// Compute desired tunnel based on network conditions
var desiredTunnel: TunnelConfig? = null
if (networkState.isEthernetConnected && settings.isTunnelOnEthernetEnabled) {
desiredTunnel = preferredEthernetTunnel()
} else if (isMobileDataActive() && settings.isTunnelOnMobileDataEnabled) {
desiredTunnel = preferredMobileDataTunnel()
} else if (
isWifiActive() && settings.isTunnelOnWifiEnabled && !isCurrentSSIDTrusted()
) {
desiredTunnel = preferredWifiTunnel()
var preferredTunnel: TunnelConfig? = null
if (ethernetActive && settings.isTunnelOnEthernetEnabled) {
preferredTunnel = preferredEthernetTunnel()
} else if (mobileDataActive && settings.isTunnelOnMobileDataEnabled) {
preferredTunnel = preferredMobileDataTunnel()
} else if (wifiActive && settings.isTunnelOnWifiEnabled && !isWifiTrusted()) {
preferredTunnel = preferredWifiTunnel()
}
// Override for no connectivity if enabled
if (isNoConnectivity() && settings.isStopOnNoInternetEnabled) {
desiredTunnel = null
if (!networkState.hasInternet() && settings.isStopOnNoInternetEnabled) {
preferredTunnel = null
}
// Determine current active tunnel (assuming only one can be active)
val currentTunnel = activeTunnels.entries.firstOrNull()?.key
// Handle tunnel start/stop/change
if (desiredTunnel != null) {
if (currentTunnel != desiredTunnel.id) {
return Start(desiredTunnel)
if (preferredTunnel != null) {
if (currentTunnel != preferredTunnel.id) {
return Start(preferredTunnel)
}
} else {
if (currentTunnel != null) {
@@ -61,12 +59,9 @@ data class AutoTunnelState(
return DoNothing
}
// also need to check for Wi-Fi state as there is some overlap when they are both connected
private fun isMobileDataActive(): Boolean {
return !networkState.isEthernetConnected &&
!networkState.isWifiConnected &&
networkState.isMobileDataConnected
}
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? {
return tunnels.firstOrNull { it.isMobileDataTunnel }
@@ -81,27 +76,21 @@ data class AutoTunnelState(
}
private fun preferredWifiTunnel(): TunnelConfig? {
return getTunnelWithMatchingTunnelNetwork()
return getTunnelWithMappedNetwork()
?: tunnels.firstOrNull { it.isPrimaryTunnel }
?: tunnels.firstOrNull()
}
// ignore cellular state as there is overlap where it may still be active, but not prioritized
private fun isWifiActive(): Boolean {
return !networkState.isEthernetConnected && networkState.isWifiConnected
private fun isWifiTrusted(): Boolean {
return with(networkState.activeNetwork) {
this is ActiveNetwork.Wifi && isTrustedNetwork(this.ssid)
}
}
private fun isNoConnectivity(): Boolean {
return !networkState.isEthernetConnected &&
!networkState.isWifiConnected &&
!networkState.isMobileDataConnected
}
private fun isTrustedNetwork(ssid: String): Boolean =
hasMatch(ssid, settings.trustedNetworkSSIDs)
private fun isCurrentSSIDTrusted(): Boolean {
return networkState.wifiName?.let { hasTrustedWifiName(it) } == true
}
private fun hasTrustedWifiName(
private fun hasMatch(
wifiName: String,
wifiNames: Set<String> = settings.trustedNetworkSSIDs,
): Boolean {
@@ -112,9 +101,10 @@ data class AutoTunnelState(
}
}
private fun getTunnelWithMatchingTunnelNetwork(): TunnelConfig? {
return networkState.wifiName?.let { wifiName ->
tunnels.firstOrNull { hasTrustedWifiName(wifiName, it.tunnelNetworks) }
private fun getTunnelWithMappedNetwork(): TunnelConfig? =
when (val network = networkState.activeNetwork) {
is ActiveNetwork.Wifi ->
tunnels.firstOrNull { hasMatch(network.ssid, it.tunnelNetworks) }
else -> null
}
}
}
@@ -1,9 +0,0 @@
package com.zaneschepke.wireguardautotunnel.domain.state
data class ConnectivityState(
val wifiAvailable: Boolean,
val ethernetAvailable: Boolean,
val cellularAvailable: Boolean,
) {
val allOffline = !wifiAvailable && !ethernetAvailable && !cellularAvailable
}
@@ -1,38 +1,48 @@
package com.zaneschepke.wireguardautotunnel.domain.state
import com.zaneschepke.networkmonitor.ActiveNetwork as MonitorActiveNetwork
import com.zaneschepke.networkmonitor.ConnectivityState
import com.zaneschepke.networkmonitor.util.WifiSecurityType
data class NetworkState(
val isWifiConnected: Boolean = false,
val isMobileDataConnected: Boolean = false,
val isEthernetConnected: Boolean = false,
val wifiName: String? = null,
val isWifiSecure: Boolean? = null,
val locationServicesEnabled: Boolean? = null,
val locationPermissionGranted: Boolean? = null,
) {
fun hasNoCapabilities(): Boolean {
return !isWifiConnected && !isMobileDataConnected && !isEthernetConnected
}
sealed class ActiveNetwork {
data object Disconnected : ActiveNetwork()
companion object {
fun from(connectivityState: ConnectivityState): NetworkState {
return NetworkState(
isWifiSecure =
when (connectivityState.wifiState.securityType) {
data object Ethernet : ActiveNetwork()
data object Cellular : ActiveNetwork()
data class Wifi(val ssid: String, val isSecure: Boolean?) : ActiveNetwork()
}
data class NetworkState(
val activeNetwork: ActiveNetwork = ActiveNetwork.Disconnected,
val locationServicesEnabled: Boolean = false,
val locationPermissionGranted: Boolean = false,
) {
fun hasInternet(): Boolean = activeNetwork !is ActiveNetwork.Disconnected
}
fun ConnectivityState.toDomain(): NetworkState {
val domainNetwork: ActiveNetwork =
when (val network = this.activeNetwork) {
is MonitorActiveNetwork.Wifi -> {
val isSecure =
when (network.securityType) {
WifiSecurityType.OPEN,
WifiSecurityType.UNKNOWN -> false
null -> null
else -> true
},
isWifiConnected = connectivityState.wifiState.connected,
isMobileDataConnected = connectivityState.cellularConnected,
isEthernetConnected = connectivityState.ethernetConnected,
wifiName = connectivityState.wifiState.ssid,
locationPermissionGranted = connectivityState.wifiState.locationPermissionsGranted,
locationServicesEnabled = connectivityState.wifiState.locationServicesEnabled,
)
}
ActiveNetwork.Wifi(ssid = network.ssid, isSecure = isSecure)
}
is MonitorActiveNetwork.Cellular -> ActiveNetwork.Cellular
is MonitorActiveNetwork.Ethernet -> ActiveNetwork.Ethernet
is MonitorActiveNetwork.Disconnected -> ActiveNetwork.Disconnected
}
}
return NetworkState(
activeNetwork = domainNetwork,
locationPermissionGranted = this.locationPermissionsGranted,
locationServicesEnabled = this.locationServicesEnabled,
)
}
@@ -22,24 +22,14 @@ class NavController(
return false
}
fun popUpTo(route: NavKey, inclusive: Boolean = false) {
fun popUpTo(route: NavKey) {
onChange(currentRoute)
val targetRoute =
if (route is Route.AutoTunnel && !isDisclosureShown) Route.LocationDisclosure else route
val index = backStack.indexOfLast { it == targetRoute }
if (index != -1) {
val popUpToIndex = if (inclusive) index else index + 1
while (backStack.size > popUpToIndex) {
backStack.removeLastOrNull()
}
} else {
// Only add if it's not already the top
if (backStack.lastOrNull() != targetRoute) {
backStack.add(targetRoute)
}
}
backStack.clear()
if (route is Route.Tunnels) backStack.add(targetRoute)
else backStack.addAll(setOf(Route.Tunnels, targetRoute))
}
val currentRoute: NavKey?
@@ -36,8 +36,8 @@ import androidx.core.net.toUri
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.zaneschepke.networkmonitor.ActiveNetwork
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.domain.enums.NetworkType
import com.zaneschepke.wireguardautotunnel.ui.LocalNavController
import com.zaneschepke.wireguardautotunnel.ui.LocalSharedVm
import com.zaneschepke.wireguardautotunnel.ui.common.button.SurfaceRow
@@ -59,9 +59,9 @@ fun AutoTunnelScreen(viewModel: AutoTunnelViewModel = hiltViewModel()) {
val clipboard = rememberClipboardHelper()
val sharedUiState by shareViewModel.container.stateFlow.collectAsStateWithLifecycle()
val autoTunnelState by viewModel.container.stateFlow.collectAsStateWithLifecycle()
val uiState by viewModel.container.stateFlow.collectAsStateWithLifecycle()
if (autoTunnelState.isLoading) return
if (uiState.isLoading) return
val batteryActivity =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
@@ -79,11 +79,11 @@ fun AutoTunnelScreen(viewModel: AutoTunnelViewModel = hiltViewModel()) {
}
val (ethernetTunnel, mobileDataTunnel, mappedTunnels) =
remember(autoTunnelState.tunnels) {
remember(uiState.tunnels) {
Triple(
autoTunnelState.tunnels.firstOrNull { it.isEthernetTunnel },
autoTunnelState.tunnels.firstOrNull { it.isMobileDataTunnel },
autoTunnelState.tunnels.any { it.tunnelNetworks.isNotEmpty() },
uiState.tunnels.firstOrNull { it.isEthernetTunnel },
uiState.tunnels.firstOrNull { it.isMobileDataTunnel },
uiState.tunnels.any { it.tunnelNetworks.isNotEmpty() },
)
}
@@ -94,8 +94,8 @@ fun AutoTunnelScreen(viewModel: AutoTunnelViewModel = hiltViewModel()) {
) {
Column {
val (title, buttonText, icon) =
remember(autoTunnelState.autoTunnelActive) {
when (autoTunnelState.autoTunnelActive) {
remember(uiState.autoTunnelActive) {
when (uiState.autoTunnelActive) {
true ->
Triple(
context.getString(R.string.auto_tunnel_running),
@@ -140,35 +140,20 @@ fun AutoTunnelScreen(viewModel: AutoTunnelViewModel = hiltViewModel()) {
stringResource(R.string.networks),
modifier = Modifier.padding(horizontal = 16.dp),
)
val activeNetworkType by
remember(autoTunnelState.connectivityState) {
val localizedNetworkType by
remember(uiState.connectivityState) {
derivedStateOf {
val connectivity = autoTunnelState.connectivityState
when {
connectivity?.ethernetConnected == true -> NetworkType.ETHERNET
connectivity?.wifiState?.connected == true -> NetworkType.WIFI
connectivity?.cellularConnected == true -> NetworkType.MOBILE_DATA
else -> NetworkType.NONE
when (uiState.connectivityState?.activeNetwork) {
is ActiveNetwork.Wifi -> context.getString(R.string.wifi)
is ActiveNetwork.Ethernet -> context.getString(R.string.ethernet)
is ActiveNetwork.Cellular -> context.getString(R.string.mobile_data)
is ActiveNetwork.Disconnected -> context.getString(R.string.no_network)
null -> context.getString(R.string.no_network)
}
}
}
val localizedNetworkType =
when (activeNetworkType) {
NetworkType.WIFI -> stringResource(R.string.wifi)
NetworkType.ETHERNET -> stringResource(R.string.ethernet)
NetworkType.MOBILE_DATA -> stringResource(R.string.mobile_data)
NetworkType.NONE -> stringResource(R.string.no_network)
}
val ssid by
remember(autoTunnelState.connectivityState) {
derivedStateOf {
autoTunnelState.connectivityState?.wifiState?.ssid
?: context.getString(R.string.unknown)
}
}
SurfaceRow(
leading = {
Icon(ImageVector.vectorResource(R.drawable.globe), contentDescription = null)
@@ -181,7 +166,7 @@ fun AutoTunnelScreen(viewModel: AutoTunnelViewModel = hiltViewModel()) {
}
},
description =
if (activeNetworkType == NetworkType.WIFI) {
(uiState.connectivityState?.activeNetwork as? ActiveNetwork.Wifi)?.let {
{
Column {
DescriptionText(
@@ -189,10 +174,8 @@ fun AutoTunnelScreen(viewModel: AutoTunnelViewModel = hiltViewModel()) {
append(stringResource(R.string.security_type))
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
append(
autoTunnelState.connectivityState
?.wifiState
?.securityType
?.name ?: stringResource(R.string.unknown)
it.securityType?.name
?: stringResource(R.string.unknown)
)
}
}
@@ -201,21 +184,24 @@ fun AutoTunnelScreen(viewModel: AutoTunnelViewModel = hiltViewModel()) {
buildAnnotatedString {
append(stringResource(R.string.network_name))
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
append(ssid)
append(it.ssid)
}
}
)
}
}
} else null,
},
trailing =
if (activeNetworkType == NetworkType.WIFI) {
if (uiState.connectivityState?.activeNetwork is ActiveNetwork.Wifi) {
{ Icon(Icons.Outlined.ContentCopy, contentDescription = null) }
} else null,
onClick =
if (activeNetworkType == NetworkType.WIFI) {
{ clipboard.copy(ssid, context.getString(R.string.wifi)) }
} else null,
onClick = {
when (val network = uiState.connectivityState?.activeNetwork) {
is ActiveNetwork.Wifi ->
clipboard.copy(network.ssid, context.getString(R.string.wifi))
else -> Unit
}
},
)
SurfaceRow(
@@ -223,7 +209,7 @@ fun AutoTunnelScreen(viewModel: AutoTunnelViewModel = hiltViewModel()) {
title = stringResource(R.string.tunnel_on_wifi),
trailing = { modifier ->
SwitchWithDivider(
checked = autoTunnelState.autoTunnelSettings.isTunnelOnWifiEnabled,
checked = uiState.autoTunnelSettings.isTunnelOnWifiEnabled,
onClick = { viewModel.setAutoTunnelOnWifiEnabled(it) },
modifier = modifier,
)
@@ -248,7 +234,7 @@ fun AutoTunnelScreen(viewModel: AutoTunnelViewModel = hiltViewModel()) {
title = stringResource(R.string.tunnel_mobile_data),
trailing = { modifier ->
SwitchWithDivider(
checked = autoTunnelState.autoTunnelSettings.isTunnelOnMobileDataEnabled,
checked = uiState.autoTunnelSettings.isTunnelOnMobileDataEnabled,
onClick = { viewModel.setTunnelOnCellular(it) },
modifier = modifier,
)
@@ -271,7 +257,7 @@ fun AutoTunnelScreen(viewModel: AutoTunnelViewModel = hiltViewModel()) {
title = stringResource(R.string.tunnel_on_ethernet),
trailing = { modifier ->
SwitchWithDivider(
checked = autoTunnelState.autoTunnelSettings.isTunnelOnEthernetEnabled,
checked = uiState.autoTunnelSettings.isTunnelOnEthernetEnabled,
onClick = { viewModel.setTunnelOnEthernet(it) },
modifier = modifier,
)
@@ -295,13 +281,13 @@ fun AutoTunnelScreen(viewModel: AutoTunnelViewModel = hiltViewModel()) {
description = { DescriptionText(stringResource(R.string.stop_on_internet_loss)) },
trailing = {
ThemedSwitch(
checked = autoTunnelState.autoTunnelSettings.isStopOnNoInternetEnabled,
checked = uiState.autoTunnelSettings.isStopOnNoInternetEnabled,
onClick = { viewModel.setStopOnNoInternetEnabled(it) },
)
},
onClick = {
viewModel.setStopOnNoInternetEnabled(
!autoTunnelState.autoTunnelSettings.isStopOnNoInternetEnabled
!uiState.autoTunnelSettings.isStopOnNoInternetEnabled
)
},
)
@@ -316,13 +302,11 @@ fun AutoTunnelScreen(viewModel: AutoTunnelViewModel = hiltViewModel()) {
title = stringResource(R.string.restart_at_boot),
trailing = {
ThemedSwitch(
checked = autoTunnelState.autoTunnelSettings.startOnBoot,
checked = uiState.autoTunnelSettings.startOnBoot,
onClick = { viewModel.setStartAtBoot(it) },
)
},
onClick = {
viewModel.setStartAtBoot(!autoTunnelState.autoTunnelSettings.startOnBoot)
},
onClick = { viewModel.setStartAtBoot(!uiState.autoTunnelSettings.startOnBoot) },
)
SurfaceRow(
leading = { Icon(Icons.Outlined.Settings, contentDescription = null) },
@@ -47,35 +47,37 @@ fun WifiSettingsScreen(viewModel: AutoTunnelViewModel = hiltViewModel()) {
val context = LocalContext.current
val navController = LocalNavController.current
val autoTunnelState by viewModel.container.stateFlow.collectAsStateWithLifecycle()
val uiState by viewModel.container.stateFlow.collectAsStateWithLifecycle()
if (autoTunnelState.isLoading) return
if (uiState.isLoading) return
var showLocationDialog by remember { mutableStateOf(false) }
var currentText by rememberSaveable { mutableStateOf("") }
LaunchedEffect(autoTunnelState.autoTunnelSettings.trustedNetworkSSIDs) { currentText = "" }
LaunchedEffect(uiState.autoTunnelSettings.trustedNetworkSSIDs) { currentText = "" }
val warnings by
remember(
autoTunnelState.connectivityState?.wifiState,
autoTunnelState.autoTunnelSettings.trustedNetworkSSIDs,
autoTunnelState.autoTunnelSettings.wifiDetectionMethod,
autoTunnelState.tunnels,
uiState.connectivityState,
uiState.autoTunnelSettings.trustedNetworkSSIDs,
uiState.autoTunnelSettings.wifiDetectionMethod,
uiState.tunnels,
) {
derivedStateOf {
val wifiState = autoTunnelState.connectivityState?.wifiState
val needsLocation =
autoTunnelState.autoTunnelSettings.wifiDetectionMethod
.needsLocationPermissions()
uiState.autoTunnelSettings.wifiDetectionMethod.needsLocationPermissions()
val hasConfigs =
autoTunnelState.autoTunnelSettings.trustedNetworkSSIDs.isNotEmpty() ||
autoTunnelState.tunnels.any { it.tunnelNetworks.isNotEmpty() }
uiState.autoTunnelSettings.trustedNetworkSSIDs.isNotEmpty() ||
uiState.tunnels.any { it.tunnelNetworks.isNotEmpty() }
val showServicesWarning =
(wifiState?.locationServicesEnabled == false) && needsLocation && hasConfigs
(uiState.connectivityState?.locationServicesEnabled == false) &&
needsLocation &&
hasConfigs
val showPermissionsWarning =
(wifiState?.locationPermissionsGranted == false) && needsLocation && hasConfigs
(uiState.connectivityState?.locationPermissionsGranted == false) &&
needsLocation &&
hasConfigs
showServicesWarning to showPermissionsWarning
}
@@ -138,9 +140,7 @@ fun WifiSettingsScreen(viewModel: AutoTunnelViewModel = hiltViewModel()) {
DescriptionText(
stringResource(
R.string.current_template,
autoTunnelState.autoTunnelSettings.wifiDetectionMethod.asTitleString(
context
),
uiState.autoTunnelSettings.wifiDetectionMethod.asTitleString(context),
)
)
},
@@ -157,14 +157,12 @@ fun WifiSettingsScreen(viewModel: AutoTunnelViewModel = hiltViewModel()) {
},
trailing = {
ThemedSwitch(
checked = autoTunnelState.autoTunnelSettings.isWildcardsEnabled,
checked = uiState.autoTunnelSettings.isWildcardsEnabled,
onClick = { viewModel.setWildcardsEnabled(it) },
)
},
onClick = {
viewModel.setWildcardsEnabled(
!autoTunnelState.autoTunnelSettings.isWildcardsEnabled
)
viewModel.setWildcardsEnabled(!uiState.autoTunnelSettings.isWildcardsEnabled)
},
)
}
@@ -174,14 +172,13 @@ fun WifiSettingsScreen(viewModel: AutoTunnelViewModel = hiltViewModel()) {
title = stringResource(R.string.trusted_wifi_names),
expandedContent = {
TrustedNetworkTextBox(
autoTunnelState.autoTunnelSettings.trustedNetworkSSIDs,
uiState.autoTunnelSettings.trustedNetworkSSIDs,
onDelete = { viewModel.removeTrustedNetworkName(it) },
currentText = currentText,
onSave = { ssid -> viewModel.saveTrustedNetworkName(ssid) },
onValueChange = { currentText = it },
supporting = {
if (autoTunnelState.autoTunnelSettings.isWildcardsEnabled)
WildcardsLabel()
if (uiState.autoTunnelSettings.isWildcardsEnabled) WildcardsLabel()
},
modifier = Modifier.padding(top = 4.dp),
)
@@ -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)
@@ -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 -2
View File
@@ -1,6 +1,6 @@
object Constants {
const val VERSION_NAME = "4.1.2"
const val VERSION_CODE = 40102
const val VERSION_NAME = "4.1.5"
const val VERSION_CODE = 40105
const val TARGET_SDK = 36
const val MIN_SDK = 26
const val APP_ID = "com.zaneschepke.wireguardautotunnel"
+18 -9
View File
@@ -2,28 +2,37 @@ default_platform(:android)
platform :android do
private_lane :build_aab do
gradle(
task: "clean bundleGoogleRelease",
properties: {
"noSplits" => true
}
)
end
desc 'Deploy a new internal version to the Google Play Store'
lane :internal do
gradle(task: "clean bundleGoogleRelease")
build_aab
upload_to_play_store(track: 'internal', skip_upload_apk: true)
end
desc "Deploy an alpha version to the Google Play"
lane :alpha do
gradle(task: "clean bundleGoogleRelease")
upload_to_play_store(track: 'alpha', skip_upload_apk: true)
lane :alpha do
build_aab
upload_to_play_store(track: 'alpha', skip_upload_apk: true)
end
desc "Deploy a beta version to the Google Play"
lane :beta do
gradle(task: "clean bundleGoogleRelease")
build_aab
upload_to_play_store(track: 'beta', skip_upload_apk: true)
end
desc "Deploy a new version to the Google Play"
lane :production do
gradle(task: "clean bundleGoogleRelease")
upload_to_play_store(skip_upload_apk: true)
end
lane :production do
build_aab
upload_to_play_store(skip_upload_apk: true)
end
end
@@ -0,0 +1,5 @@
What's new:
- Resource usage bugfix
- Improve network monitoring
- Tab navigation bugfix
- Tunnel metered default bugfix
@@ -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
@@ -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.
+9 -7
View File
@@ -1,27 +1,28 @@
[versions]
accompanist = "0.37.3"
activityCompose = "1.11.0"
amneziawgAndroid = "2.2.0"
amneziawgAndroid = "2.2.2"
androidx-junit = "1.3.0"
icmp4a = "1.0.0"
ipaddress = "5.5.1"
leakcanaryAndroid = "3.0-alpha-8"
orbitCompose = "10.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"
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,19 +33,19 @@ tunnel = "1.4.0"
androidGradlePlugin = "8.12.3"
kotlin = "2.2.21"
ksp = "2.3.0"
composeBom = "2025.10.01"
composeBom = "2025.11.00"
compose = "1.9.4"
icons = "1.7.8"
workRuntimeKtxVersion = "2.11.0"
zxingAndroidEmbedded = "4.3.0"
coreSplashscreen = "1.0.1"
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
@@ -130,6 +131,7 @@ androidx-compose-ui = { module = "androidx.compose.ui:ui", version.ref = "compos
androidx-compose-animation-graphics = { module = "androidx.compose.animation:animation-graphics", version.ref = "compose" }
icmp4a = { module = "com.marsounjan:icmp4a", version.ref = "icmp4a" }
ipaddress = { module = "com.github.seancfoley:ipaddress", version.ref = "ipaddress" }
leakcanary-android = { module = "com.squareup.leakcanary:leakcanary-android", version.ref = "leakcanaryAndroid" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" }
androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" }
@@ -1,11 +1,9 @@
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.Network
@@ -13,11 +11,10 @@ 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.channels.awaitClose
@@ -41,7 +38,6 @@ class AndroidNetworkMonitor(
companion object {
const val LOCATION_SERVICES_FILTER: String = "android.location.PROVIDERS_CHANGED"
const val ANDROID_UNKNOWN_SSID: String = "<unknown ssid>"
const val SHELL_COMMAND_TIMEOUT_MS = 2_000L
}
@@ -67,219 +63,170 @@ class AndroidNetworkMonitor(
private val locationManager =
appContext.getSystemService(Context.LOCATION_SERVICE) as LocationManager?
private val activeWifiNetworks =
ConcurrentHashMap<String, Pair<Network?, NetworkCapabilities?>>()
private val activeCellularNetworks =
ConcurrentHashMap<String, Pair<Network?, NetworkCapabilities?>>()
private val permissionsChangedFlow = MutableStateFlow(false)
private var permissionReceiver: BroadcastReceiver? = null
private var locationServicesReceiver: 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 var wifiInterfaceCallback: ConnectivityManager.NetworkCallback? = null // NEW
@OptIn(ExperimentalCoroutinesApi::class)
private val defaultNetworkFlow: Flow<TransportEvent> =
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) {
Timber.d("Default onAvailable: $network")
}
override fun onLost(network: Network) {
trySend(TransportEvent.Lost(network))
}
override fun onCapabilitiesChanged(
network: Network,
caps: NetworkCapabilities,
) {
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) {
Timber.d("Ignoring VPN default network change: $network")
return
}
trySend(TransportEvent.CapabilitiesChanged(network, caps))
}
}
} 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))
}
}
}
connectivityManager?.registerDefaultNetworkCallback(defaultNetworkCallback!!)
trySend(
TransportEvent.Permissions(
Permissions(
locationManager?.isLocationServicesEnabled() ?: false,
appContext.hasRequiredLocationPermissions(),
)
)
)
awaitClose {
connectivityManager?.unregisterNetworkCallback(defaultNetworkCallback!!)
}
}
}
@OptIn(ExperimentalCoroutinesApi::class)
private val wifiFlow: Flow<TransportEvent> =
combine(configurationListener.detectionMethod, permissionsChangedFlow) {
detectionMethod,
changed ->
Pair(detectionMethod, changed)
combine(configurationListener.detectionMethod, permissionsChangedFlow) { detectionMethod, _
->
detectionMethod
}
.flatMapLatest { (detectionMethod, _) -> // cancels previous flow
Timber.d("Permission or detection method changed, recreating wifiFlow")
createWifiNetworkCallbackFlow(detectionMethod)
}
private fun isAndroidTv(): Boolean =
appContext.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK)
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
}
.flatMapLatest { detectionMethod -> createWifiNetworkCallbackFlow(detectionMethod) }
private fun createWifiNetworkCallbackFlow(
detectionMethod: WifiDetectionMethod
): Flow<TransportEvent> = callbackFlow {
fun handleOnWifiLost(network: Network) {
Timber.d("Wi-Fi onLost: network=$network")
activeWifiNetworks.remove(network.toString())
if (activeWifiNetworks.isEmpty()) {
Timber.d("All Wi-Fi networks disconnected, clearing currentSsid and wifiConnected")
trySend(TransportEvent.Lost(network))
} else {
Timber.d("Wi-Fi onLost, but still connected to other networks, ignoring")
// This can happen when switching between APs of the same SSID
val onAvailable: (Network) -> Unit = { network -> 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))
}
}
fun handleOnWifiAvailable(network: Network) {
Timber.d("Wi-Fi onAvailable: network=$network")
activeWifiNetworks[network.toString()] = Pair(network, null)
trySend(TransportEvent.Available(network, detectionMethod))
}
fun handleOnWifiCapabilitiesChanged(
network: Network,
networkCapabilities: NetworkCapabilities,
) {
Timber.d("Wi-Fi onCapabilitiesChanged: network=$network")
activeWifiNetworks[network.toString()] = Pair(network, networkCapabilities)
trySend(
TransportEvent.CapabilitiesChanged(network, networkCapabilities, detectionMethod)
)
}
wifiCallback =
when {
detectionMethod == WifiDetectionMethod.LEGACY ||
Build.VERSION.SDK_INT < Build.VERSION_CODES.S ->
object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
handleOnWifiAvailable(network)
}
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)
override fun onLost(network: Network) {
handleOnWifiLost(network)
}
}
.also { Timber.d("Creating Wi-Fi callback without location info flags") }
else ->
object : ConnectivityManager.NetworkCallback(FLAG_INCLUDE_LOCATION_INFO) {
override fun onLost(network: Network) = onLost(network)
override fun onAvailable(network: Network) {
if (detectionMethod != WifiDetectionMethod.DEFAULT)
handleOnWifiAvailable(network)
}
override fun onCapabilitiesChanged(
network: Network,
caps: NetworkCapabilities,
) = onCapabilitiesChanged(network, caps)
}
} else {
object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) = onAvailable(network)
override fun onCapabilitiesChanged(
network: Network,
networkCapabilities: NetworkCapabilities,
) {
if (detectionMethod == WifiDetectionMethod.DEFAULT)
handleOnWifiCapabilitiesChanged(network, networkCapabilities)
}
override fun onLost(network: Network) = onLost(network)
override fun onLost(network: Network) {
handleOnWifiLost(network)
}
}
.also { Timber.d("Creating Wi-Fi callback with location info flags") }
override fun onCapabilitiesChanged(
network: Network,
caps: NetworkCapabilities,
) = onCapabilitiesChanged(network, caps)
}
}
val request =
NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
.build()
NetworkRequest.Builder().addTransportType(NetworkCapabilities.TRANSPORT_WIFI).build()
connectivityManager?.registerNetworkCallback(request, wifiCallback!!)
trySend(
TransportEvent.Permissions(
permissions =
Permissions(
locationManager?.isLocationServicesEnabled() ?: false,
hasRequiredLocationPermissions(),
)
)
)
awaitClose {
runCatching { connectivityManager?.unregisterNetworkCallback(wifiCallback!!) }
.onFailure { Timber.e(it, "Error unregistering network callback") }
}
}
private val wifiInterfaceFlow: Flow<Boolean> = callbackFlow {
val localCallback =
object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
Timber.d("Wi-Fi Interface onAvailable (Adapter ON): network=$network")
trySend(true)
}
override fun onLost(network: Network) {
Timber.d("Wi-Fi Interface onLost (Adapter OFF): network=$network")
trySend(false)
}
}
wifiInterfaceCallback = localCallback
// wifi Transport only
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") }
.onFailure { Timber.e(it, "Error unregistering WiFi network callback") }
}
}
private val cellularFlow: Flow<TransportEvent> = callbackFlow {
val cellularLocalCallback =
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) {
Timber.d("Cellular onAvailable: network=$network")
activeCellularNetworks[network.toString()] = Pair(network, null)
trySend(TransportEvent.Available(network))
}
override fun onAvailable(network: Network) = onAvailable(network)
override fun onLost(network: Network) {
Timber.d("Cellular onLost: network=$network")
activeCellularNetworks.remove(network.toString())
if (activeCellularNetworks.isEmpty()) {
Timber.d("All cellular networks disconnected")
trySend(TransportEvent.Lost(network))
} else {
Timber.d("Cellular onLost, but still connected to other, ignoring")
}
}
override fun onLost(network: Network) = onLost(network)
override fun onCapabilitiesChanged(
network: Network,
networkCapabilities: NetworkCapabilities,
) {
Timber.d("Cellular onCapabilitiesChanged: network=$network")
activeCellularNetworks[network.toString()] = Pair(network, networkCapabilities)
}
override fun onCapabilitiesChanged(network: Network, caps: NetworkCapabilities) =
onCapabilitiesChanged(network, caps)
}
cellularCallback = cellularLocalCallback
val request =
NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR)
.build()
connectivityManager?.registerNetworkCallback(request, cellularCallback!!)
trySend(TransportEvent.Unknown)
awaitClose {
@@ -289,27 +236,34 @@ class AndroidNetworkMonitor(
}
private val ethernetFlow: Flow<TransportEvent> = callbackFlow {
val ethernetLocalCallback =
object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
Timber.d("Ethernet onAvailable: network=$network")
trySend(TransportEvent.Available(network))
}
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))
}
override fun onLost(network: Network) {
Timber.d("Ethernet onLost: network=$network")
trySend(TransportEvent.Lost(network))
}
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)
}
ethernetCallback = ethernetLocalCallback
val request =
NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.addTransportType(NetworkCapabilities.TRANSPORT_ETHERNET)
.build()
connectivityManager?.registerNetworkCallback(request, ethernetCallback!!)
trySend(TransportEvent.Unknown)
awaitClose {
@@ -318,24 +272,93 @@ 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 ->
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 ->
if (
event.networkCapabilities.hasCapability(
NetworkCapabilities.NET_CAPABILITY_INTERNET
)
)
event.networkCapabilities
else null
is TransportEvent.Lost -> null
else -> null
}
}
.stateIn(applicationScope, SharingStarted.Eagerly, null)
private val ethernetStateFlow: Flow<NetworkCapabilities?> =
ethernetFlow
.map { event ->
when (event) {
is TransportEvent.CapabilitiesChanged ->
if (
event.networkCapabilities.hasCapability(
NetworkCapabilities.NET_CAPABILITY_INTERNET
)
)
event.networkCapabilities
else null
is TransportEvent.Lost -> null
else -> null
}
}
.stateIn(applicationScope, SharingStarted.Eagerly, null)
private suspend fun getSsidByDetectionMethod(
detectionMethod: WifiDetectionMethod?,
networkCapabilities: NetworkCapabilities?,
): String {
val method = detectionMethod ?: WifiDetectionMethod.DEFAULT
val method = detectionMethod ?: DEFAULT
return try {
when (method) {
WifiDetectionMethod.DEFAULT ->
DEFAULT ->
networkCapabilities?.getWifiSsid()
?: wifiManager?.getWifiSsid()
?: ANDROID_UNKNOWN_SSID
WifiDetectionMethod.LEGACY ->
wifiManager?.getWifiSsid() ?: ANDROID_UNKNOWN_SSID
WifiDetectionMethod.ROOT ->
LEGACY -> wifiManager?.getWifiSsid() ?: ANDROID_UNKNOWN_SSID
ROOT ->
withTimeoutOrNull(SHELL_COMMAND_TIMEOUT_MS) {
configurationListener.rootShell.getCurrentWifiName()
} ?: ANDROID_UNKNOWN_SSID
WifiDetectionMethod.SHIZUKU ->
SHIZUKU ->
withTimeoutOrNull(SHELL_COMMAND_TIMEOUT_MS) {
ShizukuShell(applicationScope)
.singleResponseCommand(WIFI_SSID_SHELL_COMMAND)
@@ -350,91 +373,99 @@ class AndroidNetworkMonitor(
.also { Timber.d("Current SSID via ${method.name}: $it") }
}
// prevent false positive late mobile data changes to combat android api quirks
private fun isLateCellularChange(previous: ConnectivityState, new: ConnectivityState): Boolean {
return (previous.wifiState.connected != new.wifiState.connected &&
previous.wifiState.ssid == new.wifiState.ssid &&
previous.cellularConnected != new.cellularConnected)
}
private data class NetworkData(
val defaultEvent: TransportEvent,
val wifiCaps: NetworkCapabilities?,
val cellularCaps: NetworkCapabilities?,
val ethernetCaps: NetworkCapabilities?,
)
private val networkFlows: Flow<NetworkData> =
combine(defaultNetworkFlow, wifiStateFlow, cellularStateFlow, ethernetStateFlow) {
defaultEvent,
wifiCaps,
cellularCaps,
ethernetCaps ->
NetworkData(defaultEvent, wifiCaps, cellularCaps, ethernetCaps)
}
override val connectivityStateFlow: SharedFlow<ConnectivityState> =
combine(
wifiFlow.scan(
WifiState(
locationPermissionsGranted = hasRequiredLocationPermissions(),
locationServicesEnabled =
locationManager?.isLocationServicesEnabled() ?: false,
)
) { previous, event ->
when (event) {
is TransportEvent.Available ->
previous.copy(
connected = true,
ssid =
getSsidByDetectionMethod(
event.wifiDetectionMethod ?: WifiDetectionMethod.DEFAULT,
null,
),
securityType = wifiManager?.getCurrentSecurityType(),
)
is TransportEvent.CapabilitiesChanged ->
previous.copy(
connected = true,
ssid =
getSsidByDetectionMethod(
event.wifiDetectionMethod ?: WifiDetectionMethod.DEFAULT,
null,
),
securityType = wifiManager?.getCurrentSecurityType(),
)
is TransportEvent.Permissions -> {
previous.copy(
locationPermissionsGranted =
event.permissions.locationPermissionGranted,
locationServicesEnabled = event.permissions.locationServicesEnabled,
)
}
is TransportEvent.Lost ->
previous.copy(connected = false, securityType = null, ssid = null)
is TransportEvent.Unknown -> previous
}
},
cellularFlow,
ethernetFlow,
wifiInterfaceFlow,
) { wifiState, cellular, ethernet, isWifiInterfaceOn ->
val cellularConnected = cellular is TransportEvent.Available
val ethernetConnected = ethernet is TransportEvent.Available
combine(networkFlows, airplaneModeFlow, configurationListener.detectionMethod) {
networkData,
isAirplaneOn,
detectionMethod ->
val defaultEvent = networkData.defaultEvent
val wifiCaps = networkData.wifiCaps
val cellularCaps = networkData.cellularCaps
val ethernetCaps = networkData.ethernetCaps
// if wifi is off, force wifi state to disconnected
val finalWifiState =
if (!isWifiInterfaceOn) {
wifiState.copy(connected = false, securityType = null, ssid = null)
} else {
wifiState
val permissions =
when (defaultEvent) {
is TransportEvent.Permissions -> defaultEvent.permissions
else ->
Permissions(
locationManager?.isLocationServicesEnabled() ?: false,
appContext.hasRequiredLocationPermissions(),
)
}
ConnectivityState(
finalWifiState,
cellularConnected = cellularConnected,
ethernetConnected = ethernetConnected,
val defaultCaps =
when (defaultEvent) {
is TransportEvent.CapabilitiesChanged -> defaultEvent.networkCapabilities
else ->
connectivityManager?.getNetworkCapabilities(
connectivityManager.activeNetwork
)
}
?: return@combine ConnectivityState(
ActiveNetwork.Disconnected,
permissions.locationServicesEnabled,
permissions.locationPermissionGranted,
)
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,
)
.also { Timber.i("Connectivity Status: $it") }
}
.scan(
ConnectivityState(
WifiState(
locationPermissionsGranted = hasRequiredLocationPermissions(),
locationServicesEnabled =
locationManager?.isLocationServicesEnabled() ?: false,
)
)
) { previous, current ->
if (isLateCellularChange(previous, current)) {
Timber.d("Skipping late cellular change")
previous
} else {
current
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())
}
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
}
}
ConnectivityState(
activeNetwork,
permissions.locationServicesEnabled,
permissions.locationPermissionGranted,
)
}
}
.distinctUntilChanged()
@@ -450,7 +481,7 @@ class AndroidNetworkMonitor(
init {
val receiverFlags =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
Context.RECEIVER_EXPORTED // System broadcast
Context.RECEIVER_EXPORTED
} else {
0
}
@@ -459,19 +490,16 @@ class AndroidNetworkMonitor(
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")
// get Wi-Fi info on permission change and update permission state
if (
connectivityStateFlow.replayCache
.firstOrNull()
?.wifiState
?.locationPermissionsGranted != isGranted
) {
Timber.d(
"Location permissions have changed, canceling and restarting callback flow"
)
activeWifiNetworks.clear()
permissionsChangedFlow.update { !permissionsChangedFlow.value }
}
}
@@ -491,14 +519,11 @@ class AndroidNetworkMonitor(
if (
connectivityStateFlow.replayCache
.firstOrNull()
?.wifiState
?.locationServicesEnabled != isLocationServicesEnabled
) {
Timber.d(
"Location services have changed, canceling and restarting callback flow"
)
// trigger cancel and recreate of callbackFlow
activeWifiNetworks.clear()
permissionsChangedFlow.update { !permissionsChangedFlow.value }
}
}
@@ -518,12 +543,10 @@ class AndroidNetworkMonitor(
permissionReceiver?.let { appContext.unregisterReceiver(it) }
locationServicesReceiver?.let { appContext.unregisterReceiver(it) }
defaultNetworkCallback?.let { connectivityManager?.unregisterNetworkCallback(it) }
wifiCallback?.let { connectivityManager?.unregisterNetworkCallback(it) }
cellularCallback?.let { connectivityManager?.unregisterNetworkCallback(it) }
ethernetCallback?.let { connectivityManager?.unregisterNetworkCallback(it) }
wifiInterfaceCallback?.let {
connectivityManager?.unregisterNetworkCallback(it)
} // NEW
}
.onFailure { Timber.e(it, "Error during cleanup") }
Timber.d("NetworkMonitor cleaned up")
@@ -3,25 +3,38 @@ package com.zaneschepke.networkmonitor
import com.zaneschepke.networkmonitor.util.WifiSecurityType
data class ConnectivityState(
val wifiState: WifiState,
val ethernetConnected: Boolean = false,
val cellularConnected: Boolean = false,
) {
fun hasConnectivity(): Boolean = wifiState.connected || ethernetConnected || cellularConnected
}
data class WifiState(
val connected: Boolean = false,
val ssid: String? = null,
val securityType: WifiSecurityType? = null,
val activeNetwork: ActiveNetwork,
val locationPermissionsGranted: Boolean,
val locationServicesEnabled: Boolean,
) {
override fun toString(): String =
"connected=$connected, ssid=${if(ssid == AndroidNetworkMonitor.ANDROID_UNKNOWN_SSID || ssid == null) ssid else ssid.first() + "..."} securityType=$securityType, locationPermissionsGranted=$locationPermissionsGranted"
fun hasInternet(): Boolean = activeNetwork !is ActiveNetwork.Disconnected
override fun toString(): String {
val networkInfo =
when (activeNetwork) {
is ActiveNetwork.Disconnected -> "Disconnected"
is ActiveNetwork.Ethernet -> "Ethernet"
is ActiveNetwork.Cellular -> "Cellular"
is ActiveNetwork.Wifi -> {
val ssidDisplay =
if (activeNetwork.ssid == AndroidNetworkMonitor.ANDROID_UNKNOWN_SSID)
activeNetwork.ssid
else activeNetwork.ssid.first() + "..."
"Wifi(ssid=$ssidDisplay, securityType=${activeNetwork.securityType})"
}
}
return "activeNetwork=$networkInfo, locationPermissionsGranted=$locationPermissionsGranted, locationServicesEnabled=$locationServicesEnabled"
}
}
data class Permissions(
val locationServicesEnabled: Boolean = false,
val locationPermissionGranted: Boolean = false,
)
data class Permissions(val locationServicesEnabled: Boolean, val locationPermissionGranted: Boolean)
sealed class ActiveNetwork {
data object Disconnected : ActiveNetwork()
data class Wifi(val ssid: String, val securityType: WifiSecurityType?) : ActiveNetwork()
data object Cellular : ActiveNetwork()
data object Ethernet : ActiveNetwork()
}
@@ -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
}