react-native-ensys-camera-x 1.0.7
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/CustomCamera.podspec +20 -0
- package/LICENSE +20 -0
- package/README.md +833 -0
- package/android/build.gradle +135 -0
- package/android/consumer-rules.pro +47 -0
- package/android/src/main/AndroidManifest.xml +72 -0
- package/android/src/main/java/android/print/PdfPrint.java +51 -0
- package/android/src/main/java/com/customcamera/AnimatedImageViewManager.kt +199 -0
- package/android/src/main/java/com/customcamera/AudioPlayerView.kt +322 -0
- package/android/src/main/java/com/customcamera/AudioRecorder.kt +506 -0
- package/android/src/main/java/com/customcamera/BarcodeScannerActivity.kt +361 -0
- package/android/src/main/java/com/customcamera/BlurBackgroundView.kt +199 -0
- package/android/src/main/java/com/customcamera/CacheCleanupManager.kt +386 -0
- package/android/src/main/java/com/customcamera/CameraAspectRatioMode.kt +77 -0
- package/android/src/main/java/com/customcamera/CameraAsset.kt +211 -0
- package/android/src/main/java/com/customcamera/CameraTheme.kt +479 -0
- package/android/src/main/java/com/customcamera/CompressionCacheStore.kt +504 -0
- package/android/src/main/java/com/customcamera/CompressionFileNameUtils.kt +7 -0
- package/android/src/main/java/com/customcamera/CompressionProfile.kt +195 -0
- package/android/src/main/java/com/customcamera/CustomCameraActivity.kt +7291 -0
- package/android/src/main/java/com/customcamera/CustomCameraFileProvider.kt +9 -0
- package/android/src/main/java/com/customcamera/CustomCameraModule.kt +2479 -0
- package/android/src/main/java/com/customcamera/CustomCameraPackage.kt +53 -0
- package/android/src/main/java/com/customcamera/DisplayUtils.kt +9 -0
- package/android/src/main/java/com/customcamera/DocumentPreviewView.kt +846 -0
- package/android/src/main/java/com/customcamera/DrawingView.kt +214 -0
- package/android/src/main/java/com/customcamera/EmojiPickerData.kt +228 -0
- package/android/src/main/java/com/customcamera/FilterStripAdapter.kt +158 -0
- package/android/src/main/java/com/customcamera/GifSupport.kt +215 -0
- package/android/src/main/java/com/customcamera/GlobalAudioPlayer.kt +320 -0
- package/android/src/main/java/com/customcamera/ImageExporter.kt +1394 -0
- package/android/src/main/java/com/customcamera/ImageFormatSupport.kt +163 -0
- package/android/src/main/java/com/customcamera/Localization.kt +206 -0
- package/android/src/main/java/com/customcamera/MediaAdapters.kt +758 -0
- package/android/src/main/java/com/customcamera/MediaCacheManager.kt +782 -0
- package/android/src/main/java/com/customcamera/MediaExportFormats.kt +201 -0
- package/android/src/main/java/com/customcamera/MediaFileIo.kt +310 -0
- package/android/src/main/java/com/customcamera/MediaMetadataStore.kt +494 -0
- package/android/src/main/java/com/customcamera/MediaPreviewEditor.kt +14865 -0
- package/android/src/main/java/com/customcamera/MediaPublisher.kt +423 -0
- package/android/src/main/java/com/customcamera/MediaStorageWorkflow.kt +722 -0
- package/android/src/main/java/com/customcamera/MediaStoreRepository.kt +378 -0
- package/android/src/main/java/com/customcamera/MediaThumbnailView.kt +286 -0
- package/android/src/main/java/com/customcamera/MediaUtils.kt +37 -0
- package/android/src/main/java/com/customcamera/NativeVideoViewManager.kt +845 -0
- package/android/src/main/java/com/customcamera/OfficeDocumentConverter.kt +2791 -0
- package/android/src/main/java/com/customcamera/PendingCaptureCrops.kt +88 -0
- package/android/src/main/java/com/customcamera/PowerPointPreview.kt +4077 -0
- package/android/src/main/java/com/customcamera/RecentPickerStore.kt +74 -0
- package/android/src/main/java/com/customcamera/SecureFileEraser.kt +411 -0
- package/android/src/main/java/com/customcamera/SecureKeyModule.kt +88 -0
- package/android/src/main/java/com/customcamera/SecureMediaDeleter.kt +493 -0
- package/android/src/main/java/com/customcamera/StickerPickerData.kt +107 -0
- package/android/src/main/java/com/customcamera/SvgDrawable.kt +175 -0
- package/android/src/main/java/com/customcamera/SvgIconViewManager.kt +77 -0
- package/android/src/main/java/com/customcamera/TextEntryController.kt +553 -0
- package/android/src/main/java/com/customcamera/ThumbnailLoader.kt +455 -0
- package/android/src/main/java/com/customcamera/UploadService.kt +909 -0
- package/android/src/main/java/com/customcamera/VideoEditUtils.kt +210 -0
- package/android/src/main/java/com/customcamera/VideoRotationTranscoder.kt +1992 -0
- package/android/src/main/java/com/customcamera/gif/AnimatedGifEncoder.kt +473 -0
- package/android/src/main/java/com/customcamera/gif/GifDecoder.kt +690 -0
- package/android/src/main/java/com/customcamera/gif/GifFramesDrawable.kt +160 -0
- package/android/src/main/java/com/customcamera/gif/GifHttp.kt +112 -0
- package/android/src/main/java/com/customcamera/gif/GifImageLoader.kt +186 -0
- package/android/src/main/java/com/customcamera/gif/GifModels.kt +74 -0
- package/android/src/main/java/com/customcamera/gif/GifProvider.kt +49 -0
- package/android/src/main/java/com/customcamera/gif/GifProviders.kt +27 -0
- package/android/src/main/java/com/customcamera/gif/GifRepository.kt +124 -0
- package/android/src/main/java/com/customcamera/gif/GifSafety.kt +33 -0
- package/android/src/main/java/com/customcamera/gif/GiphyGifProvider.kt +146 -0
- package/android/src/main/java/com/customcamera/gif/LZWEncoder.kt +258 -0
- package/android/src/main/java/com/customcamera/gif/NeuQuant.kt +435 -0
- package/android/src/main/java/com/customcamera/gif/TenorGifProvider.kt +147 -0
- package/android/src/main/res/drawable/bg_brush_selected.xml +5 -0
- package/android/src/main/res/drawable/bg_color_swatch.xml +5 -0
- package/android/src/main/res/drawable/bg_editor_toolbar.xml +6 -0
- package/android/src/main/res/drawable/bg_icon_circle.xml +5 -0
- package/android/src/main/res/drawable/bg_icon_circle_light.xml +5 -0
- package/android/src/main/res/drawable/bg_mode_tab_active.xml +6 -0
- package/android/src/main/res/drawable/bg_pill.xml +6 -0
- package/android/src/main/res/drawable/bg_recording_dot.xml +6 -0
- package/android/src/main/res/drawable/bg_selection_badge.xml +8 -0
- package/android/src/main/res/drawable/bg_send_button.xml +5 -0
- package/android/src/main/res/drawable/bg_shutter_inner.xml +5 -0
- package/android/src/main/res/drawable/bg_shutter_recording.xml +6 -0
- package/android/src/main/res/drawable/bg_shutter_ring.xml +8 -0
- package/android/src/main/res/drawable/bg_shutter_video_idle.xml +5 -0
- package/android/src/main/res/drawable/bg_thumbnail.xml +9 -0
- package/android/src/main/res/drawable/ic_aspect_ratio.xml +17 -0
- package/android/src/main/res/drawable/ic_audio.xml +10 -0
- package/android/src/main/res/drawable/ic_back.xml +10 -0
- package/android/src/main/res/drawable/ic_brush_medium.xml +12 -0
- package/android/src/main/res/drawable/ic_brush_thick.xml +12 -0
- package/android/src/main/res/drawable/ic_brush_thin.xml +12 -0
- package/android/src/main/res/drawable/ic_check.xml +17 -0
- package/android/src/main/res/drawable/ic_chevron_down.xml +10 -0
- package/android/src/main/res/drawable/ic_chevron_up.xml +13 -0
- package/android/src/main/res/drawable/ic_clear_drawing.xml +10 -0
- package/android/src/main/res/drawable/ic_close.xml +10 -0
- package/android/src/main/res/drawable/ic_cover.xml +9 -0
- package/android/src/main/res/drawable/ic_crop.xml +10 -0
- package/android/src/main/res/drawable/ic_delete.xml +9 -0
- package/android/src/main/res/drawable/ic_document.xml +10 -0
- package/android/src/main/res/drawable/ic_done.xml +10 -0
- package/android/src/main/res/drawable/ic_download.xml +10 -0
- package/android/src/main/res/drawable/ic_emoji.xml +10 -0
- package/android/src/main/res/drawable/ic_filter.xml +10 -0
- package/android/src/main/res/drawable/ic_flash_auto.xml +7 -0
- package/android/src/main/res/drawable/ic_flash_off.xml +23 -0
- package/android/src/main/res/drawable/ic_flash_on.xml +16 -0
- package/android/src/main/res/drawable/ic_flip.xml +15 -0
- package/android/src/main/res/drawable/ic_gallery.xml +10 -0
- package/android/src/main/res/drawable/ic_gif.xml +24 -0
- package/android/src/main/res/drawable/ic_lock.xml +19 -0
- package/android/src/main/res/drawable/ic_lock_open.xml +22 -0
- package/android/src/main/res/drawable/ic_marker.xml +10 -0
- package/android/src/main/res/drawable/ic_more_vert.xml +18 -0
- package/android/src/main/res/drawable/ic_overlay_close.xml +19 -0
- package/android/src/main/res/drawable/ic_overlay_resize.xml +18 -0
- package/android/src/main/res/drawable/ic_pause.xml +10 -0
- package/android/src/main/res/drawable/ic_photo_mode.xml +10 -0
- package/android/src/main/res/drawable/ic_play.xml +10 -0
- package/android/src/main/res/drawable/ic_redo.xml +10 -0
- package/android/src/main/res/drawable/ic_rotate.xml +9 -0
- package/android/src/main/res/drawable/ic_send.xml +10 -0
- package/android/src/main/res/drawable/ic_share.xml +10 -0
- package/android/src/main/res/drawable/ic_stat_upload.xml +10 -0
- package/android/src/main/res/drawable/ic_sticker.xml +24 -0
- package/android/src/main/res/drawable/ic_text.xml +10 -0
- package/android/src/main/res/drawable/ic_text_align_center.xml +11 -0
- package/android/src/main/res/drawable/ic_text_align_left.xml +11 -0
- package/android/src/main/res/drawable/ic_text_align_right.xml +11 -0
- package/android/src/main/res/drawable/ic_text_bg.xml +11 -0
- package/android/src/main/res/drawable/ic_trim.xml +23 -0
- package/android/src/main/res/drawable/ic_undo.xml +10 -0
- package/android/src/main/res/drawable/ic_video_mode.xml +10 -0
- package/android/src/main/res/drawable/ic_video_note.xml +13 -0
- package/android/src/main/res/drawable/ic_volume_off.xml +9 -0
- package/android/src/main/res/drawable/ic_volume_on.xml +9 -0
- package/android/src/main/res/drawable/sticker_heart.xml +9 -0
- package/android/src/main/res/drawable/sticker_smile.xml +9 -0
- package/android/src/main/res/drawable/sticker_star.xml +9 -0
- package/android/src/main/res/drawable/sticker_thumbsup.xml +9 -0
- package/android/src/main/res/values/colors.xml +34 -0
- package/android/src/main/res/values/ids.xml +15 -0
- package/android/src/main/res/values/strings.xml +15 -0
- package/android/src/main/res/values/styles.xml +20 -0
- package/android/src/main/res/xml/file_paths.xml +34 -0
- package/ios/CustomCamera.h +5 -0
- package/ios/CustomCamera.mm +214 -0
- package/lib/module/AudioPlayerContext.js +233 -0
- package/lib/module/AudioPlayerContext.js.map +1 -0
- package/lib/module/CameraPreview.js +304 -0
- package/lib/module/CameraPreview.js.map +1 -0
- package/lib/module/NativeAnimatedImageView.js +41 -0
- package/lib/module/NativeAnimatedImageView.js.map +1 -0
- package/lib/module/NativeAudioPlayerView.js +33 -0
- package/lib/module/NativeAudioPlayerView.js.map +1 -0
- package/lib/module/NativeBlurView.js +35 -0
- package/lib/module/NativeBlurView.js.map +1 -0
- package/lib/module/NativeCustomCamera.js +128 -0
- package/lib/module/NativeCustomCamera.js.map +1 -0
- package/lib/module/NativeCustomCamera.web.js +143 -0
- package/lib/module/NativeCustomCamera.web.js.map +1 -0
- package/lib/module/NativeCustomVideoView.js +5 -0
- package/lib/module/NativeCustomVideoView.js.map +1 -0
- package/lib/module/NativeDocumentPreviewView.js +156 -0
- package/lib/module/NativeDocumentPreviewView.js.map +1 -0
- package/lib/module/NativeMediaThumbnailView.js +110 -0
- package/lib/module/NativeMediaThumbnailView.js.map +1 -0
- package/lib/module/NativeSvgIcon.js +32 -0
- package/lib/module/NativeSvgIcon.js.map +1 -0
- package/lib/module/ResponsivePreviewContainer.js +171 -0
- package/lib/module/ResponsivePreviewContainer.js.map +1 -0
- package/lib/module/accessibility.js +61 -0
- package/lib/module/accessibility.js.map +1 -0
- package/lib/module/config.js +352 -0
- package/lib/module/config.js.map +1 -0
- package/lib/module/cpuOptimization.js +296 -0
- package/lib/module/cpuOptimization.js.map +1 -0
- package/lib/module/db/DatabaseManager.js +191 -0
- package/lib/module/db/DatabaseManager.js.map +1 -0
- package/lib/module/db/MediaMapper.js +284 -0
- package/lib/module/db/MediaMapper.js.map +1 -0
- package/lib/module/db/MediaRepository.js +242 -0
- package/lib/module/db/MediaRepository.js.map +1 -0
- package/lib/module/db/index.js +41 -0
- package/lib/module/db/index.js.map +1 -0
- package/lib/module/db/migrations.js +180 -0
- package/lib/module/db/migrations.js.map +1 -0
- package/lib/module/db/react-native-sqlcipher.d.js +2 -0
- package/lib/module/db/react-native-sqlcipher.d.js.map +1 -0
- package/lib/module/db/secureKey.js +87 -0
- package/lib/module/db/secureKey.js.map +1 -0
- package/lib/module/db/types.js +57 -0
- package/lib/module/db/types.js.map +1 -0
- package/lib/module/db/useMediaDatabase.js +123 -0
- package/lib/module/db/useMediaDatabase.js.map +1 -0
- package/lib/module/defaultIcons.js +2259 -0
- package/lib/module/defaultIcons.js.map +1 -0
- package/lib/module/deviceCapabilities.js +408 -0
- package/lib/module/deviceCapabilities.js.map +1 -0
- package/lib/module/errors.js +267 -0
- package/lib/module/errors.js.map +1 -0
- package/lib/module/iconConfig.js +796 -0
- package/lib/module/iconConfig.js.map +1 -0
- package/lib/module/icons.js +514 -0
- package/lib/module/icons.js.map +1 -0
- package/lib/module/index.js +251 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/jest-globals.d.js +2 -0
- package/lib/module/jest-globals.d.js.map +1 -0
- package/lib/module/library/MediaLibrary.js +107 -0
- package/lib/module/library/MediaLibrary.js.map +1 -0
- package/lib/module/library/MediaLibraryScreen.js +2597 -0
- package/lib/module/library/MediaLibraryScreen.js.map +1 -0
- package/lib/module/library/components/AudioPreview.js +74 -0
- package/lib/module/library/components/AudioPreview.js.map +1 -0
- package/lib/module/library/components/DocumentPreview.js +331 -0
- package/lib/module/library/components/DocumentPreview.js.map +1 -0
- package/lib/module/library/components/ExpandableFab.js +343 -0
- package/lib/module/library/components/ExpandableFab.js.map +1 -0
- package/lib/module/library/components/FileTiles.js +251 -0
- package/lib/module/library/components/FileTiles.js.map +1 -0
- package/lib/module/library/components/ImagePreview.js +70 -0
- package/lib/module/library/components/ImagePreview.js.map +1 -0
- package/lib/module/library/components/MediaMetadataHeader.js +175 -0
- package/lib/module/library/components/MediaMetadataHeader.js.map +1 -0
- package/lib/module/library/components/RecordAudioScreen.js +816 -0
- package/lib/module/library/components/RecordAudioScreen.js.map +1 -0
- package/lib/module/library/components/VideoPreview.js +453 -0
- package/lib/module/library/components/VideoPreview.js.map +1 -0
- package/lib/module/library/components/ViewerCounter.js +55 -0
- package/lib/module/library/components/ViewerCounter.js.map +1 -0
- package/lib/module/library/compressionEstimate.js +507 -0
- package/lib/module/library/compressionEstimate.js.map +1 -0
- package/lib/module/library/fileMetadata.js +196 -0
- package/lib/module/library/fileMetadata.js.map +1 -0
- package/lib/module/library/index.js +42 -0
- package/lib/module/library/index.js.map +1 -0
- package/lib/module/library/libraryIconRegistry.js +69 -0
- package/lib/module/library/libraryIconRegistry.js.map +1 -0
- package/lib/module/library/libraryIcons.js +858 -0
- package/lib/module/library/libraryIcons.js.map +1 -0
- package/lib/module/library/localizedFormat.js +181 -0
- package/lib/module/library/localizedFormat.js.map +1 -0
- package/lib/module/library/palette.js +225 -0
- package/lib/module/library/palette.js.map +1 -0
- package/lib/module/library/responsive.js +53 -0
- package/lib/module/library/responsive.js.map +1 -0
- package/lib/module/locales.js +416 -0
- package/lib/module/locales.js.map +1 -0
- package/lib/module/localization.js +1064 -0
- package/lib/module/localization.js.map +1 -0
- package/lib/module/logger.js +20 -0
- package/lib/module/logger.js.map +1 -0
- package/lib/module/lowMemoryCache.js +206 -0
- package/lib/module/lowMemoryCache.js.map +1 -0
- package/lib/module/mediaHistory.js +33 -0
- package/lib/module/mediaHistory.js.map +1 -0
- package/lib/module/mediaStorageService.js +402 -0
- package/lib/module/mediaStorageService.js.map +1 -0
- package/lib/module/memory.js +79 -0
- package/lib/module/memory.js.map +1 -0
- package/lib/module/nativeActions.js +307 -0
- package/lib/module/nativeActions.js.map +1 -0
- package/lib/module/nativeComponentSupport.js +61 -0
- package/lib/module/nativeComponentSupport.js.map +1 -0
- package/lib/module/networkPerformance.js +24 -0
- package/lib/module/networkPerformance.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/performance.js +104 -0
- package/lib/module/performance.js.map +1 -0
- package/lib/module/performanceMonitor.js +155 -0
- package/lib/module/performanceMonitor.js.map +1 -0
- package/lib/module/permissions.js +370 -0
- package/lib/module/permissions.js.map +1 -0
- package/lib/module/rendering.js +40 -0
- package/lib/module/rendering.js.map +1 -0
- package/lib/module/security.js +18 -0
- package/lib/module/security.js.map +1 -0
- package/lib/module/state.js +266 -0
- package/lib/module/state.js.map +1 -0
- package/lib/module/storage.js +400 -0
- package/lib/module/storage.js.map +1 -0
- package/lib/module/theme.js +390 -0
- package/lib/module/theme.js.map +1 -0
- package/lib/module/types.js +4 -0
- package/lib/module/types.js.map +1 -0
- package/lib/module/utils.js +115 -0
- package/lib/module/utils.js.map +1 -0
- package/lib/module/validation.js +288 -0
- package/lib/module/validation.js.map +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/src/AudioPlayerContext.d.ts +27 -0
- package/lib/typescript/src/AudioPlayerContext.d.ts.map +1 -0
- package/lib/typescript/src/CameraPreview.d.ts +13 -0
- package/lib/typescript/src/CameraPreview.d.ts.map +1 -0
- package/lib/typescript/src/NativeAnimatedImageView.d.ts +33 -0
- package/lib/typescript/src/NativeAnimatedImageView.d.ts.map +1 -0
- package/lib/typescript/src/NativeAudioPlayerView.d.ts +12 -0
- package/lib/typescript/src/NativeAudioPlayerView.d.ts.map +1 -0
- package/lib/typescript/src/NativeBlurView.d.ts +12 -0
- package/lib/typescript/src/NativeBlurView.d.ts.map +1 -0
- package/lib/typescript/src/NativeCustomCamera.d.ts +167 -0
- package/lib/typescript/src/NativeCustomCamera.d.ts.map +1 -0
- package/lib/typescript/src/NativeCustomCamera.web.d.ts +47 -0
- package/lib/typescript/src/NativeCustomCamera.web.d.ts.map +1 -0
- package/lib/typescript/src/NativeCustomVideoView.d.ts +30 -0
- package/lib/typescript/src/NativeCustomVideoView.d.ts.map +1 -0
- package/lib/typescript/src/NativeDocumentPreviewView.d.ts +48 -0
- package/lib/typescript/src/NativeDocumentPreviewView.d.ts.map +1 -0
- package/lib/typescript/src/NativeMediaThumbnailView.d.ts +54 -0
- package/lib/typescript/src/NativeMediaThumbnailView.d.ts.map +1 -0
- package/lib/typescript/src/NativeSvgIcon.d.ts +25 -0
- package/lib/typescript/src/NativeSvgIcon.d.ts.map +1 -0
- package/lib/typescript/src/ResponsivePreviewContainer.d.ts +92 -0
- package/lib/typescript/src/ResponsivePreviewContainer.d.ts.map +1 -0
- package/lib/typescript/src/accessibility.d.ts +20 -0
- package/lib/typescript/src/accessibility.d.ts.map +1 -0
- package/lib/typescript/src/config.d.ts +145 -0
- package/lib/typescript/src/config.d.ts.map +1 -0
- package/lib/typescript/src/cpuOptimization.d.ts +63 -0
- package/lib/typescript/src/cpuOptimization.d.ts.map +1 -0
- package/lib/typescript/src/db/DatabaseManager.d.ts +48 -0
- package/lib/typescript/src/db/DatabaseManager.d.ts.map +1 -0
- package/lib/typescript/src/db/MediaMapper.d.ts +77 -0
- package/lib/typescript/src/db/MediaMapper.d.ts.map +1 -0
- package/lib/typescript/src/db/MediaRepository.d.ts +66 -0
- package/lib/typescript/src/db/MediaRepository.d.ts.map +1 -0
- package/lib/typescript/src/db/index.d.ts +23 -0
- package/lib/typescript/src/db/index.d.ts.map +1 -0
- package/lib/typescript/src/db/migrations.d.ts +31 -0
- package/lib/typescript/src/db/migrations.d.ts.map +1 -0
- package/lib/typescript/src/db/secureKey.d.ts +10 -0
- package/lib/typescript/src/db/secureKey.d.ts.map +1 -0
- package/lib/typescript/src/db/types.d.ts +115 -0
- package/lib/typescript/src/db/types.d.ts.map +1 -0
- package/lib/typescript/src/db/useMediaDatabase.d.ts +41 -0
- package/lib/typescript/src/db/useMediaDatabase.d.ts.map +1 -0
- package/lib/typescript/src/defaultIcons.d.ts +139 -0
- package/lib/typescript/src/defaultIcons.d.ts.map +1 -0
- package/lib/typescript/src/deviceCapabilities.d.ts +92 -0
- package/lib/typescript/src/deviceCapabilities.d.ts.map +1 -0
- package/lib/typescript/src/errors.d.ts +95 -0
- package/lib/typescript/src/errors.d.ts.map +1 -0
- package/lib/typescript/src/iconConfig.d.ts +176 -0
- package/lib/typescript/src/iconConfig.d.ts.map +1 -0
- package/lib/typescript/src/icons.d.ts +217 -0
- package/lib/typescript/src/icons.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +104 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/lib/typescript/src/library/MediaLibrary.d.ts +57 -0
- package/lib/typescript/src/library/MediaLibrary.d.ts.map +1 -0
- package/lib/typescript/src/library/MediaLibraryScreen.d.ts +51 -0
- package/lib/typescript/src/library/MediaLibraryScreen.d.ts.map +1 -0
- package/lib/typescript/src/library/components/AudioPreview.d.ts +6 -0
- package/lib/typescript/src/library/components/AudioPreview.d.ts.map +1 -0
- package/lib/typescript/src/library/components/DocumentPreview.d.ts +10 -0
- package/lib/typescript/src/library/components/DocumentPreview.d.ts.map +1 -0
- package/lib/typescript/src/library/components/ExpandableFab.d.ts +39 -0
- package/lib/typescript/src/library/components/ExpandableFab.d.ts.map +1 -0
- package/lib/typescript/src/library/components/FileTiles.d.ts +8 -0
- package/lib/typescript/src/library/components/FileTiles.d.ts.map +1 -0
- package/lib/typescript/src/library/components/ImagePreview.d.ts +5 -0
- package/lib/typescript/src/library/components/ImagePreview.d.ts.map +1 -0
- package/lib/typescript/src/library/components/MediaMetadataHeader.d.ts +16 -0
- package/lib/typescript/src/library/components/MediaMetadataHeader.d.ts.map +1 -0
- package/lib/typescript/src/library/components/RecordAudioScreen.d.ts +8 -0
- package/lib/typescript/src/library/components/RecordAudioScreen.d.ts.map +1 -0
- package/lib/typescript/src/library/components/VideoPreview.d.ts +20 -0
- package/lib/typescript/src/library/components/VideoPreview.d.ts.map +1 -0
- package/lib/typescript/src/library/components/ViewerCounter.d.ts +13 -0
- package/lib/typescript/src/library/components/ViewerCounter.d.ts.map +1 -0
- package/lib/typescript/src/library/compressionEstimate.d.ts +313 -0
- package/lib/typescript/src/library/compressionEstimate.d.ts.map +1 -0
- package/lib/typescript/src/library/fileMetadata.d.ts +33 -0
- package/lib/typescript/src/library/fileMetadata.d.ts.map +1 -0
- package/lib/typescript/src/library/index.d.ts +40 -0
- package/lib/typescript/src/library/index.d.ts.map +1 -0
- package/lib/typescript/src/library/libraryIconRegistry.d.ts +35 -0
- package/lib/typescript/src/library/libraryIconRegistry.d.ts.map +1 -0
- package/lib/typescript/src/library/libraryIcons.d.ts +85 -0
- package/lib/typescript/src/library/libraryIcons.d.ts.map +1 -0
- package/lib/typescript/src/library/localizedFormat.d.ts +49 -0
- package/lib/typescript/src/library/localizedFormat.d.ts.map +1 -0
- package/lib/typescript/src/library/palette.d.ts +104 -0
- package/lib/typescript/src/library/palette.d.ts.map +1 -0
- package/lib/typescript/src/library/responsive.d.ts +15 -0
- package/lib/typescript/src/library/responsive.d.ts.map +1 -0
- package/lib/typescript/src/locales.d.ts +103 -0
- package/lib/typescript/src/locales.d.ts.map +1 -0
- package/lib/typescript/src/localization.d.ts +1051 -0
- package/lib/typescript/src/localization.d.ts.map +1 -0
- package/lib/typescript/src/logger.d.ts +4 -0
- package/lib/typescript/src/logger.d.ts.map +1 -0
- package/lib/typescript/src/lowMemoryCache.d.ts +64 -0
- package/lib/typescript/src/lowMemoryCache.d.ts.map +1 -0
- package/lib/typescript/src/mediaHistory.d.ts +3 -0
- package/lib/typescript/src/mediaHistory.d.ts.map +1 -0
- package/lib/typescript/src/mediaStorageService.d.ts +155 -0
- package/lib/typescript/src/mediaStorageService.d.ts.map +1 -0
- package/lib/typescript/src/memory.d.ts +21 -0
- package/lib/typescript/src/memory.d.ts.map +1 -0
- package/lib/typescript/src/nativeActions.d.ts +174 -0
- package/lib/typescript/src/nativeActions.d.ts.map +1 -0
- package/lib/typescript/src/nativeComponentSupport.d.ts +30 -0
- package/lib/typescript/src/nativeComponentSupport.d.ts.map +1 -0
- package/lib/typescript/src/networkPerformance.d.ts +12 -0
- package/lib/typescript/src/networkPerformance.d.ts.map +1 -0
- package/lib/typescript/src/performance.d.ts +32 -0
- package/lib/typescript/src/performance.d.ts.map +1 -0
- package/lib/typescript/src/performanceMonitor.d.ts +45 -0
- package/lib/typescript/src/performanceMonitor.d.ts.map +1 -0
- package/lib/typescript/src/permissions.d.ts +132 -0
- package/lib/typescript/src/permissions.d.ts.map +1 -0
- package/lib/typescript/src/rendering.d.ts +9 -0
- package/lib/typescript/src/rendering.d.ts.map +1 -0
- package/lib/typescript/src/security.d.ts +6 -0
- package/lib/typescript/src/security.d.ts.map +1 -0
- package/lib/typescript/src/state.d.ts +102 -0
- package/lib/typescript/src/state.d.ts.map +1 -0
- package/lib/typescript/src/storage.d.ts +117 -0
- package/lib/typescript/src/storage.d.ts.map +1 -0
- package/lib/typescript/src/theme.d.ts +15 -0
- package/lib/typescript/src/theme.d.ts.map +1 -0
- package/lib/typescript/src/types.d.ts +802 -0
- package/lib/typescript/src/types.d.ts.map +1 -0
- package/lib/typescript/src/utils.d.ts +50 -0
- package/lib/typescript/src/utils.d.ts.map +1 -0
- package/lib/typescript/src/validation.d.ts +52 -0
- package/lib/typescript/src/validation.d.ts.map +1 -0
- package/package.json +204 -0
- package/react-native.config.js +17 -0
- package/src/AudioPlayerContext.tsx +220 -0
- package/src/CameraPreview.tsx +352 -0
- package/src/NativeAnimatedImageView.tsx +71 -0
- package/src/NativeAudioPlayerView.tsx +39 -0
- package/src/NativeBlurView.tsx +40 -0
- package/src/NativeCustomCamera.ts +305 -0
- package/src/NativeCustomCamera.web.ts +192 -0
- package/src/NativeCustomVideoView.ts +30 -0
- package/src/NativeDocumentPreviewView.tsx +196 -0
- package/src/NativeMediaThumbnailView.tsx +167 -0
- package/src/NativeSvgIcon.tsx +46 -0
- package/src/ResponsivePreviewContainer.tsx +227 -0
- package/src/accessibility.ts +80 -0
- package/src/config.ts +406 -0
- package/src/cpuOptimization.ts +365 -0
- package/src/db/DatabaseManager.ts +212 -0
- package/src/db/MediaMapper.ts +377 -0
- package/src/db/MediaRepository.ts +313 -0
- package/src/db/index.ts +73 -0
- package/src/db/migrations.ts +222 -0
- package/src/db/react-native-sqlcipher.d.ts +76 -0
- package/src/db/secureKey.ts +100 -0
- package/src/db/types.ts +123 -0
- package/src/db/useMediaDatabase.ts +191 -0
- package/src/defaultIcons.tsx +2390 -0
- package/src/deviceCapabilities.ts +438 -0
- package/src/errors.ts +350 -0
- package/src/iconConfig.tsx +962 -0
- package/src/icons.tsx +788 -0
- package/src/index.tsx +763 -0
- package/src/jest-globals.d.ts +9 -0
- package/src/library/MediaLibrary.tsx +141 -0
- package/src/library/MediaLibraryScreen.tsx +3003 -0
- package/src/library/components/AudioPreview.tsx +73 -0
- package/src/library/components/DocumentPreview.tsx +361 -0
- package/src/library/components/ExpandableFab.tsx +342 -0
- package/src/library/components/FileTiles.tsx +252 -0
- package/src/library/components/ImagePreview.tsx +72 -0
- package/src/library/components/MediaMetadataHeader.tsx +208 -0
- package/src/library/components/RecordAudioScreen.tsx +845 -0
- package/src/library/components/VideoPreview.tsx +478 -0
- package/src/library/components/ViewerCounter.tsx +71 -0
- package/src/library/compressionEstimate.ts +662 -0
- package/src/library/fileMetadata.ts +212 -0
- package/src/library/index.ts +66 -0
- package/src/library/libraryIconRegistry.tsx +86 -0
- package/src/library/libraryIcons.tsx +928 -0
- package/src/library/localizedFormat.ts +207 -0
- package/src/library/palette.ts +281 -0
- package/src/library/responsive.ts +49 -0
- package/src/locales.ts +381 -0
- package/src/localization.ts +1947 -0
- package/src/logger.ts +20 -0
- package/src/lowMemoryCache.ts +218 -0
- package/src/mediaHistory.ts +42 -0
- package/src/mediaStorageService.ts +546 -0
- package/src/memory.ts +102 -0
- package/src/nativeActions.ts +422 -0
- package/src/nativeComponentSupport.ts +60 -0
- package/src/networkPerformance.ts +30 -0
- package/src/performance.ts +137 -0
- package/src/performanceMonitor.ts +219 -0
- package/src/permissions.ts +403 -0
- package/src/rendering.ts +47 -0
- package/src/security.ts +19 -0
- package/src/state.ts +333 -0
- package/src/storage.ts +453 -0
- package/src/theme.tsx +467 -0
- package/src/types.ts +946 -0
- package/src/utils.ts +177 -0
- package/src/validation.ts +372 -0
|
@@ -0,0 +1,2479 @@
|
|
|
1
|
+
package com.customcamera
|
|
2
|
+
|
|
3
|
+
import android.Manifest
|
|
4
|
+
import android.app.Activity
|
|
5
|
+
import android.content.ContentResolver
|
|
6
|
+
import android.content.ActivityNotFoundException
|
|
7
|
+
import android.content.ClipData
|
|
8
|
+
import android.content.Context
|
|
9
|
+
import android.content.Intent
|
|
10
|
+
import android.content.pm.PackageManager
|
|
11
|
+
import android.net.Uri
|
|
12
|
+
import android.os.Build
|
|
13
|
+
import android.provider.DocumentsContract
|
|
14
|
+
import android.provider.OpenableColumns
|
|
15
|
+
import android.os.Bundle
|
|
16
|
+
import android.provider.MediaStore.Video.VideoColumns
|
|
17
|
+
import androidx.core.content.ContextCompat
|
|
18
|
+
import androidx.core.content.FileProvider
|
|
19
|
+
import android.util.Log
|
|
20
|
+
import java.io.File
|
|
21
|
+
import com.facebook.react.bridge.ActivityEventListener
|
|
22
|
+
import com.facebook.react.bridge.Arguments
|
|
23
|
+
import com.facebook.react.bridge.BaseActivityEventListener
|
|
24
|
+
import com.facebook.react.bridge.Promise
|
|
25
|
+
import com.facebook.react.bridge.ReactApplicationContext
|
|
26
|
+
import com.facebook.react.bridge.ReadableMap
|
|
27
|
+
import com.facebook.react.bridge.ReadableArray
|
|
28
|
+
import com.facebook.react.bridge.ReadableType
|
|
29
|
+
import com.facebook.react.bridge.WritableArray
|
|
30
|
+
import com.facebook.react.bridge.WritableMap
|
|
31
|
+
import com.facebook.react.bridge.ReactMethod
|
|
32
|
+
import com.facebook.react.modules.core.DeviceEventManagerModule
|
|
33
|
+
import com.facebook.react.modules.core.PermissionAwareActivity
|
|
34
|
+
import com.facebook.react.modules.core.PermissionListener
|
|
35
|
+
import kotlinx.coroutines.CoroutineScope
|
|
36
|
+
import kotlinx.coroutines.Dispatchers
|
|
37
|
+
import kotlinx.coroutines.SupervisorJob
|
|
38
|
+
import kotlinx.coroutines.launch
|
|
39
|
+
|
|
40
|
+
class CustomCameraModule(
|
|
41
|
+
reactContext: ReactApplicationContext
|
|
42
|
+
) : NativeCustomCameraSpec(reactContext) {
|
|
43
|
+
|
|
44
|
+
private var pendingPickerPromise: Promise? = null
|
|
45
|
+
private var pendingBarcodePromise: Promise? = null
|
|
46
|
+
private var pendingDocumentPromise: Promise? = null
|
|
47
|
+
private var pendingAudioPromise: Promise? = null
|
|
48
|
+
private var pendingImageVideoPromise: Promise? = null
|
|
49
|
+
private var pendingMediaEditorPromise: Promise? = null
|
|
50
|
+
|
|
51
|
+
private val activityEventListener: ActivityEventListener =
|
|
52
|
+
object : BaseActivityEventListener() {
|
|
53
|
+
override fun onActivityResult(
|
|
54
|
+
activity: Activity,
|
|
55
|
+
requestCode: Int,
|
|
56
|
+
resultCode: Int,
|
|
57
|
+
data: Intent?
|
|
58
|
+
) {
|
|
59
|
+
when (requestCode) {
|
|
60
|
+
CAMERA_PICKER_REQUEST -> {
|
|
61
|
+
val promise = pendingPickerPromise ?: return
|
|
62
|
+
pendingPickerPromise = null
|
|
63
|
+
promise.resolve(createResultMap(resultCode, data))
|
|
64
|
+
}
|
|
65
|
+
DOCUMENT_PICKER_REQUEST -> {
|
|
66
|
+
val promise = pendingDocumentPromise ?: return
|
|
67
|
+
pendingDocumentPromise = null
|
|
68
|
+
promise.resolve(buildPickedFilesResult(resultCode, data, "document"))
|
|
69
|
+
}
|
|
70
|
+
AUDIO_PICKER_REQUEST -> {
|
|
71
|
+
val promise = pendingAudioPromise ?: return
|
|
72
|
+
pendingAudioPromise = null
|
|
73
|
+
promise.resolve(buildPickedFilesResult(resultCode, data, "audio"))
|
|
74
|
+
}
|
|
75
|
+
BARCODE_SCAN_REQUEST -> {
|
|
76
|
+
val promise = pendingBarcodePromise ?: return
|
|
77
|
+
pendingBarcodePromise = null
|
|
78
|
+
val value = data?.getStringExtra(BarcodeScannerActivity.EXTRA_RESULT_VALUE)
|
|
79
|
+
if (resultCode == Activity.RESULT_OK && value != null) {
|
|
80
|
+
promise.resolve(value)
|
|
81
|
+
} else {
|
|
82
|
+
promise.reject("SCAN_CANCELLED", "Barcode scan cancelled")
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
IMAGE_VIDEO_PICKER_REQUEST -> {
|
|
86
|
+
val promise = pendingImageVideoPromise ?: return
|
|
87
|
+
pendingImageVideoPromise = null
|
|
88
|
+
promise.resolve(buildPickedMediaResult(resultCode, data))
|
|
89
|
+
}
|
|
90
|
+
MEDIA_EDITOR_REQUEST -> {
|
|
91
|
+
val promise = pendingMediaEditorPromise ?: return
|
|
92
|
+
pendingMediaEditorPromise = null
|
|
93
|
+
// Same result decoder as the camera picker: the editor finishes through the very same
|
|
94
|
+
// selection pipeline, so the payload is identical to a capture's.
|
|
95
|
+
promise.resolve(createResultMap(resultCode, data))
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
init {
|
|
102
|
+
reactApplicationContext.addActivityEventListener(activityEventListener)
|
|
103
|
+
Localization.init(reactApplicationContext)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
override fun getName(): String = NAME
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Generate a secure content URI using FileProvider for files that need to be shared
|
|
110
|
+
* outside the app. Falls back to file:// URI for older Android versions.
|
|
111
|
+
*/
|
|
112
|
+
private fun getSecureUriForFile(file: android.net.Uri): String {
|
|
113
|
+
return file.toString() // This method already receives a Uri, so just return it as string
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Generate a secure content URI using FileProvider for files that need to be shared
|
|
118
|
+
* outside the app. Falls back to file:// URI for older Android versions.
|
|
119
|
+
*/
|
|
120
|
+
private fun getSecureUriForFile(file: java.io.File): String {
|
|
121
|
+
val context = reactApplicationContext
|
|
122
|
+
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
|
123
|
+
try {
|
|
124
|
+
FileProvider.getUriForFile(
|
|
125
|
+
context,
|
|
126
|
+
"${context.packageName}.camera.fileprovider",
|
|
127
|
+
file
|
|
128
|
+
).toString()
|
|
129
|
+
} catch (e: Exception) {
|
|
130
|
+
// Fallback to file:// URI if FileProvider fails
|
|
131
|
+
android.net.Uri.fromFile(file).toString()
|
|
132
|
+
}
|
|
133
|
+
} else {
|
|
134
|
+
// For Android versions before N, file:// URIs are acceptable
|
|
135
|
+
android.net.Uri.fromFile(file).toString()
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
@ReactMethod
|
|
140
|
+
override fun openCamera(promise: Promise) {
|
|
141
|
+
val activity = reactApplicationContext.currentActivity
|
|
142
|
+
if (activity == null) {
|
|
143
|
+
promise.reject("NO_ACTIVITY", "Activity not available")
|
|
144
|
+
return
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
val intent = CustomCameraActivity.createIntent(
|
|
148
|
+
activity = activity,
|
|
149
|
+
mediaTypes = "all",
|
|
150
|
+
selectionLimit = 1,
|
|
151
|
+
initialCamera = "back",
|
|
152
|
+
enableVideo = true,
|
|
153
|
+
enableVideoNotes = true,
|
|
154
|
+
enableFilter = true
|
|
155
|
+
)
|
|
156
|
+
try {
|
|
157
|
+
activity.startActivity(intent)
|
|
158
|
+
promise.resolve("opened")
|
|
159
|
+
} catch (exception: RuntimeException) {
|
|
160
|
+
promise.reject("CAMERA_PICKER_ERROR", "Unable to open camera picker", exception)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
@ReactMethod
|
|
165
|
+
override fun setLocalization(map: ReadableMap, isRtl: Boolean, locale: String) {
|
|
166
|
+
val stringMap = mutableMapOf<String, String>()
|
|
167
|
+
val hashMap = map.toHashMap()
|
|
168
|
+
for ((key, value) in hashMap) {
|
|
169
|
+
if (value != null) {
|
|
170
|
+
stringMap[key] = value.toString()
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
Localization.setTranslations(reactApplicationContext, stringMap, isRtl, locale)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
@ReactMethod
|
|
177
|
+
override fun openCameraPicker(options: ReadableMap, promise: Promise) {
|
|
178
|
+
val activity = reactApplicationContext.currentActivity
|
|
179
|
+
if (activity == null) {
|
|
180
|
+
promise.reject("NO_ACTIVITY", "Activity not available")
|
|
181
|
+
return
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (pendingPickerPromise != null) {
|
|
185
|
+
promise.reject("PICKER_ALREADY_OPEN", "Camera picker is already open")
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
val intent = CustomCameraActivity.createIntent(
|
|
190
|
+
activity = activity,
|
|
191
|
+
mediaTypes = options.getMediaTypesOrDefault("mediaTypes", "all"),
|
|
192
|
+
selectionLimit = options.getIntOrDefault("selectionLimit", 0),
|
|
193
|
+
initialCamera = options.getInitialCameraOrDefault("initialCamera", "back"),
|
|
194
|
+
enableVideo = options.getBooleanOrDefault("enableVideo", true),
|
|
195
|
+
enableVideoNotes = options.getBooleanOrDefault("enableVideoNotes", true),
|
|
196
|
+
enableFilter = options.getBooleanOrDefault("enableFilter", true),
|
|
197
|
+
// Hold-to-record. 0 for holdToRecordDelayMs means "use the system long-press timeout";
|
|
198
|
+
// the activity clamps both durations to sane bounds.
|
|
199
|
+
enableHoldToRecord = options.getBooleanOrDefault("enableHoldToRecord", true),
|
|
200
|
+
holdToRecordDelayMs = options.getIntOrDefault("holdToRecordDelayMs", 0),
|
|
201
|
+
minRecordDurationMs = options.getIntOrDefault("minRecordDurationMs", 1000),
|
|
202
|
+
enableRecordLock = options.getBooleanOrDefault("enableRecordLock", true),
|
|
203
|
+
enableRecordCancel = options.getBooleanOrDefault("enableRecordCancel", true)
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
pendingPickerPromise = promise
|
|
207
|
+
try {
|
|
208
|
+
activity.startActivityForResult(intent, CAMERA_PICKER_REQUEST)
|
|
209
|
+
} catch (exception: RuntimeException) {
|
|
210
|
+
pendingPickerPromise = null
|
|
211
|
+
promise.reject("CAMERA_PICKER_ERROR", "Unable to open camera picker", exception)
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Open the system DOCUMENT picker via the Storage Access Framework (ACTION_OPEN_DOCUMENT).
|
|
217
|
+
*
|
|
218
|
+
* Mirrors [openCameraPicker]'s lifecycle exactly — stash the promise, launch for result, and
|
|
219
|
+
* resolve it from onActivityResult with the same `{ cancelled, assets }` shape. SAF is built into
|
|
220
|
+
* Android, so this needs NO third-party library and NO runtime storage permission (the system
|
|
221
|
+
* picker grants per-URI read access, which we make persistable so the uri stays valid after the
|
|
222
|
+
* picker closes). `selectionLimit` (0 / >1 = multi-select) and an optional `mimeTypes` override
|
|
223
|
+
* are read from [options]; the default MIME set covers the common document formats.
|
|
224
|
+
*/
|
|
225
|
+
@ReactMethod
|
|
226
|
+
override fun openDocumentPicker(options: ReadableMap, promise: Promise) {
|
|
227
|
+
val activity = reactApplicationContext.currentActivity
|
|
228
|
+
if (activity == null) {
|
|
229
|
+
promise.reject("NO_ACTIVITY", "Activity not available")
|
|
230
|
+
return
|
|
231
|
+
}
|
|
232
|
+
if (pendingDocumentPromise != null) {
|
|
233
|
+
promise.reject("PICKER_ALREADY_OPEN", "Document picker is already open")
|
|
234
|
+
return
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
val mimeTypes = options.getStringArrayOrNull("mimeTypes") ?: DEFAULT_DOCUMENT_MIME_TYPES
|
|
238
|
+
val intent = buildOpenDocumentIntent(options, mimeTypes)
|
|
239
|
+
|
|
240
|
+
pendingDocumentPromise = promise
|
|
241
|
+
try {
|
|
242
|
+
activity.startActivityForResult(intent, DOCUMENT_PICKER_REQUEST)
|
|
243
|
+
} catch (exception: RuntimeException) {
|
|
244
|
+
pendingDocumentPromise = null
|
|
245
|
+
promise.reject("DOCUMENT_PICKER_ERROR", "Unable to open document picker", exception)
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Open the system AUDIO picker via the Storage Access Framework (ACTION_OPEN_DOCUMENT), scoped to
|
|
251
|
+
* `audio/ *`. Identical lifecycle to [openDocumentPicker]; pass `mimeTypes` in [options] to narrow
|
|
252
|
+
* the selection (e.g. only `audio/mpeg`).
|
|
253
|
+
*
|
|
254
|
+
* Unlike the document picker, this one is steered to open ON THE AUDIO SECTION rather than the
|
|
255
|
+
* picker's default "Recent" view: EXTRA_INITIAL_URI (API 26+) points at the system media
|
|
256
|
+
* provider's Audio root, which AOSP DocumentsUI (and the OEM skins built on it) honours by
|
|
257
|
+
* landing directly in the audio browser. Pickers that don't recognise the hint ignore it and
|
|
258
|
+
* still apply the audio MIME filter, so the worst case everywhere is the old behavior.
|
|
259
|
+
*/
|
|
260
|
+
@ReactMethod
|
|
261
|
+
override fun openAudioPicker(options: ReadableMap, promise: Promise) {
|
|
262
|
+
val activity = reactApplicationContext.currentActivity
|
|
263
|
+
if (activity == null) {
|
|
264
|
+
promise.reject("NO_ACTIVITY", "Activity not available")
|
|
265
|
+
return
|
|
266
|
+
}
|
|
267
|
+
if (pendingAudioPromise != null) {
|
|
268
|
+
promise.reject("PICKER_ALREADY_OPEN", "Audio picker is already open")
|
|
269
|
+
return
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
val mimeTypes = options.getStringArrayOrNull("mimeTypes") ?: DEFAULT_AUDIO_MIME_TYPES
|
|
273
|
+
val intent = buildOpenDocumentIntent(options, mimeTypes).apply {
|
|
274
|
+
// With several accepted types the shared builder falls back to a */* base type; re-anchor
|
|
275
|
+
// it to audio/* (when every entry is audio) so OEM pickers that route on the base type
|
|
276
|
+
// open their audio UI instead of a generic file browser. EXTRA_MIME_TYPES still governs
|
|
277
|
+
// exactly which files are selectable.
|
|
278
|
+
if (mimeTypes.size > 1 &&
|
|
279
|
+
mimeTypes.all { it.startsWith("audio/") || it in AUDIO_CONTAINER_MIME_TYPES }
|
|
280
|
+
) {
|
|
281
|
+
type = "audio/*"
|
|
282
|
+
}
|
|
283
|
+
// Land the picker on the Audio section of the system media documents provider instead of
|
|
284
|
+
// the default Recents view. EXTRA_INITIAL_URI is honoured by DocumentsUI from API 26;
|
|
285
|
+
// providers that don't support it ignore the extra, so it is safe across third-party
|
|
286
|
+
// file managers. On API 24/25 the extra doesn't exist — skip it and rely on the MIME
|
|
287
|
+
// filter alone (the pre-26 picker has no Recents-vs-Audio landing distinction anyway).
|
|
288
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
289
|
+
putExtra(
|
|
290
|
+
DocumentsContract.EXTRA_INITIAL_URI,
|
|
291
|
+
DocumentsContract.buildRootUri(MEDIA_DOCUMENTS_AUTHORITY, MEDIA_DOCUMENTS_AUDIO_ROOT)
|
|
292
|
+
)
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
pendingAudioPromise = promise
|
|
297
|
+
try {
|
|
298
|
+
activity.startActivityForResult(intent, AUDIO_PICKER_REQUEST)
|
|
299
|
+
} catch (exception: RuntimeException) {
|
|
300
|
+
pendingAudioPromise = null
|
|
301
|
+
promise.reject("AUDIO_PICKER_ERROR", "Unable to open audio picker", exception)
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Open the system image/video picker via the Storage Access Framework (ACTION_OPEN_DOCUMENT),
|
|
307
|
+
* scoped to `image/ *` and `video/ *` MIME types. Identical lifecycle to [openDocumentPicker].
|
|
308
|
+
* This allows selecting existing photos and videos from the device gallery.
|
|
309
|
+
*/
|
|
310
|
+
@ReactMethod
|
|
311
|
+
override fun openImageVideoPicker(options: ReadableMap, promise: Promise) {
|
|
312
|
+
val activity = reactApplicationContext.currentActivity
|
|
313
|
+
if (activity == null) {
|
|
314
|
+
promise.reject("NO_ACTIVITY", "Activity not available")
|
|
315
|
+
return
|
|
316
|
+
}
|
|
317
|
+
if (pendingImageVideoPromise != null) {
|
|
318
|
+
promise.reject("PICKER_ALREADY_OPEN", "Image/Video picker is already open")
|
|
319
|
+
return
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
val selectionLimit = options.getIntOrDefault("selectionLimit", 0)
|
|
323
|
+
val allowMultiple = selectionLimit == 0 || selectionLimit > 1
|
|
324
|
+
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
|
|
325
|
+
intent.addCategory(Intent.CATEGORY_OPENABLE)
|
|
326
|
+
intent.type = "*/*"
|
|
327
|
+
intent.putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("image/*", "video/*"))
|
|
328
|
+
intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, allowMultiple)
|
|
329
|
+
intent.addFlags(
|
|
330
|
+
Intent.FLAG_GRANT_READ_URI_PERMISSION or
|
|
331
|
+
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
pendingImageVideoPromise = promise
|
|
335
|
+
try {
|
|
336
|
+
activity.startActivityForResult(intent, IMAGE_VIDEO_PICKER_REQUEST)
|
|
337
|
+
} catch (exception: RuntimeException) {
|
|
338
|
+
pendingImageVideoPromise = null
|
|
339
|
+
promise.reject("IMAGE_VIDEO_PICKER_ERROR", "Unable to open image/video picker", exception)
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Open an EXISTING photo or video — one the user just picked from the device gallery — on the
|
|
345
|
+
* camera's own preview/editor screen.
|
|
346
|
+
*
|
|
347
|
+
* This is the same Activity, the same [MediaPreviewEditor] controller and the same editing tools
|
|
348
|
+
* a freshly captured photo/video lands on; only the source of the file differs. It resolves with
|
|
349
|
+
* the identical `{ cancelled, assets }` shape as [openCameraPicker], produced by the identical
|
|
350
|
+
* send pipeline (cache → edit → publish to Documents), so a gallery item is indistinguishable
|
|
351
|
+
* from a capture to everything downstream.
|
|
352
|
+
*
|
|
353
|
+
* Item count drives the screen, exactly as it does for captures: ONE item opens the single-asset
|
|
354
|
+
* image/video editor, SEVERAL open the multi-media Add More + Send preview with the whole
|
|
355
|
+
* selection in the order it was given.
|
|
356
|
+
*
|
|
357
|
+
* `options`: either `uri` (single item) or `items` (an array of `{ uri, type?, fileName?,
|
|
358
|
+
* mimeType? }`, in pick order), plus the same optional `type` / `fileName` / `mimeType` hints for
|
|
359
|
+
* the single form and `enableFilter` for both. No usable uri rejects; a uri that is neither image
|
|
360
|
+
* nor video is dropped by the Activity (and comes back `cancelled: true` if none survive).
|
|
361
|
+
*/
|
|
362
|
+
@ReactMethod
|
|
363
|
+
override fun openMediaEditor(options: ReadableMap, promise: Promise) {
|
|
364
|
+
val activity = reactApplicationContext.currentActivity
|
|
365
|
+
if (activity == null) {
|
|
366
|
+
promise.reject("NO_ACTIVITY", "Activity not available")
|
|
367
|
+
return
|
|
368
|
+
}
|
|
369
|
+
if (pendingMediaEditorPromise != null) {
|
|
370
|
+
promise.reject("EDITOR_ALREADY_OPEN", "Media editor is already open")
|
|
371
|
+
return
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
val items = options.readEditorMediaItems()
|
|
375
|
+
if (items.isEmpty()) {
|
|
376
|
+
promise.reject("INVALID_MEDIA_URI", "A media uri is required to open the editor")
|
|
377
|
+
return
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
val intent = CustomCameraActivity.createEditorIntent(
|
|
381
|
+
activity = activity,
|
|
382
|
+
items = items,
|
|
383
|
+
enableFilter = options.getBooleanOrDefault("enableFilter", true)
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
pendingMediaEditorPromise = promise
|
|
387
|
+
try {
|
|
388
|
+
activity.startActivityForResult(intent, MEDIA_EDITOR_REQUEST)
|
|
389
|
+
} catch (exception: RuntimeException) {
|
|
390
|
+
pendingMediaEditorPromise = null
|
|
391
|
+
promise.reject("MEDIA_EDITOR_ERROR", "Unable to open the media editor", exception)
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Open a document uri in whatever external app can view it (PDF reader, editor, etc.) via
|
|
397
|
+
* ACTION_VIEW. Grants transient read permission so the target app can read the (possibly
|
|
398
|
+
* SAF-backed) uri, and picks up a FileProvider content uri automatically for file:// paths so
|
|
399
|
+
* we never leak a raw file uri to another process. Resolves `true` when an activity was
|
|
400
|
+
* launched, `false` when the device has no handler for the type.
|
|
401
|
+
*/
|
|
402
|
+
@ReactMethod
|
|
403
|
+
override fun openDocument(uri: String, mimeType: String, promise: Promise) {
|
|
404
|
+
val activity = reactApplicationContext.currentActivity
|
|
405
|
+
?: reactApplicationContext.applicationContext
|
|
406
|
+
try {
|
|
407
|
+
val parsed = Uri.parse(uri)
|
|
408
|
+
// A file:// path can't be shared with another app on API 24+ (FileUriExposedException),
|
|
409
|
+
// so wrap it in a FileProvider content uri; content:// and others are used as-is.
|
|
410
|
+
val viewUri: Uri = if (parsed.scheme == "file") {
|
|
411
|
+
val path = parsed.path
|
|
412
|
+
if (path != null) {
|
|
413
|
+
try {
|
|
414
|
+
FileProvider.getUriForFile(
|
|
415
|
+
reactApplicationContext,
|
|
416
|
+
"${reactApplicationContext.packageName}.camera.fileprovider",
|
|
417
|
+
java.io.File(path)
|
|
418
|
+
)
|
|
419
|
+
} catch (e: Exception) {
|
|
420
|
+
parsed
|
|
421
|
+
}
|
|
422
|
+
} else {
|
|
423
|
+
parsed
|
|
424
|
+
}
|
|
425
|
+
} else {
|
|
426
|
+
parsed
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
var resolvedMimeType = mimeType
|
|
430
|
+
if (resolvedMimeType.isEmpty()) {
|
|
431
|
+
val extension = android.webkit.MimeTypeMap.getFileExtensionFromUrl(uri)
|
|
432
|
+
if (extension != null) {
|
|
433
|
+
resolvedMimeType = android.webkit.MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension.lowercase()) ?: ""
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
val viewIntent = Intent(Intent.ACTION_VIEW).apply {
|
|
438
|
+
if (resolvedMimeType.isNotEmpty()) {
|
|
439
|
+
setDataAndType(viewUri, resolvedMimeType)
|
|
440
|
+
} else {
|
|
441
|
+
data = viewUri
|
|
442
|
+
}
|
|
443
|
+
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
|
444
|
+
}
|
|
445
|
+
// Wrap in a chooser for a consistent "Open with" experience. NEW_TASK is required when we
|
|
446
|
+
// fall back to the application context (no foreground activity). We deliberately do NOT rely
|
|
447
|
+
// on resolveActivity(): on API 30+ package-visibility rules make it return null even when a
|
|
448
|
+
// capable app IS installed, so we just try to launch and report false only when the OS
|
|
449
|
+
// genuinely has no handler (ActivityNotFoundException).
|
|
450
|
+
val chooser = Intent.createChooser(
|
|
451
|
+
viewIntent,
|
|
452
|
+
Localization.getString("openWith", "Open with")
|
|
453
|
+
).apply {
|
|
454
|
+
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
|
455
|
+
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
try {
|
|
459
|
+
activity.startActivity(chooser)
|
|
460
|
+
promise.resolve(true)
|
|
461
|
+
} catch (notFound: android.content.ActivityNotFoundException) {
|
|
462
|
+
promise.resolve(false)
|
|
463
|
+
}
|
|
464
|
+
} catch (exception: Exception) {
|
|
465
|
+
promise.reject("OPEN_DOCUMENT_ERROR", "Unable to open document", exception)
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
@ReactMethod
|
|
470
|
+
override fun getUploadState(promise: Promise) {
|
|
471
|
+
try {
|
|
472
|
+
val context = reactApplicationContext.applicationContext
|
|
473
|
+
val intent = Intent(context, UploadService::class.java).apply {
|
|
474
|
+
action = UploadService.ACTION_GET_NOTIFICATION_HISTORY
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// Use a REGULAR service start (never startForegroundService) for the history query.
|
|
478
|
+
// The ACTION_GET_NOTIFICATION_HISTORY branch only reads prefs and broadcasts — it
|
|
479
|
+
// never calls startForeground(). Starting it as a foreground service would arm the
|
|
480
|
+
// OS ~5s "must call startForeground()" deadline that this action never satisfies,
|
|
481
|
+
// and the resulting RemoteServiceException is thrown by the system OUTSIDE this
|
|
482
|
+
// try/catch → guaranteed release crash. A plain startService has no such deadline;
|
|
483
|
+
// if it is disallowed (app in background on API 31+) it throws here and is caught.
|
|
484
|
+
context.startService(intent)
|
|
485
|
+
|
|
486
|
+
// We'll handle the result via broadcast receiver
|
|
487
|
+
promise.resolve(null)
|
|
488
|
+
} catch (e: Exception) {
|
|
489
|
+
promise.reject("GET_UPLOAD_STATE_FAILED", "Failed to get upload state", e)
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
@ReactMethod
|
|
494
|
+
override fun getNotificationHistory(promise: Promise) {
|
|
495
|
+
try {
|
|
496
|
+
val context = reactApplicationContext.applicationContext
|
|
497
|
+
val sharedPrefs = context.getSharedPreferences("UploadServicePrefs", Context.MODE_PRIVATE)
|
|
498
|
+
val historyJson = sharedPrefs.getString("notification_history", "[]")
|
|
499
|
+
promise.resolve(historyJson)
|
|
500
|
+
} catch (e: Exception) {
|
|
501
|
+
promise.reject("GET_NOTIFICATION_HISTORY_FAILED", "Failed to get notification history", e)
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Real device-memory facts from ActivityManager, for JS-side low-end tuning.
|
|
507
|
+
* `isLowRamDevice` is the OS's own declaration (Go-edition and 1-2GB phones),
|
|
508
|
+
* `memoryClass` is the per-app heap budget in MB, and totalMem/availMem
|
|
509
|
+
* describe the whole device. JS previously GUESSED all of this from screen
|
|
510
|
+
* size, which misclassified most phones — this is the ground truth.
|
|
511
|
+
*/
|
|
512
|
+
@ReactMethod
|
|
513
|
+
override fun getDeviceCapabilities(promise: Promise) {
|
|
514
|
+
try {
|
|
515
|
+
val context = reactApplicationContext.applicationContext
|
|
516
|
+
val activityManager =
|
|
517
|
+
context.getSystemService(Context.ACTIVITY_SERVICE) as android.app.ActivityManager
|
|
518
|
+
val memoryInfo = android.app.ActivityManager.MemoryInfo()
|
|
519
|
+
activityManager.getMemoryInfo(memoryInfo)
|
|
520
|
+
|
|
521
|
+
val result = Arguments.createMap().apply {
|
|
522
|
+
putBoolean("isLowRamDevice", activityManager.isLowRamDevice)
|
|
523
|
+
putInt("memoryClassMb", activityManager.memoryClass)
|
|
524
|
+
putDouble("totalRamMb", (memoryInfo.totalMem / (1024.0 * 1024.0)))
|
|
525
|
+
putDouble("availableRamMb", (memoryInfo.availMem / (1024.0 * 1024.0)))
|
|
526
|
+
putBoolean("isCurrentlyLowMemory", memoryInfo.lowMemory)
|
|
527
|
+
putInt("cpuCores", Runtime.getRuntime().availableProcessors())
|
|
528
|
+
}
|
|
529
|
+
promise.resolve(result)
|
|
530
|
+
} catch (e: Exception) {
|
|
531
|
+
promise.reject("DEVICE_CAPABILITIES_FAILED", "Failed to read device capabilities", e)
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
@ReactMethod
|
|
536
|
+
override fun clearNotificationHistory(promise: Promise) {
|
|
537
|
+
try {
|
|
538
|
+
val context = reactApplicationContext.applicationContext
|
|
539
|
+
val sharedPrefs = context.getSharedPreferences("UploadServicePrefs", Context.MODE_PRIVATE)
|
|
540
|
+
sharedPrefs.edit().remove("notification_history").apply()
|
|
541
|
+
promise.resolve(null)
|
|
542
|
+
} catch (e: Exception) {
|
|
543
|
+
promise.reject("CLEAR_NOTIFICATION_HISTORY_FAILED", "Failed to clear notification history", e)
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
@ReactMethod
|
|
548
|
+
override fun startUpload(uris: ReadableArray, uploadUrl: String, promise: Promise) {
|
|
549
|
+
val context = reactApplicationContext.applicationContext
|
|
550
|
+
val uriList = ArrayList<String>()
|
|
551
|
+
// Read each element defensively: getString throws if JS passed a non-string element
|
|
552
|
+
// (e.g. a number/object), and that throw would escape the @ReactMethod before the
|
|
553
|
+
// promise settles → bridge crash + hung promise. Skip anything that isn't a string.
|
|
554
|
+
for (i in 0 until uris.size()) {
|
|
555
|
+
val item = try {
|
|
556
|
+
if (uris.getType(i) == ReadableType.String) uris.getString(i) else null
|
|
557
|
+
} catch (e: Exception) {
|
|
558
|
+
null
|
|
559
|
+
}
|
|
560
|
+
item?.let { uriList.add(it) }
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// Never start the (foreground) upload service with nothing to upload. The service's
|
|
564
|
+
// ACTION_START_UPLOAD branch bails on empty URIs WITHOUT calling startForeground(),
|
|
565
|
+
// which — when launched via startForegroundService on API 26+ — crashes the app with
|
|
566
|
+
// RemoteServiceException. Reject cleanly instead of starting it at all.
|
|
567
|
+
if (uriList.isEmpty() || uploadUrl.isBlank()) {
|
|
568
|
+
promise.reject("UPLOAD_START_FAILED", "No valid media URIs to upload")
|
|
569
|
+
return
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// Hand the service a React context so it can emit in-app "onUploadProgress"
|
|
573
|
+
// events (the replacement for the disabled system notifications). Same
|
|
574
|
+
// pattern as GlobalAudioPlayer.reactContext.
|
|
575
|
+
UploadService.reactContext = reactApplicationContext
|
|
576
|
+
|
|
577
|
+
fun launchUploadService() {
|
|
578
|
+
val intent = Intent(context, UploadService::class.java).apply {
|
|
579
|
+
action = UploadService.ACTION_START_UPLOAD
|
|
580
|
+
putStringArrayListExtra(UploadService.EXTRA_URIS, uriList)
|
|
581
|
+
putExtra(UploadService.EXTRA_UPLOAD_URL, uploadUrl)
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
try {
|
|
585
|
+
// startForegroundService OBLIGATES a startForeground() call (which requires
|
|
586
|
+
// posting a notification) within ~5s on API 26+. With system notifications
|
|
587
|
+
// disabled the service never calls startForeground, so it must be launched
|
|
588
|
+
// as a regular background service instead.
|
|
589
|
+
if (UploadService.SYSTEM_NOTIFICATIONS_ENABLED &&
|
|
590
|
+
Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
|
|
591
|
+
) {
|
|
592
|
+
context.startForegroundService(intent)
|
|
593
|
+
} else {
|
|
594
|
+
context.startService(intent)
|
|
595
|
+
}
|
|
596
|
+
promise.resolve(null)
|
|
597
|
+
} catch (e: Exception) {
|
|
598
|
+
promise.reject("UPLOAD_START_FAILED", "Failed to start upload service", e)
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// startUpload() can be called without the host app ever having opened the camera
|
|
603
|
+
// picker (the only other place that requests POST_NOTIFICATIONS), so the upload's
|
|
604
|
+
// progress/success/failure notifications could otherwise silently never appear on
|
|
605
|
+
// API 33+. Request it here too — best-effort, since the upload itself doesn't
|
|
606
|
+
// depend on the permission being granted, only its visible progress does.
|
|
607
|
+
// Skipped entirely while system notifications are disabled: progress is shown
|
|
608
|
+
// in-app, so the app must not prompt for notification permission.
|
|
609
|
+
if (UploadService.SYSTEM_NOTIFICATIONS_ENABLED &&
|
|
610
|
+
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
|
611
|
+
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) !=
|
|
612
|
+
PackageManager.PERMISSION_GRANTED
|
|
613
|
+
) {
|
|
614
|
+
val permissionAwareActivity = reactApplicationContext.currentActivity as? PermissionAwareActivity
|
|
615
|
+
if (permissionAwareActivity != null) {
|
|
616
|
+
permissionAwareActivity.requestPermissions(
|
|
617
|
+
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
|
|
618
|
+
NOTIFICATION_PERMISSION_REQUEST,
|
|
619
|
+
PermissionListener { requestCode, _, _ ->
|
|
620
|
+
if (requestCode == NOTIFICATION_PERMISSION_REQUEST) {
|
|
621
|
+
launchUploadService()
|
|
622
|
+
true
|
|
623
|
+
} else {
|
|
624
|
+
false
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
)
|
|
628
|
+
return
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
launchUploadService()
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
@ReactMethod
|
|
636
|
+
override fun cropImage(uri: String, options: ReadableMap, promise: Promise) {
|
|
637
|
+
// Basic stub for image cropping
|
|
638
|
+
promise.resolve(uri)
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
@ReactMethod
|
|
642
|
+
override fun rotateImage(uri: String, degrees: Double, promise: Promise) {
|
|
643
|
+
try {
|
|
644
|
+
val parsedUri = android.net.Uri.parse(uri)
|
|
645
|
+
val bitmap = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
|
646
|
+
val source = android.graphics.ImageDecoder.createSource(reactApplicationContext.contentResolver, parsedUri)
|
|
647
|
+
android.graphics.ImageDecoder.decodeBitmap(source)
|
|
648
|
+
} else {
|
|
649
|
+
@Suppress("DEPRECATION")
|
|
650
|
+
android.provider.MediaStore.Images.Media.getBitmap(reactApplicationContext.contentResolver, parsedUri)
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
val matrix = android.graphics.Matrix()
|
|
654
|
+
matrix.postRotate(degrees.toFloat())
|
|
655
|
+
val rotatedBitmap = android.graphics.Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
|
|
656
|
+
|
|
657
|
+
val file = java.io.File(reactApplicationContext.cacheDir, "rotated_${System.currentTimeMillis()}.jpg")
|
|
658
|
+
val out = java.io.FileOutputStream(file)
|
|
659
|
+
rotatedBitmap.compress(android.graphics.Bitmap.CompressFormat.JPEG, 100, out)
|
|
660
|
+
out.flush()
|
|
661
|
+
out.close()
|
|
662
|
+
|
|
663
|
+
bitmap.recycle()
|
|
664
|
+
if (bitmap != rotatedBitmap) {
|
|
665
|
+
rotatedBitmap.recycle()
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
promise.resolve(getSecureUriForFile(file))
|
|
669
|
+
} catch (e: Exception) {
|
|
670
|
+
promise.reject("ROTATE_ERROR", e.message)
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
@ReactMethod
|
|
675
|
+
override fun applyFilter(uri: String, filterType: String, promise: Promise) {
|
|
676
|
+
// Basic stub for filter applying
|
|
677
|
+
promise.resolve(uri)
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
@ReactMethod
|
|
681
|
+
override fun createAlbum(albumName: String, promise: Promise) {
|
|
682
|
+
promise.resolve(true)
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
@ReactMethod
|
|
686
|
+
override fun moveMediaToAlbum(uris: ReadableArray, albumId: String, promise: Promise) {
|
|
687
|
+
promise.resolve(true)
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* Securely delete one or more media items.
|
|
692
|
+
*
|
|
693
|
+
* Every URI is routed through [SecureMediaDeleter], which overwrites the file's bytes with
|
|
694
|
+
* encrypted cryptographically secure random data 2 MiB longer than the file, flushes that to
|
|
695
|
+
* storage, unlinks it, and then sweeps every cached copy, derived variant and metadata reference
|
|
696
|
+
* the app holds for it. Callers that want the per-file security verdict should use
|
|
697
|
+
* [secureDeleteMedia]; this method keeps its historical `boolean` contract for existing callers.
|
|
698
|
+
*
|
|
699
|
+
* Runs on [Dispatchers.IO]: overwriting a large recording takes real seconds and must never
|
|
700
|
+
* touch the main thread.
|
|
701
|
+
*/
|
|
702
|
+
@ReactMethod
|
|
703
|
+
override fun deleteMedia(uris: ReadableArray, promise: Promise) {
|
|
704
|
+
val uriList = (0 until uris.size()).mapNotNull { uris.getString(it) }
|
|
705
|
+
runOnBackgroundThread {
|
|
706
|
+
try {
|
|
707
|
+
var deletedCount = 0
|
|
708
|
+
var insecureCount = 0
|
|
709
|
+
for (uriStr in uriList) {
|
|
710
|
+
val report = SecureMediaDeleter.secureDelete(reactApplicationContext, uriStr)
|
|
711
|
+
if (report.deleted) deletedCount++
|
|
712
|
+
if (!report.fullySecure) insecureCount++
|
|
713
|
+
}
|
|
714
|
+
if (insecureCount > 0) {
|
|
715
|
+
// The boolean result cannot express "deleted but not securely", so the shortfall is at
|
|
716
|
+
// least made loud here. secureDeleteMedia() reports it to JS properly.
|
|
717
|
+
Log.e(
|
|
718
|
+
NAME,
|
|
719
|
+
"$insecureCount of ${uriList.size} deletions could not be verified as secure"
|
|
720
|
+
)
|
|
721
|
+
}
|
|
722
|
+
promise.resolve(deletedCount > 0)
|
|
723
|
+
} catch (e: Exception) {
|
|
724
|
+
Log.e(NAME, "Secure delete failed", e)
|
|
725
|
+
promise.reject("DELETE_ERROR", e.message, e)
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* Securely delete ONE media item, reporting exactly what was achieved.
|
|
732
|
+
*
|
|
733
|
+
* Unlike [deleteMedia] this never collapses the outcome to a boolean, so JS can distinguish
|
|
734
|
+
* "the file is gone and its bytes were destroyed" from "the file is gone but the overwrite
|
|
735
|
+
* failed" — the two must not be presented to the user the same way. Resolves with:
|
|
736
|
+
*
|
|
737
|
+
* - `deleted` — the file is no longer on the filesystem
|
|
738
|
+
* - `securelyOverwritten` — its bytes were overwritten AND flushed before it was unlinked
|
|
739
|
+
* - `fullySecure` — the above, plus every derived copy also erased; the ONLY flag that
|
|
740
|
+
* justifies telling a user the media was securely deleted
|
|
741
|
+
* - `originalBytes` / `overwrittenBytes` — exact size, and how much garbage went over it
|
|
742
|
+
* - `derivedFilesErased` / `derivedFilesFailed` — extra copies handled and missed
|
|
743
|
+
* - `error` — present whenever any part fell short
|
|
744
|
+
*
|
|
745
|
+
* [assetId], [fileName] and [coverUri] are optional but strongly preferred: derived variants
|
|
746
|
+
* (compressed, exported, edited, cover frames) are named after the asset id, so omitting it
|
|
747
|
+
* leaves those copies of the same media on disk. `extraUris` erases additional copies the caller
|
|
748
|
+
* knows about — typically an asset's `cachePath` when its published document is the primary.
|
|
749
|
+
*/
|
|
750
|
+
@ReactMethod
|
|
751
|
+
override fun secureDeleteMedia(uri: String, options: ReadableMap, promise: Promise) {
|
|
752
|
+
fun option(key: String): String? =
|
|
753
|
+
options.takeIf { it.hasKey(key) }?.getString(key)?.takeIf { it.isNotBlank() }
|
|
754
|
+
|
|
755
|
+
val assetId = option("assetId")
|
|
756
|
+
val fileName = option("fileName")
|
|
757
|
+
val coverUri = option("coverUri")
|
|
758
|
+
// Read off the bridge thread: a ReadableMap/ReadableArray must not be touched once the JS
|
|
759
|
+
// call returns, and the erase below runs long after that on Dispatchers.IO.
|
|
760
|
+
val extraUris = options.takeIf { it.hasKey("extraUris") }
|
|
761
|
+
?.getArray("extraUris")
|
|
762
|
+
?.let { array ->
|
|
763
|
+
(0 until array.size()).mapNotNull { array.getString(it)?.takeIf(String::isNotBlank) }
|
|
764
|
+
}
|
|
765
|
+
?: emptyList()
|
|
766
|
+
|
|
767
|
+
runOnBackgroundThread {
|
|
768
|
+
try {
|
|
769
|
+
val report = SecureMediaDeleter.secureDelete(
|
|
770
|
+
context = reactApplicationContext,
|
|
771
|
+
uriString = uri,
|
|
772
|
+
assetId = assetId,
|
|
773
|
+
fileName = fileName,
|
|
774
|
+
coverUri = coverUri,
|
|
775
|
+
extraUris = extraUris
|
|
776
|
+
)
|
|
777
|
+
promise.resolve(Arguments.createMap().apply {
|
|
778
|
+
putBoolean("deleted", report.deleted)
|
|
779
|
+
putBoolean("securelyOverwritten", report.securelyOverwritten)
|
|
780
|
+
putBoolean("fullySecure", report.fullySecure)
|
|
781
|
+
putDouble("originalBytes", report.originalBytes.toDouble())
|
|
782
|
+
putDouble("overwrittenBytes", report.overwrittenBytes.toDouble())
|
|
783
|
+
putInt("derivedFilesErased", report.derivedFilesErased)
|
|
784
|
+
putInt("derivedFilesFailed", report.derivedFilesFailed)
|
|
785
|
+
report.error?.let { putString("error", it) }
|
|
786
|
+
})
|
|
787
|
+
} catch (e: Exception) {
|
|
788
|
+
Log.e(NAME, "Secure delete failed for $uri", e)
|
|
789
|
+
promise.reject("SECURE_DELETE_ERROR", e.message, e)
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/** Wraps a `file://` path in a FileProvider content uri for cross-app sharing. */
|
|
795
|
+
private fun shareableUri(uriStr: String): Uri {
|
|
796
|
+
val parsed = Uri.parse(uriStr)
|
|
797
|
+
val scheme = parsed.scheme
|
|
798
|
+
if (scheme == "file" || scheme == null) {
|
|
799
|
+
val path = if (scheme == "file") parsed.path else uriStr
|
|
800
|
+
if (path != null) {
|
|
801
|
+
try {
|
|
802
|
+
return FileProvider.getUriForFile(
|
|
803
|
+
reactApplicationContext,
|
|
804
|
+
"${reactApplicationContext.packageName}.camera.fileprovider",
|
|
805
|
+
File(path)
|
|
806
|
+
)
|
|
807
|
+
} catch (e: Exception) {
|
|
808
|
+
// Fall through and share the original uri.
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
return parsed
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
/** Resolves a MIME type, falling back to the extension when it is empty/generic. */
|
|
816
|
+
private fun resolveShareMimeType(uriStr: String, mimeType: String): String {
|
|
817
|
+
var mime = mimeType.trim()
|
|
818
|
+
if (mime.isEmpty() || mime == "*/*") {
|
|
819
|
+
val extension = android.webkit.MimeTypeMap.getFileExtensionFromUrl(uriStr)
|
|
820
|
+
if (extension != null && extension.isNotBlank()) {
|
|
821
|
+
val guessed =
|
|
822
|
+
android.webkit.MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension.lowercase())
|
|
823
|
+
if (!guessed.isNullOrBlank()) mime = guessed
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
return mime.ifBlank { "*/*" }
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/**
|
|
830
|
+
* Share one or more media uris through the native Android share sheet using
|
|
831
|
+
* ACTION_SEND (single item) or ACTION_SEND_MULTIPLE (multiple items), with
|
|
832
|
+
* the correct MIME type for images, videos, documents and audio. File paths
|
|
833
|
+
* are wrapped in FileProvider content uris; read permission is granted to the
|
|
834
|
+
* launched chooser via FLAG_GRANT_READ_URI_PERMISSION + ClipData. Resolves
|
|
835
|
+
* `true` when the chooser was launched, `false` when no handler exists.
|
|
836
|
+
*/
|
|
837
|
+
@ReactMethod
|
|
838
|
+
override fun shareMedia(uris: ReadableArray, mimeType: String, promise: Promise) {
|
|
839
|
+
try {
|
|
840
|
+
val shareUris = ArrayList<Uri>(uris.size())
|
|
841
|
+
for (i in 0 until uris.size()) {
|
|
842
|
+
val uriStr = uris.getString(i) ?: continue
|
|
843
|
+
shareUris.add(shareableUri(uriStr))
|
|
844
|
+
}
|
|
845
|
+
if (shareUris.isEmpty()) {
|
|
846
|
+
promise.resolve(false)
|
|
847
|
+
return
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
val resolvedMime = resolveShareMimeType(shareUris.first().toString(), mimeType)
|
|
851
|
+
|
|
852
|
+
val intent = if (shareUris.size == 1) {
|
|
853
|
+
Intent(Intent.ACTION_SEND).apply {
|
|
854
|
+
type = resolvedMime
|
|
855
|
+
putExtra(Intent.EXTRA_STREAM, shareUris[0])
|
|
856
|
+
clipData = ClipData.newUri(
|
|
857
|
+
reactApplicationContext.contentResolver,
|
|
858
|
+
"shared_media",
|
|
859
|
+
shareUris[0]
|
|
860
|
+
)
|
|
861
|
+
}
|
|
862
|
+
} else {
|
|
863
|
+
Intent(Intent.ACTION_SEND_MULTIPLE).apply {
|
|
864
|
+
type = resolvedMime
|
|
865
|
+
// Uri is a Parcelable; putParcelableArrayListExtra accepts ArrayList<out Parcelable>.
|
|
866
|
+
putParcelableArrayListExtra(Intent.EXTRA_STREAM, shareUris)
|
|
867
|
+
clipData = ClipData.newUri(
|
|
868
|
+
reactApplicationContext.contentResolver,
|
|
869
|
+
"shared_media",
|
|
870
|
+
shareUris.first()
|
|
871
|
+
)
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
|
876
|
+
|
|
877
|
+
val chooser = Intent.createChooser(
|
|
878
|
+
intent,
|
|
879
|
+
Localization.getString("share", "Share")
|
|
880
|
+
)
|
|
881
|
+
chooser.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
|
882
|
+
|
|
883
|
+
// Explicitly grant URI read permission to all target apps that can handle
|
|
884
|
+
// the underlying share intent. Querying the chooser itself may only return
|
|
885
|
+
// the chooser activity rather than real share recipients.
|
|
886
|
+
try {
|
|
887
|
+
val pm = reactApplicationContext.packageManager
|
|
888
|
+
val resInfoList = pm.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY)
|
|
889
|
+
for (resolveInfo in resInfoList) {
|
|
890
|
+
val packageName = resolveInfo.activityInfo.packageName
|
|
891
|
+
for (uri in shareUris) {
|
|
892
|
+
try {
|
|
893
|
+
reactApplicationContext.grantUriPermission(packageName, uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
|
894
|
+
} catch (e: Exception) {
|
|
895
|
+
// Best-effort: ignore failures to grant to a particular package
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
} catch (e: Exception) {
|
|
900
|
+
// Ignore any failures querying package manager — fallback to intent flags
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
val activity = reactApplicationContext.currentActivity
|
|
904
|
+
if (activity != null) {
|
|
905
|
+
activity.startActivity(chooser)
|
|
906
|
+
} else {
|
|
907
|
+
val appContext = reactApplicationContext.applicationContext
|
|
908
|
+
chooser.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
|
909
|
+
appContext.startActivity(chooser)
|
|
910
|
+
}
|
|
911
|
+
promise.resolve(true)
|
|
912
|
+
} catch (e: ActivityNotFoundException) {
|
|
913
|
+
promise.resolve(false)
|
|
914
|
+
} catch (e: Exception) {
|
|
915
|
+
promise.reject("SHARE_ERROR", e.message)
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
@ReactMethod
|
|
920
|
+
override fun setCameraFlash(mode: String, promise: Promise) {
|
|
921
|
+
saveSetting("flashMode", mode)
|
|
922
|
+
promise.resolve(null)
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
@ReactMethod
|
|
926
|
+
override fun setCameraTimer(delaySeconds: Double, promise: Promise) {
|
|
927
|
+
saveSetting("timerDelay", delaySeconds.toInt())
|
|
928
|
+
promise.resolve(null)
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
@ReactMethod
|
|
932
|
+
override fun enableCameraGrid(enabled: Boolean, promise: Promise) {
|
|
933
|
+
saveSetting("gridEnabled", enabled)
|
|
934
|
+
promise.resolve(null)
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
@ReactMethod
|
|
938
|
+
override fun setMediaCaption(uri: String, caption: String, promise: Promise) {
|
|
939
|
+
promise.resolve(true)
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
@ReactMethod
|
|
943
|
+
override fun getMediaMetadata(uri: String, promise: Promise) {
|
|
944
|
+
try {
|
|
945
|
+
val parsedUri = android.net.Uri.parse(uri)
|
|
946
|
+
val map = Arguments.createMap()
|
|
947
|
+
map.putString("uri", uri)
|
|
948
|
+
|
|
949
|
+
reactApplicationContext.contentResolver.query(parsedUri, null, null, null, null)?.use { cursor ->
|
|
950
|
+
if (cursor.moveToFirst()) {
|
|
951
|
+
// Basic file info
|
|
952
|
+
val sizeIndex = cursor.getColumnIndex(android.provider.MediaStore.MediaColumns.SIZE)
|
|
953
|
+
if (sizeIndex != -1) map.putDouble("size", cursor.getLong(sizeIndex).toDouble())
|
|
954
|
+
|
|
955
|
+
// Only accept POSITIVE geometry/duration from MediaStore. The columns exist but
|
|
956
|
+
// read back 0 for plenty of entries (anything not yet scanned, and every
|
|
957
|
+
// provider-backed derived file), and storing a 0 here would satisfy the hasKey()
|
|
958
|
+
// checks below — suppressing the file probe and handing the caller a zero-sized
|
|
959
|
+
// clip it can only fall back to stale dimensions for.
|
|
960
|
+
val widthIndex = cursor.getColumnIndex(android.provider.MediaStore.MediaColumns.WIDTH)
|
|
961
|
+
if (widthIndex != -1) {
|
|
962
|
+
cursor.getInt(widthIndex).takeIf { it > 0 }?.let { map.putInt("width", it) }
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
val heightIndex = cursor.getColumnIndex(android.provider.MediaStore.MediaColumns.HEIGHT)
|
|
966
|
+
if (heightIndex != -1) {
|
|
967
|
+
cursor.getInt(heightIndex).takeIf { it > 0 }?.let { map.putInt("height", it) }
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
// Media type and duration (for videos)
|
|
971
|
+
val durationIndex = cursor.getColumnIndex(VideoColumns.DURATION)
|
|
972
|
+
if (durationIndex != -1) {
|
|
973
|
+
cursor.getLong(durationIndex).takeIf { it > 0 }?.let { map.putLong("duration", it) }
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
// Date information
|
|
977
|
+
val dateTakenIndex = cursor.getColumnIndex(android.provider.MediaStore.MediaColumns.DATE_TAKEN)
|
|
978
|
+
if (dateTakenIndex != -1) map.putLong("dateTaken", cursor.getLong(dateTakenIndex))
|
|
979
|
+
|
|
980
|
+
// Location information
|
|
981
|
+
val latitudeIndex = cursor.getColumnIndex(VideoColumns.LATITUDE)
|
|
982
|
+
if (latitudeIndex != -1) map.putDouble("latitude", cursor.getDouble(latitudeIndex))
|
|
983
|
+
|
|
984
|
+
val longitudeIndex = cursor.getColumnIndex(VideoColumns.LONGITUDE)
|
|
985
|
+
if (longitudeIndex != -1) map.putDouble("longitude", cursor.getDouble(longitudeIndex))
|
|
986
|
+
|
|
987
|
+
// MIME type
|
|
988
|
+
val mimeTypeIndex = cursor.getColumnIndex(android.provider.MediaStore.MediaColumns.MIME_TYPE)
|
|
989
|
+
if (mimeTypeIndex != -1) map.putString("mimeType", cursor.getString(mimeTypeIndex))
|
|
990
|
+
|
|
991
|
+
// Display name
|
|
992
|
+
val displayNameIndex = cursor.getColumnIndex(android.provider.MediaStore.MediaColumns.DISPLAY_NAME)
|
|
993
|
+
if (displayNameIndex != -1) map.putString("fileName", cursor.getString(displayNameIndex))
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
// MediaStore only knows WIDTH/HEIGHT/DURATION for media it indexed. A FileProvider
|
|
997
|
+
// (content://…camera.fileprovider/…) or file:// URI — e.g. the output of compressVideo —
|
|
998
|
+
// exposes none of them, so read them from the file itself. Without this the caller has no
|
|
999
|
+
// way to learn a derived clip's real geometry and would have to reuse the source's.
|
|
1000
|
+
if (!map.hasKey("width") || !map.hasKey("height") || !map.hasKey("duration")) {
|
|
1001
|
+
// First try the usual retriever probe which accepts a Context+Uri. Some providers
|
|
1002
|
+
// (or OS/device combinations) may fail for FileProvider-backed content URIs, so
|
|
1003
|
+
// fall back to probing via an open FileDescriptor if the first attempt returns null.
|
|
1004
|
+
readVideoDisplayMetadata(parsedUri)?.let { (dims, durationMs) ->
|
|
1005
|
+
val (w, h) = dims
|
|
1006
|
+
if (!map.hasKey("width") && w != null) map.putInt("width", w)
|
|
1007
|
+
if (!map.hasKey("height") && h != null) map.putInt("height", h)
|
|
1008
|
+
if (!map.hasKey("duration") && durationMs != null) map.putDouble("duration", durationMs.toDouble())
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
// Judge the first probe by what it actually produced, not by whether it returned at
|
|
1012
|
+
// all: a retriever that yields only a duration still leaves the caller without the
|
|
1013
|
+
// geometry it asked for, so the descriptor probe must still get its turn.
|
|
1014
|
+
if (!map.hasKey("width") || !map.hasKey("height")) {
|
|
1015
|
+
// Fallback: try opening a file descriptor and probe from it. This works with
|
|
1016
|
+
// FileProvider and other content URIs that grant read access via a descriptor.
|
|
1017
|
+
var pfd: android.content.res.AssetFileDescriptor? = null
|
|
1018
|
+
try {
|
|
1019
|
+
pfd = reactApplicationContext.contentResolver.openAssetFileDescriptor(parsedUri, "r")
|
|
1020
|
+
pfd?.fileDescriptor?.let { fd ->
|
|
1021
|
+
val retriever = android.media.MediaMetadataRetriever()
|
|
1022
|
+
try {
|
|
1023
|
+
retriever.setDataSource(fd)
|
|
1024
|
+
var w = retriever.extractMetadata(android.media.MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull()
|
|
1025
|
+
var h = retriever.extractMetadata(android.media.MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toIntOrNull()
|
|
1026
|
+
val duration = retriever.extractMetadata(android.media.MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLongOrNull()
|
|
1027
|
+
val rotation = retriever.extractMetadata(android.media.MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)?.toIntOrNull() ?: 0
|
|
1028
|
+
if (rotation == 90 || rotation == 270) {
|
|
1029
|
+
val swap = w
|
|
1030
|
+
w = h
|
|
1031
|
+
h = swap
|
|
1032
|
+
}
|
|
1033
|
+
if (!map.hasKey("width") && w != null) map.putInt("width", w)
|
|
1034
|
+
if (!map.hasKey("height") && h != null) map.putInt("height", h)
|
|
1035
|
+
if (!map.hasKey("duration") && duration != null) map.putDouble("duration", duration.toDouble())
|
|
1036
|
+
} finally {
|
|
1037
|
+
retriever.release()
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
} catch (e: Exception) {
|
|
1041
|
+
// Best-effort only — ignore and return whatever we could find.
|
|
1042
|
+
Log.w("CustomCameraModule", "Fallback metadata probe failed for $uri", e)
|
|
1043
|
+
} finally {
|
|
1044
|
+
try { pfd?.close() } catch (_: Exception) {}
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
promise.resolve(map)
|
|
1049
|
+
} catch (e: Exception) {
|
|
1050
|
+
promise.reject("METADATA_ERROR", e.message)
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
/**
|
|
1055
|
+
* The byte length of the exact clip the recording process finalized under [mediaId], or 0 when
|
|
1056
|
+
* the native layer remembers none.
|
|
1057
|
+
*
|
|
1058
|
+
* A just-recorded clip's cached file is replaced in the background by a smaller aspect-cropped
|
|
1059
|
+
* re-encode (CustomCameraActivity.scheduleBackgroundVideoCrop), so a fresh stat of the file on
|
|
1060
|
+
* disk no longer matches the size the recording counter approached. This returns the pre-crop
|
|
1061
|
+
* recorded length so the JS preview can quote the SAME number the recording counter showed and
|
|
1062
|
+
* the editor's "Original video" row shows (both read from [PendingCaptureCrops]), instead of the
|
|
1063
|
+
* smaller cropped file now on disk.
|
|
1064
|
+
*
|
|
1065
|
+
* It is present only for a clip recorded in THIS session: a gallery import, a user-compressed
|
|
1066
|
+
* copy (published under a fresh id), a trimmed/replaced clip (its remembered length forgotten),
|
|
1067
|
+
* or any clip after an app restart all yield 0 — and the caller then falls back to the honest
|
|
1068
|
+
* on-disk size, exactly as the native Original row falls back to its own measurement.
|
|
1069
|
+
*/
|
|
1070
|
+
@ReactMethod
|
|
1071
|
+
override fun getRecordedOriginalSize(mediaId: String, promise: Promise) {
|
|
1072
|
+
promise.resolve(PendingCaptureCrops.recordedOriginalBytes(mediaId).toDouble())
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
@ReactMethod
|
|
1076
|
+
override fun trimVideo(uri: String, startTime: Double, endTime: Double, promise: Promise) {
|
|
1077
|
+
// Basic stub for video trimming
|
|
1078
|
+
promise.resolve(uri)
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
/**
|
|
1082
|
+
* Rotation-corrected display dimensions and duration of the video at [uri], read straight from
|
|
1083
|
+
* the file. Width/height are swapped for 90/270 rotations (mirrors
|
|
1084
|
+
* MediaCacheManager.extractVideoMetadata) so they describe the frame exactly as it appears on
|
|
1085
|
+
* screen. Returns null when the URI isn't a readable video.
|
|
1086
|
+
*
|
|
1087
|
+
* MUST use try/finally, never Kotlin's `.use {}` — MediaMetadataRetriever is only AutoCloseable
|
|
1088
|
+
* from API 29, so `.use {}` crashes on API 24-28.
|
|
1089
|
+
*/
|
|
1090
|
+
private fun readVideoDisplayMetadata(
|
|
1091
|
+
uri: android.net.Uri
|
|
1092
|
+
): Pair<Pair<Int?, Int?>, Long?>? {
|
|
1093
|
+
return try {
|
|
1094
|
+
val retriever = android.media.MediaMetadataRetriever()
|
|
1095
|
+
try {
|
|
1096
|
+
retriever.setDataSource(reactApplicationContext, uri)
|
|
1097
|
+
var width = retriever
|
|
1098
|
+
.extractMetadata(android.media.MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull()
|
|
1099
|
+
var height = retriever
|
|
1100
|
+
.extractMetadata(android.media.MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toIntOrNull()
|
|
1101
|
+
val duration = retriever
|
|
1102
|
+
.extractMetadata(android.media.MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLongOrNull()
|
|
1103
|
+
val rotation = retriever
|
|
1104
|
+
.extractMetadata(android.media.MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)?.toIntOrNull() ?: 0
|
|
1105
|
+
if (rotation == 90 || rotation == 270) {
|
|
1106
|
+
val swap = width
|
|
1107
|
+
width = height
|
|
1108
|
+
height = swap
|
|
1109
|
+
}
|
|
1110
|
+
if (width == null && height == null && duration == null) null
|
|
1111
|
+
else Pair(Pair(width?.takeIf { it > 0 }, height?.takeIf { it > 0 }), duration?.takeIf { it > 0 })
|
|
1112
|
+
} finally {
|
|
1113
|
+
retriever.release()
|
|
1114
|
+
}
|
|
1115
|
+
} catch (e: Exception) {
|
|
1116
|
+
Log.w("CustomCameraModule", "Failed to read video metadata for $uri", e)
|
|
1117
|
+
null
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
/**
|
|
1122
|
+
* A readable [File] for a source URI, plus whether it is a throwaway copy this call staged.
|
|
1123
|
+
* A `content://` source has to be streamed into the cache first; that staging copy is a
|
|
1124
|
+
* DERIVED temp and must be deleted once the operation is done — the caller owns it. A
|
|
1125
|
+
* `file://` (or bare path) source is the caller's own file and is never touched.
|
|
1126
|
+
*/
|
|
1127
|
+
private data class ResolvedInput(val file: File, val isTemporaryCopy: Boolean)
|
|
1128
|
+
|
|
1129
|
+
/**
|
|
1130
|
+
* @param stagedExtension extension for the staging copy of a `content://` source. It matters:
|
|
1131
|
+
* the still-image decoders dispatch on the file name, so an image staged as `.mp4` decodes as
|
|
1132
|
+
* nothing at all. Defaults to the video case, which is what every caller but [compressImage]
|
|
1133
|
+
* wants.
|
|
1134
|
+
*/
|
|
1135
|
+
private fun resolveInputFile(
|
|
1136
|
+
context: android.content.Context,
|
|
1137
|
+
uriString: String,
|
|
1138
|
+
stagedExtension: String = "mp4"
|
|
1139
|
+
): ResolvedInput? {
|
|
1140
|
+
return try {
|
|
1141
|
+
val parsed = android.net.Uri.parse(uriString)
|
|
1142
|
+
val scheme = parsed.scheme?.lowercase()
|
|
1143
|
+
if (scheme == null || scheme == "file") {
|
|
1144
|
+
val path = parsed.path ?: uriString
|
|
1145
|
+
ResolvedInput(File(path), isTemporaryCopy = false)
|
|
1146
|
+
} else if (scheme == "content") {
|
|
1147
|
+
val tempFile = File(
|
|
1148
|
+
context.cacheDir,
|
|
1149
|
+
CompressionFileNameUtils.createUniqueFileName("temp_src", stagedExtension)
|
|
1150
|
+
)
|
|
1151
|
+
val inputStream = context.contentResolver.openInputStream(parsed)
|
|
1152
|
+
if (inputStream != null) {
|
|
1153
|
+
inputStream.use { input ->
|
|
1154
|
+
java.io.FileOutputStream(tempFile).use { output ->
|
|
1155
|
+
input.copyTo(output)
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
ResolvedInput(tempFile, isTemporaryCopy = true)
|
|
1160
|
+
} else {
|
|
1161
|
+
ResolvedInput(File(uriString), isTemporaryCopy = false)
|
|
1162
|
+
}
|
|
1163
|
+
} catch (e: Exception) {
|
|
1164
|
+
Log.w("CustomCameraModule", "Failed to resolve input file for URI $uriString", e)
|
|
1165
|
+
null
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
/**
|
|
1170
|
+
* The quality ratio a menu option string asks for, in 0.05..1.0.
|
|
1171
|
+
*
|
|
1172
|
+
* Any "Original …" option ("Original Video", "Original Image") means "leave it alone" and maps
|
|
1173
|
+
* to 1.0 — matched on the prefix so both media kinds are covered by the one rule.
|
|
1174
|
+
*/
|
|
1175
|
+
private fun parseCompressionRatio(option: String): Float {
|
|
1176
|
+
val clean = option.trim()
|
|
1177
|
+
if (clean.startsWith("Original", ignoreCase = true)) return 1.0f
|
|
1178
|
+
val digits = clean.replace("%", "").trim()
|
|
1179
|
+
val parsed = digits.toFloatOrNull() ?: return 1.0f
|
|
1180
|
+
return if (parsed > 1.0f) {
|
|
1181
|
+
(parsed / 100.0f).coerceIn(0.05f, 1.0f)
|
|
1182
|
+
} else {
|
|
1183
|
+
parsed.coerceIn(0.05f, 1.0f)
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
@ReactMethod
|
|
1188
|
+
override fun compressVideo(uri: String, qualityOrOption: String, promise: Promise) {
|
|
1189
|
+
CoroutineScope(Dispatchers.IO).launch {
|
|
1190
|
+
try {
|
|
1191
|
+
val ratio = parseCompressionRatio(qualityOrOption)
|
|
1192
|
+
if (ratio >= 1.0f) {
|
|
1193
|
+
// Only a true "Original …" pick (quality ratio 1.0) is handed back untouched. Every
|
|
1194
|
+
// level below it — including the lightest, 99% — still runs the transcode so the caller
|
|
1195
|
+
// gets a genuinely re-encoded file at the requested quality.
|
|
1196
|
+
promise.resolve(uri)
|
|
1197
|
+
return@launch
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
val context = reactApplicationContext
|
|
1201
|
+
val resolved = resolveInputFile(context, uri)
|
|
1202
|
+
if (resolved == null || !resolved.file.exists()) {
|
|
1203
|
+
promise.reject("FILE_NOT_FOUND", "Source video file not found for compression")
|
|
1204
|
+
return@launch
|
|
1205
|
+
}
|
|
1206
|
+
val inputFile = resolved.file
|
|
1207
|
+
|
|
1208
|
+
try {
|
|
1209
|
+
// Write into the FileProvider-covered cache/compressed/ subdirectory (see
|
|
1210
|
+
// res/xml/file_paths.xml). The cache ROOT is not covered, so an output written there
|
|
1211
|
+
// made FileProvider.getUriForFile throw and silently fall back to a file:// URI —
|
|
1212
|
+
// a URI the caller cannot reliably read metadata from or hand to a player. The
|
|
1213
|
+
// resolved content:// URI is what makes the compressed clip a first-class source.
|
|
1214
|
+
val outputDir = File(context.cacheDir, "compressed").apply { mkdirs() }
|
|
1215
|
+
val outputFile = File(
|
|
1216
|
+
outputDir,
|
|
1217
|
+
CompressionFileNameUtils.createUniqueFileName(
|
|
1218
|
+
"compressed_${(ratio * 100).toInt()}",
|
|
1219
|
+
"mp4"
|
|
1220
|
+
)
|
|
1221
|
+
)
|
|
1222
|
+
|
|
1223
|
+
// The source is only ever READ: compress() writes a separate file and never
|
|
1224
|
+
// overwrites, moves, or deletes its input. The progress sink is display-only: the
|
|
1225
|
+
// transcode never waits on the emit, and a JS side that ignores the event sees exactly
|
|
1226
|
+
// the behaviour it saw before.
|
|
1227
|
+
val success = VideoRotationTranscoder.compress(
|
|
1228
|
+
inputFile,
|
|
1229
|
+
outputFile,
|
|
1230
|
+
ratio,
|
|
1231
|
+
onProgress = { emitCompressionProgress(uri, qualityOrOption, it) }
|
|
1232
|
+
)
|
|
1233
|
+
if (success && outputFile.exists() && outputFile.length() > 0) {
|
|
1234
|
+
promise.resolve(getSecureUriForFile(outputFile))
|
|
1235
|
+
} else {
|
|
1236
|
+
runCatching { outputFile.delete() }
|
|
1237
|
+
promise.reject("COMPRESS_FAILED", "Failed to generate compressed video file")
|
|
1238
|
+
}
|
|
1239
|
+
} finally {
|
|
1240
|
+
// Drop the staged copy of a content:// source (never the caller's own file).
|
|
1241
|
+
if (resolved.isTemporaryCopy) runCatching { inputFile.delete() }
|
|
1242
|
+
}
|
|
1243
|
+
} catch (e: Exception) {
|
|
1244
|
+
promise.reject("COMPRESS_ERROR", e.message ?: "Error during video compression")
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
/**
|
|
1250
|
+
* Re-encode the still at [uri] smaller and resolve with a URI for the new file.
|
|
1251
|
+
*
|
|
1252
|
+
* The still-image twin of [compressVideo], and it follows the same contract exactly:
|
|
1253
|
+
*
|
|
1254
|
+
* - [qualityOrOption] is a QUALITY RATIO — "25%" / "0.25" both mean "keep a quarter", and only
|
|
1255
|
+
* a true "Original …" option (quality ratio 1.0) resolves the input URI back untouched
|
|
1256
|
+
* without creating, modifying or deleting a single file. Every level below it — down to 5% and
|
|
1257
|
+
* up to 99% — runs the encoder.
|
|
1258
|
+
* - The source is only ever READ. The compressed copy is a separate file under the
|
|
1259
|
+
* FileProvider-covered `cache/compressed/` subdirectory, so the resolved `content://` URI is
|
|
1260
|
+
* one the caller can actually read metadata from and hand to an image view (the cache ROOT is
|
|
1261
|
+
* not covered by @xml/file_paths, and an output written there silently degrades to a `file://`
|
|
1262
|
+
* URI).
|
|
1263
|
+
*
|
|
1264
|
+
* The output's container is chosen by [ImageExporter.compressionFormatFor] — JPEG, or lossy WebP
|
|
1265
|
+
* when the source could be carrying transparency — so the returned file's name always describes
|
|
1266
|
+
* its bytes. A source that is already as small as it can get is not an error: there is nothing
|
|
1267
|
+
* to gain from a bigger "compressed" copy, so the original URI is resolved instead.
|
|
1268
|
+
*/
|
|
1269
|
+
@ReactMethod
|
|
1270
|
+
override fun compressImage(uri: String, qualityOrOption: String, promise: Promise) {
|
|
1271
|
+
CoroutineScope(Dispatchers.IO).launch {
|
|
1272
|
+
try {
|
|
1273
|
+
val ratio = parseCompressionRatio(qualityOrOption)
|
|
1274
|
+
if (ratio >= 1.0f) {
|
|
1275
|
+
// Original selected (quality ratio 1.0) — resolve the original URI without touching any
|
|
1276
|
+
// file. 99% and every lighter-than-Original level still run the encoder below.
|
|
1277
|
+
promise.resolve(uri)
|
|
1278
|
+
return@launch
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
val context = reactApplicationContext
|
|
1282
|
+
// Stage a content:// source under its OWN extension: BitmapFactory and ImageDecoder both
|
|
1283
|
+
// dispatch on the file name, so a JPEG staged as "temp_src…mp4" decodes as nothing.
|
|
1284
|
+
val sourceFormat = ImageFormatSupport.fromFileName(uri)
|
|
1285
|
+
?: ImageFormatSupport.fromMimeType(
|
|
1286
|
+
runCatching { context.contentResolver.getType(android.net.Uri.parse(uri)) }.getOrNull()
|
|
1287
|
+
)
|
|
1288
|
+
val resolved = resolveInputFile(context, uri, sourceFormat?.extension ?: "jpg")
|
|
1289
|
+
if (resolved == null || !resolved.file.exists()) {
|
|
1290
|
+
promise.reject("FILE_NOT_FOUND", "Source image file not found for compression")
|
|
1291
|
+
return@launch
|
|
1292
|
+
}
|
|
1293
|
+
val inputFile = resolved.file
|
|
1294
|
+
|
|
1295
|
+
try {
|
|
1296
|
+
// Percent to SHAVE, which is what ImageExporter.compress takes — the inverse of the
|
|
1297
|
+
// quality ratio this method's option string carries.
|
|
1298
|
+
val percent = Math.round((1f - ratio) * 100f)
|
|
1299
|
+
val outputDir = File(context.cacheDir, "compressed").apply { mkdirs() }
|
|
1300
|
+
val outputFile = File(
|
|
1301
|
+
outputDir,
|
|
1302
|
+
CompressionFileNameUtils.createUniqueFileName(
|
|
1303
|
+
"compressed_$percent",
|
|
1304
|
+
ImageExporter.compressionFormatFor(inputFile).extension
|
|
1305
|
+
)
|
|
1306
|
+
)
|
|
1307
|
+
|
|
1308
|
+
// The source is only ever READ: compress() writes a separate file and never overwrites,
|
|
1309
|
+
// moves or deletes its input.
|
|
1310
|
+
val compressed = ImageExporter.compress(
|
|
1311
|
+
inputFile,
|
|
1312
|
+
outputFile,
|
|
1313
|
+
percent,
|
|
1314
|
+
onProgress = { emitCompressionProgress(uri, qualityOrOption, it) }
|
|
1315
|
+
)
|
|
1316
|
+
when (compressed) {
|
|
1317
|
+
is ImageExporter.CompressOutcome.Success ->
|
|
1318
|
+
promise.resolve(getSecureUriForFile(outputFile))
|
|
1319
|
+
// Nothing smaller was achievable, so nothing was written. Hand back the original
|
|
1320
|
+
// rather than a copy that would only be larger.
|
|
1321
|
+
ImageExporter.CompressOutcome.AlreadyMinimal -> {
|
|
1322
|
+
runCatching { outputFile.delete() }
|
|
1323
|
+
promise.resolve(uri)
|
|
1324
|
+
}
|
|
1325
|
+
ImageExporter.CompressOutcome.Failed -> {
|
|
1326
|
+
runCatching { outputFile.delete() }
|
|
1327
|
+
promise.reject("COMPRESS_FAILED", "Failed to generate compressed image file")
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
} finally {
|
|
1331
|
+
// Drop the staged copy of a content:// source (never the caller's own file).
|
|
1332
|
+
if (resolved.isTemporaryCopy) runCatching { inputFile.delete() }
|
|
1333
|
+
}
|
|
1334
|
+
} catch (e: Exception) {
|
|
1335
|
+
promise.reject("COMPRESS_ERROR", e.message ?: "Error during image compression")
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
/**
|
|
1341
|
+
* Emit one measured compression-progress report to JS on the `onCompressionProgress` device
|
|
1342
|
+
* event: `{ uri, option, progress }`, where `progress` is 0..1 and `uri` / `option` are the exact
|
|
1343
|
+
* arguments the [compressVideo] / [compressImage] call was made with, so a screen running one
|
|
1344
|
+
* compression at a time can tell a live report from a stale one.
|
|
1345
|
+
*
|
|
1346
|
+
* This is what lets a JS overlay show a REAL remaining time — `elapsed / progress` gives the
|
|
1347
|
+
* total the job is actually on course for — instead of counting a fixed prediction down and
|
|
1348
|
+
* either running out mid-compression or still promising seconds after it has finished.
|
|
1349
|
+
*
|
|
1350
|
+
* Fire-and-forget by design. The compressors throttle their own reporting, the emit is wrapped
|
|
1351
|
+
* so a torn-down React instance cannot fail a compression that is otherwise fine, and nothing in
|
|
1352
|
+
* either compression path reads the result: an unlistened event costs a map allocation.
|
|
1353
|
+
*/
|
|
1354
|
+
private fun emitCompressionProgress(uri: String, option: String, progress: Float) {
|
|
1355
|
+
runCatching {
|
|
1356
|
+
val payload = Arguments.createMap().apply {
|
|
1357
|
+
putString("uri", uri)
|
|
1358
|
+
putString("option", option)
|
|
1359
|
+
putDouble("progress", progress.coerceIn(0f, 1f).toDouble())
|
|
1360
|
+
}
|
|
1361
|
+
reactApplicationContext
|
|
1362
|
+
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
|
1363
|
+
.emit(EVENT_COMPRESSION_PROGRESS, payload)
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
@ReactMethod
|
|
1368
|
+
override fun scanQRCode(promise: Promise) {
|
|
1369
|
+
val activity = reactApplicationContext.currentActivity
|
|
1370
|
+
if (activity == null) {
|
|
1371
|
+
promise.reject("NO_ACTIVITY", "Activity not available")
|
|
1372
|
+
return
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
if (pendingBarcodePromise != null) {
|
|
1376
|
+
promise.reject("SCAN_ALREADY_OPEN", "A scan is already in progress")
|
|
1377
|
+
return
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
pendingBarcodePromise = promise
|
|
1381
|
+
try {
|
|
1382
|
+
activity.startActivityForResult(
|
|
1383
|
+
BarcodeScannerActivity.createIntent(activity),
|
|
1384
|
+
BARCODE_SCAN_REQUEST
|
|
1385
|
+
)
|
|
1386
|
+
} catch (exception: RuntimeException) {
|
|
1387
|
+
pendingBarcodePromise = null
|
|
1388
|
+
promise.reject("SCAN_ERROR", "Unable to open barcode scanner", exception)
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
@ReactMethod
|
|
1393
|
+
override fun enableFaceDetection(enabled: Boolean, promise: Promise) {
|
|
1394
|
+
saveSetting("faceDetectionEnabled", enabled)
|
|
1395
|
+
promise.resolve(null)
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
@ReactMethod
|
|
1399
|
+
override fun setFaceEffect(effectType: String, promise: Promise) {
|
|
1400
|
+
saveSetting("faceEffect", effectType)
|
|
1401
|
+
promise.resolve(null)
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
@ReactMethod
|
|
1405
|
+
override fun setCameraTheme(theme: String, promise: Promise) {
|
|
1406
|
+
saveSetting("cameraTheme", theme)
|
|
1407
|
+
CameraTheme.invalidate()
|
|
1408
|
+
promise.resolve(null)
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
@ReactMethod
|
|
1412
|
+
override fun setCameraConfig(configJson: String, promise: Promise) {
|
|
1413
|
+
saveSetting("cameraConfig", configJson)
|
|
1414
|
+
CameraTheme.invalidate()
|
|
1415
|
+
promise.resolve(null)
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
// ===== Audio Recording Methods =====
|
|
1419
|
+
|
|
1420
|
+
/**
|
|
1421
|
+
* Start recording audio using the native MediaRecorder API.
|
|
1422
|
+
*
|
|
1423
|
+
* Supported options (all optional with sensible defaults):
|
|
1424
|
+
* - `outputFormat` — `"mpeg4"` (default), `"aac_adts"`, `"amr_nb"`, `"amr_wb"`, `"three_gpp"`
|
|
1425
|
+
* - `audioEncoder` — `"aac"` (default), `"amr_nb"`, `"amr_wb"`, `"he_aac"`
|
|
1426
|
+
* - `audioSource` — `"mic"` (default), `"camcorder"`, `"voice_recognition"`, `"voice_communication"`
|
|
1427
|
+
* - `bitRate` — e.g. `128000` (default)
|
|
1428
|
+
* - `sampleRate` — e.g. `44100` (default)
|
|
1429
|
+
* - `meteringIntervalMs` — ms between amplitude callbacks (0 = disabled)
|
|
1430
|
+
*
|
|
1431
|
+
* Resolves with `{ filePath, mimeType }` on success.
|
|
1432
|
+
* Rejects with `RECORD_AUDIO_ERROR` on failure.
|
|
1433
|
+
*/
|
|
1434
|
+
@ReactMethod
|
|
1435
|
+
override fun startAudioRecording(options: ReadableMap, promise: Promise) {
|
|
1436
|
+
val activity = reactApplicationContext.currentActivity
|
|
1437
|
+
if (activity == null) {
|
|
1438
|
+
promise.reject("RECORD_AUDIO_ERROR", "Activity not available")
|
|
1439
|
+
return
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
try {
|
|
1443
|
+
// Set the react context on the recorder so it can emit JS events.
|
|
1444
|
+
AudioRecorder.setReactContext(reactApplicationContext)
|
|
1445
|
+
|
|
1446
|
+
val config = AudioRecorder.Config(
|
|
1447
|
+
outputFormat = parseOutputFormat(options.getString("outputFormat")),
|
|
1448
|
+
audioEncoder = parseAudioEncoder(options.getString("audioEncoder")),
|
|
1449
|
+
audioSource = parseAudioSource(options.getString("audioSource")),
|
|
1450
|
+
bitRate = if (options.hasKey("bitRate")) options.getInt("bitRate") else 128000,
|
|
1451
|
+
sampleRate = if (options.hasKey("sampleRate")) options.getInt("sampleRate") else 44100,
|
|
1452
|
+
fileName = if (options.hasKey("fileName")) options.getString("fileName") else null,
|
|
1453
|
+
meteringIntervalMs = if (options.hasKey("meteringIntervalMs")) options.getDouble("meteringIntervalMs").toLong() else 0L,
|
|
1454
|
+
)
|
|
1455
|
+
|
|
1456
|
+
val result = AudioRecorder.startRecording(activity, config)
|
|
1457
|
+
promise.resolve(result)
|
|
1458
|
+
} catch (e: SecurityException) {
|
|
1459
|
+
promise.reject("RECORD_AUDIO_ERROR", "Microphone permission not granted: ${e.message}", e)
|
|
1460
|
+
} catch (e: IllegalStateException) {
|
|
1461
|
+
promise.reject("RECORD_AUDIO_ERROR", e.message, e)
|
|
1462
|
+
} catch (e: Exception) {
|
|
1463
|
+
promise.reject("RECORD_AUDIO_ERROR", "Failed to start recording: ${e.message}", e)
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
/**
|
|
1468
|
+
* Stop the active recording and finalise the audio file.
|
|
1469
|
+
* Resolves with `{ filePath, durationMs, fileSizeBytes, mimeType }`.
|
|
1470
|
+
*/
|
|
1471
|
+
@ReactMethod
|
|
1472
|
+
override fun stopAudioRecording(promise: Promise) {
|
|
1473
|
+
try {
|
|
1474
|
+
val result = AudioRecorder.stopRecording()
|
|
1475
|
+
promise.resolve(result)
|
|
1476
|
+
} catch (e: IllegalStateException) {
|
|
1477
|
+
promise.reject("RECORD_AUDIO_ERROR", e.message, e)
|
|
1478
|
+
} catch (e: Exception) {
|
|
1479
|
+
promise.reject("RECORD_AUDIO_ERROR", "Failed to stop recording: ${e.message}", e)
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
/**
|
|
1484
|
+
* Cancel the active recording and delete the temporary file.
|
|
1485
|
+
* Resolves with `{ deleted: boolean }`.
|
|
1486
|
+
*/
|
|
1487
|
+
@ReactMethod
|
|
1488
|
+
override fun cancelAudioRecording(promise: Promise) {
|
|
1489
|
+
try {
|
|
1490
|
+
val deleted = AudioRecorder.cancelRecording()
|
|
1491
|
+
val result = Arguments.createMap().apply {
|
|
1492
|
+
putBoolean("deleted", deleted)
|
|
1493
|
+
}
|
|
1494
|
+
promise.resolve(result)
|
|
1495
|
+
} catch (e: IllegalStateException) {
|
|
1496
|
+
promise.reject("RECORD_AUDIO_ERROR", e.message, e)
|
|
1497
|
+
} catch (e: Exception) {
|
|
1498
|
+
promise.reject("RECORD_AUDIO_ERROR", "Failed to cancel recording: ${e.message}", e)
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
/**
|
|
1503
|
+
* Stop the active recording, validate the file, move it to the Documents
|
|
1504
|
+
* directory, and return the finalized audio asset.
|
|
1505
|
+
*
|
|
1506
|
+
* The file is validated (must exist, be non-empty, and at least 200 bytes)
|
|
1507
|
+
* before being moved. On success the temp file is deleted. The returned map
|
|
1508
|
+
* contains the full asset metadata suitable for SQLCipher persistence.
|
|
1509
|
+
*
|
|
1510
|
+
* Resolves with an asset map: `{ id, uri, type, documentUri, fileName,
|
|
1511
|
+
* mimeType, size, duration, dateTaken }`.
|
|
1512
|
+
* Rejects with `RECORD_AUDIO_ERROR` on validation or move failure.
|
|
1513
|
+
*/
|
|
1514
|
+
@ReactMethod
|
|
1515
|
+
override fun stopAndPublishAudioRecording(promise: Promise) {
|
|
1516
|
+
val activity = reactApplicationContext.currentActivity
|
|
1517
|
+
if (activity == null) {
|
|
1518
|
+
promise.reject("RECORD_AUDIO_ERROR", "Activity not available")
|
|
1519
|
+
return
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
try {
|
|
1523
|
+
// 1. Stop the recorder on the bridge thread (MediaRecorder is not thread-safe).
|
|
1524
|
+
val raw = AudioRecorder.stopRecording()
|
|
1525
|
+
|
|
1526
|
+
val cachePath = raw.getString("filePath") ?: ""
|
|
1527
|
+
val durationMs = raw.getDouble("durationMs")
|
|
1528
|
+
val mimeType = raw.getString("mimeType") ?: "audio/mp4"
|
|
1529
|
+
|
|
1530
|
+
// 2. Move file to Documents + validate on a background thread to avoid
|
|
1531
|
+
// blocking the React Native bridge while the copy is in flight.
|
|
1532
|
+
runOnBackgroundThread {
|
|
1533
|
+
try {
|
|
1534
|
+
val cacheFile = java.io.File(cachePath)
|
|
1535
|
+
if (!cacheFile.exists()) {
|
|
1536
|
+
promise.reject("RECORD_AUDIO_ERROR", "Recorded file does not exist")
|
|
1537
|
+
return@runOnBackgroundThread
|
|
1538
|
+
}
|
|
1539
|
+
if (cacheFile.length() < 200L) {
|
|
1540
|
+
cacheFile.delete()
|
|
1541
|
+
promise.reject(
|
|
1542
|
+
"RECORD_AUDIO_ERROR",
|
|
1543
|
+
"Recording too short or empty (${cacheFile.length()} bytes)"
|
|
1544
|
+
)
|
|
1545
|
+
return@runOnBackgroundThread
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
// Derive extension from the actual MIME type so we never mislabel.
|
|
1549
|
+
val ext = when (mimeType) {
|
|
1550
|
+
"audio/aac" -> "aac"
|
|
1551
|
+
"audio/amr" -> "amr"
|
|
1552
|
+
"audio/amr-wb" -> "amr"
|
|
1553
|
+
"audio/3gpp" -> "3gp"
|
|
1554
|
+
else -> "m4a"
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
val documentDir = reactApplicationContext.getExternalFilesDir(
|
|
1558
|
+
android.os.Environment.DIRECTORY_DOCUMENTS
|
|
1559
|
+
) ?: reactApplicationContext.filesDir
|
|
1560
|
+
val audioDir = java.io.File(documentDir, "media_documents/audio").apply { mkdirs() }
|
|
1561
|
+
val fileName = "recording_${System.currentTimeMillis()}.$ext"
|
|
1562
|
+
val targetFile = java.io.File(audioDir, fileName)
|
|
1563
|
+
|
|
1564
|
+
java.io.FileInputStream(cacheFile).use { input ->
|
|
1565
|
+
java.io.FileOutputStream(targetFile).use { output ->
|
|
1566
|
+
input.copyTo(output)
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
// Verify the target file, then clean up the cache file.
|
|
1571
|
+
if (!targetFile.exists() || targetFile.length() < 200L) {
|
|
1572
|
+
targetFile.delete()
|
|
1573
|
+
promise.reject("RECORD_AUDIO_ERROR", "Target file invalid after move")
|
|
1574
|
+
return@runOnBackgroundThread
|
|
1575
|
+
}
|
|
1576
|
+
cacheFile.delete()
|
|
1577
|
+
|
|
1578
|
+
// Generate a FileProvider content URI for the finalized file.
|
|
1579
|
+
val documentUri = try {
|
|
1580
|
+
androidx.core.content.FileProvider.getUriForFile(
|
|
1581
|
+
reactApplicationContext,
|
|
1582
|
+
"${reactApplicationContext.packageName}.camera.fileprovider",
|
|
1583
|
+
targetFile
|
|
1584
|
+
).toString()
|
|
1585
|
+
} catch (_: Exception) {
|
|
1586
|
+
android.net.Uri.fromFile(targetFile).toString()
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
val assetId = "audio_${System.currentTimeMillis()}"
|
|
1590
|
+
val asset = Arguments.createMap().apply {
|
|
1591
|
+
putString("id", assetId)
|
|
1592
|
+
putString("uri", documentUri)
|
|
1593
|
+
putString("documentUri", documentUri)
|
|
1594
|
+
putString("type", "audio")
|
|
1595
|
+
putString("fileName", fileName)
|
|
1596
|
+
putString("mimeType", mimeType)
|
|
1597
|
+
putDouble("size", targetFile.length().toDouble())
|
|
1598
|
+
putDouble("duration", durationMs)
|
|
1599
|
+
putDouble("dateTaken", System.currentTimeMillis().toDouble())
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
Log.i(NAME, "Audio recording published: $documentUri (${targetFile.length()} bytes, ${durationMs}ms)")
|
|
1603
|
+
promise.resolve(asset)
|
|
1604
|
+
} catch (e: Exception) {
|
|
1605
|
+
promise.reject("RECORD_AUDIO_ERROR", "Failed to publish recording: ${e.message}", e)
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
} catch (e: IllegalStateException) {
|
|
1609
|
+
promise.reject("RECORD_AUDIO_ERROR", e.message, e)
|
|
1610
|
+
} catch (e: Exception) {
|
|
1611
|
+
promise.reject("RECORD_AUDIO_ERROR", "Failed to stop and publish recording: ${e.message}", e)
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
/**
|
|
1616
|
+
* Pause the active recording. Requires API 24+.
|
|
1617
|
+
* On older API levels this rejects with `RECORD_AUDIO_ERROR`.
|
|
1618
|
+
*/
|
|
1619
|
+
@ReactMethod
|
|
1620
|
+
override fun pauseAudioRecording(promise: Promise) {
|
|
1621
|
+
try {
|
|
1622
|
+
AudioRecorder.pauseRecording()
|
|
1623
|
+
promise.resolve(null)
|
|
1624
|
+
} catch (e: UnsupportedOperationException) {
|
|
1625
|
+
promise.reject("RECORD_AUDIO_ERROR", "Pause/resume requires Android 7.0 (API 24) or later", e)
|
|
1626
|
+
} catch (e: IllegalStateException) {
|
|
1627
|
+
promise.reject("RECORD_AUDIO_ERROR", e.message, e)
|
|
1628
|
+
} catch (e: Exception) {
|
|
1629
|
+
promise.reject("RECORD_AUDIO_ERROR", "Failed to pause recording: ${e.message}", e)
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
/**
|
|
1634
|
+
* Resume a paused recording. Requires API 24+.
|
|
1635
|
+
*/
|
|
1636
|
+
@ReactMethod
|
|
1637
|
+
override fun resumeAudioRecording(promise: Promise) {
|
|
1638
|
+
try {
|
|
1639
|
+
AudioRecorder.resumeRecording()
|
|
1640
|
+
promise.resolve(null)
|
|
1641
|
+
} catch (e: UnsupportedOperationException) {
|
|
1642
|
+
promise.reject("RECORD_AUDIO_ERROR", "Pause/resume requires Android 7.0 (API 24) or later", e)
|
|
1643
|
+
} catch (e: IllegalStateException) {
|
|
1644
|
+
promise.reject("RECORD_AUDIO_ERROR", e.message, e)
|
|
1645
|
+
} catch (e: Exception) {
|
|
1646
|
+
promise.reject("RECORD_AUDIO_ERROR", "Failed to resume recording: ${e.message}", e)
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
/**
|
|
1651
|
+
* Get the current recording state as a map.
|
|
1652
|
+
* Resolves with `{ state, filePath, durationMs, isPauseSupported }`.
|
|
1653
|
+
*/
|
|
1654
|
+
@ReactMethod
|
|
1655
|
+
override fun getAudioRecordingState(promise: Promise) {
|
|
1656
|
+
try {
|
|
1657
|
+
promise.resolve(AudioRecorder.getStateMap())
|
|
1658
|
+
} catch (e: Exception) {
|
|
1659
|
+
promise.reject("RECORD_AUDIO_ERROR", "Failed to get recording state: ${e.message}", e)
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
/**
|
|
1664
|
+
* Get the current maximum amplitude from the active MediaRecorder.
|
|
1665
|
+
* Useful for building waveform visualisations.
|
|
1666
|
+
*/
|
|
1667
|
+
@ReactMethod
|
|
1668
|
+
override fun getAudioAmplitude(promise: Promise) {
|
|
1669
|
+
try {
|
|
1670
|
+
promise.resolve(AudioRecorder.getAmplitude())
|
|
1671
|
+
} catch (e: Exception) {
|
|
1672
|
+
promise.reject("RECORD_AUDIO_ERROR", "Failed to get amplitude: ${e.message}", e)
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
/** Parse output format string from JS into enum. */
|
|
1677
|
+
private fun parseOutputFormat(value: String?): AudioRecorder.OutputFormat = when (value?.lowercase()) {
|
|
1678
|
+
"mpeg4" -> AudioRecorder.OutputFormat.MPEG_4
|
|
1679
|
+
"aac_adts" -> AudioRecorder.OutputFormat.AAC_ADTS
|
|
1680
|
+
"amr_nb" -> AudioRecorder.OutputFormat.AMR_NB
|
|
1681
|
+
"amr_wb" -> AudioRecorder.OutputFormat.AMR_WB
|
|
1682
|
+
"three_gpp" -> AudioRecorder.OutputFormat.THREE_GPP
|
|
1683
|
+
else -> AudioRecorder.OutputFormat.MPEG_4
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
/** Parse audio encoder string from JS into enum. */
|
|
1687
|
+
private fun parseAudioEncoder(value: String?): AudioRecorder.AudioEncoder = when (value?.lowercase()) {
|
|
1688
|
+
"aac" -> AudioRecorder.AudioEncoder.AAC
|
|
1689
|
+
"amr_nb" -> AudioRecorder.AudioEncoder.AMR_NB
|
|
1690
|
+
"amr_wb" -> AudioRecorder.AudioEncoder.AMR_WB
|
|
1691
|
+
"he_aac" -> AudioRecorder.AudioEncoder.HE_AAC
|
|
1692
|
+
else -> AudioRecorder.AudioEncoder.AAC
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
/** Parse audio source string from JS into enum. */
|
|
1696
|
+
private fun parseAudioSource(value: String?): AudioRecorder.AudioSource = when (value?.lowercase()) {
|
|
1697
|
+
"mic" -> AudioRecorder.AudioSource.MIC
|
|
1698
|
+
"camcorder" -> AudioRecorder.AudioSource.CAMCORDER
|
|
1699
|
+
"voice_recognition" -> AudioRecorder.AudioSource.VOICE_RECOGNITION
|
|
1700
|
+
"voice_communication" -> AudioRecorder.AudioSource.VOICE_COMMUNICATION
|
|
1701
|
+
else -> AudioRecorder.AudioSource.MIC
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
// ===== Media Storage Methods =====
|
|
1705
|
+
|
|
1706
|
+
/**
|
|
1707
|
+
* Initialize media storage workflow
|
|
1708
|
+
*/
|
|
1709
|
+
@ReactMethod
|
|
1710
|
+
override fun initializeMediaStorage(promise: Promise) {
|
|
1711
|
+
try {
|
|
1712
|
+
val workflow = MediaStorageWorkflow.getInstance(reactApplicationContext)
|
|
1713
|
+
runOnBackgroundThread {
|
|
1714
|
+
workflow.initialize()
|
|
1715
|
+
promise.resolve(Arguments.createMap().apply {
|
|
1716
|
+
putBoolean("success", true)
|
|
1717
|
+
putString("message", "Media storage initialized")
|
|
1718
|
+
})
|
|
1719
|
+
}
|
|
1720
|
+
} catch (e: Exception) {
|
|
1721
|
+
promise.reject("STORAGE_INIT_ERROR", e.message, e)
|
|
1722
|
+
}
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
/**
|
|
1726
|
+
* Publish media (move/copy from cache to documents)
|
|
1727
|
+
*/
|
|
1728
|
+
@ReactMethod
|
|
1729
|
+
override fun publishMedia(mediaId: String, moveMode: Boolean, promise: Promise) {
|
|
1730
|
+
try {
|
|
1731
|
+
val workflow = MediaStorageWorkflow.getInstance(reactApplicationContext)
|
|
1732
|
+
runOnBackgroundThread {
|
|
1733
|
+
val result = workflow.publishMedia(mediaId, moveMode)
|
|
1734
|
+
val resultMap = Arguments.createMap().apply {
|
|
1735
|
+
putBoolean("success", result.success)
|
|
1736
|
+
putString("mediaId", result.mediaId)
|
|
1737
|
+
result.targetUri?.let { putString("targetUri", it) }
|
|
1738
|
+
result.fileHash?.let { putString("fileHash", it) }
|
|
1739
|
+
result.error?.let { putString("error", it) }
|
|
1740
|
+
}
|
|
1741
|
+
promise.resolve(resultMap)
|
|
1742
|
+
}
|
|
1743
|
+
} catch (e: Exception) {
|
|
1744
|
+
promise.reject("PUBLISH_ERROR", e.message, e)
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
/**
|
|
1749
|
+
* Publish multiple media in batch
|
|
1750
|
+
*/
|
|
1751
|
+
@ReactMethod
|
|
1752
|
+
override fun publishBatch(mediaIds: ReadableArray, moveMode: Boolean, promise: Promise) {
|
|
1753
|
+
try {
|
|
1754
|
+
val ids = (0 until mediaIds.size()).mapNotNull { mediaIds.getString(it) }
|
|
1755
|
+
val workflow = MediaStorageWorkflow.getInstance(reactApplicationContext)
|
|
1756
|
+
runOnBackgroundThread {
|
|
1757
|
+
val result = workflow.publishBatch(ids, moveMode)
|
|
1758
|
+
val resultMap = Arguments.createMap().apply {
|
|
1759
|
+
putInt("totalCount", result.totalCount)
|
|
1760
|
+
putInt("successCount", result.successCount)
|
|
1761
|
+
putInt("failureCount", result.failureCount)
|
|
1762
|
+
result.error?.let { putString("error", it) }
|
|
1763
|
+
}
|
|
1764
|
+
promise.resolve(resultMap)
|
|
1765
|
+
}
|
|
1766
|
+
} catch (e: Exception) {
|
|
1767
|
+
promise.reject("BATCH_PUBLISH_ERROR", e.message, e)
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
/**
|
|
1772
|
+
* Get cached media list
|
|
1773
|
+
*/
|
|
1774
|
+
@ReactMethod
|
|
1775
|
+
override fun getCachedMedia(promise: Promise) {
|
|
1776
|
+
try {
|
|
1777
|
+
val workflow = MediaStorageWorkflow.getInstance(reactApplicationContext)
|
|
1778
|
+
runOnBackgroundThread {
|
|
1779
|
+
val cachedMedia = workflow.getCachedMedia()
|
|
1780
|
+
val array = Arguments.createArray()
|
|
1781
|
+
cachedMedia.forEach { media ->
|
|
1782
|
+
array.pushMap(Arguments.createMap().apply {
|
|
1783
|
+
putString("id", media.id)
|
|
1784
|
+
putString("fileName", media.fileName)
|
|
1785
|
+
putString("fileType", media.fileType)
|
|
1786
|
+
putDouble("fileSize", media.fileSize.toDouble())
|
|
1787
|
+
media.width?.let { putInt("width", it) }
|
|
1788
|
+
media.height?.let { putInt("height", it) }
|
|
1789
|
+
media.duration?.let { putDouble("duration", it.toDouble()) }
|
|
1790
|
+
putDouble("cacheCreatedAt", media.cacheCreatedAt.toDouble())
|
|
1791
|
+
putDouble("cacheLastModified", media.cacheLastModified.toDouble())
|
|
1792
|
+
})
|
|
1793
|
+
}
|
|
1794
|
+
promise.resolve(array)
|
|
1795
|
+
}
|
|
1796
|
+
} catch (e: Exception) {
|
|
1797
|
+
promise.reject("GET_CACHED_ERROR", e.message, e)
|
|
1798
|
+
}
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
/**
|
|
1802
|
+
* Get published media list
|
|
1803
|
+
*/
|
|
1804
|
+
@ReactMethod
|
|
1805
|
+
override fun getPublishedMedia(promise: Promise) {
|
|
1806
|
+
try {
|
|
1807
|
+
val workflow = MediaStorageWorkflow.getInstance(reactApplicationContext)
|
|
1808
|
+
runOnBackgroundThread {
|
|
1809
|
+
val publishedMedia = workflow.getPublishedMedia()
|
|
1810
|
+
val array = Arguments.createArray()
|
|
1811
|
+
publishedMedia.forEach { media ->
|
|
1812
|
+
array.pushMap(Arguments.createMap().apply {
|
|
1813
|
+
putString("id", media.id)
|
|
1814
|
+
putString("fileName", media.fileName)
|
|
1815
|
+
putString("fileType", media.fileType)
|
|
1816
|
+
putDouble("fileSize", media.fileSize.toDouble())
|
|
1817
|
+
media.width?.let { putInt("width", it) }
|
|
1818
|
+
media.height?.let { putInt("height", it) }
|
|
1819
|
+
media.duration?.let { putDouble("duration", it.toDouble()) }
|
|
1820
|
+
media.documentUri?.let { putString("documentUri", it) }
|
|
1821
|
+
media.publishedAt?.let { putDouble("publishedAt", it.toDouble()) }
|
|
1822
|
+
})
|
|
1823
|
+
}
|
|
1824
|
+
promise.resolve(array)
|
|
1825
|
+
}
|
|
1826
|
+
} catch (e: Exception) {
|
|
1827
|
+
promise.reject("GET_PUBLISHED_ERROR", e.message, e)
|
|
1828
|
+
}
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
/**
|
|
1832
|
+
* Get storage statistics
|
|
1833
|
+
*/
|
|
1834
|
+
@ReactMethod
|
|
1835
|
+
override fun getStorageStats(promise: Promise) {
|
|
1836
|
+
try {
|
|
1837
|
+
val workflow = MediaStorageWorkflow.getInstance(reactApplicationContext)
|
|
1838
|
+
runOnBackgroundThread {
|
|
1839
|
+
val stats = workflow.getStorageStats()
|
|
1840
|
+
val resultMap = Arguments.createMap().apply {
|
|
1841
|
+
putDouble("cacheUsedMB", stats.cacheUsedMB.toDouble())
|
|
1842
|
+
putDouble("documentUsedMB", stats.documentUsedMB.toDouble())
|
|
1843
|
+
putDouble("totalAvailableMB", stats.totalAvailableMB.toDouble())
|
|
1844
|
+
putInt("cachedItemsCount", stats.cachedItemsCount)
|
|
1845
|
+
putInt("publishedItemsCount", stats.publishedItemsCount)
|
|
1846
|
+
putInt("duplicateItemsCount", stats.duplicateItemsCount)
|
|
1847
|
+
putDouble("oldestCacheItemAge", stats.oldestCacheItemAge.toDouble())
|
|
1848
|
+
putDouble("newestCacheItemAge", stats.newestCacheItemAge.toDouble())
|
|
1849
|
+
}
|
|
1850
|
+
promise.resolve(resultMap)
|
|
1851
|
+
}
|
|
1852
|
+
} catch (e: Exception) {
|
|
1853
|
+
promise.reject("STORAGE_STATS_ERROR", e.message, e)
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
/**
|
|
1858
|
+
* Cleanup old cache files
|
|
1859
|
+
*/
|
|
1860
|
+
@ReactMethod
|
|
1861
|
+
override fun cleanupOldCache(olderThanDays: Double, promise: Promise) {
|
|
1862
|
+
try {
|
|
1863
|
+
val workflow = MediaStorageWorkflow.getInstance(reactApplicationContext)
|
|
1864
|
+
runOnBackgroundThread {
|
|
1865
|
+
val result = workflow.cleanupOldCache(olderThanDays)
|
|
1866
|
+
val resultMap = Arguments.createMap().apply {
|
|
1867
|
+
putBoolean("success", result.success)
|
|
1868
|
+
putInt("filesRemoved", result.filesRemoved)
|
|
1869
|
+
putDouble("spaceFreedMB", result.spaceFreedMB)
|
|
1870
|
+
result.error?.let { putString("error", it) }
|
|
1871
|
+
}
|
|
1872
|
+
promise.resolve(resultMap)
|
|
1873
|
+
}
|
|
1874
|
+
} catch (e: Exception) {
|
|
1875
|
+
promise.reject("CLEANUP_ERROR", e.message, e)
|
|
1876
|
+
}
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
/**
|
|
1880
|
+
* Delete media by stored media id
|
|
1881
|
+
*/
|
|
1882
|
+
@ReactMethod
|
|
1883
|
+
override fun deleteStoredMedia(mediaId: String, promise: Promise) {
|
|
1884
|
+
try {
|
|
1885
|
+
val workflow = MediaStorageWorkflow.getInstance(reactApplicationContext)
|
|
1886
|
+
runOnBackgroundThread {
|
|
1887
|
+
val deleted = workflow.deleteMedia(mediaId)
|
|
1888
|
+
promise.resolve(deleted)
|
|
1889
|
+
}
|
|
1890
|
+
} catch (e: Exception) {
|
|
1891
|
+
promise.reject("DELETE_MEDIA_ERROR", e.message, e)
|
|
1892
|
+
}
|
|
1893
|
+
}
|
|
1894
|
+
|
|
1895
|
+
/**
|
|
1896
|
+
* Get failed transfers for retry
|
|
1897
|
+
*/
|
|
1898
|
+
@ReactMethod
|
|
1899
|
+
override fun getFailedTransfers(promise: Promise) {
|
|
1900
|
+
try {
|
|
1901
|
+
val workflow = MediaStorageWorkflow.getInstance(reactApplicationContext)
|
|
1902
|
+
runOnBackgroundThread {
|
|
1903
|
+
val failed = workflow.getFailedTransfers()
|
|
1904
|
+
val array = Arguments.createArray()
|
|
1905
|
+
failed.forEach { transfer ->
|
|
1906
|
+
array.pushMap(Arguments.createMap().apply {
|
|
1907
|
+
putString("mediaId", transfer.mediaId)
|
|
1908
|
+
putString("error", transfer.error)
|
|
1909
|
+
putDouble("failedAt", transfer.failedAt.toDouble())
|
|
1910
|
+
putInt("retryCount", transfer.retryCount)
|
|
1911
|
+
})
|
|
1912
|
+
}
|
|
1913
|
+
promise.resolve(array)
|
|
1914
|
+
}
|
|
1915
|
+
} catch (e: Exception) {
|
|
1916
|
+
promise.reject("FAILED_TRANSFERS_ERROR", e.message, e)
|
|
1917
|
+
}
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
/**
|
|
1921
|
+
* Retry failed transfers
|
|
1922
|
+
*/
|
|
1923
|
+
@ReactMethod
|
|
1924
|
+
override fun retryFailedTransfers(promise: Promise) {
|
|
1925
|
+
try {
|
|
1926
|
+
val workflow = MediaStorageWorkflow.getInstance(reactApplicationContext)
|
|
1927
|
+
runOnBackgroundThread {
|
|
1928
|
+
val results = workflow.retryFailedTransfers()
|
|
1929
|
+
val array = Arguments.createArray()
|
|
1930
|
+
results.forEach { result ->
|
|
1931
|
+
array.pushMap(Arguments.createMap().apply {
|
|
1932
|
+
putBoolean("success", result.success)
|
|
1933
|
+
putString("mediaId", result.mediaId)
|
|
1934
|
+
result.targetUri?.let { putString("targetUri", it) }
|
|
1935
|
+
result.error?.let { putString("error", it) }
|
|
1936
|
+
})
|
|
1937
|
+
}
|
|
1938
|
+
promise.resolve(array)
|
|
1939
|
+
}
|
|
1940
|
+
} catch (e: Exception) {
|
|
1941
|
+
promise.reject("RETRY_FAILED_ERROR", e.message, e)
|
|
1942
|
+
}
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
/**
|
|
1946
|
+
* Run a task on background thread
|
|
1947
|
+
*/
|
|
1948
|
+
private fun runOnBackgroundThread(task: suspend () -> Unit) {
|
|
1949
|
+
val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
|
1950
|
+
scope.launch {
|
|
1951
|
+
try {
|
|
1952
|
+
task()
|
|
1953
|
+
} catch (e: Exception) {
|
|
1954
|
+
Log.e(NAME, "Background task failed", e)
|
|
1955
|
+
}
|
|
1956
|
+
}
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
// ===== Global Audio Player Methods =====
|
|
1960
|
+
|
|
1961
|
+
@ReactMethod
|
|
1962
|
+
override fun playAudio(uri: String, title: String, promise: Promise) {
|
|
1963
|
+
try {
|
|
1964
|
+
if (GlobalAudioPlayer.reactContext == null) {
|
|
1965
|
+
GlobalAudioPlayer.reactContext = reactApplicationContext
|
|
1966
|
+
}
|
|
1967
|
+
val parsedUri = android.net.Uri.parse(uri)
|
|
1968
|
+
reactApplicationContext.currentActivity?.let { activity ->
|
|
1969
|
+
GlobalAudioPlayer.play(activity, parsedUri, title)
|
|
1970
|
+
}
|
|
1971
|
+
promise.resolve(null)
|
|
1972
|
+
} catch (e: Exception) {
|
|
1973
|
+
promise.reject("PLAY_AUDIO_ERROR", e.message, e)
|
|
1974
|
+
}
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
@ReactMethod
|
|
1978
|
+
override fun pauseAudio(promise: Promise) {
|
|
1979
|
+
try {
|
|
1980
|
+
GlobalAudioPlayer.pause()
|
|
1981
|
+
promise.resolve(null)
|
|
1982
|
+
} catch (e: Exception) {
|
|
1983
|
+
promise.reject("PAUSE_AUDIO_ERROR", e.message, e)
|
|
1984
|
+
}
|
|
1985
|
+
}
|
|
1986
|
+
|
|
1987
|
+
@ReactMethod
|
|
1988
|
+
override fun resumeAudio(promise: Promise) {
|
|
1989
|
+
try {
|
|
1990
|
+
GlobalAudioPlayer.resume()
|
|
1991
|
+
promise.resolve(null)
|
|
1992
|
+
} catch (e: Exception) {
|
|
1993
|
+
promise.reject("RESUME_AUDIO_ERROR", e.message, e)
|
|
1994
|
+
}
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
@ReactMethod
|
|
1998
|
+
override fun seekAudio(positionMs: Double, promise: Promise) {
|
|
1999
|
+
try {
|
|
2000
|
+
GlobalAudioPlayer.seekTo(positionMs.toInt())
|
|
2001
|
+
promise.resolve(null)
|
|
2002
|
+
} catch (e: Exception) {
|
|
2003
|
+
promise.reject("SEEK_AUDIO_ERROR", e.message, e)
|
|
2004
|
+
}
|
|
2005
|
+
}
|
|
2006
|
+
|
|
2007
|
+
@ReactMethod
|
|
2008
|
+
override fun stopAudio(promise: Promise) {
|
|
2009
|
+
try {
|
|
2010
|
+
GlobalAudioPlayer.release()
|
|
2011
|
+
promise.resolve(null)
|
|
2012
|
+
} catch (e: Exception) {
|
|
2013
|
+
promise.reject("STOP_AUDIO_ERROR", e.message, e)
|
|
2014
|
+
}
|
|
2015
|
+
}
|
|
2016
|
+
|
|
2017
|
+
private fun saveSetting(key: String, value: Any) {
|
|
2018
|
+
val prefs = reactApplicationContext.getSharedPreferences("CameraSettings", android.content.Context.MODE_PRIVATE)
|
|
2019
|
+
val editor = prefs.edit()
|
|
2020
|
+
when (value) {
|
|
2021
|
+
is String -> editor.putString(key, value)
|
|
2022
|
+
is Boolean -> editor.putBoolean(key, value)
|
|
2023
|
+
is Int -> editor.putInt(key, value)
|
|
2024
|
+
is Float -> editor.putFloat(key, value)
|
|
2025
|
+
else -> throw IllegalArgumentException("Unsupported setting value type: ${value::class.java}")
|
|
2026
|
+
}
|
|
2027
|
+
editor.apply()
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
override fun invalidate() {
|
|
2031
|
+
reactApplicationContext.removeActivityEventListener(activityEventListener)
|
|
2032
|
+
pendingPickerPromise?.reject("PICKER_INVALIDATED", "Camera picker was invalidated")
|
|
2033
|
+
pendingPickerPromise = null
|
|
2034
|
+
super.invalidate()
|
|
2035
|
+
}
|
|
2036
|
+
|
|
2037
|
+
private fun rejectUnsupported(promise: Promise, methodName: String) {
|
|
2038
|
+
promise.reject("UNIMPLEMENTED", "$methodName is not implemented in this module")
|
|
2039
|
+
}
|
|
2040
|
+
|
|
2041
|
+
private fun createResultMap(resultCode: Int, data: Intent?): WritableMap {
|
|
2042
|
+
val result = Arguments.createMap()
|
|
2043
|
+
if (resultCode != Activity.RESULT_OK || data == null) {
|
|
2044
|
+
result.putBoolean("cancelled", true)
|
|
2045
|
+
result.putArray("assets", Arguments.createArray())
|
|
2046
|
+
return result
|
|
2047
|
+
}
|
|
2048
|
+
|
|
2049
|
+
result.putBoolean("cancelled", data.getBooleanExtra(EXTRA_CANCELLED, false))
|
|
2050
|
+
result.putArray("assets", data.getAssetBundles().toWritableArray())
|
|
2051
|
+
return result
|
|
2052
|
+
}
|
|
2053
|
+
|
|
2054
|
+
private fun Intent.getAssetBundles(): ArrayList<Bundle> {
|
|
2055
|
+
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
2056
|
+
getParcelableArrayListExtra(EXTRA_ASSETS, Bundle::class.java) ?: arrayListOf()
|
|
2057
|
+
} else {
|
|
2058
|
+
@Suppress("DEPRECATION")
|
|
2059
|
+
getParcelableArrayListExtra(EXTRA_ASSETS) ?: arrayListOf()
|
|
2060
|
+
}
|
|
2061
|
+
}
|
|
2062
|
+
|
|
2063
|
+
private fun ArrayList<Bundle>.toWritableArray(): WritableArray {
|
|
2064
|
+
val array = Arguments.createArray()
|
|
2065
|
+
forEach { bundle ->
|
|
2066
|
+
val asset = Arguments.createMap()
|
|
2067
|
+
asset.putString("id", bundle.getString("id"))
|
|
2068
|
+
asset.putString("uri", bundle.getString("uri"))
|
|
2069
|
+
asset.putString("type", bundle.getString("type"))
|
|
2070
|
+
bundle.getString("fileName")?.let { asset.putString("fileName", it) }
|
|
2071
|
+
bundle.getString("mimeType")?.let { asset.putString("mimeType", it) }
|
|
2072
|
+
bundle.getString("caption")?.let { asset.putString("caption", it) }
|
|
2073
|
+
putOptionalInt(asset, "width", bundle)
|
|
2074
|
+
putOptionalInt(asset, "height", bundle)
|
|
2075
|
+
putOptionalDouble(asset, "duration", bundle)
|
|
2076
|
+
putOptionalDouble(asset, "size", bundle)
|
|
2077
|
+
putOptionalDouble(asset, "dateTaken", bundle)
|
|
2078
|
+
// Cache metadata (if present) — ensure JS receives updated cache path and flags
|
|
2079
|
+
bundle.getString("cachePath")?.let { asset.putString("cachePath", it) }
|
|
2080
|
+
if (bundle.containsKey("isCached")) asset.putBoolean("isCached", bundle.getBoolean("isCached"))
|
|
2081
|
+
if (bundle.containsKey("cacheCreatedAt")) asset.putDouble("cacheCreatedAt", bundle.getDouble("cacheCreatedAt"))
|
|
2082
|
+
if (bundle.containsKey("cacheLastModified")) asset.putDouble("cacheLastModified", bundle.getDouble("cacheLastModified"))
|
|
2083
|
+
bundle.getString("originalUri")?.let { asset.putString("originalUri", it) }
|
|
2084
|
+
putOptionalDouble(asset, "previewAspectRatio", bundle)
|
|
2085
|
+
bundle.getString("coverUri")?.let { asset.putString("coverUri", it) }
|
|
2086
|
+
putOptionalInt(asset, "captureContainerWidth", bundle)
|
|
2087
|
+
putOptionalInt(asset, "captureContainerHeight", bundle)
|
|
2088
|
+
if (bundle.containsKey("sizeIsDisplayEstimate")) {
|
|
2089
|
+
asset.putBoolean("sizeIsDisplayEstimate", bundle.getBoolean("sizeIsDisplayEstimate"))
|
|
2090
|
+
}
|
|
2091
|
+
array.pushMap(asset)
|
|
2092
|
+
}
|
|
2093
|
+
return array
|
|
2094
|
+
}
|
|
2095
|
+
|
|
2096
|
+
private fun putOptionalInt(map: WritableMap, key: String, bundle: Bundle) {
|
|
2097
|
+
if (bundle.containsKey(key)) {
|
|
2098
|
+
map.putInt(key, bundle.getInt(key))
|
|
2099
|
+
}
|
|
2100
|
+
}
|
|
2101
|
+
|
|
2102
|
+
private fun putOptionalDouble(map: WritableMap, key: String, bundle: Bundle) {
|
|
2103
|
+
if (bundle.containsKey(key)) {
|
|
2104
|
+
map.putDouble(key, bundle.getDouble(key))
|
|
2105
|
+
}
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2108
|
+
/**
|
|
2109
|
+
* Builds the SAF ACTION_OPEN_DOCUMENT intent shared by the document and audio pickers. A
|
|
2110
|
+
* `selectionLimit` of 0 (unlimited) or > 1 enables multi-select; the accepted [mimeTypes] are
|
|
2111
|
+
* applied via EXTRA_MIME_TYPES over a broad wildcard base type so the filter is honoured across
|
|
2112
|
+
* the various OEM document providers.
|
|
2113
|
+
*/
|
|
2114
|
+
private fun buildOpenDocumentIntent(options: ReadableMap, mimeTypes: Array<String>): Intent {
|
|
2115
|
+
val selectionLimit = options.getIntOrDefault("selectionLimit", 0)
|
|
2116
|
+
val allowMultiple = selectionLimit == 0 || selectionLimit > 1
|
|
2117
|
+
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
|
|
2118
|
+
intent.addCategory(Intent.CATEGORY_OPENABLE)
|
|
2119
|
+
intent.type = if (mimeTypes.size == 1) mimeTypes[0] else "*_/*".replace("_", "")
|
|
2120
|
+
if (mimeTypes.size > 1) intent.putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes)
|
|
2121
|
+
intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, allowMultiple)
|
|
2122
|
+
intent.addFlags(
|
|
2123
|
+
Intent.FLAG_GRANT_READ_URI_PERMISSION or
|
|
2124
|
+
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
|
|
2125
|
+
)
|
|
2126
|
+
return intent
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2129
|
+
/**
|
|
2130
|
+
* Builds the `{ cancelled, assets }` result for a SAF picker, mirroring [createResultMap]. Reads
|
|
2131
|
+
* the single (`data.data`) or multiple (`data.clipData`) selected uris, persists read access to
|
|
2132
|
+
* each, and resolves lightweight metadata (name / size / mime type) via the ContentResolver.
|
|
2133
|
+
*/
|
|
2134
|
+
private fun buildPickedFilesResult(resultCode: Int, data: Intent?, assetType: String): WritableMap {
|
|
2135
|
+
val result = Arguments.createMap()
|
|
2136
|
+
if (resultCode != Activity.RESULT_OK || data == null) {
|
|
2137
|
+
result.putBoolean("cancelled", true)
|
|
2138
|
+
result.putArray("assets", Arguments.createArray())
|
|
2139
|
+
return result
|
|
2140
|
+
}
|
|
2141
|
+
|
|
2142
|
+
val uris = ArrayList<Uri>()
|
|
2143
|
+
val clip = data.clipData
|
|
2144
|
+
if (clip != null) {
|
|
2145
|
+
for (i in 0 until clip.itemCount) {
|
|
2146
|
+
clip.getItemAt(i)?.uri?.let { uris.add(it) }
|
|
2147
|
+
}
|
|
2148
|
+
} else {
|
|
2149
|
+
data.data?.let { uris.add(it) }
|
|
2150
|
+
}
|
|
2151
|
+
|
|
2152
|
+
val resolver = reactApplicationContext.contentResolver
|
|
2153
|
+
val assets = Arguments.createArray()
|
|
2154
|
+
uris.forEachIndexed { index, uri ->
|
|
2155
|
+
// Persist read access so the uri stays valid after the picker's activity finishes.
|
|
2156
|
+
try {
|
|
2157
|
+
resolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
|
2158
|
+
} catch (_: Exception) {
|
|
2159
|
+
// Some providers don't support persistable grants; the transient grant still covers
|
|
2160
|
+
// immediate reads, so this is non-fatal.
|
|
2161
|
+
}
|
|
2162
|
+
assets.pushMap(buildAssetFromUri(uri, assetType, index, resolver))
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
// RESULT_OK but nothing chosen (rare) is treated the same as a cancel by the JS layer.
|
|
2166
|
+
result.putBoolean("cancelled", assets.size() == 0)
|
|
2167
|
+
result.putArray("assets", assets)
|
|
2168
|
+
return result
|
|
2169
|
+
}
|
|
2170
|
+
|
|
2171
|
+
/**
|
|
2172
|
+
* Builds the result for the image/video picker, determining the actual media type (photo/video)
|
|
2173
|
+
* from the MIME type of each selected file.
|
|
2174
|
+
*/
|
|
2175
|
+
private fun buildPickedMediaResult(resultCode: Int, data: Intent?): WritableMap {
|
|
2176
|
+
val result = Arguments.createMap()
|
|
2177
|
+
if (resultCode != Activity.RESULT_OK || data == null) {
|
|
2178
|
+
result.putBoolean("cancelled", true)
|
|
2179
|
+
result.putArray("assets", Arguments.createArray())
|
|
2180
|
+
return result
|
|
2181
|
+
}
|
|
2182
|
+
|
|
2183
|
+
val uris = ArrayList<Uri>()
|
|
2184
|
+
val clip = data.clipData
|
|
2185
|
+
if (clip != null) {
|
|
2186
|
+
for (i in 0 until clip.itemCount) {
|
|
2187
|
+
clip.getItemAt(i)?.uri?.let { uris.add(it) }
|
|
2188
|
+
}
|
|
2189
|
+
} else {
|
|
2190
|
+
data.data?.let { uris.add(it) }
|
|
2191
|
+
}
|
|
2192
|
+
|
|
2193
|
+
val resolver = reactApplicationContext.contentResolver
|
|
2194
|
+
val assets = Arguments.createArray()
|
|
2195
|
+
uris.forEachIndexed { index, uri ->
|
|
2196
|
+
// Persist read access so the uri stays valid after the picker's activity finishes.
|
|
2197
|
+
try {
|
|
2198
|
+
resolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
|
2199
|
+
} catch (_: Exception) {
|
|
2200
|
+
// Some providers don't support persistable grants; the transient grant still covers
|
|
2201
|
+
// immediate reads, so this is non-fatal.
|
|
2202
|
+
}
|
|
2203
|
+
|
|
2204
|
+
val mimeType = resolver.getType(uri) ?: ""
|
|
2205
|
+
val assetType = when {
|
|
2206
|
+
mimeType.startsWith("video/") -> "video"
|
|
2207
|
+
mimeType.startsWith("image/") -> "photo"
|
|
2208
|
+
else -> "photo" // Default to photo for unknown types
|
|
2209
|
+
}
|
|
2210
|
+
|
|
2211
|
+
assets.pushMap(buildMediaAssetFromUri(uri, assetType, index, resolver))
|
|
2212
|
+
}
|
|
2213
|
+
|
|
2214
|
+
// RESULT_OK but nothing chosen (rare) is treated the same as a cancel by the JS layer.
|
|
2215
|
+
result.putBoolean("cancelled", assets.size() == 0)
|
|
2216
|
+
result.putArray("assets", assets)
|
|
2217
|
+
return result
|
|
2218
|
+
}
|
|
2219
|
+
|
|
2220
|
+
private fun buildMediaAssetFromUri(
|
|
2221
|
+
uri: Uri,
|
|
2222
|
+
assetType: String,
|
|
2223
|
+
index: Int,
|
|
2224
|
+
resolver: ContentResolver
|
|
2225
|
+
): WritableMap {
|
|
2226
|
+
var displayName: String? = null
|
|
2227
|
+
var size = -1L
|
|
2228
|
+
var mimeType: String? = null
|
|
2229
|
+
var width = 0
|
|
2230
|
+
var height = 0
|
|
2231
|
+
var duration = 0L
|
|
2232
|
+
|
|
2233
|
+
try {
|
|
2234
|
+
resolver.query(
|
|
2235
|
+
uri,
|
|
2236
|
+
arrayOf(
|
|
2237
|
+
OpenableColumns.DISPLAY_NAME,
|
|
2238
|
+
OpenableColumns.SIZE,
|
|
2239
|
+
android.provider.MediaStore.MediaColumns.MIME_TYPE,
|
|
2240
|
+
android.provider.MediaStore.MediaColumns.WIDTH,
|
|
2241
|
+
android.provider.MediaStore.MediaColumns.HEIGHT,
|
|
2242
|
+
android.provider.MediaStore.Video.VideoColumns.DURATION
|
|
2243
|
+
),
|
|
2244
|
+
null,
|
|
2245
|
+
null,
|
|
2246
|
+
null
|
|
2247
|
+
)?.use { cursor ->
|
|
2248
|
+
if (cursor.moveToFirst()) {
|
|
2249
|
+
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
|
2250
|
+
if (nameIndex >= 0 && !cursor.isNull(nameIndex)) displayName = cursor.getString(nameIndex)
|
|
2251
|
+
|
|
2252
|
+
val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
|
|
2253
|
+
if (sizeIndex >= 0 && !cursor.isNull(sizeIndex)) size = cursor.getLong(sizeIndex)
|
|
2254
|
+
|
|
2255
|
+
val mimeTypeIndex = cursor.getColumnIndex(android.provider.MediaStore.MediaColumns.MIME_TYPE)
|
|
2256
|
+
if (mimeTypeIndex >= 0 && !cursor.isNull(mimeTypeIndex)) mimeType = cursor.getString(mimeTypeIndex)
|
|
2257
|
+
|
|
2258
|
+
val widthIndex = cursor.getColumnIndex(android.provider.MediaStore.MediaColumns.WIDTH)
|
|
2259
|
+
if (widthIndex >= 0 && !cursor.isNull(widthIndex)) width = cursor.getInt(widthIndex)
|
|
2260
|
+
|
|
2261
|
+
val heightIndex = cursor.getColumnIndex(android.provider.MediaStore.MediaColumns.HEIGHT)
|
|
2262
|
+
if (heightIndex >= 0 && !cursor.isNull(heightIndex)) height = cursor.getInt(heightIndex)
|
|
2263
|
+
|
|
2264
|
+
if (assetType == "video") {
|
|
2265
|
+
val durationIndex = cursor.getColumnIndex(android.provider.MediaStore.Video.VideoColumns.DURATION)
|
|
2266
|
+
if (durationIndex >= 0 && !cursor.isNull(durationIndex)) duration = cursor.getLong(durationIndex)
|
|
2267
|
+
}
|
|
2268
|
+
}
|
|
2269
|
+
}
|
|
2270
|
+
} catch (e: Exception) {
|
|
2271
|
+
Log.w("CustomCameraModule", "Failed to read metadata for $uri", e)
|
|
2272
|
+
}
|
|
2273
|
+
|
|
2274
|
+
val asset = Arguments.createMap()
|
|
2275
|
+
asset.putString("id", "gallery_${System.currentTimeMillis()}_$index")
|
|
2276
|
+
asset.putString("uri", uri.toString())
|
|
2277
|
+
asset.putString("type", assetType)
|
|
2278
|
+
// documentUri mirrors the persistent SAF uri so the JS storage layer treats it as final.
|
|
2279
|
+
asset.putString("documentUri", uri.toString())
|
|
2280
|
+
displayName?.let { asset.putString("fileName", it) }
|
|
2281
|
+
mimeType?.let { asset.putString("mimeType", it) }
|
|
2282
|
+
if (size >= 0L) asset.putDouble("size", size.toDouble())
|
|
2283
|
+
if (width > 0) asset.putInt("width", width)
|
|
2284
|
+
if (height > 0) asset.putInt("height", height)
|
|
2285
|
+
if (duration > 0L) asset.putDouble("duration", duration.toDouble())
|
|
2286
|
+
asset.putDouble("dateTaken", System.currentTimeMillis().toDouble())
|
|
2287
|
+
return asset
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2290
|
+
private fun buildAssetFromUri(
|
|
2291
|
+
uri: Uri,
|
|
2292
|
+
assetType: String,
|
|
2293
|
+
index: Int,
|
|
2294
|
+
resolver: ContentResolver
|
|
2295
|
+
): WritableMap {
|
|
2296
|
+
var displayName: String? = null
|
|
2297
|
+
var size = -1L
|
|
2298
|
+
try {
|
|
2299
|
+
resolver.query(
|
|
2300
|
+
uri,
|
|
2301
|
+
arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE),
|
|
2302
|
+
null,
|
|
2303
|
+
null,
|
|
2304
|
+
null
|
|
2305
|
+
)?.use { cursor ->
|
|
2306
|
+
if (cursor.moveToFirst()) {
|
|
2307
|
+
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
|
2308
|
+
if (nameIndex >= 0 && !cursor.isNull(nameIndex)) displayName = cursor.getString(nameIndex)
|
|
2309
|
+
val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
|
|
2310
|
+
if (sizeIndex >= 0 && !cursor.isNull(sizeIndex)) size = cursor.getLong(sizeIndex)
|
|
2311
|
+
}
|
|
2312
|
+
}
|
|
2313
|
+
} catch (e: Exception) {
|
|
2314
|
+
Log.w("CustomCameraModule", "Failed to read metadata for $uri", e)
|
|
2315
|
+
}
|
|
2316
|
+
|
|
2317
|
+
// Clip length for picked audio, so its preview header can show a duration the same way a
|
|
2318
|
+
// recorded clip's does (stopAndPublishAudioRecording already reports one).
|
|
2319
|
+
//
|
|
2320
|
+
// Deliberately a SEPARATE query from the one above: DISPLAY_NAME/SIZE are OpenableColumns
|
|
2321
|
+
// and universally supported, while DURATION is not — a provider that doesn't have the column
|
|
2322
|
+
// makes query() throw, and folding it into the projection above would cost this asset its
|
|
2323
|
+
// file name and size too. Best-effort: no duration simply means the field is omitted.
|
|
2324
|
+
var duration = -1L
|
|
2325
|
+
if (assetType == "audio") {
|
|
2326
|
+
try {
|
|
2327
|
+
resolver.query(
|
|
2328
|
+
uri,
|
|
2329
|
+
arrayOf(android.provider.MediaStore.Audio.AudioColumns.DURATION),
|
|
2330
|
+
null,
|
|
2331
|
+
null,
|
|
2332
|
+
null
|
|
2333
|
+
)?.use { cursor ->
|
|
2334
|
+
if (cursor.moveToFirst()) {
|
|
2335
|
+
val durationIndex =
|
|
2336
|
+
cursor.getColumnIndex(android.provider.MediaStore.Audio.AudioColumns.DURATION)
|
|
2337
|
+
if (durationIndex >= 0 && !cursor.isNull(durationIndex)) {
|
|
2338
|
+
duration = cursor.getLong(durationIndex)
|
|
2339
|
+
}
|
|
2340
|
+
}
|
|
2341
|
+
}
|
|
2342
|
+
} catch (e: Exception) {
|
|
2343
|
+
Log.w("CustomCameraModule", "Failed to read audio duration for $uri", e)
|
|
2344
|
+
}
|
|
2345
|
+
}
|
|
2346
|
+
|
|
2347
|
+
val asset = Arguments.createMap()
|
|
2348
|
+
asset.putString("id", "${assetType}_${System.currentTimeMillis()}_$index")
|
|
2349
|
+
asset.putString("uri", uri.toString())
|
|
2350
|
+
asset.putString("type", assetType)
|
|
2351
|
+
// documentUri mirrors the persistent SAF uri so the JS storage layer treats it as final.
|
|
2352
|
+
asset.putString("documentUri", uri.toString())
|
|
2353
|
+
displayName?.let { asset.putString("fileName", it) }
|
|
2354
|
+
resolver.getType(uri)?.let { asset.putString("mimeType", it) }
|
|
2355
|
+
if (size >= 0L) asset.putDouble("size", size.toDouble())
|
|
2356
|
+
if (duration > 0L) asset.putDouble("duration", duration.toDouble())
|
|
2357
|
+
asset.putDouble("dateTaken", System.currentTimeMillis().toDouble())
|
|
2358
|
+
return asset
|
|
2359
|
+
}
|
|
2360
|
+
|
|
2361
|
+
/**
|
|
2362
|
+
* The editor's media list, in the order JS gave it: the `items` array when present, otherwise the
|
|
2363
|
+
* single `uri` form. Entries without a parseable, schemed uri are dropped — a bad element must
|
|
2364
|
+
* not cost the user the rest of the selection.
|
|
2365
|
+
*/
|
|
2366
|
+
private fun ReadableMap.readEditorMediaItems(): List<Bundle> {
|
|
2367
|
+
val array = if (hasKey("items") && !isNull("items")) getArray("items") else null
|
|
2368
|
+
if (array != null) {
|
|
2369
|
+
val items = ArrayList<Bundle>(array.size())
|
|
2370
|
+
for (index in 0 until array.size()) {
|
|
2371
|
+
if (array.getType(index) != ReadableType.Map) continue
|
|
2372
|
+
val entry = array.getMap(index) ?: continue
|
|
2373
|
+
items.add(entry.toEditorMediaItem() ?: continue)
|
|
2374
|
+
}
|
|
2375
|
+
return items
|
|
2376
|
+
}
|
|
2377
|
+
return listOfNotNull(toEditorMediaItem())
|
|
2378
|
+
}
|
|
2379
|
+
|
|
2380
|
+
/** One `{ uri, type?, fileName?, mimeType? }` map as an editor item, or null when unusable. */
|
|
2381
|
+
private fun ReadableMap.toEditorMediaItem(): Bundle? {
|
|
2382
|
+
val rawUri = getStringOrDefault("uri", "").trim()
|
|
2383
|
+
if (rawUri.isEmpty()) return null
|
|
2384
|
+
val uri = runCatching { Uri.parse(rawUri) }.getOrNull()
|
|
2385
|
+
if (uri == null || uri.scheme.isNullOrEmpty()) return null
|
|
2386
|
+
return CustomCameraActivity.editorMediaItem(
|
|
2387
|
+
uri = uri.toString(),
|
|
2388
|
+
mediaType = getStringOrDefault("type", "").takeIf { it == "photo" || it == "video" },
|
|
2389
|
+
fileName = getStringOrDefault("fileName", "").takeIf { it.isNotBlank() },
|
|
2390
|
+
mimeType = getStringOrDefault("mimeType", "").takeIf { it.isNotBlank() }
|
|
2391
|
+
)
|
|
2392
|
+
}
|
|
2393
|
+
|
|
2394
|
+
private fun ReadableMap.getStringArrayOrNull(key: String): Array<String>? {
|
|
2395
|
+
if (!hasKey(key) || isNull(key)) return null
|
|
2396
|
+
val array = getArray(key) ?: return null
|
|
2397
|
+
val values = ArrayList<String>()
|
|
2398
|
+
for (i in 0 until array.size()) {
|
|
2399
|
+
if (array.getType(i) == ReadableType.String) array.getString(i)?.let { values.add(it) }
|
|
2400
|
+
}
|
|
2401
|
+
return if (values.isEmpty()) null else values.toTypedArray()
|
|
2402
|
+
}
|
|
2403
|
+
|
|
2404
|
+
private fun ReadableMap.getStringOrDefault(key: String, fallback: String): String =
|
|
2405
|
+
if (hasKey(key) && !isNull(key)) getString(key) ?: fallback else fallback
|
|
2406
|
+
|
|
2407
|
+
private fun ReadableMap.getMediaTypesOrDefault(key: String, fallback: String): String {
|
|
2408
|
+
val value = getStringOrDefault(key, fallback)
|
|
2409
|
+
return when (value) {
|
|
2410
|
+
"photo", "video", "all" -> value
|
|
2411
|
+
else -> fallback
|
|
2412
|
+
}
|
|
2413
|
+
}
|
|
2414
|
+
|
|
2415
|
+
private fun ReadableMap.getInitialCameraOrDefault(key: String, fallback: String): String {
|
|
2416
|
+
val value = getStringOrDefault(key, fallback)
|
|
2417
|
+
return when (value) {
|
|
2418
|
+
"front", "back" -> value
|
|
2419
|
+
else -> fallback
|
|
2420
|
+
}
|
|
2421
|
+
}
|
|
2422
|
+
|
|
2423
|
+
private fun ReadableMap.getBooleanOrDefault(key: String, fallback: Boolean): Boolean {
|
|
2424
|
+
return if (hasKey(key) && !isNull(key)) getBoolean(key) else fallback
|
|
2425
|
+
}
|
|
2426
|
+
|
|
2427
|
+
// NOTE: selectionLimit follows the contract 0 = unlimited multi-selection, >= 1 = capped
|
|
2428
|
+
// selection. Clamp to a minimum of 0 (never 1) so an explicit `selectionLimit: 0` from JS —
|
|
2429
|
+
// which CameraPreview always sends via `selectionLimit ?? 0` — is preserved as "unlimited"
|
|
2430
|
+
// instead of being silently forced into single-selection. Negative values collapse to 0,
|
|
2431
|
+
// which the activity also treats as unlimited (isSelectionUnlimited = selectionLimit <= 0).
|
|
2432
|
+
private fun ReadableMap.getIntOrDefault(key: String, fallback: Int): Int {
|
|
2433
|
+
return if (hasKey(key) && !isNull(key)) getDouble(key).toInt().coerceAtLeast(0) else fallback
|
|
2434
|
+
}
|
|
2435
|
+
|
|
2436
|
+
companion object {
|
|
2437
|
+
private const val CAMERA_PICKER_REQUEST = 7101
|
|
2438
|
+
private const val NOTIFICATION_PERMISSION_REQUEST = 7102
|
|
2439
|
+
private const val BARCODE_SCAN_REQUEST = 7103
|
|
2440
|
+
private const val DOCUMENT_PICKER_REQUEST = 7104
|
|
2441
|
+
private const val AUDIO_PICKER_REQUEST = 7105
|
|
2442
|
+
private const val IMAGE_VIDEO_PICKER_REQUEST = 7106
|
|
2443
|
+
private const val MEDIA_EDITOR_REQUEST = 7107
|
|
2444
|
+
/** Device event carrying measured compression progress — see [emitCompressionProgress]. */
|
|
2445
|
+
private const val EVENT_COMPRESSION_PROGRESS = "onCompressionProgress"
|
|
2446
|
+
// Default MIME filters for the SAF pickers. Kept broad so the common formats are selectable;
|
|
2447
|
+
// callers can override via the `mimeTypes` option.
|
|
2448
|
+
private val DEFAULT_DOCUMENT_MIME_TYPES = arrayOf(
|
|
2449
|
+
"application/pdf",
|
|
2450
|
+
"application/msword",
|
|
2451
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
2452
|
+
"application/vnd.ms-excel",
|
|
2453
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
2454
|
+
"application/vnd.ms-powerpoint",
|
|
2455
|
+
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
2456
|
+
"text/plain",
|
|
2457
|
+
"application/rtf",
|
|
2458
|
+
"application/zip"
|
|
2459
|
+
)
|
|
2460
|
+
// The audio picker accepts the whole audio/* tree (MP3, WAV, AAC, M4A, OGG, FLAC, …) plus the
|
|
2461
|
+
// container types some providers report for audio files without an audio/ prefix (.ogg/.opus
|
|
2462
|
+
// as application/ogg, .m4a occasionally as video/mp4 metadata quirks are handled by the
|
|
2463
|
+
// asset-type mapping, not here).
|
|
2464
|
+
private val DEFAULT_AUDIO_MIME_TYPES = arrayOf("audio/*", "application/ogg")
|
|
2465
|
+
// MIME types that are audio in practice but don't start with "audio/" — used when deciding
|
|
2466
|
+
// whether a custom `mimeTypes` set is still audio-only (→ keep the intent's base type audio/*).
|
|
2467
|
+
private val AUDIO_CONTAINER_MIME_TYPES = setOf("application/ogg", "application/x-ogg")
|
|
2468
|
+
// ACTION_OPEN_DOCUMENT landing hint: the Audio root of the built-in media documents provider
|
|
2469
|
+
// (com.android.providers.media.documents), i.e. the "Audio" entry in the system picker's
|
|
2470
|
+
// sidebar. Authority + root id are stable AOSP values used by DocumentsUI itself.
|
|
2471
|
+
private const val MEDIA_DOCUMENTS_AUTHORITY = "com.android.providers.media.documents"
|
|
2472
|
+
private const val MEDIA_DOCUMENTS_AUDIO_ROOT = "audio_root"
|
|
2473
|
+
const val EXTRA_CANCELLED = "com.customcamera.extra.CANCELLED"
|
|
2474
|
+
const val EXTRA_ASSETS = "com.customcamera.extra.ASSETS"
|
|
2475
|
+
const val NAME = NativeCustomCameraSpec.NAME
|
|
2476
|
+
/** Gallery sub-folder used by both capture and download so they always land in the same album. */
|
|
2477
|
+
const val GALLERY_FOLDER = "EnsysCameraX"
|
|
2478
|
+
}
|
|
2479
|
+
}
|