diff --git a/android/src/main/java/io/deckers/blob_courier/BlobCourierModule.kt b/android/src/main/java/io/deckers/blob_courier/BlobCourierModule.kt index 3abbb910..ddcf31f8 100644 --- a/android/src/main/java/io/deckers/blob_courier/BlobCourierModule.kt +++ b/android/src/main/java/io/deckers/blob_courier/BlobCourierModule.kt @@ -31,6 +31,8 @@ import io.deckers.blob_courier.react.CongestionAvoidingProgressNotifierFactory import io.deckers.blob_courier.react.processUnexpectedError import io.deckers.blob_courier.react.processUnexpectedException import io.deckers.blob_courier.react.toReactMap +import io.deckers.blob_courier.send.BlobSender +import io.deckers.blob_courier.send.SenderParameterFactory import io.deckers.blob_courier.upload.BlobUploader import io.deckers.blob_courier.upload.UploaderParameterFactory import java.net.UnknownHostException @@ -127,6 +129,43 @@ class BlobCourierModule(private val reactContext: ReactApplicationContext) : li("Called fetchBlob") } + @ReactMethod + fun sendBlob(input: ReadableMap, promise: Promise) { + li("Calling sendBlob") + thread { + try { + SenderParameterFactory() + .fromInput(input) + .fold(::Failure, ::Success) + .fmap( + BlobSender( + reactContext, + createHttpClient(), + createProgressFactory(reactContext) + )::send + ) + .map { it.toReactMap() } + .`do`( + { f -> + lv("Something went wrong during send (code=${f.code},message=${f.message})") + promise.reject(f.code, f.message) + }, + promise::resolve + ) + } catch (e: UnknownHostException) { + lv("Unknown host", e) + promise.reject(ERROR_UNKNOWN_HOST, e) + } catch (e: Exception) { + le("Unexpected exception", e) + promise.reject(ERROR_UNEXPECTED_EXCEPTION, processUnexpectedException(e).message) + } catch (e: Error) { + le("Unexpected error", e) + promise.reject(ERROR_UNEXPECTED_ERROR, processUnexpectedError(e).message) + } + } + li("Called sendBlob") + } + @ReactMethod fun uploadBlob(input: ReadableMap, promise: Promise) { li("Calling uploadBlob") diff --git a/android/src/main/java/io/deckers/blob_courier/upload/InputStreamRequestBody.kt b/android/src/main/java/io/deckers/blob_courier/common/InputStreamRequestBody.kt similarity index 96% rename from android/src/main/java/io/deckers/blob_courier/upload/InputStreamRequestBody.kt rename to android/src/main/java/io/deckers/blob_courier/common/InputStreamRequestBody.kt index 4477a7d2..38f45d67 100644 --- a/android/src/main/java/io/deckers/blob_courier/upload/InputStreamRequestBody.kt +++ b/android/src/main/java/io/deckers/blob_courier/common/InputStreamRequestBody.kt @@ -4,7 +4,7 @@ * This source code is licensed under the MPL-2.0 license found in the * LICENSE file in the root directory of this source tree. */ -package io.deckers.blob_courier.upload +package io.deckers.blob_courier.common import android.content.ContentResolver import android.net.Uri diff --git a/android/src/main/java/io/deckers/blob_courier/send/BlobSender.kt b/android/src/main/java/io/deckers/blob_courier/send/BlobSender.kt new file mode 100644 index 00000000..fd6b13ef --- /dev/null +++ b/android/src/main/java/io/deckers/blob_courier/send/BlobSender.kt @@ -0,0 +1,87 @@ +/** + * Copyright (c) Ely Deckers. + * + * This source code is licensed under the MPL-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ +package io.deckers.blob_courier.send + +import android.content.Context +import io.deckers.blob_courier.cancel.registerCancellationHandler +import io.deckers.blob_courier.common.ERROR_CANCELED_EXCEPTION +import io.deckers.blob_courier.common.ERROR_UNEXPECTED_ERROR +import io.deckers.blob_courier.common.ERROR_UNEXPECTED_EXCEPTION +import io.deckers.blob_courier.common.Failure +import io.deckers.blob_courier.common.Logger +import io.deckers.blob_courier.common.Result +import io.deckers.blob_courier.common.Success +import io.deckers.blob_courier.common.createErrorFromThrowable +import io.deckers.blob_courier.common.mapHeadersToMap +import io.deckers.blob_courier.progress.BlobCourierProgressRequest +import io.deckers.blob_courier.progress.ProgressNotifierFactory +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.IOException + +private val TAG = BlobSender::class.java.name + +private val logger = Logger(TAG) +private fun li(m: String) = logger.i(m) + +class BlobSender( + private val context: Context, + private val httpClient: OkHttpClient, + private val progressNotifierFactory: ProgressNotifierFactory +) { + + fun send(senderParameters: SenderParameters): Result> { + li("Starting unmanaged send") + + val requestBody = BlobCourierProgressRequest( + senderParameters.toRequestBody(context.contentResolver), + progressNotifierFactory.create(senderParameters.taskId) + ) + + val requestBuilder = Request.Builder() + .url(senderParameters.uri) + .method(senderParameters.method, requestBody) + .apply { + senderParameters.headers.forEach { e: Map.Entry -> + addHeader(e.key, e.value) + } + } + .build() + + val sendRequestCall = httpClient.newCall(requestBuilder) + + try { + registerCancellationHandler(context, senderParameters.taskId, sendRequestCall) + + val response = sendRequestCall.execute() + + val responseBody = response.body()?.string().orEmpty() + + li("Finished unmanaged send") + + return Success( + mapOf( + "response" to mapOf( + "code" to response.code(), + "data" to if (senderParameters.returnResponse) responseBody else "", + "headers" to mapHeadersToMap(response.headers()) + ) + ) + ) + } catch (e: IOException) { + if (sendRequestCall.isCanceled) { + return Failure(createErrorFromThrowable(ERROR_CANCELED_EXCEPTION, e)) + } + + return Failure(createErrorFromThrowable(ERROR_UNEXPECTED_EXCEPTION, e)) + } catch (e: Exception) { + return Failure(createErrorFromThrowable(ERROR_UNEXPECTED_EXCEPTION, e)) + } catch (e: Error) { + return Failure(createErrorFromThrowable(ERROR_UNEXPECTED_ERROR, e)) + } + } +} diff --git a/android/src/main/java/io/deckers/blob_courier/send/SenderParameterFactory.kt b/android/src/main/java/io/deckers/blob_courier/send/SenderParameterFactory.kt new file mode 100644 index 00000000..58dd5419 --- /dev/null +++ b/android/src/main/java/io/deckers/blob_courier/send/SenderParameterFactory.kt @@ -0,0 +1,119 @@ +/** + * Copyright (c) Ely Deckers. + * + * This source code is licensed under the MPL-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ +package io.deckers.blob_courier.send + +import android.content.ContentResolver +import android.net.Uri +import com.facebook.react.bridge.ReadableMap +import io.deckers.blob_courier.common.DEFAULT_MIME_TYPE +import io.deckers.blob_courier.common.DEFAULT_PROGRESS_TIMEOUT_MILLISECONDS +import io.deckers.blob_courier.common.DEFAULT_UPLOAD_METHOD +import io.deckers.blob_courier.common.InputStreamRequestBody +import io.deckers.blob_courier.common.PARAMETER_ABSOLUTE_FILE_PATH +import io.deckers.blob_courier.common.PARAMETER_HEADERS +import io.deckers.blob_courier.common.PARAMETER_METHOD +import io.deckers.blob_courier.common.PARAMETER_MIME_TYPE +import io.deckers.blob_courier.common.PARAMETER_SETTINGS_PROGRESS_INTERVAL +import io.deckers.blob_courier.common.PARAMETER_TASK_ID +import io.deckers.blob_courier.common.PARAMETER_URL +import io.deckers.blob_courier.common.PROVIDED_PARAMETERS +import io.deckers.blob_courier.common.ValidationResult +import io.deckers.blob_courier.common.ValidationSuccess +import io.deckers.blob_courier.common.filterHeaders +import io.deckers.blob_courier.common.getMapInt +import io.deckers.blob_courier.common.hasRequiredStringField +import io.deckers.blob_courier.common.ifNone +import io.deckers.blob_courier.common.isNotNull +import io.deckers.blob_courier.common.maybe +import io.deckers.blob_courier.common.right +import io.deckers.blob_courier.common.testKeep +import io.deckers.blob_courier.common.validationContext +import io.deckers.blob_courier.upload.FilePayload +import io.deckers.blob_courier.upload.StringPayload +import io.deckers.blob_courier.upload.UploaderParameters +import okhttp3.MediaType +import okhttp3.MultipartBody +import okhttp3.RequestBody +import java.net.URL + +private const val PARAMETER_RETURN_RESPONSE = "returnResponse" + +data class RequiredParameters( + val taskId: String, + val url: String, + val absoluteFilePath: String, +) + +data class SenderParameters( + val absoluteFilePath: Uri, + val mediaType: String, + val method: String, + val headers: Map, + val progressInterval: Int, + val returnResponse: Boolean, + val taskId: String, + val uri: URL, +) + +fun SenderParameters.toRequestBody(contentResolver: ContentResolver): RequestBody = + InputStreamRequestBody( + mediaType.let(MediaType::parse) + ?: MediaType.get(DEFAULT_MIME_TYPE), + contentResolver, + absoluteFilePath + ) + +private fun verifyRequiredParametersProvided(input: ReadableMap): + ValidationResult = + validationContext(input, isNotNull(PROVIDED_PARAMETERS)) + .fmap(testKeep(hasRequiredStringField(PARAMETER_ABSOLUTE_FILE_PATH))) + .fmap(testKeep(hasRequiredStringField(PARAMETER_TASK_ID))) + .fmap(testKeep(hasRequiredStringField(PARAMETER_URL))) + .fmap { (_, validatedParameters) -> + val (url, rest) = validatedParameters + val (taskId, rest2) = rest + val (absoluteFilePath, _) = rest2 + + ValidationSuccess(RequiredParameters(taskId, url, absoluteFilePath)) + } + +class SenderParameterFactory { + fun fromInput(input: ReadableMap): ValidationResult = + verifyRequiredParametersProvided(input) + .fmap { + val (taskId, url, absoluteFilePath) = it + + val mediaType = maybe(input.getString(PARAMETER_MIME_TYPE)).ifNone(DEFAULT_MIME_TYPE) + val method = maybe(input.getString(PARAMETER_METHOD)).ifNone(DEFAULT_UPLOAD_METHOD) + + val unfilteredHeaders = + input.getMap(PARAMETER_HEADERS)?.toHashMap() ?: emptyMap() + + val headers = filterHeaders(unfilteredHeaders) + + val returnResponse = + input.hasKey(PARAMETER_RETURN_RESPONSE) && input.getBoolean(PARAMETER_RETURN_RESPONSE) + + val progressInterval = + getMapInt( + input, + PARAMETER_SETTINGS_PROGRESS_INTERVAL, + DEFAULT_PROGRESS_TIMEOUT_MILLISECONDS + ) + + right(SenderParameters( + Uri.parse(absoluteFilePath), + mediaType, + method, + headers, + progressInterval, + returnResponse, + taskId, + URL(url), + )) + } +} diff --git a/android/src/main/java/io/deckers/blob_courier/upload/UploaderParameterFactory.kt b/android/src/main/java/io/deckers/blob_courier/upload/UploaderParameterFactory.kt index d41e282f..d51baf8b 100644 --- a/android/src/main/java/io/deckers/blob_courier/upload/UploaderParameterFactory.kt +++ b/android/src/main/java/io/deckers/blob_courier/upload/UploaderParameterFactory.kt @@ -15,6 +15,7 @@ import io.deckers.blob_courier.common.DEFAULT_MIME_TYPE import io.deckers.blob_courier.common.DEFAULT_PROGRESS_TIMEOUT_MILLISECONDS import io.deckers.blob_courier.common.DEFAULT_UPLOAD_METHOD import io.deckers.blob_courier.common.Either +import io.deckers.blob_courier.common.InputStreamRequestBody import io.deckers.blob_courier.common.PARAMETER_ABSOLUTE_FILE_PATH import io.deckers.blob_courier.common.PARAMETER_FILENAME import io.deckers.blob_courier.common.PARAMETER_HEADERS diff --git a/android/src/test/java/io/deckers/blob_courier/BlobCourierModuleTests.kt b/android/src/test/java/io/deckers/blob_courier/BlobCourierModuleTests.kt index 113741bf..94db2c34 100644 --- a/android/src/test/java/io/deckers/blob_courier/BlobCourierModuleTests.kt +++ b/android/src/test/java/io/deckers/blob_courier/BlobCourierModuleTests.kt @@ -31,7 +31,7 @@ import io.deckers.blob_courier.common.isNotNull import io.deckers.blob_courier.common.isNotNullOrEmptyString import io.deckers.blob_courier.common.validate import io.deckers.blob_courier.react.toReactMap -import io.deckers.blob_courier.upload.InputStreamRequestBody +import io.deckers.blob_courier.common.InputStreamRequestBody import io.deckers.blob_courier.upload.UploaderParameterFactory import io.deckers.blob_courier.upload.toMultipartBody import io.mockk.every diff --git a/src/ExposedTypes.tsx b/src/ExposedTypes.tsx index 63694afc..4347abb2 100644 --- a/src/ExposedTypes.tsx +++ b/src/ExposedTypes.tsx @@ -104,6 +104,14 @@ export declare interface BlobUploadRequest readonly multipartName?: string; } +export declare interface BlobSendRequest + extends BlobBaseRequest, + BlobRequestMimeType, + BlobRequestMethod, + BlobRequestReturnResponse { + readonly absoluteFilePath: string; +} + export declare interface BlobMultipartBaseRequest extends BlobBaseRequest, BlobRequestMethod, @@ -162,4 +170,6 @@ export type BlobFetchInput = BlobFetchRequest & AndroidFetchSettings & IOSFetchSettings; +export type BlobSendInput = BlobSendRequest & BlobRequestSettings; + export type BlobUploadInput = BlobUploadRequest & BlobRequestSettings; diff --git a/src/index.tsx b/src/index.tsx index 053712ed..792d8f0c 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -28,6 +28,7 @@ import type { BlobMultipartWithName, BlobFetchInput, BlobUploadInput, + BlobSendInput, } from './ExposedTypes'; import { convertMappedMultipartsWithSymbolizedKeysToArray, @@ -40,6 +41,8 @@ type BlobCancelNativeInput = BlobRequestTask; type BlobFetchNativeInput = BlobFetchInput & BlobRequestTask; +type BlobSendNativeInput = BlobSendInput & BlobRequestTask; + type BlobUploadNativeInput = BlobUploadInput & BlobRequestTask; type BlobUploadMultipartInput = BlobMultipartMapUploadRequest & @@ -56,6 +59,7 @@ type BlobUploadMultipartNativeInput = BlobMultipartArrayUploadRequest & type BlobCourierType = { cancelRequest(input: BlobCancelNativeInput): Promise<{}>; fetchBlob(input: BlobFetchNativeInput): Promise; + sendBlob(input: BlobSendNativeInput): Promise; uploadBlob( input: BlobUploadMultipartNativeInput ): Promise; @@ -167,6 +171,42 @@ const sanitizeMultipartUploadData = ( }; }; +const sanitizeSendData = ( + input: Readonly +): BlobSendNativeInput => { + const { + absoluteFilePath, + headers, + method, + mimeType, + returnResponse, + url, + } = input; + + const { taskId } = input; + + const settings = sanitizeSettingsData(input); + + const request = { + absoluteFilePath, + mimeType, + url, + }; + + const optionalRequestParameters = dict({ + headers, + method, + returnResponse, + }).fallback(BLOB_MULTIPART_UPLOAD_FALLBACK_PARAMETERS); + + return { + ...settings, + ...optionalRequestParameters, + ...request, + taskId, + }; +}; + const wrapAbortListener = async ( taskId: string, wrappedFn: () => Promise, @@ -256,6 +296,10 @@ const uploadParts = ( input.signal ); +const sendBlob = (input: Readonly) => { + return (BlobCourier as BlobCourierType).sendBlob(sanitizeSendData(input)); +}; + const uploadBlob = (input: Readonly) => { const { absoluteFilePath, filename, mimeType, multipartName } = input; @@ -286,6 +330,13 @@ const onProgress = ( onProgress: fn, taskId, }), + sendBlob: (input: BlobSendInput) => + sendBlob({ + ...input, + ...requestSettings, + onProgress: fn, + taskId, + }), uploadBlob: (input: BlobUploadRequest) => uploadBlob({ ...input, @@ -335,6 +386,12 @@ const settings = (taskId: string, requestSettings: BlobRequestSettings) => ({ }), onProgress: (fn: (e: BlobProgressEvent) => void) => onProgress(taskId, fn, requestSettings), + sendBlob: (input: BlobSendInput) => + sendBlob({ + ...input, + ...requestSettings, + taskId, + }), uploadBlob: (input: BlobUploadRequest) => uploadBlob({ ...input, @@ -362,6 +419,8 @@ export default { fetchBlob({ ...input, taskId: createTaskId() }), onProgress: (fn: (e: BlobProgressEvent) => void) => onProgress(createTaskId(), fn), + sendBlob: (input: BlobSendInput) => + sendBlob({ ...input, taskId: createTaskId() }), settings: (input: BlobRequestSettings) => settings(createTaskId(), input), uploadBlob: (input: BlobUploadInput) => uploadBlob({ ...input, taskId: createTaskId() }),