Skip to content

Commit bf431f8

Browse files
authored
feat: add request cancellation (#123)
1 parent a8a7321 commit bf431f8

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

43 files changed

+1626
-778
lines changed

README.md

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,34 @@ Object.getOwnPropertySymbols({
256256

257257
```
258258

259+
### Cancel request
260+
261+
```tsx
262+
import BlobCourier from 'react-native-blob-courier';
263+
264+
// ...
265+
266+
const abortController = new AbortController();
267+
268+
const { signal } = abortController;
269+
270+
const request0 = {
271+
// ...
272+
signal,
273+
};
274+
275+
try {
276+
BlobCourier.fetchBlob(request0);
277+
278+
abortController.abort();
279+
} catch (e) {
280+
if (e.code === ERROR_CANCELED_EXCEPTION) {
281+
// ...
282+
}
283+
}
284+
285+
// ...
286+
```
259287

260288
## Fluent interface
261289

@@ -298,10 +326,11 @@ Optional
298326
| ------------ | -------------------------------- | ----------------------------------------- | ---------------------------------------------------- |
299327
| `android` | `AndroidSettings` | Settings to be used on Android | `{ downloadManager: {}, target: 'cache', useDownloadManager: false }` |
300328
| `headers` | `{ [key: string]: string }` | Map of headers to send with the request | `{}` |
301-
| `ios` | `IOSSettings` | Settings to be used on iOS | `{ target: 'cache' }` |
329+
| `ios` | `IOSSettings` | Settings to be used on iOS | `{ target: 'cache' }` |
302330
| `headers` | `{ [key: string]: string }` | Map of headers to send with the request | `{}` |
303331
| `method` | `string` | Representing the HTTP method | `GET` |
304332
| `onProgress` | `(e: BlobProgressEvent) => void` | Function handling progress updates | `() => { }` |
333+
| `signal` | `AbortSignal` | Request cancellation manager | `null` |
305334

306335
Response
307336

@@ -354,6 +383,7 @@ Optional
354383
| `multipartName` | `string` | Name for the file multipart | `"file"` |
355384
| `onProgress` | `(e: BlobProgressEvent) => void` | Function handling progress updates | `() => { }` |
356385
| `returnResponse` | `boolean` | Return the HTTP response body? | `false` |
386+
| `signal` | `AbortSignal` | Request cancellation manager | `null` |
357387

358388
### `uploadParts(input: BlobMultipartUploadRequest)`
359389

@@ -369,9 +399,10 @@ Optional
369399
| **Field** | **Type** | **Description** | **Default** |
370400
| ---------------- | -------------------------------- | ----------------------------------------- | ----------- |
371401
| `headers` | `{ [key: string]: string }` | Map of headers to send with the request | `{}` |
372-
| `method` | `string` | The HTTP method to be used in the request | `"POST"` |
402+
| `method` | `string` | The HTTP method to be used in the request | `"POST"` |
373403
| `onProgress` | `(e: BlobProgressEvent) => void` | Function handling progress updates | `() => { }` |
374404
| `returnResponse` | `boolean` | Return the HTTP response body? | `false` |
405+
| `signal` | `AbortSignal` | Request cancellation manager | `null` |
375406

376407
Response
377408

android/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@ dependencies {
156156
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
157157
implementation 'com.android.support:multidex:1.0.3'
158158
implementation "com.squareup.okhttp3:okhttp:3.14.9"
159+
implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.0.0'
159160
testImplementation 'androidx.test:core:1.3.0'
160161
testImplementation 'junit:junit:4.13.1'
161162
testImplementation "io.mockk:mockk:1.10.2"

android/src/androidTest/java/io/deckers/blob_courier/BlobCourierInstrumentedModuleTests.kt

Lines changed: 61 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
* This source code is licensed under the MPL-2.0 license found in the
55
* LICENSE file in the root directory of this source tree.
66
*/
7+
78
import androidx.test.core.app.ApplicationProvider
89
import androidx.test.platform.app.InstrumentationRegistry
910
import com.facebook.react.bridge.Arguments
@@ -13,29 +14,39 @@ import com.facebook.react.bridge.ReactApplicationContext
1314
import io.deckers.blob_courier.BuildConfig.ADB_COMMAND_TIMEOUT_MILLISECONDS
1415
import io.deckers.blob_courier.BuildConfig.PROMISE_TIMEOUT_MILLISECONDS
1516
import io.deckers.blob_courier.Fixtures
17+
import io.deckers.blob_courier.Fixtures.createSparseFile
1618
import io.deckers.blob_courier.Fixtures.createValidTestFetchParameterMap
19+
import io.deckers.blob_courier.Fixtures.createValidUploadTestParameterMap
20+
import io.deckers.blob_courier.Fixtures.runCancelFetchBlobSuspend
21+
import io.deckers.blob_courier.Fixtures.runCancelUploadBlobSuspend
1722
import io.deckers.blob_courier.Fixtures.runFetchBlobSuspend
23+
import io.deckers.blob_courier.TestUtils
1824
import io.deckers.blob_courier.TestUtils.assertRequestFalse
1925
import io.deckers.blob_courier.TestUtils.assertRequestTrue
2026
import io.deckers.blob_courier.TestUtils.circumventHiddenApiExemptionsForMockk
2127
import io.deckers.blob_courier.TestUtils.runInstrumentedRequestToBoolean
2228
import io.deckers.blob_courier.common.DOWNLOAD_TYPE_MANAGED
29+
import io.deckers.blob_courier.common.ERROR_CANCELED_EXCEPTION
2330
import io.deckers.blob_courier.common.Logger
2431
import io.deckers.blob_courier.common.MANAGED_DOWNLOAD_SUCCESS
32+
import io.deckers.blob_courier.common.fold
33+
import io.deckers.blob_courier.common.ifLeft
2534
import io.deckers.blob_courier.common.left
2635
import io.deckers.blob_courier.common.right
2736
import io.deckers.blob_courier.react.toReactMap
2837
import io.mockk.every
2938
import io.mockk.mockkStatic
30-
import java.util.UUID
3139
import kotlinx.coroutines.Dispatchers
3240
import kotlinx.coroutines.TimeoutCancellationException
3341
import kotlinx.coroutines.delay
3442
import kotlinx.coroutines.runBlocking
3543
import kotlinx.coroutines.withContext
3644
import kotlinx.coroutines.withTimeout
45+
import org.junit.Assert
3746
import org.junit.Before
3847
import org.junit.Test
48+
import java.util.UUID
49+
3950

4051
private val TAG = BlobCourierInstrumentedModuleTests::class.java.name
4152

@@ -194,7 +205,9 @@ class BlobCourierInstrumentedModuleTests {
194205
val receivedType = result.getString("type") ?: ""
195206
val check = receivedType == DOWNLOAD_TYPE_MANAGED
196207

197-
if (check) right(result) else left("Received incorrect type `$receivedType`")
208+
if (check)
209+
right(result)
210+
else left(Fixtures.TestPromiseError(message = "Received incorrect type `$receivedType`"))
198211
}
199212
}
200213

@@ -221,21 +234,64 @@ class BlobCourierInstrumentedModuleTests {
221234
val receivedResult = result.getMap("data")?.getString("result") ?: ""
222235
val check = receivedResult == MANAGED_DOWNLOAD_SUCCESS
223236

224-
if (check) right(result) else left("Received incorrect result `$receivedResult`")
237+
if (check)
238+
right(result)
239+
else left(Fixtures.TestPromiseError(message = "Received incorrect result `$receivedResult`"))
225240
}
226241
}
227242

228243
assertRequestTrue(message, succeeded)
229244
}
230245

246+
@Test
247+
fun cancel_successful_fetch_request_resolves_promise() = runBlocking {
248+
val allRequiredParametersMap =
249+
createValidTestFetchParameterMap().plus("url" to Fixtures.LARGE_FILE).toReactMap()
250+
251+
val ctx = ReactApplicationContext(ApplicationProvider.getApplicationContext())
252+
253+
val errorOrResult =
254+
TestUtils.runRequest(
255+
{ runCancelFetchBlobSuspend(ctx, allRequiredParametersMap) },
256+
ADB_COMMAND_TIMEOUT_MILLISECONDS)
257+
258+
val isError = errorOrResult.map { false }.ifLeft(true)
259+
val code = errorOrResult.fold({ e -> e.code }, { _ -> "INVALID" })
260+
261+
Assert.assertTrue(isError)
262+
Assert.assertEquals(ERROR_CANCELED_EXCEPTION, code)
263+
}
264+
265+
@Test
266+
fun cancel_successful_upload_request_resolves_promise() = runBlocking {
267+
val file = createSparseFile(100 * 1024 * 1024)
268+
269+
val taskId = UUID.randomUUID().toString()
270+
271+
val allRequiredParametersMap =
272+
createValidUploadTestParameterMap(taskId, file.absolutePath)
273+
274+
val ctx = ReactApplicationContext(ApplicationProvider.getApplicationContext())
275+
276+
val errorOrResult = TestUtils.runRequest(
277+
{ runCancelUploadBlobSuspend(ctx, allRequiredParametersMap.toReactMap()) },
278+
60_000)
279+
280+
val isError = errorOrResult.map { false }.ifLeft(true)
281+
val code = errorOrResult.fold({ e -> e.code }, { _ -> "INVALID" })
282+
283+
Assert.assertTrue(isError)
284+
Assert.assertEquals(ERROR_CANCELED_EXCEPTION, code)
285+
}
286+
231287
@Test
232288
fun uploading_a_file_from_outside_app_data_directory_resolves_promise() = runBlocking {
233289
val someFileThatIsAlwaysAvailable = "file:///system/etc/fonts.xml"
234290

235291
val ctx = ReactApplicationContext(ApplicationProvider.getApplicationContext())
236292

237293
val uploadParametersMap =
238-
Fixtures.createValidUploadTestParameterMap(
294+
createValidUploadTestParameterMap(
239295
UUID.randomUUID().toString(),
240296
someFileThatIsAlwaysAvailable
241297
)
@@ -254,8 +310,7 @@ class BlobCourierInstrumentedModuleTests {
254310
val irrelevantTaskId = UUID.randomUUID().toString()
255311
val someNonExistentPath = "file:///this/path/does/not/exist.png"
256312
val allRequiredParametersMap =
257-
Fixtures.createValidUploadTestParameterMap(irrelevantTaskId, someNonExistentPath)
258-
313+
createValidUploadTestParameterMap(irrelevantTaskId, someNonExistentPath)
259314

260315
val ctx = ReactApplicationContext(ApplicationProvider.getApplicationContext())
261316

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
2-
package="io.deckers.blob_courier">
2+
package="io.deckers.blob_courier">
33

44
<uses-permission android:name="android.permission.INTERNET" />
55
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
66

7+
<application android:usesCleartextTraffic="true" />
8+
79
</manifest>

android/src/main/java/io/deckers/blob_courier/BlobCourierModule.kt

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import com.facebook.react.bridge.ReactContextBaseJavaModule
1212
import com.facebook.react.bridge.ReactMethod
1313
import com.facebook.react.bridge.ReadableMap
1414
import com.facebook.react.modules.network.OkHttpClientProvider
15+
import io.deckers.blob_courier.cancel.CancellationParameterFactory
16+
import io.deckers.blob_courier.cancel.RequestCanceller
1517
import io.deckers.blob_courier.common.DEFAULT_PROGRESS_TIMEOUT_MILLISECONDS
1618
import io.deckers.blob_courier.common.ERROR_UNEXPECTED_ERROR
1719
import io.deckers.blob_courier.common.ERROR_UNEXPECTED_EXCEPTION
@@ -51,6 +53,38 @@ class BlobCourierModule(private val reactContext: ReactApplicationContext) :
5153

5254
override fun getName(): String = LIBRARY_NAME
5355

56+
@ReactMethod
57+
fun cancelRequest(input: ReadableMap, promise: Promise) {
58+
li("Calling cancelRequest")
59+
60+
thread {
61+
try {
62+
val errorOrCancelResult =
63+
CancellationParameterFactory()
64+
.fromInput(input)
65+
.fold(::Failure, ::Success)
66+
.map { RequestCanceller(reactContext).cancel(it.taskId) }
67+
68+
errorOrCancelResult
69+
.fmap { Success(emptyMap<String, Any>().toReactMap()) }
70+
.`do`(
71+
{ e ->
72+
lv("Something went wrong during cancellation (code=${e.code},message=${e.message})")
73+
promise.reject(e.code, e.message)
74+
},
75+
promise::resolve)
76+
} catch (e: Exception) {
77+
le("Unexpected exception", e)
78+
promise.reject(ERROR_UNEXPECTED_EXCEPTION, processUnexpectedException(e).message)
79+
} catch (e: Error) {
80+
le("Unexpected error", e)
81+
promise.reject(ERROR_UNEXPECTED_ERROR, processUnexpectedError(e).message)
82+
}
83+
}
84+
85+
li("Called cancelRequest")
86+
}
87+
5488
@ReactMethod
5589
fun fetchBlob(input: ReadableMap, promise: Promise) {
5690
li("Calling fetchBlob")
@@ -64,7 +98,7 @@ class BlobCourierModule(private val reactContext: ReactApplicationContext) :
6498
BlobDownloader(
6599
reactContext,
66100
createHttpClient(),
67-
createProgressFactory(reactContext)
101+
createProgressFactory(reactContext),
68102
)::download
69103
)
70104

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/**
2+
* Copyright (c) Ely Deckers.
3+
*
4+
* This source code is licensed under the MPL-2.0 license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
package io.deckers.blob_courier.cancel
8+
9+
import android.content.BroadcastReceiver
10+
import android.content.Context
11+
import android.content.Intent
12+
import android.content.IntentFilter
13+
import androidx.localbroadcastmanager.content.LocalBroadcastManager
14+
import io.deckers.blob_courier.common.ACTION_CANCEL_REQUEST
15+
import io.deckers.blob_courier.common.Logger
16+
import okhttp3.Call
17+
18+
private const val TAG = "CancelController"
19+
20+
private val logger = Logger(TAG)
21+
private fun lv(m: String, e: Throwable? = null) = logger.v(m, e)
22+
23+
fun registerCancellationHandler(context: Context, taskId: String, call: Call) {
24+
lv("Registering $ACTION_CANCEL_REQUEST receiver")
25+
26+
LocalBroadcastManager.getInstance(context)
27+
.registerReceiver(object : BroadcastReceiver() {
28+
override fun onReceive(p0: Context?, intent: Intent?) {
29+
if (intent?.getStringExtra("taskId") != taskId) {
30+
return
31+
}
32+
33+
call.cancel()
34+
}
35+
}, IntentFilter(ACTION_CANCEL_REQUEST))
36+
37+
lv("Registered $ACTION_CANCEL_REQUEST receiver")
38+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/**
2+
* Copyright (c) Ely Deckers.
3+
*
4+
* This source code is licensed under the MPL-2.0 license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
package io.deckers.blob_courier.cancel
8+
9+
import com.facebook.react.bridge.ReadableMap
10+
import io.deckers.blob_courier.common.PARAMETER_TASK_ID
11+
import io.deckers.blob_courier.common.PROVIDED_PARAMETERS
12+
import io.deckers.blob_courier.common.ValidationResult
13+
import io.deckers.blob_courier.common.ValidationSuccess
14+
import io.deckers.blob_courier.common.hasRequiredStringField
15+
import io.deckers.blob_courier.common.isNotNull
16+
import io.deckers.blob_courier.common.right
17+
import io.deckers.blob_courier.common.testKeep
18+
import io.deckers.blob_courier.common.validationContext
19+
20+
data class RequiredParameters(
21+
val taskId: String,
22+
)
23+
24+
data class CancellationParameters(
25+
val taskId: String,
26+
)
27+
28+
private fun verifyRequiredParametersProvided(input: ReadableMap):
29+
ValidationResult<RequiredParameters> =
30+
validationContext(input, isNotNull(PROVIDED_PARAMETERS))
31+
.fmap(testKeep(hasRequiredStringField(PARAMETER_TASK_ID)))
32+
.fmap { (_, validatedParameters) ->
33+
val (taskId, _) = validatedParameters
34+
35+
ValidationSuccess(RequiredParameters(taskId))
36+
}
37+
38+
class CancellationParameterFactory {
39+
fun fromInput(input: ReadableMap): ValidationResult<CancellationParameters> =
40+
verifyRequiredParametersProvided(input)
41+
.fmap {
42+
val (taskId) = it
43+
44+
right(CancellationParameters(
45+
taskId
46+
))
47+
}
48+
}

0 commit comments

Comments
 (0)