From d87c58ac26fb2fb3b1c8443eb12a4468a1d230ee Mon Sep 17 00:00:00 2001 From: Rust Date: Fri, 3 Oct 2025 15:20:49 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=AE=89=E5=8D=93bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/bugfix_android_vpn_permission_ui_sync.md | 200 ++++++++++++++++++ .../controllers/kr_home_controller.dart | 6 +- .../services/singbox_imp/kr_sing_box_imp.dart | 10 +- 3 files changed, 213 insertions(+), 3 deletions(-) create mode 100644 docs/bugfix_android_vpn_permission_ui_sync.md diff --git a/docs/bugfix_android_vpn_permission_ui_sync.md b/docs/bugfix_android_vpn_permission_ui_sync.md new file mode 100644 index 0000000..9787bcd --- /dev/null +++ b/docs/bugfix_android_vpn_permission_ui_sync.md @@ -0,0 +1,200 @@ +# Android VPN 权限弹窗关闭后 UI 状态不同步问题修复 + +## 问题描述 + +在 Android 平台上,当用户第一次打开 App 后点击连接按钮时,系统会弹出 VPN 权限请求弹窗。如果用户点击取消或关闭这个弹窗,会出现以下问题: + +- **现象**: UI 上的 Switch 开关没有正确回到关闭状态,仍然显示为打开 +- **实际情况**: 节点已经尝试连接(但因权限被拒绝而失败) +- **用户体验**: UI 状态与实际连接状态不一致,造成困惑 + +## 问题根源分析 + +### 1. Android 原生层面 +在 `android/app/src/main/kotlin/com/hiddify/hiddify/bg/VPNService.kt` 文件中: + +```kotlin +override fun openTun(options: TunOptions): Int { + if (prepare(this) != null) error("android: missing vpn permission") // 第76行 + // ... +} +``` + +当用户拒绝 VPN 权限时,`prepare(this)` 返回非空值,代码会抛出 "android: missing vpn permission" 错误。 + +### 2. Flutter 服务层面 +在 `lib/app/services/singbox_imp/kr_sing_box_imp.dart` 文件中的 `kr_start()` 方法: + +**修复前的问题**: +- 当启动失败时,虽然设置了 `kr_status.value = SingboxStopped()` +- 但没有强制触发状态观察者的更新 +- 导致 UI 层的 `Obx` 观察者可能无法及时响应状态变化 + +### 3. Controller 层面 +在 `lib/app/modules/kr_home/controllers/kr_home_controller.dart` 的 `kr_toggleSwitch()` 方法中: + +**修复前的问题**: +- 异常捕获后只记录了日志 +- 没有主动触发 UI 状态同步 +- 导致 Switch 开关状态与实际连接状态不一致 + +## 修复方案 + +### 修改 1: 增强 SingBox 服务层的状态刷新 + +**文件**: `lib/app/services/singbox_imp/kr_sing_box_imp.dart:427-465` + +**修改内容**: +```dart +Future kr_start() async { + kr_status.value = SingboxStarting(); + try { + // ... 启动逻辑 ... + + await kr_singBox.start(_cutPath, kr_configName, false).map( + (r) { + KRLogUtil.kr_i('✅ SingBox 启动成功', tag: 'SingBox'); + }, + ).mapLeft((err) { + KRLogUtil.kr_e('❌ SingBox 启动失败: $err', tag: 'SingBox'); + // 确保状态重置为Stopped,触发UI更新 + kr_status.value = SingboxStopped(); + // 强制刷新状态以触发观察者 ← 新增 + kr_status.refresh(); + throw err; + }).run(); + } catch (e, stackTrace) { + KRLogUtil.kr_e('💥 SingBox 启动异常: $e', tag: 'SingBox'); + KRLogUtil.kr_e('📚 错误堆栈: $stackTrace', tag: 'SingBox'); + // 确保状态重置为Stopped,触发UI更新 + kr_status.value = SingboxStopped(); + // 强制刷新状态以触发观察者 ← 新增 + kr_status.refresh(); + rethrow; + } +} +``` + +**关键改动**: +- 在两处异常处理中都添加了 `kr_status.refresh()` 调用 +- 确保即使状态值相同(都是 Stopped),也能强制触发观察者更新 + +### 修改 2: 增强 Controller 的异常处理 + +**文件**: `lib/app/modules/kr_home/controllers/kr_home_controller.dart:529-558` + +**修改内容**: +```dart +void kr_toggleSwitch(bool value) async { + // 如果正在切换中,直接返回 + if (kr_isSwitching) { + KRLogUtil.kr_i('正在切换中,忽略本次操作', tag: 'HomeController'); + return; + } + + try { + kr_isSwitching = true; + if (value) { + await KRSingBoxImp.instance.kr_start(); + + // 添加延迟验证,确保状态正确更新 + Future.delayed(const Duration(seconds: 2), () { + kr_forceSyncConnectionStatus(); + }); + } else { + await KRSingBoxImp.instance.kr_stop(); + } + } catch (e) { + KRLogUtil.kr_e('切换失败: $e', tag: 'HomeController'); + // 当启动失败时(如VPN权限被拒绝),强制同步状态 ← 新增 + Future.delayed(const Duration(milliseconds: 100), () { + kr_forceSyncConnectionStatus(); + }); + } finally { + // 确保在任何情况下都会重置标志 + kr_isSwitching = false; + } +} +``` + +**关键改动**: +- 在 `catch` 块中添加了状态强制同步逻辑 +- 延迟 100ms 调用 `kr_forceSyncConnectionStatus()` 确保状态同步到 UI +- 这样当 VPN 权限被拒绝导致启动失败时,UI 会正确回退到断开状态 + +## 技术细节 + +### 状态流转过程 + +1. **用户点击 Switch 开启** + - UI 调用 `kr_toggleSwitch(true)` + - Controller 调用 `KRSingBoxImp.instance.kr_start()` + - SingBox 服务设置状态为 `SingboxStarting()` + +2. **VPN 权限被拒绝场景** + - Android 原生层 `VPNService.openTun()` 抛出权限错误 + - 错误传播到 SingBox 服务层 + - `kr_start()` 的异常处理捕获错误 + - 设置状态为 `SingboxStopped()` 并调用 `refresh()` + - Controller 的异常处理触发 `kr_forceSyncConnectionStatus()` + +3. **UI 状态同步** + - `kr_status.refresh()` 强制触发 GetX 的 `Obx` 观察者 + - `kr_forceSyncConnectionStatus()` 确保所有 UI 状态变量同步 + - Switch 开关状态通过 `kr_isConnected.value` 回退到 `false` + +### 为什么需要 refresh() ? + +GetX 的响应式系统通过值比较来决定是否通知观察者: +- 如果 `kr_status.value` 从 `Starting` 变为 `Stopped`,会自动通知 +- 但某些边界情况下,如果状态转换没有被正确捕获,调用 `refresh()` 可以强制通知所有观察者,不管值是否改变 + +### 为什么需要延迟调用? + +- `Future.delayed(const Duration(milliseconds: 100))` 确保异步操作链完成 +- 避免在状态还在转换过程中就尝试同步,导致读取到中间状态 + +## 测试验证 + +### 测试场景 1: 首次启动拒绝权限 +1. 安装 App 后首次启动 +2. 点击连接按钮 +3. 系统弹出 VPN 权限请求 +4. 点击"取消"或直接关闭弹窗 +5. **预期结果**: Switch 自动回到关闭状态 + +### 测试场景 2: 二次启动(已授权) +1. 用户之前已授权 VPN 权限 +2. 点击连接按钮 +3. **预期结果**: 正常连接,Switch 保持开启状态 + +### 测试场景 3: 撤销权限后重连 +1. 在系统设置中撤销 VPN 权限 +2. 返回 App 点击连接 +3. 系统再次弹出权限请求 +4. 拒绝权限 +5. **预期结果**: Switch 自动回到关闭状态 + +## 影响范围 + +- **影响平台**: 仅 Android 平台 +- **影响功能**: VPN 连接开关 +- **向后兼容**: 完全兼容,不影响已有功能 +- **性能影响**: 无明显性能影响,仅在异常情况下多执行一次状态同步 + +## 相关文件 + +1. `lib/app/services/singbox_imp/kr_sing_box_imp.dart` - SingBox 服务层 +2. `lib/app/modules/kr_home/controllers/kr_home_controller.dart` - 主页控制器 +3. `lib/app/modules/kr_home/views/kr_home_connection_info_view.dart` - Switch UI 组件 +4. `android/app/src/main/kotlin/com/hiddify/hiddify/bg/VPNService.kt` - Android VPN 服务 + +## 修复日期 + +2025-10-03 + +## 修复作者 + +collins + +OmnTech 提供技术支持 diff --git a/lib/app/modules/kr_home/controllers/kr_home_controller.dart b/lib/app/modules/kr_home/controllers/kr_home_controller.dart index a11ce2f..4985f95 100755 --- a/lib/app/modules/kr_home/controllers/kr_home_controller.dart +++ b/lib/app/modules/kr_home/controllers/kr_home_controller.dart @@ -537,7 +537,7 @@ class KRHomeController extends GetxController { kr_isSwitching = true; if (value) { await KRSingBoxImp.instance.kr_start(); - + // 添加延迟验证,确保状态正确更新 Future.delayed(const Duration(seconds: 2), () { kr_forceSyncConnectionStatus(); @@ -547,6 +547,10 @@ class KRHomeController extends GetxController { } } catch (e) { KRLogUtil.kr_e('切换失败: $e', tag: 'HomeController'); + // 当启动失败时(如VPN权限被拒绝),强制同步状态 + Future.delayed(const Duration(milliseconds: 100), () { + kr_forceSyncConnectionStatus(); + }); } finally { // 确保在任何情况下都会重置标志 kr_isSwitching = false; diff --git a/lib/app/services/singbox_imp/kr_sing_box_imp.dart b/lib/app/services/singbox_imp/kr_sing_box_imp.dart index c3c1b1c..38b6718 100755 --- a/lib/app/services/singbox_imp/kr_sing_box_imp.dart +++ b/lib/app/services/singbox_imp/kr_sing_box_imp.dart @@ -430,7 +430,7 @@ class KRSingBoxImp { KRLogUtil.kr_i('🚀 开始启动 SingBox...', tag: 'SingBox'); KRLogUtil.kr_i('📁 配置文件路径: $_cutPath', tag: 'SingBox'); KRLogUtil.kr_i('📝 配置名称: $kr_configName', tag: 'SingBox'); - + // 检查配置文件是否存在 final configFile = File(_cutPath); if (await configFile.exists()) { @@ -440,20 +440,26 @@ class KRSingBoxImp { } else { KRLogUtil.kr_w('⚠️ 配置文件不存在: $_cutPath', tag: 'SingBox'); } - + await kr_singBox.start(_cutPath, kr_configName, false).map( (r) { KRLogUtil.kr_i('✅ SingBox 启动成功', tag: 'SingBox'); }, ).mapLeft((err) { KRLogUtil.kr_e('❌ SingBox 启动失败: $err', tag: 'SingBox'); + // 确保状态重置为Stopped,触发UI更新 kr_status.value = SingboxStopped(); + // 强制刷新状态以触发观察者 + kr_status.refresh(); throw err; }).run(); } catch (e, stackTrace) { KRLogUtil.kr_e('💥 SingBox 启动异常: $e', tag: 'SingBox'); KRLogUtil.kr_e('📚 错误堆栈: $stackTrace', tag: 'SingBox'); + // 确保状态重置为Stopped,触发UI更新 kr_status.value = SingboxStopped(); + // 强制刷新状态以触发观察者 + kr_status.refresh(); rethrow; } }