diff --git a/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/1.json b/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/1.json old mode 100644 new mode 100755 diff --git a/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/10.json b/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/10.json old mode 100644 new mode 100755 diff --git a/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/11.json b/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/11.json old mode 100644 new mode 100755 diff --git a/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/12.json b/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/12.json old mode 100644 new mode 100755 diff --git a/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/13.json b/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/13.json old mode 100644 new mode 100755 diff --git a/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/14.json b/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/14.json old mode 100644 new mode 100755 diff --git a/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/15.json b/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/15.json old mode 100644 new mode 100755 diff --git a/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/16.json b/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/16.json old mode 100644 new mode 100755 diff --git a/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/17.json b/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/17.json old mode 100644 new mode 100755 diff --git a/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/18.json b/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/18.json old mode 100644 new mode 100755 diff --git a/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/19.json b/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/19.json old mode 100644 new mode 100755 diff --git a/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/2.json b/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/2.json old mode 100644 new mode 100755 diff --git a/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/20.json b/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/20.json old mode 100644 new mode 100755 diff --git a/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/21.json b/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/21.json old mode 100644 new mode 100755 diff --git a/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/22.json b/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/22.json old mode 100644 new mode 100755 diff --git a/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/23.json b/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/23.json old mode 100644 new mode 100755 diff --git a/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/24.json b/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/24.json old mode 100644 new mode 100755 diff --git a/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/25.json b/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/25.json old mode 100644 new mode 100755 diff --git a/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/26.json b/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/26.json old mode 100644 new mode 100755 diff --git a/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/27.json b/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/27.json old mode 100644 new mode 100755 diff --git a/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/28.json b/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/28.json old mode 100644 new mode 100755 diff --git a/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/29.json b/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/29.json old mode 100644 new mode 100755 diff --git a/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/3.json b/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/3.json old mode 100644 new mode 100755 diff --git a/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/30.json b/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/30.json new file mode 100755 index 000000000..6d194b466 --- /dev/null +++ b/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/30.json @@ -0,0 +1,138 @@ +{ + "formatVersion": 1, + "database": { + "version": 30, + "identityHash": "10b2fbf87de9ea4d4ffd6ebd42a30602", + "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, `is_restart_on_handshake_timeout_enabled` INTEGER NOT NULL DEFAULT 0, `max_handshake_restart_attempts` INTEGER NOT NULL DEFAULT 5, `restart_cooldown_seconds` INTEGER NOT NULL DEFAULT 30, `is_recovery_notification_enabled` INTEGER NOT NULL DEFAULT 1)", + "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"}, + {"fieldPath": "isRestartOnHandshakeTimeoutEnabled", "columnName": "is_restart_on_handshake_timeout_enabled", "affinity": "INTEGER", "notNull": true, "defaultValue": "0"}, + {"fieldPath": "maxHandshakeRestartAttempts", "columnName": "max_handshake_restart_attempts", "affinity": "INTEGER", "notNull": true, "defaultValue": "5"}, + {"fieldPath": "restartCooldownSeconds", "columnName": "restart_cooldown_seconds", "affinity": "INTEGER", "notNull": true, "defaultValue": "30"}, + {"fieldPath": "isRecoveryNotificationEnabled", "columnName": "is_recovery_notification_enabled", "affinity": "INTEGER", "notNull": true, "defaultValue": "1"} + ], + "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, '10b2fbf87de9ea4d4ffd6ebd42a30602')" + ] + } +} diff --git a/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/31.json b/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/31.json new file mode 100644 index 000000000..8970844fd --- /dev/null +++ b/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/31.json @@ -0,0 +1,139 @@ +{ + "formatVersion": 1, + "database": { + "version": 31, + "identityHash": "10b2fbf87de9ea4d4ffd6ebd42a30602", + "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, `is_restart_on_handshake_timeout_enabled` INTEGER NOT NULL DEFAULT 0, `max_handshake_restart_attempts` INTEGER NOT NULL DEFAULT 5, `restart_cooldown_seconds` INTEGER NOT NULL DEFAULT 30, `is_recovery_notification_enabled` INTEGER NOT NULL DEFAULT 1, `max_attempts_action` 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"}, + {"fieldPath": "isRestartOnHandshakeTimeoutEnabled", "columnName": "is_restart_on_handshake_timeout_enabled", "affinity": "INTEGER", "notNull": true, "defaultValue": "0"}, + {"fieldPath": "maxHandshakeRestartAttempts", "columnName": "max_handshake_restart_attempts", "affinity": "INTEGER", "notNull": true, "defaultValue": "5"}, + {"fieldPath": "restartCooldownSeconds", "columnName": "restart_cooldown_seconds", "affinity": "INTEGER", "notNull": true, "defaultValue": "30"}, + {"fieldPath": "isRecoveryNotificationEnabled", "columnName": "is_recovery_notification_enabled", "affinity": "INTEGER", "notNull": true, "defaultValue": "1"}, + {"fieldPath": "maxAttemptsAction", "columnName": "max_attempts_action", "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, '10b2fbf87de9ea4d4ffd6ebd42a30602')" + ] + } +} diff --git a/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/4.json b/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/4.json old mode 100644 new mode 100755 diff --git a/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/5.json b/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/5.json old mode 100644 new mode 100755 diff --git a/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/6.json b/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/6.json old mode 100644 new mode 100755 diff --git a/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/7.json b/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/7.json old mode 100644 new mode 100755 diff --git a/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/8.json b/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/8.json old mode 100644 new mode 100755 diff --git a/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/9.json b/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/9.json old mode 100644 new mode 100755 diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/MainActivity.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/MainActivity.kt old mode 100644 new mode 100755 index 3165f059d..2b888d122 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/MainActivity.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/MainActivity.kt @@ -95,6 +95,7 @@ import com.zaneschepke.wireguardautotunnel.ui.screens.settings.dns.DnsSettingsSc import com.zaneschepke.wireguardautotunnel.ui.screens.settings.integrations.AndroidIntegrationsScreen import com.zaneschepke.wireguardautotunnel.ui.screens.settings.lockdown.LockdownSettingsScreen import com.zaneschepke.wireguardautotunnel.ui.screens.settings.monitoring.TunnelMonitoringScreen +import com.zaneschepke.wireguardautotunnel.ui.screens.settings.monitoring.autorestart.AutoRestartScreen import com.zaneschepke.wireguardautotunnel.ui.screens.settings.monitoring.logs.LogsScreen import com.zaneschepke.wireguardautotunnel.ui.screens.settings.monitoring.ping.PingTargetScreen import com.zaneschepke.wireguardautotunnel.ui.screens.support.SupportScreen @@ -503,6 +504,7 @@ class MainActivity : AppCompatActivity() { PreferredTunnelScreen(key.tunnelNetwork) } entry { PingTargetScreen() } + entry { AutoRestartScreen() } }, ) } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/notification/NotificationManager.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/notification/NotificationManager.kt old mode 100644 new mode 100755 index 7a5c8c25e..70c73f3ee --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/notification/NotificationManager.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/notification/NotificationManager.kt @@ -57,6 +57,7 @@ interface NotificationManager { const val VPN_NOTIFICATION_ID = 100 const val TUNNEL_ERROR_NOTIFICATION_ID = 101 const val TUNNEL_MESSAGES_NOTIFICATION_ID = 102 + const val RECOVERY_NOTIFICATION_ID = 103 const val EXTRA_ID = "id" } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/notification/NotificationMonitor.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/notification/NotificationMonitor.kt old mode 100644 new mode 100755 index 74e0cf4d1..494bf5ac0 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/notification/NotificationMonitor.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/notification/NotificationMonitor.kt @@ -3,18 +3,23 @@ package com.zaneschepke.wireguardautotunnel.core.notification import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager +import com.zaneschepke.wireguardautotunnel.domain.events.BackendMessage +import com.zaneschepke.wireguardautotunnel.domain.repository.MonitoringSettingsRepository import com.zaneschepke.wireguardautotunnel.util.StringValue import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch class NotificationMonitor( private val tunnelManager: TunnelManager, private val notificationManager: NotificationManager, + private val monitoringSettingsRepository: MonitoringSettingsRepository, ) { suspend fun handleApplicationNotifications() = coroutineScope { launch { handleTunnelErrors() } launch { handleTunnelMessages() } + launch { handleRecoveryNotifications() } } private suspend fun handleTunnelErrors() = @@ -42,6 +47,7 @@ class NotificationMonitor( private suspend fun handleTunnelMessages() = tunnelManager.messageEvents.collectLatest { (tunName, message) -> + val stringValue = message.toStringValue() ?: return@collectLatest if (!WireGuardAutoTunnel.uiActive.value) { val notification = notificationManager.createNotification( @@ -49,7 +55,7 @@ class NotificationMonitor( title = tunName?.let { StringValue.DynamicString(it) } ?: StringValue.StringResource(R.string.tunnel), - description = message.toStringValue(), + description = stringValue, groupKey = NotificationManager.VPN_GROUP_KEY, ) notificationManager.show( @@ -58,4 +64,78 @@ class NotificationMonitor( ) } } + + private suspend fun handleRecoveryNotifications() { + tunnelManager.messageEvents.collect { (tunName, message) -> + val notificationsEnabled = + monitoringSettingsRepository.getMonitoringSettings().isRecoveryNotificationEnabled + val title = + tunName?.let { StringValue.DynamicString(it) } + ?: StringValue.StringResource(R.string.tunnel) + when (message) { + is BackendMessage.ConnectionDegrading -> { + if (notificationsEnabled) { + val reasonRes = + if (message.reason == BackendMessage.RestartReason.STALE_HANDSHAKE) + R.string.restart_reason_stale_handshake + else R.string.restart_reason_ping_failure + val notification = + notificationManager.createNotification( + WireGuardNotification.NotificationChannels.VPN, + title = title, + description = + StringValue.StringResource( + R.string.notif_recovery_degraded, + reasonRes, + message.attempt.toString(), + message.maxAttempts.toString(), + ), + groupKey = NotificationManager.VPN_GROUP_KEY, + onGoing = true, + ) + notificationManager.show(NotificationManager.RECOVERY_NOTIFICATION_ID, notification) + } + } + is BackendMessage.ConnectionRestored -> { + notificationManager.remove(NotificationManager.RECOVERY_NOTIFICATION_ID) + if (notificationsEnabled) { + val notification = + notificationManager.createNotification( + WireGuardNotification.NotificationChannels.VPN, + title = title, + description = StringValue.StringResource(R.string.notif_recovery_restored), + groupKey = NotificationManager.VPN_GROUP_KEY, + ) + notificationManager.show(NotificationManager.RECOVERY_NOTIFICATION_ID, notification) + } + } + is BackendMessage.ConnectionPermanentlyLost -> { + notificationManager.remove(NotificationManager.RECOVERY_NOTIFICATION_ID) + if (notificationsEnabled) { + val reasonRes = + if (message.reason == BackendMessage.RestartReason.STALE_HANDSHAKE) + R.string.restart_reason_stale_handshake + else R.string.restart_reason_ping_failure + val descRes = + if (message.isTunnelStopped) + StringValue.StringResource(R.string.notif_recovery_failed_stopped, reasonRes) + else + StringValue.StringResource(R.string.notif_recovery_failed, reasonRes) + val notification = + notificationManager.createNotification( + WireGuardNotification.NotificationChannels.VPN, + title = title, + description = descRes, + groupKey = NotificationManager.VPN_GROUP_KEY, + ) + notificationManager.show(NotificationManager.RECOVERY_NOTIFICATION_ID, notification) + } + } + is BackendMessage.ConnectionCancelled -> { + notificationManager.remove(NotificationManager.RECOVERY_NOTIFICATION_ID) + } + else -> {} + } + } + } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/TunnelLifecycleManager.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/TunnelLifecycleManager.kt old mode 100644 new mode 100755 index d2f18174d..c31601511 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/TunnelLifecycleManager.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/TunnelLifecycleManager.kt @@ -9,6 +9,7 @@ import com.zaneschepke.wireguardautotunnel.domain.events.UnknownError import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConfig import com.zaneschepke.wireguardautotunnel.domain.state.LogHealthState import com.zaneschepke.wireguardautotunnel.domain.state.PingState +import com.zaneschepke.wireguardautotunnel.domain.state.TunnelRestartProgress import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState import com.zaneschepke.wireguardautotunnel.domain.state.TunnelStatistics import java.util.concurrent.ConcurrentHashMap @@ -39,6 +40,7 @@ class TunnelLifecycleManager( ) : TunnelProvider { override val activeTunnels: StateFlow> = sharedActiveTunnels.asStateFlow() + override val restartProgress: StateFlow> = MutableStateFlow(emptyMap()) private val _errorEvents = MutableSharedFlow>() override val errorEvents: SharedFlow> = diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/TunnelManager.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/TunnelManager.kt old mode 100644 new mode 100755 index 50a717a0a..1465b9b16 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/TunnelManager.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/TunnelManager.kt @@ -6,6 +6,7 @@ import com.zaneschepke.networkmonitor.NetworkMonitor import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager import com.zaneschepke.wireguardautotunnel.core.tunnel.backend.TunnelBackend import com.zaneschepke.wireguardautotunnel.core.tunnel.handler.DynamicDnsHandler +import com.zaneschepke.wireguardautotunnel.core.tunnel.handler.HandshakeRestartHandler import com.zaneschepke.wireguardautotunnel.core.tunnel.handler.TunnelActiveStatePersister import com.zaneschepke.wireguardautotunnel.core.tunnel.handler.TunnelMonitorHandler import com.zaneschepke.wireguardautotunnel.core.tunnel.handler.TunnelServiceHandler @@ -25,6 +26,7 @@ import com.zaneschepke.wireguardautotunnel.domain.repository.MonitoringSettingsR import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository import com.zaneschepke.wireguardautotunnel.domain.state.LogHealthState import com.zaneschepke.wireguardautotunnel.domain.state.PingState +import com.zaneschepke.wireguardautotunnel.domain.state.TunnelRestartProgress import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState import com.zaneschepke.wireguardautotunnel.domain.state.TunnelStatistics import com.zaneschepke.wireguardautotunnel.util.network.NetworkUtils @@ -74,6 +76,10 @@ class TunnelManager( private val _activeTunnels = MutableStateFlow>(emptyMap()) override val activeTunnels: StateFlow> = _activeTunnels.asStateFlow() + override val restartProgress: StateFlow> + get() = handshakeRestartHandler.restartProgress + override val restartCounts: StateFlow> + get() = handshakeRestartHandler.restartCounts @OptIn(ExperimentalAtomicApi::class) val currentAppMode = AtomicReference(AppMode.VPN) @@ -114,7 +120,10 @@ class TunnelManager( override suspend fun startTunnel(tunnelConfig: TunnelConfig): Result = getProvider().startTunnel(tunnelConfig) - override suspend fun stopTunnel(tunnelId: Int) = getProvider().stopTunnel(tunnelId) + override suspend fun stopTunnel(tunnelId: Int) { + handshakeRestartHandler.cancelAndClear(tunnelId) + getProvider().stopTunnel(tunnelId) + } override suspend fun forceStopTunnel(tunnelId: Int) = getProvider().forceStopTunnel(tunnelId) @@ -191,6 +200,20 @@ class TunnelManager( ioDispatcher = ioDispatcher, ) + private val handshakeRestartHandler = + HandshakeRestartHandler( + activeTunnels = activeTunnels, + tunnelsRepository = tunnelsRepository, + monitoringSettingsRepository = monitoringSettingsRepository, + localMessageEvents = localMessageEvents, + restartTunnel = { id -> restartActiveTunnel(id) }, + stopTunnel = { id -> stopTunnel(id) }, + networkMonitor = networkMonitor, + networkUtils = networkUtils, + applicationScope = applicationScope, + ioDispatcher = ioDispatcher, + ) + private val fullTunnelMonitorHandler = TunnelMonitorHandler( activeTunnels = activeTunnels, @@ -344,7 +367,8 @@ class TunnelManager( } private suspend fun restartTunnel(tunnel: TunnelConfig) { - runCatching { stopTunnel(tunnel.id) } + // Use getProvider() directly to avoid triggering cancelAndClear on the auto-restart job + runCatching { getProvider().stopTunnel(tunnel.id) } .onFailure { e -> Timber.e(e, "Failed to stop tunnel ${tunnel.id} during restart") } delay(RESTART_TUNNEL_DELAY) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/TunnelProvider.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/TunnelProvider.kt old mode 100644 new mode 100755 index 63a5dc4bc..8ac0113ba --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/TunnelProvider.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/TunnelProvider.kt @@ -7,6 +7,7 @@ import com.zaneschepke.wireguardautotunnel.domain.events.BackendMessage import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConfig import com.zaneschepke.wireguardautotunnel.domain.state.LogHealthState import com.zaneschepke.wireguardautotunnel.domain.state.PingState +import com.zaneschepke.wireguardautotunnel.domain.state.TunnelRestartProgress import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState import com.zaneschepke.wireguardautotunnel.domain.state.TunnelStatistics import kotlinx.coroutines.flow.SharedFlow @@ -32,6 +33,7 @@ interface TunnelProvider { fun getStatistics(tunnelId: Int): TunnelStatistics? val activeTunnels: StateFlow> + val restartProgress: StateFlow> val errorEvents: SharedFlow> val messageEvents: SharedFlow> diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/handler/HandshakeRestartHandler.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/handler/HandshakeRestartHandler.kt new file mode 100755 index 000000000..1d7a1f789 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/handler/HandshakeRestartHandler.kt @@ -0,0 +1,482 @@ +package com.zaneschepke.wireguardautotunnel.core.tunnel.handler + +import com.zaneschepke.networkmonitor.ActiveNetwork +import com.zaneschepke.networkmonitor.NetworkMonitor +import com.zaneschepke.wireguardautotunnel.util.network.NetworkUtils +import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelStatus +import com.zaneschepke.wireguardautotunnel.domain.events.BackendMessage +import com.zaneschepke.wireguardautotunnel.data.model.MaxAttemptsAction +import com.zaneschepke.wireguardautotunnel.domain.repository.MonitoringSettingsRepository +import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository +import com.zaneschepke.wireguardautotunnel.domain.state.TunnelRestartProgress +import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState +import java.util.concurrent.ConcurrentHashMap +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.launch +import kotlinx.coroutines.plus +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import timber.log.Timber + +class HandshakeRestartHandler( + private val activeTunnels: StateFlow>, + private val tunnelsRepository: TunnelRepository, + private val monitoringSettingsRepository: MonitoringSettingsRepository, + private val localMessageEvents: MutableSharedFlow>, + private val restartTunnel: suspend (Int) -> Unit, + private val stopTunnel: suspend (Int) -> Unit, + private val networkMonitor: NetworkMonitor, + private val networkUtils: NetworkUtils, + private val applicationScope: CoroutineScope, + private val ioDispatcher: CoroutineDispatcher, +) { + private val mutex = Mutex() + private val jobs = ConcurrentHashMap() + + // Tracks restart timestamps per tunnel across job restarts for rate limiting + private val restartTimestamps = ConcurrentHashMap>() + + // Tracks per-tunnel restart progress (active restart or post-restart cooldown) + // and exposes it to the UI + private val _restartProgress = MutableStateFlow>(emptyMap()) + val restartProgress: StateFlow> = _restartProgress.asStateFlow() + + // Counts total restarts per tunnel since it was activated (reset on manual stop) + private val _restartCounts = MutableStateFlow>(emptyMap()) + val restartCounts: StateFlow> = _restartCounts.asStateFlow() + + // Tracks tunnels currently in a degraded state (stale handshake / ping failure detected) + // Used to emit ConnectionRestored / ConnectionPermanentlyLost events + private val degradedTunnels = ConcurrentHashMap() + + // Emits Unit after NETWORK_RECOVERY_GRACE_MS on any network interface change + // (Disconnected→WiFi/Cellular, WiFi→Cellular, Cellular→WiFi, etc.) + // — wakes up the monitoring loop early instead of waiting ~3.5 min for stale detection. + private val networkChangeFlow: Flow = + networkMonitor.connectivityStateFlow + .map { + when (it.activeNetwork) { + is ActiveNetwork.Disconnected -> null + is ActiveNetwork.Wifi -> "wifi" + is ActiveNetwork.Cellular -> "cellular" + is ActiveNetwork.Ethernet -> "ethernet" + } + } + .distinctUntilChanged() + .drop(1) // skip initial state at startup to avoid spurious trigger + .filterNotNull() // ignore disconnect events — only react when a network is available + .onEach { + Timber.d("Network interface changed ($it) — waiting ${NETWORK_RECOVERY_GRACE_MS}ms grace period") + delay(NETWORK_RECOVERY_GRACE_MS) + } + .map { } + + init { + applicationScope.launch(ioDispatcher) { + combine(activeTunnels, monitoringSettingsRepository.flow) { active, settings -> + active to settings + } + .collect { (activeTuns, settings) -> + mutex.withLock { + val activeIds = + if (settings.isRestartOnHandshakeTimeoutEnabled) { + activeTuns.keys.toSet() + } else { + emptySet() + } + + (jobs.keys - activeIds).forEach { id -> + if (_restartProgress.value.containsKey(id)) { + Timber.d( + "Skipping shutdown for tunnelId: $id (auto-restart in progress)" + ) + return@forEach + } + Timber.d( + "Shutting down handshake restart monitoring job for tunnelId: $id" + ) + jobs.remove(id)?.cancel() + } + + activeIds.forEach { id -> + if (jobs.containsKey(id)) return@forEach + // Reset count when a fresh monitoring job starts (covers the race where + // the previous coroutine incremented the count after cancelAndClear ran) + _restartCounts.update { it - id } + val tunStateFlow = + activeTunnels + .map { it[id] } + .stateIn(applicationScope + ioDispatcher) + Timber.d("Starting handshake restart monitoring job for tunnelId: $id") + jobs[id] = + applicationScope.launch(ioDispatcher) { + monitorHandshake(id, tunStateFlow) + } + } + } + } + } + } + + /** + * Called when a tunnel is manually stopped from the UI. + * Cancels any in-progress auto-restart and clears history so the rate limit resets. + */ + fun cancelAndClear(tunnelId: Int) { + Timber.d("Manual stop for tunnel $tunnelId — cancelling restart job and clearing history") + jobs.remove(tunnelId)?.cancel() + _restartProgress.update { it - tunnelId } + _restartCounts.update { it - tunnelId } + restartTimestamps.remove(tunnelId) + if (degradedTunnels.remove(tunnelId) != null) { + applicationScope.launch(ioDispatcher) { + localMessageEvents.emit(null to BackendMessage.ConnectionCancelled) + } + } + } + + /** + * Returns true if the tunnel state should trigger a restart: + * - Stale WireGuard handshake (no handshake in ~3.5 min) + * - Ping failure (ALL pinged peers are unreachable) + */ + private fun shouldTrigger(state: TunnelState, isPingMonitoringEnabled: Boolean = true): Boolean { + if (state.status !is TunnelStatus.Up) return false + if (state.statistics?.isTunnelStale() == true) return true + if (isPingMonitoringEnabled) { + state.pingStates?.let { pings -> + val attemptedPings = pings.values.filter { it.lastPingAttemptMillis != null } + if (attemptedPings.isNotEmpty() && attemptedPings.all { !it.isReachable }) return true + } + } + return false + } + + private fun triggerReason( + state: TunnelState, + isPingMonitoringEnabled: Boolean, + ): BackendMessage.RestartReason { + if (state.statistics?.isTunnelStale() == true) return BackendMessage.RestartReason.STALE_HANDSHAKE + if (isPingMonitoringEnabled) { + state.pingStates?.let { pings -> + val attempted = pings.values.filter { it.lastPingAttemptMillis != null } + if (attempted.isNotEmpty() && attempted.all { !it.isReachable }) + return BackendMessage.RestartReason.PING_FAILURE + } + } + return BackendMessage.RestartReason.STALE_HANDSHAKE + } + + private suspend fun monitorHandshake( + tunnelId: Int, + tunStateFlow: StateFlow, + ) { + // On fresh start (no restart history), wait for the tunnel to establish a healthy state + // before entering the monitoring loop. This prevents false-positive restarts triggered + // by stale WireGuard statistics that may be present when the tunnel first starts up + // (the kernel can retain old handshake timestamps until the new handshake completes). + if (restartTimestamps[tunnelId].isNullOrEmpty()) { + val initialSettings = monitoringSettingsRepository.getMonitoringSettings() + val graceMs = initialSettings.startupGraceSeconds * 1_000L + if (graceMs > 0) { + Timber.d("Fresh start: waiting for tunnel $tunnelId to establish healthy state (${graceMs}ms grace)") + withTimeoutOrNull(graceMs) { + tunStateFlow.filterNotNull().first { s -> + !shouldTrigger(s, initialSettings.isPingMonitoringEnabled) + } + } + } + } + + // Apply cooldown if we recently restarted this tunnel (e.g. job recreated after manual toggle) + restartTimestamps[tunnelId]?.lastOrNull()?.let { lastRestart -> + val settings = monitoringSettingsRepository.getMonitoringSettings() + val attemptsDone = restartTimestamps[tunnelId]?.size ?: 1 + val cooldownSec = computeCooldown(settings.restartCooldownSeconds, attemptsDone, settings.isBackoffEnabled) + val cooldownRemaining = (lastRestart + cooldownSec * 1_000L) - System.currentTimeMillis() + if (cooldownRemaining > 0) { + Timber.d( + "Cooldown active for tunnel $tunnelId, " + + "waiting ${cooldownRemaining}ms before monitoring" + ) + delay(cooldownRemaining) + } + } + + // Counts consecutive ping-failure intervals for the current failure streak. + // Resets to 0 when the tunnel becomes healthy or after a restart attempt. + var pingFailureStreak = 0 + + while (true) { + val state = tunStateFlow.value ?: break + val settings = monitoringSettingsRepository.getMonitoringSettings() + + if (!shouldTrigger(state, settings.isPingMonitoringEnabled)) { + // Tunnel healthy — emit ConnectionRestored if it was previously degraded + pingFailureStreak = 0 + if (degradedTunnels.remove(tunnelId) != null) { + val tunnelName = tunnelsRepository.getById(tunnelId)?.name + localMessageEvents.emit(tunnelName to BackendMessage.ConnectionRestored) + } + // Wait for either a trigger condition or a network reconnection event + merge( + tunStateFlow + .filter { it == null || shouldTrigger(it, settings.isPingMonitoringEnabled) } + .take(1) + .map { }, + networkChangeFlow.take(1), + ) + .first() + continue + } + + val reason = triggerReason(state, settings.isPingMonitoringEnabled) + + // For ping failures: require N consecutive failing intervals before restarting. + // Stale-handshake restarts are not subject to this threshold (WG already waits ~3.5 min). + if (reason == BackendMessage.RestartReason.PING_FAILURE) { + pingFailureStreak++ + if (pingFailureStreak < settings.pingFailuresBeforeRestart) { + Timber.d( + "Ping failure streak $pingFailureStreak/${settings.pingFailuresBeforeRestart} " + + "for tunnel $tunnelId — waiting for next ping cycle" + ) + // Wait for a NEW ping attempt (not just any state update like statistics). + // tunStateFlow emits on every stats poll (bytes, handshake time…), so using + // drop(1).first() would return almost immediately. We need to wait until + // lastPingAttemptMillis actually advances to a new cycle. + // + // Re-read from tunStateFlow.value immediately before first { } so the + // StateFlow replay always matches our baseline (no suspend point between + // the two reads → no concurrent update possible in this coroutine). + val currentPingTime = tunStateFlow.value?.pingStates + ?.values + ?.mapNotNull { it.lastPingAttemptMillis } + ?.maxOrNull() + tunStateFlow.first { newState -> + // Also break out if the tunnel is no longer triggering (recovered, + // or ping was disabled so pingStates are cleared). + if (newState == null || !shouldTrigger(newState, settings.isPingMonitoringEnabled)) return@first true + val newPingTime = newState.pingStates + ?.values + ?.mapNotNull { it.lastPingAttemptMillis } + ?.maxOrNull() + newPingTime != null && newPingTime != currentPingTime + } + continue + } + pingFailureStreak = 0 + } + + val now = System.currentTimeMillis() + val timestamps = restartTimestamps.getOrPut(tunnelId) { ArrayDeque() } + + val shouldGiveUp = + if (settings.isBackoffEnabled) { + // Count-based with exponential backoff: give up after backoffMaxAttempts + timestamps.size >= settings.backoffMaxAttempts + } else { + // Count-based: prune old timestamps, give up after maxHandshakeRestartAttempts in 1h + while (timestamps.isNotEmpty() && timestamps.first() < now - ONE_HOUR_MS) { + timestamps.removeFirst() + } + timestamps.size >= settings.maxHandshakeRestartAttempts + } + + if (shouldGiveUp) { + val totalAttempts = timestamps.size + if (settings.isBackoffEnabled) { + Timber.w( + "Backoff max attempts (${settings.backoffMaxAttempts}) reached " + + "for tunnel $tunnelId after $totalAttempts attempts — waiting for recovery" + ) + } else { + Timber.w( + "Max restart attempts (${settings.maxHandshakeRestartAttempts}) reached " + + "for tunnel $tunnelId within the last hour — waiting for recovery" + ) + } + val permanentReason = degradedTunnels[tunnelId] ?: reason + val tunnelName = tunnelsRepository.getById(tunnelId)?.name + val isTunnelStopped = settings.maxAttemptsAction == MaxAttemptsAction.STOP_TUNNEL + localMessageEvents.emit( + tunnelName to BackendMessage.ConnectionPermanentlyLost( + permanentReason, + totalAttempts, + isTunnelStopped = isTunnelStopped, + ) + ) + when (settings.maxAttemptsAction) { + MaxAttemptsAction.DO_NOTHING -> { + // Wait until healthy again or tunnel goes down + tunStateFlow.first { + it == null || (it.status is TunnelStatus.Up && !shouldTrigger(it, settings.isPingMonitoringEnabled)) + } + restartTimestamps.remove(tunnelId) + _restartProgress.update { it - tunnelId } + if (degradedTunnels.remove(tunnelId) != null) { + val tunnelNameRestored = tunnelsRepository.getById(tunnelId)?.name + localMessageEvents.emit(tunnelNameRestored to BackendMessage.ConnectionRestored) + } + continue + } + MaxAttemptsAction.STOP_TUNNEL -> { + // Clear degradedTunnels before stopping so cancelAndClear won't emit + // ConnectionCancelled — the "max restarts reached" notification stays visible + degradedTunnels.remove(tunnelId) + runCatching { stopTunnel(tunnelId) }.onFailure { e -> + if (e is CancellationException) throw e + Timber.e(e, "Failed to stop tunnel $tunnelId after max restart attempts") + } + return + } + } + } + // Pre-restart verification: ping each known target to confirm the tunnel is truly down. + // If any target is reachable, the tunnel has recovered — skip restart and reset streak. + // Conditioned on isPingEnabled (not isPingMonitoringEnabled): the user may have pings + // active without using them to trigger restarts — verification is still meaningful. + // If isPingEnabled = false, pingStates = null → targets empty → block skipped anyway. + if (settings.isPingEnabled) { + val targets = tunStateFlow.value?.pingStates?.values?.map { it.pingTarget }.orEmpty() + if (targets.isNotEmpty()) { + Timber.d("Pre-restart verification: pinging ${targets.size} target(s) for tunnel $tunnelId") + val anyReachable = targets.any { target -> + runCatching { + networkUtils.pingWithStats(target, settings.tunnelPingAttempts).isReachable + }.getOrDefault(false) + } + if (anyReachable) { + Timber.d("Pre-restart verification: tunnel $tunnelId is reachable — skipping restart, resetting streak") + pingFailureStreak = 0 + continue + } + Timber.d("Pre-restart verification: tunnel $tunnelId confirmed down — proceeding with restart") + } + } + + val attempt = timestamps.size + 1 + val effectiveMaxAttempts = + if (settings.isBackoffEnabled) settings.backoffMaxAttempts + else settings.maxHandshakeRestartAttempts + val maxAttemptsLabel = + if (settings.isBackoffEnabled) "backoff max ${settings.backoffMaxAttempts}" + else "max ${settings.maxHandshakeRestartAttempts}" + Timber.i("Auto-restarting tunnel $tunnelId due to $reason (attempt $attempt, $maxAttemptsLabel)") + + val failingTargets = + if (reason == BackendMessage.RestartReason.PING_FAILURE) { + state.pingStates + ?.filter { (_, ping) -> ping.lastPingAttemptMillis != null && !ping.isReachable } + ?.values + ?.map { it.pingTarget } + ?: emptyList() + } else emptyList() + + _restartProgress.update { + it + + (tunnelId to + TunnelRestartProgress( + isRestarting = true, + attemptNumber = attempt, + maxAttempts = effectiveMaxAttempts, + reason = reason, + failingPingTargets = failingTargets, + )) + } + // Record the attempt before restarting so the rate limit applies even if the restart fails + timestamps.addLast(now) + _restartCounts.update { it + (tunnelId to (it.getOrDefault(tunnelId, 0) + 1)) } + val tunnelName = tunnelsRepository.getById(tunnelId)?.name + degradedTunnels[tunnelId] = reason + localMessageEvents.emit( + tunnelName to BackendMessage.ConnectionDegrading(reason, attempt, effectiveMaxAttempts) + ) + runCatching { + restartTunnel(tunnelId) + } + .onFailure { e -> + if (e is CancellationException) throw e + Timber.e(e, "Failed to restart tunnel $tunnelId after $reason") + } + // Post-restart cooldown: wait remaining time before next check + val cooldownSec = computeCooldown(settings.restartCooldownSeconds, attempt, settings.isBackoffEnabled) + val cooldownEnd = now + cooldownSec * 1_000L + val cooldownRemaining = cooldownEnd - System.currentTimeMillis() + _restartProgress.update { + it + + (tunnelId to + TunnelRestartProgress( + isRestarting = false, + attemptNumber = attempt, + maxAttempts = effectiveMaxAttempts, + nextRetryAtMillis = if (cooldownRemaining > 0) cooldownEnd else 0L, + reason = reason, + failingPingTargets = failingTargets, + )) + } + if (cooldownRemaining > 0) { + Timber.d("Post-restart cooldown for tunnel $tunnelId: waiting ${cooldownRemaining}ms") + // Cancel early if the tunnel becomes healthy before the cooldown expires + // (pingStates != null ensures at least one ping cycle has completed post-restart) + withTimeoutOrNull(cooldownRemaining) { + tunStateFlow.filterNotNull().first { s -> + s.pingStates != null && !shouldTrigger(s, settings.isPingMonitoringEnabled) + } + } + } + _restartProgress.update { it - tunnelId } + + // Post-restart grace: mirrors the startup grace at the top of monitorHandshake. + // Prevents false-positive restart loops when cooldown < WireGuard re-handshake time. + // isTunnelStale() is always checked first in shouldTrigger and WireGuard can retain + // stale stats until a fresh handshake completes, so we wait just like on fresh start. + val postRestartGraceMs = settings.startupGraceSeconds * 1_000L + if (postRestartGraceMs > 0) { + withTimeoutOrNull(postRestartGraceMs) { + tunStateFlow.filterNotNull().first { s -> + !shouldTrigger(s, settings.isPingMonitoringEnabled) + } + } + } + } + } + + companion object { + const val ONE_HOUR_MS = 3_600_000L + const val NETWORK_RECOVERY_GRACE_MS = 10_000L + + /** + * Returns the cooldown in seconds for the given attempt number. + * With backoff: base × 2^(attempt-1), unbounded (give-up is handled by backoffTimeoutMinutes). + * Without backoff: always returns base. + */ + fun computeCooldown(baseSec: Int, attempt: Int, backoffEnabled: Boolean): Long { + if (!backoffEnabled) return baseSec.toLong() + val shift = (attempt - 1).coerceAtMost(30) // guard against Long overflow + return baseSec.toLong() shl shift + } + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/AppDatabase.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/AppDatabase.kt old mode 100644 new mode 100755 index 29413f5d4..c96860d1d --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/AppDatabase.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/AppDatabase.kt @@ -17,7 +17,7 @@ import com.zaneschepke.wireguardautotunnel.data.entity.* DnsSettings::class, LockdownSettings::class, ], - version = 29, + version = 35, autoMigrations = [ AutoMigration(from = 1, to = 2), @@ -45,6 +45,12 @@ import com.zaneschepke.wireguardautotunnel.data.entity.* AutoMigration(from = 24, to = 25), AutoMigration(from = 26, to = 27, spec = GlobalsMigration::class), AutoMigration(from = 27, to = 28, spec = DonationMigration::class), + AutoMigration(from = 29, to = 30), + AutoMigration(from = 30, to = 31), + AutoMigration(from = 31, to = 32), + AutoMigration(from = 32, to = 33), + AutoMigration(from = 33, to = 34), + AutoMigration(from = 34, to = 35), ], exportSchema = true, ) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/DatabaseConverters.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/DatabaseConverters.kt old mode 100644 new mode 100755 index 5bb0ddeaf..461cbfcfc --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/DatabaseConverters.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/DatabaseConverters.kt @@ -3,6 +3,7 @@ package com.zaneschepke.wireguardautotunnel.data import androidx.room.TypeConverter import com.zaneschepke.wireguardautotunnel.data.model.AppMode import com.zaneschepke.wireguardautotunnel.data.model.DnsProtocol +import com.zaneschepke.wireguardautotunnel.data.model.MaxAttemptsAction import com.zaneschepke.wireguardautotunnel.data.model.WifiDetectionMethod import kotlinx.serialization.json.Json @@ -64,4 +65,8 @@ class DatabaseConverters { @TypeConverter fun toDnsProtocol(value: Int): DnsProtocol = DnsProtocol.fromValue(value) @TypeConverter fun fromDnsProtocol(mode: DnsProtocol): Int = mode.value + + @TypeConverter fun toMaxAttemptsAction(value: Int): MaxAttemptsAction = MaxAttemptsAction.fromValue(value) + + @TypeConverter fun fromMaxAttemptsAction(action: MaxAttemptsAction): Int = action.value } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/entity/MonitoringSettings.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/entity/MonitoringSettings.kt old mode 100644 new mode 100755 index db97e3d7f..859bc36c2 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/entity/MonitoringSettings.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/entity/MonitoringSettings.kt @@ -3,6 +3,7 @@ package com.zaneschepke.wireguardautotunnel.data.entity import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey +import com.zaneschepke.wireguardautotunnel.data.model.MaxAttemptsAction @Entity(tableName = "monitoring_settings") data class MonitoringSettings( @@ -18,4 +19,22 @@ data class MonitoringSettings( val showDetailedPingStats: Boolean = false, @ColumnInfo(name = "is_local_logs_enabled", defaultValue = "0") val isLocalLogsEnabled: Boolean = false, + @ColumnInfo(name = "is_restart_on_handshake_timeout_enabled", defaultValue = "0") + val isRestartOnHandshakeTimeoutEnabled: Boolean = false, + @ColumnInfo(name = "max_handshake_restart_attempts", defaultValue = "5") + val maxHandshakeRestartAttempts: Int = 5, + @ColumnInfo(name = "restart_cooldown_seconds", defaultValue = "30") + val restartCooldownSeconds: Int = 30, + @ColumnInfo(name = "is_recovery_notification_enabled", defaultValue = "1") + val isRecoveryNotificationEnabled: Boolean = true, + @ColumnInfo(name = "max_attempts_action", defaultValue = "0") + val maxAttemptsAction: MaxAttemptsAction = MaxAttemptsAction.DO_NOTHING, + @ColumnInfo(name = "ping_failures_before_restart", defaultValue = "1") + val pingFailuresBeforeRestart: Int = 1, + @ColumnInfo(name = "is_backoff_enabled", defaultValue = "0") + val isBackoffEnabled: Boolean = false, + @ColumnInfo(name = "backoff_max_attempts", defaultValue = "3") + val backoffMaxAttempts: Int = 3, + @ColumnInfo(name = "startup_grace_seconds", defaultValue = "30") + val startupGraceSeconds: Int = 30, ) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/mapper/MonitoringSettingsMapper.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/mapper/MonitoringSettingsMapper.kt old mode 100644 new mode 100755 index 35fc8f7e8..5302e23b6 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/mapper/MonitoringSettingsMapper.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/mapper/MonitoringSettingsMapper.kt @@ -13,6 +13,15 @@ fun Entity.toDomain(): Domain = tunnelPingTimeoutSeconds = tunnelPingTimeoutSeconds, showDetailedPingStats = showDetailedPingStats, isLocalLogsEnabled = isLocalLogsEnabled, + isRestartOnHandshakeTimeoutEnabled = isRestartOnHandshakeTimeoutEnabled, + maxHandshakeRestartAttempts = maxHandshakeRestartAttempts, + restartCooldownSeconds = restartCooldownSeconds, + isRecoveryNotificationEnabled = isRecoveryNotificationEnabled, + maxAttemptsAction = maxAttemptsAction, + pingFailuresBeforeRestart = pingFailuresBeforeRestart, + isBackoffEnabled = isBackoffEnabled, + backoffMaxAttempts = backoffMaxAttempts, + startupGraceSeconds = startupGraceSeconds, ) fun Domain.toEntity(): Entity = @@ -25,4 +34,13 @@ fun Domain.toEntity(): Entity = tunnelPingTimeoutSeconds = tunnelPingTimeoutSeconds, showDetailedPingStats = showDetailedPingStats, isLocalLogsEnabled = isLocalLogsEnabled, + isRestartOnHandshakeTimeoutEnabled = isRestartOnHandshakeTimeoutEnabled, + maxHandshakeRestartAttempts = maxHandshakeRestartAttempts, + restartCooldownSeconds = restartCooldownSeconds, + isRecoveryNotificationEnabled = isRecoveryNotificationEnabled, + maxAttemptsAction = maxAttemptsAction, + pingFailuresBeforeRestart = pingFailuresBeforeRestart, + isBackoffEnabled = isBackoffEnabled, + backoffMaxAttempts = backoffMaxAttempts, + startupGraceSeconds = startupGraceSeconds, ) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/model/MaxAttemptsAction.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/model/MaxAttemptsAction.kt new file mode 100644 index 000000000..a31657de4 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/model/MaxAttemptsAction.kt @@ -0,0 +1,10 @@ +package com.zaneschepke.wireguardautotunnel.data.model + +enum class MaxAttemptsAction(val value: Int) { + DO_NOTHING(0), + STOP_TUNNEL(1); + + companion object { + fun fromValue(value: Int): MaxAttemptsAction = entries.find { it.value == value } ?: DO_NOTHING + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/events/BackendMessage.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/events/BackendMessage.kt old mode 100644 new mode 100755 index 92ee21b32..d749819eb --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/events/BackendMessage.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/events/BackendMessage.kt @@ -5,12 +5,21 @@ import com.zaneschepke.wireguardautotunnel.util.StringValue sealed class BackendMessage { + enum class RestartReason { STALE_HANDSHAKE, PING_FAILURE } + data object DynamicDnsSuccess : BackendMessage() - fun toStringRes() = + data class ConnectionDegrading(val reason: RestartReason, val attempt: Int, val maxAttempts: Int) : BackendMessage() + + data object ConnectionRestored : BackendMessage() + + data class ConnectionPermanentlyLost(val reason: RestartReason, val totalAttempts: Int, val isTunnelStopped: Boolean = false) : BackendMessage() + + data object ConnectionCancelled : BackendMessage() + + fun toStringValue(): StringValue? = when (this) { - DynamicDnsSuccess -> R.string.ddns_success_message + DynamicDnsSuccess -> StringValue.StringResource(R.string.ddns_success_message) + else -> null } - - fun toStringValue() = StringValue.StringResource(this.toStringRes()) } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/model/MonitoringSettings.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/model/MonitoringSettings.kt old mode 100644 new mode 100755 index 627df646b..23ed88fc1 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/model/MonitoringSettings.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/model/MonitoringSettings.kt @@ -1,5 +1,7 @@ package com.zaneschepke.wireguardautotunnel.domain.model +import com.zaneschepke.wireguardautotunnel.data.model.MaxAttemptsAction + data class MonitoringSettings( val id: Int = 0, val isPingEnabled: Boolean = false, @@ -9,4 +11,13 @@ data class MonitoringSettings( val tunnelPingTimeoutSeconds: Int? = null, val showDetailedPingStats: Boolean = false, val isLocalLogsEnabled: Boolean = false, + val isRestartOnHandshakeTimeoutEnabled: Boolean = false, + val maxHandshakeRestartAttempts: Int = 5, + val restartCooldownSeconds: Int = 30, + val isRecoveryNotificationEnabled: Boolean = true, + val maxAttemptsAction: MaxAttemptsAction = MaxAttemptsAction.DO_NOTHING, + val pingFailuresBeforeRestart: Int = 1, + val isBackoffEnabled: Boolean = false, + val backoffMaxAttempts: Int = 3, + val startupGraceSeconds: Int = 30, ) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/state/TunnelRestartProgress.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/state/TunnelRestartProgress.kt new file mode 100755 index 000000000..a54511926 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/state/TunnelRestartProgress.kt @@ -0,0 +1,14 @@ +package com.zaneschepke.wireguardautotunnel.domain.state + +import com.zaneschepke.wireguardautotunnel.domain.events.BackendMessage + +data class TunnelRestartProgress( + val isRestarting: Boolean = false, + val attemptNumber: Int = 0, + val maxAttempts: Int = 0, + // Epoch ms when the post-restart cooldown ends; 0 = no pending cooldown + val nextRetryAtMillis: Long = 0L, + val reason: BackendMessage.RestartReason? = null, + // Non-empty when reason == PING_FAILURE, lists the unreachable targets + val failingPingTargets: List = emptyList(), +) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/dropdown/LabelledNumberDropdown.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/dropdown/LabelledNumberDropdown.kt old mode 100644 new mode 100755 index 2d7adba55..f3d0122ce --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/dropdown/LabelledNumberDropdown.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/dropdown/LabelledNumberDropdown.kt @@ -8,6 +8,7 @@ fun LabelledDropdown( title: String, description: (@Composable () -> Unit)? = null, leading: @Composable () -> Unit, + enabled: Boolean = true, onSelected: (T?) -> Unit, options: List, currentValue: T?, @@ -19,6 +20,7 @@ fun LabelledDropdown( leading = leading, title = title, description = description, + enabled = enabled, onClick = { isDropDownExpanded = true }, trailing = { DropdownSelector( diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/navigation/Route.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/navigation/Route.kt old mode 100644 new mode 100755 index 95bf8aa85..e89323785 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/navigation/Route.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/navigation/Route.kt @@ -75,6 +75,8 @@ sealed class Route : NavKey { @Keep @Serializable data class PreferredTunnel(val tunnelNetwork: TunnelNetwork) : Route() @Keep @Serializable data object PingTarget : Route() + + @Keep @Serializable data object AutoRestart : Route() } @Serializable @@ -128,6 +130,7 @@ enum class Tab( Route.Language, Route.Display, Route.PingTarget, + Route.AutoRestart, is Route.ConfigGlobal, Route.Logs -> SETTINGS is Route.Support, diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/navigation/components/currentBackStackEntryAsNavbarState.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/navigation/components/currentBackStackEntryAsNavbarState.kt old mode 100644 new mode 100755 index 642f3e2dd..e2f9479f1 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/navigation/components/currentBackStackEntryAsNavbarState.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/navigation/components/currentBackStackEntryAsNavbarState.kt @@ -542,6 +542,19 @@ fun currentRouteAsNavbarState( topTitle = context.getString(R.string.ping_target), showBottomItems = true, ) + AutoRestart -> + NavbarState( + topLeading = { + IconButton(onClick = { navController.pop() }) { + Icon( + Icons.AutoMirrored.Rounded.ArrowBack, + stringResource(R.string.back), + ) + } + }, + topTitle = context.getString(R.string.auto_restart), + showBottomItems = true, + ) null -> NavbarState() } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/SettingsScreen.kt old mode 100644 new mode 100755 index f225940fc..d1d49abd4 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/SettingsScreen.kt @@ -13,6 +13,7 @@ import androidx.compose.material.icons.outlined.Android import androidx.compose.material.icons.outlined.Dns import androidx.compose.material.icons.outlined.ExpandMore import androidx.compose.material.icons.outlined.NetworkPing +import androidx.compose.material.icons.outlined.RestartAlt import androidx.compose.material.icons.outlined.Pin import androidx.compose.material.icons.outlined.SettingsBackupRestore import androidx.compose.material.icons.outlined.ViewHeadline @@ -237,6 +238,18 @@ fun SettingsScreen( }, onClick = { navController.push(Route.TunnelMonitoring) }, ) + SurfaceRow( + leading = { Icon(Icons.Outlined.RestartAlt, contentDescription = null) }, + title = stringResource(R.string.auto_restart), + trailing = { modifier -> + SwitchWithDivider( + checked = uiState.monitoring.isRestartOnHandshakeTimeoutEnabled, + onClick = { viewModel.setRestartOnHandshakeTimeout(it) }, + modifier = modifier, + ) + }, + onClick = { navController.push(Route.AutoRestart) }, + ) SurfaceRow( leading = { Icon(Icons.Outlined.ViewHeadline, contentDescription = null) }, title = stringResource(R.string.local_logging), diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/monitoring/TunnelMonitoringScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/monitoring/TunnelMonitoringScreen.kt old mode 100644 new mode 100755 diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/monitoring/autorestart/AutoRestartScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/monitoring/autorestart/AutoRestartScreen.kt new file mode 100755 index 000000000..a3b752a2a --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/monitoring/autorestart/AutoRestartScreen.kt @@ -0,0 +1,223 @@ +package com.zaneschepke.wireguardautotunnel.ui.screens.settings.monitoring.autorestart + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Adjust +import androidx.compose.material.icons.outlined.FilterAlt +import androidx.compose.material.icons.outlined.HourglassBottom +import androidx.compose.material.icons.outlined.HourglassTop +import androidx.compose.material.icons.outlined.Notifications +import androidx.compose.material.icons.outlined.PowerSettingsNew +import androidx.compose.material.icons.outlined.Replay +import androidx.compose.material.icons.outlined.Timer +import androidx.compose.material.icons.outlined.TrendingUp +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import com.zaneschepke.wireguardautotunnel.ui.theme.Disabled +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.data.model.MaxAttemptsAction +import com.zaneschepke.wireguardautotunnel.ui.common.button.SurfaceRow +import com.zaneschepke.wireguardautotunnel.ui.common.button.SwitchWithDivider +import com.zaneschepke.wireguardautotunnel.ui.common.button.ThemedSwitch +import com.zaneschepke.wireguardautotunnel.ui.common.dropdown.LabelledDropdown +import com.zaneschepke.wireguardautotunnel.ui.common.label.GroupLabel +import com.zaneschepke.wireguardautotunnel.viewmodel.MonitoringViewModel +import org.koin.androidx.compose.koinViewModel + +@Composable +fun AutoRestartScreen(viewModel: MonitoringViewModel = koinViewModel()) { + val uiState by viewModel.container.stateFlow.collectAsStateWithLifecycle() + + if (uiState.isLoading) return + + Column( + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.Top), + modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()), + ) { + Column { + GroupLabel( + stringResource(R.string.auto_restart), + modifier = Modifier.padding(horizontal = 16.dp), + ) + SurfaceRow( + enabled = uiState.monitoringSettings.isPingEnabled, + leading = { + Icon( + Icons.Outlined.Adjust, + contentDescription = null, + tint = if (uiState.monitoringSettings.isPingEnabled) MaterialTheme.colorScheme.onSurface else Disabled, + ) + }, + title = stringResource(R.string.use_ping_for_detection), + trailing = { + ThemedSwitch( + checked = uiState.monitoringSettings.isPingMonitoringEnabled, + onClick = { viewModel.setPingMonitoringEnabled(it) }, + enabled = uiState.monitoringSettings.isPingEnabled, + ) + }, + onClick = { + viewModel.setPingMonitoringEnabled( + !uiState.monitoringSettings.isPingMonitoringEnabled + ) + }, + ) + if (uiState.monitoringSettings.isPingEnabled && uiState.monitoringSettings.isPingMonitoringEnabled) { + LabelledDropdown( + title = stringResource(R.string.ping_failures_before_restart), + leading = { Icon(Icons.Outlined.FilterAlt, contentDescription = null) }, + currentValue = uiState.monitoringSettings.pingFailuresBeforeRestart, + onSelected = { selected -> + selected?.let { viewModel.setPingFailuresBeforeRestart(it) } + }, + options = listOf(1, 2, 3, 4, 5), + optionToString = { it?.toString() ?: stringResource(R.string._default) }, + ) + } + LabelledDropdown( + title = stringResource(R.string.restart_cooldown), + leading = { Icon(Icons.Outlined.Timer, contentDescription = null) }, + currentValue = uiState.monitoringSettings.restartCooldownSeconds, + onSelected = { selected -> + selected?.let { viewModel.setRestartCooldownSeconds(it) } + }, + options = listOf(5, 10, 15, 30, 60, 120, 300), + optionToString = { it?.let { "${it}s" } ?: stringResource(R.string._default) }, + ) + LabelledDropdown( + title = stringResource(R.string.startup_grace), + leading = { Icon(Icons.Outlined.HourglassTop, contentDescription = null) }, + currentValue = uiState.monitoringSettings.startupGraceSeconds, + onSelected = { selected -> selected?.let { viewModel.setStartupGraceSeconds(it) } }, + options = listOf(0, 5, 10, 15, 30, 60), + optionToString = { + when (it) { + null -> stringResource(R.string._default) + 0 -> stringResource(R.string.disabled) + else -> "${it}s" + } + }, + ) + SurfaceRow( + leading = { Icon(Icons.Outlined.TrendingUp, contentDescription = null) }, + title = stringResource(R.string.exponential_backoff), + description = { + Text( + text = stringResource(R.string.exponential_backoff_description), + style = + MaterialTheme.typography.bodySmall.copy( + color = MaterialTheme.colorScheme.outline + ), + ) + }, + trailing = { + ThemedSwitch( + checked = uiState.monitoringSettings.isBackoffEnabled, + onClick = { viewModel.setBackoffEnabled(it) }, + ) + }, + onClick = { + viewModel.setBackoffEnabled(!uiState.monitoringSettings.isBackoffEnabled) + }, + ) + if (uiState.monitoringSettings.isBackoffEnabled) { + LabelledDropdown( + title = stringResource(R.string.backoff_timeout), + leading = { Icon(Icons.Outlined.HourglassBottom, contentDescription = null) }, + currentValue = uiState.monitoringSettings.backoffMaxAttempts, + onSelected = { selected -> + selected?.let { viewModel.setBackoffMaxAttempts(it) } + }, + options = listOf(2, 3, 4, 5, 6, 7), + optionToString = { n -> + if (n == null) return@LabelledDropdown stringResource(R.string._default) + val baseSec = uiState.monitoringSettings.restartCooldownSeconds.toLong() + val totalSec = baseSec * ((1L shl (n - 1)) - 1) + val display = when { + totalSec < 60 -> "${totalSec}s" + totalSec < 3600 -> { + val m = totalSec / 60 + val s = totalSec % 60 + if (s == 0L) "${m}m" else "${m}m${s}s" + } + else -> { + val h = totalSec / 3600 + val m = (totalSec % 3600) / 60 + if (m == 0L) "${h}h" else "${h}h${m}m" + } + } + "$n attempts (~$display)" + }, + ) + } else { + LabelledDropdown( + title = stringResource(R.string.max_handshake_restart_attempts), + leading = { Icon(Icons.Outlined.Replay, contentDescription = null) }, + description = { + Text( + text = stringResource(R.string.max_handshake_restart_attempts_description), + style = + MaterialTheme.typography.bodySmall.copy( + color = MaterialTheme.colorScheme.outline + ), + ) + }, + currentValue = uiState.monitoringSettings.maxHandshakeRestartAttempts, + onSelected = { selected -> + selected?.let { viewModel.setMaxHandshakeRestartAttempts(it) } + }, + options = listOf(3, 5, 10, 20), + optionToString = { it?.toString() ?: stringResource(R.string._default) }, + ) + } + LabelledDropdown( + title = stringResource(R.string.max_attempts_action), + leading = { Icon(Icons.Outlined.PowerSettingsNew, contentDescription = null) }, + currentValue = uiState.monitoringSettings.maxAttemptsAction, + onSelected = { selected -> selected?.let { viewModel.setMaxAttemptsAction(it) } }, + options = MaxAttemptsAction.entries.toList(), + optionToString = { action -> + when (action) { + MaxAttemptsAction.DO_NOTHING -> stringResource(R.string.max_attempts_action_do_nothing) + MaxAttemptsAction.STOP_TUNNEL -> stringResource(R.string.max_attempts_action_stop_tunnel) + null -> stringResource(R.string._default) + } + }, + ) + SurfaceRow( + leading = { Icon(Icons.Outlined.Notifications, contentDescription = null) }, + title = stringResource(R.string.recovery_notifications), + description = { + Text( + text = stringResource(R.string.recovery_notifications_description), + style = + MaterialTheme.typography.bodySmall.copy( + color = MaterialTheme.colorScheme.outline + ), + ) + }, + trailing = { modifier -> + SwitchWithDivider( + checked = uiState.monitoringSettings.isRecoveryNotificationEnabled, + onClick = { viewModel.setRecoveryNotificationEnabled(it) }, + modifier = modifier, + ) + }, + ) + } + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/components/TunnelList.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/components/TunnelList.kt old mode 100644 new mode 100755 index c2fd53498..a04236b66 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/components/TunnelList.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/components/TunnelList.kt @@ -12,7 +12,10 @@ import androidx.compose.foundation.rememberOverscrollEffect import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Circle import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -24,6 +27,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.domain.events.BackendMessage import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState import com.zaneschepke.wireguardautotunnel.ui.LocalNavController import com.zaneschepke.wireguardautotunnel.ui.common.button.SurfaceRow @@ -33,6 +37,7 @@ import com.zaneschepke.wireguardautotunnel.ui.state.TunnelsUiState import com.zaneschepke.wireguardautotunnel.util.extensions.asColor import com.zaneschepke.wireguardautotunnel.util.extensions.openWebUrl import com.zaneschepke.wireguardautotunnel.viewmodel.SharedAppViewModel +import kotlinx.coroutines.delay @OptIn(ExperimentalFoundationApi::class) @Composable @@ -75,19 +80,28 @@ fun TunnelList( remember(uiState.activeTunnels) { uiState.activeTunnels[tunnel.id] ?: TunnelState() } + val restartProgress = + remember(uiState.restartProgress) { uiState.restartProgress[tunnel.id] } val selected = remember(uiState.selectedTunnels) { uiState.selectedTunnels.any { it.id == tunnel.id } } - var leadingIconColor by - remember( - tunnelState.status, - tunnelState.logHealthState, - tunnelState.pingStates, - tunnelState.statistics, - ) { - mutableStateOf(tunnelState.health().asColor()) - } + + // Freeze the health color for the entire restart+cooldown cycle so the + // indicator keeps the trigger color (yellow/red) instead of going gray when + // the tunnel momentarily drops during the stop/start sequence. + var frozenHealthColor by remember(tunnel.id) { + mutableStateOf(tunnelState.health().asColor()) + } + LaunchedEffect( + restartProgress, + tunnelState.status, + tunnelState.logHealthState, + tunnelState.pingStates, + tunnelState.statistics, + ) { + if (restartProgress == null) frozenHealthColor = tunnelState.health().asColor() + } SurfaceRow( modifier = Modifier.animateItem(), @@ -95,11 +109,96 @@ fun TunnelList( Icon( Icons.Rounded.Circle, contentDescription = stringResource(R.string.tunnel_monitoring), - tint = leadingIconColor, + tint = frozenHealthColor, modifier = Modifier.size(14.dp), ) }, title = tunnel.name, + description = + if (restartProgress != null) { + { + // Countdown towards next retry (only shown during cooldown) + var secondsRemaining by + remember(restartProgress.nextRetryAtMillis) { + val ms = + restartProgress.nextRetryAtMillis - + System.currentTimeMillis() + mutableStateOf(if (ms > 0) (ms / 1000).toInt() else 0) + } + LaunchedEffect(restartProgress.nextRetryAtMillis) { + while (secondsRemaining > 0) { + delay(1000) + val ms = + restartProgress.nextRetryAtMillis - + System.currentTimeMillis() + secondsRemaining = if (ms > 0) (ms / 1000).toInt() else 0 + } + } + + val reasonText = + when (restartProgress.reason) { + BackendMessage.RestartReason.STALE_HANDSHAKE -> + stringResource(R.string.restart_reason_stale_handshake) + BackendMessage.RestartReason.PING_FAILURE -> { + val targets = restartProgress.failingPingTargets + if (targets.isNotEmpty()) { + stringResource( + R.string.restart_reason_ping_failure_targets, + targets.joinToString(", "), + ) + } else { + stringResource(R.string.restart_reason_ping_failure) + } + } + null -> null + } + + val statusText: String? = + when { + restartProgress.isRestarting -> + stringResource( + R.string.restarting_attempt, + restartProgress.attemptNumber, + restartProgress.maxAttempts, + ) + secondsRemaining > 0 -> + stringResource( + R.string.restart_cooldown_countdown, + restartProgress.attemptNumber, + restartProgress.maxAttempts, + secondsRemaining, + ) + restartProgress.attemptNumber >= restartProgress.maxAttempts -> + stringResource( + R.string.restart_max_reached, + restartProgress.attemptNumber, + restartProgress.maxAttempts, + ) + // Transient gap between countdown hitting 0 and handler clearing + // _restartProgress — don't show anything + else -> null + } + + val displayText = + when { + reasonText != null && statusText != null -> + "$reasonText · $statusText" + statusText != null -> statusText + reasonText != null -> reasonText + else -> null + } + + if (displayText != null) { + Text( + text = displayText, + style = + MaterialTheme.typography.bodySmall.copy( + color = MaterialTheme.colorScheme.outline + ), + ) + } + } + } else null, onClick = { if (uiState.selectedTunnels.isNotEmpty()) { viewModel.toggleSelectedTunnel(tunnel.id) @@ -110,20 +209,21 @@ fun TunnelList( }, selected = selected, expandedContent = - if (!tunnelState.status.isDown()) { + if (!tunnelState.status.isDown() || restartProgress != null) { { TunnelStatisticsRow( tunnel, tunnelState, uiState.isPingEnabled, uiState.showPingStats, + restartCount = uiState.restartCounts[tunnel.id] ?: 0, ) } } else null, onLongClick = { viewModel.toggleSelectedTunnel(tunnel.id) }, trailing = { modifier -> SwitchWithDivider( - checked = tunnelState.status.isUpOrStarting(), + checked = tunnelState.status.isUpOrStarting() || restartProgress != null, onClick = { checked -> if (checked) viewModel.startTunnel(tunnel) else viewModel.stopTunnel(tunnel) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/TunnelsUiState.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/TunnelsUiState.kt old mode 100644 new mode 100755 index 1b6ead927..431c2a3e6 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/TunnelsUiState.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/TunnelsUiState.kt @@ -1,6 +1,7 @@ package com.zaneschepke.wireguardautotunnel.ui.state import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConfig +import com.zaneschepke.wireguardautotunnel.domain.state.TunnelRestartProgress import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState data class TunnelsUiState( @@ -9,5 +10,6 @@ data class TunnelsUiState( val selectedTunnels: List = emptyList(), val isPingEnabled: Boolean = false, val showPingStats: Boolean = false, + val restartProgress: Map = emptyMap(), val isLoading: Boolean = true, ) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/MonitoringViewModel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/MonitoringViewModel.kt old mode 100644 new mode 100755 index a160685de..7e662e7e8 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/MonitoringViewModel.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/MonitoringViewModel.kt @@ -1,6 +1,7 @@ package com.zaneschepke.wireguardautotunnel.viewmodel import androidx.lifecycle.ViewModel +import com.zaneschepke.wireguardautotunnel.data.model.MaxAttemptsAction import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConfig import com.zaneschepke.wireguardautotunnel.domain.repository.MonitoringSettingsRepository import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository @@ -11,7 +12,6 @@ import org.orbitmvi.orbit.viewmodel.container class MonitoringViewModel( private val monitoringSettingsRepository: MonitoringSettingsRepository, - private val tunnelRepository: TunnelRepository, private val tunnelsRepository: TunnelRepository, ) : ContainerHost, ViewModel() { @@ -20,7 +20,7 @@ class MonitoringViewModel( MonitoringUiState(), buildSettings = { repeatOnSubscribedStopTimeout = 5000L }, ) { - combine(monitoringSettingsRepository.flow, tunnelRepository.userTunnelsFlow) { + combine(monitoringSettingsRepository.flow, tunnelsRepository.userTunnelsFlow) { monitoringSettings, tunnels -> state.copy( @@ -57,4 +57,60 @@ class MonitoringViewModel( fun setPingTarget(tunnel: TunnelConfig, target: String?) = intent { tunnelsRepository.save(tunnel.copy(pingTarget = target?.ifBlank { null })) } + + fun setRestartOnHandshakeTimeout(to: Boolean) = intent { + monitoringSettingsRepository.upsert( + state.monitoringSettings.copy(isRestartOnHandshakeTimeoutEnabled = to) + ) + } + + fun setMaxHandshakeRestartAttempts(to: Int) = intent { + monitoringSettingsRepository.upsert( + state.monitoringSettings.copy(maxHandshakeRestartAttempts = to) + ) + } + + fun setRestartCooldownSeconds(to: Int) = intent { + monitoringSettingsRepository.upsert( + state.monitoringSettings.copy(restartCooldownSeconds = to) + ) + } + + fun setPingMonitoringEnabled(to: Boolean) = intent { + monitoringSettingsRepository.upsert( + state.monitoringSettings.copy(isPingMonitoringEnabled = to) + ) + } + + fun setRecoveryNotificationEnabled(to: Boolean) = intent { + monitoringSettingsRepository.upsert( + state.monitoringSettings.copy(isRecoveryNotificationEnabled = to) + ) + } + + fun setMaxAttemptsAction(to: MaxAttemptsAction) = intent { + monitoringSettingsRepository.upsert(state.monitoringSettings.copy(maxAttemptsAction = to)) + } + + fun setPingFailuresBeforeRestart(to: Int) = intent { + monitoringSettingsRepository.upsert( + state.monitoringSettings.copy(pingFailuresBeforeRestart = to) + ) + } + + fun setBackoffEnabled(to: Boolean) = intent { + monitoringSettingsRepository.upsert(state.monitoringSettings.copy(isBackoffEnabled = to)) + } + + fun setBackoffMaxAttempts(to: Int) = intent { + monitoringSettingsRepository.upsert( + state.monitoringSettings.copy(backoffMaxAttempts = to) + ) + } + + fun setStartupGraceSeconds(to: Int) = intent { + monitoringSettingsRepository.upsert( + state.monitoringSettings.copy(startupGraceSeconds = to) + ) + } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/SettingsViewModel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/SettingsViewModel.kt old mode 100644 new mode 100755 index 17d7460ba..021e5f200 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/SettingsViewModel.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/SettingsViewModel.kt @@ -86,6 +86,10 @@ class SettingsViewModel( monitoringRepository.upsert(state.monitoring.copy(isPingEnabled = to)) } + fun setRestartOnHandshakeTimeout(to: Boolean) = intent { + monitoringRepository.upsert(state.monitoring.copy(isRestartOnHandshakeTimeoutEnabled = to)) + } + fun setRemoteEnabled(to: Boolean) = intent { settingsRepository.upsert( state.settings.copy( diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/SharedAppViewModel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/SharedAppViewModel.kt old mode 100644 new mode 100755 index b5aab3904..17f6a9d4e --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/SharedAppViewModel.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/SharedAppViewModel.kt @@ -70,13 +70,16 @@ class SharedAppViewModel( monitoringSettingsRepository.flow, tunnelManager.activeTunnels, selectedTunnelsRepository.flow, - ) { tunnels, monitoringSettings, activeTuns, selectedTuns -> + combine(tunnelManager.restartProgress, tunnelManager.restartCounts) { p, c -> Pair(p, c) }, + ) { tunnels, monitoringSettings, activeTuns, selectedTuns, (restartProgress, restartCounts) -> TunnelsUiState( tunnels = tunnels, isPingEnabled = monitoringSettings.isPingEnabled, showPingStats = monitoringSettings.showDetailedPingStats, activeTunnels = activeTuns, selectedTunnels = selectedTuns, + restartProgress = restartProgress, + restartCounts = restartCounts, isLoading = false, ) } @@ -126,7 +129,7 @@ class SharedAppViewModel( intent { tunnelManager.messageEvents.collect { (_, message) -> - postSideEffect(GlobalSideEffect.Snackbar(message.toStringValue())) + message.toStringValue()?.let { postSideEffect(GlobalSideEffect.Snackbar(it)) } } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml old mode 100644 new mode 100755 index 27f01c295..97fda04f0 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -464,4 +464,36 @@ https://apt.izzysoft.de/fdroid/index/apk/com.zaneschepke.wireguardautotunnel The URL must be secure and serve a .conf file. %1$s only + Auto-restart + Auto-restart on tunnel failure + Automatically restart the tunnel when a stale handshake is detected (~3.5 min) or when ping monitoring detects a failure. + Max restarts per hour + Stops auto-restarting after this many attempts within one hour to avoid restart loops. + Tunnel restarted automatically: stale WireGuard handshake (~3.5 min). + Tunnel restarted automatically: all ping targets unreachable. + Cooldown between restarts + Restart on ping failure + Restarting… + Stale handshake + Ping unreachable + Ping unreachable: %1$s + Restarting… %1$d/%2$d + Restart %1$d/%2$d · next in %3$ds + Restart %1$d/%2$d · max reached + %1$s · restarting… (%2$s/%3$s) + Connection restored + %1$s · max restarts reached + %1$s · max restarts reached · VPN stopped + Recovery notifications + Get notified when a tunnel failure is detected and when it recovers + On max attempts reached + Keep waiting + Stop tunnel + Consecutive ping failures before first restart + Exponential backoff + Doubles the cooldown after each failed restart attempt + Give up after + Stop retrying after this many restart attempts. Estimated total time is based on the current cooldown setting. + Startup grace period + Wait for the tunnel to establish a connection before monitoring begins. Prevents false restarts caused by stale statistics on startup.