@oxy.so/services 13.6.0 → 13.8.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 (114) 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/ui/components/AccountLanguageBoundary.js +35 -0
  8. package/lib/commonjs/ui/components/AccountLanguageBoundary.js.map +1 -0
  9. package/lib/commonjs/ui/components/LanguageBridge.js +37 -34
  10. package/lib/commonjs/ui/components/LanguageBridge.js.map +1 -1
  11. package/lib/commonjs/ui/components/OxyProvider.js +19 -26
  12. package/lib/commonjs/ui/components/OxyProvider.js.map +1 -1
  13. package/lib/commonjs/ui/components/signIn/OxySignUpPanel.js +8 -2
  14. package/lib/commonjs/ui/components/signIn/OxySignUpPanel.js.map +1 -1
  15. package/lib/commonjs/ui/context/OxyContext.js +23 -2
  16. package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
  17. package/lib/commonjs/ui/hooks/useEntryLink.js +97 -0
  18. package/lib/commonjs/ui/hooks/useEntryLink.js.map +1 -0
  19. package/lib/commonjs/ui/hooks/useLanguageManagement.js +45 -14
  20. package/lib/commonjs/ui/hooks/useLanguageManagement.js.map +1 -1
  21. package/lib/commonjs/ui/utils/entryLink.js +54 -0
  22. package/lib/commonjs/ui/utils/entryLink.js.map +1 -0
  23. package/lib/commonjs/ui/utils/nativeAssetFiles.js +11 -0
  24. package/lib/commonjs/ui/utils/nativeAssetFiles.js.map +1 -0
  25. package/lib/commonjs/ui/utils/nativeAssetFiles.macos.js +11 -0
  26. package/lib/commonjs/ui/utils/nativeAssetFiles.macos.js.map +1 -0
  27. package/lib/commonjs/ui/utils/nativeAssetFiles.native.js +202 -0
  28. package/lib/commonjs/ui/utils/nativeAssetFiles.native.js.map +1 -0
  29. package/lib/module/ui/components/AccountLanguageBoundary.js +32 -0
  30. package/lib/module/ui/components/AccountLanguageBoundary.js.map +1 -0
  31. package/lib/module/ui/components/LanguageBridge.js +38 -35
  32. package/lib/module/ui/components/LanguageBridge.js.map +1 -1
  33. package/lib/module/ui/components/OxyProvider.js +18 -27
  34. package/lib/module/ui/components/OxyProvider.js.map +1 -1
  35. package/lib/module/ui/components/signIn/OxySignUpPanel.js +8 -2
  36. package/lib/module/ui/components/signIn/OxySignUpPanel.js.map +1 -1
  37. package/lib/module/ui/context/OxyContext.js +24 -3
  38. package/lib/module/ui/context/OxyContext.js.map +1 -1
  39. package/lib/module/ui/hooks/useEntryLink.js +94 -0
  40. package/lib/module/ui/hooks/useEntryLink.js.map +1 -0
  41. package/lib/module/ui/hooks/useLanguageManagement.js +45 -14
  42. package/lib/module/ui/hooks/useLanguageManagement.js.map +1 -1
  43. package/lib/module/ui/utils/entryLink.js +49 -0
  44. package/lib/module/ui/utils/entryLink.js.map +1 -0
  45. package/lib/module/ui/utils/nativeAssetFiles.js +7 -0
  46. package/lib/module/ui/utils/nativeAssetFiles.js.map +1 -0
  47. package/lib/module/ui/utils/nativeAssetFiles.macos.js +7 -0
  48. package/lib/module/ui/utils/nativeAssetFiles.macos.js.map +1 -0
  49. package/lib/module/ui/utils/nativeAssetFiles.native.js +197 -0
  50. package/lib/module/ui/utils/nativeAssetFiles.native.js.map +1 -0
  51. package/lib/typescript/commonjs/ui/components/AccountLanguageBoundary.d.ts +8 -0
  52. package/lib/typescript/commonjs/ui/components/AccountLanguageBoundary.d.ts.map +1 -0
  53. package/lib/typescript/commonjs/ui/components/LanguageBridge.d.ts +7 -17
  54. package/lib/typescript/commonjs/ui/components/LanguageBridge.d.ts.map +1 -1
  55. package/lib/typescript/commonjs/ui/components/OxyProvider.d.ts.map +1 -1
  56. package/lib/typescript/commonjs/ui/components/signIn/OxySignUpPanel.d.ts.map +1 -1
  57. package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
  58. package/lib/typescript/commonjs/ui/context/oxyContextTypes.d.ts +5 -0
  59. package/lib/typescript/commonjs/ui/context/oxyContextTypes.d.ts.map +1 -1
  60. package/lib/typescript/commonjs/ui/hooks/useEntryLink.d.ts +15 -0
  61. package/lib/typescript/commonjs/ui/hooks/useEntryLink.d.ts.map +1 -0
  62. package/lib/typescript/commonjs/ui/hooks/useLanguageManagement.d.ts +5 -1
  63. package/lib/typescript/commonjs/ui/hooks/useLanguageManagement.d.ts.map +1 -1
  64. package/lib/typescript/commonjs/ui/types/navigation.d.ts +4 -0
  65. package/lib/typescript/commonjs/ui/types/navigation.d.ts.map +1 -1
  66. package/lib/typescript/commonjs/ui/utils/entryLink.d.ts +8 -0
  67. package/lib/typescript/commonjs/ui/utils/entryLink.d.ts.map +1 -0
  68. package/lib/typescript/commonjs/ui/utils/nativeAssetFiles.d.ts +4 -0
  69. package/lib/typescript/commonjs/ui/utils/nativeAssetFiles.d.ts.map +1 -0
  70. package/lib/typescript/commonjs/ui/utils/nativeAssetFiles.macos.d.ts +4 -0
  71. package/lib/typescript/commonjs/ui/utils/nativeAssetFiles.macos.d.ts.map +1 -0
  72. package/lib/typescript/commonjs/ui/utils/nativeAssetFiles.native.d.ts +4 -0
  73. package/lib/typescript/commonjs/ui/utils/nativeAssetFiles.native.d.ts.map +1 -0
  74. package/lib/typescript/module/ui/components/AccountLanguageBoundary.d.ts +8 -0
  75. package/lib/typescript/module/ui/components/AccountLanguageBoundary.d.ts.map +1 -0
  76. package/lib/typescript/module/ui/components/LanguageBridge.d.ts +7 -17
  77. package/lib/typescript/module/ui/components/LanguageBridge.d.ts.map +1 -1
  78. package/lib/typescript/module/ui/components/OxyProvider.d.ts.map +1 -1
  79. package/lib/typescript/module/ui/components/signIn/OxySignUpPanel.d.ts.map +1 -1
  80. package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
  81. package/lib/typescript/module/ui/context/oxyContextTypes.d.ts +5 -0
  82. package/lib/typescript/module/ui/context/oxyContextTypes.d.ts.map +1 -1
  83. package/lib/typescript/module/ui/hooks/useEntryLink.d.ts +15 -0
  84. package/lib/typescript/module/ui/hooks/useEntryLink.d.ts.map +1 -0
  85. package/lib/typescript/module/ui/hooks/useLanguageManagement.d.ts +5 -1
  86. package/lib/typescript/module/ui/hooks/useLanguageManagement.d.ts.map +1 -1
  87. package/lib/typescript/module/ui/types/navigation.d.ts +4 -0
  88. package/lib/typescript/module/ui/types/navigation.d.ts.map +1 -1
  89. package/lib/typescript/module/ui/utils/entryLink.d.ts +8 -0
  90. package/lib/typescript/module/ui/utils/entryLink.d.ts.map +1 -0
  91. package/lib/typescript/module/ui/utils/nativeAssetFiles.d.ts +4 -0
  92. package/lib/typescript/module/ui/utils/nativeAssetFiles.d.ts.map +1 -0
  93. package/lib/typescript/module/ui/utils/nativeAssetFiles.macos.d.ts +4 -0
  94. package/lib/typescript/module/ui/utils/nativeAssetFiles.macos.d.ts.map +1 -0
  95. package/lib/typescript/module/ui/utils/nativeAssetFiles.native.d.ts +4 -0
  96. package/lib/typescript/module/ui/utils/nativeAssetFiles.native.d.ts.map +1 -0
  97. package/package.json +6 -5
  98. package/src/ui/components/AccountLanguageBoundary.tsx +35 -0
  99. package/src/ui/components/LanguageBridge.tsx +43 -39
  100. package/src/ui/components/OxyProvider.tsx +18 -24
  101. package/src/ui/components/__tests__/AccountLanguageBoundary.test.tsx +87 -0
  102. package/src/ui/components/__tests__/LanguageBridge.test.tsx +80 -60
  103. package/src/ui/components/signIn/OxySignUpPanel.tsx +9 -1
  104. package/src/ui/context/OxyContext.tsx +34 -2
  105. package/src/ui/context/oxyContextTypes.ts +5 -0
  106. package/src/ui/hooks/useEntryLink.ts +103 -0
  107. package/src/ui/hooks/useLanguageManagement.ts +53 -18
  108. package/src/ui/types/navigation.ts +4 -0
  109. package/src/ui/utils/__tests__/entryLink.test.ts +30 -0
  110. package/src/ui/utils/__tests__/nativeAssetFiles.test.ts +270 -0
  111. package/src/ui/utils/entryLink.ts +61 -0
  112. package/src/ui/utils/nativeAssetFiles.macos.ts +6 -0
  113. package/src/ui/utils/nativeAssetFiles.native.ts +212 -0
  114. package/src/ui/utils/nativeAssetFiles.ts +6 -0
@@ -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,61 @@
1
+ import { SUPPORTED_LANGUAGES, normalizeLocale } from '@oxy.so/core';
2
+ import {
3
+ CAMPAIGN_LABEL_MAX_LENGTH,
4
+ isLanguageTag,
5
+ hasCampaignControlCharacters,
6
+ CAMPAIGN_CLICK_ID_MAX_LENGTH,
7
+ type CampaignAttribution,
8
+ } from '@oxy.so/contracts/client';
9
+
10
+ const CAMPAIGN_PARAMETERS = {
11
+ utmSource: 'utm_source',
12
+ utmMedium: 'utm_medium',
13
+ utmCampaign: 'utm_campaign',
14
+ utmContent: 'utm_content',
15
+ utmTerm: 'utm_term',
16
+ fbclid: 'fbclid',
17
+ } as const;
18
+
19
+ export function readCampaignAttribution(value: unknown): CampaignAttribution | undefined {
20
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
21
+ const result: CampaignAttribution = {};
22
+ for (const field of Object.keys(CAMPAIGN_PARAMETERS) as (keyof CampaignAttribution)[]) {
23
+ const raw = Reflect.get(value, field);
24
+ if (typeof raw !== 'string') continue;
25
+ const normalized = raw.trim();
26
+ const limit = field === 'fbclid' ? CAMPAIGN_CLICK_ID_MAX_LENGTH : CAMPAIGN_LABEL_MAX_LENGTH;
27
+ if (!normalized || normalized.length > limit || hasCampaignControlCharacters(normalized))
28
+ continue;
29
+ result[field] = normalized;
30
+ }
31
+ return Object.keys(result).length ? result : undefined;
32
+ }
33
+
34
+ /** Consumes only explicit locale/campaign parameters, never the URL or referrer itself. */
35
+ export function readEntryLink(url: string | null): {
36
+ language?: string;
37
+ campaignAttribution?: CampaignAttribution;
38
+ } {
39
+ if (!url) return {};
40
+ let parsed: URL;
41
+ try {
42
+ parsed = new URL(url);
43
+ } catch {
44
+ return {};
45
+ }
46
+ const languages = parsed.searchParams.getAll('lang');
47
+ const requestedLanguage = languages.length === 1 ? languages[0] : undefined;
48
+ const language =
49
+ requestedLanguage && isLanguageTag(requestedLanguage)
50
+ ? (normalizeLocale(requestedLanguage) ??
51
+ SUPPORTED_LANGUAGES.find(
52
+ (candidate) => candidate.language === requestedLanguage.split('-')[0]?.toLowerCase(),
53
+ )?.code)
54
+ : undefined;
55
+ const fields: Record<string, string> = {};
56
+ for (const [field, query] of Object.entries(CAMPAIGN_PARAMETERS)) {
57
+ const values = parsed.searchParams.getAll(query);
58
+ if (values.length === 1) fields[field] = values[0];
59
+ }
60
+ return { language, campaignAttribution: readCampaignAttribution(fields) };
61
+ }
@@ -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
+ }
@@ -0,0 +1,212 @@
1
+ import type { AssetFilePartRequest, AssetFileSource, OxyServices } from '@oxy.so/core';
2
+ import type { Directory, File } from 'expo-file-system';
3
+
4
+ // File.slice() reads the WHOLE file on Expo; RN Blob does the same on Java's
5
+ // heap. FileHandle reads and native file uploads keep both heaps bounded.
6
+ const COPY_CHUNK_BYTES = 1024 * 1024;
7
+ let partsDirectory: Promise<Directory> | undefined;
8
+ let nextPart = 0;
9
+ let nextHash = 0;
10
+ interface NativeFileHasher {
11
+ createHash(id: string): void;
12
+ cancelHash(id: string): void;
13
+ hashAsync(id: string, uri: string, expectedSize: number): Promise<string>;
14
+ addListener(
15
+ event: 'hashProgress',
16
+ listener: (event: { id: string; loaded: number }) => void,
17
+ ): { remove(): void };
18
+ }
19
+
20
+ // FileHandle methods are synchronous: awaiting them alone only yields a
21
+ // microtask. Let rendering, native events and abort timers run between chunks.
22
+ function yieldToEventLoop(): Promise<void> {
23
+ return new Promise((resolve) => setTimeout(resolve, 0));
24
+ }
25
+
26
+ function abortError(): Error {
27
+ const error = new Error('Upload aborted');
28
+ error.name = 'AbortError';
29
+ return error;
30
+ }
31
+
32
+ function checkAbort(signal?: AbortSignal): void {
33
+ if (signal?.aborted) throw abortError();
34
+ }
35
+
36
+ async function temporaryParts(): Promise<Directory> {
37
+ partsDirectory ??= import('expo-file-system')
38
+ .then(({ Directory, Paths }) => {
39
+ const directory = new Directory(Paths.cache, 'oxy-upload-parts');
40
+ // These are foreground transfers. A new JS runtime owns no previous parts;
41
+ // remove leftovers from a killed process before creating any new part.
42
+ if (directory.exists) directory.delete();
43
+ directory.create({ intermediates: true });
44
+ return directory;
45
+ })
46
+ .catch((error) => {
47
+ partsDirectory = undefined;
48
+ throw error;
49
+ });
50
+ return partsDirectory;
51
+ }
52
+
53
+ async function copyRange(
54
+ file: File,
55
+ target: File,
56
+ start: number,
57
+ end: number,
58
+ signal?: AbortSignal,
59
+ ): Promise<void> {
60
+ const reader = file.open();
61
+ try {
62
+ reader.offset = start;
63
+ const writer = target.open();
64
+ try {
65
+ for (let offset = start; offset < end; ) {
66
+ checkAbort(signal);
67
+ const bytes = await reader.readBytes(Math.min(COPY_CHUNK_BYTES, end - offset));
68
+ if (!bytes.length) throw new Error('File changed while preparing upload');
69
+ await writer.writeBytes(bytes);
70
+ offset += bytes.length;
71
+ await yieldToEventLoop();
72
+ }
73
+ } finally {
74
+ writer.close();
75
+ }
76
+ } finally {
77
+ reader.close();
78
+ }
79
+ }
80
+
81
+ async function putRange(
82
+ file: File,
83
+ request: AssetFilePartRequest,
84
+ ): ReturnType<AssetFileSource['putPart']> {
85
+ const [{ File }, legacy, directory] = await Promise.all([
86
+ import('expo-file-system'),
87
+ import('expo-file-system/legacy'),
88
+ temporaryParts(),
89
+ ]);
90
+ checkAbort(request.signal);
91
+ const part = new File(directory, `part-${++nextPart}`);
92
+ part.create();
93
+ try {
94
+ await copyRange(file, part, request.start, request.end, request.signal);
95
+ checkAbort(request.signal);
96
+ const task = legacy.createUploadTask(
97
+ request.url,
98
+ part.uri,
99
+ {
100
+ httpMethod: 'PUT',
101
+ uploadType: legacy.FileSystemUploadType.BINARY_CONTENT,
102
+ sessionType: legacy.FileSystemSessionType.FOREGROUND,
103
+ headers: {},
104
+ },
105
+ ({ totalBytesSent }) => request.onProgress(totalBytesSent),
106
+ );
107
+ let cancel: Promise<void> | undefined;
108
+ let stop = () => {};
109
+ const interrupted = new Promise<never>((_resolve, reject) => {
110
+ stop = () => {
111
+ cancel ??= task.cancelAsync();
112
+ void cancel.catch(() => undefined);
113
+ reject(abortError());
114
+ };
115
+ });
116
+ const timer = setTimeout(() => {
117
+ stop();
118
+ }, request.timeoutMs);
119
+ request.signal?.addEventListener('abort', stop, { once: true });
120
+ try {
121
+ checkAbort(request.signal);
122
+ const result = await Promise.race([task.uploadAsync(), interrupted]);
123
+ if (!result) throw abortError();
124
+ return result.status >= 200 && result.status < 300
125
+ ? { ok: true }
126
+ : { ok: false, status: result.status };
127
+ } catch {
128
+ if (request.signal?.aborted) throw abortError();
129
+ // The core session owner applies its bounded retry/backoff policy.
130
+ return { ok: false, status: 0 };
131
+ } finally {
132
+ clearTimeout(timer);
133
+ request.signal?.removeEventListener('abort', stop);
134
+ if (cancel) await cancel.catch(() => undefined);
135
+ }
136
+ } finally {
137
+ if (part.exists) part.delete();
138
+ }
139
+ }
140
+
141
+ async function openNativeFile(uri: string): Promise<AssetFileSource> {
142
+ const { File } = await import('expo-file-system');
143
+ let nativeHasher: NativeFileHasher | null = null;
144
+ try {
145
+ const { requireOptionalNativeModule } = await import('expo-modules-core');
146
+ nativeHasher = requireOptionalNativeModule<NativeFileHasher>('OxyAssetFiles');
147
+ } catch {
148
+ // Hosts without the optional Expo module bridge retain bounded file access.
149
+ }
150
+ const file = new File(uri);
151
+ if (!file.exists) throw new Error('Native upload file is unavailable');
152
+ const size = file.size;
153
+ return {
154
+ size,
155
+ // Older native binaries can still use bounded JS reads after an OTA. New
156
+ // binaries hash entirely on a native worker, without sending bytes to JS.
157
+ hash: nativeHasher
158
+ ? async (onProgress, signal) => {
159
+ checkAbort(signal);
160
+ if (file.size !== size) throw new Error('File changed while hashing');
161
+ const id = `upload-hash-${++nextHash}`;
162
+ const listener = nativeHasher.addListener('hashProgress', (event) => {
163
+ if (event.id === id && !signal?.aborted) onProgress?.(event.loaded);
164
+ });
165
+ const stop = () => nativeHasher.cancelHash(id);
166
+ try {
167
+ nativeHasher.createHash(id);
168
+ signal?.addEventListener('abort', stop, { once: true });
169
+ if (signal?.aborted) stop();
170
+ const digest = await nativeHasher.hashAsync(id, uri, size);
171
+ checkAbort(signal);
172
+ return digest;
173
+ } catch (error) {
174
+ checkAbort(signal);
175
+ throw error;
176
+ } finally {
177
+ signal?.removeEventListener('abort', stop);
178
+ listener.remove();
179
+ }
180
+ }
181
+ : undefined,
182
+ async readRange(start, end) {
183
+ if (file.size !== size) throw new Error('File changed while uploading');
184
+ if (start < 0 || end > size || end < start) throw new Error('Invalid upload file range');
185
+ const reader = file.open();
186
+ try {
187
+ reader.offset = start;
188
+ const output = new Uint8Array(end - start);
189
+ let read = 0;
190
+ while (read < output.length) {
191
+ const bytes = await reader.readBytes(Math.min(COPY_CHUNK_BYTES, output.length - read));
192
+ if (!bytes.length) throw new Error('File changed while hashing');
193
+ output.set(bytes, read);
194
+ read += bytes.length;
195
+ await yieldToEventLoop();
196
+ }
197
+ return output;
198
+ } finally {
199
+ reader.close();
200
+ }
201
+ },
202
+ putPart: (request) => {
203
+ if (file.size !== size) throw new Error('File changed while uploading');
204
+ return putRange(file, request);
205
+ },
206
+ };
207
+ }
208
+
209
+ /** One provider-owned adapter; no native filesystem module loads before an upload. */
210
+ export function installNativeAssetFiles(oxy: OxyServices): () => void {
211
+ return oxy.assets.installFileAdapter(openNativeFile);
212
+ }
@@ -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
+ }