You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

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,可先单独上一版验证前四条。


方案二:自动回连提速 + 确权失败的处置口径

这份含策略取舍,动手前需要先拍板下面「待定口径」两条。

前提澄清(这两件事已经是现状,不需要改)

  1. 确权本来就是「先连上再校验」:verifyConnected() 由 event == 2(链路已通)触发, 不是连接的前置条件。
  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 修复、无口径争议, 建议先上;方案二牵涉「闸门该多严」的产品决定,定了口径再动。