Compare commits

..

36 Commits

Author SHA1 Message Date
Zane Schepke 85a27f48a2 chore: release v4.1.8 2025-11-14 14:11:22 -05:00
Zane Schepke 1f978cdf96 fix: rapid network changes race in network monitoring 2025-11-14 14:06:59 -05:00
Zane Schepke 4f816fa175 chore: release v4.1.7 2025-11-12 15:12:53 -05:00
Zane Schepke ee4ac4e968 fix: improve qr device support and scanner
#844
closes #1040
2025-11-12 14:13:35 -05:00
Zane Schepke ff53454966 fix: underlying network detection race
#1052
2025-11-12 11:59:20 -05:00
Zane Schepke 22c17ef66b fix: tile update crash when triggerd from non-user profile 2025-11-11 17:43:30 -05:00
Zane Schepke 7a60b90d2b fix: qr scanning scanning can cause crash 2025-11-11 17:29:05 -05:00
Zane Schepke 5fd3f89a59 feat: show tunnel uptime, improve duration display
closes #820
2025-11-11 16:20:08 -05:00
Zane Schepke 9510f43252 fix: global overrides regression, support prompt bug 2025-11-10 20:51:41 -05:00
Zane Schepke 064aa6aa74 fix: error notification bug 2025-11-10 00:56:56 -05:00
Zane Schepke 0c09add0e4 chore: add custom funding link 2025-11-09 12:42:21 -05:00
Zane Schepke fd0fd33f71 chore: release v4.1.6 2025-11-08 20:23:09 -05:00
Zane Schepke aaeb251bbf chore: shorten ur short description 2025-11-08 20:11:48 -05:00
Zane Schepke e563608e49 chore: bump deps 2025-11-08 20:06:09 -05:00
Zane Schepke 584f0386b6 fix: network monitor ignoring valid states for underlying networks 2025-11-08 14:00:14 -05:00
Zane Schepke cf49c34bff ci: simplify publish 2025-11-08 00:43:47 -05:00
Zane Schepke a0f89d40f5 chore: DE short description length too long 2025-11-08 00:17:29 -05:00
Zane Schepke 4da05e23f1 chore: release v4.1.5 2025-11-07 23:58:45 -05:00
Zane Schepke 6749719e21 chore: bump deps, update app description 2025-11-07 23:50:07 -05:00
Zane Schepke 1c160ff5f9 fix: network monitor should ignore default network VPN events
#1038
2025-11-07 21:54:16 -05:00
Zane Schepke 861440b7db fix: disable metered option for Android 9 and lower
closes #1044

#1031
2025-11-07 20:49:32 -05:00
Zane Schepke bdb0d27b53 ci: add aab build workflow 2025-11-05 00:47:46 -05:00
Zane Schepke 9b3283a2b1 chore: release 4.1.4 2025-11-04 20:20:41 -05:00
Zane Schepke 78def29980 fix: keep network monitor for full app lifecyle 2025-11-04 20:16:23 -05:00
Zane Schepke e83bbdf23a fix: tunnel service bind race 2025-11-04 19:59:30 -05:00
Zane Schepke 4beeb4e01e fix: network monitoring bug 2025-11-04 17:48:40 -05:00
Zane Schepke 4bcd810b38 chore: release 4.1.3 2025-11-04 03:57:24 -05:00
Zane Schepke e71174995b fix: tab back navigation bug 2025-11-04 03:39:23 -05:00
Zane Schepke f256a32bda fix: restore proper metered tunnel default
closes #1035
2025-11-04 03:03:24 -05:00
Zane Schepke c49666303a fix: network monitor changes for Android 10 2025-11-04 02:00:58 -05:00
Zane Schepke 3a9b435e50 fix: default wifi method needs flag 2025-11-03 11:52:34 -05:00
Zane Schepke 0993f60977 fix: auto tunnel service binder 2025-11-03 10:55:57 -05:00
Zane Schepke 3d88feb97c fix: r8 ip parsing bug
closes #1031
2025-11-03 09:45:56 -05:00
Zane Schepke f61e6d6c6e fix: network detection bug
closes #1032
2025-11-03 08:20:35 -05:00
Zane Schepke df864ade95 fix: binder leak 2025-11-03 02:24:19 -05:00
Zane Schepke 0abe3f67ef chore: fix fastlane deploy 2025-11-02 03:30:16 -05:00
70 changed files with 1636 additions and 722 deletions
+1
View File
@@ -1,3 +1,4 @@
ko_fi: zaneschepke
liberapay: zaneschepke
github: zaneschepke
custom: ["https://wgtunnel.com/donate/"]
+130
View File
@@ -0,0 +1,130 @@
name: build-aab
permissions:
contents: read
on:
workflow_dispatch:
inputs:
build_type:
type: choice
description: "Build type"
required: true
default: release
options:
- release
flavor:
type: choice
description: "Product flavor"
required: true
default: google
options:
- google
secrets:
SIGNING_KEY_ALIAS:
required: false
SIGNING_KEY_PASSWORD:
required: false
SIGNING_STORE_PASSWORD:
required: false
SERVICE_ACCOUNT_JSON:
required: false
KEYSTORE:
required: false
workflow_call:
inputs:
build_type:
type: string
description: "Build type"
required: true
default: release
flavor:
type: string
description: "Product flavor"
required: false
default: google
secrets:
SIGNING_KEY_ALIAS:
required: false
SIGNING_KEY_PASSWORD:
required: false
SIGNING_STORE_PASSWORD:
required: false
SERVICE_ACCOUNT_JSON:
required: false
KEYSTORE:
required: false
env:
UPLOAD_DIR_ANDROID: android_artifacts
jobs:
build:
runs-on: ubuntu-latest
env:
SIGNING_KEY_ALIAS: ${{ secrets.SIGNING_KEY_ALIAS }}
SIGNING_KEY_PASSWORD: ${{ secrets.SIGNING_KEY_PASSWORD }}
SIGNING_STORE_PASSWORD: ${{ secrets.SIGNING_STORE_PASSWORD }}
KEY_STORE_FILE: 'android_keystore.jks'
KEY_STORE_LOCATION: ${{ github.workspace }}/app/keystore/
outputs:
UPLOAD_DIR_ANDROID: ${{ env.UPLOAD_DIR_ANDROID }}
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Set up JDK 17
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: '17'
cache: gradle
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Decode Keystore
id: decode_keystore
uses: timheuer/base64-to-file@v1.2
with:
fileName: ${{ env.KEY_STORE_FILE }}
fileDir: ${{ env.KEY_STORE_LOCATION }}
encodedString: ${{ secrets.KEYSTORE }}
- name: Create keystore path env var
if: ${{ inputs.build_type != 'debug' }}
run: |
store_path=${{ env.KEY_STORE_LOCATION }}${{ env.KEY_STORE_FILE }}
echo "KEY_STORE_PATH=$store_path" >> $GITHUB_ENV
- name: Build AAB (noSplits=true)
run: |
flavor=${{ inputs.flavor }}
build_type=${{ inputs.build_type }}
case $build_type in
"release")
./gradlew :app:bundle${flavor^}Release \
-PnoSplits=true \
--info
;;
esac
- name: Get release AAB path
id: aab-path
run: |
AAB_PATH=$(find app/build/outputs/bundle -iname "*google*release*.aab" -type f | head -1)
if [ -z "$AAB_PATH" ]; then
echo "Error: AAB not found!" >&2
exit 1
fi
echo "Found AAB: $AAB_PATH"
echo "path=$AAB_PATH" >> $GITHUB_OUTPUT
- name: Upload AAB Artifact
uses: actions/upload-artifact@v5
with:
name: google-play-aab
path: ${{ steps.aab-path.outputs.path }}
retention-days: 7
if-no-files-found: error
+25 -15
View File
@@ -32,14 +32,6 @@ on:
description: "Tag name for release"
required: false
default: 1.1.1
flavor:
type: choice
description: "Product flavor"
required: true
default: standalone
options:
- fdroid
- standalone
workflow_call:
inputs:
flavor:
@@ -51,7 +43,11 @@ on:
jobs:
build-fdroid:
if: ${{ github.event_name == 'push' || inputs.release_type == 'release' || inputs.flavor == 'fdroid' }}
if: >-
${{
github.event_name == 'push' ||
inputs.release_type != 'none'
}}
uses: ./.github/workflows/build.yml
secrets: inherit
with:
@@ -59,16 +55,26 @@ jobs:
flavor: fdroid
build-standalone:
if: ${{ github.event_name == 'push' || inputs.release_type == 'release' || inputs.release_type == 'debug' || inputs.flavor == 'standalone' }}
if: >-
${{
github.event_name == 'push' ||
inputs.release_type != 'none'
}}
uses: ./.github/workflows/build.yml
secrets: inherit
with:
build_type: ${{ github.event_name == 'push' && 'release' || inputs.release_type }}
flavor: standalone
publish:
publish-github:
if: >-
${{
github.event_name == 'push' ||
inputs.release_type != 'none'
}}
needs:
- build-standalone
- build-fdroid
- build-standalone
name: publish-github
runs-on: ubuntu-latest
steps:
@@ -118,7 +124,7 @@ jobs:
- name: Set version release notes
if: ${{ github.event_name == 'push' || inputs.release_type == 'release' }}
run: |
VERSION_CODE=$(grep "const val VERSION_CODE" buildSrc/src/main/kotlin/Constants.kt | awk -F'"' '{print $2}')
VERSION_CODE=$(sed -nE 's/.*const val VERSION_CODE[[:space:]]*=[[:space:]]*([0-9]+).*/\1/p' buildSrc/src/main/kotlin/Constants.kt)
RELEASE_NOTES="$(cat ${{ github.workspace }}/fastlane/metadata/android/en-US/changelogs/${VERSION_CODE}.txt || echo "No changelog found for ${VERSION_CODE}")"
echo "RELEASE_NOTES<<EOF" >> $GITHUB_ENV
echo "$RELEASE_NOTES" >> $GITHUB_ENV
@@ -166,9 +172,13 @@ jobs:
publish-fdroid-public:
runs-on: ubuntu-latest
if: >-
${{
github.event_name == 'push' ||
inputs.release_type != 'none'
}}
needs:
- build-fdroid
if: ${{ github.event_name == 'push' || inputs.release_type == 'release' }}
- publish-github
steps:
- name: Dispatch update for fdroid repo
uses: peter-evans/repository-dispatch@v4
+6 -2
View File
@@ -30,10 +30,10 @@ android {
splits {
abi {
isEnable = true
isEnable = !project.hasProperty("noSplits")
reset()
include("armeabi-v7a", "arm64-v8a")
isUniversalApk = true
isUniversalApk = !project.hasProperty("noSplits")
}
}
@@ -136,6 +136,8 @@ android {
licensee {
allowedLicenses().forEach { allow(it) }
allowedLicenseUrls().forEach { allowUrl(it) }
// foss, but missing license
ignoreDependencies("com.github.T8RIN.QuickieExtended")
}
android.applicationVariants.all {
@@ -242,6 +244,8 @@ dependencies {
debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.manifest)
debugImplementation(libs.leakcanary.android)
// Room database backup
implementation(libs.roomdatabasebackup) {
exclude(group = "org.reactivestreams", module = "reactive-streams")
@@ -0,0 +1,523 @@
{
"formatVersion": 1,
"database": {
"version": 29,
"identityHash": "345471c118dee1b7688afa81d835e62c",
"entities": [
{
"tableName": "tunnel_config",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `wg_quick` TEXT NOT NULL, `tunnel_networks` TEXT NOT NULL DEFAULT '', `is_mobile_data_tunnel` INTEGER NOT NULL DEFAULT false, `is_primary_tunnel` INTEGER NOT NULL DEFAULT false, `am_quick` TEXT NOT NULL DEFAULT '', `is_Active` INTEGER NOT NULL DEFAULT false, `restart_on_ping_failure` INTEGER NOT NULL DEFAULT false, `ping_target` TEXT DEFAULT null, `is_ethernet_tunnel` INTEGER NOT NULL DEFAULT false, `is_ipv4_preferred` INTEGER NOT NULL DEFAULT true, `position` INTEGER NOT NULL DEFAULT 0, `auto_tunnel_apps` TEXT NOT NULL DEFAULT '[]', `is_metered` INTEGER NOT NULL DEFAULT false)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "wgQuick",
"columnName": "wg_quick",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "tunnelNetworks",
"columnName": "tunnel_networks",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "isMobileDataTunnel",
"columnName": "is_mobile_data_tunnel",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isPrimaryTunnel",
"columnName": "is_primary_tunnel",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "amQuick",
"columnName": "am_quick",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "isActive",
"columnName": "is_Active",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "restartOnPingFailure",
"columnName": "restart_on_ping_failure",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "pingTarget",
"columnName": "ping_target",
"affinity": "TEXT",
"defaultValue": "null"
},
{
"fieldPath": "isEthernetTunnel",
"columnName": "is_ethernet_tunnel",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isIpv4Preferred",
"columnName": "is_ipv4_preferred",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "true"
},
{
"fieldPath": "position",
"columnName": "position",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "autoTunnelApps",
"columnName": "auto_tunnel_apps",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "'[]'"
},
{
"fieldPath": "isMetered",
"columnName": "is_metered",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_tunnel_config_name",
"unique": true,
"columnNames": [
"name"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_tunnel_config_name` ON `${TABLE_NAME}` (`name`)"
}
]
},
{
"tableName": "proxy_settings",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `socks5_proxy_enabled` INTEGER NOT NULL DEFAULT 0, `socks5_proxy_bind_address` TEXT, `http_proxy_enable` INTEGER NOT NULL DEFAULT 0, `http_proxy_bind_address` TEXT, `proxy_username` TEXT, `proxy_password` TEXT)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "socks5ProxyEnabled",
"columnName": "socks5_proxy_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "socks5ProxyBindAddress",
"columnName": "socks5_proxy_bind_address",
"affinity": "TEXT"
},
{
"fieldPath": "httpProxyEnabled",
"columnName": "http_proxy_enable",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "httpProxyBindAddress",
"columnName": "http_proxy_bind_address",
"affinity": "TEXT"
},
{
"fieldPath": "proxyUsername",
"columnName": "proxy_username",
"affinity": "TEXT"
},
{
"fieldPath": "proxyPassword",
"columnName": "proxy_password",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
},
{
"tableName": "general_settings",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `is_shortcuts_enabled` INTEGER NOT NULL DEFAULT 0, `is_restore_on_boot_enabled` INTEGER NOT NULL DEFAULT 0, `is_multi_tunnel_enabled` INTEGER NOT NULL DEFAULT 0, `global_split_tunnel_enabled` INTEGER NOT NULL DEFAULT 0, `app_mode` INTEGER NOT NULL DEFAULT 0, `theme` TEXT NOT NULL DEFAULT 'AUTOMATIC', `locale` TEXT, `remote_key` TEXT, `is_remote_control_enabled` INTEGER NOT NULL DEFAULT 0, `is_pin_lock_enabled` INTEGER NOT NULL DEFAULT 0, `is_always_on_vpn_enabled` INTEGER NOT NULL DEFAULT 0, `already_donated` INTEGER NOT NULL DEFAULT 0)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isShortcutsEnabled",
"columnName": "is_shortcuts_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "isRestoreOnBootEnabled",
"columnName": "is_restore_on_boot_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "isMultiTunnelEnabled",
"columnName": "is_multi_tunnel_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "isGlobalSplitTunnelEnabled",
"columnName": "global_split_tunnel_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "appMode",
"columnName": "app_mode",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "theme",
"columnName": "theme",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "'AUTOMATIC'"
},
{
"fieldPath": "locale",
"columnName": "locale",
"affinity": "TEXT"
},
{
"fieldPath": "remoteKey",
"columnName": "remote_key",
"affinity": "TEXT"
},
{
"fieldPath": "isRemoteControlEnabled",
"columnName": "is_remote_control_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "isPinLockEnabled",
"columnName": "is_pin_lock_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "isAlwaysOnVpnEnabled",
"columnName": "is_always_on_vpn_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "alreadyDonated",
"columnName": "already_donated",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
},
{
"tableName": "auto_tunnel_settings",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `is_tunnel_enabled` INTEGER NOT NULL DEFAULT 0, `is_tunnel_on_mobile_data_enabled` INTEGER NOT NULL DEFAULT 0, `trusted_network_ssids` TEXT NOT NULL DEFAULT '', `is_tunnel_on_ethernet_enabled` INTEGER NOT NULL DEFAULT 0, `is_tunnel_on_wifi_enabled` INTEGER NOT NULL DEFAULT 0, `is_wildcards_enabled` INTEGER NOT NULL DEFAULT 0, `is_stop_on_no_internet_enabled` INTEGER NOT NULL DEFAULT 0, `debounce_delay_seconds` INTEGER NOT NULL DEFAULT 3, `is_tunnel_on_unsecure_enabled` INTEGER NOT NULL DEFAULT 0, `wifi_detection_method` INTEGER NOT NULL DEFAULT 0, `start_on_boot` INTEGER NOT NULL DEFAULT 0)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isAutoTunnelEnabled",
"columnName": "is_tunnel_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "isTunnelOnMobileDataEnabled",
"columnName": "is_tunnel_on_mobile_data_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "trustedNetworkSSIDs",
"columnName": "trusted_network_ssids",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "isTunnelOnEthernetEnabled",
"columnName": "is_tunnel_on_ethernet_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "isTunnelOnWifiEnabled",
"columnName": "is_tunnel_on_wifi_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "isWildcardsEnabled",
"columnName": "is_wildcards_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "isStopOnNoInternetEnabled",
"columnName": "is_stop_on_no_internet_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "debounceDelaySeconds",
"columnName": "debounce_delay_seconds",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "3"
},
{
"fieldPath": "isTunnelOnUnsecureEnabled",
"columnName": "is_tunnel_on_unsecure_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "wifiDetectionMethod",
"columnName": "wifi_detection_method",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "startOnBoot",
"columnName": "start_on_boot",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
},
{
"tableName": "monitoring_settings",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `is_ping_enabled` INTEGER NOT NULL DEFAULT 0, `is_ping_monitoring_enabled` INTEGER NOT NULL DEFAULT 1, `tunnel_ping_interval_sec` INTEGER NOT NULL DEFAULT 30, `tunnel_ping_attempts` INTEGER NOT NULL DEFAULT 3, `tunnel_ping_timeout_sec` INTEGER, `show_detailed_ping_stats` INTEGER NOT NULL DEFAULT 0, `is_local_logs_enabled` INTEGER NOT NULL DEFAULT 0)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isPingEnabled",
"columnName": "is_ping_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "isPingMonitoringEnabled",
"columnName": "is_ping_monitoring_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "1"
},
{
"fieldPath": "tunnelPingIntervalSeconds",
"columnName": "tunnel_ping_interval_sec",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "30"
},
{
"fieldPath": "tunnelPingAttempts",
"columnName": "tunnel_ping_attempts",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "3"
},
{
"fieldPath": "tunnelPingTimeoutSeconds",
"columnName": "tunnel_ping_timeout_sec",
"affinity": "INTEGER"
},
{
"fieldPath": "showDetailedPingStats",
"columnName": "show_detailed_ping_stats",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "isLocalLogsEnabled",
"columnName": "is_local_logs_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
},
{
"tableName": "dns_settings",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `dns_protocol` INTEGER NOT NULL DEFAULT 0, `dns_endpoint` TEXT, `global_tunnel_dns_enabled` INTEGER NOT NULL DEFAULT 0)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "dnsProtocol",
"columnName": "dns_protocol",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "dnsEndpoint",
"columnName": "dns_endpoint",
"affinity": "TEXT"
},
{
"fieldPath": "isGlobalTunnelDnsEnabled",
"columnName": "global_tunnel_dns_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
},
{
"tableName": "lockdown_settings",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `bypass_lan` INTEGER NOT NULL DEFAULT 0, `metered` INTEGER NOT NULL DEFAULT 0, `dual_stack` INTEGER NOT NULL DEFAULT 0)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bypassLan",
"columnName": "bypass_lan",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "metered",
"columnName": "metered",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "dualStack",
"columnName": "dual_stack",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '345471c118dee1b7688afa81d835e62c')"
]
}
}
+5 -5
View File
@@ -59,10 +59,7 @@
android:supportsRtl="true"
android:theme="@style/Theme.App.Start"
tools:targetApi="tiramisu">
<activity
android:name="com.journeyapps.barcodescanner.CaptureActivity"
android:screenOrientation="portrait"
tools:replace="screenOrientation" />
<activity
android:name=".MainActivity"
android:exported="true"
@@ -198,7 +195,10 @@
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.QUICKBOOT_POWERON" />
<action android:name="com.htc.intent.action.QUICKBOOT_POWERON" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
<receiver
@@ -40,6 +40,7 @@ import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
import com.zaneschepke.networkmonitor.NetworkMonitor
import com.zaneschepke.wireguardautotunnel.data.AppDatabase
import com.zaneschepke.wireguardautotunnel.data.DataStoreManager.Companion.shouldShowDonationSnackbar
import com.zaneschepke.wireguardautotunnel.data.model.AppMode
@@ -111,6 +112,7 @@ class MainActivity : AppCompatActivity() {
@Inject lateinit var appStateRepository: AppStateRepository
@Inject lateinit var tunnelRepository: TunnelRepository
@Inject lateinit var appDatabase: AppDatabase
@Inject lateinit var networkMonitor: NetworkMonitor
private lateinit var roomBackup: RoomBackup
@@ -277,7 +279,7 @@ class MainActivity : AppCompatActivity() {
append(context.getString(R.string.donation_prompt_suffix))
}
LaunchedEffect(uiState.shouldShowDonationSnackbar) {
LaunchedEffect(Unit) {
if (
uiState.shouldShowDonationSnackbar && !uiState.settings.alreadyDonated
) {
@@ -520,6 +522,7 @@ class MainActivity : AppCompatActivity() {
override fun onResume() {
super.onResume()
networkMonitor.checkPermissionsAndUpdateState()
WireGuardAutoTunnel.setUiActive(true)
}
@@ -34,6 +34,7 @@ class RestartReceiver : BroadcastReceiver() {
tunnelManager.handleReboot()
}
Intent.ACTION_MY_PACKAGE_REPLACED -> {
Timber.i("Restoring state on package upgrade")
tunnelManager.handleRestore()
logReader.deleteAndClearLogs()
appStateRepository.setShouldShowDonationSnackbar(true)
@@ -32,7 +32,7 @@ constructor(
description =
StringValue.StringResource(
R.string.tunnel_error_template,
error.toStringValue(),
error.stringRes,
),
groupKey = NotificationManager.VPN_GROUP_KEY,
)
@@ -1,5 +1,11 @@
package com.zaneschepke.wireguardautotunnel.core.service
import android.os.Binder
import java.lang.ref.WeakReference
class LocalBinder(val service: TunnelService) : Binder()
class LocalBinder(service: TunnelService) : Binder() {
private val serviceRef = WeakReference(service)
val service: TunnelService?
get() = serviceRef.get()
}
@@ -21,6 +21,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import timber.log.Timber
class ServiceManager
@@ -137,17 +138,25 @@ constructor(
suspend fun startTunnelService(appMode: AppMode) =
tunnelMutex.withLock {
if (_tunnelService.value != null) return@withLock
val serviceClass =
when (appMode) {
AppMode.VPN,
AppMode.LOCK_DOWN -> VpnForegroundService::class.java
AppMode.KERNEL,
AppMode.PROXY -> TunnelForegroundService::class.java
}
val intent = Intent(context, serviceClass)
context.startForegroundService(intent)
context.bindService(intent, tunnelServiceConnection, Context.BIND_AUTO_CREATE)
if (_tunnelService.value != null) {
Timber.d("Service already exists, waiting for disconnect")
withTimeoutOrNull(2000L) { _tunnelService.first { it == null } }
?: Timber.w("Timeout waiting for existing service to disconnect")
}
if (_tunnelService.value == null) {
val serviceClass =
when (appMode) {
AppMode.VPN,
AppMode.LOCK_DOWN -> VpnForegroundService::class.java
AppMode.KERNEL,
AppMode.PROXY -> TunnelForegroundService::class.java
}
val intent = Intent(context, serviceClass)
context.startForegroundService(intent)
context.bindService(intent, tunnelServiceConnection, Context.BIND_AUTO_CREATE)
} else {
Timber.e("Service still not null after timeout")
}
}
suspend fun stopTunnelService() =
@@ -157,7 +166,7 @@ constructor(
try {
context.unbindService(tunnelServiceConnection)
} catch (e: Exception) {
Timber.e(e, "Failed to stop Tunnel Service")
Timber.e(e, "Failed to unbind Tunnel Service")
}
}
}
@@ -24,11 +24,12 @@ import com.zaneschepke.wireguardautotunnel.domain.repository.AutoTunnelSettingsR
import com.zaneschepke.wireguardautotunnel.domain.repository.GeneralSettingRepository
import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository
import com.zaneschepke.wireguardautotunnel.domain.state.AutoTunnelState
import com.zaneschepke.wireguardautotunnel.domain.state.NetworkState
import com.zaneschepke.wireguardautotunnel.domain.state.toDomain
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.extensions.to
import com.zaneschepke.wireguardautotunnel.util.extensions.toMillis
import dagger.hilt.android.AndroidEntryPoint
import java.lang.ref.WeakReference
import javax.inject.Inject
import javax.inject.Provider
import kotlinx.coroutines.*
@@ -60,7 +61,16 @@ class AutoTunnelService : LifecycleService() {
private val autoTunnelStateFlow = MutableStateFlow(defaultState)
class LocalBinder(val service: AutoTunnelService) : Binder()
private var autoTunnelJob: Job? = null
private var permissionsJob: Job? = null
private var autoTunnelFailoverJob: Job? = null
class LocalBinder(service: AutoTunnelService) : Binder() {
private val serviceRef = WeakReference(service)
val service: AutoTunnelService?
get() = serviceRef.get()
}
private val binder = LocalBinder(this)
@@ -83,8 +93,10 @@ class AutoTunnelService : LifecycleService() {
fun start() {
launchWatcherNotification()
startAutoTunnelStateJob()
startLocationPermissionsNotificationJob()
autoTunnelJob?.cancel()
autoTunnelJob = startAutoTunnelStateJob()
permissionsJob?.cancel()
permissionsJob = startLocationPermissionsNotificationJob()
}
fun stop() {
@@ -93,7 +105,6 @@ class AutoTunnelService : LifecycleService() {
override fun onDestroy() {
serviceManager.handleAutoTunnelServiceDestroy()
networkMonitor.destroy()
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
super.onDestroy()
}
@@ -124,12 +135,12 @@ class AutoTunnelService : LifecycleService() {
)
}
private fun startAutoTunnelStateJob() =
private fun startAutoTunnelStateJob(): Job =
lifecycleScope.launch(ioDispatcher) {
val networkFlow =
debouncedConnectivityStateFlow
.flowOn(ioDispatcher)
.map(NetworkState::from)
.map { it.toDomain() }
.map(::NetworkChange)
.distinctUntilChanged()
@@ -202,6 +213,7 @@ class AutoTunnelService : LifecycleService() {
handleAutoTunnelEvent(autoTunnelStateFlow.value.determineAutoTunnelEvent(change))
// re-evaluate network state after a short duration to prevent missed state changes
reevaluationJob = launch {
val snapshotNetwork = autoTunnelStateFlow.value.networkState
delay(REEVALUATE_CHECK_DELAY)
@@ -224,7 +236,7 @@ class AutoTunnelService : LifecycleService() {
return combine(
settingsRepository.flow.map { it.appMode }.distinctUntilChanged(),
autoTunnelRepository.get().flow,
tunnelsRepository.flow.map { tunnels ->
tunnelsRepository.userTunnelsFlow.map { tunnels ->
// isActive is ignored for equality checks so user can manually toggle off
// tunnel with auto-tunnel
tunnels.map { it.copy(isActive = false) }
@@ -266,8 +278,8 @@ class AutoTunnelService : LifecycleService() {
.map {
NetworkPermissionState(
it.settings.wifiDetectionMethod.to(),
it.networkState.locationServicesEnabled == true,
it.networkState.locationPermissionGranted == true,
it.networkState.locationServicesEnabled,
it.networkState.locationPermissionGranted,
(it.tunnels.any { tunnel -> tunnel.tunnelNetworks.isNotEmpty() } ||
it.settings.trustedNetworkSSIDs.isNotEmpty()),
)
@@ -348,6 +360,7 @@ class AutoTunnelService : LifecycleService() {
}
}
// restart network flow on debounce changes
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
private val debouncedConnectivityStateFlow: Flow<ConnectivityState> by lazy {
autoTunnelRepository
@@ -31,7 +31,7 @@ fun MutableStateFlow<Map<TunnelConfig, TunnelState>>.exists(id: Int): Boolean {
}
fun MutableStateFlow<Map<TunnelConfig, TunnelState>>.isUp(id: Int): Boolean {
return this.value.any { it.key.id == id && it.value.status == TunnelStatus.Up }
return this.value.any { it.key.id == id && it.value.status is TunnelStatus.Up }
}
fun MutableStateFlow<Map<TunnelConfig, TunnelState>>.isStarting(id: Int): Boolean {
@@ -322,11 +322,11 @@ constructor(
withContext(ioDispatcher) {
val settings = settingsRepository.getGeneralSettings()
val autoTunnelSettings = autoTunnelSettingsRepository.getAutoTunnelSettings()
val tunnels = tunnelsRepository.getAll()
val tunnels = tunnelsRepository.userTunnelsFlow.firstOrNull()
if (autoTunnelSettings.isAutoTunnelEnabled)
return@withContext restoreAutoTunnel(autoTunnelSettings)
if (settings.appMode == AppMode.LOCK_DOWN) handleLockDownModeInit()
if (tunnels.any { it.isActive }) {
if (tunnels?.any { it.isActive } == true) {
if (settings.appMode == AppMode.VPN && !serviceManager.hasVpnPermission())
return@withContext localErrorEvents.emit(null to NotAuthorized())
when (settings.appMode) {
@@ -350,7 +350,7 @@ constructor(
withContext(ioDispatcher) {
val settings = settingsRepository.getGeneralSettings()
val autoTunnelSettings = autoTunnelSettingsRepository.getAutoTunnelSettings()
val defaultTunnel = tunnelsRepository.getStartTunnel()
val defaultTunnel = tunnelsRepository.getDefaultTunnel()
if (autoTunnelSettings.startOnBoot)
return@withContext restoreAutoTunnel(autoTunnelSettings)
if (settings.isRestoreOnBootEnabled) {
@@ -95,26 +95,7 @@ constructor(
val connectivityStateFlow = networkMonitor.connectivityStateFlow.stateIn(this)
val isNetworkConnected = connectivityStateFlow.map { it.hasConnectivity() }.stateIn(this)
data class NetworkChangeKey(
val ethernetConnected: Boolean,
val wifiConnected: Boolean,
val cellularConnected: Boolean,
val wifiSsid: String?,
)
connectivityStateFlow
.map {
NetworkChangeKey(
ethernetConnected = it.ethernetConnected,
wifiConnected = it.wifiState.connected,
cellularConnected = it.cellularConnected,
wifiSsid = if (it.wifiState.connected) it.wifiState.ssid else null,
)
}
.distinctUntilChanged()
.stateIn(this)
val isNetworkConnected = connectivityStateFlow.map { it.hasInternet() }.stateIn(this)
combine(
settingsRepository.flow.distinctUntilChangedBy { it.appMode },
@@ -266,7 +247,7 @@ constructor(
}
// Wait for the tunnel to be fully active
tunStateFlow.filter { state -> state?.status == TunnelStatus.Up }.first()
tunStateFlow.filter { state -> state?.status is TunnelStatus.Up }.first()
// small delay to make sure tunnel is fully up before we actively monitor
delay(3_000L)
@@ -90,7 +90,7 @@ constructor(
} catch (e: BackendException) {
throw e.toBackendCoreException()
// TODO this should be mapped to BackendException in the lib
} catch (e: IOException) {
} catch (_: IOException) {
throw VpnUnauthorized()
}
}
@@ -17,7 +17,7 @@ import com.zaneschepke.wireguardautotunnel.data.entity.*
DnsSettings::class,
LockdownSettings::class,
],
version = 28,
version = 29,
autoMigrations =
[
AutoMigration(from = 1, to = 2),
@@ -50,23 +50,27 @@ interface TunnelConfigDao {
@Query(
"""
SELECT * FROM tunnel_config
ORDER BY
CASE WHEN is_primary_tunnel = 1 THEN 0 ELSE 1 END,
position ASC
LIMIT 1"""
SELECT * FROM tunnel_config
WHERE name != '${TunnelConfig.GLOBAL_CONFIG_NAME}'
ORDER BY
CASE WHEN is_primary_tunnel = 1 THEN 0 ELSE 1 END,
position ASC
LIMIT 1
"""
)
suspend fun getDefaultTunnel(): TunnelConfig?
@Query(
"""
SELECT * FROM tunnel_config
ORDER BY
CASE WHEN is_Active = 1 THEN 0
WHEN is_primary_tunnel = 1 THEN 1
ELSE 2 END,
position ASC
LIMIT 1"""
SELECT * FROM tunnel_config
WHERE name != '${TunnelConfig.GLOBAL_CONFIG_NAME}'
ORDER BY
CASE WHEN is_Active = 1 THEN 0
WHEN is_primary_tunnel = 1 THEN 1
ELSE 2 END,
position ASC
LIMIT 1
"""
)
suspend fun getStartTunnel(): TunnelConfig?
@@ -28,7 +28,7 @@ data class TunnelConfig(
@ColumnInfo(name = "position", defaultValue = "0") val position: Int = 0,
@ColumnInfo(name = "auto_tunnel_apps", defaultValue = "[]")
val autoTunnelApps: Set<String> = emptySet(),
@ColumnInfo(name = "is_metered", defaultValue = "true") val isMetered: Boolean = true,
@ColumnInfo(name = "is_metered", defaultValue = "false") val isMetered: Boolean = false,
) {
companion object {
const val GLOBAL_CONFIG_NAME = "4675ab06-903a-438b-8485-6ea4187a9512"
@@ -411,3 +411,56 @@ val MIGRATION_25_26 =
db.execSQL("ALTER TABLE `general_settings_new` RENAME TO `general_settings`")
}
}
val MIGRATION_28_29 =
object : Migration(28, 29) {
override fun migrate(database: SupportSQLiteDatabase) {
// Migrate tunnel_config table
database.execSQL(
"""
CREATE TABLE IF NOT EXISTS `tunnel_config_new` (
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` TEXT NOT NULL,
`wg_quick` TEXT NOT NULL,
`tunnel_networks` TEXT NOT NULL DEFAULT '',
`is_mobile_data_tunnel` INTEGER NOT NULL DEFAULT false,
`is_primary_tunnel` INTEGER NOT NULL DEFAULT false,
`am_quick` TEXT NOT NULL DEFAULT '',
`is_Active` INTEGER NOT NULL DEFAULT false,
`restart_on_ping_failure` INTEGER NOT NULL DEFAULT false,
`ping_target` TEXT DEFAULT null,
`is_ethernet_tunnel` INTEGER NOT NULL DEFAULT false,
`is_ipv4_preferred` INTEGER NOT NULL DEFAULT true,
`position` INTEGER NOT NULL DEFAULT 0,
`auto_tunnel_apps` TEXT NOT NULL DEFAULT '[]',
`is_metered` INTEGER NOT NULL DEFAULT false
)
"""
.trimIndent()
)
database.execSQL(
"""
INSERT INTO `tunnel_config_new` (
`id`, `name`, `wg_quick`, `tunnel_networks`, `is_mobile_data_tunnel`,
`is_primary_tunnel`, `am_quick`, `is_Active`, `restart_on_ping_failure`,
`ping_target`, `is_ethernet_tunnel`, `is_ipv4_preferred`, `position`,
`auto_tunnel_apps`, `is_metered`
)
SELECT
`id`, `name`, `wg_quick`, `tunnel_networks`, `is_mobile_data_tunnel`,
`is_primary_tunnel`, `am_quick`, `is_Active`, `restart_on_ping_failure`,
`ping_target`, `is_ethernet_tunnel`, `is_ipv4_preferred`, `position`,
`auto_tunnel_apps`, 0 AS `is_metered`
FROM `tunnel_config`
"""
.trimIndent()
)
database.execSQL("DROP TABLE `tunnel_config`")
database.execSQL("ALTER TABLE `tunnel_config_new` RENAME TO `tunnel_config`")
database.execSQL(
"CREATE UNIQUE INDEX IF NOT EXISTS `index_tunnel_config_name` ON `tunnel_config` (`name`)"
)
}
}
@@ -55,6 +55,8 @@ class DataStoreAppStateRepository(
pref[DataStoreManager.locationDisclosureShown] ?: false,
isBatteryOptimizationDisableShown =
pref[DataStoreManager.batteryDisableShown] ?: false,
shouldShowDonationSnackbar =
pref[DataStoreManager.shouldShowDonationSnackbar] ?: false,
)
} catch (e: IllegalArgumentException) {
Timber.e(e)
@@ -9,6 +9,7 @@ import com.zaneschepke.wireguardautotunnel.data.DatabaseCallback
import com.zaneschepke.wireguardautotunnel.data.dao.*
import com.zaneschepke.wireguardautotunnel.data.migrations.MIGRATION_23_24
import com.zaneschepke.wireguardautotunnel.data.migrations.MIGRATION_25_26
import com.zaneschepke.wireguardautotunnel.data.migrations.MIGRATION_28_29
import com.zaneschepke.wireguardautotunnel.data.network.GitHubApi
import com.zaneschepke.wireguardautotunnel.data.network.KtorClient
import com.zaneschepke.wireguardautotunnel.data.network.KtorGitHubApi
@@ -56,8 +57,11 @@ class RepositoryModule {
AppDatabase::class.java,
context.getString(R.string.db_name),
)
.addMigrations(MIGRATION_23_24(dataStoreManager.dataStore))
.addMigrations(MIGRATION_25_26)
.addMigrations(
MIGRATION_23_24(dataStoreManager.dataStore),
MIGRATION_25_26,
MIGRATION_28_29,
)
.fallbackToDestructiveMigration(true)
.addCallback(callback)
.build()
@@ -1,8 +0,0 @@
package com.zaneschepke.wireguardautotunnel.domain.enums
enum class NetworkType {
WIFI,
ETHERNET,
MOBILE_DATA,
NONE,
}
@@ -2,7 +2,7 @@ package com.zaneschepke.wireguardautotunnel.domain.enums
sealed class TunnelStatus {
data object Up : TunnelStatus()
data class Up(val startTime: Long) : TunnelStatus()
data object Down : TunnelStatus()
@@ -15,11 +15,11 @@ sealed class TunnelStatus {
}
fun isUp(): Boolean {
return this == Up
return this is Up
}
fun isUpOrStarting(): Boolean {
return this == Up || this == Starting
return this is Up || this == Starting
}
fun isDownOrStopping(): Boolean {
@@ -27,7 +27,7 @@ data class TunnelConfig(
val isIpv4Preferred: Boolean = true,
val position: Int = 0,
val autoTunnelApps: Set<String> = setOf(),
val isMetered: Boolean = true,
val isMetered: Boolean = false,
) {
override fun equals(other: Any?): Boolean {
@@ -25,29 +25,27 @@ data class AutoTunnelState(
is NetworkChange,
is SettingsChange -> {
// Compute desired tunnel based on network conditions
var desiredTunnel: TunnelConfig? = null
if (networkState.isEthernetConnected && settings.isTunnelOnEthernetEnabled) {
desiredTunnel = preferredEthernetTunnel()
} else if (isMobileDataActive() && settings.isTunnelOnMobileDataEnabled) {
desiredTunnel = preferredMobileDataTunnel()
} else if (
isWifiActive() && settings.isTunnelOnWifiEnabled && !isCurrentSSIDTrusted()
) {
desiredTunnel = preferredWifiTunnel()
var preferredTunnel: TunnelConfig? = null
if (ethernetActive && settings.isTunnelOnEthernetEnabled) {
preferredTunnel = preferredEthernetTunnel()
} else if (mobileDataActive && settings.isTunnelOnMobileDataEnabled) {
preferredTunnel = preferredMobileDataTunnel()
} else if (wifiActive && settings.isTunnelOnWifiEnabled && !isWifiTrusted()) {
preferredTunnel = preferredWifiTunnel()
}
// Override for no connectivity if enabled
if (isNoConnectivity() && settings.isStopOnNoInternetEnabled) {
desiredTunnel = null
if (!networkState.hasInternet() && settings.isStopOnNoInternetEnabled) {
preferredTunnel = null
}
// Determine current active tunnel (assuming only one can be active)
val currentTunnel = activeTunnels.entries.firstOrNull()?.key
// Handle tunnel start/stop/change
if (desiredTunnel != null) {
if (currentTunnel != desiredTunnel.id) {
return Start(desiredTunnel)
if (preferredTunnel != null) {
if (currentTunnel != preferredTunnel.id) {
return Start(preferredTunnel)
}
} else {
if (currentTunnel != null) {
@@ -61,12 +59,9 @@ data class AutoTunnelState(
return DoNothing
}
// also need to check for Wi-Fi state as there is some overlap when they are both connected
private fun isMobileDataActive(): Boolean {
return !networkState.isEthernetConnected &&
!networkState.isWifiConnected &&
networkState.isMobileDataConnected
}
private val ethernetActive: Boolean = networkState.activeNetwork is ActiveNetwork.Ethernet
private val mobileDataActive: Boolean = networkState.activeNetwork is ActiveNetwork.Cellular
private val wifiActive: Boolean = networkState.activeNetwork is ActiveNetwork.Wifi
private fun preferredMobileDataTunnel(): TunnelConfig? {
return tunnels.firstOrNull { it.isMobileDataTunnel }
@@ -81,27 +76,21 @@ data class AutoTunnelState(
}
private fun preferredWifiTunnel(): TunnelConfig? {
return getTunnelWithMatchingTunnelNetwork()
return getTunnelWithMappedNetwork()
?: tunnels.firstOrNull { it.isPrimaryTunnel }
?: tunnels.firstOrNull()
}
// ignore cellular state as there is overlap where it may still be active, but not prioritized
private fun isWifiActive(): Boolean {
return !networkState.isEthernetConnected && networkState.isWifiConnected
private fun isWifiTrusted(): Boolean {
return with(networkState.activeNetwork) {
this is ActiveNetwork.Wifi && isTrustedNetwork(this.ssid)
}
}
private fun isNoConnectivity(): Boolean {
return !networkState.isEthernetConnected &&
!networkState.isWifiConnected &&
!networkState.isMobileDataConnected
}
private fun isTrustedNetwork(ssid: String): Boolean =
hasMatch(ssid, settings.trustedNetworkSSIDs)
private fun isCurrentSSIDTrusted(): Boolean {
return networkState.wifiName?.let { hasTrustedWifiName(it) } == true
}
private fun hasTrustedWifiName(
private fun hasMatch(
wifiName: String,
wifiNames: Set<String> = settings.trustedNetworkSSIDs,
): Boolean {
@@ -112,9 +101,10 @@ data class AutoTunnelState(
}
}
private fun getTunnelWithMatchingTunnelNetwork(): TunnelConfig? {
return networkState.wifiName?.let { wifiName ->
tunnels.firstOrNull { hasTrustedWifiName(wifiName, it.tunnelNetworks) }
private fun getTunnelWithMappedNetwork(): TunnelConfig? =
when (val network = networkState.activeNetwork) {
is ActiveNetwork.Wifi ->
tunnels.firstOrNull { hasMatch(network.ssid, it.tunnelNetworks) }
else -> null
}
}
}
@@ -1,9 +0,0 @@
package com.zaneschepke.wireguardautotunnel.domain.state
data class ConnectivityState(
val wifiAvailable: Boolean,
val ethernetAvailable: Boolean,
val cellularAvailable: Boolean,
) {
val allOffline = !wifiAvailable && !ethernetAvailable && !cellularAvailable
}
@@ -1,38 +1,48 @@
package com.zaneschepke.wireguardautotunnel.domain.state
import com.zaneschepke.networkmonitor.ActiveNetwork as MonitorActiveNetwork
import com.zaneschepke.networkmonitor.ConnectivityState
import com.zaneschepke.networkmonitor.util.WifiSecurityType
data class NetworkState(
val isWifiConnected: Boolean = false,
val isMobileDataConnected: Boolean = false,
val isEthernetConnected: Boolean = false,
val wifiName: String? = null,
val isWifiSecure: Boolean? = null,
val locationServicesEnabled: Boolean? = null,
val locationPermissionGranted: Boolean? = null,
) {
fun hasNoCapabilities(): Boolean {
return !isWifiConnected && !isMobileDataConnected && !isEthernetConnected
}
sealed class ActiveNetwork {
data object Disconnected : ActiveNetwork()
companion object {
fun from(connectivityState: ConnectivityState): NetworkState {
return NetworkState(
isWifiSecure =
when (connectivityState.wifiState.securityType) {
data object Ethernet : ActiveNetwork()
data object Cellular : ActiveNetwork()
data class Wifi(val ssid: String, val isSecure: Boolean?) : ActiveNetwork()
}
data class NetworkState(
val activeNetwork: ActiveNetwork = ActiveNetwork.Disconnected,
val locationServicesEnabled: Boolean = false,
val locationPermissionGranted: Boolean = false,
) {
fun hasInternet(): Boolean = activeNetwork !is ActiveNetwork.Disconnected
}
fun ConnectivityState.toDomain(): NetworkState {
val domainNetwork: ActiveNetwork =
when (val network = this.activeNetwork) {
is MonitorActiveNetwork.Wifi -> {
val isSecure =
when (network.securityType) {
WifiSecurityType.OPEN,
WifiSecurityType.UNKNOWN -> false
null -> null
else -> true
},
isWifiConnected = connectivityState.wifiState.connected,
isMobileDataConnected = connectivityState.cellularConnected,
isEthernetConnected = connectivityState.ethernetConnected,
wifiName = connectivityState.wifiState.ssid,
locationPermissionGranted = connectivityState.wifiState.locationPermissionsGranted,
locationServicesEnabled = connectivityState.wifiState.locationServicesEnabled,
)
}
ActiveNetwork.Wifi(ssid = network.ssid, isSecure = isSecure)
}
is MonitorActiveNetwork.Cellular -> ActiveNetwork.Cellular
is MonitorActiveNetwork.Ethernet -> ActiveNetwork.Ethernet
is MonitorActiveNetwork.Disconnected -> ActiveNetwork.Disconnected
}
}
return NetworkState(
activeNetwork = domainNetwork,
locationPermissionGranted = this.locationPermissionsGranted,
locationServicesEnabled = this.locationServicesEnabled,
)
}
@@ -12,6 +12,8 @@ data class TunnelState(
) {
fun health(): Health {
if (status !is TunnelStatus.Up) return Health.UNKNOWN
val uptime = uptime()
val now = System.currentTimeMillis()
if (pingStates == null && logHealthState == null && statistics == null)
@@ -37,13 +39,21 @@ data class TunnelState(
// Stats health if no logs or pings
statistics?.let { stats ->
if (stats.isTunnelStale()) return Health.STALE
if (stats.rx() == 0L) return Health.UNKNOWN
val rx = stats.rx()
if (uptime >= STATS_HEALTH_SUCCESS_TIMEOUT_MS && rx == 0L) return Health.UNHEALTHY
if (rx == 0L) return Health.UNKNOWN
return Health.HEALTHY
}
return Health.UNKNOWN
}
fun uptime(): Long {
val up = status as? TunnelStatus.Up ?: return 0L
if (up.startTime == 0L) return 0L
return System.currentTimeMillis() - up.startTime
}
enum class Health {
UNKNOWN,
UNHEALTHY,
@@ -53,5 +63,6 @@ data class TunnelState(
companion object {
const val LOG_HEALTH_SUCCESS_TIMEOUT_MS = 2 * 60 * 1000L // 2 minutes
const val STATS_HEALTH_SUCCESS_TIMEOUT_MS = 15 * 1000L // 15 sec
}
}
@@ -1,8 +1,6 @@
package com.zaneschepke.wireguardautotunnel.ui.common.snackbar
import android.R.attr.padding
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Favorite
@@ -22,24 +22,14 @@ class NavController(
return false
}
fun popUpTo(route: NavKey, inclusive: Boolean = false) {
fun popUpTo(route: NavKey) {
onChange(currentRoute)
val targetRoute =
if (route is Route.AutoTunnel && !isDisclosureShown) Route.LocationDisclosure else route
val index = backStack.indexOfLast { it == targetRoute }
if (index != -1) {
val popUpToIndex = if (inclusive) index else index + 1
while (backStack.size > popUpToIndex) {
backStack.removeLastOrNull()
}
} else {
// Only add if it's not already the top
if (backStack.lastOrNull() != targetRoute) {
backStack.add(targetRoute)
}
}
backStack.clear()
if (route is Route.Tunnels) backStack.add(targetRoute)
else backStack.addAll(setOf(Route.Tunnels, targetRoute))
}
val currentRoute: NavKey?
@@ -36,8 +36,8 @@ import androidx.core.net.toUri
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.zaneschepke.networkmonitor.ActiveNetwork
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.domain.enums.NetworkType
import com.zaneschepke.wireguardautotunnel.ui.LocalNavController
import com.zaneschepke.wireguardautotunnel.ui.LocalSharedVm
import com.zaneschepke.wireguardautotunnel.ui.common.button.SurfaceRow
@@ -59,9 +59,9 @@ fun AutoTunnelScreen(viewModel: AutoTunnelViewModel = hiltViewModel()) {
val clipboard = rememberClipboardHelper()
val sharedUiState by shareViewModel.container.stateFlow.collectAsStateWithLifecycle()
val autoTunnelState by viewModel.container.stateFlow.collectAsStateWithLifecycle()
val uiState by viewModel.container.stateFlow.collectAsStateWithLifecycle()
if (autoTunnelState.isLoading) return
if (uiState.isLoading) return
val batteryActivity =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
@@ -79,11 +79,11 @@ fun AutoTunnelScreen(viewModel: AutoTunnelViewModel = hiltViewModel()) {
}
val (ethernetTunnel, mobileDataTunnel, mappedTunnels) =
remember(autoTunnelState.tunnels) {
remember(uiState.tunnels) {
Triple(
autoTunnelState.tunnels.firstOrNull { it.isEthernetTunnel },
autoTunnelState.tunnels.firstOrNull { it.isMobileDataTunnel },
autoTunnelState.tunnels.any { it.tunnelNetworks.isNotEmpty() },
uiState.tunnels.firstOrNull { it.isEthernetTunnel },
uiState.tunnels.firstOrNull { it.isMobileDataTunnel },
uiState.tunnels.any { it.tunnelNetworks.isNotEmpty() },
)
}
@@ -94,8 +94,8 @@ fun AutoTunnelScreen(viewModel: AutoTunnelViewModel = hiltViewModel()) {
) {
Column {
val (title, buttonText, icon) =
remember(autoTunnelState.autoTunnelActive) {
when (autoTunnelState.autoTunnelActive) {
remember(uiState.autoTunnelActive) {
when (uiState.autoTunnelActive) {
true ->
Triple(
context.getString(R.string.auto_tunnel_running),
@@ -140,35 +140,20 @@ fun AutoTunnelScreen(viewModel: AutoTunnelViewModel = hiltViewModel()) {
stringResource(R.string.networks),
modifier = Modifier.padding(horizontal = 16.dp),
)
val activeNetworkType by
remember(autoTunnelState.connectivityState) {
val localizedNetworkType by
remember(uiState.connectivityState) {
derivedStateOf {
val connectivity = autoTunnelState.connectivityState
when {
connectivity?.ethernetConnected == true -> NetworkType.ETHERNET
connectivity?.wifiState?.connected == true -> NetworkType.WIFI
connectivity?.cellularConnected == true -> NetworkType.MOBILE_DATA
else -> NetworkType.NONE
when (uiState.connectivityState?.activeNetwork) {
is ActiveNetwork.Wifi -> context.getString(R.string.wifi)
is ActiveNetwork.Ethernet -> context.getString(R.string.ethernet)
is ActiveNetwork.Cellular -> context.getString(R.string.mobile_data)
is ActiveNetwork.Disconnected -> context.getString(R.string.no_network)
null -> context.getString(R.string.no_network)
}
}
}
val localizedNetworkType =
when (activeNetworkType) {
NetworkType.WIFI -> stringResource(R.string.wifi)
NetworkType.ETHERNET -> stringResource(R.string.ethernet)
NetworkType.MOBILE_DATA -> stringResource(R.string.mobile_data)
NetworkType.NONE -> stringResource(R.string.no_network)
}
val ssid by
remember(autoTunnelState.connectivityState) {
derivedStateOf {
autoTunnelState.connectivityState?.wifiState?.ssid
?: context.getString(R.string.unknown)
}
}
SurfaceRow(
leading = {
Icon(ImageVector.vectorResource(R.drawable.globe), contentDescription = null)
@@ -181,7 +166,7 @@ fun AutoTunnelScreen(viewModel: AutoTunnelViewModel = hiltViewModel()) {
}
},
description =
if (activeNetworkType == NetworkType.WIFI) {
(uiState.connectivityState?.activeNetwork as? ActiveNetwork.Wifi)?.let {
{
Column {
DescriptionText(
@@ -189,10 +174,8 @@ fun AutoTunnelScreen(viewModel: AutoTunnelViewModel = hiltViewModel()) {
append(stringResource(R.string.security_type))
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
append(
autoTunnelState.connectivityState
?.wifiState
?.securityType
?.name ?: stringResource(R.string.unknown)
it.securityType?.name
?: stringResource(R.string.unknown)
)
}
}
@@ -201,21 +184,24 @@ fun AutoTunnelScreen(viewModel: AutoTunnelViewModel = hiltViewModel()) {
buildAnnotatedString {
append(stringResource(R.string.network_name))
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
append(ssid)
append(it.ssid)
}
}
)
}
}
} else null,
},
trailing =
if (activeNetworkType == NetworkType.WIFI) {
if (uiState.connectivityState?.activeNetwork is ActiveNetwork.Wifi) {
{ Icon(Icons.Outlined.ContentCopy, contentDescription = null) }
} else null,
onClick =
if (activeNetworkType == NetworkType.WIFI) {
{ clipboard.copy(ssid, context.getString(R.string.wifi)) }
} else null,
onClick = {
when (val network = uiState.connectivityState?.activeNetwork) {
is ActiveNetwork.Wifi ->
clipboard.copy(network.ssid, context.getString(R.string.wifi))
else -> Unit
}
},
)
SurfaceRow(
@@ -223,7 +209,7 @@ fun AutoTunnelScreen(viewModel: AutoTunnelViewModel = hiltViewModel()) {
title = stringResource(R.string.tunnel_on_wifi),
trailing = { modifier ->
SwitchWithDivider(
checked = autoTunnelState.autoTunnelSettings.isTunnelOnWifiEnabled,
checked = uiState.autoTunnelSettings.isTunnelOnWifiEnabled,
onClick = { viewModel.setAutoTunnelOnWifiEnabled(it) },
modifier = modifier,
)
@@ -248,7 +234,7 @@ fun AutoTunnelScreen(viewModel: AutoTunnelViewModel = hiltViewModel()) {
title = stringResource(R.string.tunnel_mobile_data),
trailing = { modifier ->
SwitchWithDivider(
checked = autoTunnelState.autoTunnelSettings.isTunnelOnMobileDataEnabled,
checked = uiState.autoTunnelSettings.isTunnelOnMobileDataEnabled,
onClick = { viewModel.setTunnelOnCellular(it) },
modifier = modifier,
)
@@ -271,7 +257,7 @@ fun AutoTunnelScreen(viewModel: AutoTunnelViewModel = hiltViewModel()) {
title = stringResource(R.string.tunnel_on_ethernet),
trailing = { modifier ->
SwitchWithDivider(
checked = autoTunnelState.autoTunnelSettings.isTunnelOnEthernetEnabled,
checked = uiState.autoTunnelSettings.isTunnelOnEthernetEnabled,
onClick = { viewModel.setTunnelOnEthernet(it) },
modifier = modifier,
)
@@ -295,13 +281,13 @@ fun AutoTunnelScreen(viewModel: AutoTunnelViewModel = hiltViewModel()) {
description = { DescriptionText(stringResource(R.string.stop_on_internet_loss)) },
trailing = {
ThemedSwitch(
checked = autoTunnelState.autoTunnelSettings.isStopOnNoInternetEnabled,
checked = uiState.autoTunnelSettings.isStopOnNoInternetEnabled,
onClick = { viewModel.setStopOnNoInternetEnabled(it) },
)
},
onClick = {
viewModel.setStopOnNoInternetEnabled(
!autoTunnelState.autoTunnelSettings.isStopOnNoInternetEnabled
!uiState.autoTunnelSettings.isStopOnNoInternetEnabled
)
},
)
@@ -316,13 +302,11 @@ fun AutoTunnelScreen(viewModel: AutoTunnelViewModel = hiltViewModel()) {
title = stringResource(R.string.restart_at_boot),
trailing = {
ThemedSwitch(
checked = autoTunnelState.autoTunnelSettings.startOnBoot,
checked = uiState.autoTunnelSettings.startOnBoot,
onClick = { viewModel.setStartAtBoot(it) },
)
},
onClick = {
viewModel.setStartAtBoot(!autoTunnelState.autoTunnelSettings.startOnBoot)
},
onClick = { viewModel.setStartAtBoot(!uiState.autoTunnelSettings.startOnBoot) },
)
SurfaceRow(
leading = { Icon(Icons.Outlined.Settings, contentDescription = null) },
@@ -47,35 +47,37 @@ fun WifiSettingsScreen(viewModel: AutoTunnelViewModel = hiltViewModel()) {
val context = LocalContext.current
val navController = LocalNavController.current
val autoTunnelState by viewModel.container.stateFlow.collectAsStateWithLifecycle()
val uiState by viewModel.container.stateFlow.collectAsStateWithLifecycle()
if (autoTunnelState.isLoading) return
if (uiState.isLoading) return
var showLocationDialog by remember { mutableStateOf(false) }
var currentText by rememberSaveable { mutableStateOf("") }
LaunchedEffect(autoTunnelState.autoTunnelSettings.trustedNetworkSSIDs) { currentText = "" }
LaunchedEffect(uiState.autoTunnelSettings.trustedNetworkSSIDs) { currentText = "" }
val warnings by
remember(
autoTunnelState.connectivityState?.wifiState,
autoTunnelState.autoTunnelSettings.trustedNetworkSSIDs,
autoTunnelState.autoTunnelSettings.wifiDetectionMethod,
autoTunnelState.tunnels,
uiState.connectivityState,
uiState.autoTunnelSettings.trustedNetworkSSIDs,
uiState.autoTunnelSettings.wifiDetectionMethod,
uiState.tunnels,
) {
derivedStateOf {
val wifiState = autoTunnelState.connectivityState?.wifiState
val needsLocation =
autoTunnelState.autoTunnelSettings.wifiDetectionMethod
.needsLocationPermissions()
uiState.autoTunnelSettings.wifiDetectionMethod.needsLocationPermissions()
val hasConfigs =
autoTunnelState.autoTunnelSettings.trustedNetworkSSIDs.isNotEmpty() ||
autoTunnelState.tunnels.any { it.tunnelNetworks.isNotEmpty() }
uiState.autoTunnelSettings.trustedNetworkSSIDs.isNotEmpty() ||
uiState.tunnels.any { it.tunnelNetworks.isNotEmpty() }
val showServicesWarning =
(wifiState?.locationServicesEnabled == false) && needsLocation && hasConfigs
(uiState.connectivityState?.locationServicesEnabled == false) &&
needsLocation &&
hasConfigs
val showPermissionsWarning =
(wifiState?.locationPermissionsGranted == false) && needsLocation && hasConfigs
(uiState.connectivityState?.locationPermissionsGranted == false) &&
needsLocation &&
hasConfigs
showServicesWarning to showPermissionsWarning
}
@@ -138,9 +140,7 @@ fun WifiSettingsScreen(viewModel: AutoTunnelViewModel = hiltViewModel()) {
DescriptionText(
stringResource(
R.string.current_template,
autoTunnelState.autoTunnelSettings.wifiDetectionMethod.asTitleString(
context
),
uiState.autoTunnelSettings.wifiDetectionMethod.asTitleString(context),
)
)
},
@@ -157,14 +157,12 @@ fun WifiSettingsScreen(viewModel: AutoTunnelViewModel = hiltViewModel()) {
},
trailing = {
ThemedSwitch(
checked = autoTunnelState.autoTunnelSettings.isWildcardsEnabled,
checked = uiState.autoTunnelSettings.isWildcardsEnabled,
onClick = { viewModel.setWildcardsEnabled(it) },
)
},
onClick = {
viewModel.setWildcardsEnabled(
!autoTunnelState.autoTunnelSettings.isWildcardsEnabled
)
viewModel.setWildcardsEnabled(!uiState.autoTunnelSettings.isWildcardsEnabled)
},
)
}
@@ -174,14 +172,13 @@ fun WifiSettingsScreen(viewModel: AutoTunnelViewModel = hiltViewModel()) {
title = stringResource(R.string.trusted_wifi_names),
expandedContent = {
TrustedNetworkTextBox(
autoTunnelState.autoTunnelSettings.trustedNetworkSSIDs,
uiState.autoTunnelSettings.trustedNetworkSSIDs,
onDelete = { viewModel.removeTrustedNetworkName(it) },
currentText = currentText,
onSave = { ssid -> viewModel.saveTrustedNetworkName(ssid) },
onValueChange = { currentText = it },
supporting = {
if (autoTunnelState.autoTunnelSettings.isWildcardsEnabled)
WildcardsLabel()
if (uiState.autoTunnelSettings.isWildcardsEnabled) WildcardsLabel()
},
modifier = Modifier.padding(top = 4.dp),
)
@@ -1,5 +1,6 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.settings.lockdown
import android.os.Build
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
@@ -103,12 +104,14 @@ fun LockdownSettingsScreen(viewModel: LockdownViewModel = hiltViewModel()) {
trailing = { ThemedSwitch(checked = bypassLan, onClick = { bypassLan = it }) },
onClick = { bypassLan = !bypassLan },
)
SurfaceRow(
leading = { Icon(Icons.Outlined.DataUsage, contentDescription = null) },
title = stringResource(R.string.metered_tunnel),
trailing = { ThemedSwitch(checked = metered, onClick = { metered = it }) },
onClick = { metered = !metered },
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
SurfaceRow(
leading = { Icon(Icons.Outlined.DataUsage, contentDescription = null) },
title = stringResource(R.string.metered_tunnel),
trailing = { ThemedSwitch(checked = metered, onClick = { metered = it }) },
onClick = { metered = !metered },
)
}
SurfaceRow(
leading = {
Icon(ImageVector.vectorResource(R.drawable.host), contentDescription = null)
@@ -12,9 +12,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.journeyapps.barcodescanner.ScanContract
import com.journeyapps.barcodescanner.ScanOptions
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.LocalNavController
import com.zaneschepke.wireguardautotunnel.ui.LocalSharedVm
@@ -29,7 +26,10 @@ import com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.components.UrlImpo
import com.zaneschepke.wireguardautotunnel.ui.sideeffect.LocalSideEffect
import com.zaneschepke.wireguardautotunnel.util.FileUtils
import com.zaneschepke.wireguardautotunnel.util.StringValue
import io.github.g00fy2.quickie.QRResult
import io.github.g00fy2.quickie.ScanQRCode
import org.orbitmvi.orbit.compose.collectSideEffect
import timber.log.Timber
@Composable
fun TunnelsScreen() {
@@ -65,14 +65,26 @@ fun TunnelsScreen() {
onData = { data -> viewModel.importFromUri(data) },
)
val scanLauncher =
rememberLauncherForActivityResult(
contract = ScanContract(),
onResult = { result ->
if (result != null && result.contents.isNotEmpty())
viewModel.importFromQr(result.contents)
},
)
val scanQrCodeLauncher =
rememberLauncherForActivityResult(ScanQRCode()) { result ->
when (result) {
is QRResult.QRError -> {
Timber.e(result.exception, "QR Code")
}
QRResult.QRMissingPermission -> {
viewModel.showSnackMessage(
StringValue.StringResource(R.string.camera_permission_required)
)
}
is QRResult.QRSuccess -> {
result.content.rawValue?.let { viewModel.importFromQr(it) }
?: viewModel.showSnackMessage(
StringValue.StringResource(R.string.config_error)
)
}
QRResult.QRUserCanceled -> Unit
}
}
val requestPermissionLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted
@@ -83,9 +95,7 @@ fun TunnelsScreen() {
)
return@rememberLauncherForActivityResult
}
scanLauncher.launch(
ScanOptions().setDesiredBarcodeFormats(ScanOptions.QR_CODE).setBeepEnabled(false)
)
scanQrCodeLauncher.launch(null)
}
if (showDeleteModal) {
@@ -17,11 +17,11 @@ import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.R
@Composable
fun GettingStartedLabel(onClick: (url: String) -> Unit) {
fun GettingStartedLabel(onClick: (url: String) -> Unit, modifier: Modifier = Modifier) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier.padding(top = 100.dp).fillMaxSize(),
modifier = modifier.padding(top = 100.dp).fillMaxSize(),
) {
val url = stringResource(id = R.string.docs_url)
val gettingStarted = buildAnnotatedString {
@@ -59,7 +59,12 @@ fun TunnelList(
flingBehavior = ScrollableDefaults.flingBehavior(),
) {
if (sharedState.tunnels.isEmpty()) {
item { GettingStartedLabel(onClick = { context.openWebUrl(it) }) }
item {
GettingStartedLabel(
onClick = { context.openWebUrl(it) },
modifier = Modifier.animateItem(),
)
}
}
items(sharedState.tunnels, key = { it.id }) { tunnel ->
val tunnelState =
@@ -81,6 +86,7 @@ fun TunnelList(
}
SurfaceRow(
modifier = Modifier.animateItem(),
leading = {
Icon(
Icons.Rounded.Circle,
@@ -19,8 +19,10 @@ import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState
import com.zaneschepke.wireguardautotunnel.ui.common.label.lowercaseLabel
import com.zaneschepke.wireguardautotunnel.util.NumberUtils
import com.zaneschepke.wireguardautotunnel.util.extensions.abbreviateKey
import com.zaneschepke.wireguardautotunnel.util.extensions.localizedDuration
import com.zaneschepke.wireguardautotunnel.util.extensions.millisAgo
import java.util.Locale
import kotlinx.coroutines.delay
@Composable
@@ -33,6 +35,7 @@ fun TunnelStatisticsRow(
val context = LocalContext.current
val textStyle = MaterialTheme.typography.bodySmall
val textColor = MaterialTheme.colorScheme.outline
val locale = remember { Locale.getDefault() }
// needs to be set as peer stats for duplicates return as a single set of stats
val peers by
@@ -65,6 +68,19 @@ fun TunnelStatisticsRow(
verticalArrangement = Arrangement.spacedBy(10.dp),
horizontalAlignment = Alignment.Start,
) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
"uptime: ${tunnelState.uptime().localizedDuration(locale)}",
style = textStyle,
color = textColor,
)
}
}
peers.forEach { peerBase64 ->
key(peerBase64) {
val peerStats = remember(stats, peerBase64) { stats.peerStats(peerBase64) }
@@ -88,11 +104,7 @@ fun TunnelStatisticsRow(
derivedStateOf {
stats.latestHandshakeEpochMillis.let { lastHandshake ->
if (lastHandshake == 0L) null
else
NumberUtils.getSecondsBetween(
lastHandshake,
currentTimeMillis,
)
else lastHandshake.millisAgo().localizedDuration(locale)
}
}
}
@@ -105,9 +117,10 @@ fun TunnelStatisticsRow(
val lastPingedSeconds by
remember(pingState, currentTimeMillis) {
derivedStateOf {
pingState?.lastSuccessfulPingMillis?.let { lastPing ->
NumberUtils.getSecondsBetween(lastPing, currentTimeMillis)
}
pingState
?.lastSuccessfulPingMillis
?.millisAgo()
?.localizedDuration(locale)
}
}
@@ -156,7 +169,7 @@ fun TunnelStatisticsRow(
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
"$handshakeText: ${handshake?.let { lowercaseLabel(stringResource(R.string.sec_ago_template, it.toString())) } ?: neverText}",
"$handshakeText: ${handshake?.let { lowercaseLabel(it) } ?: neverText}",
style = textStyle,
color = textColor,
)
@@ -219,12 +232,7 @@ fun TunnelStatisticsRow(
stringResource(
R.string.ping_success_template,
lastPingedSeconds?.let { sec ->
lowercaseLabel(
stringResource(
R.string.sec_ago_template,
sec.toString(),
)
)
lowercaseLabel(sec)
} ?: neverText,
)
)
@@ -1,5 +1,6 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.settings
import android.os.Build
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
@@ -148,36 +149,38 @@ fun TunnelSettingsScreen(viewModel: TunnelViewModel) {
},
onClick = { viewModel.setIpv4Preferred(!tunnel.isIpv4Preferred) },
)
SurfaceRow(
leading = {
Icon(
Icons.Outlined.DataUsage,
contentDescription = null,
tint =
if (sharedUiState.proxyEnabled) Disabled
else MaterialTheme.colorScheme.onSurface,
)
},
title = stringResource(R.string.metered_tunnel),
enabled = !sharedUiState.proxyEnabled,
description =
if (sharedUiState.proxyEnabled) {
{
DescriptionText(
stringResource(R.string.unavailable_in_mode),
disabled = true,
)
}
} else null,
trailing = {
ThemedSwitch(
checked = tunnel.isMetered,
onClick = { viewModel.setMetered(it) },
enabled = !sharedUiState.proxyEnabled,
)
},
onClick = { viewModel.setMetered(!tunnel.isMetered) },
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
SurfaceRow(
leading = {
Icon(
Icons.Outlined.DataUsage,
contentDescription = null,
tint =
if (sharedUiState.proxyEnabled) Disabled
else MaterialTheme.colorScheme.onSurface,
)
},
title = stringResource(R.string.metered_tunnel),
enabled = !sharedUiState.proxyEnabled,
description =
if (sharedUiState.proxyEnabled) {
{
DescriptionText(
stringResource(R.string.unavailable_in_mode),
disabled = true,
)
}
} else null,
trailing = {
ThemedSwitch(
checked = tunnel.isMetered,
onClick = { viewModel.setMetered(it) },
enabled = !sharedUiState.proxyEnabled,
)
},
onClick = { viewModel.setMetered(!tunnel.isMetered) },
)
}
}
}
}
@@ -2,8 +2,6 @@ package com.zaneschepke.wireguardautotunnel.util
import com.vdurmont.semver4j.Semver
import java.math.BigDecimal
import java.time.Duration
import java.time.Instant
import kotlin.math.pow
import timber.log.Timber
@@ -28,16 +26,6 @@ object NumberUtils {
return (Math.random() * 100000).toInt()
}
fun getSecondsBetween(start: Long, end: Long): Long? {
return if (start != 0L && end != 0L) {
val startInstant = Instant.ofEpochMilli(start)
val endInstant = Instant.ofEpochMilli(end)
return Duration.between(startInstant, endInstant).seconds
} else {
null
}
}
fun compareVersions(newVersion: String, currentVersion: String): Int {
try {
val newSemver = Semver(newVersion, Semver.SemverType.LOOSE)
@@ -186,13 +186,23 @@ fun Context.launchAppSettings() {
}
}
fun Context.requestTunnelTileServiceStateUpdate() {
TileService.requestListeningState(this, ComponentName(this, TunnelControlTile::class.java))
}
fun Context.requestTunnelTileServiceStateUpdate() =
runCatching {
TileService.requestListeningState(
this,
ComponentName(this, TunnelControlTile::class.java),
)
}
.onFailure { Timber.w(it) }
fun Context.requestAutoTunnelTileServiceUpdate() {
TileService.requestListeningState(this, ComponentName(this, AutoTunnelControlTile::class.java))
}
fun Context.requestAutoTunnelTileServiceUpdate() =
runCatching {
TileService.requestListeningState(
this,
ComponentName(this, AutoTunnelControlTile::class.java),
)
}
.onFailure { Timber.w(it) }
fun Context.getAllInternetCapablePackages(): List<PackageInfo> {
val permissions = arrayOf(Manifest.permission.INTERNET)
@@ -77,7 +77,7 @@ fun BackendMode.asAmBackendMode(): Backend.BackendMode {
fun Tunnel.State.asTunnelState(): TunnelStatus {
return when (this) {
Tunnel.State.DOWN -> TunnelStatus.Down
Tunnel.State.UP -> TunnelStatus.Up
Tunnel.State.UP -> TunnelStatus.Up(System.currentTimeMillis())
}
}
@@ -114,6 +114,6 @@ fun org.amnezia.awg.backend.BackendException.toBackendCoreException(): BackendCo
fun com.wireguard.android.backend.Tunnel.State.asTunnelState(): TunnelStatus {
return when (this) {
com.wireguard.android.backend.Tunnel.State.DOWN -> TunnelStatus.Down
com.wireguard.android.backend.Tunnel.State.UP -> TunnelStatus.Up
com.wireguard.android.backend.Tunnel.State.UP -> TunnelStatus.Up(System.currentTimeMillis())
}
}
@@ -1,6 +1,9 @@
package com.zaneschepke.wireguardautotunnel.util.extensions
import android.content.Context
import android.icu.text.MeasureFormat
import android.icu.util.Measure
import android.icu.util.MeasureUnit
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Lock
import androidx.compose.material.icons.outlined.Terminal
@@ -18,6 +21,8 @@ import com.zaneschepke.wireguardautotunnel.ui.theme.AlertRed
import com.zaneschepke.wireguardautotunnel.ui.theme.CoolGray
import com.zaneschepke.wireguardautotunnel.ui.theme.SilverTree
import com.zaneschepke.wireguardautotunnel.ui.theme.Straw
import java.util.Locale
import kotlin.time.Duration.Companion.milliseconds
fun WifiDetectionMethod.asTitleString(context: Context): String {
return when (this) {
@@ -82,3 +87,35 @@ fun TunnelState.Health.asColor(): Color {
TunnelState.Health.STALE -> Straw
}
}
fun Long.localizedDuration(locale: Locale = Locale.getDefault()): String {
require(this >= 0L) { "Duration cannot be negative" }
val duration = this.milliseconds
if (duration < 1000.milliseconds) {
return MeasureFormat.getInstance(locale, MeasureFormat.FormatWidth.SHORT)
.format(Measure(0, MeasureUnit.SECOND))
}
val totalSeconds = duration.inWholeSeconds
val days = totalSeconds / 86_400
val hours = (totalSeconds % 86_400) / 3_600
val minutes = (totalSeconds % 3_600) / 60
val seconds = totalSeconds % 60
val measures = buildList {
if (days > 0) add(Measure(days, MeasureUnit.DAY))
if (hours > 0) add(Measure(hours, MeasureUnit.HOUR))
if (minutes > 0) add(Measure(minutes, MeasureUnit.MINUTE))
if (seconds > 0) add(Measure(seconds, MeasureUnit.SECOND))
}
return MeasureFormat.getInstance(locale, MeasureFormat.FormatWidth.SHORT)
.formatMeasures(*measures.toTypedArray())
}
fun Long.millisAgo(): Long {
return System.currentTimeMillis() - this
}
-1
View File
@@ -302,7 +302,6 @@
<string name="set_custom_ping_target">Vlastní cíl pingu (volitelné)</string>
<string name="tunnel_ping_interval">Interval pingování tunelu</string>
<string name="ping_timeout">Časový limit pingování tunelu</string>
<string name="sec_ago_template">Před %1$s s</string>
<string name="latency_template">Latence: %1$s</string>
<string name="ping_target_template">Cíl pingu: %1$s</string>
<string name="backup_success">Úspěšně zazálohováno. %1$s</string>
-1
View File
@@ -247,7 +247,6 @@
<string name="display_detailed_ping_stats">Detaillierte Ping-Statistiken anzeigen</string>
<string name="reachable_template">Erreichbar: %1$s</string>
<string name="ping_success_template">Letzter erfolgreicher Ping: %1$s</string>
<string name="sec_ago_template">vor %1$s Sek</string>
<string name="latency_template">Latenz: %1$s</string>
<string name="jitter_template">Jitter: %1$s</string>
<string name="packets_sent_template">Versendete Pakete: %1$s</string>
-1
View File
@@ -247,7 +247,6 @@
<string name="display_detailed_ping_stats">Mostrar estadísticas detalladas del ping</string>
<string name="reachable_template">Alcanzable: %1$s</string>
<string name="ping_success_template">Último ping recibido: %1$s</string>
<string name="sec_ago_template">hace %1$s segundos</string>
<string name="latency_template">Latencia: %1$s</string>
<string name="jitter_template">Jitter: %1$s</string>
<string name="packets_sent_template">Paquetes enviados: %1$s</string>
-1
View File
@@ -263,7 +263,6 @@
<string name="pinger_bounce_successful">Tunneli taaskäivitamine pingija poolt õnnestus.</string>
<string name="reachable_template">Leitav: %1$s</string>
<string name="ping_success_template">Viimane õnnestunud ping: %1$s</string>
<string name="sec_ago_template">%1$s sekundi eest</string>
<string name="dns_provider">Nimelahenduse pakkuja</string>
<string name="dns_protocol">Nimelahenduse protokoll</string>
<string name="system">Süsteemne</string>
-1
View File
@@ -252,7 +252,6 @@
<string name="packets_sent_template">Pacchetti inviati: %1$s</string>
<string name="jitter_template">Jitter: %1$s</string>
<string name="latency_template">Latenza: %1$s</string>
<string name="sec_ago_template">%1$s sec fa</string>
<string name="ping_success_template">Ultimo ping riuscito: %1$s</string>
<string name="reachable_template">Raggiungibile: %1$s</string>
<string name="current_template">Corrente: %1$s</string>
-1
View File
@@ -248,7 +248,6 @@
<string name="display_detailed_ping_stats">Wyświetl szczegółowe statystyki pingowania</string>
<string name="reachable_template">Osiągalny: %1$s</string>
<string name="ping_success_template">Ostatni udany ping: %1$s</string>
<string name="sec_ago_template">%1$s sek. temu</string>
<string name="latency_template">Opóźnienie: %1$s</string>
<string name="jitter_template">Jitter: %1$s</string>
<string name="packets_sent_template">Pakiety wysłane: %1$s</string>
-1
View File
@@ -240,7 +240,6 @@
<string name="backup_application">Резервирование данных</string>
<string name="restore_application">Восстановление данных</string>
<string name="tunnel_monitoring">Отслеживание туннеля</string>
<string name="sec_ago_template">%1$s сек.</string>
<string name="latency_template">Задержка: %1$s</string>
<string name="packets_sent_template">Отправлено пакетов: %1$s</string>
<string name="packet_loss_template">Потеряно пакетов: %.2f%%</string>
-1
View File
@@ -247,7 +247,6 @@
<string name="display_detailed_ping_stats">پِنگ کے تفصیلی اعدادوشمار دکھائیں</string>
<string name="reachable_template">قابل رسائی: %1$s</string>
<string name="ping_success_template">آخری کامیاب پِنگ: %1$s</string>
<string name="sec_ago_template">%1$s سیکنڈ پہلے</string>
<string name="latency_template">تاخیر: %1$s</string>
<string name="jitter_template">ہلچل: %1$s</string>
<string name="packets_sent_template">پیکٹ بھیجے گئے: %1$s</string>
@@ -247,7 +247,6 @@
<string name="display_detailed_ping_stats">展示详细的 ping 数据</string>
<string name="reachable_template">可抵达:%1$s</string>
<string name="ping_success_template">上次成功的 ping 操作:%1$s</string>
<string name="sec_ago_template">%1$s 秒前</string>
<string name="latency_template">延迟:%1$s</string>
<string name="jitter_template">抖动:%1$s</string>
<string name="packets_sent_template">已发送数据包:%1$s</string>
@@ -205,7 +205,6 @@
<string name="jitter_template">抖動: %1$s</string>
<string name="warning">警告</string>
<string name="ip_or_hostname">IP 或主機名稱</string>
<string name="sec_ago_template">%1$s 秒前</string>
<string name="restarting_app">正在重啟應用程式以應用變更…</string>
<string name="packets_sent_template">已發送封包: %1$s</string>
<string name="packet_loss_template">丟失封包: %.2f%%</string>
+2 -3
View File
@@ -110,7 +110,7 @@
<string name="root_accepted">Root shell accepted</string>
<string name="show_amnezia_properties">Show Amnezia properties</string>
<string name="never">Never</string>
<string name="handshake">Handshake</string>
<string name="handshake">Last handshake</string>
<string name="logs">Logs</string>
<string name="appearance">Appearance</string>
<string name="notifications">Notifications</string>
@@ -273,7 +273,6 @@
<string name="display_detailed_ping_stats">Display detailed ping stats</string>
<string name="reachable_template">Reachable: %1$s</string>
<string name="ping_success_template">Last successful ping: %1$s</string>
<string name="sec_ago_template">%1$s seconds ago</string>
<string name="latency_template">Latency: %1$s</string>
<string name="jitter_template">Jitter: %1$s</string>
<string name="packets_sent_template">Packets sent: %1$s</string>
@@ -434,7 +433,7 @@
<string name="info">Info</string>
<string name="already_donated">Already donated</string>
<string name="already_donated_description">Disables future donation prompts</string>
<string name="donation_prompt_prefix">Thanks for using WG Tunnel! If you can, please consider</string>
<string name="donation_prompt_prefix">Thanks for using WG Tunnel! If you are able, please consider</string>
<string name="donation_prompt_link">supporting the project</string>
<string name="donation_prompt_suffix">to keep it free and improving.</string>
</resources>
+2 -2
View File
@@ -1,6 +1,6 @@
object Constants {
const val VERSION_NAME = "4.1.2"
const val VERSION_CODE = 40102
const val VERSION_NAME = "4.1.8"
const val VERSION_CODE = 40108
const val TARGET_SDK = 36
const val MIN_SDK = 26
const val APP_ID = "com.zaneschepke.wireguardautotunnel"
+1 -1
View File
@@ -20,7 +20,7 @@ fun allowedLicenses(): List<String> {
}
fun allowedLicenseUrls(): List<String> {
return listOf("https://github.com/journeyapps/zxing-android-embedded/blob/master/COPYING",
return listOf("https://jsoup.org/license", "http://opensource.org/licenses/bsd-license.php", "https://github.com/journeyapps/zxing-android-embedded/blob/master/COPYING",
"https://github.com/RikkaApps/Shizuku-API/blob/master/LICENSE", "https://github.com/rafi0101/Android-Room-Database-Backup/blob/master/LICENSE",
"https://opensource.org/license/mit")
}
+18 -9
View File
@@ -2,28 +2,37 @@ default_platform(:android)
platform :android do
private_lane :build_aab do
gradle(
task: "clean bundleGoogleRelease",
properties: {
"noSplits" => true
}
)
end
desc 'Deploy a new internal version to the Google Play Store'
lane :internal do
gradle(task: "clean bundleGoogleRelease")
build_aab
upload_to_play_store(track: 'internal', skip_upload_apk: true)
end
desc "Deploy an alpha version to the Google Play"
lane :alpha do
gradle(task: "clean bundleGoogleRelease")
upload_to_play_store(track: 'alpha', skip_upload_apk: true)
lane :alpha do
build_aab
upload_to_play_store(track: 'alpha', skip_upload_apk: true)
end
desc "Deploy a beta version to the Google Play"
lane :beta do
gradle(task: "clean bundleGoogleRelease")
build_aab
upload_to_play_store(track: 'beta', skip_upload_apk: true)
end
desc "Deploy a new version to the Google Play"
lane :production do
gradle(task: "clean bundleGoogleRelease")
upload_to_play_store(skip_upload_apk: true)
end
lane :production do
build_aab
upload_to_play_store(skip_upload_apk: true)
end
end
@@ -1 +1 @@
Ein WireGuard- und AmneziaWG-VPN-Client mit automatischem Tunneling, Sperrung und Proxying.
Ein WireGuard & AmneziaWG VPN-Client mit Auto-Tunnel, Sperre & Proxy.
@@ -0,0 +1,5 @@
What's new:
- Resource usage bugfix
- Improve network monitoring
- Tab navigation bugfix
- Tunnel metered default bugfix
@@ -0,0 +1,3 @@
What's new:
- Auto tunnel network detection bugfix
- Tunnel notification sometimes don't start bugfix
@@ -0,0 +1,3 @@
What's new:
- Fixes crash on older Android versions where metered tunnel override is unavailable
- Fixes auto-tunnel network monitor incorrectly detecting VPN changes
@@ -0,0 +1,3 @@
What's new:
- Auto-tunnel regression bugfix
- Resource usage bugfix for kill switch mode
@@ -0,0 +1,6 @@
What's new:
- Improved QR scanning and device support
- Display tunnel uptime
- Fixes quick tile crash bug when running app in multiple profiles
- Fixes global overrides regression causing unexpected tunnel start errors
- Fixes network detection race while VPN is active
@@ -0,0 +1,2 @@
What's new:
- Rapid network changes cause invalid network state bugfix
@@ -1,15 +1,13 @@
- Tunnel Import Methods: Easily add tunnels using .conf files, ZIP archives, manual entry, or QR code scanning.
- Auto-Tunneling: Automatically activate tunnels based on Wi-Fi SSID, Ethernet connections, or mobile data networks.
- Split Tunneling: Flexible support for routing specific apps or traffic through the VPN.
- WireGuard Modes: Full compatibility with WireGuard in both kernel and userspace implementations.
- AmneziaWG Integration: Userspace mode for AmneziaWG, providing robust censorship evasion.
- Always-On VPN: Ensures continuous protection with Android's Always-On VPN feature.
- Quick Controls: Quick Settings tile and home screen shortcuts for easy VPN toggling.
- Automation Support: Intent-based automation for controlling tunnels.
- Auto-Restore: Seamlessly restores auto-tunneling and active tunnels after device restarts or app updates.
- Proxying Options: Built-in HTTP and SOCKS5 proxy support within tunnels.
- Lockdown Mode: Custom kill switch for maximum leak prevention and security.
- Dynamic DNS Handling: Detects and updates DNS changes without tunnel restarts.
- Monitoring Tools: Advanced tunnel monitoring features for tunnel performance monitoring.
- Android TV Support: Android TV support for secure streaming and browsing.
- Advanced DNS: DNS over HTTPS support for tunnel endpoint resolutions.
WG Tunnel is a WireGuard VPN client that strikes the balance between simplicity and robustness, making it the ideal client for casual and power users alike.
Whether you simply want to automate when you're connected to your VPN or you're a power user with advanced privacy use cases, WG Tunnel has you covered.
- **Auto-Tunneling:** Automatically activate tunnels based on Wi-Fi SSID, Ethernet connections, or mobile data networks.
- **Split Tunneling:** Flexible support for routing specific apps or traffic through the VPN.
- **App Modes:** Support for multiple tunnel modes, including standard VPN, kernel, lockdown (custom kill switch), and proxy modes.
- **AmneziaWG Integration:** Full support for AmneziaWG, providing robust censorship evasion.
- **Proxying Options:** Built-in HTTP and SOCKS5 proxy support allowing third-party apps to tunnel their traffic.
- **Quick Controls:** Quick Settings tile and home screen shortcuts for easy toggling actions.
- **Automation Support:** Intent-based automation for controlling tunnels and auto-tunneling.
- **Dynamic DNS Handling:** Detects and updates DNS changes without tunnel restarts.
- **Monitoring Tools:** Advanced tunnel monitoring features for tunnel performance monitoring.
- **Android TV Support:** Android TV support for nearly all app features.
@@ -1 +1 @@
آٹو ٹنلنگ، لاک ڈاؤن اور پراکسینگ کے ساتھ ایک وائرس گارڈ اور یمنیزیا ویپیاین کلائنٹ۔
وائرس گارڈ اور یمنیزیا وی پی این کلائنٹ۔ آٹو ٹنلنگ، لاک ڈاؤن، پراکسی۔
+15 -13
View File
@@ -1,27 +1,28 @@
[versions]
accompanist = "0.37.3"
activityCompose = "1.11.0"
amneziawgAndroid = "2.2.0"
amneziawgAndroid = "2.2.3"
androidx-junit = "1.3.0"
icmp4a = "1.0.0"
ipaddress = "5.5.1"
orbitCompose = "10.0.0"
leakcanaryAndroid = "3.0-alpha-8"
orbitCompose = "11.0.0"
roomdatabasebackup = "1.1.0"
shizuku = "13.1.5"
appcompat = "1.7.1"
coreKtx = "1.17.0"
datastorePreferences = "1.2.0-beta01"
datastorePreferences = "1.2.0-rc01"
desugar_jdk_libs = "2.1.5"
espressoCore = "3.7.0"
hiltAndroid = "2.57.2"
hiltCompiler = "1.3.0"
hiltNavigationCompose = "1.3.0"
navigation3 = "1.0.0-beta01"
navigation3 = "1.0.0-rc01"
junit = "4.13.2"
kotlinx-serialization-json = "1.9.0"
ktorClientCore = "3.3.1"
ktorClientCore = "3.3.2"
lifecycle-runtime-compose = "2.9.4"
material3 = "1.5.0-alpha07"
material3 = "1.5.0-alpha08"
pinLockCompose = "1.0.5"
qrose = "1.0.1"
roomVersion = "2.8.3"
@@ -31,20 +32,20 @@ timber = "5.0.1"
tunnel = "1.4.0"
androidGradlePlugin = "8.12.3"
kotlin = "2.2.21"
ksp = "2.3.0"
composeBom = "2025.10.01"
ksp = "2.3.1"
composeBom = "2025.11.00"
compose = "1.9.4"
icons = "1.7.8"
workRuntimeKtxVersion = "2.11.0"
zxingAndroidEmbedded = "4.3.0"
coreSplashscreen = "1.0.1"
quickieFoss = "1.15.7"
coreSplashscreen = "1.2.0"
gradlePlugins-grgit = "5.3.3"
reorderable = "3.0.0"
material = "1.13.0"
storage = "1.6.0"
ktfmt = "0.25.0"
licensee = "1.14.1"
lifecycleViewmodelNavigation3 = "2.10.0-beta01"
lifecycleViewmodelNavigation3 = "2.10.0-rc01"
[bundles]
# Core AndroidX foundations
@@ -93,7 +94,7 @@ wireguard-tunnel = ["tunnel", "amneziawg-android"]
shizuku = ["shizuku-api", "shizuku-provider"]
# UI utilities
ui-utilities = ["pin-lock-compose", "qrose", "reorderable", "zxing-android-embedded", "androidx-core-splashscreen"]
ui-utilities = ["pin-lock-compose", "qrose", "reorderable", "quickie-foss", "androidx-core-splashscreen"]
# Misc utilities
misc-utilities = ["semver4j", "icmp4a", "slf4j-android", "timber"]
@@ -130,6 +131,7 @@ androidx-compose-ui = { module = "androidx.compose.ui:ui", version.ref = "compos
androidx-compose-animation-graphics = { module = "androidx.compose.animation:animation-graphics", version.ref = "compose" }
icmp4a = { module = "com.marsounjan:icmp4a", version.ref = "icmp4a" }
ipaddress = { module = "com.github.seancfoley:ipaddress", version.ref = "ipaddress" }
leakcanary-android = { module = "com.squareup.leakcanary:leakcanary-android", version.ref = "leakcanaryAndroid" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" }
androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" }
@@ -182,7 +184,7 @@ qrose = { module = "io.github.alexzhirkevich:qrose", version.ref = "qrose" }
semver4j = { module = "com.vdurmont:semver4j", version.ref = "semver4j" }
slf4j-android = { module = "org.slf4j:slf4j-android", version.ref = "slf4jAndroid" }
timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" }
zxing-android-embedded = { module = "com.journeyapps:zxing-android-embedded", version.ref = "zxingAndroidEmbedded" }
quickie-foss = { module = "com.github.T8RIN.QuickieExtended:quickie-foss", version.ref = "quickieFoss" }
desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" }
reorderable = { module = "sh.calvin.reorderable:reorderable", version.ref = "reorderable" }
@@ -1,11 +1,9 @@
package com.zaneschepke.networkmonitor
import android.Manifest
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.location.LocationManager
import android.net.ConnectivityManager
import android.net.Network
@@ -13,17 +11,19 @@ import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.net.wifi.WifiManager
import android.os.Build
import androidx.core.content.ContextCompat
import com.wireguard.android.util.RootShell
import com.zaneschepke.networkmonitor.AndroidNetworkMonitor.WifiDetectionMethod.*
import com.zaneschepke.networkmonitor.shizuku.ShizukuShell
import com.zaneschepke.networkmonitor.util.*
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.withTimeoutOrNull
import timber.log.Timber
import kotlin.concurrent.atomics.AtomicReference
import kotlin.concurrent.atomics.ExperimentalAtomicApi
class AndroidNetworkMonitor(
private val appContext: Context,
@@ -41,7 +41,6 @@ class AndroidNetworkMonitor(
companion object {
const val LOCATION_SERVICES_FILTER: String = "android.location.PROVIDERS_CHANGED"
const val ANDROID_UNKNOWN_SSID: String = "<unknown ssid>"
const val SHELL_COMMAND_TIMEOUT_MS = 2_000L
}
@@ -67,219 +66,184 @@ class AndroidNetworkMonitor(
private val locationManager =
appContext.getSystemService(Context.LOCATION_SERVICE) as LocationManager?
private val activeWifiNetworks =
ConcurrentHashMap<String, Pair<Network?, NetworkCapabilities?>>()
private val activeCellularNetworks =
ConcurrentHashMap<String, Pair<Network?, NetworkCapabilities?>>()
private val permissionsChangedFlow = MutableStateFlow(false)
private var permissionReceiver: BroadcastReceiver? = null
private var locationServicesReceiver: BroadcastReceiver? = null
private var 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 var wifiInterfaceCallback: ConnectivityManager.NetworkCallback? = null // NEW
private val airplaneModeState = MutableStateFlow(appContext.isAirplaneModeOn())
private val airplaneModeFlow: Flow<Boolean> = airplaneModeState.asStateFlow()
// recreate defaultNetwork flow on permission/detection method changes to get newly available
// network info
@OptIn(ExperimentalCoroutinesApi::class)
private val defaultNetworkFlow: Flow<TransportEvent> =
combine(configurationListener.detectionMethod, permissionsChangedFlow) { detectionMethod, _
->
detectionMethod
}
.flatMapLatest { detectionMethod ->
callbackFlow {
if (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && detectionMethod == DEFAULT
) {
defaultNetworkCallback =
object :
ConnectivityManager.NetworkCallback(FLAG_INCLUDE_LOCATION_INFO) {
override fun onAvailable(network: Network) {
// ignore onAvailable has it doesn't contain detailed network
// information in capabilities
Timber.d("Default onAvailable: $network")
}
override fun onLost(network: Network) {
trySend(TransportEvent.Lost(network))
}
override fun onCapabilitiesChanged(
network: Network,
caps: NetworkCapabilities,
) {
trySend(TransportEvent.CapabilitiesChanged(network, caps))
}
}
} else {
defaultNetworkCallback =
object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
Timber.d("Default onAvailable: $network")
}
override fun onLost(network: Network) {
trySend(TransportEvent.Lost(network))
}
override fun onCapabilitiesChanged(
network: Network,
caps: NetworkCapabilities,
) {
trySend(TransportEvent.CapabilitiesChanged(network, caps))
}
}
}
connectivityManager?.registerDefaultNetworkCallback(defaultNetworkCallback!!)
trySend(
TransportEvent.Permissions(
Permissions(
locationManager?.isLocationServicesEnabled() ?: false,
appContext.hasRequiredLocationPermissions(),
)
)
)
awaitClose {
connectivityManager?.unregisterNetworkCallback(defaultNetworkCallback!!)
}
}
}
// recreate Wi-Fi flow on permission/detection method changes to get newly available network
// info
@OptIn(ExperimentalCoroutinesApi::class)
private val wifiFlow: Flow<TransportEvent> =
combine(configurationListener.detectionMethod, permissionsChangedFlow) {
detectionMethod,
changed ->
Pair(detectionMethod, changed)
combine(configurationListener.detectionMethod, permissionsChangedFlow) { detectionMethod, _
->
detectionMethod
}
.flatMapLatest { (detectionMethod, _) -> // cancels previous flow
Timber.d("Permission or detection method changed, recreating wifiFlow")
createWifiNetworkCallbackFlow(detectionMethod)
}
private fun isAndroidTv(): Boolean =
appContext.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK)
private fun hasRequiredLocationPermissions(): Boolean {
val fineLocationGranted =
ContextCompat.checkSelfPermission(
appContext,
Manifest.permission.ACCESS_FINE_LOCATION,
) == PackageManager.PERMISSION_GRANTED
val backgroundLocationGranted =
if (
(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) &&
// exclude Android TV on Q as background location is not required on this
// version
!(Build.VERSION.SDK_INT == Build.VERSION_CODES.Q && isAndroidTv())
) {
ContextCompat.checkSelfPermission(
appContext,
Manifest.permission.ACCESS_BACKGROUND_LOCATION,
) == PackageManager.PERMISSION_GRANTED
} else {
true // No need for ACCESS_BACKGROUND_LOCATION on Android P or Android TV on Q
}
return fineLocationGranted && backgroundLocationGranted
}
.flatMapLatest { detectionMethod -> createWifiNetworkCallbackFlow(detectionMethod) }
private fun createWifiNetworkCallbackFlow(
detectionMethod: WifiDetectionMethod
): Flow<TransportEvent> = callbackFlow {
fun handleOnWifiLost(network: Network) {
Timber.d("Wi-Fi onLost: network=$network")
activeWifiNetworks.remove(network.toString())
if (activeWifiNetworks.isEmpty()) {
Timber.d("All Wi-Fi networks disconnected, clearing currentSsid and wifiConnected")
trySend(TransportEvent.Lost(network))
} else {
Timber.d("Wi-Fi onLost, but still connected to other networks, ignoring")
// This can happen when switching between APs of the same SSID
val onAvailable: (Network) -> Unit = { network ->
// ignore onAvailable has it doesn't contain detailed network information in
// capabilities
Timber.d("WiFi onAvailable: $network")
}
val onLost: (Network) -> Unit = { network ->
Timber.d("WiFi onLost: $network")
trySend(TransportEvent.Lost(network))
}
val onCapabilitiesChanged: (Network, NetworkCapabilities) -> Unit = { network, caps ->
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) {
trySend(TransportEvent.CapabilitiesChanged(network, caps))
}
}
fun handleOnWifiAvailable(network: Network) {
Timber.d("Wi-Fi onAvailable: network=$network")
activeWifiNetworks[network.toString()] = Pair(network, null)
trySend(TransportEvent.Available(network, detectionMethod))
}
fun handleOnWifiCapabilitiesChanged(
network: Network,
networkCapabilities: NetworkCapabilities,
) {
Timber.d("Wi-Fi onCapabilitiesChanged: network=$network")
activeWifiNetworks[network.toString()] = Pair(network, networkCapabilities)
trySend(
TransportEvent.CapabilitiesChanged(network, networkCapabilities, detectionMethod)
)
}
wifiCallback =
when {
detectionMethod == WifiDetectionMethod.LEGACY ||
Build.VERSION.SDK_INT < Build.VERSION_CODES.S ->
object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
handleOnWifiAvailable(network)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && detectionMethod == DEFAULT) {
object : ConnectivityManager.NetworkCallback(FLAG_INCLUDE_LOCATION_INFO) {
override fun onAvailable(network: Network) = onAvailable(network)
override fun onLost(network: Network) {
handleOnWifiLost(network)
}
}
.also { Timber.d("Creating Wi-Fi callback without location info flags") }
else ->
object : ConnectivityManager.NetworkCallback(FLAG_INCLUDE_LOCATION_INFO) {
override fun onLost(network: Network) = onLost(network)
override fun onAvailable(network: Network) {
if (detectionMethod != WifiDetectionMethod.DEFAULT)
handleOnWifiAvailable(network)
}
override fun onCapabilitiesChanged(
network: Network,
caps: NetworkCapabilities,
) = onCapabilitiesChanged(network, caps)
}
} else {
object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) = onAvailable(network)
override fun onCapabilitiesChanged(
network: Network,
networkCapabilities: NetworkCapabilities,
) {
if (detectionMethod == WifiDetectionMethod.DEFAULT)
handleOnWifiCapabilitiesChanged(network, networkCapabilities)
}
override fun onLost(network: Network) = onLost(network)
override fun onLost(network: Network) {
handleOnWifiLost(network)
}
}
.also { Timber.d("Creating Wi-Fi callback with location info flags") }
override fun onCapabilitiesChanged(
network: Network,
caps: NetworkCapabilities,
) = onCapabilitiesChanged(network, caps)
}
}
val request =
NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
.apply { addTransportType(NetworkCapabilities.TRANSPORT_WIFI) }
.build()
connectivityManager?.registerNetworkCallback(request, wifiCallback!!)
trySend(
TransportEvent.Permissions(
permissions =
Permissions(
locationManager?.isLocationServicesEnabled() ?: false,
hasRequiredLocationPermissions(),
)
)
)
awaitClose {
runCatching { connectivityManager?.unregisterNetworkCallback(wifiCallback!!) }
.onFailure { Timber.e(it, "Error unregistering network callback") }
}
}
private val wifiInterfaceFlow: Flow<Boolean> = callbackFlow {
val localCallback =
object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
Timber.d("Wi-Fi Interface onAvailable (Adapter ON): network=$network")
trySend(true)
}
override fun onLost(network: Network) {
Timber.d("Wi-Fi Interface onLost (Adapter OFF): network=$network")
trySend(false)
}
}
wifiInterfaceCallback = localCallback
// wifi Transport only
val request =
NetworkRequest.Builder().addTransportType(NetworkCapabilities.TRANSPORT_WIFI).build()
connectivityManager?.registerNetworkCallback(request, wifiInterfaceCallback!!)
@Suppress("DEPRECATION") val isWifiInitiallyOn = wifiManager?.isWifiEnabled == true
trySend(isWifiInitiallyOn)
awaitClose {
runCatching { connectivityManager?.unregisterNetworkCallback(wifiInterfaceCallback!!) }
.onFailure { Timber.e(it, "Error unregistering Wi-Fi interface callback") }
.onFailure { Timber.e(it, "Error unregistering WiFi network callback") }
}
}
private val cellularFlow: Flow<TransportEvent> = callbackFlow {
val cellularLocalCallback =
val onAvailable: (Network) -> Unit = { network ->
Timber.d("Cellular onAvailable: $network")
}
val onLost: (Network) -> Unit = { network ->
Timber.d("Cellular onLost: $network")
trySend(TransportEvent.Lost(network))
}
val onCapabilitiesChanged: (Network, NetworkCapabilities) -> Unit = { network, caps ->
Timber.d("Cellular onCapabilitiesChanged: $network")
trySend(TransportEvent.CapabilitiesChanged(network, caps))
}
cellularCallback =
object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
Timber.d("Cellular onAvailable: network=$network")
activeCellularNetworks[network.toString()] = Pair(network, null)
trySend(TransportEvent.Available(network))
}
override fun onAvailable(network: Network) = onAvailable(network)
override fun onLost(network: Network) {
Timber.d("Cellular onLost: network=$network")
activeCellularNetworks.remove(network.toString())
if (activeCellularNetworks.isEmpty()) {
Timber.d("All cellular networks disconnected")
trySend(TransportEvent.Lost(network))
} else {
Timber.d("Cellular onLost, but still connected to other, ignoring")
}
}
override fun onLost(network: Network) = onLost(network)
override fun onCapabilitiesChanged(
network: Network,
networkCapabilities: NetworkCapabilities,
) {
Timber.d("Cellular onCapabilitiesChanged: network=$network")
activeCellularNetworks[network.toString()] = Pair(network, networkCapabilities)
}
override fun onCapabilitiesChanged(network: Network, caps: NetworkCapabilities) =
onCapabilitiesChanged(network, caps)
}
cellularCallback = cellularLocalCallback
val request =
NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR)
.apply { addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR) }
.build()
connectivityManager?.registerNetworkCallback(request, cellularCallback!!)
trySend(TransportEvent.Unknown)
awaitClose {
@@ -289,27 +253,35 @@ class AndroidNetworkMonitor(
}
private val ethernetFlow: Flow<TransportEvent> = callbackFlow {
val ethernetLocalCallback =
object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
Timber.d("Ethernet onAvailable: network=$network")
trySend(TransportEvent.Available(network))
}
val onAvailable: (Network) -> Unit = { network ->
Timber.d("Ethernet onAvailable: $network")
}
val onLost: (Network) -> Unit = { network ->
Timber.d("Ethernet onLost: $network")
trySend(TransportEvent.Lost(network))
}
val onCapabilitiesChanged: (Network, NetworkCapabilities) -> Unit = { network, caps ->
Timber.d("Ethernet onCapabilitiesChanged: $network")
trySend(TransportEvent.CapabilitiesChanged(network, caps))
}
override fun onLost(network: Network) {
Timber.d("Ethernet onLost: network=$network")
trySend(TransportEvent.Lost(network))
}
ethernetCallback =
object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) = onAvailable(network)
override fun onLost(network: Network) = onLost(network)
override fun onCapabilitiesChanged(network: Network, caps: NetworkCapabilities) =
onCapabilitiesChanged(network, caps)
}
ethernetCallback = ethernetLocalCallback
val request =
NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.addTransportType(NetworkCapabilities.TRANSPORT_ETHERNET)
.apply { addTransportType(NetworkCapabilities.TRANSPORT_ETHERNET) }
.build()
connectivityManager?.registerNetworkCallback(request, ethernetCallback!!)
trySend(TransportEvent.Unknown)
awaitClose {
@@ -318,24 +290,56 @@ class AndroidNetworkMonitor(
}
}
private val wifiStateFlow: Flow<NetworkCapabilities?> =
wifiFlow
.map { event ->
when (event) {
is TransportEvent.CapabilitiesChanged -> event.networkCapabilities
is TransportEvent.Lost -> null
else -> null
}
}
.stateIn(applicationScope, SharingStarted.Eagerly, null)
private val cellularStateFlow: Flow<NetworkCapabilities?> =
cellularFlow
.map { event ->
when (event) {
is TransportEvent.CapabilitiesChanged -> event.networkCapabilities
is TransportEvent.Lost -> null
else -> null
}
}
.stateIn(applicationScope, SharingStarted.Eagerly, null)
private val ethernetStateFlow: Flow<NetworkCapabilities?> =
ethernetFlow
.map { event ->
when (event) {
is TransportEvent.CapabilitiesChanged -> event.networkCapabilities
is TransportEvent.Lost -> null
else -> null
}
}
.stateIn(applicationScope, SharingStarted.Eagerly, null)
private suspend fun getSsidByDetectionMethod(
detectionMethod: WifiDetectionMethod?,
networkCapabilities: NetworkCapabilities?,
): String {
val method = detectionMethod ?: WifiDetectionMethod.DEFAULT
val method = detectionMethod ?: DEFAULT
return try {
when (method) {
WifiDetectionMethod.DEFAULT ->
DEFAULT ->
networkCapabilities?.getWifiSsid()
?: wifiManager?.getWifiSsid()
?: ANDROID_UNKNOWN_SSID
WifiDetectionMethod.LEGACY ->
wifiManager?.getWifiSsid() ?: ANDROID_UNKNOWN_SSID
WifiDetectionMethod.ROOT ->
LEGACY -> wifiManager?.getWifiSsid() ?: ANDROID_UNKNOWN_SSID
ROOT ->
withTimeoutOrNull(SHELL_COMMAND_TIMEOUT_MS) {
configurationListener.rootShell.getCurrentWifiName()
} ?: ANDROID_UNKNOWN_SSID
WifiDetectionMethod.SHIZUKU ->
SHIZUKU ->
withTimeoutOrNull(SHELL_COMMAND_TIMEOUT_MS) {
ShizukuShell(applicationScope)
.singleResponseCommand(WIFI_SSID_SHELL_COMMAND)
@@ -350,107 +354,166 @@ class AndroidNetworkMonitor(
.also { Timber.d("Current SSID via ${method.name}: $it") }
}
// prevent false positive late mobile data changes to combat android api quirks
private fun isLateCellularChange(previous: ConnectivityState, new: ConnectivityState): Boolean {
return (previous.wifiState.connected != new.wifiState.connected &&
previous.wifiState.ssid == new.wifiState.ssid &&
previous.cellularConnected != new.cellularConnected)
}
// default network events don't contain detailed capability information of underlying networks,
// so we need to track separately
private data class NetworkData(
val defaultNetworkEvent: TransportEvent,
val wifiCapabilities: NetworkCapabilities?,
val cellularCaps: NetworkCapabilities?,
val ethernetCaps: NetworkCapabilities?,
)
// combine our network flows to keep sync
private val networkFlows: Flow<NetworkData> =
combine(defaultNetworkFlow, wifiStateFlow, cellularStateFlow, ethernetStateFlow) {
defaultEvent,
wifiCaps,
cellularCaps,
ethernetCaps ->
NetworkData(defaultEvent, wifiCaps, cellularCaps, ethernetCaps)
}
// tracking to prevent races that occur when VPN is first activated
private val lastKnownActiveNetwork = MutableStateFlow<ActiveNetwork>(ActiveNetwork.Disconnected)
@OptIn(ExperimentalAtomicApi::class) private val vpnActiveState = AtomicReference(false)
@OptIn(ExperimentalCoroutinesApi::class, ExperimentalAtomicApi::class, FlowPreview::class)
override val connectivityStateFlow: SharedFlow<ConnectivityState> =
combine(
wifiFlow.scan(
WifiState(
locationPermissionsGranted = hasRequiredLocationPermissions(),
locationServicesEnabled =
locationManager?.isLocationServicesEnabled() ?: false,
)
) { previous, event ->
when (event) {
is TransportEvent.Available ->
previous.copy(
connected = true,
ssid =
getSsidByDetectionMethod(
event.wifiDetectionMethod ?: WifiDetectionMethod.DEFAULT,
null,
),
securityType = wifiManager?.getCurrentSecurityType(),
)
is TransportEvent.CapabilitiesChanged ->
previous.copy(
connected = true,
ssid =
getSsidByDetectionMethod(
event.wifiDetectionMethod ?: WifiDetectionMethod.DEFAULT,
null,
),
securityType = wifiManager?.getCurrentSecurityType(),
)
is TransportEvent.Permissions -> {
previous.copy(
locationPermissionsGranted =
event.permissions.locationPermissionGranted,
locationServicesEnabled = event.permissions.locationServicesEnabled,
)
}
is TransportEvent.Lost ->
previous.copy(connected = false, securityType = null, ssid = null)
is TransportEvent.Unknown -> previous
}
},
cellularFlow,
ethernetFlow,
wifiInterfaceFlow,
) { wifiState, cellular, ethernet, isWifiInterfaceOn ->
val cellularConnected = cellular is TransportEvent.Available
val ethernetConnected = ethernet is TransportEvent.Available
combine(networkFlows, airplaneModeFlow, configurationListener.detectionMethod) {
networkData,
isAirplaneOn,
detectionMethod ->
val defaultEvent = networkData.defaultNetworkEvent
val wifiCaps = networkData.wifiCapabilities
val cellularCaps = networkData.cellularCaps
val ethernetCaps = networkData.ethernetCaps
// if wifi is off, force wifi state to disconnected
val finalWifiState =
if (!isWifiInterfaceOn) {
wifiState.copy(connected = false, securityType = null, ssid = null)
val permissions =
when (defaultEvent) {
is TransportEvent.Permissions -> defaultEvent.permissions
else ->
Permissions(
locationManager?.isLocationServicesEnabled() ?: false,
appContext.hasRequiredLocationPermissions(),
)
}
// determine default network capabilities
val defaultCaps =
when (defaultEvent) {
is TransportEvent.CapabilitiesChanged -> defaultEvent.networkCapabilities
else ->
connectivityManager?.activeNetwork?.let {
connectivityManager.getNetworkCapabilities(it)
}
}
?: return@combine ConnectivityState(
activeNetwork = ActiveNetwork.Disconnected,
locationPermissionsGranted = permissions.locationPermissionGranted,
locationServicesEnabled = permissions.locationServicesEnabled,
vpnState = VpnState.Inactive,
)
val vpnPreviouslyActive =
vpnActiveState.exchange(
defaultCaps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)
)
val isVpnActive = vpnActiveState.load()
// determine vpn state
val vpnState: VpnState =
if (!isVpnActive) {
VpnState.Inactive
} else {
wifiState
VpnState.Active(
hasInternet =
defaultCaps.hasCapability(
NetworkCapabilities.NET_CAPABILITY_VALIDATED
)
)
}
val activeNetwork: ActiveNetwork =
run {
if (!isVpnActive) {
when {
defaultCaps.hasTransport(
NetworkCapabilities.TRANSPORT_ETHERNET
) -> ActiveNetwork.Ethernet
defaultCaps.hasTransport(
NetworkCapabilities.TRANSPORT_WIFI
) -> {
val ssid =
getSsidByDetectionMethod(detectionMethod, defaultCaps)
ActiveNetwork.Wifi(
ssid,
wifiManager?.getCurrentSecurityType(),
)
}
defaultCaps.hasTransport(
NetworkCapabilities.TRANSPORT_CELLULAR
) && !isAirplaneOn -> ActiveNetwork.Cellular
else -> ActiveNetwork.Disconnected
}
} else {
val fromCaps =
when {
ethernetCaps != null -> ActiveNetwork.Ethernet
wifiCaps != null -> {
val ssid =
getSsidByDetectionMethod(detectionMethod, wifiCaps)
ActiveNetwork.Wifi(
ssid,
wifiManager?.getCurrentSecurityType(),
)
}
cellularCaps != null && !isAirplaneOn ->
ActiveNetwork.Cellular
else -> null
}
fromCaps
?: if (!vpnPreviouslyActive) {
lastKnownActiveNetwork.value
} else {
ActiveNetwork.Disconnected
}
}
}
.also { network -> lastKnownActiveNetwork.value = network }
ConnectivityState(
finalWifiState,
cellularConnected = cellularConnected,
ethernetConnected = ethernetConnected,
)
.also { Timber.i("Connectivity Status: $it") }
}
.scan(
ConnectivityState(
WifiState(
locationPermissionsGranted = hasRequiredLocationPermissions(),
locationServicesEnabled =
locationManager?.isLocationServicesEnabled() ?: false,
)
activeNetwork = activeNetwork,
locationPermissionsGranted = permissions.locationPermissionGranted,
locationServicesEnabled = permissions.locationServicesEnabled,
vpnState = vpnState,
)
) { previous, current ->
if (isLateCellularChange(previous, current)) {
Timber.d("Skipping late cellular change")
previous
} else {
current
}
}
.distinctUntilChanged()
.debounce { 300L }
.shareIn(applicationScope, SharingStarted.Eagerly, replay = 1)
// utility to send local broadcast to trigger a recheck of location permissions onResume,
// especially for getting SSID
// that we did not have permission to read before, will trigger a recreation of the Wi-Fi flows
// if permission was changed
override fun checkPermissionsAndUpdateState() {
val action = actionPermissionCheck
val intent = Intent(action)
val intent = Intent(action).apply { setPackage(appContext.packageName) }
Timber.d("Sending broadcast: $action")
appContext.sendBroadcast(intent)
}
init {
val receiverFlags =
val exportedFlags =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
Context.RECEIVER_EXPORTED // System broadcast
Context.RECEIVER_EXPORTED
} else {
0
}
val localFlags =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
Context.RECEIVER_NOT_EXPORTED
} else {
0
}
@@ -459,28 +522,26 @@ class AndroidNetworkMonitor(
object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == actionPermissionCheck) {
val isGranted = hasRequiredLocationPermissions()
val isGranted = appContext.hasRequiredLocationPermissions()
Timber.d("Received permission check broadcast, isGranted: $isGranted")
// get Wi-Fi info on permission change and update permission state
if (
connectivityStateFlow.replayCache
.firstOrNull()
?.wifiState
?.locationPermissionsGranted != isGranted
) {
Timber.d(
"Location permissions have changed, canceling and restarting callback flow"
)
activeWifiNetworks.clear()
permissionsChangedFlow.update { !permissionsChangedFlow.value }
}
}
}
}
permissionReceiver?.let {
appContext.registerReceiver(it, IntentFilter(actionPermissionCheck), receiverFlags)
}
appContext.registerReceiver(
permissionReceiver,
IntentFilter(actionPermissionCheck),
localFlags,
)
locationServicesReceiver =
object : BroadcastReceiver() {
@@ -491,39 +552,49 @@ class AndroidNetworkMonitor(
if (
connectivityStateFlow.replayCache
.firstOrNull()
?.wifiState
?.locationServicesEnabled != isLocationServicesEnabled
) {
Timber.d(
"Location services have changed, canceling and restarting callback flow"
)
// trigger cancel and recreate of callbackFlow
activeWifiNetworks.clear()
permissionsChangedFlow.update { !permissionsChangedFlow.value }
}
}
}
}
locationServicesReceiver?.let {
appContext.registerReceiver(
locationServicesReceiver,
IntentFilter(LOCATION_SERVICES_FILTER),
receiverFlags,
)
}
appContext.registerReceiver(
locationServicesReceiver,
IntentFilter(LOCATION_SERVICES_FILTER),
exportedFlags,
)
airplaneReceiver =
object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == Intent.ACTION_AIRPLANE_MODE_CHANGED) {
Timber.d("Received airplane mode changed broadcast")
airplaneModeState.update { appContext.isAirplaneModeOn() }
}
}
}
appContext.registerReceiver(
airplaneReceiver,
IntentFilter(Intent.ACTION_AIRPLANE_MODE_CHANGED),
exportedFlags,
)
airplaneModeState.update { appContext.isAirplaneModeOn() }
}
override fun destroy() {
runCatching {
permissionReceiver?.let { appContext.unregisterReceiver(it) }
locationServicesReceiver?.let { appContext.unregisterReceiver(it) }
airplaneReceiver?.let { appContext.unregisterReceiver(it) }
defaultNetworkCallback?.let { connectivityManager?.unregisterNetworkCallback(it) }
wifiCallback?.let { connectivityManager?.unregisterNetworkCallback(it) }
cellularCallback?.let { connectivityManager?.unregisterNetworkCallback(it) }
ethernetCallback?.let { connectivityManager?.unregisterNetworkCallback(it) }
wifiInterfaceCallback?.let {
connectivityManager?.unregisterNetworkCallback(it)
} // NEW
}
.onFailure { Timber.e(it, "Error during cleanup") }
Timber.d("NetworkMonitor cleaned up")
@@ -3,25 +3,45 @@ package com.zaneschepke.networkmonitor
import com.zaneschepke.networkmonitor.util.WifiSecurityType
data class ConnectivityState(
val wifiState: WifiState,
val ethernetConnected: Boolean = false,
val cellularConnected: Boolean = false,
) {
fun hasConnectivity(): Boolean = wifiState.connected || ethernetConnected || cellularConnected
}
data class WifiState(
val connected: Boolean = false,
val ssid: String? = null,
val securityType: WifiSecurityType? = null,
val activeNetwork: ActiveNetwork,
val locationPermissionsGranted: Boolean,
val locationServicesEnabled: Boolean,
val vpnState: VpnState,
) {
override fun toString(): String =
"connected=$connected, ssid=${if(ssid == AndroidNetworkMonitor.ANDROID_UNKNOWN_SSID || ssid == null) ssid else ssid.first() + "..."} securityType=$securityType, locationPermissionsGranted=$locationPermissionsGranted"
fun hasInternet(): Boolean = activeNetwork !is ActiveNetwork.Disconnected
override fun toString(): String {
val networkInfo =
when (activeNetwork) {
is ActiveNetwork.Disconnected -> "Disconnected"
is ActiveNetwork.Ethernet -> "Ethernet"
is ActiveNetwork.Cellular -> "Cellular"
is ActiveNetwork.Wifi -> {
val ssidDisplay =
if (activeNetwork.ssid == AndroidNetworkMonitor.ANDROID_UNKNOWN_SSID)
activeNetwork.ssid
else activeNetwork.ssid.first() + "..."
"Wifi(ssid=$ssidDisplay, securityType=${activeNetwork.securityType})"
}
}
return "activeNetwork=$networkInfo, locationPermissionsGranted=$locationPermissionsGranted, locationServicesEnabled=$locationServicesEnabled"
}
}
data class Permissions(
val locationServicesEnabled: Boolean = false,
val locationPermissionGranted: Boolean = false,
)
data class Permissions(val locationServicesEnabled: Boolean, val locationPermissionGranted: Boolean)
sealed class ActiveNetwork {
data object Disconnected : ActiveNetwork()
data class Wifi(val ssid: String, val securityType: WifiSecurityType?) : ActiveNetwork()
data object Cellular : ActiveNetwork()
data object Ethernet : ActiveNetwork()
}
sealed interface VpnState {
object Inactive : VpnState
data class Active(val hasInternet: Boolean) : VpnState
}
@@ -1,10 +1,15 @@
package com.zaneschepke.networkmonitor.util
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.location.LocationManager
import android.net.NetworkCapabilities
import android.net.wifi.WifiInfo
import android.net.wifi.WifiManager
import android.os.Build
import android.provider.Settings
import androidx.core.content.ContextCompat
import com.wireguard.android.util.RootShell
import com.zaneschepke.networkmonitor.AndroidNetworkMonitor.Companion.ANDROID_UNKNOWN_SSID
import kotlinx.coroutines.Dispatchers
@@ -62,3 +67,29 @@ fun LocationManager.isLocationServicesEnabled(): Boolean {
false
}
}
fun Context.hasRequiredLocationPermissions(): Boolean {
val fineLocationGranted =
ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
val backgroundLocationGranted =
if (
(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) &&
// exclude Android TV on Q as background location is not required on this
// version
!(Build.VERSION.SDK_INT == Build.VERSION_CODES.Q &&
packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK))
) {
ContextCompat.checkSelfPermission(
this,
Manifest.permission.ACCESS_BACKGROUND_LOCATION,
) == PackageManager.PERMISSION_GRANTED
} else {
true // No need for ACCESS_BACKGROUND_LOCATION on Android P or Android TV on Q
}
return fineLocationGranted && backgroundLocationGranted
}
fun Context.isAirplaneModeOn(): Boolean {
return Settings.Global.getInt(contentResolver, Settings.Global.AIRPLANE_MODE_ON, 0) != 0
}