@onekeyfe/hd-transport-react-native 1.2.0-alpha.9 → 1.2.0-alpha.90
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.
- package/dist/BleTransport.d.ts +2 -4
- package/dist/BleTransport.d.ts.map +1 -1
- package/dist/bleStrategy.d.ts +7 -2
- package/dist/bleStrategy.d.ts.map +1 -1
- package/dist/constants.d.ts +2 -2
- package/dist/constants.d.ts.map +1 -1
- package/dist/index.d.ts +114 -14
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +750 -312
- package/dist/subscribeBleOn.d.ts.map +1 -1
- package/dist/transportLog.d.ts +2 -0
- package/dist/transportLog.d.ts.map +1 -0
- package/dist/types.d.ts +1 -0
- package/dist/types.d.ts.map +1 -1
- package/jest.config.js +10 -0
- package/package.json +7 -6
- package/src/BleTransport.ts +10 -42
- package/src/__tests__/BleTransport.test.ts +114 -0
- package/src/__tests__/bleStrategy.test.ts +89 -6
- package/src/__tests__/connectTimeout.test.ts +292 -0
- package/src/__tests__/constants.test.ts +20 -0
- package/src/__tests__/enumerate.test.ts +132 -0
- package/src/__tests__/protocolReprobe.test.ts +132 -0
- package/src/__tests__/protocolV1SchemaFixture.ts +39 -0
- package/src/__tests__/protocolV2Link.test.ts +758 -46
- package/src/__tests__/staleCallTimeout.test.ts +210 -0
- package/src/__tests__/writePacketTimeout.test.ts +305 -0
- package/src/bleStrategy.ts +26 -36
- package/src/constants.ts +9 -14
- package/src/index.ts +990 -330
- package/src/subscribeBleOn.ts +0 -2
- package/src/transportLog.ts +1 -0
- package/src/types.ts +1 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"subscribeBleOn.d.ts","sourceRoot":"","sources":["../src/subscribeBleOn.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AAE7C,eAAO,MAAM,cAAc,eAAgB,aAAa,kBAAc,QAAQ,IAAI,
|
|
1
|
+
{"version":3,"file":"subscribeBleOn.d.ts","sourceRoot":"","sources":["../src/subscribeBleOn.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AAE7C,eAAO,MAAM,cAAc,eAAgB,aAAa,kBAAc,QAAQ,IAAI,CAmB9E,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"transportLog.d.ts","sourceRoot":"","sources":["../src/transportLog.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,sBAAsB,EAAE,MAAM,wBAAwB,CAAC"}
|
package/dist/types.d.ts
CHANGED
package/dist/types.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,wBAAwB,CAAC;AAE3D,YAAY,EAAE,UAAU,IAAI,aAAa,EAAE,MAAM,sBAAsB,CAAC;AAExE,MAAM,MAAM,gBAAgB,GAAG;IAC7B,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB,CAAC;AAEF,MAAM,MAAM,eAAe,GAAG;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,oBAAoB,CAAC,EAAE,OAAO,CAAC;IAC/B,gBAAgB,CAAC,EAAE,YAAY,CAAC;
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,wBAAwB,CAAC;AAE3D,YAAY,EAAE,UAAU,IAAI,aAAa,EAAE,MAAM,sBAAsB,CAAC;AAExE,MAAM,MAAM,gBAAgB,GAAG;IAC7B,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB,CAAC;AAEF,MAAM,MAAM,eAAe,GAAG;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,oBAAoB,CAAC,EAAE,OAAO,CAAC;IAC/B,gBAAgB,CAAC,EAAE,YAAY,CAAC;IAChC,YAAY,CAAC,EAAE,YAAY,CAAC;CAC7B,CAAC"}
|
package/jest.config.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
module.exports = {
|
|
2
|
+
preset: '../../jest.config.js',
|
|
3
|
+
testEnvironment: 'node',
|
|
4
|
+
modulePathIgnorePatterns: ['node_modules', '<rootDir>/dist'],
|
|
5
|
+
moduleNameMapper: {
|
|
6
|
+
// The workspace symlinks resolve to prebuilt dist outputs that can lag behind src.
|
|
7
|
+
'^@onekeyfe/hd-transport$': '<rootDir>/../hd-transport/src/index.ts',
|
|
8
|
+
'^@onekeyfe/hd-shared$': '<rootDir>/../shared/src/index.ts',
|
|
9
|
+
},
|
|
10
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@onekeyfe/hd-transport-react-native",
|
|
3
|
-
"version": "1.2.0-alpha.
|
|
3
|
+
"version": "1.2.0-alpha.90",
|
|
4
4
|
"homepage": "https://github.com/OneKeyHQ/hardware-js-sdk#readme",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -15,15 +15,16 @@
|
|
|
15
15
|
"scripts": {
|
|
16
16
|
"dev": "rimraf dist && rollup -c ../../build/rollup.config.js -w",
|
|
17
17
|
"build": "rimraf dist && rollup -c ../../build/rollup.config.js",
|
|
18
|
+
"test": "jest",
|
|
18
19
|
"lint": "eslint .",
|
|
19
20
|
"lint:fix": "eslint . --fix"
|
|
20
21
|
},
|
|
21
22
|
"dependencies": {
|
|
22
|
-
"@onekeyfe/hd-core": "1.2.0-alpha.
|
|
23
|
-
"@onekeyfe/hd-shared": "1.2.0-alpha.
|
|
24
|
-
"@onekeyfe/hd-transport": "1.2.0-alpha.
|
|
25
|
-
"@onekeyfe/react-native-ble-utils": "^0.1.
|
|
23
|
+
"@onekeyfe/hd-core": "1.2.0-alpha.90",
|
|
24
|
+
"@onekeyfe/hd-shared": "1.2.0-alpha.90",
|
|
25
|
+
"@onekeyfe/hd-transport": "1.2.0-alpha.90",
|
|
26
|
+
"@onekeyfe/react-native-ble-utils": "^0.1.6",
|
|
26
27
|
"react-native-ble-plx": "3.5.1"
|
|
27
28
|
},
|
|
28
|
-
"gitHead": "
|
|
29
|
+
"gitHead": "e8271225634f3058498af8beee06d9de705b6710"
|
|
29
30
|
}
|
package/src/BleTransport.ts
CHANGED
|
@@ -1,12 +1,5 @@
|
|
|
1
|
-
import { BleErrorCode } from 'react-native-ble-plx';
|
|
2
|
-
import { wait } from '@onekeyfe/hd-shared';
|
|
3
|
-
|
|
4
|
-
import { bleLogger } from './logger';
|
|
5
|
-
|
|
6
1
|
import type { Characteristic, Device, Subscription } from 'react-native-ble-plx';
|
|
7
2
|
|
|
8
|
-
const Log = bleLogger;
|
|
9
|
-
|
|
10
3
|
export default class BleTransport {
|
|
11
4
|
id: string;
|
|
12
5
|
|
|
@@ -14,7 +7,7 @@ export default class BleTransport {
|
|
|
14
7
|
|
|
15
8
|
device: Device;
|
|
16
9
|
|
|
17
|
-
mtuSize
|
|
10
|
+
mtuSize: number | undefined;
|
|
18
11
|
|
|
19
12
|
writeCharacteristic: Characteristic;
|
|
20
13
|
|
|
@@ -28,10 +21,6 @@ export default class BleTransport {
|
|
|
28
21
|
|
|
29
22
|
monitorToken?: number;
|
|
30
23
|
|
|
31
|
-
static MAX_RETRIES = 5;
|
|
32
|
-
|
|
33
|
-
static RETRY_DELAY = 2000;
|
|
34
|
-
|
|
35
24
|
constructor(
|
|
36
25
|
device: Device,
|
|
37
26
|
writeCharacteristic: Characteristic,
|
|
@@ -44,36 +33,15 @@ export default class BleTransport {
|
|
|
44
33
|
}
|
|
45
34
|
|
|
46
35
|
/**
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
36
|
+
* Bulk-transfer write (Protocol V1 FirmwareUpload / EmmcFileWrite only).
|
|
37
|
+
*
|
|
38
|
+
* Must stay writeWithoutResponse on every platform. writeWithResponse serialises
|
|
39
|
+
* each packet into its own connection-interval round trip, which on iOS turned a
|
|
40
|
+
* 1.7MB firmware upload (~13.5k packets) into a >10 minute transfer that never
|
|
41
|
+
* finished before the app-level timeout. Protocol V2 and ordinary V1 control
|
|
42
|
+
* messages pick their write type separately and are unaffected by this method.
|
|
51
43
|
*/
|
|
52
|
-
async writeWithRetry(data: string
|
|
53
|
-
|
|
54
|
-
await this.writeCharacteristic.writeWithoutResponse(data);
|
|
55
|
-
} catch (error) {
|
|
56
|
-
Log?.debug(
|
|
57
|
-
`Write retry attempt ${BleTransport.MAX_RETRIES - retryCount + 1}, error: ${error}`
|
|
58
|
-
);
|
|
59
|
-
if (retryCount > 0) {
|
|
60
|
-
await wait(BleTransport.RETRY_DELAY);
|
|
61
|
-
if (
|
|
62
|
-
error.errorCode === BleErrorCode.DeviceDisconnected ||
|
|
63
|
-
error.errorCode === BleErrorCode.CharacteristicNotFound
|
|
64
|
-
) {
|
|
65
|
-
try {
|
|
66
|
-
await this.device.connect();
|
|
67
|
-
await this.device.discoverAllServicesAndCharacteristics();
|
|
68
|
-
} catch (e) {
|
|
69
|
-
Log?.debug(`Connect or discoverAllServicesAndCharacteristics error: ${e}`);
|
|
70
|
-
}
|
|
71
|
-
} else {
|
|
72
|
-
Log?.debug(`writeCharacteristic error: ${error}`);
|
|
73
|
-
}
|
|
74
|
-
return this.writeWithRetry(data, retryCount - 1);
|
|
75
|
-
}
|
|
76
|
-
throw error;
|
|
77
|
-
}
|
|
44
|
+
async writeWithRetry(data: string): Promise<void> {
|
|
45
|
+
await this.writeCharacteristic.writeWithoutResponse(data);
|
|
78
46
|
}
|
|
79
47
|
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { BleErrorCode } from 'react-native-ble-plx';
|
|
2
|
+
import { Platform } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import BleTransport from '../BleTransport';
|
|
5
|
+
|
|
6
|
+
jest.mock(
|
|
7
|
+
'react-native',
|
|
8
|
+
() => ({
|
|
9
|
+
Platform: { OS: 'ios' },
|
|
10
|
+
}),
|
|
11
|
+
{ virtual: true }
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
jest.mock(
|
|
15
|
+
'react-native-ble-plx',
|
|
16
|
+
() => ({
|
|
17
|
+
BleErrorCode: {
|
|
18
|
+
DeviceDisconnected: 205,
|
|
19
|
+
CharacteristicNotFound: 404,
|
|
20
|
+
},
|
|
21
|
+
}),
|
|
22
|
+
{ virtual: true }
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
jest.mock('@onekeyfe/hd-shared', () => ({
|
|
26
|
+
...jest.requireActual('@onekeyfe/hd-shared'),
|
|
27
|
+
wait: jest.fn(() => Promise.resolve()),
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
describe('BleTransport side-effecting writes', () => {
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
Platform.OS = 'ios';
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('does not reconnect or replay after the device disconnects', async () => {
|
|
36
|
+
const error = Object.assign(new Error('device disconnected'), {
|
|
37
|
+
errorCode: BleErrorCode.DeviceDisconnected,
|
|
38
|
+
});
|
|
39
|
+
const writeCharacteristic = {
|
|
40
|
+
isWritableWithResponse: true,
|
|
41
|
+
writeWithResponse: jest.fn(() => Promise.resolve()),
|
|
42
|
+
writeWithoutResponse: jest.fn(() => Promise.reject(error)),
|
|
43
|
+
};
|
|
44
|
+
const device = {
|
|
45
|
+
id: 'classic-id',
|
|
46
|
+
connect: jest.fn(() => Promise.resolve()),
|
|
47
|
+
discoverAllServicesAndCharacteristics: jest.fn(() => Promise.resolve()),
|
|
48
|
+
};
|
|
49
|
+
const transport = new BleTransport(device as any, writeCharacteristic as any, {} as any);
|
|
50
|
+
|
|
51
|
+
await expect(transport.writeWithRetry('payload')).rejects.toBe(error);
|
|
52
|
+
|
|
53
|
+
expect(writeCharacteristic.writeWithoutResponse).toHaveBeenCalledTimes(1);
|
|
54
|
+
expect(device.connect).not.toHaveBeenCalled();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('keeps iOS bulk transfer on writeWithoutResponse even when the characteristic supports responses', async () => {
|
|
58
|
+
// writeWithResponse serialises every packet into its own connection-interval
|
|
59
|
+
// round trip. On a ~1.7MB firmware (~13.5k packets) that stalled the upload past
|
|
60
|
+
// the 600s app-level timeout, so bulk transfer must never opt into it.
|
|
61
|
+
const writeCharacteristic = {
|
|
62
|
+
isWritableWithResponse: true,
|
|
63
|
+
writeWithResponse: jest.fn(() => Promise.resolve()),
|
|
64
|
+
writeWithoutResponse: jest.fn(() => Promise.resolve()),
|
|
65
|
+
};
|
|
66
|
+
const transport = new BleTransport(
|
|
67
|
+
{ id: 'classic-id' } as any,
|
|
68
|
+
writeCharacteristic as any,
|
|
69
|
+
{} as any
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
await transport.writeWithRetry('payload');
|
|
73
|
+
|
|
74
|
+
expect(writeCharacteristic.writeWithResponse).not.toHaveBeenCalled();
|
|
75
|
+
expect(writeCharacteristic.writeWithoutResponse).toHaveBeenCalledWith('payload');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('keeps Android Protocol V1 writes on writeWithoutResponse', async () => {
|
|
79
|
+
Platform.OS = 'android';
|
|
80
|
+
const writeCharacteristic = {
|
|
81
|
+
isWritableWithResponse: true,
|
|
82
|
+
writeWithResponse: jest.fn(() => Promise.resolve()),
|
|
83
|
+
writeWithoutResponse: jest.fn(() => Promise.resolve()),
|
|
84
|
+
};
|
|
85
|
+
const transport = new BleTransport(
|
|
86
|
+
{ id: 'classic-id' } as any,
|
|
87
|
+
writeCharacteristic as any,
|
|
88
|
+
{} as any
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
await transport.writeWithRetry('payload');
|
|
92
|
+
|
|
93
|
+
expect(writeCharacteristic.writeWithResponse).not.toHaveBeenCalled();
|
|
94
|
+
expect(writeCharacteristic.writeWithoutResponse).toHaveBeenCalledWith('payload');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('uses writeWithoutResponse on iOS when the characteristic lacks response support', async () => {
|
|
98
|
+
const writeCharacteristic = {
|
|
99
|
+
isWritableWithResponse: false,
|
|
100
|
+
writeWithResponse: jest.fn(() => Promise.resolve()),
|
|
101
|
+
writeWithoutResponse: jest.fn(() => Promise.resolve()),
|
|
102
|
+
};
|
|
103
|
+
const transport = new BleTransport(
|
|
104
|
+
{ id: 'classic-id' } as any,
|
|
105
|
+
writeCharacteristic as any,
|
|
106
|
+
{} as any
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
await transport.writeWithRetry('payload');
|
|
110
|
+
|
|
111
|
+
expect(writeCharacteristic.writeWithResponse).not.toHaveBeenCalled();
|
|
112
|
+
expect(writeCharacteristic.writeWithoutResponse).toHaveBeenCalledWith('payload');
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
2
|
hasWritableCapability,
|
|
3
|
-
resolveBleWriteMode,
|
|
4
3
|
resolveProtocolV2PacketCapacity,
|
|
4
|
+
shouldRefreshNegotiatedMtu,
|
|
5
|
+
shouldWriteProtocolV2WithResponse,
|
|
5
6
|
} from '../bleStrategy';
|
|
6
7
|
|
|
7
8
|
describe('React Native BLE strategy', () => {
|
|
@@ -12,30 +13,54 @@ describe('React Native BLE strategy', () => {
|
|
|
12
13
|
};
|
|
13
14
|
|
|
14
15
|
expect(hasWritableCapability(characteristic)).toBe(true);
|
|
15
|
-
expect(resolveBleWriteMode(characteristic)).toBe('withoutResponse');
|
|
16
16
|
});
|
|
17
17
|
|
|
18
|
-
test('
|
|
18
|
+
test('uses the conservative ATT payload when MTU is unavailable', () => {
|
|
19
19
|
expect(
|
|
20
20
|
resolveProtocolV2PacketCapacity({
|
|
21
21
|
platform: 'android',
|
|
22
|
-
androidPacketLength: 192,
|
|
23
22
|
mtu: null,
|
|
24
23
|
})
|
|
25
24
|
).toBe(20);
|
|
26
25
|
});
|
|
27
26
|
|
|
27
|
+
test('only refreshes an unavailable or default MTU snapshot', () => {
|
|
28
|
+
expect(shouldRefreshNegotiatedMtu(undefined)).toBe(true);
|
|
29
|
+
expect(shouldRefreshNegotiatedMtu(23)).toBe(true);
|
|
30
|
+
expect(shouldRefreshNegotiatedMtu(185)).toBe(false);
|
|
31
|
+
expect(shouldRefreshNegotiatedMtu(247)).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
|
|
28
34
|
test('caps Android packet length by negotiated MTU payload', () => {
|
|
29
35
|
expect(
|
|
30
36
|
resolveProtocolV2PacketCapacity({
|
|
31
37
|
platform: 'android',
|
|
32
|
-
androidPacketLength:
|
|
38
|
+
androidPacketLength: 244,
|
|
33
39
|
mtu: 100,
|
|
34
40
|
})
|
|
35
41
|
).toBe(97);
|
|
36
42
|
});
|
|
37
43
|
|
|
38
|
-
test('keeps
|
|
44
|
+
test('keeps the default Android packet length within the validated ceiling', () => {
|
|
45
|
+
expect(
|
|
46
|
+
resolveProtocolV2PacketCapacity({
|
|
47
|
+
platform: 'android',
|
|
48
|
+
mtu: 517,
|
|
49
|
+
})
|
|
50
|
+
).toBe(244);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('allows an explicit Android packet length override for validated experiments', () => {
|
|
54
|
+
expect(
|
|
55
|
+
resolveProtocolV2PacketCapacity({
|
|
56
|
+
platform: 'android',
|
|
57
|
+
androidPacketLength: 514,
|
|
58
|
+
mtu: 517,
|
|
59
|
+
})
|
|
60
|
+
).toBe(514);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('caps iOS packet length by the system-negotiated write payload', () => {
|
|
39
64
|
expect(
|
|
40
65
|
resolveProtocolV2PacketCapacity({
|
|
41
66
|
platform: 'ios',
|
|
@@ -44,4 +69,62 @@ describe('React Native BLE strategy', () => {
|
|
|
44
69
|
})
|
|
45
70
|
).toBe(244);
|
|
46
71
|
});
|
|
72
|
+
|
|
73
|
+
test('uses the reported iOS MTU without a compatibility fallback', () => {
|
|
74
|
+
expect(
|
|
75
|
+
resolveProtocolV2PacketCapacity({
|
|
76
|
+
platform: 'ios',
|
|
77
|
+
iosPacketLength: 128,
|
|
78
|
+
mtu: 23,
|
|
79
|
+
})
|
|
80
|
+
).toBe(20);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('uses a smaller system-negotiated iOS write payload', () => {
|
|
84
|
+
expect(
|
|
85
|
+
resolveProtocolV2PacketCapacity({
|
|
86
|
+
platform: 'ios',
|
|
87
|
+
iosPacketLength: 244,
|
|
88
|
+
mtu: 185,
|
|
89
|
+
})
|
|
90
|
+
).toBe(182);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('uses withoutResponse for a high-volume write unless explicitly overridden', () => {
|
|
94
|
+
const characteristic = {
|
|
95
|
+
isWritableWithResponse: true,
|
|
96
|
+
isWritableWithoutResponse: true,
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
expect(
|
|
100
|
+
shouldWriteProtocolV2WithResponse({
|
|
101
|
+
platform: 'ios',
|
|
102
|
+
highThroughput: true,
|
|
103
|
+
requestedWithResponse: false,
|
|
104
|
+
characteristic,
|
|
105
|
+
})
|
|
106
|
+
).toBe(false);
|
|
107
|
+
expect(
|
|
108
|
+
shouldWriteProtocolV2WithResponse({
|
|
109
|
+
platform: 'ios',
|
|
110
|
+
highThroughput: true,
|
|
111
|
+
requestedWithResponse: true,
|
|
112
|
+
characteristic,
|
|
113
|
+
})
|
|
114
|
+
).toBe(true);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('falls back to withResponse when withoutResponse is unavailable', () => {
|
|
118
|
+
expect(
|
|
119
|
+
shouldWriteProtocolV2WithResponse({
|
|
120
|
+
platform: 'ios',
|
|
121
|
+
highThroughput: true,
|
|
122
|
+
requestedWithResponse: false,
|
|
123
|
+
characteristic: {
|
|
124
|
+
isWritableWithResponse: true,
|
|
125
|
+
isWritableWithoutResponse: false,
|
|
126
|
+
},
|
|
127
|
+
})
|
|
128
|
+
).toBe(true);
|
|
129
|
+
});
|
|
47
130
|
});
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { EventEmitter } from 'events';
|
|
2
|
+
import { BleErrorCode } from 'react-native-ble-plx';
|
|
3
|
+
import { HardwareErrorCode } from '@onekeyfe/hd-shared';
|
|
4
|
+
|
|
5
|
+
import ReactNativeBleTransport, {
|
|
6
|
+
BLE_CONNECT_TIMEOUT_MANAGER_RESET_THRESHOLD,
|
|
7
|
+
BLE_CONNECT_TIMEOUT_MS,
|
|
8
|
+
BLE_GATT_SETUP_TIMEOUT_MS,
|
|
9
|
+
} from '../index';
|
|
10
|
+
import protocolV1Schema from './protocolV1SchemaFixture';
|
|
11
|
+
|
|
12
|
+
jest.mock(
|
|
13
|
+
'react-native',
|
|
14
|
+
() => ({
|
|
15
|
+
Platform: { OS: 'ios', select: (spec: Record<string, unknown>) => spec.ios },
|
|
16
|
+
PermissionsAndroid: {
|
|
17
|
+
PERMISSIONS: {},
|
|
18
|
+
RESULTS: {},
|
|
19
|
+
request: jest.fn(),
|
|
20
|
+
requestMultiple: jest.fn(),
|
|
21
|
+
},
|
|
22
|
+
}),
|
|
23
|
+
{ virtual: true }
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
jest.mock('react-native-ble-plx', () => ({
|
|
27
|
+
BleATTErrorCode: { InvalidHandle: 1, UnlikelyError: 14 },
|
|
28
|
+
BleError: Error,
|
|
29
|
+
BleErrorCode: {
|
|
30
|
+
DeviceDisconnected: 201,
|
|
31
|
+
OperationStartFailed: 601,
|
|
32
|
+
DeviceMTUChangeFailed: 401,
|
|
33
|
+
OperationCancelled: 2,
|
|
34
|
+
OperationTimedOut: 3,
|
|
35
|
+
DeviceAlreadyConnected: 203,
|
|
36
|
+
},
|
|
37
|
+
BleManager: jest.fn(),
|
|
38
|
+
ScanMode: { LowLatency: 2 },
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
jest.mock('@onekeyfe/react-native-ble-utils', () => ({
|
|
42
|
+
__esModule: true,
|
|
43
|
+
default: {
|
|
44
|
+
getConnectedPeripherals: jest.fn(() => Promise.resolve([])),
|
|
45
|
+
getBondedPeripherals: jest.fn(() => Promise.resolve([])),
|
|
46
|
+
pairDevice: jest.fn(() => Promise.resolve()),
|
|
47
|
+
},
|
|
48
|
+
}));
|
|
49
|
+
|
|
50
|
+
const UUID = 'stalled-connect-device';
|
|
51
|
+
|
|
52
|
+
const flush = () =>
|
|
53
|
+
new Promise(resolve => {
|
|
54
|
+
setImmediate(resolve);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
async function advanceUntil(settled: () => boolean, totalMs: number, stepMs = 250) {
|
|
58
|
+
for (let elapsed = 0; elapsed < totalMs; elapsed += stepMs) {
|
|
59
|
+
jest.advanceTimersByTime(stepMs);
|
|
60
|
+
// eslint-disable-next-line no-await-in-loop
|
|
61
|
+
await flush();
|
|
62
|
+
if (settled()) return;
|
|
63
|
+
}
|
|
64
|
+
throw new Error(`fake timers exhausted after ${totalMs}ms before the connect settled`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** A device whose native connect() never settles — the observed iOS failure mode. */
|
|
68
|
+
function createHarness(connectImpl: () => Promise<unknown>) {
|
|
69
|
+
const connect = jest.fn(connectImpl);
|
|
70
|
+
const writeCharacteristic = {
|
|
71
|
+
uuid: '00000002-0000-1000-8000-00805f9b34fb',
|
|
72
|
+
isWritableWithResponse: true,
|
|
73
|
+
};
|
|
74
|
+
const notifyCharacteristic = {
|
|
75
|
+
uuid: '00000003-0000-1000-8000-00805f9b34fb',
|
|
76
|
+
isNotifiable: true,
|
|
77
|
+
};
|
|
78
|
+
const device = {
|
|
79
|
+
id: UUID,
|
|
80
|
+
name: 'OneKey Classic',
|
|
81
|
+
localName: 'OneKey Classic',
|
|
82
|
+
serviceUUIDs: ['00000001-0000-1000-8000-00805f9b34fb'],
|
|
83
|
+
isConnected: jest.fn(() => Promise.resolve(false)),
|
|
84
|
+
cancelConnection: jest.fn(() => Promise.resolve()),
|
|
85
|
+
connect,
|
|
86
|
+
discoverAllServicesAndCharacteristics: jest.fn(() => Promise.resolve()),
|
|
87
|
+
characteristicsForService: jest.fn(() =>
|
|
88
|
+
Promise.resolve([writeCharacteristic, notifyCharacteristic])
|
|
89
|
+
),
|
|
90
|
+
services: jest.fn(() => Promise.resolve([])),
|
|
91
|
+
onDisconnected: jest.fn(() => ({ remove: jest.fn() })),
|
|
92
|
+
};
|
|
93
|
+
const transport = new ReactNativeBleTransport({ scanTimeout: 1 });
|
|
94
|
+
const bleManager = {
|
|
95
|
+
devices: jest.fn(() => Promise.resolve([device])),
|
|
96
|
+
connectedDevices: jest.fn(() => Promise.resolve([])),
|
|
97
|
+
connectToDevice: jest.fn(connectImpl),
|
|
98
|
+
cancelTransaction: jest.fn(() => Promise.resolve()),
|
|
99
|
+
cancelDeviceConnection: jest.fn(() => Promise.resolve()),
|
|
100
|
+
onStateChange: jest.fn((listener: (state: string) => void) => {
|
|
101
|
+
// Dispatch asynchronously: subscribeBleOn wires its own cleanup after
|
|
102
|
+
// registering, so a synchronous callback would run before it is ready.
|
|
103
|
+
setImmediate(() => listener('PoweredOn'));
|
|
104
|
+
return { remove: jest.fn() };
|
|
105
|
+
}),
|
|
106
|
+
state: jest.fn(() => Promise.resolve('PoweredOn')),
|
|
107
|
+
startDeviceScan: jest.fn(),
|
|
108
|
+
stopDeviceScan: jest.fn(),
|
|
109
|
+
};
|
|
110
|
+
(transport as any).blePlxManager = bleManager;
|
|
111
|
+
transport.init(
|
|
112
|
+
{ debug: jest.fn(), error: jest.fn(), warn: jest.fn() } as any,
|
|
113
|
+
new EventEmitter()
|
|
114
|
+
);
|
|
115
|
+
transport.configure(protocolV1Schema);
|
|
116
|
+
return { transport, device, bleManager, connect };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
describe('BLE connect timeout', () => {
|
|
120
|
+
beforeAll(() => {
|
|
121
|
+
jest.useFakeTimers({ doNotFake: ['setImmediate', 'performance'] });
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
afterAll(() => {
|
|
125
|
+
jest.useRealTimers();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
afterEach(() => {
|
|
129
|
+
jest.clearAllTimers();
|
|
130
|
+
jest.restoreAllMocks();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('a native connect that never settles is bounded instead of blocking forever', async () => {
|
|
134
|
+
// iOS applies its own connect timeout on a serial queue; when that queue is busy
|
|
135
|
+
// the timeout never fires and acquire() blocks until the app-level 60s timeout.
|
|
136
|
+
const { transport } = createHarness(
|
|
137
|
+
() =>
|
|
138
|
+
new Promise(() => {
|
|
139
|
+
// never settles
|
|
140
|
+
})
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
const errors: Array<{ errorCode?: unknown }> = [];
|
|
144
|
+
let settled = false;
|
|
145
|
+
transport.acquire({ uuid: UUID }).catch(e => {
|
|
146
|
+
errors.push(e);
|
|
147
|
+
settled = true;
|
|
148
|
+
});
|
|
149
|
+
await flush();
|
|
150
|
+
|
|
151
|
+
await advanceUntil(() => settled, BLE_CONNECT_TIMEOUT_MS + 5000);
|
|
152
|
+
|
|
153
|
+
expect(errors).toHaveLength(1);
|
|
154
|
+
expect(errors[0]?.errorCode).toBe(HardwareErrorCode.BleConnectedError);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('the connect budget leaves generous headroom over a healthy connect', () => {
|
|
158
|
+
// Healthy connects finish in ~2-3s (the native budget is 3s); this backstop only
|
|
159
|
+
// fires when the native timeout itself fails to.
|
|
160
|
+
expect(BLE_CONNECT_TIMEOUT_MS).toBeGreaterThanOrEqual(6000);
|
|
161
|
+
expect(BLE_CONNECT_TIMEOUT_MS).toBeLessThanOrEqual(12000);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('a stalled connect is abandoned natively so the next attempt is not cancelled by it', async () => {
|
|
165
|
+
const { transport, bleManager } = createHarness(
|
|
166
|
+
() =>
|
|
167
|
+
new Promise(() => {
|
|
168
|
+
// never settles
|
|
169
|
+
})
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
let settled = false;
|
|
173
|
+
transport.acquire({ uuid: UUID }).catch(() => {
|
|
174
|
+
settled = true;
|
|
175
|
+
});
|
|
176
|
+
await flush();
|
|
177
|
+
await advanceUntil(() => settled, BLE_CONNECT_TIMEOUT_MS + 5000);
|
|
178
|
+
|
|
179
|
+
expect(bleManager.cancelDeviceConnection).toHaveBeenCalledWith(UUID);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('repeated stalled connects recreate the BLE manager', async () => {
|
|
183
|
+
const { transport, bleManager } = createHarness(
|
|
184
|
+
() =>
|
|
185
|
+
new Promise(() => {
|
|
186
|
+
// never settles
|
|
187
|
+
})
|
|
188
|
+
);
|
|
189
|
+
const destroy = jest.fn();
|
|
190
|
+
(bleManager as unknown as { destroy: jest.Mock }).destroy = destroy;
|
|
191
|
+
|
|
192
|
+
for (let attempt = 0; attempt < BLE_CONNECT_TIMEOUT_MANAGER_RESET_THRESHOLD; attempt += 1) {
|
|
193
|
+
let settled = false;
|
|
194
|
+
transport.acquire({ uuid: UUID }).catch(() => {
|
|
195
|
+
settled = true;
|
|
196
|
+
});
|
|
197
|
+
// eslint-disable-next-line no-await-in-loop
|
|
198
|
+
await flush();
|
|
199
|
+
// eslint-disable-next-line no-await-in-loop
|
|
200
|
+
await advanceUntil(() => settled, BLE_CONNECT_TIMEOUT_MS + 5000);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
expect(destroy).toHaveBeenCalledTimes(1);
|
|
204
|
+
expect((transport as unknown as { blePlxManager?: unknown }).blePlxManager).toBeUndefined();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test('a recreated BLE manager starts with fresh timeout budgets for every device', () => {
|
|
208
|
+
const { transport, bleManager } = createHarness(() => Promise.resolve());
|
|
209
|
+
(bleManager as unknown as { destroy: jest.Mock }).destroy = jest.fn();
|
|
210
|
+
(transport as any).connectionSetupTimeoutCounts.set('setup-device', 1);
|
|
211
|
+
(transport as any).writeTimeoutCounts.set('write-device', 1);
|
|
212
|
+
|
|
213
|
+
(transport as any).resetPlxManager();
|
|
214
|
+
|
|
215
|
+
expect((transport as any).connectionSetupTimeoutCounts.size).toBe(0);
|
|
216
|
+
expect((transport as any).writeTimeoutCounts.size).toBe(0);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test('native connect timeouts contribute to the same manager reset budget', async () => {
|
|
220
|
+
const { transport, bleManager } = createHarness(() => Promise.resolve());
|
|
221
|
+
const destroy = jest.fn();
|
|
222
|
+
(bleManager as unknown as { destroy: jest.Mock }).destroy = destroy;
|
|
223
|
+
const nativeTimeout = Object.assign(new Error('Operation timed out'), {
|
|
224
|
+
errorCode: BleErrorCode.OperationTimedOut,
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
for (let attempt = 0; attempt < BLE_CONNECT_TIMEOUT_MANAGER_RESET_THRESHOLD; attempt += 1) {
|
|
228
|
+
// eslint-disable-next-line no-await-in-loop
|
|
229
|
+
await expect(
|
|
230
|
+
(transport as any).connectWithTimeout(UUID, () => Promise.reject(nativeTimeout))
|
|
231
|
+
).rejects.toBe(nativeTimeout);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
expect(destroy).toHaveBeenCalledTimes(1);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test('GATT discovery is bounded and abandons the native connection', async () => {
|
|
238
|
+
const { transport, device, bleManager } = createHarness(() => Promise.resolve());
|
|
239
|
+
device.discoverAllServicesAndCharacteristics.mockImplementation(
|
|
240
|
+
() =>
|
|
241
|
+
new Promise(() => {
|
|
242
|
+
// never settles
|
|
243
|
+
})
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
const errors: Array<{ errorCode?: unknown }> = [];
|
|
247
|
+
let settled = false;
|
|
248
|
+
(transport as any).resolveCharacteristicsWithTimeout(UUID, device).catch((error: unknown) => {
|
|
249
|
+
errors.push(error as { errorCode?: unknown });
|
|
250
|
+
settled = true;
|
|
251
|
+
});
|
|
252
|
+
await flush();
|
|
253
|
+
await advanceUntil(() => settled, BLE_GATT_SETUP_TIMEOUT_MS + 5000);
|
|
254
|
+
|
|
255
|
+
expect(errors).toHaveLength(1);
|
|
256
|
+
expect(errors[0]?.errorCode).toBe(HardwareErrorCode.BleConnectedError);
|
|
257
|
+
expect(bleManager.cancelDeviceConnection).toHaveBeenCalledWith(UUID);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test('a successful GATT retry clears the timeout budget before an abandoned call settles', async () => {
|
|
261
|
+
const { transport, device, bleManager } = createHarness(() => Promise.resolve());
|
|
262
|
+
let resolveAbandonedDiscovery: (() => void) | undefined;
|
|
263
|
+
device.discoverAllServicesAndCharacteristics
|
|
264
|
+
.mockImplementationOnce(
|
|
265
|
+
() =>
|
|
266
|
+
new Promise<void>(resolve => {
|
|
267
|
+
resolveAbandonedDiscovery = resolve;
|
|
268
|
+
})
|
|
269
|
+
)
|
|
270
|
+
.mockResolvedValueOnce(undefined);
|
|
271
|
+
|
|
272
|
+
let firstSettled = false;
|
|
273
|
+
(transport as any).resolveCharacteristicsWithTimeout(UUID, device).catch(() => {
|
|
274
|
+
firstSettled = true;
|
|
275
|
+
});
|
|
276
|
+
await flush();
|
|
277
|
+
await advanceUntil(() => firstSettled, BLE_GATT_SETUP_TIMEOUT_MS + 5000);
|
|
278
|
+
|
|
279
|
+
await expect(
|
|
280
|
+
(transport as any).resolveCharacteristicsWithTimeout(UUID, device)
|
|
281
|
+
).resolves.toMatchObject({
|
|
282
|
+
writeCharacteristic: expect.any(Object),
|
|
283
|
+
notifyCharacteristic: expect.any(Object),
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
resolveAbandonedDiscovery?.();
|
|
287
|
+
await flush();
|
|
288
|
+
|
|
289
|
+
expect(bleManager.cancelDeviceConnection).toHaveBeenCalledTimes(1);
|
|
290
|
+
expect((transport as any).connectionSetupTimeoutCounts.has(UUID)).toBe(false);
|
|
291
|
+
});
|
|
292
|
+
});
|