Skip to content

Commit d85b358

Browse files
committed
feat: implement native multipart uploads
1 parent 373dac3 commit d85b358

36 files changed

Lines changed: 2792 additions & 8 deletions

package/expo-package/android/build.gradle

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,9 @@ if (isNewArchitectureEnabled()) {
2929
def getExtOrIntegerDefault(name) {
3030
return rootProject.ext.has(name) ? rootProject.ext.get(name) : (project.properties["StreamChatExpo_" + name]).toInteger()
3131
}
32+
def canonicalProjectDir = projectDir.getCanonicalFile()
3233
def localSharedNativeRootDir = new File(projectDir, "src/main/java/com/streamchatreactnative/shared")
33-
def sharedNativeRootDir = new File(projectDir, "../../shared-native/android")
34+
def sharedNativeRootDir = new File(canonicalProjectDir, "../../shared-native/android")
3435
def hasNativeSources = { File dir ->
3536
dir.exists() && !fileTree(dir).matching { include "**/*.kt"; include "**/*.java" }.files.isEmpty()
3637
}
@@ -88,10 +89,10 @@ tasks.register("syncSharedShimmerSources") {
8889
outputs.upToDateWhen { false }
8990
doLast {
9091
def sourceRootDir = null
91-
if (hasNativeSources(localSharedNativeRootDir)) {
92-
sourceRootDir = localSharedNativeRootDir
93-
} else if (hasNativeSources(sharedNativeRootDir)) {
92+
if (hasNativeSources(sharedNativeRootDir)) {
9493
sourceRootDir = sharedNativeRootDir
94+
} else if (hasNativeSources(localSharedNativeRootDir)) {
95+
sourceRootDir = localSharedNativeRootDir
9596
}
9697

9798
if (sourceRootDir == null) {

package/expo-package/android/src/main/java/com/streamchatexpo/StreamChatExpoPackage.java

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,16 @@
1414
import java.util.Map;
1515

1616
public class StreamChatExpoPackage extends TurboReactPackage {
17+
private static final String STREAM_MULTIPART_UPLOADER_MODULE = "StreamMultipartUploader";
1718
private static final String STREAM_VIDEO_THUMBNAIL_MODULE = "StreamVideoThumbnail";
1819

1920
@Nullable
2021
@Override
2122
public NativeModule getModule(String name, ReactApplicationContext reactContext) {
23+
if (name.equals(STREAM_MULTIPART_UPLOADER_MODULE) && BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
24+
return createNewArchModule("com.streamchatexpo.StreamMultipartUploaderModule", reactContext);
25+
}
26+
2227
if (name.equals(STREAM_VIDEO_THUMBNAIL_MODULE) && BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
2328
return createNewArchModule("com.streamchatexpo.StreamVideoThumbnailModule", reactContext);
2429
}
@@ -31,6 +36,17 @@ public ReactModuleInfoProvider getReactModuleInfoProvider() {
3136
return () -> {
3237
final Map<String, ReactModuleInfo> moduleInfos = new HashMap<>();
3338
boolean isTurboModule = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED;
39+
moduleInfos.put(
40+
STREAM_MULTIPART_UPLOADER_MODULE,
41+
new ReactModuleInfo(
42+
STREAM_MULTIPART_UPLOADER_MODULE,
43+
STREAM_MULTIPART_UPLOADER_MODULE,
44+
false, // canOverrideExistingModule
45+
false, // needsEagerInit
46+
false, // hasConstants
47+
false, // isCxxModule
48+
isTurboModule // isTurboModule
49+
));
3450
moduleInfos.put(
3551
STREAM_VIDEO_THUMBNAIL_MODULE,
3652
new ReactModuleInfo(
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package com.streamchatexpo
2+
3+
import com.facebook.react.bridge.Arguments
4+
import com.facebook.react.bridge.Promise
5+
import com.facebook.react.bridge.ReactApplicationContext
6+
import com.facebook.react.bridge.ReadableArray
7+
import com.facebook.react.modules.core.DeviceEventManagerModule
8+
import com.streamchatreactnative.shared.upload.StreamMultipartUploadRequestParser
9+
import com.streamchatreactnative.shared.upload.StreamMultipartUploader
10+
import java.util.concurrent.Executors
11+
12+
class StreamMultipartUploaderModule(
13+
reactContext: ReactApplicationContext,
14+
) : NativeStreamMultipartUploaderSpec(reactContext) {
15+
override fun getName(): String = NAME
16+
17+
override fun addListener(eventType: String) = Unit
18+
19+
override fun removeListeners(count: Double) = Unit
20+
21+
override fun cancelUpload(uploadId: String, promise: Promise) {
22+
StreamMultipartUploader.cancel(uploadId)
23+
promise.resolve(null)
24+
}
25+
26+
override fun uploadMultipart(
27+
uploadId: String,
28+
url: String,
29+
method: String,
30+
headers: ReadableArray,
31+
parts: ReadableArray,
32+
progress: com.facebook.react.bridge.ReadableMap?,
33+
promise: Promise,
34+
) {
35+
executor.execute {
36+
try {
37+
val request = StreamMultipartUploadRequestParser.parse(
38+
uploadId = uploadId,
39+
url = url,
40+
method = method,
41+
headers = headers,
42+
parts = parts,
43+
progress = progress,
44+
)
45+
val response =
46+
StreamMultipartUploader.upload(reactApplicationContext, request) { loaded, total ->
47+
emitProgress(uploadId, loaded, total)
48+
}
49+
50+
val payload = Arguments.createMap().apply {
51+
putString("body", response.body)
52+
putArray("headers", Arguments.createArray().apply {
53+
response.headers.forEach { (name, value) ->
54+
pushMap(
55+
Arguments.createMap().apply {
56+
putString("name", name)
57+
putString("value", value)
58+
},
59+
)
60+
}
61+
})
62+
putDouble("status", response.status.toDouble())
63+
putString("statusText", response.statusText)
64+
}
65+
promise.resolve(payload)
66+
} catch (error: Throwable) {
67+
promise.reject("stream_multipart_upload_error", error.message, error)
68+
}
69+
}
70+
}
71+
72+
private fun emitProgress(uploadId: String, loaded: Long, total: Long?) {
73+
val payload = Arguments.createMap().apply {
74+
putDouble("loaded", loaded.toDouble())
75+
if (total != null) {
76+
putDouble("total", total.toDouble())
77+
} else {
78+
putNull("total")
79+
}
80+
putString("uploadId", uploadId)
81+
}
82+
83+
reactApplicationContext
84+
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
85+
.emit(PROGRESS_EVENT_NAME, payload)
86+
}
87+
88+
companion object {
89+
const val NAME = "StreamMultipartUploader"
90+
private const val PROGRESS_EVENT_NAME = "streamMultipartUploadProgress"
91+
private val executor = Executors.newCachedThreadPool()
92+
}
93+
}

package/expo-package/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@
9797
},
9898
"ios": {
9999
"modulesProvider": {
100+
"StreamMultipartUploader": "StreamMultipartUploader",
100101
"StreamVideoThumbnail": "StreamVideoThumbnail"
101102
},
102103
"componentProvider": {

package/expo-package/src/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
getLocalAssetUri,
1111
getPhotos,
1212
iOS14RefreshGallerySelection,
13+
multipartUpload,
1314
NativeShimmerView,
1415
oniOS14GalleryLibrarySelectionChange,
1516
overrideAudioRecordingConfiguration,
@@ -32,6 +33,7 @@ registerNativeHandlers({
3233
getLocalAssetUri,
3334
getPhotos,
3435
iOS14RefreshGallerySelection,
36+
multipartUpload,
3537
NativeShimmerView,
3638
oniOS14GalleryLibrarySelectionChange,
3739
overrideAudioRecordingConfiguration,
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import type { TurboModule } from 'react-native';
2+
3+
import { TurboModuleRegistry } from 'react-native';
4+
5+
export type UploadHeader = {
6+
name: string;
7+
value: string;
8+
};
9+
10+
export type UploadPart = {
11+
fieldName: string;
12+
fileName?: string | null;
13+
kind: string;
14+
mimeType?: string | null;
15+
uri?: string | null;
16+
value?: string | null;
17+
};
18+
19+
export type UploadProgressConfig = {
20+
count?: number | null;
21+
intervalMs?: number | null;
22+
};
23+
24+
export type UploadProgressEvent = {
25+
loaded: number;
26+
total?: number | null;
27+
uploadId: string;
28+
};
29+
30+
export type UploadResponse = {
31+
body: string;
32+
headers?: ReadonlyArray<UploadHeader> | null;
33+
status: number;
34+
statusText?: string | null;
35+
};
36+
37+
export interface Spec extends TurboModule {
38+
addListener(eventType: string): void;
39+
cancelUpload(uploadId: string): Promise<void>;
40+
removeListeners(count: number): void;
41+
uploadMultipart(
42+
uploadId: string,
43+
url: string,
44+
method: string,
45+
headers: ReadonlyArray<UploadHeader>,
46+
parts: ReadonlyArray<UploadPart>,
47+
progress?: UploadProgressConfig | null,
48+
): Promise<UploadResponse>;
49+
}
50+
51+
export default TurboModuleRegistry.getEnforcing<Spec>('StreamMultipartUploader');
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import { NativeEventEmitter } from 'react-native';
2+
3+
import NativeStreamMultipartUploader, {
4+
type UploadHeader,
5+
type UploadPart,
6+
type UploadProgressConfig,
7+
type UploadProgressEvent,
8+
type UploadResponse as NativeUploadResponse,
9+
} from './NativeStreamMultipartUploader';
10+
11+
const STREAM_MULTIPART_UPLOAD_PROGRESS_EVENT = 'streamMultipartUploadProgress';
12+
13+
const multipartUploadEventEmitter = new NativeEventEmitter(NativeStreamMultipartUploader);
14+
15+
const toUploadHeaders = (headers: Record<string, string>): UploadHeader[] =>
16+
Object.entries(headers).map(([name, value]) => ({ name, value }));
17+
18+
const fromUploadHeaders = (
19+
headers?: ReadonlyArray<UploadHeader> | null,
20+
): Record<string, string> | undefined => {
21+
if (!headers?.length) {
22+
return undefined;
23+
}
24+
25+
return headers.reduce<Record<string, string>>((acc, header) => {
26+
acc[header.name] = header.value;
27+
return acc;
28+
}, {});
29+
};
30+
31+
const createAbortError = () => {
32+
const error = new Error('Request aborted');
33+
error.name = 'CanceledError';
34+
return error;
35+
};
36+
37+
type MultipartUploadRequest = {
38+
headers: Record<string, string>;
39+
method: string;
40+
onProgress?: (event: { loaded: number; total?: number }) => void;
41+
parts: UploadPart[];
42+
progress?: UploadProgressConfig;
43+
signal?: AbortSignal;
44+
uploadId: string;
45+
url: string;
46+
};
47+
48+
type MultipartUploadResponse = Omit<NativeUploadResponse, 'headers'> & {
49+
headers?: Record<string, string>;
50+
};
51+
52+
export const uploadMultipart = async ({
53+
headers,
54+
method,
55+
onProgress,
56+
parts,
57+
progress,
58+
signal,
59+
uploadId,
60+
url,
61+
}: MultipartUploadRequest): Promise<MultipartUploadResponse> => {
62+
let progressSubscription:
63+
| {
64+
remove: () => void;
65+
}
66+
| undefined;
67+
let removedAbortListener = false;
68+
69+
const abortUpload = async () => {
70+
try {
71+
await NativeStreamMultipartUploader.cancelUpload(uploadId);
72+
} catch {
73+
// Ignore cancellation races for already-finished uploads.
74+
}
75+
};
76+
77+
const removeAbortListener = () => {
78+
if (!removedAbortListener) {
79+
signal?.removeEventListener('abort', handleAbort);
80+
removedAbortListener = true;
81+
}
82+
};
83+
84+
const handleAbort = () => {
85+
abortUpload().catch(() => undefined);
86+
};
87+
88+
if (signal?.aborted) {
89+
await abortUpload();
90+
throw createAbortError();
91+
}
92+
93+
if (onProgress) {
94+
progressSubscription = multipartUploadEventEmitter.addListener(
95+
STREAM_MULTIPART_UPLOAD_PROGRESS_EVENT,
96+
(event: UploadProgressEvent) => {
97+
if (event.uploadId !== uploadId) {
98+
return;
99+
}
100+
101+
onProgress({
102+
loaded: event.loaded,
103+
total: typeof event.total === 'number' ? event.total : undefined,
104+
});
105+
},
106+
);
107+
}
108+
109+
signal?.addEventListener('abort', handleAbort, { once: true });
110+
111+
try {
112+
const response = await NativeStreamMultipartUploader.uploadMultipart(
113+
uploadId,
114+
url,
115+
method,
116+
toUploadHeaders(headers),
117+
parts,
118+
progress ?? {},
119+
);
120+
121+
if (signal?.aborted) {
122+
throw createAbortError();
123+
}
124+
125+
return {
126+
...response,
127+
headers: fromUploadHeaders(response.headers),
128+
};
129+
} catch (error) {
130+
if (signal?.aborted) {
131+
throw createAbortError();
132+
}
133+
134+
throw error;
135+
} finally {
136+
progressSubscription?.remove();
137+
removeAbortListener();
138+
}
139+
};

package/expo-package/src/optionalDependencies/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export * from './generateThumbnail';
44
export * from './getLocalAssetUri';
55
export * from './getPhotos';
66
export * from './iOS14RefreshGallerySelection';
7+
export * from './multipartUpload';
78
export * from './NativeShimmerView';
89
export * from './oniOS14GalleryLibrarySelectionChange';
910
export * from './pickDocument';

0 commit comments

Comments
 (0)