diff --git a/package/android/src/main/java/com/mrousavy/camera/core/CameraError.kt b/package/android/src/main/java/com/mrousavy/camera/core/CameraError.kt index c15880cc64..e275bbf1a6 100644 --- a/package/android/src/main/java/com/mrousavy/camera/core/CameraError.kt +++ b/package/android/src/main/java/com/mrousavy/camera/core/CameraError.kt @@ -208,6 +208,8 @@ class FrameInvalidError : "- If you want to use runOnJS, increment it's ref-count: `frame.incrementRefCount()`" ) class InvalidImageTypeError : CameraError("capture", "invalid-image-type", "Captured an Image with an invalid Image type!") +class InvalidPathError(message: String) : + CameraError("capture", "invalid-path", "The given path is invalid! $message") class CodeTypeNotSupportedError(codeType: String) : CameraError( diff --git a/package/android/src/main/java/com/mrousavy/camera/core/CameraSession.kt b/package/android/src/main/java/com/mrousavy/camera/core/CameraSession.kt index 16d9aa5b69..946e6106b6 100644 --- a/package/android/src/main/java/com/mrousavy/camera/core/CameraSession.kt +++ b/package/android/src/main/java/com/mrousavy/camera/core/CameraSession.kt @@ -492,7 +492,7 @@ class CameraSession(private val context: Context, private val callback: Callback } } - suspend fun takePhoto(flash: Flash, enableShutterSound: Boolean, outputOrientation: Orientation): Photo { + suspend fun takePhoto(flash: Flash, enableShutterSound: Boolean, outputOrientation: Orientation, path: String?): Photo { val camera = camera ?: throw CameraNotReadyError() val photoOutput = photoOutput ?: throw PhotoNotEnabledError() @@ -504,7 +504,7 @@ class CameraSession(private val context: Context, private val callback: Callback photoOutput.targetRotation = outputOrientation.toSurfaceRotation() val enableShutterSoundActual = getEnableShutterSoundActual(enableShutterSound) - val photoFile = photoOutput.takePicture(context, enableShutterSoundActual, metadataProvider, callback, CameraQueues.cameraExecutor) + val photoFile = photoOutput.takePicture(context, enableShutterSoundActual, metadataProvider, callback, CameraQueues.cameraExecutor, path) val isMirrored = photoFile.metadata.isReversedHorizontal val bitmapOptions = BitmapFactory.Options().also { @@ -538,7 +538,7 @@ class CameraSession(private val context: Context, private val callback: Callback if (recording != null) throw RecordingInProgressError() val videoOutput = videoOutput ?: throw VideoNotEnabledError() - val file = FileUtils.createTempFile(context, options.fileType.toExtension()) + val file = FileUtils.getDestinationFile(context, options.path, options.fileType.toExtension()) val outputOptions = FileOutputOptions.Builder(file).also { outputOptions -> metadataProvider.location?.let { location -> Log.i(TAG, "Setting Video Location to ${location.latitude}, ${location.longitude}...") diff --git a/package/android/src/main/java/com/mrousavy/camera/core/extensions/ImageCapture+takePicture.kt b/package/android/src/main/java/com/mrousavy/camera/core/extensions/ImageCapture+takePicture.kt index 4b4325c680..74380b6fbb 100644 --- a/package/android/src/main/java/com/mrousavy/camera/core/extensions/ImageCapture+takePicture.kt +++ b/package/android/src/main/java/com/mrousavy/camera/core/extensions/ImageCapture+takePicture.kt @@ -25,14 +25,15 @@ suspend inline fun ImageCapture.takePicture( enableShutterSound: Boolean, metadataProvider: MetadataProvider, callback: CameraSession.Callback, - executor: Executor + executor: Executor, + path: String? ): PhotoFileInfo = suspendCancellableCoroutine { continuation -> // Shutter sound val shutterSound = if (enableShutterSound) MediaActionSound() else null shutterSound?.load(MediaActionSound.SHUTTER_CLICK) - val file = FileUtils.createTempFile(context, ".jpg") + val file = FileUtils.getDestinationFile(context, path, ".jpg") val outputFileOptionsBuilder = OutputFileOptions.Builder(file).also { options -> val metadata = ImageCapture.Metadata() metadataProvider.location?.let { location -> diff --git a/package/android/src/main/java/com/mrousavy/camera/core/types/RecordVideoOptions.kt b/package/android/src/main/java/com/mrousavy/camera/core/types/RecordVideoOptions.kt index 3f2fa7ed0a..5abfec6119 100644 --- a/package/android/src/main/java/com/mrousavy/camera/core/types/RecordVideoOptions.kt +++ b/package/android/src/main/java/com/mrousavy/camera/core/types/RecordVideoOptions.kt @@ -4,6 +4,7 @@ import com.facebook.react.bridge.ReadableMap class RecordVideoOptions(map: ReadableMap) { var fileType: VideoFileType = VideoFileType.MOV + var path: String? = null var videoCodec = VideoCodec.H264 var videoBitRateOverride: Double? = null var videoBitRateMultiplier: Double? = null @@ -12,6 +13,9 @@ class RecordVideoOptions(map: ReadableMap) { if (map.hasKey("fileType")) { fileType = VideoFileType.fromUnionValue(map.getString("fileType")) } + if (map.hasKey("path")) { + path = map.getString("path") + } if (map.hasKey("videoCodec")) { videoCodec = VideoCodec.fromUnionValue(map.getString("videoCodec")) } diff --git a/package/android/src/main/java/com/mrousavy/camera/core/types/SnapshotOptions.kt b/package/android/src/main/java/com/mrousavy/camera/core/types/SnapshotOptions.kt index e73c83a8f5..41c3da6b98 100644 --- a/package/android/src/main/java/com/mrousavy/camera/core/types/SnapshotOptions.kt +++ b/package/android/src/main/java/com/mrousavy/camera/core/types/SnapshotOptions.kt @@ -2,11 +2,12 @@ package com.mrousavy.camera.core.types import com.facebook.react.bridge.ReadableMap -data class SnapshotOptions(val quality: Int) { +data class SnapshotOptions(val quality: Int, val path: String?) { companion object { fun fromJSValue(options: ReadableMap): SnapshotOptions { - val quality = options.getInt("quality") - return SnapshotOptions(quality) + val quality = if (options.hasKey("quality")) options.getInt("quality") else 100 + val path = options.getString("path") + return SnapshotOptions(quality, path) } } } diff --git a/package/android/src/main/java/com/mrousavy/camera/core/utils/FileUtils.kt b/package/android/src/main/java/com/mrousavy/camera/core/utils/FileUtils.kt index 9c3de05514..6cf520664b 100644 --- a/package/android/src/main/java/com/mrousavy/camera/core/utils/FileUtils.kt +++ b/package/android/src/main/java/com/mrousavy/camera/core/utils/FileUtils.kt @@ -2,6 +2,8 @@ package com.mrousavy.camera.core.utils import android.content.Context import android.graphics.Bitmap +import android.net.Uri +import com.mrousavy.camera.core.InvalidPathError import java.io.File import java.io.FileOutputStream @@ -12,10 +14,36 @@ class FileUtils { it.deleteOnExit() } - fun writeBitmapTofile(bitmap: Bitmap, file: File, quality: Int = 100) { + fun writeBitmapTofile(bitmap: Bitmap, file: File, quality: Int) { FileOutputStream(file).use { stream -> bitmap.compress(Bitmap.CompressFormat.JPEG, quality, stream) } } + + fun getDestinationFile(context: Context, path: String?, fileExtension: String): File { + val destinationPath = Uri.parse(path).path + if (destinationPath != null) { + val destinationFile = File(destinationPath) + // Check if the directory exists + if (!destinationFile.parentFile.exists()) { + throw InvalidPathError("Directory does not exist: ${destinationFile.parentFile.path}") + } + // Check if the directory is a directory + if (!destinationFile.parentFile.isDirectory) { + throw InvalidPathError("Path directory is not a directory: ${destinationFile.parentFile.path}") + } + // Check if the directory is readable and writable + if (!destinationFile.parentFile.canRead() || !destinationFile.parentFile.canWrite()) { + throw InvalidPathError("Path directory is not readable or writable: ${destinationFile.parentFile.path}") + } + // Check if the path doesn't exist + if (destinationFile.exists()) { + throw InvalidPathError("File already exists at path: $path") + } + return destinationFile + } else { + return createTempFile(context, fileExtension) + } + } } } diff --git a/package/android/src/main/java/com/mrousavy/camera/react/CameraView+TakePhoto.kt b/package/android/src/main/java/com/mrousavy/camera/react/CameraView+TakePhoto.kt index 26253dd484..9ec2c3b2c6 100644 --- a/package/android/src/main/java/com/mrousavy/camera/react/CameraView+TakePhoto.kt +++ b/package/android/src/main/java/com/mrousavy/camera/react/CameraView+TakePhoto.kt @@ -20,7 +20,8 @@ suspend fun CameraView.takePhoto(optionsMap: ReadableMap): WritableMap { val photo = cameraSession.takePhoto( Flash.fromUnionValue(flash), enableShutterSound, - orientation + orientation, + options["path"] as? String ) Log.i(TAG, "Successfully captured ${photo.width} x ${photo.height} photo!") diff --git a/package/android/src/main/java/com/mrousavy/camera/react/CameraView+TakeSnapshot.kt b/package/android/src/main/java/com/mrousavy/camera/react/CameraView+TakeSnapshot.kt index 20bb1a4ada..8893e9b325 100644 --- a/package/android/src/main/java/com/mrousavy/camera/react/CameraView+TakeSnapshot.kt +++ b/package/android/src/main/java/com/mrousavy/camera/react/CameraView+TakeSnapshot.kt @@ -18,7 +18,7 @@ fun CameraView.takeSnapshot(options: SnapshotOptions): WritableMap { onShutter(ShutterType.SNAPSHOT) - val file = FileUtils.createTempFile(context, ".jpg") + val file = FileUtils.getDestinationFile(context, options.path, ".jpg") FileUtils.writeBitmapTofile(bitmap, file, options.quality) Log.i(TAG, "Successfully saved snapshot to file!") diff --git a/package/ios/Core/CameraError.swift b/package/ios/Core/CameraError.swift index fdd356238c..40ee1d9fd6 100644 --- a/package/ios/Core/CameraError.swift +++ b/package/ios/Core/CameraError.swift @@ -219,6 +219,7 @@ enum CaptureError { case timedOut case insufficientStorage case failedWritingMetadata(cause: Error?) + case invalidPath(message: String? = nil) case unknown(message: String? = nil) var code: String { @@ -251,6 +252,8 @@ enum CaptureError { return "insufficient-storage" case .failedWritingMetadata: return "failed-writing-metadata" + case .invalidPath: + return "invalid-path" case .unknown: return "unknown" } @@ -286,6 +289,8 @@ enum CaptureError { return "Failed to write video/photo metadata! (Cause: \(cause?.localizedDescription ?? "unknown"))" case .insufficientStorage: return "There is not enough storage space available." + case let .invalidPath(message: message): + return "The given path is invalid! \(message ?? "(no additional message)")" case let .unknown(message: message): return message ?? "An unknown error occured while capturing a video/photo." } diff --git a/package/ios/Core/CameraSession+Photo.swift b/package/ios/Core/CameraSession+Photo.swift index dfb7dc8224..7815ab3de9 100644 --- a/package/ios/Core/CameraSession+Photo.swift +++ b/package/ios/Core/CameraSession+Photo.swift @@ -81,8 +81,22 @@ extension CameraSession { // shutter sound let enableShutterSound = options["enableShutterSound"] as? Bool ?? true + // Destination URL + let destinationURL: URL + do { + destinationURL = try FileUtils.getDestinationURL(path: options["path"] as? String, fileExtension: "jpeg") + VisionLogger.log(level: .info, message: "Will record to file: \(destinationURL)") + } catch let error as CameraError { + promise.reject(error: error) + return + } catch { + promise.reject(error: .unknown(message: "An unknown error occurred while getting the destination URL.")) + return + } + // Actually do the capture! let photoCaptureDelegate = PhotoCaptureDelegate(promise: promise, + path: destinationURL, enableShutterSound: enableShutterSound, metadataProvider: self.metadataProvider, cameraSessionDelegate: self.delegate) diff --git a/package/ios/Core/CameraSession+Video.swift b/package/ios/Core/CameraSession+Video.swift index 4bae070c83..5d775836bd 100644 --- a/package/ios/Core/CameraSession+Video.swift +++ b/package/ios/Core/CameraSession+Video.swift @@ -86,21 +86,23 @@ extension CameraSession { } } - // Create temporary file - let errorPointer = ErrorPointer(nilLiteral: ()) + // Destination URL let fileExtension = options.fileType.descriptor ?? "mov" - guard let tempFilePath = RCTTempFilePath(fileExtension, errorPointer) else { - let message = errorPointer?.pointee?.description - onError(.capture(.createTempFileError(message: message))) + let destinationURL: URL + do { + destinationURL = try FileUtils.getDestinationURL(path: options.path, fileExtension: fileExtension) + VisionLogger.log(level: .info, message: "Will record to file: \(destinationURL)") + } catch let error as CameraError { + onError(error) + return + } catch { + onError(.unknown(message: "An unknown error occurred while getting the destination URL.")) return } - VisionLogger.log(level: .info, message: "Will record to temporary file: \(tempFilePath)") - let tempURL = URL(string: "file://\(tempFilePath)")! - do { // Create RecordingSession for the temp file - let recordingSession = try RecordingSession(url: tempURL, + let recordingSession = try RecordingSession(url: destinationURL, fileType: options.fileType, metadataProvider: self.metadataProvider, completion: onFinish) diff --git a/package/ios/Core/PhotoCaptureDelegate.swift b/package/ios/Core/PhotoCaptureDelegate.swift index 205cc34361..990fb2e59f 100644 --- a/package/ios/Core/PhotoCaptureDelegate.swift +++ b/package/ios/Core/PhotoCaptureDelegate.swift @@ -12,15 +12,18 @@ import AVFoundation class PhotoCaptureDelegate: GlobalReferenceHolder, AVCapturePhotoCaptureDelegate { private let promise: Promise + private let path: URL private let enableShutterSound: Bool private let cameraSessionDelegate: CameraSessionDelegate? private let metadataProvider: MetadataProvider required init(promise: Promise, + path: URL, enableShutterSound: Bool, metadataProvider: MetadataProvider, cameraSessionDelegate: CameraSessionDelegate?) { self.promise = promise + self.path = path self.enableShutterSound = enableShutterSound self.metadataProvider = metadataProvider self.cameraSessionDelegate = cameraSessionDelegate @@ -48,7 +51,7 @@ class PhotoCaptureDelegate: GlobalReferenceHolder, AVCapturePhotoCaptureDelegate } do { - let path = try FileUtils.writePhotoToTempFile(photo: photo, metadataProvider: metadataProvider) + try FileUtils.writePhotoToFile(photo: photo, path: path, metadataProvider: metadataProvider) let exif = photo.metadata["{Exif}"] as? [String: Any] let width = exif?["PixelXDimension"] diff --git a/package/ios/Core/Types/RecordVideoOptions.swift b/package/ios/Core/Types/RecordVideoOptions.swift index 09072e0275..d2b3bd08e9 100644 --- a/package/ios/Core/Types/RecordVideoOptions.swift +++ b/package/ios/Core/Types/RecordVideoOptions.swift @@ -11,6 +11,7 @@ import Foundation struct RecordVideoOptions { var fileType: AVFileType = .mov + var path: String? var flash: Torch = .off var codec: AVVideoCodecType? /** @@ -28,6 +29,10 @@ struct RecordVideoOptions { if let fileTypeOption = dictionary["fileType"] as? String { fileType = try AVFileType(withString: fileTypeOption) } + // Path + if let pathOption = dictionary["path"] as? String { + path = pathOption + } // Flash if let flashOption = dictionary["flash"] as? String { flash = try Torch(jsValue: flashOption) diff --git a/package/ios/Core/Utils/FileUtils.swift b/package/ios/Core/Utils/FileUtils.swift index fc19276558..5e753c6314 100644 --- a/package/ios/Core/Utils/FileUtils.swift +++ b/package/ios/Core/Utils/FileUtils.swift @@ -12,34 +12,57 @@ import Foundation import UIKit enum FileUtils { - /** - Writes Data to a temporary file and returns the file path. - */ - private static func writeDataToTempFile(data: Data, fileExtension: String = "jpeg") throws -> URL { - let filename = UUID().uuidString + "." + fileExtension - let tempFilePath = FileManager.default.temporaryDirectory - .appendingPathComponent(filename) + private static func writeDataToFile(data: Data, path: URL) throws { do { - try data.write(to: tempFilePath) + try data.write(to: path) } catch { throw CameraError.capture(.fileError(cause: error)) } - return tempFilePath } - static func writePhotoToTempFile(photo: AVCapturePhoto, metadataProvider: MetadataProvider) throws -> URL { + private static func getTemporaryFileURL(fileExtension: String = "jpeg") -> URL { + let filename = UUID().uuidString + "." + fileExtension + return FileManager.default.temporaryDirectory.appendingPathComponent(filename) + } + + static func writePhotoToFile(photo: AVCapturePhoto, path:URL, metadataProvider: MetadataProvider) throws { guard let data = photo.fileDataRepresentation(with: metadataProvider) else { throw CameraError.capture(.imageDataAccessError) } - let path = try writeDataToTempFile(data: data) - return path + try writeDataToFile(data: data, path: path) } - static func writeUIImageToTempFile(image: UIImage, compressionQuality: CGFloat = 1.0) throws -> URL { + static func writeUIImageToFile(image: UIImage, path: URL, compressionQuality: CGFloat = 1.0) throws { guard let data = image.jpegData(compressionQuality: compressionQuality) else { throw CameraError.capture(.imageDataAccessError) } - let path = try writeDataToTempFile(data: data) - return path + try writeDataToFile(data: data, path: path) + } + + static func getDestinationURL(path: String?, fileExtension: String) throws -> URL { + if let path = path { + let destinationURL = URL(string: path.starts(with: "file://") ? path : "file://" + path)! + let directory = destinationURL.deletingLastPathComponent() + // Check if the directory exists + if !FileManager.default.fileExists(atPath: directory.path) { + throw CameraError.capture(.invalidPath(message: "Directory does not exist: \(directory.path)")) + } + // Check if the directory is a directory + var isDirectory: ObjCBool = false + if !FileManager.default.fileExists(atPath: directory.path, isDirectory: &isDirectory) || !isDirectory.boolValue { + throw CameraError.capture(.invalidPath(message: "Path directory is not a directory: \(directory.path)")) + } + // Check if the directory is readable and writable + if !FileManager.default.isReadableFile(atPath: directory.path) || !FileManager.default.isWritableFile(atPath: directory.path) { + throw CameraError.capture(.invalidPath(message: "Path directory is not readable or writable: \(directory.path)")) + } + // Check if the path doesn't exist + if FileManager.default.fileExists(atPath: path) { + throw CameraError.capture(.invalidPath(message: "File already exists at path: \(path)")) + } + return destinationURL + } else { + return getTemporaryFileURL(fileExtension: fileExtension) + } } } diff --git a/package/ios/React/CameraView+TakeSnapshot.swift b/package/ios/React/CameraView+TakeSnapshot.swift index d6ccbef3cd..dbbe98ef7b 100644 --- a/package/ios/React/CameraView+TakeSnapshot.swift +++ b/package/ios/React/CameraView+TakeSnapshot.swift @@ -10,7 +10,7 @@ import AVFoundation import UIKit extension CameraView { - func takeSnapshot(options _: NSDictionary, promise: Promise) { + func takeSnapshot(options: NSDictionary, promise: Promise) { // If video is not enabled, we won't get any buffers in onFrameListeners. abort it. guard video else { promise.reject(error: .capture(.videoNotEnabled)) @@ -40,7 +40,8 @@ extension CameraView { let ciImage = CIImage(cvPixelBuffer: imageBuffer) let image = UIImage(ciImage: ciImage, scale: 1.0, orientation: .up) do { - let path = try FileUtils.writeUIImageToTempFile(image: image) + let path = try FileUtils.getDestinationURL(path: options["path"] as? String, fileExtension: "jpeg") + try FileUtils.writeUIImageToFile(image: image, path: path) promise.resolve([ "path": path.absoluteString, "width": image.size.width, diff --git a/package/src/Camera.tsx b/package/src/Camera.tsx index 6024e00137..0e572176b1 100644 --- a/package/src/Camera.tsx +++ b/package/src/Camera.tsx @@ -109,7 +109,7 @@ export class Camera extends React.PureComponent { //#region View-specific functions (UIViewManager) /** - * Take a single photo and write it's content to a temporary file. + * Take a single photo. * * @throws {@linkcode CameraCaptureError} When any kind of error occured while capturing the photo. * Use the {@linkcode CameraCaptureError.code | code} property to get the actual error @@ -130,7 +130,7 @@ export class Camera extends React.PureComponent { } /** - * Captures a snapshot of the Camera view and write it's content to a temporary file. + * Captures a snapshot of the Camera view. * * - On iOS, `takeSnapshot` waits for a Frame from the video pipeline and therefore requires `video` to be enabled. * - On Android, `takeSnapshot` performs a GPU view screenshot from the preview view. @@ -315,7 +315,7 @@ export class Camera extends React.PureComponent { } /** - * Cancel the current video recording. The temporary video file will be deleted, + * Cancel the current video recording. The video file will be deleted, * and the `startRecording`'s `onRecordingError` callback will be invoked with a `capture/recording-canceled` error. * * @throws {@linkcode CameraCaptureError} When any kind of error occured while canceling the video recording. diff --git a/package/src/types/PhotoFile.ts b/package/src/types/PhotoFile.ts index 8bfb06cb04..79be293bb0 100644 --- a/package/src/types/PhotoFile.ts +++ b/package/src/types/PhotoFile.ts @@ -29,6 +29,10 @@ export interface TakePhotoOptions { * @default true */ enableShutterSound?: boolean + /** + * The path where the photo should be saved to. + */ + path?: string } /** diff --git a/package/src/types/Snapshot.ts b/package/src/types/Snapshot.ts index 25093751e2..c42a69e884 100644 --- a/package/src/types/Snapshot.ts +++ b/package/src/types/Snapshot.ts @@ -6,6 +6,10 @@ export interface TakeSnapshotOptions { * @default 100 */ quality?: number + /** + * The path where the snapshot should be saved to. + */ + path?: string } export type SnapshotFile = Pick diff --git a/package/src/types/VideoFile.ts b/package/src/types/VideoFile.ts index 28007f2d53..298d9d0709 100644 --- a/package/src/types/VideoFile.ts +++ b/package/src/types/VideoFile.ts @@ -41,6 +41,10 @@ export interface RecordVideoOptions { * @default 'normal' */ videoBitRate?: 'extra-low' | 'low' | 'normal' | 'high' | 'extra-high' | number + /** + * The path where the video should be saved to. + */ + path?: string } /**