Skip to content

Commit 2ab7458

Browse files
authored
feat: Add custom path for takePhoto/takeSnapshot/startRecording (#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
1 parent a8d13fa commit 2ab7458

30 files changed

Lines changed: 384 additions & 128 deletions

package/android/src/main/java/com/mrousavy/camera/core/CameraError.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ class PixelFormatNotSupportedError(format: String) :
5858
class FlashUnavailableError :
5959
CameraError(
6060
"device",
61-
"flash-unavailable",
61+
"flash-not-available",
6262
"The Camera Device does not have a flash unit! Make sure you select a device where `device.hasFlash`/`device.hasTorch` is true."
6363
)
6464
class FocusNotSupportedError :
@@ -191,6 +191,7 @@ class NoRecordingInProgressError :
191191
class RecordingCanceledError : CameraError("capture", "recording-canceled", "The active recording was canceled.")
192192
class FileIOError(throwable: Throwable) :
193193
CameraError("capture", "file-io-error", "An unexpected File IO error occurred! Error: ${throwable.message}.", throwable)
194+
class InvalidPathError(path: String) : CameraError("capture", "invalid-path", "The given path ($path) is invalid, or not writable!")
194195
class RecordingInProgressError :
195196
CameraError(
196197
"capture",
Lines changed: 15 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,45 @@
11
package com.mrousavy.camera.core
22

33
import android.media.AudioManager
4-
import android.util.Log
54
import com.mrousavy.camera.core.extensions.takePicture
65
import com.mrousavy.camera.core.types.Flash
76
import com.mrousavy.camera.core.types.Orientation
7+
import com.mrousavy.camera.core.types.TakePhotoOptions
88
import com.mrousavy.camera.core.utils.FileUtils
99

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

16-
if (flash != Flash.OFF && !camera.cameraInfo.hasFlashUnit()) {
16+
// Flash
17+
if (options.flash != Flash.OFF && !camera.cameraInfo.hasFlashUnit()) {
1718
throw FlashUnavailableError()
1819
}
19-
20-
photoOutput.flashMode = flash.toFlashMode()
21-
val enableShutterSoundActual = getEnableShutterSoundActual(enableShutterSound)
22-
20+
photoOutput.flashMode = options.flash.toFlashMode()
21+
// Shutter sound
22+
val enableShutterSound = options.enableShutterSound && !audioManager.isSilent
23+
// isMirrored (EXIF)
2324
val isMirrored = photoConfig.config.isMirrored
25+
26+
// Shoot photo!
2427
val photoFile = photoOutput.takePicture(
25-
context,
28+
options.file.file,
2629
isMirrored,
27-
enableShutterSoundActual,
30+
enableShutterSound,
2831
metadataProvider,
2932
callback,
3033
CameraQueues.cameraExecutor
3134
)
3235

36+
// Parse resulting photo (EXIF data)
3337
val size = FileUtils.getImageSize(photoFile.uri.path)
3438
val rotation = photoOutput.targetRotation
3539
val orientation = Orientation.fromSurfaceRotation(rotation)
3640

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

40-
private fun CameraSession.getEnableShutterSoundActual(enable: Boolean): Boolean {
41-
if (enable && audioManager.ringerMode != AudioManager.RINGER_MODE_NORMAL) {
42-
Log.i(CameraSession.TAG, "Ringer mode is silent (${audioManager.ringerMode}), disabling shutter sound...")
43-
return false
44-
}
45-
46-
return enable
47-
}
44+
private val AudioManager.isSilent: Boolean
45+
get() = ringerMode != AudioManager.RINGER_MODE_NORMAL

package/android/src/main/java/com/mrousavy/camera/core/CameraSession+Video.kt

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import androidx.camera.video.VideoRecordEvent
1010
import com.mrousavy.camera.core.extensions.getCameraError
1111
import com.mrousavy.camera.core.types.RecordVideoOptions
1212
import com.mrousavy.camera.core.types.Video
13-
import com.mrousavy.camera.core.utils.FileUtils
1413

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

27-
val file = FileUtils.createTempFile(context, options.fileType.toExtension())
28-
val outputOptions = FileOutputOptions.Builder(file).also { outputOptions ->
26+
// Create output video file
27+
val outputOptions = FileOutputOptions.Builder(options.file.file).also { outputOptions ->
2928
metadataProvider.location?.let { location ->
3029
Log.i(CameraSession.TAG, "Setting Video Location to ${location.latitude}, ${location.longitude}...")
3130
outputOptions.setLocation(location)
3231
}
3332
}.build()
33+
34+
// TODO: Move this to JS so users can prepare recordings earlier
35+
// Prepare recording
3436
var pendingRecording = videoOutput.output.prepareRecording(context, outputOptions)
3537
if (enableAudio) {
3638
checkMicrophonePermission()
3739
pendingRecording = pendingRecording.withAudioEnabled()
3840
}
3941
pendingRecording = pendingRecording.asPersistentRecording()
4042

41-
val size = videoOutput.attachedSurfaceResolution ?: Size(0, 0)
4243
isRecordingCanceled = false
4344
recording = pendingRecording.start(CameraQueues.cameraExecutor) { event ->
4445
when (event) {
@@ -55,7 +56,7 @@ fun CameraSession.startRecording(
5556
Log.i(CameraSession.TAG, "Recording was canceled, deleting file..")
5657
onError(RecordingCanceledError())
5758
try {
58-
file.delete()
59+
options.file.file.delete()
5960
} catch (e: Throwable) {
6061
this.callback.onError(FileIOError(e))
6162
}
@@ -73,9 +74,12 @@ fun CameraSession.startRecording(
7374
return@start
7475
}
7576
}
77+
78+
// Prepare output result
7679
val durationMs = event.recordingStats.recordedDurationNanos / 1_000_000
7780
Log.i(CameraSession.TAG, "Successfully completed video recording! Captured ${durationMs.toDouble() / 1_000.0} seconds.")
7881
val path = event.outputResults.outputUri.path ?: throw UnknownRecorderError(false, null)
82+
val size = videoOutput.attachedSurfaceResolution ?: Size(0, 0)
7983
val video = Video(path, durationMs, size)
8084
callback(video)
8185
}

package/android/src/main/java/com/mrousavy/camera/core/extensions/ImageCapture+takePicture.kt

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package com.mrousavy.camera.core.extensions
22

33
import android.annotation.SuppressLint
4-
import android.content.Context
54
import android.media.MediaActionSound
65
import android.util.Log
76
import androidx.camera.core.ImageCapture
@@ -10,7 +9,7 @@ import androidx.camera.core.ImageCaptureException
109
import com.mrousavy.camera.core.CameraSession
1110
import com.mrousavy.camera.core.MetadataProvider
1211
import com.mrousavy.camera.core.types.ShutterType
13-
import com.mrousavy.camera.core.utils.FileUtils
12+
import java.io.File
1413
import java.net.URI
1514
import java.util.concurrent.Executor
1615
import kotlin.coroutines.resume
@@ -21,7 +20,7 @@ data class PhotoFileInfo(val uri: URI, val metadata: ImageCapture.Metadata)
2120

2221
@SuppressLint("RestrictedApi")
2322
suspend inline fun ImageCapture.takePicture(
24-
context: Context,
23+
file: File,
2524
isMirrored: Boolean,
2625
enableShutterSound: Boolean,
2726
metadataProvider: MetadataProvider,
@@ -33,7 +32,7 @@ suspend inline fun ImageCapture.takePicture(
3332
val shutterSound = if (enableShutterSound) MediaActionSound() else null
3433
shutterSound?.load(MediaActionSound.SHUTTER_CLICK)
3534

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

47+
// Take a photo with callbacks
4848
takePicture(
4949
outputFileOptions,
5050
executor,
Lines changed: 19 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,27 @@
11
package com.mrousavy.camera.core.types
22

3+
import android.content.Context
34
import com.facebook.react.bridge.ReadableMap
5+
import com.mrousavy.camera.core.utils.FileUtils
6+
import com.mrousavy.camera.core.utils.OutputFile
47

5-
class RecordVideoOptions(map: ReadableMap) {
6-
var fileType: VideoFileType = VideoFileType.MOV
7-
var videoCodec = VideoCodec.H264
8-
var videoBitRateOverride: Double? = null
9-
var videoBitRateMultiplier: Double? = null
8+
class RecordVideoOptions(
9+
val file: OutputFile,
10+
val videoCodec: VideoCodec,
11+
val videoBitRateOverride: Double?,
12+
val videoBitRateMultiplier: Double?
13+
) {
1014

11-
init {
12-
if (map.hasKey("fileType")) {
13-
fileType = VideoFileType.fromUnionValue(map.getString("fileType"))
14-
}
15-
if (map.hasKey("videoCodec")) {
16-
videoCodec = VideoCodec.fromUnionValue(map.getString("videoCodec"))
17-
}
18-
if (map.hasKey("videoBitRateOverride")) {
19-
videoBitRateOverride = map.getDouble("videoBitRateOverride")
20-
}
21-
if (map.hasKey("videoBitRateMultiplier")) {
22-
videoBitRateMultiplier = map.getDouble("videoBitRateMultiplier")
15+
companion object {
16+
fun fromJSValue(context: Context, map: ReadableMap): RecordVideoOptions {
17+
val directory = if (map.hasKey("path")) FileUtils.getDirectory(map.getString("path")) else context.cacheDir
18+
val fileType = if (map.hasKey("fileType")) VideoFileType.fromUnionValue(map.getString("fileType")) else VideoFileType.MOV
19+
val videoCodec = if (map.hasKey("videoCodec")) VideoCodec.fromUnionValue(map.getString("videoCodec")) else VideoCodec.H264
20+
val videoBitRateOverride = if (map.hasKey("videoBitRateOverride")) map.getDouble("videoBitRateOverride") else null
21+
val videoBitRateMultiplier = if (map.hasKey("videoBitRateMultiplier")) map.getDouble("videoBitRateMultiplier") else null
22+
23+
val outputFile = OutputFile(context, directory, fileType.toExtension())
24+
return RecordVideoOptions(outputFile, videoCodec, videoBitRateOverride, videoBitRateMultiplier)
2325
}
2426
}
2527
}

package/android/src/main/java/com/mrousavy/camera/core/types/SnapshotOptions.kt

Lines changed: 0 additions & 12 deletions
This file was deleted.
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package com.mrousavy.camera.core.types
2+
3+
import android.content.Context
4+
import com.facebook.react.bridge.ReadableMap
5+
import com.mrousavy.camera.core.utils.FileUtils
6+
import com.mrousavy.camera.core.utils.OutputFile
7+
8+
data class TakePhotoOptions(val file: OutputFile, val flash: Flash, val enableShutterSound: Boolean) {
9+
10+
companion object {
11+
fun fromJS(context: Context, map: ReadableMap): TakePhotoOptions {
12+
val flash = if (map.hasKey("flash")) Flash.fromUnionValue(map.getString("flash")) else Flash.OFF
13+
val enableShutterSound = if (map.hasKey("enableShutterSound")) map.getBoolean("enableShutterSound") else false
14+
val directory = if (map.hasKey("path")) FileUtils.getDirectory(map.getString("path")) else context.cacheDir
15+
16+
val outputFile = OutputFile(context, directory, ".jpg")
17+
return TakePhotoOptions(outputFile, flash, enableShutterSound)
18+
}
19+
}
20+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package com.mrousavy.camera.core.types
2+
3+
import android.content.Context
4+
import com.facebook.react.bridge.ReadableMap
5+
import com.mrousavy.camera.core.utils.FileUtils
6+
import com.mrousavy.camera.core.utils.OutputFile
7+
8+
data class TakeSnapshotOptions(val file: OutputFile, val quality: Int) {
9+
10+
companion object {
11+
fun fromJSValue(context: Context, map: ReadableMap): TakeSnapshotOptions {
12+
val quality = if (map.hasKey("quality")) map.getInt("quality") else 100
13+
val directory = if (map.hasKey("path")) FileUtils.getDirectory(map.getString("path")) else context.cacheDir
14+
15+
val outputFile = OutputFile(context, directory, ".jpg")
16+
return TakeSnapshotOptions(outputFile, quality)
17+
}
18+
}
19+
}

package/android/src/main/java/com/mrousavy/camera/core/utils/FileUtils.kt

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,24 @@
11
package com.mrousavy.camera.core.utils
22

3-
import android.content.Context
43
import android.graphics.Bitmap
54
import android.graphics.BitmapFactory
65
import android.util.Size
6+
import com.mrousavy.camera.core.InvalidPathError
77
import java.io.File
88
import java.io.FileOutputStream
99

1010
class FileUtils {
1111
companion object {
12-
fun createTempFile(context: Context, extension: String): File =
13-
File.createTempFile("mrousavy-", extension, context.cacheDir).also {
14-
it.deleteOnExit()
12+
fun getDirectory(path: String?): File {
13+
if (path == null) {
14+
throw InvalidPathError("null")
1515
}
16+
val file = File(path)
17+
if (!file.isDirectory) {
18+
throw InvalidPathError(path)
19+
}
20+
return file
21+
}
1622

1723
fun writeBitmapTofile(bitmap: Bitmap, file: File, quality: Int) {
1824
FileOutputStream(file).use { stream ->
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package com.mrousavy.camera.core.utils
2+
3+
import android.content.Context
4+
import java.io.File
5+
6+
data class OutputFile(val context: Context, val directory: File, val extension: String) {
7+
val file = File.createTempFile("mrousavy", extension, directory)
8+
9+
init {
10+
if (directory.absolutePath.contains(context.cacheDir.absolutePath)) {
11+
// If this is a temp file (inside temp directory), the file will be deleted once the app closes
12+
file.deleteOnExit()
13+
}
14+
}
15+
}

0 commit comments

Comments
 (0)