expo-media-library 18.0.0-canary-20250729-d8899ae → 18.0.0

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.
Files changed (66) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/android/build.gradle +3 -2
  3. package/android/src/main/java/expo/modules/medialibrary/Exceptions.kt +12 -0
  4. package/android/src/main/java/expo/modules/medialibrary/MediaLibraryModule.kt +124 -234
  5. package/android/src/main/java/expo/modules/medialibrary/MediaLibraryUtils.kt +26 -12
  6. package/android/src/main/java/expo/modules/medialibrary/albums/AddAssetsToAlbum.kt +35 -31
  7. package/android/src/main/java/expo/modules/medialibrary/albums/AlbumUtils.kt +22 -22
  8. package/android/src/main/java/expo/modules/medialibrary/albums/CreateAlbum.kt +45 -67
  9. package/android/src/main/java/expo/modules/medialibrary/albums/DeleteAlbums.kt +5 -26
  10. package/android/src/main/java/expo/modules/medialibrary/albums/GetAlbum.kt +6 -12
  11. package/android/src/main/java/expo/modules/medialibrary/albums/GetAlbums.kt +51 -56
  12. package/android/src/main/java/expo/modules/medialibrary/albums/RemoveAssetsFromAlbum.kt +4 -13
  13. package/android/src/main/java/expo/modules/medialibrary/albums/migration/CheckIfAlbumShouldBeMigrated.kt +16 -16
  14. package/android/src/main/java/expo/modules/medialibrary/albums/migration/MigrateAlbum.kt +32 -35
  15. package/android/src/main/java/expo/modules/medialibrary/assets/AssetUtils.kt +18 -21
  16. package/android/src/main/java/expo/modules/medialibrary/assets/CreateAsset.kt +40 -51
  17. package/android/src/main/java/expo/modules/medialibrary/assets/DeleteAssets.kt +4 -11
  18. package/android/src/main/java/expo/modules/medialibrary/assets/GetAssetInfo.kt +5 -12
  19. package/android/src/main/java/expo/modules/medialibrary/assets/GetAssets.kt +40 -52
  20. package/android/src/main/java/expo/modules/medialibrary/contracts/DeleteContract.kt +38 -0
  21. package/android/src/main/java/expo/modules/medialibrary/contracts/WriteContract.kt +38 -0
  22. package/expo-module.config.json +1 -1
  23. package/local-maven-repo/host/exp/exponent/expo.modules.medialibrary/18.0.0/expo.modules.medialibrary-18.0.0-sources.jar +0 -0
  24. package/local-maven-repo/host/exp/exponent/expo.modules.medialibrary/18.0.0/expo.modules.medialibrary-18.0.0-sources.jar.md5 +1 -0
  25. package/local-maven-repo/host/exp/exponent/expo.modules.medialibrary/18.0.0/expo.modules.medialibrary-18.0.0-sources.jar.sha1 +1 -0
  26. package/local-maven-repo/host/exp/exponent/expo.modules.medialibrary/18.0.0/expo.modules.medialibrary-18.0.0-sources.jar.sha256 +1 -0
  27. package/local-maven-repo/host/exp/exponent/expo.modules.medialibrary/18.0.0/expo.modules.medialibrary-18.0.0-sources.jar.sha512 +1 -0
  28. package/local-maven-repo/host/exp/exponent/expo.modules.medialibrary/18.0.0/expo.modules.medialibrary-18.0.0.aar +0 -0
  29. package/local-maven-repo/host/exp/exponent/expo.modules.medialibrary/18.0.0/expo.modules.medialibrary-18.0.0.aar.md5 +1 -0
  30. package/local-maven-repo/host/exp/exponent/expo.modules.medialibrary/18.0.0/expo.modules.medialibrary-18.0.0.aar.sha1 +1 -0
  31. package/local-maven-repo/host/exp/exponent/expo.modules.medialibrary/18.0.0/expo.modules.medialibrary-18.0.0.aar.sha256 +1 -0
  32. package/local-maven-repo/host/exp/exponent/expo.modules.medialibrary/18.0.0/expo.modules.medialibrary-18.0.0.aar.sha512 +1 -0
  33. package/local-maven-repo/host/exp/exponent/expo.modules.medialibrary/{18.0.0-canary-20250729-d8899ae/expo.modules.medialibrary-18.0.0-canary-20250729-d8899ae.module → 18.0.0/expo.modules.medialibrary-18.0.0.module} +30 -23
  34. package/local-maven-repo/host/exp/exponent/expo.modules.medialibrary/18.0.0/expo.modules.medialibrary-18.0.0.module.md5 +1 -0
  35. package/local-maven-repo/host/exp/exponent/expo.modules.medialibrary/18.0.0/expo.modules.medialibrary-18.0.0.module.sha1 +1 -0
  36. package/local-maven-repo/host/exp/exponent/expo.modules.medialibrary/18.0.0/expo.modules.medialibrary-18.0.0.module.sha256 +1 -0
  37. package/local-maven-repo/host/exp/exponent/expo.modules.medialibrary/18.0.0/expo.modules.medialibrary-18.0.0.module.sha512 +1 -0
  38. package/local-maven-repo/host/exp/exponent/expo.modules.medialibrary/{18.0.0-canary-20250729-d8899ae/expo.modules.medialibrary-18.0.0-canary-20250729-d8899ae.pom → 18.0.0/expo.modules.medialibrary-18.0.0.pom} +7 -1
  39. package/local-maven-repo/host/exp/exponent/expo.modules.medialibrary/18.0.0/expo.modules.medialibrary-18.0.0.pom.md5 +1 -0
  40. package/local-maven-repo/host/exp/exponent/expo.modules.medialibrary/18.0.0/expo.modules.medialibrary-18.0.0.pom.sha1 +1 -0
  41. package/local-maven-repo/host/exp/exponent/expo.modules.medialibrary/18.0.0/expo.modules.medialibrary-18.0.0.pom.sha256 +1 -0
  42. package/local-maven-repo/host/exp/exponent/expo.modules.medialibrary/18.0.0/expo.modules.medialibrary-18.0.0.pom.sha512 +1 -0
  43. package/local-maven-repo/host/exp/exponent/expo.modules.medialibrary/maven-metadata.xml +4 -4
  44. package/local-maven-repo/host/exp/exponent/expo.modules.medialibrary/maven-metadata.xml.md5 +1 -1
  45. package/local-maven-repo/host/exp/exponent/expo.modules.medialibrary/maven-metadata.xml.sha1 +1 -1
  46. package/local-maven-repo/host/exp/exponent/expo.modules.medialibrary/maven-metadata.xml.sha256 +1 -1
  47. package/local-maven-repo/host/exp/exponent/expo.modules.medialibrary/maven-metadata.xml.sha512 +1 -1
  48. package/package.json +5 -4
  49. package/local-maven-repo/host/exp/exponent/expo.modules.medialibrary/18.0.0-canary-20250729-d8899ae/expo.modules.medialibrary-18.0.0-canary-20250729-d8899ae-sources.jar +0 -0
  50. package/local-maven-repo/host/exp/exponent/expo.modules.medialibrary/18.0.0-canary-20250729-d8899ae/expo.modules.medialibrary-18.0.0-canary-20250729-d8899ae-sources.jar.md5 +0 -1
  51. package/local-maven-repo/host/exp/exponent/expo.modules.medialibrary/18.0.0-canary-20250729-d8899ae/expo.modules.medialibrary-18.0.0-canary-20250729-d8899ae-sources.jar.sha1 +0 -1
  52. package/local-maven-repo/host/exp/exponent/expo.modules.medialibrary/18.0.0-canary-20250729-d8899ae/expo.modules.medialibrary-18.0.0-canary-20250729-d8899ae-sources.jar.sha256 +0 -1
  53. package/local-maven-repo/host/exp/exponent/expo.modules.medialibrary/18.0.0-canary-20250729-d8899ae/expo.modules.medialibrary-18.0.0-canary-20250729-d8899ae-sources.jar.sha512 +0 -1
  54. package/local-maven-repo/host/exp/exponent/expo.modules.medialibrary/18.0.0-canary-20250729-d8899ae/expo.modules.medialibrary-18.0.0-canary-20250729-d8899ae.aar +0 -0
  55. package/local-maven-repo/host/exp/exponent/expo.modules.medialibrary/18.0.0-canary-20250729-d8899ae/expo.modules.medialibrary-18.0.0-canary-20250729-d8899ae.aar.md5 +0 -1
  56. package/local-maven-repo/host/exp/exponent/expo.modules.medialibrary/18.0.0-canary-20250729-d8899ae/expo.modules.medialibrary-18.0.0-canary-20250729-d8899ae.aar.sha1 +0 -1
  57. package/local-maven-repo/host/exp/exponent/expo.modules.medialibrary/18.0.0-canary-20250729-d8899ae/expo.modules.medialibrary-18.0.0-canary-20250729-d8899ae.aar.sha256 +0 -1
  58. package/local-maven-repo/host/exp/exponent/expo.modules.medialibrary/18.0.0-canary-20250729-d8899ae/expo.modules.medialibrary-18.0.0-canary-20250729-d8899ae.aar.sha512 +0 -1
  59. package/local-maven-repo/host/exp/exponent/expo.modules.medialibrary/18.0.0-canary-20250729-d8899ae/expo.modules.medialibrary-18.0.0-canary-20250729-d8899ae.module.md5 +0 -1
  60. package/local-maven-repo/host/exp/exponent/expo.modules.medialibrary/18.0.0-canary-20250729-d8899ae/expo.modules.medialibrary-18.0.0-canary-20250729-d8899ae.module.sha1 +0 -1
  61. package/local-maven-repo/host/exp/exponent/expo.modules.medialibrary/18.0.0-canary-20250729-d8899ae/expo.modules.medialibrary-18.0.0-canary-20250729-d8899ae.module.sha256 +0 -1
  62. package/local-maven-repo/host/exp/exponent/expo.modules.medialibrary/18.0.0-canary-20250729-d8899ae/expo.modules.medialibrary-18.0.0-canary-20250729-d8899ae.module.sha512 +0 -1
  63. package/local-maven-repo/host/exp/exponent/expo.modules.medialibrary/18.0.0-canary-20250729-d8899ae/expo.modules.medialibrary-18.0.0-canary-20250729-d8899ae.pom.md5 +0 -1
  64. package/local-maven-repo/host/exp/exponent/expo.modules.medialibrary/18.0.0-canary-20250729-d8899ae/expo.modules.medialibrary-18.0.0-canary-20250729-d8899ae.pom.sha1 +0 -1
  65. package/local-maven-repo/host/exp/exponent/expo.modules.medialibrary/18.0.0-canary-20250729-d8899ae/expo.modules.medialibrary-18.0.0-canary-20250729-d8899ae.pom.sha256 +0 -1
  66. package/local-maven-repo/host/exp/exponent/expo.modules.medialibrary/18.0.0-canary-20250729-d8899ae/expo.modules.medialibrary-18.0.0-canary-20250729-d8899ae.pom.sha512 +0 -1
package/CHANGELOG.md CHANGED
@@ -4,6 +4,16 @@
4
4
 
5
5
  ### 🛠 Breaking changes
6
6
 
7
+ ### 🎉 New features
8
+
9
+ ### 🐛 Bug fixes
10
+
11
+ ### 💡 Others
12
+
13
+ ## 18.0.0 — 2025-08-13
14
+
15
+ ### 🛠 Breaking changes
16
+
7
17
  - [Android] Fix `getAssetsAsync` loading performance, add `resolveWithFullInfo` option to control whether to load full EXIF data for images. This is a breaking change for Android apps, as it might break the orientation of images in some cases. ([#37957](https://github.com/expo/expo/pull/37957) by [@kosmydel](https://github.com/kosmydel)).
8
18
 
9
19
  ### 🎉 New features
@@ -16,6 +26,8 @@
16
26
 
17
27
  ### 💡 Others
18
28
 
29
+ - [Android] Migrate to coroutines. ([#38193](https://github.com/expo/expo/pull/38193) by [@Wenszel](http://github.com/wenszel))
30
+
19
31
  ## 17.1.7 - 2025-06-04
20
32
 
21
33
  ### 🐛 Bug fixes
@@ -4,19 +4,20 @@ plugins {
4
4
  }
5
5
 
6
6
  group = 'host.exp.exponent'
7
- version = '18.0.0-canary-20250729-d8899ae'
7
+ version = '18.0.0'
8
8
 
9
9
  android {
10
10
  namespace "expo.modules.medialibrary"
11
11
  defaultConfig {
12
12
  versionCode 37
13
- versionName "18.0.0-canary-20250729-d8899ae"
13
+ versionName "18.0.0"
14
14
  }
15
15
  }
16
16
 
17
17
  dependencies {
18
18
  implementation "androidx.annotation:annotation:1.2.0"
19
19
  api "androidx.exifinterface:exifinterface:1.3.3"
20
+ implementation 'androidx.activity:activity-ktx:1.10.1'
20
21
 
21
22
  if (project.findProject(':expo-modules-test-core')) {
22
23
  testImplementation project(':expo-modules-test-core')
@@ -31,3 +31,15 @@ class ContentEntryException :
31
31
 
32
32
  class AssetFileException(message: String) :
33
33
  CodedException(message)
34
+
35
+ class UnableToLoadPermissionException(message: String, cause: Throwable? = null) :
36
+ CodedException(message, cause)
37
+
38
+ class UnableToLoadException(message: String, cause: Throwable? = null) :
39
+ CodedException(message, cause)
40
+
41
+ class UnableToDeleteException(message: String, cause: Throwable? = null) :
42
+ CodedException(message, cause)
43
+
44
+ class UnableToSaveException(message: String, cause: Throwable? = null) :
45
+ CodedException(message, cause)
@@ -8,10 +8,8 @@ import android.Manifest.permission.READ_MEDIA_VIDEO
8
8
  import android.Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED
9
9
  import android.Manifest.permission.WRITE_EXTERNAL_STORAGE
10
10
  import android.annotation.SuppressLint
11
- import android.app.Activity
12
11
  import android.content.Context
13
12
  import android.content.Intent
14
- import android.content.IntentSender.SendIntentException
15
13
  import android.content.pm.PackageManager
16
14
  import android.database.ContentObserver
17
15
  import android.net.Uri
@@ -21,44 +19,42 @@ import android.os.Bundle
21
19
  import android.os.Handler
22
20
  import android.os.Looper
23
21
  import android.provider.MediaStore
24
- import android.util.Log
25
22
  import androidx.annotation.RequiresApi
26
- import expo.modules.core.errors.ModuleDestroyedException
27
23
  import expo.modules.interfaces.permissions.Permissions.askForPermissionsWithPermissionsManager
28
24
  import expo.modules.interfaces.permissions.Permissions.getPermissionsWithPermissionsManager
29
25
  import expo.modules.kotlin.Promise
30
- import expo.modules.kotlin.exception.CodedException
26
+ import expo.modules.kotlin.activityresult.AppContextActivityResultLauncher
31
27
  import expo.modules.kotlin.exception.Exceptions
28
+ import expo.modules.kotlin.functions.Coroutine
32
29
  import expo.modules.kotlin.modules.Module
33
30
  import expo.modules.kotlin.modules.ModuleDefinition
34
- import expo.modules.medialibrary.MediaLibraryModule.Action
35
- import expo.modules.medialibrary.albums.AddAssetsToAlbum
36
- import expo.modules.medialibrary.albums.CreateAlbum
37
- import expo.modules.medialibrary.albums.CreateAlbumWithInitialFileUri
38
- import expo.modules.medialibrary.albums.DeleteAlbums
39
- import expo.modules.medialibrary.albums.GetAlbum
40
- import expo.modules.medialibrary.albums.GetAlbums
41
- import expo.modules.medialibrary.albums.RemoveAssetsFromAlbum
31
+ import expo.modules.medialibrary.albums.addAssetsToAlbum
32
+ import expo.modules.medialibrary.albums.createAlbum
33
+ import expo.modules.medialibrary.albums.createAlbumWithInitialFileUri
34
+ import expo.modules.medialibrary.albums.deleteAlbums
35
+ import expo.modules.medialibrary.albums.getAlbum
36
+ import expo.modules.medialibrary.albums.getAlbums
42
37
  import expo.modules.medialibrary.albums.getAssetsInAlbums
43
- import expo.modules.medialibrary.albums.migration.CheckIfAlbumShouldBeMigrated
44
- import expo.modules.medialibrary.albums.migration.MigrateAlbum
45
- import expo.modules.medialibrary.assets.CreateAssetWithAlbumId
46
- import expo.modules.medialibrary.assets.DeleteAssets
47
- import expo.modules.medialibrary.assets.GetAssetInfo
48
- import expo.modules.medialibrary.assets.GetAssets
49
- import kotlinx.coroutines.CoroutineScope
50
- import kotlinx.coroutines.Dispatchers
51
- import kotlinx.coroutines.cancel
52
- import kotlinx.coroutines.launch
38
+ import expo.modules.medialibrary.albums.migration.checkIfAlbumShouldBeMigrated
39
+ import expo.modules.medialibrary.albums.migration.migrateAlbum
40
+ import expo.modules.medialibrary.albums.removeAssetsFromAlbum
41
+ import expo.modules.medialibrary.assets.createAssetWithAlbumId
42
+ import expo.modules.medialibrary.assets.deleteAssets
43
+ import expo.modules.medialibrary.assets.getAssetInfo
44
+ import expo.modules.medialibrary.assets.getAssets
45
+ import expo.modules.medialibrary.contracts.DeleteContract
46
+ import expo.modules.medialibrary.contracts.DeleteContractInput
47
+ import expo.modules.medialibrary.contracts.WriteContract
48
+ import expo.modules.medialibrary.contracts.WriteContractInput
53
49
  import java.lang.ref.WeakReference
54
50
 
55
51
  class MediaLibraryModule : Module() {
56
52
  private val context: Context
57
53
  get() = appContext.reactContext ?: throw Exceptions.ReactContextLost()
58
- private val moduleCoroutineScope = CoroutineScope(Dispatchers.IO)
59
54
  private var imagesObserver: MediaStoreContentObserver? = null
60
55
  private var videosObserver: MediaStoreContentObserver? = null
61
- private var awaitingAction: Action? = null
56
+ private lateinit var deleteLauncher: AppContextActivityResultLauncher<DeleteContractInput, Boolean>
57
+ private lateinit var writeLauncher: AppContextActivityResultLauncher<WriteContractInput, Boolean>
62
58
  private val isExpoGo by lazy {
63
59
  context.resources.getString(R.string.is_expo_go).toBoolean()
64
60
  }
@@ -103,134 +99,84 @@ class MediaLibraryModule : Module() {
103
99
  )
104
100
  }
105
101
 
106
- AsyncFunction("saveToLibraryAsync") { localUri: String, promise: Promise ->
107
- throwUnlessPermissionsGranted {
108
- withModuleScope(promise) {
109
- CreateAssetWithAlbumId(context, localUri, promise, false)
110
- .execute()
111
- }
112
- }
102
+ AsyncFunction("saveToLibraryAsync") Coroutine { localUri: String ->
103
+ requireSystemPermissions()
104
+ return@Coroutine createAssetWithAlbumId(context, localUri, false)
113
105
  }
114
106
 
115
- AsyncFunction("createAssetAsync") { localUri: String, albumId: String?, promise: Promise ->
116
- throwUnlessPermissionsGranted {
117
- withModuleScope(promise) {
118
- CreateAssetWithAlbumId(context, localUri, promise, true, albumId)
119
- .execute()
120
- }
121
- }
107
+ AsyncFunction("createAssetAsync") Coroutine { localUri: String, albumId: String? ->
108
+ requireSystemPermissions()
109
+ return@Coroutine createAssetWithAlbumId(context, localUri, true, albumId)
122
110
  }
123
111
 
124
- AsyncFunction("addAssetsToAlbumAsync") { assetsId: List<String>, albumId: String, copyToAlbum: Boolean, promise: Promise ->
125
- throwUnlessPermissionsGranted {
126
- val action = actionIfUserGrantedPermission(promise) {
127
- withModuleScope(promise) {
128
- AddAssetsToAlbum(context, assetsId.toTypedArray(), albumId, copyToAlbum, promise)
129
- .execute()
130
- }
131
- }
132
- runActionWithPermissions(if (copyToAlbum) emptyList() else assetsId, action)
133
- }
112
+ AsyncFunction("addAssetsToAlbumAsync") Coroutine { assetsId: Array<String>, albumId: String, copyToAlbum: Boolean ->
113
+ requireSystemPermissions()
114
+ requestMediaLibraryActionPermission(if (copyToAlbum) emptyArray() else assetsId)
115
+ return@Coroutine addAssetsToAlbum(context, assetsId, albumId, copyToAlbum)
134
116
  }
135
117
 
136
- AsyncFunction("removeAssetsFromAlbumAsync") { assetsId: List<String>, albumId: String, promise: Promise ->
137
- throwUnlessPermissionsGranted {
138
- val action = actionIfUserGrantedPermission(promise) {
139
- withModuleScope(promise) {
140
- RemoveAssetsFromAlbum(context, assetsId.toTypedArray(), albumId, promise)
141
- .execute()
142
- }
143
- }
144
- runActionWithPermissions(assetsId, action)
145
- }
118
+ AsyncFunction("removeAssetsFromAlbumAsync") Coroutine { assetsId: Array<String>, albumId: String ->
119
+ requireSystemPermissions()
120
+ requestMediaLibraryActionPermission(assetsId)
121
+ return@Coroutine removeAssetsFromAlbum(context, assetsId, albumId)
146
122
  }
147
123
 
148
- AsyncFunction("deleteAssetsAsync") { assetsId: List<String>, promise: Promise ->
149
- throwUnlessPermissionsGranted {
150
- val action = actionIfUserGrantedPermission(promise) {
151
- withModuleScope(promise) {
152
- DeleteAssets(context, assetsId.toTypedArray(), promise)
153
- .execute()
154
- }
155
- }
156
- runActionWithPermissions(assetsId, action, useDeletePermission = true)
157
- }
124
+ AsyncFunction("deleteAssetsAsync") Coroutine { assetsId: Array<String> ->
125
+ requireSystemPermissions()
126
+ requestMediaLibraryActionPermission(assetsId, needsDeletePermission = true)
127
+ return@Coroutine deleteAssets(context, assetsId)
158
128
  }
159
129
 
160
- AsyncFunction("getAssetInfoAsync") { assetId: String, _: Map<String, Any?>?/* unused on android atm */, promise: Promise ->
161
- throwUnlessPermissionsGranted(isWrite = false) {
162
- withModuleScope(promise) {
163
- GetAssetInfo(context, assetId, promise).execute()
164
- }
165
- }
130
+ AsyncFunction("getAssetInfoAsync") Coroutine { assetId: String, _: Map<String, Any?>?/* unused on android atm */ ->
131
+ requireSystemPermissions(false)
132
+ return@Coroutine getAssetInfo(context, assetId)
166
133
  }
167
134
 
168
- AsyncFunction("getAlbumsAsync") { _: Map<String, Any?>?/* unused on android atm */, promise: Promise ->
169
- throwUnlessPermissionsGranted(isWrite = false) {
170
- withModuleScope(promise) {
171
- GetAlbums(context, promise).execute()
172
- }
173
- }
135
+ AsyncFunction("getAlbumsAsync") Coroutine { _: Map<String, Any?>?/* unused on android atm */ ->
136
+ requireSystemPermissions(false)
137
+ return@Coroutine getAlbums(context)
174
138
  }
175
139
 
176
- AsyncFunction("getAlbumAsync") { albumName: String, promise: Promise ->
177
- throwUnlessPermissionsGranted(isWrite = false) {
178
- withModuleScope(promise) {
179
- GetAlbum(context, albumName, promise)
180
- .execute()
181
- }
182
- }
140
+ AsyncFunction("getAlbumAsync") Coroutine { albumName: String ->
141
+ requireSystemPermissions(false)
142
+ return@Coroutine getAlbum(context, albumName)
183
143
  }
184
144
 
185
- AsyncFunction("createAlbumAsync") { albumName: String, assetId: String?, copyAsset: Boolean, initialAssetUri: Uri?, promise: Promise ->
186
- throwUnlessPermissionsGranted {
187
- val action = actionIfUserGrantedPermission(promise) {
188
- withModuleScope(promise) {
189
- assetId?.let {
190
- CreateAlbum(context, albumName, assetId, copyAsset, promise)
191
- .execute()
192
- }
145
+ AsyncFunction("createAlbumAsync") Coroutine { albumName: String, assetId: String?, copyAsset: Boolean, initialAssetUri: Uri? ->
146
+ requireSystemPermissions()
193
147
 
194
- initialAssetUri?.let {
195
- CreateAlbumWithInitialFileUri(context, albumName, it, promise)
196
- .execute()
197
- }
198
- }
199
- }
200
- val assetIdList = if (!copyAsset && assetId != null) {
201
- listOf(assetId)
202
- } else {
203
- emptyList()
204
- }
205
- runActionWithPermissions(assetIdList, action)
148
+ val assetIdArray = if (!copyAsset && assetId != null) {
149
+ arrayOf(assetId)
150
+ } else {
151
+ emptyArray()
206
152
  }
207
- }
208
153
 
209
- AsyncFunction("deleteAlbumsAsync") { albumIds: List<String>, promise: Promise ->
210
- throwUnlessPermissionsGranted {
211
- val action = actionIfUserGrantedPermission(promise) {
212
- withModuleScope(promise) {
213
- DeleteAlbums(context, albumIds, promise)
214
- .execute()
215
- }
216
- }
217
- val assetIds = getAssetsInAlbums(context, *albumIds.toTypedArray())
218
- runActionWithPermissions(assetIds, action)
154
+ requestMediaLibraryActionPermission(assetIdArray)
155
+
156
+ return@Coroutine if (assetId != null) {
157
+ createAlbum(context, albumName, assetId, copyAsset)
158
+ } else if (initialAssetUri != null) {
159
+ createAlbumWithInitialFileUri(context, albumName, initialAssetUri)
160
+ } else {
161
+ throw AlbumException("Could not create the album")
219
162
  }
220
163
  }
221
164
 
222
- AsyncFunction("getAssetsAsync") { assetOptions: AssetsOptions, promise: Promise ->
223
- throwUnlessPermissionsGranted(isWrite = false) {
224
- withModuleScope(promise) {
225
- GetAssets(context, assetOptions, promise)
226
- .execute()
227
- }
228
- }
165
+ AsyncFunction("deleteAlbumsAsync") Coroutine { albumIds: Array<String> ->
166
+ requireSystemPermissions()
167
+ val assetIds = getAssetsInAlbums(context, *albumIds).toTypedArray()
168
+ requestMediaLibraryActionPermission(assetIds)
169
+ return@Coroutine deleteAlbums(context, albumIds)
229
170
  }
230
171
 
231
- AsyncFunction("migrateAlbumIfNeededAsync") { albumId: String, promise: Promise ->
172
+ AsyncFunction("getAssetsAsync") Coroutine { assetOptions: AssetsOptions ->
173
+ requireSystemPermissions(false)
174
+ return@Coroutine getAssets(context, assetOptions)
175
+ }
176
+
177
+ AsyncFunction("migrateAlbumIfNeededAsync") Coroutine { albumId: String ->
232
178
  if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
233
- return@AsyncFunction
179
+ return@Coroutine
234
180
  }
235
181
 
236
182
  val assetsIds = getAssetsInAlbums(context, albumId)
@@ -238,7 +184,7 @@ class MediaLibraryModule : Module() {
238
184
  .toTypedArray()
239
185
  // The album is empty, nothing to migrate
240
186
  if (assetsIds.isEmpty()) {
241
- return@AsyncFunction
187
+ return@Coroutine
242
188
  }
243
189
 
244
190
  val assets = MediaLibraryUtils.getAssetsById(
@@ -259,33 +205,20 @@ class MediaLibraryModule : Module() {
259
205
 
260
206
  val albumDir = assets[0].parentFile ?: throw AlbumPathException()
261
207
  if (albumDir.canWrite()) {
262
- return@AsyncFunction
263
- }
264
-
265
- val action = actionIfUserGrantedPermission(promise) {
266
- moduleCoroutineScope.launch {
267
- MigrateAlbum(context, assets, albumDir.name, promise)
268
- .execute()
269
- }
208
+ return@Coroutine
270
209
  }
271
210
 
272
- val needsToCheckPermissions = assets.map { it.assetId }
273
- runActionWithPermissions(needsToCheckPermissions, action)
211
+ val idsOfAssets = assets.map { it.assetId }.toTypedArray()
212
+ requestMediaLibraryActionPermission(idsOfAssets)
213
+ return@Coroutine migrateAlbum(context, assets, albumDir.name)
274
214
  }
275
215
 
276
- AsyncFunction("albumNeedsMigrationAsync") { albumId: String, promise: Promise ->
277
- throwUnlessPermissionsGranted(isWrite = false) {
278
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
279
- moduleCoroutineScope.launch {
280
- try {
281
- CheckIfAlbumShouldBeMigrated(context, albumId, promise)
282
- .execute()
283
- } catch (e: CodedException) {
284
- promise.reject(e)
285
- }
286
- }
287
- }
288
- promise.resolve(false)
216
+ AsyncFunction("albumNeedsMigrationAsync") Coroutine { albumId: String ->
217
+ requireSystemPermissions(false)
218
+ return@Coroutine if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
219
+ checkIfAlbumShouldBeMigrated(context, albumId)
220
+ } else {
221
+ false
289
222
  }
290
223
  }
291
224
 
@@ -333,33 +266,14 @@ class MediaLibraryModule : Module() {
333
266
  }
334
267
  }
335
268
 
336
- OnActivityResult { _, payload ->
337
- awaitingAction?.takeIf { payload.requestCode == WRITE_REQUEST_CODE || payload.requestCode == DELETE_REQUEST_CODE }?.let {
338
- it.runWithPermissions(payload.resultCode == Activity.RESULT_OK)
339
- awaitingAction = null
340
- }
341
- }
342
-
343
- OnDestroy {
344
- try {
345
- moduleCoroutineScope.cancel(ModuleDestroyedException())
346
- } catch (e: IllegalStateException) {
347
- Log.e(TAG, "The scope does not have a job in it")
348
- }
269
+ RegisterActivityContracts {
270
+ deleteLauncher =
271
+ registerForActivityResult(DeleteContract(this@MediaLibraryModule))
272
+ writeLauncher =
273
+ registerForActivityResult(WriteContract(this@MediaLibraryModule))
349
274
  }
350
275
  }
351
276
 
352
- private inline fun withModuleScope(promise: Promise, crossinline block: () -> Unit) =
353
- moduleCoroutineScope.launch {
354
- try {
355
- block()
356
- } catch (e: CodedException) {
357
- promise.reject(e)
358
- } catch (e: ModuleDestroyedException) {
359
- promise.reject(TAG, "MediaLibrary module destroyed", e)
360
- }
361
- }
362
-
363
277
  private val isMissingPermissions: Boolean
364
278
  get() = hasReadPermissions()
365
279
 
@@ -430,19 +344,14 @@ class MediaLibraryModule : Module() {
430
344
  return granularPermissions
431
345
  }
432
346
 
433
- private inline fun throwUnlessPermissionsGranted(isWrite: Boolean = true, block: () -> Unit) {
347
+ private fun requireSystemPermissions(isWritePermissionRequired: Boolean = true) {
434
348
  val missingPermissionsCondition =
435
- if (isWrite) isMissingWritePermission else isMissingPermissions
349
+ if (isWritePermissionRequired) isMissingWritePermission else isMissingPermissions
436
350
  if (missingPermissionsCondition) {
437
351
  val missingPermissionsMessage =
438
- if (isWrite) ERROR_NO_WRITE_PERMISSION_MESSAGE else ERROR_NO_PERMISSIONS_MESSAGE
352
+ if (isWritePermissionRequired) ERROR_NO_WRITE_PERMISSION_MESSAGE else ERROR_NO_PERMISSIONS_MESSAGE
439
353
  throw PermissionsException(missingPermissionsMessage)
440
354
  }
441
- block()
442
- }
443
-
444
- private fun interface Action {
445
- fun runWithPermissions(permissionsWereGranted: Boolean)
446
355
  }
447
356
 
448
357
  private fun hasReadPermissions(): Boolean {
@@ -481,56 +390,43 @@ class MediaLibraryModule : Module() {
481
390
  ?.not() ?: false
482
391
  }
483
392
 
484
- private fun runActionWithPermissions(assetsId: List<String>, action: Action, useDeletePermission: Boolean = false) {
485
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
486
- val pathsWithoutPermissions = MediaLibraryUtils.getAssetsUris(context, assetsId)
487
- .filter { uri ->
488
- context.checkUriPermission(
489
- uri,
490
- Binder.getCallingPid(),
491
- Binder.getCallingUid(), Intent.FLAG_GRANT_WRITE_URI_PERMISSION
492
- ) != PackageManager.PERMISSION_GRANTED
493
- }
494
-
495
- if (pathsWithoutPermissions.isNotEmpty()) {
496
- val request = if (useDeletePermission) {
497
- MediaStore.createDeleteRequest(context.contentResolver, pathsWithoutPermissions)
498
- } else {
499
- MediaStore.createWriteRequest(context.contentResolver, pathsWithoutPermissions)
500
- }
501
-
502
- try {
503
- awaitingAction = action
504
- appContext.throwingActivity.startIntentSenderForResult(
505
- request.intentSender,
506
- if (useDeletePermission) DELETE_REQUEST_CODE else WRITE_REQUEST_CODE,
507
- null,
508
- 0,
509
- 0,
510
- 0
511
- )
512
- } catch (e: SendIntentException) {
513
- awaitingAction = null
514
- throw e
515
- }
516
- // the action will be called when permissions are granted
517
- return
518
- }
393
+ private suspend fun requestMediaLibraryActionPermission(
394
+ assetIds: Array<String>,
395
+ needsDeletePermission: Boolean = false
396
+ ) {
397
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
398
+ return
519
399
  }
520
- action.runWithPermissions(true)
521
- }
522
400
 
523
- private fun actionIfUserGrantedPermission(
524
- promise: Promise,
525
- block: () -> Unit
526
- ) = Action { permissionsWereGranted ->
527
- if (!permissionsWereGranted) {
528
- promise.reject(PermissionsException(ERROR_USER_DID_NOT_GRANT_WRITE_PERMISSIONS_MESSAGE))
401
+ val uris = MediaLibraryUtils.getAssetsUris(context, assetIds)
402
+ val urisWithoutPermission = uris.filterNot { uri ->
403
+ hasWritePermissionForUri(uri)
404
+ }
405
+
406
+ if (urisWithoutPermission.isEmpty()) {
407
+ return
408
+ }
409
+
410
+ val granted = if (needsDeletePermission) {
411
+ deleteLauncher.launch(DeleteContractInput(uris = urisWithoutPermission))
529
412
  } else {
530
- block()
413
+ writeLauncher.launch(WriteContractInput(uris = urisWithoutPermission))
414
+ }
415
+
416
+ if (!granted) {
417
+ throw PermissionsException(ERROR_USER_DID_NOT_GRANT_WRITE_PERMISSIONS_MESSAGE)
531
418
  }
532
419
  }
533
420
 
421
+ private fun hasWritePermissionForUri(uri: Uri): Boolean {
422
+ return context.checkUriPermission(
423
+ uri,
424
+ Binder.getCallingPid(),
425
+ Binder.getCallingUid(),
426
+ Intent.FLAG_GRANT_WRITE_URI_PERMISSION
427
+ ) == PackageManager.PERMISSION_GRANTED
428
+ }
429
+
534
430
  private inner class MediaStoreContentObserver(handler: Handler, private val mMediaType: Int) :
535
431
  ContentObserver(handler) {
536
432
 
@@ -560,10 +456,4 @@ class MediaLibraryModule : Module() {
560
456
  null
561
457
  ).use { countCursor -> countCursor?.count ?: 0 }
562
458
  }
563
-
564
- companion object {
565
- private const val WRITE_REQUEST_CODE = 7463
566
- private const val DELETE_REQUEST_CODE = 7464
567
- internal val TAG = MediaLibraryModule::class.java.simpleName
568
- }
569
459
  }
@@ -4,6 +4,7 @@ import android.content.ContentResolver
4
4
  import android.content.ContentUris
5
5
  import android.content.Context
6
6
  import android.content.pm.PackageManager
7
+ import android.media.MediaScannerConnection
7
8
  import android.net.Uri
8
9
  import android.os.Build
9
10
  import android.os.Environment
@@ -11,11 +12,15 @@ import android.provider.MediaStore
11
12
  import android.text.TextUtils
12
13
  import android.util.Log
13
14
  import android.webkit.MimeTypeMap
14
- import expo.modules.kotlin.Promise
15
+ import kotlinx.coroutines.Dispatchers
16
+ import kotlinx.coroutines.ensureActive
17
+ import kotlinx.coroutines.withContext
15
18
  import java.io.File
16
19
  import java.io.FileInputStream
17
20
  import java.io.FileOutputStream
18
21
  import java.io.IOException
22
+ import kotlin.coroutines.resume
23
+ import kotlin.coroutines.suspendCoroutine
19
24
 
20
25
  object MediaLibraryUtils {
21
26
  class AssetFile(pathname: String, val assetId: String, val mimeType: String) : File(pathname)
@@ -75,7 +80,11 @@ object MediaLibraryUtils {
75
80
  }
76
81
  }
77
82
 
78
- fun deleteAssets(context: Context, selection: String?, selectionArgs: Array<out String?>?, promise: Promise) {
83
+ suspend fun deleteAssets(
84
+ context: Context,
85
+ selection: String?,
86
+ selectionArgs: Array<out String?>?
87
+ ): Boolean = withContext(Dispatchers.IO) {
79
88
  val projection = arrayOf(MediaStore.MediaColumns._ID, MediaStore.MediaColumns.DATA)
80
89
  try {
81
90
  context.contentResolver.query(
@@ -89,6 +98,9 @@ object MediaLibraryUtils {
89
98
  throw AssetFileException("Could not delete assets. Cursor is null.")
90
99
  } else {
91
100
  while (filesToDelete.moveToNext()) {
101
+ // Interrupting file deletion when scope is closed is desired, as
102
+ // user might want to stop this process in the meantime
103
+ coroutineContext.ensureActive()
92
104
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
93
105
  val columnId = filesToDelete.getColumnIndex(MediaStore.MediaColumns._ID)
94
106
  val id = filesToDelete.getLong(columnId)
@@ -112,18 +124,13 @@ object MediaLibraryUtils {
112
124
  }
113
125
  }
114
126
  }
115
- promise.resolve(true)
116
127
  }
117
128
  }
129
+ return@withContext true
118
130
  } catch (e: SecurityException) {
119
- promise.reject(
120
- ERROR_UNABLE_TO_SAVE_PERMISSION,
121
- "Could not delete asset: need WRITE_EXTERNAL_STORAGE permission.",
122
- e
123
- )
131
+ throw UnableToDeleteException("Could not delete asset: need WRITE_EXTERNAL_STORAGE permission.", e)
124
132
  } catch (e: Exception) {
125
- e.printStackTrace()
126
- promise.reject(ERROR_UNABLE_TO_DELETE, "Could not delete file.", e)
133
+ throw UnableToDeleteException("Could not delete file: ${e.message}", e)
127
134
  }
128
135
  }
129
136
 
@@ -188,9 +195,9 @@ object MediaLibraryUtils {
188
195
  fun getMimeType(contentResolver: ContentResolver, uri: Uri): String? =
189
196
  contentResolver.getType(uri) ?: getMimeTypeFromFileUrl(uri.toString())
190
197
 
191
- fun getAssetsUris(context: Context, assetsId: List<String?>?): List<Uri> {
198
+ fun getAssetsUris(context: Context, assetsId: Array<String>): List<Uri> {
192
199
  val result = mutableListOf<Uri>()
193
- val selection = MediaStore.MediaColumns._ID + " IN (" + TextUtils.join(",", assetsId!!) + " )"
200
+ val selection = MediaStore.MediaColumns._ID + " IN (" + TextUtils.join(",", assetsId) + " )"
194
201
  val selectionArgs: Array<String>? = null
195
202
  val projection = arrayOf(MediaStore.MediaColumns._ID, MediaStore.MediaColumns.MIME_TYPE)
196
203
  context.contentResolver.query(
@@ -258,4 +265,11 @@ object MediaLibraryUtils {
258
265
  */
259
266
  fun hasManifestPermission(context: Context, permission: String): Boolean =
260
267
  getManifestPermissions(context).contains(permission)
268
+
269
+ suspend fun scanFile(context: Context, paths: Array<String>, mimeTypes: Array<String>?) =
270
+ suspendCoroutine { complete ->
271
+ MediaScannerConnection.scanFile(context, paths, mimeTypes) { path: String, uri: Uri? ->
272
+ complete.resume(Pair(path, uri))
273
+ }
274
+ }
261
275
  }