@onekeyfe/hd-core 1.2.2-alpha.0 → 1.2.2-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.
Files changed (38) hide show
  1. package/__tests__/AllNetworkGetAddressBase.tracing.test.ts +2 -2
  2. package/__tests__/device-lifecycle-events.test.ts +67 -0
  3. package/__tests__/deviceUploadNft.test.ts +27 -0
  4. package/__tests__/firmware-update/firmware-update-v4-install-poll.test.ts +322 -21
  5. package/__tests__/open-wallet-session.test.ts +613 -80
  6. package/__tests__/pro2HostAssetPackage.test.ts +108 -1
  7. package/__tests__/protocol-v2.test.ts +231 -49
  8. package/__tests__/protocolV2FileWrite.test.ts +40 -0
  9. package/dist/api/FirmwareUpdateV4.d.ts +2 -0
  10. package/dist/api/FirmwareUpdateV4.d.ts.map +1 -1
  11. package/dist/api/OpenWalletSession.d.ts.map +1 -1
  12. package/dist/api/allnetwork/AllNetworkGetAddressBase.d.ts.map +1 -1
  13. package/dist/api/helpers/protocolV2FileWrite.d.ts +1 -0
  14. package/dist/api/helpers/protocolV2FileWrite.d.ts.map +1 -1
  15. package/dist/api/protocol-v2/DeviceUploadNft.d.ts.map +1 -1
  16. package/dist/api/protocol-v2/DeviceUploadWallpaper.d.ts.map +1 -1
  17. package/dist/core/RequestQueue.d.ts +2 -0
  18. package/dist/core/RequestQueue.d.ts.map +1 -1
  19. package/dist/core/index.d.ts +2 -1
  20. package/dist/core/index.d.ts.map +1 -1
  21. package/dist/index.d.ts +12 -1
  22. package/dist/index.js +1454 -1144
  23. package/dist/protocols/protocol-v2/walletSession.d.ts.map +1 -1
  24. package/dist/utils/patch.d.ts +1 -1
  25. package/dist/utils/patch.d.ts.map +1 -1
  26. package/dist/utils/pro2HostAssetPackage.d.ts.map +1 -1
  27. package/package.json +4 -4
  28. package/src/api/FirmwareUpdateV4.ts +73 -23
  29. package/src/api/OpenWalletSession.ts +0 -3
  30. package/src/api/allnetwork/AllNetworkGetAddressBase.ts +3 -1
  31. package/src/api/helpers/protocolV2FileWrite.ts +11 -5
  32. package/src/api/protocol-v2/DeviceUploadNft.ts +4 -1
  33. package/src/api/protocol-v2/DeviceUploadWallpaper.ts +17 -3
  34. package/src/core/RequestQueue.ts +16 -1
  35. package/src/core/index.ts +48 -21
  36. package/src/data/messages/messages-protocol-v2.json +1471 -1367
  37. package/src/protocols/protocol-v2/walletSession.ts +157 -38
  38. package/src/utils/pro2HostAssetPackage.ts +143 -58
@@ -1,3 +1,4 @@
1
+ /* eslint-disable no-bitwise -- LZ4 decoding and deterministic test data generation require bitwise operations. */
1
2
  import { sha3_512 } from '@noble/hashes/sha3';
2
3
 
3
4
  import {
@@ -5,6 +6,82 @@ import {
5
6
  supportsPro2HostAssetPackage,
6
7
  } from '../src/utils/pro2HostAssetPackage';
7
8
 
9
+ const decodeRawLz4Block = (compressed: Uint8Array, expectedLength: number) => {
10
+ const output = new Uint8Array(expectedLength);
11
+ let inputOffset = 0;
12
+ let outputOffset = 0;
13
+
14
+ const readLength = (initialLength: number) => {
15
+ let length = initialLength;
16
+ if (length === 15) {
17
+ let extension = 255;
18
+ while (extension === 255) {
19
+ extension = compressed[inputOffset];
20
+ inputOffset += 1;
21
+ length += extension;
22
+ }
23
+ }
24
+ return length;
25
+ };
26
+
27
+ while (inputOffset < compressed.byteLength) {
28
+ const token = compressed[inputOffset];
29
+ inputOffset += 1;
30
+ const literalLength = readLength(token >>> 4);
31
+ output.set(compressed.subarray(inputOffset, inputOffset + literalLength), outputOffset);
32
+ inputOffset += literalLength;
33
+ outputOffset += literalLength;
34
+ if (inputOffset >= compressed.byteLength) break;
35
+
36
+ const matchOffset = compressed[inputOffset] | (compressed[inputOffset + 1] << 8);
37
+ inputOffset += 2;
38
+ const matchLength = readLength(token & 0x0f) + 4;
39
+ for (let index = 0; index < matchLength; index += 1) {
40
+ output[outputOffset] = output[outputOffset - matchOffset];
41
+ outputOffset += 1;
42
+ }
43
+ }
44
+
45
+ expect(outputOffset).toBe(expectedLength);
46
+ return output;
47
+ };
48
+
49
+ const decodeFirstPackageEntry = (packageData: Uint8Array, rawLength: number) => {
50
+ const containerHeaderSize = 0x5f90;
51
+ const archive = packageData.subarray(containerHeaderSize);
52
+ const archiveView = new DataView(archive.buffer, archive.byteOffset, archive.byteLength);
53
+ const compressedOffset = archiveView.getUint32(42 + 0x100, true);
54
+ const compressed = archive.subarray(compressedOffset);
55
+ const compressedView = new DataView(
56
+ compressed.buffer,
57
+ compressed.byteOffset,
58
+ compressed.byteLength
59
+ );
60
+ const blockCount = compressedView.getUint16(0, true);
61
+ const blockSize = 1 << compressedView.getUint16(2, true);
62
+ let blockOffset = 8 + blockCount * 4;
63
+ const decodedBlocks: Uint8Array[] = [];
64
+
65
+ for (let index = 0; index < blockCount; index += 1) {
66
+ const compressedLength = compressedView.getUint32(8 + index * 4, true);
67
+ const expectedLength = Math.min(blockSize, rawLength - index * blockSize);
68
+ decodedBlocks.push(
69
+ decodeRawLz4Block(
70
+ compressed.subarray(blockOffset, blockOffset + compressedLength),
71
+ expectedLength
72
+ )
73
+ );
74
+ blockOffset += compressedLength;
75
+ }
76
+
77
+ const decoded = new Uint8Array(rawLength);
78
+ decodedBlocks.reduce((offset, block) => {
79
+ decoded.set(block, offset);
80
+ return offset + block.byteLength;
81
+ }, 0);
82
+ return decoded;
83
+ };
84
+
8
85
  describe('Pro2 host asset package', () => {
9
86
  test('builds the unsigned RESOURCE container and LZ4-blocked archive expected by firmware', () => {
10
87
  const raw = new TextEncoder().encode('123456789');
@@ -40,12 +117,42 @@ describe('Pro2 host asset package', () => {
40
117
 
41
118
  const compressedOffset = archiveView.getUint32(42 + 0x100, true);
42
119
  expect(archiveView.getUint16(compressedOffset, true)).toBe(1);
43
- expect(archiveView.getUint16(compressedOffset + 2, true)).toBe(12);
120
+ expect(archiveView.getUint16(compressedOffset + 2, true)).toBe(14);
44
121
  expect(archiveView.getUint32(compressedOffset + 4, true)).toBe(0);
45
122
  expect(archiveView.getUint32(compressedOffset + 8, true)).toBe(10);
46
123
  expect(payload.subarray(compressedOffset + 12)).toEqual(new Uint8Array([0x90, ...raw]));
47
124
  });
48
125
 
126
+ test('round-trips multi-block data byte-for-byte with best-match compression', () => {
127
+ const raw = Uint8Array.from({ length: 16_384 * 3 + 137 }, (_, index) => {
128
+ const column = index % 604;
129
+ const row = Math.floor(index / 604);
130
+ return (column * 31 + row * 17) & 0xff;
131
+ });
132
+
133
+ const packageData = buildPro2HostAssetPackage([{ name: 'wallpaper.bin', data: raw }]);
134
+
135
+ expect(decodeFirstPackageEntry(packageData, raw.byteLength)).toEqual(raw);
136
+ });
137
+
138
+ test('falls back to 8 KiB blocks when a compressed 16 KiB block exceeds firmware capacity', () => {
139
+ let state = 0x12345678;
140
+ const raw = Uint8Array.from({ length: 16_384 }, () => {
141
+ state ^= state << 13;
142
+ state ^= state >>> 17;
143
+ state ^= state << 5;
144
+ return state & 0xff;
145
+ });
146
+
147
+ const packageData = buildPro2HostAssetPackage([{ name: 'wallpaper.bin', data: raw }]);
148
+ const archive = packageData.subarray(0x5f90);
149
+ const archiveView = new DataView(archive.buffer, archive.byteOffset, archive.byteLength);
150
+ const compressedOffset = archiveView.getUint32(42 + 0x100, true);
151
+
152
+ expect(archiveView.getUint16(compressedOffset + 2, true)).toBe(13);
153
+ expect(decodeFirstPackageEntry(packageData, raw.byteLength)).toEqual(raw);
154
+ });
155
+
49
156
  test.each([
50
157
  ['1.0.0', false],
51
158
  ['1.0.1-beta.1', false],
@@ -7,6 +7,7 @@ import { encode as encodeJpeg } from 'jpeg-js';
7
7
  import {
8
8
  DeviceRebootType,
9
9
  DeviceSessionPinType,
10
+ DeviceSessionSeedDomain,
10
11
  DeviceSettingsPage,
11
12
  DeviceType,
12
13
  } from '@onekeyfe/hd-transport';
@@ -255,6 +256,9 @@ describe('DeviceUploadWallpaper', () => {
255
256
  });
256
257
 
257
258
  test('uploads and applies the fixed wallpaper package on firmware 1.0.1', async () => {
259
+ const getSettingsSpy = jest
260
+ .spyOn(DataManager, 'getSettings')
261
+ .mockReturnValue('react-native' as any);
258
262
  const typedCall = jest.fn().mockImplementation((request, _response, params) => {
259
263
  if (request === 'FilesystemDirMake') return { message: {} };
260
264
  if (request === 'FilesystemFileWrite') {
@@ -271,17 +275,24 @@ describe('DeviceUploadWallpaper', () => {
271
275
  payload: {
272
276
  method: 'deviceUploadWallpaper',
273
277
  jpegBase64: createJpegBase64(604, 1024),
278
+ fileName: 'ignored-on-package-firmware.bin',
274
279
  },
275
280
  });
276
281
  const device = stubWallpaperDevice({
277
282
  commands: { typedCall },
283
+ state: { versions: { firmware: '1.0.1' } },
278
284
  getCurrentFirmwareVersionString: jest.fn(() => '1.0.1'),
279
285
  });
280
286
  (method as any).device = device;
281
287
  method.postMessage = jest.fn();
282
288
 
283
- method.init();
284
- const result = await method.run();
289
+ let result;
290
+ try {
291
+ method.init();
292
+ result = await method.run();
293
+ } finally {
294
+ getSettingsSpy.mockRestore();
295
+ }
285
296
 
286
297
  const fileWrites = typedCall.mock.calls.filter(call => call[0] === 'FilesystemFileWrite');
287
298
  expect(new Set(fileWrites.map(call => call[2].file.path))).toEqual(
@@ -290,6 +301,7 @@ describe('DeviceUploadWallpaper', () => {
290
301
  expect(fileWrites[0][2].file.data.subarray(0, 4)).toEqual(
291
302
  new Uint8Array([0x4f, 0x4b, 0x50, 0x50])
292
303
  );
304
+ expect(fileWrites[0][2].file.data).toHaveLength(1960);
293
305
  expect(typedCall).toHaveBeenLastCalledWith('DeviceSettingsSet', 'Success', {
294
306
  settings: { wallpaper_path: 'vol1:/wallpapers/wallpaper.okpkg' },
295
307
  });
@@ -300,6 +312,40 @@ describe('DeviceUploadWallpaper', () => {
300
312
  });
301
313
  });
302
314
 
315
+ test('keeps the legacy wallpaper path on a 1.0.1 prerelease', async () => {
316
+ const typedCall = jest.fn().mockImplementation((request, _response, params) => {
317
+ if (request === 'FilesystemDirMake') return { message: {} };
318
+ if (request === 'FilesystemFileWrite') {
319
+ const file = params.file as { data: Uint8Array; offset: number };
320
+ return { message: { processed_byte: file.offset + file.data.byteLength } };
321
+ }
322
+ if (request === 'DeviceSettingsSet') return { message: {} };
323
+ throw new Error(`Unexpected request: ${request}`);
324
+ });
325
+ const method = new DeviceUploadWallpaper({
326
+ id: 1,
327
+ payload: {
328
+ method: 'deviceUploadWallpaper',
329
+ jpegBase64: createJpegBase64(604, 1024),
330
+ fileName: 'prerelease-wallpaper.bin',
331
+ },
332
+ });
333
+ const device = stubWallpaperDevice({
334
+ commands: { typedCall },
335
+ state: { versions: { firmware: '1.0.1-beta.1' } },
336
+ });
337
+ (method as any).device = device;
338
+
339
+ method.init();
340
+ const result = await method.run();
341
+
342
+ expect(result.path).toBe('vol1:/wallpapers/prerelease-wallpaper.bin');
343
+ const fileWrites = typedCall.mock.calls.filter(call => call[0] === 'FilesystemFileWrite');
344
+ expect(new Set(fileWrites.map(call => call[2].file.path))).toEqual(
345
+ new Set(['vol1:/wallpapers/prerelease-wallpaper.bin'])
346
+ );
347
+ });
348
+
303
349
  test('文件上传失败时不修改 wallpaper_path', async () => {
304
350
  const typedCall = jest.fn().mockImplementation(request => {
305
351
  if (request === 'FilesystemDirMake') return { message: {} };
@@ -1079,10 +1125,9 @@ describe('Protocol V2 feature adapter', () => {
1079
1125
  expect(typedCall).toHaveBeenCalledWith('DeviceSessionAskPassphrase', 'Success', {
1080
1126
  passphrase: '',
1081
1127
  on_device: false,
1128
+ seed_domains: [DeviceSessionSeedDomain.SeedDomain_Standard],
1082
1129
  });
1083
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
1084
- seed_domains: [],
1085
- });
1130
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
1086
1131
  device.passphraseState = 'state-1';
1087
1132
  expect(device.getInternalState()).toBe('session-1');
1088
1133
  });
@@ -1135,13 +1180,10 @@ describe('Protocol V2 feature adapter', () => {
1135
1180
  expect(
1136
1181
  typedCall.mock.calls.filter(call => call[0] === 'DeviceSessionAskPassphrase')
1137
1182
  ).toHaveLength(1);
1138
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
1139
- seed_domains: [],
1140
- });
1183
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
1141
1184
  expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
1142
1185
  session_id: 'standard-session',
1143
1186
  btc_test_address: 'standard-state',
1144
- seed_domains: [],
1145
1187
  });
1146
1188
  expect(deviceWalletSessionStore.getStandard(deviceId)).toEqual({
1147
1189
  passphraseState: 'standard-state',
@@ -1506,10 +1548,9 @@ describe('Protocol V2 feature adapter', () => {
1506
1548
  expect(typedCall).toHaveBeenCalledWith('DeviceSessionAskPassphrase', 'Success', {
1507
1549
  passphrase: 'host hidden wallet',
1508
1550
  on_device: false,
1551
+ seed_domains: [DeviceSessionSeedDomain.SeedDomain_Standard],
1509
1552
  });
1510
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
1511
- seed_domains: [],
1512
- });
1553
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
1513
1554
  });
1514
1555
 
1515
1556
  test('deviceStatusGet returns raw DeviceStatus and updates dynamic features', async () => {
@@ -1618,7 +1659,6 @@ describe('Protocol V2 feature adapter', () => {
1618
1659
  expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
1619
1660
  session_id: 'session-a',
1620
1661
  btc_test_address: 'state-a',
1621
- seed_domains: [],
1622
1662
  });
1623
1663
  expect(device.getInternalState()).toBe('session-b');
1624
1664
  });
@@ -1692,7 +1732,6 @@ describe('Protocol V2 feature adapter', () => {
1692
1732
  {
1693
1733
  session_id: 'session-a',
1694
1734
  btc_test_address: 'state-a',
1695
- seed_domains: [],
1696
1735
  },
1697
1736
  ],
1698
1737
  ]);
@@ -1739,10 +1778,14 @@ describe('Protocol V2 feature adapter', () => {
1739
1778
  [
1740
1779
  'DeviceSessionAskPassphrase',
1741
1780
  'Success',
1742
- { passphrase: 'host hidden wallet', on_device: false },
1781
+ {
1782
+ passphrase: 'host hidden wallet',
1783
+ on_device: false,
1784
+ seed_domains: [DeviceSessionSeedDomain.SeedDomain_Standard],
1785
+ },
1743
1786
  ],
1744
1787
  ['DeviceStatusGet', 'DeviceStatus', {}],
1745
- ['DeviceSessionGet', 'DeviceSession', { seed_domains: [] }],
1788
+ ['DeviceSessionGet', 'DeviceSession', {}],
1746
1789
  ]);
1747
1790
  });
1748
1791
 
@@ -1904,7 +1947,6 @@ describe('Protocol V2 feature adapter', () => {
1904
1947
  expect(promptPassphrase).not.toHaveBeenCalled();
1905
1948
  expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
1906
1949
  btc_test_address: 'expected-state',
1907
- seed_domains: [],
1908
1950
  });
1909
1951
  expect(typedCall).toHaveBeenCalledWith('LockDevice', 'Success', {});
1910
1952
  expect(typedCall.mock.calls.filter(call => call[0] === 'DeviceSessionGet')).toHaveLength(1);
@@ -2202,7 +2244,7 @@ describe('Protocol V2 feature adapter', () => {
2202
2244
  firmwareVersion: '4.15.0',
2203
2245
  passphraseProtection: true,
2204
2246
  sessionId: 'feature-session',
2205
- unlockedAttachPin: true,
2247
+ unlockedAttachPin: false,
2206
2248
  };
2207
2249
  const typedCall = jest
2208
2250
  .fn()
@@ -2295,7 +2337,6 @@ describe('Protocol V2 feature adapter', () => {
2295
2337
  ).resolves.toMatchObject({ passphraseState: 'expected-state' });
2296
2338
  expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
2297
2339
  btc_test_address: 'expected-state',
2298
- seed_domains: [],
2299
2340
  });
2300
2341
  expect(promptPassphrase).not.toHaveBeenCalled();
2301
2342
  });
@@ -2369,7 +2410,6 @@ describe('Protocol V2 feature adapter', () => {
2369
2410
  {
2370
2411
  session_id: 'session-pro2-app',
2371
2412
  btc_test_address: 'state-pro2-app',
2372
- seed_domains: [],
2373
2413
  },
2374
2414
  ],
2375
2415
  ]);
@@ -2559,10 +2599,9 @@ describe('Protocol V2 feature adapter', () => {
2559
2599
  expect(typedCall).toHaveBeenNthCalledWith(2, 'DeviceSessionAskPassphrase', 'Success', {
2560
2600
  passphrase: 'host hidden wallet',
2561
2601
  on_device: false,
2602
+ seed_domains: [DeviceSessionSeedDomain.SeedDomain_Standard],
2562
2603
  });
2563
- expect(typedCall).toHaveBeenLastCalledWith('DeviceSessionGet', 'DeviceSession', {
2564
- seed_domains: [],
2565
- });
2604
+ expect(typedCall).toHaveBeenLastCalledWith('DeviceSessionGet', 'DeviceSession', {});
2566
2605
  });
2567
2606
 
2568
2607
  test('does not mark Pro2 passphrase enabled from a main PIN session alone', async () => {
@@ -2642,16 +2681,14 @@ describe('Protocol V2 feature adapter', () => {
2642
2681
  expect(device.getInternalState()).toBeUndefined();
2643
2682
  expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
2644
2683
  btc_test_address: 'expected-state',
2645
- seed_domains: [],
2646
2684
  });
2647
2685
  expect(typedCall).toHaveBeenCalledWith('DeviceSessionAskPassphrase', 'Success', {
2648
2686
  passphrase: 'host hidden wallet',
2649
2687
  on_device: false,
2688
+ seed_domains: [DeviceSessionSeedDomain.SeedDomain_Standard],
2650
2689
  });
2651
2690
  expect(typedCall).toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
2652
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
2653
- seed_domains: [],
2654
- });
2691
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
2655
2692
  });
2656
2693
 
2657
2694
  test('fails closed instead of switching to Main PIN during a standard-wallet safety check', async () => {
@@ -2749,20 +2786,19 @@ describe('Protocol V2 feature adapter', () => {
2749
2786
  await expect(
2750
2787
  device.checkPassphraseStateSafety('stale-hidden-state', true, false)
2751
2788
  ).resolves.toBe(true);
2752
- expect(typedCall).toHaveBeenCalledTimes(5);
2789
+ expect(typedCall).toHaveBeenCalledTimes(6);
2753
2790
  expect(typedCall).toHaveBeenCalledWith('ProtocolInfoRequest', 'ProtocolInfo', {
2754
2791
  eventless_wallet_session: true,
2755
2792
  });
2756
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
2757
- seed_domains: [],
2758
- });
2793
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
2759
2794
  expect(typedCall).toHaveBeenCalledWith('DeviceSessionAskPassphrase', 'Success', {
2760
2795
  passphrase: '',
2761
2796
  on_device: false,
2797
+ seed_domains: [DeviceSessionSeedDomain.SeedDomain_Standard],
2762
2798
  });
2763
2799
  expect(typedCall).toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
2764
2800
  expect(typedCall.mock.calls.filter(([request]) => request === 'DeviceStatusGet')).toHaveLength(
2765
- 2
2801
+ 3
2766
2802
  );
2767
2803
  expect(typedCall).not.toHaveBeenCalledWith('DeviceSessionAskPin', 'Success', expect.anything());
2768
2804
  });
@@ -5627,7 +5663,7 @@ describe('Protocol V2 firmware update targets', () => {
5627
5663
  expect(method.postTipMessage).not.toHaveBeenCalled();
5628
5664
  expect(method.postProgressMessage).not.toHaveBeenCalled();
5629
5665
  await cancelableAction?.();
5630
- expect(cancelDevice).toHaveBeenCalledTimes(1);
5666
+ expect(cancelDevice).not.toHaveBeenCalled();
5631
5667
  });
5632
5668
 
5633
5669
  test('does not send the install request when Protocol V2 staging fails', async () => {
@@ -5732,6 +5768,7 @@ describe('Protocol V2 firmware update targets', () => {
5732
5768
  );
5733
5769
  expect(typedCall).not.toHaveBeenCalledWith('DeviceFirmwareUpdateRequest', 'Success', {});
5734
5770
  expect((method as any).protocolV2InstallNeedsReconnect).toBe(true);
5771
+ expect((method as any).protocolV2InstallDisconnectObserved).toBe(true);
5735
5772
  });
5736
5773
 
5737
5774
  test.each([
@@ -7147,7 +7184,14 @@ describe('Protocol V2 firmware update targets', () => {
7147
7184
  true
7148
7185
  );
7149
7186
  expect((method as any).exitProtocolV2BootloaderToNormal).not.toHaveBeenCalled();
7150
- expect(method.postProgressMessage).toHaveBeenCalledWith(100, 'transferData');
7187
+ expect(method.postProgressMessage).toHaveBeenCalledWith(
7188
+ 100,
7189
+ 'transferData',
7190
+ expect.objectContaining({
7191
+ transferredBytes: 5,
7192
+ totalBytes: 5,
7193
+ })
7194
+ );
7151
7195
  expect((method as any).completeProtocolV2FinalVerification).toHaveBeenCalledTimes(1);
7152
7196
  });
7153
7197
 
@@ -7221,7 +7265,14 @@ describe('Protocol V2 firmware update targets', () => {
7221
7265
  expect.objectContaining({ processedSize: 2, totalSize: 3 })
7222
7266
  );
7223
7267
  expect(method.postProgressMessage).toHaveBeenCalledTimes(1);
7224
- expect(method.postProgressMessage).toHaveBeenCalledWith(100, 'transferData');
7268
+ expect(method.postProgressMessage).toHaveBeenCalledWith(
7269
+ 100,
7270
+ 'transferData',
7271
+ expect.objectContaining({
7272
+ transferredBytes: 3,
7273
+ totalBytes: 3,
7274
+ })
7275
+ );
7225
7276
  expect((method as any).protocolV2StartFirmwareUpdate).toHaveBeenCalledTimes(1);
7226
7277
  expect((method as any).protocolV2StartFirmwareUpdate).toHaveBeenCalledWith({
7227
7278
  targets: [{ target_id: 4, path: 'vol0:/application_p1.bin' }],
@@ -8541,26 +8592,43 @@ describe('Protocol V2 firmware update targets', () => {
8541
8592
  (method as any).verifyProtocolV2StagedFile = jest.fn().mockResolvedValue(undefined);
8542
8593
  (method as any).protocolV2StartFirmwareUpdate = jest.fn();
8543
8594
  (method as any).waitForProtocolV2FirmwareUpdateComplete = jest.fn();
8595
+ const dateNowSpy = jest
8596
+ .spyOn(Date, 'now')
8597
+ .mockReturnValueOnce(1_000)
8598
+ .mockReturnValueOnce(5_000);
8544
8599
 
8545
- await (method as any).executeProtocolV2SourceUpdate({
8546
- installSources: [],
8547
- resourceSources: [
8548
- {
8549
- name: 'images.okpkg',
8550
- source: {
8551
- size: 3,
8552
- readAt: jest.fn(),
8553
- close: jest.fn(),
8600
+ try {
8601
+ await (method as any).executeProtocolV2SourceUpdate({
8602
+ installSources: [],
8603
+ resourceSources: [
8604
+ {
8605
+ name: 'images.okpkg',
8606
+ source: {
8607
+ size: 3,
8608
+ readAt: jest.fn(),
8609
+ close: jest.fn(),
8610
+ },
8611
+ devicePath: 'vol0:/bundles/images/images.okpkg',
8554
8612
  },
8555
- devicePath: 'vol0:/bundles/images/images.okpkg',
8556
- },
8557
- ],
8558
- });
8613
+ ],
8614
+ });
8615
+ } finally {
8616
+ dateNowSpy.mockRestore();
8617
+ }
8559
8618
 
8560
8619
  expect((method as any).protocolV2SourceUpdateProcess).toHaveBeenCalledTimes(1);
8561
8620
  expect((method as any).protocolV2SourceUpdateProcess).toHaveBeenCalledWith(
8562
- expect.objectContaining({ filePath: 'vol0:/bundles/images/images.okpkg' })
8621
+ expect.objectContaining({
8622
+ filePath: 'vol0:/bundles/images/images.okpkg',
8623
+ transferStartedAt: 1_000,
8624
+ })
8563
8625
  );
8626
+ expect(method.postProgressMessage).toHaveBeenLastCalledWith(100, 'transferData', {
8627
+ transferredBytes: 3,
8628
+ totalBytes: 3,
8629
+ rateBytesPerSecond: 1,
8630
+ elapsedMs: 4_000,
8631
+ });
8564
8632
  expect((method as any).verifyProtocolV2StagedFile).toHaveBeenCalledWith(
8565
8633
  'vol0:/bundles/images/images.okpkg',
8566
8634
  3
@@ -8790,6 +8858,84 @@ describe('Protocol V2 firmware update targets', () => {
8790
8858
  expect(recoverProtocolV2FileTransfer).not.toHaveBeenCalled();
8791
8859
  });
8792
8860
 
8861
+ test('does not recover or wrap a cancelled V4 file transfer', async () => {
8862
+ const method = new FirmwareUpdateV4({
8863
+ id: 1,
8864
+ payload: {
8865
+ method: 'firmwareUpdateV4',
8866
+ },
8867
+ });
8868
+ const abortController = new AbortController();
8869
+ method.abortSignal = abortController.signal;
8870
+ (method as any).fileWriteChunk = jest.fn().mockImplementation(() => {
8871
+ abortController.abort();
8872
+ return Promise.reject(new Error('transport disposed'));
8873
+ });
8874
+ const recoverProtocolV2FileTransfer = jest.fn();
8875
+ (method as any).recoverProtocolV2FileTransfer = recoverProtocolV2FileTransfer;
8876
+ const source = await openFirmwareByteSource({
8877
+ binary: new Uint8Array([1]).buffer,
8878
+ });
8879
+
8880
+ try {
8881
+ await expect(
8882
+ (method as any).protocolV2SourceUpdateProcess({
8883
+ source,
8884
+ filePath: 'vol1:firmware.bin',
8885
+ processedSize: 0,
8886
+ totalSize: 1,
8887
+ })
8888
+ ).rejects.toMatchObject({ errorCode: HardwareErrorCode.CallQueueActionCancelled });
8889
+ } finally {
8890
+ await source?.close();
8891
+ }
8892
+ expect(recoverProtocolV2FileTransfer).not.toHaveBeenCalled();
8893
+ });
8894
+
8895
+ test('keeps public transfer bytes monotonic when a V4 file retry restarts at zero', async () => {
8896
+ const method = new FirmwareUpdateV4({
8897
+ id: 1,
8898
+ payload: {
8899
+ method: 'firmwareUpdateV4',
8900
+ },
8901
+ });
8902
+ let writeCount = 0;
8903
+ (method as any).fileWriteChunk = jest.fn(
8904
+ (_path: string, _size: number, offset: number, data: Uint8Array) => {
8905
+ writeCount += 1;
8906
+ if (writeCount === 3) {
8907
+ return Promise.reject(new Error('transport timeout'));
8908
+ }
8909
+ return Promise.resolve({
8910
+ message: { processed_byte: offset + data.byteLength },
8911
+ });
8912
+ }
8913
+ );
8914
+ (method as any).getProtocolV2FirmwareChunkSize = jest.fn().mockReturnValue(1000);
8915
+ (method as any).recoverProtocolV2FileTransfer = jest.fn().mockResolvedValue(undefined);
8916
+ method.postProgressMessage = jest.fn();
8917
+ const source = await openFirmwareByteSource({
8918
+ binary: new Uint8Array(3000).buffer,
8919
+ });
8920
+
8921
+ try {
8922
+ await (method as any).protocolV2SourceUpdateProcess({
8923
+ source,
8924
+ filePath: 'vol1:firmware.bin',
8925
+ processedSize: 0,
8926
+ totalSize: 3000,
8927
+ });
8928
+ } finally {
8929
+ await source?.close();
8930
+ }
8931
+
8932
+ const transferredBytes = (method.postProgressMessage as jest.Mock).mock.calls.map(
8933
+ ([, , metrics]) => metrics.transferredBytes
8934
+ );
8935
+ expect(transferredBytes).toEqual([1000, 2000, 3000]);
8936
+ expect((method as any).recoverProtocolV2FileTransfer).toHaveBeenCalledTimes(1);
8937
+ });
8938
+
8793
8939
  test('throttles repeated transfer progress while preserving file completion', async () => {
8794
8940
  const method = new FirmwareUpdateV4({
8795
8941
  id: 1,
@@ -9148,6 +9294,42 @@ describe('Protocol V2 firmware update targets', () => {
9148
9294
  expect(typedCall).toHaveBeenCalledTimes(6);
9149
9295
  });
9150
9296
 
9297
+ test('preserves the underlying transport error code after firmware transfer retries', async () => {
9298
+ const method = new FirmwareUpdateV4({
9299
+ id: 1,
9300
+ payload: {
9301
+ method: 'firmwareUpdateV4',
9302
+ },
9303
+ });
9304
+ const typedCall = jest
9305
+ .fn()
9306
+ .mockRejectedValue(ERRORS.TypedError(HardwareErrorCode.BleTimeoutError, 'response timeout'));
9307
+
9308
+ (method as any).device = stubDevice({
9309
+ getCommands: () => ({ typedCall }),
9310
+ });
9311
+ method.postProgressMessage = jest.fn();
9312
+ method.postTipMessage = jest.fn();
9313
+ (method as any).recoverProtocolV2FileTransfer = jest.fn().mockResolvedValue(undefined);
9314
+
9315
+ const source = await openFirmwareByteSource({
9316
+ binary: new Uint8Array([1, 2, 3]).buffer,
9317
+ });
9318
+ await expect(
9319
+ (method as any).protocolV2SourceUpdateProcess({
9320
+ source,
9321
+ filePath: 'vol0:/firmware.bin',
9322
+ processedSize: 0,
9323
+ totalSize: 3,
9324
+ })
9325
+ ).rejects.toMatchObject({
9326
+ errorCode: HardwareErrorCode.EmmcFileWriteFirmwareError,
9327
+ params: { causeCode: HardwareErrorCode.BleTimeoutError },
9328
+ });
9329
+ await source?.close();
9330
+ expect(typedCall).toHaveBeenCalledTimes(3);
9331
+ });
9332
+
9151
9333
  // TODO(#850/#855): PR #855 added resume-on-retry and per-chunk retry on the
9152
9334
  // writeProtocolV2File path. PR #850 replaced that path with FirmwareByteSource
9153
9335
  // streaming (protocolV2SourceUpdateProcess), which restarts a failed transfer from
@@ -10,6 +10,46 @@ jest.mock('../src/data/config', () => ({
10
10
  }));
11
11
 
12
12
  describe('writeProtocolV2File', () => {
13
+ test('allows a verified caller-specific BLE chunk limit', async () => {
14
+ const getSettingsSpy = jest
15
+ .spyOn(DataManager, 'getSettings')
16
+ .mockReturnValue('react-native' as any);
17
+ const isBleConnectSpy = jest.spyOn(DataManager, 'isBleConnect').mockReturnValue(true);
18
+ const data = new Uint8Array(1961);
19
+ const typedCall = jest.fn().mockResolvedValue({ message: {} });
20
+
21
+ try {
22
+ await writeProtocolV2File({
23
+ commands: { typedCall } as any,
24
+ path: 'vol1:/wallpapers/wallpaper.okpkg',
25
+ data,
26
+ bleChunkSizeLimit: 1960,
27
+ });
28
+ } finally {
29
+ getSettingsSpy.mockRestore();
30
+ isBleConnectSpy.mockRestore();
31
+ }
32
+
33
+ expect(typedCall).toHaveBeenCalledTimes(2);
34
+ expect(typedCall.mock.calls[0][2].file.data).toEqual(data.slice(0, 1960));
35
+ expect(typedCall.mock.calls[1][2].file.data).toEqual(data.slice(1960));
36
+ });
37
+
38
+ test('does not apply the BLE-only limit to WebUSB', async () => {
39
+ const data = new Uint8Array(1961);
40
+ const typedCall = jest.fn().mockResolvedValue({ message: {} });
41
+
42
+ await writeProtocolV2File({
43
+ commands: { typedCall } as any,
44
+ path: 'vol1:/wallpapers/wallpaper.okpkg',
45
+ data,
46
+ bleChunkSizeLimit: 1960,
47
+ });
48
+
49
+ expect(typedCall).toHaveBeenCalledTimes(1);
50
+ expect(typedCall.mock.calls[0][2].file.data).toEqual(data);
51
+ });
52
+
13
53
  test('按分片写入并只在首片设置 overwrite', async () => {
14
54
  const data = new Uint8Array(4097);
15
55
  const typedCall = jest.fn().mockResolvedValue({ message: {} });
@@ -18,10 +18,12 @@ export default class FirmwareUpdateV4 extends FirmwareUpdateBaseMethod<FirmwareU
18
18
  private protocolV2LatestFinalDeviceInfo?;
19
19
  private protocolV2InstallBaselineVersions;
20
20
  private protocolV2InstallNeedsReconnect;
21
+ private protocolV2InstallDisconnectObserved;
21
22
  private protocolV2InstallTerminalSuccessObserved;
22
23
  private protocolV2LastRuntimeProbeFeatures?;
23
24
  private protocolV2LastTransferProgress?;
24
25
  private protocolV2LastTransferProgressAt;
26
+ private protocolV2LastTransferredBytes;
25
27
  init(): void;
26
28
  private getProtocolV2FirmwareChunkSize;
27
29
  run(): Promise<{