Skip to content

Commit

Permalink
feat: emit download progress events
Browse files Browse the repository at this point in the history
See #13
  • Loading branch information
alpha0010 committed May 27, 2021
1 parent 784645e commit a9fd3ad
Show file tree
Hide file tree
Showing 11 changed files with 408 additions and 159 deletions.
107 changes: 17 additions & 90 deletions android/src/main/java/com/alpha0010/fs/FileAccessModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,16 @@ import android.os.StatFs
import android.provider.MediaStore
import android.util.Base64
import com.facebook.react.bridge.*
import com.facebook.react.modules.network.OkHttpClientProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import okhttp3.*
import okhttp3.Callback
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.io.InputStream
import java.security.MessageDigest

class FileAccessModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
class FileAccessModule(reactContext: ReactApplicationContext) :
ReactContextBaseJavaModule(reactContext) {
private val ioScope = CoroutineScope(Dispatchers.IO)

override fun getName(): String {
Expand Down Expand Up @@ -214,67 +211,8 @@ class FileAccessModule(reactContext: ReactApplicationContext) : ReactContextBase
}

@ReactMethod
fun fetch(resource: String, init: ReadableMap, promise: Promise) {
val request = try {
// Request will be saved to a file, no reason to also save in cache.
val builder = Request.Builder()
.url(resource)
.cacheControl(CacheControl.Builder().noStore().build())

if (init.hasKey("method")) {
if (init.hasKey("body")) {
builder.method(
init.getString("method")!!,
RequestBody.create(null, init.getString("body")!!)
)
} else {
builder.method(init.getString("method")!!, null)
}
}

if (init.hasKey("headers")) {
for (header in init.getMap("headers")!!.entryIterator) {
builder.header(header.key, header.value as String)
}
}

builder.build()
} catch (e: Throwable) {
promise.reject(e)
return
}

// Share client with RN core library.
val call = OkHttpClientProvider.getOkHttpClient().newCall(request)
call.enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
promise.reject(e)
}

override fun onResponse(call: Call, response: Response) {
try {
response.use {
if (init.hasKey("path")) {
parsePathToFile(init.getString("path")!!)
.outputStream()
.use { response.body()!!.byteStream().copyTo(it) }
}

val headers = response.headers().names().map { it to response.header(it) }
promise.resolve(Arguments.makeNativeMap(mapOf(
"headers" to Arguments.makeNativeMap(headers.toMap()),
"ok" to response.isSuccessful,
"redirected" to response.isRedirect,
"status" to response.code(),
"statusText" to response.message(),
"url" to response.request().url().toString()
)))
}
} catch (e: Throwable) {
promise.reject(e)
}
}
})
fun fetch(requestId: Int, resource: String, init: ReadableMap) {
NetworkHandler(reactApplicationContext).fetch(requestId, resource, init)
}

@ReactMethod
Expand Down Expand Up @@ -348,7 +286,8 @@ class FileAccessModule(reactContext: ReactApplicationContext) : ReactContextBase
ioScope.launch {
try {
if (!parsePathToFile(source).renameTo(parsePathToFile(target))) {
parsePathToFile(source).also { it.copyTo(parsePathToFile(target), overwrite = true) }.delete()
parsePathToFile(source).also { it.copyTo(parsePathToFile(target), overwrite = true) }
.delete()
}
promise.resolve(null)
} catch (e: Throwable) {
Expand Down Expand Up @@ -376,13 +315,17 @@ class FileAccessModule(reactContext: ReactApplicationContext) : ReactContextBase
try {
val file = parsePathToFile(path)
if (file.exists()) {
promise.resolve(Arguments.makeNativeMap(mapOf(
"filename" to file.name,
"lastModified" to file.lastModified(),
"path" to file.path,
"size" to file.length(),
"type" to if (file.isDirectory) "directory" else "file",
)))
promise.resolve(
Arguments.makeNativeMap(
mapOf(
"filename" to file.name,
"lastModified" to file.lastModified(),
"path" to file.path,
"size" to file.length(),
"type" to if (file.isDirectory) "directory" else "file",
)
)
)
} else {
promise.reject("ENOENT", "'$path' does not exist.")
}
Expand Down Expand Up @@ -432,20 +375,4 @@ class FileAccessModule(reactContext: ReactApplicationContext) : ReactContextBase
parsePathToFile(path).inputStream()
}
}

/**
* Return a File object and do some basic sanitization of the passed path.
*/
private fun parsePathToFile(path: String): File {
return if (path.contains("://")) {
try {
val pathUri = Uri.parse(path)
File(pathUri.path!!)
} catch (e: Throwable) {
File(path)
}
} else {
File(path)
}
}
}
125 changes: 125 additions & 0 deletions android/src/main/java/com/alpha0010/fs/NetworkHandler.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
package com.alpha0010.fs

import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.ReactContext
import com.facebook.react.bridge.ReadableMap
import com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter
import com.facebook.react.modules.network.OkHttpClientProvider
import okhttp3.*
import java.io.IOException

const val FETCH_EVENT = "FetchEvent"

class NetworkHandler(reactContext: ReactContext) {
private val emitter = reactContext.getJSModule(RCTDeviceEventEmitter::class.java)

fun fetch(requestId: Int, resource: String, init: ReadableMap) {
val request = try {
buildRequest(resource, init)
} catch (e: Throwable) {
onFetchError(requestId, e)
return
}

// Share client with RN core library.
val call = getClient { bytesRead, contentLength, done ->
emitter.emit(
FETCH_EVENT, Arguments.makeNativeMap(
mapOf(
"requestId" to requestId,
"state" to "progress",
"bytesRead" to bytesRead,
"contentLength" to contentLength,
"done" to done
)
)
)
}.newCall(request)
call.enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
onFetchError(requestId, e)
}

override fun onResponse(call: Call, response: Response) {
try {
response.use {
if (init.hasKey("path")) {
parsePathToFile(init.getString("path")!!)
.outputStream()
.use { response.body()!!.byteStream().copyTo(it) }
}

val headers = response.headers().names().map { it to response.header(it) }
emitter.emit(
FETCH_EVENT, Arguments.makeNativeMap(
mapOf(
"requestId" to requestId,
"state" to "complete",
"headers" to Arguments.makeNativeMap(headers.toMap()),
"ok" to response.isSuccessful,
"redirected" to response.isRedirect,
"status" to response.code(),
"statusText" to response.message(),
"url" to response.request().url().toString()
)
)
)
}
} catch (e: Throwable) {
onFetchError(requestId, e)
}
}
})
}

private fun buildRequest(resource: String, init: ReadableMap): Request {
// Request will be saved to a file, no reason to also save in cache.
val builder = Request.Builder()
.url(resource)
.cacheControl(CacheControl.Builder().noStore().build())

if (init.hasKey("method")) {
if (init.hasKey("body")) {
builder.method(
init.getString("method")!!,
RequestBody.create(null, init.getString("body")!!)
)
} else {
builder.method(init.getString("method")!!, null)
}
}

if (init.hasKey("headers")) {
for (header in init.getMap("headers")!!.entryIterator) {
builder.header(header.key, header.value as String)
}
}

return builder.build()
}

private fun getClient(listener: ProgressListener): OkHttpClient {
return OkHttpClientProvider
.getOkHttpClient()
.newBuilder()
.addNetworkInterceptor { chain ->
val originalResponse = chain.proceed(chain.request())
originalResponse.body()
?.let { originalResponse.newBuilder().body(ProgressResponseBody(it, listener)).build() }
?: originalResponse
}
.build()
}

private fun onFetchError(requestId: Int, e: Throwable) {
emitter.emit(
FETCH_EVENT, Arguments.makeNativeMap(
mapOf(
"requestId" to requestId,
"state" to "error",
"message" to e.localizedMessage
)
)
)
}
}
43 changes: 43 additions & 0 deletions android/src/main/java/com/alpha0010/fs/ProgressResponseBody.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package com.alpha0010.fs

import okhttp3.ResponseBody
import okio.Buffer
import okio.BufferedSource
import okio.ForwardingSource
import okio.Okio

typealias ProgressListener = (bytesRead: Long, contentLength: Long, done: Boolean) -> Unit

const val MIN_EVENT_INTERVAL = 150L

class ProgressResponseBody(
private val responseBody: ResponseBody,
private val listener: ProgressListener
) : ResponseBody() {
private var bufferedSource: BufferedSource? = null
private var lastEventTime = 0L

override fun contentType() = responseBody.contentType()

override fun contentLength() = responseBody.contentLength()

override fun source(): BufferedSource {
return bufferedSource ?: Okio.buffer(object : ForwardingSource(responseBody.source()) {
var totalBytesRead = 0L

override fun read(sink: Buffer, byteCount: Long): Long {
val bytesRead = super.read(sink, byteCount)
val isDone = bytesRead == -1L
totalBytesRead += if (isDone) 0 else bytesRead

val currentTime = System.currentTimeMillis()
if (currentTime - lastEventTime > MIN_EVENT_INTERVAL || isDone) {
lastEventTime = currentTime
listener(totalBytesRead, contentLength(), isDone)
}

return bytesRead
}
}).also { bufferedSource = it }
}
}
20 changes: 20 additions & 0 deletions android/src/main/java/com/alpha0010/fs/Util.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.alpha0010.fs

import android.net.Uri
import java.io.File

/**
* Return a File object and do some basic sanitization of the passed path.
*/
fun parsePathToFile(path: String): File {
return if (path.contains("://")) {
try {
val pathUri = Uri.parse(path)
File(pathUri.path!!)
} catch (e: Throwable) {
File(path)
}
} else {
File(path)
}
}
1 change: 1 addition & 0 deletions ios/FileAccess-Bridging-Header.h
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
#import <React/RCTBridgeModule.h>
#import <React/RCTEventEmitter.h>
7 changes: 3 additions & 4 deletions ios/FileAccess.m
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,9 @@ @interface RCT_EXTERN_REMAP_MODULE(RNFileAccess, FileAccess, NSObject)
withResolver:(RCTPromiseResolveBlock)resolve
withRejecter:(RCTPromiseRejectBlock)reject)

RCT_EXTERN_METHOD(fetch:(NSString *)resource
withConfig:(NSDictionary *)config
withResolver:(RCTPromiseResolveBlock)resolve
withRejecter:(RCTPromiseRejectBlock)reject)
RCT_EXTERN_METHOD(fetch:(nonnull NSNumber *)requestId
withResource:(NSString *)resource
withConfig:(NSDictionary *)config)

RCT_EXTERN_METHOD(getAppGroupDir:(NSString *)groupName
withResolver:(RCTPromiseResolveBlock)resolve
Expand Down
Loading

0 comments on commit a9fd3ad

Please sign in to comment.