@gaozh1024/photo-picker 0.1.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/README.md ADDED
@@ -0,0 +1,90 @@
1
+ # @gaozh1024/photo-picker
2
+
3
+ Permissionless Android system media selection for Expo and React Native.
4
+
5
+ ## Android behavior
6
+
7
+ The native module selects the backend before launching the activity:
8
+
9
+ | Android/device capability | Backend | User-visible UI |
10
+ | --------------------------------------------------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
11
+ | Android 13+ with the OS Photo Picker available | `android-photo-picker` / `android.provider.action.PICK_IMAGES` | Android Photo Picker, including the `所有照片` / `相册` layout shown in the reference image |
12
+ | Android 12 and below, or a device without a usable Photo Picker | `android-open-document` / `android.intent.action.OPEN_DOCUMENT` | Android DocumentsUI file picker |
13
+
14
+ The Photo Picker is provided by the OS or OEM. Its colors, labels, tabs, privacy banner, album presentation, and exact layout are not controlled by this package. The reference image is therefore a device smoke-test target, not a drawable screen that the library can reproduce pixel-for-pixel.
15
+
16
+ The package does not declare or request `READ_EXTERNAL_STORAGE`, `WRITE_EXTERNAL_STORAGE`, `READ_MEDIA_IMAGES`, `READ_MEDIA_VIDEO`, or `READ_MEDIA_VISUAL_USER_SELECTED`. It returns the URIs selected in the current picker session and copies them into the app cache. It does not enumerate the device media library.
17
+
18
+ The Android 14 limited-library permission flow is intentionally out of scope for this API. A future library-access capability must be a separate permission and MediaStore API; it must not change the meaning of `pickMedia()`.
19
+
20
+ ## Usage
21
+
22
+ ```tsx
23
+ import { PhotoAlbumScreen } from '@gaozh1024/photo-picker';
24
+
25
+ // Keep the existing route name if migrating from photo-album-picker.
26
+ <Stack.Screen name="PhotoAlbum" component={PhotoAlbumScreen} />;
27
+ ```
28
+
29
+ `PhotoAlbumScreen` preserves the existing selection, crop, callback, and upload flow. For custom flows, use the exported `pickMedia` API, the callback registry, and `PhotoCropScreen`.
30
+
31
+ `maxSelection` must be a positive integer after normalization. The JavaScript flow does not impose the old arbitrary limit of `100`. The Android Photo Picker may reject a request above the OS-supported multi-select limit with `PICKER_SELECTION_LIMIT_UNSUPPORTED`; the caller should reduce the requested limit or use a compatible device/backend. Crop mode always selects one image.
32
+
33
+ ## Result and diagnostics
34
+
35
+ `pickMedia()` resolves with:
36
+
37
+ ```ts
38
+ {
39
+ cancelled: boolean;
40
+ assets: PhotoAlbumItem[];
41
+ source?: 'android-photo-picker' | 'android-open-document';
42
+ action?: string;
43
+ }
44
+ ```
45
+
46
+ `source` and `action` describe the backend that actually handled the request. A cancellation is still a successful result and should be checked through `cancelled`; it may include the same backend diagnostics. For selected assets, `source` is also retained on each asset.
47
+
48
+ Native failures expose a stable `code` recognized by `isPhotoPickerNativeError`:
49
+
50
+ - `PICKER_BUSY`: another picker request is already in flight; allow it to finish before retrying.
51
+ - `PICKER_LAUNCH_FAILED`: the resolved activity could not be started; report the error and inspect the device/provider configuration.
52
+ - `PICKER_SELECTION_LIMIT_UNSUPPORTED`: the requested multi-select limit exceeds the Photo Picker capability; reduce `maxSelection`.
53
+
54
+ Unknown native/provider failures remain ordinary `Error` values. A failed selection/materialization must not be treated as a successful asset result.
55
+
56
+ ## Native rebuild requirement
57
+
58
+ The package contains native Android code. After installing or updating it, rebuild and reinstall the Android application; an OTA JavaScript update cannot install or replace the native module.
59
+
60
+ ```sh
61
+ pnpm --dir packages/photo-picker test
62
+ pnpm --dir packages/photo-picker typecheck
63
+ pnpm --dir packages/photo-picker build
64
+ ```
65
+
66
+ For local Yalc development:
67
+
68
+ ```sh
69
+ pnpm build
70
+ pnpm exec yalc publish --push
71
+ ```
72
+
73
+ Then, in the consuming application:
74
+
75
+ ```sh
76
+ pnpm exec yalc add @gaozh1024/photo-picker
77
+ ```
78
+
79
+ ## Device smoke gate
80
+
81
+ The TypeScript tests cover option normalization, crop/single-select behavior, result source/action shape, cancellation semantics, and native error-code discrimination. They cannot prove which Android activity an OEM launches.
82
+
83
+ Before release, verify on real devices:
84
+
85
+ 1. Android 13 and 14/15 devices with Photo Picker: the picker opens as the OS Photo Picker, `source` is `android-photo-picker`, and `action` is `android.provider.action.PICK_IMAGES`.
86
+ 2. Android 12 or a device without a usable Photo Picker: the UI is DocumentsUI, `source` is `android-open-document`, and `action` is `android.intent.action.OPEN_DOCUMENT`.
87
+ 3. Single-select, mixed image/video, multi-select, crop, cancel, an over-limit request, repeated taps, and a provider launch failure.
88
+ 4. The app's merged manifest contains no media-read permission added by this package, and no runtime media permission prompt appears.
89
+
90
+ Record the device model, Android version, selected backend, action, and error code for every failure. Do not use the appearance of a limited-library banner as proof of this package's `pickMedia()` behavior; limited-library support is a separate future capability.
@@ -0,0 +1,19 @@
1
+ plugins {
2
+ id 'com.android.library'
3
+ id 'expo-module-gradle-plugin'
4
+ }
5
+
6
+ group = 'com.gaozh1024'
7
+ version = '0.1.1'
8
+
9
+ android {
10
+ namespace "com.gaozh1024.photopicker"
11
+ defaultConfig {
12
+ versionCode 1
13
+ versionName "0.1.1"
14
+ }
15
+ }
16
+
17
+ dependencies {
18
+ implementation 'androidx.activity:activity-ktx:1.11.0'
19
+ }
@@ -0,0 +1,7 @@
1
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android">
2
+ <queries>
3
+ <intent>
4
+ <action android:name="android.provider.action.PICK_IMAGES" />
5
+ </intent>
6
+ </queries>
7
+ </manifest>
@@ -0,0 +1,82 @@
1
+ package com.gaozh1024.photopicker
2
+
3
+ import android.app.Activity
4
+ import android.content.Context
5
+ import android.content.Intent
6
+ import android.net.Uri
7
+ import android.provider.MediaStore
8
+ import expo.modules.kotlin.activityresult.AppContextActivityResultContract
9
+ import java.io.Serializable
10
+
11
+ internal data class PickerBackend(
12
+ val source: String,
13
+ val action: String,
14
+ ) : Serializable
15
+
16
+ internal data class PhotoPickerContractOptions(
17
+ val mediaType: String,
18
+ val maxSelection: Int,
19
+ val allowsMultipleSelection: Boolean,
20
+ val backend: PickerBackend,
21
+ ) : Serializable
22
+
23
+ internal sealed class PhotoPickerContractResult {
24
+ data class Success(val uris: List<Uri>, val backend: PickerBackend) : PhotoPickerContractResult()
25
+ data class Cancelled(val backend: PickerBackend) : PhotoPickerContractResult()
26
+ }
27
+
28
+ internal class PhotoPickerContract : AppContextActivityResultContract<PhotoPickerContractOptions, PhotoPickerContractResult> {
29
+ override fun createIntent(context: Context, input: PhotoPickerContractOptions): Intent {
30
+ val intent = Intent(input.backend.action)
31
+
32
+ val mediaType = when (input.mediaType) {
33
+ "photo" -> "image/*"
34
+ "video" -> "video/*"
35
+ else -> "*/*"
36
+ }
37
+ intent.type = mediaType
38
+ if (input.mediaType == "all") {
39
+ intent.putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("image/*", "video/*"))
40
+ }
41
+ if (input.backend.source == "android-open-document") {
42
+ intent.addCategory(Intent.CATEGORY_OPENABLE)
43
+ }
44
+ intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
45
+
46
+ if (input.allowsMultipleSelection && input.maxSelection > 1) {
47
+ intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
48
+ if (input.backend.source == "android-photo-picker") {
49
+ intent.putExtra(
50
+ MediaStore.EXTRA_PICK_IMAGES_MAX,
51
+ input.maxSelection,
52
+ )
53
+ }
54
+ }
55
+ return intent
56
+ }
57
+
58
+ override fun parseResult(
59
+ input: PhotoPickerContractOptions,
60
+ resultCode: Int,
61
+ intent: Intent?,
62
+ ): PhotoPickerContractResult {
63
+ if (resultCode != Activity.RESULT_OK || intent == null) {
64
+ return PhotoPickerContractResult.Cancelled(input.backend)
65
+ }
66
+
67
+ val uris = buildList {
68
+ intent.data?.let(::add)
69
+ intent.clipData?.let { clipData ->
70
+ for (index in 0 until clipData.itemCount) {
71
+ add(clipData.getItemAt(index).uri)
72
+ }
73
+ }
74
+ }.distinct().take(input.maxSelection)
75
+
76
+ return if (uris.isEmpty()) {
77
+ PhotoPickerContractResult.Cancelled(input.backend)
78
+ } else {
79
+ PhotoPickerContractResult.Success(uris, input.backend)
80
+ }
81
+ }
82
+ }
@@ -0,0 +1,291 @@
1
+ package com.gaozh1024.photopicker
2
+
3
+ import android.content.ActivityNotFoundException
4
+ import android.content.ContentResolver
5
+ import android.content.Context
6
+ import android.content.Intent
7
+ import android.graphics.BitmapFactory
8
+ import android.media.MediaMetadataRetriever
9
+ import android.net.Uri
10
+ import android.os.Build
11
+ import android.provider.MediaStore
12
+ import android.provider.OpenableColumns
13
+ import expo.modules.kotlin.activityresult.AppContextActivityResultLauncher
14
+ import expo.modules.kotlin.activityresult.AppContextActivityResultFallbackCallback
15
+ import expo.modules.kotlin.exception.CodedException
16
+ import expo.modules.kotlin.exception.Exceptions
17
+ import expo.modules.kotlin.functions.Coroutine
18
+ import expo.modules.kotlin.modules.Module
19
+ import expo.modules.kotlin.modules.ModuleDefinition
20
+ import java.io.File
21
+ import java.io.FileOutputStream
22
+ import java.text.SimpleDateFormat
23
+ import java.security.MessageDigest
24
+ import java.util.Date
25
+ import java.util.Locale
26
+ import java.util.TimeZone
27
+ import java.util.UUID
28
+ import java.util.concurrent.atomic.AtomicBoolean
29
+
30
+ private class PickerException(
31
+ code: String,
32
+ message: String,
33
+ cause: Throwable? = null,
34
+ ) : CodedException(code, message, cause)
35
+
36
+ class PhotoPickerModule : Module() {
37
+ private val context: Context
38
+ get() = appContext.reactContext ?: throw Exceptions.ReactContextLost()
39
+
40
+ private val pickerCacheDirectory: File
41
+ get() = File(appContext.cacheDirectory, "photo-picker")
42
+
43
+ private lateinit var pickerLauncher: AppContextActivityResultLauncher<PhotoPickerContractOptions, PhotoPickerContractResult>
44
+ private val pickerInFlight = AtomicBoolean(false)
45
+
46
+ override fun definition() = ModuleDefinition {
47
+ Name("PhotoPickerModule")
48
+
49
+ RegisterActivityContracts {
50
+ pickerLauncher = registerForActivityResult(
51
+ PhotoPickerContract(),
52
+ AppContextActivityResultFallbackCallback { _, _ ->
53
+ // The original JS coroutine cannot survive process/context replacement,
54
+ // but the restored launcher must not leave this module permanently busy.
55
+ pickerInFlight.set(false)
56
+ },
57
+ )
58
+ }
59
+
60
+ AsyncFunction("pickMedia") Coroutine { options: Map<String, Any?>? ->
61
+ if (!pickerInFlight.compareAndSet(false, true)) {
62
+ throw PickerException("PICKER_BUSY", "A media picker request is already in progress")
63
+ }
64
+
65
+ try {
66
+ val backend = resolveBackend()
67
+ val normalized = normalizeOptions(options, backend)
68
+ validateSelectionLimit(normalized, backend)
69
+ val input = normalized.copy(backend = backend)
70
+ val result = try {
71
+ pickerLauncher.launch(input)
72
+ } catch (error: ActivityNotFoundException) {
73
+ throw PickerException(
74
+ "PICKER_LAUNCH_FAILED",
75
+ "Unable to launch the resolved media picker (${backend.action})",
76
+ error,
77
+ )
78
+ } catch (error: SecurityException) {
79
+ throw PickerException(
80
+ "PICKER_LAUNCH_FAILED",
81
+ "The resolved media picker rejected the launch (${backend.action})",
82
+ error,
83
+ )
84
+ } catch (error: IllegalArgumentException) {
85
+ throw PickerException(
86
+ "PICKER_LAUNCH_FAILED",
87
+ "The resolved media picker received invalid launch arguments (${backend.action})",
88
+ error,
89
+ )
90
+ }
91
+ when (result) {
92
+ is PhotoPickerContractResult.Cancelled -> mapOf(
93
+ "cancelled" to true,
94
+ "assets" to emptyList<Map<String, Any?>>(),
95
+ "source" to result.backend.source,
96
+ "action" to result.backend.action,
97
+ )
98
+ is PhotoPickerContractResult.Success -> mapOf(
99
+ "cancelled" to false,
100
+ "assets" to result.uris.map { materialize(it, result.backend) },
101
+ "source" to result.backend.source,
102
+ "action" to result.backend.action,
103
+ )
104
+ }
105
+ } finally {
106
+ pickerInFlight.set(false)
107
+ }
108
+ }
109
+
110
+ AsyncFunction("releaseMedia") { uris: List<String> ->
111
+ val root = pickerCacheDirectory.canonicalFile
112
+ uris.forEach { uriString ->
113
+ val file = uriString.toFileOrNull() ?: return@forEach
114
+ if (file.canonicalFile.path.startsWith(root.path)) {
115
+ file.delete()
116
+ }
117
+ }
118
+ }
119
+
120
+ AsyncFunction("clearPickerCache") {
121
+ pickerCacheDirectory.deleteRecursively()
122
+ }
123
+ }
124
+
125
+ private fun normalizeOptions(options: Map<String, Any?>?, backend: PickerBackend): PhotoPickerContractOptions {
126
+ val mediaType = (options?.get("mediaType") as? String).let {
127
+ if (it == "photo" || it == "video") it else "all"
128
+ }
129
+ val requestedMax = (options?.get("maxSelection") as? Number)?.toInt() ?: 1
130
+ val allowsMultiple = (options?.get("allowsMultipleSelection") as? Boolean) ?: (requestedMax > 1)
131
+ val maxSelection = if (allowsMultiple) requestedMax.coerceAtLeast(1) else 1
132
+ return PhotoPickerContractOptions(mediaType, maxSelection, allowsMultiple, backend)
133
+ }
134
+
135
+ private fun resolveBackend(): PickerBackend {
136
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
137
+ val photoPickerIntent = Intent(MediaStore.ACTION_PICK_IMAGES)
138
+ if (photoPickerIntent.resolveActivity(context.packageManager) != null) {
139
+ return PickerBackend("android-photo-picker", MediaStore.ACTION_PICK_IMAGES)
140
+ }
141
+ }
142
+ return PickerBackend("android-open-document", Intent.ACTION_OPEN_DOCUMENT)
143
+ }
144
+
145
+ private fun validateSelectionLimit(options: PhotoPickerContractOptions, backend: PickerBackend) {
146
+ if (!options.allowsMultiple || options.maxSelection <= 1 || backend.source != "android-photo-picker") return
147
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
148
+
149
+ val systemLimit = MediaStore.getPickImagesMaxLimit()
150
+ if (options.maxSelection > systemLimit) {
151
+ throw PickerException(
152
+ "PICKER_SELECTION_LIMIT_UNSUPPORTED",
153
+ "Requested ${options.maxSelection} media items, but this Photo Picker supports at most $systemLimit",
154
+ )
155
+ }
156
+ }
157
+
158
+ private fun materialize(sourceUri: Uri, backend: PickerBackend): Map<String, Any?> {
159
+ val resolver = context.contentResolver
160
+ val mimeType = resolver.getType(sourceUri) ?: "application/octet-stream"
161
+ val sourceName = queryDisplayName(resolver, sourceUri) ?: defaultFileName(mimeType)
162
+ val safeName = sanitizeFileName(sourceName)
163
+ val destinationDirectory = File(pickerCacheDirectory, UUID.randomUUID().toString()).apply { mkdirs() }
164
+ val destination = File(destinationDirectory, safeName)
165
+
166
+ resolver.openInputStream(sourceUri).use { input ->
167
+ requireNotNull(input) { "Unable to read selected media URI" }
168
+ FileOutputStream(destination).use { output -> input.copyTo(output) }
169
+ }
170
+
171
+ val dimensions = readDimensions(resolver, sourceUri, mimeType)
172
+ val durationMs = readDuration(resolver, sourceUri, mimeType)
173
+ val capturedAt = readCapturedAt(resolver, sourceUri)
174
+ val mediaType = if (mimeType.startsWith("video/")) "video" else "photo"
175
+ val fileUri = Uri.fromFile(destination).toString()
176
+
177
+ return mapOf(
178
+ "id" to sha256(sourceUri.toString()),
179
+ "uri" to fileUri,
180
+ "localUri" to fileUri,
181
+ "originalUri" to sourceUri.toString(),
182
+ "filename" to safeName,
183
+ "fileName" to safeName,
184
+ "mimeType" to mimeType,
185
+ "mediaType" to mediaType,
186
+ "fileSize" to destination.length(),
187
+ "width" to dimensions.first,
188
+ "height" to dimensions.second,
189
+ "duration" to (durationMs?.toDouble()?.div(1000.0)),
190
+ "durationMs" to durationMs,
191
+ "metadata" to capturedAt?.let { mapOf("capturedAt" to it) },
192
+ "source" to backend.source,
193
+ "action" to backend.action,
194
+ )
195
+ }
196
+
197
+ private fun readCapturedAt(resolver: ContentResolver, uri: Uri): String? {
198
+ return try {
199
+ resolver.query(
200
+ uri,
201
+ // MediaStore uses the legacy `datetaken` column name. Some picker
202
+ // providers do not expose it; the query is intentionally best-effort
203
+ // and the server can still enrich image EXIF metadata after upload.
204
+ arrayOf("datetaken", "date_modified"),
205
+ null,
206
+ null,
207
+ null,
208
+ )?.use { cursor ->
209
+ if (!cursor.moveToFirst()) return@use null
210
+ val dateTakenIndex = cursor.getColumnIndex("datetaken")
211
+ val dateModifiedIndex = cursor.getColumnIndex("date_modified")
212
+ val dateTaken = if (dateTakenIndex >= 0 && !cursor.isNull(dateTakenIndex)) cursor.getLong(dateTakenIndex) else 0L
213
+ val dateModifiedSeconds = if (dateModifiedIndex >= 0 && !cursor.isNull(dateModifiedIndex)) cursor.getLong(dateModifiedIndex) else 0L
214
+ val timestampMs = when {
215
+ dateTaken > 0L -> dateTaken
216
+ dateModifiedSeconds > 0L -> dateModifiedSeconds * 1000L
217
+ else -> 0L
218
+ }
219
+ if (timestampMs > 0L) formatRFC3339(timestampMs) else null
220
+ }
221
+ } catch (_: Exception) {
222
+ null
223
+ }
224
+ }
225
+
226
+ private fun formatRFC3339(timestampMs: Long): String {
227
+ return SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply {
228
+ timeZone = TimeZone.getTimeZone("UTC")
229
+ }.format(Date(timestampMs))
230
+ }
231
+
232
+ private fun queryDisplayName(resolver: ContentResolver, uri: Uri): String? {
233
+ return resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor ->
234
+ if (cursor.moveToFirst()) cursor.getString(0) else null
235
+ }
236
+ }
237
+
238
+ private fun readDimensions(resolver: ContentResolver, uri: Uri, mimeType: String): Pair<Int, Int> {
239
+ if (mimeType.startsWith("image/")) {
240
+ return resolver.openFileDescriptor(uri, "r")?.use { descriptor ->
241
+ val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
242
+ BitmapFactory.decodeFileDescriptor(descriptor.fileDescriptor, null, options)
243
+ Pair(options.outWidth.coerceAtLeast(0), options.outHeight.coerceAtLeast(0))
244
+ } ?: Pair(0, 0)
245
+ }
246
+ val retriever = MediaMetadataRetriever()
247
+ return try {
248
+ retriever.setDataSource(context, uri)
249
+ Pair(
250
+ retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull() ?: 0,
251
+ retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toIntOrNull() ?: 0,
252
+ )
253
+ } catch (_: Exception) {
254
+ Pair(0, 0)
255
+ } finally {
256
+ retriever.release()
257
+ }
258
+ }
259
+
260
+ private fun readDuration(resolver: ContentResolver, uri: Uri, mimeType: String): Long? {
261
+ if (!mimeType.startsWith("video/")) return null
262
+ val retriever = MediaMetadataRetriever()
263
+ return try {
264
+ retriever.setDataSource(context, uri)
265
+ retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLongOrNull()
266
+ } catch (_: Exception) {
267
+ null
268
+ } finally {
269
+ retriever.release()
270
+ }
271
+ }
272
+
273
+ private fun sanitizeFileName(name: String): String {
274
+ val cleaned = name.replace(Regex("[^A-Za-z0-9._-]"), "_").trim('_')
275
+ return if (cleaned.isBlank()) "media-${UUID.randomUUID()}" else cleaned.take(180)
276
+ }
277
+
278
+ private fun defaultFileName(mimeType: String): String {
279
+ val extension = mimeType.substringAfter('/', "bin").lowercase(Locale.ROOT)
280
+ return "media-${UUID.randomUUID()}.$extension"
281
+ }
282
+
283
+ private fun sha256(value: String): String {
284
+ val digest = MessageDigest.getInstance("SHA-256").digest(value.toByteArray())
285
+ return digest.joinToString("") { byte -> "%02x".format(byte) }
286
+ }
287
+
288
+ private fun String.toFileOrNull(): File? {
289
+ return if (startsWith("file://")) runCatching { File(Uri.parse(this).path ?: return null) }.getOrNull() else null
290
+ }
291
+ }