@oxy.so/services 13.7.0 → 13.9.0

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 (76) hide show
  1. package/android/build.gradle +1 -0
  2. package/android/src/main/java/so/oxy/assets/OxyAssetFilesModule.kt +49 -0
  3. package/android/src/main/java/so/oxy/assets/StreamSha256.kt +23 -0
  4. package/android/src/test/java/so/oxy/assets/StreamSha256Test.kt +45 -0
  5. package/expo-module.config.json +3 -2
  6. package/ios/OxyAssetFilesModule.swift +72 -0
  7. package/lib/commonjs/index.js +10 -0
  8. package/lib/commonjs/index.js.map +1 -1
  9. package/lib/commonjs/ui/components/OxyDeviceSignIn.js +192 -0
  10. package/lib/commonjs/ui/components/OxyDeviceSignIn.js.map +1 -0
  11. package/lib/commonjs/ui/context/OxyContext.js +4 -0
  12. package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
  13. package/lib/commonjs/ui/hooks/accountDialogSnapshot.js +1 -0
  14. package/lib/commonjs/ui/hooks/accountDialogSnapshot.js.map +1 -1
  15. package/lib/commonjs/ui/index.js +7 -0
  16. package/lib/commonjs/ui/index.js.map +1 -1
  17. package/lib/commonjs/ui/utils/nativeAssetFiles.js +11 -0
  18. package/lib/commonjs/ui/utils/nativeAssetFiles.js.map +1 -0
  19. package/lib/commonjs/ui/utils/nativeAssetFiles.macos.js +11 -0
  20. package/lib/commonjs/ui/utils/nativeAssetFiles.macos.js.map +1 -0
  21. package/lib/commonjs/ui/utils/nativeAssetFiles.native.js +202 -0
  22. package/lib/commonjs/ui/utils/nativeAssetFiles.native.js.map +1 -0
  23. package/lib/module/index.js +3 -0
  24. package/lib/module/index.js.map +1 -1
  25. package/lib/module/ui/components/OxyDeviceSignIn.js +188 -0
  26. package/lib/module/ui/components/OxyDeviceSignIn.js.map +1 -0
  27. package/lib/module/ui/context/OxyContext.js +5 -1
  28. package/lib/module/ui/context/OxyContext.js.map +1 -1
  29. package/lib/module/ui/hooks/accountDialogSnapshot.js +1 -0
  30. package/lib/module/ui/hooks/accountDialogSnapshot.js.map +1 -1
  31. package/lib/module/ui/index.js +1 -0
  32. package/lib/module/ui/index.js.map +1 -1
  33. package/lib/module/ui/utils/nativeAssetFiles.js +7 -0
  34. package/lib/module/ui/utils/nativeAssetFiles.js.map +1 -0
  35. package/lib/module/ui/utils/nativeAssetFiles.macos.js +7 -0
  36. package/lib/module/ui/utils/nativeAssetFiles.macos.js.map +1 -0
  37. package/lib/module/ui/utils/nativeAssetFiles.native.js +197 -0
  38. package/lib/module/ui/utils/nativeAssetFiles.native.js.map +1 -0
  39. package/lib/typescript/commonjs/index.d.ts +2 -0
  40. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  41. package/lib/typescript/commonjs/ui/components/OxyDeviceSignIn.d.ts +29 -0
  42. package/lib/typescript/commonjs/ui/components/OxyDeviceSignIn.d.ts.map +1 -0
  43. package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
  44. package/lib/typescript/commonjs/ui/hooks/accountDialogSnapshot.d.ts.map +1 -1
  45. package/lib/typescript/commonjs/ui/index.d.ts +2 -0
  46. package/lib/typescript/commonjs/ui/index.d.ts.map +1 -1
  47. package/lib/typescript/commonjs/ui/utils/nativeAssetFiles.d.ts +4 -0
  48. package/lib/typescript/commonjs/ui/utils/nativeAssetFiles.d.ts.map +1 -0
  49. package/lib/typescript/commonjs/ui/utils/nativeAssetFiles.macos.d.ts +4 -0
  50. package/lib/typescript/commonjs/ui/utils/nativeAssetFiles.macos.d.ts.map +1 -0
  51. package/lib/typescript/commonjs/ui/utils/nativeAssetFiles.native.d.ts +4 -0
  52. package/lib/typescript/commonjs/ui/utils/nativeAssetFiles.native.d.ts.map +1 -0
  53. package/lib/typescript/module/index.d.ts +2 -0
  54. package/lib/typescript/module/index.d.ts.map +1 -1
  55. package/lib/typescript/module/ui/components/OxyDeviceSignIn.d.ts +29 -0
  56. package/lib/typescript/module/ui/components/OxyDeviceSignIn.d.ts.map +1 -0
  57. package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
  58. package/lib/typescript/module/ui/hooks/accountDialogSnapshot.d.ts.map +1 -1
  59. package/lib/typescript/module/ui/index.d.ts +2 -0
  60. package/lib/typescript/module/ui/index.d.ts.map +1 -1
  61. package/lib/typescript/module/ui/utils/nativeAssetFiles.d.ts +4 -0
  62. package/lib/typescript/module/ui/utils/nativeAssetFiles.d.ts.map +1 -0
  63. package/lib/typescript/module/ui/utils/nativeAssetFiles.macos.d.ts +4 -0
  64. package/lib/typescript/module/ui/utils/nativeAssetFiles.macos.d.ts.map +1 -0
  65. package/lib/typescript/module/ui/utils/nativeAssetFiles.native.d.ts +4 -0
  66. package/lib/typescript/module/ui/utils/nativeAssetFiles.native.d.ts.map +1 -0
  67. package/package.json +5 -4
  68. package/src/index.ts +4 -0
  69. package/src/ui/components/OxyDeviceSignIn.tsx +172 -0
  70. package/src/ui/context/OxyContext.tsx +13 -1
  71. package/src/ui/hooks/accountDialogSnapshot.ts +1 -0
  72. package/src/ui/index.ts +2 -0
  73. package/src/ui/utils/__tests__/nativeAssetFiles.test.ts +270 -0
  74. package/src/ui/utils/nativeAssetFiles.macos.ts +6 -0
  75. package/src/ui/utils/nativeAssetFiles.native.ts +212 -0
  76. package/src/ui/utils/nativeAssetFiles.ts +6 -0
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Sign-in for a device with no keyboard — a TV, a photo frame, a kiosk: a large
3
+ * QR that any phone camera opens, and the code to compare on the phone.
4
+ *
5
+ * The QR encodes the request's https approval page
6
+ * (`signIn.approvalUrl`, `auth.oxy.so/device?user_code=…`), not the
7
+ * `oxycommons://` deep link the account dialog shows: whoever scans a frame may
8
+ * not have Commons, and the page works in any phone browser. It prints the same
9
+ * code this surface shows, so the person can check they are approving THIS
10
+ * screen before they press Allow there.
11
+ *
12
+ * It owns one QR-only request (`controller.startInlineQr()`), exactly like the
13
+ * sign-in entry's embedded QR (`signIn/InlineCommonsQr.tsx`): started on mount,
14
+ * renewed when it expires, withdrawn on unmount while it is still approvable.
15
+ * It never pushes to a phone or opens Commons. On approval the controller
16
+ * claims the session and `OxyProvider` signs the app in, so the host simply
17
+ * stops rendering this.
18
+ *
19
+ * Anti-phishing: the plate encodes `signIn.approvalUrl` and the code line shows
20
+ * `signIn.authorizeCode`, both PUBLIC; nothing about the application is read out
21
+ * of either here. The secret `sessionToken` never leaves the controller.
22
+ */
23
+
24
+ import type React from 'react';
25
+ import { useEffect } from 'react';
26
+ import { StyleSheet, View } from 'react-native';
27
+ import QRCode from 'react-native-qrcode-svg';
28
+ import { Button } from '@oxy.so/bloom/button';
29
+ import { Code } from '@oxy.so/bloom/code';
30
+ import { RiCheckboxCircleLine } from '@oxy.so/bloom/icons/RiCheckboxCircleLine';
31
+ import { RiRefreshLine } from '@oxy.so/bloom/icons/RiRefreshLine';
32
+ import { SpinnerIcon } from '@oxy.so/bloom/loading';
33
+ import { useTheme } from '@oxy.so/bloom/theme';
34
+ import { Text } from '@oxy.so/bloom/typography';
35
+ import { useOxy } from '../context/oxyRuntimeContext';
36
+ import { useI18n } from '../hooks/useI18n';
37
+ import { useAccountDialogSnapshot } from '../hooks/accountDialogSnapshot';
38
+
39
+ /** High-contrast, un-themed on purpose: scan reliability. */
40
+ const QR_PLATE_BG = '#FFFFFF';
41
+ const QR_FOREGROUND = '#000000';
42
+ /** Big enough to scan from across a room at the default size. */
43
+ const DEFAULT_QR_SIZE = 280;
44
+ /** The quiet zone around the symbol, as a fraction of its size. */
45
+ const PLATE_PADDING_RATIO = 0.06;
46
+
47
+ export interface OxyDeviceSignInProps {
48
+ /** Edge of the QR symbol in points. Defaults to 280. */
49
+ size?: number;
50
+ }
51
+
52
+ export const OxyDeviceSignIn: React.FC<OxyDeviceSignInProps> = ({ size = DEFAULT_QR_SIZE }) => {
53
+ const theme = useTheme();
54
+ const { t } = useI18n();
55
+ const { accountDialogController: controller } = useOxy();
56
+ const { signIn } = useAccountDialogSnapshot(controller);
57
+ const live = signIn.inline && (signIn.phase === 'starting' || signIn.phase === 'waiting');
58
+ const expired = signIn.inline && signIn.phase === 'error' && signIn.failure === 'expired';
59
+
60
+ // One request for as long as this is on screen. Withdrawn on the way out only
61
+ // while it is still ours and still approvable.
62
+ useEffect(() => {
63
+ if (!controller) return;
64
+ const { signIn: current } = controller.getSnapshot();
65
+ if (!(current.inline && (current.phase === 'starting' || current.phase === 'waiting'))) {
66
+ void controller.startInlineQr();
67
+ }
68
+ return () => {
69
+ const { signIn: last } = controller.getSnapshot();
70
+ if (last.inline && (last.phase === 'starting' || last.phase === 'waiting'))
71
+ controller.cancelSignIn();
72
+ };
73
+ }, [controller]);
74
+
75
+ // A code only lives a few minutes; an expired one is replaced, not reported.
76
+ useEffect(() => {
77
+ if (controller && expired) void controller.startInlineQr();
78
+ }, [controller, expired]);
79
+
80
+ const padding = Math.round(size * PLATE_PADDING_RATIO);
81
+ const showsCode = live && signIn.approvalUrl && signIn.progress !== 'confirming-identity';
82
+
83
+ let plate: React.ReactNode;
84
+ if (signIn.inline && signIn.progress === 'identity-confirmed') {
85
+ plate = <RiCheckboxCircleLine size="3xl" fill={theme.colors.success} />;
86
+ } else if (showsCode && signIn.approvalUrl) {
87
+ plate = (
88
+ <QRCode
89
+ value={signIn.approvalUrl}
90
+ size={size}
91
+ backgroundColor={QR_PLATE_BG}
92
+ color={QR_FOREGROUND}
93
+ />
94
+ );
95
+ } else if (live || expired || (signIn.inline && signIn.phase === 'completed')) {
96
+ plate = <SpinnerIcon size={Math.round(size / 6)} color={QR_FOREGROUND} />;
97
+ } else {
98
+ // Failed for a reason renewing will not fix by itself, or superseded and
99
+ // then withdrawn: one press for a new code.
100
+ plate = (
101
+ <Button
102
+ appearance="plain"
103
+ tone="neutral"
104
+ leadingIcon={RiRefreshLine}
105
+ onPress={() => void controller?.startInlineQr()}
106
+ testID="device-sign-in-renew"
107
+ >
108
+ {t('signin.qr.renew')}
109
+ </Button>
110
+ );
111
+ }
112
+
113
+ return (
114
+ <View style={styles.block} testID="oxy-device-sign-in">
115
+ <Text style={[styles.title, { color: theme.colors.text }]}>{t('signin.device.title')}</Text>
116
+ <Text style={[styles.caption, { color: theme.colors.textSecondary }]}>
117
+ {t('signin.device.caption')}
118
+ </Text>
119
+ <View
120
+ style={[styles.plate, { width: size + padding * 2, height: size + padding * 2, padding }]}
121
+ >
122
+ {plate}
123
+ </View>
124
+ {showsCode && signIn.authorizeCode ? (
125
+ <View style={styles.codeBlock}>
126
+ <Text style={[styles.codeHint, { color: theme.colors.textSecondary }]}>
127
+ {t('signin.device.codeHint')}
128
+ </Text>
129
+ <Code testID="device-sign-in-code" style={styles.code}>
130
+ {signIn.authorizeCode}
131
+ </Code>
132
+ </View>
133
+ ) : null}
134
+ </View>
135
+ );
136
+ };
137
+
138
+ const styles = StyleSheet.create({
139
+ block: {
140
+ alignItems: 'center',
141
+ gap: 16,
142
+ },
143
+ title: {
144
+ fontSize: 28,
145
+ lineHeight: 34,
146
+ fontWeight: '600',
147
+ textAlign: 'center',
148
+ },
149
+ caption: {
150
+ fontSize: 16,
151
+ lineHeight: 22,
152
+ textAlign: 'center',
153
+ },
154
+ plate: {
155
+ borderRadius: 16,
156
+ backgroundColor: QR_PLATE_BG,
157
+ alignItems: 'center',
158
+ justifyContent: 'center',
159
+ },
160
+ codeBlock: {
161
+ alignItems: 'center',
162
+ gap: 4,
163
+ },
164
+ codeHint: {
165
+ fontSize: 13,
166
+ lineHeight: 18,
167
+ textAlign: 'center',
168
+ },
169
+ code: {
170
+ fontSize: 14,
171
+ },
172
+ });
@@ -1,3 +1,4 @@
1
+ import { installNativeAssetFiles } from '../utils/nativeAssetFiles';
1
2
  import {
2
3
  startNativeOAuthSignIn,
3
4
  type StartNativeOAuthSignInOptions,
@@ -9,7 +10,15 @@ import {
9
10
  setIsolatedOAuthSession,
10
11
  } from './isolatedOAuthSession';
11
12
  import type React from 'react';
12
- import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
13
+ import {
14
+ useCallback,
15
+ useEffect,
16
+ useLayoutEffect,
17
+ useMemo,
18
+ useRef,
19
+ useState,
20
+ useSyncExternalStore,
21
+ } from 'react';
13
22
  import { AppState, Linking, Platform } from 'react-native';
14
23
  import { OxyServices } from '@oxy.so/core';
15
24
  import type { LoginSessionResult } from '@oxy.so/contracts';
@@ -182,6 +191,9 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
182
191
  }
183
192
  }
184
193
  const oxyServices = oxyServicesRef.current;
194
+ // Child passive effects may upload immediately on mount. Configure transport
195
+ // during commit, before those effects, without mutating the client in render.
196
+ useLayoutEffect(() => installNativeAssetFiles(oxyServices), [oxyServices]);
185
197
 
186
198
  // The device-first persisted auth-state store (per-origin device credential on
187
199
  // web; SecureStore session blob on native). Built ONCE per provider mount.
@@ -28,6 +28,7 @@ export const EMPTY_ACCOUNT_DIALOG_SNAPSHOT: AccountDialogSnapshot = {
28
28
  phase: 'idle',
29
29
  authorizeCode: null,
30
30
  qrPayload: null,
31
+ approvalUrl: null,
31
32
  expiresAt: null,
32
33
  error: null,
33
34
  failure: null,
package/src/ui/index.ts CHANGED
@@ -27,6 +27,8 @@ import '../registerPlatform';
27
27
  // Components
28
28
  export { default as OxyProvider } from './components/OxyProvider';
29
29
  export { default as OxySignInButton } from './components/OxySignInButton';
30
+ export { OxyDeviceSignIn } from './components/OxyDeviceSignIn';
31
+ export type { OxyDeviceSignInProps } from './components/OxyDeviceSignIn';
30
32
  export { default as OxyAuthPrompt } from './components/OxyAuthPrompt';
31
33
  export type { OxyAuthPromptProps } from './components/OxyAuthPrompt';
32
34
  export { LogoIcon } from './components/logo/LogoIcon';
@@ -0,0 +1,270 @@
1
+ import type { AssetFileAdapter, OxyServices } from '@oxy.so/core';
2
+ import { installNativeAssetFiles } from '../nativeAssetFiles.native';
3
+
4
+ const originalUri = 'file:///capture.mp4';
5
+ const mockFiles = new Map<string, { size: number; written: number }>();
6
+ const mockReads: number[] = [];
7
+ const mockDeleted: string[] = [];
8
+ let mockNativeHasher: unknown = null;
9
+ jest.mock('expo-modules-core', () => ({ requireOptionalNativeModule: () => mockNativeHasher }));
10
+ let mockUpload: jest.Mock;
11
+ let mockCancel: jest.Mock;
12
+ let mockRequest: { url: string; uri: string; options: unknown } | undefined;
13
+
14
+ jest.mock('expo-file-system', () => {
15
+ const uriOf = (value: string | { uri: string }) =>
16
+ typeof value === 'string' ? value : value.uri;
17
+ class Directory {
18
+ uri: string;
19
+ constructor(base: string, name: string) {
20
+ this.uri = `${base}/${name}`;
21
+ }
22
+ get exists() {
23
+ return false;
24
+ }
25
+ create() {}
26
+ delete() {}
27
+ }
28
+ class File {
29
+ uri: string;
30
+ constructor(base: string | { uri: string }, name?: string) {
31
+ this.uri = name ? `${uriOf(base)}/${name}` : uriOf(base);
32
+ }
33
+ get exists() {
34
+ return mockFiles.has(this.uri);
35
+ }
36
+ get size() {
37
+ return mockFiles.get(this.uri)?.size ?? 0;
38
+ }
39
+ create() {
40
+ mockFiles.set(this.uri, { size: 0, written: 0 });
41
+ }
42
+ delete() {
43
+ mockDeleted.push(this.uri);
44
+ mockFiles.delete(this.uri);
45
+ }
46
+ open() {
47
+ const uri = this.uri;
48
+ let offset = 0;
49
+ let closed = false;
50
+ return {
51
+ get offset() {
52
+ return offset;
53
+ },
54
+ set offset(next: number) {
55
+ offset = next;
56
+ },
57
+ readBytes(length: number) {
58
+ if (closed) throw new Error('Closed');
59
+ mockReads.push(length);
60
+ const size = mockFiles.get(uri)?.size ?? 0;
61
+ const bytes = new Uint8Array(Math.max(0, Math.min(length, size - offset))).fill(7);
62
+ offset += bytes.length;
63
+ return bytes;
64
+ },
65
+ writeBytes(bytes: Uint8Array) {
66
+ if (closed) throw new Error('Closed');
67
+ const file = mockFiles.get(uri);
68
+ if (!file) throw new Error('Missing target');
69
+ file.written += bytes.length;
70
+ file.size += bytes.length;
71
+ },
72
+ close() {
73
+ closed = true;
74
+ },
75
+ };
76
+ }
77
+ }
78
+ return { File, Directory, Paths: { cache: 'file:///cache' } };
79
+ });
80
+ jest.mock('expo-file-system/legacy', () => ({
81
+ FileSystemUploadType: { BINARY_CONTENT: 0 },
82
+ FileSystemSessionType: { FOREGROUND: 0 },
83
+ createUploadTask: (
84
+ url: string,
85
+ uri: string,
86
+ options: unknown,
87
+ progress: (event: { totalBytesSent: number }) => void,
88
+ ) => {
89
+ mockRequest = { url, uri, options };
90
+ return { uploadAsync: () => mockUpload(uri, progress), cancelAsync: () => mockCancel() };
91
+ },
92
+ }));
93
+
94
+ function adapter(): AssetFileAdapter {
95
+ let installed: AssetFileAdapter | undefined;
96
+ const oxy = {
97
+ assets: {
98
+ installFileAdapter: (value: AssetFileAdapter) => {
99
+ installed = value;
100
+ return () => {};
101
+ },
102
+ },
103
+ } as unknown as OxyServices;
104
+ installNativeAssetFiles(oxy);
105
+ if (!installed) throw new Error('Adapter was not installed');
106
+ return installed;
107
+ }
108
+
109
+ beforeEach(() => {
110
+ mockNativeHasher = null;
111
+ mockFiles.clear();
112
+ mockFiles.set(originalUri, { size: 370 * 1024 * 1024, written: 0 });
113
+ mockReads.length = 0;
114
+ mockDeleted.length = 0;
115
+ mockRequest = undefined;
116
+ mockCancel = jest.fn(async () => undefined);
117
+ mockUpload = jest.fn(
118
+ async (uri: string, progress: (event: { totalBytesSent: number }) => void) => {
119
+ progress({ totalBytesSent: mockFiles.get(uri)?.size ?? 0 });
120
+ return { status: 200 };
121
+ },
122
+ );
123
+ });
124
+
125
+ test('reads bounded ranges and streams only a temporary part of a larger-than-heap file', async () => {
126
+ const source = await adapter()(originalUri);
127
+ expect(source.size).toBe(370 * 1024 * 1024);
128
+ const bytes = await source.readRange(16 * 1024 * 1024, 24 * 1024 * 1024);
129
+ expect(bytes.length).toBe(8 * 1024 * 1024);
130
+ expect(bytes[bytes.length - 1]).toBe(7);
131
+ const onProgress = jest.fn();
132
+ await expect(
133
+ source.putPart({
134
+ url: 'https://storage.example/part?signed=1',
135
+ start: 32 * 1024 * 1024,
136
+ end: 40 * 1024 * 1024,
137
+ timeoutMs: 1000,
138
+ onProgress,
139
+ }),
140
+ ).resolves.toEqual({ ok: true });
141
+ expect(Math.max(...mockReads)).toBe(1024 * 1024);
142
+ expect(mockReads.reduce((sum, size) => sum + size, 0)).toBe(16 * 1024 * 1024);
143
+ expect(mockRequest).toMatchObject({
144
+ url: 'https://storage.example/part?signed=1',
145
+ options: { httpMethod: 'PUT', uploadType: 0, sessionType: 0, headers: {} },
146
+ });
147
+ expect(onProgress).toHaveBeenCalledWith(8 * 1024 * 1024);
148
+ expect(mockFiles.has(originalUri)).toBe(true);
149
+ expect(mockFiles.size).toBe(1);
150
+ expect(mockDeleted).toHaveLength(1);
151
+ });
152
+
153
+ test('abort cancels the native transfer and deletes its part while preserving the capture', async () => {
154
+ const source = await adapter()(originalUri);
155
+ const controller = new AbortController();
156
+ mockUpload.mockImplementation(() => new Promise(() => {}));
157
+ const pending = source.putPart({
158
+ url: 'https://storage.example/part',
159
+ start: 0,
160
+ end: 2 * 1024 * 1024,
161
+ timeoutMs: 1000,
162
+ signal: controller.signal,
163
+ onProgress: jest.fn(),
164
+ });
165
+ while (!mockRequest) await new Promise((resolve) => setTimeout(resolve, 0));
166
+ controller.abort();
167
+ await expect(pending).rejects.toMatchObject({ name: 'AbortError' });
168
+ expect(mockCancel).toHaveBeenCalledTimes(1);
169
+ expect(mockFiles.size).toBe(1);
170
+ expect(mockFiles.has(originalUri)).toBe(true);
171
+ });
172
+
173
+ test('a stalled native PUT times out and cleans its temporary part', async () => {
174
+ const source = await adapter()(originalUri);
175
+ mockUpload.mockImplementation(() => new Promise(() => {}));
176
+ await expect(
177
+ source.putPart({
178
+ url: 'https://storage.example/part',
179
+ start: 0,
180
+ end: 1024,
181
+ timeoutMs: 5,
182
+ onProgress: jest.fn(),
183
+ }),
184
+ ).resolves.toEqual({ ok: false, status: 0 });
185
+ expect(mockCancel).toHaveBeenCalledTimes(1);
186
+ expect(mockFiles.size).toBe(1);
187
+ });
188
+
189
+ test('hash reads let scheduled UI work run before the complete range is read', async () => {
190
+ const source = await adapter()(originalUri);
191
+ let readsAtTick = -1;
192
+ const tick = new Promise<void>((resolve) =>
193
+ setTimeout(() => {
194
+ readsAtTick = mockReads.length;
195
+ resolve();
196
+ }, 0),
197
+ );
198
+ await source.readRange(0, 8 * 1024 * 1024);
199
+ await tick;
200
+ expect(readsAtTick).toBeGreaterThan(0);
201
+ expect(readsAtTick).toBeLessThan(8);
202
+ });
203
+
204
+ test('an abort timer interrupts part preparation before copying the complete range', async () => {
205
+ const source = await adapter()(originalUri);
206
+ const controller = new AbortController();
207
+ setTimeout(() => controller.abort(), 0);
208
+ await expect(
209
+ source.putPart({
210
+ url: 'https://storage.example/part',
211
+ start: 0,
212
+ end: 8 * 1024 * 1024,
213
+ timeoutMs: 1000,
214
+ signal: controller.signal,
215
+ onProgress: jest.fn(),
216
+ }),
217
+ ).rejects.toMatchObject({ name: 'AbortError' });
218
+ expect(mockReads.length).toBeLessThan(8);
219
+ expect(mockUpload).not.toHaveBeenCalled();
220
+ expect(mockFiles.size).toBe(1);
221
+ expect(mockFiles.has(originalUri)).toBe(true);
222
+ });
223
+
224
+ test('uses the native streaming hash without reading bytes into JavaScript', async () => {
225
+ let progress: (event: { id: string; loaded: number }) => void;
226
+ const remove = jest.fn();
227
+ const digest = 'ab'.repeat(32);
228
+ mockNativeHasher = {
229
+ createHash: jest.fn(),
230
+ cancelHash: jest.fn(),
231
+ addListener: jest.fn((_event, callback) => {
232
+ progress = callback;
233
+ return { remove };
234
+ }),
235
+ hashAsync: jest.fn(async (id, _uri, size) => {
236
+ progress({ id: 'another-upload', loaded: 1 });
237
+ progress({ id, loaded: size });
238
+ return digest;
239
+ }),
240
+ };
241
+ const source = await adapter()(originalUri);
242
+ const onProgress = jest.fn();
243
+ await expect(source.hash?.(onProgress)).resolves.toBe(digest);
244
+ expect(mockReads).toHaveLength(0);
245
+ expect(onProgress).toHaveBeenCalledTimes(1);
246
+ expect(onProgress).toHaveBeenCalledWith(source.size);
247
+ expect(remove).toHaveBeenCalledTimes(1);
248
+ });
249
+
250
+ test('native hash cancellation waits for worker cleanup and removes its listener', async () => {
251
+ const controller = new AbortController();
252
+ const remove = jest.fn();
253
+ let finish: (value: string) => void;
254
+ const cancelHash = jest.fn(() => finish('ab'.repeat(32)));
255
+ mockNativeHasher = {
256
+ createHash: jest.fn(),
257
+ cancelHash,
258
+ addListener: () => ({ remove }),
259
+ hashAsync: () =>
260
+ new Promise<string>((resolve) => {
261
+ finish = resolve;
262
+ }),
263
+ };
264
+ const source = await adapter()(originalUri);
265
+ const pending = source.hash?.(undefined, controller.signal);
266
+ controller.abort();
267
+ await expect(pending).rejects.toMatchObject({ name: 'AbortError' });
268
+ expect(cancelHash).toHaveBeenCalledTimes(1);
269
+ expect(remove).toHaveBeenCalledTimes(1);
270
+ });
@@ -0,0 +1,6 @@
1
+ import type { OxyServices } from '@oxy.so/core';
2
+
3
+ /** Web/Node use their existing Blob transport. Metro selects the native adapter. */
4
+ export function installNativeAssetFiles(_oxy: OxyServices): undefined {
5
+ return undefined;
6
+ }