rns-mediapicker 0.0.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,69 @@
1
+ ### rns-mediapicker
2
+ A high-performance media picker for React Native and Expo. It handles image/video selection with native efficiency, automatic JPEG compression, and white-background flattening for transparent images.
3
+
4
+
5
+ ```bash
6
+ installation
7
+ yarn add rns-mediapicker
8
+ ```
9
+
10
+ ```bash
11
+ Add the plugin to your app.json or app.config.js to automate permissions and Android manifest queries.
12
+
13
+ {
14
+ "expo": {
15
+ "plugins": ["rns-mediapicker"]
16
+ }
17
+ }
18
+ ```
19
+
20
+ ```js
21
+ import FastMediaPicker from 'rns-mediapicker';
22
+
23
+ const pickMedia = async () => {
24
+ try {
25
+ const result = await FastMediaPicker.pick(
26
+ false, // useCamera: true to open camera, false for gallery
27
+ 'both', // mediaType: 'image', 'video', or 'both'
28
+ 'back' // env: 'front' or 'back' camera
29
+ );
30
+
31
+ console.log("File URI:", result.uri);
32
+ console.log("Dimensions:", result.width, "x", result.height);
33
+ console.log("Is Video:", result.isVideo);
34
+ } catch (error) {
35
+ if (error.code === 'E_CANCELLED') {
36
+ console.log('User closed the picker');
37
+ } else {
38
+ console.error('Picker Error:', error.message);
39
+ }
40
+ }
41
+ };
42
+ ```
43
+
44
+ ```bash
45
+ api-parameters
46
+ pick(useCamera, mediaType, env)
47
+
48
+ 1. useCamera (boolean): Default is false.
49
+ 2. mediaType (string): 'image', 'video', or 'both'. Default is 'both'.
50
+ 3. env (string): 'front' or 'back'. Specifically for camera mode.
51
+ ```
52
+
53
+ ```bash
54
+ response-object
55
+ {
56
+ uri: string; // path to the processed file
57
+ width: number; // media width
58
+ height: number; // media height
59
+ type: string; // 'image' or 'video'
60
+ isVideo: boolean; // helper flag
61
+ }
62
+
63
+ -features-included
64
+ 1. Android 11+ Intent Queries: Automatically handled via plugin.
65
+ 2. FileProvider: Safe URI sharing handled internally.
66
+ 3. iOS Permissions: Usage descriptions injected into Info.plist.
67
+ 4. Image Flattening: Transparent images are automatically flattened onto white backgrounds.
68
+ 5. Swipe-to-Dismiss: iOS swipe-down gestures are correctly caught as cancellations.
69
+ ```
@@ -0,0 +1,70 @@
1
+ // 1. Buildscript defines WHERE to get the plugins
2
+ buildscript {
3
+ ext.kotlin_version = project.hasProperty('kotlinVersion') ? project.kotlinVersion : '1.8.10'
4
+ repositories {
5
+ google()
6
+ mavenCentral()
7
+ }
8
+ dependencies {
9
+ classpath "com.android.tools.build:gradle:7.4.2"
10
+ classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
11
+ }
12
+ }
13
+
14
+ // 2. Apply the plugins immediately after buildscript
15
+ apply plugin: 'com.android.library'
16
+ apply plugin: 'kotlin-android'
17
+
18
+ // 3. Define helper functions
19
+ def safeExtGet(prop, fallback) {
20
+ rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
21
+ }
22
+
23
+ // 4. The Android block - should now be recognized
24
+ android {
25
+ // Crucial for AGP 7+
26
+ namespace "com.rnsnativecall"
27
+
28
+ compileSdkVersion safeExtGet('compileSdkVersion', 33)
29
+
30
+ defaultConfig {
31
+ minSdkVersion safeExtGet('minSdkVersion', 21)
32
+ targetSdkVersion safeExtGet('targetSdkVersion', 33)
33
+
34
+ testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
35
+ }
36
+
37
+ compileOptions {
38
+ sourceCompatibility JavaVersion.VERSION_17
39
+ targetCompatibility JavaVersion.VERSION_17
40
+ }
41
+
42
+ kotlinOptions {
43
+ jvmTarget = '17' // Changed from 1.8
44
+ }
45
+
46
+ // lintOptions is deprecated in newer AGP, using lint instead
47
+ lint {
48
+ abortOnError false
49
+ }
50
+ }
51
+
52
+ repositories {
53
+ mavenCentral()
54
+ google()
55
+ }
56
+
57
+ dependencies {
58
+ implementation "com.facebook.react:react-native:+"
59
+
60
+ implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
61
+ implementation "com.google.firebase:firebase-messaging:23.4.0"
62
+ implementation "androidx.appcompat:appcompat:1.6.1"
63
+
64
+ implementation "com.google.android.material:material:1.9.0"
65
+ implementation "androidx.core:core-ktx:1.12.0"
66
+
67
+ // Glide for profile pictures
68
+ implementation "com.github.bumptech.glide:glide:4.15.1"
69
+ annotationProcessor "com.github.bumptech.glide:compiler:4.15.1"
70
+ }
@@ -0,0 +1,372 @@
1
+ package com.rnsmediapicker
2
+
3
+ import android.app.Activity
4
+ import android.content.Intent
5
+ import android.graphics.*
6
+ import android.media.MediaMetadataRetriever
7
+ import android.net.Uri
8
+ import android.os.Build
9
+ import android.provider.MediaStore
10
+ import android.webkit.MimeTypeMap
11
+ import androidx.core.content.FileProvider
12
+ import com.facebook.react.bridge.*
13
+ import com.facebook.react.module.annotations.ReactModule
14
+ import com.facebook.react.modules.core.DeviceEventManagerModule
15
+ import com.facebook.react.modules.core.PermissionAwareActivity
16
+ import com.facebook.react.modules.core.PermissionListener
17
+ import java.io.File
18
+ import java.io.FileOutputStream
19
+ import java.util.*
20
+
21
+ @ReactModule(name = "FastMediaPicker")
22
+ class FastMediaPickerModule(
23
+ private val reactContext: ReactApplicationContext,
24
+ ) : ReactContextBaseJavaModule(reactContext),
25
+ ActivityEventListener,
26
+ PermissionListener {
27
+ private var pickerPromise: Promise? = null
28
+ private var cameraCaptureUri: Uri? = null
29
+ private var pendingFrontCamera = false
30
+
31
+ companion object {
32
+ private const val PICKER_REQUEST_CODE = 4123
33
+ private const val CAMERA_PERMISSION_REQUEST = 9123
34
+ private const val MAX_IMAGE_SIZE = 1200
35
+ private const val JPEG_QUALITY = 85
36
+ }
37
+
38
+ init {
39
+ reactContext.addActivityEventListener(this)
40
+ }
41
+
42
+ override fun getName(): String = "FastMediaPicker"
43
+
44
+ // -------------------- REQUIRED EVENT CONTRACT --------------------
45
+
46
+ @ReactMethod
47
+ fun addListener(eventName: String) {
48
+ // Required for RN event emitter (even if unused)
49
+ }
50
+
51
+ @ReactMethod
52
+ fun removeListeners(count: Int) {
53
+ // Required for RN event emitter (even if unused)
54
+ }
55
+
56
+ private fun sendLog(message: String) {
57
+ android.util.Log.d("FastMediaPicker", message)
58
+ try {
59
+ reactContext
60
+ .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
61
+ .emit("onPickerLog", message)
62
+ } catch (_: Exception) {
63
+ }
64
+ }
65
+
66
+ // -------------------- PUBLIC API --------------------
67
+
68
+ @ReactMethod
69
+ fun pick(
70
+ useCamera: Boolean,
71
+ mediaType: String,
72
+ env: String?,
73
+ promise: Promise,
74
+ ) {
75
+ val activity =
76
+ currentActivity ?: run {
77
+ promise.reject("E_ACTIVITY_DOES_NOT_EXIST", "Activity doesn't exist")
78
+ return
79
+ }
80
+
81
+ cleanup()
82
+ pickerPromise = promise
83
+ pendingFrontCamera = env?.lowercase() == "front"
84
+
85
+ try {
86
+ if (useCamera) {
87
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M &&
88
+ activity.checkSelfPermission(android.Manifest.permission.CAMERA)
89
+ != android.content.pm.PackageManager.PERMISSION_GRANTED
90
+ ) {
91
+ val permissionAwareActivity =
92
+ activity as? PermissionAwareActivity
93
+ ?: run {
94
+ promise.reject("E_ACTIVITY_NOT_PERMISSION_AWARE", "Activity not PermissionAware")
95
+ cleanup()
96
+ return
97
+ }
98
+
99
+ permissionAwareActivity.requestPermissions(
100
+ arrayOf(android.Manifest.permission.CAMERA),
101
+ CAMERA_PERMISSION_REQUEST,
102
+ this,
103
+ )
104
+ return
105
+ }
106
+ launchCamera(activity, pendingFrontCamera)
107
+ } else {
108
+ launchPicker(activity, mediaType.lowercase())
109
+ }
110
+ } catch (e: Exception) {
111
+ promise.reject("E_LAUNCH_FAILED", e.message)
112
+ cleanup()
113
+ }
114
+ }
115
+
116
+ // -------------------- PICKER / CAMERA --------------------
117
+
118
+ private fun launchPicker(
119
+ activity: Activity,
120
+ type: String,
121
+ ) {
122
+ val intent =
123
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
124
+ Intent(MediaStore.ACTION_PICK_IMAGES).apply {
125
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
126
+ this.type =
127
+ when (type) {
128
+ "image" -> "image/*"
129
+ "video" -> "video/*"
130
+ else -> "*/*"
131
+ }
132
+ }
133
+ } else {
134
+ Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
135
+ addCategory(Intent.CATEGORY_OPENABLE)
136
+ addFlags(
137
+ Intent.FLAG_GRANT_READ_URI_PERMISSION or
138
+ Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION,
139
+ )
140
+ this.type =
141
+ when (type) {
142
+ "image" -> "image/*"
143
+ "video" -> "video/*"
144
+ else -> "*/*"
145
+ }
146
+ }
147
+ }
148
+
149
+ activity.startActivityForResult(intent, PICKER_REQUEST_CODE)
150
+ }
151
+
152
+ private fun launchCamera(
153
+ activity: Activity,
154
+ useFrontCamera: Boolean,
155
+ ) {
156
+ val photoFile = createTempFile("jpg")
157
+ val authority = "${reactContext.packageName}.provider"
158
+ cameraCaptureUri = FileProvider.getUriForFile(reactContext, authority, photoFile)
159
+
160
+ val intent =
161
+ Intent(MediaStore.ACTION_IMAGE_CAPTURE).apply {
162
+ putExtra(MediaStore.EXTRA_OUTPUT, cameraCaptureUri)
163
+ addFlags(
164
+ Intent.FLAG_GRANT_READ_URI_PERMISSION or
165
+ Intent.FLAG_GRANT_WRITE_URI_PERMISSION,
166
+ )
167
+ if (useFrontCamera) {
168
+ putExtra("android.intent.extras.CAMERA_FACING", 1)
169
+ putExtra("android.intent.extra.USE_FRONT_CAMERA", true)
170
+ }
171
+ }
172
+
173
+ activity.startActivityForResult(intent, PICKER_REQUEST_CODE)
174
+ }
175
+
176
+ // -------------------- ACTIVITY CALLBACKS --------------------
177
+
178
+ override fun onActivityResult(
179
+ activity: Activity?,
180
+ requestCode: Int,
181
+ resultCode: Int,
182
+ data: Intent?,
183
+ ) {
184
+ if (requestCode != PICKER_REQUEST_CODE || pickerPromise == null) return
185
+
186
+ if (resultCode == Activity.RESULT_CANCELED) {
187
+ pickerPromise?.reject("E_CANCELLED", "User cancelled")
188
+ cleanup()
189
+ return
190
+ }
191
+
192
+ val uri = data?.data ?: cameraCaptureUri
193
+
194
+ if (uri != null && Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
195
+ try {
196
+ reactContext.contentResolver.takePersistableUriPermission(
197
+ uri,
198
+ Intent.FLAG_GRANT_READ_URI_PERMISSION,
199
+ )
200
+ } catch (_: Exception) {
201
+ }
202
+ }
203
+
204
+ if (uri != null) {
205
+ processMedia(uri)
206
+ } else {
207
+ pickerPromise?.reject("E_NO_URI", "No media selected")
208
+ cleanup()
209
+ }
210
+ }
211
+
212
+ override fun onRequestPermissionsResult(
213
+ requestCode: Int,
214
+ permissions: Array<String>,
215
+ grantResults: IntArray,
216
+ ): Boolean {
217
+ if (requestCode == CAMERA_PERMISSION_REQUEST) {
218
+ if (grantResults.isNotEmpty() &&
219
+ grantResults[0] == android.content.pm.PackageManager.PERMISSION_GRANTED
220
+ ) {
221
+ currentActivity?.let {
222
+ launchCamera(it, pendingFrontCamera)
223
+ }
224
+ } else {
225
+ pickerPromise?.reject("E_CAMERA_PERMISSION", "Camera permission denied")
226
+ cleanup()
227
+ }
228
+ return true
229
+ }
230
+ return false
231
+ }
232
+
233
+ // -------------------- MEDIA PROCESSING --------------------
234
+
235
+ private fun processMedia(uri: Uri) {
236
+ val resolver = reactContext.contentResolver
237
+ val mimeType = resolver.getType(uri) ?: "image/jpeg"
238
+ val isVideo = mimeType.startsWith("video/")
239
+
240
+ try {
241
+ if (isVideo) {
242
+ val ext =
243
+ MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType) ?: "mp4"
244
+ val tempFile = createTempFile(ext)
245
+
246
+ resolver.openInputStream(uri)?.use { input ->
247
+ FileOutputStream(tempFile).use { output -> input.copyTo(output) }
248
+ }
249
+
250
+ val (w, h) = getVideoDimensions(tempFile)
251
+ resolveResult(Uri.fromFile(tempFile).toString(), true, w, h)
252
+ } else {
253
+ val tempFile = processAndSaveImage(uri)
254
+ val opts = BitmapFactory.Options().apply { inJustDecodeBounds = true }
255
+ BitmapFactory.decodeFile(tempFile.absolutePath, opts)
256
+ resolveResult(
257
+ Uri.fromFile(tempFile).toString(),
258
+ false,
259
+ opts.outWidth,
260
+ opts.outHeight,
261
+ )
262
+ }
263
+ } catch (e: Exception) {
264
+ pickerPromise?.reject("E_PROCESS_ERROR", e.message)
265
+ } finally {
266
+ cleanup()
267
+ }
268
+ }
269
+
270
+ private fun getVideoDimensions(file: File): Pair<Int, Int> {
271
+ val retriever = MediaMetadataRetriever()
272
+ return try {
273
+ retriever.setDataSource(file.absolutePath)
274
+ val w =
275
+ retriever
276
+ .extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)
277
+ ?.toInt() ?: 0
278
+ val h =
279
+ retriever
280
+ .extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)
281
+ ?.toInt() ?: 0
282
+ val rot =
283
+ retriever
284
+ .extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)
285
+ ?.toInt() ?: 0
286
+ if (rot == 90 || rot == 270) h to w else w to h
287
+ } finally {
288
+ retriever.release()
289
+ }
290
+ }
291
+
292
+ private fun processAndSaveImage(uri: Uri): File {
293
+ val tempFile = createTempFile("jpg")
294
+ val resolver = reactContext.contentResolver
295
+
296
+ val opts =
297
+ BitmapFactory.Options().apply {
298
+ inJustDecodeBounds = true
299
+ resolver.openInputStream(uri)?.use {
300
+ BitmapFactory.decodeStream(it, null, this)
301
+ }
302
+ inSampleSize =
303
+ calculateInSampleSize(outWidth, outHeight, MAX_IMAGE_SIZE, MAX_IMAGE_SIZE)
304
+ inJustDecodeBounds = false
305
+ }
306
+
307
+ val original =
308
+ resolver.openInputStream(uri)?.use {
309
+ BitmapFactory.decodeStream(it, null, opts)
310
+ } ?: throw RuntimeException("Decode failed")
311
+
312
+ val result =
313
+ Bitmap.createBitmap(original.width, original.height, Bitmap.Config.ARGB_8888)
314
+
315
+ Canvas(result).apply {
316
+ drawColor(Color.WHITE)
317
+ drawBitmap(original, 0f, 0f, null)
318
+ }
319
+
320
+ FileOutputStream(tempFile).use {
321
+ result.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, it)
322
+ }
323
+
324
+ original.recycle()
325
+ result.recycle()
326
+ return tempFile
327
+ }
328
+
329
+ private fun calculateInSampleSize(
330
+ w: Int,
331
+ h: Int,
332
+ reqW: Int,
333
+ reqH: Int,
334
+ ): Int {
335
+ var size = 1
336
+ if (h > reqH || w > reqW) {
337
+ val halfH = h / 2
338
+ val halfW = w / 2
339
+ while (halfH / size >= reqH && halfW / size >= reqW) {
340
+ size *= 2
341
+ }
342
+ }
343
+ return size
344
+ }
345
+
346
+ private fun resolveResult(
347
+ uri: String,
348
+ isVideo: Boolean,
349
+ w: Int,
350
+ h: Int,
351
+ ) {
352
+ val map =
353
+ Arguments.createMap().apply {
354
+ putString("uri", uri)
355
+ putBoolean("isVideo", isVideo)
356
+ putString("type", if (isVideo) "video" else "image")
357
+ putDouble("width", w.toDouble())
358
+ putDouble("height", h.toDouble())
359
+ }
360
+ pickerPromise?.resolve(map)
361
+ }
362
+
363
+ private fun createTempFile(ext: String): File = File(reactContext.cacheDir, "fast-${UUID.randomUUID()}.$ext")
364
+
365
+ private fun cleanup() {
366
+ pickerPromise = null
367
+ cameraCaptureUri = null
368
+ pendingFrontCamera = false
369
+ }
370
+
371
+ override fun onNewIntent(intent: Intent?) {}
372
+ }
@@ -0,0 +1,13 @@
1
+ package com.rnsmediapicker
2
+
3
+ import com.facebook.react.ReactPackage
4
+ import com.facebook.react.bridge.NativeModule
5
+ import com.facebook.react.bridge.ReactApplicationContext
6
+ import com.facebook.react.uimanager.ViewManager
7
+
8
+ class FastMediaPickerPackage : ReactPackage {
9
+ override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> =
10
+ listOf(FastMediaPickerModule(reactContext))
11
+
12
+ override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> = emptyList()
13
+ }
@@ -0,0 +1,5 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <paths>
3
+ <external-path name="my_images" path="." />
4
+ <cache-path name="cache" path="." />
5
+ </paths>
package/app.plugin.js ADDED
@@ -0,0 +1,5 @@
1
+ const withMediaPicker = require('./withMediaPicker');
2
+
3
+ module.exports = function (config) {
4
+ return withMediaPicker(config);
5
+ };
package/index.d.ts ADDED
@@ -0,0 +1,32 @@
1
+ declare module 'rns-mediapicker' {
2
+ export interface PickerResponse {
3
+ /** The local file URI of the processed media */
4
+ uri: string;
5
+ /** Width of the image or video in pixels */
6
+ width: number;
7
+ /** Height of the image or video in pixels */
8
+ height: number;
9
+ /** The type of media: 'image' or 'video' */
10
+ type: 'image' | 'video';
11
+ /** Helper boolean for quick video checking */
12
+ isVideo: boolean;
13
+ }
14
+
15
+ export interface FastMediaPickerType {
16
+ /**
17
+ * Launch the media picker or camera.
18
+ * * @param useCamera - Set to true to open the camera directly. Defaults to false.
19
+ * @param mediaType - Specify 'image', 'video', or 'both'. Defaults to 'both'.
20
+ * @param env - Use 'front' to specify the front camera on Android/iOS.
21
+ * @returns A promise that resolves with the media metadata.
22
+ */
23
+ pick(
24
+ useCamera?: boolean,
25
+ mediaType?: 'image' | 'video' | 'both',
26
+ env?: 'front' | 'back' | string
27
+ ): Promise<PickerResponse>;
28
+ }
29
+
30
+ const FastMediaPicker: FastMediaPickerType;
31
+ export default FastMediaPicker;
32
+ }
package/index.js ADDED
@@ -0,0 +1,36 @@
1
+ import { NativeModules, TurboModuleRegistry, Platform } from 'react-native';
2
+
3
+ // 1. Try to find the module in the new TurboModuleRegistry or the legacy NativeModules
4
+ const NativeModule = NativeModules.FastMediaPicker
5
+ ? NativeModules.FastMediaPicker
6
+ : TurboModuleRegistry.get('FastMediaPicker');
7
+
8
+ /**
9
+ * High-performance media picker for React Native.
10
+ * Supports image and video picking with automatic compression and white-background flattening.
11
+ */
12
+ const FastMediaPicker = {
13
+ /**
14
+ * Launch the media picker or camera.
15
+ * * @param {boolean} useCamera - Whether to open the camera directly.
16
+ * @param {string} mediaType - 'image', 'video', or 'both'.
17
+ * @param {string} env - Use 'front' for front camera on supported devices.
18
+ * @returns {Promise<{uri: string, width: number, height: number, type: string, isVideo: boolean}>}
19
+ */
20
+ pick: async (useCamera = false, mediaType = 'both', env = '') => {
21
+ if (!NativeModule) {
22
+ throw new Error(
23
+ "FastMediaPicker: Native module not found. Ensure you have run 'npx expo prebuild' and rebuilt the native app."
24
+ );
25
+ }
26
+
27
+ try {
28
+ // In some RN versions, TurboModules require the exact argument count
29
+ return await NativeModule.pick(useCamera, mediaType, env);
30
+ } catch (error) {
31
+ throw error;
32
+ }
33
+ }
34
+ };
35
+
36
+ export default FastMediaPicker;
@@ -0,0 +1,11 @@
1
+ #import <React/RCTBridgeModule.h>
2
+
3
+ @interface RCT_EXTERN_MODULE(FastMediaPicker, NSObject)
4
+
5
+ RCT_EXTERN_METHOD(pick:(BOOL)useCamera
6
+ mediaType:(NSString *)mediaType
7
+ env:(NSString *)env
8
+ resolver:(RCTPromiseResolveBlock)resolve
9
+ rejecter:(RCTPromiseRejectBlock)reject)
10
+
11
+ @end
@@ -0,0 +1,194 @@
1
+ import AVFoundation
2
+ import Photos
3
+ import UIKit
4
+ import UniformTypeIdentifiers
5
+ import React
6
+
7
+ @objc(FastMediaPicker)
8
+ // 1. Added UIAdaptivePresentationControllerDelegate to the class signature
9
+ class FastMediaPicker: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate, UIAdaptivePresentationControllerDelegate, RCTBridgeModule {
10
+
11
+ static func moduleName() -> String! {
12
+ return "FastMediaPicker"
13
+ }
14
+
15
+ static func requiresMainQueueSetup() -> Bool {
16
+ return true
17
+ }
18
+
19
+ private var resolve: RCTPromiseResolveBlock?
20
+ private var reject: RCTPromiseRejectBlock?
21
+
22
+ @objc(pick:mediaType:env:resolver:rejecter:)
23
+ func pick(
24
+ _ useCamera: Bool,
25
+ mediaType: String,
26
+ env: String?,
27
+ resolver resolve: @escaping RCTPromiseResolveBlock,
28
+ rejecter reject: @escaping RCTPromiseRejectBlock
29
+ ) {
30
+ self.resolve = resolve
31
+ self.reject = reject
32
+
33
+ DispatchQueue.main.async {
34
+ let picker = UIImagePickerController()
35
+ picker.delegate = self
36
+ // 2. Set the presentation controller delegate to catch swipes
37
+ picker.presentationController?.delegate = self
38
+ picker.allowsEditing = false
39
+
40
+ if useCamera {
41
+ guard UIImagePickerController.isSourceTypeAvailable(.camera) else {
42
+ self.safeReject("E_CAMERA_UNAVAILABLE", "Camera not available")
43
+ return
44
+ }
45
+ picker.sourceType = .camera
46
+ if let env = env?.lowercased(), env == "front" {
47
+ picker.cameraDevice = .front
48
+ }
49
+ } else {
50
+ picker.sourceType = .photoLibrary
51
+ }
52
+
53
+ let typeLower = mediaType.lowercased()
54
+ if typeLower == "image" {
55
+ picker.mediaTypes = [UTType.image.identifier]
56
+ } else if typeLower == "video" {
57
+ picker.mediaTypes = [UTType.movie.identifier]
58
+ picker.videoQuality = .typeHigh
59
+ } else {
60
+ picker.mediaTypes = [UTType.image.identifier, UTType.movie.identifier]
61
+ }
62
+
63
+ if let vc = self.topMostViewController() {
64
+ vc.present(picker, animated: true, completion: nil)
65
+ } else {
66
+ self.safeReject("E_NO_VC", "Could not find view controller")
67
+ }
68
+ }
69
+ }
70
+
71
+ // MARK: - Delegate Methods
72
+
73
+ // Handles the "Cancel" button tap
74
+ func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
75
+ picker.dismiss(animated: true) {
76
+ self.safeReject("E_CANCELLED", "User cancelled")
77
+ }
78
+ }
79
+
80
+ // 3. Handles the swipe-down gesture dismissal
81
+ func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
82
+ self.safeReject("E_CANCELLED", "User cancelled via swipe")
83
+ }
84
+
85
+ func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
86
+ picker.dismiss(animated: true)
87
+
88
+ if let mediaURL = info[.mediaURL] as? URL {
89
+ handlePickedVideo(url: mediaURL)
90
+ } else if let image = (info[.editedImage] as? UIImage) ?? (info[.originalImage] as? UIImage) {
91
+ processPickedImage(image)
92
+ } else {
93
+ safeReject("E_NO_MEDIA", "Failed to retrieve media")
94
+ }
95
+ }
96
+
97
+ // MARK: - Handlers
98
+ private func processPickedImage(_ image: UIImage) {
99
+ let fileURL = makeTempURL(ext: "jpg")
100
+
101
+ // Create a white background to prevent transparent areas from turning black
102
+ let format = UIGraphicsImageRendererFormat()
103
+ format.scale = image.scale
104
+ format.opaque = true // This forces a solid background
105
+
106
+ let renderer = UIGraphicsImageRenderer(size: image.size, format: format)
107
+ let whiteBgImage = renderer.image { context in
108
+ UIColor.white.setFill()
109
+ context.fill(CGRect(origin: .zero, size: image.size))
110
+ image.draw(at: .zero)
111
+ }
112
+
113
+ // Convert the flattened image to JPEG
114
+ guard let data = whiteBgImage.jpegData(compressionQuality: 0.8) else {
115
+ safeReject("E_ENCODE", "JPEG conversion failed")
116
+ return
117
+ }
118
+
119
+ do {
120
+ try data.write(to: fileURL)
121
+ resolve?([
122
+ "uri": fileURL.absoluteString,
123
+ "width": image.size.width,
124
+ "height": image.size.height,
125
+ "type": "image",
126
+ "isVideo": false
127
+ ])
128
+ cleanupPromise()
129
+ } catch {
130
+ safeReject("E_WRITE", error.localizedDescription)
131
+ }
132
+ }
133
+
134
+ private func handlePickedVideo(url: URL) {
135
+ // 4. Force lowercase extension
136
+ let ext = url.pathExtension.isEmpty ? "mp4" : url.pathExtension.lowercased()
137
+ let fileURL = makeTempURL(ext: ext)
138
+
139
+ do {
140
+ if FileManager.default.fileExists(atPath: fileURL.path) {
141
+ try FileManager.default.removeItem(at: fileURL)
142
+ }
143
+ try FileManager.default.copyItem(at: url, to: fileURL)
144
+ let dims = videoDimensions(for: fileURL)
145
+ resolve?([
146
+ "uri": fileURL.absoluteString,
147
+ "width": dims.width,
148
+ "height": dims.height,
149
+ "type": "video",
150
+ "isVideo": true // Added isVideo
151
+ ])
152
+ cleanupPromise()
153
+ } catch {
154
+ safeReject("E_VIDEO_COPY", error.localizedDescription)
155
+ }
156
+ }
157
+
158
+ // MARK: - Helpers
159
+ private func safeReject(_ code: String, _ message: String) {
160
+ self.reject?(code, message, nil)
161
+ cleanupPromise()
162
+ }
163
+
164
+ private func cleanupPromise() {
165
+ resolve = nil
166
+ reject = nil
167
+ }
168
+
169
+ private func topMostViewController() -> UIViewController? {
170
+ let keyWindow = UIApplication.shared.connectedScenes
171
+ .compactMap { $0 as? UIWindowScene }
172
+ .flatMap { $0.windows }
173
+ .first { $0.isKeyWindow }
174
+
175
+ var topController = keyWindow?.rootViewController
176
+ while let presented = topController?.presentedViewController {
177
+ topController = presented
178
+ }
179
+ return topController
180
+ }
181
+
182
+ private func makeTempURL(ext: String) -> URL {
183
+ let tempDir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
184
+ // Ensure ext is lowercase here just in case
185
+ return tempDir.appendingPathComponent("fastpicker-\(UUID().uuidString).\(ext.lowercased())")
186
+ }
187
+
188
+ private func videoDimensions(for url: URL) -> (width: CGFloat, height: CGFloat) {
189
+ let asset = AVAsset(url: url)
190
+ guard let track = asset.tracks(withMediaType: .video).first else { return (0, 0) }
191
+ let size = track.naturalSize.applying(track.preferredTransform)
192
+ return (width: abs(size.width), height: abs(size.height))
193
+ }
194
+ }
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "rns-mediapicker",
3
+ "version": "0.0.1",
4
+ "description": "High-performance React Native module for picking media on Android and iOS.",
5
+ "main": "index.js",
6
+ "types": "index.d.ts",
7
+ "expo": {
8
+ "autolinking": {
9
+ "ios": {
10
+ "podspec": "rns-mediapicker.podspec"
11
+ }
12
+ },
13
+ "plugin": "./app.plugin.js"
14
+ },
15
+ "homepage": "https://github.com/raiidr/rns-mediapicker",
16
+ "license": "MIT",
17
+ "author": "Raiidr <info@raiidr.com>",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/raiidr/rns-mediapicker.git"
21
+ },
22
+ "scripts": {
23
+ "p": "npm publish --access public"
24
+ },
25
+ "keywords": [
26
+ "react-native",
27
+ "mediapicker",
28
+ "images",
29
+ "videos",
30
+ "android",
31
+ "ios",
32
+ "expo",
33
+ "config-plugin"
34
+ ],
35
+ "files": [
36
+ "android",
37
+ "ios",
38
+ "app.plugin.js",
39
+ "index.d.ts",
40
+ "index.js",
41
+ "react-native.config.js",
42
+ "rns-mediapicker.podspec",
43
+ "withMediaPicker.js"
44
+ ],
45
+ "peerDependencies": {
46
+ "expo": ">=45.0.0",
47
+ "react-native": ">=0.60.0"
48
+ },
49
+ "dependencies": {
50
+ "@expo/config-plugins": "^9.0.0"
51
+ }
52
+ }
@@ -0,0 +1,11 @@
1
+ module.exports = {
2
+ dependencies: {
3
+ 'rns-mediapicker': {
4
+ platforms: {
5
+ ios: {
6
+ podspecPath: 'rns-mediapicker.podspec',
7
+ },
8
+ },
9
+ },
10
+ },
11
+ };
@@ -0,0 +1,18 @@
1
+ require "json"
2
+
3
+ package = JSON.parse(File.read(File.join(__dir__, "package.json")))
4
+
5
+ Pod::Spec.new do |s|
6
+ s.name = "rns-mediapicker"
7
+ s.version = package["version"]
8
+ s.summary = package["description"]
9
+ s.homepage = package["homepage"]
10
+ s.license = package["license"]
11
+ s.authors = package["author"]
12
+ s.platforms = { :ios => "13.4" }
13
+ s.source = { :git => "https://github.com/raiidr/rns-mediapicker.git", :tag => "#{s.version}" }
14
+
15
+ s.source_files = "ios/**/*.{h,m,mm,swift}"
16
+
17
+ s.dependency "React-Core"
18
+ end
@@ -0,0 +1,116 @@
1
+ const { withAndroidManifest, withInfoPlist, AndroidConfig } = require('@expo/config-plugins');
2
+
3
+ const { getMainApplicationOrThrow } = AndroidConfig.Manifest;
4
+
5
+ /**
6
+ * Android Configuration
7
+ */
8
+ const withAndroidConfig = (config) => {
9
+ return withAndroidManifest(config, (config) => {
10
+ const androidManifest = config.modResults;
11
+
12
+ // 1. Add Permissions
13
+ const permissions = [
14
+ 'android.permission.CAMERA',
15
+ 'android.permission.READ_EXTERNAL_STORAGE',
16
+ 'android.permission.WRITE_EXTERNAL_STORAGE',
17
+ 'android.permission.READ_MEDIA_IMAGES',
18
+ 'android.permission.READ_MEDIA_VIDEO',
19
+ ];
20
+
21
+ if (!androidManifest.manifest['uses-permission']) {
22
+ androidManifest.manifest['uses-permission'] = [];
23
+ }
24
+
25
+ permissions.forEach((perm) => {
26
+ if (!androidManifest.manifest['uses-permission'].find((p) => p.$['android:name'] === perm)) {
27
+ androidManifest.manifest['uses-permission'].push({ $: { 'android:name': perm } });
28
+ }
29
+ });
30
+
31
+ // 2. Add Queries (For Android 11+ visibility)
32
+ if (!androidManifest.manifest.queries) {
33
+ androidManifest.manifest.queries = [{}];
34
+ }
35
+ const queries = androidManifest.manifest.queries[0];
36
+ if (!queries.intent) queries.intent = [];
37
+
38
+ const pickerIntents = [
39
+ { action: [{ $: { 'android:name': 'android.media.action.IMAGE_CAPTURE' } }] },
40
+ { action: [{ $: { 'android:name': 'android.provider.action.PICK_IMAGES' } }] },
41
+ {
42
+ action: [{ $: { 'android:name': 'android.intent.action.OPEN_DOCUMENT' } }],
43
+ data: [{ $: { 'android:mimeType': 'image/*' } }]
44
+ },
45
+ {
46
+ action: [{ $: { 'android:name': 'android.intent.action.OPEN_DOCUMENT' } }],
47
+ data: [{ $: { 'android:mimeType': 'video/*' } }]
48
+ }
49
+ ];
50
+
51
+ pickerIntents.forEach((intent) => {
52
+ const intentName = intent.action[0].$['android:name'];
53
+ const exists = queries.intent.find(i => i.action[0].$['android:name'] === intentName);
54
+ if (!exists) queries.intent.push(intent);
55
+ });
56
+
57
+ // 3. Ensure FileProvider exists (The "Null" Fix)
58
+ const mainApplication = getMainApplicationOrThrow(androidManifest);
59
+ if (!mainApplication.provider) {
60
+ mainApplication.provider = [];
61
+ }
62
+
63
+ const providerClassName = 'androidx.core.content.FileProvider';
64
+ const providerAuthority = '${applicationId}.provider';
65
+
66
+ const hasProvider = mainApplication.provider.find(
67
+ (p) => p.$['android:name'] === providerClassName || p.$['android:authorities'] === providerAuthority
68
+ );
69
+
70
+ if (!hasProvider) {
71
+ mainApplication.provider.push({
72
+ $: {
73
+ 'android:name': providerClassName,
74
+ 'android:authorities': providerAuthority,
75
+ 'android:exported': 'false',
76
+ 'android:grantUriPermissions': 'true',
77
+ },
78
+ 'meta-data': [
79
+ {
80
+ $: {
81
+ 'android:name': 'android.support.FILE_PROVIDER_PATHS',
82
+ 'android:resource': '@xml/file_paths',
83
+ },
84
+ },
85
+ ],
86
+ });
87
+ }
88
+
89
+ return config;
90
+ });
91
+ };
92
+
93
+ /**
94
+ * iOS Configuration
95
+ */
96
+ const withIosConfig = (config) => {
97
+ return withInfoPlist(config, (config) => {
98
+ config.modResults.NSCameraUsageDescription =
99
+ config.modResults.NSCameraUsageDescription || "Allow $(PRODUCT_NAME) to access your camera to take photos and videos.";
100
+ config.modResults.NSPhotoLibraryUsageDescription =
101
+ config.modResults.NSPhotoLibraryUsageDescription || "Allow $(PRODUCT_NAME) to access your photo library to select media.";
102
+ config.modResults.NSMicrophoneUsageDescription =
103
+ config.modResults.NSMicrophoneUsageDescription || "Allow $(PRODUCT_NAME) to access your microphone when recording video.";
104
+
105
+ return config;
106
+ });
107
+ };
108
+
109
+ /**
110
+ * Export the Plugin
111
+ */
112
+ module.exports = function withMediaPicker(config) {
113
+ config = withAndroidConfig(config);
114
+ config = withIosConfig(config);
115
+ return config;
116
+ };