@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 +90 -0
- package/android/build.gradle +19 -0
- package/android/src/main/AndroidManifest.xml +7 -0
- package/android/src/main/java/com/gaozh1024/photopicker/PhotoPickerContract.kt +82 -0
- package/android/src/main/java/com/gaozh1024/photopicker/PhotoPickerModule.kt +291 -0
- package/dist/index.d.mts +218 -0
- package/dist/index.d.ts +218 -0
- package/dist/index.js +620 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +568 -0
- package/dist/index.mjs.map +1 -0
- package/expo-module.config.json +6 -0
- package/package.json +62 -0
- package/react-native.config.js +8 -0
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,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
|
+
}
|