feat(smart-app): implement complete mobile app MVP

- App.tsx: full navigation (Auth stack + Main tabs with 5 screens)
- Auth: LoginScreen, RegisterScreen, ForgotPasswordScreen
- HomeScreen: dashboard with IoT metrics, weather widget, alerts, quick actions, sensors
- MapScreen: interactive map with layer toggles (6 layers)
- MarketplaceScreen: categories (6), products (5), search
- ChatScreen: AI chat with quick prompts (4), bot responses
- ProfileScreen: user info, stats, menu (9 items), logout
- AlertsScreen: alert list with severity, acknowledge
- SensorsScreen: sensor list with type filters (6 types), search
- ZonesScreen: zone cards with stats
- SettingsScreen: language picker (FR/EN/ES/DE), privacy, about
- Stores: iotStore (sensors, zones, alerts), notificationStore, uiStore + i18n
- Hooks: useSensors, useAlerts, useNotifications, useLocation
- Components: Card, Button, LoadingSpinner, ErrorBoundary, Header
- Services: iotService, notificationService (with axios API client)
- Utils: formatters (temp, AQI, noise, dates), validators (email, password, IBAN)
- Theme: colors.ts with full design system (Blue Ocean palette)
- Ditto: fixed MongoDB connection, new JWT secrets, official gateway image
This commit is contained in:
Eric FELIXINE
2026-06-01 18:00:35 -04:00
parent 08ca495bde
commit e30ae8ed09
35578 changed files with 3703534 additions and 43 deletions

View File

@@ -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" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<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="com.canhub.cropper.CropImageActivity"
android:theme="@style/Base.Theme.AppCompat" />
<!-- 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>

View File

@@ -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
)
)
}
}

View File

@@ -0,0 +1,38 @@
package expo.modules.imagepicker
import androidx.core.net.toUri
import expo.modules.kotlin.exception.CodedException
import java.io.File
internal class FailedToDeduceTypeException :
CodedException("Can not deduce type of the returned 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 MissingCurrentActivityException :
CodedException("Activity which was provided during module initialization is no longer available")
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")

View File

@@ -0,0 +1,310 @@
package expo.modules.imagepicker
import android.Manifest
import android.Manifest.permission.READ_MEDIA_IMAGES
import android.Manifest.permission.READ_MEDIA_VIDEO
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
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.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 mediaFile = createOutputFile(cacheDirectory, options.mediaTypes.toFileExtension())
val uri = mediaFile.toContentUri(context)
val contractOptions = options.toCameraContractOptions(uri.toString())
launchContract({ cameraLauncher.launch(contractOptions) }, options)
}
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 currentActivity
get() = appContext.activityProvider?.currentActivity ?: throw MissingCurrentActivityException()
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) {
listOfNotNull(
READ_MEDIA_IMAGES,
READ_MEDIA_VIDEO
).toTypedArray()
} else {
listOfNotNull(
Manifest.permission.WRITE_EXTERNAL_STORAGE,
Manifest.permission.READ_EXTERNAL_STORAGE.takeIf { !writeOnly }
).toTypedArray()
}
private fun ensureTargetActivityIsAvailable(options: ImagePickerOptions) {
val cameraIntent = Intent(options.mediaTypes.toCameraIntentAction())
if (cameraIntent.resolveActivity(currentActivity.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.TIRAMISU) {
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.TIRAMISU },
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: List<Pair<MediaType, Uri>>,
val options: ImagePickerOptions
)

View File

@@ -0,0 +1,96 @@
package expo.modules.imagepicker
import java.io.Serializable
import android.provider.MediaStore
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 = 0.2
@Field
@IntRange(from = 0)
var selectionLimit: Int = UNLIMITED_SELECTION
@Field
var base64: Boolean = false
@Field
var exif: Boolean = false
@Field
var mediaTypes: MediaTypes = MediaTypes.IMAGES
@IntRange(from = 0)
var videoMaxDuration: Int = 0
@Field
var aspect: Pair<Int, Int>? = null
@Field
var cameraType: CameraType = CameraType.BACK
@Field
val legacy: Boolean = false
fun toCameraContractOptions(uri: String) = CameraContractOptions(uri, this)
fun toImageLibraryContractOptions() = ImageLibraryContractOptions(this)
}
internal enum class MediaTypes(val value: String) : Enumerable {
IMAGES("Images"),
VIDEOS("Videos"),
ALL("All");
fun toMimeType(): String {
return when (this) {
IMAGES -> ImageAllMimeType
VIDEOS -> VideoAllMimeType
ALL -> AllMimeType
}
}
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
}
}
private companion object {
const val ImageAllMimeType = "image/*"
const val VideoAllMimeType = "video/*"
const val AllMimeType = "*/*"
}
}
internal enum class CameraType(val value: String) : Enumerable {
BACK("back"),
FRONT("front")
}

View File

@@ -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")
}

View File

@@ -0,0 +1,280 @@
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
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 =
contentResolver.getType(uri)
?: getTypeFromFileUrl(uri.toString())
?: throw FailedToDeduceTypeException()
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/") -> MediaType.IMAGE
type.contains("video/") -> MediaType.VIDEO
else -> throw FailedToDeduceTypeException()
}
}
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
}

View File

@@ -0,0 +1,125 @@
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.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: List<Pair<MediaType, Uri>>,
options: ImagePickerOptions
): ImagePickerResponse {
val results = bareResult.map { (mediaType, uri) ->
when (mediaType) {
MediaType.VIDEO -> handleVideo(uri)
MediaType.IMAGE -> handleImage(uri, options)
}
}
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 = getType(context.contentResolver, sourceUri)
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? = context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
cursor.moveToFirst()
val name: String? = cursor.getString(nameIndex)
val size = cursor.getLong(sizeIndex)
AdditionalFileData(
name,
size
)
}
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)
return ImagePickerAsset(
type = MediaType.VIDEO,
uri = outputUri.toString(),
width = metadataRetriever.extractInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH),
height = metadataRetriever.extractInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT),
fileName = fileData?.fileName,
fileSize = fileData?.fileSize,
mimeType = mimeType,
duration = metadataRetriever.extractInt(MediaMetadataRetriever.METADATA_KEY_DURATION),
rotation = metadataRetriever.extractInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION),
assetId = sourceUri.getMediaStoreAssetId()
)
} catch (cause: FailedToExtractVideoMetadataException) {
throw FailedToExtractVideoMetadataException(outputFile, cause)
}
}
}
data class AdditionalFileData(
val fileName: String?,
val fileSize: Long?
)

View File

@@ -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.mediaTypes.toCameraIntentAction())
.putExtra(MediaStore.EXTRA_OUTPUT, input.uri.toUri())
.apply {
if (input.options.mediaTypes.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

View File

@@ -0,0 +1,13 @@
package expo.modules.imagepicker.contracts
import android.net.Uri
import expo.modules.imagepicker.MediaType
/**
* Data required to be returned upon successful contract completion
*/
internal sealed class ImagePickerContractResult private constructor() {
class Success(val data: List<Pair<MediaType, Uri>>) : ImagePickerContractResult()
object Cancelled : ImagePickerContractResult()
object Error : ImagePickerContractResult()
}

View File

@@ -0,0 +1,74 @@
package expo.modules.imagepicker.contracts
import android.app.Activity
import android.content.Context
import android.content.Intent
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.CropImageActivity
import com.canhub.cropper.CropImageOptions
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, CropImageActivity::class.java).apply {
val mediaType = expo.modules.imagepicker.getType(context.contentResolver, input.sourceUri.toUri())
val compressFormat = mediaType.toBitmapCompressFormat()
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
}
}
)
)
}
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

View File

@@ -0,0 +1,132 @@
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.mediaTypes) {
MediaTypes.VIDEOS -> {
PickVisualMedia.VideoOnly
}
MediaTypes.IMAGES -> {
PickVisualMedia.ImageOnly
}
else -> {
PickVisualMedia.ImageAndVideo
}
}
)
.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.mediaTypes) {
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

View File

@@ -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)
}
}
}

View File

@@ -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)
}
}

View File

@@ -0,0 +1,35 @@
package expo.modules.imagepicker.exporters
import android.content.ContentResolver
import android.graphics.BitmapFactory
import android.media.ExifInterface
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 exifInterface = ExifInterface(output.absolutePath)
val imageRotation = exifInterface.getAttributeInt(ExifInterface.TAG_ORIENTATION, 0)
val isRotatedLandscape = (imageRotation == ExifInterface.ORIENTATION_ROTATE_90 || imageRotation == ExifInterface.ORIENTATION_ROTATE_270)
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(output.absolutePath, options)
// 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.
val width: Int = if (isRotatedLandscape) options.outHeight else options.outWidth
val height: Int = if (isRotatedLandscape) options.outWidth else options.outHeight
return ImageExportResult(
width,
height,
output
)
}
}

View File

@@ -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()

View File

@@ -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>