mirror of
https://github.com/wgtunnel/android.git
synced 2026-07-03 14:07:49 +02:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c8adb380b | |||
| 614f97fd14 | |||
| fbd470f5d2 | |||
| 5f89b2ed31 | |||
| 9503a3284b | |||
| 68c1a19bd3 | |||
| f3bb6667c3 | |||
| 244a990c37 | |||
| cbf07600b4 | |||
| ec8d90d13d | |||
| 85acca8604 |
@@ -0,0 +1,513 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 31,
|
||||
"identityHash": "1dee3799f1c6526c48723fd2fee58d11",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "tunnel_config",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` 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, `quick_config` TEXT NOT NULL DEFAULT '', `dynamic_dns` INTEGER NOT NULL DEFAULT false, `is_ethernet_tunnel` INTEGER NOT NULL DEFAULT false, `prefer_ipv6` INTEGER NOT NULL DEFAULT false, `position` INTEGER NOT NULL DEFAULT 0, `auto_tunnel_apps` TEXT NOT NULL DEFAULT '[]', `is_metered` INTEGER NOT NULL DEFAULT false, `ipv4_fallback` INTEGER NOT NULL DEFAULT false, `ipv6_restore` INTEGER NOT NULL DEFAULT false)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"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": "quickConfig",
|
||||
"columnName": "quick_config",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "''"
|
||||
},
|
||||
{
|
||||
"fieldPath": "dynamicDnsEnabled",
|
||||
"columnName": "dynamic_dns",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "false"
|
||||
},
|
||||
{
|
||||
"fieldPath": "isEthernetTunnel",
|
||||
"columnName": "is_ethernet_tunnel",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "false"
|
||||
},
|
||||
{
|
||||
"fieldPath": "isIpv6Preferred",
|
||||
"columnName": "prefer_ipv6",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "false"
|
||||
},
|
||||
{
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"fieldPath": "ipv4FallbackEnabled",
|
||||
"columnName": "ipv4_fallback",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "false"
|
||||
},
|
||||
{
|
||||
"fieldPath": "ipv6RestoreEnabled",
|
||||
"columnName": "ipv6_restore",
|
||||
"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, `screen_recording_security` INTEGER NOT NULL DEFAULT 1, `global_amnezia_enabled` INTEGER NOT NULL DEFAULT 0, `tunnel_scripting_enabled` 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": "tunnelMode",
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"fieldPath": "screenRecordingSecurityEnabled",
|
||||
"columnName": "screen_recording_security",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "1"
|
||||
},
|
||||
{
|
||||
"fieldPath": "isGlobalAmneziaEnabled",
|
||||
"columnName": "global_amnezia_enabled",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "tunnelScriptingEnabled",
|
||||
"columnName": "tunnel_scripting_enabled",
|
||||
"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, `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, `disable_on_captive_portal` INTEGER NOT NULL DEFAULT 1)",
|
||||
"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": "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"
|
||||
},
|
||||
{
|
||||
"fieldPath": "disableTunnelOnCaptivePortal",
|
||||
"columnName": "disable_on_captive_portal",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "1"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "monitoring_settings",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `is_local_logs_enabled` INTEGER NOT NULL DEFAULT 0, `tunnel_statistics_enabled` INTEGER NOT NULL DEFAULT 1, `tunnel_statistics_poll_interval` INTEGER NOT NULL DEFAULT 3)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isLocalLogsEnabled",
|
||||
"columnName": "is_local_logs_enabled",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "tunnelStatisticsEnabled",
|
||||
"columnName": "tunnel_statistics_enabled",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "1"
|
||||
},
|
||||
{
|
||||
"fieldPath": "tunnelStatisticsPollInterval",
|
||||
"columnName": "tunnel_statistics_poll_interval",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "3"
|
||||
}
|
||||
],
|
||||
"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, '1dee3799f1c6526c48723fd2fee58d11')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -251,26 +251,6 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
if (showLocalNetworkRationale) {
|
||||
LocalNetworkPermissionDialog(
|
||||
onDismiss = {
|
||||
showLocalNetworkRationale = false
|
||||
toaster.show(
|
||||
message = context.getString(R.string.local_network_permission_denied),
|
||||
type = ToastType.Warning,
|
||||
duration = 6000.milliseconds,
|
||||
)
|
||||
},
|
||||
onContinue = {
|
||||
showLocalNetworkRationale = false
|
||||
|
||||
localNetworkPermissionLauncher.launch(
|
||||
Manifest.permission.ACCESS_LOCAL_NETWORK
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
val startingStack = buildList {
|
||||
add(Route.Tunnels)
|
||||
if (intent?.action == Intent.ACTION_APPLICATION_PREFERENCES) add(Route.Settings)
|
||||
@@ -360,6 +340,27 @@ class MainActivity : AppCompatActivity() {
|
||||
},
|
||||
)
|
||||
|
||||
if (showLocalNetworkRationale) {
|
||||
LocalNetworkPermissionDialog(
|
||||
onDismiss = {
|
||||
showLocalNetworkRationale = false
|
||||
toaster.show(
|
||||
message =
|
||||
context.getString(R.string.local_network_permission_denied),
|
||||
type = ToastType.Warning,
|
||||
duration = 6000.milliseconds,
|
||||
)
|
||||
},
|
||||
onAttest = {
|
||||
showLocalNetworkRationale = false
|
||||
|
||||
localNetworkPermissionLauncher.launch(
|
||||
Manifest.permission.ACCESS_LOCAL_NETWORK
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
uiState.pendingWgImportUrl?.let { url ->
|
||||
val host = Uri.parse(url).host ?: url
|
||||
InfoDialog(
|
||||
|
||||
+12
-3
@@ -9,10 +9,12 @@ import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelMode
|
||||
import com.zaneschepke.wireguardautotunnel.domain.events.TunnelActionEvent
|
||||
import com.zaneschepke.wireguardautotunnel.domain.model.DnsSettings
|
||||
import com.zaneschepke.wireguardautotunnel.domain.model.GeneralSettings
|
||||
import com.zaneschepke.wireguardautotunnel.domain.model.LockdownSettings
|
||||
import com.zaneschepke.wireguardautotunnel.domain.model.MonitoringSettings
|
||||
import com.zaneschepke.wireguardautotunnel.domain.model.ProxySettings
|
||||
import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConfig
|
||||
import com.zaneschepke.wireguardautotunnel.domain.repository.GeneralSettingRepository
|
||||
import com.zaneschepke.wireguardautotunnel.domain.repository.LockdownSettingsRepository
|
||||
import com.zaneschepke.wireguardautotunnel.domain.repository.MonitoringSettingsRepository
|
||||
import com.zaneschepke.wireguardautotunnel.domain.repository.ProxySettingsRepository
|
||||
import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository
|
||||
@@ -44,6 +46,7 @@ class TunnelCoordinator(
|
||||
dnsSettingsRepository: RoomDnsSettingsRepository,
|
||||
monitoringSettingsRepository: MonitoringSettingsRepository,
|
||||
proxyRepository: ProxySettingsRepository,
|
||||
lockdownModeRepository: LockdownSettingsRepository,
|
||||
scope: CoroutineScope,
|
||||
) {
|
||||
|
||||
@@ -66,6 +69,7 @@ class TunnelCoordinator(
|
||||
val dns: DnsSettings,
|
||||
val monitoring: MonitoringSettings,
|
||||
val proxy: ProxySettings,
|
||||
val lockdown: LockdownSettings,
|
||||
)
|
||||
|
||||
private val runtimeSettingsSnapshot =
|
||||
@@ -74,12 +78,14 @@ class TunnelCoordinator(
|
||||
dnsSettingsRepository.flow,
|
||||
monitoringSettingsRepository.flow,
|
||||
proxyRepository.flow,
|
||||
) { general, dns, monitoring, proxy ->
|
||||
lockdownModeRepository.flow,
|
||||
) { general, dns, monitoring, proxy, lockdown ->
|
||||
RuntimeSettingsSnapshot(
|
||||
general = general,
|
||||
dns = dns,
|
||||
monitoring = monitoring,
|
||||
proxy = proxy,
|
||||
lockdown = lockdown,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -149,6 +155,7 @@ class TunnelCoordinator(
|
||||
val dnsSettings = snapshot.dns
|
||||
val proxySettings = snapshot.proxy
|
||||
val monitoringSettings = snapshot.monitoring
|
||||
val lockdownSettings = snapshot.lockdown
|
||||
|
||||
val config = tunnelConfig.getConfig()
|
||||
val policy =
|
||||
@@ -184,8 +191,10 @@ class TunnelCoordinator(
|
||||
}
|
||||
|
||||
TunnelMode.LOCK_DOWN -> {
|
||||
|
||||
BackendMode.Proxy.KillSwitchPrimary(runConfig)
|
||||
BackendMode.Proxy.KillSwitchPrimary(
|
||||
runConfig,
|
||||
lockdownSettings.toKillSwitchConfig(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ import com.zaneschepke.wireguardautotunnel.data.entity.TunnelConfig
|
||||
DnsSettings::class,
|
||||
LockdownSettings::class,
|
||||
],
|
||||
version = 30,
|
||||
version = 31,
|
||||
autoMigrations =
|
||||
[
|
||||
AutoMigration(from = 1, to = 2),
|
||||
@@ -63,6 +63,7 @@ import com.zaneschepke.wireguardautotunnel.data.entity.TunnelConfig
|
||||
AutoMigration(from = 26, to = 27, spec = GlobalsMigration::class),
|
||||
AutoMigration(from = 27, to = 28, spec = DonationMigration::class),
|
||||
AutoMigration(from = 29, to = 30, spec = SingleConfigMigration::class),
|
||||
AutoMigration(from = 30, to = 31),
|
||||
],
|
||||
exportSchema = true,
|
||||
)
|
||||
|
||||
+3
@@ -18,4 +18,7 @@ interface AutoTunnelSettingsDao {
|
||||
|
||||
@Query("UPDATE auto_tunnel_settings SET is_tunnel_enabled = :enabled")
|
||||
suspend fun updateAutoTunnelEnabled(enabled: Boolean)
|
||||
|
||||
@Query("UPDATE auto_tunnel_settings SET disable_on_captive_portal = :enabled")
|
||||
suspend fun updateDisableOnCaptivePortal(enabled: Boolean)
|
||||
}
|
||||
|
||||
+2
@@ -27,4 +27,6 @@ data class AutoTunnelSettings(
|
||||
@ColumnInfo(name = "wifi_detection_method", defaultValue = "0")
|
||||
val wifiDetectionMethod: WifiDetectionMethod = WifiDetectionMethod.fromValue(0),
|
||||
@ColumnInfo(name = "start_on_boot", defaultValue = "0") val startOnBoot: Boolean = false,
|
||||
@ColumnInfo(name = "disable_on_captive_portal", defaultValue = "1")
|
||||
val disableTunnelOnCaptivePortal: Boolean = true,
|
||||
)
|
||||
|
||||
+4
@@ -26,4 +26,8 @@ class RoomAutoTunnelSettingsRepository(private val autoTunnelSettingsDao: AutoTu
|
||||
override suspend fun updateAutoTunnelEnabled(enabled: Boolean) {
|
||||
autoTunnelSettingsDao.updateAutoTunnelEnabled(enabled)
|
||||
}
|
||||
|
||||
override suspend fun updateDisableOnCaptivePortal(enabled: Boolean) {
|
||||
autoTunnelSettingsDao.updateDisableOnCaptivePortal(enabled)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ val coordinatorModule = module {
|
||||
get(),
|
||||
get(),
|
||||
get(),
|
||||
get(),
|
||||
get(named(Scope.APPLICATION)),
|
||||
)
|
||||
}
|
||||
|
||||
+1
@@ -14,4 +14,5 @@ data class AutoTunnelSettings(
|
||||
val isTunnelOnUnsecureEnabled: Boolean = false,
|
||||
val wifiDetectionMethod: WifiDetectionMethod = WifiDetectionMethod.fromValue(0),
|
||||
val startOnBoot: Boolean = false,
|
||||
val disableTunnelOnCaptivePortal: Boolean = true,
|
||||
)
|
||||
|
||||
+2
@@ -11,4 +11,6 @@ interface AutoTunnelSettingsRepository {
|
||||
suspend fun getAutoTunnelSettings(): AutoTunnelSettings
|
||||
|
||||
suspend fun updateAutoTunnelEnabled(enabled: Boolean)
|
||||
|
||||
suspend fun updateDisableOnCaptivePortal(enabled: Boolean)
|
||||
}
|
||||
|
||||
@@ -11,16 +11,20 @@ sealed class ActiveNetwork {
|
||||
|
||||
data object Cellular : ActiveNetwork()
|
||||
|
||||
data class Wifi(val ssid: String, val isSecure: Boolean?) : ActiveNetwork()
|
||||
data class Wifi(
|
||||
val ssid: String,
|
||||
val isSecure: Boolean?,
|
||||
val requiresCaptivePortalLogin: 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
|
||||
}
|
||||
// Has a network that can actually transfer data (not suspended)
|
||||
val hasUsableNetwork: Boolean = false,
|
||||
)
|
||||
|
||||
fun ConnectivityState.toDomain(): NetworkState {
|
||||
val domainNetwork: ActiveNetwork =
|
||||
@@ -33,7 +37,11 @@ fun ConnectivityState.toDomain(): NetworkState {
|
||||
null -> null
|
||||
else -> true
|
||||
}
|
||||
ActiveNetwork.Wifi(ssid = network.ssid, isSecure = isSecure)
|
||||
ActiveNetwork.Wifi(
|
||||
ssid = network.ssid,
|
||||
isSecure = isSecure,
|
||||
requiresCaptivePortalLogin(),
|
||||
)
|
||||
}
|
||||
is MonitorActiveNetwork.Cellular -> ActiveNetwork.Cellular
|
||||
is MonitorActiveNetwork.Ethernet -> ActiveNetwork.Ethernet
|
||||
@@ -44,5 +52,6 @@ fun ConnectivityState.toDomain(): NetworkState {
|
||||
activeNetwork = domainNetwork,
|
||||
locationPermissionGranted = this.locationPermissionsGranted,
|
||||
locationServicesEnabled = this.locationServicesEnabled,
|
||||
hasUsableNetwork = hasUsableNetwork(),
|
||||
)
|
||||
}
|
||||
|
||||
+13
-1
@@ -28,7 +28,19 @@ class AutoTunnelEngine {
|
||||
|
||||
val activeTunnelIds = backend.activeTunnels.keys.toSet()
|
||||
|
||||
if (!network.hasInternet()) {
|
||||
val isOnCaptivePortalWifi =
|
||||
network.activeNetwork is ActiveNetwork.Wifi &&
|
||||
network.activeNetwork.requiresCaptivePortalLogin
|
||||
|
||||
if (isOnCaptivePortalWifi && settings.disableTunnelOnCaptivePortal) {
|
||||
return if (activeTunnelIds.isNotEmpty()) {
|
||||
Decision.Sync(start = emptySet(), stop = activeTunnelIds)
|
||||
} else {
|
||||
Decision.None
|
||||
}
|
||||
}
|
||||
|
||||
if (!network.hasUsableNetwork) {
|
||||
return if (settings.isStopOnNoInternetEnabled) {
|
||||
Decision.StopDueToNoInternet
|
||||
} else {
|
||||
|
||||
+2
-2
@@ -192,11 +192,11 @@ class AutoTunnelService : LifecycleService() {
|
||||
reconciliationMutex.withLock {
|
||||
val currentNetworkState = networkEngine.stableState.value?.state?.toDomain()
|
||||
|
||||
val stillNoInternet = currentNetworkState?.hasInternet() == false
|
||||
val stillNoUsableNetwork = currentNetworkState?.hasUsableNetwork == false
|
||||
val stopOnNoInternetEnabled =
|
||||
autoTunnelRepository.flow.firstOrNull()?.isStopOnNoInternetEnabled == true
|
||||
|
||||
if (stillNoInternet && stopOnNoInternetEnabled) {
|
||||
if (stillNoUsableNetwork && stopOnNoInternetEnabled) {
|
||||
val currentActiveIds =
|
||||
tunnelCoordinator.backendStatus.value.activeTunnels.keys
|
||||
|
||||
|
||||
+33
-35
@@ -1,12 +1,11 @@
|
||||
package com.zaneschepke.wireguardautotunnel.ui.common.dialog
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -15,11 +14,12 @@ import androidx.compose.ui.unit.dp
|
||||
import com.zaneschepke.wireguardautotunnel.R
|
||||
|
||||
@Composable
|
||||
fun LocalNetworkPermissionDialog(onDismiss: () -> Unit, onContinue: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(text = stringResource(R.string.local_network_permission_title)) },
|
||||
text = {
|
||||
fun LocalNetworkPermissionDialog(onDismiss: () -> Unit, onAttest: () -> Unit) {
|
||||
InfoDialog(
|
||||
onAttest = onAttest,
|
||||
onDismiss = onDismiss,
|
||||
title = stringResource(R.string.local_network_permission_title),
|
||||
body = {
|
||||
Column {
|
||||
Text(
|
||||
text = stringResource(R.string.local_network_permission_intro),
|
||||
@@ -31,45 +31,43 @@ fun LocalNetworkPermissionDialog(onDismiss: () -> Unit, onContinue: () -> Unit)
|
||||
Text(
|
||||
text = stringResource(R.string.local_network_permission_issues_intro),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.local_network_permission_feature_tunnels),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.local_network_permission_feature_autotunnel),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.local_network_permission_feature_proxy),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.local_network_permission_nearby_devices),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(
|
||||
text = stringResource(R.string.local_network_permission_feature_tunnels),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.local_network_permission_feature_autotunnel),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.local_network_permission_feature_proxy),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.local_network_permission_recommendation),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.local_network_permission_nearby_devices),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onContinue) { Text(text = stringResource(R.string._continue)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(text = stringResource(R.string.not_now)) }
|
||||
},
|
||||
confirmText = stringResource(R.string._continue),
|
||||
)
|
||||
}
|
||||
|
||||
+16
@@ -10,6 +10,7 @@ import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Filter1
|
||||
import androidx.compose.material.icons.outlined.Map
|
||||
import androidx.compose.material.icons.outlined.PublicOff
|
||||
import androidx.compose.material.icons.outlined.WifiFind
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -191,6 +192,21 @@ fun WifiSettingsScreen(viewModel: AutoTunnelViewModel = koinViewModel()) {
|
||||
)
|
||||
},
|
||||
)
|
||||
SurfaceRow(
|
||||
leading = { Icon(Icons.Outlined.PublicOff, contentDescription = null) },
|
||||
title = stringResource(R.string.stop_while_captive_portal),
|
||||
onClick = {
|
||||
viewModel.setDisabledOnCaptivePortal(
|
||||
!uiState.autoTunnelSettings.disableTunnelOnCaptivePortal
|
||||
)
|
||||
},
|
||||
trailing = {
|
||||
ThemedSwitch(
|
||||
checked = uiState.autoTunnelSettings.disableTunnelOnCaptivePortal,
|
||||
onClick = { viewModel.setDisabledOnCaptivePortal(it) },
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
Column {
|
||||
GroupLabel(stringResource(R.string.tunnels), Modifier.padding(horizontal = 16.dp))
|
||||
|
||||
@@ -6,15 +6,11 @@ object Constants {
|
||||
const val BASE_LOG_FILE_NAME = "wg_tunnel_logs"
|
||||
|
||||
const val VPN_SETTINGS_PACKAGE = "android.net.vpn.SETTINGS"
|
||||
const val SYSTEM_EXEMPT_SERVICE_TYPE_ID = 1 shl 10
|
||||
const val SPECIAL_USE_SERVICE_TYPE_ID = 1 shl 30
|
||||
|
||||
const val QR_CODE_NAME_PROPERTY = "# Name ="
|
||||
|
||||
const val FDROID_FLAVOR = "fdroid"
|
||||
const val GOOGLE_PLAY_FLAVOR = "google"
|
||||
const val STANDALONE_FLAVOR = "standalone"
|
||||
const val RELEASE = "release"
|
||||
|
||||
const val BASE_RELEASE_URL = "https://github.com/wgtunnel/wgtunnel/releases/tag/"
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ object DnsValidator {
|
||||
return Result.Valid
|
||||
}
|
||||
|
||||
private fun validateUdp(value: String): DnsValidator.Result {
|
||||
private fun validateUdp(value: String): Result {
|
||||
val parts = value.split(":")
|
||||
|
||||
val host = parts.getOrNull(0)?.trim()
|
||||
@@ -93,14 +93,14 @@ object DnsValidator {
|
||||
|
||||
// basic IP/hostname sanity check
|
||||
if (!isValidHostOrIp(host)) {
|
||||
return DnsValidator.Result.Invalid(DnsError.InvalidIpOrHost)
|
||||
return Result.Invalid(DnsError.InvalidIpOrHost)
|
||||
}
|
||||
|
||||
if (port !in 1..65535) {
|
||||
return DnsValidator.Result.Invalid(DnsError.InvalidPort)
|
||||
return Result.Invalid(DnsError.InvalidPort)
|
||||
}
|
||||
|
||||
return DnsValidator.Result.Valid
|
||||
return Result.Valid
|
||||
}
|
||||
|
||||
private fun isValidHostOrIp(value: String): Boolean {
|
||||
|
||||
@@ -1,22 +1,9 @@
|
||||
package com.zaneschepke.wireguardautotunnel.util
|
||||
|
||||
import com.vdurmont.semver4j.Semver
|
||||
import java.math.BigDecimal
|
||||
import kotlin.math.pow
|
||||
import timber.log.Timber
|
||||
|
||||
object NumberUtils {
|
||||
private const val BYTES_IN_KB = 1024.0
|
||||
private val BYTES_IN_MB = BYTES_IN_KB.pow(2.0)
|
||||
private val keyValidationRegex = """^[A-Za-z0-9+/]{42}[AEIMQUYcgkosw480]=${'$'}""".toRegex()
|
||||
|
||||
fun bytesToMB(bytes: Long): BigDecimal {
|
||||
return bytes.toBigDecimal().divide(BYTES_IN_MB.toBigDecimal())
|
||||
}
|
||||
|
||||
fun isValidKey(key: String): Boolean {
|
||||
return key.matches(keyValidationRegex)
|
||||
}
|
||||
|
||||
fun generateRandomTunnelName(): String {
|
||||
return "tunnel${randomFive()}"
|
||||
|
||||
+1
-80
@@ -1,26 +1,17 @@
|
||||
package com.zaneschepke.wireguardautotunnel.util.extensions
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Context.POWER_SERVICE
|
||||
import android.content.Intent
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.content.pm.PackageInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.PowerManager
|
||||
import android.provider.Settings
|
||||
import android.service.quicksettings.TileService
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.net.toUri
|
||||
import com.zaneschepke.wireguardautotunnel.MainActivity
|
||||
import com.zaneschepke.wireguardautotunnel.R
|
||||
import com.zaneschepke.wireguardautotunnel.service.tile.AutoTunnelControlTile
|
||||
import com.zaneschepke.wireguardautotunnel.service.tile.TunnelControlTile
|
||||
import com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.splittunnel.state.TunnelApp
|
||||
import com.zaneschepke.wireguardautotunnel.util.Constants
|
||||
import com.zaneschepke.wireguardautotunnel.util.FileUtils
|
||||
import java.io.File
|
||||
@@ -36,11 +27,6 @@ fun Context.openWebUrl(url: String): Result<Unit> = runCatching {
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
fun Context.isBatteryOptimizationsDisabled(): Boolean {
|
||||
val pm = getSystemService(POWER_SERVICE) as PowerManager
|
||||
return pm.isIgnoringBatteryOptimizations(packageName)
|
||||
}
|
||||
|
||||
fun Context.launchNotificationSettings() {
|
||||
if (isRunningOnTv()) return launchAppSettings()
|
||||
val settingsIntent: Intent =
|
||||
@@ -87,21 +73,6 @@ fun Context.hasSAFSupport(mimeType: String): Boolean {
|
||||
}
|
||||
}
|
||||
|
||||
fun Context.launchShareFile(file: File) {
|
||||
FileProvider.getUriForFile(this, getString(R.string.provider), file)
|
||||
val shareIntent =
|
||||
Intent().apply {
|
||||
action = Intent.ACTION_SEND
|
||||
type = FileUtils.ALL_FILE_TYPES
|
||||
putExtra(Intent.EXTRA_STREAM, file)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
val chooserIntent =
|
||||
Intent.createChooser(shareIntent, "").apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }
|
||||
this.startActivity(chooserIntent)
|
||||
}
|
||||
|
||||
fun Context.launchSupportEmail(): Result<Unit> = runCatching {
|
||||
val intent =
|
||||
Intent(Intent.ACTION_SENDTO).apply {
|
||||
@@ -128,7 +99,7 @@ fun Context.isRunningOnTv(): Boolean {
|
||||
fun Context.launchVpnSettings(): Result<Unit> {
|
||||
return kotlin.runCatching {
|
||||
val intent =
|
||||
Intent(Constants.VPN_SETTINGS_PACKAGE).apply { setFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }
|
||||
Intent(Constants.VPN_SETTINGS_PACKAGE).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK }
|
||||
startActivity(intent)
|
||||
}
|
||||
}
|
||||
@@ -147,14 +118,6 @@ fun Context.launchLocationServicesSettings(): Result<Unit> {
|
||||
}
|
||||
}
|
||||
|
||||
fun Context.launchSettings(): Result<Unit> {
|
||||
return kotlin.runCatching {
|
||||
val intent =
|
||||
Intent(Settings.ACTION_SETTINGS).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK }
|
||||
startActivity(intent)
|
||||
}
|
||||
}
|
||||
|
||||
fun Context.launchAppSettings() {
|
||||
kotlin
|
||||
.runCatching {
|
||||
@@ -171,48 +134,6 @@ fun Context.launchAppSettings() {
|
||||
}
|
||||
}
|
||||
|
||||
fun Context.requestTunnelTileServiceStateUpdate() =
|
||||
runCatching {
|
||||
TileService.requestListeningState(
|
||||
this,
|
||||
ComponentName(this, TunnelControlTile::class.java),
|
||||
)
|
||||
}
|
||||
.onFailure { Timber.w(it) }
|
||||
|
||||
fun Context.requestAutoTunnelTileServiceUpdate() =
|
||||
runCatching {
|
||||
TileService.requestListeningState(
|
||||
this,
|
||||
ComponentName(this, AutoTunnelControlTile::class.java),
|
||||
)
|
||||
}
|
||||
.onFailure { Timber.w(it) }
|
||||
|
||||
fun Context.getAllInternetCapablePackages(): List<PackageInfo> {
|
||||
val permissions = arrayOf(Manifest.permission.INTERNET)
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
packageManager.getPackagesHoldingPermissions(
|
||||
permissions,
|
||||
PackageManager.PackageInfoFlags.of(0L),
|
||||
)
|
||||
} else {
|
||||
packageManager.getPackagesHoldingPermissions(permissions, 0)
|
||||
}
|
||||
}
|
||||
|
||||
fun Context.getSplitTunnelApps(): List<TunnelApp> {
|
||||
val packages = getAllInternetCapablePackages()
|
||||
return packages
|
||||
.filter { it.applicationInfo != null }
|
||||
.map { pkg ->
|
||||
TunnelApp(
|
||||
packageManager.getApplicationLabel(pkg.applicationInfo!!).toString(),
|
||||
pkg.packageName,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun Context.canInstallPackages(): Boolean {
|
||||
return packageManager.canRequestPackageInstalls()
|
||||
}
|
||||
|
||||
-8
@@ -1,8 +0,0 @@
|
||||
package com.zaneschepke.wireguardautotunnel.util.extensions
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
|
||||
fun <K, V> Flow<Map<K, V>>.distinctByKeys(): Flow<Map<K, V>> {
|
||||
return distinctUntilChanged { old, new -> old.keys == new.keys }
|
||||
}
|
||||
@@ -18,10 +18,6 @@ fun <T, R : Comparable<R>> List<T>.isSortedBy(selector: (T) -> R): Boolean {
|
||||
return zipWithNext().all { (a, b) -> selector(a) <= selector(b) }
|
||||
}
|
||||
|
||||
fun Int.toMillis(): Long {
|
||||
return this * 1_000L
|
||||
}
|
||||
|
||||
fun Double.round(decimals: Int): Double {
|
||||
val factor = 10.0.pow(decimals)
|
||||
return (this * factor).roundToInt() / factor
|
||||
|
||||
@@ -145,6 +145,10 @@ class AutoTunnelViewModel(
|
||||
)
|
||||
}
|
||||
|
||||
fun setDisabledOnCaptivePortal(enabled: Boolean) = intent {
|
||||
autoTunnelRepository.updateDisableOnCaptivePortal(enabled)
|
||||
}
|
||||
|
||||
fun removeTunnelNetwork(tunnel: TunnelConfig, ssid: String) = intent {
|
||||
tunnelsRepository.save(
|
||||
tunnel.copy(
|
||||
|
||||
@@ -544,14 +544,13 @@
|
||||
|
||||
<string name="local_network_permission_issues_intro">Without this permission, you may experience issues with:</string>
|
||||
|
||||
<string name="local_network_permission_feature_tunnels">- Connecting to certain tunnels</string>
|
||||
<string name="local_network_permission_feature_tunnels">- Connection issues with split tunneling, LAN bypass, or servers hosted on your local network</string>
|
||||
<string name="local_network_permission_feature_autotunnel">- Auto-tunneling and split tunneling features</string>
|
||||
<string name="local_network_permission_feature_proxy">- Local proxy and bypass functionality</string>
|
||||
|
||||
<string name="local_network_permission_recommendation">Granting this permission is strongly recommended.</string>
|
||||
<string name="local_network_permission_nearby_devices">Note: Android labels this permission as “nearby devices”.</string>
|
||||
|
||||
<string name="not_now">Not now</string>
|
||||
|
||||
<string name="local_network_permission_denied">Local network access denied. Some features may not work properly</string>
|
||||
<string name="stop_while_captive_portal">Stop tunnel while captive portal is present</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
object Constants {
|
||||
const val VERSION_NAME = "5.0.6"
|
||||
const val VERSION_CODE = 50006
|
||||
const val VERSION_NAME = "5.0.7"
|
||||
const val VERSION_CODE = 50007
|
||||
const val TARGET_SDK = 37
|
||||
const val MIN_SDK = 26
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
What's new:
|
||||
- Auto tunnel feature to disable active tunnels on captive portal networks
|
||||
- Improve local network permission dialog wording and theming
|
||||
- Bugfix for Dynamic DNS feature not working correctly
|
||||
- Bugfix for Lockdown mode with IPv4 only tunnels
|
||||
- Bugfix for DNS resolution hanging issues for peer resolution
|
||||
@@ -6,7 +6,7 @@ icmp4a = "1.0.0"
|
||||
ipaddress = "5.6.2"
|
||||
koinBom = "4.2.2"
|
||||
kotlinxCoroutinesAndroid = "1.11.0"
|
||||
leakcanaryAndroid = "3.0-alpha-8"
|
||||
leakcanaryAndroid = "3.0-alpha-9"
|
||||
lottieCompose = "6.7.1"
|
||||
orbitCompose = "11.0.0"
|
||||
roomdatabasebackup = "1.1.0"
|
||||
@@ -19,7 +19,7 @@ espressoCore = "3.7.0"
|
||||
navigation3 = "1.1.3"
|
||||
junit = "4.13.2"
|
||||
kotlinx-serialization-json = "1.11.0"
|
||||
ktorClientCore = "3.5.0"
|
||||
ktorClientCore = "3.5.1"
|
||||
lifecycle-runtime-compose = "2.11.0"
|
||||
material3 = "1.5.0-alpha22"
|
||||
pinLockCompose = "1.0.5"
|
||||
|
||||
+176
-169
@@ -25,7 +25,6 @@ import com.zaneschepke.networkmonitor.util.hasRequiredLocationPermissions
|
||||
import com.zaneschepke.networkmonitor.util.isAirplaneModeOn
|
||||
import com.zaneschepke.networkmonitor.util.isLocationServicesEnabled
|
||||
import java.net.Inet6Address
|
||||
import kotlin.concurrent.atomics.ExperimentalAtomicApi
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
@@ -35,14 +34,15 @@ import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.shareIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import timber.log.Timber
|
||||
|
||||
@@ -93,18 +93,102 @@ class AndroidNetworkMonitor(
|
||||
|
||||
private val permissionsChangedFlow = MutableStateFlow(false)
|
||||
|
||||
private var permissionReceiver: BroadcastReceiver? = null
|
||||
private var locationServicesReceiver: BroadcastReceiver? = null
|
||||
private var airplaneReceiver: BroadcastReceiver? = null
|
||||
private var defaultNetworkCallback: ConnectivityManager.NetworkCallback? = null
|
||||
private var wifiCallback: ConnectivityManager.NetworkCallback? = null
|
||||
private var cellularCallback: ConnectivityManager.NetworkCallback? = null
|
||||
private var ethernetCallback: ConnectivityManager.NetworkCallback? = null
|
||||
|
||||
private val airplaneModeState = MutableStateFlow(appContext.isAirplaneModeOn())
|
||||
private val activeCellularNetworks =
|
||||
MutableStateFlow<Map<Network, NetworkCapabilities>>(emptyMap())
|
||||
private val airplaneModeFlow: Flow<Boolean> = airplaneModeState.asStateFlow()
|
||||
|
||||
private val permissionCheckFlow: Flow<Unit> = callbackFlow {
|
||||
val receiver =
|
||||
object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action == actionPermissionCheck) {
|
||||
val isGranted = appContext.hasRequiredLocationPermissions()
|
||||
Timber.d("Received permission check broadcast, isGranted: $isGranted")
|
||||
|
||||
if (
|
||||
connectivityStateFlow.replayCache
|
||||
.firstOrNull()
|
||||
?.locationPermissionsGranted != isGranted
|
||||
) {
|
||||
Timber.d("Location permissions changed, restarting flows")
|
||||
permissionsChangedFlow.update { !permissionsChangedFlow.value }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val flags =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
Context.RECEIVER_NOT_EXPORTED
|
||||
} else 0
|
||||
|
||||
appContext.registerReceiver(receiver, IntentFilter(actionPermissionCheck), flags)
|
||||
awaitClose { appContext.unregisterReceiver(receiver) }
|
||||
}
|
||||
|
||||
private val locationServicesFlow: Flow<Unit> = callbackFlow {
|
||||
val receiver =
|
||||
object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action == LOCATION_SERVICES_FILTER) {
|
||||
val enabled = locationManager?.isLocationServicesEnabled() ?: false
|
||||
Timber.d("Location services changed: $enabled")
|
||||
|
||||
if (
|
||||
connectivityStateFlow.replayCache
|
||||
.firstOrNull()
|
||||
?.locationServicesEnabled != enabled
|
||||
) {
|
||||
Timber.d("Location services changed, restarting flows")
|
||||
permissionsChangedFlow.update { !permissionsChangedFlow.value }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val flags =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
Context.RECEIVER_EXPORTED
|
||||
} else 0
|
||||
|
||||
appContext.registerReceiver(receiver, IntentFilter(LOCATION_SERVICES_FILTER), flags)
|
||||
awaitClose { appContext.unregisterReceiver(receiver) }
|
||||
}
|
||||
|
||||
private val airplaneModeReceiverFlow: Flow<Boolean> = callbackFlow {
|
||||
val receiver =
|
||||
object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action == Intent.ACTION_AIRPLANE_MODE_CHANGED) {
|
||||
val isOn = intent.getBooleanExtra("state", false)
|
||||
Timber.d("Airplane mode changed: $isOn")
|
||||
if (isOn) activeCellularNetworks.value = emptyMap()
|
||||
airplaneModeState.update { isOn }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val flags =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
Context.RECEIVER_EXPORTED
|
||||
} else 0
|
||||
|
||||
appContext.registerReceiver(
|
||||
receiver,
|
||||
IntentFilter(Intent.ACTION_AIRPLANE_MODE_CHANGED),
|
||||
flags,
|
||||
)
|
||||
awaitClose { appContext.unregisterReceiver(receiver) }
|
||||
}
|
||||
|
||||
init {
|
||||
applicationScope.launch { permissionCheckFlow.collect() }
|
||||
applicationScope.launch { locationServicesFlow.collect() }
|
||||
applicationScope.launch { airplaneModeReceiverFlow.collect() }
|
||||
|
||||
// Set initial airplane mode state
|
||||
airplaneModeState.update { appContext.isAirplaneModeOn() }
|
||||
}
|
||||
|
||||
// tracking to prevent races that occur when VPN is first activated and to prevent redundant
|
||||
// location queries in Legacy mode
|
||||
@@ -193,10 +277,11 @@ class AndroidNetworkMonitor(
|
||||
}
|
||||
.flatMapLatest { detectionMethod ->
|
||||
callbackFlow {
|
||||
if (
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && detectionMethod == DEFAULT
|
||||
) {
|
||||
defaultNetworkCallback =
|
||||
val defaultNetworkCallback =
|
||||
if (
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
|
||||
detectionMethod == DEFAULT
|
||||
) {
|
||||
object :
|
||||
ConnectivityManager.NetworkCallback(FLAG_INCLUDE_LOCATION_INFO) {
|
||||
override fun onAvailable(network: Network) {
|
||||
@@ -214,8 +299,7 @@ class AndroidNetworkMonitor(
|
||||
trySend(TransportEvent.CapabilitiesChanged(network, caps))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
defaultNetworkCallback =
|
||||
} else {
|
||||
object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) {
|
||||
Timber.d("Default onAvailable: $network")
|
||||
@@ -232,8 +316,8 @@ class AndroidNetworkMonitor(
|
||||
trySend(TransportEvent.CapabilitiesChanged(network, caps))
|
||||
}
|
||||
}
|
||||
}
|
||||
connectivityManager?.registerDefaultNetworkCallback(defaultNetworkCallback!!)
|
||||
}
|
||||
connectivityManager?.registerDefaultNetworkCallback(defaultNetworkCallback)
|
||||
|
||||
trySend(
|
||||
TransportEvent.Permissions(
|
||||
@@ -245,7 +329,7 @@ class AndroidNetworkMonitor(
|
||||
)
|
||||
|
||||
awaitClose {
|
||||
connectivityManager?.unregisterNetworkCallback(defaultNetworkCallback!!)
|
||||
connectivityManager?.unregisterNetworkCallback(defaultNetworkCallback)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -276,7 +360,7 @@ class AndroidNetworkMonitor(
|
||||
}
|
||||
}
|
||||
|
||||
wifiCallback =
|
||||
val wifiCallback =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && detectionMethod == DEFAULT) {
|
||||
object : ConnectivityManager.NetworkCallback(FLAG_INCLUDE_LOCATION_INFO) {
|
||||
override fun onAvailable(network: Network) = onAvailable(network)
|
||||
@@ -306,12 +390,12 @@ class AndroidNetworkMonitor(
|
||||
.apply { addTransportType(NetworkCapabilities.TRANSPORT_WIFI) }
|
||||
.build()
|
||||
|
||||
connectivityManager?.registerNetworkCallback(request, wifiCallback!!)
|
||||
connectivityManager?.registerNetworkCallback(request, wifiCallback)
|
||||
|
||||
trySend(TransportEvent.Unknown)
|
||||
|
||||
awaitClose {
|
||||
runCatching { connectivityManager?.unregisterNetworkCallback(wifiCallback!!) }
|
||||
runCatching { connectivityManager?.unregisterNetworkCallback(wifiCallback) }
|
||||
.onFailure { Timber.e(it, "Error unregistering WiFi network callback") }
|
||||
}
|
||||
}
|
||||
@@ -319,14 +403,19 @@ class AndroidNetworkMonitor(
|
||||
private val cellularFlow: Flow<TransportEvent> = callbackFlow {
|
||||
val onAvailable: (Network) -> Unit = { network ->
|
||||
Timber.d("Cellular onAvailable: $network")
|
||||
// Defensive cleanup
|
||||
activeCellularNetworks.update { it - network }
|
||||
val caps = connectivityManager?.getNetworkCapabilities(network)
|
||||
if (caps != null && caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) {
|
||||
activeCellularNetworks.update { it + (network to caps) }
|
||||
trySend(TransportEvent.CapabilitiesChanged(network, caps))
|
||||
}
|
||||
}
|
||||
|
||||
val onLost: (Network) -> Unit = { network ->
|
||||
Timber.d("Cellular onLost: $network")
|
||||
activeCellularNetworks.update { it - network }
|
||||
trySend(TransportEvent.Lost(network))
|
||||
}
|
||||
|
||||
val onCapabilitiesChanged: (Network, NetworkCapabilities) -> Unit = { network, caps ->
|
||||
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) {
|
||||
activeCellularNetworks.update { it + (network to caps) }
|
||||
@@ -334,7 +423,7 @@ class AndroidNetworkMonitor(
|
||||
}
|
||||
}
|
||||
|
||||
cellularCallback =
|
||||
val cellularCallback =
|
||||
object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) = onAvailable(network)
|
||||
|
||||
@@ -348,10 +437,10 @@ class AndroidNetworkMonitor(
|
||||
NetworkRequest.Builder()
|
||||
.addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR)
|
||||
.build()
|
||||
connectivityManager?.registerNetworkCallback(request, cellularCallback!!)
|
||||
connectivityManager?.registerNetworkCallback(request, cellularCallback)
|
||||
trySend(TransportEvent.Unknown)
|
||||
awaitClose {
|
||||
runCatching { connectivityManager?.unregisterNetworkCallback(cellularCallback!!) }
|
||||
runCatching { connectivityManager?.unregisterNetworkCallback(cellularCallback) }
|
||||
.onFailure { Timber.e(it, "Error unregistering cellular network callback") }
|
||||
}
|
||||
}
|
||||
@@ -369,7 +458,7 @@ class AndroidNetworkMonitor(
|
||||
trySend(TransportEvent.CapabilitiesChanged(network, caps))
|
||||
}
|
||||
|
||||
ethernetCallback =
|
||||
val ethernetCallback =
|
||||
object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) = onAvailable(network)
|
||||
|
||||
@@ -384,12 +473,12 @@ class AndroidNetworkMonitor(
|
||||
.apply { addTransportType(NetworkCapabilities.TRANSPORT_ETHERNET) }
|
||||
.build()
|
||||
|
||||
connectivityManager?.registerNetworkCallback(request, ethernetCallback!!)
|
||||
connectivityManager?.registerNetworkCallback(request, ethernetCallback)
|
||||
|
||||
trySend(TransportEvent.Unknown)
|
||||
|
||||
awaitClose {
|
||||
runCatching { connectivityManager?.unregisterNetworkCallback(ethernetCallback!!) }
|
||||
runCatching { connectivityManager?.unregisterNetworkCallback(ethernetCallback) }
|
||||
.onFailure { Timber.e(it, "Error unregistering ethernet network callback") }
|
||||
}
|
||||
}
|
||||
@@ -442,25 +531,12 @@ class AndroidNetworkMonitor(
|
||||
.also { Timber.d("Current SSID via ${method.name}: $it") }
|
||||
}
|
||||
|
||||
private fun hasGoodCellularNetwork(): Boolean =
|
||||
activeCellularNetworks.value.values.any { caps ->
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) &&
|
||||
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
|
||||
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) &&
|
||||
(Build.VERSION.SDK_INT < Build.VERSION_CODES.P ||
|
||||
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_SUSPENDED))
|
||||
}
|
||||
|
||||
private fun getGoodCellularNetwork(): Network? =
|
||||
activeCellularNetworks.value.entries
|
||||
.firstOrNull { (_, caps) ->
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) &&
|
||||
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
|
||||
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) &&
|
||||
(Build.VERSION.SDK_INT < Build.VERSION_CODES.P ||
|
||||
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_SUSPENDED))
|
||||
}
|
||||
?.key
|
||||
private fun hasValidatedInternet(caps: NetworkCapabilities?): Boolean {
|
||||
if (caps == null) return false
|
||||
return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
|
||||
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) &&
|
||||
hasNotSuspended(caps)
|
||||
}
|
||||
|
||||
// default network events don't contain detailed capability information of underlying networks,
|
||||
// so we need to track separately
|
||||
@@ -481,11 +557,37 @@ class AndroidNetworkMonitor(
|
||||
NetworkData(defaultEvent, wifiEvent, cellularEvent, ethernetEvent)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class, ExperimentalAtomicApi::class, FlowPreview::class)
|
||||
private fun hasNotSuspended(caps: NetworkCapabilities?): Boolean {
|
||||
if (caps == null) return false
|
||||
return Build.VERSION.SDK_INT < Build.VERSION_CODES.P ||
|
||||
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_SUSPENDED)
|
||||
}
|
||||
|
||||
// For multi-sim selection, prefers foreground, then validated internet, then not suspended
|
||||
private fun pickBestCellularNetworkEntry(): Map.Entry<Network, NetworkCapabilities>? {
|
||||
if (activeCellularNetworks.value.isEmpty()) return null
|
||||
|
||||
return activeCellularNetworks.value.entries.maxByOrNull { (_, caps) ->
|
||||
var score = 0
|
||||
if (caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_FOREGROUND)) score += 100
|
||||
if (hasValidatedInternet(caps)) score += 50
|
||||
if (hasNotSuspended(caps)) score += 20
|
||||
if (
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.P &&
|
||||
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_CONGESTED)
|
||||
) {
|
||||
score += 10
|
||||
}
|
||||
if (caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)) score += 5
|
||||
score
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
override val connectivityStateFlow: SharedFlow<ConnectivityState> =
|
||||
combine(
|
||||
networkFlows,
|
||||
airplaneModeFlow,
|
||||
airplaneModeState,
|
||||
configurationListener.detectionMethod,
|
||||
privateDnsFlow,
|
||||
) { networkData, isAirplaneOn, detectionMethod, privateDnsSettings ->
|
||||
@@ -514,8 +616,10 @@ class AndroidNetworkMonitor(
|
||||
if (defaultCaps == null || defaultNetwork == null) {
|
||||
return@combine ConnectivityState(
|
||||
activeNetwork = ActiveNetwork.Disconnected(),
|
||||
cellularNetworks = emptyMap(),
|
||||
locationPermissionsGranted = permissions.locationPermissionGranted,
|
||||
locationServicesEnabled = permissions.locationServicesEnabled,
|
||||
airplaneModeOn = isAirplaneOn,
|
||||
vpnState = VpnState.Inactive,
|
||||
)
|
||||
}
|
||||
@@ -537,23 +641,18 @@ class AndroidNetworkMonitor(
|
||||
val physicalNetwork: ActiveNetwork =
|
||||
when {
|
||||
networkData.ethernetEvent is TransportEvent.CapabilitiesChanged &&
|
||||
networkData.ethernetEvent.networkCapabilities?.let { caps ->
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) &&
|
||||
caps.hasCapability(
|
||||
NetworkCapabilities.NET_CAPABILITY_INTERNET
|
||||
) &&
|
||||
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
|
||||
} == true -> {
|
||||
ActiveNetwork.Ethernet(networkData.ethernetEvent.network)
|
||||
networkData.ethernetEvent.networkCapabilities?.hasTransport(
|
||||
NetworkCapabilities.TRANSPORT_ETHERNET
|
||||
) == true -> {
|
||||
ActiveNetwork.Ethernet(
|
||||
networkData.ethernetEvent.network,
|
||||
networkData.ethernetEvent.networkCapabilities,
|
||||
)
|
||||
}
|
||||
|
||||
networkData.wifiNetworkEvent is TransportEvent.CapabilitiesChanged &&
|
||||
networkData.wifiNetworkEvent.networkCapabilities?.let { caps ->
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) &&
|
||||
caps.hasCapability(
|
||||
NetworkCapabilities.NET_CAPABILITY_INTERNET
|
||||
) &&
|
||||
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
|
||||
} == true -> {
|
||||
|
||||
val wifiEvent = networkData.wifiNetworkEvent
|
||||
@@ -597,20 +696,23 @@ class AndroidNetworkMonitor(
|
||||
securityType,
|
||||
currentNetworkId,
|
||||
wifiEvent.network,
|
||||
wifiEvent.networkCapabilities,
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
val bestCellularEntry =
|
||||
pickBestCellularNetworkEntry()
|
||||
?: activeCellularNetworks.value.entries.firstOrNull()
|
||||
|
||||
// only count cellular as connected if validated AND not in airplane mode
|
||||
!isAirplaneOn && hasGoodCellularNetwork() -> {
|
||||
val goodNetwork = getGoodCellularNetwork()
|
||||
if (goodNetwork != null) {
|
||||
ActiveNetwork.Cellular(goodNetwork)
|
||||
if (bestCellularEntry != null) {
|
||||
ActiveNetwork.Cellular(
|
||||
bestCellularEntry.key,
|
||||
bestCellularEntry.value,
|
||||
)
|
||||
} else {
|
||||
ActiveNetwork.Disconnected()
|
||||
}
|
||||
}
|
||||
|
||||
else -> ActiveNetwork.Disconnected()
|
||||
}
|
||||
|
||||
lastKnownActiveNetwork.value = physicalNetwork
|
||||
@@ -638,9 +740,11 @@ class AndroidNetworkMonitor(
|
||||
|
||||
ConnectivityState(
|
||||
activeNetwork = physicalNetwork,
|
||||
cellularNetworks = activeCellularNetworks.value,
|
||||
locationPermissionsGranted = permissions.locationPermissionGranted,
|
||||
locationServicesEnabled = permissions.locationServicesEnabled,
|
||||
vpnState = vpnState,
|
||||
airplaneModeOn = isAirplaneOn,
|
||||
effectiveDnsInfo = effectiveDns,
|
||||
underlyingDnsInfo = underlyingDns,
|
||||
hasIpv6 = hasIpv6Support(underlyingNetwork, physicalNetwork),
|
||||
@@ -660,101 +764,4 @@ class AndroidNetworkMonitor(
|
||||
Timber.d("Sending broadcast: $action")
|
||||
appContext.sendBroadcast(intent)
|
||||
}
|
||||
|
||||
init {
|
||||
val exportedFlags =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
Context.RECEIVER_EXPORTED
|
||||
} else {
|
||||
0
|
||||
}
|
||||
val localFlags =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
Context.RECEIVER_NOT_EXPORTED
|
||||
} else {
|
||||
0
|
||||
}
|
||||
|
||||
permissionReceiver =
|
||||
object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action == actionPermissionCheck) {
|
||||
val isGranted = appContext.hasRequiredLocationPermissions()
|
||||
Timber.d("Received permission check broadcast, isGranted: $isGranted")
|
||||
if (
|
||||
connectivityStateFlow.replayCache
|
||||
.firstOrNull()
|
||||
?.locationPermissionsGranted != isGranted
|
||||
) {
|
||||
Timber.d(
|
||||
"Location permissions have changed, canceling and restarting callback flow"
|
||||
)
|
||||
permissionsChangedFlow.update { !permissionsChangedFlow.value }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
appContext.registerReceiver(
|
||||
permissionReceiver,
|
||||
IntentFilter(actionPermissionCheck),
|
||||
localFlags,
|
||||
)
|
||||
|
||||
locationServicesReceiver =
|
||||
object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action == LOCATION_SERVICES_FILTER) {
|
||||
Timber.d("Received location services broadcast")
|
||||
val isLocationServicesEnabled = locationManager?.isLocationServicesEnabled()
|
||||
if (
|
||||
connectivityStateFlow.replayCache
|
||||
.firstOrNull()
|
||||
?.locationServicesEnabled != isLocationServicesEnabled
|
||||
) {
|
||||
Timber.d(
|
||||
"Location services have changed, canceling and restarting callback flow"
|
||||
)
|
||||
permissionsChangedFlow.update { !permissionsChangedFlow.value }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
appContext.registerReceiver(
|
||||
locationServicesReceiver,
|
||||
IntentFilter(LOCATION_SERVICES_FILTER),
|
||||
exportedFlags,
|
||||
)
|
||||
|
||||
airplaneReceiver =
|
||||
object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action == Intent.ACTION_AIRPLANE_MODE_CHANGED) {
|
||||
val isAirplaneOn = intent.getBooleanExtra("state", false)
|
||||
Timber.d("Airplane mode changed to new state: $isAirplaneOn")
|
||||
airplaneModeState.update { isAirplaneOn }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
appContext.registerReceiver(
|
||||
airplaneReceiver,
|
||||
IntentFilter(Intent.ACTION_AIRPLANE_MODE_CHANGED),
|
||||
exportedFlags,
|
||||
)
|
||||
airplaneModeState.update { appContext.isAirplaneModeOn() }
|
||||
}
|
||||
|
||||
override fun destroy() {
|
||||
runCatching {
|
||||
permissionReceiver?.let { appContext.unregisterReceiver(it) }
|
||||
locationServicesReceiver?.let { appContext.unregisterReceiver(it) }
|
||||
airplaneReceiver?.let { appContext.unregisterReceiver(it) }
|
||||
defaultNetworkCallback?.let { connectivityManager?.unregisterNetworkCallback(it) }
|
||||
wifiCallback?.let { connectivityManager?.unregisterNetworkCallback(it) }
|
||||
cellularCallback?.let { connectivityManager?.unregisterNetworkCallback(it) }
|
||||
ethernetCallback?.let { connectivityManager?.unregisterNetworkCallback(it) }
|
||||
}
|
||||
.onFailure { Timber.e(it, "Error during cleanup") }
|
||||
Timber.d("NetworkMonitor cleaned up")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,71 @@
|
||||
package com.zaneschepke.networkmonitor
|
||||
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.os.Build
|
||||
import com.zaneschepke.networkmonitor.util.WifiSecurityType
|
||||
|
||||
data class ConnectivityState(
|
||||
val activeNetwork: ActiveNetwork,
|
||||
val cellularNetworks: Map<Network, NetworkCapabilities>,
|
||||
val locationPermissionsGranted: Boolean,
|
||||
val locationServicesEnabled: Boolean,
|
||||
val vpnState: VpnState,
|
||||
val effectiveDnsInfo: DnsInfo = DnsInfo(),
|
||||
val underlyingDnsInfo: DnsInfo = DnsInfo(),
|
||||
val hasIpv6: Boolean = false,
|
||||
val airplaneModeOn: Boolean = false,
|
||||
) {
|
||||
fun hasInternet(): Boolean = activeNetwork !is ActiveNetwork.Disconnected
|
||||
|
||||
fun hasUsableNetwork(): Boolean {
|
||||
if (!hasActiveNetwork()) return false
|
||||
|
||||
return when (activeNetwork) {
|
||||
is ActiveNetwork.Cellular -> hasAnyUsableCellular()
|
||||
is ActiveNetwork.Wifi,
|
||||
is ActiveNetwork.Ethernet -> hasInternetCapability()
|
||||
is ActiveNetwork.Disconnected -> false
|
||||
}
|
||||
}
|
||||
|
||||
fun requiresCaptivePortalLogin(): Boolean {
|
||||
return activeNetwork is ActiveNetwork.Wifi &&
|
||||
activeNetwork.capabilities?.hasCapability(
|
||||
NetworkCapabilities.NET_CAPABILITY_CAPTIVE_PORTAL
|
||||
) == true
|
||||
}
|
||||
|
||||
fun hasAnyUsableCellular(): Boolean {
|
||||
if (cellularNetworks.isEmpty()) return false
|
||||
|
||||
if (cellularNetworks.values.any { hasValidatedInternet(it) }) return true
|
||||
|
||||
return cellularNetworks.values.any {
|
||||
it.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) && hasNotSuspended(it)
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasValidatedInternet(caps: NetworkCapabilities?): Boolean {
|
||||
if (caps == null) return false
|
||||
return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
|
||||
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) &&
|
||||
hasNotSuspended(caps)
|
||||
}
|
||||
|
||||
private fun hasInternetCapability(
|
||||
caps: NetworkCapabilities? = activeNetwork.capabilities
|
||||
): Boolean {
|
||||
if (caps == null) return false
|
||||
return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
}
|
||||
|
||||
private fun hasNotSuspended(caps: NetworkCapabilities?): Boolean {
|
||||
if (caps == null) return false
|
||||
return Build.VERSION.SDK_INT < Build.VERSION_CODES.P ||
|
||||
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_SUSPENDED)
|
||||
}
|
||||
|
||||
fun hasActiveNetwork(): Boolean = activeNetwork !is ActiveNetwork.Disconnected
|
||||
|
||||
override fun toString(): String {
|
||||
val networkInfo =
|
||||
@@ -36,6 +89,7 @@ data class Permissions(val locationServicesEnabled: Boolean, val locationPermiss
|
||||
|
||||
sealed class ActiveNetwork {
|
||||
abstract val network: Network?
|
||||
abstract val capabilities: NetworkCapabilities?
|
||||
|
||||
fun key(): String {
|
||||
return when (this) {
|
||||
@@ -46,18 +100,28 @@ sealed class ActiveNetwork {
|
||||
}
|
||||
}
|
||||
|
||||
data class Disconnected(override val network: Network? = null) : ActiveNetwork()
|
||||
data class Disconnected(
|
||||
override val network: Network? = null,
|
||||
override val capabilities: NetworkCapabilities? = null,
|
||||
) : ActiveNetwork()
|
||||
|
||||
data class Wifi(
|
||||
val ssid: String,
|
||||
val securityType: WifiSecurityType?,
|
||||
val networkId: String,
|
||||
override val network: Network?,
|
||||
override val capabilities: NetworkCapabilities? = null,
|
||||
) : ActiveNetwork()
|
||||
|
||||
data class Cellular(override val network: Network?) : ActiveNetwork()
|
||||
data class Cellular(
|
||||
override val network: Network?,
|
||||
override val capabilities: NetworkCapabilities? = null,
|
||||
) : ActiveNetwork()
|
||||
|
||||
data class Ethernet(override val network: Network?) : ActiveNetwork()
|
||||
data class Ethernet(
|
||||
override val network: Network?,
|
||||
override val capabilities: NetworkCapabilities? = null,
|
||||
) : ActiveNetwork()
|
||||
}
|
||||
|
||||
sealed interface VpnState {
|
||||
|
||||
@@ -6,6 +6,4 @@ interface NetworkMonitor {
|
||||
val connectivityStateFlow: Flow<ConnectivityState>
|
||||
|
||||
fun checkPermissionsAndUpdateState()
|
||||
|
||||
fun destroy()
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@ dependencies {
|
||||
|
||||
api(libs.amneziawg.parser)
|
||||
implementation(libs.libsu)
|
||||
implementation(libs.ipaddress)
|
||||
|
||||
implementation(libs.timber)
|
||||
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
package com.zaneschepke.tunnel.backend
|
||||
|
||||
class DynamicDnsController(
|
||||
private val stabilityWindowMs: Long,
|
||||
private val failureWindowMs: Long,
|
||||
private val minCheckIntervalMs: Long,
|
||||
) {
|
||||
private var lastStableHealthySinceMs = -1L
|
||||
private var failureWindowStartMs = -1L
|
||||
private var lastCheckMs = 0L
|
||||
|
||||
fun shouldCheck(now: Long, isHealthy: Boolean, isHandshakeFailure: Boolean): Boolean {
|
||||
if (isHealthy) {
|
||||
lastStableHealthySinceMs = now
|
||||
}
|
||||
if (isHandshakeFailure) {
|
||||
if (failureWindowStartMs < 0) failureWindowStartMs = now
|
||||
} else {
|
||||
failureWindowStartMs = -1L
|
||||
}
|
||||
|
||||
val stableEnough =
|
||||
lastStableHealthySinceMs > 0 && now - lastStableHealthySinceMs >= stabilityWindowMs
|
||||
val failureEnough =
|
||||
failureWindowStartMs > 0 && now - failureWindowStartMs >= failureWindowMs
|
||||
val rateLimited = now - lastCheckMs >= minCheckIntervalMs
|
||||
|
||||
// Trigger on either long stable healthy period OR prolonged handshake failure
|
||||
return (stableEnough || failureEnough) && rateLimited
|
||||
}
|
||||
|
||||
fun markChecked(now: Long) {
|
||||
lastCheckMs = now
|
||||
}
|
||||
}
|
||||
@@ -39,8 +39,11 @@ import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.mapNotNull
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.isActive
|
||||
@@ -175,7 +178,7 @@ class TunnelBackend(
|
||||
mode.config.`interface`.postUp?.let { runScripts(it, tunnel.id) }
|
||||
}
|
||||
|
||||
tunnelJobs[tunnel.id] = startTunnelJobs(result.handle, tunnel, updatedMode)
|
||||
tunnelJobs[tunnel.id] = startTunnelJobs(result.handle, tunnel, mode)
|
||||
} catch (t: Throwable) {
|
||||
if (t is kotlinx.coroutines.CancellationException) {
|
||||
Timber.d("Bootstrap job cancelled for tunnel ${tunnel.id}")
|
||||
@@ -190,7 +193,8 @@ class TunnelBackend(
|
||||
private suspend fun setupServiceForMode(tunnel: Tunnel, mode: BackendMode) {
|
||||
when (mode) {
|
||||
is BackendMode.Proxy.KillSwitchPrimary -> {
|
||||
serviceHolder.ensureVpnProtectorRegistered()
|
||||
val service = serviceHolder.ensureVpnProtectorRegistered()
|
||||
service.setKillSwitch(mode.killSwitchConfig)
|
||||
}
|
||||
is BackendMode.Proxy.Standard -> {
|
||||
serviceHolder.getTunnelService()
|
||||
@@ -425,39 +429,25 @@ class TunnelBackend(
|
||||
}
|
||||
|
||||
private fun CoroutineScope.startDynamicDnsJob(handle: Int, tunnelId: Int) = launch {
|
||||
val controller =
|
||||
DynamicDnsController(
|
||||
stabilityWindowMs = DDNS_STABILITY_WINDOW,
|
||||
failureWindowMs = DDNS_FAILURE_WINDOW,
|
||||
minCheckIntervalMs = DDNS_MIN_CHECK_INTERVAL,
|
||||
)
|
||||
status
|
||||
.mapNotNull { it.activeTunnels[tunnelId]?.transportState }
|
||||
.map { it is Tunnel.State.Up.HandshakeFailure }
|
||||
.distinctUntilChanged()
|
||||
.collectLatest { isFailing ->
|
||||
if (!isFailing) return@collectLatest
|
||||
|
||||
combine(
|
||||
stableNetworkEngine.stableState.filterNotNull(),
|
||||
status.mapNotNull { it.activeTunnels[tunnelId] },
|
||||
) { stable, activeTunnel ->
|
||||
stable to activeTunnel
|
||||
}
|
||||
.collect { (stable, activeTunnel) ->
|
||||
if (!stable.state.hasInternet()) return@collect
|
||||
delay(DDNS_FAILURE_WINDOW.milliseconds)
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
val isHealthy = activeTunnel.transportState is Tunnel.State.Up.Healthy
|
||||
val isHandshakeFailure =
|
||||
activeTunnel.transportState is Tunnel.State.Up.HandshakeFailure
|
||||
|
||||
if (!controller.shouldCheck(now, isHealthy, isHandshakeFailure)) return@collect
|
||||
|
||||
controller.markChecked(now)
|
||||
|
||||
val mode = activeTunnel.mode ?: return@collect
|
||||
|
||||
reconcilePeers(
|
||||
tunnelId = tunnelId,
|
||||
handle = handle,
|
||||
mode = mode,
|
||||
reason = PeerUpdateReason.DDNS_CHECK,
|
||||
)
|
||||
while (isActive) {
|
||||
val stable = stableNetworkEngine.stableState.value
|
||||
if (stable?.state?.hasActiveNetwork() == true) {
|
||||
val tunnel = _status.value.activeTunnels[tunnelId] ?: continue
|
||||
tunnel.mode?.let { mode ->
|
||||
reconcilePeers(tunnelId, handle, mode, PeerUpdateReason.DDNS_CHECK)
|
||||
}
|
||||
}
|
||||
delay(DDNS_MIN_CHECK_INTERVAL.milliseconds)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -471,9 +461,7 @@ class TunnelBackend(
|
||||
return freshDns
|
||||
.mapNotNull { (pubKey, dnsResult) ->
|
||||
val current = currentEndpoints[pubKey] ?: return@mapNotNull null
|
||||
val currentEndpoint = current.endpoint ?: return@mapNotNull null
|
||||
|
||||
val normalizedCurrent = normalizeEndpointForComparison(currentEndpoint)
|
||||
val currentHost = current.host ?: return@mapNotNull null
|
||||
|
||||
val freshAddress =
|
||||
if (preferIpv6 && dnsResult.ipv6.isNotEmpty()) {
|
||||
@@ -482,7 +470,7 @@ class TunnelBackend(
|
||||
dnsResult.ipv4.firstOrNull() ?: dnsResult.ipv6.firstOrNull()
|
||||
} ?: return@mapNotNull null
|
||||
|
||||
if (freshAddress != normalizedCurrent) {
|
||||
if (freshAddress != currentHost) {
|
||||
pubKey to freshAddress
|
||||
} else {
|
||||
null
|
||||
@@ -491,18 +479,6 @@ class TunnelBackend(
|
||||
.toMap()
|
||||
}
|
||||
|
||||
private fun normalizeEndpointForComparison(endpoint: String): String {
|
||||
val host = endpoint.substringBeforeLast(":")
|
||||
val port = endpoint.substringAfterLast(":")
|
||||
|
||||
return if (host.contains(":")) {
|
||||
// Looks like IPv6
|
||||
if (host.startsWith("[")) endpoint else "[$host]:$port"
|
||||
} else {
|
||||
endpoint
|
||||
}
|
||||
}
|
||||
|
||||
private fun CoroutineScope.startIpv6Job(
|
||||
handle: Int,
|
||||
tunnelId: Int,
|
||||
@@ -719,7 +695,6 @@ class TunnelBackend(
|
||||
companion object {
|
||||
private const val DDNS_MIN_CHECK_INTERVAL = 30_000L
|
||||
private const val DDNS_FAILURE_WINDOW = 15_000L
|
||||
private const val DDNS_STABILITY_WINDOW = 15_000L
|
||||
private const val IPV4_FALLBACK_FAILURE_COUNT = 4
|
||||
private const val IPV4_FALLBACK_FAILURE_DURATION = 10_000L
|
||||
private const val RECOVERY_STABILITY_WINDOW = 5_000L
|
||||
|
||||
@@ -1,23 +1,86 @@
|
||||
package com.zaneschepke.tunnel.backend.dns
|
||||
|
||||
import android.content.Context
|
||||
import android.net.DnsResolver
|
||||
import android.net.Network
|
||||
import android.os.Build
|
||||
import android.os.CancellationSignal
|
||||
import androidx.annotation.RequiresApi
|
||||
import com.zaneschepke.tunnel.model.DnsBootstrapResult
|
||||
import java.net.InetAddress
|
||||
import java.util.concurrent.Executor
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import timber.log.Timber
|
||||
|
||||
internal class AndroidNetworkResolver(private val network: Network) : PeerResolver {
|
||||
internal class AndroidNetworkResolver(private val network: Network) : PeerResolver, KoinComponent {
|
||||
private val context: Context by inject()
|
||||
|
||||
@Suppress("NewApi")
|
||||
private val dnsResolver: DnsResolver by lazy {
|
||||
if (Build.VERSION.SDK_INT >= 37) {
|
||||
DnsResolver(context, null)
|
||||
} else {
|
||||
@Suppress("DEPRECATION") DnsResolver.getInstance()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun resolve(host: String): DnsBootstrapResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
// use underlying network for resolution
|
||||
val ips = network.getAllByName(host)
|
||||
try {
|
||||
val ips =
|
||||
withTimeoutOrNull(2_200L.milliseconds) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
resolveAsync(host)
|
||||
} else {
|
||||
network.getAllByName(host).toList()
|
||||
}
|
||||
}
|
||||
?: run {
|
||||
Timber.w("DNS resolution timed out after 2200ms for $host")
|
||||
return@withContext DnsBootstrapResult()
|
||||
}
|
||||
|
||||
Timber.d("Resolution from network bind socket: ${ips.contentToString()}")
|
||||
Timber.d("Resolution from network bind socket: $ips")
|
||||
|
||||
val v4 = ips.filter { it.address.size == 4 }.map { it.hostAddress }
|
||||
val v6 = ips.filter { it.address.size == 16 }.map { it.hostAddress }
|
||||
val v4 = ips.filter { it.address.size == 4 }.map { it.hostAddress }
|
||||
val v6 = ips.filter { it.address.size == 16 }.map { it.hostAddress }
|
||||
|
||||
DnsBootstrapResult(v4, v6)
|
||||
DnsBootstrapResult(v4, v6)
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "System DNS failed to resolve host")
|
||||
DnsBootstrapResult()
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
private suspend fun resolveAsync(host: String): List<InetAddress> =
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
val signal = CancellationSignal()
|
||||
continuation.invokeOnCancellation { signal.cancel() }
|
||||
|
||||
dnsResolver.query(
|
||||
network,
|
||||
host,
|
||||
DnsResolver.FLAG_EMPTY,
|
||||
Executor { it.run() },
|
||||
signal,
|
||||
object : DnsResolver.Callback<List<InetAddress>> {
|
||||
override fun onAnswer(answer: List<InetAddress>, rcode: Int) {
|
||||
continuation.resume(answer)
|
||||
}
|
||||
|
||||
override fun onError(error: DnsResolver.DnsException) {
|
||||
continuation.resumeWithException(error)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,58 @@
|
||||
package com.zaneschepke.tunnel.backend.dns
|
||||
|
||||
import com.zaneschepke.tunnel.DnsConfigManager
|
||||
import android.net.Network
|
||||
import com.zaneschepke.tunnel.model.DnsBoostrapConfig
|
||||
import com.zaneschepke.tunnel.model.DnsBootstrapResult
|
||||
import com.zaneschepke.tunnel.util.DnsHostUtils
|
||||
import timber.log.Timber
|
||||
|
||||
class CustomDnsResolver(private val dnsConfig: DnsBoostrapConfig, private val bypass: Boolean) :
|
||||
PeerResolver {
|
||||
class CustomDnsResolver(
|
||||
private val dnsConfig: DnsBoostrapConfig,
|
||||
private val bypass: Boolean,
|
||||
network: Network,
|
||||
) : PeerResolver {
|
||||
|
||||
private val systemResolver = AndroidNetworkResolver(network)
|
||||
|
||||
override suspend fun resolve(host: String): DnsBootstrapResult {
|
||||
return DnsConfigManager.resolveHostBootstrap(
|
||||
host = host,
|
||||
protocol = dnsConfig.protocol,
|
||||
upstream = dnsConfig.upstream ?: DnsBoostrapConfig.DEFAULT_PLAIN_UPSTREAM,
|
||||
bypass = bypass,
|
||||
)
|
||||
|
||||
val upstream = dnsConfig.upstream
|
||||
if (upstream.isNullOrBlank()) {
|
||||
Timber.w("Custom DNS mode selected but no upstream configured")
|
||||
return DnsBootstrapResult()
|
||||
}
|
||||
|
||||
val resolvedUpstream =
|
||||
if (DnsHostUtils.needsResolution(upstream)) {
|
||||
Timber.d("Upstream DNS needs resolution, resolving via system resolver")
|
||||
val hostToResolve = DnsHostUtils.extractHost(upstream)
|
||||
|
||||
val resolutionResult = systemResolver.resolve(hostToResolve)
|
||||
|
||||
val ip = resolutionResult.ipv4.firstOrNull() ?: resolutionResult.ipv6.firstOrNull()
|
||||
if (ip == null) {
|
||||
Timber.w("Failed to resolve custom DNS upstream host: $upstream")
|
||||
return DnsBootstrapResult()
|
||||
}
|
||||
|
||||
DnsHostUtils.replaceHostWithIP(upstream, ip)
|
||||
} else {
|
||||
upstream
|
||||
}
|
||||
|
||||
Timber.d("Using custom resolver with resolved upstream $resolvedUpstream")
|
||||
|
||||
return try {
|
||||
NativeDnsResolver.resolveHostBootstrap(
|
||||
host = host,
|
||||
protocol = dnsConfig.protocol,
|
||||
resolvedUpstream = resolvedUpstream,
|
||||
originalUpstream = upstream,
|
||||
bypass = bypass,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Timber.w(e, "Custom DNS resolution failed for host=$host upstream=$resolvedUpstream")
|
||||
DnsBootstrapResult()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
package com.zaneschepke.tunnel.backend.dns
|
||||
|
||||
import android.net.Network
|
||||
import com.zaneschepke.networkmonitor.ConnectivityState
|
||||
import com.zaneschepke.networkmonitor.PrivateDnsMode
|
||||
import com.zaneschepke.networkmonitor.StableNetworkEngine
|
||||
import com.zaneschepke.tunnel.model.BackendMode
|
||||
import com.zaneschepke.tunnel.model.DnsBoostrapConfig
|
||||
import com.zaneschepke.tunnel.model.DnsBoostrapMode
|
||||
import com.zaneschepke.tunnel.model.DnsBootstrapResult
|
||||
import com.zaneschepke.tunnel.model.PublicKey
|
||||
@@ -33,7 +29,12 @@ class EndpointResolver(
|
||||
|
||||
while (isActive) {
|
||||
val snapshot = stableNetworkEngine.stableState.value?.state
|
||||
val network = snapshot?.activeNetwork?.network ?: continue
|
||||
val network =
|
||||
snapshot?.activeNetwork?.network
|
||||
?: run {
|
||||
delay(100.milliseconds)
|
||||
continue
|
||||
}
|
||||
|
||||
val dnsMode = getDnsMode()
|
||||
val bypassNeeded = mode is BackendMode.Vpn || isKillSwitchEnabled()
|
||||
@@ -43,22 +44,17 @@ class EndpointResolver(
|
||||
if (results.containsKey(peer.publicKey)) continue
|
||||
val host = peer.endpoint?.substringBeforeLast(":") ?: continue
|
||||
|
||||
val dnsResult =
|
||||
val resolver: PeerResolver =
|
||||
when (dnsMode) {
|
||||
is DnsBoostrapMode.Custom -> {
|
||||
resolveWithCustomConfig(dnsMode.config, host, bypassNeeded)
|
||||
}
|
||||
is DnsBoostrapMode.System -> {
|
||||
resolveWithSystemStrategy(snapshot, network, host, bypassNeeded)
|
||||
}
|
||||
is DnsBoostrapMode.System -> AndroidNetworkResolver(network)
|
||||
is DnsBoostrapMode.Custom ->
|
||||
CustomDnsResolver(dnsMode.config, bypassNeeded, network)
|
||||
}
|
||||
|
||||
if (
|
||||
dnsResult != null &&
|
||||
(dnsResult.ipv4.isNotEmpty() || dnsResult.ipv6.isNotEmpty())
|
||||
) {
|
||||
results[peer.publicKey] =
|
||||
dnsResult.copy(ipv6 = dnsResult.ipv6.map { "[$it]" })
|
||||
val result = resolver.resolve(host)
|
||||
|
||||
if (result.ipv4.isNotEmpty() || result.ipv6.isNotEmpty()) {
|
||||
results[peer.publicKey] = result.copy(ipv6 = result.ipv6.map { "[$it]" })
|
||||
progressed = true
|
||||
}
|
||||
}
|
||||
@@ -78,79 +74,6 @@ class EndpointResolver(
|
||||
return@coroutineScope results
|
||||
}
|
||||
|
||||
private suspend fun resolveWithSystemStrategy(
|
||||
snapshot: ConnectivityState,
|
||||
network: Network,
|
||||
host: String,
|
||||
bypass: Boolean,
|
||||
): DnsBootstrapResult? {
|
||||
val dnsInfo = snapshot.underlyingDnsInfo
|
||||
val hasDnsServers = dnsInfo.servers.isNotEmpty()
|
||||
val hasPrivateDnsHostname =
|
||||
dnsInfo.privateDnsMode == PrivateDnsMode.HOSTNAME &&
|
||||
!dnsInfo.privateDnsHostname.isNullOrBlank()
|
||||
|
||||
return when {
|
||||
// Private DNS hostname, use DoT/DoH via custom resolver
|
||||
hasPrivateDnsHostname -> {
|
||||
val hostname = dnsInfo.privateDnsHostname!!
|
||||
val config =
|
||||
DnsBoostrapConfig.SPECIAL_ANDROID_DOH_SERVERS[hostname]?.let {
|
||||
DnsBoostrapConfig.DoH(it)
|
||||
} ?: DnsBoostrapConfig.DoT(hostname)
|
||||
|
||||
Timber.d("System and Private DNS, using ${config.protocol} for $host")
|
||||
resolveWithCustomConfig(config, host, bypass)
|
||||
}
|
||||
|
||||
// Normal system DNS
|
||||
hasDnsServers -> {
|
||||
try {
|
||||
Timber.d("Using system DNS with network provided DNS servers")
|
||||
AndroidNetworkResolver(network).resolve(host)
|
||||
} catch (e: Exception) {
|
||||
Timber.w(e, "AndroidNetworkResolver failed for $host")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
// No DNS servers on network, fall back to custom with well known
|
||||
else -> {
|
||||
Timber.d("No DNS servers on network, falling back to public DNS for $host")
|
||||
val publicConfig = DnsBoostrapConfig.Plain(DnsBoostrapConfig.DEFAULT_PLAIN_UPSTREAM)
|
||||
resolveWithCustomConfig(publicConfig, host, bypass)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun resolveWithCustomConfig(
|
||||
config: DnsBoostrapConfig,
|
||||
host: String,
|
||||
bypass: Boolean,
|
||||
): DnsBootstrapResult? {
|
||||
val upstream =
|
||||
config.upstream
|
||||
?: when (config) {
|
||||
is DnsBoostrapConfig.DoH -> DnsBoostrapConfig.DEFAULT_DOH_UPSTREAM
|
||||
is DnsBoostrapConfig.DoT -> DnsBoostrapConfig.DEFAULT_DOT_UPSTREAM
|
||||
is DnsBoostrapConfig.Plain -> DnsBoostrapConfig.DEFAULT_PLAIN_UPSTREAM
|
||||
}
|
||||
|
||||
return try {
|
||||
CustomDnsResolver(config, bypass).resolve(host)
|
||||
} catch (e: Exception) {
|
||||
Timber.w(
|
||||
e,
|
||||
"DNS resolution failed for host=%s protocol=%s upstream=%s bypass=%s",
|
||||
host,
|
||||
config.protocol,
|
||||
upstream,
|
||||
bypass,
|
||||
)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val MAX_BACKOFF = 30_000L
|
||||
}
|
||||
|
||||
+14
-8
@@ -1,30 +1,36 @@
|
||||
package com.zaneschepke.tunnel
|
||||
package com.zaneschepke.tunnel.backend.dns
|
||||
|
||||
import com.zaneschepke.tunnel.model.DnsBoostrapConfig
|
||||
import com.zaneschepke.tunnel.model.DnsBootstrapResult
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
internal object DnsConfigManager {
|
||||
internal object NativeDnsResolver {
|
||||
|
||||
private external fun resolveBootstrap(
|
||||
host: String,
|
||||
protocol: String,
|
||||
upstream: String,
|
||||
underlyingDnsServers: String,
|
||||
resolvedUpstream: String,
|
||||
originalUpstream: String,
|
||||
bypass: Int,
|
||||
): String
|
||||
|
||||
suspend fun resolveHostBootstrap(
|
||||
host: String,
|
||||
protocol: String,
|
||||
upstream: String,
|
||||
underlyingDnsServers: String = DnsBoostrapConfig.DEFAULT_UNDERLYING_SERVERS,
|
||||
resolvedUpstream: String,
|
||||
originalUpstream: String,
|
||||
bypass: Boolean,
|
||||
): DnsBootstrapResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
val bypassOption = if (bypass) 1 else 0
|
||||
val raw = resolveBootstrap(host, protocol, upstream, underlyingDnsServers, bypassOption)
|
||||
val raw =
|
||||
resolveBootstrap(
|
||||
host = host,
|
||||
protocol = protocol,
|
||||
resolvedUpstream = resolvedUpstream,
|
||||
originalUpstream = originalUpstream,
|
||||
bypass = bypassOption,
|
||||
)
|
||||
|
||||
if (raw.startsWith("ERR|")) {
|
||||
throw RuntimeException(raw.removePrefix("ERR|"))
|
||||
@@ -13,7 +13,10 @@ sealed class BackendMode {
|
||||
override fun withConfig(config: Config) = copy(config = config)
|
||||
}
|
||||
|
||||
data class KillSwitchPrimary(override val config: Config) : Proxy() {
|
||||
data class KillSwitchPrimary(
|
||||
override val config: Config,
|
||||
val killSwitchConfig: KillSwitchConfig,
|
||||
) : Proxy() {
|
||||
override fun withConfig(config: Config) = copy(config = config)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,18 +24,6 @@ sealed class DnsBoostrapConfig(open val upstream: String?) {
|
||||
override val protocol: String
|
||||
get() = "dot"
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val DEFAULT_UNDERLYING_SERVERS = "1.1.1.1,8.8.8.8"
|
||||
const val DEFAULT_PLAIN_UPSTREAM = "1.1.1.1"
|
||||
const val DEFAULT_DOH_UPSTREAM = "https://cloudflare-dns.com/dns-query"
|
||||
const val DEFAULT_DOT_UPSTREAM = "one.one.one.one"
|
||||
val SPECIAL_ANDROID_DOH_SERVERS =
|
||||
mapOf(
|
||||
"cloudflare-dns.com" to "https://cloudflare-dns.com/dns-query",
|
||||
"dns.google" to "https://dns.google/dns-query",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class DnsBootstrapResult(
|
||||
|
||||
@@ -44,6 +44,8 @@ class VpnService : android.net.VpnService(), KillSwitch, SocketProtector {
|
||||
@Volatile private var hevBridgeFd: ParcelFileDescriptor? = null
|
||||
@Volatile private var vpnTunFd: ParcelFileDescriptor? = null
|
||||
|
||||
@Volatile private var currentKillSwitchConfig: KillSwitchConfig? = null
|
||||
|
||||
override fun onCreate() {
|
||||
serviceHolder.set(this)
|
||||
super.onCreate()
|
||||
@@ -187,10 +189,17 @@ class VpnService : android.net.VpnService(), KillSwitch, SocketProtector {
|
||||
private fun disableKillSwitch() {
|
||||
hevBridgeFd?.close()
|
||||
hevBridgeFd = null
|
||||
currentKillSwitchConfig = null
|
||||
}
|
||||
|
||||
override fun setKillSwitch(config: KillSwitchConfig?) {
|
||||
if (config == null) return disableKillSwitch()
|
||||
|
||||
if (hevBridgeFd != null && currentKillSwitchConfig == config) {
|
||||
Timber.d("Kill Switch already active with identical config, skipping")
|
||||
return
|
||||
}
|
||||
|
||||
hevBridgeFd?.close()
|
||||
val intent = backend.applicationProvider.createVpnConfigurePendingIntent(this@VpnService)
|
||||
hevBridgeFd =
|
||||
@@ -212,12 +221,12 @@ class VpnService : android.net.VpnService(), KillSwitch, SocketProtector {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
setMetered(config.metered)
|
||||
}
|
||||
addAddress(IPV6_ULA, 128)
|
||||
addRoute(IPV6_DEFAULT_ROUTE, 0)
|
||||
setMtu(DEFAULT_MTU)
|
||||
addDnsServer(DEFAULT_DNS_SERVER)
|
||||
}
|
||||
.establish()
|
||||
currentKillSwitchConfig = config
|
||||
}
|
||||
|
||||
fun createTunInterface(tunnel: Tunnel, config: Config) {
|
||||
@@ -362,7 +371,6 @@ class VpnService : android.net.VpnService(), KillSwitch, SocketProtector {
|
||||
private const val LOCKDOWN_SESSION_NAME = "Lockdown"
|
||||
private const val LOCALHOST = "127.0.0.1"
|
||||
private const val IPV4_INTERFACE_ADDRESS = "10.0.0.1"
|
||||
private const val IPV6_ULA = "fd00::1"
|
||||
private const val IPV6_INTERFACE_ADDRESS = "2001:db8::1"
|
||||
const val LOCKDOWN_USERNAME = "local"
|
||||
private const val IPV4_DEFAULT_ROUTE = "0.0.0.0"
|
||||
|
||||
@@ -16,11 +16,4 @@ data class ActiveTunnel(
|
||||
val uptime: Long? = null,
|
||||
val lastPeerUpdateMs: Long = 0L,
|
||||
val isFallenBackToIpv4ForNetwork: Boolean = false,
|
||||
) {
|
||||
val isPeerUpdating: Boolean
|
||||
get() = System.currentTimeMillis() - lastPeerUpdateMs < PEER_UPDATE_GRACE_MS
|
||||
|
||||
companion object {
|
||||
private const val PEER_UPDATE_GRACE_MS = 8_000L
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.zaneschepke.tunnel.util
|
||||
|
||||
import inet.ipaddr.IPAddressString
|
||||
import java.net.URI
|
||||
|
||||
object DnsHostUtils {
|
||||
|
||||
/** Extracts the host portion from a DoH/DoT/Plain upstream string. */
|
||||
fun extractHost(upstream: String): String {
|
||||
val trimmed = upstream.trim()
|
||||
|
||||
// DoH full url
|
||||
if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) {
|
||||
return try {
|
||||
URI(trimmed).host ?: trimmed
|
||||
} catch (_: Exception) {
|
||||
trimmed
|
||||
}
|
||||
}
|
||||
|
||||
val hostPart = trimmed.substringBeforeLast(":")
|
||||
return hostPart.removeSurrounding("[", "]")
|
||||
}
|
||||
|
||||
/** Replaces the hostname in the upstream string with the given IP address. */
|
||||
fun replaceHostWithIP(upstream: String, newIp: String): String {
|
||||
val trimmed = upstream.trim()
|
||||
|
||||
val cleanedIp = newIp.trim().removeSurrounding("[", "]")
|
||||
val isIpv6 = isIpAddress(cleanedIp) && cleanedIp.contains(":")
|
||||
|
||||
val replacementIp = if (isIpv6) "[$cleanedIp]" else cleanedIp
|
||||
|
||||
// handle full url for DoH
|
||||
if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) {
|
||||
return try {
|
||||
val uri = URI(trimmed)
|
||||
val newAuthority =
|
||||
if (uri.port != -1) {
|
||||
"$replacementIp:${uri.port}"
|
||||
} else {
|
||||
replacementIp
|
||||
}
|
||||
|
||||
URI(uri.scheme, newAuthority, uri.path, uri.query, uri.fragment).toString()
|
||||
} catch (_: Exception) {
|
||||
// ust return the IP if URL parsing fails
|
||||
replacementIp
|
||||
}
|
||||
}
|
||||
|
||||
// host:port format DoT and plain
|
||||
if (trimmed.contains(":")) {
|
||||
val port = trimmed.substringAfterLast(":")
|
||||
// Only treat as port if it's numeric
|
||||
if (port.toIntOrNull() != null) {
|
||||
return "$replacementIp:$port"
|
||||
}
|
||||
}
|
||||
|
||||
// bare hostname/ip
|
||||
return replacementIp
|
||||
}
|
||||
|
||||
fun isIpAddress(host: String): Boolean {
|
||||
val cleaned = host.trim().removeSurrounding("[", "]")
|
||||
return try {
|
||||
val addr = IPAddressString(cleaned).address
|
||||
addr != null && (addr.isIPv4 || addr.isIPv6)
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun needsResolution(upstream: String): Boolean {
|
||||
val host = extractHost(upstream)
|
||||
return host.isNotBlank() && !isIpAddress(host)
|
||||
}
|
||||
}
|
||||
@@ -43,47 +43,46 @@ type Transport interface {
|
||||
func ResolveBootstrap(
|
||||
host *C.char,
|
||||
protocol *C.char,
|
||||
upstream *C.char,
|
||||
underlyingDnsServers *C.char,
|
||||
resolvedUpstream *C.char,
|
||||
originalUpstream *C.char,
|
||||
bypass C.int,
|
||||
) *C.char {
|
||||
|
||||
h := C.GoString(host)
|
||||
p := C.GoString(protocol)
|
||||
u := C.GoString(upstream)
|
||||
underlying := C.GoString(underlyingDnsServers)
|
||||
resolved := C.GoString(resolvedUpstream)
|
||||
original := C.GoString(originalUpstream)
|
||||
bp := bypass == 1
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
shared.LogDebug(
|
||||
"DNS",
|
||||
"ResolveBootstrap called host=%s protocol=%s upstream=%s bypass=%t",
|
||||
h, p, u, bp,
|
||||
)
|
||||
shared.LogDebug("DNS", "ResolveBootstrap called host=%s protocol=%s resolved=%s original=%s bypass=%t",
|
||||
h, p, resolved, original, bp)
|
||||
|
||||
v4, v6, err := Resolve(ctx, h, p, u, bp, underlying)
|
||||
v4, v6, err := Resolve(ctx, h, p, resolved, original, bp)
|
||||
if err != nil {
|
||||
shared.LogError("DNS", "ResolveBootstrap failed for %s: %v", h, err)
|
||||
return C.CString("ERR|" + err.Error())
|
||||
}
|
||||
|
||||
v4Str := make([]string, len(v4))
|
||||
for i, ip := range v4 {
|
||||
v4Str[i] = ip.String()
|
||||
}
|
||||
v6Str := make([]string, len(v6))
|
||||
for i, ip := range v6 {
|
||||
v6Str[i] = ip.String()
|
||||
}
|
||||
|
||||
result := "v4=" + strings.Join(v4Str, ",") +
|
||||
";v6=" + strings.Join(v6Str, ",")
|
||||
result := fmt.Sprintf("v4=%s;v6=%s",
|
||||
strings.Join(toStringSlice(v4), ","),
|
||||
strings.Join(toStringSlice(v6), ","),
|
||||
)
|
||||
|
||||
shared.LogDebug("DNS", "ResolveBootstrap success for %s: %s", h, result)
|
||||
return C.CString(result)
|
||||
}
|
||||
|
||||
func toStringSlice(addrs []netip.Addr) []string {
|
||||
out := make([]string, len(addrs))
|
||||
for i, a := range addrs {
|
||||
out[i] = a.String()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type DoTTransport struct {
|
||||
Client *dns.Client
|
||||
Servers []string
|
||||
@@ -264,20 +263,26 @@ func resolveServerAddrs(
|
||||
|
||||
func (t PlainTransport) Query(ctx context.Context, msg *dns.Msg) (*dns.Msg, error) {
|
||||
for _, server := range t.Servers {
|
||||
m, _, err := t.Client.Exchange(msg, server)
|
||||
m, _, err := t.Client.ExchangeContext(ctx, msg, server)
|
||||
if err == nil && m != nil && m.Rcode == dns.RcodeSuccess {
|
||||
return m, nil
|
||||
}
|
||||
if err != nil {
|
||||
shared.LogDebug("DNS", "Plain DNS query to %s failed: %v", server, err)
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("all DNS servers failed")
|
||||
}
|
||||
|
||||
func (t DoTTransport) Query(ctx context.Context, msg *dns.Msg) (*dns.Msg, error) {
|
||||
for _, server := range t.Servers {
|
||||
m, _, err := t.Client.Exchange(msg, server)
|
||||
m, _, err := t.Client.ExchangeContext(ctx, msg, server)
|
||||
if err == nil && m != nil && m.Rcode == dns.RcodeSuccess {
|
||||
return m, nil
|
||||
}
|
||||
if err != nil {
|
||||
shared.LogDebug("DNS", "DoT Exchange to %s failed: %v", server, err)
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("all DoT servers failed")
|
||||
}
|
||||
@@ -343,11 +348,11 @@ func parseDNSAnswers(msg *dns.Msg, qtype uint16) []netip.Addr {
|
||||
|
||||
func Resolve(
|
||||
ctx context.Context,
|
||||
host, protocol, upstream string,
|
||||
host, protocol, resolvedUpstream, originalUpstream string,
|
||||
bypass bool,
|
||||
underlying string,
|
||||
) ([]netip.Addr, []netip.Addr, error) {
|
||||
t, err := buildTransport(ctx, protocol, upstream, bypass, underlying)
|
||||
|
||||
t, err := buildTransport(protocol, resolvedUpstream, originalUpstream, bypass)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
@@ -355,88 +360,89 @@ func Resolve(
|
||||
}
|
||||
|
||||
func buildTransport(
|
||||
ctx context.Context,
|
||||
protocol, upstream string,
|
||||
protocol, resolvedUpstream, originalUpstream string,
|
||||
bypass bool,
|
||||
underlying string,
|
||||
) (Transport, error) {
|
||||
|
||||
switch protocol {
|
||||
case "doh":
|
||||
u, err := url.Parse(upstream)
|
||||
// Parse original for SNI
|
||||
origURL, err := url.Parse(originalUpstream)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("invalid original DoH upstream: %w", err)
|
||||
}
|
||||
hostname := u.Hostname()
|
||||
port := u.Port()
|
||||
|
||||
originalHost := origURL.Hostname()
|
||||
|
||||
// Parse resolved to get the IP
|
||||
resolvedURL, _ := url.Parse(resolvedUpstream)
|
||||
dialHost := resolvedURL.Hostname()
|
||||
if dialHost == "" {
|
||||
dialHost = originalHost // fallback
|
||||
}
|
||||
|
||||
port := origURL.Port()
|
||||
if port == "" {
|
||||
port = "443"
|
||||
}
|
||||
u.Host = net.JoinHostPort(hostname, port)
|
||||
|
||||
// Pre-resolve with IPv4-first ordering + bypass
|
||||
servers, _, err := resolveServerAddrs(ctx, u.Host, bypass, "443", underlying)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(servers) == 0 {
|
||||
return nil, fmt.Errorf("no addresses resolved for DoH server")
|
||||
}
|
||||
|
||||
// Custom dialer that tries servers in order
|
||||
// tries ipv4 first and then ipv6
|
||||
dialer := GetDialer(bypass)
|
||||
|
||||
transport := &http.Transport{
|
||||
DialContext: func(ctx context.Context, network, _ string) (net.Conn, error) {
|
||||
for _, addr := range servers {
|
||||
conn, err := dialer.DialContext(ctx, network, addr)
|
||||
if err == nil {
|
||||
return conn, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("all DoH addresses failed")
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return dialer.DialContext(ctx, network, net.JoinHostPort(dialHost, port))
|
||||
},
|
||||
TLSClientConfig: &tls.Config{
|
||||
ServerName: hostname,
|
||||
ServerName: originalHost, // Use original hostname for certificate validation
|
||||
},
|
||||
}
|
||||
|
||||
finalURL := origURL.String()
|
||||
if !strings.HasPrefix(finalURL, "https://") {
|
||||
finalURL = "https://" + finalURL
|
||||
}
|
||||
|
||||
return DoHTransport{
|
||||
Client: &http.Client{Timeout: 5 * time.Second, Transport: transport},
|
||||
URL: u.String(),
|
||||
Servers: servers,
|
||||
Hostname: hostname,
|
||||
URL: finalURL,
|
||||
Hostname: originalHost,
|
||||
}, nil
|
||||
|
||||
case "dot":
|
||||
servers, sni, err := resolveServerAddrs(ctx, upstream, bypass, "853", underlying)
|
||||
// Get SNI from original
|
||||
origHost, origPort, err := net.SplitHostPort(originalUpstream)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
origHost = originalUpstream
|
||||
origPort = "853"
|
||||
}
|
||||
if len(servers) == 0 {
|
||||
return nil, fmt.Errorf("no addresses resolved for DoT server")
|
||||
|
||||
// Get connection target from resolved
|
||||
resolvedHost, resolvedPort, _ := net.SplitHostPort(resolvedUpstream)
|
||||
if resolvedHost == "" {
|
||||
resolvedHost = resolvedUpstream
|
||||
resolvedPort = origPort
|
||||
}
|
||||
|
||||
client := &dns.Client{
|
||||
Net: "tcp-tls",
|
||||
Dialer: GetDialer(bypass),
|
||||
Timeout: 5 * time.Second,
|
||||
Timeout: 6 * time.Second,
|
||||
TLSConfig: &tls.Config{
|
||||
ServerName: sni,
|
||||
ServerName: origHost,
|
||||
MinVersion: tls.VersionTLS12,
|
||||
},
|
||||
}
|
||||
|
||||
return DoTTransport{
|
||||
Client: client,
|
||||
Servers: servers,
|
||||
Servers: []string{net.JoinHostPort(resolvedHost, resolvedPort)},
|
||||
}, nil
|
||||
|
||||
default: // plain DNS
|
||||
_, addr, err := parseUpstream(upstream)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
servers, _, err := resolveServerAddrs(ctx, addr, bypass, "53", underlying)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
default: // plain
|
||||
host, port, _ := net.SplitHostPort(resolvedUpstream)
|
||||
if host == "" {
|
||||
host = resolvedUpstream
|
||||
port = "53"
|
||||
}
|
||||
|
||||
client := &dns.Client{
|
||||
@@ -446,7 +452,7 @@ func buildTransport(
|
||||
}
|
||||
return PlainTransport{
|
||||
Client: client,
|
||||
Servers: servers,
|
||||
Servers: []string{net.JoinHostPort(host, port)},
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,45 +6,45 @@ struct go_string { const char *str; long n; };
|
||||
extern char* ResolveBootstrap(
|
||||
const char* host,
|
||||
const char* protocol,
|
||||
const char* upstream,
|
||||
const char* underlyingDnsServers,
|
||||
const char* resolvedUpstream,
|
||||
const char* originalUpstream,
|
||||
int bypass);
|
||||
|
||||
JNIEXPORT jstring JNICALL
|
||||
Java_com_zaneschepke_tunnel_DnsConfigManager_resolveBootstrap(
|
||||
Java_com_zaneschepke_tunnel_backend_dns_NativeDnsResolver_resolveBootstrap(
|
||||
JNIEnv* env,
|
||||
jclass clazz,
|
||||
jstring host,
|
||||
jstring protocol,
|
||||
jstring upstream,
|
||||
jstring underlyingDnsServers,
|
||||
jstring resolvedUpstream,
|
||||
jstring originalUpstream,
|
||||
jint bypass)
|
||||
{
|
||||
if (host == NULL || protocol == NULL || upstream == NULL || underlyingDnsServers == NULL) {
|
||||
if (host == NULL || protocol == NULL || resolvedUpstream == NULL || originalUpstream == NULL) {
|
||||
return (*env)->NewStringUTF(env, "ERR|invalid arguments");
|
||||
}
|
||||
|
||||
const char* chost = (*env)->GetStringUTFChars(env, host, NULL);
|
||||
const char* cprotocol = (*env)->GetStringUTFChars(env, protocol, NULL);
|
||||
const char* cupstream = (*env)->GetStringUTFChars(env, upstream, NULL);
|
||||
const char* cunderlying = (*env)->GetStringUTFChars(env, underlyingDnsServers, NULL);
|
||||
const char* chost = (*env)->GetStringUTFChars(env, host, NULL);
|
||||
const char* cprotocol = (*env)->GetStringUTFChars(env, protocol, NULL);
|
||||
const char* cresolvedUpstream = (*env)->GetStringUTFChars(env, resolvedUpstream, NULL);
|
||||
const char* coriginalUpstream = (*env)->GetStringUTFChars(env, originalUpstream, NULL);
|
||||
|
||||
if (chost == NULL || cprotocol == NULL || cupstream == NULL || cunderlying == NULL) {
|
||||
if (chost == NULL || cprotocol == NULL || cresolvedUpstream == NULL || coriginalUpstream == NULL) {
|
||||
return (*env)->NewStringUTF(env, "ERR|out of memory");
|
||||
}
|
||||
|
||||
char* resultC = ResolveBootstrap(
|
||||
chost,
|
||||
cprotocol,
|
||||
cupstream,
|
||||
cunderlying,
|
||||
cresolvedUpstream,
|
||||
coriginalUpstream,
|
||||
bypass ? 1 : 0
|
||||
);
|
||||
|
||||
(*env)->ReleaseStringUTFChars(env, host, chost);
|
||||
(*env)->ReleaseStringUTFChars(env, protocol, cprotocol);
|
||||
(*env)->ReleaseStringUTFChars(env, upstream, cupstream);
|
||||
(*env)->ReleaseStringUTFChars(env, underlyingDnsServers, cunderlying);
|
||||
(*env)->ReleaseStringUTFChars(env, resolvedUpstream, cresolvedUpstream);
|
||||
(*env)->ReleaseStringUTFChars(env, originalUpstream, coriginalUpstream);
|
||||
|
||||
if (resultC == NULL) {
|
||||
return (*env)->NewStringUTF(env, "ERR|null response");
|
||||
|
||||
Reference in New Issue
Block a user