308 lines
15 KiB
Kotlin
308 lines
15 KiB
Kotlin
package com.mrousavy.camera
|
|
|
|
import android.Manifest
|
|
import android.content.Context
|
|
import android.content.pm.PackageManager
|
|
import android.hardware.camera2.CameraCharacteristics
|
|
import android.hardware.camera2.CameraManager
|
|
import android.media.ImageReader
|
|
import android.media.MediaRecorder
|
|
import android.os.Build
|
|
import android.util.Log
|
|
import androidx.camera.core.CameraSelector
|
|
import androidx.camera.core.ImageCapture
|
|
import androidx.camera.extensions.HdrImageCaptureExtender
|
|
import androidx.camera.extensions.NightImageCaptureExtender
|
|
import androidx.core.content.ContextCompat
|
|
import com.mrousavy.camera.parsers.*
|
|
import com.mrousavy.camera.utils.*
|
|
import com.facebook.react.bridge.*
|
|
import com.facebook.react.modules.core.PermissionAwareActivity
|
|
import com.facebook.react.modules.core.PermissionListener
|
|
import kotlinx.coroutines.Dispatchers
|
|
import kotlinx.coroutines.GlobalScope
|
|
import kotlinx.coroutines.launch
|
|
|
|
class CameraViewModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
|
|
companion object {
|
|
const val REACT_CLASS = "CameraView"
|
|
var RequestCode = 10
|
|
|
|
fun parsePermissionStatus(status: Int): String {
|
|
return when(status) {
|
|
PackageManager.PERMISSION_DENIED -> "denied"
|
|
PackageManager.PERMISSION_GRANTED -> "authorized"
|
|
else -> "not-determined"
|
|
}
|
|
}
|
|
}
|
|
|
|
override fun getName(): String {
|
|
return REACT_CLASS
|
|
}
|
|
|
|
private fun findCameraView(id: Int): CameraView = reactApplicationContext.currentActivity?.findViewById(id) ?: throw ViewNotFoundError(id)
|
|
|
|
@ReactMethod
|
|
fun takePhoto(viewTag: Int, options: ReadableMap, promise: Promise) {
|
|
GlobalScope.launch(Dispatchers.Main) {
|
|
withPromise(promise) {
|
|
val view = findCameraView(viewTag)
|
|
view.takePhoto(options)
|
|
}
|
|
}
|
|
}
|
|
|
|
@ReactMethod
|
|
fun takeSnapshot(viewTag: Int, options: ReadableMap, promise: Promise) {
|
|
GlobalScope.launch(Dispatchers.Main) {
|
|
withPromise(promise) {
|
|
val view = findCameraView(viewTag)
|
|
view.takeSnapshot(options)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TODO: startRecording() cannot be awaited, because I can't have a Promise and a onRecordedCallback in the same function. Hopefully TurboModules allows that
|
|
@ReactMethod(isBlockingSynchronousMethod = true)
|
|
fun startRecording(viewTag: Int, options: ReadableMap, onRecordCallback: Callback) {
|
|
GlobalScope.launch(Dispatchers.Main) {
|
|
val view = findCameraView(viewTag)
|
|
view.startRecording(options, onRecordCallback)
|
|
}
|
|
}
|
|
|
|
@ReactMethod
|
|
fun stopRecording(viewTag: Int, promise: Promise) {
|
|
withPromise(promise) {
|
|
val view = findCameraView(viewTag)
|
|
view.stopRecording()
|
|
return@withPromise null
|
|
}
|
|
}
|
|
|
|
@ReactMethod
|
|
fun focus(viewTag: Int, point: ReadableMap, promise: Promise) {
|
|
GlobalScope.launch(Dispatchers.Main) {
|
|
withPromise(promise) {
|
|
val view = findCameraView(viewTag)
|
|
view.focus(point)
|
|
return@withPromise null
|
|
}
|
|
}
|
|
}
|
|
|
|
@ReactMethod
|
|
fun getAvailableVideoCodecs(viewTag: Int, promise: Promise) {
|
|
withPromise(promise) {
|
|
val view = findCameraView(viewTag)
|
|
view.getAvailableVideoCodecs()
|
|
}
|
|
}
|
|
|
|
@ReactMethod
|
|
fun getAvailablePhotoCodecs(viewTag: Int, promise: Promise) {
|
|
withPromise(promise) {
|
|
val view = findCameraView(viewTag)
|
|
view.getAvailablePhotoCodecs()
|
|
}
|
|
}
|
|
|
|
// TODO: This uses the Camera2 API to list all characteristics of a camera device and therefore doesn't work with Camera1. Find a way to use CameraX for this
|
|
@ReactMethod
|
|
fun getAvailableCameraDevices(promise: Promise) {
|
|
withPromise(promise) {
|
|
val manager = reactApplicationContext.getSystemService(Context.CAMERA_SERVICE) as? CameraManager
|
|
?: throw CameraManagerUnavailableError()
|
|
|
|
val cameraDevices: WritableArray = Arguments.createArray()
|
|
|
|
manager.cameraIdList.forEach loop@{ id ->
|
|
val cameraSelector = CameraSelector.Builder().byID(id).build()
|
|
// TODO: ImageCapture.Builder - I'm not setting the target resolution, does that matter?
|
|
val imageCaptureBuilder = ImageCapture.Builder()
|
|
|
|
val characteristics = manager.getCameraCharacteristics(id)
|
|
val capabilities = characteristics.get(CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES)!!
|
|
|
|
// Check if the device is actually a "basic camera" (i.e. filter out depth-only sensors)
|
|
if (!capabilities.contains(CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES_BACKWARD_COMPATIBLE)) {
|
|
Log.d(REACT_CLASS, "Skipping Camera #${id} because it does not support backward compatible capabilities..")
|
|
return@loop
|
|
}
|
|
|
|
val isMultiCam = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P &&
|
|
capabilities.contains(CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES_LOGICAL_MULTI_CAMERA)
|
|
val deviceTypes = characteristics.getDeviceTypes()
|
|
|
|
val cameraConfig = characteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP)!!
|
|
val lensFacing = characteristics.get(CameraCharacteristics.LENS_FACING)!!
|
|
val hasFlash = characteristics.get(CameraCharacteristics.FLASH_INFO_AVAILABLE)!!
|
|
val maxScalerZoom = characteristics.get(CameraCharacteristics.SCALER_AVAILABLE_MAX_DIGITAL_ZOOM)!!
|
|
val supportsDepthCapture = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M &&
|
|
capabilities.contains(CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES_DEPTH_OUTPUT)
|
|
val supportsRawCapture = capabilities.contains(CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES_RAW)
|
|
val isoRange = characteristics.get(CameraCharacteristics.SENSOR_INFO_SENSITIVITY_RANGE)
|
|
val stabilizationModes = characteristics.get(CameraCharacteristics.CONTROL_AVAILABLE_VIDEO_STABILIZATION_MODES)!! // only digital, no optical
|
|
val zoomRange = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R)
|
|
characteristics.get(CameraCharacteristics.CONTROL_ZOOM_RATIO_RANGE)
|
|
else null
|
|
val name = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P)
|
|
characteristics.get(CameraCharacteristics.INFO_VERSION)
|
|
else null
|
|
val fpsRanges = characteristics.get(CameraCharacteristics.CONTROL_AE_AVAILABLE_TARGET_FPS_RANGES)!!
|
|
|
|
var supportsHdr = false
|
|
var supportsLowLightBoost = false
|
|
try {
|
|
val hdrExtension = HdrImageCaptureExtender.create(imageCaptureBuilder)
|
|
supportsHdr = hdrExtension.isExtensionAvailable(cameraSelector)
|
|
|
|
val nightExtension = NightImageCaptureExtender.create(imageCaptureBuilder)
|
|
supportsLowLightBoost = nightExtension.isExtensionAvailable(cameraSelector)
|
|
} catch (e: Throwable) {
|
|
// error on checking availability. falls back to "false"
|
|
Log.e(REACT_CLASS, "Failed to check HDR/Night Mode extension availability.", e)
|
|
}
|
|
|
|
val fieldOfView = characteristics.getFieldOfView()
|
|
|
|
val map = Arguments.createMap()
|
|
val formats = Arguments.createArray()
|
|
map.putString("id", id)
|
|
map.putArray("devices", deviceTypes)
|
|
map.putString("position", parseLensFacing(lensFacing))
|
|
map.putString("name", name ?: "${parseLensFacing(lensFacing)} ($id)")
|
|
map.putBoolean("hasFlash", hasFlash)
|
|
map.putBoolean("hasTorch", hasFlash)
|
|
map.putBoolean("isMultiCam", isMultiCam)
|
|
map.putBoolean("supportsRawCapture", supportsRawCapture)
|
|
map.putBoolean("supportsDepthCapture", supportsDepthCapture)
|
|
map.putBoolean("supportsLowLightBoost", supportsLowLightBoost)
|
|
if (zoomRange != null) {
|
|
map.putDouble("minZoom", zoomRange.lower.toDouble())
|
|
map.putDouble("maxZoom", zoomRange.upper.toDouble())
|
|
} else {
|
|
map.putDouble("minZoom", 1.0)
|
|
map.putDouble("maxZoom", maxScalerZoom.toDouble())
|
|
}
|
|
map.putDouble("neutralZoom", characteristics.neutralZoomPercent.toDouble())
|
|
|
|
val maxImageOutputSize = cameraConfig.getOutputSizes(ImageReader::class.java).maxByOrNull { it.width * it.height }!!
|
|
|
|
// TODO: Should I really check MediaRecorder::class instead of SurfaceView::class?
|
|
// Recording should always be done in the most efficient format, which is the format native to the camera framework
|
|
cameraConfig.getOutputSizes(MediaRecorder::class.java).forEach { size ->
|
|
val isHighestPhotoQualitySupported = areUltimatelyEqual(size, maxImageOutputSize)
|
|
|
|
// Get the number of seconds that each frame will take to process
|
|
val secondsPerFrame = cameraConfig.getOutputMinFrameDuration(MediaRecorder::class.java, size) / 1_000_000_000.0
|
|
|
|
val frameRateRanges = Arguments.createArray()
|
|
if (secondsPerFrame > 0) {
|
|
val fps = (1.0 / secondsPerFrame).toInt()
|
|
val frameRateRange = Arguments.createMap()
|
|
frameRateRange.putInt("minFrameRate", 1)
|
|
frameRateRange.putInt("maxFrameRate", fps)
|
|
frameRateRanges.pushMap(frameRateRange)
|
|
}
|
|
fpsRanges.forEach { range ->
|
|
val frameRateRange = Arguments.createMap()
|
|
frameRateRange.putInt("minFrameRate", range.lower)
|
|
frameRateRange.putInt("maxFrameRate", range.upper)
|
|
frameRateRanges.pushMap(frameRateRange)
|
|
}
|
|
|
|
// TODO Revisit getAvailableCameraDevices (colorSpaces, more than YUV?)
|
|
val colorSpaces = Arguments.createArray()
|
|
colorSpaces.pushString("yuv")
|
|
|
|
// TODO Revisit getAvailableCameraDevices (more accurate video stabilization modes)
|
|
val videoStabilizationModes = Arguments.createArray()
|
|
if (stabilizationModes.contains(CameraCharacteristics.CONTROL_VIDEO_STABILIZATION_MODE_OFF))
|
|
videoStabilizationModes.pushString("off")
|
|
if (stabilizationModes.contains(CameraCharacteristics.CONTROL_VIDEO_STABILIZATION_MODE_ON)) {
|
|
videoStabilizationModes.pushString("auto")
|
|
videoStabilizationModes.pushString("standard")
|
|
}
|
|
|
|
val format = Arguments.createMap()
|
|
format.putDouble("photoHeight", size.height.toDouble())
|
|
format.putDouble("photoWidth", size.width.toDouble())
|
|
format.putDouble("videoHeight", size.height.toDouble()) // TODO: Revisit getAvailableCameraDevices (videoHeight == photoHeight?)
|
|
format.putDouble("videoWidth", size.width.toDouble()) // TODO: Revisit getAvailableCameraDevices (videoWidth == photoWidth?)
|
|
format.putBoolean("isHighestPhotoQualitySupported", isHighestPhotoQualitySupported)
|
|
format.putInt("maxISO", isoRange?.upper)
|
|
format.putInt("minISO", isoRange?.lower)
|
|
format.putDouble("fieldOfView", fieldOfView) // TODO: Revisit getAvailableCameraDevices (is fieldOfView accurate?)
|
|
format.putDouble("maxZoom", (zoomRange?.upper ?: maxScalerZoom).toDouble())
|
|
format.putArray("colorSpaces", colorSpaces)
|
|
format.putBoolean("supportsVideoHDR", false) // TODO: supportsVideoHDR
|
|
format.putBoolean("supportsPhotoHDR", supportsHdr)
|
|
format.putArray("frameRateRanges", frameRateRanges)
|
|
format.putString("autoFocusSystem", "none") // TODO: Revisit getAvailableCameraDevices (autoFocusSystem) (CameraCharacteristics.CONTROL_AF_AVAILABLE_MODES or CameraCharacteristics.LENS_INFO_FOCUS_DISTANCE_CALIBRATION)
|
|
format.putArray("videoStabilizationModes", videoStabilizationModes)
|
|
formats.pushMap(format)
|
|
}
|
|
|
|
map.putArray("formats", formats)
|
|
cameraDevices.pushMap(map)
|
|
}
|
|
|
|
return@withPromise cameraDevices
|
|
}
|
|
}
|
|
|
|
@ReactMethod
|
|
fun getCameraPermissionStatus(promise: Promise) {
|
|
val status = ContextCompat.checkSelfPermission(reactApplicationContext, Manifest.permission.CAMERA)
|
|
promise.resolve(parsePermissionStatus(status))
|
|
}
|
|
|
|
@ReactMethod
|
|
fun getMicrophonePermissionStatus(promise: Promise) {
|
|
val status = ContextCompat.checkSelfPermission(reactApplicationContext, Manifest.permission.RECORD_AUDIO)
|
|
promise.resolve(parsePermissionStatus(status))
|
|
}
|
|
|
|
@ReactMethod
|
|
fun requestCameraPermission(promise: Promise) {
|
|
val activity = reactApplicationContext.currentActivity
|
|
if (activity is PermissionAwareActivity) {
|
|
val currentRequestCode = RequestCode
|
|
RequestCode++
|
|
val listener = PermissionListener { requestCode: Int, _: Array<String>, grantResults: IntArray ->
|
|
if (requestCode == currentRequestCode) {
|
|
val permissionStatus = grantResults[0]
|
|
promise.resolve(parsePermissionStatus(permissionStatus))
|
|
return@PermissionListener true
|
|
}
|
|
return@PermissionListener false
|
|
}
|
|
activity.requestPermissions(arrayOf(Manifest.permission.CAMERA), currentRequestCode, listener)
|
|
} else {
|
|
promise.reject("NO_ACTIVITY", "No PermissionAwareActivity was found! Make sure the app has launched before calling this function.")
|
|
}
|
|
}
|
|
|
|
@ReactMethod
|
|
fun requestMicrophonePermission(promise: Promise) {
|
|
val activity = reactApplicationContext.currentActivity
|
|
if (activity is PermissionAwareActivity) {
|
|
val currentRequestCode = RequestCode
|
|
RequestCode++
|
|
val listener = PermissionListener { requestCode: Int, _: Array<String>, grantResults: IntArray ->
|
|
if (requestCode == currentRequestCode) {
|
|
val permissionStatus = grantResults[0]
|
|
promise.resolve(parsePermissionStatus(permissionStatus))
|
|
return@PermissionListener true
|
|
}
|
|
return@PermissionListener false
|
|
}
|
|
activity.requestPermissions(arrayOf(Manifest.permission.RECORD_AUDIO), currentRequestCode, listener)
|
|
} else {
|
|
promise.reject("NO_ACTIVITY", "No PermissionAwareActivity was found! Make sure the app has launched before calling this function.")
|
|
}
|
|
}
|
|
}
|