chore: add mobile app

This commit is contained in:
2026-02-07 22:33:02 +01:00
parent db34640acc
commit e89384ad4e
29327 changed files with 3886944 additions and 12 deletions
+32
View File
@@ -0,0 +1,32 @@
plugins {
id 'com.android.library'
id 'expo-module-gradle-plugin'
}
group = 'host.exp.exponent'
version = '17.0.10'
android {
namespace "expo.modules.imagepicker"
defaultConfig {
versionCode 22
versionName "17.0.10"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
testOptions {
unitTests.includeAndroidResources = true
}
}
dependencies {
implementation "androidx.activity:activity-ktx:1.10.0"
implementation "androidx.appcompat:appcompat:1.7.0"
implementation "androidx.exifinterface:exifinterface:1.3.7"
implementation "com.vanniktech:android-image-cropper:4.6.0"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.3"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.3"
if (project.findProject(':expo-modules-test-core')) {
androidTestImplementation project(':expo-modules-test-core')
}
}
@@ -0,0 +1,51 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Required for picking images from camera directly -->
<uses-permission android:name="android.permission.CAMERA" />
<!-- Required for picking images from camera roll -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<application>
<service
android:name="com.google.android.gms.metadata.ModuleDependencies"
android:enabled="false"
android:exported="false"
tools:ignore="MissingClass">
<intent-filter>
<action android:name="com.google.android.gms.metadata.MODULE_DEPENDENCIES" />
</intent-filter>
<meta-data
android:name="photopicker_activity:0:required"
android:value="" />
</service>
<activity
android:name="expo.modules.imagepicker.ExpoCropImageActivity"
android:theme="@style/Base.Theme.AppCompat"
android:exported="false"
tools:replace="android:exported" />
<!-- https://developer.android.com/guide/topics/manifest/provider-element.html -->
<provider
android:name=".fileprovider.ImagePickerFileProvider"
android:authorities="${applicationId}.ImagePickerFileProvider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/image_picker_provider_paths" />
</provider>
</application>
<queries>
<intent>
<!-- Required for picking images from the camera roll if targeting API 30 -->
<action android:name="android.media.action.IMAGE_CAPTURE" />
</intent>
<intent>
<!-- Required for picking images from the camera if targeting API 30 -->
<action android:name="android.media.action.ACTION_VIDEO_CAPTURE" />
</intent>
</queries>
</manifest>
@@ -0,0 +1,82 @@
package expo.modules.imagepicker
import android.graphics.Color
import android.view.Menu
import com.canhub.cropper.CropImageActivity
import com.canhub.cropper.CropImageOptions
/**
* A wrapper around `CropImageActivity` to provide custom theming and functionality.
*
* This activity applies a custom color palette based on the application's theme
* and ensures that all icons are tinted correctly for both light and dark modes.
* It uses reflection to access private fields in the base `CropImageActivity`
* to avoid forking the library.
*/
class ExpoCropImageActivity : CropImageActivity() {
private var currentIconColor: Int = Color.BLACK
// region Lifecycle Methods
override fun onCreate(savedInstanceState: android.os.Bundle?) {
super.onCreate(savedInstanceState)
// Fetch the private cropImageOptions and apply the palette as early as possible so
// the toolbar and menu icons are tinted correctly on first render.
getCropOptions()?.let { opts ->
val isNight = (resources.configuration.uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK) == android.content.res.Configuration.UI_MODE_NIGHT_YES
applyPalette(isNight, opts)
invokeSetCustomizations()
invalidateOptionsMenu() // Recreate the menu to apply the new icon colors.
}
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
val result = super.onCreateOptionsMenu(menu)
tintAllMenuItems(menu)
return result
}
override fun onPrepareOptionsMenu(menu: Menu): Boolean {
val result = super.onPrepareOptionsMenu(menu)
tintAllMenuItems(menu)
return result
}
// endregion
private fun applyPalette(isNight: Boolean, opts: CropImageOptions) {
// Apply palette to options and get the toolbar widget color
val toolbarWidgetColor = ExpoCropImageUtils.applyPaletteToOptions(theme, resources, isNight, opts)
// Set the current icon color for menu tinting
currentIconColor = toolbarWidgetColor
// Set up toolbar color with fallback for status bar theming
val defaultToolbarColor = if (isNight) Color.BLACK else Color.WHITE
val toolbarColor = opts.toolbarColor ?: defaultToolbarColor
ExpoCropImageUtils.applyWindowTheming(window, toolbarColor, isNight)
// Remove action bar elevation for a flat design
supportActionBar?.elevation = 0f
}
// region Helper Methods
private fun getCropOptions(): CropImageOptions? =
runCatching {
CropImageActivity::class.java.getDeclaredField("cropImageOptions")
.apply { isAccessible = true }
.get(this) as? CropImageOptions
}.getOrNull()
private fun invokeSetCustomizations() =
runCatching {
CropImageActivity::class.java.getDeclaredMethod("setCustomizations")
.apply { isAccessible = true }
.invoke(this)
}
private fun tintAllMenuItems(menu: Menu) {
for (i in 0 until menu.size()) {
menu.getItem(i)?.icon?.mutate()?.setTint(currentIconColor)
}
}
// endregion
}
@@ -0,0 +1,87 @@
package expo.modules.imagepicker
import android.graphics.Color
import android.util.TypedValue
import com.canhub.cropper.CropImageOptions
import androidx.core.view.WindowInsetsControllerCompat
/**
* Utility functions for ExpoCropImageActivity theming and color management.
*/
object ExpoCropImageUtils {
/**
* Gets a color value from the theme attributes.
* @param theme The theme to resolve the attribute from
* @param attr The attribute resource ID
* @return The color value or null if not found
*/
fun getThemeColor(theme: android.content.res.Resources.Theme, attr: Int): Int? = runCatching {
val tv = TypedValue()
if (theme.resolveAttribute(attr, tv, true)) tv.data else null
}.getOrNull()
/**
* Gets a color resource value directly.
* @param resources The resources to get the color from
* @param colorResId The color resource ID
* @return The color value or null if not found
*/
fun getColorResource(resources: android.content.res.Resources, colorResId: Int): Int? = runCatching {
resources.getColor(colorResId, null)
}.getOrNull()
/**
* Applies a color palette to CropImageOptions based on theme attributes or color resources.
* @param theme The theme to resolve colors from
* @param resources The resources to get colors from
* @param isNight Whether the app is in dark mode
* @param options The CropImageOptions to apply colors to
* @return The toolbar widget color that was applied
*/
fun applyPaletteToOptions(
theme: android.content.res.Resources.Theme,
resources: android.content.res.Resources,
isNight: Boolean,
options: CropImageOptions
): Int {
// Try theme attributes first, then fall back to color resources
val customToolbar = getThemeColor(theme, R.attr.expoCropToolbarColor)
?: getColorResource(resources, R.color.expoCropToolbarColor)
val customIconColor = getThemeColor(theme, R.attr.expoCropToolbarIconColor)
?: getColorResource(resources, R.color.expoCropToolbarIconColor)
val customActionTextColor = getThemeColor(theme, R.attr.expoCropToolbarActionTextColor)
?: getColorResource(resources, R.color.expoCropToolbarActionTextColor)
val customBackButtonIconColor = getThemeColor(theme, R.attr.expoCropBackButtonIconColor)
?: getColorResource(resources, R.color.expoCropBackButtonIconColor)
val customBg = getThemeColor(theme, R.attr.expoCropBackgroundColor)
?: getColorResource(resources, R.color.expoCropBackgroundColor)
val defaultColor = if (isNight) Color.BLACK else Color.WHITE
val toolbarWidgetColor = customIconColor ?: if (isNight) Color.WHITE else Color.BLACK
options.activityBackgroundColor = customBg ?: defaultColor
options.toolbarColor = customToolbar ?: defaultColor
options.toolbarTitleColor = toolbarWidgetColor
options.toolbarBackButtonColor = customBackButtonIconColor ?: toolbarWidgetColor
options.activityMenuIconColor = toolbarWidgetColor
options.activityMenuTextColor = customActionTextColor ?: if (isNight) Color.WHITE else Color.BLACK
return toolbarWidgetColor
}
/**
* Applies window-level theming (status bar, etc.) based on the applied palette.
* @param window The window to apply theming to
* @param toolbarColor The toolbar color that was applied
* @param isNight Whether the app is in dark mode
*/
fun applyWindowTheming(
window: android.view.Window,
toolbarColor: Int,
isNight: Boolean
) {
window.statusBarColor = toolbarColor
WindowInsetsControllerCompat(window, window.decorView).isAppearanceLightStatusBars = !isNight
}
}
@@ -0,0 +1,164 @@
package expo.modules.imagepicker
import androidx.exifinterface.media.ExifInterface
object ImagePickerConstants {
const val TAG = "ExponentImagePicker"
const val MAXIMUM_QUALITY = 1.0
const val CACHE_DIR_NAME = "ImagePicker"
/**
* Expose List<Pair<Type, Exif>> as [Iterable] for easier access.
*/
val EXIF_TAGS = object : Iterable<Pair<String, String>> {
/**
* Map { "string" | "double" | "int" -> List<String> } into List<"string" | "double" | "int", String>
*/
override fun iterator(): Iterator<Pair<String, String>> =
typeToTags
.flatMap { (type, tags) -> tags.map { tag -> type to tag } }
.iterator()
private val typeToTags = mapOf(
"string" to listOf(
ExifInterface.TAG_ARTIST,
ExifInterface.TAG_CFA_PATTERN,
ExifInterface.TAG_COMPONENTS_CONFIGURATION,
ExifInterface.TAG_COPYRIGHT,
ExifInterface.TAG_DATETIME,
ExifInterface.TAG_DATETIME_DIGITIZED,
ExifInterface.TAG_DATETIME_ORIGINAL,
ExifInterface.TAG_DEVICE_SETTING_DESCRIPTION,
ExifInterface.TAG_EXIF_VERSION,
ExifInterface.TAG_FILE_SOURCE,
ExifInterface.TAG_FLASHPIX_VERSION,
ExifInterface.TAG_GPS_AREA_INFORMATION,
ExifInterface.TAG_GPS_DATESTAMP,
ExifInterface.TAG_GPS_DEST_BEARING_REF,
ExifInterface.TAG_GPS_DEST_DISTANCE_REF,
ExifInterface.TAG_GPS_DEST_LATITUDE_REF,
ExifInterface.TAG_GPS_DEST_LONGITUDE_REF,
ExifInterface.TAG_GPS_H_POSITIONING_ERROR,
ExifInterface.TAG_GPS_IMG_DIRECTION_REF,
ExifInterface.TAG_GPS_LATITUDE_REF,
ExifInterface.TAG_GPS_LONGITUDE_REF,
ExifInterface.TAG_GPS_MAP_DATUM,
ExifInterface.TAG_GPS_MEASURE_MODE,
ExifInterface.TAG_GPS_PROCESSING_METHOD,
ExifInterface.TAG_GPS_SATELLITES,
ExifInterface.TAG_GPS_SPEED_REF,
ExifInterface.TAG_GPS_STATUS,
ExifInterface.TAG_GPS_TIMESTAMP,
ExifInterface.TAG_GPS_TRACK_REF,
ExifInterface.TAG_GPS_VERSION_ID,
ExifInterface.TAG_IMAGE_DESCRIPTION,
ExifInterface.TAG_IMAGE_UNIQUE_ID,
ExifInterface.TAG_INTEROPERABILITY_INDEX,
ExifInterface.TAG_MAKE,
ExifInterface.TAG_MAKER_NOTE,
ExifInterface.TAG_MODEL,
ExifInterface.TAG_OECF,
ExifInterface.TAG_RELATED_SOUND_FILE,
ExifInterface.TAG_SCENE_TYPE,
ExifInterface.TAG_SOFTWARE,
ExifInterface.TAG_SPATIAL_FREQUENCY_RESPONSE,
ExifInterface.TAG_SPECTRAL_SENSITIVITY,
ExifInterface.TAG_SUBSEC_TIME,
ExifInterface.TAG_SUBSEC_TIME_DIGITIZED,
ExifInterface.TAG_SUBSEC_TIME_ORIGINAL,
ExifInterface.TAG_USER_COMMENT
),
"double" to listOf(
ExifInterface.TAG_APERTURE_VALUE,
ExifInterface.TAG_BRIGHTNESS_VALUE,
ExifInterface.TAG_COMPRESSED_BITS_PER_PIXEL,
ExifInterface.TAG_DIGITAL_ZOOM_RATIO,
ExifInterface.TAG_EXPOSURE_BIAS_VALUE,
ExifInterface.TAG_EXPOSURE_INDEX,
ExifInterface.TAG_EXPOSURE_TIME,
ExifInterface.TAG_FLASH_ENERGY,
ExifInterface.TAG_FOCAL_LENGTH,
ExifInterface.TAG_FOCAL_PLANE_X_RESOLUTION,
ExifInterface.TAG_FOCAL_PLANE_Y_RESOLUTION,
ExifInterface.TAG_F_NUMBER,
ExifInterface.TAG_GPS_ALTITUDE,
ExifInterface.TAG_GPS_DEST_BEARING,
ExifInterface.TAG_GPS_DEST_DISTANCE,
ExifInterface.TAG_GPS_DEST_LATITUDE,
ExifInterface.TAG_GPS_DEST_LONGITUDE,
ExifInterface.TAG_GPS_DOP,
ExifInterface.TAG_GPS_IMG_DIRECTION,
ExifInterface.TAG_GPS_LATITUDE,
ExifInterface.TAG_GPS_LONGITUDE,
ExifInterface.TAG_GPS_SPEED,
ExifInterface.TAG_GPS_TRACK,
ExifInterface.TAG_MAX_APERTURE_VALUE,
ExifInterface.TAG_PRIMARY_CHROMATICITIES,
ExifInterface.TAG_REFERENCE_BLACK_WHITE,
ExifInterface.TAG_SHUTTER_SPEED_VALUE,
ExifInterface.TAG_SUBJECT_DISTANCE,
ExifInterface.TAG_WHITE_POINT,
ExifInterface.TAG_X_RESOLUTION,
ExifInterface.TAG_Y_CB_CR_COEFFICIENTS,
ExifInterface.TAG_Y_RESOLUTION
),
"int" to listOf(
ExifInterface.TAG_BITS_PER_SAMPLE,
ExifInterface.TAG_COLOR_SPACE,
ExifInterface.TAG_COMPRESSION,
ExifInterface.TAG_CONTRAST,
ExifInterface.TAG_CUSTOM_RENDERED,
ExifInterface.TAG_DEFAULT_CROP_SIZE,
ExifInterface.TAG_DNG_VERSION,
ExifInterface.TAG_EXPOSURE_MODE,
ExifInterface.TAG_EXPOSURE_PROGRAM,
ExifInterface.TAG_FLASH,
ExifInterface.TAG_FOCAL_LENGTH_IN_35MM_FILM,
ExifInterface.TAG_FOCAL_PLANE_RESOLUTION_UNIT,
ExifInterface.TAG_GAIN_CONTROL,
ExifInterface.TAG_GPS_ALTITUDE_REF,
ExifInterface.TAG_GPS_DIFFERENTIAL,
ExifInterface.TAG_IMAGE_LENGTH,
ExifInterface.TAG_IMAGE_WIDTH,
ExifInterface.TAG_ISO_SPEED_RATINGS,
ExifInterface.TAG_JPEG_INTERCHANGE_FORMAT,
ExifInterface.TAG_JPEG_INTERCHANGE_FORMAT_LENGTH,
ExifInterface.TAG_LIGHT_SOURCE,
ExifInterface.TAG_METERING_MODE,
ExifInterface.TAG_NEW_SUBFILE_TYPE,
ExifInterface.TAG_ORF_ASPECT_FRAME,
ExifInterface.TAG_ORF_PREVIEW_IMAGE_LENGTH,
ExifInterface.TAG_ORF_PREVIEW_IMAGE_START,
ExifInterface.TAG_ORIENTATION,
ExifInterface.TAG_PHOTOMETRIC_INTERPRETATION,
ExifInterface.TAG_PIXEL_X_DIMENSION,
ExifInterface.TAG_PIXEL_Y_DIMENSION,
ExifInterface.TAG_PLANAR_CONFIGURATION,
ExifInterface.TAG_RESOLUTION_UNIT,
ExifInterface.TAG_ROWS_PER_STRIP,
ExifInterface.TAG_RW2_ISO,
ExifInterface.TAG_RW2_SENSOR_BOTTOM_BORDER,
ExifInterface.TAG_RW2_SENSOR_LEFT_BORDER,
ExifInterface.TAG_RW2_SENSOR_RIGHT_BORDER,
ExifInterface.TAG_RW2_SENSOR_TOP_BORDER,
ExifInterface.TAG_SAMPLES_PER_PIXEL,
ExifInterface.TAG_SATURATION,
ExifInterface.TAG_SCENE_CAPTURE_TYPE,
ExifInterface.TAG_SENSING_METHOD,
ExifInterface.TAG_SHARPNESS,
ExifInterface.TAG_STRIP_BYTE_COUNTS,
ExifInterface.TAG_STRIP_OFFSETS,
ExifInterface.TAG_SUBFILE_TYPE,
ExifInterface.TAG_SUBJECT_AREA,
ExifInterface.TAG_SUBJECT_DISTANCE_RANGE,
ExifInterface.TAG_SUBJECT_LOCATION,
ExifInterface.TAG_THUMBNAIL_IMAGE_LENGTH,
ExifInterface.TAG_THUMBNAIL_IMAGE_WIDTH,
ExifInterface.TAG_TRANSFER_FUNCTION,
ExifInterface.TAG_WHITE_BALANCE,
ExifInterface.TAG_Y_CB_CR_POSITIONING,
ExifInterface.TAG_Y_CB_CR_SUB_SAMPLING
)
)
}
}
@@ -0,0 +1,32 @@
package expo.modules.imagepicker
import androidx.core.net.toUri
import expo.modules.kotlin.exception.CodedException
import java.io.File
internal class FailedToCreateFileException(path: String, cause: Throwable? = null) :
CodedException("Failed to create the file '$path'", cause)
internal class FailedToPickMediaException :
CodedException("Failed to parse PhotoPicker result")
internal class FailedToExtractVideoMetadataException(file: File? = null, cause: Throwable? = null) :
CodedException("Failed to extract metadata from video file '${file?.toUri()?.toString() ?: ""}'", cause)
internal class FailedToWriteExifDataToFileException(file: File, cause: Throwable) :
CodedException("Failed to write EXIF data to file '${file.toUri()}", cause)
internal class FailedToWriteFileException(file: File? = null, cause: Throwable? = null) :
CodedException("Failed to write a file '${file?.toUri()?.toString() ?: ""}'", cause)
internal class FailedToReadFileException(file: File, cause: Throwable? = null) :
CodedException("Failed to read a file '${file.toUri()}", cause)
internal class MissingActivityToHandleIntent(intentType: String?) :
CodedException("Failed to resolve activity to handle the intent of type '$intentType'")
internal class MissingModuleException(moduleName: String) :
CodedException("Module '$moduleName' not found. Are you sure all modules are linked correctly?")
internal class UserRejectedPermissionsException :
CodedException("User rejected permissions")
@@ -0,0 +1,307 @@
package expo.modules.imagepicker
import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.os.OperationCanceledException
import androidx.core.content.ContextCompat
import expo.modules.core.errors.ModuleNotFoundException
import expo.modules.imagepicker.contracts.CameraContract
import expo.modules.imagepicker.contracts.CameraContractOptions
import expo.modules.imagepicker.contracts.CropImageContract
import expo.modules.imagepicker.contracts.CropImageContractOptions
import expo.modules.imagepicker.contracts.ImageLibraryContract
import expo.modules.imagepicker.contracts.ImageLibraryContractOptions
import expo.modules.imagepicker.contracts.ImagePickerContractResult
import expo.modules.imagepicker.contracts.MediaList
import expo.modules.interfaces.permissions.Permissions
import expo.modules.interfaces.permissions.PermissionsResponse
import expo.modules.interfaces.permissions.PermissionsResponseListener
import expo.modules.interfaces.permissions.PermissionsStatus
import expo.modules.kotlin.Promise
import expo.modules.kotlin.activityresult.AppContextActivityResultLauncher
import expo.modules.kotlin.exception.Exceptions
import expo.modules.kotlin.functions.Coroutine
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import expo.modules.kotlin.weak
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import java.io.File
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
// TODO(@bbarthec): rename to ExpoImagePicker
private const val moduleName = "ExponentImagePicker"
const val ACCESS_PRIVILEGES_PERMISSION_KEY = "accessPrivileges"
class ImagePickerModule : Module() {
override fun definition() = ModuleDefinition {
Name(moduleName)
// region JS API
AsyncFunction("requestMediaLibraryPermissionsAsync") { writeOnly: Boolean, promise: Promise ->
val manager = appContext.permissions ?: throw Exceptions.PermissionsModuleNotFound()
val permissions = getMediaLibraryPermissions(writeOnly)
manager.askForPermissions(createPermissionsDecorator(promise), *permissions)
}
AsyncFunction("getMediaLibraryPermissionsAsync") { writeOnly: Boolean, promise: Promise ->
val manager = appContext.permissions ?: throw Exceptions.PermissionsModuleNotFound()
val permissions = getMediaLibraryPermissions(writeOnly)
manager.getPermissions(createPermissionsDecorator(promise), *permissions)
}
AsyncFunction("requestCameraPermissionsAsync") { promise: Promise ->
Permissions.askForPermissionsWithPermissionsManager(appContext.permissions, promise, Manifest.permission.CAMERA)
}
AsyncFunction("getCameraPermissionsAsync") { promise: Promise ->
Permissions.getPermissionsWithPermissionsManager(appContext.permissions, promise, Manifest.permission.CAMERA)
}
AsyncFunction("launchCameraAsync") Coroutine { options: ImagePickerOptions ->
ensureTargetActivityIsAvailable(options)
ensureCameraPermissionsAreGranted()
val tempFile = createOutputFile(cacheDirectory, options.nativeMediaTypes.toFileExtension())
try {
val uri = tempFile.toContentUri(context)
val contractOptions = options.toCameraContractOptions(uri.toString())
launchContract({ cameraLauncher.launch(contractOptions) }, options)
} finally {
tempFile.delete()
}
}
AsyncFunction("launchImageLibraryAsync") Coroutine { options: ImagePickerOptions ->
val contractOptions = options.toImageLibraryContractOptions()
launchContract({ imageLibraryLauncher.launch(contractOptions) }, options)
}
AsyncFunction("getPendingResultAsync") Coroutine { ->
val (bareResult, options) = pendingMediaPickingResult ?: return@Coroutine null
pendingMediaPickingResult = null
mediaHandler.readExtras(bareResult, options)
}
// endregion
RegisterActivityContracts {
cameraLauncher = registerForActivityResult(
CameraContract(this@ImagePickerModule)
) { input, result -> handleResultUponActivityDestruction(result, input.options) }
imageLibraryLauncher = registerForActivityResult(
ImageLibraryContract(this@ImagePickerModule)
) { input, result -> handleResultUponActivityDestruction(result, input.options) }
cropImageLauncher = registerForActivityResult(
CropImageContract(this@ImagePickerModule)
) { input, result -> handleResultUponActivityDestruction(result, input.options) }
}
}
// TODO (@bbarthec): generalize it as almost every module re-declares this approach
val context: Context
get() = requireNotNull(appContext.reactContext) { "React Application Context is null" }
private val mediaHandler = MediaHandler(this)
private lateinit var cameraLauncher: AppContextActivityResultLauncher<CameraContractOptions, ImagePickerContractResult>
private lateinit var imageLibraryLauncher: AppContextActivityResultLauncher<ImageLibraryContractOptions, ImagePickerContractResult>
private lateinit var cropImageLauncher: AppContextActivityResultLauncher<CropImageContractOptions, ImagePickerContractResult>
private val cacheDirectory: File
get() = appContext.cacheDirectory
/**
* Stores result for an operation that has been interrupted by the activity destruction.
* The results are stored only for successful, non-cancelled-by-user scenario.
* Each new picking operation overrides previous state (for cancelled operation `null` is set).
* The user can retrieve the data using exported `getPendingResultAsync` method.
*/
private var pendingMediaPickingResult: PendingMediaPickingResult? = null
private var isPickerOpen = false
private fun createPermissionsDecorator(promise: Promise): PermissionsResponseListener {
val weakContext = appContext.reactContext.weak()
return PermissionsResponseListener { permissionsMap ->
val areAllGranted = permissionsMap.all { (_, response) -> response.status == PermissionsStatus.GRANTED }
val areAllDenied = permissionsMap.isNotEmpty() && permissionsMap.all { (_, response) -> response.status == PermissionsStatus.DENIED }
val canAskAgain = permissionsMap.all { (_, response) -> response.canAskAgain }
val permissionsBundle =
Bundle().apply {
putString(PermissionsResponse.EXPIRES_KEY, PermissionsResponse.PERMISSION_EXPIRES_NEVER)
putString(
PermissionsResponse.STATUS_KEY,
when {
areAllGranted -> PermissionsStatus.GRANTED.status
areAllDenied -> PermissionsStatus.DENIED.status
else -> PermissionsStatus.UNDETERMINED.status
}
)
putBoolean(PermissionsResponse.CAN_ASK_AGAIN_KEY, canAskAgain)
putBoolean(PermissionsResponse.GRANTED_KEY, areAllGranted)
}
if (areAllGranted) {
permissionsBundle.putString(ACCESS_PRIVILEGES_PERMISSION_KEY, "all")
promise.resolve(permissionsBundle)
return@PermissionsResponseListener
}
// On Android < 14 we always return `all` or `none`, since it doesn't support limited access
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
permissionsBundle.putString(ACCESS_PRIVILEGES_PERMISSION_KEY, "none")
promise.resolve(permissionsBundle)
return@PermissionsResponseListener
}
val context = weakContext.get() ?: run {
promise.reject(Exceptions.ReactContextLost())
return@PermissionsResponseListener
}
// For photo and video access android will return DENIED status if the user selected "allow only selected"
// We need to check if that is the case and overwrite the result.
val hasPartialAccess = ContextCompat.checkSelfPermission(context, Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED) == PackageManager.PERMISSION_GRANTED
if (hasPartialAccess) {
permissionsBundle.putBoolean(PermissionsResponse.GRANTED_KEY, true)
permissionsBundle.putBoolean(PermissionsResponse.CAN_ASK_AGAIN_KEY, true)
permissionsBundle.putString(PermissionsResponse.STATUS_KEY, PermissionsStatus.GRANTED.status)
permissionsBundle.putString(ACCESS_PRIVILEGES_PERMISSION_KEY, "limited")
} else {
permissionsBundle.putString(ACCESS_PRIVILEGES_PERMISSION_KEY, "none")
}
promise.resolve(permissionsBundle)
}
}
/**
* Calls [launchPicker] and unifies flow shared between "launchCameraAsync" and "launchImageLibraryAsync"
*/
private suspend fun launchContract(
pickerLauncher: suspend () -> ImagePickerContractResult,
options: ImagePickerOptions
): Any {
return try {
if (isPickerOpen) {
return ImagePickerResponse(canceled = true)
}
isPickerOpen = true
var result = launchPicker(pickerLauncher)
if (
!options.allowsMultipleSelection &&
options.allowsEditing &&
result.data.size == 1 &&
result.data[0].first == MediaType.IMAGE
) {
result = launchPicker {
cropImageLauncher.launch(CropImageContractOptions(result.data[0].second.toString(), options))
}
}
mediaHandler.readExtras(result.data, options)
} catch (cause: OperationCanceledException) {
return ImagePickerResponse(canceled = true)
} finally {
isPickerOpen = false
}
}
/**
* Function that would store the results coming from 3-rd party Activity in case Android decides to
* destroy the launching application that is backgrounded.
*/
private fun handleResultUponActivityDestruction(result: ImagePickerContractResult, options: ImagePickerOptions) {
if (result is ImagePickerContractResult.Success) {
pendingMediaPickingResult = PendingMediaPickingResult(result.data, options)
}
}
/**
* Launches picker (image library or camera)
*/
private suspend fun launchPicker(
pickerLauncher: suspend () -> ImagePickerContractResult
): ImagePickerContractResult.Success = withContext(Dispatchers.IO) {
when (val pickingResult = pickerLauncher()) {
is ImagePickerContractResult.Success -> pickingResult
is ImagePickerContractResult.Cancelled -> throw OperationCanceledException()
is ImagePickerContractResult.Error -> throw FailedToPickMediaException()
}
}
// endregion
// region Utils
private fun getMediaLibraryPermissions(writeOnly: Boolean): Array<String> =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
emptyArray<String>()
} else {
listOfNotNull(
Manifest.permission.WRITE_EXTERNAL_STORAGE,
Manifest.permission.READ_EXTERNAL_STORAGE.takeIf { !writeOnly }
).toTypedArray()
}
private fun ensureTargetActivityIsAvailable(options: ImagePickerOptions) {
val cameraIntent = Intent(options.nativeMediaTypes.toCameraIntentAction())
if (cameraIntent.resolveActivity(appContext.throwingActivity.application.packageManager) == null) {
throw MissingActivityToHandleIntent(cameraIntent.type)
}
}
private suspend fun ensureCameraPermissionsAreGranted(): Unit = suspendCancellableCoroutine { continuation ->
val permissions = appContext.permissions ?: throw ModuleNotFoundException("Permissions")
permissions.askForPermissions(
{ permissionsResponse ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
if (permissionsResponse[Manifest.permission.CAMERA]?.status == PermissionsStatus.GRANTED) {
continuation.resume(Unit)
} else {
continuation.resumeWithException(UserRejectedPermissionsException())
}
} else if (
permissionsResponse[Manifest.permission.WRITE_EXTERNAL_STORAGE]?.status == PermissionsStatus.GRANTED &&
permissionsResponse[Manifest.permission.CAMERA]?.status == PermissionsStatus.GRANTED
) {
continuation.resume(Unit)
} else {
continuation.resumeWithException(UserRejectedPermissionsException())
}
},
*listOfNotNull(
Manifest.permission.WRITE_EXTERNAL_STORAGE.takeIf { Build.VERSION.SDK_INT < Build.VERSION_CODES.Q },
Manifest.permission.CAMERA
).toTypedArray()
)
}
// endregion
}
/**
* Simple data structure to hold the data that has to be preserved after the Activity is destroyed.
*/
internal data class PendingMediaPickingResult(
val data: MediaList,
val options: ImagePickerOptions
)
@@ -0,0 +1,133 @@
package expo.modules.imagepicker
import java.io.Serializable
import android.provider.MediaStore
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.FloatRange
import androidx.annotation.IntRange
import expo.modules.imagepicker.contracts.CameraContractOptions
import expo.modules.imagepicker.contracts.ImageLibraryContractOptions
import expo.modules.kotlin.records.Field
import expo.modules.kotlin.records.Record
import expo.modules.kotlin.types.Enumerable
internal const val UNLIMITED_SELECTION: Int = 0
internal class ImagePickerOptions : Record, Serializable {
@Field
var allowsEditing: Boolean = false
@Field
var allowsMultipleSelection: Boolean = false
@Field
@FloatRange(from = 0.0, to = 1.0)
var quality: Double = 1.0
@Field
@IntRange(from = 0)
var selectionLimit: Int = UNLIMITED_SELECTION
@Field
var base64: Boolean = false
@Field
var exif: Boolean = false
@Field
var mediaTypes: Array<JSMediaTypes> = arrayOf(JSMediaTypes.IMAGES)
@IntRange(from = 0)
var videoMaxDuration: Int = 0
@Field
var aspect: Pair<Int, Int>? = null
@Field
var shape: CropShape = CropShape.RECTANGLE
@Field
var cameraType: CameraType = CameraType.BACK
@Field
val orderedSelection: Boolean = false
@Field
val defaultTab: DefaultTab = DefaultTab.PHOTOS
@Field
val legacy: Boolean = false
val nativeMediaTypes: MediaTypes
get() = MediaTypes.fromJSMediaTypesArray(mediaTypes)
fun toCameraContractOptions(uri: String) = CameraContractOptions(uri, this)
fun toImageLibraryContractOptions() = ImageLibraryContractOptions(this)
}
/**
* Used to keep compatibility with js media types
*/
internal enum class JSMediaTypes(val value: String) : Enumerable {
IMAGES("images"),
VIDEOS("videos"),
LIVE_PHOTOS("livePhotos")
}
internal enum class CropShape(val value: String) : Enumerable {
RECTANGLE("rectangle"),
OVAL("oval")
}
internal enum class MediaTypes(val value: String) : Enumerable {
IMAGES("Images"),
VIDEOS("Videos"),
ALL("All");
fun toFileExtension(): String {
return when (this) {
VIDEOS -> ".mp4"
else -> ".jpeg"
}
}
/**
* Return [MediaStore]'s intent capture action associated with given media types
*/
fun toCameraIntentAction(): String {
return when (this) {
VIDEOS -> MediaStore.ACTION_VIDEO_CAPTURE
else -> MediaStore.ACTION_IMAGE_CAPTURE
}
}
companion object {
fun fromJSMediaTypesArray(mediaTypes: Array<JSMediaTypes>): MediaTypes {
return if (!mediaTypes.contains(JSMediaTypes.VIDEOS)) {
IMAGES
} else if (mediaTypes.contains(JSMediaTypes.VIDEOS) && !mediaTypes.contains(JSMediaTypes.IMAGES)) {
VIDEOS
} else {
ALL
}
}
}
}
internal enum class CameraType(val value: String) : Enumerable {
BACK("back"),
FRONT("front")
}
internal enum class DefaultTab(val value: String) : Enumerable {
PHOTOS("photos"),
ALBUMS("albums");
fun toDefaultTab(): ActivityResultContracts.PickVisualMedia.DefaultTab {
return when (this) {
PHOTOS -> ActivityResultContracts.PickVisualMedia.DefaultTab.PhotosTab
ALBUMS -> ActivityResultContracts.PickVisualMedia.DefaultTab.AlbumsTab
}
}
}
@@ -0,0 +1,31 @@
package expo.modules.imagepicker
import android.os.Bundle
import expo.modules.kotlin.records.Field
import expo.modules.kotlin.records.Record
import expo.modules.kotlin.types.Enumerable
internal class ImagePickerAsset(
@Field val assetId: String? = null,
@Field val type: MediaType? = MediaType.IMAGE,
@Field val uri: String = "",
@Field val width: Int = 0,
@Field val height: Int = 0,
@Field val fileName: String? = null,
@Field val fileSize: Long? = null,
@Field val mimeType: String? = null,
@Field val base64: String? = null,
@Field val exif: Bundle? = null,
@Field val duration: Int? = null,
@Field val rotation: Int? = null
) : Record
internal class ImagePickerResponse(
@Field val canceled: Boolean = false,
@Field val assets: List<ImagePickerAsset>? = null
) : Record
enum class MediaType(val value: String) : Enumerable {
VIDEO("video"),
IMAGE("image")
}
@@ -0,0 +1,301 @@
package expo.modules.imagepicker
import android.content.ClipData
import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.media.MediaMetadataRetriever
import android.net.Uri
import android.provider.DocumentsContract
import android.util.Log
import android.webkit.MimeTypeMap
import androidx.core.content.FileProvider
import androidx.core.net.toFile
import androidx.exifinterface.media.ExifInterface
import expo.modules.core.utilities.FileUtilities
import expo.modules.imagepicker.ImagePickerConstants.TAG
import kotlinx.coroutines.runInterruptible
import java.io.File
import java.io.FileNotFoundException
import java.io.FileOutputStream
import java.io.IOException
import kotlin.io.use
internal fun createOutputFile(cacheDir: File, extension: String): File {
val filePath = FileUtilities.generateOutputPath(cacheDir, ImagePickerConstants.CACHE_DIR_NAME, extension)
return try {
File(filePath).apply { createNewFile() }
} catch (cause: IOException) {
throw FailedToCreateFileException(filePath, cause)
}
}
internal fun getType(contentResolver: ContentResolver, uri: Uri): String? {
val mimeFromCursor: () -> String? = {
contentResolver
.query(
uri,
listOf(DocumentsContract.Document.COLUMN_MIME_TYPE).toTypedArray(),
null,
null,
null
).use { cursor ->
if (cursor?.moveToFirst() == true) {
val columnIndex = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_MIME_TYPE)
if (columnIndex != -1 && !cursor.isNull(columnIndex)) {
cursor.getString(columnIndex)
}
}
null
}
}
return contentResolver.getType(uri)
?: mimeFromCursor()
?: getTypeFromFileUrl(uri.toString())
}
private fun getTypeFromFileUrl(url: String): String? {
val extension = MimeTypeMap.getFileExtensionFromUrl(url)
return extension?.let { MimeTypeMap.getSingleton().getMimeTypeFromExtension(it) }
}
/**
* Convert this [File] to [Uri] that might be accessed by 3rd party Activities, eg. by camera application
*/
internal fun File.toContentUri(context: Context): Uri {
return try {
FileProvider.getUriForFile(context, context.packageName + ".ImagePickerFileProvider", this)
} catch (e: Exception) {
Uri.fromFile(this)
}
}
internal fun File.toBitmapCompressFormat(): Bitmap.CompressFormat = when {
this.extension.endsWith("png", ignoreCase = true) -> Bitmap.CompressFormat.PNG
else -> Bitmap.CompressFormat.JPEG
}
internal fun Bitmap.CompressFormat.toImageFileExtension(): String {
return when (this) {
Bitmap.CompressFormat.PNG -> ".png"
Bitmap.CompressFormat.JPEG -> ".jpeg"
else -> throw RuntimeException("Compress format not supported '${this.name}'")
}
}
internal fun String.toImageFileExtension(): String = when {
this.endsWith("png", ignoreCase = true) -> ".png"
this.endsWith("gif", ignoreCase = true) -> ".gif"
this.endsWith("bmp", ignoreCase = true) -> ".bmp"
this.endsWith("webp", ignoreCase = true) -> ".webp"
!this.endsWith("jpeg", ignoreCase = true) -> {
Log.w(TAG, "Image file $this is of unsupported type. Falling back to JPEG instead.")
".jpeg"
}
else -> ".jpeg"
}
internal fun Uri.toMediaType(contentResolver: ContentResolver): MediaType? {
val type = getType(contentResolver, this)
return when {
type?.contains("image/") == true -> MediaType.IMAGE
type?.contains("video/") == true -> MediaType.VIDEO
else -> null
}
}
internal fun String.toBitmapCompressFormat(): Bitmap.CompressFormat = when {
this.endsWith("png", ignoreCase = true) ||
this.endsWith("gif", ignoreCase = true) ||
this.endsWith("bmp", ignoreCase = true) ||
this.endsWith("webp", ignoreCase = true) -> {
// The result image won't ever be a GIF of a BMP as the cropper doesn't support it.
Bitmap.CompressFormat.PNG
}
else -> {
if (!this.endsWith("jpeg", ignoreCase = true)) {
Log.w(TAG, "Image file $this is of unsupported type. Falling back to JPEG instead.")
}
Bitmap.CompressFormat.JPEG
}
}
internal fun MediaMetadataRetriever.extractInt(key: Int): Int {
return this.extractMetadata(key)?.toInt() ?: throw FailedToExtractVideoMetadataException()
}
/**
* [Iterable] implementation for [ClipData] items
*/
val ClipData.items: Iterable<ClipData.Item>
get() = object : Iterable<ClipData.Item> {
override fun iterator() = object : Iterator<ClipData.Item> {
var index = 0
val count = itemCount
override fun hasNext(): Boolean = index < count
override fun next(): ClipData.Item = getItemAt(index++)
}
}
/**
* Gets all data that is associated with this [Intent].
* Original data order is preserved.
*
* Adapted from [androidx.activity.result.contract.ActivityResultContracts.GetMultipleContents.getClipDataUris]
*/
internal fun Intent.getAllDataUris(): List<Uri> {
// Use a LinkedHashSet to maintain any ordering that may be present in the ClipData
val resultSet = LinkedHashSet<Uri>()
data
?.let { resultSet.add(it) }
clipData
?.items
?.map { it.uri }
?.let { resultSet.addAll(it) }
return resultSet.toList()
}
/**
* Copy the media file from `sourceUri` to `destinationUri`.
*
* @param sourceUri uri to the file to copy the data from
* @param targetFile file to save the media data into
*/
internal suspend fun copyFile(
sourceUri: Uri,
targetFile: File,
contentResolver: ContentResolver
) = runInterruptible {
val targetUri = Uri.fromFile(targetFile)
// source and destination are the same file
if (sourceUri.compareTo(targetUri) == 0) {
return@runInterruptible
}
try {
contentResolver.openInputStream(sourceUri)?.use { inputStream ->
FileOutputStream(targetFile).use { fileOutputStream ->
inputStream.copyTo(fileOutputStream)
return@runInterruptible
}
} ?: throw FailedToReadFileException(sourceUri.toFile())
} catch (cause: FileNotFoundException) {
throw FailedToWriteFileException(targetFile, cause)
}
}
internal suspend fun copyExifData(
sourceUri: Uri,
targetFile: File,
contentResolver: ContentResolver
) = runInterruptible {
val targetUri = Uri.fromFile(targetFile)
if (sourceUri.compareTo(targetUri) == 0) {
return@runInterruptible
}
val omittableTags = listOf(
ExifInterface.TAG_IMAGE_LENGTH,
ExifInterface.TAG_IMAGE_WIDTH,
ExifInterface.TAG_PIXEL_X_DIMENSION,
ExifInterface.TAG_PIXEL_Y_DIMENSION,
ExifInterface.TAG_ORIENTATION
)
try {
contentResolver.openInputStream(sourceUri)?.use { inputStream ->
val sourceExif = ExifInterface(inputStream)
val targetExif = ExifInterface(targetFile)
ImagePickerConstants.EXIF_TAGS
.filter { (_, tag) -> !omittableTags.contains(tag) }
.map { (_, tag) -> tag to sourceExif.getAttribute(tag) }
.filter { (_, value) -> value != null }
.forEach { (tag, value) -> targetExif.setAttribute(tag, value) }
try {
targetExif.saveAttributes()
} catch (cause: IOException) {
throw FailedToWriteExifDataToFileException(targetFile, cause)
}
} ?: throw FailedToReadFileException(sourceUri.toFile())
} catch (cause: FileNotFoundException) {
throw FailedToWriteFileException(targetFile, cause)
}
}
/*
Getting asset ID (and metadata) on Android is not that obvious. When getting a `content://` URI
using `ACTION_GET_CONTENT` or `ACTION_OPEN_DOCUMENT` intents, there are 3 possible ways:
1. When the user selects a photo from **Images** section of the picker (on the left drawer)
In this case we get a URI from `com.android.providers.media.MediaDocumentsProvider`,
that inherits from `DocumentsProvider`. The URI looks like this:
```
com.android.providers.media.documents/document/image:56
```
In this case, the `56` is the ID we're looking for.
2. When the user selects a photo from **Downloads** section, another content provider is used:
`DownloadStorageProvider` which is a bit different, and also differs depending on Android version:
- On API 29+ it also inherits from `com.android.providers.downloads.DocumentsProvider`
and the URI looks like this:
```
com.android.providers.downloads.documents/document/msf:56
```
Where "msf" is abbr. of "media store file" and 56 is our asset ID
- On API <29 it looks similar:
```
com.android.providers.downloads.documents/document/128
```
but the 128 is an internal ID of downloads provider, unrelated to media store asset ID.
3. When the user selects a photo by browsing the filesystem, the URI looks like this:
```
com.android.externalstorage.documents/document/primary:Download:filename.jpg
```
No ID in this case
*/
/**
* Checks whether this [Uri] is a `com.android.providers.media.documents` provider uri
*/
internal val Uri.isMediaProviderUri
get() = this.authority == "com.android.providers.media.documents"
/**
* Checks whether this [Uri] is a `com.android.providers.downloads.documents` provider uri
*/
internal val Uri.isDownloadsProviderUri
get() = this.authority == "com.android.providers.downloads.documents"
/**
* Checks whether asset represented by this [Uri] can be queried in the media store
*/
internal val Uri.isMediaStoreAssetUri
get() = isMediaProviderUri || (
isDownloadsProviderUri &&
DocumentsContract
.getDocumentId(this)
.startsWith("msf:")
)
/**
* If the URI represents a media store asset, this returns its ID. Otherwise, returns `null`.
*
* See the detailed explanation above in this file (ImagePickerUtils.kt).
*/
internal fun Uri.getMediaStoreAssetId(): String? {
if (isMediaStoreAssetUri) {
val rawId = DocumentsContract.getDocumentId(this)
return if (rawId.contains(':')) rawId.split(':')[1] else rawId
}
return null
}
@@ -0,0 +1,148 @@
package expo.modules.imagepicker
import android.content.Context
import android.media.MediaMetadataRetriever
import android.net.Uri
import android.provider.OpenableColumns
import android.util.Base64
import androidx.core.net.toUri
import expo.modules.imagepicker.contracts.MediaList
import expo.modules.imagepicker.exporters.CompressionImageExporter
import expo.modules.imagepicker.exporters.ImageExporter
import expo.modules.imagepicker.exporters.RawImageExporter
import expo.modules.kotlin.providers.AppContextProvider
import java.io.File
internal class MediaHandler(
private val appContextProvider: AppContextProvider
) {
private val context: Context
get() = requireNotNull(appContextProvider.appContext.reactContext) { "React Application Context is null" }
internal suspend fun readExtras(
bareResult: MediaList,
options: ImagePickerOptions
): ImagePickerResponse {
val results = bareResult.map { (mediaType, uri) ->
when (mediaType) {
MediaType.VIDEO -> handleVideo(uri)
MediaType.IMAGE -> handleImage(uri, options)
null -> ImagePickerAsset(
type = null,
uri = uri.toString()
)
}
}
return ImagePickerResponse(
canceled = false,
assets = results
)
}
private val cacheDirectory: File
get() = appContextProvider.appContext.cacheDirectory
private suspend fun handleImage(
sourceUri: Uri,
options: ImagePickerOptions
): ImagePickerAsset {
val exporter: ImageExporter = if (options.quality == ImagePickerConstants.MAXIMUM_QUALITY) {
RawImageExporter()
} else {
CompressionImageExporter(appContextProvider, options.quality)
}
val mimeType = requireNotNull(getType(context.contentResolver, sourceUri)) // this won't be null due to previous checks
val outputFile = createOutputFile(cacheDirectory, mimeType.toImageFileExtension())
val exportedImage = exporter.exportAsync(sourceUri, outputFile, context.contentResolver)
val base64 = options.base64.takeIf { it }
?.let { exportedImage.data(context.contentResolver) }
?.let { Base64.encodeToString(it.toByteArray(), Base64.NO_WRAP) }
val exif = options.exif.takeIf { it }
?.let { exportedImage.exif(context.contentResolver) }
val fileData = getAdditionalFileData(sourceUri)
return ImagePickerAsset(
type = MediaType.IMAGE,
uri = Uri.fromFile(outputFile).toString(),
width = exportedImage.width,
height = exportedImage.height,
fileName = fileData?.fileName ?: outputFile.name,
fileSize = fileData?.fileSize ?: outputFile.length(),
mimeType = mimeType,
base64 = base64,
exif = exif,
assetId = sourceUri.getMediaStoreAssetId()
)
}
private fun getAdditionalFileData(uri: Uri): AdditionalFileData? {
val queriedFields = listOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE).toTypedArray()
return context.contentResolver.query(uri, queriedFields, null, null, null)?.use { cursor ->
if (!cursor.moveToFirst()) {
return null
}
runCatching {
val name = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME).takeIf { it != -1 }?.let { cursor.getString(it) }
val size = cursor.getColumnIndex(OpenableColumns.SIZE).takeIf { it != -1 }?.let { cursor.getLong(it) }
AdditionalFileData(
name,
size
)
}.getOrNull()
}
}
private suspend fun handleVideo(
sourceUri: Uri
): ImagePickerAsset {
val outputFile = createOutputFile(cacheDirectory, ".mp4")
copyFile(sourceUri, outputFile, context.contentResolver)
val outputUri = outputFile.toUri()
try {
val metadataRetriever = MediaMetadataRetriever().apply {
setDataSource(context, outputUri)
}
val fileData = getAdditionalFileData(sourceUri)
val mimeType = getType(context.contentResolver, sourceUri)
// Extract basic metadata
var width = metadataRetriever.extractInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)
var height = metadataRetriever.extractInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)
val rotation = metadataRetriever.extractInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)
// Android returns the encoded width/height which do not take the display rotation into
// account. For videos recorded in portrait mode the encoded dimensions are often landscape
// (e.g. 1920x1080) paired with a 90°/270° rotation flag. iOS adjusts these values before
// reporting them, so to keep the behaviour consistent across platforms we swap the width
// and height when the rotation indicates the video should be displayed in portrait.
if (rotation % 180 != 0) {
width = height.also { height = width }
}
return ImagePickerAsset(
type = MediaType.VIDEO,
uri = outputUri.toString(),
width = width,
height = height,
fileName = fileData?.fileName,
fileSize = fileData?.fileSize,
mimeType = mimeType,
duration = metadataRetriever.extractInt(MediaMetadataRetriever.METADATA_KEY_DURATION),
rotation = rotation,
assetId = sourceUri.getMediaStoreAssetId()
)
} catch (cause: FailedToExtractVideoMetadataException) {
throw FailedToExtractVideoMetadataException(outputFile, cause)
}
}
}
data class AdditionalFileData(
val fileName: String?,
val fileSize: Long?
)
@@ -0,0 +1,66 @@
package expo.modules.imagepicker.contracts
import android.app.Activity
import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.provider.MediaStore
import androidx.activity.result.contract.ActivityResultContract
import androidx.core.net.toUri
import expo.modules.imagepicker.ImagePickerOptions
import expo.modules.imagepicker.CameraType
import expo.modules.imagepicker.toMediaType
import expo.modules.kotlin.activityresult.AppContextActivityResultContract
import expo.modules.kotlin.providers.AppContextProvider
import java.io.Serializable
/**
* An [ActivityResultContract] to [take a picture][MediaStore.ACTION_IMAGE_CAPTURE] or [take a video][MediaStore.ACTION_VIDEO_CAPTURE]
* saving it into the provided content-[Uri].
*
* @see [androidx.activity.result.contract.ActivityResultContracts.TakePicture] or [androidx.activity.result.contract.ActivityResultContracts.CaptureVideo]
*/
internal class CameraContract(
private val appContextProvider: AppContextProvider
) : AppContextActivityResultContract<CameraContractOptions, ImagePickerContractResult> {
private val contentResolver: ContentResolver
get() = requireNotNull(appContextProvider.appContext.reactContext) {
"React Application Context is null"
}.contentResolver
override fun createIntent(context: Context, input: CameraContractOptions): Intent =
Intent(input.options.nativeMediaTypes.toCameraIntentAction())
.putExtra(MediaStore.EXTRA_OUTPUT, input.uri.toUri())
.apply {
if (input.options.nativeMediaTypes.toCameraIntentAction() == MediaStore.ACTION_VIDEO_CAPTURE) {
putExtra(MediaStore.EXTRA_DURATION_LIMIT, input.options.videoMaxDuration)
}
if (input.options.cameraType == CameraType.FRONT) {
putExtra("android.intent.extras.LENS_FACING_FRONT", 1)
putExtra("android.intent.extras.CAMERA_FACING", 1)
putExtra("android.intent.extra.USE_FRONT_CAMERA", true)
} else {
putExtra("android.intent.extras.LENS_FACING_BACK", 1)
putExtra("android.intent.extras.CAMERA_FACING", 0)
putExtra("android.intent.extra.USE_FRONT_CAMERA", false)
}
}
override fun parseResult(input: CameraContractOptions, resultCode: Int, intent: Intent?): ImagePickerContractResult =
if (resultCode == Activity.RESULT_CANCELED) {
ImagePickerContractResult.Cancelled
} else {
val uri = Uri.parse(input.uri)
val type = uri.toMediaType(contentResolver)
ImagePickerContractResult.Success(listOf(type to uri))
}
}
internal data class CameraContractOptions(
/**
* Destination file in a form of content-[Uri] to save results coming from camera to.
*/
val uri: String,
val options: ImagePickerOptions
) : Serializable
@@ -0,0 +1,15 @@
package expo.modules.imagepicker.contracts
import android.net.Uri
import expo.modules.imagepicker.MediaType
typealias MediaList = List<Pair<MediaType?, Uri>>
/**
* Data required to be returned upon successful contract completion
*/
internal sealed class ImagePickerContractResult {
class Success(val data: MediaList) : ImagePickerContractResult()
object Cancelled : ImagePickerContractResult()
object Error : ImagePickerContractResult()
}
@@ -0,0 +1,81 @@
package expo.modules.imagepicker.contracts
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.os.Build
import androidx.core.net.toFile
import androidx.core.net.toUri
import androidx.core.os.bundleOf
import com.canhub.cropper.CropImage
import com.canhub.cropper.CropImageOptions
import com.canhub.cropper.CropImageView
import expo.modules.imagepicker.CropShape
import expo.modules.imagepicker.ImagePickerOptions
import expo.modules.imagepicker.MediaType
import expo.modules.imagepicker.copyExifData
import expo.modules.imagepicker.createOutputFile
import expo.modules.imagepicker.toBitmapCompressFormat
import expo.modules.imagepicker.toImageFileExtension
import expo.modules.kotlin.activityresult.AppContextActivityResultContract
import expo.modules.kotlin.providers.AppContextProvider
import kotlinx.coroutines.runBlocking
import java.io.Serializable
internal class CropImageContract(
private val appContextProvider: AppContextProvider
) : AppContextActivityResultContract<CropImageContractOptions, ImagePickerContractResult> {
override fun createIntent(context: Context, input: CropImageContractOptions) = Intent(context, expo.modules.imagepicker.ExpoCropImageActivity::class.java).apply {
val mediaType = expo.modules.imagepicker.getType(context.contentResolver, input.sourceUri.toUri())
val compressFormat = mediaType?.toBitmapCompressFormat() ?: Bitmap.CompressFormat.JPEG
val cacheDirectory = appContextProvider.appContext.cacheDirectory
val outputUri = createOutputFile(cacheDirectory, compressFormat.toImageFileExtension()).toUri()
putExtra(
CropImage.CROP_IMAGE_EXTRA_BUNDLE,
bundleOf(
CropImage.CROP_IMAGE_EXTRA_SOURCE to input.sourceUri.toUri(),
CropImage.CROP_IMAGE_EXTRA_OPTIONS to CropImageOptions().apply {
outputCompressFormat = compressFormat
outputCompressQuality = (input.options.quality * 100).toInt()
this.customOutputUri = outputUri
input.options.aspect?.let { (x, y) ->
aspectRatioX = x
aspectRatioY = y
fixAspectRatio = true
initialCropWindowPaddingRatio = 0f
}
cropShape = when (input.options.shape) {
CropShape.RECTANGLE -> CropImageView.CropShape.RECTANGLE
CropShape.OVAL -> CropImageView.CropShape.OVAL
}
}
)
)
}
override fun parseResult(input: CropImageContractOptions, resultCode: Int, intent: Intent?): ImagePickerContractResult {
val result = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent?.getParcelableExtra(CropImage.CROP_IMAGE_EXTRA_RESULT, CropImage.ActivityResult::class.java)
} else {
@Suppress("DEPRECATION")
intent?.getParcelableExtra(CropImage.CROP_IMAGE_EXTRA_RESULT)
}
if (resultCode == Activity.RESULT_CANCELED || result == null) {
return ImagePickerContractResult.Cancelled
}
val targetUri = requireNotNull(result.uriContent)
val contentResolver = requireNotNull(appContextProvider.appContext.reactContext) { "React Application Context is null" }.contentResolver
runBlocking { copyExifData(input.sourceUri.toUri(), targetUri.toFile(), contentResolver) }
return ImagePickerContractResult.Success(listOf(MediaType.IMAGE to targetUri))
}
}
internal data class CropImageContractOptions(
val sourceUri: String,
val options: ImagePickerOptions
) : Serializable
@@ -0,0 +1,134 @@
package expo.modules.imagepicker.contracts
import android.app.Activity
import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts.PickMultipleVisualMedia
import androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia
import expo.modules.imagepicker.ImagePickerOptions
import expo.modules.imagepicker.MediaTypes
import expo.modules.imagepicker.UNLIMITED_SELECTION
import expo.modules.imagepicker.getAllDataUris
import expo.modules.imagepicker.toMediaType
import expo.modules.kotlin.activityresult.AppContextActivityResultContract
import expo.modules.kotlin.exception.Exceptions
import expo.modules.kotlin.providers.AppContextProvider
import java.io.Serializable
/**
* An [androidx.activity.result.contract.ActivityResultContract] to prompt the user to pick single or multiple image(s) or/and video(s),
* receiving a `content://` [Uri] for each piece of content.
*
* @see [androidx.activity.result.contract.ActivityResultContracts.GetContent],
* @see [androidx.activity.result.contract.ActivityResultContracts.GetMultipleContents]
*/
internal class ImageLibraryContract(
private val appContextProvider: AppContextProvider
) : AppContextActivityResultContract<ImageLibraryContractOptions, ImagePickerContractResult> {
private val contentResolver: ContentResolver
get() = appContextProvider.appContext.reactContext?.contentResolver
?: throw Exceptions.ReactContextLost()
override fun createIntent(context: Context, input: ImageLibraryContractOptions): Intent {
if (input.options.legacy) {
return createLegacyIntent(input.options)
}
val request = PickVisualMediaRequest.Builder()
.setMediaType(
when (input.options.nativeMediaTypes) {
MediaTypes.VIDEOS -> {
PickVisualMedia.VideoOnly
}
MediaTypes.IMAGES -> {
PickVisualMedia.ImageOnly
}
else -> {
PickVisualMedia.ImageAndVideo
}
}
)
.setOrderedSelection(input.options.orderedSelection)
.setDefaultTab(input.options.defaultTab.toDefaultTab())
.build()
if (input.options.allowsMultipleSelection) {
val selectionLimit = input.options.selectionLimit
if (selectionLimit == 1) {
// If multiple selection is allowed but the limit is 1, we should ignore
// the multiple selection flag and just treat it as a single selection.
return PickVisualMedia().createIntent(context, request)
}
if (selectionLimit > 1) {
return PickMultipleVisualMedia(selectionLimit).createIntent(context, request)
}
// If the selection limit is 0, it is the same as unlimited selection.
if (selectionLimit == UNLIMITED_SELECTION) {
return PickMultipleVisualMedia().createIntent(context, request)
}
}
return PickVisualMedia().createIntent(context, request)
}
override fun parseResult(input: ImageLibraryContractOptions, resultCode: Int, intent: Intent?) =
if (resultCode == Activity.RESULT_CANCELED) {
ImagePickerContractResult.Cancelled
} else {
intent?.takeIf { resultCode == Activity.RESULT_OK }?.getAllDataUris()?.let { uris ->
if (input.options.allowsMultipleSelection) {
val results = uris.map { uri ->
uri.toMediaType(contentResolver) to uri
}.let {
if (input.options.selectionLimit > 0) {
it.take(input.options.selectionLimit)
} else {
it
}
}
ImagePickerContractResult.Success(results)
} else {
if (intent.data != null) {
intent.data?.let { uri ->
val type = uri.toMediaType(contentResolver)
ImagePickerContractResult.Success(listOf(type to uri))
}
} else {
uris.firstOrNull()?.let { uri ->
val type = uri.toMediaType(contentResolver)
ImagePickerContractResult.Success(listOf(type to uri))
} ?: ImagePickerContractResult.Error
}
}
} ?: ImagePickerContractResult.Error
}
private fun createLegacyIntent(options: ImagePickerOptions) = Intent(Intent.ACTION_GET_CONTENT)
.addCategory(Intent.CATEGORY_OPENABLE)
.setType("*/*")
.putExtra(
Intent.EXTRA_MIME_TYPES,
when (options.nativeMediaTypes) {
MediaTypes.IMAGES -> arrayOf("image/*")
MediaTypes.VIDEOS -> arrayOf("video/*")
else -> arrayOf("image/*", "video/*")
}
).apply {
if (options.allowsMultipleSelection) {
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
}
}
}
internal data class ImageLibraryContractOptions(
val options: ImagePickerOptions
) : Serializable
@@ -0,0 +1,79 @@
package expo.modules.imagepicker.exporters
import android.content.ContentResolver
import android.graphics.Bitmap
import android.net.Uri
import androidx.annotation.FloatRange
import androidx.core.net.toFile
import expo.modules.imagepicker.FailedToReadFileException
import expo.modules.imagepicker.FailedToWriteFileException
import expo.modules.imagepicker.MissingModuleException
import expo.modules.imagepicker.copyExifData
import expo.modules.imagepicker.toBitmapCompressFormat
import expo.modules.kotlin.providers.AppContextProvider
import kotlinx.coroutines.runInterruptible
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.FileNotFoundException
import java.io.FileOutputStream
import java.io.IOException
import java.util.concurrent.ExecutionException
class CompressionImageExporter(
private val appContextProvider: AppContextProvider,
@FloatRange(from = 0.0, to = 1.0)
quality: Double
) : ImageExporter {
private val compressQuality = (quality * 100).toInt()
override suspend fun exportAsync(
source: Uri,
output: File,
contentResolver: ContentResolver
): ImageExportResult {
val bitmap = readBitmap(source)
val compressFormat = output.toBitmapCompressFormat()
writeImage(bitmap, output, compressFormat)
copyExifData(source, output, contentResolver)
return object : ImageExportResult(
bitmap.width,
bitmap.height,
output
) {
override suspend fun data(contentResolver: ContentResolver): ByteArrayOutputStream {
val outputStream = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.JPEG, compressQuality, outputStream)
return outputStream
}
}
}
private suspend fun readBitmap(source: Uri): Bitmap = runInterruptible {
val loaderResult = appContextProvider.appContext.imageLoader
?.loadImageForManipulationFromURL(source.toString())
?: throw MissingModuleException("ImageLoader")
try {
loaderResult.get()
} catch (cause: ExecutionException) {
throw FailedToReadFileException(source.toFile(), cause)
}
}
/**
* Compress and save the `bitmap` to `file`
* @throws [IOException]
*/
private suspend fun writeImage(
bitmap: Bitmap,
output: File,
compressFormat: Bitmap.CompressFormat
) = runInterruptible {
try {
FileOutputStream(output).use { out -> bitmap.compress(compressFormat, compressQuality, out) }
} catch (cause: FileNotFoundException) {
throw FailedToWriteFileException(output, cause)
}
}
}
@@ -0,0 +1,34 @@
package expo.modules.imagepicker.exporters
import android.graphics.BitmapFactory
import androidx.exifinterface.media.ExifInterface
import java.io.File
/**
* Image will be rotated to orientation suggested by the exif data, because of that the width and height
* returned by the picker should be switched if the image is rotated 90 or 270 degrees.
*/
class DimensionsExporter(private val file: File) {
private val isRotatedLandscape by lazy {
val exifInterface = ExifInterface(file.absolutePath)
val imageRotation = exifInterface.getAttributeInt(ExifInterface.TAG_ORIENTATION, 0)
return@lazy (
imageRotation == ExifInterface.ORIENTATION_ROTATE_90 ||
imageRotation == ExifInterface.ORIENTATION_ROTATE_270 ||
imageRotation == ExifInterface.ORIENTATION_TRANSPOSE ||
imageRotation == ExifInterface.ORIENTATION_TRANSVERSE
)
}
private val metadata by lazy {
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(file.absolutePath, options)
options
}
val width: Int
get() = if (isRotatedLandscape) metadata.outHeight else metadata.outWidth
val height
get() = if (isRotatedLandscape) metadata.outWidth else metadata.outHeight
}
@@ -0,0 +1,71 @@
package expo.modules.imagepicker.exporters
import android.content.ContentResolver
import android.net.Uri
import android.os.Bundle
import androidx.core.net.toUri
import androidx.exifinterface.media.ExifInterface
import expo.modules.imagepicker.FailedToReadFileException
import expo.modules.imagepicker.ImagePickerConstants
import kotlinx.coroutines.runInterruptible
import java.io.ByteArrayOutputStream
import java.io.File
/**
* Interface allowing exporting an image in a different ways.
*/
interface ImageExporter {
/**
* Export the file under `source` [Uri] to the `output` [File]
*/
suspend fun exportAsync(source: Uri, output: File, contentResolver: ContentResolver): ImageExportResult
}
/**
* Results of exporting an image to the given file.
* Allows accessing extra data associated with the underlying image file.
*/
open class ImageExportResult(
val width: Int,
val height: Int,
private val imageFile: File
) {
/**
* Allows accessing the underlying byte data in a lazy manner.
*/
open suspend fun data(contentResolver: ContentResolver): ByteArrayOutputStream = runInterruptible {
contentResolver.openInputStream(imageFile.toUri())?.use { inputStream ->
ByteArrayOutputStream().use { outputStream ->
inputStream.copyTo(outputStream)
return@runInterruptible outputStream
}
} ?: throw FailedToReadFileException(imageFile)
}
/**
* Allows accessing to the EXIF data associated with this image.
*/
open suspend fun exif(contentResolver: ContentResolver): Bundle = runInterruptible {
contentResolver.openInputStream(imageFile.toUri())?.use { inputStream ->
return@runInterruptible Bundle().apply {
val exifInterface = ExifInterface(inputStream)
ImagePickerConstants.EXIF_TAGS
.filter { (_, tag) -> exifInterface.getAttribute(tag) != null }
.forEach { (type, tag) ->
when (type) {
"string" -> putString(tag, exifInterface.getAttribute(tag))
"int" -> putInt(tag, exifInterface.getAttributeInt(tag, 0))
"double" -> putDouble(tag, exifInterface.getAttributeDouble(tag, 0.0))
}
}
// Explicitly get latitude, longitude, altitude with their specific accessor functions.
exifInterface.latLong?.let { latLong ->
putDouble(ExifInterface.TAG_GPS_LATITUDE, latLong[0])
putDouble(ExifInterface.TAG_GPS_LONGITUDE, latLong[1])
putDouble(ExifInterface.TAG_GPS_ALTITUDE, exifInterface.getAltitude(0.0))
}
}
} ?: throw FailedToReadFileException(imageFile)
}
}
@@ -0,0 +1,23 @@
package expo.modules.imagepicker.exporters
import android.content.ContentResolver
import android.net.Uri
import expo.modules.imagepicker.copyFile
import java.io.File
class RawImageExporter : ImageExporter {
override suspend fun exportAsync(
source: Uri,
output: File,
contentResolver: ContentResolver
): ImageExportResult {
copyFile(source, output, contentResolver)
val dimensionsExporter = DimensionsExporter(output)
return ImageExportResult(
dimensionsExporter.width,
dimensionsExporter.height,
output
)
}
}
@@ -0,0 +1,8 @@
package expo.modules.imagepicker.fileprovider
import androidx.core.content.FileProvider
/**
* Dummy class for proving files for this module.
*/
class ImagePickerFileProvider : FileProvider()
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Custom colors for image picker cropping interface -->
<!-- Dark mode defaults -->
<color name="expoCropToolbarColor">#00000000</color> <!-- Transparent -->
<color name="expoCropToolbarIconColor">#ffffff</color> <!-- White icons -->
<color name="expoCropToolbarActionTextColor">#ffffff</color> <!-- White text -->
<color name="expoCropBackButtonIconColor">#ffffff</color> <!-- White back button -->
<color name="expoCropBackgroundColor">#000000</color> <!-- Black background -->
</resources>
@@ -0,0 +1,7 @@
<resources>
<attr name="expoCropToolbarColor" format="color"/>
<attr name="expoCropToolbarIconColor" format="color"/>
<attr name="expoCropToolbarActionTextColor" format="color"/>
<attr name="expoCropBackButtonIconColor" format="color"/>
<attr name="expoCropBackgroundColor" format="color"/>
</resources>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Custom colors for image picker cropping interface -->
<!-- Light mode defaults -->
<color name="expoCropToolbarColor">#00000000</color> <!-- Transparent -->
<color name="expoCropToolbarIconColor">#000000</color> <!-- Black icons -->
<color name="expoCropToolbarActionTextColor">#000000</color> <!-- Black text -->
<color name="expoCropBackButtonIconColor">#000000</color> <!-- Black back button -->
<color name="expoCropBackgroundColor">#ffffff</color> <!-- White background -->
</resources>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<files-path name="expo_files" path="." />
<cache-path name="cached_expo_files" path="." />
</paths>