@ammarahmed/react-native-upload 6.33.0 → 7.0.0-alpha.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.
package/.eslintignore CHANGED
@@ -1,2 +1 @@
1
- example/RNBackgroundExample/node_modules
2
- example/RNBackgroundExample/e2e
1
+ example/UploadExample/node_modules
package/README.md CHANGED
@@ -140,6 +140,171 @@ const options = {
140
140
 
141
141
  Note the `field` property is required for multipart uploads.
142
142
 
143
+ ## S3 / Cloud Multipart Upload (Chunked)
144
+
145
+ For large files (e.g. > 25MB) that need to be uploaded in chunked parts to AWS S3, Google Cloud Storage, Cloudflare R2, or MinIO using presigned URLs:
146
+
147
+ ```typescript
148
+ import Upload from "@ammarahmed/react-native-upload";
149
+
150
+ const upload = Upload.createMultipartS3({
151
+ uploadId: "unique-upload-id-123",
152
+ path: "/path/to/large-video.mp4",
153
+ parts: [
154
+ "https://s3.amazonaws.com/bucket/file?partNumber=1&uploadId=...",
155
+ "https://s3.amazonaws.com/bucket/file?partNumber=2&uploadId=...",
156
+ // ... presigned URLs for each part
157
+ ],
158
+ maxParallel: 3, // Optional: maximum parallel chunk uploads (default: 3)
159
+ notification: {
160
+ enabled: true,
161
+ filename: "large-video.mp4", // Optional: human-readable name in notification
162
+ },
163
+ appGroup: "group.com.myapp", // Optional: iOS App Group for Share Extensions
164
+ });
165
+
166
+ // Track progress
167
+ upload.onProgress((event) => {
168
+ console.log(`Uploaded ${event.bytesUploaded}/${event.totalBytes} bytes (${(event.progress * 100).toFixed(1)}%)`);
169
+ console.log(`Current chunk index: ${event.chunkIndex}`);
170
+ });
171
+
172
+ try {
173
+ const result = await upload.start();
174
+ if (result.success) {
175
+ // Array of ETags matching the order of the parts (for S3 CompleteMultipartUpload)
176
+ console.log("Upload completed with ETags:", result.etags);
177
+ }
178
+ } catch (error) {
179
+ if (error.code === "CANCELLED") {
180
+ console.log("Upload was cancelled");
181
+ } else {
182
+ console.error("Upload failed:", error);
183
+ }
184
+ }
185
+
186
+ // To cancel mid-upload:
187
+ // await upload.cancel();
188
+ // or statically:
189
+ // await Upload.cancelMultipartS3("unique-upload-id-123");
190
+ ```
191
+
192
+ ### Resumption on Network Failure
193
+
194
+ If an upload is interrupted or network drops, you can resume by passing `existingEtags`. `onProgress` emits an `etags` array matching the parts list (with empty strings for pending parts):
195
+
196
+ ```typescript
197
+ const upload = Upload.createMultipartS3({
198
+ uploadId: savedUploadId, // Re-use the existing S3 upload ID
199
+ path: "/path/to/large-video.mp4",
200
+ parts: presignedUrls,
201
+ existingEtags: savedEtags, // Fixed-length array with ETags for completed parts, "" for pending parts
202
+ });
203
+ ```
204
+
205
+ ### Disabling resumability
206
+
207
+ Uploads are resumable by default: their state is persisted so that they survive the app process being killed. Pass `resumable: false` to an individual upload to turn that off:
208
+
209
+ ```typescript
210
+ Upload.createMultipartS3({ uploadId, path, parts, resumable: false });
211
+ ```
212
+
213
+ A non-resumable upload still keeps running while the app is in the background, but nothing is written to disk. If the app process dies and the app is launched again, the upload is not resumed and does not show up in `getMultipartS3Uploads()`.
214
+
215
+ - Android: the foreground service is not restarted for it and no headless completion runs after a process death.
216
+ - iOS: iOS may keep sending parts that were already queued while the app is dead. On the next launch the library cancels them and deletes their chunk files. The multipart upload is never completed, so abort it on your server if needed.
217
+
218
+ ### Finishing the upload in the background
219
+
220
+ An S3 multipart upload is only complete once you send `CompleteMultipartUpload` with the part ETags. To make that happen even when the app is suspended, or was killed and restarted for the upload, register a completion handler. Do it at the top level of your entry file (`index.js`), outside of any component, so it exists when the app is started in the background:
221
+
222
+ ```typescript
223
+ import Upload from "@ammarahmed/react-native-upload";
224
+
225
+ Upload.setMultipartCompletionHandler(async ({ uploadId, status, etags, error }) => {
226
+ if (status !== "completed") return;
227
+ // Load whatever you saved when you started the upload (bucket, key, ...)
228
+ await completeMultipartUpload(uploadId, etags);
229
+ });
230
+ ```
231
+
232
+ - The handler is called when every part has been uploaded (or the upload failed). When it resolves, the upload is acknowledged and its stored state is removed. If it throws, the upload is not acknowledged and stays pending.
233
+ - It is called while the app is running and, on Android, from a headless JS task when the app process was not running. Save what you need to finish the upload (for example the S3 key) to storage before starting it; the headless task starts your JS bundle from scratch.
234
+ - If you use the handler, don't also finish the upload from the `start()` promise.
235
+ - Fallback: completed uploads are persisted natively. If the handler could not run, check on every app launch and finish them without uploading again:
236
+
237
+ ```typescript
238
+ const pending = await Upload.getMultipartS3Uploads();
239
+ for (const upload of pending) {
240
+ if (upload.status === "completed") {
241
+ await completeMultipartUpload(upload.uploadId, upload.etags);
242
+ await Upload.acknowledgeMultipartS3(upload.uploadId);
243
+ }
244
+ }
245
+ ```
246
+
247
+ `status` is `running`, `completed` or `failed`. A failed upload keeps its `etags`, so you can start it again with `existingEtags`.
248
+
249
+ ### iOS Background Behavior
250
+
251
+ All multipart uploads share one background `NSURLSession`. Every part is queued when the upload starts, so iOS keeps uploading while the app is suspended and wakes the app when parts finish. Upload state is persisted and reattached when the app is relaunched.
252
+
253
+ Forward the system's background session event to the library in your `AppDelegate`:
254
+
255
+ ```swift
256
+ func application(
257
+ _ application: UIApplication,
258
+ handleEventsForBackgroundURLSession identifier: String,
259
+ completionHandler: @escaping () -> Void
260
+ ) {
261
+ RNFileUploader.setCompletionHandlerWithIdentifier(identifier, completionHandler: completionHandler)
262
+ }
263
+ ```
264
+
265
+ (With Swift, import the library header in your bridging header: `#import <react-native-upload/RNFileUploader.h>`.)
266
+
267
+ Notes:
268
+ - The system completion handler is released when your JS calls `canSuspendIfBackground()`, which the library calls after the handler runs. Keep the handler short, iOS gives the app limited time when it wakes it.
269
+ - Chunk files are written to a copy in Application Support (or the App Group container when `appGroup` is set) until the upload ends, so you need free space for a second copy of the file.
270
+ - If the user force-quits the app, iOS cancels the background transfers. Resume with `existingEtags`.
271
+
272
+ ### Android Foreground Service and Headless Task
273
+
274
+ Background support on Android is opt-in. The library does not add any permissions or services to your app. Add the following to your app's `AndroidManifest.xml`:
275
+
276
+ ```xml
277
+ <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
278
+ <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
279
+ <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
280
+
281
+ <application>
282
+ <service
283
+ android:name="com.appfolio.uploader.MultipartUploadService"
284
+ android:exported="false"
285
+ android:foregroundServiceType="dataSync" />
286
+ <service
287
+ android:name="com.appfolio.uploader.MultipartCompletionService"
288
+ android:exported="false" />
289
+ </application>
290
+ ```
291
+
292
+ How it works:
293
+ - Uploads run in a native engine that is independent of your Activity and of the React instance. State (part URLs, ETags, status) is saved to disk after every part.
294
+ - With `notification.enabled` the engine runs a foreground service, which also keeps your JS timers running while the app is in the background.
295
+ - If the system restarts the service after killing the app's process, the engine resumes the remaining parts on its own.
296
+ - When an upload finishes and no React instance is running, `MultipartCompletionService` starts a headless JS task that runs your completion handler.
297
+ - After an upload ends, the service stays alive until the handler resolves (or `Upload.acknowledgeMultipartS3(uploadId)` is called), or for 30 seconds, or 2 minutes if the headless task was started.
298
+
299
+ Notes:
300
+ - Google Play requires a declaration for `FOREGROUND_SERVICE_DATA_SYNC`.
301
+ - `POST_NOTIFICATIONS` must be requested at runtime on Android 13+ for the notification to appear.
302
+ - The foreground service is only started while your app is in the foreground. Without it, the process can still be killed by the system, and uploads are then resumed the next time the app starts.
303
+ - A force stop or an OEM that kills the app when it is removed from recents does not restart the service. The persisted uploads resume the next time the app is opened, and completed ones can be finished with `getMultipartS3Uploads()` as shown above.
304
+ - On Android 15+ `dataSync` services have a time limit; the service stops itself when the system times it out.
305
+ - The library compiles against `compileSdkVersion` 35 or higher.
306
+ - Notification texts can be customized with `onProgressTitle`, `onStartMessage`, `onResumeMessage`, `onProgressMessage` (use `{progress}` for the percentage) and `notificationChannelName`.
307
+
143
308
  # API
144
309
 
145
310
  ## Top Level Functions
@@ -326,7 +491,7 @@ Is there an example/sandbox app to test out this package?
326
491
 
327
492
  > Yes, there is a simple react native app that comes with an [express](https://github.com/expressjs/express) server where you can see react-native-background-upload in action and try things out in an isolated local environment.
328
493
 
329
- [RNBackgroundExample](https://github.com/Vydia/react-native-background-upload/blob/master/example/RNBackgroundExample)
494
+ [UploadExample](example/UploadExample)
330
495
 
331
496
  Does it support iOS camera roll assets?
332
497
 
@@ -22,7 +22,7 @@ buildscript {
22
22
  apply plugin: 'com.android.library'
23
23
  apply plugin: 'kotlin-android'
24
24
 
25
- def DEFAULT_COMPILE_SDK_VERSION = 31
25
+ def DEFAULT_COMPILE_SDK_VERSION = 35
26
26
  def DEFAULT_BUILD_TOOLS_VERSION = "30.0.2"
27
27
  def DEFAULT_TARGET_SDK_VERSION = 29
28
28
 
@@ -0,0 +1,33 @@
1
+ package com.appfolio.uploader
2
+
3
+ import android.content.Context
4
+ import android.content.Intent
5
+ import android.util.Log
6
+ import com.facebook.react.HeadlessJsTaskService
7
+ import com.facebook.react.jstasks.HeadlessJsTaskConfig
8
+
9
+ class MultipartCompletionService : HeadlessJsTaskService() {
10
+ companion object {
11
+ private const val TAG = "MultipartCompletion"
12
+ private const val TASK_KEY = "RNMultipartUploadCompletion"
13
+ private const val TASK_TIMEOUT_MS = 120_000L
14
+ private const val EXTRA_UPLOAD_ID = "extra_upload_id"
15
+
16
+ fun start(context: Context, uploadId: String) {
17
+ try {
18
+ val intent = Intent(context, MultipartCompletionService::class.java).apply {
19
+ putExtra(EXTRA_UPLOAD_ID, uploadId)
20
+ }
21
+ context.startService(intent)
22
+ } catch (e: Exception) {
23
+ Log.w(TAG, "Could not start MultipartCompletionService: ${e.message}")
24
+ }
25
+ }
26
+ }
27
+
28
+ override fun getTaskConfig(intent: Intent?): HeadlessJsTaskConfig? {
29
+ val uploadId = intent?.getStringExtra(EXTRA_UPLOAD_ID) ?: return null
30
+ val payload = MultipartUploader.getInstance(this).completionPayload(uploadId) ?: return null
31
+ return HeadlessJsTaskConfig(TASK_KEY, payload, TASK_TIMEOUT_MS, true)
32
+ }
33
+ }
@@ -0,0 +1,200 @@
1
+ package com.appfolio.uploader
2
+
3
+ import android.app.ActivityManager
4
+ import android.app.Notification
5
+ import android.app.NotificationChannel
6
+ import android.app.NotificationManager
7
+ import android.app.Service
8
+ import android.content.Context
9
+ import android.content.Intent
10
+ import android.content.pm.ServiceInfo
11
+ import android.os.Binder
12
+ import android.os.Build
13
+ import android.os.IBinder
14
+ import android.util.Log
15
+ import androidx.core.app.NotificationCompat
16
+
17
+ class MultipartUploadService : Service() {
18
+ companion object {
19
+ private const val TAG = "MultipartUploadService"
20
+ private const val CHANNEL_ID = "BackgroundUploadChannel"
21
+ private const val NOTIFICATION_ID = 1002
22
+
23
+ @Volatile
24
+ var instance: MultipartUploadService? = null
25
+
26
+ const val ACTION_START = "com.appfolio.uploader.START_FOREGROUND_UPLOAD"
27
+ const val ACTION_STOP = "com.appfolio.uploader.STOP_FOREGROUND_UPLOAD"
28
+
29
+ const val EXTRA_TITLE = "extra_title"
30
+ const val EXTRA_MESSAGE = "extra_message"
31
+
32
+ private fun isAppInForeground(context: Context): Boolean {
33
+ val state = ActivityManager.RunningAppProcessInfo()
34
+ ActivityManager.getMyMemoryState(state)
35
+ return state.importance <= ActivityManager.RunningAppProcessInfo.IMPORTANCE_VISIBLE
36
+ }
37
+
38
+ fun ensureChannel(context: Context, channelName: String) {
39
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
40
+ val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager
41
+ val channel = NotificationChannel(CHANNEL_ID, channelName, NotificationManager.IMPORTANCE_LOW)
42
+ manager?.createNotificationChannel(channel)
43
+ }
44
+ }
45
+
46
+ fun start(context: Context, title: String, message: String, channelName: String) {
47
+ if (!isAppInForeground(context)) {
48
+ Log.w(TAG, "App is not in the foreground, uploading without a foreground service")
49
+ return
50
+ }
51
+ try {
52
+ ensureChannel(context, channelName)
53
+ val intent = Intent(context, MultipartUploadService::class.java).apply {
54
+ action = ACTION_START
55
+ putExtra(EXTRA_TITLE, title)
56
+ putExtra(EXTRA_MESSAGE, message)
57
+ }
58
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
59
+ context.startForegroundService(intent)
60
+ } else {
61
+ context.startService(intent)
62
+ }
63
+ } catch (e: Exception) {
64
+ // If Android OS blocks startForegroundService (e.g. ForegroundServiceStartNotAllowedException),
65
+ // catch it so the app process does not crash. The coroutine will still run in process.
66
+ Log.w(TAG, "Could not start MultipartUploadService: ${e.message}")
67
+ }
68
+ }
69
+
70
+ fun updateProgress(context: Context, title: String, progress: Int, message: String) {
71
+ if (instance == null) return
72
+ try {
73
+ val notification = buildNotification(context, title, progress, message, ongoing = true)
74
+ val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager
75
+ manager?.notify(NOTIFICATION_ID, notification)
76
+ } catch (e: Exception) {
77
+ Log.w(TAG, "Could not update progress: ${e.message}")
78
+ }
79
+ }
80
+
81
+ fun stop(context: Context) {
82
+ try {
83
+ instance?.stopForegroundAndSelf()
84
+ val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager
85
+ notificationManager?.cancel(NOTIFICATION_ID)
86
+ } catch (e: Exception) {
87
+ Log.w(TAG, "Could not stop MultipartUploadService: ${e.message}")
88
+ }
89
+ }
90
+
91
+ private fun buildNotification(context: Context, title: String, progress: Int, message: String, ongoing: Boolean): Notification {
92
+ val builder = NotificationCompat.Builder(context, CHANNEL_ID)
93
+ .setContentTitle(title)
94
+ .setContentText(message)
95
+ .setSmallIcon(android.R.drawable.stat_sys_upload)
96
+ .setPriority(NotificationCompat.PRIORITY_LOW)
97
+ .setOngoing(ongoing)
98
+ .setAutoCancel(!ongoing)
99
+
100
+ if (progress in 0..100) {
101
+ builder.setProgress(100, progress, false)
102
+ } else {
103
+ builder.setProgress(0, 0, true)
104
+ }
105
+
106
+ return builder.build()
107
+ }
108
+ }
109
+
110
+ private val binder = LocalBinder()
111
+ private var isStarted = false
112
+ private var notificationManager: NotificationManager? = null
113
+
114
+ inner class LocalBinder : Binder() {
115
+ fun getService(): MultipartUploadService = this@MultipartUploadService
116
+ }
117
+
118
+ override fun onCreate() {
119
+ super.onCreate()
120
+ instance = this
121
+ notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager
122
+ }
123
+
124
+ override fun onBind(intent: Intent?): IBinder = binder
125
+
126
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
127
+ if (intent == null) {
128
+ val resumed = MultipartUploader.getInstance(this).resumeInterrupted().firstOrNull { it.notificationEnabled }
129
+ if (resumed == null) {
130
+ stopSelf()
131
+ return START_NOT_STICKY
132
+ }
133
+ try {
134
+ ensureChannel(this, resumed.channelName)
135
+ startForegroundWithNotification(resumed.title, 0, resumed.resumeMessage)
136
+ } catch (e: Exception) {
137
+ Log.e(TAG, "Failed startForeground on restart: ${e.message}")
138
+ stopSelf()
139
+ return START_NOT_STICKY
140
+ }
141
+ return START_STICKY
142
+ }
143
+ when (intent.action) {
144
+ ACTION_START -> {
145
+ val title = intent.getStringExtra(EXTRA_TITLE) ?: ""
146
+ val message = intent.getStringExtra(EXTRA_MESSAGE) ?: ""
147
+ try {
148
+ startForegroundWithNotification(title, 0, message)
149
+ } catch (e: Exception) {
150
+ Log.e(TAG, "Failed startForeground in onStartCommand: ${e.message}")
151
+ // Stop service if foreground transition was denied to prevent ANR / crash
152
+ stopSelf()
153
+ }
154
+ }
155
+ ACTION_STOP -> {
156
+ stopForegroundAndSelf()
157
+ }
158
+ }
159
+ return if (intent.action == ACTION_START) START_STICKY else START_NOT_STICKY
160
+ }
161
+
162
+ private fun startForegroundWithNotification(title: String, progress: Int, message: String) {
163
+ val notification = buildNotification(this, title, progress, message, ongoing = true)
164
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
165
+ startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
166
+ } else {
167
+ startForeground(NOTIFICATION_ID, notification)
168
+ }
169
+ isStarted = true
170
+ }
171
+
172
+ fun stopForegroundAndSelf() {
173
+ isStarted = false
174
+ try {
175
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
176
+ stopForeground(STOP_FOREGROUND_REMOVE)
177
+ } else {
178
+ @Suppress("DEPRECATION")
179
+ stopForeground(true)
180
+ }
181
+ notificationManager?.cancel(NOTIFICATION_ID)
182
+ } catch (e: Exception) {
183
+ Log.w(TAG, "Error in stopForeground: ${e.message}")
184
+ } finally {
185
+ stopSelf()
186
+ }
187
+ }
188
+
189
+ override fun onTimeout(startId: Int, fgsType: Int) {
190
+ stopForegroundAndSelf()
191
+ }
192
+
193
+ override fun onDestroy() {
194
+ if (instance === this) {
195
+ instance = null
196
+ }
197
+ stopForegroundAndSelf()
198
+ super.onDestroy()
199
+ }
200
+ }