expo-image-picker 13.2.0 → 13.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -10,11 +10,32 @@
10
10
 
11
11
  ### 💡 Others
12
12
 
13
+ ## 13.3.1 — 2022-07-25
14
+
15
+ _This version does not introduce any user-facing changes._
16
+
17
+ ## 13.3.0 — 2022-07-16
18
+
19
+ ### 🎉 New features
20
+
21
+ - 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))
22
+ - 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))
23
+ - 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))
24
+ - On Android added support for selecting multiple images/videos. ([#18161](https://github.com/expo/expo/pull/18161) by [@bbarthec](https://github.com/bbarthec))
25
+
26
+ ### 🐛 Bug fixes
27
+
28
+ - 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))
29
+
30
+ ## 13.2.1 — 2022-07-11
31
+
32
+ _This version does not introduce any user-facing changes._
33
+
13
34
  ## 13.2.0 — 2022-07-07
14
35
 
15
36
  ### 🎉 New features
16
37
 
17
- - On iOS 14+ added support for selecting multiple images/videos. ([#18102](https://github.com/expo/expo/pull/18102) by [@barthap](https://github.com/barthap))
38
+ - 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))
18
39
 
19
40
  ### 🐛 Bug fixes
20
41
 
@@ -22,7 +43,7 @@
22
43
 
23
44
  ### 💡 Others
24
45
 
25
- - On Android migrated to the new `registerForActivityResult` mechanism. ([#17671](https://github.com/expo/expo/pull/17671), ([#17987](https://github.com/expo/expo/pull/17987) by [@bbarthec](https://github.com/bbarthec))
46
+ - 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))
26
47
  - 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))
27
48
  - Migrated Expo modules definitions to the new naming convention. ([#17193](https://github.com/expo/expo/pull/17193) by [@tsapeta](https://github.com/tsapeta))
28
49
 
@@ -3,7 +3,7 @@ apply plugin: 'kotlin-android'
3
3
  apply plugin: 'maven-publish'
4
4
 
5
5
  group = 'host.exp.exponent'
6
- version = '13.2.0'
6
+ version = '13.3.1'
7
7
 
8
8
  buildscript {
9
9
  def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
@@ -74,7 +74,7 @@ android {
74
74
  minSdkVersion safeExtGet("minSdkVersion", 21)
75
75
  targetSdkVersion safeExtGet("targetSdkVersion", 31)
76
76
  versionCode 22
77
- versionName "13.2.0"
77
+ versionName "13.3.1"
78
78
  }
79
79
  lintOptions {
80
80
  abortOnError false
@@ -84,9 +84,11 @@ android {
84
84
  dependencies {
85
85
  implementation project(':expo-modules-core')
86
86
 
87
- implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:${getKotlinVersion()}"
88
- implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.3")
89
- implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.3")
90
- implementation "com.github.CanHub:Android-Image-Cropper:1.1.1"
87
+ implementation "androidx.activity:activity-ktx:1.4.0"
88
+ implementation "androidx.appcompat:appcompat:1.4.2"
91
89
  implementation "androidx.exifinterface:exifinterface:1.3.3"
90
+ implementation "com.github.CanHub:Android-Image-Cropper:4.3.0"
91
+ implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:${getKotlinVersion()}"
92
+ implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.3"
93
+ implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.3"
92
94
  }
@@ -1,5 +1,3 @@
1
- @file:OptIn(ExperimentalContracts::class)
2
-
3
1
  package expo.modules.imagepicker
4
2
 
5
3
  import android.Manifest
@@ -10,6 +8,8 @@ import expo.modules.core.errors.ModuleNotFoundException
10
8
  import android.os.OperationCanceledException
11
9
  import expo.modules.imagepicker.contracts.CameraContract
12
10
  import expo.modules.imagepicker.contracts.CameraContractOptions
11
+ import expo.modules.imagepicker.contracts.CropImageContract
12
+ import expo.modules.imagepicker.contracts.CropImageContractOptions
13
13
  import expo.modules.imagepicker.contracts.ImageLibraryContract
14
14
  import expo.modules.imagepicker.contracts.ImageLibraryContractOptions
15
15
  import expo.modules.imagepicker.contracts.ImagePickerContractResult
@@ -24,7 +24,6 @@ import kotlinx.coroutines.Dispatchers
24
24
  import kotlinx.coroutines.launch
25
25
  import kotlinx.coroutines.suspendCancellableCoroutine
26
26
  import kotlinx.coroutines.withContext
27
- import kotlin.contracts.ExperimentalContracts
28
27
  import kotlin.coroutines.resume
29
28
  import kotlin.coroutines.resumeWithException
30
29
 
@@ -71,7 +70,7 @@ class ImagePickerModule : Module() {
71
70
  launchContract({ imageLibraryLauncher.launch(contractOptions) }, options)
72
71
  }
73
72
 
74
- AsyncFunction("getPendingResultAsync") Coroutine { _: Promise ->
73
+ AsyncFunction("getPendingResultAsync") Coroutine { ->
75
74
  val (bareResult, options) = pendingMediaPickingResult ?: return@Coroutine null
76
75
 
77
76
  pendingMediaPickingResult = null
@@ -90,6 +89,9 @@ class ImagePickerModule : Module() {
90
89
  imageLibraryLauncher = appContext.registerForActivityResult(
91
90
  ImageLibraryContract(this@ImagePickerModule),
92
91
  ) { input, result -> handleResultUponActivityDestruction(result, input.options) }
92
+ cropImageLauncher = appContext.registerForActivityResult(
93
+ CropImageContract(this@ImagePickerModule),
94
+ ) { input, result -> handleResultUponActivityDestruction(result, input.options) }
93
95
  }
94
96
  }
95
97
  }
@@ -106,6 +108,7 @@ class ImagePickerModule : Module() {
106
108
 
107
109
  private lateinit var cameraLauncher: AppContextActivityResultLauncher<CameraContractOptions, ImagePickerContractResult>
108
110
  private lateinit var imageLibraryLauncher: AppContextActivityResultLauncher<ImageLibraryContractOptions, ImagePickerContractResult>
111
+ private lateinit var cropImageLauncher: AppContextActivityResultLauncher<CropImageContractOptions, ImagePickerContractResult>
109
112
 
110
113
  /**
111
114
  * Stores result for an operation that has been interrupted by the activity destruction.
@@ -123,8 +126,18 @@ class ImagePickerModule : Module() {
123
126
  options: ImagePickerOptions,
124
127
  ): Any {
125
128
  return try {
126
- val bareResult = launchPicker(pickerLauncher)
127
- mediaHandler.readExtras(bareResult, options)
129
+ var result = launchPicker(pickerLauncher)
130
+ if (
131
+ !options.allowsMultipleSelection &&
132
+ options.allowsEditing &&
133
+ result.data.size == 1 &&
134
+ result.data[0].first == MediaType.IMAGE
135
+ ) {
136
+ result = launchPicker {
137
+ cropImageLauncher.launch(CropImageContractOptions(result.data[0].second, options))
138
+ }
139
+ }
140
+ mediaHandler.readExtras(result.data, options)
128
141
  } catch (cause: OperationCanceledException) {
129
142
  ImagePickerCancelledResponse()
130
143
  }
@@ -145,9 +158,9 @@ class ImagePickerModule : Module() {
145
158
  */
146
159
  private suspend fun launchPicker(
147
160
  pickerLauncher: suspend () -> ImagePickerContractResult,
148
- ): Pair<MediaType, Uri> = withContext(Dispatchers.Main) {
161
+ ): ImagePickerContractResult.Success = withContext(Dispatchers.Main) {
149
162
  when (val pickingResult = pickerLauncher()) {
150
- is ImagePickerContractResult.Success -> pickingResult.data
163
+ is ImagePickerContractResult.Success -> pickingResult
151
164
  is ImagePickerContractResult.Cancelled -> throw OperationCanceledException()
152
165
  }
153
166
  }
@@ -191,15 +204,10 @@ class ImagePickerModule : Module() {
191
204
  // endregion
192
205
  }
193
206
 
194
- internal enum class PickingSource {
195
- CAMERA,
196
- IMAGE_LIBRARY
197
- }
198
-
199
207
  /**
200
208
  * Simple data structure to hold the data that has to be preserved after the Activity is destroyed.
201
209
  */
202
210
  internal data class PendingMediaPickingResult(
203
- val data: Pair<MediaType, Uri>,
211
+ val data: List<Pair<MediaType, Uri>>,
204
212
  val options: ImagePickerOptions
205
213
  )
@@ -14,6 +14,9 @@ internal class ImagePickerOptions : Record, Serializable {
14
14
  @Field
15
15
  var allowsEditing: Boolean = false
16
16
 
17
+ @Field
18
+ var allowsMultipleSelection: Boolean = false
19
+
17
20
  @Field
18
21
  @FloatRange(from = 0.0, to = 1.0)
19
22
  var quality: Double = 0.2
@@ -9,32 +9,42 @@ internal class ImagePickerCancelledResponse : Record {
9
9
  val cancelled: Boolean = true
10
10
  }
11
11
 
12
- internal sealed class ImagePickerMediaResponse(
13
- @Field val type: MediaType,
14
- @Field val uri: String,
15
- @Field val width: Int,
16
- @Field val height: Int,
17
- ) : Record {
12
+ internal sealed class ImagePickerResponse : Record {
18
13
  @Field
19
14
  val cancelled: Boolean = false
20
15
 
21
- class Image(
22
- uri: String,
23
- width: Int,
24
- height: Int,
25
-
26
- @Field val base64: String?,
27
- @Field val exif: Bundle?
28
- ) : ImagePickerMediaResponse(MediaType.IMAGE, uri, width, height)
29
-
30
- class Video(
31
- uri: String,
32
- width: Int,
33
- height: Int,
34
-
35
- @Field val duration: Int,
36
- @Field val rotation: Int
37
- ) : ImagePickerMediaResponse(MediaType.VIDEO, uri, width, height)
16
+ sealed class Single(
17
+ @Field val type: MediaType,
18
+ @Field val uri: String,
19
+ @Field val width: Int,
20
+ @Field val height: Int,
21
+ @Field val assetId: String?,
22
+ ) : ImagePickerResponse() {
23
+ class Image(
24
+ uri: String,
25
+ width: Int,
26
+ height: Int,
27
+ assetId: String?,
28
+
29
+ @Field val base64: String?,
30
+ @Field val exif: Bundle?
31
+ ) : Single(MediaType.IMAGE, uri, width, height, assetId)
32
+
33
+ class Video(
34
+ uri: String,
35
+ width: Int,
36
+ height: Int,
37
+ assetId: String?,
38
+
39
+ @Field val duration: Int,
40
+ @Field val rotation: Int
41
+ ) : Single(MediaType.VIDEO, uri, width, height, assetId)
42
+ }
43
+
44
+ class Multiple(
45
+ @Field
46
+ val selected: List<Single>
47
+ ) : ImagePickerResponse()
38
48
  }
39
49
 
40
50
  enum class MediaType(val value: String) {
@@ -1,10 +1,13 @@
1
1
  package expo.modules.imagepicker
2
2
 
3
+ import android.content.ClipData
3
4
  import android.content.ContentResolver
4
5
  import android.content.Context
6
+ import android.content.Intent
5
7
  import android.graphics.Bitmap
6
8
  import android.media.MediaMetadataRetriever
7
9
  import android.net.Uri
10
+ import android.provider.DocumentsContract
8
11
  import android.util.Log
9
12
  import android.webkit.MimeTypeMap
10
13
  import androidx.core.content.FileProvider
@@ -100,6 +103,42 @@ internal fun MediaMetadataRetriever.extractInt(key: Int): Int {
100
103
  return this.extractMetadata(key)?.toInt() ?: throw FailedToExtractVideoMetadataException()
101
104
  }
102
105
 
106
+ /**
107
+ * [Iterable] implementation for [ClipData] items
108
+ */
109
+ val ClipData.items: Iterable<ClipData.Item>
110
+ get() = object : Iterable<ClipData.Item> {
111
+ override fun iterator() = object : Iterator<ClipData.Item> {
112
+ var index = 0
113
+ val count = itemCount
114
+
115
+ override fun hasNext(): Boolean = index < count
116
+
117
+ override fun next(): ClipData.Item = getItemAt(index++)
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Gets all data that is associated with this [Intent].
123
+ * Original data order is preserved.
124
+ *
125
+ * Adapted from [androidx.activity.result.contract.ActivityResultContracts.GetMultipleContents.getClipDataUris]
126
+ */
127
+ internal fun Intent.getAllDataUris(): List<Uri> {
128
+ // Use a LinkedHashSet to maintain any ordering that may be present in the ClipData
129
+ val resultSet = LinkedHashSet<Uri>()
130
+
131
+ data
132
+ ?.let { resultSet.add(it) }
133
+
134
+ clipData
135
+ ?.items
136
+ ?.map { it.uri }
137
+ ?.let { resultSet.addAll(it) }
138
+
139
+ return resultSet.toList()
140
+ }
141
+
103
142
  /**
104
143
  * Copy the media file from `sourceUri` to `destinationUri`.
105
144
  *
@@ -168,3 +207,72 @@ internal suspend fun copyExifData(
168
207
  throw FailedToWriteFileException(targetFile, cause)
169
208
  }
170
209
  }
210
+
211
+ /*
212
+ Getting asset ID (and metadata) on Android is not that obvious. When getting a `content://` URI
213
+ using `ACTION_GET_CONTENT` or `ACTION_OPEN_DOCUMENT` intents, there are 3 possible ways:
214
+
215
+ 1. When the user selects a photo from **Images** section of the picker (on the left drawer)
216
+ In this case we get a URI from `com.android.providers.media.MediaDocumentsProvider`,
217
+ that inherits from `DocumentsProvider`. The URI looks like this:
218
+ ```
219
+ com.android.providers.media.documents/document/image:56
220
+ ```
221
+ In this case, the `56` is the ID we're looking for.
222
+
223
+ 2. When the user selects a photo from **Downloads** section, another content provider is used:
224
+ `DownloadStorageProvider` which is a bit different, and also differs depending on Android version:
225
+ - On API 29+ it also inherits from `com.android.providers.downloads.DocumentsProvider`
226
+ and the URI looks like this:
227
+ ```
228
+ com.android.providers.downloads.documents/document/msf:56
229
+ ```
230
+ Where "msf" is abbr. of "media store file" and 56 is our asset ID
231
+ - On API <29 it looks similar:
232
+ ```
233
+ com.android.providers.downloads.documents/document/128
234
+ ```
235
+ but the 128 is an internal ID of downloads provider, unrelated to media store asset ID.
236
+
237
+ 3. When the user selects a photo by browsing the filesystem, the URI looks like this:
238
+ ```
239
+ com.android.externalstorage.documents/document/primary:Download:filename.jpg
240
+ ```
241
+ No ID in this case
242
+ */
243
+
244
+ /**
245
+ * Checks whether this [Uri] is a `com.android.providers.media.documents` provider uri
246
+ */
247
+ internal val Uri.isMediaProviderUri
248
+ get() = this.authority == "com.android.providers.media.documents"
249
+
250
+ /**
251
+ * Checks whether this [Uri] is a `com.android.providers.downloads.documents` provider uri
252
+ */
253
+ internal val Uri.isDownloadsProviderUri
254
+ get() = this.authority == "com.android.providers.downloads.documents"
255
+
256
+ /**
257
+ * Checks whether asset represented by this [Uri] can be queried in the media store
258
+ */
259
+ internal val Uri.isMediaStoreAssetUri
260
+ get() = isMediaProviderUri || (
261
+ isDownloadsProviderUri &&
262
+ DocumentsContract
263
+ .getDocumentId(this)
264
+ .startsWith("msf:")
265
+ )
266
+
267
+ /**
268
+ * If the URI represents a media store asset, this returns its ID. Otherwise, returns `null`.
269
+ *
270
+ * See the detailed explanation above in this file (ImagePickerUtils.kt).
271
+ */
272
+ internal fun Uri.getMediaStoreAssetId(): String? {
273
+ if (isMediaStoreAssetUri) {
274
+ val rawId = DocumentsContract.getDocumentId(this)
275
+ return if (rawId.contains(':')) rawId.split(':')[1] else rawId
276
+ }
277
+ return null
278
+ }
@@ -17,17 +17,27 @@ internal class MediaHandler(
17
17
  get() = requireNotNull(appContextProvider.appContext.reactContext) { "React Application Context is null" }
18
18
 
19
19
  internal suspend fun readExtras(
20
- bareResult: Pair<MediaType, Uri>,
20
+ bareResult: List<Pair<MediaType, Uri>>,
21
21
  options: ImagePickerOptions,
22
- ): ImagePickerMediaResponse = when (bareResult.first) {
23
- MediaType.VIDEO -> handleVideo(bareResult.second)
24
- MediaType.IMAGE -> handleImage(bareResult.second, options)
22
+ ): ImagePickerResponse {
23
+ val results = bareResult.map { (mediaType, uri) ->
24
+ when (mediaType) {
25
+ MediaType.VIDEO -> handleVideo(uri)
26
+ MediaType.IMAGE -> handleImage(uri, options)
27
+ }
28
+ }
29
+
30
+ return if (results.size == 1) {
31
+ results[0]
32
+ } else {
33
+ ImagePickerResponse.Multiple(results)
34
+ }
25
35
  }
26
36
 
27
37
  private suspend fun handleImage(
28
38
  sourceUri: Uri,
29
39
  options: ImagePickerOptions,
30
- ): ImagePickerMediaResponse.Image {
40
+ ): ImagePickerResponse.Single.Image {
31
41
  val exporter: ImageExporter = if (options.quality == ImagePickerConstants.MAXIMUM_QUALITY) {
32
42
  RawImageExporter()
33
43
  } else {
@@ -43,18 +53,19 @@ internal class MediaHandler(
43
53
  val exif = options.exif.takeIf { it }
44
54
  ?.let { exportedImage.exif(context.contentResolver) }
45
55
 
46
- return ImagePickerMediaResponse.Image(
56
+ return ImagePickerResponse.Single.Image(
47
57
  uri = Uri.fromFile(outputFile).toString(),
48
58
  width = exportedImage.width,
49
59
  height = exportedImage.height,
50
60
  base64 = base64,
51
61
  exif = exif,
62
+ assetId = sourceUri.getMediaStoreAssetId()
52
63
  )
53
64
  }
54
65
 
55
66
  private suspend fun handleVideo(
56
67
  sourceUri: Uri,
57
- ): ImagePickerMediaResponse.Video {
68
+ ): ImagePickerResponse.Single.Video {
58
69
  val outputFile = createOutputFile(context.cacheDir, ".mp4")
59
70
  copyFile(sourceUri, outputFile, context.contentResolver)
60
71
  val outputUri = outputFile.toUri()
@@ -64,12 +75,13 @@ internal class MediaHandler(
64
75
  setDataSource(context, outputUri)
65
76
  }
66
77
 
67
- ImagePickerMediaResponse.Video(
78
+ ImagePickerResponse.Single.Video(
68
79
  uri = outputUri.toString(),
69
80
  width = metadataRetriever.extractInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH),
70
81
  height = metadataRetriever.extractInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT),
71
82
  duration = metadataRetriever.extractInt(MediaMetadataRetriever.METADATA_KEY_DURATION),
72
83
  rotation = metadataRetriever.extractInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION),
84
+ assetId = sourceUri.getMediaStoreAssetId()
73
85
  )
74
86
  } catch (cause: FailedToExtractVideoMetadataException) {
75
87
  throw FailedToExtractVideoMetadataException(outputFile, cause)
@@ -1,6 +1,7 @@
1
1
  package expo.modules.imagepicker.contracts
2
2
 
3
3
  import android.app.Activity
4
+ import android.content.ContentResolver
4
5
  import android.content.Context
5
6
  import android.content.Intent
6
7
  import android.net.Uri
@@ -21,6 +22,11 @@ import java.io.Serializable
21
22
  internal class CameraContract(
22
23
  private val appContextProvider: AppContextProvider,
23
24
  ) : AppContextActivityResultContract<CameraContractOptions, ImagePickerContractResult> {
25
+ val contentResolver: ContentResolver
26
+ get() = requireNotNull(appContextProvider.appContext.reactContext) {
27
+ "React Application Context is null"
28
+ }.contentResolver
29
+
24
30
  override fun createIntent(context: Context, input: CameraContractOptions): Intent =
25
31
  Intent(input.options.mediaTypes.toCameraIntentAction())
26
32
  .putExtra(MediaStore.EXTRA_OUTPUT, input.uri)
@@ -34,14 +40,16 @@ internal class CameraContract(
34
40
  if (resultCode == Activity.RESULT_CANCELED) {
35
41
  ImagePickerContractResult.Cancelled()
36
42
  } else {
37
- val contentResolver = requireNotNull(appContextProvider.appContext.reactContext) { "React Application Context is null. " }.contentResolver
38
43
  val uri = input.uri
39
44
  val type = uri.toMediaType(contentResolver)
40
- ImagePickerContractResult.Success(type to uri)
45
+ ImagePickerContractResult.Success(listOf(type to uri))
41
46
  }
42
47
  }
43
48
 
44
49
  internal data class CameraContractOptions(
50
+ /**
51
+ * Destination file in a form of content-[Uri] to save results coming from camera to.
52
+ */
45
53
  val uri: Uri,
46
54
  val options: ImagePickerOptions,
47
55
  ) : Serializable
@@ -1,18 +1,12 @@
1
1
  package expo.modules.imagepicker.contracts
2
2
 
3
3
  import android.net.Uri
4
- import androidx.activity.result.contract.ActivityResultContract
5
4
  import expo.modules.imagepicker.MediaType
6
5
 
7
- /**
8
- * Alias for [androidx.activity.result.contract.ActivityResultContract] with required generic types already specified
9
- */
10
- internal typealias ImagePickerContract = ActivityResultContract<Any?, ImagePickerContractResult>
11
-
12
6
  /**
13
7
  * Data required to be returned upon successful contract completion
14
8
  */
15
9
  internal sealed class ImagePickerContractResult private constructor() {
16
10
  class Cancelled : ImagePickerContractResult()
17
- class Success(val data: Pair<MediaType, Uri>) : ImagePickerContractResult()
11
+ class Success(val data: List<Pair<MediaType, Uri>>) : ImagePickerContractResult()
18
12
  }
@@ -12,44 +12,34 @@ import com.canhub.cropper.CropImageActivity
12
12
  import com.canhub.cropper.CropImageOptions
13
13
  import expo.modules.imagepicker.ImagePickerOptions
14
14
  import expo.modules.imagepicker.MediaType
15
- import expo.modules.imagepicker.PickingSource
16
15
  import expo.modules.imagepicker.copyExifData
17
16
  import expo.modules.imagepicker.createOutputFile
18
17
  import expo.modules.imagepicker.toBitmapCompressFormat
19
18
  import expo.modules.imagepicker.toImageFileExtension
19
+ import expo.modules.kotlin.activityresult.AppContextActivityResultContract
20
20
  import expo.modules.kotlin.providers.AppContextProvider
21
21
  import kotlinx.coroutines.runBlocking
22
+ import java.io.Serializable
22
23
 
23
24
  internal class CropImageContract(
24
25
  private val appContextProvider: AppContextProvider,
25
- private val sourceUri: Uri,
26
- private val options: ImagePickerOptions,
27
- private val pickingSource: PickingSource
28
- ) : ImagePickerContract() {
29
- override fun createIntent(context: Context, input: Any?) = Intent(context, CropImageActivity::class.java).apply {
30
- // for [IMAGE_LIBRARY] we need to create a new file as up to this point we've been operating on the original media asset
31
- // for [CAMERA] we do not have to do it as it's already been created at the beginning of the picking process
32
- val needCreateNewFile = pickingSource == PickingSource.IMAGE_LIBRARY
33
- val mediaType = expo.modules.imagepicker.getType(context.contentResolver, sourceUri)
34
-
26
+ ) : AppContextActivityResultContract<CropImageContractOptions, ImagePickerContractResult> {
27
+ override fun createIntent(context: Context, input: CropImageContractOptions) = Intent(context, CropImageActivity::class.java).apply {
28
+ val mediaType = expo.modules.imagepicker.getType(context.contentResolver, input.sourceUri)
35
29
  val compressFormat = mediaType.toBitmapCompressFormat()
36
-
37
- val outputUri: Uri = if (needCreateNewFile) {
38
- createOutputFile(context.cacheDir, compressFormat.toImageFileExtension()).toUri()
39
- } else {
40
- sourceUri
41
- }
30
+ val outputUri = createOutputFile(context.cacheDir, compressFormat.toImageFileExtension()).toUri()
42
31
 
43
32
  putExtra(
44
33
  CropImage.CROP_IMAGE_EXTRA_BUNDLE,
45
34
  bundleOf(
46
- CropImage.CROP_IMAGE_EXTRA_SOURCE to sourceUri,
35
+ CropImage.CROP_IMAGE_EXTRA_SOURCE to input.sourceUri,
47
36
  CropImage.CROP_IMAGE_EXTRA_OPTIONS to CropImageOptions().apply {
48
37
  outputCompressFormat = compressFormat
49
- outputCompressQuality = (this@CropImageContract.options.quality * 100).toInt()
50
- this.outputUri = outputUri
38
+ outputCompressQuality = (input.options.quality * 100).toInt()
51
39
 
52
- this@CropImageContract.options.aspect?.let { (x, y) ->
40
+ this.customOutputUri = outputUri
41
+
42
+ input.options.aspect?.let { (x, y) ->
53
43
  aspectRatioX = x
54
44
  aspectRatioY = y
55
45
  fixAspectRatio = true
@@ -62,14 +52,19 @@ internal class CropImageContract(
62
52
  )
63
53
  }
64
54
 
65
- override fun parseResult(resultCode: Int, intent: Intent?): ImagePickerContractResult {
55
+ override fun parseResult(input: CropImageContractOptions, resultCode: Int, intent: Intent?): ImagePickerContractResult {
66
56
  val result = intent?.getParcelableExtra<CropImage.ActivityResult?>(CropImage.CROP_IMAGE_EXTRA_RESULT)
67
57
  if (resultCode == Activity.RESULT_CANCELED || result == null) {
68
58
  return ImagePickerContractResult.Cancelled()
69
59
  }
70
- val targetUri = requireNotNull(result.uri)
60
+ val targetUri = requireNotNull(result.uriContent)
71
61
  val contentResolver = requireNotNull(appContextProvider.appContext.reactContext) { "React Application Context is null" }.contentResolver
72
- runBlocking { copyExifData(sourceUri, targetUri.toFile(), contentResolver) }
73
- return ImagePickerContractResult.Success(MediaType.IMAGE to targetUri)
62
+ runBlocking { copyExifData(input.sourceUri, targetUri.toFile(), contentResolver) }
63
+ return ImagePickerContractResult.Success(listOf(MediaType.IMAGE to targetUri))
74
64
  }
75
65
  }
66
+
67
+ internal data class CropImageContractOptions(
68
+ val sourceUri: Uri,
69
+ val options: ImagePickerOptions,
70
+ ) : Serializable
@@ -1,10 +1,12 @@
1
1
  package expo.modules.imagepicker.contracts
2
2
 
3
3
  import android.app.Activity
4
+ import android.content.ContentResolver
4
5
  import android.content.Context
5
6
  import android.content.Intent
6
7
  import android.net.Uri
7
8
  import expo.modules.imagepicker.ImagePickerOptions
9
+ import expo.modules.imagepicker.getAllDataUris
8
10
  import expo.modules.imagepicker.toMediaType
9
11
  import expo.modules.kotlin.activityresult.AppContextActivityResultContract
10
12
  import expo.modules.kotlin.providers.AppContextProvider
@@ -14,25 +16,37 @@ import java.io.Serializable
14
16
  * An [androidx.activity.result.contract.ActivityResultContract] to prompt the user to pick single or multiple image(s) or/and video(s),
15
17
  * receiving a `content://` [Uri] for each piece of content.
16
18
  *
17
- * @see [androidx.activity.result.contract.ActivityResultContracts.GetContent]
19
+ * @see [androidx.activity.result.contract.ActivityResultContracts.GetContent],
20
+ * @see [androidx.activity.result.contract.ActivityResultContracts.GetMultipleContents]
18
21
  */
19
22
  internal class ImageLibraryContract(
20
23
  private val appContextProvider: AppContextProvider,
21
24
  ) : AppContextActivityResultContract<ImageLibraryContractOptions, ImagePickerContractResult> {
25
+ val contentResolver: ContentResolver
26
+ get() = requireNotNull(appContextProvider.appContext.reactContext) {
27
+ "React Application Context is null"
28
+ }.contentResolver
29
+
22
30
  override fun createIntent(context: Context, input: ImageLibraryContractOptions) =
23
31
  Intent(Intent.ACTION_GET_CONTENT)
24
32
  .addCategory(Intent.CATEGORY_OPENABLE)
25
33
  .setType(input.options.mediaTypes.toMimeType())
34
+ .apply {
35
+ if (input.options.allowsMultipleSelection) {
36
+ putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
37
+ }
38
+ }
26
39
 
27
40
  override fun parseResult(input: ImageLibraryContractOptions, resultCode: Int, intent: Intent?) =
28
41
  if (resultCode == Activity.RESULT_CANCELED) {
29
42
  ImagePickerContractResult.Cancelled()
43
+ } else if (input.options.allowsMultipleSelection) {
44
+ val uris = requireNotNull(intent).getAllDataUris()
45
+ ImagePickerContractResult.Success(uris.map { uri -> uri.toMediaType(contentResolver) to uri })
30
46
  } else {
31
47
  val uri = requireNotNull(requireNotNull(intent).data)
32
- val contentResolver = requireNotNull(appContextProvider.appContext.reactContext) { "React Application Context is null. " }.contentResolver
33
48
  val type = uri.toMediaType(contentResolver)
34
-
35
- ImagePickerContractResult.Success(type to uri)
49
+ ImagePickerContractResult.Success(listOf(type to uri))
36
50
  }
37
51
  }
38
52
 
@@ -68,7 +68,7 @@ export declare function getPendingResultAsync(): Promise<(ImagePickerResult | Im
68
68
  /**
69
69
  * Display the system UI for taking a photo with the camera. Requires `Permissions.CAMERA`.
70
70
  * On Android and iOS 10 `Permissions.CAMERA_ROLL` is also required. On mobile web, this must be
71
- * called immediately in a user interaction like a button press, otherwise the browser will bloc
71
+ * called immediately in a user interaction like a button press, otherwise the browser will block
72
72
  * the request without a warning.
73
73
  * > **Note:** Make sure that you handle `MainActivity` destruction on **Android**. See [ImagePicker.getPendingResultAsync](#imagepickergetpendingresultasync).
74
74
  * > **Notes for Web:** The system UI can only be shown after user activation (e.g. a `Button` press).
@@ -118,7 +118,7 @@ export async function getPendingResultAsync() {
118
118
  /**
119
119
  * Display the system UI for taking a photo with the camera. Requires `Permissions.CAMERA`.
120
120
  * On Android and iOS 10 `Permissions.CAMERA_ROLL` is also required. On mobile web, this must be
121
- * called immediately in a user interaction like a button press, otherwise the browser will bloc
121
+ * called immediately in a user interaction like a button press, otherwise the browser will block
122
122
  * the request without a warning.
123
123
  * > **Note:** Make sure that you handle `MainActivity` destruction on **Android**. See [ImagePicker.getPendingResultAsync](#imagepickergetpendingresultasync).
124
124
  * > **Notes for Web:** The system UI can only be shown after user activation (e.g. a `Button` press).