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.
Files changed (506) hide show
  1. package/CustomCamera.podspec +20 -0
  2. package/LICENSE +20 -0
  3. package/README.md +833 -0
  4. package/android/build.gradle +135 -0
  5. package/android/consumer-rules.pro +47 -0
  6. package/android/src/main/AndroidManifest.xml +72 -0
  7. package/android/src/main/java/android/print/PdfPrint.java +51 -0
  8. package/android/src/main/java/com/customcamera/AnimatedImageViewManager.kt +199 -0
  9. package/android/src/main/java/com/customcamera/AudioPlayerView.kt +322 -0
  10. package/android/src/main/java/com/customcamera/AudioRecorder.kt +506 -0
  11. package/android/src/main/java/com/customcamera/BarcodeScannerActivity.kt +361 -0
  12. package/android/src/main/java/com/customcamera/BlurBackgroundView.kt +199 -0
  13. package/android/src/main/java/com/customcamera/CacheCleanupManager.kt +386 -0
  14. package/android/src/main/java/com/customcamera/CameraAspectRatioMode.kt +77 -0
  15. package/android/src/main/java/com/customcamera/CameraAsset.kt +211 -0
  16. package/android/src/main/java/com/customcamera/CameraTheme.kt +479 -0
  17. package/android/src/main/java/com/customcamera/CompressionCacheStore.kt +504 -0
  18. package/android/src/main/java/com/customcamera/CompressionFileNameUtils.kt +7 -0
  19. package/android/src/main/java/com/customcamera/CompressionProfile.kt +195 -0
  20. package/android/src/main/java/com/customcamera/CustomCameraActivity.kt +7291 -0
  21. package/android/src/main/java/com/customcamera/CustomCameraFileProvider.kt +9 -0
  22. package/android/src/main/java/com/customcamera/CustomCameraModule.kt +2479 -0
  23. package/android/src/main/java/com/customcamera/CustomCameraPackage.kt +53 -0
  24. package/android/src/main/java/com/customcamera/DisplayUtils.kt +9 -0
  25. package/android/src/main/java/com/customcamera/DocumentPreviewView.kt +846 -0
  26. package/android/src/main/java/com/customcamera/DrawingView.kt +214 -0
  27. package/android/src/main/java/com/customcamera/EmojiPickerData.kt +228 -0
  28. package/android/src/main/java/com/customcamera/FilterStripAdapter.kt +158 -0
  29. package/android/src/main/java/com/customcamera/GifSupport.kt +215 -0
  30. package/android/src/main/java/com/customcamera/GlobalAudioPlayer.kt +320 -0
  31. package/android/src/main/java/com/customcamera/ImageExporter.kt +1394 -0
  32. package/android/src/main/java/com/customcamera/ImageFormatSupport.kt +163 -0
  33. package/android/src/main/java/com/customcamera/Localization.kt +206 -0
  34. package/android/src/main/java/com/customcamera/MediaAdapters.kt +758 -0
  35. package/android/src/main/java/com/customcamera/MediaCacheManager.kt +782 -0
  36. package/android/src/main/java/com/customcamera/MediaExportFormats.kt +201 -0
  37. package/android/src/main/java/com/customcamera/MediaFileIo.kt +310 -0
  38. package/android/src/main/java/com/customcamera/MediaMetadataStore.kt +494 -0
  39. package/android/src/main/java/com/customcamera/MediaPreviewEditor.kt +14865 -0
  40. package/android/src/main/java/com/customcamera/MediaPublisher.kt +423 -0
  41. package/android/src/main/java/com/customcamera/MediaStorageWorkflow.kt +722 -0
  42. package/android/src/main/java/com/customcamera/MediaStoreRepository.kt +378 -0
  43. package/android/src/main/java/com/customcamera/MediaThumbnailView.kt +286 -0
  44. package/android/src/main/java/com/customcamera/MediaUtils.kt +37 -0
  45. package/android/src/main/java/com/customcamera/NativeVideoViewManager.kt +845 -0
  46. package/android/src/main/java/com/customcamera/OfficeDocumentConverter.kt +2791 -0
  47. package/android/src/main/java/com/customcamera/PendingCaptureCrops.kt +88 -0
  48. package/android/src/main/java/com/customcamera/PowerPointPreview.kt +4077 -0
  49. package/android/src/main/java/com/customcamera/RecentPickerStore.kt +74 -0
  50. package/android/src/main/java/com/customcamera/SecureFileEraser.kt +411 -0
  51. package/android/src/main/java/com/customcamera/SecureKeyModule.kt +88 -0
  52. package/android/src/main/java/com/customcamera/SecureMediaDeleter.kt +493 -0
  53. package/android/src/main/java/com/customcamera/StickerPickerData.kt +107 -0
  54. package/android/src/main/java/com/customcamera/SvgDrawable.kt +175 -0
  55. package/android/src/main/java/com/customcamera/SvgIconViewManager.kt +77 -0
  56. package/android/src/main/java/com/customcamera/TextEntryController.kt +553 -0
  57. package/android/src/main/java/com/customcamera/ThumbnailLoader.kt +455 -0
  58. package/android/src/main/java/com/customcamera/UploadService.kt +909 -0
  59. package/android/src/main/java/com/customcamera/VideoEditUtils.kt +210 -0
  60. package/android/src/main/java/com/customcamera/VideoRotationTranscoder.kt +1992 -0
  61. package/android/src/main/java/com/customcamera/gif/AnimatedGifEncoder.kt +473 -0
  62. package/android/src/main/java/com/customcamera/gif/GifDecoder.kt +690 -0
  63. package/android/src/main/java/com/customcamera/gif/GifFramesDrawable.kt +160 -0
  64. package/android/src/main/java/com/customcamera/gif/GifHttp.kt +112 -0
  65. package/android/src/main/java/com/customcamera/gif/GifImageLoader.kt +186 -0
  66. package/android/src/main/java/com/customcamera/gif/GifModels.kt +74 -0
  67. package/android/src/main/java/com/customcamera/gif/GifProvider.kt +49 -0
  68. package/android/src/main/java/com/customcamera/gif/GifProviders.kt +27 -0
  69. package/android/src/main/java/com/customcamera/gif/GifRepository.kt +124 -0
  70. package/android/src/main/java/com/customcamera/gif/GifSafety.kt +33 -0
  71. package/android/src/main/java/com/customcamera/gif/GiphyGifProvider.kt +146 -0
  72. package/android/src/main/java/com/customcamera/gif/LZWEncoder.kt +258 -0
  73. package/android/src/main/java/com/customcamera/gif/NeuQuant.kt +435 -0
  74. package/android/src/main/java/com/customcamera/gif/TenorGifProvider.kt +147 -0
  75. package/android/src/main/res/drawable/bg_brush_selected.xml +5 -0
  76. package/android/src/main/res/drawable/bg_color_swatch.xml +5 -0
  77. package/android/src/main/res/drawable/bg_editor_toolbar.xml +6 -0
  78. package/android/src/main/res/drawable/bg_icon_circle.xml +5 -0
  79. package/android/src/main/res/drawable/bg_icon_circle_light.xml +5 -0
  80. package/android/src/main/res/drawable/bg_mode_tab_active.xml +6 -0
  81. package/android/src/main/res/drawable/bg_pill.xml +6 -0
  82. package/android/src/main/res/drawable/bg_recording_dot.xml +6 -0
  83. package/android/src/main/res/drawable/bg_selection_badge.xml +8 -0
  84. package/android/src/main/res/drawable/bg_send_button.xml +5 -0
  85. package/android/src/main/res/drawable/bg_shutter_inner.xml +5 -0
  86. package/android/src/main/res/drawable/bg_shutter_recording.xml +6 -0
  87. package/android/src/main/res/drawable/bg_shutter_ring.xml +8 -0
  88. package/android/src/main/res/drawable/bg_shutter_video_idle.xml +5 -0
  89. package/android/src/main/res/drawable/bg_thumbnail.xml +9 -0
  90. package/android/src/main/res/drawable/ic_aspect_ratio.xml +17 -0
  91. package/android/src/main/res/drawable/ic_audio.xml +10 -0
  92. package/android/src/main/res/drawable/ic_back.xml +10 -0
  93. package/android/src/main/res/drawable/ic_brush_medium.xml +12 -0
  94. package/android/src/main/res/drawable/ic_brush_thick.xml +12 -0
  95. package/android/src/main/res/drawable/ic_brush_thin.xml +12 -0
  96. package/android/src/main/res/drawable/ic_check.xml +17 -0
  97. package/android/src/main/res/drawable/ic_chevron_down.xml +10 -0
  98. package/android/src/main/res/drawable/ic_chevron_up.xml +13 -0
  99. package/android/src/main/res/drawable/ic_clear_drawing.xml +10 -0
  100. package/android/src/main/res/drawable/ic_close.xml +10 -0
  101. package/android/src/main/res/drawable/ic_cover.xml +9 -0
  102. package/android/src/main/res/drawable/ic_crop.xml +10 -0
  103. package/android/src/main/res/drawable/ic_delete.xml +9 -0
  104. package/android/src/main/res/drawable/ic_document.xml +10 -0
  105. package/android/src/main/res/drawable/ic_done.xml +10 -0
  106. package/android/src/main/res/drawable/ic_download.xml +10 -0
  107. package/android/src/main/res/drawable/ic_emoji.xml +10 -0
  108. package/android/src/main/res/drawable/ic_filter.xml +10 -0
  109. package/android/src/main/res/drawable/ic_flash_auto.xml +7 -0
  110. package/android/src/main/res/drawable/ic_flash_off.xml +23 -0
  111. package/android/src/main/res/drawable/ic_flash_on.xml +16 -0
  112. package/android/src/main/res/drawable/ic_flip.xml +15 -0
  113. package/android/src/main/res/drawable/ic_gallery.xml +10 -0
  114. package/android/src/main/res/drawable/ic_gif.xml +24 -0
  115. package/android/src/main/res/drawable/ic_lock.xml +19 -0
  116. package/android/src/main/res/drawable/ic_lock_open.xml +22 -0
  117. package/android/src/main/res/drawable/ic_marker.xml +10 -0
  118. package/android/src/main/res/drawable/ic_more_vert.xml +18 -0
  119. package/android/src/main/res/drawable/ic_overlay_close.xml +19 -0
  120. package/android/src/main/res/drawable/ic_overlay_resize.xml +18 -0
  121. package/android/src/main/res/drawable/ic_pause.xml +10 -0
  122. package/android/src/main/res/drawable/ic_photo_mode.xml +10 -0
  123. package/android/src/main/res/drawable/ic_play.xml +10 -0
  124. package/android/src/main/res/drawable/ic_redo.xml +10 -0
  125. package/android/src/main/res/drawable/ic_rotate.xml +9 -0
  126. package/android/src/main/res/drawable/ic_send.xml +10 -0
  127. package/android/src/main/res/drawable/ic_share.xml +10 -0
  128. package/android/src/main/res/drawable/ic_stat_upload.xml +10 -0
  129. package/android/src/main/res/drawable/ic_sticker.xml +24 -0
  130. package/android/src/main/res/drawable/ic_text.xml +10 -0
  131. package/android/src/main/res/drawable/ic_text_align_center.xml +11 -0
  132. package/android/src/main/res/drawable/ic_text_align_left.xml +11 -0
  133. package/android/src/main/res/drawable/ic_text_align_right.xml +11 -0
  134. package/android/src/main/res/drawable/ic_text_bg.xml +11 -0
  135. package/android/src/main/res/drawable/ic_trim.xml +23 -0
  136. package/android/src/main/res/drawable/ic_undo.xml +10 -0
  137. package/android/src/main/res/drawable/ic_video_mode.xml +10 -0
  138. package/android/src/main/res/drawable/ic_video_note.xml +13 -0
  139. package/android/src/main/res/drawable/ic_volume_off.xml +9 -0
  140. package/android/src/main/res/drawable/ic_volume_on.xml +9 -0
  141. package/android/src/main/res/drawable/sticker_heart.xml +9 -0
  142. package/android/src/main/res/drawable/sticker_smile.xml +9 -0
  143. package/android/src/main/res/drawable/sticker_star.xml +9 -0
  144. package/android/src/main/res/drawable/sticker_thumbsup.xml +9 -0
  145. package/android/src/main/res/values/colors.xml +34 -0
  146. package/android/src/main/res/values/ids.xml +15 -0
  147. package/android/src/main/res/values/strings.xml +15 -0
  148. package/android/src/main/res/values/styles.xml +20 -0
  149. package/android/src/main/res/xml/file_paths.xml +34 -0
  150. package/ios/CustomCamera.h +5 -0
  151. package/ios/CustomCamera.mm +214 -0
  152. package/lib/module/AudioPlayerContext.js +233 -0
  153. package/lib/module/AudioPlayerContext.js.map +1 -0
  154. package/lib/module/CameraPreview.js +304 -0
  155. package/lib/module/CameraPreview.js.map +1 -0
  156. package/lib/module/NativeAnimatedImageView.js +41 -0
  157. package/lib/module/NativeAnimatedImageView.js.map +1 -0
  158. package/lib/module/NativeAudioPlayerView.js +33 -0
  159. package/lib/module/NativeAudioPlayerView.js.map +1 -0
  160. package/lib/module/NativeBlurView.js +35 -0
  161. package/lib/module/NativeBlurView.js.map +1 -0
  162. package/lib/module/NativeCustomCamera.js +128 -0
  163. package/lib/module/NativeCustomCamera.js.map +1 -0
  164. package/lib/module/NativeCustomCamera.web.js +143 -0
  165. package/lib/module/NativeCustomCamera.web.js.map +1 -0
  166. package/lib/module/NativeCustomVideoView.js +5 -0
  167. package/lib/module/NativeCustomVideoView.js.map +1 -0
  168. package/lib/module/NativeDocumentPreviewView.js +156 -0
  169. package/lib/module/NativeDocumentPreviewView.js.map +1 -0
  170. package/lib/module/NativeMediaThumbnailView.js +110 -0
  171. package/lib/module/NativeMediaThumbnailView.js.map +1 -0
  172. package/lib/module/NativeSvgIcon.js +32 -0
  173. package/lib/module/NativeSvgIcon.js.map +1 -0
  174. package/lib/module/ResponsivePreviewContainer.js +171 -0
  175. package/lib/module/ResponsivePreviewContainer.js.map +1 -0
  176. package/lib/module/accessibility.js +61 -0
  177. package/lib/module/accessibility.js.map +1 -0
  178. package/lib/module/config.js +352 -0
  179. package/lib/module/config.js.map +1 -0
  180. package/lib/module/cpuOptimization.js +296 -0
  181. package/lib/module/cpuOptimization.js.map +1 -0
  182. package/lib/module/db/DatabaseManager.js +191 -0
  183. package/lib/module/db/DatabaseManager.js.map +1 -0
  184. package/lib/module/db/MediaMapper.js +284 -0
  185. package/lib/module/db/MediaMapper.js.map +1 -0
  186. package/lib/module/db/MediaRepository.js +242 -0
  187. package/lib/module/db/MediaRepository.js.map +1 -0
  188. package/lib/module/db/index.js +41 -0
  189. package/lib/module/db/index.js.map +1 -0
  190. package/lib/module/db/migrations.js +180 -0
  191. package/lib/module/db/migrations.js.map +1 -0
  192. package/lib/module/db/react-native-sqlcipher.d.js +2 -0
  193. package/lib/module/db/react-native-sqlcipher.d.js.map +1 -0
  194. package/lib/module/db/secureKey.js +87 -0
  195. package/lib/module/db/secureKey.js.map +1 -0
  196. package/lib/module/db/types.js +57 -0
  197. package/lib/module/db/types.js.map +1 -0
  198. package/lib/module/db/useMediaDatabase.js +123 -0
  199. package/lib/module/db/useMediaDatabase.js.map +1 -0
  200. package/lib/module/defaultIcons.js +2259 -0
  201. package/lib/module/defaultIcons.js.map +1 -0
  202. package/lib/module/deviceCapabilities.js +408 -0
  203. package/lib/module/deviceCapabilities.js.map +1 -0
  204. package/lib/module/errors.js +267 -0
  205. package/lib/module/errors.js.map +1 -0
  206. package/lib/module/iconConfig.js +796 -0
  207. package/lib/module/iconConfig.js.map +1 -0
  208. package/lib/module/icons.js +514 -0
  209. package/lib/module/icons.js.map +1 -0
  210. package/lib/module/index.js +251 -0
  211. package/lib/module/index.js.map +1 -0
  212. package/lib/module/jest-globals.d.js +2 -0
  213. package/lib/module/jest-globals.d.js.map +1 -0
  214. package/lib/module/library/MediaLibrary.js +107 -0
  215. package/lib/module/library/MediaLibrary.js.map +1 -0
  216. package/lib/module/library/MediaLibraryScreen.js +2597 -0
  217. package/lib/module/library/MediaLibraryScreen.js.map +1 -0
  218. package/lib/module/library/components/AudioPreview.js +74 -0
  219. package/lib/module/library/components/AudioPreview.js.map +1 -0
  220. package/lib/module/library/components/DocumentPreview.js +331 -0
  221. package/lib/module/library/components/DocumentPreview.js.map +1 -0
  222. package/lib/module/library/components/ExpandableFab.js +343 -0
  223. package/lib/module/library/components/ExpandableFab.js.map +1 -0
  224. package/lib/module/library/components/FileTiles.js +251 -0
  225. package/lib/module/library/components/FileTiles.js.map +1 -0
  226. package/lib/module/library/components/ImagePreview.js +70 -0
  227. package/lib/module/library/components/ImagePreview.js.map +1 -0
  228. package/lib/module/library/components/MediaMetadataHeader.js +175 -0
  229. package/lib/module/library/components/MediaMetadataHeader.js.map +1 -0
  230. package/lib/module/library/components/RecordAudioScreen.js +816 -0
  231. package/lib/module/library/components/RecordAudioScreen.js.map +1 -0
  232. package/lib/module/library/components/VideoPreview.js +453 -0
  233. package/lib/module/library/components/VideoPreview.js.map +1 -0
  234. package/lib/module/library/components/ViewerCounter.js +55 -0
  235. package/lib/module/library/components/ViewerCounter.js.map +1 -0
  236. package/lib/module/library/compressionEstimate.js +507 -0
  237. package/lib/module/library/compressionEstimate.js.map +1 -0
  238. package/lib/module/library/fileMetadata.js +196 -0
  239. package/lib/module/library/fileMetadata.js.map +1 -0
  240. package/lib/module/library/index.js +42 -0
  241. package/lib/module/library/index.js.map +1 -0
  242. package/lib/module/library/libraryIconRegistry.js +69 -0
  243. package/lib/module/library/libraryIconRegistry.js.map +1 -0
  244. package/lib/module/library/libraryIcons.js +858 -0
  245. package/lib/module/library/libraryIcons.js.map +1 -0
  246. package/lib/module/library/localizedFormat.js +181 -0
  247. package/lib/module/library/localizedFormat.js.map +1 -0
  248. package/lib/module/library/palette.js +225 -0
  249. package/lib/module/library/palette.js.map +1 -0
  250. package/lib/module/library/responsive.js +53 -0
  251. package/lib/module/library/responsive.js.map +1 -0
  252. package/lib/module/locales.js +416 -0
  253. package/lib/module/locales.js.map +1 -0
  254. package/lib/module/localization.js +1064 -0
  255. package/lib/module/localization.js.map +1 -0
  256. package/lib/module/logger.js +20 -0
  257. package/lib/module/logger.js.map +1 -0
  258. package/lib/module/lowMemoryCache.js +206 -0
  259. package/lib/module/lowMemoryCache.js.map +1 -0
  260. package/lib/module/mediaHistory.js +33 -0
  261. package/lib/module/mediaHistory.js.map +1 -0
  262. package/lib/module/mediaStorageService.js +402 -0
  263. package/lib/module/mediaStorageService.js.map +1 -0
  264. package/lib/module/memory.js +79 -0
  265. package/lib/module/memory.js.map +1 -0
  266. package/lib/module/nativeActions.js +307 -0
  267. package/lib/module/nativeActions.js.map +1 -0
  268. package/lib/module/nativeComponentSupport.js +61 -0
  269. package/lib/module/nativeComponentSupport.js.map +1 -0
  270. package/lib/module/networkPerformance.js +24 -0
  271. package/lib/module/networkPerformance.js.map +1 -0
  272. package/lib/module/package.json +1 -0
  273. package/lib/module/performance.js +104 -0
  274. package/lib/module/performance.js.map +1 -0
  275. package/lib/module/performanceMonitor.js +155 -0
  276. package/lib/module/performanceMonitor.js.map +1 -0
  277. package/lib/module/permissions.js +370 -0
  278. package/lib/module/permissions.js.map +1 -0
  279. package/lib/module/rendering.js +40 -0
  280. package/lib/module/rendering.js.map +1 -0
  281. package/lib/module/security.js +18 -0
  282. package/lib/module/security.js.map +1 -0
  283. package/lib/module/state.js +266 -0
  284. package/lib/module/state.js.map +1 -0
  285. package/lib/module/storage.js +400 -0
  286. package/lib/module/storage.js.map +1 -0
  287. package/lib/module/theme.js +390 -0
  288. package/lib/module/theme.js.map +1 -0
  289. package/lib/module/types.js +4 -0
  290. package/lib/module/types.js.map +1 -0
  291. package/lib/module/utils.js +115 -0
  292. package/lib/module/utils.js.map +1 -0
  293. package/lib/module/validation.js +288 -0
  294. package/lib/module/validation.js.map +1 -0
  295. package/lib/typescript/package.json +1 -0
  296. package/lib/typescript/src/AudioPlayerContext.d.ts +27 -0
  297. package/lib/typescript/src/AudioPlayerContext.d.ts.map +1 -0
  298. package/lib/typescript/src/CameraPreview.d.ts +13 -0
  299. package/lib/typescript/src/CameraPreview.d.ts.map +1 -0
  300. package/lib/typescript/src/NativeAnimatedImageView.d.ts +33 -0
  301. package/lib/typescript/src/NativeAnimatedImageView.d.ts.map +1 -0
  302. package/lib/typescript/src/NativeAudioPlayerView.d.ts +12 -0
  303. package/lib/typescript/src/NativeAudioPlayerView.d.ts.map +1 -0
  304. package/lib/typescript/src/NativeBlurView.d.ts +12 -0
  305. package/lib/typescript/src/NativeBlurView.d.ts.map +1 -0
  306. package/lib/typescript/src/NativeCustomCamera.d.ts +167 -0
  307. package/lib/typescript/src/NativeCustomCamera.d.ts.map +1 -0
  308. package/lib/typescript/src/NativeCustomCamera.web.d.ts +47 -0
  309. package/lib/typescript/src/NativeCustomCamera.web.d.ts.map +1 -0
  310. package/lib/typescript/src/NativeCustomVideoView.d.ts +30 -0
  311. package/lib/typescript/src/NativeCustomVideoView.d.ts.map +1 -0
  312. package/lib/typescript/src/NativeDocumentPreviewView.d.ts +48 -0
  313. package/lib/typescript/src/NativeDocumentPreviewView.d.ts.map +1 -0
  314. package/lib/typescript/src/NativeMediaThumbnailView.d.ts +54 -0
  315. package/lib/typescript/src/NativeMediaThumbnailView.d.ts.map +1 -0
  316. package/lib/typescript/src/NativeSvgIcon.d.ts +25 -0
  317. package/lib/typescript/src/NativeSvgIcon.d.ts.map +1 -0
  318. package/lib/typescript/src/ResponsivePreviewContainer.d.ts +92 -0
  319. package/lib/typescript/src/ResponsivePreviewContainer.d.ts.map +1 -0
  320. package/lib/typescript/src/accessibility.d.ts +20 -0
  321. package/lib/typescript/src/accessibility.d.ts.map +1 -0
  322. package/lib/typescript/src/config.d.ts +145 -0
  323. package/lib/typescript/src/config.d.ts.map +1 -0
  324. package/lib/typescript/src/cpuOptimization.d.ts +63 -0
  325. package/lib/typescript/src/cpuOptimization.d.ts.map +1 -0
  326. package/lib/typescript/src/db/DatabaseManager.d.ts +48 -0
  327. package/lib/typescript/src/db/DatabaseManager.d.ts.map +1 -0
  328. package/lib/typescript/src/db/MediaMapper.d.ts +77 -0
  329. package/lib/typescript/src/db/MediaMapper.d.ts.map +1 -0
  330. package/lib/typescript/src/db/MediaRepository.d.ts +66 -0
  331. package/lib/typescript/src/db/MediaRepository.d.ts.map +1 -0
  332. package/lib/typescript/src/db/index.d.ts +23 -0
  333. package/lib/typescript/src/db/index.d.ts.map +1 -0
  334. package/lib/typescript/src/db/migrations.d.ts +31 -0
  335. package/lib/typescript/src/db/migrations.d.ts.map +1 -0
  336. package/lib/typescript/src/db/secureKey.d.ts +10 -0
  337. package/lib/typescript/src/db/secureKey.d.ts.map +1 -0
  338. package/lib/typescript/src/db/types.d.ts +115 -0
  339. package/lib/typescript/src/db/types.d.ts.map +1 -0
  340. package/lib/typescript/src/db/useMediaDatabase.d.ts +41 -0
  341. package/lib/typescript/src/db/useMediaDatabase.d.ts.map +1 -0
  342. package/lib/typescript/src/defaultIcons.d.ts +139 -0
  343. package/lib/typescript/src/defaultIcons.d.ts.map +1 -0
  344. package/lib/typescript/src/deviceCapabilities.d.ts +92 -0
  345. package/lib/typescript/src/deviceCapabilities.d.ts.map +1 -0
  346. package/lib/typescript/src/errors.d.ts +95 -0
  347. package/lib/typescript/src/errors.d.ts.map +1 -0
  348. package/lib/typescript/src/iconConfig.d.ts +176 -0
  349. package/lib/typescript/src/iconConfig.d.ts.map +1 -0
  350. package/lib/typescript/src/icons.d.ts +217 -0
  351. package/lib/typescript/src/icons.d.ts.map +1 -0
  352. package/lib/typescript/src/index.d.ts +104 -0
  353. package/lib/typescript/src/index.d.ts.map +1 -0
  354. package/lib/typescript/src/library/MediaLibrary.d.ts +57 -0
  355. package/lib/typescript/src/library/MediaLibrary.d.ts.map +1 -0
  356. package/lib/typescript/src/library/MediaLibraryScreen.d.ts +51 -0
  357. package/lib/typescript/src/library/MediaLibraryScreen.d.ts.map +1 -0
  358. package/lib/typescript/src/library/components/AudioPreview.d.ts +6 -0
  359. package/lib/typescript/src/library/components/AudioPreview.d.ts.map +1 -0
  360. package/lib/typescript/src/library/components/DocumentPreview.d.ts +10 -0
  361. package/lib/typescript/src/library/components/DocumentPreview.d.ts.map +1 -0
  362. package/lib/typescript/src/library/components/ExpandableFab.d.ts +39 -0
  363. package/lib/typescript/src/library/components/ExpandableFab.d.ts.map +1 -0
  364. package/lib/typescript/src/library/components/FileTiles.d.ts +8 -0
  365. package/lib/typescript/src/library/components/FileTiles.d.ts.map +1 -0
  366. package/lib/typescript/src/library/components/ImagePreview.d.ts +5 -0
  367. package/lib/typescript/src/library/components/ImagePreview.d.ts.map +1 -0
  368. package/lib/typescript/src/library/components/MediaMetadataHeader.d.ts +16 -0
  369. package/lib/typescript/src/library/components/MediaMetadataHeader.d.ts.map +1 -0
  370. package/lib/typescript/src/library/components/RecordAudioScreen.d.ts +8 -0
  371. package/lib/typescript/src/library/components/RecordAudioScreen.d.ts.map +1 -0
  372. package/lib/typescript/src/library/components/VideoPreview.d.ts +20 -0
  373. package/lib/typescript/src/library/components/VideoPreview.d.ts.map +1 -0
  374. package/lib/typescript/src/library/components/ViewerCounter.d.ts +13 -0
  375. package/lib/typescript/src/library/components/ViewerCounter.d.ts.map +1 -0
  376. package/lib/typescript/src/library/compressionEstimate.d.ts +313 -0
  377. package/lib/typescript/src/library/compressionEstimate.d.ts.map +1 -0
  378. package/lib/typescript/src/library/fileMetadata.d.ts +33 -0
  379. package/lib/typescript/src/library/fileMetadata.d.ts.map +1 -0
  380. package/lib/typescript/src/library/index.d.ts +40 -0
  381. package/lib/typescript/src/library/index.d.ts.map +1 -0
  382. package/lib/typescript/src/library/libraryIconRegistry.d.ts +35 -0
  383. package/lib/typescript/src/library/libraryIconRegistry.d.ts.map +1 -0
  384. package/lib/typescript/src/library/libraryIcons.d.ts +85 -0
  385. package/lib/typescript/src/library/libraryIcons.d.ts.map +1 -0
  386. package/lib/typescript/src/library/localizedFormat.d.ts +49 -0
  387. package/lib/typescript/src/library/localizedFormat.d.ts.map +1 -0
  388. package/lib/typescript/src/library/palette.d.ts +104 -0
  389. package/lib/typescript/src/library/palette.d.ts.map +1 -0
  390. package/lib/typescript/src/library/responsive.d.ts +15 -0
  391. package/lib/typescript/src/library/responsive.d.ts.map +1 -0
  392. package/lib/typescript/src/locales.d.ts +103 -0
  393. package/lib/typescript/src/locales.d.ts.map +1 -0
  394. package/lib/typescript/src/localization.d.ts +1051 -0
  395. package/lib/typescript/src/localization.d.ts.map +1 -0
  396. package/lib/typescript/src/logger.d.ts +4 -0
  397. package/lib/typescript/src/logger.d.ts.map +1 -0
  398. package/lib/typescript/src/lowMemoryCache.d.ts +64 -0
  399. package/lib/typescript/src/lowMemoryCache.d.ts.map +1 -0
  400. package/lib/typescript/src/mediaHistory.d.ts +3 -0
  401. package/lib/typescript/src/mediaHistory.d.ts.map +1 -0
  402. package/lib/typescript/src/mediaStorageService.d.ts +155 -0
  403. package/lib/typescript/src/mediaStorageService.d.ts.map +1 -0
  404. package/lib/typescript/src/memory.d.ts +21 -0
  405. package/lib/typescript/src/memory.d.ts.map +1 -0
  406. package/lib/typescript/src/nativeActions.d.ts +174 -0
  407. package/lib/typescript/src/nativeActions.d.ts.map +1 -0
  408. package/lib/typescript/src/nativeComponentSupport.d.ts +30 -0
  409. package/lib/typescript/src/nativeComponentSupport.d.ts.map +1 -0
  410. package/lib/typescript/src/networkPerformance.d.ts +12 -0
  411. package/lib/typescript/src/networkPerformance.d.ts.map +1 -0
  412. package/lib/typescript/src/performance.d.ts +32 -0
  413. package/lib/typescript/src/performance.d.ts.map +1 -0
  414. package/lib/typescript/src/performanceMonitor.d.ts +45 -0
  415. package/lib/typescript/src/performanceMonitor.d.ts.map +1 -0
  416. package/lib/typescript/src/permissions.d.ts +132 -0
  417. package/lib/typescript/src/permissions.d.ts.map +1 -0
  418. package/lib/typescript/src/rendering.d.ts +9 -0
  419. package/lib/typescript/src/rendering.d.ts.map +1 -0
  420. package/lib/typescript/src/security.d.ts +6 -0
  421. package/lib/typescript/src/security.d.ts.map +1 -0
  422. package/lib/typescript/src/state.d.ts +102 -0
  423. package/lib/typescript/src/state.d.ts.map +1 -0
  424. package/lib/typescript/src/storage.d.ts +117 -0
  425. package/lib/typescript/src/storage.d.ts.map +1 -0
  426. package/lib/typescript/src/theme.d.ts +15 -0
  427. package/lib/typescript/src/theme.d.ts.map +1 -0
  428. package/lib/typescript/src/types.d.ts +802 -0
  429. package/lib/typescript/src/types.d.ts.map +1 -0
  430. package/lib/typescript/src/utils.d.ts +50 -0
  431. package/lib/typescript/src/utils.d.ts.map +1 -0
  432. package/lib/typescript/src/validation.d.ts +52 -0
  433. package/lib/typescript/src/validation.d.ts.map +1 -0
  434. package/package.json +204 -0
  435. package/react-native.config.js +17 -0
  436. package/src/AudioPlayerContext.tsx +220 -0
  437. package/src/CameraPreview.tsx +352 -0
  438. package/src/NativeAnimatedImageView.tsx +71 -0
  439. package/src/NativeAudioPlayerView.tsx +39 -0
  440. package/src/NativeBlurView.tsx +40 -0
  441. package/src/NativeCustomCamera.ts +305 -0
  442. package/src/NativeCustomCamera.web.ts +192 -0
  443. package/src/NativeCustomVideoView.ts +30 -0
  444. package/src/NativeDocumentPreviewView.tsx +196 -0
  445. package/src/NativeMediaThumbnailView.tsx +167 -0
  446. package/src/NativeSvgIcon.tsx +46 -0
  447. package/src/ResponsivePreviewContainer.tsx +227 -0
  448. package/src/accessibility.ts +80 -0
  449. package/src/config.ts +406 -0
  450. package/src/cpuOptimization.ts +365 -0
  451. package/src/db/DatabaseManager.ts +212 -0
  452. package/src/db/MediaMapper.ts +377 -0
  453. package/src/db/MediaRepository.ts +313 -0
  454. package/src/db/index.ts +73 -0
  455. package/src/db/migrations.ts +222 -0
  456. package/src/db/react-native-sqlcipher.d.ts +76 -0
  457. package/src/db/secureKey.ts +100 -0
  458. package/src/db/types.ts +123 -0
  459. package/src/db/useMediaDatabase.ts +191 -0
  460. package/src/defaultIcons.tsx +2390 -0
  461. package/src/deviceCapabilities.ts +438 -0
  462. package/src/errors.ts +350 -0
  463. package/src/iconConfig.tsx +962 -0
  464. package/src/icons.tsx +788 -0
  465. package/src/index.tsx +763 -0
  466. package/src/jest-globals.d.ts +9 -0
  467. package/src/library/MediaLibrary.tsx +141 -0
  468. package/src/library/MediaLibraryScreen.tsx +3003 -0
  469. package/src/library/components/AudioPreview.tsx +73 -0
  470. package/src/library/components/DocumentPreview.tsx +361 -0
  471. package/src/library/components/ExpandableFab.tsx +342 -0
  472. package/src/library/components/FileTiles.tsx +252 -0
  473. package/src/library/components/ImagePreview.tsx +72 -0
  474. package/src/library/components/MediaMetadataHeader.tsx +208 -0
  475. package/src/library/components/RecordAudioScreen.tsx +845 -0
  476. package/src/library/components/VideoPreview.tsx +478 -0
  477. package/src/library/components/ViewerCounter.tsx +71 -0
  478. package/src/library/compressionEstimate.ts +662 -0
  479. package/src/library/fileMetadata.ts +212 -0
  480. package/src/library/index.ts +66 -0
  481. package/src/library/libraryIconRegistry.tsx +86 -0
  482. package/src/library/libraryIcons.tsx +928 -0
  483. package/src/library/localizedFormat.ts +207 -0
  484. package/src/library/palette.ts +281 -0
  485. package/src/library/responsive.ts +49 -0
  486. package/src/locales.ts +381 -0
  487. package/src/localization.ts +1947 -0
  488. package/src/logger.ts +20 -0
  489. package/src/lowMemoryCache.ts +218 -0
  490. package/src/mediaHistory.ts +42 -0
  491. package/src/mediaStorageService.ts +546 -0
  492. package/src/memory.ts +102 -0
  493. package/src/nativeActions.ts +422 -0
  494. package/src/nativeComponentSupport.ts +60 -0
  495. package/src/networkPerformance.ts +30 -0
  496. package/src/performance.ts +137 -0
  497. package/src/performanceMonitor.ts +219 -0
  498. package/src/permissions.ts +403 -0
  499. package/src/rendering.ts +47 -0
  500. package/src/security.ts +19 -0
  501. package/src/state.ts +333 -0
  502. package/src/storage.ts +453 -0
  503. package/src/theme.tsx +467 -0
  504. package/src/types.ts +946 -0
  505. package/src/utils.ts +177 -0
  506. 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
+ }