@uploadista/expo 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +567 -0
- package/expo-env.d.ts +3 -0
- package/package.json +45 -0
- package/src/client/create-uploadista-client.ts +67 -0
- package/src/client/index.ts +4 -0
- package/src/index.ts +77 -0
- package/src/services/abort-controller-factory.ts +32 -0
- package/src/services/base64-service.ts +29 -0
- package/src/services/checksum-service.ts +14 -0
- package/src/services/create-expo-services.ts +85 -0
- package/src/services/expo-file-system-provider.ts +227 -0
- package/src/services/file-reader-service.ts +184 -0
- package/src/services/fingerprint-service.ts +107 -0
- package/src/services/http-client.ts +235 -0
- package/src/services/id-generation-service.ts +14 -0
- package/src/services/index.ts +13 -0
- package/src/services/platform-service.ts +71 -0
- package/src/services/storage-service.ts +62 -0
- package/src/services/websocket-factory.ts +62 -0
- package/src/types/upload-input.ts +5 -0
- package/src/types.ts +116 -0
- package/src/utils/hash-util.ts +70 -0
- package/tsconfig.json +28 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Expo client for Uploadista
|
|
3
|
+
*
|
|
4
|
+
* This package provides Expo-specific implementations of the Uploadista client services,
|
|
5
|
+
* allowing file uploads through Expo's managed APIs.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* ```ts
|
|
9
|
+
* import { createUploadistaClient } from '@uploadista/expo'
|
|
10
|
+
*
|
|
11
|
+
* const client = createUploadistaClient({
|
|
12
|
+
* baseUrl: 'https://api.example.com',
|
|
13
|
+
* storageId: 'my-storage',
|
|
14
|
+
* chunkSize: 1024 * 1024, // 1MB
|
|
15
|
+
* })
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* Advanced usage with custom services:
|
|
19
|
+
* ```ts
|
|
20
|
+
* import { createExpoServices } from '@uploadista/expo/services'
|
|
21
|
+
* import { createUploadistaClientCore } from '@uploadista/client-core'
|
|
22
|
+
*
|
|
23
|
+
* const services = createExpoServices()
|
|
24
|
+
* const client = createUploadistaClientCore({
|
|
25
|
+
* endpoint: 'https://api.example.com',
|
|
26
|
+
* services
|
|
27
|
+
* })
|
|
28
|
+
* ```
|
|
29
|
+
*
|
|
30
|
+
* Legacy usage with FileSystemProvider (backward compatible):
|
|
31
|
+
* ```ts
|
|
32
|
+
* import { ExpoFileSystemProvider } from '@uploadista/expo'
|
|
33
|
+
*
|
|
34
|
+
* const provider = new ExpoFileSystemProvider()
|
|
35
|
+
* const file = await provider.pickImage()
|
|
36
|
+
* ```
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
// Re-export core types from upload-client-core
|
|
40
|
+
export type {
|
|
41
|
+
Base64Service,
|
|
42
|
+
ConnectionPoolConfig,
|
|
43
|
+
FileReaderService,
|
|
44
|
+
FileSource,
|
|
45
|
+
HttpClient,
|
|
46
|
+
HttpRequestOptions,
|
|
47
|
+
HttpResponse,
|
|
48
|
+
IdGenerationService,
|
|
49
|
+
ServiceContainer,
|
|
50
|
+
SliceResult,
|
|
51
|
+
StorageService,
|
|
52
|
+
} from "@uploadista/client-core";
|
|
53
|
+
// Export client factory
|
|
54
|
+
export {
|
|
55
|
+
createUploadistaClient,
|
|
56
|
+
type UploadistaClientOptions,
|
|
57
|
+
} from "./client";
|
|
58
|
+
// Re-export service implementations and factories
|
|
59
|
+
export {
|
|
60
|
+
createAsyncStorageService,
|
|
61
|
+
createExpoBase64Service,
|
|
62
|
+
createExpoFileReaderService,
|
|
63
|
+
createExpoHttpClient,
|
|
64
|
+
createExpoIdGenerationService,
|
|
65
|
+
createExpoServices,
|
|
66
|
+
type ExpoServiceOptions,
|
|
67
|
+
} from "./services";
|
|
68
|
+
|
|
69
|
+
export { ExpoFileSystemProvider } from "./services/expo-file-system-provider";
|
|
70
|
+
// Export Expo-specific types
|
|
71
|
+
export type {
|
|
72
|
+
CameraOptions,
|
|
73
|
+
FileInfo,
|
|
74
|
+
FilePickResult,
|
|
75
|
+
FileSystemProvider,
|
|
76
|
+
PickerOptions,
|
|
77
|
+
} from "./types";
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AbortControllerFactory,
|
|
3
|
+
AbortControllerLike,
|
|
4
|
+
AbortSignalLike,
|
|
5
|
+
} from "@uploadista/client-core";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Expo AbortController implementation that wraps native AbortController
|
|
9
|
+
* Expo provides an AbortController API that is compatible with the browser AbortController API
|
|
10
|
+
*/
|
|
11
|
+
class ExpoAbortController implements AbortControllerLike {
|
|
12
|
+
private native: AbortController;
|
|
13
|
+
|
|
14
|
+
constructor() {
|
|
15
|
+
this.native = new AbortController();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
get signal(): AbortSignalLike {
|
|
19
|
+
return this.native.signal;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
abort(_reason?: unknown): void {
|
|
23
|
+
this.native.abort();
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Factory for creating Expo AbortController instances
|
|
29
|
+
*/
|
|
30
|
+
export const createExpoAbortControllerFactory = (): AbortControllerFactory => ({
|
|
31
|
+
create: (): AbortControllerLike => new ExpoAbortController(),
|
|
32
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { Base64Service } from "@uploadista/client-core";
|
|
2
|
+
import { fromBase64 as decode, toBase64 as encode } from "js-base64";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Expo-specific implementation of Base64Service using js-base64 library
|
|
6
|
+
* Expo/React Native doesn't have native btoa/atob functions, so we use js-base64
|
|
7
|
+
*/
|
|
8
|
+
export function createExpoBase64Service(): Base64Service {
|
|
9
|
+
return {
|
|
10
|
+
toBase64(data: ArrayBuffer): string {
|
|
11
|
+
// Convert ArrayBuffer to Uint8Array
|
|
12
|
+
const uint8Array = new Uint8Array(data);
|
|
13
|
+
// Convert Uint8Array to string
|
|
14
|
+
const binary = Array.from(uint8Array)
|
|
15
|
+
.map((byte) => String.fromCharCode(byte))
|
|
16
|
+
.join("");
|
|
17
|
+
return encode(binary);
|
|
18
|
+
},
|
|
19
|
+
|
|
20
|
+
fromBase64(data: string): ArrayBuffer {
|
|
21
|
+
const binary = decode(data);
|
|
22
|
+
const uint8Array = new Uint8Array(binary.length);
|
|
23
|
+
for (let i = 0; i < binary.length; i++) {
|
|
24
|
+
uint8Array[i] = binary.charCodeAt(i);
|
|
25
|
+
}
|
|
26
|
+
return uint8Array.buffer;
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ChecksumService } from "@uploadista/client-core";
|
|
2
|
+
import { computeUint8ArraySha256 } from "../utils/hash-util";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Creates a ChecksumService for Expo environments
|
|
6
|
+
* Computes SHA-256 checksums of file data using Web Crypto API
|
|
7
|
+
*/
|
|
8
|
+
export function createExpoChecksumService(): ChecksumService {
|
|
9
|
+
return {
|
|
10
|
+
computeChecksum: async (data: Uint8Array<ArrayBuffer>) => {
|
|
11
|
+
return computeUint8ArraySha256(data);
|
|
12
|
+
},
|
|
13
|
+
};
|
|
14
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ConnectionPoolConfig,
|
|
3
|
+
createInMemoryStorageService,
|
|
4
|
+
type ServiceContainer,
|
|
5
|
+
} from "@uploadista/client-core";
|
|
6
|
+
import type { ReactNativeUploadInput } from "@uploadista/react-native-core";
|
|
7
|
+
import { createExpoAbortControllerFactory } from "./abort-controller-factory";
|
|
8
|
+
import { createExpoBase64Service } from "./base64-service";
|
|
9
|
+
import { createExpoFileReaderService } from "./file-reader-service";
|
|
10
|
+
import { createExpoHttpClient } from "./http-client";
|
|
11
|
+
import { createExpoIdGenerationService } from "./id-generation-service";
|
|
12
|
+
import { createExpoPlatformService } from "./platform-service";
|
|
13
|
+
import { createAsyncStorageService } from "./storage-service";
|
|
14
|
+
import { createExpoWebSocketFactory } from "./websocket-factory";
|
|
15
|
+
import { createExpoChecksumService } from "./checksum-service";
|
|
16
|
+
import { createExpoFingerprintService } from "./fingerprint-service";
|
|
17
|
+
|
|
18
|
+
export interface ExpoServiceOptions {
|
|
19
|
+
/**
|
|
20
|
+
* HTTP client configuration for connection pooling
|
|
21
|
+
*/
|
|
22
|
+
connectionPooling?: ConnectionPoolConfig;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Whether to use AsyncStorage for persistence
|
|
26
|
+
* If false, uses in-memory storage
|
|
27
|
+
* @default true
|
|
28
|
+
*/
|
|
29
|
+
useAsyncStorage?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Creates a service container with Expo-specific implementations
|
|
34
|
+
* of all required services for the upload client
|
|
35
|
+
*
|
|
36
|
+
* @param options - Configuration options for Expo services
|
|
37
|
+
* @returns ServiceContainer with Expo implementations
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* ```typescript
|
|
41
|
+
* import { createExpoServices } from '@uploadista/expo/services';
|
|
42
|
+
*
|
|
43
|
+
* const services = createExpoServices({
|
|
44
|
+
* useAsyncStorage: true,
|
|
45
|
+
* connectionPooling: {
|
|
46
|
+
* maxConnectionsPerHost: 6,
|
|
47
|
+
* connectionTimeout: 30000,
|
|
48
|
+
* }
|
|
49
|
+
* });
|
|
50
|
+
* ```
|
|
51
|
+
*/
|
|
52
|
+
export function createExpoServices(
|
|
53
|
+
options: ExpoServiceOptions = {},
|
|
54
|
+
): ServiceContainer<ReactNativeUploadInput> {
|
|
55
|
+
const { connectionPooling, useAsyncStorage = true } = options;
|
|
56
|
+
|
|
57
|
+
// Create storage service (AsyncStorage or in-memory fallback)
|
|
58
|
+
const storage = useAsyncStorage
|
|
59
|
+
? createAsyncStorageService()
|
|
60
|
+
: createInMemoryStorageService();
|
|
61
|
+
|
|
62
|
+
// Create other services
|
|
63
|
+
const idGeneration = createExpoIdGenerationService();
|
|
64
|
+
const httpClient = createExpoHttpClient(connectionPooling);
|
|
65
|
+
const fileReader = createExpoFileReaderService();
|
|
66
|
+
const base64 = createExpoBase64Service();
|
|
67
|
+
const websocket = createExpoWebSocketFactory();
|
|
68
|
+
const abortController = createExpoAbortControllerFactory();
|
|
69
|
+
const platform = createExpoPlatformService();
|
|
70
|
+
const checksumService = createExpoChecksumService();
|
|
71
|
+
const fingerprintService = createExpoFingerprintService();
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
storage,
|
|
75
|
+
idGeneration,
|
|
76
|
+
httpClient,
|
|
77
|
+
fileReader,
|
|
78
|
+
base64,
|
|
79
|
+
websocket,
|
|
80
|
+
abortController,
|
|
81
|
+
platform,
|
|
82
|
+
checksumService,
|
|
83
|
+
fingerprintService,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import * as DocumentPicker from "expo-document-picker";
|
|
2
|
+
import * as FileSystem from "expo-file-system";
|
|
3
|
+
import * as ImagePicker from "expo-image-picker";
|
|
4
|
+
import type {
|
|
5
|
+
CameraOptions,
|
|
6
|
+
FileInfo,
|
|
7
|
+
FilePickResult,
|
|
8
|
+
FileSystemProvider,
|
|
9
|
+
PickerOptions,
|
|
10
|
+
} from "../types";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* File system provider implementation for Expo managed environment
|
|
14
|
+
* Uses Expo DocumentPicker, ImagePicker, Camera, and FileSystem APIs
|
|
15
|
+
*/
|
|
16
|
+
export class ExpoFileSystemProvider implements FileSystemProvider {
|
|
17
|
+
async pickDocument(options?: PickerOptions): Promise<FilePickResult> {
|
|
18
|
+
try {
|
|
19
|
+
const result = (await DocumentPicker.getDocumentAsync({
|
|
20
|
+
type: options?.allowedTypes || ["*/*"],
|
|
21
|
+
copyToCacheDirectory: true,
|
|
22
|
+
})) as {
|
|
23
|
+
canceled: boolean;
|
|
24
|
+
assets?: Array<{
|
|
25
|
+
uri: string;
|
|
26
|
+
name: string;
|
|
27
|
+
size?: number;
|
|
28
|
+
mimeType?: string;
|
|
29
|
+
}>;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
if (result.canceled) {
|
|
33
|
+
throw new Error("Document picker was cancelled");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const asset = result.assets?.[0];
|
|
37
|
+
if (!asset) {
|
|
38
|
+
throw new Error("No document selected");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
uri: asset.uri,
|
|
43
|
+
name: asset.name,
|
|
44
|
+
size: asset.size || 0,
|
|
45
|
+
mimeType: asset.mimeType,
|
|
46
|
+
};
|
|
47
|
+
} catch (error) {
|
|
48
|
+
if (error instanceof Error && error.message.includes("cancelled")) {
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
throw new Error(
|
|
52
|
+
`Failed to pick document: ${error instanceof Error ? error.message : String(error)}`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async pickImage(options?: PickerOptions): Promise<FilePickResult> {
|
|
58
|
+
try {
|
|
59
|
+
// Request permissions
|
|
60
|
+
const { status } =
|
|
61
|
+
await ImagePicker.requestMediaLibraryPermissionsAsync();
|
|
62
|
+
if (status !== "granted") {
|
|
63
|
+
throw new Error("Camera roll permission not granted");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const result = await ImagePicker.launchImageLibraryAsync({
|
|
67
|
+
// biome-ignore lint/suspicious/noExplicitAny: Expo ImagePicker mediaTypes type compatibility
|
|
68
|
+
mediaTypes: "Images" as any,
|
|
69
|
+
selectionLimit: options?.allowMultiple ? 0 : 1,
|
|
70
|
+
quality: 1,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
if (result.canceled) {
|
|
74
|
+
throw new Error("Image picker was cancelled");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const asset = result.assets?.[0];
|
|
78
|
+
if (!asset) {
|
|
79
|
+
throw new Error("No image selected");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
uri: asset.uri,
|
|
84
|
+
name: asset.fileName || `image-${Date.now()}.jpg`,
|
|
85
|
+
size: asset.fileSize || 0,
|
|
86
|
+
mimeType: "image/jpeg",
|
|
87
|
+
};
|
|
88
|
+
} catch (error) {
|
|
89
|
+
if (error instanceof Error && error.message.includes("cancelled")) {
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
throw new Error(
|
|
93
|
+
`Failed to pick image: ${error instanceof Error ? error.message : String(error)}`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async pickVideo(options?: PickerOptions): Promise<FilePickResult> {
|
|
99
|
+
try {
|
|
100
|
+
// Request permissions
|
|
101
|
+
const { status } =
|
|
102
|
+
await ImagePicker.requestMediaLibraryPermissionsAsync();
|
|
103
|
+
if (status !== "granted") {
|
|
104
|
+
throw new Error("Camera roll permission not granted");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const result = await ImagePicker.launchImageLibraryAsync({
|
|
108
|
+
// biome-ignore lint/suspicious/noExplicitAny: Expo ImagePicker mediaTypes type compatibility
|
|
109
|
+
mediaTypes: "Videos" as any,
|
|
110
|
+
selectionLimit: options?.allowMultiple ? 0 : 1,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
if (result.canceled) {
|
|
114
|
+
throw new Error("Video picker was cancelled");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const asset = result.assets?.[0];
|
|
118
|
+
if (!asset) {
|
|
119
|
+
throw new Error("No video selected");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
uri: asset.uri,
|
|
124
|
+
name: asset.fileName || `video-${Date.now()}.mp4`,
|
|
125
|
+
size: asset.fileSize || 0,
|
|
126
|
+
mimeType: "video/mp4",
|
|
127
|
+
};
|
|
128
|
+
} catch (error) {
|
|
129
|
+
if (error instanceof Error && error.message.includes("cancelled")) {
|
|
130
|
+
throw error;
|
|
131
|
+
}
|
|
132
|
+
throw new Error(
|
|
133
|
+
`Failed to pick video: ${error instanceof Error ? error.message : String(error)}`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async pickCamera(options?: CameraOptions): Promise<FilePickResult> {
|
|
139
|
+
try {
|
|
140
|
+
// Request camera permissions
|
|
141
|
+
const { status } = await ImagePicker.requestCameraPermissionsAsync();
|
|
142
|
+
if (status !== "granted") {
|
|
143
|
+
throw new Error("Camera permission not granted");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const result = await ImagePicker.launchCameraAsync({
|
|
147
|
+
allowsEditing: false,
|
|
148
|
+
aspect: [4, 3],
|
|
149
|
+
quality: options?.quality ?? 1,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
if (result.canceled) {
|
|
153
|
+
throw new Error("Camera capture was cancelled");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const asset = result.assets?.[0];
|
|
157
|
+
if (!asset) {
|
|
158
|
+
throw new Error("No photo captured");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
uri: asset.uri,
|
|
163
|
+
name: asset.fileName || `photo-${Date.now()}.jpg`,
|
|
164
|
+
size: asset.fileSize || 0,
|
|
165
|
+
mimeType: "image/jpeg",
|
|
166
|
+
};
|
|
167
|
+
} catch (error) {
|
|
168
|
+
if (error instanceof Error && error.message.includes("cancelled")) {
|
|
169
|
+
throw error;
|
|
170
|
+
}
|
|
171
|
+
throw new Error(
|
|
172
|
+
`Failed to capture photo: ${error instanceof Error ? error.message : String(error)}`,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async readFile(uri: string): Promise<ArrayBuffer> {
|
|
178
|
+
try {
|
|
179
|
+
// Read file as base64
|
|
180
|
+
const base64String = await FileSystem.readAsStringAsync(uri, {
|
|
181
|
+
encoding: FileSystem.EncodingType.Base64,
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// Convert base64 to ArrayBuffer
|
|
185
|
+
// Use js-base64 for decoding since atob is not available in all RN environments
|
|
186
|
+
const { fromBase64 } = await import("js-base64");
|
|
187
|
+
const binaryString = fromBase64(base64String);
|
|
188
|
+
const bytes = new Uint8Array(binaryString.length);
|
|
189
|
+
for (let i = 0; i < binaryString.length; i++) {
|
|
190
|
+
bytes[i] = binaryString.charCodeAt(i);
|
|
191
|
+
}
|
|
192
|
+
return bytes.buffer;
|
|
193
|
+
} catch (error) {
|
|
194
|
+
throw new Error(
|
|
195
|
+
`Failed to read file: ${error instanceof Error ? error.message : String(error)}`,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async getDocumentUri(filePath: string): Promise<string> {
|
|
201
|
+
// In Expo, the file path is typically already a URI
|
|
202
|
+
return filePath;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async getFileInfo(uri: string): Promise<FileInfo> {
|
|
206
|
+
try {
|
|
207
|
+
const fileInfo = await FileSystem.getInfoAsync(uri);
|
|
208
|
+
|
|
209
|
+
if (!fileInfo.exists) {
|
|
210
|
+
throw new Error("File does not exist");
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
uri,
|
|
215
|
+
name: uri.split("/").pop() || "unknown",
|
|
216
|
+
size: fileInfo.size ?? 0,
|
|
217
|
+
modificationTime: fileInfo.modificationTime
|
|
218
|
+
? fileInfo.modificationTime * 1000
|
|
219
|
+
: undefined,
|
|
220
|
+
};
|
|
221
|
+
} catch (error) {
|
|
222
|
+
throw new Error(
|
|
223
|
+
`Failed to get file info: ${error instanceof Error ? error.message : String(error)}`,
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
FileReaderService,
|
|
3
|
+
FileSource,
|
|
4
|
+
SliceResult,
|
|
5
|
+
} from "@uploadista/client-core";
|
|
6
|
+
import type { ExpoUploadInput } from "@/types/upload-input";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Expo-specific implementation of FileReaderService
|
|
10
|
+
* Handles Blob, File, and URI-based file inputs using Expo FileSystem APIs
|
|
11
|
+
*/
|
|
12
|
+
export function createExpoFileReaderService(): FileReaderService<ExpoUploadInput> {
|
|
13
|
+
return {
|
|
14
|
+
async openFile(input: unknown, _chunkSize: number): Promise<FileSource> {
|
|
15
|
+
// Handle Blob/File objects
|
|
16
|
+
if (input instanceof Blob) {
|
|
17
|
+
return createBlobFileSource(input);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Handle URI strings or URI objects from Expo APIs
|
|
21
|
+
if (
|
|
22
|
+
typeof input === "string" ||
|
|
23
|
+
(input && typeof input === "object" && "uri" in input)
|
|
24
|
+
) {
|
|
25
|
+
const uri =
|
|
26
|
+
typeof input === "string" ? input : (input as { uri: string }).uri;
|
|
27
|
+
return createExpoUriFileSource(uri);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
throw new Error(
|
|
31
|
+
"Unsupported file input type for Expo. Expected Blob, File, URI string, or {uri: string}",
|
|
32
|
+
);
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Create a FileSource from a Blob object
|
|
39
|
+
*/
|
|
40
|
+
function createBlobFileSource(blob: Blob): FileSource {
|
|
41
|
+
return {
|
|
42
|
+
input: blob,
|
|
43
|
+
size: blob.size,
|
|
44
|
+
async slice(start: number, end: number): Promise<SliceResult> {
|
|
45
|
+
const chunk = blob.slice(start, end);
|
|
46
|
+
|
|
47
|
+
// React Native/Expo Blob may not have arrayBuffer() method
|
|
48
|
+
// Always use FileReader fallback for compatibility
|
|
49
|
+
const arrayBuffer = await blobToArrayBuffer(chunk);
|
|
50
|
+
|
|
51
|
+
const done = end >= blob.size;
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
done,
|
|
55
|
+
value: new Uint8Array(arrayBuffer),
|
|
56
|
+
size: chunk.size,
|
|
57
|
+
};
|
|
58
|
+
},
|
|
59
|
+
close() {
|
|
60
|
+
// No cleanup needed for Blob
|
|
61
|
+
},
|
|
62
|
+
name: null,
|
|
63
|
+
lastModified: null,
|
|
64
|
+
type: null,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Convert Blob to ArrayBuffer using FileReader (fallback for React Native/Expo)
|
|
70
|
+
*/
|
|
71
|
+
function blobToArrayBuffer(blob: Blob): Promise<ArrayBuffer> {
|
|
72
|
+
return new Promise((resolve, reject) => {
|
|
73
|
+
const reader = new FileReader();
|
|
74
|
+
reader.onload = () => {
|
|
75
|
+
if (reader.result instanceof ArrayBuffer) {
|
|
76
|
+
resolve(reader.result);
|
|
77
|
+
} else {
|
|
78
|
+
reject(new Error("FileReader result is not an ArrayBuffer"));
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
reader.onerror = () => reject(reader.error);
|
|
82
|
+
reader.readAsArrayBuffer(blob);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Create a FileSource from a URI using Expo FileSystem
|
|
88
|
+
* This implementation uses expo-file-system for native file access
|
|
89
|
+
*/
|
|
90
|
+
function createExpoUriFileSource(uri: string): FileSource {
|
|
91
|
+
// For Expo URIs, we use FileSystem to read the file
|
|
92
|
+
let cachedBlob: Blob | null = null;
|
|
93
|
+
let cachedSize: number | null = null;
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
input: uri,
|
|
97
|
+
size: cachedSize,
|
|
98
|
+
async slice(start: number, end: number): Promise<SliceResult> {
|
|
99
|
+
// Fetch the blob if not cached
|
|
100
|
+
if (!cachedBlob) {
|
|
101
|
+
try {
|
|
102
|
+
// Use Expo FileSystem to read the file as base64
|
|
103
|
+
const FileSystem = await getExpoFileSystem();
|
|
104
|
+
const fileInfo = await FileSystem.getInfoAsync(uri);
|
|
105
|
+
|
|
106
|
+
if (!fileInfo.exists) {
|
|
107
|
+
throw new Error(`File does not exist at URI: ${uri}`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
cachedSize = fileInfo.size ?? 0;
|
|
111
|
+
|
|
112
|
+
// Read the entire file as base64
|
|
113
|
+
const base64String = await FileSystem.readAsStringAsync(uri, {
|
|
114
|
+
encoding: FileSystem.EncodingType.Base64,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// Convert base64 to Uint8Array and cache size
|
|
118
|
+
const uint8Array = base64ToUint8Array(base64String);
|
|
119
|
+
cachedSize = uint8Array.length;
|
|
120
|
+
|
|
121
|
+
// Create a Blob from the Uint8Array buffer
|
|
122
|
+
// React Native Blob constructor accepts array-like objects
|
|
123
|
+
// biome-ignore lint/suspicious/noExplicitAny: React Native Blob constructor type compatibility
|
|
124
|
+
cachedBlob = new Blob([uint8Array.buffer] as any);
|
|
125
|
+
} catch (error) {
|
|
126
|
+
throw new Error(`Failed to read file from URI ${uri}: ${error}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const chunk = cachedBlob.slice(start, end);
|
|
131
|
+
|
|
132
|
+
// React Native/Expo Blob may not have arrayBuffer() method
|
|
133
|
+
// Always use FileReader fallback for compatibility
|
|
134
|
+
const arrayBuffer = await blobToArrayBuffer(chunk);
|
|
135
|
+
|
|
136
|
+
const done = end >= cachedBlob.size;
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
done,
|
|
140
|
+
value: new Uint8Array(arrayBuffer),
|
|
141
|
+
size: chunk.size,
|
|
142
|
+
};
|
|
143
|
+
},
|
|
144
|
+
close() {
|
|
145
|
+
// Clear cached blob
|
|
146
|
+
cachedBlob = null;
|
|
147
|
+
cachedSize = null;
|
|
148
|
+
},
|
|
149
|
+
name: uri,
|
|
150
|
+
lastModified: null,
|
|
151
|
+
type: null,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Dynamically import Expo FileSystem
|
|
157
|
+
* This allows the service to work even if expo-file-system is not installed
|
|
158
|
+
*/
|
|
159
|
+
async function getExpoFileSystem() {
|
|
160
|
+
try {
|
|
161
|
+
return require("expo-file-system");
|
|
162
|
+
} catch (_error) {
|
|
163
|
+
throw new Error(
|
|
164
|
+
"expo-file-system is required but not installed. " +
|
|
165
|
+
"Please install it with: npx expo install expo-file-system",
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Convert base64 string to Uint8Array
|
|
172
|
+
* Uses js-base64 library for cross-platform compatibility
|
|
173
|
+
*/
|
|
174
|
+
function base64ToUint8Array(base64: string): Uint8Array {
|
|
175
|
+
// Use js-base64 for decoding (works in all environments)
|
|
176
|
+
const { fromBase64 } = require("js-base64");
|
|
177
|
+
const binaryString = fromBase64(base64);
|
|
178
|
+
|
|
179
|
+
const bytes = new Uint8Array(binaryString.length);
|
|
180
|
+
for (let i = 0; i < binaryString.length; i++) {
|
|
181
|
+
bytes[i] = binaryString.charCodeAt(i);
|
|
182
|
+
}
|
|
183
|
+
return bytes;
|
|
184
|
+
}
|