@onekeyfe/hd-core 1.2.8-alpha.13 → 1.2.8-alpha.130

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 (125) hide show
  1. package/__tests__/allNetworkGetAddress-privacy-networks.test.ts +78 -0
  2. package/__tests__/core-dispose.test.ts +114 -1
  3. package/__tests__/core-error-output.test.ts +21 -0
  4. package/__tests__/device-connector-protocol.test.ts +5 -2
  5. package/__tests__/device-lifecycle-events.test.ts +118 -3
  6. package/__tests__/firmware-update/firmware-artifact-source.test.ts +11 -0
  7. package/__tests__/logBlockEvent.test.ts +292 -0
  8. package/__tests__/logger.test.ts +18 -0
  9. package/__tests__/monero-get-address.test.ts +147 -0
  10. package/__tests__/monero-key-image-sync.test.ts +272 -0
  11. package/__tests__/monero-sign-transaction.test.ts +436 -0
  12. package/__tests__/protocol-v2-resources.test.ts +14 -0
  13. package/__tests__/protocol-v2.test.ts +845 -3
  14. package/__tests__/protocolV2FileWrite.test.ts +434 -7
  15. package/__tests__/search-devices.test.ts +36 -0
  16. package/__tests__/usb-identity-probe.test.ts +358 -0
  17. package/__tests__/zcash-protobuf.test.ts +141 -0
  18. package/__tests__/zcash-sign-pczt.test.ts +446 -0
  19. package/dist/api/BaseMethod.d.ts.map +1 -1
  20. package/dist/api/FileWrite.d.ts.map +1 -1
  21. package/dist/api/FirmwareUpdateV4.d.ts +5 -0
  22. package/dist/api/FirmwareUpdateV4.d.ts.map +1 -1
  23. package/dist/api/SearchDevices.d.ts +21 -1
  24. package/dist/api/SearchDevices.d.ts.map +1 -1
  25. package/dist/api/allnetwork/AllNetworkGetAddressBase.d.ts.map +1 -1
  26. package/dist/api/firmware/FirmwareArtifactSource.d.ts.map +1 -1
  27. package/dist/api/helpers/pathUtils.d.ts +1 -0
  28. package/dist/api/helpers/pathUtils.d.ts.map +1 -1
  29. package/dist/api/helpers/protocolV2FileWrite.d.ts +5 -1
  30. package/dist/api/helpers/protocolV2FileWrite.d.ts.map +1 -1
  31. package/dist/api/index.d.ts +6 -0
  32. package/dist/api/index.d.ts.map +1 -1
  33. package/dist/api/monero/MoneroGetAddress.d.ts +11 -0
  34. package/dist/api/monero/MoneroGetAddress.d.ts.map +1 -0
  35. package/dist/api/monero/MoneroGetWatchKey.d.ts +11 -0
  36. package/dist/api/monero/MoneroGetWatchKey.d.ts.map +1 -0
  37. package/dist/api/monero/MoneroKeyImageSync.d.ts +20 -0
  38. package/dist/api/monero/MoneroKeyImageSync.d.ts.map +1 -0
  39. package/dist/api/monero/MoneroSignTransaction.d.ts +17 -0
  40. package/dist/api/monero/MoneroSignTransaction.d.ts.map +1 -0
  41. package/dist/api/monero/helpers.d.ts +5 -0
  42. package/dist/api/monero/helpers.d.ts.map +1 -0
  43. package/dist/api/protocol-v2/DeviceUploadNft.d.ts.map +1 -1
  44. package/dist/api/protocol-v2/DeviceUploadWallpaper.d.ts.map +1 -1
  45. package/dist/api/zcash/ZcashGetAddress.d.ts +12 -0
  46. package/dist/api/zcash/ZcashGetAddress.d.ts.map +1 -0
  47. package/dist/api/zcash/ZcashSignPczt.d.ts +16 -0
  48. package/dist/api/zcash/ZcashSignPczt.d.ts.map +1 -0
  49. package/dist/core/index.d.ts +3 -1
  50. package/dist/core/index.d.ts.map +1 -1
  51. package/dist/device/Device.d.ts +1 -0
  52. package/dist/device/Device.d.ts.map +1 -1
  53. package/dist/device/DeviceCommands.d.ts +4 -4
  54. package/dist/events/logBlockEvent.d.ts +10 -0
  55. package/dist/events/logBlockEvent.d.ts.map +1 -1
  56. package/dist/events/ui-request.d.ts +2 -0
  57. package/dist/events/ui-request.d.ts.map +1 -1
  58. package/dist/index.d.ts +183 -7
  59. package/dist/index.d.ts.map +1 -1
  60. package/dist/index.js +5625 -4192
  61. package/dist/inject.d.ts.map +1 -1
  62. package/dist/protocols/protocol-v2/resources.d.ts +1 -0
  63. package/dist/protocols/protocol-v2/resources.d.ts.map +1 -1
  64. package/dist/types/api/allNetworkGetAddress.d.ts +5 -1
  65. package/dist/types/api/allNetworkGetAddress.d.ts.map +1 -1
  66. package/dist/types/api/export.d.ts +6 -0
  67. package/dist/types/api/export.d.ts.map +1 -1
  68. package/dist/types/api/index.d.ts +12 -0
  69. package/dist/types/api/index.d.ts.map +1 -1
  70. package/dist/types/api/moneroGetAddress.d.ts +15 -0
  71. package/dist/types/api/moneroGetAddress.d.ts.map +1 -0
  72. package/dist/types/api/moneroGetWatchKey.d.ts +15 -0
  73. package/dist/types/api/moneroGetWatchKey.d.ts.map +1 -0
  74. package/dist/types/api/moneroKeyImageSync.d.ts +24 -0
  75. package/dist/types/api/moneroKeyImageSync.d.ts.map +1 -0
  76. package/dist/types/api/moneroSignTransaction.d.ts +32 -0
  77. package/dist/types/api/moneroSignTransaction.d.ts.map +1 -0
  78. package/dist/types/api/zcashGetAddress.d.ts +22 -0
  79. package/dist/types/api/zcashGetAddress.d.ts.map +1 -0
  80. package/dist/types/api/zcashSignPczt.d.ts +9 -0
  81. package/dist/types/api/zcashSignPczt.d.ts.map +1 -0
  82. package/dist/types/device.d.ts +10 -0
  83. package/dist/types/device.d.ts.map +1 -1
  84. package/dist/utils/logger.d.ts.map +1 -1
  85. package/dist/utils/patch.d.ts +1 -1
  86. package/dist/utils/patch.d.ts.map +1 -1
  87. package/package.json +4 -4
  88. package/src/api/BaseMethod.ts +2 -0
  89. package/src/api/FileWrite.ts +7 -1
  90. package/src/api/FirmwareUpdateV4.ts +358 -74
  91. package/src/api/SearchDevices.ts +172 -7
  92. package/src/api/allnetwork/AllNetworkGetAddressBase.ts +38 -1
  93. package/src/api/firmware/FirmwareArtifactSource.ts +12 -3
  94. package/src/api/helpers/pathUtils.ts +23 -0
  95. package/src/api/helpers/protocolV2FileWrite.ts +375 -111
  96. package/src/api/index.ts +7 -0
  97. package/src/api/monero/MoneroGetAddress.ts +71 -0
  98. package/src/api/monero/MoneroGetWatchKey.ts +69 -0
  99. package/src/api/monero/MoneroKeyImageSync.ts +164 -0
  100. package/src/api/monero/MoneroSignTransaction.ts +319 -0
  101. package/src/api/monero/helpers.ts +28 -0
  102. package/src/api/protocol-v2/DeviceUploadNft.ts +6 -1
  103. package/src/api/protocol-v2/DeviceUploadWallpaper.ts +15 -1
  104. package/src/api/zcash/ZcashGetAddress.ts +119 -0
  105. package/src/api/zcash/ZcashSignPczt.ts +152 -0
  106. package/src/core/index.ts +49 -17
  107. package/src/data/messages/messages-protocol-v2.json +138 -1
  108. package/src/device/Device.ts +21 -3
  109. package/src/device/DeviceConnector.ts +4 -4
  110. package/src/events/logBlockEvent.ts +55 -3
  111. package/src/events/ui-request.ts +2 -0
  112. package/src/index.ts +1 -0
  113. package/src/inject.ts +13 -0
  114. package/src/protocols/protocol-v2/resources.ts +6 -1
  115. package/src/types/api/allNetworkGetAddress.ts +9 -1
  116. package/src/types/api/export.ts +15 -0
  117. package/src/types/api/index.ts +20 -0
  118. package/src/types/api/moneroGetAddress.ts +27 -0
  119. package/src/types/api/moneroGetWatchKey.ts +28 -0
  120. package/src/types/api/moneroKeyImageSync.ts +45 -0
  121. package/src/types/api/moneroSignTransaction.ts +54 -0
  122. package/src/types/api/zcashGetAddress.ts +35 -0
  123. package/src/types/api/zcashSignPczt.ts +18 -0
  124. package/src/types/device.ts +18 -0
  125. package/src/utils/logger.ts +4 -0
@@ -34,6 +34,7 @@ import DeviceSettings from '../src/api/device/DeviceSettings';
34
34
  import DeviceUnlock from '../src/api/device/DeviceUnlock';
35
35
  import DeviceWipe from '../src/api/device/DeviceWipe';
36
36
  import DeviceUploadWallpaper from '../src/api/protocol-v2/DeviceUploadWallpaper';
37
+ import DeviceUploadNft from '../src/api/protocol-v2/DeviceUploadNft';
37
38
  import DeviceStatusGet from '../src/api/protocol-v2/DeviceStatusGet';
38
39
  import FilesystemFormat from '../src/api/protocol-v2/FilesystemFormat';
39
40
  import FilesystemPermissionFix from '../src/api/protocol-v2/FilesystemPermissionFix';
@@ -44,6 +45,7 @@ import FirmwareUpdateV3 from '../src/api/FirmwareUpdateV3';
44
45
  import FirmwareUpdateV4, {
45
46
  assertProtocolV2FirmwareTargetsSupported,
46
47
  assertProtocolV2ReconnectIdentity,
48
+ supportsProtocolV2BleFileWriteWindow,
47
49
  } from '../src/api/FirmwareUpdateV4';
48
50
  import GetPassphraseState from '../src/api/GetPassphraseState';
49
51
  import GetOnekeyFeatures from '../src/api/GetOnekeyFeatures';
@@ -67,6 +69,7 @@ import StellarGetAddress from '../src/api/stellar/StellarGetAddress';
67
69
  import BenfenSignMessage from '../src/api/benfen/BenfenSignMessage';
68
70
  import { getBitcoinForkSupportedProtocols } from '../src/api/btc/helpers/versionLimit';
69
71
  import { DataManager } from '../src/data-manager';
72
+ import { LoggerNames, getLogger } from '../src/utils/logger';
70
73
  import { createCoreApi } from '../src/inject';
71
74
  import { Device, preloadSessionCache } from '../src/device/Device';
72
75
  import { deviceWalletSessionStore } from '../src/device/DeviceWalletSessionStore';
@@ -120,7 +123,13 @@ import {
120
123
 
121
124
  import type { DeviceCommands } from '../src/device/DeviceCommands';
122
125
  import type { Features } from '../src/types';
123
- import type { DeviceStatus, ProtocolInfo } from '@onekeyfe/hd-transport';
126
+ import type {
127
+ DeviceStatus,
128
+ FilesystemFileWrite,
129
+ MessageFromOneKey,
130
+ ProtocolInfo,
131
+ TransportCallOptions,
132
+ } from '@onekeyfe/hd-transport';
124
133
 
125
134
  jest.mock('../src/data/config', () => ({
126
135
  getSDKVersion: jest.fn(() => '1.0.0'),
@@ -653,6 +662,162 @@ describe('UploadPortfolio', () => {
653
662
  });
654
663
  });
655
664
 
665
+ describe('Pro2 / Neo application asset pipelines', () => {
666
+ afterEach(() => jest.restoreAllMocks());
667
+
668
+ test.each(
669
+ [EDeviceType.Pro2, EDeviceType.Neo].flatMap(deviceType =>
670
+ (['wallpaper', 'nft', 'portfolio'] as const).flatMap(asset =>
671
+ [
672
+ { fail: false, recover: false },
673
+ { fail: true, recover: false },
674
+ { fail: true, recover: true },
675
+ ].map(outcome => ({ deviceType, asset, ...outcome }))
676
+ )
677
+ )
678
+ )(
679
+ '$deviceType $asset applies only a fully confirmed upload (fail=$fail, recover=$recover)',
680
+ async ({ deviceType, asset, fail, recover }) => {
681
+ jest.spyOn(DataManager, 'getSettings').mockReturnValue('react-native');
682
+ jest.spyOn(DataManager, 'isBleConnect').mockReturnValue(true);
683
+ const failure = new Error('BLE response timeout');
684
+ let pipelineAttempts = 0;
685
+ const applyMessage = {
686
+ wallpaper: 'DeviceSettingsSet',
687
+ nft: 'NftUpdate',
688
+ portfolio: 'PortfolioUpdate',
689
+ }[asset];
690
+ const typedCall = jest.fn(
691
+ async (
692
+ name: string,
693
+ _response: string,
694
+ first: FilesystemFileWrite,
695
+ options?: TransportCallOptions
696
+ ) => {
697
+ if (name !== 'FilesystemFileWrite') return { type: 'Success', message: {} };
698
+ const pipeline = options?.fileWritePipeline;
699
+ if (!pipeline) {
700
+ if (fail && !recover) throw failure;
701
+ const { data } = first.file;
702
+ if (!(data instanceof Uint8Array)) throw new Error('Expected Uint8Array file data');
703
+ return {
704
+ type: 'FilesystemFile',
705
+ message: { ...first.file, processed_byte: first.file.offset + data.byteLength },
706
+ };
707
+ }
708
+ pipelineAttempts += 1;
709
+ let request: FilesystemFileWrite | undefined = first;
710
+ let response: MessageFromOneKey = { type: 'FilesystemFile', message: {} };
711
+ while (request) {
712
+ const { data } = request.file;
713
+ if (!(data instanceof Uint8Array)) throw new Error('Expected Uint8Array file data');
714
+ expect(data.byteLength).toBeLessThanOrEqual(1800);
715
+ expect(typedCall.mock.calls.some(call => call[0] === applyMessage)).toBe(false);
716
+ response = {
717
+ type: 'FilesystemFile',
718
+ message: { ...request.file, processed_byte: request.file.offset + data.byteLength },
719
+ };
720
+ pipeline.onResponse(response);
721
+ if (fail && (!recover || pipelineAttempts === 1)) throw failure;
722
+ request = (await pipeline.next()) as FilesystemFileWrite | undefined;
723
+ }
724
+ return response;
725
+ }
726
+ );
727
+ let method: DeviceUploadWallpaper | DeviceUploadNft | UploadPortfolio;
728
+ if (asset === 'wallpaper') {
729
+ method = new DeviceUploadWallpaper({
730
+ id: 1,
731
+ payload: { method: 'deviceUploadWallpaper', jpegBase64: createJpegBase64(604, 1024) },
732
+ });
733
+ } else if (asset === 'nft') {
734
+ method = new DeviceUploadNft({
735
+ id: 1,
736
+ payload: {
737
+ method: 'deviceUploadNft',
738
+ imageJpegBase64: createJpegBase64(540, 540),
739
+ thumbnailJpegBase64: createJpegBase64(263, 263),
740
+ title: 'NFT',
741
+ subtitle: '',
742
+ timestampMs: 1_760_000_000_000,
743
+ },
744
+ });
745
+ } else {
746
+ method = new UploadPortfolio({
747
+ id: 1,
748
+ payload: {
749
+ method: 'uploadPortfolio',
750
+ packageBase64: Buffer.from(new Uint8Array(3601)).toString('base64'),
751
+ uiMode: 'progress',
752
+ },
753
+ });
754
+ }
755
+ const commands = { typedCall, device: undefined as Device | undefined };
756
+ const acquire = jest.fn().mockResolvedValue(undefined);
757
+ method.device = stubDevice({
758
+ commands,
759
+ originalDescriptor: { protocolType: 'V2' },
760
+ features: { deviceType, mode: 'normal' },
761
+ state: {
762
+ identity: { serialNo: 'test-asset-device', deviceType },
763
+ status: { mode: 'normal', initialized: false },
764
+ versions: { firmware: '1.0.3', bootloader: '1.0.0' },
765
+ },
766
+ acquire,
767
+ initialize: jest.fn().mockResolvedValue(undefined),
768
+ ensureProtocolV2RuntimeContext: jest.fn().mockResolvedValue({
769
+ version: 2,
770
+ supported_messages: [60412, 60805, 60809, 61400, 61500],
771
+ }),
772
+ refreshProtocolV2SettingsAfterMutation: jest.fn().mockResolvedValue({}),
773
+ }) as unknown as Device;
774
+ commands.device = method.device;
775
+ method.postMessage = jest.fn();
776
+ method.init();
777
+
778
+ if (fail && !recover) {
779
+ await expect(method.run()).rejects.toBe(failure);
780
+ expect(typedCall.mock.calls.some(call => call[0] === applyMessage)).toBe(false);
781
+ expect(method.postMessage).not.toHaveBeenCalledWith(
782
+ expect.objectContaining({ type: UI_REQUEST.REQUEST_BUTTON })
783
+ );
784
+ } else {
785
+ await method.run();
786
+ expect(typedCall.mock.calls[typedCall.mock.calls.length - 1][0]).toBe(applyMessage);
787
+ expect(typedCall.mock.calls.filter(call => call[0] === applyMessage)).toHaveLength(1);
788
+ expect(method.postMessage).toHaveBeenCalledWith(
789
+ expect.objectContaining({
790
+ type: UI_REQUEST.DEVICE_PROGRESS,
791
+ payload: expect.objectContaining({ progress: 100 }),
792
+ })
793
+ );
794
+ if (asset === 'wallpaper') {
795
+ expect(method.postMessage).toHaveBeenLastCalledWith(
796
+ expect.objectContaining({
797
+ type: UI_REQUEST.REQUEST_BUTTON,
798
+ payload: expect.objectContaining({
799
+ deviceOnly: true,
800
+ completion: 'operation-completed',
801
+ operation: 'deviceUploadWallpaper',
802
+ }),
803
+ })
804
+ );
805
+ const postMessageMock = method.postMessage as jest.Mock;
806
+ const promptOrder =
807
+ postMessageMock.mock.invocationCallOrder[postMessageMock.mock.calls.length - 1];
808
+ const applyOrder = typedCall.mock.invocationCallOrder[typedCall.mock.calls.length - 1];
809
+ expect(promptOrder).toBeLessThan(applyOrder);
810
+ }
811
+ }
812
+ const recoveryAttempts = recover ? 1 : 3;
813
+ expect(typedCall.mock.calls.filter(call => call[3]?.fileWritePipeline)).toHaveLength(
814
+ fail ? recoveryAttempts + 1 : 1
815
+ );
816
+ expect(acquire).toHaveBeenCalledTimes(fail ? recoveryAttempts : 0);
817
+ }
818
+ );
819
+ });
820
+
656
821
  const descriptor = {
657
822
  id: 'ble-id',
658
823
  path: 'usb-path',
@@ -4991,6 +5156,76 @@ describe('Protocol V2 firmware update targets', () => {
4991
5156
  expect(typedCall.mock.calls.map(call => call[0])).toEqual(['DeviceInfoGet']);
4992
5157
  });
4993
5158
 
5159
+ test.each([undefined, 1500])(
5160
+ 'uses short BLE readiness attempts and reconnects after a lost response (override %s)',
5161
+ async protocolV2DeviceInfoTimeoutMs => {
5162
+ const method = new FirmwareUpdateV4({
5163
+ id: 1,
5164
+ payload: { method: 'firmwareUpdateV4', protocolV2DeviceInfoTimeoutMs },
5165
+ });
5166
+ const typedCall = jest
5167
+ .fn()
5168
+ .mockRejectedValueOnce(new Error('BLE response timeout'))
5169
+ .mockResolvedValue({ type: 'DeviceInfo', message: protocolV2BootloaderDeviceInfo });
5170
+ method.device = stubDevice({
5171
+ originalDescriptor: { id: 'ble-id', protocolType: 'V2' },
5172
+ getCommands: () => ({ typedCall }),
5173
+ probeProtocolV2RuntimeState: jest.fn().mockResolvedValue({ mode: 'bootloader' }),
5174
+ }) as unknown as Device;
5175
+ jest.spyOn(method, 'isBleReconnect').mockReturnValue(true);
5176
+ const update = method as unknown as {
5177
+ reconnectProtocolV2Device: jest.Mock;
5178
+ waitForProtocolV2BootloaderMode: (timeout: number, interval: number) => Promise<unknown>;
5179
+ protocolV2ExpectedSerialNumber: string;
5180
+ getProtocolV2DeviceInfoTimeout: () => number;
5181
+ };
5182
+ update.protocolV2ExpectedSerialNumber = 'PR9999999999';
5183
+ update.reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
5184
+
5185
+ await update.waitForProtocolV2BootloaderMode(60_000, 0);
5186
+
5187
+ expect(typedCall).toHaveBeenCalledTimes(2);
5188
+ expect(typedCall).toHaveBeenNthCalledWith(
5189
+ 1,
5190
+ 'DeviceInfoGet',
5191
+ 'DeviceInfo',
5192
+ expect.anything(),
5193
+ { timeoutMs: protocolV2DeviceInfoTimeoutMs ?? 5000 }
5194
+ );
5195
+ expect(update.reconnectProtocolV2Device).toHaveBeenCalledTimes(2);
5196
+ expect(update.reconnectProtocolV2Device).toHaveBeenCalledWith({ skipProtocolProbe: true });
5197
+ expect(update.getProtocolV2DeviceInfoTimeout()).toBe(protocolV2DeviceInfoTimeoutMs ?? 30_000);
5198
+ }
5199
+ );
5200
+
5201
+ test('disconnects the pre-reboot BLE connection before waiting for bootloader readiness', async () => {
5202
+ const method = new FirmwareUpdateV4({ id: 1, payload: { method: 'firmwareUpdateV4' } });
5203
+ const disconnect = jest.fn().mockResolvedValue(undefined);
5204
+ method.device = stubDevice({
5205
+ originalDescriptor: { id: 'ble-id', protocolType: 'V2' },
5206
+ deviceConnector: { disconnect },
5207
+ }) as unknown as Device;
5208
+ jest.spyOn(method, 'isBleReconnect').mockReturnValue(true);
5209
+ method.postTipMessage = jest.fn();
5210
+ const update = method as unknown as {
5211
+ protocolV2Reboot: jest.Mock;
5212
+ waitForProtocolV2BootloaderMode: jest.Mock;
5213
+ rebootProtocolV2ToBootloader: () => Promise<boolean>;
5214
+ };
5215
+ update.protocolV2Reboot = jest.fn().mockResolvedValue(undefined);
5216
+ update.waitForProtocolV2BootloaderMode = jest.fn().mockResolvedValue({ mode: 'bootloader' });
5217
+
5218
+ await expect(update.rebootProtocolV2ToBootloader()).resolves.toBe(true);
5219
+
5220
+ expect(disconnect).toHaveBeenCalledWith('ble-id');
5221
+ expect(disconnect.mock.invocationCallOrder[0]).toBeGreaterThan(
5222
+ update.protocolV2Reboot.mock.invocationCallOrder[0]
5223
+ );
5224
+ expect(disconnect.mock.invocationCallOrder[0]).toBeLessThan(
5225
+ update.waitForProtocolV2BootloaderMode.mock.invocationCallOrder[0]
5226
+ );
5227
+ });
5228
+
4994
5229
  test('polls again when Protocol V2 bootloader serial is temporarily unavailable', async () => {
4995
5230
  const method = new FirmwareUpdateV4({
4996
5231
  id: 1,
@@ -8883,6 +9118,587 @@ describe('Protocol V2 firmware update targets', () => {
8883
9118
  expect((method as any).protocolV2SourceUpdateProcess).not.toHaveBeenCalled();
8884
9119
  });
8885
9120
 
9121
+ describe('RESC header check link recovery', () => {
9122
+ const currentHeader = {
9123
+ version: [1, 0, 0],
9124
+ payloadHash: '11'.repeat(64),
9125
+ headerHash: '22'.repeat(64),
9126
+ };
9127
+ const linkLost = () =>
9128
+ ERRORS.TypedError(
9129
+ HardwareErrorCode.RuntimeError,
9130
+ 'Device protocol has not been detected for AA:BB'
9131
+ );
9132
+ const createResource = (name: string) => ({
9133
+ name,
9134
+ source: { size: 3, readAt: jest.fn(), close: jest.fn() },
9135
+ devicePath: `vol0:/bundles/${name}`,
9136
+ ...currentHeader,
9137
+ });
9138
+ const createMethod = () => {
9139
+ const method = new FirmwareUpdateV4({
9140
+ id: 1,
9141
+ payload: {
9142
+ method: 'firmwareUpdateV4',
9143
+ },
9144
+ });
9145
+ method.postTipMessage = jest.fn();
9146
+ method.postProgressMessage = jest.fn();
9147
+ (method as any).protocolV2SourceUpdateProcess = jest.fn(
9148
+ ({ processedSize }: { processedSize: number }) => Promise.resolve(processedSize + 3)
9149
+ );
9150
+ (method as any).verifyProtocolV2StagedFile = jest.fn().mockResolvedValue(undefined);
9151
+ (method as any).recoverProtocolV2FileTransfer = jest.fn().mockResolvedValue(undefined);
9152
+ return method;
9153
+ };
9154
+
9155
+ test('reconnects and asks again when the link drops during a header check', async () => {
9156
+ const method = createMethod();
9157
+ (method as any).readProtocolV2DeviceFileHeader = jest
9158
+ .fn()
9159
+ .mockRejectedValueOnce(linkLost())
9160
+ .mockResolvedValue(currentHeader);
9161
+
9162
+ await (method as any).executeProtocolV2TransferPhase({
9163
+ installSources: [],
9164
+ resourceSources: [createResource('images.okpkg'), createResource('fonts.okpkg')],
9165
+ });
9166
+
9167
+ expect((method as any).recoverProtocolV2FileTransfer).toHaveBeenCalledTimes(1);
9168
+ expect((method as any).readProtocolV2DeviceFileHeader).toHaveBeenCalledTimes(3);
9169
+ expect((method as any).protocolV2SourceUpdateProcess).not.toHaveBeenCalled();
9170
+ });
9171
+
9172
+ test.each([
9173
+ ['BLE disconnect', () => ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected)],
9174
+ ['BLE timeout', () => ERRORS.TypedError(HardwareErrorCode.BleTimeoutError)],
9175
+ [
9176
+ 'invalidated Protocol V2 link',
9177
+ () =>
9178
+ Object.assign(new Error('Protocol V2 link generation changed'), {
9179
+ name: 'ProtocolV2LinkError',
9180
+ code: 'generation',
9181
+ }),
9182
+ ],
9183
+ [
9184
+ 'released React Native BLE transport',
9185
+ () => new Error('React Native BLE transport released'),
9186
+ ],
9187
+ ['released Electron BLE transport', () => new Error('Electron BLE transport released')],
9188
+ ['cleaned Electron BLE state', () => new Error('Electron BLE device state cleaned')],
9189
+ [
9190
+ 'Electron BLE device that is no longer connected',
9191
+ () => ERRORS.TypedError(HardwareErrorCode.TransportNotFound, 'Device AA:BB not connected'),
9192
+ ],
9193
+ ])('rethrows a %s from the header check for recovery', async (_label, createError) => {
9194
+ const method = createMethod();
9195
+ const error = createError();
9196
+ (method as any).readProtocolV2DeviceFileHeader = jest.fn().mockRejectedValue(error);
9197
+
9198
+ await expect(
9199
+ (method as any).isProtocolV2ResourceBundleUpToDate(createResource('images.okpkg'))
9200
+ ).rejects.toBe(error);
9201
+ });
9202
+
9203
+ test('treats a device-side header read failure as stale without reconnecting', async () => {
9204
+ const method = createMethod();
9205
+ (method as any).readProtocolV2DeviceFileHeader = jest
9206
+ .fn()
9207
+ .mockRejectedValue(ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'Failure: busy'));
9208
+
9209
+ await (method as any).executeProtocolV2TransferPhase({
9210
+ installSources: [],
9211
+ resourceSources: [createResource('images.okpkg')],
9212
+ });
9213
+
9214
+ expect((method as any).recoverProtocolV2FileTransfer).not.toHaveBeenCalled();
9215
+ expect((method as any).protocolV2SourceUpdateProcess).toHaveBeenCalledTimes(1);
9216
+ });
9217
+
9218
+ test('limits header-check reconnects per transfer phase', async () => {
9219
+ const method = createMethod();
9220
+ (method as any).readProtocolV2DeviceFileHeader = jest.fn().mockRejectedValue(linkLost());
9221
+
9222
+ await (method as any).executeProtocolV2TransferPhase({
9223
+ installSources: [],
9224
+ resourceSources: ['a.okpkg', 'b.okpkg', 'c.okpkg'].map(createResource),
9225
+ });
9226
+
9227
+ expect((method as any).recoverProtocolV2FileTransfer).toHaveBeenCalledTimes(2);
9228
+ expect((method as any).protocolV2SourceUpdateProcess).toHaveBeenCalledTimes(3);
9229
+ });
9230
+
9231
+ test.each([
9232
+ ['no device to reconnect to', () => ERRORS.TypedError(HardwareErrorCode.DeviceNotFound)],
9233
+ [
9234
+ 'an identity mismatch',
9235
+ () =>
9236
+ ERRORS.TypedError(
9237
+ HardwareErrorCode.DeviceNotFound,
9238
+ 'Protocol V2 reconnect physical identity mismatch: expected A, received B'
9239
+ ),
9240
+ ],
9241
+ [
9242
+ 'an unavailable identity',
9243
+ () =>
9244
+ ERRORS.TypedError(
9245
+ HardwareErrorCode.DeviceNotFound,
9246
+ 'Protocol V2 reconnect physical identity unavailable: expected path A'
9247
+ ),
9248
+ ],
9249
+ [
9250
+ 'a failed identity query',
9251
+ () => ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'Failure: busy'),
9252
+ ],
9253
+ ])(
9254
+ 'aborts instead of resending when the header-check reconnect fails with %s',
9255
+ async (_label, createError) => {
9256
+ const method = createMethod();
9257
+ const error = createError();
9258
+ (method as any).readProtocolV2DeviceFileHeader = jest.fn().mockRejectedValue(linkLost());
9259
+ (method as any).recoverProtocolV2FileTransfer = jest.fn().mockRejectedValue(error);
9260
+
9261
+ await expect(
9262
+ (method as any).executeProtocolV2TransferPhase({
9263
+ installSources: [],
9264
+ resourceSources: [createResource('images.okpkg'), createResource('fonts.okpkg')],
9265
+ })
9266
+ ).rejects.toBe(error);
9267
+ expect((method as any).recoverProtocolV2FileTransfer).toHaveBeenCalledTimes(1);
9268
+ expect((method as any).protocolV2SourceUpdateProcess).not.toHaveBeenCalled();
9269
+ }
9270
+ );
9271
+
9272
+ test('writes nothing to a reconnected device whose identity query fails', async () => {
9273
+ const method = createMethod();
9274
+ // Run the real recovery: the session is replaced before the identity check.
9275
+ delete (method as any).recoverProtocolV2FileTransfer;
9276
+ const initialize = jest.fn();
9277
+ (method as any).device = stubDevice({ initialize });
9278
+ (method as any).reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
9279
+ (method as any).verifyProtocolV2ReconnectIdentity = jest
9280
+ .fn()
9281
+ .mockRejectedValue(ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'Failure: busy'));
9282
+ (method as any).readProtocolV2DeviceFileHeader = jest.fn().mockRejectedValue(linkLost());
9283
+
9284
+ await expect(
9285
+ (method as any).executeProtocolV2TransferPhase({
9286
+ installSources: [],
9287
+ resourceSources: [createResource('images.okpkg')],
9288
+ })
9289
+ ).rejects.toMatchObject({ message: expect.stringContaining('Failure: busy') });
9290
+ expect((method as any).reconnectProtocolV2Device).toHaveBeenCalledTimes(1);
9291
+ expect(initialize).not.toHaveBeenCalled();
9292
+ expect((method as any).protocolV2SourceUpdateProcess).not.toHaveBeenCalled();
9293
+ });
9294
+
9295
+ test('reports a cancel that tears the header-check reconnect down as the cancel', async () => {
9296
+ const method = createMethod();
9297
+ delete (method as any).recoverProtocolV2FileTransfer;
9298
+ const abortController = new AbortController();
9299
+ method.abortSignal = abortController.signal;
9300
+ (method as any).reconnectProtocolV2Device = jest.fn(() => {
9301
+ abortController.abort();
9302
+ return Promise.reject(ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected));
9303
+ });
9304
+ (method as any).readProtocolV2DeviceFileHeader = jest.fn().mockRejectedValue(linkLost());
9305
+
9306
+ await expect(
9307
+ (method as any).executeProtocolV2TransferPhase({
9308
+ installSources: [],
9309
+ resourceSources: [createResource('images.okpkg')],
9310
+ })
9311
+ ).rejects.toMatchObject({ errorCode: HardwareErrorCode.CallQueueActionCancelled });
9312
+ expect((method as any).protocolV2SourceUpdateProcess).not.toHaveBeenCalled();
9313
+ });
9314
+ });
9315
+
9316
+ test.each([
9317
+ ['1.0.3', '1.0.3', true],
9318
+ ['1.0.10', '1.0.3', true],
9319
+ ['1.0.2', '1.0.3', false],
9320
+ ['0.0.0.1', '1.0.3', false],
9321
+ ['1.0.3', '', false],
9322
+ ] as const)(
9323
+ 'gates the BLE file write window on bootloader %s with minimum %s',
9324
+ (bootloaderVersion, minBootloaderVersion, expected) => {
9325
+ expect(supportsProtocolV2BleFileWriteWindow(bootloaderVersion, minBootloaderVersion)).toBe(
9326
+ expected
9327
+ );
9328
+ }
9329
+ );
9330
+
9331
+ test('keeps released 1.0.2 bootloaders serial by default', () => {
9332
+ expect(supportsProtocolV2BleFileWriteWindow('1.0.3')).toBe(true);
9333
+ expect(supportsProtocolV2BleFileWriteWindow('1.0.2')).toBe(false);
9334
+ });
9335
+
9336
+ test.each([
9337
+ ['react-native', 'bootloader', true],
9338
+ ['desktop-web-ble', 'bootloader', true],
9339
+ ['lowlevel', 'bootloader', false],
9340
+ ['webusb', 'bootloader', false],
9341
+ ['react-native', 'normal', false],
9342
+ ] as const)(
9343
+ 'enables the BLE file write window for %s in %s mode only with a supported bootloader',
9344
+ (env, mode, expected) => {
9345
+ const getSettingsSpy = jest.spyOn(DataManager, 'getSettings').mockReturnValue(env);
9346
+ const method = new FirmwareUpdateV4({
9347
+ id: 1,
9348
+ payload: {
9349
+ method: 'firmwareUpdateV4',
9350
+ },
9351
+ });
9352
+ (method as any).device = stubDevice({
9353
+ features: { mode, bootloaderMode: mode === 'bootloader', bootloaderVersion: '1.0.3' },
9354
+ });
9355
+
9356
+ try {
9357
+ expect((method as any).shouldUseProtocolV2BleFileWriteWindow('1.0.3')).toBe(expected);
9358
+ expect((method as any).shouldUseProtocolV2BleFileWriteWindow('1.0.4')).toBe(false);
9359
+ } finally {
9360
+ getSettingsSpy.mockRestore();
9361
+ }
9362
+ }
9363
+ );
9364
+
9365
+ test.each([
9366
+ ['1.0.2', false],
9367
+ ['1.0.3', true],
9368
+ // The parsed bootloader version drops prerelease tags.
9369
+ ['1.0.3-rc.1', true],
9370
+ ] as const)(
9371
+ 'gates the default minimum on a running %s bootloader in React Native',
9372
+ (bootloaderVersion, expected) => {
9373
+ const getSettingsSpy = jest.spyOn(DataManager, 'getSettings').mockReturnValue('react-native');
9374
+ const method = new FirmwareUpdateV4({
9375
+ id: 1,
9376
+ payload: {
9377
+ method: 'firmwareUpdateV4',
9378
+ },
9379
+ });
9380
+ (method as any).device = stubDevice({
9381
+ features: { mode: 'bootloader', bootloaderMode: true, bootloaderVersion },
9382
+ });
9383
+
9384
+ try {
9385
+ expect((method as any).shouldUseProtocolV2BleFileWriteWindow()).toBe(expected);
9386
+ } finally {
9387
+ getSettingsSpy.mockRestore();
9388
+ }
9389
+ }
9390
+ );
9391
+
9392
+ describe('windowed BLE file write', () => {
9393
+ const confirm = (request: { file: { offset: number; data: ArrayBuffer } }) => ({
9394
+ type: 'FilesystemFile',
9395
+ message: { processed_byte: request.file.offset + request.file.data.byteLength },
9396
+ });
9397
+ const streamAll = (requests: any[], onConfirmed?: () => void) =>
9398
+ jest.fn(async (_name: string, _resType: string, first: any, options: any) => {
9399
+ // A serial write sends one chunk per call.
9400
+ if (!options.fileWritePipeline) {
9401
+ requests.push(first);
9402
+ return confirm(first);
9403
+ }
9404
+ const { next, onResponse } = options.fileWritePipeline;
9405
+ let request = first;
9406
+ let last;
9407
+ while (request) {
9408
+ requests.push(request);
9409
+ last = confirm(request);
9410
+ onConfirmed?.();
9411
+ onResponse(last);
9412
+ request = await next();
9413
+ }
9414
+ return last;
9415
+ });
9416
+ const createMethod = (typedCall: jest.Mock) => {
9417
+ const method = new FirmwareUpdateV4({
9418
+ id: 1,
9419
+ payload: {
9420
+ method: 'firmwareUpdateV4',
9421
+ },
9422
+ });
9423
+ (method as any).device = stubDevice({ getCommands: () => ({ typedCall }) });
9424
+ (method as any).shouldUseProtocolV2BleFileWriteWindow = jest.fn().mockReturnValue(true);
9425
+ (method as any).getProtocolV2FirmwareChunkSize = jest.fn().mockReturnValue(1000);
9426
+ (method as any).recoverProtocolV2FileTransfer = jest.fn().mockResolvedValue(undefined);
9427
+ method.postProgressMessage = jest.fn();
9428
+ return method;
9429
+ };
9430
+ const transfer = async (method: FirmwareUpdateV4, filePath = 'vol0:/application_p1.bin') => {
9431
+ const source = await openFirmwareByteSource({ binary: new Uint8Array(2500).buffer });
9432
+ try {
9433
+ const processedSize: number = await (method as any).protocolV2SourceUpdateProcess({
9434
+ source,
9435
+ filePath,
9436
+ processedSize: 500,
9437
+ totalSize: 3000,
9438
+ });
9439
+ return processedSize;
9440
+ } finally {
9441
+ await source?.close();
9442
+ }
9443
+ };
9444
+
9445
+ test('streams the file through one FileWrite call and reports confirmed chunks', async () => {
9446
+ const requests: any[] = [];
9447
+ const typedCall = streamAll(requests);
9448
+ const method = createMethod(typedCall);
9449
+
9450
+ await expect(transfer(method)).resolves.toBe(3000);
9451
+
9452
+ expect(typedCall).toHaveBeenCalledTimes(1);
9453
+ expect(typedCall).toHaveBeenCalledWith(
9454
+ 'FilesystemFileWrite',
9455
+ 'FilesystemFile',
9456
+ expect.anything(),
9457
+ expect.objectContaining({
9458
+ writeWithResponse: false,
9459
+ fileWritePipeline: expect.objectContaining({
9460
+ next: expect.any(Function),
9461
+ onResponse: expect.any(Function),
9462
+ }),
9463
+ })
9464
+ );
9465
+ expect(requests.map(request => request.file.offset)).toEqual([0, 1000, 2000]);
9466
+ expect(requests.map(request => request.file.data.byteLength)).toEqual([1000, 1000, 500]);
9467
+ expect(requests.map(request => request.overwrite)).toEqual([true, false, false]);
9468
+ expect(requests.map(request => request.ui_percentage)).toEqual([50, 84, 99]);
9469
+ requests.forEach(request =>
9470
+ expect(request).toMatchObject({
9471
+ append: false,
9472
+ file: { total_size: 2500, path: 'vol0:/application_p1.bin' },
9473
+ })
9474
+ );
9475
+ const transferredBytes = (method.postProgressMessage as jest.Mock).mock.calls.map(
9476
+ ([, , metrics]) => metrics.transferredBytes
9477
+ );
9478
+ expect(transferredBytes).toEqual([1500, 2500, 3000]);
9479
+ });
9480
+
9481
+ test('caps windowed staging writes at the generic BLE chunk', async () => {
9482
+ const requests: any[] = [];
9483
+ const method = createMethod(streamAll(requests));
9484
+ delete (method as any).getProtocolV2FirmwareChunkSize;
9485
+ (method as any).params = { platform: 'native', chunkSize: 4096 };
9486
+
9487
+ await expect(transfer(method)).resolves.toBe(3000);
9488
+
9489
+ expect(requests.map(request => request.file.offset)).toEqual([0, 1800]);
9490
+ expect(requests.map(request => request.file.data.byteLength)).toEqual([1800, 700]);
9491
+ });
9492
+
9493
+ test.each(['react-native', 'desktop-web-ble'] as const)(
9494
+ 'bounds each windowed response wait in %s',
9495
+ async env => {
9496
+ const getSettingsSpy = jest.spyOn(DataManager, 'getSettings').mockReturnValue(env);
9497
+ const typedCall = streamAll([]);
9498
+
9499
+ try {
9500
+ await transfer(createMethod(typedCall));
9501
+ } finally {
9502
+ getSettingsSpy.mockRestore();
9503
+ }
9504
+
9505
+ expect(typedCall.mock.calls[0][3]).toEqual(expect.objectContaining({ timeoutMs: 10_000 }));
9506
+ }
9507
+ );
9508
+
9509
+ const failWindowThenWriteSerial = (requests: any[]) => {
9510
+ const writeSerial = streamAll(requests);
9511
+ return jest.fn((name: string, resType: string, first: any, options: any) => {
9512
+ if (options.fileWritePipeline) {
9513
+ return Promise.reject(new Error('Protocol V2 file write response does not match'));
9514
+ }
9515
+ return writeSerial(name, resType, first, options);
9516
+ });
9517
+ };
9518
+
9519
+ test('falls back to serial whole-file overwrite after a failed windowed exchange', async () => {
9520
+ const requests: any[] = [];
9521
+ const typedCall = failWindowThenWriteSerial(requests);
9522
+ const method = createMethod(typedCall);
9523
+
9524
+ await expect(transfer(method)).resolves.toBe(3000);
9525
+
9526
+ expect((method as any).recoverProtocolV2FileTransfer).toHaveBeenCalledTimes(1);
9527
+ // One failed pipeline, then serial chunks overwriting the file from offset 0.
9528
+ expect(typedCall).toHaveBeenCalledTimes(4);
9529
+ expect(typedCall.mock.calls[0][3]).toHaveProperty('fileWritePipeline');
9530
+ typedCall.mock.calls
9531
+ .slice(1)
9532
+ .forEach(call => expect(call[3]).not.toHaveProperty('fileWritePipeline'));
9533
+ expect(typedCall.mock.calls[1][2]).toEqual(
9534
+ expect.objectContaining({ overwrite: true, file: expect.objectContaining({ offset: 0 }) })
9535
+ );
9536
+ expect(requests.map(request => request.file.offset)).toEqual([0, 1000, 2000]);
9537
+ const chunkSize = (method as any).getProtocolV2FirmwareChunkSize as jest.Mock;
9538
+ expect(chunkSize).toHaveBeenNthCalledWith(1, 'write', 'vol0:/application_p1.bin', true);
9539
+ expect(chunkSize).toHaveBeenNthCalledWith(2, 'write', 'vol0:/application_p1.bin', false);
9540
+ });
9541
+
9542
+ test('keeps subsequent files serial after a failed windowed exchange', async () => {
9543
+ const typedCall = failWindowThenWriteSerial([]);
9544
+ const method = createMethod(typedCall);
9545
+ await transfer(method);
9546
+ typedCall.mockClear();
9547
+
9548
+ await expect(transfer(method, 'vol0:/resource.bin')).resolves.toBe(3000);
9549
+
9550
+ expect(typedCall).toHaveBeenCalledTimes(3);
9551
+ typedCall.mock.calls.forEach(call => {
9552
+ expect(call[3]).not.toHaveProperty('fileWritePipeline');
9553
+ expect(call[2].file.path).toBe('vol0:/resource.bin');
9554
+ });
9555
+ expect((method as any).shouldUseProtocolV2BleFileWriteWindow).toHaveBeenCalledTimes(1);
9556
+ });
9557
+
9558
+ test('bounds failed file transfers to three attempts after serial fallback', async () => {
9559
+ const typedCall = jest.fn().mockRejectedValue(new Error('BLE response timeout'));
9560
+ const method = createMethod(typedCall);
9561
+ await expect(transfer(method)).rejects.toMatchObject({
9562
+ errorCode: HardwareErrorCode.EmmcFileWriteFirmwareError,
9563
+ });
9564
+ expect(typedCall).toHaveBeenCalledTimes(3);
9565
+ expect((method as any).recoverProtocolV2FileTransfer).toHaveBeenCalledTimes(2);
9566
+ expect(typedCall.mock.calls[0][3]).toHaveProperty('fileWritePipeline');
9567
+ typedCall.mock.calls
9568
+ .slice(1)
9569
+ .forEach(call => expect(call[3]).not.toHaveProperty('fileWritePipeline'));
9570
+ });
9571
+
9572
+ test('logs confirmed progress every ten seconds and average speed through Core', async () => {
9573
+ let now = 1000;
9574
+ const clock = jest.spyOn(Date, 'now').mockImplementation(() => now);
9575
+ const metrics = jest.spyOn(getLogger(LoggerNames.Core), 'log');
9576
+ const method = createMethod(
9577
+ streamAll([], () => {
9578
+ now += 5000;
9579
+ })
9580
+ );
9581
+ try {
9582
+ await expect(transfer(method)).resolves.toBe(3000);
9583
+ expect(metrics).toHaveBeenCalledTimes(3);
9584
+ expect(metrics).toHaveBeenNthCalledWith(
9585
+ 2,
9586
+ '[FirmwareUpdateV4] FileWrite metrics',
9587
+ expect.objectContaining({
9588
+ status: 'progress',
9589
+ fileWriteWindow: 2,
9590
+ chunkSize: 1000,
9591
+ transferredBytes: 2000,
9592
+ totalBytes: 2500,
9593
+ elapsedMs: 10_000,
9594
+ rateBytesPerSecond: 200,
9595
+ attempt: 1,
9596
+ retryCount: 0,
9597
+ })
9598
+ );
9599
+ expect(metrics).toHaveBeenLastCalledWith(
9600
+ '[FirmwareUpdateV4] FileWrite metrics',
9601
+ expect.objectContaining({
9602
+ status: 'completed',
9603
+ transferredBytes: 2500,
9604
+ elapsedMs: 15_000,
9605
+ rateBytesPerSecond: 167,
9606
+ })
9607
+ );
9608
+ } finally {
9609
+ clock.mockRestore();
9610
+ metrics.mockRestore();
9611
+ }
9612
+ });
9613
+
9614
+ test('stops sending on cancel and raises it after the transport call returns', async () => {
9615
+ const abortController = new AbortController();
9616
+ const requests: any[] = [];
9617
+ const succeed = streamAll(requests);
9618
+ const typedCall = jest.fn((name: string, resType: string, first: any, options: any) =>
9619
+ succeed(name, resType, first, {
9620
+ ...options,
9621
+ fileWritePipeline: {
9622
+ ...options.fileWritePipeline,
9623
+ onResponse: (response: any) => {
9624
+ options.fileWritePipeline.onResponse(response);
9625
+ abortController.abort();
9626
+ },
9627
+ },
9628
+ })
9629
+ );
9630
+ const method = createMethod(typedCall);
9631
+ method.abortSignal = abortController.signal;
9632
+
9633
+ await expect(transfer(method)).rejects.toMatchObject({
9634
+ errorCode: HardwareErrorCode.CallQueueActionCancelled,
9635
+ });
9636
+ expect(requests.map(request => request.file.offset)).toEqual([0]);
9637
+ expect((method as any).recoverProtocolV2FileTransfer).not.toHaveBeenCalled();
9638
+ });
9639
+
9640
+ test('raises a source read error after the windowed call and retries the file windowed', async () => {
9641
+ const requests: any[] = [];
9642
+ const typedCall = streamAll(requests);
9643
+ const method = createMethod(typedCall);
9644
+ const bytes = new Uint8Array(2500);
9645
+ let failed = false;
9646
+ const source = {
9647
+ size: bytes.length,
9648
+ readAt: jest.fn((offset: number, length: number) => {
9649
+ if (offset === 1000 && !failed) {
9650
+ failed = true;
9651
+ return Promise.reject(new Error('artifact read failed'));
9652
+ }
9653
+ return Promise.resolve(bytes.slice(offset, offset + length).buffer);
9654
+ }),
9655
+ close: jest.fn(),
9656
+ };
9657
+
9658
+ await expect(
9659
+ (method as any).protocolV2SourceUpdateProcess({
9660
+ source,
9661
+ filePath: 'vol0:/application_p1.bin',
9662
+ processedSize: 0,
9663
+ totalSize: 2500,
9664
+ })
9665
+ ).resolves.toBe(2500);
9666
+
9667
+ // A host-side read error says nothing about the bootloader, so the retry keeps the window.
9668
+ expect(typedCall).toHaveBeenCalledTimes(2);
9669
+ typedCall.mock.calls.forEach(call => expect(call[3]).toHaveProperty('fileWritePipeline'));
9670
+ expect((method as any).recoverProtocolV2FileTransfer).toHaveBeenCalledTimes(1);
9671
+ expect(requests.map(request => request.file.offset)).toEqual([0, 0, 1000, 2000]);
9672
+ });
9673
+
9674
+ test('rejects a windowed write that does not confirm the whole file', async () => {
9675
+ const typedCall = jest.fn((_name: string, _resType: string, first: any) =>
9676
+ Promise.resolve(confirm(first))
9677
+ );
9678
+ const method = createMethod(typedCall);
9679
+ const source = await openFirmwareByteSource({ binary: new Uint8Array(2500).buffer });
9680
+
9681
+ try {
9682
+ await expect(
9683
+ (method as any).writeProtocolV2SourceWindowed({
9684
+ source,
9685
+ filePath: 'vol0:/application_p1.bin',
9686
+ chunkSize: 1000,
9687
+ processedSize: 500,
9688
+ totalSize: 3000,
9689
+ onChunkConfirmed: jest.fn(),
9690
+ })
9691
+ ).rejects.toMatchObject({
9692
+ errorCode: HardwareErrorCode.EmmcFileWriteFirmwareError,
9693
+ message: expect.stringContaining('invalid processed_byte 1000 for file size 2500'),
9694
+ });
9695
+ } finally {
9696
+ await source?.close();
9697
+ }
9698
+ expect(typedCall).toHaveBeenCalledTimes(1);
9699
+ });
9700
+ });
9701
+
8886
9702
  test('uses absolute processed_byte offsets and disables append for firmware file writes', async () => {
8887
9703
  const method = new FirmwareUpdateV4({
8888
9704
  id: 1,
@@ -9612,6 +10428,27 @@ describe('Protocol V2 firmware update targets', () => {
9612
10428
  expect(writePayloads.map(payload => payload.file.data.byteLength)).toEqual([1960, 1]);
9613
10429
  });
9614
10430
 
10431
+ test.each([
10432
+ [false, 'vol0:/application_p1.bin', 1960],
10433
+ [true, 'vol0:/application_p1.bin', 1800],
10434
+ [true, 'vol0:/bundles/images/animation.okpkg', 1800],
10435
+ ])(
10436
+ 'sizes BLE write chunks for fileWriteWindow=%s and %s',
10437
+ (fileWriteWindow, filePath, expected) => {
10438
+ const method = new FirmwareUpdateV4({
10439
+ id: 1,
10440
+ payload: {
10441
+ method: 'firmwareUpdateV4',
10442
+ },
10443
+ });
10444
+ (method as any).params = { platform: 'native', chunkSize: 4096 };
10445
+
10446
+ expect(
10447
+ (method as any).getProtocolV2FirmwareChunkSize('write', filePath, fileWriteWindow)
10448
+ ).toBe(expected);
10449
+ }
10450
+ );
10451
+
9615
10452
  test('keeps confirmation active after the Request write until terminal Success arrives', async () => {
9616
10453
  const method = new FirmwareUpdateV4({
9617
10454
  id: 1,
@@ -10150,8 +10987,7 @@ describe('Protocol V2 firmware reconnect identity', () => {
10150
10987
  });
10151
10988
 
10152
10989
  await expect((method as any).readProtocolV2DeviceFileHeader('bundle.okpp')).resolves.toBeNull();
10153
- expect(readChunkLengths.length).toBeGreaterThan(1);
10154
- expect(Math.max(...readChunkLengths)).toBe(900);
10990
+ expect(readChunkLengths).toEqual([640]);
10155
10991
  });
10156
10992
 
10157
10993
  test('skips resource transfer when installed okpkg hashes match the downloaded package', async () => {
@@ -10207,6 +11043,12 @@ describe('Protocol V2 firmware reconnect identity', () => {
10207
11043
  path: 'vol0:/bundles/images/images.okpkg',
10208
11044
  });
10209
11045
  expect((method as any).protocolV2SourceUpdateProcess).not.toHaveBeenCalled();
11046
+ expect(typedCall).toHaveBeenCalledTimes(2);
11047
+ expect(typedCall).toHaveBeenCalledWith('FilesystemFileRead', 'FilesystemFile', {
11048
+ file: { path: 'vol0:/bundles/images/images.okpkg', offset: 0, total_size: 0 },
11049
+ chunk_len: 640,
11050
+ ui_percentage: undefined,
11051
+ });
10210
11052
  });
10211
11053
 
10212
11054
  test('updates a resource when the installed file size differs despite matching headers', async () => {