Skip to content

Commit

Permalink
MIME types for camera pictures in file chooser (#60)
Browse files Browse the repository at this point in the history
  • Loading branch information
smikhalevski authored Apr 6, 2024
1 parent 090ff15 commit bc8dd64
Show file tree
Hide file tree
Showing 7 changed files with 203 additions and 85 deletions.
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -891,8 +891,9 @@ EventBus.getDefault().register(
activity,

// 🟡 Points to Android/data/com.myapplication/cache
activity.externalCacheDir,
BuildConfig.APPLICATION_ID + ".provider"
externalCacheDir?.let {
TempCameraFileFactory(this, it, BuildConfig.APPLICATION_ID + ".provider")
}
)
)
```
Expand Down
34 changes: 32 additions & 2 deletions android/example/src/main/java/com/example/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,30 @@ import com.facebook.FacebookSdk
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
import org.racehorse.*
import org.racehorse.ActivityPlugin
import org.racehorse.AssetLoaderPlugin
import org.racehorse.BiometricEncryptedStoragePlugin
import org.racehorse.BiometricPlugin
import org.racehorse.DeepLinkPlugin
import org.racehorse.DevicePlugin
import org.racehorse.DownloadPlugin
import org.racehorse.EncryptedStoragePlugin
import org.racehorse.EventBridge
import org.racehorse.FacebookLoginPlugin
import org.racehorse.FacebookSharePlugin
import org.racehorse.FileChooserPlugin
import org.racehorse.FirebasePlugin
import org.racehorse.GooglePlayReferrerPlugin
import org.racehorse.GoogleSignInPlugin
import org.racehorse.HttpsPlugin
import org.racehorse.KeyboardPlugin
import org.racehorse.NetworkPlugin
import org.racehorse.NotificationsPlugin
import org.racehorse.OpenDeepLinkEvent
import org.racehorse.PermissionsPlugin
import org.racehorse.ProxyPathHandler
import org.racehorse.StaticPathHandler
import org.racehorse.TempCameraFileFactory
import org.racehorse.evergreen.BundleReadyEvent
import org.racehorse.evergreen.EvergreenPlugin
import org.racehorse.evergreen.UpdateMode
Expand Down Expand Up @@ -58,7 +81,14 @@ class MainActivity : AppCompatActivity() {
eventBus.register(assetLoaderPlugin)
eventBus.register(DevicePlugin(this))
eventBus.register(EncryptedStoragePlugin(File(filesDir, "storage"), BuildConfig.APPLICATION_ID.toByteArray()))
eventBus.register(FileChooserPlugin(this, externalCacheDir, "${BuildConfig.APPLICATION_ID}.provider"))
eventBus.register(
FileChooserPlugin(
this,
externalCacheDir?.let {
TempCameraFileFactory(this, it, "${BuildConfig.APPLICATION_ID}.provider")
}
)
)
eventBus.register(DownloadPlugin(this))
eventBus.register(FirebasePlugin())
eventBus.register(GooglePlayReferrerPlugin(this))
Expand Down
161 changes: 100 additions & 61 deletions android/racehorse/src/main/java/org/racehorse/FileChooserPlugin.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,19 @@ package org.racehorse

import android.Manifest
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.provider.MediaStore
import android.webkit.MimeTypeMap
import android.webkit.ValueCallback
import android.webkit.WebChromeClient.FileChooserParams
import androidx.activity.ComponentActivity
import androidx.core.content.FileProvider
import org.greenrobot.eventbus.Subscribe
import org.racehorse.utils.askForPermission
import org.racehorse.utils.getMimeTypeFromSignature
import org.racehorse.utils.launchActivityForResult
import org.racehorse.webview.ShowFileChooserEvent
import java.io.File
Expand All @@ -20,34 +23,89 @@ import java.io.IOException
/**
* Allows the user to choose a file on the device.
*
* If [cacheDir] or [authority] are omitted, camera becomes unavailable since [FileChooserPlugin] cannot save
* temporary files with captured images.
*
* @param activity The activity that starts intents.
* @param cacheDir The directory to store files captured by camera activity.
* @param authority The [authority of the content provider](https://developer.android.com/guide/topics/providers/content-provider-basics#ContentURIs)
* that provides access to [cacheDir] for camera app.
*/
open class FileChooserPlugin(
private val activity: ComponentActivity,
private val cacheDir: File? = null,
private val authority: String? = null
private val cameraFileFactory: CameraFileFactory? = null
) {

@Subscribe
open fun onShowFileChooser(event: ShowFileChooserEvent) {
if (event.shouldHandle()) {
FileChooserLauncher(activity, cacheDir, authority, event.filePathCallback, event.fileChooserParams).start()
FileChooserLauncher(activity, cameraFileFactory, event.filePathCallback, event.fileChooserParams).start()
}
}
}

interface CameraFileFactory {
/**
* Creates a new camera file, or returns `null` if file cannot be created.
*/
fun create(fileName: String?, callback: (cameraFile: CameraFile?) -> Unit)
}

interface CameraFile {
/**
* File URI that is shared with the camera app that flushes captured data to it.
*/
val contentUri: Uri

/**
* Returns the persisted file URI that is returned by the file chooser to web view, or `null` if file cannot be
* returned from file chooser (for example, if file is empty, or non-existent).
*/
fun retrieveFileChooserUri(): Uri?
}

class TempCameraFileFactory(
private val context: Context,
private val cacheDir: File,
private val authority: String
) : CameraFileFactory {

override fun create(fileName: String?, callback: (cameraFile: CameraFile?) -> Unit) {
var file: File? = null

try {
cacheDir.mkdirs()

file = File.createTempFile("camera", "", cacheDir)
file.deleteOnExit()

callback(TempCameraFile(file, FileProvider.getUriForFile(context, authority, file)))
} catch (e: IOException) {
file?.delete()
e.printStackTrace()
callback(null)
}
}
}

private class TempCameraFile(private val file: File, override val contentUri: Uri) : CameraFile {

override fun retrieveFileChooserUri(): Uri? {
if (file.length() == 0L) {
file.delete()
return null
}

return Uri.fromFile(
file.getMimeTypeFromSignature()
?.let(MimeTypeMap.getSingleton()::getExtensionFromMimeType)
?.let { File("${file.absolutePath}.$it") }
?.takeIf(file::renameTo)
?.also(File::deleteOnExit)
?: file
)
}
}

private class FileChooserLauncher(
val activity: ComponentActivity,
val cacheDir: File?,
val authority: String?,
val filePathCallback: ValueCallback<Array<Uri>>,
val fileChooserParams: FileChooserParams,
private val activity: ComponentActivity,
private val cameraFileFactory: CameraFileFactory?,
private val filePathCallback: ValueCallback<Array<Uri>>,
private val fileChooserParams: FileChooserParams,
) {

private val mimeTypes = fileChooserParams.acceptTypes.joinToString(",")
Expand All @@ -57,80 +115,61 @@ private class FileChooserLauncher(

fun start() {
if (
(isImage || isVideo) &&
(cacheDir != null && authority != null && activity.packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY))
cameraFileFactory == null ||
!(isImage || isVideo) ||
!activity.packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY)
) {
activity.askForPermission(Manifest.permission.CAMERA, ::launchChooser)
} else {
// No camera-related MIME types, camera isn't supported, or capture result cannot be saved
launchChooser(false)
launchChooser(null)
}
}

private fun launchChooser(isCameraEnabled: Boolean) {
// The shared file for camera capture
var file: File? = null

val fileUri = if (isCameraEnabled && cacheDir != null && authority != null) {
try {
cacheDir.mkdirs()

file = File.createTempFile("camera", "", cacheDir)
file.deleteOnExit()

FileProvider.getUriForFile(activity, authority, file)
} catch (e: IOException) {
file?.delete()
file = null
e.printStackTrace()
null
activity.askForPermission(Manifest.permission.CAMERA) { isGranted ->
if (isGranted) {
requireNotNull(cameraFileFactory).create(fileChooserParams.filenameHint, ::launchChooser)
} else {
launchChooser(null)
}
} else null
}
}

private fun launchChooser(cameraFile: CameraFile?) {
var intent = Intent(Intent.ACTION_GET_CONTENT)
.setType(mimeTypes)
.addCategory(Intent.CATEGORY_OPENABLE)
.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, fileChooserParams.mode == FileChooserParams.MODE_OPEN_MULTIPLE)

if (fileUri != null) {
val extraIntents = ArrayList<Intent>()
if (cameraFile != null) {
val cameraIntents = ArrayList<Intent>()

if (isImage) {
extraIntents.add(
cameraIntents.add(
Intent(MediaStore.ACTION_IMAGE_CAPTURE)
.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
.putExtra(MediaStore.EXTRA_OUTPUT, fileUri)
.putExtra(MediaStore.EXTRA_OUTPUT, cameraFile.contentUri)
)
}
if (isVideo) {
extraIntents.add(
cameraIntents.add(
Intent(MediaStore.ACTION_VIDEO_CAPTURE)
.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
.putExtra(MediaStore.EXTRA_OUTPUT, fileUri)
.putExtra(MediaStore.EXTRA_OUTPUT, cameraFile.contentUri)
)
}
if (extraIntents.isNotEmpty()) {
if (cameraIntents.isNotEmpty()) {
intent = Intent.createChooser(intent, null)
.putExtra(Intent.EXTRA_INITIAL_INTENTS, extraIntents.toTypedArray())
.putExtra(Intent.EXTRA_INITIAL_INTENTS, cameraIntents.toTypedArray())
}
}

val isLaunched = activity.launchActivityForResult(intent) {
val uris = parseFileChooserResult(it.resultCode, it.data)

val isLaunched = activity.launchActivityForResult(intent) { result ->
filePathCallback.onReceiveValue(
when {
uris != null -> uris

// Maybe user captured an image or video with a camera
file != null -> if (file.length() == 0L) {
file.delete()
arrayOf()
} else {
arrayOf(Uri.fromFile(file))
}

else -> arrayOf()
try {
cameraFile?.retrieveFileChooserUri()?.let { arrayOf(it) }
?: parseFileChooserResult(result.resultCode, result.data)
?: arrayOf()
} catch (e: Throwable) {
e.printStackTrace()
arrayOf()
}
)
}
Expand Down
35 changes: 35 additions & 0 deletions android/racehorse/src/main/java/org/racehorse/utils/Files.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package org.racehorse.utils

import java.io.DataInputStream
import java.io.File

/**
* 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",
)

/**
* Returns MIME type of a file from its leading bytes.
*/
fun File.getMimeTypeFromSignature(): String? = try {
val signature = DataInputStream(inputStream()).use(DataInputStream::readLong).toULong()

mimeTypeSignatureMap.find { (mask) -> signature and mask == mask }?.second
} catch (_: Throwable) {
null
}
2 changes: 1 addition & 1 deletion web/example/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { AssetLoaderExample } from './examples/AssetLoaderExample';
export function App() {
return (
<>
<FileInputExample />
<ToastExample />
<AssetLoaderExample />
<BiometricExample />
Expand All @@ -37,7 +38,6 @@ export function App() {
<NetworkExample />
<EncryptedStorageExample />
<CookieExample />
<FileInputExample />
<GeolocationExample />
<LocalStorageExample />
<DeviceExample />
Expand Down
29 changes: 15 additions & 14 deletions web/example/src/examples/DownloadExample.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,30 +98,31 @@ export function DownloadExample() {
};

return (
<Fragment key={download.id}>
<hr />
<p>
{download.id + '. '}
<ol
key={download.id}
start={download.id}
>
<li>
<a
href={download.status === DownloadStatus.SUCCESSFUL ? '#' : undefined}
onClick={handlePreviewDownload}
>
{download.title}
</a>{' '}
</a>
{
{
[DownloadStatus.PENDING]: '⬇️',
[DownloadStatus.RUNNING]: '⬇️ ' + (((download.totalSize / download.downloadedSize) * 100) | 0) + '%',
[DownloadStatus.PAUSED]: '⏸',
[DownloadStatus.PENDING]: ' ⬇️',
[DownloadStatus.RUNNING]: ' ⬇️ ' + (((download.totalSize / download.downloadedSize) * 100) | 0) + '%',
[DownloadStatus.PAUSED]: ' ⏸',
[DownloadStatus.SUCCESSFUL]: '',
[DownloadStatus.FAILED]: '🔴',
[DownloadStatus.FAILED]: ' 🔴',
}[download.status]
}
</p>
<p>
<button onClick={handleDeleteDownload}>{'❌ Delete'}</button>
</p>
</Fragment>
<p>
<button onClick={handleDeleteDownload}>{'❌ Delete'}</button>
</p>
</li>
</ol>
);
})}
</>
Expand Down
Loading

0 comments on commit bc8dd64

Please sign in to comment.