22 KiB
恒玄耳机连接链路:两份改动方案
背景与问题定位见本文「附:为什么分两份」。两份互相独立,可以分别上、分别验证。
- 方案一:修「换个耳机就连不上」。纯 bug,无策略取舍,改动小。
- 方案二:自动回连提速 + 确权失败的处置口径。含策略决定,需要先定口径再动手。
方案一:修「换个耳机就连不上」
根因
bes_scan_view.dart:169-181 把用户点的那一台排在候选列表最后:
candidates.add(liveId); // ① 原生当前连着的 ← 旧耳机 A
candidates.add(last); // ② lastDeviceAddress ← 旧耳机 A
candidates.add(id); // ③ 用户点的那一台 ← 新耳机 B
这个顺序是为 Android TWS 双地址写的(同一只耳机:配对列表地址 ≠ 跑 SPP 的地址), 但它分不清「同一台耳机的另一个地址」和「另一台耳机」。
叠加 Android 原生的静默丢弃 BluetoothManager.kt:628-640:
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 行 的 _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)。
而两个 fallback 的来源是闭环的:
adapter.bondedDevices ──► 扫描页列表 ──► 用户点击 ──► connectToDevice(addr)
└─► 原生 lastDeviceId = addr
├─► currentConnectedDevice() ─┐
└─► savePairedDevice │
└─► lastDeviceAddress ──┤
只能吐回 App 自己拨过的地址 ◄─────────────────────────┘
currentConnectedDevice() 与 lastDeviceAddress 永远产生不了一个「新地址」。
所以注释里说的 TWS 双地址(「配对列表是 …C1:C4,SPP 实际走 …BE:F8」)这条链根本够不着——
…BE:F8 没有任何入口能进入系统。原作者瞄准的问题是对的,但接错了源:
这两个 fallback 唯一的实际效果就是把上一台耳机塞进候选列表。
反过来,那个 SPP 地址若真的存在于系统配对列表里,它就是同名的另一行—— 正好落在「同名设备遍历」的范围内。两种情况下遍历同名行都不比现状差。
改法
// 身份由连上之后的 MAC 校验来定,不由拨哪个地址来定(见 1.4 + BesDeviceAuth)。
// 所以连接阶段只要「拨通一条链路」即可,不需要在地址层面追求精确。
final tapped = (d['address'] as String? ?? '');
final tappedName = (d['name'] as String? ?? '').trim().toUpperCase();
final candidates = <String>[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 行),
所以不必每个候选都干等满 8 秒:
Future<bool> _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)
_statusWorker = ever<int>(_service.deviceStatus, (v) {
if (v == 2 && mounted && !_finished && !_connecting) _finish();
});
_connecting 期间由 _connect 自己在循环结束后决定收尾,避免第一个候选一连上就跳走。
1.3 Android 原生:connect() 不再静默丢弃(BluetoothManager.kt:628)
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 行) 先判isConnected,不依赖它被同步写入。- iOS 不需要改:
BluetoothManager.swift:138的connect()没有isConnecting门禁, 每次都走attachAndConnect→resetConnectionState(),本来就不会丢弃。
1.4 连接后回填真实设备身份(bes_bluetooth_service.dart:654)
connectToDevice 在调原生之前就乐观写了 connectedDeviceId = 目标地址:
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() 第一行就是:
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 的映射并持久化
static const String _macCacheKey = 'bes_device_mac_cache'; // {deviceId: MAC}
// event == 2、用 1.4 回填出真实 connectedDeviceId 之后:
final cached = (_storage.read<Map>(_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,可先单独上一版验证前四条。
方案二:自动回连提速 + 确权失败的处置口径
这份含策略取舍,动手前需要先拍板下面「待定口径」两条。
前提澄清(这两件事已经是现状,不需要改)
- 确权本来就是「先连上再校验」:
verifyConnected()由event == 2(链路已通)触发, 不是连接的前置条件。 - MAC 缓存在本地就不打接口:bes_device_auth.dart:149 的白名单短路在任何网络调用之前,同步返回。
所以确权不是「换耳机连不上」的原因——那条失败路径上 deviceStatus 从来没到过 2,
verifyConnected 一次都没被调用。方案二解决的是另外三件事。
2.1 只有 authorized 才写缓存,unknown 每次回连都重来
_remember() 只在 180 与
229 两个 authorized 分支调用。
判不出结论时什么都不缓存,于是每次自动回连都重跑一遍:
- 断网 →
Api.binddevice被.timeout(10s)卡满 → 每次回连白等 10 秒才开始查固件版本/电量 - iOS 固件 0.0.3(
AA 09已不回BB 0A,_listen里有 TODO)→resolveDeviceMac每次空等 3 秒
改法:让确权脱离握手链路——白名单命中仍同步放行;没命中则后台跑,握手立即继续。 这正是「校验通过就无感,不通过再提醒」。
final auth = BesDeviceAuth.tryFastPath(); // 白名单命中 → 同步 authorized
if (auth == null) {
unawaited(BesDeviceAuth.verifyInBackground()); // 慢路径:拿到 denied 再断开+提示
}
// 握手不再等待,立即继续
待定口径 ①:确权改后台后,未授权设备会拿到数秒的完整功能才被切断。 当前注释写的是「必须排在握手和 AI 编排之前——未确权的设备不该被当成一台可用耳机对待」。 这道闸门的定位是防非授权硬件蹭服务,不是防滥用,我倾向于可以接受; 但如果口径是「一秒都不能用」,那 2.1 只能改成「缩短超时」而不是「移出链路」。
2.2 _reported 是死字段
第 80/87/115/123/181/230 行 全是
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 修复、无口径争议, 建议先上;方案二牵涉「闸门该多严」的产品决定,定了口径再动。