@onekeyfe/hd-transport-react-native 1.2.2 → 1.2.3-alpha.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,106 @@
1
+ import { Platform } from 'react-native';
2
+ import BleUtils from '@onekeyfe/react-native-ble-utils';
3
+
4
+ /**
5
+ * Android 16+ keeps its side of a bond the device no longer has keys for, and reports it
6
+ * with ACTION_KEY_MISSING instead of a GATT status. The device then drops the link, so
7
+ * without this signal a wiped device is indistinguishable from any other disconnect.
8
+ *
9
+ * The signal only describes the link that was being encrypted when it fired. Callers must
10
+ * bound it to their own connection attempt with `sinceMs`; reusing an older one would turn
11
+ * an ordinary disconnect (a firmware-update reboot, for example) into a terminal bond error.
12
+ */
13
+
14
+ // An app can still resolve a react-native-ble-utils older than 0.1.8, and the JS bundle can
15
+ // run on a native build that predates the event, so neither method is assumed to exist.
16
+ type KeyMissingCapableBleUtils = Partial<
17
+ Pick<typeof BleUtils, 'supportsDeviceKeyMissing' | 'onDeviceKeyMissing'>
18
+ >;
19
+
20
+ type KeyMissingListener = (deviceId: string) => void;
21
+
22
+ const lastKeyMissingAt = new Map<string, number>();
23
+ const listeners = new Set<KeyMissingListener>();
24
+ let unsubscribeNative: (() => void) | undefined;
25
+
26
+ const normalizeDeviceId = (deviceId: string) => deviceId.toLowerCase();
27
+
28
+ const getCapableBleUtils = (): KeyMissingCapableBleUtils | undefined => {
29
+ if (Platform.OS !== 'android') return undefined;
30
+ const bleUtils: KeyMissingCapableBleUtils = BleUtils;
31
+ if (
32
+ typeof bleUtils.supportsDeviceKeyMissing !== 'function' ||
33
+ typeof bleUtils.onDeviceKeyMissing !== 'function'
34
+ ) {
35
+ return undefined;
36
+ }
37
+ return bleUtils.supportsDeviceKeyMissing() ? bleUtils : undefined;
38
+ };
39
+
40
+ /** False means the event can never fire here, so nothing should wait for it. */
41
+ export const isBleKeyMissingSupported = () => getCapableBleUtils() !== undefined;
42
+
43
+ /** Idempotent. Returns whether the signal is available on this OS and native build. */
44
+ export const startBleKeyMissingTracking = (): boolean => {
45
+ if (unsubscribeNative) return true;
46
+ const bleUtils = getCapableBleUtils();
47
+ if (!bleUtils?.onDeviceKeyMissing) return false;
48
+
49
+ unsubscribeNative = bleUtils.onDeviceKeyMissing(event => {
50
+ if (typeof event?.id !== 'string') return;
51
+ const deviceId = normalizeDeviceId(event.id);
52
+ lastKeyMissingAt.set(deviceId, Date.now());
53
+ listeners.forEach(listener => listener(deviceId));
54
+ });
55
+ return true;
56
+ };
57
+
58
+ export const stopBleKeyMissingTracking = () => {
59
+ unsubscribeNative?.();
60
+ unsubscribeNative = undefined;
61
+ lastKeyMissingAt.clear();
62
+ listeners.clear();
63
+ };
64
+
65
+ export const hasBleKeyMissingSince = (deviceId: string, sinceMs: number): boolean => {
66
+ const at = lastKeyMissingAt.get(normalizeDeviceId(deviceId));
67
+ return at !== undefined && at >= sinceMs;
68
+ };
69
+
70
+ /** Calls back for every later signal on this device until the returned cleanup runs. */
71
+ export const onBleKeyMissing = (deviceId: string, callback: () => void): (() => void) => {
72
+ const target = normalizeDeviceId(deviceId);
73
+ const listener: KeyMissingListener = id => {
74
+ if (id === target) callback();
75
+ };
76
+ listeners.add(listener);
77
+ return () => {
78
+ listeners.delete(listener);
79
+ };
80
+ };
81
+
82
+ /**
83
+ * Resolves true once a signal at or after `sinceMs` exists for the device, false when
84
+ * none arrives within `timeoutMs`. The broadcast and the GATT disconnect it explains are
85
+ * delivered on separate paths, so the disconnect can reach JS first.
86
+ */
87
+ export const waitForBleKeyMissing = (
88
+ deviceId: string,
89
+ sinceMs: number,
90
+ timeoutMs: number
91
+ ): Promise<boolean> => {
92
+ if (!startBleKeyMissingTracking()) return Promise.resolve(false);
93
+ if (hasBleKeyMissingSince(deviceId, sinceMs)) return Promise.resolve(true);
94
+
95
+ return new Promise(resolve => {
96
+ const settle = (found: boolean) => {
97
+ clearTimeout(timer);
98
+ cleanup();
99
+ resolve(found);
100
+ };
101
+ const cleanup = onBleKeyMissing(deviceId, () => {
102
+ if (hasBleKeyMissingSince(deviceId, sinceMs)) settle(true);
103
+ });
104
+ const timer = setTimeout(() => settle(false), timeoutMs);
105
+ });
106
+ };
package/src/index.ts CHANGED
@@ -47,6 +47,18 @@ import {
47
47
  getInfosForServiceUuid,
48
48
  isSameBleUuid,
49
49
  } from './constants';
50
+ import {
51
+ isBleKeyMissingSupported,
52
+ startBleKeyMissingTracking,
53
+ stopBleKeyMissingTracking,
54
+ waitForBleKeyMissing,
55
+ } from './bleKeyMissing';
56
+ import {
57
+ markBleLinkEncrypted,
58
+ startBleEncryptionTracking,
59
+ stopBleEncryptionTracking,
60
+ waitForAndroidLinkEncryption,
61
+ } from './bleEncryption';
50
62
  import { isNativeBleDisconnectError, toBleDisconnectHardwareError } from './bleNativeDisconnect';
51
63
  import {
52
64
  isBleStaleBondHardwareError,
@@ -62,6 +74,7 @@ import type { Deferred } from '@onekeyfe/hd-shared';
62
74
  import type { Characteristic, Device, Subscription } from 'react-native-ble-plx';
63
75
  import type EventEmitter from 'events';
64
76
  import type { BleAcquireInput, TransportOptions } from './types';
77
+ import type { AndroidLinkEncryption } from './bleEncryption';
65
78
 
66
79
  type FirmwareInstallBleAcquireInput = BleAcquireInput & {
67
80
  /**
@@ -188,6 +201,38 @@ const shouldRethrowProtocolProbeError = (error: unknown): boolean => {
188
201
  code === HardwareErrorCode.BleWriteCharacteristicError
189
202
  );
190
203
  };
204
+ /**
205
+ * Android reports a lost bond (ACTION_KEY_MISSING) while it encrypts a new link, so only
206
+ * a failure this soon after the link started can be explained by it. Later disconnects,
207
+ * such as a firmware-update reboot, must keep their own meaning.
208
+ */
209
+ export const ANDROID_KEY_MISSING_LINK_WINDOW_MS = 10_000;
210
+ /** The broadcast and the GATT disconnect it explains travel separately; either can land first. */
211
+ export const ANDROID_KEY_MISSING_GRACE_MS = 500;
212
+ /**
213
+ * Android reports a bonded link's first encryption result within about half a second of
214
+ * connecting. Past this, the result is treated as unknown and the link is used as before.
215
+ */
216
+ export const ANDROID_ENCRYPTION_RESULT_TIMEOUT_MS = 1500;
217
+ /**
218
+ * A system re-pairing needs the user to accept a pairing request and confirm the code on the
219
+ * device. The Bluetooth stack gives up after 30s and then reports key missing.
220
+ */
221
+ export const ANDROID_SYSTEM_REPAIR_TIMEOUT_MS = 35_000;
222
+ /**
223
+ * How a link dropped by a device that refuses a stale bond reaches JS. A wedged write is
224
+ * excluded: the system is still re-pairing then and has not reported key missing yet.
225
+ */
226
+ const isAndroidLinkLossError = (error: unknown): boolean => {
227
+ const code = (error as { errorCode?: unknown })?.errorCode;
228
+ return (
229
+ isNativeBleDisconnectError(error) ||
230
+ code === HardwareErrorCode.BleDeviceNotBonded ||
231
+ code === HardwareErrorCode.BleDeviceDisconnected ||
232
+ code === HardwareErrorCode.BleCharacteristicNotifyError ||
233
+ code === HardwareErrorCode.BleConnectedError
234
+ );
235
+ };
191
236
  /** Consecutive wedged writes on one device before the BLE manager itself is recreated. */
192
237
  export const BLE_WRITE_TIMEOUT_MANAGER_RESET_THRESHOLD = 2;
193
238
  const DEVICE_SCAN_TIMEOUT_MS = 3000;
@@ -540,6 +585,9 @@ export default class ReactNativeBleTransport {
540
585
  */
541
586
  private staleBondErrors: Map<string, Error> = new Map();
542
587
 
588
+ /** When the current Android link attempt began; bounds which key-missing signals apply. */
589
+ private androidLinkStartedAt: Map<string, number> = new Map();
590
+
543
591
  /** Strict or previously confirmed V2 target while acquire installs notifications. */
544
592
  private acquiringProtocolV2 = new Set<string>();
545
593
 
@@ -604,6 +652,11 @@ export default class ReactNativeBleTransport {
604
652
  init(logger: any, emitter: EventEmitter) {
605
653
  setBleLogger(logger);
606
654
  this.emitter = emitter;
655
+ if (Platform.OS === 'android') {
656
+ // Link security events are only meaningful if they were observed since the link came up.
657
+ startBleKeyMissingTracking();
658
+ startBleEncryptionTracking();
659
+ }
607
660
  }
608
661
 
609
662
  configure(signedData: any) {
@@ -1055,7 +1108,13 @@ export default class ReactNativeBleTransport {
1055
1108
  throw ERRORS.TypedError(HardwareErrorCode.BleRequiredUUID);
1056
1109
  }
1057
1110
 
1058
- return this.runLifecycleOperation(uuid, () => this.acquireUnlocked(input));
1111
+ return this.runLifecycleOperation(uuid, async () => {
1112
+ try {
1113
+ return await this.acquireUnlocked(input);
1114
+ } catch (error) {
1115
+ throw await this.resolveAndroidBondInvalid(uuid, error);
1116
+ }
1117
+ });
1059
1118
  }
1060
1119
 
1061
1120
  private async acquireUnlocked(input: FirmwareInstallBleAcquireInput) {
@@ -1135,6 +1194,8 @@ export default class ReactNativeBleTransport {
1135
1194
 
1136
1195
  let device: Device | null = null;
1137
1196
  const isAndroid = Platform.OS === 'android';
1197
+ // Only a bond that existed before this acquire can have been lost by the device.
1198
+ let androidBondedBeforeConnect = false;
1138
1199
  // A firmware-install reconnect always refreshes: the new firmware may expose a different table.
1139
1200
  const refreshAndroidGattCache =
1140
1201
  isAndroid && (!!skipProtocolProbe || this.androidGattCacheRefreshes.has(uuid));
@@ -1166,11 +1227,19 @@ export default class ReactNativeBleTransport {
1166
1227
 
1167
1228
  if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
1168
1229
  if (Platform.OS === 'android') {
1230
+ // Subscribe before the link exists: key missing is broadcast while Android encrypts it.
1231
+ startBleKeyMissingTracking();
1232
+ startBleEncryptionTracking();
1233
+ // Failures before the new link starts must not be read against the previous link.
1234
+ this.androidLinkStartedAt.delete(uuid);
1169
1235
  // Initiate bonding locally before GATT can trigger peripheral-initiated pairing.
1170
1236
  try {
1171
1237
  const bondState = await pairDevice(uuid);
1238
+ androidBondedBeforeConnect = !bondState.bonding && bondState.bonded;
1172
1239
  if (bondState.bonding) {
1173
- await onDeviceBondState(uuid, this.bondAbortController.signal);
1240
+ await onDeviceBondState(uuid, this.bondAbortController.signal, {
1241
+ systemInitiated: bondState.initiated === false,
1242
+ });
1174
1243
  } else if (!bondState.bonded) {
1175
1244
  throw ERRORS.TypedError(HardwareErrorCode.BleDeviceNotBonded, 'device is not bonded');
1176
1245
  }
@@ -1182,6 +1251,7 @@ export default class ReactNativeBleTransport {
1182
1251
  });
1183
1252
  throw error;
1184
1253
  }
1254
+ this.androidLinkStartedAt.set(uuid, Date.now());
1185
1255
  }
1186
1256
  if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
1187
1257
 
@@ -1369,7 +1439,13 @@ export default class ReactNativeBleTransport {
1369
1439
  this.acquiringProtocolV2.add(uuid);
1370
1440
  }
1371
1441
 
1442
+ let linkEncryption: AndroidLinkEncryption | undefined;
1372
1443
  try {
1444
+ // A firmware-install reconnect keeps its existing sequence.
1445
+ if (androidBondedBeforeConnect && !skipProtocolProbe) {
1446
+ linkEncryption = await this.waitForAndroidLinkSecurity(uuid);
1447
+ if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
1448
+ }
1373
1449
  await this.installTransportForAcquire(uuid, acquiredDevice, {
1374
1450
  writeCharacteristic,
1375
1451
  notifyCharacteristic,
@@ -1421,6 +1497,11 @@ export default class ReactNativeBleTransport {
1421
1497
  throw ERRORS.TypedError(HardwareErrorCode.TransportNotFound);
1422
1498
  }
1423
1499
  this.attachDisconnectSubscription(currentTransport, currentTransport.device, uuid);
1500
+ if (linkEncryption === 'unresolved') {
1501
+ // Notifications and the probe only work on an encrypted link, so the next reuse of this
1502
+ // link needs no result that was reported before tracking started.
1503
+ markBleLinkEncrypted(uuid);
1504
+ }
1424
1505
  return { uuid, protocolType };
1425
1506
  } catch (error) {
1426
1507
  // A failed acquire must retire the physical link before Core retries. Logical
@@ -1689,16 +1770,77 @@ export default class ReactNativeBleTransport {
1689
1770
  `Device protocol has not been detected for ${uuid}`
1690
1771
  );
1691
1772
  }
1692
- if (protocol === 'V2') {
1693
- return this.callProtocolV2(uuid, name, data, options);
1694
- }
1695
-
1696
1773
  const forceRun = name === 'Initialize' || name === 'Cancel';
1697
- if (this.runPromise && !forceRun) {
1774
+ if (protocol !== 'V2' && this.runPromise && !forceRun) {
1698
1775
  throw ERRORS.TypedError(HardwareErrorCode.TransportCallInProgress);
1699
1776
  }
1700
1777
 
1701
- return this.callProtocolV1(uuid, name, data, options);
1778
+ try {
1779
+ return protocol === 'V2'
1780
+ ? await this.callProtocolV2(uuid, name, data, options)
1781
+ : await this.callProtocolV1(uuid, name, data, options);
1782
+ } catch (error) {
1783
+ // An expected-V1 acquire skips the probe, so the first call is what meets the dropped link.
1784
+ throw await this.resolveAndroidBondInvalid(uuid, error);
1785
+ }
1786
+ }
1787
+
1788
+ /**
1789
+ * Android 16+ keeps a bond the device no longer holds keys for. The device drops the link
1790
+ * and the only structured evidence is ACTION_KEY_MISSING, so a link loss right after
1791
+ * connect is re-read as an invalid bond when that signal belongs to the same attempt.
1792
+ */
1793
+ private async resolveAndroidBondInvalid(uuid: string, error: unknown): Promise<unknown> {
1794
+ if (Platform.OS !== 'android' || !isBleKeyMissingSupported()) return error;
1795
+ if (isBleStaleBondHardwareError(error) || !isAndroidLinkLossError(error)) return error;
1796
+ const linkStartedAt = this.androidLinkStartedAt.get(uuid);
1797
+ if (
1798
+ linkStartedAt === undefined ||
1799
+ Date.now() - linkStartedAt > ANDROID_KEY_MISSING_LINK_WINDOW_MS
1800
+ ) {
1801
+ return error;
1802
+ }
1803
+ if (!(await waitForBleKeyMissing(uuid, linkStartedAt, ANDROID_KEY_MISSING_GRACE_MS))) {
1804
+ return error;
1805
+ }
1806
+ Log?.debug('[ReactNativeBleTransport] Android key missing, bond is invalid:', uuid);
1807
+ return ERRORS.TypedError(HardwareErrorCode.BleBondInvalid, undefined, {
1808
+ phase: 'connect',
1809
+ reason: 'key_missing',
1810
+ });
1811
+ }
1812
+
1813
+ /**
1814
+ * Android 16+ encrypts a bonded link on its own right after connecting. Subscribing to
1815
+ * notifications before that finishes gets the CCCD write rejected, and the framework's retry
1816
+ * encrypts again with the same stale key; firmware that allows one failure per link then drops
1817
+ * it before the system can re-pair. Holding GATT until the result lets a lost bond be re-paired
1818
+ * in place. Without the platform signal this resolves immediately.
1819
+ */
1820
+ private async waitForAndroidLinkSecurity(uuid: string): Promise<AndroidLinkEncryption> {
1821
+ const linkStartedAt = this.androidLinkStartedAt.get(uuid);
1822
+ if (linkStartedAt === undefined) return 'unresolved';
1823
+ const waitStartedAt = Date.now();
1824
+ const result = await waitForAndroidLinkEncryption({
1825
+ deviceId: uuid,
1826
+ linkStartedAt,
1827
+ resultTimeoutMs: ANDROID_ENCRYPTION_RESULT_TIMEOUT_MS,
1828
+ repairTimeoutMs: ANDROID_SYSTEM_REPAIR_TIMEOUT_MS,
1829
+ keyMissingGraceMs: ANDROID_KEY_MISSING_GRACE_MS,
1830
+ signal: this.bondAbortController.signal,
1831
+ onRepairStarted: () => {
1832
+ Log?.debug(
1833
+ '[ReactNativeBleTransport] Android bond lost, waiting for system re-pairing:',
1834
+ uuid
1835
+ );
1836
+ },
1837
+ });
1838
+ Log?.debug('[ReactNativeBleTransport] Android link encryption', {
1839
+ connectIdSuffix: uuid.slice(-8),
1840
+ result,
1841
+ waitedMs: Date.now() - waitStartedAt,
1842
+ });
1843
+ return result;
1702
1844
  }
1703
1845
 
1704
1846
  private async callProtocolV1(
@@ -1966,6 +2108,9 @@ export default class ReactNativeBleTransport {
1966
2108
  this.androidPriorityResetTimers.forEach(timeout => clearTimeout(timeout));
1967
2109
  this.androidPriorityResetTimers.clear();
1968
2110
  this.androidHighPriorityDevices.clear();
2111
+ this.androidLinkStartedAt.clear();
2112
+ stopBleKeyMissingTracking();
2113
+ stopBleEncryptionTracking();
1969
2114
  const error = ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
1970
2115
  this.runPromise?.reject(error);
1971
2116
  this.runPromise = null;