Skip to content

Commit a9fd3ad

Browse files
committed
feat: emit download progress events
See #13
1 parent 784645e commit a9fd3ad

11 files changed

+408
-159
lines changed

android/src/main/java/com/alpha0010/fs/FileAccessModule.kt

+17-90
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,16 @@ import android.os.StatFs
77
import android.provider.MediaStore
88
import android.util.Base64
99
import com.facebook.react.bridge.*
10-
import com.facebook.react.modules.network.OkHttpClientProvider
1110
import kotlinx.coroutines.CoroutineScope
1211
import kotlinx.coroutines.Dispatchers
1312
import kotlinx.coroutines.launch
14-
import okhttp3.*
15-
import okhttp3.Callback
1613
import java.io.File
1714
import java.io.FileOutputStream
18-
import java.io.IOException
1915
import java.io.InputStream
2016
import java.security.MessageDigest
2117

22-
class FileAccessModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
18+
class FileAccessModule(reactContext: ReactApplicationContext) :
19+
ReactContextBaseJavaModule(reactContext) {
2320
private val ioScope = CoroutineScope(Dispatchers.IO)
2421

2522
override fun getName(): String {
@@ -214,67 +211,8 @@ class FileAccessModule(reactContext: ReactApplicationContext) : ReactContextBase
214211
}
215212

216213
@ReactMethod
217-
fun fetch(resource: String, init: ReadableMap, promise: Promise) {
218-
val request = try {
219-
// Request will be saved to a file, no reason to also save in cache.
220-
val builder = Request.Builder()
221-
.url(resource)
222-
.cacheControl(CacheControl.Builder().noStore().build())
223-
224-
if (init.hasKey("method")) {
225-
if (init.hasKey("body")) {
226-
builder.method(
227-
init.getString("method")!!,
228-
RequestBody.create(null, init.getString("body")!!)
229-
)
230-
} else {
231-
builder.method(init.getString("method")!!, null)
232-
}
233-
}
234-
235-
if (init.hasKey("headers")) {
236-
for (header in init.getMap("headers")!!.entryIterator) {
237-
builder.header(header.key, header.value as String)
238-
}
239-
}
240-
241-
builder.build()
242-
} catch (e: Throwable) {
243-
promise.reject(e)
244-
return
245-
}
246-
247-
// Share client with RN core library.
248-
val call = OkHttpClientProvider.getOkHttpClient().newCall(request)
249-
call.enqueue(object : Callback {
250-
override fun onFailure(call: Call, e: IOException) {
251-
promise.reject(e)
252-
}
253-
254-
override fun onResponse(call: Call, response: Response) {
255-
try {
256-
response.use {
257-
if (init.hasKey("path")) {
258-
parsePathToFile(init.getString("path")!!)
259-
.outputStream()
260-
.use { response.body()!!.byteStream().copyTo(it) }
261-
}
262-
263-
val headers = response.headers().names().map { it to response.header(it) }
264-
promise.resolve(Arguments.makeNativeMap(mapOf(
265-
"headers" to Arguments.makeNativeMap(headers.toMap()),
266-
"ok" to response.isSuccessful,
267-
"redirected" to response.isRedirect,
268-
"status" to response.code(),
269-
"statusText" to response.message(),
270-
"url" to response.request().url().toString()
271-
)))
272-
}
273-
} catch (e: Throwable) {
274-
promise.reject(e)
275-
}
276-
}
277-
})
214+
fun fetch(requestId: Int, resource: String, init: ReadableMap) {
215+
NetworkHandler(reactApplicationContext).fetch(requestId, resource, init)
278216
}
279217

280218
@ReactMethod
@@ -348,7 +286,8 @@ class FileAccessModule(reactContext: ReactApplicationContext) : ReactContextBase
348286
ioScope.launch {
349287
try {
350288
if (!parsePathToFile(source).renameTo(parsePathToFile(target))) {
351-
parsePathToFile(source).also { it.copyTo(parsePathToFile(target), overwrite = true) }.delete()
289+
parsePathToFile(source).also { it.copyTo(parsePathToFile(target), overwrite = true) }
290+
.delete()
352291
}
353292
promise.resolve(null)
354293
} catch (e: Throwable) {
@@ -376,13 +315,17 @@ class FileAccessModule(reactContext: ReactApplicationContext) : ReactContextBase
376315
try {
377316
val file = parsePathToFile(path)
378317
if (file.exists()) {
379-
promise.resolve(Arguments.makeNativeMap(mapOf(
380-
"filename" to file.name,
381-
"lastModified" to file.lastModified(),
382-
"path" to file.path,
383-
"size" to file.length(),
384-
"type" to if (file.isDirectory) "directory" else "file",
385-
)))
318+
promise.resolve(
319+
Arguments.makeNativeMap(
320+
mapOf(
321+
"filename" to file.name,
322+
"lastModified" to file.lastModified(),
323+
"path" to file.path,
324+
"size" to file.length(),
325+
"type" to if (file.isDirectory) "directory" else "file",
326+
)
327+
)
328+
)
386329
} else {
387330
promise.reject("ENOENT", "'$path' does not exist.")
388331
}
@@ -432,20 +375,4 @@ class FileAccessModule(reactContext: ReactApplicationContext) : ReactContextBase
432375
parsePathToFile(path).inputStream()
433376
}
434377
}
435-
436-
/**
437-
* Return a File object and do some basic sanitization of the passed path.
438-
*/
439-
private fun parsePathToFile(path: String): File {
440-
return if (path.contains("://")) {
441-
try {
442-
val pathUri = Uri.parse(path)
443-
File(pathUri.path!!)
444-
} catch (e: Throwable) {
445-
File(path)
446-
}
447-
} else {
448-
File(path)
449-
}
450-
}
451378
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
package com.alpha0010.fs
2+
3+
import com.facebook.react.bridge.Arguments
4+
import com.facebook.react.bridge.ReactContext
5+
import com.facebook.react.bridge.ReadableMap
6+
import com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter
7+
import com.facebook.react.modules.network.OkHttpClientProvider
8+
import okhttp3.*
9+
import java.io.IOException
10+
11+
const val FETCH_EVENT = "FetchEvent"
12+
13+
class NetworkHandler(reactContext: ReactContext) {
14+
private val emitter = reactContext.getJSModule(RCTDeviceEventEmitter::class.java)
15+
16+
fun fetch(requestId: Int, resource: String, init: ReadableMap) {
17+
val request = try {
18+
buildRequest(resource, init)
19+
} catch (e: Throwable) {
20+
onFetchError(requestId, e)
21+
return
22+
}
23+
24+
// Share client with RN core library.
25+
val call = getClient { bytesRead, contentLength, done ->
26+
emitter.emit(
27+
FETCH_EVENT, Arguments.makeNativeMap(
28+
mapOf(
29+
"requestId" to requestId,
30+
"state" to "progress",
31+
"bytesRead" to bytesRead,
32+
"contentLength" to contentLength,
33+
"done" to done
34+
)
35+
)
36+
)
37+
}.newCall(request)
38+
call.enqueue(object : Callback {
39+
override fun onFailure(call: Call, e: IOException) {
40+
onFetchError(requestId, e)
41+
}
42+
43+
override fun onResponse(call: Call, response: Response) {
44+
try {
45+
response.use {
46+
if (init.hasKey("path")) {
47+
parsePathToFile(init.getString("path")!!)
48+
.outputStream()
49+
.use { response.body()!!.byteStream().copyTo(it) }
50+
}
51+
52+
val headers = response.headers().names().map { it to response.header(it) }
53+
emitter.emit(
54+
FETCH_EVENT, Arguments.makeNativeMap(
55+
mapOf(
56+
"requestId" to requestId,
57+
"state" to "complete",
58+
"headers" to Arguments.makeNativeMap(headers.toMap()),
59+
"ok" to response.isSuccessful,
60+
"redirected" to response.isRedirect,
61+
"status" to response.code(),
62+
"statusText" to response.message(),
63+
"url" to response.request().url().toString()
64+
)
65+
)
66+
)
67+
}
68+
} catch (e: Throwable) {
69+
onFetchError(requestId, e)
70+
}
71+
}
72+
})
73+
}
74+
75+
private fun buildRequest(resource: String, init: ReadableMap): Request {
76+
// Request will be saved to a file, no reason to also save in cache.
77+
val builder = Request.Builder()
78+
.url(resource)
79+
.cacheControl(CacheControl.Builder().noStore().build())
80+
81+
if (init.hasKey("method")) {
82+
if (init.hasKey("body")) {
83+
builder.method(
84+
init.getString("method")!!,
85+
RequestBody.create(null, init.getString("body")!!)
86+
)
87+
} else {
88+
builder.method(init.getString("method")!!, null)
89+
}
90+
}
91+
92+
if (init.hasKey("headers")) {
93+
for (header in init.getMap("headers")!!.entryIterator) {
94+
builder.header(header.key, header.value as String)
95+
}
96+
}
97+
98+
return builder.build()
99+
}
100+
101+
private fun getClient(listener: ProgressListener): OkHttpClient {
102+
return OkHttpClientProvider
103+
.getOkHttpClient()
104+
.newBuilder()
105+
.addNetworkInterceptor { chain ->
106+
val originalResponse = chain.proceed(chain.request())
107+
originalResponse.body()
108+
?.let { originalResponse.newBuilder().body(ProgressResponseBody(it, listener)).build() }
109+
?: originalResponse
110+
}
111+
.build()
112+
}
113+
114+
private fun onFetchError(requestId: Int, e: Throwable) {
115+
emitter.emit(
116+
FETCH_EVENT, Arguments.makeNativeMap(
117+
mapOf(
118+
"requestId" to requestId,
119+
"state" to "error",
120+
"message" to e.localizedMessage
121+
)
122+
)
123+
)
124+
}
125+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package com.alpha0010.fs
2+
3+
import okhttp3.ResponseBody
4+
import okio.Buffer
5+
import okio.BufferedSource
6+
import okio.ForwardingSource
7+
import okio.Okio
8+
9+
typealias ProgressListener = (bytesRead: Long, contentLength: Long, done: Boolean) -> Unit
10+
11+
const val MIN_EVENT_INTERVAL = 150L
12+
13+
class ProgressResponseBody(
14+
private val responseBody: ResponseBody,
15+
private val listener: ProgressListener
16+
) : ResponseBody() {
17+
private var bufferedSource: BufferedSource? = null
18+
private var lastEventTime = 0L
19+
20+
override fun contentType() = responseBody.contentType()
21+
22+
override fun contentLength() = responseBody.contentLength()
23+
24+
override fun source(): BufferedSource {
25+
return bufferedSource ?: Okio.buffer(object : ForwardingSource(responseBody.source()) {
26+
var totalBytesRead = 0L
27+
28+
override fun read(sink: Buffer, byteCount: Long): Long {
29+
val bytesRead = super.read(sink, byteCount)
30+
val isDone = bytesRead == -1L
31+
totalBytesRead += if (isDone) 0 else bytesRead
32+
33+
val currentTime = System.currentTimeMillis()
34+
if (currentTime - lastEventTime > MIN_EVENT_INTERVAL || isDone) {
35+
lastEventTime = currentTime
36+
listener(totalBytesRead, contentLength(), isDone)
37+
}
38+
39+
return bytesRead
40+
}
41+
}).also { bufferedSource = it }
42+
}
43+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package com.alpha0010.fs
2+
3+
import android.net.Uri
4+
import java.io.File
5+
6+
/**
7+
* Return a File object and do some basic sanitization of the passed path.
8+
*/
9+
fun parsePathToFile(path: String): File {
10+
return if (path.contains("://")) {
11+
try {
12+
val pathUri = Uri.parse(path)
13+
File(pathUri.path!!)
14+
} catch (e: Throwable) {
15+
File(path)
16+
}
17+
} else {
18+
File(path)
19+
}
20+
}

ios/FileAccess-Bridging-Header.h

+1
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
#import <React/RCTBridgeModule.h>
2+
#import <React/RCTEventEmitter.h>

ios/FileAccess.m

+3-4
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,9 @@ @interface RCT_EXTERN_REMAP_MODULE(RNFileAccess, FileAccess, NSObject)
3232
withResolver:(RCTPromiseResolveBlock)resolve
3333
withRejecter:(RCTPromiseRejectBlock)reject)
3434

35-
RCT_EXTERN_METHOD(fetch:(NSString *)resource
36-
withConfig:(NSDictionary *)config
37-
withResolver:(RCTPromiseResolveBlock)resolve
38-
withRejecter:(RCTPromiseRejectBlock)reject)
35+
RCT_EXTERN_METHOD(fetch:(nonnull NSNumber *)requestId
36+
withResource:(NSString *)resource
37+
withConfig:(NSDictionary *)config)
3938

4039
RCT_EXTERN_METHOD(getAppGroupDir:(NSString *)groupName
4140
withResolver:(RCTPromiseResolveBlock)resolve

0 commit comments

Comments
 (0)