diff --git a/README.md b/README.md index 9b24064..81b8272 100644 --- a/README.md +++ b/README.md @@ -287,6 +287,17 @@ const uploadResult = await backgroundUpload( console.log(written, total); } ); + +//OR + +const uploadResult = await backgroundUpload( + url, + fileUrl, + { uploadType: UploadType.MULTIPART, httpMethod: 'POST', headers }, + (written, total) => { + console.log(written, total); + } +); ``` ### Download File @@ -406,28 +417,50 @@ await clearCache(); // this will clear cache of thumbnails cache directory ## Background Upload -- ###### backgroundUpload: (url: string, fileUrl: string, options: FileSystemUploadOptions, onProgress?: ((writtem: number, total: number) => void) | undefined) => Promise< any > +- ###### backgroundUpload: (url: string, fileUrl: string, options: UploaderOptions, onProgress?: ((writtem: number, total: number) => void) | undefined) => Promise< any > -- ###### ` FileSystemUploadOptions` +- ###### ` UploaderOptions` ```js -type FileSystemUploadOptions = ( +export enum UploadType { + BINARY_CONTENT = 0, + MULTIPART = 1, +} + +export enum UploaderHttpMethod { + POST = 'POST', + PUT = 'PUT', + PATCH = 'PATCH', +} + +export declare type HTTPResponse = { + status: number; + headers: Record; + body: string; +}; + +export declare type HttpMethod = 'POST' | 'PUT' | 'PATCH'; + +export declare type UploaderOptions = ( | { - uploadType?: FileSystemUploadType.BINARY_CONTENT, + uploadType?: UploadType.BINARY_CONTENT; + mimeType?: string; } | { - uploadType: FileSystemUploadType.MULTIPART, // will add soon - fieldName?: string, // will add soon - mimeType?: string, - parameters?: Record, + uploadType: UploadType.MULTIPART; + fieldName?: string; + mimeType?: string; + parameters?: Record; } ) & { - headers?: Record, - httpMethod?: FileSystemAcceptedUploadHttpMethod, - sessionType?: FileSystemSessionType, + headers?: Record; + httpMethod?: UploaderHttpMethod; }; ``` +**Note:** some of the uploader code is borrowed from [Expo](https://github.com/expo/expo) +I tested file uploader on this backend [Nodejs-File-Uploader](https://github.com/numandev1/nodejs-file-uploader) + ### Download - ##### download: ( fileUrl: string, downloadProgress?: (progress: number) => void, progressDivider?: number ) => Promise< string > diff --git a/android/src/main/java/com/reactnativecompressor/CompressorModule.kt b/android/src/main/java/com/reactnativecompressor/CompressorModule.kt index 2be52b8..3a4a65e 100644 --- a/android/src/main/java/com/reactnativecompressor/CompressorModule.kt +++ b/android/src/main/java/com/reactnativecompressor/CompressorModule.kt @@ -15,12 +15,14 @@ import com.reactnativecompressor.Utils.Uploader import com.reactnativecompressor.Utils.Utils import com.reactnativecompressor.Utils.Utils.generateCacheFilePath import com.reactnativecompressor.Utils.Utils.getRealPath +import com.reactnativecompressor.Utils.convertReadableMapToUploaderOptions import com.reactnativecompressor.Video.VideoMain class CompressorModule(private val reactContext: ReactApplicationContext) : CompressorSpec(reactContext) { private val imageMain: ImageMain = ImageMain(reactContext) private val videoMain: VideoMain = VideoMain(reactContext) private val audioMain: AudioMain = AudioMain(reactContext) + private val uploader: Uploader = Uploader(reactContext) private val videoThumbnail: CreateVideoThumbnailClass = CreateVideoThumbnailClass(reactContext) override fun initialize() { @@ -120,7 +122,7 @@ class CompressorModule(private val reactContext: ReactApplicationContext) : Comp fileUrl: String, options: ReadableMap, promise: Promise) { - Uploader.upload(fileUrl, options, reactContext, promise) + uploader.upload(fileUrl, options, reactContext, promise) } @ReactMethod diff --git a/android/src/main/java/com/reactnativecompressor/Utils/Uploader.kt b/android/src/main/java/com/reactnativecompressor/Utils/Uploader.kt index c0df850..586a942 100644 --- a/android/src/main/java/com/reactnativecompressor/Utils/Uploader.kt +++ b/android/src/main/java/com/reactnativecompressor/Utils/Uploader.kt @@ -1,117 +1,189 @@ package com.reactnativecompressor.Utils +import android.annotation.SuppressLint +import android.content.ContentResolver +import android.net.Uri import android.util.Log +import android.webkit.MimeTypeMap import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReadableMap -import io.github.lizhangqu.coreprogress.ProgressHelper -import io.github.lizhangqu.coreprogress.ProgressUIListener import okhttp3.Call import okhttp3.Callback -import okhttp3.MediaType +import okhttp3.Headers import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.MultipartBody import okhttp3.OkHttpClient -import okhttp3.Request.Builder +import okhttp3.Request import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.asRequestBody import okhttp3.Response import java.io.File import java.io.IOException -import java.util.Locale - -object Uploader { - private const val TAG = "asyncTaskUploader" - - fun upload(fileUrl: String, _options: ReadableMap?, reactContext: ReactApplicationContext, promise: Promise) { - val options = _options?.let { UploaderHelper.fromMap(it) } - val uploadableFile = File(fileUrl) - val url = options?.url - var contentType: String? = "video" - val okHttpClient = OkHttpClient() - val builder = Builder() - if (url != null) { - builder.url(url) - } - val headerIterator = options?.headers?.keySetIterator() - while (headerIterator?.hasNextKey() == true) { - val key = headerIterator.nextKey() - val value = options.headers?.getString(key) - Log.d(TAG, "$key value: $value") - builder.addHeader(key, value.toString()) - if (key.lowercase(Locale.getDefault()) == "content-type:") { - contentType = value +import java.net.URLConnection +import java.util.concurrent.TimeUnit +import java.util.regex.Pattern + +class Uploader(private val reactContext: ReactApplicationContext) { + val TAG = "asyncTaskUploader" + var client: OkHttpClient? = null + val MIN_EVENT_DT_MS: Long = 100 + + fun upload(fileUriString: String, _options: ReadableMap, reactContext: ReactApplicationContext, promise: Promise) { + val options:UploaderOptions=convertReadableMapToUploaderOptions(_options) + val url = options.url + val uuid = options.uuid + val progressListener: CountingRequestListener = object : CountingRequestListener { + private var mLastUpdate: Long = -1 + override fun onProgress(bytesWritten: Long, contentLength: Long) { + val currentTime = System.currentTimeMillis() + + // Throttle events. Sending too many events will block the JS event loop. + // Make sure to send the last event when we're at 100%. + if (currentTime > mLastUpdate + MIN_EVENT_DT_MS || bytesWritten == contentLength) { + mLastUpdate = currentTime + EventEmitterHandler.sendUploadProgressEvent(bytesWritten,contentLength,uuid) } } - val mediaType: MediaType? = contentType?.toMediaTypeOrNull(); - val body = RequestBody.create(mediaType, uploadableFile) - val requestBody = ProgressHelper.withProgress(body, object : ProgressUIListener() { - //if you don't need this method, don't override this methd. It isn't an abstract method, just an empty method. - override fun onUIProgressStart(totalBytes: Long) { - super.onUIProgressStart(totalBytes) - Log.d(TAG, "onUIProgressStart:$totalBytes") - } - - override fun onUIProgressChanged(numBytes: Long, totalBytes: Long, percent: Float, speed: Float) { - EventEmitterHandler.sendUploadProgressEvent(numBytes,totalBytes,options?.uuid) - Log.d(TAG, "=============start===============") - Log.d(TAG, "numBytes:$numBytes") - Log.d(TAG, "totalBytes:$totalBytes") - Log.d(TAG, "percent:$percent") - Log.d(TAG, "speed:$speed") - Log.d(TAG, "============= end ===============") - } - - //if you don't need this method, don't override this methd. It isn't an abstract method, just an empty method. - override fun onUIProgressFinish() { - super.onUIProgressFinish() - Log.d(TAG, "onUIProgressFinish:") - } - }) - builder.put(requestBody) - val call = okHttpClient.newCall(builder.build()) - call.enqueue(object : Callback { - override fun onFailure(call: Call, e: IOException) { - Log.d(TAG, "=============onFailure===============") - promise.reject("") - e.printStackTrace() - } - - @Throws(IOException::class) - override fun onResponse(call: Call, response: Response) { - Log.d(TAG, "=============onResponse===============") - Log.d(TAG, "request headers:" + response.request.headers) - Log.d(TAG, "response code:" + response.code) - Log.d(TAG, "response headers:" + response.headers) - Log.d(TAG, "response body:" + response.body!!.string()) - val param = Arguments.createMap() - param.putInt("status", response.code) - promise.resolve(param) - } + } + val request = createUploadRequest( + url, fileUriString, options + ) { requestBody -> CountingRequestBody(requestBody, progressListener) } + + okHttpClient?.let { + it.newCall(request).enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + Log.e(TAG, e.message.toString()) + promise.reject(TAG, e.message, e) + } + + override fun onResponse(call: Call, response: Response) { + val param = Arguments.createMap() + param.putInt("status", response.code) + param.putString("body", response.body?.string()) + param.putMap("headers", translateHeaders(response.headers)) + response.close() + promise.resolve(param) + } }) + } ?: run { + promise.reject(UploaderOkHttpNullException()) + } + } -} + @get:Synchronized + private val okHttpClient: OkHttpClient? + get() { + if (client == null) { + val builder = OkHttpClient.Builder() + .connectTimeout(60, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) + .writeTimeout(60, TimeUnit.SECONDS) + client = builder.build() + } + return client + } -class UploaderHelper { - var uuid: String? = null - var method: String? = null - var headers: ReadableMap? = null - var url: String? = null - - companion object { - fun fromMap(map: ReadableMap): UploaderHelper { - val options = UploaderHelper() - val iterator = map.keySetIterator() - while (iterator.hasNextKey()) { - val key = iterator.nextKey() - when (key) { - "uuid" -> options.uuid = map.getString(key) - "method" -> options.method = map.getString(key) - "headers" -> options.headers = map.getMap(key) - "url" -> options.url = map.getString(key) + private fun slashifyFilePath(path: String?): String? { + return if (path == null) { + null + } else if (path.startsWith("file:///")) { + path + } else { + // Ensure leading schema with a triple slash + Pattern.compile("^file:/*").matcher(path).replaceAll("file:///") + } + } + + @Throws(IOException::class) + private fun createUploadRequest(url: String, fileUriString: String, options: UploaderOptions, decorator: RequestBodyDecorator): Request { + val fileUri = Uri.parse(slashifyFilePath(fileUriString)) + fileUri.checkIfFileExists() + + val requestBuilder = Request.Builder().url(url) + options.headers?.let { + it.forEach { (key, value) -> requestBuilder.addHeader(key, value) } + } + + val body = createRequestBody(options, decorator, fileUri.toFile()) + return options.httpMethod.let { requestBuilder.method(it.value, body).build() } + } + + @SuppressLint("NewApi") + private fun createRequestBody(options: UploaderOptions, decorator: RequestBodyDecorator, file: File): RequestBody { + return when (options.uploadType) { + UploadType.BINARY_CONTENT -> { + val mimeType: String? = if (options.mimeType?.isNotEmpty() == true) { + options.mimeType + } else { + getContentType(reactContext, file) ?: "application/octet-stream" + } + val contentType = mimeType?.toMediaTypeOrNull() + decorator.decorate(file.asRequestBody(contentType)) + } + + UploadType.MULTIPART -> { + val bodyBuilder = MultipartBody.Builder().setType(MultipartBody.FORM) + options.parameters?.let { + (it as Map) + .forEach { (key, value) -> bodyBuilder.addFormDataPart(key, value.toString()) } } + val mimeType: String = options.mimeType ?: URLConnection.guessContentTypeFromName(file.name) + + val fieldName = options.fieldName ?: file.name + bodyBuilder.addFormDataPart(fieldName, file.name, decorator.decorate(file.asRequestBody(mimeType.toMediaTypeOrNull()))) + bodyBuilder.build() + } + } + } + + fun getContentType(context: ReactApplicationContext, file: File): String? { + val contentResolver: ContentResolver = context.contentResolver + val fileUri = Uri.fromFile(file) + + // Try to get the MIME type from the ContentResolver + val mimeType = contentResolver.getType(fileUri) + + // If the ContentResolver couldn't determine the MIME type, try to infer it from the file extension + if (mimeType == null) { + val fileExtension = MimeTypeMap.getFileExtensionFromUrl(fileUri.toString()) + if (fileExtension != null) { + return MimeTypeMap.getSingleton().getMimeTypeFromExtension(fileExtension.toLowerCase()) + } + } + + return mimeType + } + + @Throws(IOException::class) + private fun Uri.checkIfFileExists() { + val file = this.toFile() + if (!file.exists()) { + throw IOException("Directory for '${file.path}' doesn't exist.") + } + } + + // extension functions of Uri class + private fun Uri.toFile() = if (this.path != null) { + File(this.path!!) + } else { + throw IOException("Invalid Uri: $this") + } + + private fun translateHeaders(headers: Headers): ReadableMap { + val responseHeaders = Arguments.createMap() + for (i in 0 until headers.size) { + val headerName = headers.name(i) + // multiple values for the same header + if (responseHeaders.hasKey(headerName)) { + val existingValue = responseHeaders.getString(headerName) + responseHeaders.putString(headerName, "$existingValue, ${headers.value(i)}") + } else { + responseHeaders.putString(headerName, headers.value(i)) } - return options } + return responseHeaders } } diff --git a/android/src/main/java/com/reactnativecompressor/Utils/UploaderHelper.kt b/android/src/main/java/com/reactnativecompressor/Utils/UploaderHelper.kt new file mode 100644 index 0000000..2af22e0 --- /dev/null +++ b/android/src/main/java/com/reactnativecompressor/Utils/UploaderHelper.kt @@ -0,0 +1,121 @@ +package com.reactnativecompressor.Utils + +import com.facebook.react.bridge.ReadableMap +import okhttp3.RequestBody +import okio.Buffer +import okio.BufferedSink +import okio.ForwardingSink +import okio.IOException +import okio.Sink +import okio.buffer + +interface Record +annotation class Field(val key: String = "") +interface Enumerable + +@FunctionalInterface +fun interface RequestBodyDecorator { + fun decorate(requestBody: RequestBody): RequestBody +} +enum class EncodingType(val value: String) : Enumerable { + UTF8("utf8"), + BASE64("base64") +} + +enum class UploadType(val value: Int) : Enumerable { + BINARY_CONTENT(0), + MULTIPART(1) +} +data class UploaderOptions( + val headers: Map?, + val httpMethod: HttpMethod = HttpMethod.POST, + val uploadType: UploadType, + val fieldName: String?, + val mimeType: String?, + val parameters: Map?, + val uuid:String, + val url:String +) : Record + +enum class HttpMethod(val value: String) : Enumerable { + POST("POST"), + PUT("PUT"), + PATCH("PATCH") +} + +internal class UploaderOkHttpNullException : + CodedException("okHttpClient is null") +internal class CookieHandlerNotFoundException : + CodedException("Failed to find CookieHandler") +interface CodedThrowable { + val code: String? + val message: String? +} +abstract class CodedException : Exception, CodedThrowable { + constructor(message: String?) : super(message) + constructor(cause: Throwable?) : super(cause) + constructor(message: String?, cause: Throwable?) : super(message, cause) + + override val code: String + get() = "ERR_UNSPECIFIED_ANDROID_EXCEPTION" +} + +@FunctionalInterface +interface CountingRequestListener { + fun onProgress(bytesWritten: Long, contentLength: Long) +} + +@FunctionalInterface + + +private class CountingSink( + sink: Sink, + private val requestBody: RequestBody, + private val progressListener: CountingRequestListener +) : ForwardingSink(sink) { + private var bytesWritten = 0L + + override fun write(source: Buffer, byteCount: Long) { + super.write(source, byteCount) + + bytesWritten += byteCount + progressListener.onProgress(bytesWritten, requestBody.contentLength()) + } +} + +class CountingRequestBody( + private val requestBody: RequestBody, + private val progressListener: CountingRequestListener +) : RequestBody() { + override fun contentType() = requestBody.contentType() + + @Throws(IOException::class) + override fun contentLength() = requestBody.contentLength() + + override fun writeTo(sink: BufferedSink) { + val countingSink = CountingSink(sink, this, progressListener) + + val bufferedSink = countingSink.buffer() + requestBody.writeTo(bufferedSink) + bufferedSink.flush() + } +} + + +fun convertReadableMapToUploaderOptions(options: ReadableMap): UploaderOptions { + val headers = options.getMap("headers")?.toHashMap() as? Map + val httpMethod = HttpMethod.valueOf(options.getString("httpMethod") ?: "POST") + val uploadTypeInt = options.getInt("uploadType") + val uploadType = when (uploadTypeInt) { + UploadType.BINARY_CONTENT.value -> UploadType.BINARY_CONTENT + UploadType.MULTIPART.value -> UploadType.MULTIPART + else -> UploadType.BINARY_CONTENT // Provide a default value or handle the case as needed + } + val fieldName = options.getString("fieldName")?: "file" + val mimeType = options.getString("mimeType")?: "" + val parameters = options.getMap("parameters")?.toHashMap() as? Map + val uuid = options.getString("uuid") ?: "" + val url = options.getString("url") ?: "" + + return UploaderOptions(headers, httpMethod, uploadType, fieldName, mimeType, parameters, uuid, url) +} diff --git a/example/src/Screens/Video/index.tsx b/example/src/Screens/Video/index.tsx index e8bdbb1..c2a2bfe 100644 --- a/example/src/Screens/Video/index.tsx +++ b/example/src/Screens/Video/index.tsx @@ -4,16 +4,23 @@ import { Video, getRealPath, backgroundUpload, + UploadType, + UploaderHttpMethod, createVideoThumbnail, clearCache, } from 'react-native-compressor'; + import * as ImagePicker from 'react-native-image-picker'; import CameraRoll from '@react-native-camera-roll/camera-roll'; import prettyBytes from 'pretty-bytes'; import { getFileInfo } from '../../Utils'; import ProgressBar from '../../Components/ProgressBar'; import type { ProgressBarRafType } from '../../Components/ProgressBar'; - +// const DOMAIN = 'http://localhost:8080'; +const DOMAIN = 'http://192.168.1.3:8080'; +const uploadPutRequest = `${DOMAIN}/upload/putRequestFile.mov`; +const uploadPostRequest = `${DOMAIN}/upload`; +// const uploadPostRequestFail = `${DOMAIN}/uploadFail`; export default function App() { const progressRef = useRef(); const cancellationIdRef = useRef(''); @@ -171,42 +178,66 @@ export default function App() { Video.cancelCompression(cancellationIdRef.current); }; - const uploadSource = async () => { - if (!sourceVideo) return; + const uploadByPostRequest = async ( + type: 'actual' | 'compressed' = 'actual' + ) => { + const localFileUrl = type === 'actual' ? sourceVideo : compressedVideo; + if (!localFileUrl) return; try { + console.log('upload start', localFileUrl); + const headers = { + Authorization: `Bearer ABCABC`, + }; const result = await backgroundUpload( - 'http://w.hbu50.com:8080/hello.mp4', - sourceVideo, - { httpMethod: 'PUT' }, + uploadPostRequest, + localFileUrl, + { + uploadType: UploadType.MULTIPART, + httpMethod: UploaderHttpMethod.POST, + // fieldName: 'file', + // mimeType: 'video/quicktime', + parameters: { message: 'this is test message' }, + headers, + }, (written, total) => { progressRef.current?.setProgress(written / total); console.log(written, total); } ); - console.log(result); + + console.log(result, 'result'); } catch (error) { - console.log(error); + console.log('error=>', error); } finally { progressRef.current?.setProgress(0); } }; - const uploadCompressed = async () => { - if (!compressedVideo) return; + const uploadByPutRequest = async (type: 'actual' | 'compressed') => { + const localFileUrl = type === 'actual' ? sourceVideo : compressedVideo; + if (!localFileUrl) return; try { - progressRef.current?.setProgress(1); + console.log('upload start', sourceVideo); + const headers = { + Authorization: `Bearer ABCABC`, + }; const result = await backgroundUpload( - 'http://w.hbu50.com:8080/hello.mp4', - compressedVideo, - { httpMethod: 'PUT' }, + uploadPutRequest, + localFileUrl, + { + uploadType: UploadType.BINARY_CONTENT, + httpMethod: UploaderHttpMethod.PUT, + headers, + }, (written, total) => { progressRef.current?.setProgress(written / total); console.log(written, total); } ); - console.log(result); + + console.log(result, 'result'); } catch (error) { - console.log(error); + console.log('error=>', error); } finally { progressRef.current?.setProgress(0); } @@ -253,7 +284,8 @@ export default function App() { resizeMode="contain" /> {sourceSize && Size: {sourceSize}} -