mirror of
https://github.com/wgtunnel/android.git
synced 2026-07-03 14:07:49 +02:00
Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 85a27f48a2 | |||
| 1f978cdf96 | |||
| 4f816fa175 | |||
| ee4ac4e968 | |||
| ff53454966 | |||
| 22c17ef66b | |||
| 7a60b90d2b | |||
| 5fd3f89a59 | |||
| 9510f43252 | |||
| 064aa6aa74 | |||
| 0c09add0e4 | |||
| fd0fd33f71 | |||
| aaeb251bbf | |||
| e563608e49 | |||
| 584f0386b6 | |||
| cf49c34bff | |||
| a0f89d40f5 | |||
| 4da05e23f1 | |||
| 6749719e21 | |||
| 1c160ff5f9 | |||
| 861440b7db | |||
| bdb0d27b53 | |||
| 9b3283a2b1 | |||
| 78def29980 | |||
| e83bbdf23a | |||
| 4beeb4e01e | |||
| 4bcd810b38 | |||
| e71174995b | |||
| f256a32bda | |||
| c49666303a | |||
| 3a9b435e50 | |||
| 0993f60977 | |||
| 3d88feb97c | |||
| f61e6d6c6e | |||
| df864ade95 | |||
| 0abe3f67ef |
@@ -1,3 +1,4 @@
|
||||
ko_fi: zaneschepke
|
||||
liberapay: zaneschepke
|
||||
github: zaneschepke
|
||||
custom: ["https://wgtunnel.com/donate/"]
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -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)
|
||||
|
||||
+1
-1
@@ -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
-12
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+23
-10
@@ -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)
|
||||
|
||||
+1
-1
@@ -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`)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -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 {
|
||||
|
||||
+28
-38
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-9
@@ -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
|
||||
}
|
||||
+36
-26
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
-2
@@ -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
|
||||
|
||||
+4
-14
@@ -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?
|
||||
|
||||
+38
-54
@@ -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) },
|
||||
|
||||
+21
-24
@@ -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),
|
||||
)
|
||||
|
||||
+9
-6
@@ -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)
|
||||
|
||||
+24
-14
@@ -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) {
|
||||
|
||||
+2
-2
@@ -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 {
|
||||
|
||||
+7
-1
@@ -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,
|
||||
|
||||
+24
-16
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
+33
-30
@@ -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)
|
||||
|
||||
+16
-6
@@ -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)
|
||||
|
||||
+2
-2
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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" }
|
||||
|
||||
|
||||
+363
-292
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user