mirror of
https://github.com/wgtunnel/android.git
synced 2026-07-03 14:07:49 +02:00
Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c8adb380b | |||
| 614f97fd14 | |||
| fbd470f5d2 | |||
| 5f89b2ed31 | |||
| 9503a3284b | |||
| 68c1a19bd3 | |||
| f3bb6667c3 | |||
| 244a990c37 | |||
| cbf07600b4 | |||
| ec8d90d13d | |||
| 85acca8604 | |||
| 0a9773d202 | |||
| 3cb4480a65 | |||
| a7f3255a76 | |||
| 7d7b99f448 | |||
| 74e9e462bb | |||
| 619e3c1cde | |||
| 77f8a8215b | |||
| 8772036dd7 | |||
| 63625ccbd7 | |||
| 9ac7ae77b3 | |||
| e062fbb34d | |||
| 16d5586433 |
@@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -53,7 +53,6 @@
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.App.Start"
|
||||
tools:targetApi="tiramisu">
|
||||
@@ -74,6 +73,13 @@
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="wg" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<action android:name="android.intent.action.SHOW_APP_INFO" />
|
||||
@@ -171,7 +177,7 @@
|
||||
<service
|
||||
android:name=".service.tile.TunnelControlTile"
|
||||
android:exported="true"
|
||||
android:icon="@drawable/ic_notification"
|
||||
android:icon="@drawable/ic_qs_logo"
|
||||
android:label="@string/tunnel_control"
|
||||
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
|
||||
<meta-data
|
||||
@@ -188,7 +194,7 @@
|
||||
<service
|
||||
android:name=".service.tile.AutoTunnelControlTile"
|
||||
android:exported="true"
|
||||
android:icon="@drawable/ic_notification"
|
||||
android:icon="@drawable/ic_qs_logo"
|
||||
android:label="@string/auto_tunnel"
|
||||
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
|
||||
<meta-data
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
package com.zaneschepke.wireguardautotunnel
|
||||
|
||||
import ProxySettingsScreen
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.graphics.Color
|
||||
import android.net.Uri
|
||||
import android.net.VpnService
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.Settings
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.SystemBarStyle
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
@@ -31,15 +33,10 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.CheckCircleOutline
|
||||
import androidx.compose.material.icons.outlined.Error
|
||||
import androidx.compose.material.icons.outlined.ErrorOutline
|
||||
import androidx.compose.material.icons.outlined.FavoriteBorder
|
||||
import androidx.compose.material.icons.outlined.Info
|
||||
import androidx.compose.material.icons.outlined.Warning
|
||||
import androidx.compose.material.icons.outlined.WarningAmber
|
||||
import androidx.compose.material.icons.rounded.Error
|
||||
import androidx.compose.material.icons.rounded.Info
|
||||
import androidx.compose.material.icons.rounded.Warning
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
@@ -55,6 +52,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -66,6 +64,7 @@ import androidx.compose.ui.text.intl.Locale
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
||||
@@ -87,6 +86,8 @@ import com.zaneschepke.wireguardautotunnel.domain.sideeffect.GlobalSideEffect
|
||||
import com.zaneschepke.wireguardautotunnel.ui.LocalIsAndroidTV
|
||||
import com.zaneschepke.wireguardautotunnel.ui.LocalNavController
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.banner.AppAlertBanner
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.dialog.InfoDialog
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.dialog.LocalNetworkPermissionDialog
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.dialog.VpnDeniedDialog
|
||||
import com.zaneschepke.wireguardautotunnel.ui.navigation.Route
|
||||
import com.zaneschepke.wireguardautotunnel.ui.navigation.SecureRoute
|
||||
@@ -136,6 +137,7 @@ import com.zaneschepke.wireguardautotunnel.util.extensions.installApk
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.openWebUrl
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.restartApp
|
||||
import com.zaneschepke.wireguardautotunnel.util.permission.LocalNetworkPermissionHelper
|
||||
import com.zaneschepke.wireguardautotunnel.viewmodel.ConfigEditViewModel
|
||||
import com.zaneschepke.wireguardautotunnel.viewmodel.SharedAppViewModel
|
||||
import com.zaneschepke.wireguardautotunnel.viewmodel.SplitTunnelViewModel
|
||||
@@ -181,7 +183,8 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
roomBackup = RoomBackup(this).database(appDatabase).enableLogDebug(true).maxFileCount(5)
|
||||
|
||||
handleIncomingIntent(intent)
|
||||
handleConfigFileIntent(intent)
|
||||
handleWgDeepLinkIntent(intent)
|
||||
|
||||
installSplashScreen().apply {
|
||||
setKeepOnScreenCondition { !viewModel.container.stateFlow.value.isAppLoaded }
|
||||
@@ -205,6 +208,48 @@ class MainActivity : AppCompatActivity() {
|
||||
var requestingTunnelMode by remember {
|
||||
mutableStateOf<Pair<TunnelMode?, TunnelConfig?>>(Pair(null, null))
|
||||
}
|
||||
var showLocalNetworkRationale by remember { mutableStateOf(false) }
|
||||
var hasPromptedLocalNetwork by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
val localNetworkPermissionLauncher =
|
||||
rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission()
|
||||
) { isGranted ->
|
||||
if (!isGranted) {
|
||||
val canAskAgain =
|
||||
ActivityCompat.shouldShowRequestPermissionRationale(
|
||||
this,
|
||||
Manifest.permission.ACCESS_LOCAL_NETWORK,
|
||||
)
|
||||
|
||||
if (!canAskAgain) {
|
||||
val intent =
|
||||
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
||||
data = Uri.fromParts("package", packageName, null)
|
||||
}
|
||||
startActivity(intent)
|
||||
} else {
|
||||
toaster.show(
|
||||
message =
|
||||
context.getString(R.string.local_network_permission_denied),
|
||||
type = ToastType.Warning,
|
||||
duration = 6000.milliseconds,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(uiState.isAppLoaded) {
|
||||
if (
|
||||
uiState.isAppLoaded &&
|
||||
!hasPromptedLocalNetwork &&
|
||||
LocalNetworkPermissionHelper.shouldRequestPermission() &&
|
||||
!LocalNetworkPermissionHelper.isPermissionGranted(context)
|
||||
) {
|
||||
hasPromptedLocalNetwork = true
|
||||
showLocalNetworkRationale = true
|
||||
}
|
||||
}
|
||||
|
||||
val startingStack = buildList {
|
||||
add(Route.Tunnels)
|
||||
@@ -295,6 +340,38 @@ 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(
|
||||
onDismiss = { viewModel.dismissWgImport() },
|
||||
onAttest = { viewModel.importFromUrl(url) },
|
||||
title = stringResource(R.string.add_from_url),
|
||||
body = { Text(stringResource(R.string.wg_url_confirm_message, host)) },
|
||||
confirmText = stringResource(R.string.okay),
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
if (uiState.shouldShowDonationSnackbar && !uiState.alreadyDonated) {
|
||||
viewModel.setShouldShowDonationSnackbar(false)
|
||||
@@ -597,6 +674,21 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleWgDeepLinkIntent(intent: Intent) {
|
||||
if (intent.action == Intent.ACTION_VIEW) {
|
||||
val uri = intent.data ?: return
|
||||
if (uri.scheme == "wg") {
|
||||
val httpsUrl = uri.toString().replaceFirst("wg://", "https://")
|
||||
viewModel.promptWgImport(httpsUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
networkMonitor.checkPermissionsAndUpdateState()
|
||||
}
|
||||
|
||||
fun performBackup(encrypt: Boolean = false, password: String? = null) {
|
||||
roomBackup
|
||||
.backupLocation(RoomBackup.BACKUP_FILE_LOCATION_CUSTOM_DIALOG)
|
||||
@@ -673,18 +765,14 @@ class MainActivity : AppCompatActivity() {
|
||||
.restore()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
networkMonitor.checkPermissionsAndUpdateState()
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
handleIncomingIntent(intent)
|
||||
handleConfigFileIntent(intent)
|
||||
handleWgDeepLinkIntent(intent)
|
||||
}
|
||||
|
||||
private fun handleIncomingIntent(intent: Intent?) {
|
||||
private fun handleConfigFileIntent(intent: Intent?) {
|
||||
intent ?: return
|
||||
when (intent.action) {
|
||||
Intent.ACTION_VIEW,
|
||||
|
||||
@@ -51,6 +51,13 @@ class WireGuardAutoTunnel : Application(), KoinComponent {
|
||||
|
||||
private val backend: Backend by inject()
|
||||
|
||||
private val alwaysOnCallback =
|
||||
object : VpnService.AlwaysOnCallback {
|
||||
override fun alwaysOnTriggered() {
|
||||
applicationScope.launch { tunnelCoordinator.startDefault() }
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(KoinViewModelScopeApi::class)
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
@@ -86,13 +93,7 @@ class WireGuardAutoTunnel : Application(), KoinComponent {
|
||||
Timber.plant(ReleaseTree())
|
||||
}
|
||||
|
||||
backend.setAlwaysOnCallback(
|
||||
object : VpnService.AlwaysOnCallback {
|
||||
override fun alwaysOnTriggered() {
|
||||
applicationScope.launch { tunnelCoordinator.startDefault() }
|
||||
}
|
||||
}
|
||||
)
|
||||
backend.setAlwaysOnCallback(alwaysOnCallback)
|
||||
|
||||
val dispatcher = get<TunnelEventDispatcher>()
|
||||
val coordinator = get<TunnelCoordinator>()
|
||||
|
||||
+30
-7
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -117,7 +123,7 @@ class TunnelCoordinator(
|
||||
|
||||
// enforce single tunnel, for now
|
||||
if (backendStatus.value.activeTunnels.isNotEmpty()) {
|
||||
stopActiveTunnelsInternal()
|
||||
stopActiveTunnelsInternal(source)
|
||||
}
|
||||
|
||||
startTunnelInternal(config, source)
|
||||
@@ -131,7 +137,13 @@ class TunnelCoordinator(
|
||||
stopTunnelInternal(id, source)
|
||||
}
|
||||
|
||||
suspend fun stopActiveTunnels() = tunnelMutex.withLock { stopActiveTunnelsInternal() }
|
||||
suspend fun stopActiveTunnels(source: TunnelActionSource = TunnelActionSource.USER) =
|
||||
tunnelMutex.withLock {
|
||||
if (source == TunnelActionSource.USER) {
|
||||
_userOverrideFlow.tryEmit(Unit)
|
||||
}
|
||||
stopActiveTunnelsInternal(source)
|
||||
}
|
||||
|
||||
private suspend fun startTunnelInternal(
|
||||
tunnelConfig: TunnelConfig,
|
||||
@@ -143,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 =
|
||||
@@ -178,8 +191,10 @@ class TunnelCoordinator(
|
||||
}
|
||||
|
||||
TunnelMode.LOCK_DOWN -> {
|
||||
|
||||
BackendMode.Proxy.KillSwitchPrimary(runConfig)
|
||||
BackendMode.Proxy.KillSwitchPrimary(
|
||||
runConfig,
|
||||
lockdownSettings.toKillSwitchConfig(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,7 +233,7 @@ class TunnelCoordinator(
|
||||
_actions.emit(TunnelActionEvent.Stopped(tunnelId = id, source = source))
|
||||
}
|
||||
|
||||
stopActiveTunnelsInternal()
|
||||
stopActiveTunnelsInternal(source)
|
||||
return@withLock
|
||||
}
|
||||
|
||||
@@ -243,7 +258,15 @@ class TunnelCoordinator(
|
||||
.onFailure { _errors.emit(TunnelErrorEvent.from(it, id)) }
|
||||
}
|
||||
|
||||
private suspend fun stopActiveTunnelsInternal() {
|
||||
private suspend fun stopActiveTunnelsInternal(
|
||||
source: TunnelActionSource = TunnelActionSource.USER
|
||||
) {
|
||||
val active = tunnelProvider.backendStatus.value.activeTunnels
|
||||
|
||||
active.keys.forEach { id ->
|
||||
_actions.emit(TunnelActionEvent.Stopped(tunnelId = id, source = source))
|
||||
}
|
||||
|
||||
tunnelProvider.stopActiveTunnels()
|
||||
}
|
||||
}
|
||||
|
||||
+3
-4
@@ -19,9 +19,8 @@ class ShortcutsActivity : ComponentActivity() {
|
||||
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
applicationScope.launch {
|
||||
shortcutCoordinator.handle(intent)
|
||||
finish()
|
||||
}
|
||||
finish()
|
||||
|
||||
applicationScope.launch { shortcutCoordinator.handle(intent) }
|
||||
}
|
||||
}
|
||||
|
||||
-9
@@ -4,14 +4,11 @@ import com.zaneschepke.tunnel.Tunnel
|
||||
import com.zaneschepke.tunnel.backend.Backend
|
||||
import com.zaneschepke.tunnel.model.BackendMode
|
||||
import com.zaneschepke.tunnel.state.BackendStatus
|
||||
import com.zaneschepke.wireguardautotunnel.domain.events.BackendCoreException
|
||||
import com.zaneschepke.wireguardautotunnel.domain.events.BackendMessage
|
||||
import com.zaneschepke.wireguardautotunnel.domain.model.LockdownSettings
|
||||
import kotlin.concurrent.atomics.ExperimentalAtomicApi
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
@@ -52,10 +49,4 @@ class TunnelBackendProvider(
|
||||
override suspend fun disableLockDown(): Result<Unit> {
|
||||
return backend.disableKillSwitch()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
private val localErrorEvents = MutableSharedFlow<Pair<String?, BackendCoreException>>()
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
private val localMessageEvents = MutableSharedFlow<Pair<String?, BackendMessage>>()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
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.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.zaneschepke.wireguardautotunnel.R
|
||||
|
||||
@Composable
|
||||
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),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.local_network_permission_issues_intro),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
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.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,
|
||||
)
|
||||
}
|
||||
},
|
||||
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))
|
||||
|
||||
@@ -18,5 +18,6 @@ data class GlobalAppUiState(
|
||||
val selectedTunnelCount: Int = 0,
|
||||
val alreadyDonated: Boolean = false,
|
||||
val isPinVerified: Boolean = false,
|
||||
val pendingWgImportUrl: String? = null,
|
||||
val isScreenRecordingProtectionEnabled: Boolean = false,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package com.zaneschepke.wireguardautotunnel.util.extensions
|
||||
|
||||
import io.ktor.client.statement.HttpResponse
|
||||
import io.ktor.client.statement.bodyAsText
|
||||
|
||||
suspend fun HttpResponse.isHtmlResponse(): Boolean {
|
||||
val contentType = headers["Content-Type"] ?: ""
|
||||
if (contentType.contains("text/html", ignoreCase = true)) return true
|
||||
|
||||
val bodyStart = bodyAsText().trimStart()
|
||||
return bodyStart.startsWith("<!DOCTYPE", ignoreCase = true) ||
|
||||
bodyStart.startsWith("<html", ignoreCase = true)
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package com.zaneschepke.wireguardautotunnel.util.permission
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import androidx.core.content.ContextCompat
|
||||
|
||||
object LocalNetworkPermissionHelper {
|
||||
|
||||
fun shouldRequestPermission(): Boolean {
|
||||
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN
|
||||
}
|
||||
|
||||
fun isPermissionGranted(context: Context): Boolean {
|
||||
return if (shouldRequestPermission()) {
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_LOCAL_NETWORK) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
+21
-7
@@ -29,6 +29,7 @@ import com.zaneschepke.wireguardautotunnel.util.StringValue
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.QuickConfig
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.TunnelName
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.asStringValue
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.isHtmlResponse
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.saveTunnelsUniquely
|
||||
import com.zaneschepke.wireguardautotunnel.util.network.NetworkUtils
|
||||
import io.ktor.client.HttpClient
|
||||
@@ -274,17 +275,30 @@ class SharedAppViewModel(
|
||||
|
||||
fun importFromQr(conf: String) = intent { importFromClipboard(conf) }
|
||||
|
||||
fun promptWgImport(url: String) = intent { reduce { state.copy(pendingWgImportUrl = url) } }
|
||||
|
||||
fun dismissWgImport() = intent { reduce { state.copy(pendingWgImportUrl = null) } }
|
||||
|
||||
fun importFromUrl(url: String) = intent {
|
||||
reduce { state.copy(pendingWgImportUrl = null) }
|
||||
|
||||
try {
|
||||
httpClient.prepareGet(url).execute { response ->
|
||||
if (response.status.value in 200..299) {
|
||||
val body = response.bodyAsText()
|
||||
importFromClipboard(body)
|
||||
} else {
|
||||
throw IOException(
|
||||
"Failed to download file with error status: ${response.status.value}"
|
||||
)
|
||||
if (response.status.value !in 200..299) {
|
||||
throw IOException("Server returned error: ${response.status.value}")
|
||||
}
|
||||
|
||||
if (response.isHtmlResponse()) {
|
||||
postSideEffect(
|
||||
GlobalSideEffect.Snackbar(
|
||||
StringValue.StringResource(R.string.error_invalid_config_url),
|
||||
ToastType.Error,
|
||||
)
|
||||
)
|
||||
return@execute
|
||||
}
|
||||
val body = response.bodyAsText()
|
||||
importFromClipboard(body)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e)
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="1024"
|
||||
android:viewportHeight="1024">
|
||||
<group
|
||||
android:scaleX="1.18"
|
||||
android:scaleY="1.18"
|
||||
android:pivotX="512"
|
||||
android:pivotY="512"
|
||||
android:translateX="-45"
|
||||
android:translateY="-45">
|
||||
<path
|
||||
android:pathData="M779.7,207.8C782.5,207.8 785.4,207.8 788.2,207.8C851.8,207.6 851.8,207.6 871.1,225.5C882.4,237.3 887.6,250.9 887.5,267.1C886.9,284.9 879,300.9 872,317C871,319.2 870.1,321.4 869.1,323.7C867.7,327.1 866.2,330.6 864.7,334.1C861,342.7 857.3,351.4 853.7,360.1C847.1,375.7 840.5,391.3 833.8,406.9C831.1,413.1 828.5,419.2 825.8,425.4C822.9,432.3 819.9,439.2 817,446C804.9,474 804.9,474 793.3,502.2C791,507.8 788.5,513.3 785.9,518.7C782,527 778.5,535.5 775,544C772,551 769,557.9 766.1,564.9C765.6,566 765.1,567.2 764.6,568.4C763.5,570.7 762.5,573.1 761.5,575.5C760,579 758.5,582.6 757,586.1C751.8,598.2 746.6,610.4 741.2,622.4C737.3,631.3 733.4,640.3 729.5,649.2C725.8,657.9 722,666.6 718,675.2C715.7,680.4 713.5,685.6 711.4,690.8C708.5,697.8 705.4,704.7 702.1,711.6C700.8,714.5 699.4,717.4 698,720.4C685.7,746.7 672.9,772.5 643.9,783.1C639.6,784.4 635.5,784.7 631,785C630.2,785.1 629.5,785.1 628.7,785.2C610.7,785.8 596.7,779.5 583.5,767.8C569.4,754.5 562,735.7 554.3,718.3C552.1,713.5 549.9,708.7 547.5,704.1C543.9,696.9 540.8,689.6 537.6,682.2C534.6,675 531.4,667.8 528.3,660.6C522.3,646.9 516.3,633.3 510.4,619.6C508.5,615.1 506.5,610.6 504.6,606.1C494.1,582.1 494.1,582.1 489.6,571.2C488,567.4 486.4,563.6 484.6,559.8C481.8,553.6 479.2,547.4 476.6,541.1C472.4,531.2 472.4,531.2 468,521.5C461.3,507.3 455.5,492.6 449.5,478.1C444.9,466.9 440.2,455.8 435.3,444.7C431.5,436.1 427.7,427.4 424.1,418.8C423.8,418.1 423.5,417.4 423.2,416.6C420,409.1 416.8,401.6 413.7,394C413.4,393.3 413.2,392.7 412.9,392C411.6,389 410.4,386 409.2,383C406.8,377.1 404.3,371.4 401.6,365.7C397.9,357.9 394.6,349.9 391.3,341.9C390.4,339.6 389.5,337.4 388.6,335.2C388,333.7 387.3,332.2 386.7,330.7C384.7,325.7 382.6,320.7 380.6,315.8C380,314.5 379.5,313.2 378.9,311.9C377.9,309.5 376.9,307.1 375.9,304.7C368.4,286.7 361.9,264.8 369.7,245.7C373.8,237.4 377.9,230 385,224C385.6,223.5 386.2,223 386.8,222.4C397,213.9 412.5,209.7 425.8,210.1C442.3,212.1 455.2,220.4 466,233C471.9,241.6 476.5,250.7 480.9,260.1C481.5,261.3 482.2,262.6 482.8,263.9C487.3,273.5 491.7,283.1 496,292.8C497,294.9 497.9,297.1 498.9,299.2C503.6,309.6 508.1,320.1 512.5,330.6C515.1,336.6 517.7,342.5 520.5,348.4C524.1,356.1 527.4,363.9 530.6,371.8C531.7,374.4 532.8,377 533.9,379.7C534.3,380.6 534.3,380.6 534.7,381.6C536.3,385.3 537.8,388.9 539.5,392.5C541.5,396.7 543.3,401 545.1,405.3C545.4,406 545.6,406.6 545.9,407.3C547,409.9 548.1,412.6 549.2,415.2C552.5,423.2 555.9,431.1 559.6,438.9C561.7,443.4 563.7,447.9 565.5,452.5C567.6,457.7 569.8,462.9 572.3,467.9C575.9,475.7 579.2,483.5 582.5,491.4C585.8,499.2 589.1,507 592.5,514.7C617.2,571.5 617.2,571.5 625.1,591.7C625.8,593.9 625.8,593.9 627,595C627.3,594.3 627.6,593.5 627.9,592.7C632.8,580 637.7,567.3 643.4,554.9C646.9,547.2 650.2,539.5 653.4,531.8C653.7,531.1 654,530.4 654.3,529.7C657.2,522.9 660,516 662.9,509.1C663.3,508 663.8,506.9 664.2,505.8C665,503.9 665.9,501.9 666.7,499.9C668.9,494.5 671.3,489.1 673.8,483.7C675.7,479.4 677.6,475 679.5,470.7C679.9,469.7 680.3,468.7 680.8,467.7C682.2,464.5 683.6,461.2 685,458C686,455.6 687.1,453.3 688.1,450.9C689.7,447.1 691.3,443.4 693,439.6C695.8,433.2 698.5,426.8 701.3,420.3C714.6,389.7 714.6,389.7 727.2,358.9C729.7,352.7 732.4,346.8 735.3,340.8C736.9,337.4 738.2,333.8 739.6,330.3C740.1,329.1 740.5,327.9 741,326.7C741.3,325.8 741.6,324.9 742,324C741.2,324 741.2,324 740.3,324C726.5,324.1 712.7,324.1 698.9,324.1C692.2,324.1 685.5,324.1 678.9,324.2C672.4,324.2 665.9,324.2 659.5,324.2C657,324.2 654.6,324.2 652.1,324.2C640.5,324.3 629,324.2 617.4,323.1C616.6,323.1 615.8,323 614.9,322.9C597,321 581.4,314.3 569,301C566.5,297.8 564.7,294.6 563,291C562.5,290 562,289 561.5,288C556.5,276.1 555.8,261.8 559.6,249.4C562,243.5 565.3,238.2 569,233C569.7,232 569.7,232 570.4,231C581.2,217.3 599.1,212.3 615.8,210.2C637.1,208 659,208.7 680.4,208.5C683.5,208.4 686.5,208.4 689.5,208.4C719.6,208.1 749.6,207.9 779.7,207.8Z"
|
||||
android:fillColor="#FFFFFF"/>
|
||||
<path
|
||||
android:pathData="M263.3,223.5C276.3,234.5 282.6,248.9 289.6,264C290.9,266.8 292.2,269.5 293.5,272.2C303,291.5 311.4,311.3 320,331C320.3,331.7 320.6,332.5 321,333.2C323.4,338.9 325.9,344.5 328.3,350.2C332.2,359.1 336.1,368.1 340,377C346.2,391.1 352.3,405.2 358.4,419.3C360.8,424.9 363.3,430.5 365.7,436.1C366.5,438 367.3,439.9 368.1,441.7C370.6,447.4 373,453 375.5,458.7C382.5,474.8 389.4,491 396.4,507.2C398.1,511.3 399.9,515.5 401.7,519.6C408.7,536 415.8,552.5 422.7,568.9C424,572 425.3,575.2 426.7,578.3C433,593.2 439.2,608.1 445.5,623C447.5,627.7 449.4,632.4 451.4,637C453.8,642.9 456.3,648.7 458.7,654.6C459.9,657.5 461.2,660.4 462.4,663.3C467.1,674.6 471.8,685.9 476.4,697.3C477.1,698.8 477.1,698.8 477.7,700.4C484.3,716.8 488.7,735.5 482,752.6C479.3,758.3 475.9,763.2 472,768C471.6,768.6 471.1,769.2 470.7,769.8C464.5,777.8 455.6,782.4 446,785C445,785.3 444,785.7 443,786C431.6,787 420.6,786.5 410,782C408.7,781.5 408.7,781.5 407.4,781C380.7,769.4 369.2,736.1 358.2,711.6C356.7,708.3 355.1,705 353.6,701.7C340.8,674.4 329,646.7 317.5,618.9C316,615.2 314.3,611.5 312.7,607.9C309.7,601.4 306.9,594.9 304.1,588.4C303.9,587.8 303.6,587.2 303.3,586.5C299.7,578 296.1,569.4 292.6,560.8C291,556.9 289.2,553 287.4,549.1C284.7,543 282,536.9 279.3,530.8C278.7,529.3 278.1,527.9 277.4,526.5C276.2,523.6 274.9,520.8 273.7,517.9C272.3,514.6 270.8,511.3 269.4,508C264.2,496.1 259.1,484.2 254.1,472.3C250.4,463.6 246.7,455 242.9,446.3C242.5,445.4 242.2,444.5 241.8,443.6C240,439.6 238.3,435.6 236.5,431.6C234.9,427.8 233.2,424.1 231.6,420.4C231.4,419.7 231.1,419.1 230.8,418.5C226.1,407.6 221.5,396.7 217,385.8C214.5,379.6 211.9,373.5 209.2,367.5C207.4,363.7 205.8,359.9 204.1,356.1C203.8,355.2 203.4,354.4 203,353.5C199.1,344.2 195.2,334.9 191.3,325.6C191,324.9 190.7,324.1 190.3,323.3C187.1,315.5 183.8,307.6 180.7,299.8C180.2,298.6 179.7,297.4 179.2,296.2C177.6,291.9 176.2,287.5 175,283C174.8,282.4 174.7,281.8 174.5,281.2C171.2,266.5 173.7,250.9 181.4,238.2C191.3,223.5 203.1,215.7 220.6,212.3C236.3,210.9 250.7,213.5 263.3,223.5Z"
|
||||
android:fillColor="#FFFFFF"/>
|
||||
</group>
|
||||
</vector>
|
||||
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.5 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.6 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 3.4 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 5.6 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 7.8 KiB |
@@ -138,6 +138,7 @@
|
||||
<string name="config_error">Ungültige Konfiguration</string>
|
||||
<string name="join_matrix">Matrix-Community beitreten</string>
|
||||
<string name="error_download_failed">Download der Konfiguration fehlgeschlagen</string>
|
||||
<string name="wg_url_confirm_message">Möchtest du wirklich Tunnel von %1$s hinzufügen? Verbinde dich niemals mit einem nicht vertrauenswürdigen VPN!</string>
|
||||
<string name="add_from_url">Von URL hinzufügen</string>
|
||||
<string name="export_logs">Gespeicherte Logs exportieren</string>
|
||||
<string name="app_permission_title">Steuere Tunnel und Auto-Tunnel Funktionen.</string>
|
||||
|
||||
@@ -155,6 +155,7 @@
|
||||
<string name="delete">Удалить</string>
|
||||
<string name="export_failed">Экспорт не выполнен</string>
|
||||
<string name="error_download_failed">Невозможно скачать конфигурацию</string>
|
||||
<string name="wg_url_confirm_message">Добавить туннели от %1$s? Никогда не подключайтесь к неизвестному VPN!</string>
|
||||
<string name="select_all">Выбрать все</string>
|
||||
<string name="export_success">Экспорт успешно выполнен</string>
|
||||
<string name="check_for_update">Проверить обновление</string>
|
||||
|
||||
@@ -153,6 +153,7 @@
|
||||
<string name="add_from_url">Add from URL</string>
|
||||
<string name="enter_config_url">Enter config URL</string>
|
||||
<string name="error_download_failed">Failed to download config</string>
|
||||
<string name="wg_url_confirm_message">Are you sure you want to add tunnels from %1$s? Never connect to an untrusted VPN!</string>
|
||||
<string name="save">Save</string>
|
||||
<string name="search">Search</string>
|
||||
<string name="select">Select</string>
|
||||
@@ -535,4 +536,21 @@
|
||||
<string name="hide_password">Hide password</string>
|
||||
<string name="restore_failed_wrong_password">Restore failed. Wrong password</string>
|
||||
<string name="restore_failed_invalid_file">Restore failed. Select a valid backup file (.sqlite3 or .sqlite3.aes)</string>
|
||||
<string name="error_invalid_config_url">This link returned an invalid config file. Make sure you are using a direct download link</string>
|
||||
|
||||
<string name="local_network_permission_title">Local Network Access Needed</string>
|
||||
|
||||
<string name="local_network_permission_intro">WG Tunnel needs access to your local network for several features to work properly.</string>
|
||||
|
||||
<string name="local_network_permission_issues_intro">Without this permission, you may experience issues with:</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="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,5 +1,4 @@
|
||||
import com.ncorti.ktfmt.gradle.tasks.KtfmtFormatTask
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.application) apply false
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
object Constants {
|
||||
const val VERSION_NAME = "5.0.4"
|
||||
const val VERSION_CODE = 50004
|
||||
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,5 @@
|
||||
What's new:
|
||||
- Bugfix for certain scenarios that were not cleaning up the vpn service fully
|
||||
- Allows tunnel imports via wg:// url deep links
|
||||
- Improvements to Always-On VPN reliability
|
||||
- Improved cellular connectivity detection for dual sims
|
||||
@@ -0,0 +1,3 @@
|
||||
What's new:
|
||||
- Bugfix for Android 17 local network permission requirement
|
||||
- Bugfix for app shortcuts causing crash
|
||||
@@ -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"
|
||||
|
||||
+194
-171
@@ -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,16 +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 airplaneModeFlow: Flow<Boolean> = airplaneModeState.asStateFlow()
|
||||
private val activeCellularNetworks =
|
||||
MutableStateFlow<Map<Network, NetworkCapabilities>>(emptyMap())
|
||||
|
||||
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
|
||||
@@ -191,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) {
|
||||
@@ -212,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")
|
||||
@@ -230,8 +316,8 @@ class AndroidNetworkMonitor(
|
||||
trySend(TransportEvent.CapabilitiesChanged(network, caps))
|
||||
}
|
||||
}
|
||||
}
|
||||
connectivityManager?.registerDefaultNetworkCallback(defaultNetworkCallback!!)
|
||||
}
|
||||
connectivityManager?.registerDefaultNetworkCallback(defaultNetworkCallback)
|
||||
|
||||
trySend(
|
||||
TransportEvent.Permissions(
|
||||
@@ -243,7 +329,7 @@ class AndroidNetworkMonitor(
|
||||
)
|
||||
|
||||
awaitClose {
|
||||
connectivityManager?.unregisterNetworkCallback(defaultNetworkCallback!!)
|
||||
connectivityManager?.unregisterNetworkCallback(defaultNetworkCallback)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -274,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)
|
||||
@@ -304,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") }
|
||||
}
|
||||
}
|
||||
@@ -317,17 +403,27 @@ class AndroidNetworkMonitor(
|
||||
private val cellularFlow: Flow<TransportEvent> = callbackFlow {
|
||||
val onAvailable: (Network) -> Unit = { network ->
|
||||
Timber.d("Cellular onAvailable: $network")
|
||||
}
|
||||
val onLost: (Network) -> Unit = { network ->
|
||||
Timber.d("Cellular onLost: $network")
|
||||
trySend(TransportEvent.Lost(network))
|
||||
}
|
||||
val onCapabilitiesChanged: (Network, NetworkCapabilities) -> Unit = { network, caps ->
|
||||
Timber.d("Cellular onCapabilitiesChanged: $network")
|
||||
trySend(TransportEvent.CapabilitiesChanged(network, caps))
|
||||
val caps = connectivityManager?.getNetworkCapabilities(network)
|
||||
if (caps != null && caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) {
|
||||
activeCellularNetworks.update { it + (network to caps) }
|
||||
trySend(TransportEvent.CapabilitiesChanged(network, caps))
|
||||
}
|
||||
}
|
||||
|
||||
cellularCallback =
|
||||
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) }
|
||||
trySend(TransportEvent.CapabilitiesChanged(network, caps))
|
||||
}
|
||||
}
|
||||
|
||||
val cellularCallback =
|
||||
object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) = onAvailable(network)
|
||||
|
||||
@@ -339,15 +435,12 @@ class AndroidNetworkMonitor(
|
||||
|
||||
val request =
|
||||
NetworkRequest.Builder()
|
||||
.apply { addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR) }
|
||||
.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") }
|
||||
}
|
||||
}
|
||||
@@ -365,7 +458,7 @@ class AndroidNetworkMonitor(
|
||||
trySend(TransportEvent.CapabilitiesChanged(network, caps))
|
||||
}
|
||||
|
||||
ethernetCallback =
|
||||
val ethernetCallback =
|
||||
object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) = onAvailable(network)
|
||||
|
||||
@@ -380,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") }
|
||||
}
|
||||
}
|
||||
@@ -438,6 +531,13 @@ class AndroidNetworkMonitor(
|
||||
.also { Timber.d("Current SSID via ${method.name}: $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)
|
||||
}
|
||||
|
||||
// default network events don't contain detailed capability information of underlying networks,
|
||||
// so we need to track separately
|
||||
private data class NetworkData(
|
||||
@@ -457,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 ->
|
||||
@@ -490,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,
|
||||
)
|
||||
}
|
||||
@@ -513,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
|
||||
@@ -573,28 +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 &&
|
||||
networkData.cellularEvent is TransportEvent.CapabilitiesChanged &&
|
||||
networkData.cellularEvent.networkCapabilities?.let { caps ->
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) &&
|
||||
caps.hasCapability(
|
||||
NetworkCapabilities.NET_CAPABILITY_INTERNET
|
||||
) &&
|
||||
caps.hasCapability(
|
||||
NetworkCapabilities.NET_CAPABILITY_VALIDATED
|
||||
) &&
|
||||
caps.hasCapability(
|
||||
NetworkCapabilities.NET_CAPABILITY_NOT_SUSPENDED
|
||||
)
|
||||
} == true -> {
|
||||
ActiveNetwork.Cellular(networkData.cellularEvent.network)
|
||||
if (bestCellularEntry != null) {
|
||||
ActiveNetwork.Cellular(
|
||||
bestCellularEntry.key,
|
||||
bestCellularEntry.value,
|
||||
)
|
||||
} else {
|
||||
ActiveNetwork.Disconnected()
|
||||
}
|
||||
}
|
||||
|
||||
else -> ActiveNetwork.Disconnected()
|
||||
}
|
||||
|
||||
lastKnownActiveNetwork.value = physicalNetwork
|
||||
@@ -622,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),
|
||||
@@ -644,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,6 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
|
||||
<!--foreground service special use for non VPN service tunnels, android 14-->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
<!--foreground service special use for VPN service tunnels, android 14-->
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -23,10 +23,8 @@ import com.zaneschepke.tunnel.state.KillSwitchState
|
||||
import com.zaneschepke.tunnel.util.RootShell
|
||||
import com.zaneschepke.tunnel.util.RootShellException
|
||||
import com.zaneschepke.tunnel.util.buildResolvedPeers
|
||||
import com.zaneschepke.tunnel.util.isLastTunnelOfServiceType
|
||||
import com.zaneschepke.tunnel.util.toHostMap
|
||||
import com.zaneschepke.wireguardautotunnel.parser.ActiveConfig
|
||||
import java.lang.ref.WeakReference
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
@@ -41,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
|
||||
@@ -92,8 +93,6 @@ class TunnelBackend(
|
||||
NETWORK_CHANGE_RESET,
|
||||
}
|
||||
|
||||
private var dnsConfigJob: Job? = null
|
||||
|
||||
private val statusCallback = StatusCallback { handle, code ->
|
||||
val state = Tunnel.State.fromNative(code) ?: return@StatusCallback
|
||||
val tunnelId = byHandle[handle] ?: return@StatusCallback
|
||||
@@ -107,7 +106,7 @@ class TunnelBackend(
|
||||
tunnelMutex.withLock {
|
||||
runCatching {
|
||||
if (_status.value.activeTunnels.containsKey(tunnel.id)) {
|
||||
Timber.d("Tunnel ${tunnel.id} already running — ignoring start")
|
||||
Timber.w("Tunnel ${tunnel.id} already running")
|
||||
return@runCatching
|
||||
}
|
||||
|
||||
@@ -130,87 +129,81 @@ class TunnelBackend(
|
||||
if (scriptsEnabled)
|
||||
mode.config.`interface`.preUp?.let { runScripts(it, tunnel.id) }
|
||||
|
||||
val fd = setupServiceForMode(tunnel, mode)
|
||||
setupServiceForMode(tunnel, mode)
|
||||
|
||||
if (hasDynamicEndpoints(mode)) {
|
||||
pendingResolutionJobs[tunnel.id] = startTunnelBootstrapJob(tunnel, mode, fd)
|
||||
pendingResolutionJobs[tunnel.id] = startTunnelBootstrapJob(tunnel, mode)
|
||||
} else {
|
||||
val result = engine.start(tunnel, mode, fd)
|
||||
val result = engine.start(tunnel, mode)
|
||||
onEngineStartResult(tunnel.id, result)
|
||||
|
||||
if (scriptsEnabled) {
|
||||
mode.config.`interface`.postUp?.let { runScripts(it, tunnel.id) }
|
||||
}
|
||||
|
||||
tunnelJobs[tunnel.id] = startTunnelJobs(result.handle, tunnel, mode)
|
||||
}
|
||||
}
|
||||
.onFailure { cleanup(tunnel.id) }
|
||||
}
|
||||
|
||||
private fun startTunnelBootstrapJob(tunnel: Tunnel, mode: BackendMode, fd: Int?) =
|
||||
scope.launch {
|
||||
try {
|
||||
updateTunnelBootstrapState(tunnel.id, BootstrapState.ResolvingDns)
|
||||
private fun startTunnelBootstrapJob(tunnel: Tunnel, mode: BackendMode) = scope.launch {
|
||||
try {
|
||||
updateTunnelBootstrapState(tunnel.id, BootstrapState.ResolvingDns)
|
||||
|
||||
val resultMap = endpointResolver.resolvePeers(mode)
|
||||
ensureActive()
|
||||
val resultMap = endpointResolver.resolvePeers(mode)
|
||||
ensureActive()
|
||||
|
||||
val networkHasIpv6 = stableNetworkEngine.stableState.value?.state?.hasIpv6 ?: false
|
||||
val hostMap =
|
||||
resultMap.toHostMap(
|
||||
preferIpv6 =
|
||||
tunnel.ipStrategy is Tunnel.IpStrategy.PreferIpv6 && networkHasIpv6
|
||||
)
|
||||
val resolvedPeers = mode.config.buildResolvedPeers(hostMap)
|
||||
val networkHasIpv6 = stableNetworkEngine.stableState.value?.state?.hasIpv6 ?: false
|
||||
val hostMap =
|
||||
resultMap.toHostMap(
|
||||
preferIpv6 = tunnel.ipStrategy is Tunnel.IpStrategy.PreferIpv6 && networkHasIpv6
|
||||
)
|
||||
val resolvedPeers = mode.config.buildResolvedPeers(hostMap)
|
||||
|
||||
updateTunnelBootstrapState(tunnel.id, BootstrapState.Complete)
|
||||
updateTunnelBootstrapState(tunnel.id, BootstrapState.Complete)
|
||||
|
||||
val resolvedConfig = mode.config.copy(peers = resolvedPeers)
|
||||
val updatedMode =
|
||||
when (mode) {
|
||||
is BackendMode.Vpn -> mode.copy(config = resolvedConfig)
|
||||
is BackendMode.Proxy.Standard -> mode.copy(config = resolvedConfig)
|
||||
is BackendMode.Proxy.KillSwitchPrimary -> mode.copy(config = resolvedConfig)
|
||||
}
|
||||
|
||||
val result = engine.start(tunnel, updatedMode, fd)
|
||||
onEngineStartResult(tunnel.id, result)
|
||||
|
||||
val scriptsEnabled = tunnel.scriptsEnabled
|
||||
if (scriptsEnabled) {
|
||||
mode.config.`interface`.postUp?.let { runScripts(it, tunnel.id) }
|
||||
val resolvedConfig = mode.config.copy(peers = resolvedPeers)
|
||||
val updatedMode =
|
||||
when (mode) {
|
||||
is BackendMode.Vpn -> mode.copy(config = resolvedConfig)
|
||||
is BackendMode.Proxy.Standard -> mode.copy(config = resolvedConfig)
|
||||
is BackendMode.Proxy.KillSwitchPrimary -> mode.copy(config = resolvedConfig)
|
||||
}
|
||||
|
||||
tunnelJobs[tunnel.id] = startTunnelJobs(result.handle, tunnel, updatedMode)
|
||||
} catch (t: Throwable) {
|
||||
if (t is kotlinx.coroutines.CancellationException) {
|
||||
Timber.d("Bootstrap job cancelled for tunnel ${tunnel.id}")
|
||||
throw t
|
||||
} else {
|
||||
Timber.e(t, "Tunnel bootstrap failed for ${tunnel.id}")
|
||||
}
|
||||
cleanup(tunnel.id)
|
||||
} finally {
|
||||
pendingResolutionJobs.remove(tunnel.id)
|
||||
val result = engine.start(tunnel, updatedMode)
|
||||
onEngineStartResult(tunnel.id, result)
|
||||
|
||||
val scriptsEnabled = tunnel.scriptsEnabled
|
||||
if (scriptsEnabled) {
|
||||
mode.config.`interface`.postUp?.let { runScripts(it, tunnel.id) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun setupServiceForMode(tunnel: Tunnel, mode: BackendMode): Int? {
|
||||
var fd: Int? = null
|
||||
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}")
|
||||
} else {
|
||||
Timber.e(t, "Tunnel bootstrap failed for ${tunnel.id}")
|
||||
cleanup(tunnel.id)
|
||||
}
|
||||
if (t is kotlinx.coroutines.CancellationException) throw t
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
is BackendMode.Vpn -> {
|
||||
val service = serviceHolder.ensureVpnProtectorRegistered()
|
||||
fd = service.createTunInterface(tunnel, mode.config)?.detachFd()
|
||||
service.createTunInterface(tunnel, mode.config)
|
||||
}
|
||||
}
|
||||
return fd
|
||||
}
|
||||
|
||||
private fun onEngineStartResult(tunnelId: Int, result: EngineStartResult) {
|
||||
@@ -221,13 +214,27 @@ class TunnelBackend(
|
||||
byTunnelId[tunnelId] = result.handle
|
||||
}
|
||||
|
||||
private fun cleanup(tunnelId: Int) {
|
||||
private suspend fun cleanup(tunnelId: Int) {
|
||||
pendingResolutionJobs.remove(tunnelId)?.cancel()
|
||||
tunnelJobs.remove(tunnelId)?.cancel()
|
||||
|
||||
val activeTunnels = _status.value.activeTunnels
|
||||
|
||||
val vpnTypeCount = activeTunnels.values.count { it.mode is BackendMode.Vpn }
|
||||
|
||||
val proxyTypeCount = activeTunnels.values.count { it.mode is BackendMode.Proxy.Standard }
|
||||
|
||||
removeActiveTunnel(tunnelId)
|
||||
byTunnelId[tunnelId]?.let { byHandle.remove(it) }
|
||||
byTunnelId.remove(tunnelId)
|
||||
peerUpdateMutexes.remove(tunnelId)
|
||||
|
||||
if (vpnTypeCount == 1) {
|
||||
serviceHolder.stopVpnService()
|
||||
}
|
||||
if (proxyTypeCount == 1) {
|
||||
serviceHolder.stopTunnelService()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun runScripts(commands: List<String>, tunnelId: Int) {
|
||||
@@ -246,29 +253,20 @@ class TunnelBackend(
|
||||
}
|
||||
|
||||
override fun setAlwaysOnCallback(alwaysOnCallback: VpnService.AlwaysOnCallback) {
|
||||
ServiceHolder.alwaysOnCallback = WeakReference(alwaysOnCallback)
|
||||
ServiceHolder.alwaysOnCallback = alwaysOnCallback
|
||||
}
|
||||
|
||||
override suspend fun stop(id: Int): Result<Unit> = tunnelMutex.withLock {
|
||||
runCatching {
|
||||
val activeTun = _status.value.activeTunnels[id] ?: return@runCatching
|
||||
val mode = activeTun.mode ?: return@runCatching
|
||||
updateTunnelTransportState(id, Tunnel.State.Stopping)
|
||||
|
||||
val isLast = _status.value.activeTunnels.size == 1
|
||||
val isLastOfServiceType = _status.value.isLastTunnelOfServiceType(id)
|
||||
|
||||
try {
|
||||
stopTunnelInternal(id, activeTun)
|
||||
} finally {
|
||||
applicationProvider.refreshTile(serviceHolder.context)
|
||||
if (isLast) VpnBackend.setStatusCallback(null)
|
||||
if (isLastOfServiceType) {
|
||||
when (mode) {
|
||||
is BackendMode.Proxy.KillSwitchPrimary,
|
||||
is BackendMode.Vpn -> serviceHolder.stopVpnService()
|
||||
is BackendMode.Proxy.Standard -> serviceHolder.stopTunnelService()
|
||||
}
|
||||
if (_status.value.activeTunnels.isEmpty()) {
|
||||
VpnBackend.setStatusCallback(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -277,8 +275,6 @@ class TunnelBackend(
|
||||
private suspend fun stopTunnelInternal(tunnelId: Int, activeTunnel: ActiveTunnel) {
|
||||
updateTunnelTransportState(tunnelId, Tunnel.State.Stopping)
|
||||
|
||||
pendingResolutionJobs.remove(tunnelId)?.cancel()
|
||||
|
||||
val handle = byTunnelId[tunnelId]
|
||||
|
||||
if (handle == null) {
|
||||
@@ -433,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -479,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()) {
|
||||
@@ -490,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
|
||||
@@ -499,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,
|
||||
@@ -727,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
|
||||
|
||||
@@ -8,7 +8,7 @@ import com.zaneschepke.wireguardautotunnel.parser.PeerSection
|
||||
|
||||
internal interface TunnelEngine {
|
||||
|
||||
suspend fun start(tunnel: Tunnel, mode: BackendMode, fd: Int?): EngineStartResult
|
||||
suspend fun start(tunnel: Tunnel, mode: BackendMode): EngineStartResult
|
||||
|
||||
suspend fun stop(handle: Int, mode: BackendMode)
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import java.util.UUID
|
||||
|
||||
internal class WireGuardTunnelEngine(private val serviceHolder: ServiceHolder) : TunnelEngine {
|
||||
|
||||
override suspend fun start(tunnel: Tunnel, mode: BackendMode, fd: Int?): EngineStartResult {
|
||||
override suspend fun start(tunnel: Tunnel, mode: BackendMode): EngineStartResult {
|
||||
|
||||
val ifName = WGT_INTERFACE_PREFIX + tunnel.id
|
||||
|
||||
@@ -56,7 +56,8 @@ internal class WireGuardTunnelEngine(private val serviceHolder: ServiceHolder) :
|
||||
startProxyTunnel(ifName, mode.config, proxyConfig, false)
|
||||
}
|
||||
is BackendMode.Vpn -> {
|
||||
startVpnTunnel(ifName, mode.config, fd)
|
||||
val service = serviceHolder.getVpnService()
|
||||
startVpnTunnel(ifName, mode.config, service.detachVpnTunnelFd())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -4,7 +4,6 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import com.zaneschepke.tunnel.ProxyBackend
|
||||
import com.zaneschepke.tunnel.util.BackendException
|
||||
import java.lang.ref.WeakReference
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -50,7 +49,7 @@ internal class ServiceHolder(val context: Context) {
|
||||
}
|
||||
|
||||
if (_vpnService.value == null) {
|
||||
context.startService(Intent(context, VpnService::class.java))
|
||||
VpnService.start(context, VpnService::class.java)
|
||||
}
|
||||
|
||||
return try {
|
||||
@@ -76,16 +75,22 @@ internal class ServiceHolder(val context: Context) {
|
||||
|
||||
suspend fun stopVpnService() {
|
||||
val service = _vpnService.value ?: return
|
||||
clearVpnService()
|
||||
service.shutdown()
|
||||
withTimeoutOrNull(1_000L.milliseconds) { vpnServiceFlow.first { it == null } }
|
||||
try {
|
||||
service.shutdown()
|
||||
withTimeoutOrNull(1_500L.milliseconds) { vpnServiceFlow.first { it == null } }
|
||||
} finally {
|
||||
clearVpnService()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun stopTunnelService() {
|
||||
val service = _tunnelService.value ?: return
|
||||
clearTunnelService()
|
||||
service.shutdown()
|
||||
withTimeoutOrNull(1_000L.milliseconds) { tunnelServiceFlow.first { it == null } }
|
||||
try {
|
||||
service.shutdown()
|
||||
withTimeoutOrNull(1_500L.milliseconds) { tunnelServiceFlow.first { it == null } }
|
||||
} finally {
|
||||
clearTunnelService()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -104,6 +109,6 @@ internal class ServiceHolder(val context: Context) {
|
||||
const val SPECIAL_USE_SERVICE_TYPE_ID = 1 shl 30
|
||||
const val DEFAULT_MTU = 1280
|
||||
// for consumer to set AOVPN callback
|
||||
var alwaysOnCallback: WeakReference<VpnService.AlwaysOnCallback>? = null
|
||||
var alwaysOnCallback: VpnService.AlwaysOnCallback? = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ class TunnelService : LifecycleService() {
|
||||
(intent.component!!.packageName != packageName)
|
||||
) {
|
||||
Timber.d("TunnelService started by system")
|
||||
alwaysOnCallback?.get()?.alwaysOnTriggered()
|
||||
alwaysOnCallback?.alwaysOnTriggered()
|
||||
}
|
||||
|
||||
return START_STICKY
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.zaneschepke.tunnel.service
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.TrafficStats
|
||||
import android.os.Build
|
||||
@@ -39,10 +40,11 @@ class VpnService : android.net.VpnService(), KillSwitch, SocketProtector {
|
||||
|
||||
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val shutdownScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
@Volatile private var userActivatedShutdown = false
|
||||
private var hevBridgeJob: Job? = null
|
||||
@Volatile private var fd: ParcelFileDescriptor? = null
|
||||
@Volatile private var hevBridgeFd: ParcelFileDescriptor? = null
|
||||
@Volatile private var vpnTunFd: ParcelFileDescriptor? = null
|
||||
|
||||
@Volatile private var currentKillSwitchConfig: KillSwitchConfig? = null
|
||||
|
||||
override fun onCreate() {
|
||||
serviceHolder.set(this)
|
||||
@@ -58,31 +60,21 @@ class VpnService : android.net.VpnService(), KillSwitch, SocketProtector {
|
||||
// Stop the companion foreground service alongside the VPN teardown
|
||||
stopService(Intent(this, VpnCompanionService::class.java))
|
||||
|
||||
closeVpnTunnelFd()
|
||||
disableKillSwitch()
|
||||
hevBridgeJob?.cancel()
|
||||
serviceScope.cancel()
|
||||
stopHevSocks5Bridge()
|
||||
if (!userActivatedShutdown) {
|
||||
Timber.d("Service being killed by system, clean up tunnels")
|
||||
shutdownScope.launch { backend.stopAllActiveTunnels() }
|
||||
}
|
||||
} finally {
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalAtomicApi::class)
|
||||
fun shutdown() {
|
||||
userActivatedShutdown = true
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
override fun onRevoke() {
|
||||
Timber.w("VPN privilege revoked by system")
|
||||
userActivatedShutdown = false
|
||||
Timber.w("VPN revoked by user via system settings")
|
||||
disableKillSwitch()
|
||||
stopHevSocks5Bridge()
|
||||
serviceScope.launch { backend.stopAllActiveTunnels() }
|
||||
shutdownScope.launch { backend.stopAllActiveTunnels() }
|
||||
stopSelf()
|
||||
super.onRevoke()
|
||||
}
|
||||
@@ -90,21 +82,40 @@ class VpnService : android.net.VpnService(), KillSwitch, SocketProtector {
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
serviceHolder.set(this)
|
||||
|
||||
// Ensure the companion service is up immediately to provide foreground process
|
||||
bootKeepaliveService()
|
||||
|
||||
// Service restarted by system or Always-on VPN started
|
||||
if (
|
||||
intent == null ||
|
||||
intent.component == null ||
|
||||
(intent.component!!.packageName != packageName)
|
||||
) {
|
||||
Timber.d("VpnService started by system (Always-On trigger)")
|
||||
alwaysOnCallback?.get()?.alwaysOnTriggered()
|
||||
// system recovery restart
|
||||
if (intent == null) {
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
val isUserLaunch = intent.getBooleanExtra(getUserLaunchExtraKey(this), false)
|
||||
|
||||
val platformSaysAlwaysOn =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
isAlwaysOn
|
||||
} else {
|
||||
false
|
||||
}
|
||||
|
||||
val isAlwaysOnTrigger =
|
||||
!isUserLaunch && (intent.action == SERVICE_INTERFACE || platformSaysAlwaysOn)
|
||||
|
||||
if (isAlwaysOnTrigger) {
|
||||
Timber.d("VpnService started by system (Always-On trigger)")
|
||||
alwaysOnCallback?.alwaysOnTriggered()
|
||||
}
|
||||
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
fun shutdown() {
|
||||
// have to close fds before we can trigger service shutdown
|
||||
closeVpnTunnelFd()
|
||||
disableKillSwitch()
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
private fun bootKeepaliveService() {
|
||||
try {
|
||||
val intent = Intent(this, VpnCompanionService::class.java)
|
||||
@@ -119,7 +130,7 @@ class VpnService : android.net.VpnService(), KillSwitch, SocketProtector {
|
||||
val job = serviceScope.launch {
|
||||
TrafficStats.setThreadStatsTag(HEV_BRIDGE_TRAFFIC_TAG)
|
||||
try {
|
||||
val vpnFd = fd ?: throw IOException("No VPN interface fd available")
|
||||
val vpnFd = hevBridgeFd ?: throw IOException("No VPN interface fd available")
|
||||
|
||||
repeat(60) { attempt ->
|
||||
try {
|
||||
@@ -176,15 +187,22 @@ class VpnService : android.net.VpnService(), KillSwitch, SocketProtector {
|
||||
}
|
||||
|
||||
private fun disableKillSwitch() {
|
||||
fd?.close()
|
||||
fd = null
|
||||
hevBridgeFd?.close()
|
||||
hevBridgeFd = null
|
||||
currentKillSwitchConfig = null
|
||||
}
|
||||
|
||||
override fun setKillSwitch(config: KillSwitchConfig?) {
|
||||
if (config == null) return disableKillSwitch()
|
||||
fd?.close()
|
||||
|
||||
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)
|
||||
fd =
|
||||
hevBridgeFd =
|
||||
Builder()
|
||||
.apply {
|
||||
setSession(LOCKDOWN_SESSION_NAME)
|
||||
@@ -203,84 +221,102 @@ 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): ParcelFileDescriptor? {
|
||||
fun createTunInterface(tunnel: Tunnel, config: Config) {
|
||||
val intent = backend.applicationProvider.createVpnConfigurePendingIntent(this@VpnService)
|
||||
return Builder()
|
||||
.apply {
|
||||
setSession(tunnel.name)
|
||||
setConfigureIntent(intent)
|
||||
setMtu(config.`interface`.mtu ?: DEFAULT_MTU)
|
||||
setBlocking(true)
|
||||
setUnderlyingNetworks(null)
|
||||
vpnTunFd?.close()
|
||||
vpnTunFd = null
|
||||
vpnTunFd =
|
||||
Builder()
|
||||
.apply {
|
||||
setSession(tunnel.name)
|
||||
setConfigureIntent(intent)
|
||||
setMtu(config.`interface`.mtu ?: DEFAULT_MTU)
|
||||
setBlocking(true)
|
||||
setUnderlyingNetworks(null)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
setMetered(tunnel.isMetered)
|
||||
}
|
||||
|
||||
config.`interface`.includedApplications?.forEach { addAllowedApplication(it) }
|
||||
config.`interface`.excludedApplications?.forEach { addDisallowedApplication(it) }
|
||||
|
||||
var hasIpv4 = false
|
||||
var hasIpv6 = false
|
||||
var sawDefaultRoute = false
|
||||
|
||||
// Parse interface addresses
|
||||
config.`interface`.address?.split(",")?.forEach { rawAddress ->
|
||||
val (address, prefixLength) = rawAddress.parseInetNetwork()
|
||||
addAddress(address, prefixLength)
|
||||
if (address is Inet4Address) hasIpv4 = true else hasIpv6 = true
|
||||
}
|
||||
|
||||
// Parse peer routes
|
||||
config.peers.forEach { peer ->
|
||||
peer.allowedIPs
|
||||
?.split(",")
|
||||
?.map { it.trim() }
|
||||
?.filter { it.isNotEmpty() }
|
||||
?.forEach { entry ->
|
||||
val (address, prefix) = entry.parseInetNetwork()
|
||||
addRoute(address, prefix)
|
||||
|
||||
if (prefix == 0) {
|
||||
sawDefaultRoute = true
|
||||
}
|
||||
if (address is Inet4Address) hasIpv4 = true else hasIpv6 = true
|
||||
}
|
||||
}
|
||||
|
||||
// "Kill-switch" semantics (mirrors wireguard-android)
|
||||
val isKillSwitchRouting = sawDefaultRoute && config.peers.size == 1
|
||||
|
||||
if (!isKillSwitchRouting) {
|
||||
allowFamily(OsConstants.AF_INET)
|
||||
allowFamily(OsConstants.AF_INET6)
|
||||
}
|
||||
|
||||
// Only add DNS servers whose family is supported
|
||||
config.`interface`.dns?.let { rawDns ->
|
||||
val dnsConfig = rawDns.parseDns()
|
||||
dnsConfig.dnsServers.forEach { dnsServer ->
|
||||
val isIpv6 = dnsServer is Inet6Address
|
||||
if ((isIpv6 && hasIpv6) || (!isIpv6 && hasIpv4)) {
|
||||
addDnsServer(dnsServer)
|
||||
} else {
|
||||
Timber.w(
|
||||
"Dropped DNS server $dnsServer: IP family not allowed by interface/routes"
|
||||
)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
setMetered(tunnel.isMetered)
|
||||
}
|
||||
|
||||
config.`interface`.includedApplications?.forEach { addAllowedApplication(it) }
|
||||
config.`interface`.excludedApplications?.forEach {
|
||||
addDisallowedApplication(it)
|
||||
}
|
||||
|
||||
var hasIpv4 = false
|
||||
var hasIpv6 = false
|
||||
var sawDefaultRoute = false
|
||||
|
||||
// Parse interface addresses
|
||||
config.`interface`.address?.split(",")?.forEach { rawAddress ->
|
||||
val (address, prefixLength) = rawAddress.parseInetNetwork()
|
||||
addAddress(address, prefixLength)
|
||||
if (address is Inet4Address) hasIpv4 = true else hasIpv6 = true
|
||||
}
|
||||
|
||||
// Parse peer routes
|
||||
config.peers.forEach { peer ->
|
||||
peer.allowedIPs
|
||||
?.split(",")
|
||||
?.map { it.trim() }
|
||||
?.filter { it.isNotEmpty() }
|
||||
?.forEach { entry ->
|
||||
val (address, prefix) = entry.parseInetNetwork()
|
||||
addRoute(address, prefix)
|
||||
|
||||
if (prefix == 0) {
|
||||
sawDefaultRoute = true
|
||||
}
|
||||
if (address is Inet4Address) hasIpv4 = true else hasIpv6 = true
|
||||
}
|
||||
}
|
||||
|
||||
// "Kill-switch" semantics (mirrors wireguard-android)
|
||||
val isKillSwitchRouting = sawDefaultRoute && config.peers.size == 1
|
||||
|
||||
if (!isKillSwitchRouting) {
|
||||
allowFamily(OsConstants.AF_INET)
|
||||
allowFamily(OsConstants.AF_INET6)
|
||||
}
|
||||
|
||||
// Only add DNS servers whose family is supported
|
||||
config.`interface`.dns?.let { rawDns ->
|
||||
val dnsConfig = rawDns.parseDns()
|
||||
dnsConfig.dnsServers.forEach { dnsServer ->
|
||||
val isIpv6 = dnsServer is Inet6Address
|
||||
if ((isIpv6 && hasIpv6) || (!isIpv6 && hasIpv4)) {
|
||||
addDnsServer(dnsServer)
|
||||
} else {
|
||||
Timber.w(
|
||||
"Dropped DNS server $dnsServer: IP family not allowed by interface/routes"
|
||||
)
|
||||
}
|
||||
}
|
||||
dnsConfig.searchDomains.forEach { addSearchDomain(it) }
|
||||
}
|
||||
dnsConfig.searchDomains.forEach { addSearchDomain(it) }
|
||||
}
|
||||
}
|
||||
.establish()
|
||||
.establish()
|
||||
}
|
||||
|
||||
fun detachVpnTunnelFd(): Int? {
|
||||
val tunFd = vpnTunFd
|
||||
vpnTunFd = null
|
||||
return tunFd?.detachFd()
|
||||
}
|
||||
|
||||
fun closeVpnTunnelFd() {
|
||||
try {
|
||||
vpnTunFd?.close()
|
||||
} catch (_: Exception) {}
|
||||
vpnTunFd = null
|
||||
}
|
||||
|
||||
override fun startHevSocks5Bridge(port: Int, pass: String) {
|
||||
@@ -317,10 +353,24 @@ class VpnService : android.net.VpnService(), KillSwitch, SocketProtector {
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private fun getUserLaunchExtraKey(context: Context): String {
|
||||
return "${context.packageName}.EXTRA_IS_USER_LAUNCH"
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun start(context: Context, serviceClass: Class<out VpnService>) {
|
||||
val intent =
|
||||
Intent(context, serviceClass).apply {
|
||||
action = SERVICE_INTERFACE
|
||||
putExtra(getUserLaunchExtraKey(context), true)
|
||||
}
|
||||
context.startService(intent)
|
||||
}
|
||||
|
||||
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