Skip to content

Commit

Permalink
feat: Add custom path for takePhoto/takeSnapshot/`startRecordin…
Browse files Browse the repository at this point in the history
…g` (#3103)

* feat: Add custom `path` for `takePhoto`/`takeSnapshot`/`startRecording`

* feat: Implement custom `path` for iOS

* chore: Parse strongly typed

* Update Flash.swift

* feat: Add `path` for `takePhoto` on Android

* feat: Throw error if flash is not available on iOS

* feat: Support `path` for `takeSnapshot` on Android

* feat: Support `path` for `startRecording` on Android

* fix: Move logic into `OutputFile` to allow deletion if it is a temp file

* fix: Make file URL parsing more solid

* fix: Fix video file type

* Update MediaPage.tsx

* fix: Don't look for directories
  • Loading branch information
mrousavy authored Jul 29, 2024
1 parent a8d13fa commit 2ab7458
Show file tree
Hide file tree
Showing 30 changed files with 384 additions and 128 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ class PixelFormatNotSupportedError(format: String) :
class FlashUnavailableError :
CameraError(
"device",
"flash-unavailable",
"flash-not-available",
"The Camera Device does not have a flash unit! Make sure you select a device where `device.hasFlash`/`device.hasTorch` is true."
)
class FocusNotSupportedError :
Expand Down Expand Up @@ -191,6 +191,7 @@ class NoRecordingInProgressError :
class RecordingCanceledError : CameraError("capture", "recording-canceled", "The active recording was canceled.")
class FileIOError(throwable: Throwable) :
CameraError("capture", "file-io-error", "An unexpected File IO error occurred! Error: ${throwable.message}.", throwable)
class InvalidPathError(path: String) : CameraError("capture", "invalid-path", "The given path ($path) is invalid, or not writable!")
class RecordingInProgressError :
CameraError(
"capture",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,47 +1,45 @@
package com.mrousavy.camera.core

import android.media.AudioManager
import android.util.Log
import com.mrousavy.camera.core.extensions.takePicture
import com.mrousavy.camera.core.types.Flash
import com.mrousavy.camera.core.types.Orientation
import com.mrousavy.camera.core.types.TakePhotoOptions
import com.mrousavy.camera.core.utils.FileUtils

suspend fun CameraSession.takePhoto(flash: Flash, enableShutterSound: Boolean): Photo {
suspend fun CameraSession.takePhoto(options: TakePhotoOptions): Photo {
val camera = camera ?: throw CameraNotReadyError()
val configuration = configuration ?: throw CameraNotReadyError()
val photoConfig = configuration.photo as? CameraConfiguration.Output.Enabled<CameraConfiguration.Photo> ?: throw PhotoNotEnabledError()
val photoOutput = photoOutput ?: throw PhotoNotEnabledError()

if (flash != Flash.OFF && !camera.cameraInfo.hasFlashUnit()) {
// Flash
if (options.flash != Flash.OFF && !camera.cameraInfo.hasFlashUnit()) {
throw FlashUnavailableError()
}

photoOutput.flashMode = flash.toFlashMode()
val enableShutterSoundActual = getEnableShutterSoundActual(enableShutterSound)

photoOutput.flashMode = options.flash.toFlashMode()
// Shutter sound
val enableShutterSound = options.enableShutterSound && !audioManager.isSilent
// isMirrored (EXIF)
val isMirrored = photoConfig.config.isMirrored

// Shoot photo!
val photoFile = photoOutput.takePicture(
context,
options.file.file,
isMirrored,
enableShutterSoundActual,
enableShutterSound,
metadataProvider,
callback,
CameraQueues.cameraExecutor
)

// Parse resulting photo (EXIF data)
val size = FileUtils.getImageSize(photoFile.uri.path)
val rotation = photoOutput.targetRotation
val orientation = Orientation.fromSurfaceRotation(rotation)

return Photo(photoFile.uri.path, size.width, size.height, orientation, isMirrored)
}

private fun CameraSession.getEnableShutterSoundActual(enable: Boolean): Boolean {
if (enable && audioManager.ringerMode != AudioManager.RINGER_MODE_NORMAL) {
Log.i(CameraSession.TAG, "Ringer mode is silent (${audioManager.ringerMode}), disabling shutter sound...")
return false
}

return enable
}
private val AudioManager.isSilent: Boolean
get() = ringerMode != AudioManager.RINGER_MODE_NORMAL
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import androidx.camera.video.VideoRecordEvent
import com.mrousavy.camera.core.extensions.getCameraError
import com.mrousavy.camera.core.types.RecordVideoOptions
import com.mrousavy.camera.core.types.Video
import com.mrousavy.camera.core.utils.FileUtils

@OptIn(ExperimentalPersistentRecording::class)
@SuppressLint("MissingPermission", "RestrictedApi")
Expand All @@ -24,21 +23,23 @@ fun CameraSession.startRecording(
if (recording != null) throw RecordingInProgressError()
val videoOutput = videoOutput ?: throw VideoNotEnabledError()

val file = FileUtils.createTempFile(context, options.fileType.toExtension())
val outputOptions = FileOutputOptions.Builder(file).also { outputOptions ->
// Create output video file
val outputOptions = FileOutputOptions.Builder(options.file.file).also { outputOptions ->
metadataProvider.location?.let { location ->
Log.i(CameraSession.TAG, "Setting Video Location to ${location.latitude}, ${location.longitude}...")
outputOptions.setLocation(location)
}
}.build()

// TODO: Move this to JS so users can prepare recordings earlier
// Prepare recording
var pendingRecording = videoOutput.output.prepareRecording(context, outputOptions)
if (enableAudio) {
checkMicrophonePermission()
pendingRecording = pendingRecording.withAudioEnabled()
}
pendingRecording = pendingRecording.asPersistentRecording()

val size = videoOutput.attachedSurfaceResolution ?: Size(0, 0)
isRecordingCanceled = false
recording = pendingRecording.start(CameraQueues.cameraExecutor) { event ->
when (event) {
Expand All @@ -55,7 +56,7 @@ fun CameraSession.startRecording(
Log.i(CameraSession.TAG, "Recording was canceled, deleting file..")
onError(RecordingCanceledError())
try {
file.delete()
options.file.file.delete()
} catch (e: Throwable) {
this.callback.onError(FileIOError(e))
}
Expand All @@ -73,9 +74,12 @@ fun CameraSession.startRecording(
return@start
}
}

// Prepare output result
val durationMs = event.recordingStats.recordedDurationNanos / 1_000_000
Log.i(CameraSession.TAG, "Successfully completed video recording! Captured ${durationMs.toDouble() / 1_000.0} seconds.")
val path = event.outputResults.outputUri.path ?: throw UnknownRecorderError(false, null)
val size = videoOutput.attachedSurfaceResolution ?: Size(0, 0)
val video = Video(path, durationMs, size)
callback(video)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package com.mrousavy.camera.core.extensions

import android.annotation.SuppressLint
import android.content.Context
import android.media.MediaActionSound
import android.util.Log
import androidx.camera.core.ImageCapture
Expand All @@ -10,7 +9,7 @@ import androidx.camera.core.ImageCaptureException
import com.mrousavy.camera.core.CameraSession
import com.mrousavy.camera.core.MetadataProvider
import com.mrousavy.camera.core.types.ShutterType
import com.mrousavy.camera.core.utils.FileUtils
import java.io.File
import java.net.URI
import java.util.concurrent.Executor
import kotlin.coroutines.resume
Expand All @@ -21,7 +20,7 @@ data class PhotoFileInfo(val uri: URI, val metadata: ImageCapture.Metadata)

@SuppressLint("RestrictedApi")
suspend inline fun ImageCapture.takePicture(
context: Context,
file: File,
isMirrored: Boolean,
enableShutterSound: Boolean,
metadataProvider: MetadataProvider,
Expand All @@ -33,7 +32,7 @@ suspend inline fun ImageCapture.takePicture(
val shutterSound = if (enableShutterSound) MediaActionSound() else null
shutterSound?.load(MediaActionSound.SHUTTER_CLICK)

val file = FileUtils.createTempFile(context, ".jpg")
// Create output file
val outputFileOptionsBuilder = OutputFileOptions.Builder(file).also { options ->
val metadata = ImageCapture.Metadata()
metadataProvider.location?.let { location ->
Expand All @@ -45,6 +44,7 @@ suspend inline fun ImageCapture.takePicture(
}
val outputFileOptions = outputFileOptionsBuilder.build()

// Take a photo with callbacks
takePicture(
outputFileOptions,
executor,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,25 +1,27 @@
package com.mrousavy.camera.core.types

import android.content.Context
import com.facebook.react.bridge.ReadableMap
import com.mrousavy.camera.core.utils.FileUtils
import com.mrousavy.camera.core.utils.OutputFile

class RecordVideoOptions(map: ReadableMap) {
var fileType: VideoFileType = VideoFileType.MOV
var videoCodec = VideoCodec.H264
var videoBitRateOverride: Double? = null
var videoBitRateMultiplier: Double? = null
class RecordVideoOptions(
val file: OutputFile,
val videoCodec: VideoCodec,
val videoBitRateOverride: Double?,
val videoBitRateMultiplier: Double?
) {

init {
if (map.hasKey("fileType")) {
fileType = VideoFileType.fromUnionValue(map.getString("fileType"))
}
if (map.hasKey("videoCodec")) {
videoCodec = VideoCodec.fromUnionValue(map.getString("videoCodec"))
}
if (map.hasKey("videoBitRateOverride")) {
videoBitRateOverride = map.getDouble("videoBitRateOverride")
}
if (map.hasKey("videoBitRateMultiplier")) {
videoBitRateMultiplier = map.getDouble("videoBitRateMultiplier")
companion object {
fun fromJSValue(context: Context, map: ReadableMap): RecordVideoOptions {
val directory = if (map.hasKey("path")) FileUtils.getDirectory(map.getString("path")) else context.cacheDir
val fileType = if (map.hasKey("fileType")) VideoFileType.fromUnionValue(map.getString("fileType")) else VideoFileType.MOV
val videoCodec = if (map.hasKey("videoCodec")) VideoCodec.fromUnionValue(map.getString("videoCodec")) else VideoCodec.H264
val videoBitRateOverride = if (map.hasKey("videoBitRateOverride")) map.getDouble("videoBitRateOverride") else null
val videoBitRateMultiplier = if (map.hasKey("videoBitRateMultiplier")) map.getDouble("videoBitRateMultiplier") else null

val outputFile = OutputFile(context, directory, fileType.toExtension())
return RecordVideoOptions(outputFile, videoCodec, videoBitRateOverride, videoBitRateMultiplier)
}
}
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.mrousavy.camera.core.types

import android.content.Context
import com.facebook.react.bridge.ReadableMap
import com.mrousavy.camera.core.utils.FileUtils
import com.mrousavy.camera.core.utils.OutputFile

data class TakePhotoOptions(val file: OutputFile, val flash: Flash, val enableShutterSound: Boolean) {

companion object {
fun fromJS(context: Context, map: ReadableMap): TakePhotoOptions {
val flash = if (map.hasKey("flash")) Flash.fromUnionValue(map.getString("flash")) else Flash.OFF
val enableShutterSound = if (map.hasKey("enableShutterSound")) map.getBoolean("enableShutterSound") else false
val directory = if (map.hasKey("path")) FileUtils.getDirectory(map.getString("path")) else context.cacheDir

val outputFile = OutputFile(context, directory, ".jpg")
return TakePhotoOptions(outputFile, flash, enableShutterSound)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.mrousavy.camera.core.types

import android.content.Context
import com.facebook.react.bridge.ReadableMap
import com.mrousavy.camera.core.utils.FileUtils
import com.mrousavy.camera.core.utils.OutputFile

data class TakeSnapshotOptions(val file: OutputFile, val quality: Int) {

companion object {
fun fromJSValue(context: Context, map: ReadableMap): TakeSnapshotOptions {
val quality = if (map.hasKey("quality")) map.getInt("quality") else 100
val directory = if (map.hasKey("path")) FileUtils.getDirectory(map.getString("path")) else context.cacheDir

val outputFile = OutputFile(context, directory, ".jpg")
return TakeSnapshotOptions(outputFile, quality)
}
}
}
Original file line number Diff line number Diff line change
@@ -1,18 +1,24 @@
package com.mrousavy.camera.core.utils

import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.util.Size
import com.mrousavy.camera.core.InvalidPathError
import java.io.File
import java.io.FileOutputStream

class FileUtils {
companion object {
fun createTempFile(context: Context, extension: String): File =
File.createTempFile("mrousavy-", extension, context.cacheDir).also {
it.deleteOnExit()
fun getDirectory(path: String?): File {
if (path == null) {
throw InvalidPathError("null")
}
val file = File(path)
if (!file.isDirectory) {
throw InvalidPathError(path)
}
return file
}

fun writeBitmapTofile(bitmap: Bitmap, file: File, quality: Int) {
FileOutputStream(file).use { stream ->
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.mrousavy.camera.core.utils

import android.content.Context
import java.io.File

data class OutputFile(val context: Context, val directory: File, val extension: String) {
val file = File.createTempFile("mrousavy", extension, directory)

init {
if (directory.absolutePath.contains(context.cacheDir.absolutePath)) {
// If this is a temp file (inside temp directory), the file will be deleted once the app closes
file.deleteOnExit()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,17 @@ import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.ReadableMap
import com.facebook.react.bridge.WritableMap
import com.mrousavy.camera.core.takePhoto
import com.mrousavy.camera.core.types.Flash
import com.mrousavy.camera.core.types.TakePhotoOptions

private const val TAG = "CameraView.takePhoto"

@SuppressLint("UnsafeOptInUsageError")
suspend fun CameraView.takePhoto(optionsMap: ReadableMap): WritableMap {
val options = optionsMap.toHashMap()
Log.i(TAG, "Taking photo... Options: $options")
Log.i(TAG, "Taking photo... Options: ${optionsMap.toHashMap()}")

val flash = options["flash"] as? String ?: "off"
val enableShutterSound = options["enableShutterSound"] as? Boolean ?: true

val photo = cameraSession.takePhoto(
Flash.fromUnionValue(flash),
enableShutterSound
)
// Parse options and shoot photo
val options = TakePhotoOptions.fromJS(context, optionsMap)
val photo = cameraSession.takePhoto(options)

Log.i(TAG, "Successfully captured ${photo.width} x ${photo.height} photo!")

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,27 +6,29 @@ import com.facebook.react.bridge.WritableMap
import com.mrousavy.camera.core.SnapshotFailedError
import com.mrousavy.camera.core.SnapshotFailedPreviewNotEnabledError
import com.mrousavy.camera.core.types.ShutterType
import com.mrousavy.camera.core.types.SnapshotOptions
import com.mrousavy.camera.core.types.TakeSnapshotOptions
import com.mrousavy.camera.core.utils.FileUtils

private const val TAG = "CameraView.takeSnapshot"

fun CameraView.takeSnapshot(options: SnapshotOptions): WritableMap {
fun CameraView.takeSnapshot(options: TakeSnapshotOptions): WritableMap {
Log.i(TAG, "Capturing snapshot of Camera View...")
val previewView = previewView ?: throw SnapshotFailedPreviewNotEnabledError()
val bitmap = previewView.bitmap ?: throw SnapshotFailedError()

// Shutter Event (JS)
onShutter(ShutterType.SNAPSHOT)

val file = FileUtils.createTempFile(context, ".jpg")
FileUtils.writeBitmapTofile(bitmap, file, options.quality)
// Write snapshot to .jpg file
FileUtils.writeBitmapTofile(bitmap, options.file.file, options.quality)

Log.i(TAG, "Successfully saved snapshot to file!")

val orientation = cameraSession.outputOrientation

// Parse output data
val map = Arguments.createMap()
map.putString("path", file.absolutePath)
map.putString("path", options.file.file.absolutePath)
map.putInt("width", bitmap.width)
map.putInt("height", bitmap.height)
map.putString("orientation", orientation.unionValue)
Expand Down
Loading

0 comments on commit 2ab7458

Please sign in to comment.