package com.margelo.nitro.one import android.Manifest import android.app.Activity import android.content.ActivityNotFoundException import android.content.Intent import android.content.pm.PackageManager import android.graphics.Bitmap import android.graphics.BitmapFactory import android.media.MediaMetadataRetriever import android.net.Uri import android.os.Build import android.provider.MediaStore import android.provider.OpenableColumns import android.webkit.MimeTypeMap import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts.PickMultipleVisualMedia import androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import androidx.core.content.FileProvider import com.facebook.react.bridge.ActivityEventListener import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.modules.core.PermissionAwareActivity import com.facebook.react.modules.core.PermissionListener import com.margelo.nitro.NitroModules import com.margelo.nitro.core.Promise import dev.onejs.onenative.OneImageOrientation import dev.onejs.onenative.OneNativeCropActivity import java.io.File import java.io.FileOutputStream import java.util.UUID // imperative image picker: the system photo picker for the library and a // camera capture intent for photos. the system picker needs no permission; // the camera needs the CAMERA permission, which one prebuild writes from // native.app imagePicker. picked assets are copied into the app cache and // returned as file uris. backing out, a denied permission, and a missing // camera all resolve canceled; only runtime failures reject. calls arrive on // the js thread and results on the ui thread, so the pending slot is only // taken or settled under the lock. class HybridOneImagePicker : HybridOneImagePickerSpec(), ActivityEventListener, PermissionListener { private val lock = Any() private var pendingPickerPromise: Promise? = null private var pendingPickerVerb: String = "launchLibrary" private var pendingPermissionPromise: Promise? = null private var pendingLimit: Int = 1 private var pendingSingle: PickVisualMedia? = null private var pendingMultiple: PickMultipleVisualMedia? = null private var pendingUsedPicker: Boolean = false private var pendingCameraFile: File? = null private var pendingCameraVideo: Boolean = false private var pendingEdit: Boolean = false private var pendingAspectX: Int = 0 private var pendingAspectY: Int = 0 private var pendingQuality: Int = 100 private var pendingVideoMaxDuration: Long = 0 private var pendingVideoHighQuality: Boolean = true private val context: ReactApplicationContext get() = NitroModules.applicationContext ?: throw IllegalStateException("ImagePicker: the react context is not ready") init { NitroModules.applicationContext?.addActivityEventListener(this) } override fun dispose() { NitroModules.applicationContext?.removeActivityEventListener(this) val picker: Promise? val permission: Promise? val verb: String synchronized(lock) { picker = pendingPickerPromise permission = pendingPermissionPromise verb = pendingPickerVerb clearPickerPending() pendingPermissionPromise = null } picker?.reject(OneNativeError(E_FAILED, "ImagePicker.$verb: torn down mid-request")) permission?.reject(OneNativeError(E_FAILED, "ImagePicker.requestCameraPermissions: torn down mid-request")) super.dispose() } // a hybrid object has no lifecycle owner to register activity result // launchers against, so it launches intents the classic way and reads // results through the activity event listener. @Suppress("DEPRECATION") override fun launchLibrary(options: ResolvedImagePickerOptions): Promise { val promise = Promise() if (!takePickerPending("launchLibrary", promise)) return promise val activity = NitroModules.applicationContext?.currentActivity if (activity == null) { rejectPickerPending("launchLibrary", "found no activity to present from") return promise } val allowsImages = options.mediaTypes.contains(ImagePickerMediaType.IMAGES) val allowsVideos = options.mediaTypes.contains(ImagePickerMediaType.VIDEOS) if (!allowsImages && !allowsVideos) { // unreachable from the js entries, which validate first; settle // rather than hang a direct caller. rejectPickerPending("launchLibrary", "mediaTypes must list at least one media type") return promise } // zero is unlimited on both sides, so the value passes through. val selectionLimit = options.selectionLimit.toInt() pendingLimit = selectionLimit // the crop UI edits one image: js rejects anything else first, so a // direct caller reaching here with more settles rather than hangs. if (options.allowsEditing) { val singleImage = options.mediaTypes.contains(ImagePickerMediaType.IMAGES) && !options.mediaTypes.contains(ImagePickerMediaType.VIDEOS) && selectionLimit == 1 if (!singleImage) { rejectPickerPending("launchLibrary", "allowsEditing only supports one image") return promise } pendingEdit = true pendingAspectX = options.aspect?.x?.toInt() ?: 0 pendingAspectY = options.aspect?.y?.toInt() ?: 0 } pendingQuality = ((options.quality) * 100).toInt().coerceIn(0, 100) val mediaType = when { allowsImages && allowsVideos -> PickVisualMedia.ImageAndVideo allowsVideos -> PickVisualMedia.VideoOnly else -> PickVisualMedia.ImageOnly } try { if (PickVisualMedia.isPhotoPickerAvailable(activity)) { pendingUsedPicker = true val request = PickVisualMediaRequest.Builder().setMediaType(mediaType).build() val intent = if (selectionLimit == 1) { val contract = PickVisualMedia() pendingSingle = contract contract.createIntent(activity, request) } else { val contract = PickMultipleVisualMedia(effectiveMax(selectionLimit)) pendingMultiple = contract contract.createIntent(activity, request) } activity.startActivityForResult(intent, REQUEST_LIBRARY, null) } else { pendingUsedPicker = false activity.startActivityForResult( documentIntent(allowsImages, allowsVideos, selectionLimit), REQUEST_LIBRARY, null ) } } catch (e: ActivityNotFoundException) { // the availability check passed but nothing handles the intent // (or the documents app is missing): one fallback attempt, then out. if (pendingUsedPicker) { try { pendingUsedPicker = false pendingSingle = null pendingMultiple = null activity.startActivityForResult( documentIntent(allowsImages, allowsVideos, selectionLimit), REQUEST_LIBRARY, null ) } catch (fallback: ActivityNotFoundException) { rejectPickerPending("launchLibrary", "found no picker on this device") } } else { rejectPickerPending("launchLibrary", "found no picker on this device") } } catch (e: Exception) { rejectPickerPending("launchLibrary", e.message ?: "could not open the picker") } return promise } override fun launchCamera(options: ResolvedImagePickerOptions): Promise { val promise = Promise() if (!takePickerPending("launchCamera", promise)) return promise val context = context val activity = context.currentActivity if (activity == null) { rejectPickerPending("launchCamera", "found no activity to present from") return promise } // a photo unless mediaTypes is videos-only, which records one video; // mixed requests capture a photo on both platforms. val wantsVideo = options.mediaTypes.contains(ImagePickerMediaType.VIDEOS) && !options.mediaTypes.contains(ImagePickerMediaType.IMAGES) if (options.allowsEditing && wantsVideo) { // unreachable from the js entries, which validate first. rejectPickerPending("launchCamera", "allowsEditing only supports images") return promise } pendingCameraVideo = wantsVideo pendingEdit = options.allowsEditing pendingAspectX = options.aspect?.x?.toInt() ?: 0 pendingAspectY = options.aspect?.y?.toInt() ?: 0 pendingQuality = (options.quality * 100).toInt().coerceIn(0, 100) pendingVideoMaxDuration = options.videoMaxDuration.toLong() pendingVideoHighQuality = options.videoQuality == "high" // refusing and missing hardware are ordinary outcomes: both resolve // canceled, like backing out of the picker. if (!context.packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY)) { resolvePickerCanceled() return promise } if (!isCameraDeclared()) { rejectPickerPending( "launchCamera", "camera needs the CAMERA permission: set native.app imagePicker.camera " + "and rerun one prebuild" ) return promise } if ( ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED ) { startCapture(activity) return promise } if (cameraRationale(activity) || cameraAskedBefore(activity)) { // denied before, askable or permanent: stay quiet and resolve // canceled rather than nag on every launch. the explicit // requestCameraPermissions call is the way back. resolvePickerCanceled() return promise } // undetermined: ask once, then capture or cancel on the answer. val aware = activity as? PermissionAwareActivity if (aware == null) { rejectPickerPending("launchCamera", "cannot request the camera permission here") return promise } markCameraAsked() aware.requestPermissions( arrayOf(Manifest.permission.CAMERA), REQUEST_CAMERA_PERMISSION, this ) return promise } override fun getCameraPermissions(): Promise { // a read, never a prompt; outside the pending slot so it answers during a pick. return Promise.resolved(cameraPermissionResponse()) } override fun requestCameraPermissions(): Promise { val context = context if (!isCameraDeclared()) { return Promise.rejected( OneNativeError(E_FAILED, "ImagePicker.requestCameraPermissions: camera needs the CAMERA permission: " + "set native.app imagePicker.camera and rerun one prebuild" ) ) } if ( ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED ) { return Promise.resolved(cameraPermissionResponse()) } val activity = context.currentActivity val aware = activity as? PermissionAwareActivity if (activity == null || aware == null) { return Promise.rejected( OneNativeError(E_FAILED, "ImagePicker.requestCameraPermissions: found no activity to prompt from") ) } val promise = Promise() synchronized(lock) { if (pendingPermissionPromise != null) { return Promise.rejected( OneNativeError(E_FAILED, "ImagePicker.requestCameraPermissions: another request is already in flight" ) ) } pendingPermissionPromise = promise } markCameraAsked() aware.requestPermissions( arrayOf(Manifest.permission.CAMERA), REQUEST_PERMISSION_CALL, this ) return promise } override fun onRequestPermissionsResult( requestCode: Int, permissions: Array, grantResults: IntArray ): Boolean { val granted = grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED if (requestCode == REQUEST_PERMISSION_CALL) { val pending = synchronized(lock) { val pending = pendingPermissionPromise pendingPermissionPromise = null pending } pending?.resolve(cameraPermissionResponse()) return true } if (requestCode != REQUEST_CAMERA_PERMISSION) return false if (granted) { val activity = context.currentActivity if (activity == null) { rejectPickerPending("launchCamera", "found no activity to present from") } else { startCapture(activity) } } else { resolvePickerCanceled() } return true } override fun onActivityResult( activity: Activity, requestCode: Int, resultCode: Int, data: Intent? ) { if (requestCode == REQUEST_LIBRARY) { handleLibraryResult(resultCode, data) } else if (requestCode == REQUEST_CAMERA) { handleCameraResult(activity, resultCode) } else if (requestCode == REQUEST_CROP) { handleCropResult(resultCode, data) } } override fun onNewIntent(intent: Intent) {} // granted, or denied with rationale: both are known states. otherwise // the asked flag separates the undetermined first run from a permanent // denial, which the platform reports identically. private fun cameraPermissionResponse(): CameraPermissionResponse { val context = context val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED val activity = context.currentActivity val (status, canAskAgain) = when { granted -> Pair(CameraPermissionStatus.GRANTED, true) activity != null && cameraRationale(activity) -> Pair(CameraPermissionStatus.DENIED, true) activity != null && cameraAskedBefore(activity) -> Pair(CameraPermissionStatus.DENIED, false) activity == null && cameraAskedBefore(null) -> Pair(CameraPermissionStatus.DENIED, false) else -> Pair(CameraPermissionStatus.UNDETERMINED, true) } return CameraPermissionResponse(status, granted, canAskAgain) } private fun cameraRationale(activity: Activity): Boolean { return ActivityCompat.shouldShowRequestPermissionRationale( activity, Manifest.permission.CAMERA ) } private fun cameraAskedKey(): String = "one-native-image-picker.camera-asked" private fun cameraAskedBefore(activity: Activity?): Boolean { val context = activity ?: context return context .getSharedPreferences("one-native-image-picker", Activity.MODE_PRIVATE) .getBoolean(cameraAskedKey(), false) } private fun markCameraAsked() { context .getSharedPreferences("one-native-image-picker", Activity.MODE_PRIVATE) .edit() .putBoolean(cameraAskedKey(), true) .apply() } private fun isCameraDeclared(): Boolean { val context = context val info = try { if (Build.VERSION.SDK_INT >= 33) { context.packageManager.getPackageInfo( context.packageName, PackageManager.PackageInfoFlags.of( PackageManager.GET_PERMISSIONS.toLong() ) ) } else { @Suppress("DEPRECATION") context.packageManager.getPackageInfo( context.packageName, PackageManager.GET_PERMISSIONS ) } } catch (e: Exception) { return false } return info.requestedPermissions?.contains(Manifest.permission.CAMERA) == true } private fun startCapture(activity: Activity) { if (pendingCameraVideo) { startVideoCapture(activity) } else { startCamera(activity) } } @Suppress("DEPRECATION") private fun startCamera(activity: Activity) { val context = context try { val file = cacheFile("IMG", "jpg") val uri = FileProvider.getUriForFile( context, "${context.packageName}.one-native.fileprovider", file ) val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE).apply { putExtra(MediaStore.EXTRA_OUTPUT, uri) addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION) } for (resolved in context.packageManager.queryIntentActivities( intent, PackageManager.MATCH_DEFAULT_ONLY )) { context.grantUriPermission( resolved.activityInfo.packageName, uri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION ) } pendingCameraFile = file activity.startActivityForResult(intent, REQUEST_CAMERA, null) } catch (e: ActivityNotFoundException) { pendingCameraFile = null resolvePickerCanceled() } catch (e: Exception) { pendingCameraFile = null rejectPickerPending("launchCamera", e.message ?: "could not open the camera") } } @Suppress("DEPRECATION") private fun startVideoCapture(activity: Activity) { val context = context try { val file = cacheFile("VID", "mp4") val uri = FileProvider.getUriForFile( context, "${context.packageName}.one-native.fileprovider", file ) val intent = Intent(MediaStore.ACTION_VIDEO_CAPTURE).apply { putExtra(MediaStore.EXTRA_OUTPUT, uri) addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION) if (pendingVideoMaxDuration > 0) { putExtra(MediaStore.EXTRA_DURATION_LIMIT, pendingVideoMaxDuration.toInt()) } putExtra( MediaStore.EXTRA_VIDEO_QUALITY, if (pendingVideoHighQuality) 1 else 0 ) } for (resolved in context.packageManager.queryIntentActivities( intent, PackageManager.MATCH_DEFAULT_ONLY )) { context.grantUriPermission( resolved.activityInfo.packageName, uri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION ) } pendingCameraFile = file activity.startActivityForResult(intent, REQUEST_CAMERA, null) } catch (e: ActivityNotFoundException) { pendingCameraFile = null resolvePickerCanceled() } catch (e: Exception) { pendingCameraFile = null rejectPickerPending("launchCamera", e.message ?: "could not open the camera") } } private fun startCrop(activity: Activity, file: File) { try { val intent = Intent(context, OneNativeCropActivity::class.java).apply { putExtra(OneNativeCropActivity.EXTRA_INPUT_PATH, file.absolutePath) putExtra(OneNativeCropActivity.EXTRA_ASPECT_X, pendingAspectX) putExtra(OneNativeCropActivity.EXTRA_ASPECT_Y, pendingAspectY) putExtra(OneNativeCropActivity.EXTRA_QUALITY, pendingQuality) } activity.startActivityForResult(intent, REQUEST_CROP, null) } catch (e: Exception) { val verb = synchronized(lock) { pendingPickerVerb } rejectPickerPending(verb, e.message ?: "could not open the crop editor") } } private fun handleLibraryResult(resultCode: Int, data: Intent?) { val single = pendingSingle val multiple = pendingMultiple val sourceUris = when { single != null -> single.parseResult(resultCode, data)?.let { listOf(it) } ?: emptyList() multiple != null -> multiple.parseResult(resultCode, data) else -> fallbackUris(resultCode, data) } if (sourceUris.isEmpty()) { resolvePickerCanceled() return } val limited = if (pendingLimit > 0) sourceUris.take(pendingLimit) else sourceUris try { if (pendingEdit) { // single image, guaranteed by the launch checks: the crop // screen owns the pending promise from here. val activity = context.currentActivity if (activity == null) { rejectPickerPending("launchLibrary", "found no activity to present from") return } val (_, file) = copyUriToCache(limited.first()) startCrop(activity, file) return } resolvePickerPending(limited.map { copyToCache(it) }.toTypedArray()) } catch (e: Exception) { rejectPickerPending("launchLibrary", e.message ?: "could not copy a picked item") } } private fun fallbackUris(resultCode: Int, data: Intent?): List { if (resultCode != Activity.RESULT_OK || data == null) return emptyList() val clip = data.clipData if (clip != null) { val uris = mutableListOf() for (index in 0 until clip.itemCount) { clip.getItemAt(index).uri?.let { uris.add(it) } } return uris } return data.data?.let { listOf(it) } ?: emptyList() } private fun handleCameraResult(activity: Activity, resultCode: Int) { val file = pendingCameraFile pendingCameraFile = null if (resultCode != Activity.RESULT_OK || file == null || !file.exists() || file.length() == 0L) { file?.delete() resolvePickerCanceled() return } try { if (pendingCameraVideo) { val size = videoSize(file) val duration = videoDurationMs(file) resolvePickerPending( arrayOf( ImagePickerAsset( Uri.fromFile(file).toString(), size.first.toDouble(), size.second.toDouble(), "video/mp4", file.name, file.length().toDouble(), duration?.toDouble(), ImagePickerAssetType.VIDEO ) ) ) return } if (pendingEdit) { // the crop screen owns the pending promise from here. startCrop(activity, file) return } val sized = applyQuality(file) val size = imageSize(sized) resolvePickerPending( arrayOf( ImagePickerAsset( Uri.fromFile(sized).toString(), size.first.toDouble(), size.second.toDouble(), "image/jpeg", sized.name, sized.length().toDouble(), null, ImagePickerAssetType.IMAGE ) ) ) } catch (e: Exception) { file.delete() rejectPickerPending("launchCamera", e.message ?: "could not read the capture") } } private fun handleCropResult(resultCode: Int, data: Intent?) { val verb = synchronized(lock) { pendingPickerVerb } if (resultCode != Activity.RESULT_OK) { resolvePickerCanceled() return } val path = data?.getStringExtra(OneNativeCropActivity.EXTRA_OUTPUT_PATH) val file = if (path != null) File(path) else null if (file == null || !file.exists() || file.length() == 0L) { file?.delete() rejectPickerPending(verb, "the crop returned no image") return } try { val size = imageSize(file) resolvePickerPending( arrayOf( ImagePickerAsset( Uri.fromFile(file).toString(), size.first.toDouble(), size.second.toDouble(), "image/jpeg", file.name, file.length().toDouble(), null, ImagePickerAssetType.IMAGE ) ) ) } catch (e: Exception) { file.delete() rejectPickerPending(verb, e.message ?: "could not read the crop") } } private fun copyToCache(source: Uri): ImagePickerAsset { return copyUriToCache(source).first } private fun copyUriToCache(source: Uri): Pair { val resolver = context.contentResolver val mimeType = resolver.getType(source) val isVideo = mimeType?.startsWith("video/") == true val displayName = queryDisplayName(source) val extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType) ?: displayName?.substringAfterLast('.', "")?.takeIf { it.isNotEmpty() && it.length <= 5 && !it.contains('/') } ?: "dat" var dest = cacheFile(if (isVideo) "VID" else "IMG", extension) (resolver.openInputStream(source) ?: throw IllegalStateException("cannot open $source")).use { input -> FileOutputStream(dest).use { output -> input.copyTo(output) } } if (!isVideo) { dest = applyQuality(dest) } val size = if (isVideo) videoSize(dest) else imageSize(dest) val duration = if (isVideo) videoDurationMs(dest) else null val asset = ImagePickerAsset( Uri.fromFile(dest).toString(), size.first.toDouble(), size.second.toDouble(), mimeType, displayName ?: dest.name, dest.length().toDouble(), duration?.toDouble(), if (isVideo) ImagePickerAssetType.VIDEO else ImagePickerAssetType.IMAGE ) return Pair(asset, dest) } // recompress an image file to jpeg at the pending quality. returns the // file to read: the original when quality is full or the image did not // decode. private fun applyQuality(file: File): File { if (pendingQuality >= 100) return file val options = BitmapFactory.Options().apply { inJustDecodeBounds = false } val decoded = file.inputStream().use { BitmapFactory.decodeStream(it, null, options) } ?: return file // the new jpeg carries no exif, so the orientation moves into the pixels. val bitmap = OneImageOrientation.upright(decoded, OneImageOrientation.read(file)) val dest = cacheFile("IMG", "jpg") try { FileOutputStream(dest).use { output -> if (!bitmap.compress(Bitmap.CompressFormat.JPEG, pendingQuality, output)) { return file } } } catch (e: Exception) { return file } finally { if (bitmap !== decoded) bitmap.recycle() decoded.recycle() } file.delete() return dest } private fun queryDisplayName(source: Uri): String? { val cursor = context.contentResolver.query( source, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null ) ?: return null cursor.use { if (!it.moveToFirst()) return null val index = it.getColumnIndex(OpenableColumns.DISPLAY_NAME) if (index < 0) return null return it.getString(index) } } private fun imageSize(file: File): Pair { val options = BitmapFactory.Options().apply { inJustDecodeBounds = true } file.inputStream().use { BitmapFactory.decodeStream(it, null, options) } if (options.outWidth <= 0 || options.outHeight <= 0) return Pair(0, 0) // exif orientations 5 to 8 store the pixels transposed to the display // axes, so the reported size swaps to match what renders, as on ios. return if (OneImageOrientation.isTransposed(OneImageOrientation.read(file))) { Pair(options.outHeight, options.outWidth) } else { Pair(options.outWidth, options.outHeight) } } private fun videoSize(file: File): Pair { val retriever = MediaMetadataRetriever() try { retriever.setDataSource(file.absolutePath) val width = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH) ?.toIntOrNull() ?: 0 val height = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT) ?.toIntOrNull() ?: 0 return Pair(width, height) } catch (e: Exception) { return Pair(0, 0) } finally { try { retriever.release() } catch (ignored: Exception) { } } } private fun videoDurationMs(file: File): Long? { val retriever = MediaMetadataRetriever() try { retriever.setDataSource(file.absolutePath) return retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION) ?.toLongOrNull() } catch (e: Exception) { return null } finally { try { retriever.release() } catch (ignored: Exception) { } } } private fun cacheFile(prefix: String, extension: String): File { val dir = File(context.cacheDir, "one-native-image-picker") dir.mkdirs() return File(dir, "${prefix}_${UUID.randomUUID()}.$extension") } private fun documentIntent( allowsImages: Boolean, allowsVideos: Boolean, selectionLimit: Int ): Intent { return Intent(Intent.ACTION_OPEN_DOCUMENT).apply { addCategory(Intent.CATEGORY_OPENABLE) when { allowsImages && allowsVideos -> { type = "*/*" putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("image/*", "video/*")) } allowsVideos -> type = "video/*" else -> type = "image/*" } putExtra(Intent.EXTRA_ALLOW_MULTIPLE, selectionLimit != 1) } } // zero is unlimited on both sides; on api 33+ the real system maximum // applies, below that the backported picker clamps the same sentinel // androidx itself uses. private fun effectiveMax(selectionLimit: Int): Int { val systemMax = if (Build.VERSION.SDK_INT >= 33) MediaStore.getPickImagesMaxLimit() else Int.MAX_VALUE if (selectionLimit <= 0) return systemMax if (Build.VERSION.SDK_INT >= 33) return minOf(selectionLimit, systemMax) return selectionLimit } // one launch in flight: native owns the slot, so a second launch rejects // instead of clobbering the pending promise. permission reads never take // this slot. private fun takePickerPending(verb: String, promise: Promise): Boolean { synchronized(lock) { if (pendingPickerPromise == null) { pendingPickerPromise = promise pendingPickerVerb = verb return true } } promise.reject(OneNativeError(E_FAILED, "ImagePicker.$verb: another request is already in flight")) return false } private fun takePickerPromise(): Promise? = synchronized(lock) { val promise = pendingPickerPromise clearPickerPending() promise } private fun resolvePickerPending(assets: Array) { takePickerPromise()?.resolve(ImagePickerNativeResult(false, assets)) } private fun resolvePickerCanceled() { takePickerPromise()?.resolve(ImagePickerNativeResult(true, null)) } private fun rejectPickerPending(verb: String, message: String) { pendingCameraFile?.delete() takePickerPromise()?.reject(OneNativeError(E_FAILED, "ImagePicker.$verb: $message")) } private fun clearPickerPending() { pendingPickerPromise = null pendingLimit = 1 pendingSingle = null pendingMultiple = null pendingUsedPicker = false pendingCameraFile = null pendingCameraVideo = false pendingEdit = false pendingAspectX = 0 pendingAspectY = 0 pendingQuality = 100 pendingVideoMaxDuration = 0 pendingVideoHighQuality = true } companion object { private const val E_FAILED = "E_IMAGE_PICKER_FAILED" private const val REQUEST_LIBRARY = 0x1A01 private const val REQUEST_CAMERA = 0x1A02 private const val REQUEST_CAMERA_PERMISSION = 0x1A03 private const val REQUEST_PERMISSION_CALL = 0x1A04 private const val REQUEST_CROP = 0x1A05 } }