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
+2
View File
@@ -0,0 +1,2 @@
// @generated by expo-module-scripts
module.exports = require('expo-module-scripts/eslintrc.base.js');
+616
View File
@@ -0,0 +1,616 @@
# Changelog
## Unpublished
### 🛠 Breaking changes
### 🎉 New features
### 🐛 Bug fixes
### 💡 Others
## 17.0.10 — 2025-12-11
### 🐛 Bug fixes
- [android] do not crash if `ContentProvider` doesn't provide mime type ([#41495](https://github.com/expo/expo/pull/41495) by [@vonovak](https://github.com/vonovak))
## 17.0.9 — 2025-12-05
_This version does not introduce any user-facing changes._
## 17.0.8 — 2025-09-11
_This version does not introduce any user-facing changes._
## 17.0.7 — 2025-09-08
### 🐛 Bug fixes
- [ios] Avoid transcoding for .avif and .tiff like it works for .heic. This keeps the asset in its original container/codec (e.g. TIFF instead of JPEG) and is required for the new fast-path. Apps that relied on .automatic re-encoded output can pass preferredAssetRepresentationMode: '.automatic' to restore the old behaviour.([#39398](https://github.com/expo/expo/pull/39398) by [@chocky335](https://github.com/chocky335))
## 17.0.6 — 2025-09-02
_This version does not introduce any user-facing changes._
## 17.0.5 — 2025-08-28
### 🐛 Bug fixes
- [iOS] Fix wrong dimensions reported for portrait images ([#39230](https://github.com/expo/expo/pull/39230) by [@hirbod](https://github.com/hirbod))
## 17.0.4 — 2025-08-27
_This version does not introduce any user-facing changes._
## 17.0.3 — 2025-08-25
_This version does not introduce any user-facing changes._
## 17.0.2 — 2025-08-16
_This version does not introduce any user-facing changes._
## 17.0.1 — 2025-08-15
_This version does not introduce any user-facing changes._
## 17.0.0 — 2025-08-13
### 🛠 Breaking changes
- [Web] Changed web `uri` to use blob URLs instead of base64 data URLs for better performance. The `uri` property will now be a blob URL instead of a base64 data URL, while the `base64` property behavior remains unchanged. ([#37447](https://github.com/expo/expo/pull/37447) by [@hirbod](https://github.com/hirbod))
- [ios] The default for `preferredAssetRepresentationMode` is now `.current` instead of `.automatic`. This keeps the asset in its original container/codec (e.g. HEIC instead of JPEG) and is required for the new fast-path. Apps that relied on `.automatic` re-encoded output can pass `preferredAssetRepresentationMode: '.automatic'` to restore the old behaviour. ([#37569](https://github.com/expo/expo/pull/37569) by [@hirbod](https://github.com/hirbod))
### 🎉 New features
- [android] Introduce customizable cropping UI with light and dark theme support ([#37573](https://github.com/expo/expo/pull/37573) by [@hirbod](https://github.com/hirbod))
- [android] Add new `shape` option to customize the shape of the crop zone ([#38148](https://github.com/expo/expo/pull/38148)) by [@janicduplessis](https://github.com/janicduplessis)
### 🐛 Bug fixes
- Prevent external applications from accessing the CropImageActivity ([#37223](https://github.com/expo/expo/pull/37223) by [@aladine](https://github.com/aladine))
- [Web] Corrected camera capture attributes on web where front camera was using 'environment' and back camera was using 'user'. Reversed the values to ensure proper camera selection. ([#37447](https://github.com/expo/expo/pull/37447) by [@hirbod](https://github.com/hirbod))
- [ios]: Enhance image reading logic to prioritize cropped images and improve orientation handling ([#37846](https://github.com/expo/expo/pull/37846) by [@hirbod](https://github.com/hirbod))
- [android] Adjust video metadata extraction to account for rotation ([#37849](https://github.com/expo/expo/pull/37849) by [@hirbod](https://github.com/hirbod))
### 💡 Others
- [ios] Images now use a _fast-path_ if possible: the original file is copied once and its size is read from the header (no full decode / re-encode). This requires `quality: 1`, `allowsEditing: false` and `preferredAssetRepresentationMode: .current` (new default) ([#37569](https://github.com/expo/expo/pull/37569) by [@hirbod](https://github.com/hirbod))
- [ios] Videos picked with `VideoExportPreset.Passthrough` are no longer transcoded and are copied only once. ([#37569](https://github.com/expo/expo/pull/37569) by [@hirbod](https://github.com/hirbod))
## 16.1.4 — 2025-04-30
_This version does not introduce any user-facing changes._
## 16.1.3 — 2025-04-25
_This version does not introduce any user-facing changes._
## 16.1.2 — 2025-04-14
_This version does not introduce any user-facing changes._
## 16.1.1 — 2025-04-09
_This version does not introduce any user-facing changes._
## 16.1.0 — 2025-04-04
### 🎉 New features
- Added support for ordered selection and default tab selection on Android. ([#34223](https://github.com/expo/expo/pull/34223) by [@fobos531](https://github.com/fobos531))
- Add support for cameraType in web implementation ([#35221](https://github.com/expo/expo/pull/35221) by [@RyogaK](https://github.com/RyogaK))
### 🐛 Bug fixes
- [Android] Fix `getPendingResultAsync` incorrect return type ([#35791](https://github.com/expo/expo/pull/35791) by [@sorenfrederiksen](https://github.com/sorenfrederiksen))
- [iOS] Use preferred representation mode to support heic ([#35840](https://github.com/expo/expo/pull/35840) by [@jakex7](https://github.com/jakex7))
### 💡 Others
- [Android] Started using expo modules gradle plugin. ([#34176](https://github.com/expo/expo/pull/34176) by [@lukmccall](https://github.com/lukmccall))
- [apple] Migrate remaining `expo-module.config.json` to unified platform syntax. ([#34445](https://github.com/expo/expo/pull/34445) by [@reichhartd](https://github.com/reichhartd))
- [iOS] Fix warnings which will become errors in Swift 6. ([#35288](https://github.com/expo/expo/pull/35288) by [@behenate](https://github.com/behenate))
## 16.0.6 - 2025-02-10
_This version does not introduce any user-facing changes._
## 16.0.5 - 2025-01-31
### 🐛 Bug fixes
- [ios] fixed concurrency freeze on low-end iOS devices when selecting multiple images. ([#34585](https://github.com/expo/expo/pull/34585) by [@chrfalch](https://github.com/chrfalch))
- [android] fixed removal of temporary file after picking image using camera ([#34797](https://github.com/expo/expo/pull/34797) by [@chrfalch](https://github.com/chrfalch))
## 16.0.4 - 2025-01-10
_This version does not introduce any user-facing changes._
## 16.0.3 — 2024-11-22
_This version does not introduce any user-facing changes._
## 16.0.2 — 2024-11-14
_This version does not introduce any user-facing changes._
## 16.0.1 — 2024-11-04
### 🐛 Bug fixes
- Fix incorrect width/height reported with EXIF tags 5 and 7 ([#32534](https://github.com/expo/expo/pull/32534) by [@gaearon](https://github.com/gaearon))
## 16.0.0 — 2024-10-22
### 🛠 Breaking changes
- Remove `READ_MEDIA_IMAGES` and `READ_MEDIA_VIDEO` permissions. ([#31902](https://github.com/expo/expo/pull/31902) by [@aleqsio](https://github.com/aleqsio))
- Bumped iOS deployment target to 15.1. ([#30840](https://github.com/expo/expo/pull/30840) by [@tsapeta](https://github.com/tsapeta))
- The default value for `quality` option has been changed from `0.2` to `1.0` for better performance and to match the most common expectation. ([#30896](https://github.com/expo/expo/pull/30896) by [@tsapeta](https://github.com/tsapeta))
- `ImagePicker.MediaTypeOptions` have been deprecated. Use a single MediaType or an array of MediaTypes instead. ([#30957](https://github.com/expo/expo/pull/30957) by [@behenate](https://github.com/behenate))
### 🎉 New features
- [web] Include `file` object from input for server uploads. ([#31788](https://github.com/expo/expo/pull/31788) by [@EvanBacon](https://github.com/EvanBacon))
- [iOS] Add support for picking live photos from the library. ([#30957](https://github.com/expo/expo/pull/30957) by [@behenate](https://github.com/behenate))
### 🐛 Bug fixes
- [iOS] Provide more image metadata in the result object. ([#29648](https://github.com/expo/expo/pull/29648) by [@vonovak](https://github.com/vonovak))
- Only import from `expo/config-plugins` to follow proper dependency chains. ([#30499](https://github.com/expo/expo/pull/30499) by [@byCedric](https://github.com/byCedric))
- [web] Return missing values from video selection. ([#30966](https://github.com/expo/expo/pull/30966) by [@entiendoNull](https://github.com/entiendoNull))
- [Android] Fix permissions on devices running Android 11 and 12. ([#31396](https://github.com/expo/expo/pull/31396) by [@alanjhughes](https://github.com/alanjhughes))
### 💡 Others
- Refactored the code on iOS and made promise resolution faster. ([#30896](https://github.com/expo/expo/pull/30896) by [@tsapeta](https://github.com/tsapeta))
## 15.0.7 - 2024-07-03
### 🐛 Bug fixes
- [iOS] Fix an issue where the app will crash when using the popover presentation style on iPad. ([#29892](https://github.com/expo/expo/pull/29892) by [@alanjhughes](https://github.com/alanjhughes))
## 15.0.6 - 2024-06-20
### 🐛 Bug fixes
- Support removing microphone permissions through config plugin. ([#29749](https://github.com/expo/expo/pull/29749) by [@alanjhughes](https://github.com/alanjhughes))
## 15.0.5 — 2024-05-15
### 🐛 Bug fixes
- [Android] Add support for granular permissions. ([#28897](https://github.com/expo/expo/pull/28897) by [@lukmccall](https://github.com/lukmccall))
## 15.0.4 — 2024-05-01
_This version does not introduce any user-facing changes._
## 15.0.3 — 2024-04-29
### 🎉 New features
- Add new `legacy` option to `ImagePickerOptions` to allow using the legacy image picker on android. ([#28514](https://github.com/expo/expo/pull/28514) by [@alanjhughes](https://github.com/alanjhughes))
### 🐛 Bug fixes
- Fixed type exports for isolatedModules option in typescript ([#28499](https://github.com/expo/expo/pull/28499) by [@megacherry](https://github.com/megacherry))
- On Android, fixed an issue where multiple pickers could be opened, causing a crash. ([#28509](https://github.com/expo/expo/pull/28509) by [@haileyok](https://github.com/haileyok))
## 15.0.2 — 2024-04-23
_This version does not introduce any user-facing changes._
## 15.0.1 — 2024-04-22
### 🐛 Bug fixes
- Fixed an issue where cropped images were not returning file size and file name on Android. ([#28352](https://github.com/expo/expo/pull/28352) by [@fobos531](https://github.com/fobos531))
## 15.0.0 — 2024-04-18
### 🎉 New features
- Add ability to disable permissions in config plugin by passing `false` instead of permission messages. ([#28107](https://github.com/expo/expo/pull/28107) by [@EvanBacon](https://github.com/EvanBacon))
### 🐛 Bug fixes
- On Android `fileSize` was named `filesize` which did not match the docs & typescript definition. ([#27293](https://github.com/expo/expo/pull/27293) by [@WookieFPV](https://github.com/wookieFPV))
- Fixed cancelled picker dialog not resolving with expected result on web. ([#11847](https://github.com/expo/expo/pull/27454) by [@raqso](https://github.com/raqso))
### 💡 Others
- drop unused web `name` property. ([#27437](https://github.com/expo/expo/pull/27437) by [@EvanBacon](https://github.com/EvanBacon))
- Convert WEBP to PNG instead JPEG when selecting an item in the Media Library with editing enabled. ([#26419](https://github.com/expo/expo/pull/26419) by [@NikitaDudin](https://github.com/NikitaDudin))
- Receiving a correct file extension for WEBP files instead `.jpeg` in the ImagePicker result. ([#26419](https://github.com/expo/expo/pull/26419) by [@NikitaDudin](https://github.com/NikitaDudin))
- Removed deprecated backward compatible Gradle settings. ([#28083](https://github.com/expo/expo/pull/28083) by [@kudo](https://github.com/kudo))
## 14.7.1 - 2023-12-19
_This version does not introduce any user-facing changes._
## 14.7.0 — 2023-11-14
### 🛠 Breaking changes
- Bumped iOS deployment target to 13.4. ([#25063](https://github.com/expo/expo/pull/25063) by [@gabrieldonadel](https://github.com/gabrieldonadel))
- On `Android` bump `compileSdkVersion` and `targetSdkVersion` to `34`. ([#24708](https://github.com/expo/expo/pull/24708) by [@alanjhughes](https://github.com/alanjhughes))
## 14.6.0 — 2023-10-17
### 🛠 Breaking changes
- Dropped support for Android SDK 21 and 22. ([#24201](https://github.com/expo/expo/pull/24201) by [@behenate](https://github.com/behenate))
### 🎉 New features
- On Android, support `fileName` and `filesize` in the returned assets. ([#24524](https://github.com/expo/expo/pull/24524) by [@alanjhughes](https://github.com/alanjhughes))
- Support returning the mime type of the returned assets. ([#24659](https://github.com/expo/expo/pull/24659) by [@alanjhughes](https://github.com/alanjhughes))
## 14.5.0 — 2023-09-04
### 🎉 New features
- Added support for React Native 0.73. ([#24018](https://github.com/expo/expo/pull/24018) by [@kudo](https://github.com/kudo))
### 🐛 Bug fixes
- On Android, the `Uri` type is not serializable and causes a crash when recreating the activity. ([#23768](https://github.com/expo/expo/pull/23768) by [@alanjhughes](https://github.com/alanjhughes))
### 💡 Others
- Remove deprecation warnings on `ImagePickerResult`. ([#24226](https://github.com/expo/expo/pull/24226) by [@alanjhughes](https://github.com/alanjhughes))
## 14.4.1 — 2023-08-02
_This version does not introduce any user-facing changes._
## 14.4.0 — 2023-07-28
_This version does not introduce any user-facing changes._
## 14.3.2 - 2023-07-23
### 💡 Others
- On Android, restore behavior from [#22658](https://github.com/expo/expo/pull/22658). ([#23617](https://github.com/expo/expo/pull/23617) by [@alanhughes](https://github.com/alanjhughes)) ([#22658](https://github.com/expo/expo/pull/22658), [#23617](https://github.com/expo/expo/pull/23617) by [@alanjhughes](https://github.com/alanjhughes))
## 14.3.1 - 2023-07-04
### 🐛 Bug fixes
- Fix manifest merger build fail on Android. ([#23191](https://github.com/expo/expo/pull/23191) by [@alexandrius](https://github.com/alexandrius))
- [Android] Fix backported photo picker crashing with null intent. ([#23224](https://github.com/expo/expo/pull/23224) by [@thespacemanatee](https://github.com/thespacemanatee))
## 14.3.0 — 2023-06-13
### 🎉 New features
- Added ability to choose the preferred asset representation mode on iOS 14+. ([#22456](https://github.com/expo/expo/pull/22456) by [@thespacemanatee](https://github.com/thespacemanatee))
- Updated the Android image picker to use a [more streamlined and modern interface](https://developer.android.com/training/data-storage/shared/photopicker), closely resembling the one on iOS. [#22658](https://github.com/expo/expo/pull/22658) by [@fobos531](https://github.com/fobos531)
### 🐛 Bug fixes
- Fixed Android build warnings for Gradle version 8. ([#22537](https://github.com/expo/expo/pull/22537), [#22609](https://github.com/expo/expo/pull/22609) by [@kudo](https://github.com/kudo))
- Fixed an issue that allowed picking non-image/video files when passing `MediaTypeOptions.All` ([#22606](https://github.com/expo/expo/pull/22606) by [@fobos531](https://github.com/fobos531))
## 14.2.0 — 2023-05-08
### 🎉 New features
- Added ability to choose the camera-facing type. ([#22143](https://github.com/expo/expo/pull/22143) by [@YoussefHenna](https://github.com/YoussefHenna))
### 🐛 Bug fixes
- Fix images unexpectedly being converted to `.png` when opening `.bmp` files and selecting any quality in `ImagePickerOptions`. ([#21361](https://github.com/expo/expo/pull/21361) by [@behenate](https://github.com/behenate))
- Fix issue where the array of permissions could end up empty causing an exception. ([#21589](https://github.com/expo/expo/pull/21589) by [@alanhughes](https://github.com/alanjhughes))
- Fix rotated videos returning incorrect width/height. [#12573](https://github.com/expo/expo/issues/12573) ([#21758](https://github.com/expo/expo/pull/21758) by [@mmmulani](https://github.com/mmmulani))
- Fix NullPointerException for launchCameraAsync on Android 13. ([#22123](https://github.com/expo/expo/pull/22123) by [@witheroux](https://github.com/witheroux))
- [Android] Fix image picker returning inverted dimensions when selecting vertical images without editing. ([#22383](https://github.com/expo/expo/pull/22383) by [@behenate](https://github.com/behenate))
## 14.1.1 — 2023-02-09
### ⚠️ Notices
- Removed deprecated fields from pick result type and deprecated `UIImagePickerPresentationStyle` enum values. ([#21078](https://github.com/expo/expo/pull/21078) by [@Simek](https://github.com/Simek))
## 14.1.0 — 2023-01-26
### 🎉 New features
- Add support for [granular permissions](https://developer.android.com/about/versions/13/behavior-changes-13) on Android 13. ([#20908](https://github.com/expo/expo/pull/20908) by [@alanhughes](https://github.com/alanjhughes))
### 💡 Others
- On Android bump `compileSdkVersion` and `targetSdkVersion` to `33`. ([#20721](https://github.com/expo/expo/pull/20721) by [@lukmccall](https://github.com/lukmccall))
## 14.0.3 — 2022-12-30
### 💡 Others
- Avoid dependency on `uuid`. ([#20476](https://github.com/expo/expo/pull/20476) by [@LinusU](https://github.com/LinusU))
## 14.0.2 - 2022-11-21
### 🐛 Bug fixes
- Fix support for animated GIFs on iOS. ([#20034](https://github.com/expo/expo/pull/20034) by [@barthap](https://github.com/barthap))
## 14.0.1 - 2022-11-08
### 🐛 Bug fixes
- Fix incorrect asset type for videos on iOS. ([#19932](https://github.com/expo/expo/pull/19932) by [@tsapeta](https://github.com/tsapeta))
## 14.0.0 — 2022-10-25
### 🛠 Breaking changes
- Remove deprecated `requestCameraRollPermissionsAsync` and `getCameraRollPermissionsAsync` methods, as well as associated to them `CameraRollPermissionResponse` type. ([#18600](https://github.com/expo/expo/pull/18600) by [@Simek](https://github.com/Simek))
- Bumped iOS deployment target to 13.0 and deprecated support for iOS 12. ([#18873](https://github.com/expo/expo/pull/18873) by [@tsapeta](https://github.com/tsapeta))
- Reworked the picking result object for simplicity and better compatibility with the multiple selection. ([#19570](https://github.com/expo/expo/pull/19570) by [@tsapeta](https://github.com/tsapeta))
### 🎉 New features
- The new `PHPickerViewController` is now default picker interface on iOS 14+. ([#18871](https://github.com/expo/expo/pull/18871) by [@barthap](https://github.com/barthap))
### 🐛 Bug fixes
- Fix images taken with `launchCameraAsync` being translated incorrectly on some camera orientations. ([#19185](https://github.com/expo/expo/pull/19185) by [@jacobjaffe](https://github.com/JacobJaffe) and [@reececox](https://github.com/reececox))
- Fix error where `launchImageLibraryAsync()` saved the photo to a global cache directory that was inaccessible in Expo Go. ([#19205](https://github.com/expo/expo/pull/19205) by [@aleqsio](https://github.com/aleqsio))
### 💡 Others
- Drop `@expo/config-plugins` dependency in favor of peer dependency on `expo`. ([#18595](https://github.com/expo/expo/pull/18595) by [@EvanBacon](https://github.com/EvanBacon))
## 13.3.1 — 2022-07-25
_This version does not introduce any user-facing changes._
## 13.3.0 — 2022-07-16
### 🎉 New features
- On iOS 14+ added support for selection limit and on iOS 15+ for ordered selection. ([#18142](https://github.com/expo/expo/pull/18142), [#18143](https://github.com/expo/expo/pull/18143) by [@barthap](https://github.com/barthap))
- The picker now resolves media library asset ID. ([#18236](https://github.com/expo/expo/pull/18236), [#18185](https://github.com/expo/expo/pull/18185) by [@barthap](https://github.com/barthap))
- On iOS, the picker now resolves file name and size and media library asset ID. ([#18179](https://github.com/expo/expo/pull/18179) by [@barthap](https://github.com/barthap))
- On Android added support for selecting multiple images/videos. ([#18161](https://github.com/expo/expo/pull/18161) by [@bbarthec](https://github.com/bbarthec))
### 🐛 Bug fixes
- On Android restored support for `allowsEditing` option that was disabled when migrating to `registerForActivityResult` mechanism. ([#17963](https://github.com/expo/expo/pull/17963) by [@bbarthec](https://github.com/bbarthec))
## 13.2.1 — 2022-07-11
_This version does not introduce any user-facing changes._
## 13.2.0 — 2022-07-07
### 🎉 New features
- On iOS 14+ added support for selecting multiple images/videos. ([#18102](https://github.com/expo/expo/pull/18102), [#18138](https://github.com/expo/expo/pull/18138), [#18145](https://github.com/expo/expo/pull/18145) by [@barthap](https://github.com/barthap))
### 🐛 Bug fixes
- Fix crash when picking a GIF image on iOS. ([#18135](https://github.com/expo/expo/pull/18135) by [@barthap](https://github.com/barthap))
### 💡 Others
- On Android migrated to the new `registerForActivityResult` mechanism. This migration disables `allowsEditing` option. ([#17671](https://github.com/expo/expo/pull/17671), ([#17987](https://github.com/expo/expo/pull/17987) by [@bbarthec](https://github.com/bbarthec))
- Native module on Android is now written in Kotlin using [Sweet API](https://docs.expo.dev/modules/module-api). ([#17668](https://github.com/expo/expo/pull/17668) by [@bbarthec](https://github.com/bbarthec))
- Migrated Expo modules definitions to the new naming convention. ([#17193](https://github.com/expo/expo/pull/17193) by [@tsapeta](https://github.com/tsapeta))
## 13.1.1 — 2022-04-27
_This version does not introduce any user-facing changes._
## 13.1.0 — 2022-04-25
### 🎉 New features
- [plugin] Added ability to disable permissions. ([#17168](https://github.com/expo/expo/pull/17168) by [@EvanBacon](https://github.com/EvanBacon))
## 13.0.1 — 2022-04-20
_This version does not introduce any user-facing changes._
## 13.0.0 — 2022-04-18
### 🛠 Breaking changes
- On Android migrated cropping library from `com.theartofdev.edmodo:android-image-cropper@2.8.0` (available from `jcenter()`) to `com.github.CanHub:Android-Image-Cropper@1.1.1` (available from `jitpack.io`). ([#11647](https://github.com/expo/expo/pull/11647) by [@bbarthec](https://github.com/bbarthec))
### 🎉 New features
- Native module on iOS is now written in Swift using the new API. ([#15977](https://github.com/expo/expo/pull/15977) by [@bbarthec](https://github.com/bbarthec))
### 🐛 Bug fixes
- Fixed crashes on Android after image is picked by adding missing dependency `expo-image-loader`. ([#17019](https://github.com/expo/expo/pull/17019) by [@M1ST4KE](https://github.com/M1ST4KE))
- Fix failure on Android when `allowsEditing` is `true` and non-jpeg file picked. ([#16615](https://github.com/expo/expo/pull/16615) by [@mnightingale](https://github.com/mnightingale))
### ⚠ Notices
- Deleted the `UIImagePickerPresentationStyle.BlurOverFullScreen` option as it does not work on iOS. ([#16925](https://github.com/expo/expo/pull/16925) by [@barthap](https://github.com/barthap))
- Deprecated all `PascalCase` values of the `UIImagePickerPresentationStyle` enum. Use their `SNAKE_UPPERCASE` counterparts instead. ([#16925](https://github.com/expo/expo/pull/16925) by [@barthap](https://github.com/barthap))
- Underlying values of the `UIImagePickerPresentationStyle` are now strings. They were integers before. ([#16925](https://github.com/expo/expo/pull/16925) by [@barthap](https://github.com/barthap))
### 💡 Others
- Updated `@expo/config-plugins` from `4.0.2` to `4.0.14` ([#15621](https://github.com/expo/expo/pull/15621) by [@EvanBacon](https://github.com/EvanBacon))
- Export missing `PermissionResponse` type. ([#15744](https://github.com/expo/expo/pull/15744) by [@Simek](https://github.com/Simek))
### ⚠️ Notices
- On Android bump `compileSdkVersion` to `31`, `targetSdkVersion` to `31` and `Java` version to `11`. ([#16941](https://github.com/expo/expo/pull/16941) by [@bbarthec](https://github.com/bbarthec))
## 12.0.2 - 2022-02-01
### 🐛 Bug fixes
- Fix `Plugin with id 'maven' not found` build error from Android Gradle 7. ([#16080](https://github.com/expo/expo/pull/16080) by [@kudo](https://github.com/kudo))
## 12.0.1 — 2021-12-15
### 🐛 Bug fixes
- Fix unresolved promise when picker was dismissed with a swipe-down on iOS. ([#15511](https://github.com/expo/expo/pull/15511) by [@barthap](https://github.com/barthap))
## 12.0.0 — 2021-12-03
### 🛠 Breaking changes
- Remove typo workaround for `getMediaLibaryPermissionsAsync` method. ([#14646](https://github.com/expo/expo/pull/14646) by [@Simek](https://github.com/Simek))
### 🐛 Bug fixes
- On Web add missing `cancelled` property to the return values of `launchCameraAsync` and `launchImageLibraryAsync` methods. ([#14646](https://github.com/expo/expo/pull/14646) by [@Simek](https://github.com/Simek))
### 💡 Others
- Export missing types: `ImageInfo`, `ImagePickerMultipleResult`, `OpenFileBrowserOptions`, `ExpandImagePickerResult`, `UIImagePickerControllerQualityType` and `UIImagePickerPresentationStyle`. ([#14646](https://github.com/expo/expo/pull/14646) by [@Simek](https://github.com/Simek))
- Extract return object containing `{ cancelled: true }` to separate type `ImagePickerCancelledResult` for `launchCameraAsync` and `launchImageLibraryAsync` methods. ([#14646](https://github.com/expo/expo/pull/14646) by [@Simek](https://github.com/Simek))
## 11.0.1 — 2021-10-01
_This version does not introduce any user-facing changes._
## 11.0.0 — 2021-09-28
### 🛠 Breaking changes
- Dropped support for iOS 11.0 ([#14383](https://github.com/expo/expo/pull/14383) by [@cruzach](https://github.com/cruzach))
### 🎉 New features
- Add useCameraPermissions and useMediaLibraryPermissions hooks from modules factory. ([#13859](https://github.com/expo/expo/pull/13859) by [@bycedric](https://github.com/bycedric))
- Add possibility to change presentation style on iOS. ([#14068](https://github.com/expo/expo/pull/14068) by [@mstach60161](https://github.com/mstach60161))
### 🐛 Bug fixes
- Add missing `GPSHPositioningError` exif parameter on Android. ([#13998](https://github.com/expo/expo/pull/13998) by [@mstach60161](https://github.com/mstach60161))
- Fix promise not resolving when the app is moved to the background on Android. ([#13975](https://github.com/expo/expo/pull/13975) by [@mstach60161](https://github.com/mstach60161))
- Fix building errors from use_frameworks! in Podfile. ([#14523](https://github.com/expo/expo/pull/14523) by [@kudo](https://github.com/kudo))
### 💡 Others
- Updated `@expo/config-plugins` ([#14443](https://github.com/expo/expo/pull/14443) by [@EvanBacon](https://github.com/EvanBacon))
## 10.2.0 — 2021-06-16
### 🐛 Bug fixes
- Fixed `base64` return on web. ([#12529](https://github.com/expo/expo/pull/12529) by [@simonezuccala](https://github.com/simonezuccala) and [@misterdev](https://github.com/misterdev))
- Enable kotlin in all modules. ([#12716](https://github.com/expo/expo/pull/12716) by [@wschurman](https://github.com/wschurman))
- Fixed cases where Picker & Camera would be transparent on iOS 14.5. ([#12897](https://github.com/expo/expo/pull/12897) by [@cruzach](https://github.com/cruzach))
- Add missing exif data to cropped image on Android. ([#14038](https://github.com/expo/expo/pull/14038) by [@mstach60161](https://github.com/mstach60161))
### 💡 Others
- Migrated from `unimodules-file-system-interface` and `unimodules-permissions-interface` to `expo-modules-core`. ([#12961](https://github.com/expo/expo/pull/12961) by [@tsapeta](https://github.com/tsapeta))
- Migrated from `AsyncTask` to Kotlin concurrency utilities. ([#13800](https://github.com/expo/expo/pull/13800) by [@m1st4ke](https://github.com/m1st4ke))
## 10.1.3 — 2021-04-13
_This version does not introduce any user-facing changes._
## 10.1.2 — 2021-04-09
### 🐛 Bug fixes
- Added SSR guard. ([#12420](https://github.com/expo/expo/pull/12420) by [@EvanBacon](https://github.com/EvanBacon))
- Reverted focus state listener PR. ([#12420](https://github.com/expo/expo/pull/12420) by [@EvanBacon](https://github.com/EvanBacon))
## 10.1.1 — 2021-03-31
_This version does not introduce any user-facing changes._
## 10.1.0 — 2021-03-10
### 🎉 New features
- Converted plugin to TypeScript. ([#11715](https://github.com/expo/expo/pull/11715) by [@EvanBacon](https://github.com/EvanBacon))
- Updated Android build configuration to target Android 11 (added support for Android SDK 30). ([#11647](https://github.com/expo/expo/pull/11647) by [@bbarthec](https://github.com/bbarthec))
### 🐛 Bug fixes
- Fixed `launchCameraAsync()` with `allowsEditing` option crashing for some android users. ([#11825](https://github.com/expo/expo/pull/11825) by [@lukmccall](https://github.com/lukmccall))
- Fixed cancelled picker dialog not resolving with expected result on web. ([#11847](https://github.com/expo/expo/pull/11847) by [@jayprado](https://github.com/jayprado))
- Fixed incorrect file URI on Android. ([#11823](https://github.com/expo/expo/pull/11823) by [@lukmccall](https://github.com/lukmccall))
## 10.0.0 — 2021-01-15
### 🛠 Breaking changes
- Dropped support for iOS 10.0 ([#11344](https://github.com/expo/expo/pull/11344) by [@tsapeta](https://github.com/tsapeta))
### 🎉 New features
- Created config plugins ([#11538](https://github.com/expo/expo/pull/11538) by [@EvanBacon](https://github.com/EvanBacon))
### 🐛 Bug fixes
- Fixed possible unsafe call in VideoResultTask. ([#11552](https://github.com/expo/expo/pull/11552) by [@Duell10111](https://github.com/Duell10111))
## 9.2.1 — 2020-12-09
### 🐛 Bug fixes
- Fix typo in media library permission methods. ([#11292](https://github.com/expo/expo/pull/11292) by [@bycedric](https://github.com/bycedric))
## 9.2.0 — 2020-11-17
_This version does not introduce any user-facing changes._
## 9.1.1 — 2020-09-23
### 🐛 Bug fixes
- `launchImageLibraryAsync()` should be callable with no options argument ([#10306](https://github.com/expo/expo/pull/10306))
## 9.1.0 — 2020-08-18
### 🎉 New features
- Added a way to handle results when activity was killed by the android. ([#9697](https://github.com/expo/expo/pull/9697) by [@lukmccall](https://github.com/lukmccall))
### 🐛 Bug fixes
- Return array of `ImagePickerResult` when `allowsMultipleSelection` is set to `true` on Web. ([#9402](https://github.com/expo/expo/pull/9402) by [@isthaison](https://github.com/isthaison))
- video recorded on iOS recorded was producing [low resolution](https://github.com/expo/expo/issues/6224) videos, not it's fixed, and can be configured via the `videoQuality` option. ([#9808](https://github.com/expo/expo/pull/9808) by [@vujevits](https://github.com/vujevits))
## 9.0.0 — 2020-08-11
### 🛠 Breaking changes
- Added camera and external storage permissions declarations to `AndroidManifest.xml` on Android. ([#9230](https://github.com/expo/expo/pull/9230) by [@bycedric](https://github.com/bycedric))
### 🎉 New features
- Added support for the limited `CAMERA_ROLL` permission on iOS 14. ([#9423](https://github.com/expo/expo/pull/9423) by [@lukmccall](https://github.com/lukmccall))
- Added `videoMaxDuration` option to `launchCameraAsync()` to configure video recording duration limit. ([#9486](https://github.com/expo/expo/pull/9486) by [@barthap](https://github.com/barthap))
## 8.4.0 — 2020-07-27
### 🐛 Bug fixes
- Fixed downsizing cropped image, when `allowsEditing` was `true`. ([#9316](https://github.com/expo/expo/pull/9316) by [@barthap](https://github.com/barthap))
## 8.3.0 — 2020-05-29
_This version does not introduce any user-facing changes._
## 8.2.0 — 2020-05-27
### 🛠 Breaking changes
- The base64 output will no longer contain newline and special character (`\n`, `\r`) on Android. ([#7841](https://github.com/expo/expo/pull/7841) by [@jarvisluong](https://github.com/jarvisluong))
### 🐛 Bug fixes
- Skip asking for camera permissions on web with `ImagePicker.getCameraPermissionsAsync`. ([#8475](https://github.com/expo/expo/pull/8475) by [@EvanBacon](https://github.com/EvanBacon))
- Fixed exception when calling `ImagePicker.getCameraPermissionsAsync` on Web. ([#7498](https://github.com/expo/expo/pull/7498) by [@IjzerenHein](https://github.com/IjzerenHein))
+108
View File
@@ -0,0 +1,108 @@
<p>
<a href="https://docs.expo.dev/versions/latest/sdk/imagepicker/">
<img
src="../../.github/resources/expo-image-picker.svg"
alt="expo-image-picker"
height="64" />
</a>
</p>
Provides access to the system's UI for selecting images and videos from the phone's library or taking a photo with the camera.
# API documentation
- [Documentation for the latest stable release](https://docs.expo.dev/versions/latest/sdk/imagepicker/)
- [Documentation for the main branch](https://docs.expo.dev/versions/unversioned/sdk/imagepicker/)
# Installation in managed Expo projects
For [managed](https://docs.expo.dev/archive/managed-vs-bare/) Expo projects, please follow the installation instructions in the [API documentation for the latest stable release](https://docs.expo.dev/versions/latest/sdk/imagepicker/).
# Installation in bare React Native projects
For bare React Native projects, you must ensure that you have [installed and configured the `expo` package](https://docs.expo.dev/bare/installing-expo-modules/) before continuing.
### Add the package to your npm dependencies
```
npx expo install expo-image-picker
```
### Configure for Android
> This is only required for usage in bare React Native apps.
This package automatically adds the `CAMERA`, `READ_EXTERNAL_STORAGE`, and `WRITE_EXTERNAL_STORAGE` permissions. They are used when picking images from the camera directly, or from the camera roll.
```xml
<!-- Added permissions -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
```
### Configure for iOS
> This is only required for usage in bare React Native apps.
Add `NSPhotoLibraryUsageDescription`, `NSCameraUsageDescription`, and `NSMicrophoneUsageDescription` keys to your `Info.plist`:
```xml
<key>NSPhotoLibraryUsageDescription</key>
<string>Give $(PRODUCT_NAME) permission to save photos</string>
<key>NSCameraUsageDescription</key>
<string>Give $(PRODUCT_NAME) permission to access your camera</string>
<key>NSMicrophoneUsageDescription</key>
<string>Give $(PRODUCT_NAME) permission to use your microphone</string>
```
Run `npx pod-install` after installing the npm package.
## Config Plugin
> This plugin is applied automatically in EAS Build, only add the config plugin if you want to pass in extra properties.
After installing this npm package, add the [config plugin](https://docs.expo.dev/config-plugins/introduction) to the [`plugins`](https://docs.expo.io/versions/latest/config/app/#plugins) array of your `app.json` or `app.config.js`:
```json
{
"expo": {
"plugins": ["expo-image-picker"]
}
}
```
Next, rebuild your app as described in the ["Adding custom native code"](https://docs.expo.dev/workflow/customizing/) guide.
### API
The plugin provides props for extra customization. Every time you change the props or plugins, you'll need to rebuild (and `prebuild`) the native app. If no extra properties are added, defaults will be used.
- `photosPermission` (_string | false_): Sets the iOS `NSPhotoLibraryUsageDescription` permission message to the `Info.plist`. Setting `false` will skip adding the permission on iOS and **does not** skip the permission on Android. Defaults to `Allow $(PRODUCT_NAME) to access your photos`.
- `cameraPermission` (_string | false_): Sets the iOS `NSCameraUsageDescription` permission message to the `Info.plist`. Setting `false` will skip adding the permission on iOS and **does not** skip the permission on Android. Defaults to `Allow $(PRODUCT_NAME) to access your camera`.
- `microphonePermission` (_string | false_): Sets the iOS `NSCameraUsageDescription` permission message to the `Info.plist`. Setting `false` will skip adding the permission on iOS and skips adding the `android.permission.RECORD_AUDIO` Android permission. Defaults to `Allow $(PRODUCT_NAME) to access your photos`.
### Example
```json
{
"expo": {
"plugins": [
[
"expo-image-picker",
{
"photosPermission": "custom photos permission",
"cameraPermission": "Allow $(PRODUCT_NAME) to open the camera",
"//": "Disables the microphone permission",
"microphonePermission": false
}
]
]
}
}
```
# Contributing
Contributions are very welcome! Please refer to guidelines described in the [contributing guide](https://github.com/expo/expo#contributing).
+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>
+1
View File
@@ -0,0 +1 @@
module.exports = require('./plugin/build/withImagePicker');
@@ -0,0 +1,3 @@
declare const _default: any;
export default _default;
//# sourceMappingURL=ExponentImagePicker.d.ts.map
@@ -0,0 +1 @@
{"version":3,"file":"ExponentImagePicker.d.ts","sourceRoot":"","sources":["../src/ExponentImagePicker.ts"],"names":[],"mappings":";AACA,wBAA0D"}
@@ -0,0 +1,3 @@
import { requireNativeModule } from 'expo-modules-core';
export default requireNativeModule('ExponentImagePicker');
//# sourceMappingURL=ExponentImagePicker.js.map
@@ -0,0 +1 @@
{"version":3,"file":"ExponentImagePicker.js","sourceRoot":"","sources":["../src/ExponentImagePicker.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AACxD,eAAe,mBAAmB,CAAC,qBAAqB,CAAC,CAAC","sourcesContent":["import { requireNativeModule } from 'expo-modules-core';\nexport default requireNativeModule('ExponentImagePicker');\n"]}
@@ -0,0 +1,12 @@
import { PermissionResponse } from 'expo-modules-core';
import { ImagePickerOptions, ImagePickerResult } from './ImagePicker.types';
declare const _default: {
launchImageLibraryAsync({ mediaTypes, allowsMultipleSelection, base64, }: ImagePickerOptions): Promise<ImagePickerResult>;
launchCameraAsync({ mediaTypes, allowsMultipleSelection, base64, cameraType, }: ImagePickerOptions): Promise<ImagePickerResult>;
getCameraPermissionsAsync(): Promise<PermissionResponse>;
requestCameraPermissionsAsync(): Promise<PermissionResponse>;
getMediaLibraryPermissionsAsync(_writeOnly: boolean): Promise<PermissionResponse>;
requestMediaLibraryPermissionsAsync(_writeOnly: boolean): Promise<PermissionResponse>;
};
export default _default;
//# sourceMappingURL=ExponentImagePicker.web.d.ts.map
@@ -0,0 +1 @@
{"version":3,"file":"ExponentImagePicker.web.d.ts","sourceRoot":"","sources":["../src/ExponentImagePicker.web.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAA8B,MAAM,mBAAmB,CAAC;AAEnF,OAAO,EAGL,kBAAkB,EAClB,iBAAiB,EAGlB,MAAM,qBAAqB,CAAC;;8EAcxB,kBAAkB,GAAG,OAAO,CAAC,iBAAiB,CAAC;oFAiB/C,kBAAkB,GAAG,OAAO,CAAC,iBAAiB,CAAC;;;gDA2BA,OAAO;oDAGH,OAAO,GAAG,OAAO,CAAC,kBAAkB,CAAC;;AApD7F,wBAuDE"}
@@ -0,0 +1,222 @@
import { PermissionStatus, Platform } from 'expo-modules-core';
import { CameraType, } from './ImagePicker.types';
import { parseMediaTypes } from './utils';
const MediaTypeInput = {
images: 'image/*',
videos: 'video/mp4,video/quicktime,video/x-m4v,video/*',
livePhotos: '',
};
export default {
async launchImageLibraryAsync({ mediaTypes = ['images'], allowsMultipleSelection = false, base64 = false, }) {
// SSR guard
if (!Platform.isDOMAvailable) {
return { canceled: true, assets: null };
}
return await openFileBrowserAsync({
mediaTypes,
allowsMultipleSelection,
base64,
});
},
async launchCameraAsync({ mediaTypes = ['images'], allowsMultipleSelection = false, base64 = false, cameraType, }) {
// SSR guard
if (!Platform.isDOMAvailable) {
return { canceled: true, assets: null };
}
return await openFileBrowserAsync({
mediaTypes,
allowsMultipleSelection,
capture: cameraType ?? true,
base64,
});
},
/*
* Delegate to expo-permissions to request camera permissions
*/
async getCameraPermissionsAsync() {
return permissionGrantedResponse();
},
async requestCameraPermissionsAsync() {
return permissionGrantedResponse();
},
/*
* Camera roll permissions don't need to be requested on web, so we always
* respond with granted.
*/
async getMediaLibraryPermissionsAsync(_writeOnly) {
return permissionGrantedResponse();
},
async requestMediaLibraryPermissionsAsync(_writeOnly) {
return permissionGrantedResponse();
},
};
function permissionGrantedResponse() {
return {
status: PermissionStatus.GRANTED,
expires: 'never',
granted: true,
canAskAgain: true,
};
}
/**
* Opens a file browser dialog or camera on supported platforms and returns the selected files.
* Handles both single and multiple file selection.
*/
function openFileBrowserAsync({ mediaTypes, capture = false, allowsMultipleSelection = false, base64, }) {
const parsedMediaTypes = parseMediaTypes(mediaTypes);
const mediaTypeFormat = createMediaTypeFormat(parsedMediaTypes);
const input = document.createElement('input');
input.style.display = 'none';
input.setAttribute('type', 'file');
input.setAttribute('accept', mediaTypeFormat);
input.setAttribute('id', String(Math.random()));
input.setAttribute('data-testid', 'file-input');
if (allowsMultipleSelection) {
input.setAttribute('multiple', 'multiple');
}
if (capture) {
switch (capture) {
case true:
input.setAttribute('capture', 'camera');
break;
case CameraType.front:
input.setAttribute('capture', 'user');
break;
case CameraType.back:
input.setAttribute('capture', 'environment');
}
}
document.body.appendChild(input);
return new Promise((resolve) => {
input.addEventListener('change', async () => {
if (input.files?.length) {
const files = allowsMultipleSelection ? input.files : [input.files[0]];
const assets = await Promise.all(Array.from(files).map((file) => readFile(file, { base64 })));
resolve({ canceled: false, assets });
}
else {
resolve({ canceled: true, assets: null });
}
document.body.removeChild(input);
});
input.addEventListener('cancel', () => {
input.dispatchEvent(new Event('change'));
});
const event = new MouseEvent('click');
input.dispatchEvent(event);
});
}
/**
* Gets metadata for an image file using a blob URL
* TODO (Hirbod): add exif support for feature parity with native
*/
async function getImageMetadata(blobUrl) {
return new Promise((resolve) => {
const image = new Image();
image.onload = () => {
resolve({
width: image.naturalWidth ?? image.width,
height: image.naturalHeight ?? image.height,
});
};
image.onerror = () => resolve({ width: 0, height: 0 });
image.src = blobUrl;
});
}
/**
* Gets metadata for a video file using a blob URL
*/
async function getVideoMetadata(blobUrl) {
return new Promise((resolve) => {
const video = document.createElement('video');
video.preload = 'metadata';
video.onloadedmetadata = () => {
resolve({
width: video.videoWidth,
height: video.videoHeight,
duration: video.duration,
});
};
video.onerror = () => resolve({ width: 0, height: 0, duration: 0 });
video.src = blobUrl;
});
}
/**
* Reads a file as base64
*/
async function readFileAsBase64(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => {
reject(new Error('Failed to read the selected media because the operation failed.'));
};
reader.onload = (event) => {
const result = event.target?.result;
if (typeof result !== 'string') {
reject(new Error('Failed to read file as base64'));
return;
}
// Remove the data URL prefix to get just the base64 data
resolve(result.split(',')[1]);
};
reader.readAsDataURL(file);
});
}
/**
* Reads a file and returns its data as an ImagePickerAsset.
* Handles both base64 and blob URL modes, and extracts metadata for images and videos.
*/
async function readFile(targetFile, options) {
const mimeType = targetFile.type;
const baseUri = URL.createObjectURL(targetFile);
try {
let metadata;
let base64;
if (mimeType.startsWith('image/')) {
metadata = await getImageMetadata(baseUri);
}
else if (mimeType.startsWith('video/')) {
metadata = await getVideoMetadata(baseUri);
}
else {
throw new Error(`Unsupported file type: ${mimeType}. Only images and videos are supported.`);
}
if (options.base64) {
base64 = await readFileAsBase64(targetFile);
}
return {
uri: baseUri,
width: metadata.width,
height: metadata.height,
type: mimeType.startsWith('image/') ? 'image' : 'video',
mimeType,
fileName: targetFile.name,
fileSize: targetFile.size,
file: targetFile,
...(metadata.duration !== undefined && { duration: metadata.duration }),
...(base64 && { base64 }),
};
}
catch (error) {
throw error;
}
}
/**
* Creates the accept attribute value for the file input based on the requested media types.
* Filters out livePhotos as they're not supported on web.
*/
function createMediaTypeFormat(mediaTypes) {
const filteredMediaTypes = mediaTypes.filter((mediaType) => mediaType !== 'livePhotos');
if (filteredMediaTypes.length === 0) {
return 'image/*';
}
let result = '';
for (const mediaType of filteredMediaTypes) {
// Make sure the types don't repeat
if (!result.includes(MediaTypeInput[mediaType])) {
result = result.concat(',', MediaTypeInput[mediaType]);
}
}
return result;
}
//# sourceMappingURL=ExponentImagePicker.web.js.map
File diff suppressed because one or more lines are too long
+100
View File
@@ -0,0 +1,100 @@
import { PermissionExpiration, PermissionHookOptions, PermissionResponse, PermissionStatus } from 'expo-modules-core';
import { CameraPermissionResponse, ImagePickerErrorResult, ImagePickerOptions, ImagePickerResult, MediaLibraryPermissionResponse } from './ImagePicker.types';
/**
* Checks user's permissions for accessing camera.
* @return A promise that fulfills with an object of type [CameraPermissionResponse](#camerapermissionresponse).
*/
export declare function getCameraPermissionsAsync(): Promise<CameraPermissionResponse>;
/**
* Checks user's permissions for accessing photos.
* @param writeOnly Whether to request write or read and write permissions. Defaults to `false`
* @return A promise that fulfills with an object of type [MediaLibraryPermissionResponse](#medialibrarypermissionresponse).
*/
export declare function getMediaLibraryPermissionsAsync(writeOnly?: boolean): Promise<MediaLibraryPermissionResponse>;
/**
* Asks the user to grant permissions for accessing camera. This does nothing on web because the
* browser camera is not used.
* @return A promise that fulfills with an object of type [CameraPermissionResponse](#camerarollpermissionresponse).
*/
export declare function requestCameraPermissionsAsync(): Promise<CameraPermissionResponse>;
/**
* Asks the user to grant permissions for accessing user's photo. This method does nothing on web.
* @param writeOnly Whether to request write or read and write permissions. Defaults to `false`
* @return A promise that fulfills with an object of type [MediaLibraryPermissionResponse](#medialibrarypermissionresponse).
*/
export declare function requestMediaLibraryPermissionsAsync(writeOnly?: boolean): Promise<MediaLibraryPermissionResponse>;
/**
* Check or request permissions to access the media library.
* This uses both `requestMediaLibraryPermissionsAsync` and `getMediaLibraryPermissionsAsync` to interact with the permissions.
*
* @example
* ```ts
* const [status, requestPermission] = ImagePicker.useMediaLibraryPermissions();
* ```
*/
export declare const useMediaLibraryPermissions: (options?: PermissionHookOptions<{
writeOnly?: boolean;
}> | undefined) => [MediaLibraryPermissionResponse | null, () => Promise<MediaLibraryPermissionResponse>, () => Promise<MediaLibraryPermissionResponse>];
/**
* Check or request permissions to access the camera.
* This uses both `requestCameraPermissionsAsync` and `getCameraPermissionsAsync` to interact with the permissions.
*
* @example
* ```ts
* const [status, requestPermission] = ImagePicker.useCameraPermissions();
* ```
*/
export declare const useCameraPermissions: (options?: PermissionHookOptions<object> | undefined) => [PermissionResponse | null, () => Promise<PermissionResponse>, () => Promise<PermissionResponse>];
/**
* Android system sometimes kills the `MainActivity` after the `ImagePicker` finishes. When this
* happens, we lose the data selected using the `ImagePicker`. However, you can retrieve the lost
* data by calling `getPendingResultAsync`. You can test this functionality by turning on
* `Don't keep activities` in the developer options.
* @return
* - **On Android:** a promise that resolves to an object of exactly same type as in
* `ImagePicker.launchImageLibraryAsync` or `ImagePicker.launchCameraAsync` if the `ImagePicker`
* finished successfully. Otherwise, an object of type [`ImagePickerErrorResult`](#imagepickerimagepickererrorresult).
* - **On other platforms:** `null`
*/
export declare function getPendingResultAsync(): Promise<ImagePickerResult | ImagePickerErrorResult | null>;
/**
* Display the system UI for taking a photo with the camera. Requires `Permissions.CAMERA`.
* On Android and iOS 10 `Permissions.CAMERA_ROLL` is also required. On mobile web, this must be
* called immediately in a user interaction like a button press, otherwise the browser will block
* the request without a warning.
* > **Note:** Make sure that you handle `MainActivity` destruction on **Android**. See [ImagePicker.getPendingResultAsync](#imagepickergetpendingresultasync).
* > **Notes for Web:** The system UI can only be shown after user activation (e.g. a `Button` press).
* Therefore, calling `launchCameraAsync` in `componentDidMount`, for example, will **not** work as
* intended. The `cancelled` event will not be returned in the browser due to platform restrictions
* and inconsistencies across browsers.
* @param options An `ImagePickerOptions` object.
* @return A promise that resolves to an object with `canceled` and `assets` fields.
* When the user canceled the action the `assets` is always `null`, otherwise it's an array of
* the selected media assets which have a form of [`ImagePickerAsset`](#imagepickerasset).
*/
export declare function launchCameraAsync(options?: ImagePickerOptions): Promise<ImagePickerResult>;
/**
* Display the system UI for choosing an image or a video from the phone's library.
* Requires `Permissions.MEDIA_LIBRARY` on iOS 10 only. On mobile web, this must be called
* immediately in a user interaction like a button press, otherwise the browser will block the
* request without a warning.
*
* **Animated GIFs support:** On Android, if the selected image is an animated GIF, the result image will be an
* animated GIF too if and only if `quality` is explicitly set to `1.0` and `allowsEditing` is set to `false`.
* Otherwise compression and/or cropper will pick the first frame of the GIF and return it as the
* result (on Android the result will be a PNG). On iOS, both quality and cropping are supported.
*
* > **Notes for Web:** The system UI can only be shown after user activation (e.g. a `Button` press).
* Therefore, calling `launchImageLibraryAsync` in `componentDidMount`, for example, will **not**
* work as intended. The `cancelled` event will not be returned in the browser due to platform
* restrictions and inconsistencies across browsers.
* @param options An object extended by [`ImagePickerOptions`](#imagepickeroptions).
* @return A promise that resolves to an object with `canceled` and `assets` fields.
* When the user canceled the action the `assets` is always `null`, otherwise it's an array of
* the selected media assets which have a form of [`ImagePickerAsset`](#imagepickerasset).
*/
export declare function launchImageLibraryAsync(options?: ImagePickerOptions): Promise<ImagePickerResult>;
export * from './ImagePicker.types';
export type { PermissionExpiration, PermissionHookOptions, PermissionResponse };
export { PermissionStatus };
//# sourceMappingURL=ImagePicker.d.ts.map
@@ -0,0 +1 @@
{"version":3,"file":"ImagePicker.d.ts","sourceRoot":"","sources":["../src/ImagePicker.ts"],"names":[],"mappings":"AAAA,OAAO,EAGL,oBAAoB,EACpB,qBAAqB,EACrB,kBAAkB,EAClB,gBAAgB,EAEjB,MAAM,mBAAmB,CAAC;AAG3B,OAAO,EACL,wBAAwB,EACxB,sBAAsB,EACtB,kBAAkB,EAClB,iBAAiB,EACjB,8BAA8B,EAC/B,MAAM,qBAAqB,CAAC;AAmC7B;;;GAGG;AACH,wBAAsB,yBAAyB,IAAI,OAAO,CAAC,wBAAwB,CAAC,CAEnF;AAGD;;;;GAIG;AACH,wBAAsB,+BAA+B,CACnD,SAAS,GAAE,OAAe,GACzB,OAAO,CAAC,8BAA8B,CAAC,CAEzC;AAGD;;;;GAIG;AACH,wBAAsB,6BAA6B,IAAI,OAAO,CAAC,wBAAwB,CAAC,CAEvF;AAGD;;;;GAIG;AACH,wBAAsB,mCAAmC,CACvD,SAAS,GAAE,OAAe,GACzB,OAAO,CAAC,8BAA8B,CAAC,CAGzC;AAGD;;;;;;;;GAQG;AACH,eAAO,MAAM,0BAA0B;gBAEvB,OAAO;wJAKrB,CAAC;AAGH;;;;;;;;GAQG;AACH,eAAO,MAAM,oBAAoB,4JAG/B,CAAC;AAGH;;;;;;;;;;GAUG;AACH,wBAAsB,qBAAqB,IAAI,OAAO,CACpD,iBAAiB,GAAG,sBAAsB,GAAG,IAAI,CAClD,CAKA;AAGD;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,iBAAiB,CACrC,OAAO,GAAE,kBAAuB,GAC/B,OAAO,CAAC,iBAAiB,CAAC,CAM5B;AAGD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAsB,uBAAuB,CAC3C,OAAO,GAAE,kBAAuB,GAC/B,OAAO,CAAC,iBAAiB,CAAC,CAc5B;AAED,cAAc,qBAAqB,CAAC;AAEpC,YAAY,EAAE,oBAAoB,EAAE,qBAAqB,EAAE,kBAAkB,EAAE,CAAC;AAChF,OAAO,EAAE,gBAAgB,EAAE,CAAC"}
+161
View File
@@ -0,0 +1,161 @@
import { CodedError, createPermissionHook, PermissionStatus, UnavailabilityError, } from 'expo-modules-core';
import ExponentImagePicker from './ExponentImagePicker';
import { mapDeprecatedOptions } from './utils';
function validateOptions(options) {
const { aspect, quality, videoMaxDuration } = options;
if (aspect != null) {
const [x, y] = aspect;
if (x <= 0 || y <= 0) {
throw new CodedError('ERR_INVALID_ARGUMENT', `Invalid aspect ratio values ${x}:${y}. Provide positive numbers.`);
}
}
if (quality && (quality < 0 || quality > 1)) {
throw new CodedError('ERR_INVALID_ARGUMENT', `Invalid 'quality' value ${quality}. Provide a value between 0 and 1.`);
}
if (videoMaxDuration && videoMaxDuration < 0) {
throw new CodedError('ERR_INVALID_ARGUMENT', `Invalid 'videoMaxDuration' value ${videoMaxDuration}. Provide a non-negative number.`);
}
return options;
}
// @needsAudit
/**
* Checks user's permissions for accessing camera.
* @return A promise that fulfills with an object of type [CameraPermissionResponse](#camerapermissionresponse).
*/
export async function getCameraPermissionsAsync() {
return ExponentImagePicker.getCameraPermissionsAsync();
}
// @needsAudit
/**
* Checks user's permissions for accessing photos.
* @param writeOnly Whether to request write or read and write permissions. Defaults to `false`
* @return A promise that fulfills with an object of type [MediaLibraryPermissionResponse](#medialibrarypermissionresponse).
*/
export async function getMediaLibraryPermissionsAsync(writeOnly = false) {
return ExponentImagePicker.getMediaLibraryPermissionsAsync(writeOnly);
}
// @needsAudit
/**
* Asks the user to grant permissions for accessing camera. This does nothing on web because the
* browser camera is not used.
* @return A promise that fulfills with an object of type [CameraPermissionResponse](#camerarollpermissionresponse).
*/
export async function requestCameraPermissionsAsync() {
return ExponentImagePicker.requestCameraPermissionsAsync();
}
// @needsAudit
/**
* Asks the user to grant permissions for accessing user's photo. This method does nothing on web.
* @param writeOnly Whether to request write or read and write permissions. Defaults to `false`
* @return A promise that fulfills with an object of type [MediaLibraryPermissionResponse](#medialibrarypermissionresponse).
*/
export async function requestMediaLibraryPermissionsAsync(writeOnly = false) {
const imagePickerMethod = ExponentImagePicker.requestMediaLibraryPermissionsAsync;
return imagePickerMethod(writeOnly);
}
// @needsAudit
/**
* Check or request permissions to access the media library.
* This uses both `requestMediaLibraryPermissionsAsync` and `getMediaLibraryPermissionsAsync` to interact with the permissions.
*
* @example
* ```ts
* const [status, requestPermission] = ImagePicker.useMediaLibraryPermissions();
* ```
*/
export const useMediaLibraryPermissions = createPermissionHook({
// TODO(cedric): permission requesters should have an options param or a different requester
getMethod: (options) => getMediaLibraryPermissionsAsync(options?.writeOnly),
requestMethod: (options) => requestMediaLibraryPermissionsAsync(options?.writeOnly),
});
// @needsAudit
/**
* Check or request permissions to access the camera.
* This uses both `requestCameraPermissionsAsync` and `getCameraPermissionsAsync` to interact with the permissions.
*
* @example
* ```ts
* const [status, requestPermission] = ImagePicker.useCameraPermissions();
* ```
*/
export const useCameraPermissions = createPermissionHook({
getMethod: getCameraPermissionsAsync,
requestMethod: requestCameraPermissionsAsync,
});
// @needsAudit
/**
* Android system sometimes kills the `MainActivity` after the `ImagePicker` finishes. When this
* happens, we lose the data selected using the `ImagePicker`. However, you can retrieve the lost
* data by calling `getPendingResultAsync`. You can test this functionality by turning on
* `Don't keep activities` in the developer options.
* @return
* - **On Android:** a promise that resolves to an object of exactly same type as in
* `ImagePicker.launchImageLibraryAsync` or `ImagePicker.launchCameraAsync` if the `ImagePicker`
* finished successfully. Otherwise, an object of type [`ImagePickerErrorResult`](#imagepickerimagepickererrorresult).
* - **On other platforms:** `null`
*/
export async function getPendingResultAsync() {
if (ExponentImagePicker.getPendingResultAsync) {
return ExponentImagePicker.getPendingResultAsync();
}
return null;
}
// @needsAudit
/**
* Display the system UI for taking a photo with the camera. Requires `Permissions.CAMERA`.
* On Android and iOS 10 `Permissions.CAMERA_ROLL` is also required. On mobile web, this must be
* called immediately in a user interaction like a button press, otherwise the browser will block
* the request without a warning.
* > **Note:** Make sure that you handle `MainActivity` destruction on **Android**. See [ImagePicker.getPendingResultAsync](#imagepickergetpendingresultasync).
* > **Notes for Web:** The system UI can only be shown after user activation (e.g. a `Button` press).
* Therefore, calling `launchCameraAsync` in `componentDidMount`, for example, will **not** work as
* intended. The `cancelled` event will not be returned in the browser due to platform restrictions
* and inconsistencies across browsers.
* @param options An `ImagePickerOptions` object.
* @return A promise that resolves to an object with `canceled` and `assets` fields.
* When the user canceled the action the `assets` is always `null`, otherwise it's an array of
* the selected media assets which have a form of [`ImagePickerAsset`](#imagepickerasset).
*/
export async function launchCameraAsync(options = {}) {
if (!ExponentImagePicker.launchCameraAsync) {
throw new UnavailabilityError('ImagePicker', 'launchCameraAsync');
}
const mappedOptions = mapDeprecatedOptions(options);
return await ExponentImagePicker.launchCameraAsync(validateOptions(mappedOptions));
}
// @needsAudit
/**
* Display the system UI for choosing an image or a video from the phone's library.
* Requires `Permissions.MEDIA_LIBRARY` on iOS 10 only. On mobile web, this must be called
* immediately in a user interaction like a button press, otherwise the browser will block the
* request without a warning.
*
* **Animated GIFs support:** On Android, if the selected image is an animated GIF, the result image will be an
* animated GIF too if and only if `quality` is explicitly set to `1.0` and `allowsEditing` is set to `false`.
* Otherwise compression and/or cropper will pick the first frame of the GIF and return it as the
* result (on Android the result will be a PNG). On iOS, both quality and cropping are supported.
*
* > **Notes for Web:** The system UI can only be shown after user activation (e.g. a `Button` press).
* Therefore, calling `launchImageLibraryAsync` in `componentDidMount`, for example, will **not**
* work as intended. The `cancelled` event will not be returned in the browser due to platform
* restrictions and inconsistencies across browsers.
* @param options An object extended by [`ImagePickerOptions`](#imagepickeroptions).
* @return A promise that resolves to an object with `canceled` and `assets` fields.
* When the user canceled the action the `assets` is always `null`, otherwise it's an array of
* the selected media assets which have a form of [`ImagePickerAsset`](#imagepickerasset).
*/
export async function launchImageLibraryAsync(options = {}) {
const mappedOptions = mapDeprecatedOptions(options);
if (!ExponentImagePicker.launchImageLibraryAsync) {
throw new UnavailabilityError('ImagePicker', 'launchImageLibraryAsync');
}
if (mappedOptions?.allowsEditing && mappedOptions.allowsMultipleSelection) {
console.warn('[expo-image-picker] `allowsEditing` is not supported when `allowsMultipleSelection` is enabled and will be ignored.' +
"Disable either 'allowsEditing' or 'allowsMultipleSelection' in 'launchImageLibraryAsync' " +
'to fix this warning.');
}
return await ExponentImagePicker.launchImageLibraryAsync(mappedOptions);
}
export * from './ImagePicker.types';
export { PermissionStatus };
//# sourceMappingURL=ImagePicker.js.map
File diff suppressed because one or more lines are too long
@@ -0,0 +1,551 @@
import { PermissionResponse } from 'expo-modules-core';
/**
* Alias for `PermissionResponse` type exported by `expo-modules-core`.
*/
export type CameraPermissionResponse = PermissionResponse;
/**
* Extends `PermissionResponse` type exported by `expo-modules-core`, containing additional iOS-specific field.
*/
export type MediaLibraryPermissionResponse = PermissionResponse & {
/**
* Indicates if your app has access to the whole or only part of the photo library. Possible values are:
* - `'all'` if the user granted your app access to the whole photo library
* - `'limited'` if the user granted your app access only to selected photos (only available on Android API 34+ and iOS 14.0+)
* - `'none'` if user denied or hasn't yet granted the permission
*/
accessPrivileges?: 'all' | 'limited' | 'none';
};
/**
* @deprecated To set media types available in the image picker use an array of [`MediaType`](#mediatype) instead.
*/
export declare enum MediaTypeOptions {
/**
* Images and videos.
*/
All = "All",
/**
* Only videos.
*/
Videos = "Videos",
/**
* Only images.
*/
Images = "Images"
}
/**
* Media types that can be picked by the image picker.
* - `'images'` - for images.
* - `'videos'` - for videos.
* - `'livePhotos'` - for live photos (iOS only).
*
* > When the `livePhotos` type is added to the media types array and a live photo is selected,
* > the resulting `ImagePickerAsset` will contain an unaltered image and the `pairedVideoAsset` field will contain a
* > video asset paired with the image. This option will be ignored when the `allowsEditing` option is enabled. Due
* > to platform limitations live photos are returned at original quality, regardless of the `quality` option.
*
* > When on Android or Web `livePhotos` type passed as a media type will be ignored.
*/
export type MediaType = 'images' | 'videos' | 'livePhotos';
/**
* The default tab with which the image picker will be opened.
* - `'photos'` - the photos/videos tab will be opened.
* - `'albums'` - the albums tab will be opened.
*
* @platform android
*/
export type DefaultTab = 'photos' | 'albums';
export declare enum VideoExportPreset {
/**
* Resolution: __Unchanged__ •
* Video compression: __None__ •
* Audio compression: __None__
*/
Passthrough = 0,
/**
* Resolution: __Depends on the device__ •
* Video compression: __H.264__ •
* Audio compression: __AAC__
*/
LowQuality = 1,
/**
* Resolution: __Depends on the device__ •
* Video compression: __H.264__ •
* Audio compression: __AAC__
*/
MediumQuality = 2,
/**
* Resolution: __Depends on the device__ •
* Video compression: __H.264__ •
* Audio compression: __AAC__
*/
HighestQuality = 3,
/**
* Resolution: __640 × 480__ •
* Video compression: __H.264__ •
* Audio compression: __AAC__
*/
H264_640x480 = 4,
/**
* Resolution: __960 × 540__ •
* Video compression: __H.264__ •
* Audio compression: __AAC__
*/
H264_960x540 = 5,
/**
* Resolution: __1280 × 720__ •
* Video compression: __H.264__ •
* Audio compression: __AAC__
*/
H264_1280x720 = 6,
/**
* Resolution: __1920 × 1080__ •
* Video compression: __H.264__ •
* Audio compression: __AAC__
*/
H264_1920x1080 = 7,
/**
* Resolution: __3840 × 2160__ •
* Video compression: __H.264__ •
* Audio compression: __AAC__
*/
H264_3840x2160 = 8,
/**
* Resolution: __1920 × 1080__ •
* Video compression: __HEVC__ •
* Audio compression: __AAC__
*/
HEVC_1920x1080 = 9,
/**
* Resolution: __3840 × 2160__ •
* Video compression: __HEVC__ •
* Audio compression: __AAC__
*/
HEVC_3840x2160 = 10
}
export declare enum UIImagePickerControllerQualityType {
/**
* Highest available resolution.
*/
High = 0,
/**
* Depends on the device.
*/
Medium = 1,
/**
* Depends on the device.
*/
Low = 2,
/**
* 640 × 480
*/
VGA640x480 = 3,
/**
* 1280 × 720
*/
IFrame1280x720 = 4,
/**
* 960 × 540
*/
IFrame960x540 = 5
}
/**
* Picker presentation style. Its values are directly mapped to the [`UIModalPresentationStyle`](https://developer.apple.com/documentation/uikit/uiviewcontroller/1621355-modalpresentationstyle).
*
* @platform ios
*/
export declare enum UIImagePickerPresentationStyle {
/**
* A presentation style in which the presented picker covers the screen.
*/
FULL_SCREEN = "fullScreen",
/**
* A presentation style that partially covers the underlying content.
*/
PAGE_SHEET = "pageSheet",
/**
* A presentation style that displays the picker centered in the screen.
*/
FORM_SHEET = "formSheet",
/**
* A presentation style where the picker is displayed over the app's content.
*/
CURRENT_CONTEXT = "currentContext",
/**
* A presentation style in which the picker view covers the screen.
*/
OVER_FULL_SCREEN = "overFullScreen",
/**
* A presentation style where the picker is displayed over the app's content.
*/
OVER_CURRENT_CONTEXT = "overCurrentContext",
/**
* A presentation style where the picker is displayed in a popover view.
*/
POPOVER = "popover",
/**
* The default presentation style chosen by the system.
* On older iOS versions, falls back to `WebBrowserPresentationStyle.FullScreen`.
*
* @platform ios
*/
AUTOMATIC = "automatic"
}
/**
* Picker preferred asset representation mode. Its values are directly mapped to the [`PHPickerConfigurationAssetRepresentationMode`](https://developer.apple.com/documentation/photokit/phpickerconfigurationassetrepresentationmode).
*
* @platform ios
*/
export declare enum UIImagePickerPreferredAssetRepresentationMode {
/**
* A mode that indicates that the system chooses the appropriate asset representation.
*/
Automatic = "automatic",
/**
* A mode that uses the most compatible asset representation.
*/
Compatible = "compatible",
/**
* A mode that uses the current representation to avoid transcoding, if possible.
*/
Current = "current"
}
export declare enum CameraType {
/**
* Back/rear camera.
*/
back = "back",
/**
* Front camera
*/
front = "front"
}
/**
* @hidden
* @deprecated Use `ImagePickerAsset` instead
*/
export type ImageInfo = ImagePickerAsset;
/**
* Represents an asset (image or video) returned by the image picker or camera.
*/
export type ImagePickerAsset = {
/**
* URI to the local image or video file (usable as the source of an `Image` element, in the case of
* an image) and `width` and `height` specify the dimensions of the media.
*/
uri: string;
/**
* The unique ID that represents the picked image or video, if picked from the library. It can be used
* by [expo-media-library](./media-library) to manage the picked asset.
*
* > This might be `null` when the ID is unavailable or the user gave limited permission to access the media library.
* > On Android, the ID is unavailable when the user selects a photo by directly browsing file system.
*
* @platform android
* @platform ios
*/
assetId?: string | null;
/**
* Width of the image or video. Can be `0` if the system did not provide the width.
*/
width: number;
/**
* Height of the image or video. Can be `0` if the system did not provide the height.
*/
height: number;
/**
* The type of the asset.
* - `'image'` - for images.
* - `'video'` - for videos.
* - `'livePhoto'` - for live photos. (iOS only)
* - `'pairedVideo'` - for videos paired with photos, which can be combined to create a live photo. (iOS only)
* - `null` - when the type could not be determined. This is rare but can happen with some Android ContentProviders.
*/
type?: 'image' | 'video' | 'livePhoto' | 'pairedVideo' | null;
/**
* Preferred filename to use when saving this item. This might be `null` when the name is unavailable
* or user gave limited permission to access the media library.
*
*/
fileName?: string | null;
/**
* File size of the picked image or video, in bytes.
*
*/
fileSize?: number;
/**
* The `exif` field is included if the `exif` option is truthy, and is an object containing the
* image's EXIF data. The names of this object's properties are EXIF tags and the values are the
* respective EXIF values for those tags.
*
* @platform android
* @platform ios
*/
exif?: Record<string, any> | null;
/**
* When the `base64` option is truthy, it is a Base64-encoded string of the selected image's JPEG data, otherwise `null`.
* If you prepend this with `'data:image/jpeg;base64,'` to create a data URI,
* you can use it as the source of an `Image` element; for example:
* ```ts
* <Image
* source={{ uri: 'data:image/jpeg;base64,' + asset.base64 }}
* style={{ width: 200, height: 200 }}
* />
* ```
*/
base64?: string | null;
/**
* Length of the video in milliseconds or `null` if the asset is not a video.
*/
duration?: number | null;
/**
* The MIME type of the selected asset or `null` if could not be determined.
*/
mimeType?: string;
/**
* Contains information about the video paired with the image file. This property is only set when `livePhotos` media type was present in the `mediaTypes` array when launching the picker and a live photo was selected.
*
* @platform ios
*/
pairedVideoAsset?: ImagePickerAsset | null;
/**
* The web `File` object containing the selected media. This property is web-only and can be used to upload to a server with `FormData`.
*
* @platform web
*/
file?: File;
};
export type ImagePickerErrorResult = {
/**
* The error code.
*/
code: string;
/**
* The error message.
*/
message: string;
/**
* The exception which caused the error.
*/
exception?: string;
};
/**
* Type representing successful and canceled pick result.
*/
export type ImagePickerResult = ImagePickerSuccessResult | ImagePickerCanceledResult;
/**
* Type representing successful pick result.
*/
export type ImagePickerSuccessResult = {
/**
* Boolean flag set to `false` showing that the request was successful.
*/
canceled: false;
/**
* An array of picked assets.
*/
assets: ImagePickerAsset[];
};
/**
* Type representing canceled pick result.
*/
export type ImagePickerCanceledResult = {
/**
* Boolean flag set to `true` showing that the request was canceled.
*/
canceled: true;
/**
* `null` signifying that the request was canceled.
*/
assets: null;
};
/**
* @hidden
* @deprecated Use `ImagePickerResult` instead.
*/
export type ImagePickerCancelledResult = ImagePickerCanceledResult;
/**
* @hidden
* @deprecated `ImagePickerMultipleResult` has been deprecated in favor of `ImagePickerResult`.
*/
export type ImagePickerMultipleResult = ImagePickerResult;
/**
* The shape of the crop area.
*/
export type CropShape = 'rectangle' | 'oval';
export type ImagePickerOptions = {
/**
* Whether to show a UI to edit the image after it is picked. On Android the user can crop and
* rotate the image and on iOS simply crop it.
*
* > - Cropping multiple images is not supported - this option is mutually exclusive with `allowsMultipleSelection`.
* > - On iOS, this option is ignored if `allowsMultipleSelection` is enabled.
* > - On iOS cropping a `.bmp` image will convert it to `.png`.
*
* @default false
* @platform android
* @platform ios
*/
allowsEditing?: boolean;
/**
* An array with two entries `[x, y]` specifying the aspect ratio to maintain if the user is
* allowed to edit the image (by passing `allowsEditing: true`). This is only applicable on
* Android, since on iOS the crop rectangle is always a square.
*/
aspect?: [number, number];
/**
* Specify the shape of the crop area if the user is allowed to edit the image
* (by passing `allowsEditing: true`). This option is only applicable on Android.
*
* @default rectangle
* @platform android
*/
shape?: CropShape;
/**
* Specify the quality of compression, from `0` to `1`. `0` means compress for small size,
* `1` means compress for maximum quality.
* > Note: If the selected image has been compressed before, the size of the output file may be
* > bigger than the size of the original image.
*
* > Note: On iOS, if a `.bmp` or `.png` image is selected from the library, this option is ignored.
*
* @default 1.0
* @platform android
* @platform ios
*/
quality?: number;
/**
* Choose what type of media to pick.
* @default 'images'
*/
mediaTypes?: MediaType | MediaType[] | MediaTypeOptions;
/**
* Whether to also include the EXIF data for the image. On iOS the EXIF data does not include GPS
* tags in the camera case.
*
* @platform android
* @platform ios
*/
exif?: boolean;
/**
* Whether to also include the image data in Base64 format.
*/
base64?: boolean;
/**
* Specify preset which will be used to compress selected video.
* @default ImagePicker.VideoExportPreset.Passthrough
* @platform ios 11+
* @deprecated See [`videoExportPreset`](https://developer.apple.com/documentation/uikit/uiimagepickercontroller/2890964-videoexportpreset?language=objc)
* in Apple documentation.
*/
videoExportPreset?: VideoExportPreset;
/**
* Specify the quality of recorded videos. Defaults to the highest quality available for the device.
* @default ImagePicker.UIImagePickerControllerQualityType.High
* @platform ios
*/
videoQuality?: UIImagePickerControllerQualityType;
/**
* Whether or not to allow selecting multiple media files at once.
*
* > Cropping multiple images is not supported - this option is mutually exclusive with `allowsEditing`.
* > If this option is enabled, then `allowsEditing` is ignored.
*
* @default false
* @platform android
* @platform ios 14+
* @platform web
*/
allowsMultipleSelection?: boolean;
/**
* The maximum number of items that user can select. Applicable when `allowsMultipleSelection` is enabled.
* Setting the value to `0` sets the selection limit to the maximum that the system supports.
*
* @platform android
* @platform ios 14+
* @default 0
*/
selectionLimit?: number;
/**
* Whether to display number badges when assets are selected. The badges are numbered
* in selection order. Assets are then returned in the exact same order they were selected.
*
* > Assets should be returned in the selection order regardless of this option,
* > but there is no guarantee that it is always true when this option is disabled.
*
* @platform ios 15+
* @default false
*/
orderedSelection?: boolean;
/**
* Choose the default tab with which the image picker will be opened.
* @default 'photos'
* @platform android
*/
defaultTab?: DefaultTab;
/**
* Maximum duration, in seconds, for video recording. Setting this to `0` disables the limit.
* Defaults to `0` (no limit).
* - **On iOS**, when `allowsEditing` is set to `true`, maximum duration is limited to 10 minutes.
* This limit is applied automatically, if `0` or no value is specified.
* - **On Android**, effect of this option depends on support of installed camera app.
* - **On Web** this option has no effect - the limit is browser-dependant.
*/
videoMaxDuration?: number;
/**
* Choose [presentation style](https://developer.apple.com/documentation/uikit/uiviewcontroller/1621355-modalpresentationstyle?language=objc)
* to customize view during taking photo/video.
* @default ImagePicker.UIImagePickerPresentationStyle.Automatic
* @platform ios
*/
presentationStyle?: UIImagePickerPresentationStyle;
/**
* Selects the camera-facing type. The `CameraType` enum provides two options:
* `front` for the front-facing camera and `back` for the back-facing camera.
* - **On Android**, the behavior of this option may vary based on the camera app installed on the device.
* - **On Web**, if this option is not provided, use "camera" as the default value of internal input element for backwards compatibility.
* @default CameraType.back
*/
cameraType?: CameraType;
/**
* Choose [preferred asset representation mode](https://developer.apple.com/documentation/photokit/phpickerconfigurationassetrepresentationmode)
* to use when loading assets.
* @default ImagePicker.UIImagePickerPreferredAssetRepresentationMode.Automatic
* @platform ios 14+
*/
preferredAssetRepresentationMode?: UIImagePickerPreferredAssetRepresentationMode;
/**
* Uses the legacy image picker on Android. This will allow media to be selected from outside the users photo library.
* @platform android
* @default false
*/
legacy?: boolean;
};
/**
* @hidden
* @deprecated Only used internally.
*/
export type OpenFileBrowserOptions = {
/**
* Choose what type of media to pick.
* @default 'images'
*/
mediaTypes: MediaType | MediaType[] | MediaTypeOptions;
capture?: boolean | CameraType;
/**
* Whether or not to allow selecting multiple media files at once.
* @platform web
*/
allowsMultipleSelection: boolean;
/**
* Whether to also include the image data in Base64 format.
*/
base64: boolean;
};
/**
* @hidden
* @deprecated Use `ImagePickerResult` or `OpenFileBrowserOptions` instead.
*/
export type ExpandImagePickerResult<T extends ImagePickerOptions | OpenFileBrowserOptions> = T extends {
allowsMultipleSelection: true;
} ? ImagePickerResult : ImagePickerResult;
//# sourceMappingURL=ImagePicker.types.d.ts.map
@@ -0,0 +1 @@
{"version":3,"file":"ImagePicker.types.d.ts","sourceRoot":"","sources":["../src/ImagePicker.types.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AAGvD;;GAEG;AACH,MAAM,MAAM,wBAAwB,GAAG,kBAAkB,CAAC;AAG1D;;GAEG;AACH,MAAM,MAAM,8BAA8B,GAAG,kBAAkB,GAAG;IAChE;;;;;OAKG;IACH,gBAAgB,CAAC,EAAE,KAAK,GAAG,SAAS,GAAG,MAAM,CAAC;CAC/C,CAAC;AAGF;;GAEG;AACH,oBAAY,gBAAgB;IAC1B;;OAEG;IACH,GAAG,QAAQ;IACX;;OAEG;IACH,MAAM,WAAW;IACjB;;OAEG;IACH,MAAM,WAAW;CAClB;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,MAAM,SAAS,GAAG,QAAQ,GAAG,QAAQ,GAAG,YAAY,CAAC;AAE3D;;;;;;GAMG;AACH,MAAM,MAAM,UAAU,GAAG,QAAQ,GAAG,QAAQ,CAAC;AAG7C,oBAAY,iBAAiB;IAC3B;;;;OAIG;IACH,WAAW,IAAI;IACf;;;;OAIG;IACH,UAAU,IAAI;IACd;;;;OAIG;IACH,aAAa,IAAI;IACjB;;;;OAIG;IACH,cAAc,IAAI;IAClB;;;;OAIG;IACH,YAAY,IAAI;IAChB;;;;OAIG;IACH,YAAY,IAAI;IAChB;;;;OAIG;IACH,aAAa,IAAI;IACjB;;;;OAIG;IACH,cAAc,IAAI;IAClB;;;;OAIG;IACH,cAAc,IAAI;IAClB;;;;OAIG;IACH,cAAc,IAAI;IAClB;;;;OAIG;IACH,cAAc,KAAK;CACpB;AAGD,oBAAY,kCAAkC;IAC5C;;OAEG;IACH,IAAI,IAAI;IACR;;OAEG;IACH,MAAM,IAAI;IACV;;OAEG;IACH,GAAG,IAAI;IACP;;OAEG;IACH,UAAU,IAAI;IACd;;OAEG;IACH,cAAc,IAAI;IAClB;;OAEG;IACH,aAAa,IAAI;CAClB;AAED;;;;GAIG;AACH,oBAAY,8BAA8B;IACxC;;OAEG;IACH,WAAW,eAAe;IAC1B;;OAEG;IACH,UAAU,cAAc;IACxB;;OAEG;IACH,UAAU,cAAc;IACxB;;OAEG;IACH,eAAe,mBAAmB;IAClC;;OAEG;IACH,gBAAgB,mBAAmB;IACnC;;OAEG;IACH,oBAAoB,uBAAuB;IAC3C;;OAEG;IACH,OAAO,YAAY;IACnB;;;;;OAKG;IACH,SAAS,cAAc;CACxB;AAED;;;;GAIG;AACH,oBAAY,6CAA6C;IACvD;;OAEG;IACH,SAAS,cAAc;IACvB;;OAEG;IACH,UAAU,eAAe;IACzB;;OAEG;IACH,OAAO,YAAY;CACpB;AAED,oBAAY,UAAU;IACpB;;OAEG;IACH,IAAI,SAAS;IACb;;OAEG;IACH,KAAK,UAAU;CAChB;AAED;;;GAGG;AACH,MAAM,MAAM,SAAS,GAAG,gBAAgB,CAAC;AAEzC;;GAEG;AACH,MAAM,MAAM,gBAAgB,GAAG;IAC7B;;;OAGG;IACH,GAAG,EAAE,MAAM,CAAC;IACZ;;;;;;;;;OASG;IACH,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB;;OAEG;IACH,KAAK,EAAE,MAAM,CAAC;IACd;;OAEG;IACH,MAAM,EAAE,MAAM,CAAC;IACf;;;;;;;OAOG;IACH,IAAI,CAAC,EAAE,OAAO,GAAG,OAAO,GAAG,WAAW,GAAG,aAAa,GAAG,IAAI,CAAC;IAC9D;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;;;;;OAOG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,IAAI,CAAC;IAClC;;;;;;;;;;OAUG;IACH,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB;;OAEG;IACH,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB;;OAEG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;;OAIG;IACH,gBAAgB,CAAC,EAAE,gBAAgB,GAAG,IAAI,CAAC;IAE3C;;;;OAIG;IACH,IAAI,CAAC,EAAE,IAAI,CAAC;CACb,CAAC;AAGF,MAAM,MAAM,sBAAsB,GAAG;IACnC;;OAEG;IACH,IAAI,EAAE,MAAM,CAAC;IACb;;OAEG;IACH,OAAO,EAAE,MAAM,CAAC;IAChB;;OAEG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB,CAAC;AAGF;;GAEG;AACH,MAAM,MAAM,iBAAiB,GAAG,wBAAwB,GAAG,yBAAyB,CAAC;AAErF;;GAEG;AACH,MAAM,MAAM,wBAAwB,GAAG;IACrC;;OAEG;IACH,QAAQ,EAAE,KAAK,CAAC;IAChB;;OAEG;IACH,MAAM,EAAE,gBAAgB,EAAE,CAAC;CAC5B,CAAC;AAEF;;GAEG;AACH,MAAM,MAAM,yBAAyB,GAAG;IACtC;;OAEG;IACH,QAAQ,EAAE,IAAI,CAAC;IACf;;OAEG;IACH,MAAM,EAAE,IAAI,CAAC;CACd,CAAC;AAEF;;;GAGG;AACH,MAAM,MAAM,0BAA0B,GAAG,yBAAyB,CAAC;AAEnE;;;GAGG;AACH,MAAM,MAAM,yBAAyB,GAAG,iBAAiB,CAAC;AAE1D;;GAEG;AACH,MAAM,MAAM,SAAS,GAAG,WAAW,GAAG,MAAM,CAAC;AAG7C,MAAM,MAAM,kBAAkB,GAAG;IAC/B;;;;;;;;;;;OAWG;IACH,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB;;;;OAIG;IACH,MAAM,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC1B;;;;;;OAMG;IACH,KAAK,CAAC,EAAE,SAAS,CAAC;IAClB;;;;;;;;;;;OAWG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;OAGG;IACH,UAAU,CAAC,EAAE,SAAS,GAAG,SAAS,EAAE,GAAG,gBAAgB,CAAC;IACxD;;;;;;OAMG;IACH,IAAI,CAAC,EAAE,OAAO,CAAC;IACf;;OAEG;IACH,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB;;;;;;OAMG;IACH,iBAAiB,CAAC,EAAE,iBAAiB,CAAC;IACtC;;;;OAIG;IACH,YAAY,CAAC,EAAE,kCAAkC,CAAC;IAClD;;;;;;;;;;OAUG;IACH,uBAAuB,CAAC,EAAE,OAAO,CAAC;IAClC;;;;;;;OAOG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;;;;;;;OASG;IACH,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B;;;;OAIG;IACH,UAAU,CAAC,EAAE,UAAU,CAAC;IACxB;;;;;;;OAOG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B;;;;;OAKG;IACH,iBAAiB,CAAC,EAAE,8BAA8B,CAAC;IACnD;;;;;;OAMG;IACH,UAAU,CAAC,EAAE,UAAU,CAAC;IACxB;;;;;OAKG;IACH,gCAAgC,CAAC,EAAE,6CAA6C,CAAC;IACjF;;;;OAIG;IACH,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB,CAAC;AAEF;;;GAGG;AACH,MAAM,MAAM,sBAAsB,GAAG;IACnC;;;OAGG;IACH,UAAU,EAAE,SAAS,GAAG,SAAS,EAAE,GAAG,gBAAgB,CAAC;IAEvD,OAAO,CAAC,EAAE,OAAO,GAAG,UAAU,CAAC;IAC/B;;;OAGG;IACH,uBAAuB,EAAE,OAAO,CAAC;IACjC;;OAEG;IACH,MAAM,EAAE,OAAO,CAAC;CACjB,CAAC;AAEF;;;GAGG;AACH,MAAM,MAAM,uBAAuB,CAAC,CAAC,SAAS,kBAAkB,GAAG,sBAAsB,IACvF,CAAC,SAAS;IAAE,uBAAuB,EAAE,IAAI,CAAA;CAAE,GAAG,iBAAiB,GAAG,iBAAiB,CAAC"}
@@ -0,0 +1,192 @@
// @needsAudit
/**
* @deprecated To set media types available in the image picker use an array of [`MediaType`](#mediatype) instead.
*/
export var MediaTypeOptions;
(function (MediaTypeOptions) {
/**
* Images and videos.
*/
MediaTypeOptions["All"] = "All";
/**
* Only videos.
*/
MediaTypeOptions["Videos"] = "Videos";
/**
* Only images.
*/
MediaTypeOptions["Images"] = "Images";
})(MediaTypeOptions || (MediaTypeOptions = {}));
// @needsAudit
export var VideoExportPreset;
(function (VideoExportPreset) {
/**
* Resolution: __Unchanged__ •
* Video compression: __None__ •
* Audio compression: __None__
*/
VideoExportPreset[VideoExportPreset["Passthrough"] = 0] = "Passthrough";
/**
* Resolution: __Depends on the device__ •
* Video compression: __H.264__ •
* Audio compression: __AAC__
*/
VideoExportPreset[VideoExportPreset["LowQuality"] = 1] = "LowQuality";
/**
* Resolution: __Depends on the device__ •
* Video compression: __H.264__ •
* Audio compression: __AAC__
*/
VideoExportPreset[VideoExportPreset["MediumQuality"] = 2] = "MediumQuality";
/**
* Resolution: __Depends on the device__ •
* Video compression: __H.264__ •
* Audio compression: __AAC__
*/
VideoExportPreset[VideoExportPreset["HighestQuality"] = 3] = "HighestQuality";
/**
* Resolution: __640 × 480__ •
* Video compression: __H.264__ •
* Audio compression: __AAC__
*/
VideoExportPreset[VideoExportPreset["H264_640x480"] = 4] = "H264_640x480";
/**
* Resolution: __960 × 540__ •
* Video compression: __H.264__ •
* Audio compression: __AAC__
*/
VideoExportPreset[VideoExportPreset["H264_960x540"] = 5] = "H264_960x540";
/**
* Resolution: __1280 × 720__ •
* Video compression: __H.264__ •
* Audio compression: __AAC__
*/
VideoExportPreset[VideoExportPreset["H264_1280x720"] = 6] = "H264_1280x720";
/**
* Resolution: __1920 × 1080__ •
* Video compression: __H.264__ •
* Audio compression: __AAC__
*/
VideoExportPreset[VideoExportPreset["H264_1920x1080"] = 7] = "H264_1920x1080";
/**
* Resolution: __3840 × 2160__ •
* Video compression: __H.264__ •
* Audio compression: __AAC__
*/
VideoExportPreset[VideoExportPreset["H264_3840x2160"] = 8] = "H264_3840x2160";
/**
* Resolution: __1920 × 1080__ •
* Video compression: __HEVC__ •
* Audio compression: __AAC__
*/
VideoExportPreset[VideoExportPreset["HEVC_1920x1080"] = 9] = "HEVC_1920x1080";
/**
* Resolution: __3840 × 2160__ •
* Video compression: __HEVC__ •
* Audio compression: __AAC__
*/
VideoExportPreset[VideoExportPreset["HEVC_3840x2160"] = 10] = "HEVC_3840x2160";
})(VideoExportPreset || (VideoExportPreset = {}));
// @needsAudit
export var UIImagePickerControllerQualityType;
(function (UIImagePickerControllerQualityType) {
/**
* Highest available resolution.
*/
UIImagePickerControllerQualityType[UIImagePickerControllerQualityType["High"] = 0] = "High";
/**
* Depends on the device.
*/
UIImagePickerControllerQualityType[UIImagePickerControllerQualityType["Medium"] = 1] = "Medium";
/**
* Depends on the device.
*/
UIImagePickerControllerQualityType[UIImagePickerControllerQualityType["Low"] = 2] = "Low";
/**
* 640 × 480
*/
UIImagePickerControllerQualityType[UIImagePickerControllerQualityType["VGA640x480"] = 3] = "VGA640x480";
/**
* 1280 × 720
*/
UIImagePickerControllerQualityType[UIImagePickerControllerQualityType["IFrame1280x720"] = 4] = "IFrame1280x720";
/**
* 960 × 540
*/
UIImagePickerControllerQualityType[UIImagePickerControllerQualityType["IFrame960x540"] = 5] = "IFrame960x540";
})(UIImagePickerControllerQualityType || (UIImagePickerControllerQualityType = {}));
/**
* Picker presentation style. Its values are directly mapped to the [`UIModalPresentationStyle`](https://developer.apple.com/documentation/uikit/uiviewcontroller/1621355-modalpresentationstyle).
*
* @platform ios
*/
export var UIImagePickerPresentationStyle;
(function (UIImagePickerPresentationStyle) {
/**
* A presentation style in which the presented picker covers the screen.
*/
UIImagePickerPresentationStyle["FULL_SCREEN"] = "fullScreen";
/**
* A presentation style that partially covers the underlying content.
*/
UIImagePickerPresentationStyle["PAGE_SHEET"] = "pageSheet";
/**
* A presentation style that displays the picker centered in the screen.
*/
UIImagePickerPresentationStyle["FORM_SHEET"] = "formSheet";
/**
* A presentation style where the picker is displayed over the app's content.
*/
UIImagePickerPresentationStyle["CURRENT_CONTEXT"] = "currentContext";
/**
* A presentation style in which the picker view covers the screen.
*/
UIImagePickerPresentationStyle["OVER_FULL_SCREEN"] = "overFullScreen";
/**
* A presentation style where the picker is displayed over the app's content.
*/
UIImagePickerPresentationStyle["OVER_CURRENT_CONTEXT"] = "overCurrentContext";
/**
* A presentation style where the picker is displayed in a popover view.
*/
UIImagePickerPresentationStyle["POPOVER"] = "popover";
/**
* The default presentation style chosen by the system.
* On older iOS versions, falls back to `WebBrowserPresentationStyle.FullScreen`.
*
* @platform ios
*/
UIImagePickerPresentationStyle["AUTOMATIC"] = "automatic";
})(UIImagePickerPresentationStyle || (UIImagePickerPresentationStyle = {}));
/**
* Picker preferred asset representation mode. Its values are directly mapped to the [`PHPickerConfigurationAssetRepresentationMode`](https://developer.apple.com/documentation/photokit/phpickerconfigurationassetrepresentationmode).
*
* @platform ios
*/
export var UIImagePickerPreferredAssetRepresentationMode;
(function (UIImagePickerPreferredAssetRepresentationMode) {
/**
* A mode that indicates that the system chooses the appropriate asset representation.
*/
UIImagePickerPreferredAssetRepresentationMode["Automatic"] = "automatic";
/**
* A mode that uses the most compatible asset representation.
*/
UIImagePickerPreferredAssetRepresentationMode["Compatible"] = "compatible";
/**
* A mode that uses the current representation to avoid transcoding, if possible.
*/
UIImagePickerPreferredAssetRepresentationMode["Current"] = "current";
})(UIImagePickerPreferredAssetRepresentationMode || (UIImagePickerPreferredAssetRepresentationMode = {}));
export var CameraType;
(function (CameraType) {
/**
* Back/rear camera.
*/
CameraType["back"] = "back";
/**
* Front camera
*/
CameraType["front"] = "front";
})(CameraType || (CameraType = {}));
//# sourceMappingURL=ImagePicker.types.js.map
File diff suppressed because one or more lines are too long
+4
View File
@@ -0,0 +1,4 @@
import { ImagePickerOptions, MediaType, MediaTypeOptions } from './ImagePicker.types';
export declare function parseMediaTypes(mediaTypes: MediaTypeOptions | MediaType | MediaType[]): MediaType[];
export declare function mapDeprecatedOptions(options: ImagePickerOptions): ImagePickerOptions;
//# sourceMappingURL=utils.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../src/utils.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,kBAAkB,EAAE,SAAS,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AAEtF,wBAAgB,eAAe,CAC7B,UAAU,EAAE,gBAAgB,GAAG,SAAS,GAAG,SAAS,EAAE,GACrD,SAAS,EAAE,CAsBb;AAGD,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,kBAAkB,sBAK/D"}
+28
View File
@@ -0,0 +1,28 @@
// @hidden
import { MediaTypeOptions } from './ImagePicker.types';
export function parseMediaTypes(mediaTypes) {
const mediaTypeOptionsToMediaType = {
Images: ['images'],
Videos: ['videos'],
All: ['images', 'videos'],
};
if (mediaTypes === MediaTypeOptions.Images ||
mediaTypes === MediaTypeOptions.Videos ||
mediaTypes === MediaTypeOptions.All) {
console.warn('[expo-image-picker] `ImagePicker.MediaTypeOptions` have been deprecated. Use `ImagePicker.MediaType` or an array of `ImagePicker.MediaType` instead.');
return mediaTypeOptionsToMediaType[mediaTypes];
}
// Unlike iOS, Android can't auto-cast to array
if (typeof mediaTypes === 'string') {
return [mediaTypes];
}
return mediaTypes;
}
// We deprecated the MediaTypeOptions in SDK52, we should remove it in future release.
export function mapDeprecatedOptions(options) {
if (!options.mediaTypes) {
return options;
}
return { ...options, mediaTypes: parseMediaTypes(options.mediaTypes ?? []) };
}
//# sourceMappingURL=utils.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"utils.js","sourceRoot":"","sources":["../src/utils.ts"],"names":[],"mappings":"AAAA,UAAU;AACV,OAAO,EAAiC,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AAEtF,MAAM,UAAU,eAAe,CAC7B,UAAsD;IAEtD,MAAM,2BAA2B,GAA0C;QACzE,MAAM,EAAE,CAAC,QAAQ,CAAC;QAClB,MAAM,EAAE,CAAC,QAAQ,CAAC;QAClB,GAAG,EAAE,CAAC,QAAQ,EAAE,QAAQ,CAAC;KAC1B,CAAC;IAEF,IACE,UAAU,KAAK,gBAAgB,CAAC,MAAM;QACtC,UAAU,KAAK,gBAAgB,CAAC,MAAM;QACtC,UAAU,KAAK,gBAAgB,CAAC,GAAG,EACnC,CAAC;QACD,OAAO,CAAC,IAAI,CACV,sJAAsJ,CACvJ,CAAC;QACF,OAAO,2BAA2B,CAAC,UAAU,CAAC,CAAC;IACjD,CAAC;IACD,+CAA+C;IAC/C,IAAI,OAAO,UAAU,KAAK,QAAQ,EAAE,CAAC;QACnC,OAAO,CAAC,UAAU,CAAC,CAAC;IACtB,CAAC;IACD,OAAO,UAAU,CAAC;AACpB,CAAC;AAED,sFAAsF;AACtF,MAAM,UAAU,oBAAoB,CAAC,OAA2B;IAC9D,IAAI,CAAC,OAAO,CAAC,UAAU,EAAE,CAAC;QACxB,OAAO,OAAO,CAAC;IACjB,CAAC;IACD,OAAO,EAAE,GAAG,OAAO,EAAE,UAAU,EAAE,eAAe,CAAC,OAAO,CAAC,UAAU,IAAI,EAAE,CAAC,EAAE,CAAC;AAC/E,CAAC","sourcesContent":["// @hidden\nimport { ImagePickerOptions, MediaType, MediaTypeOptions } from './ImagePicker.types';\n\nexport function parseMediaTypes(\n mediaTypes: MediaTypeOptions | MediaType | MediaType[]\n): MediaType[] {\n const mediaTypeOptionsToMediaType: Record<MediaTypeOptions, MediaType[]> = {\n Images: ['images'],\n Videos: ['videos'],\n All: ['images', 'videos'],\n };\n\n if (\n mediaTypes === MediaTypeOptions.Images ||\n mediaTypes === MediaTypeOptions.Videos ||\n mediaTypes === MediaTypeOptions.All\n ) {\n console.warn(\n '[expo-image-picker] `ImagePicker.MediaTypeOptions` have been deprecated. Use `ImagePicker.MediaType` or an array of `ImagePicker.MediaType` instead.'\n );\n return mediaTypeOptionsToMediaType[mediaTypes];\n }\n // Unlike iOS, Android can't auto-cast to array\n if (typeof mediaTypes === 'string') {\n return [mediaTypes];\n }\n return mediaTypes;\n}\n\n// We deprecated the MediaTypeOptions in SDK52, we should remove it in future release.\nexport function mapDeprecatedOptions(options: ImagePickerOptions) {\n if (!options.mediaTypes) {\n return options;\n }\n return { ...options, mediaTypes: parseMediaTypes(options.mediaTypes ?? []) };\n}\n"]}
+15
View File
@@ -0,0 +1,15 @@
{
"platforms": ["apple", "android"],
"apple": {
"modules": ["ImagePickerModule"]
},
"android": {
"modules": ["expo.modules.imagepicker.ImagePickerModule"],
"publication": {
"groupId": "host.exp.exponent",
"artifactId": "expo.modules.imagepicker",
"version": "17.0.10",
"repository": "local-maven-repo"
}
}
}
@@ -0,0 +1,34 @@
require 'json'
package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))
Pod::Spec.new do |s|
s.name = 'ExpoImagePicker'
s.version = package['version']
s.summary = package['description']
s.description = package['description']
s.license = package['license']
s.author = package['author']
s.homepage = package['homepage']
s.platforms = {
:ios => '15.1'
}
s.swift_version = '5.9'
s.source = { git: 'https://github.com/expo/expo.git' }
s.static_framework = true
s.dependency 'ExpoModulesCore'
# Swift/Objective-C compatibility
s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES',
'SWIFT_COMPILATION_MODE' => 'wholemodule'
}
if !$ExpoUseSources&.include?(package['name']) && ENV['EXPO_USE_SOURCE'].to_i == 0 && File.exist?("#{s.name}.xcframework") && Gem::Version.new(Pod::VERSION) >= Gem::Version.new('1.10.0')
s.source_files = "**/*.h"
s.vendored_frameworks = "#{s.name}.xcframework"
else
s.source_files = "**/*.{h,m,swift}"
end
end
@@ -0,0 +1,141 @@
// Copyright 2022-present 650 Industries. All rights reserved.
import ExpoModulesCore
internal final class PermissionsModuleNotFoundException: Exception {
override var reason: String {
"Permissions module not found. Are you sure that Expo modules are properly linked?"
}
}
internal final class FileSystemModuleNotFoundException: Exception {
override var reason: String {
"FileSystem module not found. Are you sure that Expo modules are properly linked?"
}
}
internal final class LoggerModuleNotFoundException: Exception {
override var reason: String {
"Logger module not found. Are you sure that Expo modules are properly linked?"
}
}
internal final class MissingCameraPermissionException: Exception {
override var reason: String {
"Missing camera or camera roll permission"
}
}
internal final class MissingMicrophonePermissionException: Exception {
override var reason: String {
"Missing microphone permission. Please enable it with the `expo-image-picker` config plugin"
}
}
internal final class MissingPhotoLibraryPermissionException: Exception {
override var reason: String {
"Missing photo library permission"
}
}
internal final class CameraUnavailableOnSimulatorException: Exception {
override var reason: String {
"Camera not available on simulator"
}
}
internal final class MultiselectUnavailableException: Exception {
override var reason: String {
"Multiple selection is only available on iOS 14+"
}
}
internal final class MissingCurrentViewControllerException: Exception {
override var reason: String {
"Cannot determine currently presented view controller"
}
}
internal final class MaxDurationWhileEditingExceededException: Exception {
override var reason: String {
"'videoMaxDuration' limits to 600 when 'allowsEditing=true'"
}
}
internal final class InvalidMediaTypeException: GenericException<String?> {
override var reason: String {
"Cannot handle '\(param ?? "nil")' media type"
}
}
internal final class FailedToCreateGifException: Exception {
override var reason: String {
"Failed to create image destination for GIF export"
}
}
internal final class FailedToExportGifException: Exception {
override var reason: String {
"Failed to export requested GIF"
}
}
internal final class FailedToWriteImageException: Exception {
override var reason: String {
"Failed to write data to a file"
}
}
internal final class FailedToReadImageException: Exception {
override var reason: String {
"Failed to read picked image"
}
}
internal final class FailedToReadImageDataException: Exception {
override var reason: String {
"Failed to read data from a file"
}
}
internal final class FailedToReadVideoSizeException: Exception {
override var reason: String {
"Failed to read the video size"
}
}
internal final class FailedToReadVideoException: Exception {
override var reason: String {
"Failed to read picked video"
}
}
internal final class FailedToTranscodeVideoException: Exception {
override var reason: String {
"Failed to transcode picked video"
}
}
internal final class UnsupportedVideoExportPresetException: GenericException<String> {
override var reason: String {
"Video cannot be transcoded with export preset: \(param)"
}
}
internal final class FailedToPickVideoException: Exception {
override var reason: String {
"Video could not be picked"
}
}
internal final class FailedToReadImageDataForBase64Exception: Exception {
override var reason: String {
"Failed to read image data to perform base64 encoding"
}
}
internal final class FailedToPickLivePhotoException: Exception {
override var reason: String {
"Failed to read the selected item as a live photo"
}
}
@@ -0,0 +1,108 @@
// Copyright 2022-present 650 Industries. All rights reserved.
import PhotosUI
/**
Protocol that describes scenarios we care about while the user is picking media.
*/
protocol OnMediaPickingResultHandler {
func didPickMultipleMedia(selection: [PHPickerResult])
func didPickMedia(mediaInfo: MediaInfo)
func didCancelPicking()
}
/**
This class is responsible for responding to any events that are happening in `UIImagePickerController`.
It then forwards them back in unified way via `OnMediaPickingResultHandler`.
The functionality of this delegate is separated from the main module class for two reasons:
1) main module cannot inherit from `NSObject` (and that's required by three protocols we must conform to),
because it already inherits from `Module` class and Swift language does not allow multiple inheritance,
2) it separates some logic from the main module class and hopefully makes it cleaner.
*/
internal class ImagePickerHandler: NSObject,
PHPickerViewControllerDelegate,
UINavigationControllerDelegate,
UIImagePickerControllerDelegate,
UIAdaptivePresentationControllerDelegate {
private let onMediaPickingResultHandler: OnMediaPickingResultHandler
private let hideStatusBarWhenPresented: Bool
private var statusBarVisibilityController = StatusBarVisibilityController()
init(onMediaPickingResultHandler: OnMediaPickingResultHandler, hideStatusBarWhenPresented: Bool) {
self.onMediaPickingResultHandler = onMediaPickingResultHandler
self.hideStatusBarWhenPresented = hideStatusBarWhenPresented
}
private func handlePickedMedia(mediaInfo: MediaInfo) {
statusBarVisibilityController.maybeRestoreStatusBarVisibility()
onMediaPickingResultHandler.didPickMedia(mediaInfo: mediaInfo)
}
private func handlePickedMedia(selection: [PHPickerResult]) {
statusBarVisibilityController.maybeRestoreStatusBarVisibility()
onMediaPickingResultHandler.didPickMultipleMedia(selection: selection)
}
private func handlePickingCancellation() {
statusBarVisibilityController.maybeRestoreStatusBarVisibility()
onMediaPickingResultHandler.didCancelPicking()
}
// MARK: - UIImagePickerControllerDelegate
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: MediaInfo) {
handlePickedMedia(mediaInfo: info)
picker.dismiss(animated: true)
}
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
handlePickingCancellation()
picker.dismiss(animated: true)
}
// MARK: - PHPickerViewControllerDelegate
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
// The PHPickerViewController returns empty collection when canceled
if results.isEmpty {
handlePickingCancellation()
} else {
handlePickedMedia(selection: results)
}
picker.dismiss(animated: true)
}
// MARK: - UIAdaptivePresentationControllerDelegate
func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
handlePickingCancellation()
}
// MARK: - UINavigationControllerDelegate
func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) {
statusBarVisibilityController.maybePreserveVisibilityAndHideStatusBar(hideStatusBarWhenPresented)
}
}
/**
Protocol that is a common type for supported picker controllers.
*/
internal protocol PickerUIController: UIViewController {
func setResultHandler(_ handler: ImagePickerHandler)
}
extension UIImagePickerController: PickerUIController {
func setResultHandler(_ handler: ImagePickerHandler) {
self.delegate = handler
self.presentationController?.delegate = handler
}
}
extension PHPickerViewController: PickerUIController {
func setResultHandler(_ handler: ImagePickerHandler) {
self.delegate = handler
self.presentationController?.delegate = handler
}
}
@@ -0,0 +1,251 @@
// Copyright 2022-present 650 Industries. All rights reserved.
import UIKit
import PhotosUI
import ExpoModulesCore
typealias MediaInfo = [UIImagePickerController.InfoKey: Any]
/**
Helper struct storing single picking operation context variables that have their own non-sharable state.
*/
struct PickingContext {
let promise: Promise
let options: ImagePickerOptions
let imagePickerHandler: ImagePickerHandler
}
enum OperationType {
case ask
case get
}
public class ImagePickerModule: Module, OnMediaPickingResultHandler {
public func definition() -> ModuleDefinition {
// TODO: (@bbarthec) change to "ExpoImagePicker" and propagate to other platforms
Name("ExponentImagePicker")
OnCreate {
self.appContext?.permissions?.register([
CameraPermissionRequester(),
MediaLibraryPermissionRequester(),
MediaLibraryWriteOnlyPermissionRequester()
])
}
AsyncFunction("getCameraPermissionsAsync", { (promise: Promise) in
self.handlePermissionRequest(requesterClass: CameraPermissionRequester.self, operationType: .get, promise: promise)
})
AsyncFunction("getMediaLibraryPermissionsAsync", { (writeOnly: Bool, promise: Promise) in
self.handlePermissionRequest(requesterClass: self.getMediaLibraryPermissionRequester(writeOnly), operationType: .get, promise: promise)
})
AsyncFunction("requestCameraPermissionsAsync", { (promise: Promise) in
self.handlePermissionRequest(requesterClass: CameraPermissionRequester.self, operationType: .ask, promise: promise)
})
AsyncFunction("requestMediaLibraryPermissionsAsync", { (writeOnly: Bool, promise: Promise) in
self.handlePermissionRequest(requesterClass: self.getMediaLibraryPermissionRequester(writeOnly), operationType: .ask, promise: promise)
})
AsyncFunction("launchCameraAsync", { (options: ImagePickerOptions, promise: Promise) in
guard let permissions = self.appContext?.permissions else {
return promise.reject(PermissionsModuleNotFoundException())
}
guard permissions.hasGrantedPermission(usingRequesterClass: CameraPermissionRequester.self) else {
return promise.reject(MissingCameraPermissionException())
}
self.launchImagePicker(sourceType: .camera, options: options, promise: promise)
})
.runOnQueue(DispatchQueue.main)
AsyncFunction("launchImageLibraryAsync", { (options: ImagePickerOptions, promise: Promise) in
self.launchImagePicker(sourceType: .photoLibrary, options: options, promise: promise)
})
.runOnQueue(DispatchQueue.main)
}
private var currentPickingContext: PickingContext?
private func handlePermissionRequest(requesterClass: AnyClass, operationType: OperationType, promise: Promise) {
guard let permissions = self.appContext?.permissions else {
return promise.reject(PermissionsModuleNotFoundException())
}
switch operationType {
case .get: permissions.getPermissionUsingRequesterClass(requesterClass, resolve: promise.resolver, reject: promise.legacyRejecter)
case .ask: permissions.askForPermission(usingRequesterClass: requesterClass, resolve: promise.resolver, reject: promise.legacyRejecter)
}
}
private func getMediaLibraryPermissionRequester(_ writeOnly: Bool) -> AnyClass {
return writeOnly ? MediaLibraryWriteOnlyPermissionRequester.self : MediaLibraryPermissionRequester.self
}
private func launchImagePicker(sourceType: UIImagePickerController.SourceType, options: ImagePickerOptions, promise: Promise) {
let imagePickerDelegate = ImagePickerHandler(onMediaPickingResultHandler: self, hideStatusBarWhenPresented: options.allowsEditing && !options.allowsMultipleSelection)
let pickingContext = PickingContext(promise: promise,
options: options,
imagePickerHandler: imagePickerDelegate)
if !options.allowsEditing && sourceType != .camera {
self.launchMultiSelectPicker(pickingContext: pickingContext)
} else {
self.launchLegacyImagePicker(sourceType: sourceType, pickingContext: pickingContext)
}
}
private func launchLegacyImagePicker(sourceType: UIImagePickerController.SourceType, pickingContext: PickingContext) {
let options = pickingContext.options
let picker = UIImagePickerController()
picker.fixCannotMoveEditingBox()
if sourceType == .camera {
#if targetEnvironment(simulator)
return pickingContext.promise.reject(CameraUnavailableOnSimulatorException())
#else
picker.sourceType = .camera
picker.cameraDevice = options.cameraType == .front ? .front : .rear
#endif
}
if sourceType == .photoLibrary {
picker.sourceType = .photoLibrary
}
picker.mediaTypes = options.toMediaTypesArray()
if options.requiresMicrophonePermission() && sourceType == .camera {
do {
try checkMicrophonePermissions()
} catch {
pickingContext.promise.reject(error)
return
}
}
picker.videoExportPreset = options.videoExportPreset.toAVAssetExportPreset()
picker.videoQuality = options.videoQuality.toQualityType()
picker.videoMaximumDuration = options.videoMaxDuration
if options.allowsEditing {
picker.allowsEditing = options.allowsEditing
if options.videoMaxDuration > 600 {
return pickingContext.promise.reject(MaxDurationWhileEditingExceededException())
}
if options.videoMaxDuration == 0 {
picker.videoMaximumDuration = 600.0
}
}
presentPickerUI(picker, pickingContext: pickingContext)
}
private func checkMicrophonePermissions() throws {
guard Bundle.main.object(forInfoDictionaryKey: "NSMicrophoneUsageDescription") != nil else {
throw MissingMicrophonePermissionException()
}
}
private func launchMultiSelectPicker(pickingContext: PickingContext) {
var configuration = PHPickerConfiguration(photoLibrary: PHPhotoLibrary.shared())
let options = pickingContext.options
// selection limit = 1 --> single selection, reflects the old picker behavior
configuration.selectionLimit = options.allowsMultipleSelection ? options.selectionLimit : SINGLE_SELECTION
configuration.filter = options.toPickerFilter()
configuration.preferredAssetRepresentationMode = options.preferredAssetRepresentationMode.toAssetRepresentationMode()
configuration.selection = options.orderedSelection ? .ordered : .default
let picker = PHPickerViewController(configuration: configuration)
presentPickerUI(picker, pickingContext: pickingContext)
}
private func presentPickerUI(_ picker: PickerUIController, pickingContext context: PickingContext) {
guard let currentViewController = self.appContext?.utilities?.currentViewController() else {
return context.promise.reject(MissingCurrentViewControllerException())
}
picker.modalPresentationStyle = context.options.presentationStyle.toPresentationStyle()
if UIDevice.current.userInterfaceIdiom == .pad {
let viewFrame = currentViewController.view.frame
picker.popoverPresentationController?.sourceRect = CGRect(
x: viewFrame.midX,
y: viewFrame.maxY,
width: 0,
height: 0
)
picker.popoverPresentationController?.sourceView = currentViewController.view
}
picker.setResultHandler(context.imagePickerHandler)
// Store picking context as we're navigating to the different view controller (starting asynchronous flow)
self.currentPickingContext = context
currentViewController.present(picker, animated: true, completion: nil)
}
// MARK: - OnMediaPickingResultHandler
func didCancelPicking() {
self.currentPickingContext?.promise.resolve(ImagePickerResponse(assets: nil, canceled: true))
self.currentPickingContext = nil
}
func didPickMultipleMedia(selection: [PHPickerResult]) {
guard let options = self.currentPickingContext?.options,
let promise = self.currentPickingContext?.promise else {
log.error("Picking operation context has been lost.")
return
}
guard let fileSystem = self.appContext?.fileSystem else {
return promise.reject(FileSystemModuleNotFoundException())
}
let mediaHandler = MediaHandler(fileSystem: fileSystem,
options: options)
// Clean up the currently stored picking context
self.currentPickingContext = nil
Task {
do {
let assets = try await mediaHandler.handleMultipleMedia(selection)
promise.resolve(ImagePickerResponse(assets: assets, canceled: false))
} catch {
promise.reject(error)
}
}
}
func didPickMedia(mediaInfo: MediaInfo) {
guard let options = self.currentPickingContext?.options,
let promise = self.currentPickingContext?.promise else {
log.error("Picking operation context has been lost.")
return
}
guard let fileSystem = self.appContext?.fileSystem else {
return promise.reject(FileSystemModuleNotFoundException())
}
// Clean up the currently stored picking context
self.currentPickingContext = nil
let mediaHandler = MediaHandler(fileSystem: fileSystem,
options: options)
Task {
do {
let asset = try await mediaHandler.handleMedia(mediaInfo)
promise.resolve(ImagePickerResponse(assets: [asset], canceled: false))
} catch {
promise.reject(error)
}
}
}
}
@@ -0,0 +1,256 @@
// Copyright 2022-present 650 Industries. All rights reserved.
import ExpoModulesCore
import MobileCoreServices
import PhotosUI
internal let MAXIMUM_QUALITY = 1.0
internal let UNLIMITED_SELECTION = 0
internal let SINGLE_SELECTION = 1
internal struct ImagePickerOptions: Record {
@Field
var allowsEditing: Bool = false
@Field
var aspect: [Double]
@Field
var quality: Double = 1.0
@Field
var mediaTypes: [MediaType] = [.images]
@Field
var exif: Bool
@Field
var base64: Bool = false
@Field
var videoExportPreset: VideoExportPreset = .passthrough
@Field
var videoQuality: VideoQuality = .typeHigh
@Field
var videoMaxDuration: Double = 0
@Field
var presentationStyle: PresentationStyle = .automatic
@Field
var preferredAssetRepresentationMode: PreferredAssetRepresentationMode = .current
@Field
var cameraType: CameraType = .back
@Field
var allowsMultipleSelection: Bool = false
@Field
var selectionLimit: Int = UNLIMITED_SELECTION
@Field
var orderedSelection: Bool = false
func toMediaTypesArray() -> [String] {
var mediaTypesArray = mediaTypes.map { mediaType in
mediaType.toUTTypeString()
}
// For legacy picker selecting only livePhotos is not allowed
if mediaTypes.contains(.livePhotos) && !mediaTypes.contains(.images) {
mediaTypesArray.append(UTType.image.identifier)
}
if mediaTypesArray.isEmpty {
return [UTType.image.identifier]
}
return mediaTypesArray
}
func toPickerFilter() -> PHPickerFilter {
let allowedArray = mediaTypes.map { mediaType in
mediaType.toPickerFilter()
}
if allowedArray.isEmpty {
return .images
}
return .any(of: allowedArray)
}
func requiresMicrophonePermission() -> Bool {
return mediaTypes.contains { mediaType in
mediaType.requiresMicrophonePermission()
}
}
}
internal enum PresentationStyle: String, Enumerable {
case fullScreen
case pageSheet
case formSheet
case currentContext
case overFullScreen
case overCurrentContext
case popover
case none
case automatic
func toPresentationStyle() -> UIModalPresentationStyle {
switch self {
case .fullScreen:
return .fullScreen
case .pageSheet:
return .pageSheet
case .formSheet:
return .formSheet
case .currentContext:
return .currentContext
case .overFullScreen:
return .overFullScreen
case .overCurrentContext:
return .overCurrentContext
case .popover:
return .popover
case .none:
return .none
case .automatic:
if #available(iOS 13.0, *) {
return .automatic
}
// default prior iOS 13
return .fullScreen
}
}
}
internal enum PreferredAssetRepresentationMode: String, Enumerable {
case automatic
case compatible
case current
func toAssetRepresentationMode() -> PHPickerConfiguration.AssetRepresentationMode {
switch self {
case .automatic:
return .automatic
case .compatible:
return .compatible
case .current:
return .current
}
}
}
internal enum VideoQuality: Int, Enumerable {
case typeHigh = 0
case typeMedium = 1
case typeLow = 2
case type640x480 = 3
case typeIFrame1280x720 = 4
case typeIFrame960x540 = 5
func toQualityType() -> UIImagePickerController.QualityType {
switch self {
case .typeHigh:
return .typeHigh
case .typeMedium:
return .typeMedium
case .typeLow:
return .typeLow
case .type640x480:
return .type640x480
case .typeIFrame1280x720:
return .typeIFrame1280x720
case .typeIFrame960x540:
return .typeIFrame960x540
}
}
}
internal enum MediaType: String, Enumerable {
case videos
case images
case livePhotos
func toUTTypeString() -> String {
switch self {
case .images:
return UTType.image.identifier
case .videos:
return UTType.movie.identifier
case .livePhotos:
return UTType.livePhoto.identifier
}
}
func requiresMicrophonePermission() -> Bool {
switch self {
case .images:
return false
case .videos:
return true
case .livePhotos:
return false
}
}
func toPickerFilter() -> PHPickerFilter {
switch self {
case .images:
return .images
case .videos:
return .videos
case .livePhotos:
return .livePhotos
}
}
}
internal enum VideoExportPreset: Int, Enumerable {
case passthrough = 0
case lowQuality = 1
case mediumQuality = 2
case highestQuality = 3
case h264_640x480 = 4
case h264_960x540 = 5
case h264_1280x720 = 6
case h264_1920x1080 = 7
case h264_3840x2160 = 8
case hevc_1920x1080 = 9
case hevc_3840_2160 = 10
func toAVAssetExportPreset() -> String {
switch self {
case .passthrough:
return AVAssetExportPresetPassthrough
case .lowQuality:
return AVAssetExportPresetLowQuality
case .mediumQuality:
return AVAssetExportPresetMediumQuality
case .highestQuality:
return AVAssetExportPresetHighestQuality
case .h264_640x480:
return AVAssetExportPreset640x480
case .h264_960x540:
return AVAssetExportPreset960x540
case .h264_1280x720:
return AVAssetExportPreset1280x720
case .h264_1920x1080:
return AVAssetExportPreset1920x1080
case .h264_3840x2160:
return AVAssetExportPreset3840x2160
case .hevc_1920x1080:
return AVAssetExportPresetHEVC1920x1080
case .hevc_3840_2160:
return AVAssetExportPresetHEVC3840x2160
}
}
}
internal enum CameraType: String, Enumerable {
case back
case front
}
@@ -0,0 +1,123 @@
// Copyright 2022-present 650 Industries. All rights reserved.
import Photos
import ExpoModulesCore
public class CameraPermissionRequester: NSObject, EXPermissionsRequester {
static public func permissionType() -> String {
return "camera"
}
public func requestPermissions(resolver resolve: @escaping EXPromiseResolveBlock, rejecter reject: EXPromiseRejectBlock) {
AVCaptureDevice.requestAccess(for: AVMediaType.video) { [weak self] _ in
resolve(self?.getPermissions())
}
}
public func getPermissions() -> [AnyHashable: Any] {
var systemStatus: AVAuthorizationStatus
var status: EXPermissionStatus
let cameraUsageDescription = Bundle.main.object(forInfoDictionaryKey: "NSCameraUsageDescription")
if cameraUsageDescription == nil {
EXFatal(EXErrorWithMessage("""
This app is missing 'NSCameraUsageDescription', video services will fail. \
Ensure this key exists in the app's Info.plist
"""))
systemStatus = AVAuthorizationStatus.denied
} else {
systemStatus = AVCaptureDevice.authorizationStatus(for: AVMediaType.video)
}
switch systemStatus {
case .authorized:
status = EXPermissionStatusGranted
case .restricted,
.denied:
status = EXPermissionStatusDenied
case .notDetermined:
fallthrough
@unknown default:
status = EXPermissionStatusUndetermined
}
return [
"status": status.rawValue
]
}
}
public class MediaLibraryPermissionRequester: DefaultMediaLibraryPermissionRequester,
EXPermissionsRequester {
public static func permissionType() -> String {
return "mediaLibrary"
}
}
public class MediaLibraryWriteOnlyPermissionRequester: DefaultMediaLibraryPermissionRequester,
EXPermissionsRequester {
public static func permissionType() -> String {
return "mediaLibraryWriteOnly"
}
override internal func accessLevel() -> PHAccessLevel {
return PHAccessLevel.addOnly
}
}
// MARK: - Permission requesters shared implementation extracted to an extension (mixin pattern)
/**
* Dummy class just to prevent extending NSObject publicly/globally.
*/
public class DefaultMediaLibraryPermissionRequester: NSObject {}
/**
* This extension is adding default implmentation for EXPermissionsRequester that can be shared by many classe.
* In Swift language you cannot override static methods in subclasses, so you cannot subclass any already implemented
* PermissionRequester as instances of this class are registered by the unique name coming from `static func permissionType()`.
* To prevent repeating the similar code for every MediaLibrary PermissionRequester (the only differences so far are
* aforementioned permissionType and accessLevel, while the latter can be easily overritten) I've extracted the code
* to this extension. I'm using as a mixin that implements major part of EXPermissionsRequester protocol.
*/
extension DefaultMediaLibraryPermissionRequester {
@objc
public func requestPermissions(resolver resolve: @escaping EXPromiseResolveBlock, rejecter reject: EXPromiseRejectBlock) {
PHPhotoLibrary.requestAuthorization(for: self.accessLevel()) { [weak self] (_: PHAuthorizationStatus) in
resolve(self?.getPermissions())
}
}
@objc
public func getPermissions() -> [AnyHashable: Any] {
let authorizationStatus = PHPhotoLibrary.authorizationStatus(for: self.accessLevel())
var status: EXPermissionStatus
var scope: String
switch authorizationStatus {
case .authorized:
status = EXPermissionStatusGranted
scope = "all"
case .limited:
status = EXPermissionStatusGranted
scope = "limited"
case .denied, .restricted:
status = EXPermissionStatusDenied
scope = "none"
case .notDetermined:
fallthrough
@unknown default:
status = EXPermissionStatusUndetermined
scope = "none"
}
return [
"status": status.rawValue,
"accessPrivileges": scope
]
}
@objc
internal func accessLevel() -> PHAccessLevel {
return PHAccessLevel.readWrite
}
}
@@ -0,0 +1,43 @@
// Copyright 2022-present 650 Industries. All rights reserved.
// swiftlint:disable redundant_optional_initialization
// Unfortunately, property wrappers must be initialized in those records, otherwise the memberwise initializer
// would require `Field<FieldType?>` as an argument instead of `FieldType?`.
// TODO: (@tsapeta) Figure out if we can fix that
import ExpoModulesCore
internal typealias ImagePickerResult = Result<ImagePickerResponse, Exception>
internal typealias SelectedMediaResult = Result<AssetInfo, Exception>
/**
Convenience alias, a dictionary representing EXIF data
*/
internal typealias ExifInfo = [String: Any]
/**
Represents a picker response.
*/
internal struct ImagePickerResponse: Record {
@Field var assets: [AssetInfo]? = nil
@Field var canceled: Bool = true
}
/**
Represents a single asset (image, live photo or video).
*/
internal struct AssetInfo: Record {
@Field var assetId: String? = nil
@Field var type: String = "image"
@Field var uri: String = ""
@Field var width: Double = 0
@Field var height: Double = 0
@Field var fileName: String? = nil
@Field var fileSize: Int? = nil
@Field var mimeType: String? = nil
@Field var base64: String? = nil
@Field var exif: ExifInfo? = nil
@Field var duration: Double? = nil
@Field var pairedVideoAsset: AssetInfo? = nil
}
@@ -0,0 +1,16 @@
// Copyright 2024-present 650 Industries. All rights reserved.
/**
Asynchronously maps the given sequence (sequentially).
*/
func asyncMap<ItemsType: Sequence, ResultType>(
_ items: ItemsType,
_ transform: (ItemsType.Element) async throws -> ResultType
) async rethrows -> [ResultType] {
var values = [ResultType]()
for item in items {
try await values.append(transform(item))
}
return values
}
+392
View File
@@ -0,0 +1,392 @@
// Copyright 2024-present 650 Industries. All rights reserved.
import CoreGraphics
import ExpoModulesCore
import ImageIO
import Photos
import UniformTypeIdentifiers
extension UTType {
static var avif: UTType {
UTType(importedAs: "public.avif")
}
}
internal struct ImageUtils {
static func readImageFrom(mediaInfo: MediaInfo, shouldReadCroppedImage: Bool) -> UIImage? {
// ---------------------------------------------------------------------------
// 1. Fast-path (allowsEditing == true)
// ---------------------------------------------------------------------------
// When the user confirms a crop in the system UI, UIKit puts the final bitmap
// with the crop *and* the correct visual orientation already baked in
// under `UIImagePickerController.InfoKey.editedImage`.
// Re-using that image means we don't have to:
// translate `cropRect` into the sensor's coordinate space, nor
// worry about EXIF orientation flags that differ across formats.
// This single line therefore handles the vast majority of cases safely.
if shouldReadCroppedImage, let systemCropped = mediaInfo[.editedImage] as? UIImage {
return systemCropped.fixOrientation()
}
// ---------------------------------------------------------------------------
// 2. Manual path (no .editedImage available)
// ---------------------------------------------------------------------------
// Some edge-cases (older iOS versions, multi-selection via PHPicker, or
// editing disabled) don't supply `.editedImage`. In those cases we:
// a) pull `.originalImage`,
// b) apply `cropRect` *before* touching orientation, and
// c) call `fixOrientation()` once at the end to normalize the bitmap.
guard let originalImage = mediaInfo[.originalImage] as? UIImage else {
return nil
}
if shouldReadCroppedImage,
let cropRect = mediaInfo[.cropRect] as? CGRect,
let cropped = ImageUtils.crop(image: originalImage, to: cropRect) {
// Crop first (rect is defined in the original pixel space), then rotate.
return cropped.fixOrientation()
}
// No editing only remove any EXIF orientation so JavaScript consumers see
// an upright image.
return originalImage.fixOrientation()
}
static func crop(image: UIImage, to: CGRect) -> UIImage? {
guard let cgImage = image.cgImage?.cropping(to: to) else {
return nil
}
return UIImage(cgImage: cgImage, scale: image.scale, orientation: image.imageOrientation)
}
static func readDataAndFileExtension(
image: UIImage,
mediaInfo: MediaInfo,
options: ImagePickerOptions
) throws -> (imageData: Data?, fileExtension: String) {
// nil when an image is picked from camera
let referenceUrl = mediaInfo[.referenceURL] as? URL
switch referenceUrl?.absoluteString {
case .some(let s) where s.contains("ext=PNG"):
let data = image.pngData()
return (data, ".png")
case .some(let s) where s.contains("ext=WEBP"):
if options.allowsEditing {
// switch to png if editing
let data = image.pngData()
return (data, ".png")
}
return (nil, ".webp")
case .some(let s) where s.contains("ext=BMP"):
if options.allowsEditing {
// switch to png if editing
let data = image.pngData()
return (data, ".png")
}
return (nil, ".bmp")
case .some(let s) where s.contains("ext=GIF"):
var rawData: Data?
if let imgUrl = mediaInfo[.imageURL] as? URL {
rawData = try? Data(contentsOf: imgUrl)
}
let inputData = rawData ?? image.jpegData(compressionQuality: options.quality)
let metadata = mediaInfo[.mediaMetadata] as? [String: Any]
let cropRect = options.allowsEditing ? mediaInfo[.cropRect] as? CGRect : nil
let gifData = try processGifData(
inputData: inputData,
compressionQuality: options.quality,
initialMetadata: metadata,
cropRect: cropRect
)
return (gifData, ".gif")
default:
let data = image.jpegData(compressionQuality: options.quality)
return (data, ".jpg")
}
}
static func readDataAndFileExtension(
image: UIImage,
rawData: Data,
itemProvider: NSItemProvider,
options: ImagePickerOptions
) throws -> (imageData: Data?, fileExtension: String) {
let preferredFormat = itemProvider.registeredTypeIdentifiers.first
switch preferredFormat {
case UTType.bmp.identifier:
if options.allowsEditing {
// switch to png if editing
let data = image.pngData()
return (data, ".png")
}
return (rawData, ".bmp")
case UTType.png.identifier:
let data = image.pngData()
return (data, ".png")
case UTType.webP.identifier:
if options.allowsEditing {
// switch to png if editing
let data = image.pngData()
return (data, ".png")
}
return (rawData, ".webp")
case UTType.gif.identifier:
let gifData = try processGifData(
inputData: rawData,
compressionQuality: options.quality,
initialMetadata: nil
)
return (gifData, ".gif")
case UTType.heic.identifier:
return (rawData, ".heic")
case UTType.tiff.identifier:
return (rawData, ".tiff")
case UTType.avif.identifier:
return (rawData, ".avif")
default:
if options.quality >= 1.0 {
return (rawData, ".jpg")
}
let data = image.jpegData(compressionQuality: options.quality)
return (data, ".jpg")
}
}
static func write(imageData: Data?, to: URL) throws {
do {
try imageData?.write(to: to, options: [.atomic])
} catch {
throw FailedToWriteImageException()
.causedBy(error)
}
}
/**
@returns `true` upon copying success and `false` otherwise
*/
static func tryCopyingOriginalImageFrom(mediaInfo: MediaInfo, to: URL) -> Bool {
guard let from = mediaInfo[.imageURL] as? URL else {
return false
}
do {
try FileManager.default.copyItem(atPath: from.path, toPath: to.path)
return true
} catch {
return false
}
}
/**
Reads base64 representation of the image data. If the data is `nil` fallbacks to reading the data from the url.
*/
static func readBase64From(imageData: Data?, orImageFileUrl url: URL, tryReadingFile: Bool) throws
-> String? {
if tryReadingFile {
do {
let data = try Data(contentsOf: url)
return data.base64EncodedString()
} catch {
throw FailedToReadImageDataException()
.causedBy(error)
}
}
guard let data = imageData else {
throw FailedToReadImageDataForBase64Exception()
}
return data.base64EncodedString()
}
static func readExifFrom(mediaInfo: MediaInfo) async -> ExifInfo? {
let metadata = mediaInfo[.mediaMetadata] as? [String: Any]
if let metadata {
return ImageUtils.readExifFrom(imageMetadata: metadata)
}
guard let imageUrl = mediaInfo[.referenceURL] as? URL else {
log.error("Could not fetch metadata for image")
return nil
}
let assets = PHAsset.fetchAssets(withALAssetURLs: [imageUrl], options: nil)
guard let asset = assets.firstObject else {
log.error("Could not fetch metadata for image '\(imageUrl.absoluteString)'.")
return nil
}
let options = PHContentEditingInputRequestOptions()
options.isNetworkAccessAllowed = true
return await withCheckedContinuation { continuation in
asset.requestContentEditingInput(with: options) { input, _ in
guard let imageUrl = input?.fullSizeImageURL,
let properties = CIImage(contentsOf: imageUrl)?.properties
else {
log.error("Could not fetch metadata for '\(imageUrl.absoluteString)'.")
return continuation.resume(returning: nil)
}
let exif = ImageUtils.readExifFrom(imageMetadata: properties)
return continuation.resume(returning: exif)
}
}
}
static func optionallyReadExifFrom(
mediaInfo: MediaInfo,
shouldReadExif: Bool,
completion: @escaping (_ result: ExifInfo?) -> Void
) {
if !shouldReadExif {
return completion(nil)
}
let metadata = mediaInfo[.mediaMetadata] as? [String: Any]
if let metadata {
let exif = ImageUtils.readExifFrom(imageMetadata: metadata)
return completion(exif)
}
guard let imageUrl = mediaInfo[.referenceURL] as? URL else {
log.error("Could not fetch metadata for image")
return completion(nil)
}
let assets = PHAsset.fetchAssets(withALAssetURLs: [imageUrl], options: nil)
guard let asset = assets.firstObject else {
log.error("Could not fetch metadata for image '\(imageUrl.absoluteString)'.")
return completion(nil)
}
let options = PHContentEditingInputRequestOptions()
options.isNetworkAccessAllowed = true
asset.requestContentEditingInput(with: options) { input, _ in
guard let imageUrl = input?.fullSizeImageURL,
let properties = CIImage(contentsOf: imageUrl)?.properties
else {
log.error("Could not fetch metadata for '\(imageUrl.absoluteString)'.")
return completion(nil)
}
let exif = ImageUtils.readExifFrom(imageMetadata: properties)
return completion(exif)
}
}
static func readExifFrom(data: Data) -> ExifInfo? {
if let cgImageSource = CGImageSourceCreateWithData(data as CFData, nil) {
if let properties = CGImageSourceCopyPropertiesAtIndex(cgImageSource, 0, nil)
as? [String: Any] {
return ImageUtils.readExifFrom(imageMetadata: properties)
}
}
return nil
}
static func readExifFrom(imageMetadata: [String: Any]) -> ExifInfo {
var exif: ExifInfo = imageMetadata[kCGImagePropertyExifDictionary as String] as? ExifInfo ?? [:]
// Copy ["{GPS}"]["<tag>"] to ["GPS<tag>"]
if let gps = imageMetadata[kCGImagePropertyGPSDictionary as String] as? [String: Any] {
gps.forEach { key, value in
exif["GPS\(key)"] = value
}
}
if let tiff = imageMetadata[kCGImagePropertyTIFFDictionary as String] as? [String: Any] {
// Inject tiff data (make, model, resolution...)
exif.merge(tiff) { current, _ in current }
}
return exif
}
static func processGifData(
inputData: Data?,
compressionQuality: Double?,
initialMetadata: [String: Any]?,
cropRect: CGRect? = nil
) throws -> Data? {
let quality = compressionQuality ?? MAXIMUM_QUALITY
// for uncropped, maximum quality image we can just pass through the raw data
if cropRect == nil && quality >= MAXIMUM_QUALITY {
return inputData
}
guard let sourceData = inputData,
let imageSource = CGImageSourceCreateWithData(sourceData as CFData, nil)
else {
throw FailedToReadImageException()
}
let gifProperties = CGImageSourceCopyProperties(imageSource, nil) as? [String: Any]
let frameCount = CGImageSourceGetCount(imageSource)
let destinationData = NSMutableData()
guard let imageDestination = CGImageDestinationCreateWithData(destinationData, UTType.gif.identifier as CFString, frameCount, nil) else {
throw FailedToCreateGifException()
}
let gifMetadata = initialMetadata ?? gifProperties
CGImageDestinationSetProperties(imageDestination, gifMetadata as CFDictionary?)
for frameIndex in 0..<frameCount {
guard var cgImage = CGImageSourceCreateImageAtIndex(imageSource, frameIndex, nil) else {
throw FailedToCreateGifException()
}
var frameProperties =
CGImageSourceCopyPropertiesAtIndex(imageSource, frameIndex, nil) as? [String: Any] ?? [:]
if let cropRect {
cgImage = cgImage.cropping(to: cropRect) ?? cgImage
}
frameProperties[kCGImageDestinationLossyCompressionQuality as String] = quality
CGImageDestinationAddImage(imageDestination, cgImage, frameProperties as CFDictionary)
}
if !CGImageDestinationFinalize(imageDestination) {
throw FailedToExportGifException()
}
return destinationData as Data
}
/*
* Extracts the visual dimensions from an image file, accounting for EXIF orientation.
* This ensures portrait images return portrait dimensions (height > width).
* @param url The file URL of the image to analyze
* @return A CGSize containing the visual width and height, or nil if the dimensions cannot be determined
*/
static func readVisualSizeFrom(url: URL) -> CGSize? {
// Try to fetch the dimensions from the image metadata as this is the fastest way
guard let imageSource = CGImageSourceCreateWithURL(url as CFURL, nil),
let properties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as? [CFString: Any],
let width = properties[kCGImagePropertyPixelWidth] as? CGFloat,
let height = properties[kCGImagePropertyPixelHeight] as? CGFloat else {
// Fallback: minimally decode the image using UIImage when metadata is not available
if let img = UIImage(contentsOfFile: url.path) {
return CGSize(width: img.size.width, height: img.size.height)
}
return nil
}
// Check EXIF orientation to determine if dimensions should be swapped
let orientation = properties[kCGImagePropertyOrientation] as? Int ?? 1
// Orientations 5,6,7,8 (left/right rotated) need dimension swapping
if orientation >= 5 && orientation <= 8 {
return CGSize(width: height, height: width) // Swap for portrait
}
// Keep as is for landscape
return CGSize(width: width, height: height)
}
}
+543
View File
@@ -0,0 +1,543 @@
// Copyright 2022-present 650 Industries. All rights reserved.
import ExpoModulesCore
import MobileCoreServices
import Photos
import PhotosUI
import UniformTypeIdentifiers
internal struct MediaHandler {
internal weak var fileSystem: EXFileSystemInterface?
internal let options: ImagePickerOptions
internal func handleMedia(_ mediaInfo: MediaInfo) async throws -> AssetInfo {
let mediaType: String? = mediaInfo[UIImagePickerController.InfoKey.mediaType] as? String
switch mediaType {
case UTType.image.identifier:
return try await handleImage(mediaInfo: mediaInfo)
case UTType.movie.identifier:
return try await handleVideo(mediaInfo: mediaInfo)
default:
throw InvalidMediaTypeException(mediaType)
}
}
internal func handleMultipleMedia(_ selection: [PHPickerResult]) async throws -> [AssetInfo] {
// TODO: (@hirbod): Use withThrowingTaskGroup instead of asyncMap once iOS 15 support is dropped
return try await asyncMap(selection) { selectedItem in
let itemProvider = selectedItem.itemProvider
if itemProvider.canLoadObject(ofClass: PHLivePhoto.self) && options.mediaTypes.contains(.livePhotos) {
return try await handleLivePhoto(from: selectedItem)
}
if itemProvider.hasItemConformingToTypeIdentifier(UTType.image.identifier) {
return try await handleImage(from: selectedItem)
}
if itemProvider.hasItemConformingToTypeIdentifier(UTType.movie.identifier) {
return try await handleVideo(from: selectedItem)
}
// Fallback to capability-based detection when the provider reports no identifiers (iOS bug?).
// This can happen when files have been synced via AirDrop or iTunes and the file extension is not recognized.
if itemProvider.canLoadObject(ofClass: UIImage.self) {
return try await handleImage(from: selectedItem)
}
// Default fallback assume video when image cannot be loaded.
return try await handleVideo(from: selectedItem)
}
}
// MARK: - Image
private func handleImage(mediaInfo: MediaInfo) async throws -> AssetInfo {
do {
guard
let image = ImageUtils.readImageFrom(
mediaInfo: mediaInfo, shouldReadCroppedImage: options.allowsEditing)
else {
throw FailedToReadImageException()
}
let (imageData, fileExtension) = try ImageUtils.readDataAndFileExtension(
image: image,
mediaInfo: mediaInfo,
options: options
)
let targetUrl = try generateUrl(withFileExtension: fileExtension)
let mimeType = getMimeType(from: targetUrl.pathExtension)
// no modification requested
let imageModified = options.allowsEditing || options.quality < 1
let fileWasCopied =
!imageModified
&& ImageUtils.tryCopyingOriginalImageFrom(mediaInfo: mediaInfo, to: targetUrl)
if !fileWasCopied {
try ImageUtils.write(imageData: imageData, to: targetUrl)
}
// as calling this already requires media library permission, we can access it here
// if user gave limited permissions, in the worst case this will be null
let asset = mediaInfo[.phAsset] as? PHAsset
var fileName = asset?.value(forKey: "filename") as? String
// Extension will change to png when editing BMP files, reflect that change in fileName
if let unwrappedName = fileName {
fileName = replaceFileExtension(
fileName: unwrappedName, targetExtension: fileExtension.lowercased())
}
let fileSize = getFileSize(from: targetUrl)
let base64 =
options.base64
? try ImageUtils.readBase64From(
imageData: imageData, orImageFileUrl: targetUrl, tryReadingFile: fileWasCopied) : nil
let exif = options.exif ? await ImageUtils.readExifFrom(mediaInfo: mediaInfo) : nil
let size = CGSize(width: image.size.width, height: image.size.height)
return AssetInfo(
assetId: asset?.localIdentifier,
uri: targetUrl.absoluteString,
width: Double(size.width),
height: Double(size.height),
fileName: fileName,
fileSize: fileSize,
mimeType: mimeType,
base64: base64,
exif: exif
)
} catch let exception as Exception {
throw exception
} catch {
throw UnexpectedException(error)
}
}
private func handleImage(from selectedImage: PHPickerResult) async throws -> AssetInfo {
let itemProvider = selectedImage.itemProvider
// Fast-path: copy original file when no processing is required and current representation is requested.
let fastPath =
!options.allowsEditing && options.quality >= 1
&& options.preferredAssetRepresentationMode == .current
if fastPath {
// Attempt to obtain original file URL
if let targetUrl = try? await withCheckedThrowingContinuation({ (continuation: CheckedContinuation<URL, Error>)
in itemProvider.loadFileRepresentation(forTypeIdentifier: UTType.image.identifier) { url, error in
guard let srcUrl = url else {
return continuation.resume(throwing: error ?? FailedToReadImageException())
}
do {
let destUrl = try generateUrl(withFileExtension: "." + srcUrl.pathExtension)
try FileManager.default.copyItem(at: srcUrl, to: destUrl)
continuation.resume(returning: destUrl)
} catch {
continuation.resume(throwing: error)
}
}
}) {
let cachedUrl = targetUrl
let fileExtension = "." + cachedUrl.pathExtension
let size = ImageUtils.readVisualSizeFrom(url: cachedUrl) ?? .zero
let fileSize = getFileSize(from: cachedUrl)
let mimeType = getMimeType(from: cachedUrl.pathExtension)
let fileName = itemProvider.suggestedName.map { $0 + fileExtension }
// Conditionally read raw data only if needed to avoid unnecessary I/O
var rawData: Data?
if options.base64 || options.exif {
rawData = try? Data(contentsOf: cachedUrl)
}
let base64 = options.base64 ? rawData?.base64EncodedString() : nil
let exif = options.exif ? (rawData.flatMap { ImageUtils.readExifFrom(data: $0) }) : nil
return AssetInfo(
assetId: selectedImage.assetIdentifier,
uri: cachedUrl.absoluteString,
width: Double(size.width),
height: Double(size.height),
fileName: fileName,
fileSize: fileSize,
mimeType: mimeType,
base64: base64,
exif: exif
)
}
}
// If fast copy path failed or was not available because of the props
// use slow path (existing implementation)
let rawData = try await itemProvider.loadImageDataRepresentation()
guard let image = UIImage(data: rawData) else {
throw Exception(name: "FailedCreatingUIImage", description: "")
}
let (imageData, fileExtension) = try ImageUtils.readDataAndFileExtension(
image: image,
rawData: rawData,
itemProvider: itemProvider,
options: options
)
let mimeType = getMimeType(from: String(fileExtension.dropFirst()))
let targetUrl = try generateUrl(withFileExtension: fileExtension)
try ImageUtils.write(imageData: imageData, to: targetUrl)
let fileSize = getFileSize(from: targetUrl)
let fileName = itemProvider.suggestedName.map { $0 + fileExtension }
// We need to get EXIF from original image data, as it is being lost in UIImage
let exif = options.exif ? ImageUtils.readExifFrom(data: rawData) : nil
let base64 = options.base64 ? imageData?.base64EncodedString() : nil
let size = CGSize(width: image.size.width, height: image.size.height)
return AssetInfo(
assetId: selectedImage.assetIdentifier,
uri: targetUrl.absoluteString,
width: Double(size.width),
height: Double(size.height),
fileName: fileName,
fileSize: fileSize,
mimeType: mimeType,
base64: base64,
exif: exif
)
}
// Unlike the case of regular images, we have to operate on original data of the image in order to preserve the exif data,
// otherwise it won't be possible to connect the image and video into a `PHLivePhoto` after reading it from the cache directory later.
// As a result a live photo photo cannot be compressed or edited.
private func handleLivePhoto(from selectedImage: PHPickerResult) async throws -> AssetInfo {
let itemProvider = selectedImage.itemProvider
let livePhotoObject = try await itemProvider.loadObject(ofClass: PHLivePhoto.self)
guard let livePhoto = livePhotoObject as? PHLivePhoto else {
throw FailedToPickLivePhotoException()
}
let assetResources = PHAssetResource.assetResources(for: livePhoto)
guard
let photoResource = assetResources.first(where: { $0.type == .photo }),
let videoResource = assetResources.first(where: { $0.type == .pairedVideo })
else {
throw FailedToPickLivePhotoException()
}
let fileName = photoResource.originalFilename
let pairedVideoFileName = videoResource.originalFilename
let photoFileExtension = getFileExtension(from: fileName)
let pairedVideoFileExtension = getFileExtension(from: pairedVideoFileName)
let (photoUrl, pairedVideoUrl) = try generatePairedUrls(
photoFileExtension: photoFileExtension, videoFileExtension: pairedVideoFileExtension)
let imageData = try await PHAssetResourceManager.default().requestData(
for: photoResource, options: nil)
try await PHAssetResourceManager.default().writeData(
for: photoResource, toFile: photoUrl, options: nil)
try await PHAssetResourceManager.default().writeData(
for: videoResource, toFile: pairedVideoUrl, options: nil)
let fileSize = getFileSize(from: photoUrl)
let mimeType = getMimeType(from: photoUrl.pathExtension)
let base64 = options.base64 ? imageData.base64EncodedString() : nil
let exif = options.exif ? ImageUtils.readExifFrom(data: imageData) : nil
let pairedVideoAssetInfo = try getPairedAssetInfo(
from: videoResource, fileUrl: pairedVideoUrl, assetId: selectedImage.assetIdentifier)
return AssetInfo(
assetId: selectedImage.assetIdentifier,
type: "livePhoto",
uri: photoUrl.absoluteString,
width: livePhoto.size.width,
height: livePhoto.size.height,
fileName: fileName,
fileSize: fileSize,
mimeType: mimeType,
base64: base64,
exif: exif,
pairedVideoAsset: pairedVideoAssetInfo
)
}
private func getPairedAssetInfo(
from videoResource: PHAssetResource, fileUrl: URL, assetId: String?
) throws -> AssetInfo {
let fileName = videoResource.originalFilename
guard let dimensions = VideoUtils.readSizeFrom(url: fileUrl) else {
throw FailedToReadVideoSizeException()
}
let duration = VideoUtils.readDurationFrom(url: fileUrl)
let mimeType = getMimeType(from: fileUrl.pathExtension)
let fileSize = getFileSize(from: fileUrl)
return AssetInfo(
assetId: assetId,
type: "pairedVideo",
uri: fileUrl.absoluteString,
width: dimensions.width,
height: dimensions.height,
fileName: fileName,
fileSize: fileSize,
mimeType: mimeType,
duration: duration
)
}
private func getMimeType(from pathExtension: String) -> String? {
return UTType(filenameExtension: pathExtension)?.preferredMIMEType
}
// MARK: - Video
func handleVideo(mediaInfo: MediaInfo) async throws -> AssetInfo {
// Attempt to obtain a usable URL first.
let pickedVideoUrl = VideoUtils.readVideoUrlFrom(mediaInfo: mediaInfo)
// Fast-path: if we have a PHAsset and passthrough preset, stream the full-size resource to avoid
// assets-library URLs that FileManager cannot copy.
if options.videoExportPreset == .passthrough, let asset = mediaInfo[.phAsset] as? PHAsset {
let resources = PHAssetResource.assetResources(for: asset)
if let resource = resources.first(where: { $0.type == .fullSizeVideo }) ?? resources.first(where: { $0.type == .video }) {
let originalFilename = resource.originalFilename
let fileExtension = getFileExtension(from: originalFilename)
let destinationUrl = try generateUrl(withFileExtension: fileExtension)
try await PHAssetResourceManager.default().writeData(
for: resource,
toFile: destinationUrl,
options: nil
)
let mimeType = getMimeType(from: destinationUrl.pathExtension)
return try buildVideoResult(
for: destinationUrl,
withName: originalFilename,
mimeType: mimeType,
assetId: asset.localIdentifier
)
}
}
// Legacy/regular path: copy the temporary file when we have a real URL.
guard let pickedUrl = pickedVideoUrl else {
throw FailedToReadVideoException()
}
// If the URL uses the deprecated assets-library scheme, fall back to exporting via PHAsset above.
// This can happen when files have been synced via AirDrop or iTunes.
if pickedUrl.scheme == "assets-library" {
throw FailedToReadVideoException()
}
let targetUrl = try generateUrl(withFileExtension: ".mov")
try VideoUtils.tryCopyingVideo(at: pickedUrl, to: targetUrl)
guard let dimensions = VideoUtils.readSizeFrom(url: targetUrl) else {
throw FailedToReadVideoSizeException()
}
// If video was edited (the duration is affected) then read the duration from the original edited video.
// Otherwise read the duration from the target video file.
// TODO: (@bbarthec): inspect whether it makes sense to read duration from two different assets
let videoUrlToReadDurationFrom = self.options.allowsEditing ? pickedUrl : targetUrl
let asset = mediaInfo[.phAsset] as? PHAsset
let mimeType = getMimeType(from: targetUrl.pathExtension)
let fileName = asset?.value(forKey: "filename") as? String
let fileSize = getFileSize(from: targetUrl)
return AssetInfo(
assetId: asset?.localIdentifier,
type: "video",
uri: targetUrl.absoluteString,
width: dimensions.width,
height: dimensions.height,
fileName: fileName,
fileSize: fileSize,
mimeType: mimeType,
duration: VideoUtils.readDurationFrom(url: videoUrlToReadDurationFrom)
)
}
private func handleVideo(from selectedVideo: PHPickerResult) async throws -> AssetInfo {
// Fast-path: If transcoding is disabled (passthrough) and we have direct access to the underlying
// `PHAsset`, try to copy the original/full-size video resource via `PHAssetResourceManager`.
// This avoids `loadFileRepresentation`, which can be noticeably slower once a user
// tweaks only the metadata (e.g. adjusts the capture date) because the photo service marks the
// asset as *adjusted* and will re-render a temporary file for us. Copying the resource bytes
// ourselves is dramatically faster because it just streams the already-existing file.
if options.videoExportPreset == .passthrough, let assetId = selectedVideo.assetIdentifier {
let fetchResult = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: nil)
if let asset = fetchResult.firstObject {
// Prefer the full-size resource when available, otherwise fall back to the default `.video`.
let resources = PHAssetResource.assetResources(for: asset)
if let resource = resources.first(where: { $0.type == .fullSizeVideo }) ??
resources.first(where: { $0.type == .video }) {
// Determine the file extension from the original filename so we preserve it (e.g. .MOV / .MP4)
let originalFilename = resource.originalFilename
let fileExtension = getFileExtension(from: originalFilename)
let destinationUrl = try generateUrl(withFileExtension: fileExtension)
// Stream the resource into our cache directory. This API is asynchronous but doesn't require
// a temporary file like `loadFileRepresentation`.
try await PHAssetResourceManager.default().writeData(for: resource, toFile: destinationUrl, options: nil)
// Build and return the result using the helper.
let mimeType = getMimeType(from: destinationUrl.pathExtension)
return try buildVideoResult(
for: destinationUrl,
withName: originalFilename,
mimeType: mimeType,
assetId: assetId
)
}
}
// If anything above fails we'll gracefully fall back to the existing (slower) path below.
}
let videoUrl = try await VideoUtils.loadVideoRepresentation(
provider: selectedVideo.itemProvider
) { tmpUrl in
// We need to copy the result into a place that we control, because the picker
// can remove the original file during conversion.
return try generateUrl(withFileExtension: ".\(tmpUrl.pathExtension)")
}
// Decide whether we need to transcode.
let isPassthrough = options.videoExportPreset == .passthrough
// Keep original extension for passthrough, otherwise use mp4.
let originalFileExtension = ".\(videoUrl.pathExtension)"
let transcodeFileExtension = isPassthrough ? originalFileExtension : ".mp4"
let transcodeFileType: AVFileType = .mp4
let finalUrl: URL
if isPassthrough {
finalUrl = videoUrl
} else {
// Create destination url for transcoded video
let transcodedUrl = try generateUrl(withFileExtension: transcodeFileExtension)
finalUrl = try await VideoUtils.transcodeVideoAsync(
sourceAssetUrl: videoUrl,
destinationUrl: transcodedUrl,
outputFileType: transcodeFileType,
exportPreset: options.videoExportPreset
)
}
let mimeType = getMimeType(from: finalUrl.pathExtension)
let fileName = selectedVideo.itemProvider.suggestedName.map { $0 + transcodeFileExtension }
return try buildVideoResult(
for: finalUrl, withName: fileName, mimeType: mimeType, assetId: selectedVideo.assetIdentifier)
}
// MARK: - utils
private func replaceFileExtension(fileName: String, targetExtension: String) -> String {
if !fileName.lowercased().hasSuffix(targetExtension.lowercased()) {
return deleteFileExtension(fileName: fileName) + targetExtension
}
return fileName
}
private func deleteFileExtension(fileName: String) -> String {
var components = fileName.components(separatedBy: ".")
guard components.count > 1 else {
return fileName
}
components.removeLast()
return components.joined(separator: ".")
}
private func generateUrl(withFileExtension: String) throws -> URL {
guard let fileSystem = self.fileSystem else {
throw FileSystemModuleNotFoundException()
}
let directory = fileSystem.cachesDirectory.appending(
fileSystem.cachesDirectory.hasSuffix("/") ? "" : "/" + "ImagePicker"
)
let path = fileSystem.generatePath(inDirectory: directory, withExtension: withFileExtension)
return URL(fileURLWithPath: path)
}
private func generatePairedUrls(photoFileExtension: String, videoFileExtension: String) throws
-> (URL, URL) {
let parsedVideoFileExtension =
videoFileExtension.starts(with: ".")
? String(videoFileExtension.dropFirst()) : videoFileExtension
let photoUrl = try generateUrl(withFileExtension: photoFileExtension)
let baseUrl = photoUrl.deletingLastPathComponent()
let filename = photoUrl.deletingPathExtension().lastPathComponent
let videoUrl = baseUrl.appendingPathComponent(filename).appendingPathExtension(
parsedVideoFileExtension)
return (photoUrl, videoUrl)
}
private func buildVideoResult(
for videoUrl: URL, withName fileName: String?, mimeType: String?, assetId: String?
) throws -> AssetInfo {
guard let size = VideoUtils.readSizeFrom(url: videoUrl) else {
throw FailedToReadVideoSizeException()
}
let duration = VideoUtils.readDurationFrom(url: videoUrl)
let fileSize = getFileSize(from: videoUrl)
return AssetInfo(
assetId: assetId,
type: "video",
uri: videoUrl.absoluteString,
width: size.width,
height: size.height,
fileName: fileName,
fileSize: fileSize,
mimeType: mimeType,
duration: duration
)
}
private func getFileSize(from fileUrl: URL) -> Int? {
do {
let resources = try fileUrl.resourceValues(forKeys: [.fileSizeKey])
return resources.fileSize
} catch {
log.error("Failed to get file size for \(fileUrl.absoluteString)")
return nil
}
}
private func getFileExtension(from fileName: String) -> String {
return ".\(URL(fileURLWithPath: fileName).pathExtension)"
}
}
extension PHAssetResourceManager {
fileprivate func requestData(
for assetResource: PHAssetResource, options: PHAssetResourceRequestOptions?
) async throws -> Data {
return try await withCheckedThrowingContinuation { continuation in
var data = Data()
let dataHandler = { (dataBatch: Data) in
data.append(dataBatch)
}
let completionHandler = { (error: Error?) in
if let error {
continuation.resume(throwing: error)
return
}
continuation.resume(returning: data)
}
self.requestData(for: assetResource, options: options, dataReceivedHandler: dataHandler, completionHandler: completionHandler)
}
}
}
@@ -0,0 +1,32 @@
import Foundation
import Photos
internal extension NSItemProvider {
func loadObject(ofClass objectClass: any NSItemProviderReading.Type) async throws -> NSItemProviderReading? {
return try await withCheckedThrowingContinuation { continuation in
self.loadObject(ofClass: objectClass) { result, error in
if let error {
continuation.resume(throwing: error)
return
}
continuation.resume(returning: result)
}
}
}
func loadImageDataRepresentation() async throws -> Data {
return try await withCheckedThrowingContinuation { continuation in
let preferredTypeIdentifier = self.registeredTypeIdentifiers()
.compactMap { UTType($0) }
.first { $0.conforms(to: .image) }?
.identifier ?? UTType.image.identifier
loadDataRepresentation(forTypeIdentifier: preferredTypeIdentifier) { data, error in
if let data {
continuation.resume(returning: data)
} else {
continuation.resume(throwing: FailedToReadImageException().causedBy(error))
}
}
}
}
}
@@ -0,0 +1,52 @@
// Copyright 2022-present 650 Industries. All rights reserved.
/**
Since iOS 11, launching ImagePicker with `allowsEditing` option makes cropping rectangle
slightly moved upwards, because of StatusBar visibility.
Hiding StatusBar during picking process solves the displacement issue.
See https://forums.developer.apple.com/thread/98274
*/
internal class StatusBarVisibilityController {
private var shouldRestoreStatusBarVisibility = false
func maybePreserveVisibilityAndHideStatusBar(_ shouldHideStatusBar: Bool) {
guard shouldHideStatusBar && !UIApplication.shared.isStatusBarHidden else {
return
}
shouldRestoreStatusBarVisibility = true
setStatusBarHidden(true)
}
func maybeRestoreStatusBarVisibility() {
guard shouldRestoreStatusBarVisibility else {
return
}
shouldRestoreStatusBarVisibility = false
setStatusBarHidden(false)
}
/**
Calling -[UIApplication setStatusBarHidden:withAnimation:] triggers a warning
that should be suppressable with -Wdeprecated-declarations, but is not.
The warning suggests to use -[UIViewController prefersStatusBarHidden].
Unfortunately until we stop presenting view controllers on detached VCs
the setting doesn't have any effect and we need to set status bar like that.
*/
private func setStatusBarHidden(_ hidden: Bool) {
let selector = NSSelectorFromString("setStatusBarHidden:withAnimation:")
UIApplication.shared.perform(selector, with: hidden, with: false)
// TODO: (@bbarthec) below is possible alternative
// let obj = X()
// let sel = #selector(obj.sayHiTo)
// let meth = class_getInstanceMethod(object_getClass(obj), sel)
// let imp = method_getImplementation(meth)
//
// typealias ClosureType = @convention(c) (AnyObject, Selector, String) -> Void
// let sayHiTo : ClosureType = unsafeBitCast(imp, ClosureType.self)
// sayHiTo(obj, sel, "Fabio")
// prints "Hello Fabio!"
}
}
@@ -0,0 +1,80 @@
// Copyright 2022-present 650 Industries. All rights reserved.
import UIKit
extension UIImage {
func fixOrientation() -> UIImage? {
if self.imageOrientation == UIImage.Orientation.up {
return self
}
var transform = CGAffineTransform.identity
// rotation
switch self.imageOrientation {
case .down,
.downMirrored:
transform = transform
.translatedBy(x: self.size.width, y: self.size.height)
.rotated(by: .pi)
case .left,
.leftMirrored:
transform = transform
.translatedBy(x: self.size.width, y: 0)
.rotated(by: .pi / 2)
case .right,
.rightMirrored:
transform = transform
.translatedBy(x: 0, y: self.size.height)
.rotated(by: -.pi / 2)
default:
break
}
// mirroring
switch self.imageOrientation {
case .upMirrored,
.downMirrored:
transform = transform
.translatedBy(x: self.size.width, y: 0)
.scaledBy(x: -1, y: 1)
case .leftMirrored,
.rightMirrored:
transform = transform
.translatedBy(x: self.size.height, y: 0)
.scaledBy(x: -1, y: 1)
default:
break
}
guard let cgImage = self.cgImage,
let colorSpace = cgImage.colorSpace,
let ctx = CGContext(data: nil,
width: Int(self.size.width),
height: Int(self.size.height),
bitsPerComponent: cgImage.bitsPerComponent,
bytesPerRow: 0,
space: colorSpace,
bitmapInfo: cgImage.bitmapInfo.rawValue)
else {
return nil
}
ctx.concatenate(transform)
switch self.imageOrientation {
case .left,
.leftMirrored,
.right,
.rightMirrored:
ctx.draw(cgImage, in: CGRect(x: 0, y: 0, width: self.size.height, height: self.size.width))
default:
ctx.draw(cgImage, in: CGRect(x: 0, y: 0, width: self.size.width, height: self.size.height))
}
guard let resultCgImage = ctx.makeImage() else {
return nil
}
return UIImage(cgImage: resultCgImage)
}
}
@@ -0,0 +1,57 @@
// Copyright 2016-present 650 Industries. All rights reserved.
extension UIImagePickerController {
func fixCannotMoveEditingBox() {
if let cropView = cropView,
let scrollView = scrollView,
scrollView.contentOffset.y == 0 {
let top = cropView.frame.minY + self.view.safeAreaInsets.top
let bottom = scrollView.frame.height - cropView.frame.height - top
scrollView.contentInset = UIEdgeInsets(top: top, left: 0, bottom: bottom, right: 0)
var offset: CGFloat = 0
if scrollView.contentSize.height > scrollView.contentSize.width {
offset = 0.5 * (scrollView.contentSize.height - scrollView.contentSize.width)
}
scrollView.contentOffset = CGPoint(x: 0, y: -top + offset)
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
self?.fixCannotMoveEditingBox()
}
}
var cropView: UIView? {
return findCropView(from: self.view)
}
var scrollView: UIScrollView? {
return findScrollView(from: self.view)
}
func findCropView(from view: UIView) -> UIView? {
let width = UIScreen.main.bounds.width
let size = view.bounds.size
if width == size.height, width == size.height {
return view
}
for view in view.subviews {
if let cropView = findCropView(from: view) {
return cropView
}
}
return nil
}
func findScrollView(from view: UIView) -> UIScrollView? {
if let scrollView = view as? UIScrollView {
return scrollView
}
for view in view.subviews {
if let scrollView = findScrollView(from: view) {
return scrollView
}
}
return nil
}
}
+99
View File
@@ -0,0 +1,99 @@
// Copyright 2024-present 650 Industries. All rights reserved.
import AVFoundation
import UniformTypeIdentifiers
import Photos
import ExpoModulesCore
internal struct VideoUtils {
static func tryCopyingVideo(at: URL, to: URL) throws {
do {
// we copy the file as `moveItem(at:,to:)` throws an error in iOS 13 due to missing permissions
try FileManager.default.copyItem(at: at, to: to)
} catch {
throw FailedToPickVideoException()
.causedBy(error)
}
}
/**
@returns duration in milliseconds
*/
static func readDurationFrom(url: URL) -> Double {
let asset = AVURLAsset(url: url)
return Double(asset.duration.value) / Double(asset.duration.timescale) * 1000
}
static func readSizeFrom(url: URL) -> CGSize? {
let asset = AVURLAsset(url: url)
guard let assetTrack = asset.tracks(withMediaType: .video).first else {
return nil
}
// The video could be rotated and the resulting transform can result in a negative width/height.
let size = assetTrack.naturalSize.applying(assetTrack.preferredTransform)
return CGSize(width: abs(size.width), height: abs(size.height))
}
static func readVideoUrlFrom(mediaInfo: MediaInfo) -> URL? {
return mediaInfo[.mediaURL] as? URL ?? mediaInfo[.referenceURL] as? URL
}
/**
Asynchronously transcodes asset provided as `sourceAssetUrl` according to `exportPreset`.
Result URL is returned to the `completion` closure.
Transcoded video is saved at `destinationUrl`, unless `exportPreset` is set to `passthrough`.
In this case, `sourceAssetUrl` is returned.
*/
static func transcodeVideoAsync(
sourceAssetUrl: URL,
destinationUrl: URL,
outputFileType: AVFileType,
exportPreset: VideoExportPreset
) async throws -> URL {
if case .passthrough = exportPreset {
return sourceAssetUrl
}
let asset = AVURLAsset(url: sourceAssetUrl)
let preset = exportPreset.toAVAssetExportPreset()
let canBeTranscoded = await AVAssetExportSession.compatibility(ofExportPreset: preset, with: asset, outputFileType: outputFileType)
guard canBeTranscoded else {
throw UnsupportedVideoExportPresetException(preset.description)
}
guard let exportSession = AVAssetExportSession(asset: asset, presetName: preset) else {
throw FailedToTranscodeVideoException()
}
exportSession.outputFileType = outputFileType
exportSession.outputURL = destinationUrl
await exportSession.export()
if case exportSession.status = .failed {
let error = exportSession.error
throw FailedToTranscodeVideoException().causedBy(error)
}
return destinationUrl
}
static func loadVideoRepresentation(provider: NSItemProvider, urlTransformer: @escaping (URL) throws -> URL) async throws -> URL {
return try await withCheckedThrowingContinuation { continuation in
let typeId = UTType.movie.identifier
provider.loadFileRepresentation(forTypeIdentifier: typeId) { url, error in
guard let url else {
return continuation.resume(throwing: FailedToReadVideoException().causedBy(error))
}
do {
// The provided URL is only temporary the system deletes that file when the completion handler returns.
// Since we're using it asynchronously, we need to copy the video to another location.
let newUrl = try urlTransformer(url)
try VideoUtils.tryCopyingVideo(at: url, to: newUrl)
continuation.resume(returning: newUrl)
} catch {
continuation.resume(throwing: error)
}
}
}
}
}
@@ -0,0 +1 @@
49ca9b3dce4cee0b6a3e56d11d88d59a33dd7a1e12f3feb00de7769861e271a9
@@ -0,0 +1 @@
f134926ca57ba812ad740b762e7cfa547edca55f3d7461f772448e46cd6a1def3d65187b30d1a059461982339f92ef6ce54e5cbeb07c9591558e6b7cb538889c
@@ -0,0 +1 @@
a582750c4697e74a76713e381bb579a0ba23ad9b4c92d2591768b51f43e660e1
@@ -0,0 +1 @@
48b86f26f07dd1bfa5bb3c7783e5d7523a631208ce649c383957b254f2951f3f8aea42b04e015b87808e4145df561ceac1fc4a6a05ff77460c5df36de8ff38e6
@@ -0,0 +1,129 @@
{
"formatVersion": "1.1",
"component": {
"group": "host.exp.exponent",
"module": "expo.modules.imagepicker",
"version": "17.0.10",
"attributes": {
"org.gradle.status": "release"
}
},
"createdBy": {
"gradle": {
"version": "8.14.3"
}
},
"variants": [
{
"name": "releaseVariantReleaseApiPublication",
"attributes": {
"org.gradle.category": "library",
"org.gradle.dependency.bundling": "external",
"org.gradle.libraryelements": "aar",
"org.gradle.usage": "java-api"
},
"files": [
{
"name": "expo.modules.imagepicker-17.0.10.aar",
"url": "expo.modules.imagepicker-17.0.10.aar",
"size": 150809,
"sha512": "48b86f26f07dd1bfa5bb3c7783e5d7523a631208ce649c383957b254f2951f3f8aea42b04e015b87808e4145df561ceac1fc4a6a05ff77460c5df36de8ff38e6",
"sha256": "a582750c4697e74a76713e381bb579a0ba23ad9b4c92d2591768b51f43e660e1",
"sha1": "6aef5999954b547fd3786ceb3912edd5d006b7e1",
"md5": "a0189d8d48c04a75df3d8c22b9d19194"
}
]
},
{
"name": "releaseVariantReleaseRuntimePublication",
"attributes": {
"org.gradle.category": "library",
"org.gradle.dependency.bundling": "external",
"org.gradle.libraryelements": "aar",
"org.gradle.usage": "java-runtime"
},
"dependencies": [
{
"group": "org.jetbrains.kotlin",
"module": "kotlin-stdlib-jdk7",
"version": {
"requires": "2.1.20"
}
},
{
"group": "androidx.activity",
"module": "activity-ktx",
"version": {
"requires": "1.10.0"
}
},
{
"group": "androidx.appcompat",
"module": "appcompat",
"version": {
"requires": "1.7.0"
}
},
{
"group": "androidx.exifinterface",
"module": "exifinterface",
"version": {
"requires": "1.3.7"
}
},
{
"group": "com.vanniktech",
"module": "android-image-cropper",
"version": {
"requires": "4.6.0"
}
},
{
"group": "org.jetbrains.kotlinx",
"module": "kotlinx-coroutines-android",
"version": {
"requires": "1.6.3"
}
},
{
"group": "org.jetbrains.kotlinx",
"module": "kotlinx-coroutines-core",
"version": {
"requires": "1.6.3"
}
}
],
"files": [
{
"name": "expo.modules.imagepicker-17.0.10.aar",
"url": "expo.modules.imagepicker-17.0.10.aar",
"size": 150809,
"sha512": "48b86f26f07dd1bfa5bb3c7783e5d7523a631208ce649c383957b254f2951f3f8aea42b04e015b87808e4145df561ceac1fc4a6a05ff77460c5df36de8ff38e6",
"sha256": "a582750c4697e74a76713e381bb579a0ba23ad9b4c92d2591768b51f43e660e1",
"sha1": "6aef5999954b547fd3786ceb3912edd5d006b7e1",
"md5": "a0189d8d48c04a75df3d8c22b9d19194"
}
]
},
{
"name": "releaseVariantReleaseSourcePublication",
"attributes": {
"org.gradle.category": "documentation",
"org.gradle.dependency.bundling": "external",
"org.gradle.docstype": "sources",
"org.gradle.usage": "java-runtime"
},
"files": [
{
"name": "expo.modules.imagepicker-17.0.10-sources.jar",
"url": "expo.modules.imagepicker-17.0.10-sources.jar",
"size": 24712,
"sha512": "f134926ca57ba812ad740b762e7cfa547edca55f3d7461f772448e46cd6a1def3d65187b30d1a059461982339f92ef6ce54e5cbeb07c9591558e6b7cb538889c",
"sha256": "49ca9b3dce4cee0b6a3e56d11d88d59a33dd7a1e12f3feb00de7769861e271a9",
"sha1": "38305e73c7a08febf694eb2c3bb06d0965bf7661",
"md5": "7171e4b278d51977668a0295dd69facf"
}
]
}
]
}
@@ -0,0 +1 @@
2342af5b2a781fdd28f9f408d40cd2e3c49db714c6b3f0d71a52e887c1d84e3e
@@ -0,0 +1 @@
8ec914acf8eb319b68c021ef2e7495569f50bede60aa3e0e9b2a97d0eb1ebf1d33aee2ad871c933c4ed3bfee1bcefffd9a3cbbe2015a9aac5c89fad44aaa7c9b
@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd" xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<!-- This module was also published with a richer model, Gradle metadata, -->
<!-- which should be used instead. Do not delete the following line which -->
<!-- is to indicate to Gradle or any Gradle module metadata file consumer -->
<!-- that they should prefer consuming it instead. -->
<!-- do_not_remove: published-with-gradle-metadata -->
<modelVersion>4.0.0</modelVersion>
<groupId>host.exp.exponent</groupId>
<artifactId>expo.modules.imagepicker</artifactId>
<version>17.0.10</version>
<packaging>aar</packaging>
<name>expo.modules.imagepicker</name>
<url>https://github.com/expo/expo</url>
<licenses>
<license>
<name>MIT License</name>
<url>https://github.com/expo/expo/blob/main/LICENSE</url>
</license>
</licenses>
<scm>
<connection>https://github.com/expo/expo.git</connection>
<developerConnection>https://github.com/expo/expo.git</developerConnection>
<url>https://github.com/expo/expo</url>
</scm>
<dependencies>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-stdlib-jdk7</artifactId>
<version>2.1.20</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>androidx.activity</groupId>
<artifactId>activity-ktx</artifactId>
<version>1.10.0</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>androidx.appcompat</groupId>
<artifactId>appcompat</artifactId>
<version>1.7.0</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>androidx.exifinterface</groupId>
<artifactId>exifinterface</artifactId>
<version>1.3.7</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.vanniktech</groupId>
<artifactId>android-image-cropper</artifactId>
<version>4.6.0</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlinx</groupId>
<artifactId>kotlinx-coroutines-android</artifactId>
<version>1.6.3</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlinx</groupId>
<artifactId>kotlinx-coroutines-core</artifactId>
<version>1.6.3</version>
<scope>runtime</scope>
</dependency>
</dependencies>
</project>
@@ -0,0 +1 @@
4c63cdb99a44b4e0828244b121c682eafeb045145f585e7f8b72fd3caf94a9b7
@@ -0,0 +1 @@
21053d97130509a33f4f55b7b69934c1fb50ae2a15609e68e5913bed6aa8a74204f23297ca1c3e9afc12f4615a366d2745445cf54b0e48c8b0d82da46dfb5973
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<metadata>
<groupId>host.exp.exponent</groupId>
<artifactId>expo.modules.imagepicker</artifactId>
<versioning>
<latest>17.0.10</latest>
<release>17.0.10</release>
<versions>
<version>17.0.10</version>
</versions>
<lastUpdated>20251211203607</lastUpdated>
</versioning>
</metadata>
@@ -0,0 +1 @@
e1ead712d962dbef1b680cc7b41653f5
@@ -0,0 +1 @@
86babf85a8d1b101bc61ec50353da23156947f55
@@ -0,0 +1 @@
e09d104cea1a56f4d255ece47406160d79cb2f06e778ca5f20e46b24b181eed8
@@ -0,0 +1 @@
2580cad6c336fb8ae585907550bba011dd2fd6a6f986dfe0e973a390fbf595a95cd0128d9aed010a56a361fc0b2e10d0372ce133e2a89c8a29d7cff98ca5ba09
+49
View File
@@ -0,0 +1,49 @@
{
"name": "expo-image-picker",
"version": "17.0.10",
"description": "Provides access to the system's UI for selecting images and videos from the phone's library or taking a photo with the camera.",
"main": "build/ImagePicker.js",
"types": "build/ImagePicker.d.ts",
"sideEffects": false,
"scripts": {
"build": "expo-module build",
"clean": "expo-module clean",
"lint": "expo-module lint",
"test": "expo-module test",
"prepare": "expo-module prepare",
"prepublishOnly": "expo-module prepublishOnly",
"expo-module": "expo-module"
},
"keywords": [
"react-native",
"expo",
"image",
"picker",
"image-picker"
],
"repository": {
"type": "git",
"url": "https://github.com/expo/expo.git",
"directory": "packages/expo-image-picker"
},
"bugs": {
"url": "https://github.com/expo/expo/issues"
},
"author": "650 Industries, Inc.",
"license": "MIT",
"homepage": "https://docs.expo.dev/versions/latest/sdk/imagepicker/",
"jest": {
"preset": "expo-module-scripts"
},
"dependencies": {
"expo-image-loader": "~6.0.0"
},
"devDependencies": {
"expo-module-scripts": "^5.0.8",
"@testing-library/user-event": "^14.6.1"
},
"peerDependencies": {
"expo": "*"
},
"gitHead": "b6d1de9d53724e311e417c51dd0a9f9bee1c040d"
}
@@ -0,0 +1,9 @@
import { type ConfigPlugin } from 'expo/config-plugins';
type Props = {
photosPermission?: string | false;
cameraPermission?: string | false;
microphonePermission?: string | false;
};
export declare const withAndroidImagePickerPermissions: ConfigPlugin<Props | void>;
declare const _default: ConfigPlugin<void | Props>;
export default _default;
@@ -0,0 +1,47 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.withAndroidImagePickerPermissions = void 0;
const config_plugins_1 = require("expo/config-plugins");
const pkg = require('expo-image-picker/package.json');
const CAMERA_USAGE = 'Allow $(PRODUCT_NAME) to access your camera';
const MICROPHONE_USAGE = 'Allow $(PRODUCT_NAME) to access your microphone';
const READ_PHOTOS_USAGE = 'Allow $(PRODUCT_NAME) to access your photos';
const withAndroidImagePickerPermissions = (config, { cameraPermission, microphonePermission } = {}) => {
if (microphonePermission !== false) {
config = config_plugins_1.AndroidConfig.Permissions.withPermissions(config, ['android.permission.RECORD_AUDIO']);
}
// If the user manually sets any of the permissions to `false`, then we should block the permissions to ensure no
// package can add them.
config = config_plugins_1.AndroidConfig.Permissions.withBlockedPermissions(config, [
microphonePermission === false && 'android.permission.RECORD_AUDIO',
cameraPermission === false && 'android.permission.CAMERA',
].filter(Boolean));
// NOTE(EvanBacon): It's unclear if we should block the WRITE_EXTERNAL_STORAGE/READ_EXTERNAL_STORAGE permissions since
// they're used for many other things besides image picker.
return config;
};
exports.withAndroidImagePickerPermissions = withAndroidImagePickerPermissions;
const withImagePicker = (config, { photosPermission, cameraPermission, microphonePermission } = {}) => {
config_plugins_1.IOSConfig.Permissions.createPermissionsPlugin({
NSPhotoLibraryUsageDescription: READ_PHOTOS_USAGE,
NSCameraUsageDescription: CAMERA_USAGE,
NSMicrophoneUsageDescription: MICROPHONE_USAGE,
})(config, {
NSPhotoLibraryUsageDescription: photosPermission,
NSCameraUsageDescription: cameraPermission,
NSMicrophoneUsageDescription: microphonePermission,
});
if (microphonePermission !== false) {
config = config_plugins_1.AndroidConfig.Permissions.withPermissions(config, ['android.permission.RECORD_AUDIO']);
}
// If the user manually sets any of the permissions to `false`, then we should block the permissions to ensure no
// package can add them.
config = config_plugins_1.AndroidConfig.Permissions.withBlockedPermissions(config, [
microphonePermission === false && 'android.permission.RECORD_AUDIO',
cameraPermission === false && 'android.permission.CAMERA',
].filter(Boolean));
// NOTE(EvanBacon): It's unclear if we should block the WRITE_EXTERNAL_STORAGE/READ_EXTERNAL_STORAGE permissions since
// they're used for many other things besides image picker.
return config;
};
exports.default = (0, config_plugins_1.createRunOncePlugin)(withImagePicker, pkg.name, pkg.version);
+1
View File
@@ -0,0 +1 @@
module.exports = require('expo-module-scripts/jest-preset-plugin');
@@ -0,0 +1,78 @@
import {
AndroidConfig,
type ConfigPlugin,
createRunOncePlugin,
IOSConfig,
} from 'expo/config-plugins';
const pkg = require('expo-image-picker/package.json');
const CAMERA_USAGE = 'Allow $(PRODUCT_NAME) to access your camera';
const MICROPHONE_USAGE = 'Allow $(PRODUCT_NAME) to access your microphone';
const READ_PHOTOS_USAGE = 'Allow $(PRODUCT_NAME) to access your photos';
type Props = {
photosPermission?: string | false;
cameraPermission?: string | false;
microphonePermission?: string | false;
};
export const withAndroidImagePickerPermissions: ConfigPlugin<Props | void> = (
config,
{ cameraPermission, microphonePermission } = {}
) => {
if (microphonePermission !== false) {
config = AndroidConfig.Permissions.withPermissions(config, ['android.permission.RECORD_AUDIO']);
}
// If the user manually sets any of the permissions to `false`, then we should block the permissions to ensure no
// package can add them.
config = AndroidConfig.Permissions.withBlockedPermissions(
config,
[
microphonePermission === false && 'android.permission.RECORD_AUDIO',
cameraPermission === false && 'android.permission.CAMERA',
].filter(Boolean) as string[]
);
// NOTE(EvanBacon): It's unclear if we should block the WRITE_EXTERNAL_STORAGE/READ_EXTERNAL_STORAGE permissions since
// they're used for many other things besides image picker.
return config;
};
const withImagePicker: ConfigPlugin<Props | void> = (
config,
{ photosPermission, cameraPermission, microphonePermission } = {}
) => {
IOSConfig.Permissions.createPermissionsPlugin({
NSPhotoLibraryUsageDescription: READ_PHOTOS_USAGE,
NSCameraUsageDescription: CAMERA_USAGE,
NSMicrophoneUsageDescription: MICROPHONE_USAGE,
})(config, {
NSPhotoLibraryUsageDescription: photosPermission,
NSCameraUsageDescription: cameraPermission,
NSMicrophoneUsageDescription: microphonePermission,
});
if (microphonePermission !== false) {
config = AndroidConfig.Permissions.withPermissions(config, ['android.permission.RECORD_AUDIO']);
}
// If the user manually sets any of the permissions to `false`, then we should block the permissions to ensure no
// package can add them.
config = AndroidConfig.Permissions.withBlockedPermissions(
config,
[
microphonePermission === false && 'android.permission.RECORD_AUDIO',
cameraPermission === false && 'android.permission.CAMERA',
].filter(Boolean) as string[]
);
// NOTE(EvanBacon): It's unclear if we should block the WRITE_EXTERNAL_STORAGE/READ_EXTERNAL_STORAGE permissions since
// they're used for many other things besides image picker.
return config;
};
export default createRunOncePlugin(withImagePicker, pkg.name, pkg.version);
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "expo-module-scripts/tsconfig.plugin",
"compilerOptions": {
"outDir": "build",
"rootDir": "src"
},
"include": ["./src"],
"exclude": ["**/__mocks__/*", "**/__tests__/*"]
}
@@ -0,0 +1,2 @@
import { requireNativeModule } from 'expo-modules-core';
export default requireNativeModule('ExponentImagePicker');
@@ -0,0 +1,263 @@
import { PermissionResponse, PermissionStatus, Platform } from 'expo-modules-core';
import {
CameraType,
ImagePickerAsset,
ImagePickerOptions,
ImagePickerResult,
MediaType,
OpenFileBrowserOptions,
} from './ImagePicker.types';
import { parseMediaTypes } from './utils';
const MediaTypeInput: Record<MediaType, string> = {
images: 'image/*',
videos: 'video/mp4,video/quicktime,video/x-m4v,video/*',
livePhotos: '',
};
export default {
async launchImageLibraryAsync({
mediaTypes = ['images'] as MediaType[],
allowsMultipleSelection = false,
base64 = false,
}: ImagePickerOptions): Promise<ImagePickerResult> {
// SSR guard
if (!Platform.isDOMAvailable) {
return { canceled: true, assets: null };
}
return await openFileBrowserAsync({
mediaTypes,
allowsMultipleSelection,
base64,
});
},
async launchCameraAsync({
mediaTypes = ['images'] as MediaType[],
allowsMultipleSelection = false,
base64 = false,
cameraType,
}: ImagePickerOptions): Promise<ImagePickerResult> {
// SSR guard
if (!Platform.isDOMAvailable) {
return { canceled: true, assets: null };
}
return await openFileBrowserAsync({
mediaTypes,
allowsMultipleSelection,
capture: cameraType ?? true,
base64,
});
},
/*
* Delegate to expo-permissions to request camera permissions
*/
async getCameraPermissionsAsync() {
return permissionGrantedResponse();
},
async requestCameraPermissionsAsync() {
return permissionGrantedResponse();
},
/*
* Camera roll permissions don't need to be requested on web, so we always
* respond with granted.
*/
async getMediaLibraryPermissionsAsync(_writeOnly: boolean) {
return permissionGrantedResponse();
},
async requestMediaLibraryPermissionsAsync(_writeOnly: boolean): Promise<PermissionResponse> {
return permissionGrantedResponse();
},
};
function permissionGrantedResponse(): PermissionResponse {
return {
status: PermissionStatus.GRANTED,
expires: 'never',
granted: true,
canAskAgain: true,
};
}
/**
* Opens a file browser dialog or camera on supported platforms and returns the selected files.
* Handles both single and multiple file selection.
*/
function openFileBrowserAsync({
mediaTypes,
capture = false,
allowsMultipleSelection = false,
base64,
}: OpenFileBrowserOptions): Promise<ImagePickerResult> {
const parsedMediaTypes = parseMediaTypes(mediaTypes);
const mediaTypeFormat = createMediaTypeFormat(parsedMediaTypes);
const input = document.createElement('input');
input.style.display = 'none';
input.setAttribute('type', 'file');
input.setAttribute('accept', mediaTypeFormat);
input.setAttribute('id', String(Math.random()));
input.setAttribute('data-testid', 'file-input');
if (allowsMultipleSelection) {
input.setAttribute('multiple', 'multiple');
}
if (capture) {
switch (capture) {
case true:
input.setAttribute('capture', 'camera');
break;
case CameraType.front:
input.setAttribute('capture', 'user');
break;
case CameraType.back:
input.setAttribute('capture', 'environment');
}
}
document.body.appendChild(input);
return new Promise((resolve) => {
input.addEventListener('change', async () => {
if (input.files?.length) {
const files = allowsMultipleSelection ? input.files : [input.files[0]];
const assets: ImagePickerAsset[] = await Promise.all(
Array.from(files).map((file) => readFile(file, { base64 }))
);
resolve({ canceled: false, assets });
} else {
resolve({ canceled: true, assets: null });
}
document.body.removeChild(input);
});
input.addEventListener('cancel', () => {
input.dispatchEvent(new Event('change'));
});
const event = new MouseEvent('click');
input.dispatchEvent(event);
});
}
/**
* Gets metadata for an image file using a blob URL
* TODO (Hirbod): add exif support for feature parity with native
*/
async function getImageMetadata(blobUrl: string): Promise<{ width: number; height: number }> {
return new Promise((resolve) => {
const image = new Image();
image.onload = () => {
resolve({
width: image.naturalWidth ?? image.width,
height: image.naturalHeight ?? image.height,
});
};
image.onerror = () => resolve({ width: 0, height: 0 });
image.src = blobUrl;
});
}
/**
* Gets metadata for a video file using a blob URL
*/
async function getVideoMetadata(
blobUrl: string
): Promise<{ width: number; height: number; duration: number }> {
return new Promise((resolve) => {
const video = document.createElement('video');
video.preload = 'metadata';
video.onloadedmetadata = () => {
resolve({
width: video.videoWidth,
height: video.videoHeight,
duration: video.duration,
});
};
video.onerror = () => resolve({ width: 0, height: 0, duration: 0 });
video.src = blobUrl;
});
}
/**
* Reads a file as base64
*/
async function readFileAsBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => {
reject(new Error('Failed to read the selected media because the operation failed.'));
};
reader.onload = (event: ProgressEvent<FileReader>) => {
const result = event.target?.result;
if (typeof result !== 'string') {
reject(new Error('Failed to read file as base64'));
return;
}
// Remove the data URL prefix to get just the base64 data
resolve(result.split(',')[1]);
};
reader.readAsDataURL(file);
});
}
/**
* Reads a file and returns its data as an ImagePickerAsset.
* Handles both base64 and blob URL modes, and extracts metadata for images and videos.
*/
async function readFile(targetFile: File, options: { base64: boolean }): Promise<ImagePickerAsset> {
const mimeType = targetFile.type;
const baseUri = URL.createObjectURL(targetFile);
try {
let metadata: { width: number; height: number; duration?: number };
let base64: string | undefined;
if (mimeType.startsWith('image/')) {
metadata = await getImageMetadata(baseUri);
} else if (mimeType.startsWith('video/')) {
metadata = await getVideoMetadata(baseUri);
} else {
throw new Error(`Unsupported file type: ${mimeType}. Only images and videos are supported.`);
}
if (options.base64) {
base64 = await readFileAsBase64(targetFile);
}
return {
uri: baseUri,
width: metadata.width,
height: metadata.height,
type: mimeType.startsWith('image/') ? 'image' : 'video',
mimeType,
fileName: targetFile.name,
fileSize: targetFile.size,
file: targetFile,
...(metadata.duration !== undefined && { duration: metadata.duration }),
...(base64 && { base64 }),
};
} catch (error) {
throw error;
}
}
/**
* Creates the accept attribute value for the file input based on the requested media types.
* Filters out livePhotos as they're not supported on web.
*/
function createMediaTypeFormat(mediaTypes: MediaType[]): string {
const filteredMediaTypes = mediaTypes.filter((mediaType) => mediaType !== 'livePhotos');
if (filteredMediaTypes.length === 0) {
return 'image/*';
}
let result = '';
for (const mediaType of filteredMediaTypes) {
// Make sure the types don't repeat
if (!result.includes(MediaTypeInput[mediaType])) {
result = result.concat(',', MediaTypeInput[mediaType]);
}
}
return result;
}
+219
View File
@@ -0,0 +1,219 @@
import {
CodedError,
createPermissionHook,
PermissionExpiration,
PermissionHookOptions,
PermissionResponse,
PermissionStatus,
UnavailabilityError,
} from 'expo-modules-core';
import ExponentImagePicker from './ExponentImagePicker';
import {
CameraPermissionResponse,
ImagePickerErrorResult,
ImagePickerOptions,
ImagePickerResult,
MediaLibraryPermissionResponse,
} from './ImagePicker.types';
import { mapDeprecatedOptions } from './utils';
function validateOptions(options: ImagePickerOptions) {
const { aspect, quality, videoMaxDuration } = options;
if (aspect != null) {
const [x, y] = aspect;
if (x <= 0 || y <= 0) {
throw new CodedError(
'ERR_INVALID_ARGUMENT',
`Invalid aspect ratio values ${x}:${y}. Provide positive numbers.`
);
}
}
if (quality && (quality < 0 || quality > 1)) {
throw new CodedError(
'ERR_INVALID_ARGUMENT',
`Invalid 'quality' value ${quality}. Provide a value between 0 and 1.`
);
}
if (videoMaxDuration && videoMaxDuration < 0) {
throw new CodedError(
'ERR_INVALID_ARGUMENT',
`Invalid 'videoMaxDuration' value ${videoMaxDuration}. Provide a non-negative number.`
);
}
return options;
}
// @needsAudit
/**
* Checks user's permissions for accessing camera.
* @return A promise that fulfills with an object of type [CameraPermissionResponse](#camerapermissionresponse).
*/
export async function getCameraPermissionsAsync(): Promise<CameraPermissionResponse> {
return ExponentImagePicker.getCameraPermissionsAsync();
}
// @needsAudit
/**
* Checks user's permissions for accessing photos.
* @param writeOnly Whether to request write or read and write permissions. Defaults to `false`
* @return A promise that fulfills with an object of type [MediaLibraryPermissionResponse](#medialibrarypermissionresponse).
*/
export async function getMediaLibraryPermissionsAsync(
writeOnly: boolean = false
): Promise<MediaLibraryPermissionResponse> {
return ExponentImagePicker.getMediaLibraryPermissionsAsync(writeOnly);
}
// @needsAudit
/**
* Asks the user to grant permissions for accessing camera. This does nothing on web because the
* browser camera is not used.
* @return A promise that fulfills with an object of type [CameraPermissionResponse](#camerarollpermissionresponse).
*/
export async function requestCameraPermissionsAsync(): Promise<CameraPermissionResponse> {
return ExponentImagePicker.requestCameraPermissionsAsync();
}
// @needsAudit
/**
* Asks the user to grant permissions for accessing user's photo. This method does nothing on web.
* @param writeOnly Whether to request write or read and write permissions. Defaults to `false`
* @return A promise that fulfills with an object of type [MediaLibraryPermissionResponse](#medialibrarypermissionresponse).
*/
export async function requestMediaLibraryPermissionsAsync(
writeOnly: boolean = false
): Promise<MediaLibraryPermissionResponse> {
const imagePickerMethod = ExponentImagePicker.requestMediaLibraryPermissionsAsync;
return imagePickerMethod(writeOnly);
}
// @needsAudit
/**
* Check or request permissions to access the media library.
* This uses both `requestMediaLibraryPermissionsAsync` and `getMediaLibraryPermissionsAsync` to interact with the permissions.
*
* @example
* ```ts
* const [status, requestPermission] = ImagePicker.useMediaLibraryPermissions();
* ```
*/
export const useMediaLibraryPermissions = createPermissionHook<
MediaLibraryPermissionResponse,
{ writeOnly?: boolean }
>({
// TODO(cedric): permission requesters should have an options param or a different requester
getMethod: (options) => getMediaLibraryPermissionsAsync(options?.writeOnly),
requestMethod: (options) => requestMediaLibraryPermissionsAsync(options?.writeOnly),
});
// @needsAudit
/**
* Check or request permissions to access the camera.
* This uses both `requestCameraPermissionsAsync` and `getCameraPermissionsAsync` to interact with the permissions.
*
* @example
* ```ts
* const [status, requestPermission] = ImagePicker.useCameraPermissions();
* ```
*/
export const useCameraPermissions = createPermissionHook({
getMethod: getCameraPermissionsAsync,
requestMethod: requestCameraPermissionsAsync,
});
// @needsAudit
/**
* Android system sometimes kills the `MainActivity` after the `ImagePicker` finishes. When this
* happens, we lose the data selected using the `ImagePicker`. However, you can retrieve the lost
* data by calling `getPendingResultAsync`. You can test this functionality by turning on
* `Don't keep activities` in the developer options.
* @return
* - **On Android:** a promise that resolves to an object of exactly same type as in
* `ImagePicker.launchImageLibraryAsync` or `ImagePicker.launchCameraAsync` if the `ImagePicker`
* finished successfully. Otherwise, an object of type [`ImagePickerErrorResult`](#imagepickerimagepickererrorresult).
* - **On other platforms:** `null`
*/
export async function getPendingResultAsync(): Promise<
ImagePickerResult | ImagePickerErrorResult | null
> {
if (ExponentImagePicker.getPendingResultAsync) {
return ExponentImagePicker.getPendingResultAsync();
}
return null;
}
// @needsAudit
/**
* Display the system UI for taking a photo with the camera. Requires `Permissions.CAMERA`.
* On Android and iOS 10 `Permissions.CAMERA_ROLL` is also required. On mobile web, this must be
* called immediately in a user interaction like a button press, otherwise the browser will block
* the request without a warning.
* > **Note:** Make sure that you handle `MainActivity` destruction on **Android**. See [ImagePicker.getPendingResultAsync](#imagepickergetpendingresultasync).
* > **Notes for Web:** The system UI can only be shown after user activation (e.g. a `Button` press).
* Therefore, calling `launchCameraAsync` in `componentDidMount`, for example, will **not** work as
* intended. The `cancelled` event will not be returned in the browser due to platform restrictions
* and inconsistencies across browsers.
* @param options An `ImagePickerOptions` object.
* @return A promise that resolves to an object with `canceled` and `assets` fields.
* When the user canceled the action the `assets` is always `null`, otherwise it's an array of
* the selected media assets which have a form of [`ImagePickerAsset`](#imagepickerasset).
*/
export async function launchCameraAsync(
options: ImagePickerOptions = {}
): Promise<ImagePickerResult> {
if (!ExponentImagePicker.launchCameraAsync) {
throw new UnavailabilityError('ImagePicker', 'launchCameraAsync');
}
const mappedOptions = mapDeprecatedOptions(options);
return await ExponentImagePicker.launchCameraAsync(validateOptions(mappedOptions));
}
// @needsAudit
/**
* Display the system UI for choosing an image or a video from the phone's library.
* Requires `Permissions.MEDIA_LIBRARY` on iOS 10 only. On mobile web, this must be called
* immediately in a user interaction like a button press, otherwise the browser will block the
* request without a warning.
*
* **Animated GIFs support:** On Android, if the selected image is an animated GIF, the result image will be an
* animated GIF too if and only if `quality` is explicitly set to `1.0` and `allowsEditing` is set to `false`.
* Otherwise compression and/or cropper will pick the first frame of the GIF and return it as the
* result (on Android the result will be a PNG). On iOS, both quality and cropping are supported.
*
* > **Notes for Web:** The system UI can only be shown after user activation (e.g. a `Button` press).
* Therefore, calling `launchImageLibraryAsync` in `componentDidMount`, for example, will **not**
* work as intended. The `cancelled` event will not be returned in the browser due to platform
* restrictions and inconsistencies across browsers.
* @param options An object extended by [`ImagePickerOptions`](#imagepickeroptions).
* @return A promise that resolves to an object with `canceled` and `assets` fields.
* When the user canceled the action the `assets` is always `null`, otherwise it's an array of
* the selected media assets which have a form of [`ImagePickerAsset`](#imagepickerasset).
*/
export async function launchImageLibraryAsync(
options: ImagePickerOptions = {}
): Promise<ImagePickerResult> {
const mappedOptions = mapDeprecatedOptions(options);
if (!ExponentImagePicker.launchImageLibraryAsync) {
throw new UnavailabilityError('ImagePicker', 'launchImageLibraryAsync');
}
if (mappedOptions?.allowsEditing && mappedOptions.allowsMultipleSelection) {
console.warn(
'[expo-image-picker] `allowsEditing` is not supported when `allowsMultipleSelection` is enabled and will be ignored.' +
"Disable either 'allowsEditing' or 'allowsMultipleSelection' in 'launchImageLibraryAsync' " +
'to fix this warning.'
);
}
return await ExponentImagePicker.launchImageLibraryAsync(mappedOptions);
}
export * from './ImagePicker.types';
export type { PermissionExpiration, PermissionHookOptions, PermissionResponse };
export { PermissionStatus };
+581
View File
@@ -0,0 +1,581 @@
import { PermissionResponse } from 'expo-modules-core';
// @needsAudit
/**
* Alias for `PermissionResponse` type exported by `expo-modules-core`.
*/
export type CameraPermissionResponse = PermissionResponse;
// @needsAudit
/**
* Extends `PermissionResponse` type exported by `expo-modules-core`, containing additional iOS-specific field.
*/
export type MediaLibraryPermissionResponse = PermissionResponse & {
/**
* Indicates if your app has access to the whole or only part of the photo library. Possible values are:
* - `'all'` if the user granted your app access to the whole photo library
* - `'limited'` if the user granted your app access only to selected photos (only available on Android API 34+ and iOS 14.0+)
* - `'none'` if user denied or hasn't yet granted the permission
*/
accessPrivileges?: 'all' | 'limited' | 'none';
};
// @needsAudit
/**
* @deprecated To set media types available in the image picker use an array of [`MediaType`](#mediatype) instead.
*/
export enum MediaTypeOptions {
/**
* Images and videos.
*/
All = 'All',
/**
* Only videos.
*/
Videos = 'Videos',
/**
* Only images.
*/
Images = 'Images',
}
/**
* Media types that can be picked by the image picker.
* - `'images'` - for images.
* - `'videos'` - for videos.
* - `'livePhotos'` - for live photos (iOS only).
*
* > When the `livePhotos` type is added to the media types array and a live photo is selected,
* > the resulting `ImagePickerAsset` will contain an unaltered image and the `pairedVideoAsset` field will contain a
* > video asset paired with the image. This option will be ignored when the `allowsEditing` option is enabled. Due
* > to platform limitations live photos are returned at original quality, regardless of the `quality` option.
*
* > When on Android or Web `livePhotos` type passed as a media type will be ignored.
*/
export type MediaType = 'images' | 'videos' | 'livePhotos';
/**
* The default tab with which the image picker will be opened.
* - `'photos'` - the photos/videos tab will be opened.
* - `'albums'` - the albums tab will be opened.
*
* @platform android
*/
export type DefaultTab = 'photos' | 'albums';
// @needsAudit
export enum VideoExportPreset {
/**
* Resolution: __Unchanged__ •
* Video compression: __None__ •
* Audio compression: __None__
*/
Passthrough = 0,
/**
* Resolution: __Depends on the device__ •
* Video compression: __H.264__ •
* Audio compression: __AAC__
*/
LowQuality = 1,
/**
* Resolution: __Depends on the device__ •
* Video compression: __H.264__ •
* Audio compression: __AAC__
*/
MediumQuality = 2,
/**
* Resolution: __Depends on the device__ •
* Video compression: __H.264__ •
* Audio compression: __AAC__
*/
HighestQuality = 3,
/**
* Resolution: __640 × 480__ •
* Video compression: __H.264__ •
* Audio compression: __AAC__
*/
H264_640x480 = 4,
/**
* Resolution: __960 × 540__ •
* Video compression: __H.264__ •
* Audio compression: __AAC__
*/
H264_960x540 = 5,
/**
* Resolution: __1280 × 720__ •
* Video compression: __H.264__ •
* Audio compression: __AAC__
*/
H264_1280x720 = 6,
/**
* Resolution: __1920 × 1080__ •
* Video compression: __H.264__ •
* Audio compression: __AAC__
*/
H264_1920x1080 = 7,
/**
* Resolution: __3840 × 2160__ •
* Video compression: __H.264__ •
* Audio compression: __AAC__
*/
H264_3840x2160 = 8,
/**
* Resolution: __1920 × 1080__ •
* Video compression: __HEVC__ •
* Audio compression: __AAC__
*/
HEVC_1920x1080 = 9,
/**
* Resolution: __3840 × 2160__ •
* Video compression: __HEVC__ •
* Audio compression: __AAC__
*/
HEVC_3840x2160 = 10,
}
// @needsAudit
export enum UIImagePickerControllerQualityType {
/**
* Highest available resolution.
*/
High = 0,
/**
* Depends on the device.
*/
Medium = 1,
/**
* Depends on the device.
*/
Low = 2,
/**
* 640 × 480
*/
VGA640x480 = 3,
/**
* 1280 × 720
*/
IFrame1280x720 = 4,
/**
* 960 × 540
*/
IFrame960x540 = 5,
}
/**
* Picker presentation style. Its values are directly mapped to the [`UIModalPresentationStyle`](https://developer.apple.com/documentation/uikit/uiviewcontroller/1621355-modalpresentationstyle).
*
* @platform ios
*/
export enum UIImagePickerPresentationStyle {
/**
* A presentation style in which the presented picker covers the screen.
*/
FULL_SCREEN = 'fullScreen',
/**
* A presentation style that partially covers the underlying content.
*/
PAGE_SHEET = 'pageSheet',
/**
* A presentation style that displays the picker centered in the screen.
*/
FORM_SHEET = 'formSheet',
/**
* A presentation style where the picker is displayed over the app's content.
*/
CURRENT_CONTEXT = 'currentContext',
/**
* A presentation style in which the picker view covers the screen.
*/
OVER_FULL_SCREEN = 'overFullScreen',
/**
* A presentation style where the picker is displayed over the app's content.
*/
OVER_CURRENT_CONTEXT = 'overCurrentContext',
/**
* A presentation style where the picker is displayed in a popover view.
*/
POPOVER = 'popover',
/**
* The default presentation style chosen by the system.
* On older iOS versions, falls back to `WebBrowserPresentationStyle.FullScreen`.
*
* @platform ios
*/
AUTOMATIC = 'automatic',
}
/**
* Picker preferred asset representation mode. Its values are directly mapped to the [`PHPickerConfigurationAssetRepresentationMode`](https://developer.apple.com/documentation/photokit/phpickerconfigurationassetrepresentationmode).
*
* @platform ios
*/
export enum UIImagePickerPreferredAssetRepresentationMode {
/**
* A mode that indicates that the system chooses the appropriate asset representation.
*/
Automatic = 'automatic',
/**
* A mode that uses the most compatible asset representation.
*/
Compatible = 'compatible',
/**
* A mode that uses the current representation to avoid transcoding, if possible.
*/
Current = 'current',
}
export enum CameraType {
/**
* Back/rear camera.
*/
back = 'back',
/**
* Front camera
*/
front = 'front',
}
/**
* @hidden
* @deprecated Use `ImagePickerAsset` instead
*/
export type ImageInfo = ImagePickerAsset;
/**
* Represents an asset (image or video) returned by the image picker or camera.
*/
export type ImagePickerAsset = {
/**
* URI to the local image or video file (usable as the source of an `Image` element, in the case of
* an image) and `width` and `height` specify the dimensions of the media.
*/
uri: string;
/**
* The unique ID that represents the picked image or video, if picked from the library. It can be used
* by [expo-media-library](./media-library) to manage the picked asset.
*
* > This might be `null` when the ID is unavailable or the user gave limited permission to access the media library.
* > On Android, the ID is unavailable when the user selects a photo by directly browsing file system.
*
* @platform android
* @platform ios
*/
assetId?: string | null;
/**
* Width of the image or video. Can be `0` if the system did not provide the width.
*/
width: number;
/**
* Height of the image or video. Can be `0` if the system did not provide the height.
*/
height: number;
/**
* The type of the asset.
* - `'image'` - for images.
* - `'video'` - for videos.
* - `'livePhoto'` - for live photos. (iOS only)
* - `'pairedVideo'` - for videos paired with photos, which can be combined to create a live photo. (iOS only)
* - `null` - when the type could not be determined. This is rare but can happen with some Android ContentProviders.
*/
type?: 'image' | 'video' | 'livePhoto' | 'pairedVideo' | null;
/**
* Preferred filename to use when saving this item. This might be `null` when the name is unavailable
* or user gave limited permission to access the media library.
*
*/
fileName?: string | null;
/**
* File size of the picked image or video, in bytes.
*
*/
fileSize?: number;
/**
* The `exif` field is included if the `exif` option is truthy, and is an object containing the
* image's EXIF data. The names of this object's properties are EXIF tags and the values are the
* respective EXIF values for those tags.
*
* @platform android
* @platform ios
*/
exif?: Record<string, any> | null;
/**
* When the `base64` option is truthy, it is a Base64-encoded string of the selected image's JPEG data, otherwise `null`.
* If you prepend this with `'data:image/jpeg;base64,'` to create a data URI,
* you can use it as the source of an `Image` element; for example:
* ```ts
* <Image
* source={{ uri: 'data:image/jpeg;base64,' + asset.base64 }}
* style={{ width: 200, height: 200 }}
* />
* ```
*/
base64?: string | null;
/**
* Length of the video in milliseconds or `null` if the asset is not a video.
*/
duration?: number | null;
/**
* The MIME type of the selected asset or `null` if could not be determined.
*/
mimeType?: string;
/**
* Contains information about the video paired with the image file. This property is only set when `livePhotos` media type was present in the `mediaTypes` array when launching the picker and a live photo was selected.
*
* @platform ios
*/
pairedVideoAsset?: ImagePickerAsset | null;
/**
* The web `File` object containing the selected media. This property is web-only and can be used to upload to a server with `FormData`.
*
* @platform web
*/
file?: File;
};
// @needsAudit
export type ImagePickerErrorResult = {
/**
* The error code.
*/
code: string;
/**
* The error message.
*/
message: string;
/**
* The exception which caused the error.
*/
exception?: string;
};
// @needsAudit
/**
* Type representing successful and canceled pick result.
*/
export type ImagePickerResult = ImagePickerSuccessResult | ImagePickerCanceledResult;
/**
* Type representing successful pick result.
*/
export type ImagePickerSuccessResult = {
/**
* Boolean flag set to `false` showing that the request was successful.
*/
canceled: false;
/**
* An array of picked assets.
*/
assets: ImagePickerAsset[];
};
/**
* Type representing canceled pick result.
*/
export type ImagePickerCanceledResult = {
/**
* Boolean flag set to `true` showing that the request was canceled.
*/
canceled: true;
/**
* `null` signifying that the request was canceled.
*/
assets: null;
};
/**
* @hidden
* @deprecated Use `ImagePickerResult` instead.
*/
export type ImagePickerCancelledResult = ImagePickerCanceledResult;
/**
* @hidden
* @deprecated `ImagePickerMultipleResult` has been deprecated in favor of `ImagePickerResult`.
*/
export type ImagePickerMultipleResult = ImagePickerResult;
/**
* The shape of the crop area.
*/
export type CropShape = 'rectangle' | 'oval';
// @needsAudit
export type ImagePickerOptions = {
/**
* Whether to show a UI to edit the image after it is picked. On Android the user can crop and
* rotate the image and on iOS simply crop it.
*
* > - Cropping multiple images is not supported - this option is mutually exclusive with `allowsMultipleSelection`.
* > - On iOS, this option is ignored if `allowsMultipleSelection` is enabled.
* > - On iOS cropping a `.bmp` image will convert it to `.png`.
*
* @default false
* @platform android
* @platform ios
*/
allowsEditing?: boolean;
/**
* An array with two entries `[x, y]` specifying the aspect ratio to maintain if the user is
* allowed to edit the image (by passing `allowsEditing: true`). This is only applicable on
* Android, since on iOS the crop rectangle is always a square.
*/
aspect?: [number, number];
/**
* Specify the shape of the crop area if the user is allowed to edit the image
* (by passing `allowsEditing: true`). This option is only applicable on Android.
*
* @default rectangle
* @platform android
*/
shape?: CropShape;
/**
* Specify the quality of compression, from `0` to `1`. `0` means compress for small size,
* `1` means compress for maximum quality.
* > Note: If the selected image has been compressed before, the size of the output file may be
* > bigger than the size of the original image.
*
* > Note: On iOS, if a `.bmp` or `.png` image is selected from the library, this option is ignored.
*
* @default 1.0
* @platform android
* @platform ios
*/
quality?: number;
/**
* Choose what type of media to pick.
* @default 'images'
*/
mediaTypes?: MediaType | MediaType[] | MediaTypeOptions;
/**
* Whether to also include the EXIF data for the image. On iOS the EXIF data does not include GPS
* tags in the camera case.
*
* @platform android
* @platform ios
*/
exif?: boolean;
/**
* Whether to also include the image data in Base64 format.
*/
base64?: boolean;
/**
* Specify preset which will be used to compress selected video.
* @default ImagePicker.VideoExportPreset.Passthrough
* @platform ios 11+
* @deprecated See [`videoExportPreset`](https://developer.apple.com/documentation/uikit/uiimagepickercontroller/2890964-videoexportpreset?language=objc)
* in Apple documentation.
*/
videoExportPreset?: VideoExportPreset;
/**
* Specify the quality of recorded videos. Defaults to the highest quality available for the device.
* @default ImagePicker.UIImagePickerControllerQualityType.High
* @platform ios
*/
videoQuality?: UIImagePickerControllerQualityType;
/**
* Whether or not to allow selecting multiple media files at once.
*
* > Cropping multiple images is not supported - this option is mutually exclusive with `allowsEditing`.
* > If this option is enabled, then `allowsEditing` is ignored.
*
* @default false
* @platform android
* @platform ios 14+
* @platform web
*/
allowsMultipleSelection?: boolean;
/**
* The maximum number of items that user can select. Applicable when `allowsMultipleSelection` is enabled.
* Setting the value to `0` sets the selection limit to the maximum that the system supports.
*
* @platform android
* @platform ios 14+
* @default 0
*/
selectionLimit?: number;
/**
* Whether to display number badges when assets are selected. The badges are numbered
* in selection order. Assets are then returned in the exact same order they were selected.
*
* > Assets should be returned in the selection order regardless of this option,
* > but there is no guarantee that it is always true when this option is disabled.
*
* @platform ios 15+
* @default false
*/
orderedSelection?: boolean;
/**
* Choose the default tab with which the image picker will be opened.
* @default 'photos'
* @platform android
*/
defaultTab?: DefaultTab;
/**
* Maximum duration, in seconds, for video recording. Setting this to `0` disables the limit.
* Defaults to `0` (no limit).
* - **On iOS**, when `allowsEditing` is set to `true`, maximum duration is limited to 10 minutes.
* This limit is applied automatically, if `0` or no value is specified.
* - **On Android**, effect of this option depends on support of installed camera app.
* - **On Web** this option has no effect - the limit is browser-dependant.
*/
videoMaxDuration?: number;
/**
* Choose [presentation style](https://developer.apple.com/documentation/uikit/uiviewcontroller/1621355-modalpresentationstyle?language=objc)
* to customize view during taking photo/video.
* @default ImagePicker.UIImagePickerPresentationStyle.Automatic
* @platform ios
*/
presentationStyle?: UIImagePickerPresentationStyle;
/**
* Selects the camera-facing type. The `CameraType` enum provides two options:
* `front` for the front-facing camera and `back` for the back-facing camera.
* - **On Android**, the behavior of this option may vary based on the camera app installed on the device.
* - **On Web**, if this option is not provided, use "camera" as the default value of internal input element for backwards compatibility.
* @default CameraType.back
*/
cameraType?: CameraType;
/**
* Choose [preferred asset representation mode](https://developer.apple.com/documentation/photokit/phpickerconfigurationassetrepresentationmode)
* to use when loading assets.
* @default ImagePicker.UIImagePickerPreferredAssetRepresentationMode.Automatic
* @platform ios 14+
*/
preferredAssetRepresentationMode?: UIImagePickerPreferredAssetRepresentationMode;
/**
* Uses the legacy image picker on Android. This will allow media to be selected from outside the users photo library.
* @platform android
* @default false
*/
legacy?: boolean;
};
/**
* @hidden
* @deprecated Only used internally.
*/
export type OpenFileBrowserOptions = {
/**
* Choose what type of media to pick.
* @default 'images'
*/
mediaTypes: MediaType | MediaType[] | MediaTypeOptions;
// @docsMissing
capture?: boolean | CameraType;
/**
* Whether or not to allow selecting multiple media files at once.
* @platform web
*/
allowsMultipleSelection: boolean;
/**
* Whether to also include the image data in Base64 format.
*/
base64: boolean;
};
/**
* @hidden
* @deprecated Use `ImagePickerResult` or `OpenFileBrowserOptions` instead.
*/
export type ExpandImagePickerResult<T extends ImagePickerOptions | OpenFileBrowserOptions> =
T extends { allowsMultipleSelection: true } ? ImagePickerResult : ImagePickerResult;
+36
View File
@@ -0,0 +1,36 @@
// @hidden
import { ImagePickerOptions, MediaType, MediaTypeOptions } from './ImagePicker.types';
export function parseMediaTypes(
mediaTypes: MediaTypeOptions | MediaType | MediaType[]
): MediaType[] {
const mediaTypeOptionsToMediaType: Record<MediaTypeOptions, MediaType[]> = {
Images: ['images'],
Videos: ['videos'],
All: ['images', 'videos'],
};
if (
mediaTypes === MediaTypeOptions.Images ||
mediaTypes === MediaTypeOptions.Videos ||
mediaTypes === MediaTypeOptions.All
) {
console.warn(
'[expo-image-picker] `ImagePicker.MediaTypeOptions` have been deprecated. Use `ImagePicker.MediaType` or an array of `ImagePicker.MediaType` instead.'
);
return mediaTypeOptionsToMediaType[mediaTypes];
}
// Unlike iOS, Android can't auto-cast to array
if (typeof mediaTypes === 'string') {
return [mediaTypes];
}
return mediaTypes;
}
// We deprecated the MediaTypeOptions in SDK52, we should remove it in future release.
export function mapDeprecatedOptions(options: ImagePickerOptions) {
if (!options.mediaTypes) {
return options;
}
return { ...options, mediaTypes: parseMediaTypes(options.mediaTypes ?? []) };
}

Some files were not shown because too many files have changed in this diff Show More