Skip to content

Commit

Permalink
Self-review fixes
Browse files Browse the repository at this point in the history
  • Loading branch information
smikhalevski committed Apr 16, 2024
1 parent 24a7995 commit 621d8f7
Show file tree
Hide file tree
Showing 7 changed files with 270 additions and 84 deletions.
2 changes: 1 addition & 1 deletion android/example/src/main/java/com/example/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ class MainActivity : AppCompatActivity() {
eventBus.register(BiometricPlugin(this))
eventBus.register(BiometricEncryptedStoragePlugin(this, File(filesDir, "biometric_storage")))
eventBus.register(ContactsPlugin(this))
eventBus.register(FsPlugin(this))
eventBus.register(FsPlugin(this, fileAuthority = "${BuildConfig.APPLICATION_ID}.provider"))

// From the example app
eventBus.register(ToastPlugin(this))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import androidx.core.net.toUri
import org.greenrobot.eventbus.Subscribe
import org.racehorse.utils.askForPermission
import org.racehorse.utils.copyTo
import org.racehorse.utils.guessMimeTypeFromContent
import org.racehorse.utils.guessMimeType
import org.racehorse.utils.launchActivityForResult
import org.racehorse.webview.ShowFileChooserEvent
import java.io.File
Expand Down Expand Up @@ -94,7 +94,7 @@ private class TempCameraFile(override val contentUri: Uri, private val tempFile:
return null
}

return tempFile.guessMimeTypeFromContent()
return tempFile.guessMimeType()
?.let(MimeTypeMap.getSingleton()::getExtensionFromMimeType)
?.let { File(tempFile.absolutePath + ".$it") }
?.takeIf(tempFile::renameTo)
Expand Down Expand Up @@ -153,7 +153,7 @@ private class GalleryCameraFile(
return null
}

val mimeType = tempFile.guessMimeTypeFromContent() ?: DEFAULT_MIME_TYPE
val mimeType = tempFile.guessMimeType() ?: DEFAULT_MIME_TYPE
val extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType) ?: DEFAULT_EXTENSION

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
Expand Down
158 changes: 104 additions & 54 deletions android/racehorse/src/main/java/org/racehorse/FsPlugin.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@ import android.net.Uri
import android.os.Environment
import android.webkit.WebResourceResponse
import androidx.activity.ComponentActivity
import androidx.core.content.FileProvider
import androidx.core.net.toFile
import androidx.core.net.toUri
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
import org.racehorse.utils.guessMimeTypeFromContent
import org.racehorse.utils.guessMimeType
import org.racehorse.utils.queryContent
import org.racehorse.webview.ShouldInterceptRequestEvent
import java.io.ByteArrayInputStream
Expand Down Expand Up @@ -40,6 +41,18 @@ class FsGetStatEvent(val uri: String) : RequestEvent() {
) : ResponseEvent()
}

class FsGetParentUriEvent(val uri: String) : RequestEvent() {
class ResultEvent(val uri: String?) : ResponseEvent()
}

class FsGetUrlEvent(val uri: String) : RequestEvent() {
class ResultEvent(val url: String) : ResponseEvent()
}

class FsGetExposableUriEvent(val uri: String) : RequestEvent() {
class ResultEvent(val uri: String) : ResponseEvent()
}

class FsGetMimeTypeEvent(val uri: String) : RequestEvent() {
class ResultEvent(val mimeType: String?) : ResponseEvent()
}
Expand Down Expand Up @@ -72,15 +85,15 @@ class FsDeleteEvent(val uri: String) : RequestEvent() {
class ResultEvent(val isSuccessful: Boolean) : ResponseEvent()
}

class FsGetUrlBaseEvent : RequestEvent() {
class ResultEvent(val urlBase: String) : ResponseEvent()
}

class FsResolveEvent(val uri: String, val path: String) : RequestEvent() {
class ResultEvent(val uri: String) : ResponseEvent()
}

open class FsPlugin(val activity: ComponentActivity, val urlBase: String = "https://racehorse.local/fs/") {
open class FsPlugin(
val activity: ComponentActivity,
val fileAuthority: String? = null,
val baseUrl: String = "https://racehorse.local/fs/"
) {

private companion object {
const val SCHEME_FILE = "file"
Expand All @@ -93,38 +106,78 @@ open class FsPlugin(val activity: ComponentActivity, val urlBase: String = "http
const val SYSTEM_DIR_CACHE = "cache"
const val SYSTEM_DIR_EXTERNAL = "external"
const val SYSTEM_DIR_EXTERNAL_STORAGE = "external_storage"

const val QUERY_PARAM_URI = "uri"
}

@Subscribe(threadMode = ThreadMode.BACKGROUND)
open fun onFsIsExisting(event: FsIsExistingEvent) {
val uri = getCanonicalUri(event.uri)

event.respond(FsIsExistingEvent.ResultEvent(
when (uri.scheme) {
event.respond(
FsIsExistingEvent.ResultEvent(
when (uri.scheme) {

SCHEME_FILE -> uri.toFile().exists()
SCHEME_FILE -> uri.toFile().exists()

SCHEME_CONTENT -> activity.queryContent(uri) { moveToFirst() }
SCHEME_CONTENT -> activity.queryContent(uri) { moveToFirst() }

else -> throw UnsupportedSchemeException()
}
))
else -> throw UnsupportedSchemeException()
}
)
)
}

@Subscribe(threadMode = ThreadMode.BACKGROUND)
open fun onFsGetStat(event: FsGetStatEvent) {
event.respond(getFile(event.uri).toPath().readAttributes<BasicFileAttributes>().run {
FsGetStatEvent.ResultEvent(
lastModifiedTime = lastModifiedTime().toMillis(),
lastAccessTime = lastAccessTime().toMillis(),
creationTime = creationTime().toMillis(),
isFile = isRegularFile,
isDirectory = isDirectory,
isSymbolicLink = isSymbolicLink,
isOther = isOther,
size = size(),
event.respond(
getFile(event.uri).toPath().readAttributes<BasicFileAttributes>().run {
FsGetStatEvent.ResultEvent(
lastModifiedTime = lastModifiedTime().toMillis(),
lastAccessTime = lastAccessTime().toMillis(),
creationTime = creationTime().toMillis(),
isFile = isRegularFile,
isDirectory = isDirectory,
isSymbolicLink = isSymbolicLink,
isOther = isOther,
size = size(),
)
}
)
}

@Subscribe
open fun onFsGetParentUri(event: FsGetParentUriEvent) {
event.respond(FsGetParentUriEvent.ResultEvent(getFile(event.uri).parentFile?.toUri().toString()))
}

@Subscribe
open fun onFsGetUrl(event: FsGetUrlEvent) {
event.respond(
FsGetUrlEvent.ResultEvent(
Uri.parse(baseUrl).buildUpon().appendQueryParameter(QUERY_PARAM_URI, event.uri).build().toString()
)
)
}

@Subscribe
open fun onFsGetExposableUri(event: FsGetExposableUriEvent) {
checkNotNull(fileAuthority) { "Expected a provider authority" }

val uri = getCanonicalUri(event.uri)

event.respond(
FsGetExposableUriEvent.ResultEvent(
when (uri.scheme) {

SCHEME_FILE -> FileProvider.getUriForFile(activity, fileAuthority, uri.toFile()).toString()

SCHEME_CONTENT -> uri.toString()

else -> throw UnsupportedSchemeException()
}
)
})
)
}

@Subscribe(threadMode = ThreadMode.BACKGROUND)
Expand All @@ -135,7 +188,7 @@ open class FsPlugin(val activity: ComponentActivity, val urlBase: String = "http
FsGetMimeTypeEvent.ResultEvent(
when (uri.scheme) {

SCHEME_FILE -> runCatching { uri.toFile().guessMimeTypeFromContent() }.getOrNull()
SCHEME_FILE -> uri.toFile().guessMimeType()

SCHEME_CONTENT -> activity.contentResolver.getType(uri)

Expand All @@ -154,7 +207,7 @@ open class FsPlugin(val activity: ComponentActivity, val urlBase: String = "http
open fun onFsReadDir(event: FsReadDirEvent) {
event.respond(
FsReadDirEvent.ResultEvent(
getFile(event.uri).toPath().listDirectoryEntries().map { it.fileName.toString() }
getFile(event.uri).toPath().listDirectoryEntries().map { it.toUri().toString() }
)
)
}
Expand All @@ -181,26 +234,6 @@ open class FsPlugin(val activity: ComponentActivity, val urlBase: String = "http
event.respond(FsReadEvent.ResultEvent(data))
}

private fun write(uri: Uri, data: String, encoding: String?, append: Boolean) {
val outputStream = when (uri.scheme) {

SCHEME_FILE -> FileOutputStream(uri.toFile(), append)

SCHEME_CONTENT -> requireNotNull(activity.contentResolver.openOutputStream(uri, if (append) "wa" else "w"))

else -> throw UnsupportedSchemeException()
}

outputStream.use {
val bytes = if (encoding == null) {
Base64.getDecoder().decode(data)
} else {
data.toByteArray(Charset.forName(encoding))
}
it.write(bytes)
}
}

@Subscribe(threadMode = ThreadMode.BACKGROUND)
open fun onFsAppend(event: FsAppendEvent) {
write(getCanonicalUri(event.uri), event.data, event.encoding, true)
Expand Down Expand Up @@ -247,11 +280,6 @@ open class FsPlugin(val activity: ComponentActivity, val urlBase: String = "http
)
}

@Subscribe
open fun onFsGetUrlBase(event: FsGetUrlBaseEvent) {
event.respond(FsGetUrlBaseEvent.ResultEvent(urlBase))
}

@Subscribe
open fun onFsResolve(event: FsResolveEvent) {
val baseUri = getCanonicalUri(event.uri)
Expand All @@ -275,12 +303,14 @@ open class FsPlugin(val activity: ComponentActivity, val urlBase: String = "http

@Subscribe
open fun onShouldInterceptRequest(event: ShouldInterceptRequestEvent) {
if (!event.request.url.toString().startsWith(urlBase)) {
if (!event.request.url.toString().startsWith(baseUrl)) {
// Unrelated request
return
}

event.response = try {
val uri = Uri.parse(requireNotNull(event.request.url.getQueryParameter("uri")) { "Expected resource URI" })
val uri =
getCanonicalUri(requireNotNull(event.request.url.getQueryParameter(QUERY_PARAM_URI)) { "Expected a resource URI" })

when (uri.scheme) {

Expand All @@ -304,7 +334,7 @@ open class FsPlugin(val activity: ComponentActivity, val urlBase: String = "http
null,
null,
500,
e.message ?: "Cannot read from URI",
e.message ?: "Cannot read the resource from the URI",
null,
ByteArrayInputStream(e.stackTraceToString().toByteArray())
)
Expand Down Expand Up @@ -339,6 +369,26 @@ open class FsPlugin(val activity: ComponentActivity, val urlBase: String = "http
}

protected fun getFile(uri: String) = getCanonicalUri(uri).toFile()

private fun write(uri: Uri, data: String, encoding: String?, append: Boolean) {
val outputStream = when (uri.scheme) {

SCHEME_FILE -> FileOutputStream(uri.toFile(), append)

SCHEME_CONTENT -> requireNotNull(activity.contentResolver.openOutputStream(uri, if (append) "wa" else "w"))

else -> throw UnsupportedSchemeException()
}

outputStream.use {
val bytes = if (encoding == null) {
Base64.getDecoder().decode(data)
} else {
data.toByteArray(Charset.forName(encoding))
}
it.write(bytes)
}
}
}

class UnsupportedSchemeException : Exception()
57 changes: 54 additions & 3 deletions android/racehorse/src/main/java/org/racehorse/utils/Files.kt
Original file line number Diff line number Diff line change
@@ -1,15 +1,29 @@
package org.racehorse.utils

import java.io.BufferedInputStream
import java.io.DataInputStream
import java.io.EOFException
import java.io.File
import java.io.OutputStream
import java.net.URLConnection

/**
* Returns a MIME type of a file. Type is derived either from a file signature bytes or from file extension.
*/
fun File.guessMimeType(): String? =
runCatching(::guessMimeTypeFromContent).getOrNull() ?: URLConnection.guessContentTypeFromName(name)

/**
* Returns a MIME type of a file from its leading bytes stored in a file (a file signature).
*/
fun File.guessMimeTypeFromContent() =
URLConnection.guessContentTypeFromStream(BufferedInputStream(inputStream()))
fun File.guessMimeTypeFromContent(): String? {
val signature = try {
DataInputStream(inputStream()).use(DataInputStream::readLong).toULong()
} catch (_: EOFException) {
return null
}

return mimeTypeSignatureMap.find { (mask) -> signature and mask == mask }?.second
}

/**
* The extension with the leading dot, or an empty string if there's no extension.
Expand All @@ -28,3 +42,40 @@ fun File.createTempFile() = File.createTempFile(nameWithoutExtension, extensionS
*/
fun File.copyTo(outputStream: OutputStream, bufferSize: Int = DEFAULT_BUFFER_SIZE) =
inputStream().use { it.copyTo(outputStream, bufferSize) }

/**
* Map from a file signature to a corresponding MIME type.
*
* [List of file signatures](https://en.wikipedia.org/wiki/List_of_file_signatures)
* [Binary signatures](https://www.den4b.com/wiki/ReNamer%3aBinary_Signatures)
*/
val mimeTypeSignatureMap = arrayListOf(
0xFF_D8_FF_00_00_00_00_00U to "image/jpeg",
0x47_49_46_38_37_61_00_00U to "image/gif",
0x47_49_46_38_39_61_00_00U to "image/gif",
0x89_50_4E_47_0D_0A_1A_0AU to "image/png",
0x52_49_46_46_00_00_00_00U to "image/webp",
0x49_49_2A_00_00_00_00_00U to "image/tiff",
0x4D_4D_00_2A_00_00_00_00U to "image/tiff",

0x66_74_79_70_69_73_6F_6DU to "video/mp4",
0x66_74_79_70_4D_53_4E_56U to "video/mp4",
0x00_00_00_18_66_74_79_70U to "video/mp4",
0x1A_45_DF_A3_00_00_00_00U to "video/webm",

0x49_44_33_00_00_00_00_00U to "audio/mpeg",

0x50_4B_03_04_00_00_00_00U to "application/x-zip",
0x1F_8B_08_00_00_00_00_00U to "application/gzip",
0x25_50_44_46_00_00_00_00U to "application/pdf",

0xEF_BB_BF_00_00_00_00_00U to "text/plain", // BOM UTF-8
0xFF_FE_00_00_00_00_00_00U to "text/plain", // BOM UTF-16BE
0xFE_FF_00_00_00_00_00_00U to "text/plain", // BOM UTF-16BE or UTF-32LE
0x00_00_FE_FF_00_00_00_00U to "text/plain", // BOM UTF-32BE
0x2B_2F_76_38_00_00_00_00U to "text/plain", // BOM UTF-7
0x2B_2F_76_39_00_00_00_00U to "text/plain", // BOM UTF-7
0x2B_2F_76_2B_00_00_00_00U to "text/plain", // BOM UTF-7
0x2B_2F_76_2F_00_00_00_00U to "text/plain", // BOM UTF-7
0x0E_FE_FF_00_00_00_00_00U to "text/plain", // https://en.wikipedia.org/wiki/Standard_Compression_Scheme_for_Unicode
)
2 changes: 1 addition & 1 deletion web/example/src/examples/ContactsExample.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export function ContactsExample() {
<p>
<img
style={{ display: 'block', width: '100px', height: '100px', borderRadius: '50%' }}
src={fs.File(contact.photoUri).toUrl()}
src={fs.File(contact.photoUri).getUrl()}
alt={contact.name || ''}
/>
</p>
Expand Down
Loading

0 comments on commit 621d8f7

Please sign in to comment.