Compare commits

..

14 Commits

Author SHA1 Message Date
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
28 changed files with 1161 additions and 534 deletions
+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),
)
+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.4"
const val VERSION_CODE = 40104
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
+3 -1
View File
@@ -1,10 +1,11 @@
[versions]
accompanist = "0.37.3"
activityCompose = "1.11.0"
amneziawgAndroid = "2.2.0"
amneziawgAndroid = "2.2.1"
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"
@@ -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,166 @@ 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,
) {
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 +232,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 +268,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 +369,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 +477,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 +486,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 +515,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 +539,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
}