# 恒玄耳机连接链路:两份改动方案 背景与问题定位见本文「附:为什么分两份」。两份**互相独立**,可以分别上、分别验证。 - **方案一**:修「换个耳机就连不上」。纯 bug,无策略取舍,改动小。 - **方案二**:自动回连提速 + 确权失败的处置口径。**含策略决定,需要先定口径再动手。** --- # 方案一:修「换个耳机就连不上」 ## 根因 [bes_scan_view.dart:169-181](lib/modules/devices/views/bes_scan_view.dart#L169-L181) 把**用户点的那一台排在候选列表最后**: ```dart candidates.add(liveId); // ① 原生当前连着的 ← 旧耳机 A candidates.add(last); // ② lastDeviceAddress ← 旧耳机 A candidates.add(id); // ③ 用户点的那一台 ← 新耳机 B ``` 这个顺序是为 Android **TWS 双地址**写的(同一只耳机:配对列表地址 ≠ 跑 SPP 的地址), 但它分不清「同一台耳机的另一个地址」和「另一台耳机」。 叠加 Android 原生的静默丢弃 [BluetoothManager.kt:628-640](local_plugins/bluetooth_manager/android/src/main/kotlin/com/example/bluetooth_manager/BluetoothManager.kt#L628-L640): ```kotlin fun connect(deviceId: String, ...) { cancelReconnect() // ← 会 closeSocketQuietly() if (!isConnecting.compareAndSet(false, true)) return // ← 静默 return,不报错不改状态 ``` `cancelReconnect()` **不复位 `isConnecting`**(只 interrupt 线程 + 关 socket),它只在连接线程的 `finally`(第 638 行)里清。而 CAS 紧接着同步执行,上一次的线程还没被调度到 `finally`—— **这个竞态新调用几乎必输**。 ### 失败时间线(A 已配对但不在身边) | 时刻 | 发生什么 | |---|---| | t=0 | 连 A → `isConnecting=true` → `sock.connect()` 阻塞(配对但不在场的经典蓝牙设备要等系统页超时) | | t=8s | Dart `_waitConnected` 超时 → 循环到 B → `connect(B)`:`cancelReconnect()` **关掉 A 的 socket**,随后 CAS 失败 → **静默丢弃** | | t=8s+ε | A 的线程抛 IOException → `publishBluetoothStatus(0)` → `isConnecting=false`。**此时没有任何设备在连** | | t=16s | Dart 再次超时 → 弹「连接失败:请确认 Echo-one 已开机、在手机附近」 | 重试无效:`lastDeviceAddress` 仍是 A(`_finish` 没跑过)。**永久卡死。** **触发条件**:第一个候选在 8 秒内既没连上也没失败。配对但不在范围内的经典蓝牙设备正是这个行为。 (若 A 快速失败——比如已关机且系统立即返回——则 B 能连上,所以现场表现会有偶发性。) ### A 在身边时的另一种失败 候选①立刻成功 → [第 64 行](lib/modules/devices/views/bes_scan_view.dart#L64) 的 `_statusWorker` 抢在候选循环中途开火 → `_finish()` → `savePairedDevice({address: A, name: B的名字})` → 弹「连接成功」跳主页。**用户以为换成 B 了,其实还在 A,且配对列表被写脏。** ## 改动清单 ### 1.1 候选 = 点击的那台 + 同名设备遍历,删掉两个 fallback(`bes_scan_view.dart`) #### 先说为什么现有的 fallback 必须删,而不是重排 Android 插件里**没有任何扫描/发现路径**(`startDiscovery` / `ACTION_FOUND` 全项目零命中), 地址的唯一来源是 `adapter.bondedDevices`([getBondedDevicesList:774](local_plugins/bluetooth_manager/android/src/main/kotlin/com/example/bluetooth_manager/BluetoothManager.kt#L774))。 而两个 fallback 的来源是闭环的: ``` adapter.bondedDevices ──► 扫描页列表 ──► 用户点击 ──► connectToDevice(addr) └─► 原生 lastDeviceId = addr ├─► currentConnectedDevice() ─┐ └─► savePairedDevice │ └─► lastDeviceAddress ──┤ 只能吐回 App 自己拨过的地址 ◄─────────────────────────┘ ``` **`currentConnectedDevice()` 与 `lastDeviceAddress` 永远产生不了一个「新地址」。** 所以注释里说的 TWS 双地址(「配对列表是 …C1:C4,SPP 实际走 …BE:F8」)这条链根本够不着—— …BE:F8 没有任何入口能进入系统。原作者瞄准的问题是对的,但接错了源: 这两个 fallback 唯一的实际效果就是**把上一台耳机塞进候选列表**。 反过来,那个 SPP 地址若真的存在于系统配对列表里,它就是**同名的另一行**—— 正好落在「同名设备遍历」的范围内。两种情况下遍历同名行都不比现状差。 #### 改法 ```dart // 身份由连上之后的 MAC 校验来定,不由拨哪个地址来定(见 1.4 + BesDeviceAuth)。 // 所以连接阶段只要「拨通一条链路」即可,不需要在地址层面追求精确。 final tapped = (d['address'] as String? ?? ''); final tappedName = (d['name'] as String? ?? '').trim().toUpperCase(); final candidates = [tapped]; // 同名的其余行:可能是同一只耳机的另一个 BR/EDR 地址(TWS), // 也可能是另一台同款耳机——两种都值得试,连上哪台由 MAC 校验兜底认领。 for (final row in _devices) { final a = (row['address'] as String? ?? ''); final n = (row['name'] as String? ?? '').trim().toUpperCase(); if (a.isEmpty || n != tappedName) continue; if (candidates.any((c) => c.toUpperCase() == a.toUpperCase())) continue; candidates.add(a); } // ⚠️ 不要再把 currentConnectedDevice() / lastDeviceAddress 加进来(理由见上) ``` **这一条单独就能消掉绝大部分现象**,且不需要动原生。 #### 配套:`_waitConnected` 监听 1→0 提前失败 `connectBlocking` 失败时会 `publishBluetoothStatus(0)` ([第 684 行](local_plugins/bluetooth_manager/android/src/main/kotlin/com/example/bluetooth_manager/BluetoothManager.kt#L684)), 所以不必每个候选都干等满 8 秒: ```dart Future _waitConnected({Duration timeout = const Duration(seconds: 8)}) async { final end = DateTime.now().add(timeout); bool sawConnecting = false; while (DateTime.now().isBefore(end)) { final s = _service.deviceStatus.value; if (s == 2) return true; if (s == 1) sawConnecting = true; // 已经进过「连接中」又掉回 0 = 原生明确失败了,不用等满超时 if (sawConnecting && s == 0) return false; await Future.delayed(const Duration(milliseconds: 300)); } return _service.deviceStatus.value == 2; } ``` 遍历 N 个同名设备的总耗时从「N × 8s」降到接近真实失败时间。 ⚠️ 这一条**依赖 1.3**:原生现在静默丢弃时不发任何状态,`sawConnecting && s == 0` 在丢弃场景下不会触发(状态停在 Dart 自己写的 1),仍要靠超时兜底。 ⚠️ 副作用(可接受):用户点 B、B 不在范围内时,会回落到同名的 A 并连上它。 配合 1.4,落盘与提示显示的是**真实连上的那台**,数据不会脏; 代价是用户可能拿到不是自己点的那只。若要杜绝,可在回落成功后提示 「已连接 <真实名称/地址>」而不是沿用点击行的名字。 ### 1.2 候选循环期间不让 `_statusWorker` 抢跑(`bes_scan_view.dart:64`) ```dart _statusWorker = ever(_service.deviceStatus, (v) { if (v == 2 && mounted && !_finished && !_connecting) _finish(); }); ``` `_connecting` 期间由 `_connect` 自己在循环结束后决定收尾,避免第一个候选一连上就跳走。 ### 1.3 Android 原生:`connect()` 不再静默丢弃(`BluetoothManager.kt:628`) ```kotlin fun connect(deviceId: String, serviceUuid: String = "aeaf5241-...") { cancelReconnect() // 已关掉在飞的 socket,阻塞的 sock.connect() 会立刻抛异常 Thread { // 等上一次的 finally 复位 isConnecting。不等就 CAS 几乎必然失败, // 这次 connect 会被静默丢弃——「换个耳机连不上」的直接原因。 val deadline = System.currentTimeMillis() + 2000 while (isConnecting.get() && System.currentTimeMillis() < deadline) Thread.sleep(20) if (!isConnecting.compareAndSet(false, true)) { publishBluetoothStatus(0) // 至少让 Dart 知道这次没成,别干等 8 秒 return@Thread } lastDeviceId = deviceId lastServiceUuid = serviceUuid isUserInitiatedDisconnect = false try { connectBlocking(deviceId, serviceUuid) } finally { isConnecting.set(false) } }.start() } ``` 要点: - 等待放在**工作线程**里,不能在平台线程 `Thread.sleep`(`connect` 由 MethodChannel 在主线程调用)。 - `lastDeviceId` 的赋值跟着挪进线程。已核对:唯一的读者 `getConnectedDeviceInfo()` ([第 762 行](local_plugins/bluetooth_manager/android/src/main/kotlin/com/example/bluetooth_manager/BluetoothManager.kt#L762)) 先判 `isConnected`,不依赖它被同步写入。 - **iOS 不需要改**:`BluetoothManager.swift:138` 的 `connect()` 没有 `isConnecting` 门禁, 每次都走 `attachAndConnect` → `resetConnectionState()`,本来就不会丢弃。 ### 1.4 连接后回填真实设备身份(`bes_bluetooth_service.dart:654`) `connectToDevice` 在调原生**之前**就乐观写了 `connectedDeviceId = 目标地址`: ```dart deviceStatus.value = 1; connectedDeviceId.value = deviceId; // ← 原生若丢弃/连到别的,这个值就是错的 ``` 而 `_listen` 的 `event == 2` 分支只在 `connectedDeviceId.value.isEmpty` 时才去问原生真实设备。 于是「状态里是 B、链路上是 A」会一路带到确权:拿 B 的 MAC 去 `binddevice`(绑一台没连着的耳机), 被拒时 `_reject` 又按 `connectedDeviceId == B` 判 `isCurrent=true`,把实际连着的 A 断掉。 改法:`event == 2` 时**无条件**用原生 `getConnectedDevice()` 回填,不再以「本地值为空」为条件。 ### 1.5 `deviceMac` 按 device id 建缓存,而不是断开就清 问题:`event == 0` 分支清了 7 个字段,唯独没清 `deviceMac`。注释的理由是「它是这台硬件的属性」—— 但**换耳机之后它就是上一台的属性了**。而 iOS 的 `resolveDeviceMac()` 第一行就是: ```dart if (deviceMac.value.isNotEmpty) return deviceMac.value; // ← 直接返回旧耳机的 MAC ``` 后果:iOS 换耳机后确权用旧 MAC → 命中旧白名单 → B 冒用 A 的确权通过。 #### ⚠️ 不能简单地「断开就清」——那会拖慢 iOS 自动回连 `resolveDeviceMac()` 在 iOS 上拿不到缓存时要 `queryBattery()` 再轮询**最多 3 秒**。 而它排在 `verifyConnected()` → 握手链路的最前面。于是「断开就清」会让 **每一次 iOS 自动回连都多等最多 3 秒**;在固件 0.0.3 上(`AA 09` 已不回 `BB 0A`) 更是每次都必然等满 3 秒才返回 null。这与「回连要快」的目标直接冲突。 #### 改法:把 MAC 缓存成 `deviceId → mac` 的映射并持久化 ```dart static const String _macCacheKey = 'bes_device_mac_cache'; // {deviceId: MAC} // event == 2、用 1.4 回填出真实 connectedDeviceId 之后: final cached = (_storage.read(_macCacheKey) ?? {})[connectedDeviceId.value]; deviceMac.value = (cached as String?) ?? ''; // 换了设备就是空,不会串用上一台的 // _handleRawFrame 解析出 BB 0A 属性 7 时,除了写 deviceMac, // 再按当前 connectedDeviceId 落一条映射 ``` - iOS 上 `connectedDeviceId` 是 peripheral UUID,对「同一台手机 + 同一只耳机」是稳定的, 所以这个映射可长期复用 → **回连仍然是同步命中,零等待**。 - 换耳机时 id 不同 → 查不到 → `deviceMac` 为空 → 老老实实重新查,**不会串用旧 MAC**。 - Android 不受影响:`resolveDeviceMac()` 在 Android 上直接返回 `connectedDeviceId`,不走这条。 ⚠️ 这一条**依赖 1.4**:必须先拿到真实的 `connectedDeviceId` 再查映射, 否则用 Dart 乐观写入的那个错值做键,等于换了个方式串号。 ## 对自动回连的影响:不影响,且修正了「回连锁错设备」 三条自动回连路径与本方案的关系: | 路径 | 触发条件 | 方案一的影响 | |---|---|---| | **Android ACL 广播**(耳机开机自动回连的主路径) | `ACTION_ACL_CONNECTED` 且 `btDevice.address == lastDeviceId` 且 `!isUserInitiatedDisconnect` 且 `deviceStatus == 0` → `reconnectWithRetry` | 不改这条;**1.1/1.3 修正了 `lastDeviceId` 锁错设备**(见下) | | **iOS `.peerConnected`** | 外设在 `authorizedDeviceIds` 里 → `attachAndConnect` | 不改;1.5 改后回连仍是同步命中 | | **冷启动 `tryAutoConnect()`** | `deviceStatus == 0` + `BackgroundLaunchConsent.granted` + `getPairedDevices().first` | 不改(它直接调 `connectToDevice`,不经过扫描页的 `_connect`) | **耳机关机再开机属于链路掉线,不是用户主动断开**——`isUserInitiatedDisconnect` 保持 false、 `lastDeviceId` 保留,所以 ACL 回连正常。这条不变。 **1.1/1.3 反而修好了一个回连 bug**:现状下用户点 B 却连上 A 时,原生 `lastDeviceId = A`,于是此后**耳机开机自动回连的永远是 A**。1.1 让拨号顺序正确、 1.3 让被丢弃的 connect 不再污染 `lastDeviceId`(赋值挪到抢到 `isConnecting` 之后), 回连目标才会跟着用户的选择走。 ### 1.2 会不会挡掉「停在扫描页时的 ACL 回连」 不会。`_statusWorker` 存在的目的正是这个场景,加 `!_connecting` 后: - 回连发生在候选循环**期间** → `_waitConnected` 会看到 `deviceStatus == 2` 并 `break`, 由 `_connect` 自己走 `_finish()`; - 回连发生在循环**结束后** → `finally` 已把 `_connecting` 置回 false,worker 照常开火。 两条路都有人收尾,不存在空档。 ### 与本方案无关、但会关掉自动回连的既有条件(供排查参考) `disconnect()` 会同时 `lastDeviceId = null` 且 `isUserInitiatedDisconnect = true`, **自动回连从此关闭,直到下一次显式 `connect()`**。Dart 侧会走到这里的有 5 处: 设置页手动断开、`bes_test` 调试页、OTA 让路给插件(升级后会重连)、 `BesDeviceAuth._reject`(确权拒绝)、`removePairedDevice` 命中当前设备(解绑)。 其中确权拒绝那条正是方案二 2.3 要处理的。 ## 验证方式 必须真机,且需要**两台**恒玄耳机(或一台 + 一台改过名的其它经典蓝牙设备凑数)。 | # | 步骤 | 期望 | |---|---|---| | 1 | 绑定 A → 关闭 A 电源 → 进扫描页点 B | B 在数秒内连上;**不再**出现「连接失败」 | | 2 | A 开机在旁 → 进扫描页点 B | 连上的是 B(不是 A),配对列表首项是 B | | 2b | A 开机在旁 → 点 B,但 B 拒连/不在范围 | 回落连上 A,且提示与落盘显示的是 **A** 的身份(验 1.4) | | 3 | 只有一台耳机时进扫描页 | 自动连接行为不变(回归) | | 4 | 杀进程重启 | `tryAutoConnect` 回连的是最近一次连的那台 | | 5 | Android 看 logcat | `connect(B)` 之后有 `Bluetooth Connection` 日志,不再静默无输出 | | 6 | **连上 B → 关机 B → 再开机 B** | ACL 自动回连的是 **B**(现状会回连 A,因为 `lastDeviceId` 被写成了 A) | | 7 | **iOS:已确权耳机断开后重连,掐表** | 握手开始时间与改动前一致,**不引入额外 3 秒**(验 1.5 的映射缓存生效) | | 8 | iOS:换成另一台耳机 | 确权用的是**新耳机**的 MAC,不再命中旧白名单 | ⚠️ 1.3 改原生,需重新出包;1.1/1.2/1.4/1.5 纯 Dart,可先单独上一版验证前四条。 --- # 方案二:自动回连提速 + 确权失败的处置口径 > **这份含策略取舍,动手前需要先拍板下面「待定口径」两条。** ## 前提澄清(这两件事已经是现状,不需要改) 1. **确权本来就是「先连上再校验」**:`verifyConnected()` 由 `event == 2`(链路已通)触发, 不是连接的前置条件。 2. **MAC 缓存在本地就不打接口**:[bes_device_auth.dart:149](lib/data/services/bes_device_auth.dart#L149) 的白名单短路在任何网络调用之前,同步返回。 所以确权**不是**「换耳机连不上」的原因——那条失败路径上 `deviceStatus` 从来没到过 2, `verifyConnected` 一次都没被调用。方案二解决的是另外三件事。 ## 2.1 只有 `authorized` 才写缓存,`unknown` 每次回连都重来 `_remember()` 只在 [180](lib/data/services/bes_device_auth.dart#L180) 与 [229](lib/data/services/bes_device_auth.dart#L229) 两个 authorized 分支调用。 判不出结论时什么都不缓存,于是**每次自动回连都重跑一遍**: - 断网 → `Api.binddevice` 被 `.timeout(10s)` 卡满 → 每次回连白等 10 秒才开始查固件版本/电量 - iOS 固件 0.0.3(`AA 09` 已不回 `BB 0A`,`_listen` 里有 TODO)→ `resolveDeviceMac` 每次空等 3 秒 **改法**:让确权脱离握手链路——白名单命中仍同步放行;没命中则**后台跑**,握手立即继续。 这正是「校验通过就无感,不通过再提醒」。 ```dart final auth = BesDeviceAuth.tryFastPath(); // 白名单命中 → 同步 authorized if (auth == null) { unawaited(BesDeviceAuth.verifyInBackground()); // 慢路径:拿到 denied 再断开+提示 } // 握手不再等待,立即继续 ``` **待定口径 ①**:确权改后台后,未授权设备会拿到**数秒的完整功能**才被切断。 当前注释写的是「必须排在握手和 AI 编排之前——未确权的设备不该被当成一台可用耳机对待」。 这道闸门的定位是**防非授权硬件蹭服务**,不是防滥用,我倾向于可以接受; 但如果口径是「一秒都不能用」,那 2.1 只能改成「缩短超时」而不是「移出链路」。 ## 2.2 `_reported` 是死字段 [第 80/87/115/123/181/230 行](lib/data/services/bes_device_auth.dart#L80) 全是 `add`/`remove`/`clear`,**没有任何一处读它**。它本来的职责(进程内去重,别每次回连都打接口) 完全没生效——真正在去重的是持久化白名单 `isAuthorizedLocally`。 **改法**:让它接管 `unknown` 的进程内去重。断网时一个进程周期内只白等一次 10 秒, 而不是每次回连都等。这是给自动回连提速最便宜的一刀,且无策略风险。 ## 2.3 一次 denied 会把自动回连永久关掉 `_reject()` 一次做了三件事:移出 `pairedDevices`、清 `_lastDeviceKey`、 `disconnectDevice()`(原生 `disconnect()` 里 `lastDeviceId = null`; iOS 侧 `_syncAuthorizedDevices` 也会把它移出 `authorizedDeviceIds`)。 **Dart 的 `tryAutoConnect` 和原生 ACL/`.peerConnected` 两条回连路径同时被切断**, 只能靠用户再进一次扫描页手动点。 而且有一条**误杀正品**的路径:`_resolveProductId` 匹配不上产品名时 `return headsets.first.id` 无条件兜底 → 后台若有多个经典蓝牙耳机产品, 就会拿**错的 pid** 去 `getFactoryDeviceformac` → 查不到 → `AuthorizeNoCanUse` → denied。 而设备名来源 `connectedDeviceName` 正是方案一 1.4 会写错的那个值; 再叠加 Android `getBondedDevices` 返回系统缓存的旧配对名(代码自己注释了 `DEEPVOICE` → `Echo-one` 改名问题),匹配不上的概率不低。 **改法(三条)**: - **`_resolveProductId` 去掉 `headsets.first` 兜底**:匹配不到就返回 null → `unknown` → 放行。 拿错 pid 去查 license 表,结论必然是「查不到」,等于把「不知道」伪装成「不合法」。 - **denied 不再清 `pairedDevices` / `lastDeviceId`**,改为本地黑名单 + 冷却期 (`bes_denied_macs: {mac: deniedAtMs}`,24h 内不自动回连、也不重打接口; 用户在扫描页**手动点**视为明确意图,清掉冷却强制重查)。 这样既不永久失联,也不会掉进「连上→弹窗→断开→连上」的死循环—— 那个循环正是当初移出 `pairedDevices` 要防的。 - **提示文案可自救**:`deviceConnectionRestricted` 现在是「设备连接受限,该设备未获授权」, 用户不知道该做什么。应说明这是设备未在后台登记,需联系客服/经销商。 **待定口径 ②**:denied 时**要不要还断链**? | | 判严(现在:断链 + 关闭自动回连) | 中间(断链,但保留自动回连 + 冷却) | 判宽(不断链,仅降级需授权的功能) | |---|---|---|---| | 误判代价 | 用户永久失去自动回连,且无法自救 | 24h 后自动重试一次 | 几乎无 | | 闸门强度 | 最强 | 与判严等价(联网后仍会拦) | 弱,靠功能降级兜底 | 我建议**中间档**:与现有 `unknown` 一律放行的口径一致(类注释:「判严的代价是合法用户一断网 就用不了自己的耳机」),denied 分支目前没跟上这个口径。 ## 验证方式 | # | 场景 | 期望 | |---|---|---| | 1 | 已确权耳机 + 断网,重启 App 回连 | 握手不再被 10s 超时拖住(白名单命中,本来就不该打接口——回归验证) | | 2 | **未**确权耳机 + 断网,反复回连 | 只在第一次白等 10 秒,之后走 `_reported` 去重 | | 3 | 后台把某 MAC 从 license 表移除后连接 | 提示可自救;**设备仍留在配对列表**,24h 内不再打接口 | | 4 | 后台配置两个经典蓝牙耳机产品,连一台名字对不上的 | 判为 `unknown` 放行,**不再**误判 denied | | 5 | 正常首连一台已登记的新耳机 | 无感,握手不被确权拖慢 | --- # 附:为什么分两份 两组问题在时间轴上根本不重叠: ``` 用户点 B ──► 候选循环(A→B) ──► 原生连接 ──► event==2 ──► 确权 ──► 握手 └──────── 方案一 ────────┘ └──── 方案二 ────┘ 「连不上」死在这里 这里从来没被执行到 ``` **确权层怎么调都救不了「用户点 B、代码去连 A」。** 方案一是纯 bug 修复、无口径争议, 建议先上;方案二牵涉「闸门该多严」的产品决定,定了口径再动。