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,1394 @@
1
+ package com.customcamera
2
+
3
+ import android.graphics.Bitmap
4
+ import android.graphics.BitmapFactory
5
+ import android.graphics.Canvas
6
+ import android.graphics.Color
7
+ import android.graphics.ImageDecoder
8
+ import android.graphics.Matrix
9
+ import android.media.ExifInterface
10
+ import android.media.Image
11
+ import android.media.MediaCodec
12
+ import android.media.MediaCodecInfo
13
+ import android.media.MediaFormat
14
+ import android.media.MediaMuxer
15
+ import android.os.Build
16
+ import android.os.SystemClock
17
+ import android.util.Log
18
+ import java.io.ByteArrayOutputStream
19
+ import java.io.File
20
+ import java.io.FileInputStream
21
+ import java.io.FileOutputStream
22
+ import java.io.IOException
23
+ import java.io.InputStream
24
+ import java.io.OutputStream
25
+
26
+ /**
27
+ * Re-encodes a still image for the preview editor's two header menus: [export] converts it into
28
+ * another container ("Export By") and [compress] rewrites it smaller ("Compress Image").
29
+ *
30
+ * The pairing mirrors the video side, where [VideoRotationTranscoder] holds `convertContainer` and
31
+ * `compress` for exactly the same two menus, and both of this object's entry points share the one
32
+ * contract:
33
+ *
34
+ * - **The source file is never touched.** Every export writes a brand-new file; the original
35
+ * bytes are only ever read.
36
+ * - **Geometry survives.** The image is decoded at full resolution and encoded at exactly those
37
+ * pixel dimensions, so width, height and aspect ratio come out unchanged. The one exception is
38
+ * HEIF/AVIF, whose 4:2:0 encoders need even dimensions — see [encodeStillViaCodec].
39
+ * - **Orientation survives.** EXIF rotation is baked into the pixels and the output is written
40
+ * with an upright orientation tag, because PNG/WebP/HEIF do not carry an EXIF orientation that
41
+ * consumers reliably honour. Reading the exported file therefore shows exactly what the
42
+ * preview showed — no double rotation, no sideways export.
43
+ * - **Metadata survives where the container can hold it.** Capture time, camera make/model, GPS
44
+ * and exposure data are copied across for the containers this platform can write EXIF into.
45
+ *
46
+ * JPEG, PNG and WebP go through `Bitmap.compress`. HEIF and AVIF have no `Bitmap.CompressFormat`
47
+ * on ANY API level (verified against the API 36 platform: the enum holds JPEG, PNG, WEBP,
48
+ * WEBP_LOSSY and WEBP_LOSSLESS and nothing else), so they are encoded through the platform's
49
+ * dedicated still-image encoders and written with the HEIF muxer. [MediaExportFormats] decides
50
+ * whether that path exists on this device, so this class is only ever asked for a format the
51
+ * device can actually produce.
52
+ */
53
+ object ImageExporter {
54
+ private const val TAG = "ImageExporter"
55
+
56
+ /**
57
+ * Encoder quality for lossy targets. Matches the quality the editor already uses when it bakes
58
+ * edits into a JPEG (MediaPreviewEditor.saveEditedImageComposited), so a format conversion
59
+ * never comes out visibly softer than an ordinary edited send.
60
+ */
61
+ const val DEFAULT_QUALITY = 95
62
+
63
+ private const val DEQUEUE_TIMEOUT_US = 10_000L
64
+ private const val ENCODE_TIMEOUT_NS = 30_000_000_000L
65
+ private const val MAX_IDLE_POLLS_AFTER_OUTPUT = 50
66
+
67
+ // ── Compression search bounds ───────────────────────────────────────────────────────────
68
+ /**
69
+ * Quality floor for a compressed still. Below roughly this the encoder starts producing
70
+ * blocking that is obvious at a glance, so the search stops asking for quality it cannot
71
+ * deliver and reaches for a downscale instead — fewer, cleaner pixels beat more, mushy ones.
72
+ */
73
+ private const val MIN_COMPRESSION_QUALITY = 20
74
+ /** Ceiling: re-encoding above this buys file size back without buying visible detail. */
75
+ private const val MAX_COMPRESSION_QUALITY = 95
76
+ /**
77
+ * Downscale factors tried in order when no quality in the band above reaches the target size.
78
+ * The first entry is "don't scale at all", which is the only step most images ever need.
79
+ */
80
+ private val COMPRESSION_SCALE_STEPS = floatArrayOf(1f, 0.75f, 0.5f, 0.35f)
81
+ /** Encodes per scale step. Six halvings resolve the 20..95 quality band to within one point. */
82
+ private const val MAX_QUALITY_PROBES = 6
83
+
84
+ // ── Size prediction (what a menu can quote before anything is encoded) ────────────────────
85
+ /**
86
+ * The low end of a predicted range, as a fraction of the target ceiling.
87
+ *
88
+ * [compress] aims at an exact byte CEILING (`source × (1 - percent/100)`) and keeps the highest
89
+ * quality that lands at or under it, so the produced file is never bigger than the target but is
90
+ * routinely a little smaller: the quality search resolves the band only to within about one
91
+ * step, a profile pre-cap on the longest edge removes pixels outright, and a target the encoder
92
+ * cannot reach falls back to the smallest candidate it found. All three land the output BELOW the
93
+ * ceiling, so the quoted band runs from `ceiling × this` up to the ceiling. This is the still's
94
+ * counterpart to the video side's [VideoRotationTranscoder.ESTIMATE_RATE_UNDERSHOOT] — a modelled
95
+ * tolerance, not a padded guess — and it is tighter because a byte target is a hard ceiling
96
+ * whereas a bit rate is only a target the VBR encoder spends around.
97
+ *
98
+ * The compression menu's VIDEO rows — now quoted PROPORTIONALLY rather than from the bit-rate
99
+ * model — reuse THIS fraction (not the bit-rate one) to open their band, so a video row and an
100
+ * image row shave their low end by the same amount. This constant is the one place it is defined.
101
+ */
102
+ internal const val ESTIMATE_SIZE_UNDERSHOOT = 0.85
103
+
104
+ // ── Progress reporting ──────────────────────────────────────────────────────────────────
105
+ // Both entry points report how far through the work they are so the caller's overlay can show a
106
+ // REAL remaining time rather than counting a prediction down.
107
+ //
108
+ // The fractions are CONTINUOUS, not milestones. A still's two expensive phases are single
109
+ // blocking platform calls with no callback of their own — `BitmapFactory.decodeStream` and
110
+ // `Bitmap.compress` — so each is measured through the bytes it moves: the decoder is fed a
111
+ // counting InputStream and the encoder writes through a counting OutputStream (see
112
+ // [CountingInputStream] / [CountingOutputStream]). Reporting only at the phase boundaries, as
113
+ // this used to, left the caller's countdown with seconds of silence to guess through, which is
114
+ // exactly how it ran out mid-export or froze one second short of the end.
115
+ //
116
+ // Every value is emitted through [ProgressEmitter], which keeps the sequence monotonic and
117
+ // throttles it to the same interval/delta pair the video transcode uses.
118
+ /** Smallest gap between two progress callbacks. Enough to animate; far too coarse to cost. */
119
+ private const val PROGRESS_MIN_INTERVAL_MS = 150L
120
+ /** …unless the fraction has moved this much, which forces a callback however recent the last. */
121
+ private const val PROGRESS_MIN_DELTA = 0.01f
122
+
123
+ // [compress]'s 0..1 range. Unlike the video transcode, whose fraction is a measured position on a
124
+ // known timeline, a still's compression cost is a decode plus an unknown number of encodes — the
125
+ // search stops as soon as it converges. So beyond the decode the fraction is the search's
126
+ // PROGRESS THROUGH ITS BUDGET: the boundaries below carve the range into one span per scale step,
127
+ // and each probe advances within its step's span. It is monotonic and it never claims to be
128
+ // finished early; when the search converges before spending its budget the fraction jumps
129
+ // forward, which is the truth (the work really did end sooner).
130
+ /** Fraction complete once the source is decoded and flattened — the one fixed, unavoidable cost. */
131
+ private const val PROGRESS_AFTER_DECODE = 0.15f
132
+ /**
133
+ * Upper bound of each scale step's span, indexed alongside [COMPRESSION_SCALE_STEPS]. The
134
+ * full-size step gets the largest share because it is both the most expensive (full-resolution
135
+ * encodes) and the only step the overwhelming majority of images ever need; the downscaled
136
+ * steps, reached only when the target is out of reach, are cheaper and get progressively less.
137
+ */
138
+ private val PROGRESS_SCALE_STEP_BOUNDS = floatArrayOf(0.70f, 0.85f, 0.93f, 0.97f)
139
+ /** Everything after the search — writing the winner, reading it back, copying metadata. */
140
+ private const val PROGRESS_BEFORE_WRITE = 0.97f
141
+
142
+ // [export] reports against the same contract, for the same reason — its caller shows the same
143
+ // countdown — but its 0..1 range is carved up differently, because a conversion is not a search:
144
+ // it is one decode, one encode and one read-back, in that order, every time. Both of the two
145
+ // expensive phases are measured as they run (bytes read by the decoder, bytes written by the
146
+ // encoder), so the fraction moves continuously rather than standing still between milestones.
147
+ /**
148
+ * Fraction complete once the source is decoded and flattened, for the targets encoded through
149
+ * `Bitmap.compress`.
150
+ *
151
+ * The decode is a fixed cost — full-resolution pixels out of a compressed container — while the
152
+ * encode's cost depends on the target, so the split is stated per encoder rather than shared.
153
+ * For JPEG/PNG/WebP/PDF the two phases are broadly comparable, which is what this near-even
154
+ * split says.
155
+ */
156
+ private const val PROGRESS_EXPORT_DECODE_END = 0.42f
157
+ /**
158
+ * The same boundary for HEIF/AVIF. Their encode is the MediaCodec + HEIF muxer path
159
+ * ([encodeStillViaCodec]), which pays a codec configure/start, a full RGB→YUV 4:2:0 conversion of
160
+ * every pixel in process and a muxer write on top of the encode itself — so it dwarfs the decode
161
+ * and gets a correspondingly larger share of the range.
162
+ */
163
+ private const val PROGRESS_EXPORT_CODEC_DECODE_END = 0.25f
164
+ /** Fraction complete once the single encode has produced the output file. */
165
+ private const val PROGRESS_EXPORT_AFTER_ENCODE = 0.90f
166
+ /**
167
+ * Reported once the encode is over and the pass is in its tail — decoding the output back to
168
+ * verify it and copying the metadata across. That work is real but has no measurable length, so
169
+ * it is deliberately reported ABOVE the caller's "finalizing" threshold: the countdown hands over
170
+ * to a static line for it instead of quoting a second count it cannot justify.
171
+ */
172
+ private const val PROGRESS_EXPORT_TAIL = 0.99f
173
+ /**
174
+ * Ceiling for the fraction derived from bytes written during an encode. The expected output size
175
+ * is an estimate ([estimatedEncodedBytes]), so a small file could otherwise reach — and appear to
176
+ * pass — its phase boundary while the encoder was still running. The boundary itself is only ever
177
+ * reported once the encode has actually returned.
178
+ */
179
+ private const val PROGRESS_ENCODE_MEASURED_CEILING = 0.97f
180
+
181
+ // Sub-phases of the HEIF/AVIF encode ([encodeStillViaCodec]), expressed as fractions of THAT
182
+ // encode rather than of the whole export — the caller maps them into the encode's span.
183
+ /** The codec is configured and started; nothing has been fed to it yet. */
184
+ private const val PROGRESS_CODEC_STARTED = 0.06f
185
+ /** The whole frame has been converted to YUV and handed over. */
186
+ private const val PROGRESS_CODEC_FRAME_QUEUED = 0.62f
187
+ /** How far one muxed tile advances the drain. */
188
+ private const val PROGRESS_CODEC_PER_SAMPLE = 0.12f
189
+ /** …and how far the drain may get on tiles alone, since their number is unknown up front. */
190
+ private const val PROGRESS_CODEC_MEASURED_CEILING = 0.97f
191
+
192
+ /** Dimensions of a successfully exported file, read back from the file itself. */
193
+ data class ExportResult(val width: Int, val height: Int)
194
+
195
+ /**
196
+ * What [compress] managed to do.
197
+ *
198
+ * The three cases are kept apart because the caller says something different about each: a
199
+ * [Success] reports the saving achieved, an [AlreadyMinimal] tells the user there is nothing
200
+ * left to squeeze (which is NOT an error), and a [Failed] is a genuine encode failure.
201
+ */
202
+ sealed interface CompressOutcome {
203
+ /** A smaller file was produced. [bytes] is its real length, [format] the container written. */
204
+ data class Success(
205
+ val width: Int,
206
+ val height: Int,
207
+ val bytes: Long,
208
+ val format: ImageFormatSupport.Format
209
+ ) : CompressOutcome
210
+
211
+ /**
212
+ * Every candidate came out at least as large as the source, so nothing was written.
213
+ *
214
+ * The ordinary cause is an image that has already been through a lossy encoder at a low
215
+ * quality (a re-shared chat photo, a heavily optimised thumbnail): there is no redundancy
216
+ * left for a second pass to remove, and re-encoding only adds overhead. Handing back a
217
+ * "compressed" copy that is BIGGER than the original would make the menu lie, so the
218
+ * original is kept instead.
219
+ */
220
+ object AlreadyMinimal : CompressOutcome
221
+
222
+ /** The source could not be decoded, or no encoder would produce a readable file. */
223
+ object Failed : CompressOutcome
224
+ }
225
+
226
+ /**
227
+ * Export [source] into [output] as [format].
228
+ *
229
+ * @param onProgress called on the CALLING (worker) thread with how much of the work is done,
230
+ * 0.0..1.0, CONTINUOUSLY as the decode and the encode run — see the export progress constants
231
+ * above. Measured, not predicted: the decode's fraction is the source bytes the decoder has
232
+ * consumed and the encode's is the output bytes it has written, so the number advances at
233
+ * exactly the rate the conversion does and a caller can show a remaining time that tracks the
234
+ * real work instead of a clock. Monotonic, starts at 0.0, and reaches exactly 1.0 on every
235
+ * terminal outcome INCLUDING a failure — the work really is over in both cases, and a caller
236
+ * waiting on a 1.0 that never came would sit on a stale overlay.
237
+ * @return the exported file's real dimensions, or null if the image could not be decoded, the
238
+ * encoder refused the frame, or the result cannot be decoded again. [output] is deleted on
239
+ * every failure, so a partial file is never left behind for a caller to mistake for a
240
+ * finished export.
241
+ */
242
+ fun export(
243
+ source: File,
244
+ output: File,
245
+ format: ImageFormatSupport.Format,
246
+ quality: Int = DEFAULT_QUALITY,
247
+ onProgress: ((Float) -> Unit)? = null
248
+ ): ExportResult? {
249
+ val progress = ProgressEmitter(onProgress)
250
+ progress.report(0f)
251
+ if (!source.exists() || source.length() <= 0L) {
252
+ Log.w(TAG, "Export source is missing or empty: ${source.absolutePath}")
253
+ progress.report(1f)
254
+ return null
255
+ }
256
+ // Where the decode ends and the encode begins, for THIS target's encoder.
257
+ val decodeEnd = if (MediaExportFormats.stillEncoderMimeType(format) != null) {
258
+ PROGRESS_EXPORT_CODEC_DECODE_END
259
+ } else {
260
+ PROGRESS_EXPORT_DECODE_END
261
+ }
262
+ val encodeSpan = PROGRESS_EXPORT_AFTER_ENCODE - decodeEnd
263
+ var working: Bitmap? = null
264
+ return try {
265
+ // The decode reports as it consumes the file, so a multi-second full-resolution decode is
266
+ // visible to the caller's countdown while it happens instead of only once it is over.
267
+ val decoded = decodeUpright(source) { read -> progress.advance(decodeEnd * read) }
268
+ if (decoded == null) {
269
+ Log.w(TAG, "Could not decode ${source.absolutePath} for export")
270
+ return null
271
+ }
272
+ working = decoded
273
+ // Targets with no alpha channel get the image flattened onto white FIRST, rather than
274
+ // letting the encoder drop the channel — which composites onto black and turns a
275
+ // transparent PNG's background into a solid black slab.
276
+ if (needsOpaqueSource(format) && decoded.hasAlpha()) {
277
+ val flattened = flattenOntoWhite(decoded)
278
+ if (flattened !== decoded) {
279
+ runCatching { decoded.recycle() }
280
+ working = flattened
281
+ }
282
+ }
283
+ val bitmap = working ?: return null
284
+ // The decode (and any flatten) is done: the one cost every conversion pays before the
285
+ // encoder is even configured.
286
+ progress.report(decodeEnd)
287
+
288
+ // How the encode's own progress is measured: the output bytes it has written against a
289
+ // generous estimate of what it will write in total. Deliberately generous — see
290
+ // [estimatedEncodedBytes] — so the fraction lags the encoder rather than reaching its phase
291
+ // boundary early, and the boundary itself is only ever reported once the encode returns.
292
+ val expectedBytes = estimatedEncodedBytes(bitmap, format)
293
+ val onEncodedBytes: (Long) -> Unit = { written ->
294
+ progress.advance(decodeEnd + encodeSpan * writtenFraction(written, expectedBytes))
295
+ }
296
+ val encoded = when (format) {
297
+ ImageFormatSupport.Format.JPEG ->
298
+ compressBitmap(bitmap, Bitmap.CompressFormat.JPEG, quality, output, onEncodedBytes)
299
+ ImageFormatSupport.Format.PNG ->
300
+ // PNG is lossless; its quality argument is ignored by the encoder.
301
+ compressBitmap(bitmap, Bitmap.CompressFormat.PNG, 100, output, onEncodedBytes)
302
+ ImageFormatSupport.Format.WEBP ->
303
+ compressBitmap(bitmap, webpFormat(), quality, output, onEncodedBytes)
304
+ ImageFormatSupport.Format.HEIF,
305
+ ImageFormatSupport.Format.AVIF -> {
306
+ val mime = MediaExportFormats.stillEncoderMimeType(format)
307
+ mime != null && encodeStillViaCodec(bitmap, mime, quality, output) { phase ->
308
+ progress.advance(decodeEnd + encodeSpan * phase)
309
+ }
310
+ }
311
+ ImageFormatSupport.Format.PDF ->
312
+ exportPdf(bitmap, output, onEncodedBytes)
313
+ else -> {
314
+ Log.w(TAG, "No encoder for export format $format")
315
+ false
316
+ }
317
+ }
318
+ if (!encoded || !output.exists() || output.length() <= 0L) {
319
+ runCatching { output.delete() }
320
+ return null
321
+ }
322
+ // The encode is what the wait was mostly spent on, and it is over. Everything past here —
323
+ // reading the output back and copying metadata onto it — is real work of unmeasurable
324
+ // length, so the pass reports straight through [PROGRESS_EXPORT_AFTER_ENCODE] into its tail
325
+ // rather than quoting a share of a countdown for it.
326
+ progress.report(PROGRESS_EXPORT_TAIL)
327
+ // Report the dimensions of the FILE, not of the bitmap that was handed to the encoder. The
328
+ // two differ by a pixel when a HEIF/AVIF encode rounds an odd edge down (see
329
+ // [encodeStillViaCodec]), and this doubles as the "can it be read back?" check: an encoder
330
+ // can report success and still leave a container no decoder will open, which the preview
331
+ // would render as a blank frame.
332
+ val dimensions = if (format == ImageFormatSupport.Format.PDF) {
333
+ Pair(bitmap.width, bitmap.height)
334
+ } else {
335
+ readImageBounds(output)
336
+ }
337
+ if (dimensions == null) {
338
+ Log.w(TAG, "Exported ${output.name} cannot be decoded back; discarding")
339
+ runCatching { output.delete() }
340
+ return null
341
+ }
342
+ copyMetadata(source, output, format)
343
+ ExportResult(dimensions.first, dimensions.second)
344
+ } catch (e: OutOfMemoryError) {
345
+ // A full-resolution ARGB_8888 decode is the largest allocation this library makes; on a
346
+ // low-RAM device it can exhaust the heap. Treat it as a failed export (the caller keeps
347
+ // showing the original) rather than tearing the process down.
348
+ Log.e(TAG, "Out of memory exporting ${source.name} as $format", e)
349
+ runCatching { output.delete() }
350
+ null
351
+ } catch (e: Exception) {
352
+ Log.e(TAG, "Failed to export ${source.name} as $format", e)
353
+ runCatching { output.delete() }
354
+ null
355
+ } finally {
356
+ runCatching { working?.recycle() }
357
+ // Every way out of this function is the end of the work — a finished conversion, a source
358
+ // that could not be decoded, an encoder that refused the frame, an OOM. Reporting the
359
+ // terminal 1.0 from here rather than from the success path is what guarantees a caller's
360
+ // overlay is never left waiting on a completion that a failure quietly swallowed.
361
+ progress.report(1f)
362
+ }
363
+ }
364
+
365
+ /**
366
+ * How far through an encode [written] bytes is, given [expected] as the total it will write.
367
+ *
368
+ * Capped below 1.0 ([PROGRESS_ENCODE_MEASURED_CEILING]) because [expected] is an estimate: an
369
+ * output that beats it must not be able to walk the fraction over its phase boundary while the
370
+ * encoder is still writing. Only the encoder actually returning reports the boundary.
371
+ */
372
+ private fun writtenFraction(written: Long, expected: Long): Float {
373
+ if (expected <= 0L) return 0f
374
+ return (written.toDouble() / expected.toDouble())
375
+ .toFloat()
376
+ .coerceIn(0f, PROGRESS_ENCODE_MEASURED_CEILING)
377
+ }
378
+
379
+ /**
380
+ * Roughly how many bytes encoding [bitmap] into [format] will write — the denominator the
381
+ * encode's measured progress is expressed against, never a promise about the output.
382
+ *
383
+ * Bytes per pixel, because that is what every one of these encoders scales with, and biased
384
+ * HIGH on purpose. The two ways to be wrong are not symmetric: an estimate that is too small
385
+ * pins the fraction at its ceiling with the encoder still running (the caller's countdown then
386
+ * believes the work is nearly done and freezes a second short of the end), while one that is too
387
+ * large simply makes the fraction climb more slowly than the truth and snap forward when the
388
+ * encode returns — which is the same shape as any job that finishes ahead of its prediction.
389
+ */
390
+ private fun estimatedEncodedBytes(bitmap: Bitmap, format: ImageFormatSupport.Format): Long {
391
+ val pixels = bitmap.width.toLong() * bitmap.height.toLong()
392
+ val bytesPerPixel = when (format) {
393
+ // Lossy, and at [DEFAULT_QUALITY] a photo lands well under one byte per pixel.
394
+ ImageFormatSupport.Format.JPEG -> 1.2
395
+ ImageFormatSupport.Format.WEBP -> 1.0
396
+ // The 4:2:0 codec path is the densest of the lot.
397
+ ImageFormatSupport.Format.HEIF,
398
+ ImageFormatSupport.Format.AVIF -> 0.8
399
+ // Lossless deflate over (mostly) 24-bit pixels.
400
+ ImageFormatSupport.Format.PNG -> 3.0
401
+ // A PDF page here is one embedded, deflated image plus a little structure.
402
+ ImageFormatSupport.Format.PDF -> 3.0
403
+ else -> 2.0
404
+ }
405
+ return (pixels * bytesPerPixel).toLong().coerceAtLeast(1L)
406
+ }
407
+
408
+ // ---------------------------------------------------------------------------------------
409
+ // Compress — the still-image counterpart of VideoRotationTranscoder.compress
410
+ // ---------------------------------------------------------------------------------------
411
+
412
+ /**
413
+ * The container a compressed copy of [source] is written in.
414
+ *
415
+ * Compression normalises the container, exactly as the video side does (every compressed clip
416
+ * comes out an MP4 whatever went in): a lossless or exotic still has no quality knob to turn, so
417
+ * "make this 30% smaller" is only answerable by re-encoding into something lossy. The choice is
418
+ * between the two lossy encoders every device has:
419
+ *
420
+ * - **JPEG** for anything opaque. Smallest output, universally readable.
421
+ * - **Lossy WebP** when the source's container can carry transparency. JPEG has no alpha
422
+ * channel, so a transparent PNG compressed to JPEG would come back with its background
423
+ * flattened onto white — a visible, irreversible change the user did not ask for.
424
+ *
425
+ * Decided from the container alone (no decode), so the caller can name the output file before
426
+ * any work starts. A PNG that happens to be fully opaque therefore still goes to WebP, which
427
+ * costs nothing: lossy WebP is at least as small as JPEG at equal quality.
428
+ */
429
+ fun compressionFormatFor(source: File): ImageFormatSupport.Format =
430
+ if (ImageFormatSupport.fromFileName(source.name)?.mayHaveAlpha == true) {
431
+ ImageFormatSupport.Format.WEBP
432
+ } else {
433
+ ImageFormatSupport.Format.JPEG
434
+ }
435
+
436
+ /**
437
+ * Write a copy of [source] into [output] that is [percent] smaller.
438
+ *
439
+ * The contract matches [export] — the source is only ever READ, orientation is baked into the
440
+ * pixels, metadata is carried where the container can hold it, and [output] is deleted on every
441
+ * failure — with one addition: the output is measured against the request.
442
+ *
443
+ * **Why a search rather than a fixed quality.** The video encoder takes a target bit rate, so
444
+ * "shave 30%" maps straight onto a number the codec understands. A still encoder takes a quality
445
+ * index, and the size it yields depends entirely on the image's content: quality 70 on a flat
446
+ * screenshot is a 95% saving, on fine foliage it is barely 20%, and on an already-compressed chat
447
+ * photo it can be *larger* than the input. Handing the encoder `100 - percent` and hoping would
448
+ * make the menu's numbers meaningless and would sometimes grow the file. So the target size is
449
+ * computed first and the encoder is asked repeatedly:
450
+ *
451
+ * 1. Binary-search the quality band for the HIGHEST quality that lands at or under the target,
452
+ * seeded at `100 - percent` (the mapping the video side uses, and the right answer for a
453
+ * typical photo, so the common case converges in one or two probes).
454
+ * 2. If even [MIN_COMPRESSION_QUALITY] overshoots, downscale and search again — past that floor
455
+ * the only honest way to remove bytes is to remove pixels.
456
+ * 3. If nothing anywhere beat the ORIGINAL's size, write nothing and report
457
+ * [CompressOutcome.AlreadyMinimal].
458
+ *
459
+ * Only the winning byte array is written, so the search costs encodes, not disk churn.
460
+ *
461
+ * @param percent how much of the file's size to remove, 1..95. Clamped, not validated.
462
+ * @param onProgress called on the CALLING (worker) thread with how much of the work is done,
463
+ * 0.0..1.0, as the decode and each encode probe complete — see the progress constants above.
464
+ * It is measured, not predicted: it advances when work finishes, so a caller can derive a
465
+ * remaining time that tracks the real encode instead of a clock. Monotonic, starts at 0.0, and
466
+ * reaches exactly 1.0 on every terminal outcome INCLUDING [CompressOutcome.AlreadyMinimal] and
467
+ * [CompressOutcome.Failed] — the work really is over in all three cases, and a caller that
468
+ * waited for a 1.0 that never came would sit on a stale overlay.
469
+ */
470
+ fun compress(
471
+ source: File,
472
+ output: File,
473
+ percent: Int,
474
+ onProgress: ((Float) -> Unit)? = null,
475
+ // The active image profile resolved to concrete knobs (quality band, downscale ladder, a
476
+ // pre-cap on the longest edge, and the low-RAM decode subsample). The default reproduces the
477
+ // pre-profile search exactly, so a caller that passes nothing is unaffected. Placed after
478
+ // [onProgress] so existing positional callers keep compiling.
479
+ plan: ImageCompressionPlan = ImageCompressionPlan()
480
+ ): CompressOutcome {
481
+ val progress = ProgressEmitter(onProgress)
482
+ progress.report(0f)
483
+ if (!source.exists() || source.length() <= 0L) {
484
+ Log.w(TAG, "Compression source is missing or empty: ${source.absolutePath}")
485
+ progress.report(1f)
486
+ return CompressOutcome.Failed
487
+ }
488
+ val sourceBytes = source.length()
489
+ val shave = percent.coerceIn(1, 95)
490
+ val targetBytes = (sourceBytes * (100L - shave) / 100L).coerceAtLeast(1L)
491
+ val format = compressionFormatFor(source)
492
+ val compressFormat = if (format == ImageFormatSupport.Format.WEBP) {
493
+ webpFormat()
494
+ } else {
495
+ Bitmap.CompressFormat.JPEG
496
+ }
497
+
498
+ var working: Bitmap? = null
499
+ return try {
500
+ // Measured as it consumes the file, exactly as [export]'s decode is: the search that follows
501
+ // reports often, but the decode ahead of it is one long blocking call and the caller's
502
+ // countdown has nothing else to go on until it ends.
503
+ val decoded = decodeUpright(source, plan.minDecodeSampleSize) { read ->
504
+ progress.advance(PROGRESS_AFTER_DECODE * read)
505
+ }
506
+ if (decoded == null) {
507
+ Log.w(TAG, "Could not decode ${source.absolutePath} for compression")
508
+ return CompressOutcome.Failed
509
+ }
510
+ working = decoded
511
+ // Same rule as [export]: flatten onto white OURSELVES rather than letting a JPEG encoder
512
+ // drop the alpha channel, which composites onto black.
513
+ if (needsOpaqueSource(format) && decoded.hasAlpha()) {
514
+ val flattened = flattenOntoWhite(decoded)
515
+ if (flattened !== decoded) {
516
+ runCatching { decoded.recycle() }
517
+ working = flattened
518
+ }
519
+ }
520
+ // Profile pre-cap on the longest edge, applied ONCE before the search so no probe ever
521
+ // encodes more pixels than the profile allows. Scaling both axes by the same factor keeps the
522
+ // aspect ratio exact; 0 = no cap (RECOMMENDED/MAXIMUM_QUALITY on a capable device), which
523
+ // leaves the picture at its decoded size and reproduces the pre-profile behaviour.
524
+ working?.let { current ->
525
+ val longest = maxOf(current.width, current.height)
526
+ if (plan.maxDimension > 0 && longest > plan.maxDimension) {
527
+ val capped = scaleBitmap(current, plan.maxDimension.toFloat() / longest)
528
+ if (capped != null && capped !== current) {
529
+ runCatching { current.recycle() }
530
+ working = capped
531
+ }
532
+ }
533
+ }
534
+ val bitmap = working ?: return CompressOutcome.Failed
535
+ // The decode (and any flatten) is done: the one cost every compression pays, whatever the
536
+ // search goes on to do.
537
+ progress.report(PROGRESS_AFTER_DECODE)
538
+
539
+ // The best candidate at or under the target (highest quality that fits) and, separately, the
540
+ // smallest candidate seen anywhere — the latter is the fallback when the target is simply
541
+ // out of reach, and the yardstick for "is this even worth writing".
542
+ var underTarget: ByteArray? = null
543
+ var smallest: ByteArray? = null
544
+
545
+ for ((stepIndex, scale) in plan.scaleSteps.withIndex()) {
546
+ // This step's slice of the progress range: it starts where the previous step ended and
547
+ // runs to its own bound, so a search that walks into a downscaled step keeps moving
548
+ // forward instead of restarting its count.
549
+ // getOrElse, not indexing: the bounds array is a fixed length and the profile's scale
550
+ // ladder can be shorter OR longer, so a profile must not be able to crash a compression
551
+ // over its progress bar.
552
+ val stepStart = PROGRESS_SCALE_STEP_BOUNDS.getOrElse(stepIndex - 1) { PROGRESS_AFTER_DECODE }
553
+ val stepEnd = PROGRESS_SCALE_STEP_BOUNDS.getOrElse(stepIndex) { PROGRESS_BEFORE_WRITE }
554
+ val frame = scaleBitmap(bitmap, scale) ?: continue
555
+ try {
556
+ // The quality search band comes from the profile: MAXIMUM_QUALITY holds a near-lossless
557
+ // floor, HIGH_COMPRESSION opens the band right down. Coerced so a floor above the ceiling
558
+ // (or out of 1..100) can never make the loop spin.
559
+ var low = plan.qualityFloor.coerceIn(1, 100)
560
+ var high = plan.qualityCeiling.coerceIn(low, 100)
561
+ // Full-size step: seed at the video side's mapping, which is the right answer for a
562
+ // typical photo. A downscaled step is only ever reached because no quality at full size
563
+ // was small enough, so it seeds at the FLOOR instead — if even that overshoots the probe
564
+ // loop exits after one encode and moves on, and if it fits the search spends its
565
+ // remaining probes climbing back toward quality, which is the same work either way.
566
+ var quality = if (scale >= 1f) (100 - shave).coerceIn(low, high) else low
567
+ var probes = 0
568
+ while (probes < MAX_QUALITY_PROBES && low <= high) {
569
+ probes++
570
+ val encoded = encodeToBytes(frame, compressFormat, quality) ?: break
571
+ // One probe of this step's budget is spent — reported AFTER the encode, so the
572
+ // fraction only ever describes work that has actually been done.
573
+ progress.report(
574
+ stepStart + (stepEnd - stepStart) * (probes.toFloat() / MAX_QUALITY_PROBES)
575
+ )
576
+ if (smallest == null || encoded.size < smallest!!.size) smallest = encoded
577
+ if (encoded.size <= targetBytes) {
578
+ // It fits — keep it and spend the remaining probes buying quality back.
579
+ if (underTarget == null || encoded.size > underTarget!!.size) underTarget = encoded
580
+ low = quality + 1
581
+ } else {
582
+ high = quality - 1
583
+ }
584
+ if (low > high) break
585
+ quality = (low + high) / 2
586
+ }
587
+ } finally {
588
+ if (frame !== bitmap) runCatching { frame.recycle() }
589
+ }
590
+ if (underTarget != null) break
591
+ }
592
+
593
+ // Accept the closest thing to the request that is still a real saving. Falling back to
594
+ // `smallest` mirrors the video side, which also only approximates its target and reports the
595
+ // saving it actually achieved — but never at the cost of handing back a bigger file.
596
+ val chosen = underTarget ?: smallest?.takeIf { it.size < sourceBytes }
597
+ if (chosen == null) {
598
+ Log.i(TAG, "No encode of ${source.name} came out smaller than its $sourceBytes bytes")
599
+ return CompressOutcome.AlreadyMinimal
600
+ }
601
+
602
+ // The search is over and a winner is in hand; all that is left is disk work.
603
+ progress.report(PROGRESS_BEFORE_WRITE)
604
+ output.parentFile?.mkdirs()
605
+ FileOutputStream(output).use { it.write(chosen) }
606
+ // Same read-back check [export] performs: an encoder can report success and still leave a
607
+ // container no decoder will open, which the preview would render as a blank frame.
608
+ val dimensions = readImageBounds(output)
609
+ if (dimensions == null) {
610
+ Log.w(TAG, "Compressed ${output.name} cannot be decoded back; discarding")
611
+ runCatching { output.delete() }
612
+ return CompressOutcome.Failed
613
+ }
614
+ copyMetadata(source, output, format)
615
+ CompressOutcome.Success(
616
+ width = dimensions.first,
617
+ height = dimensions.second,
618
+ // Read after copyMetadata: writing EXIF changes the length, and the caller quotes this
619
+ // figure to the user as the size of the file it is about to send.
620
+ bytes = output.length(),
621
+ format = format
622
+ )
623
+ } catch (e: OutOfMemoryError) {
624
+ Log.e(TAG, "Out of memory compressing ${source.name}", e)
625
+ runCatching { output.delete() }
626
+ CompressOutcome.Failed
627
+ } catch (e: Exception) {
628
+ Log.e(TAG, "Failed to compress ${source.name}", e)
629
+ runCatching { output.delete() }
630
+ CompressOutcome.Failed
631
+ } finally {
632
+ runCatching { working?.recycle() }
633
+ // Every way out of this function is the end of the work — success, "already minimal", a
634
+ // decode that never got started, an OOM. Reporting the terminal 1.0 from here rather than
635
+ // from the success path is what guarantees a caller's overlay is never left waiting on a
636
+ // completion that a failure quietly swallowed.
637
+ progress.report(1f)
638
+ }
639
+ }
640
+
641
+ /**
642
+ * The band of output sizes a compression at [percent] is expected to land in — what a "Compress
643
+ * Image" row quotes ("1.8 – 2.1 MB") — or null when [sourceBytes] is unknown (0), which drops
644
+ * the size from the row rather than inventing one.
645
+ *
646
+ * The still counterpart of [VideoRotationTranscoder.estimateCompressedByteRange], and it follows
647
+ * the encoder for the same reason: the number a row quotes has to be the number the search is
648
+ * aiming at, not a second calculation that can drift from it. [compress] computes exactly
649
+ * `sourceBytes × (1 - percent/100)` as its target and keeps the highest quality at or under it, so
650
+ *
651
+ * - [high] and [expected] are that target CEILING, reproduced with the identical clamp and
652
+ * integer arithmetic [compress] uses ([percent] pinned to 1..95). The search cannot exceed it,
653
+ * so it is the honest top of the band.
654
+ * - [low] is the ceiling scaled by [ESTIMATE_SIZE_UNDERSHOOT] — how far under the ceiling the
655
+ * output routinely lands (quality quantisation, a profile pre-cap, or a fallback to the
656
+ * smallest candidate when the target is out of reach).
657
+ *
658
+ * Every field is derived from the ACTUAL measured [sourceBytes] and the requested level; nothing
659
+ * here is a fixed or arbitrary size.
660
+ */
661
+ fun estimateCompressedByteRange(sourceBytes: Long, percent: Int): CompressedSizeEstimate? {
662
+ if (sourceBytes <= 0L) return null
663
+ // The exact clamp and arithmetic of [compress]'s own target, so the quoted ceiling is the
664
+ // number the search is aiming at.
665
+ val shave = percent.coerceIn(1, 95)
666
+ val ceiling = (sourceBytes * (100L - shave) / 100L).coerceAtLeast(1L)
667
+ val low = Math.round(ceiling * ESTIMATE_SIZE_UNDERSHOOT).coerceIn(1L, ceiling)
668
+ return CompressedSizeEstimate(low = low, expected = ceiling, high = ceiling)
669
+ }
670
+
671
+ /**
672
+ * A predicted output-size band: [low]..[high] around the most likely [expected], mirroring
673
+ * [VideoRotationTranscoder.CompressedSizeEstimate] so both menus quote the same shape.
674
+ */
675
+ data class CompressedSizeEstimate(val low: Long, val expected: Long, val high: Long)
676
+
677
+ /**
678
+ * [bitmap] scaled by [scale], or the input itself when no scaling is called for. Returns null
679
+ * only if the allocation failed, which the caller treats as "skip this step".
680
+ */
681
+ private fun scaleBitmap(bitmap: Bitmap, scale: Float): Bitmap? {
682
+ if (scale >= 1f) return bitmap
683
+ val width = (bitmap.width * scale).toInt().coerceAtLeast(1)
684
+ val height = (bitmap.height * scale).toInt().coerceAtLeast(1)
685
+ if (width >= bitmap.width && height >= bitmap.height) return bitmap
686
+ return runCatching { Bitmap.createScaledBitmap(bitmap, width, height, true) }.getOrNull()
687
+ }
688
+
689
+ /**
690
+ * Encode [bitmap] to memory so the size can be measured without touching the disk. Only the
691
+ * winning candidate is ever written to a file.
692
+ */
693
+ private fun encodeToBytes(
694
+ bitmap: Bitmap,
695
+ format: Bitmap.CompressFormat,
696
+ quality: Int
697
+ ): ByteArray? = runCatching {
698
+ val stream = ByteArrayOutputStream()
699
+ if (bitmap.compress(format, quality.coerceIn(1, 100), stream)) stream.toByteArray() else null
700
+ }.getOrElse {
701
+ Log.w(TAG, "Bitmap.compress to $format at quality $quality failed", it)
702
+ null
703
+ }
704
+
705
+ /**
706
+ * The pixel dimensions of [file], or null when nothing on this device can decode it.
707
+ *
708
+ * The still-image counterpart of MediaPreviewEditor.hasReadableVideoSamples. Only the bounds
709
+ * are read (no pixel allocation), which is both the cheapest way to tell a real image from a
710
+ * shell and the authoritative source for an export's dimensions.
711
+ */
712
+ private fun readImageBounds(file: File): Pair<Int, Int>? {
713
+ if (!file.exists() || file.length() <= 0L) return null
714
+ val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
715
+ runCatching { BitmapFactory.decodeFile(file.absolutePath, bounds) }
716
+ if (bounds.outWidth > 0 && bounds.outHeight > 0) {
717
+ return Pair(bounds.outWidth, bounds.outHeight)
718
+ }
719
+ // BitmapFactory declines some HEIF/AVIF variants that ImageDecoder handles, so its "no" is
720
+ // not yet a verdict.
721
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) return null
722
+ var size: Pair<Int, Int>? = null
723
+ runCatching {
724
+ ImageDecoder.decodeDrawable(ImageDecoder.createSource(file)) { decoder, info, _ ->
725
+ if (info.size.width > 0 && info.size.height > 0) {
726
+ size = Pair(info.size.width, info.size.height)
727
+ }
728
+ // Nothing is displayed, so keep the probe from allocating the full image.
729
+ decoder.setTargetSize(1, 1)
730
+ }
731
+ }
732
+ // Deliberately read outside the runCatching result: the header having decoded is the answer,
733
+ // even if the 1x1 downscale that follows it is refused by a particular decoder.
734
+ return size
735
+ }
736
+
737
+ // ---------------------------------------------------------------------------------------
738
+ // Decode
739
+ // ---------------------------------------------------------------------------------------
740
+
741
+ /**
742
+ * Decode [source] at full resolution with its EXIF rotation applied to the PIXELS.
743
+ *
744
+ * Baking the rotation is what makes the export orientation-correct in every target container:
745
+ * only JPEG carries an EXIF orientation tag consumers reliably honour, so an exported PNG or
746
+ * HEIF of a rotated capture would otherwise display sideways. With the rotation in the pixels,
747
+ * the output's stored dimensions ARE its display dimensions and nothing can double-rotate it.
748
+ *
749
+ * On [OutOfMemoryError] the decode is retried at successively coarser subsamples rather than
750
+ * failing outright — a halved image is a far better export than none.
751
+ *
752
+ * @param minSample the inSampleSize the decode STARTS at (a power-of-two floor). 1 is the
753
+ * historical full-resolution decode; a low-RAM profile passes 2 so a very large photo is halved
754
+ * on each edge before it ever lands on the heap, which is the difference between a re-encode and
755
+ * an OOM on a small device. The OOM retry ladder still doubles from here.
756
+ * @param onBytesRead how much of the source the decoder has consumed, 0.0..1.0, called on THIS
757
+ * thread as it reads. `BitmapFactory` exposes no progress of its own, so the file is handed to
758
+ * it through a [CountingInputStream] — which is all `BitmapFactory.decodeFile` does anyway,
759
+ * minus the counting. A subsample retry rewinds to the start of the file, so the fraction can
760
+ * repeat; callers pass it into a monotonic emitter, which absorbs that.
761
+ */
762
+ private fun decodeUpright(
763
+ source: File,
764
+ minSample: Int = 1,
765
+ onBytesRead: ((Float) -> Unit)? = null
766
+ ): Bitmap? {
767
+ var sample = minSample.coerceAtLeast(1)
768
+ val totalBytes = source.length()
769
+ while (sample <= 8) {
770
+ try {
771
+ val options = BitmapFactory.Options().apply {
772
+ inSampleSize = sample
773
+ inPreferredConfig = Bitmap.Config.ARGB_8888
774
+ }
775
+ val decoded = FileInputStream(source).use { stream ->
776
+ val input: InputStream =
777
+ if (onBytesRead != null && totalBytes > 0L) {
778
+ CountingInputStream(stream, totalBytes, onBytesRead)
779
+ } else {
780
+ stream
781
+ }
782
+ BitmapFactory.decodeStream(input, null, options)
783
+ }
784
+ // BitmapFactory returning nothing means the container is one it declines (some HEIF and
785
+ // AVIF variants); ImageDecoder handles those AND applies their orientation itself, which
786
+ // is why applyExifOrientation must not be run on its output.
787
+ return if (decoded != null) applyExifOrientation(source, decoded) else decodeViaImageDecoder(source)
788
+ } catch (e: OutOfMemoryError) {
789
+ Log.w(TAG, "Out of memory decoding ${source.name} at sample $sample; retrying coarser", e)
790
+ sample *= 2
791
+ } catch (e: IOException) {
792
+ // Same verdict BitmapFactory.decodeFile reaches for an unreadable file: no bitmap.
793
+ Log.w(TAG, "Could not read ${source.name} for decoding", e)
794
+ return null
795
+ }
796
+ }
797
+ return null
798
+ }
799
+
800
+ private fun decodeViaImageDecoder(source: File): Bitmap? {
801
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) return null
802
+ return runCatching {
803
+ ImageDecoder.decodeBitmap(ImageDecoder.createSource(source)) { decoder, _, _ ->
804
+ // Software config: the pixels are read back below, which a hardware bitmap forbids.
805
+ decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
806
+ decoder.isMutableRequired = false
807
+ }
808
+ }.getOrNull()
809
+ }
810
+
811
+ /**
812
+ * Rotate/flip [bitmap] according to [source]'s EXIF orientation tag, returning the upright
813
+ * image. Returns the input unchanged when the tag is absent or already normal.
814
+ */
815
+ private fun applyExifOrientation(source: File, bitmap: Bitmap): Bitmap {
816
+ val orientation = runCatching {
817
+ ExifInterface(source.absolutePath)
818
+ .getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
819
+ }.getOrDefault(ExifInterface.ORIENTATION_NORMAL)
820
+
821
+ val matrix = Matrix()
822
+ when (orientation) {
823
+ ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f)
824
+ ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f)
825
+ ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f)
826
+ ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.postScale(-1f, 1f)
827
+ ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.postScale(1f, -1f)
828
+ ExifInterface.ORIENTATION_TRANSPOSE -> {
829
+ matrix.postRotate(90f)
830
+ matrix.postScale(-1f, 1f)
831
+ }
832
+ ExifInterface.ORIENTATION_TRANSVERSE -> {
833
+ matrix.postRotate(270f)
834
+ matrix.postScale(-1f, 1f)
835
+ }
836
+ else -> return bitmap
837
+ }
838
+ return runCatching {
839
+ val rotated = Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
840
+ if (rotated !== bitmap) bitmap.recycle()
841
+ rotated
842
+ }.getOrDefault(bitmap)
843
+ }
844
+
845
+ // ---------------------------------------------------------------------------------------
846
+ // Encode
847
+ // ---------------------------------------------------------------------------------------
848
+
849
+ /** Containers with no alpha channel, whose sources must be flattened before encoding. */
850
+ private fun needsOpaqueSource(format: ImageFormatSupport.Format): Boolean = when (format) {
851
+ ImageFormatSupport.Format.JPEG,
852
+ // The still-image encoder path below is 4:2:0 YUV, which has nowhere to put an alpha channel.
853
+ ImageFormatSupport.Format.HEIF,
854
+ ImageFormatSupport.Format.AVIF -> true
855
+ else -> false
856
+ }
857
+
858
+ private fun flattenOntoWhite(bitmap: Bitmap): Bitmap = runCatching {
859
+ val flat = Bitmap.createBitmap(bitmap.width, bitmap.height, Bitmap.Config.ARGB_8888)
860
+ Canvas(flat).apply {
861
+ drawColor(Color.WHITE)
862
+ drawBitmap(bitmap, 0f, 0f, null)
863
+ }
864
+ flat
865
+ }.getOrDefault(bitmap)
866
+
867
+ /** `WEBP_LOSSY` from API 30, the (deprecated, behaviourally identical) `WEBP` below it. */
868
+ @Suppress("DEPRECATION")
869
+ private fun webpFormat(): Bitmap.CompressFormat =
870
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
871
+ Bitmap.CompressFormat.WEBP_LOSSY
872
+ } else {
873
+ Bitmap.CompressFormat.WEBP
874
+ }
875
+
876
+ /**
877
+ * @param onBytesWritten running total of bytes the encoder has written, called on THIS thread as
878
+ * it writes. `Bitmap.compress` reports nothing about its own progress, but it writes its output
879
+ * as it produces it, so the stream underneath is where a multi-second encode becomes visible.
880
+ */
881
+ private fun compressBitmap(
882
+ bitmap: Bitmap,
883
+ format: Bitmap.CompressFormat,
884
+ quality: Int,
885
+ output: File,
886
+ onBytesWritten: ((Long) -> Unit)? = null
887
+ ): Boolean = runCatching {
888
+ output.parentFile?.mkdirs()
889
+ FileOutputStream(output).use { file ->
890
+ val stream: OutputStream =
891
+ if (onBytesWritten != null) CountingOutputStream(file, onBytesWritten) else file
892
+ bitmap.compress(format, quality.coerceIn(1, 100), stream)
893
+ }
894
+ }.getOrElse {
895
+ Log.e(TAG, "Bitmap.compress to $format failed", it)
896
+ false
897
+ }
898
+
899
+ /** @param onBytesWritten as [compressBitmap]'s — `PdfDocument.writeTo` streams its output too. */
900
+ private fun exportPdf(
901
+ bitmap: Bitmap,
902
+ output: File,
903
+ onBytesWritten: ((Long) -> Unit)? = null
904
+ ): Boolean = runCatching {
905
+ output.parentFile?.mkdirs()
906
+ val document = android.graphics.pdf.PdfDocument()
907
+ val pageInfo = android.graphics.pdf.PdfDocument.PageInfo.Builder(bitmap.width, bitmap.height, 1).create()
908
+ val page = document.startPage(pageInfo)
909
+ page.canvas.drawBitmap(bitmap, 0f, 0f, null)
910
+ document.finishPage(page)
911
+ FileOutputStream(output).use { file ->
912
+ val stream: OutputStream =
913
+ if (onBytesWritten != null) CountingOutputStream(file, onBytesWritten) else file
914
+ document.writeTo(stream)
915
+ stream.flush()
916
+ }
917
+ document.close()
918
+ true
919
+ }.getOrElse {
920
+ Log.e(TAG, "Export to PDF failed", it)
921
+ false
922
+ }
923
+
924
+ /**
925
+ * Encode [bitmap] as a single still through the platform's dedicated image encoder for
926
+ * [mimeType] and write it with the HEIF muxer.
927
+ *
928
+ * This is the only way to produce HEIF/AVIF on Android — `Bitmap.CompressFormat` has no entry
929
+ * for either. The encoder is fed one frame in a flexible YUV 4:2:0 input buffer obtained via
930
+ * [MediaCodec.getInputImage], so the plane strides come from the codec rather than being
931
+ * guessed, and every output buffer it produces is written to the muxer: HEIC encoders may emit
932
+ * an image as several tiles and the muxer assembles them from the grid information carried in
933
+ * the output format.
934
+ *
935
+ * Dimensions are rounded DOWN to even numbers because 4:2:0 chroma subsampling cannot
936
+ * represent a trailing odd row or column. That costs at most one pixel on each axis and keeps
937
+ * the aspect ratio within a pixel; padding instead would add a visible border.
938
+ *
939
+ * @param onProgress how far through THIS encode the pass is, 0.0..1.0, called on this thread.
940
+ * The RGB→YUV conversion of every pixel happens in process ([writeYuv420]) and is the bulk of
941
+ * the wait on a large still, so it is reported row by row rather than as a single step; the
942
+ * codec's own draining is reported per muxed sample, since a HEIC encoder may emit an image as
943
+ * several tiles and there is no way to know in advance how many.
944
+ * @return true only when at least one sample reached the muxer, so a codec that starts and
945
+ * produces nothing is reported as a failure rather than leaving an empty container behind.
946
+ */
947
+ private fun encodeStillViaCodec(
948
+ bitmap: Bitmap,
949
+ mimeType: String,
950
+ quality: Int,
951
+ output: File,
952
+ onProgress: ((Float) -> Unit)? = null
953
+ ): Boolean {
954
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) return false
955
+ val width = bitmap.width - (bitmap.width % 2)
956
+ val height = bitmap.height - (bitmap.height % 2)
957
+ if (width < 2 || height < 2) {
958
+ Log.w(TAG, "Image too small to encode as $mimeType (${bitmap.width}x${bitmap.height})")
959
+ return false
960
+ }
961
+
962
+ var codec: MediaCodec? = null
963
+ var muxer: MediaMuxer? = null
964
+ var muxerStarted = false
965
+ var wroteSample = false
966
+ var muxedSamples = 0
967
+ try {
968
+ val format = MediaFormat.createVideoFormat(mimeType, width, height).apply {
969
+ setInteger(
970
+ MediaFormat.KEY_COLOR_FORMAT,
971
+ MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420Flexible
972
+ )
973
+ // Still-image encoders are driven by quality, not bit rate. A frame rate and a zero
974
+ // key-frame interval are set anyway because some encoders reject a video-shaped format
975
+ // that omits them.
976
+ setInteger(MediaFormat.KEY_QUALITY, quality.coerceIn(1, 100))
977
+ setInteger(MediaFormat.KEY_FRAME_RATE, 1)
978
+ setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 0)
979
+ }
980
+
981
+ output.parentFile?.mkdirs()
982
+ codec = MediaCodec.createEncoderByType(mimeType)
983
+ codec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
984
+ codec.start()
985
+ onProgress?.invoke(PROGRESS_CODEC_STARTED)
986
+
987
+ muxer = MediaMuxer(output.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_HEIF)
988
+ // The pixels are already upright (see decodeUpright), so the container must advertise no
989
+ // rotation or every viewer would rotate the image a second time. Best-effort: not every
990
+ // writer accepts the hint, and refusing it is not a reason to fail an otherwise good file.
991
+ runCatching { muxer.setOrientationHint(0) }
992
+
993
+ val yuvSpan = PROGRESS_CODEC_FRAME_QUEUED - PROGRESS_CODEC_STARTED
994
+ val queued = queueSingleFrame(codec, bitmap, width, height) { rows ->
995
+ onProgress?.invoke(PROGRESS_CODEC_STARTED + yuvSpan * rows)
996
+ }
997
+ if (!queued) return false
998
+ onProgress?.invoke(PROGRESS_CODEC_FRAME_QUEUED)
999
+
1000
+ val info = MediaCodec.BufferInfo()
1001
+ var trackIndex = -1
1002
+ val deadline = System.nanoTime() + ENCODE_TIMEOUT_NS
1003
+ var idlePolls = 0
1004
+ var draining = true
1005
+ while (draining) {
1006
+ if (System.nanoTime() > deadline) {
1007
+ Log.w(TAG, "Timed out encoding $mimeType still")
1008
+ return false
1009
+ }
1010
+ val outIndex = codec.dequeueOutputBuffer(info, DEQUEUE_TIMEOUT_US)
1011
+ if (outIndex == MediaCodec.INFO_TRY_AGAIN_LATER) {
1012
+ // Image encoders do not always raise END_OF_STREAM after their last tile, so a run of
1013
+ // idle polls AFTER real output has been written is treated as "finished". Without it
1014
+ // every successful export would spin until the deadline.
1015
+ idlePolls++
1016
+ if (wroteSample && idlePolls > MAX_IDLE_POLLS_AFTER_OUTPUT) draining = false
1017
+ } else if (outIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
1018
+ if (muxerStarted) throw IllegalStateException("Encoder format changed twice")
1019
+ trackIndex = muxer.addTrack(codec.outputFormat)
1020
+ muxer.start()
1021
+ muxerStarted = true
1022
+ } else if (outIndex >= 0) {
1023
+ idlePolls = 0
1024
+ val encoded = codec.getOutputBuffer(outIndex)
1025
+ if (info.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0) {
1026
+ // Codec config is folded into the track format by the muxer; drop the buffer.
1027
+ info.size = 0
1028
+ }
1029
+ if (info.size > 0 && encoded != null) {
1030
+ if (!muxerStarted) throw IllegalStateException("Muxer not started before data")
1031
+ encoded.position(info.offset)
1032
+ encoded.limit(info.offset + info.size)
1033
+ muxer.writeSampleData(trackIndex, encoded, info)
1034
+ wroteSample = true
1035
+ muxedSamples++
1036
+ // How many tiles this encoder will emit is not knowable in advance, so each one moves
1037
+ // the fraction a fixed step toward — never past — the end of this phase.
1038
+ onProgress?.invoke(
1039
+ minOf(
1040
+ PROGRESS_CODEC_MEASURED_CEILING,
1041
+ PROGRESS_CODEC_FRAME_QUEUED + PROGRESS_CODEC_PER_SAMPLE * muxedSamples
1042
+ )
1043
+ )
1044
+ }
1045
+ codec.releaseOutputBuffer(outIndex, false)
1046
+ if (info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) draining = false
1047
+ }
1048
+ }
1049
+ return wroteSample
1050
+ } catch (e: Exception) {
1051
+ Log.e(TAG, "Still encode to $mimeType failed", e)
1052
+ return false
1053
+ } finally {
1054
+ try { codec?.stop() } catch (_: Exception) {}
1055
+ try { codec?.release() } catch (_: Exception) {}
1056
+ // stop() on a muxer that was never started throws; release() alone is the correct teardown.
1057
+ if (muxerStarted) {
1058
+ try { muxer?.stop() } catch (_: Exception) {}
1059
+ }
1060
+ try { muxer?.release() } catch (_: Exception) {}
1061
+ }
1062
+ }
1063
+
1064
+ /**
1065
+ * Hand the encoder its one and only frame, flagged as the end of the stream.
1066
+ *
1067
+ * @param onRowsWritten how much of the frame has been converted into the codec's buffer,
1068
+ * 0.0..1.0, called on this thread as the rows are written.
1069
+ */
1070
+ private fun queueSingleFrame(
1071
+ codec: MediaCodec,
1072
+ bitmap: Bitmap,
1073
+ width: Int,
1074
+ height: Int,
1075
+ onRowsWritten: ((Float) -> Unit)? = null
1076
+ ): Boolean {
1077
+ val deadline = System.nanoTime() + ENCODE_TIMEOUT_NS
1078
+ while (System.nanoTime() < deadline) {
1079
+ val index = codec.dequeueInputBuffer(DEQUEUE_TIMEOUT_US)
1080
+ if (index < 0) continue
1081
+ val image = codec.getInputImage(index)
1082
+ if (image == null) {
1083
+ Log.w(TAG, "Encoder did not expose a flexible YUV input image")
1084
+ return false
1085
+ }
1086
+ writeYuv420(image, bitmap, width, height, onRowsWritten)
1087
+ codec.queueInputBuffer(
1088
+ index,
1089
+ 0,
1090
+ width * height * 3 / 2,
1091
+ 0L,
1092
+ MediaCodec.BUFFER_FLAG_END_OF_STREAM
1093
+ )
1094
+ return true
1095
+ }
1096
+ Log.w(TAG, "Timed out waiting for an encoder input buffer")
1097
+ return false
1098
+ }
1099
+
1100
+ /**
1101
+ * Convert [bitmap] to YUV 4:2:0 straight into the codec's [image] planes.
1102
+ *
1103
+ * The plane layout is READ from the codec (`rowStride` / `pixelStride`) rather than assumed, so
1104
+ * this writes correctly into both planar (I420) and semi-planar (NV12/NV21) buffers — the two
1105
+ * layouts hide behind `COLOR_FormatYUV420Flexible` and differ per device.
1106
+ *
1107
+ * Rows are converted two at a time so one 2x2 chroma block completes per iteration, which keeps
1108
+ * peak memory at two rows of pixels rather than a second copy of the whole image. The
1109
+ * coefficients are the standard limited-range BT.601 set (luma 16..235, chroma centred on 128),
1110
+ * which is what video and image encoders expect by default; full-range values here would come
1111
+ * back visibly washed out.
1112
+ *
1113
+ * @param onRowsWritten how much of the frame has been converted, 0.0..1.0, called once per row
1114
+ * pair. This loop touches every pixel in process and is the single most expensive step of a
1115
+ * HEIF/AVIF export, so it is where that wait has to become visible; the callback is throttled
1116
+ * by the emitter behind it, not here.
1117
+ */
1118
+ private fun writeYuv420(
1119
+ image: Image,
1120
+ bitmap: Bitmap,
1121
+ width: Int,
1122
+ height: Int,
1123
+ onRowsWritten: ((Float) -> Unit)? = null
1124
+ ) {
1125
+ val w = minOf(width, image.width)
1126
+ val h = minOf(height, image.height)
1127
+
1128
+ val yPlane = image.planes[0]
1129
+ val uPlane = image.planes[1]
1130
+ val vPlane = image.planes[2]
1131
+ val yBuffer = yPlane.buffer
1132
+ val uBuffer = uPlane.buffer
1133
+ val vBuffer = vPlane.buffer
1134
+ // Absolute puts ignore the buffer position, so a plane that starts partway into a shared
1135
+ // allocation needs its origin added explicitly.
1136
+ val yBase = yBuffer.position()
1137
+ val uBase = uBuffer.position()
1138
+ val vBase = vBuffer.position()
1139
+ val yRowStride = yPlane.rowStride
1140
+ val yPixelStride = yPlane.pixelStride
1141
+ val uRowStride = uPlane.rowStride
1142
+ val uPixelStride = uPlane.pixelStride
1143
+ val vRowStride = vPlane.rowStride
1144
+ val vPixelStride = vPlane.pixelStride
1145
+
1146
+ val top = IntArray(w)
1147
+ val bottom = IntArray(w)
1148
+
1149
+ var y = 0
1150
+ while (y + 1 < h) {
1151
+ bitmap.getPixels(top, 0, w, 0, y, w, 1)
1152
+ bitmap.getPixels(bottom, 0, w, 0, y + 1, w, 1)
1153
+
1154
+ var x = 0
1155
+ while (x + 1 < w) {
1156
+ var sumR = 0
1157
+ var sumG = 0
1158
+ var sumB = 0
1159
+ for (dy in 0..1) {
1160
+ val row = if (dy == 0) top else bottom
1161
+ for (dx in 0..1) {
1162
+ val pixel = row[x + dx]
1163
+ val r = (pixel shr 16) and 0xFF
1164
+ val g = (pixel shr 8) and 0xFF
1165
+ val b = pixel and 0xFF
1166
+ sumR += r
1167
+ sumG += g
1168
+ sumB += b
1169
+ val luma = ((66 * r + 129 * g + 25 * b + 128) shr 8) + 16
1170
+ yBuffer.put(
1171
+ yBase + (y + dy) * yRowStride + (x + dx) * yPixelStride,
1172
+ luma.coerceIn(16, 235).toByte()
1173
+ )
1174
+ }
1175
+ }
1176
+ val avgR = sumR / 4
1177
+ val avgG = sumG / 4
1178
+ val avgB = sumB / 4
1179
+ val cb = ((-38 * avgR - 74 * avgG + 112 * avgB + 128) shr 8) + 128
1180
+ val cr = ((112 * avgR - 94 * avgG - 18 * avgB + 128) shr 8) + 128
1181
+ val chromaRow = y / 2
1182
+ val chromaCol = x / 2
1183
+ uBuffer.put(
1184
+ uBase + chromaRow * uRowStride + chromaCol * uPixelStride,
1185
+ cb.coerceIn(16, 240).toByte()
1186
+ )
1187
+ vBuffer.put(
1188
+ vBase + chromaRow * vRowStride + chromaCol * vPixelStride,
1189
+ cr.coerceIn(16, 240).toByte()
1190
+ )
1191
+ x += 2
1192
+ }
1193
+ y += 2
1194
+ if (onRowsWritten != null && h > 0) onRowsWritten((y.toFloat() / h).coerceIn(0f, 1f))
1195
+ }
1196
+ }
1197
+
1198
+ // ---------------------------------------------------------------------------------------
1199
+ // Metadata
1200
+ // ---------------------------------------------------------------------------------------
1201
+
1202
+ /**
1203
+ * The EXIF tags carried from the original to the export: when and where the photo was taken,
1204
+ * what took it, and how. Deliberately a curated list rather than a blind copy — thumbnail and
1205
+ * layout tags describe the ORIGINAL bytes and would be wrong on a re-encoded file.
1206
+ */
1207
+ private val CARRIED_EXIF_TAGS = arrayOf(
1208
+ ExifInterface.TAG_DATETIME,
1209
+ ExifInterface.TAG_DATETIME_ORIGINAL,
1210
+ ExifInterface.TAG_DATETIME_DIGITIZED,
1211
+ ExifInterface.TAG_SUBSEC_TIME,
1212
+ ExifInterface.TAG_MAKE,
1213
+ ExifInterface.TAG_MODEL,
1214
+ ExifInterface.TAG_F_NUMBER,
1215
+ ExifInterface.TAG_EXPOSURE_TIME,
1216
+ ExifInterface.TAG_FOCAL_LENGTH,
1217
+ ExifInterface.TAG_WHITE_BALANCE,
1218
+ ExifInterface.TAG_FLASH,
1219
+ ExifInterface.TAG_GPS_LATITUDE,
1220
+ ExifInterface.TAG_GPS_LATITUDE_REF,
1221
+ ExifInterface.TAG_GPS_LONGITUDE,
1222
+ ExifInterface.TAG_GPS_LONGITUDE_REF,
1223
+ ExifInterface.TAG_GPS_ALTITUDE,
1224
+ ExifInterface.TAG_GPS_ALTITUDE_REF,
1225
+ ExifInterface.TAG_GPS_TIMESTAMP,
1226
+ ExifInterface.TAG_GPS_DATESTAMP
1227
+ )
1228
+
1229
+ /**
1230
+ * Copy the carried EXIF tags from [source] onto [output].
1231
+ *
1232
+ * Only attempted for containers the platform's [ExifInterface] can WRITE: JPEG on every
1233
+ * supported level, PNG and WebP from API 30. HEIF/AVIF are skipped — their orientation is
1234
+ * already handled by the muxer's zero rotation hint and there is no supported write path for
1235
+ * their metadata here. Best-effort throughout: an export is not failed because a tag would not
1236
+ * stick.
1237
+ */
1238
+ private fun copyMetadata(source: File, output: File, format: ImageFormatSupport.Format) {
1239
+ val writable = when (format) {
1240
+ ImageFormatSupport.Format.JPEG -> true
1241
+ ImageFormatSupport.Format.PNG,
1242
+ ImageFormatSupport.Format.WEBP -> Build.VERSION.SDK_INT >= Build.VERSION_CODES.R
1243
+ else -> false
1244
+ }
1245
+ if (!writable) return
1246
+ runCatching {
1247
+ val from = ExifInterface(source.absolutePath)
1248
+ val to = ExifInterface(output.absolutePath)
1249
+ for (tag in CARRIED_EXIF_TAGS) {
1250
+ from.getAttribute(tag)?.let { to.setAttribute(tag, it) }
1251
+ }
1252
+ // The rotation is baked into the exported pixels, so the tag must say "upright". Leaving
1253
+ // the source's value would rotate the already-rotated image a second time.
1254
+ to.setAttribute(
1255
+ ExifInterface.TAG_ORIENTATION,
1256
+ ExifInterface.ORIENTATION_NORMAL.toString()
1257
+ )
1258
+ to.saveAttributes()
1259
+ }.onFailure {
1260
+ Log.w(TAG, "Could not carry EXIF onto ${output.name}", it)
1261
+ }
1262
+ }
1263
+
1264
+ // ---------------------------------------------------------------------------------------
1265
+ // Progress plumbing
1266
+ // ---------------------------------------------------------------------------------------
1267
+
1268
+ /**
1269
+ * Guards the progress contract [export] and [compress] publish: values are monotonic, clamped to
1270
+ * 0..1, and throttled so a per-row or per-buffer callback cannot flood the caller's thread.
1271
+ *
1272
+ * The throttle is deliberately the same pair the video transcode uses
1273
+ * ([PROGRESS_MIN_INTERVAL_MS] / [PROGRESS_MIN_DELTA]), so a still and a clip drive the same
1274
+ * countdown at the same cadence.
1275
+ *
1276
+ * Confined to the worker thread the encode runs on; it is not synchronised.
1277
+ */
1278
+ private class ProgressEmitter(private val sink: ((Float) -> Unit)?) {
1279
+ private var last = -1f
1280
+ private var lastAtMs = 0L
1281
+
1282
+ /**
1283
+ * A measured value from inside a phase. Dropped when it would move the fraction backwards (a
1284
+ * subsample retry re-reads the file from the start) or when it is too soon and too small a
1285
+ * step to be worth a callback.
1286
+ */
1287
+ fun advance(fraction: Float) {
1288
+ val out = sink ?: return
1289
+ val clamped = fraction.coerceIn(0f, 1f)
1290
+ if (clamped <= last) return
1291
+ val now = SystemClock.elapsedRealtime()
1292
+ if (clamped - last < PROGRESS_MIN_DELTA && now - lastAtMs < PROGRESS_MIN_INTERVAL_MS) return
1293
+ last = clamped
1294
+ lastAtMs = now
1295
+ out(clamped)
1296
+ }
1297
+
1298
+ /**
1299
+ * A phase boundary: reported immediately, whatever the throttle would have said. These are the
1300
+ * values a caller's overlay reacts to (the tail hand-over, the terminal 1.0), so they must not
1301
+ * be able to fall inside a throttle window and be lost.
1302
+ */
1303
+ fun report(fraction: Float) {
1304
+ val out = sink ?: return
1305
+ val clamped = fraction.coerceIn(0f, 1f)
1306
+ if (clamped < last) return
1307
+ last = clamped
1308
+ lastAtMs = SystemClock.elapsedRealtime()
1309
+ out(clamped)
1310
+ }
1311
+ }
1312
+
1313
+ /**
1314
+ * Reports how much of a [total]-byte source has been read, as [delegate] is consumed.
1315
+ *
1316
+ * This is how a `BitmapFactory` decode — a single blocking call with no progress of its own —
1317
+ * becomes measurable. `BitmapFactory.decodeFile` is exactly `decodeStream` over a
1318
+ * `FileInputStream`, so wrapping that stream changes nothing about the decode itself. Mark and
1319
+ * reset are deliberately NOT supported (the default), which is also true of the plain
1320
+ * `FileInputStream` the decoder would otherwise be handed.
1321
+ */
1322
+ private class CountingInputStream(
1323
+ private val delegate: InputStream,
1324
+ private val total: Long,
1325
+ private val onRead: (Float) -> Unit
1326
+ ) : InputStream() {
1327
+ private var read = 0L
1328
+
1329
+ override fun read(): Int {
1330
+ val value = delegate.read()
1331
+ if (value >= 0) advance(1L)
1332
+ return value
1333
+ }
1334
+
1335
+ override fun read(b: ByteArray, off: Int, len: Int): Int {
1336
+ val count = delegate.read(b, off, len)
1337
+ if (count > 0) advance(count.toLong())
1338
+ return count
1339
+ }
1340
+
1341
+ override fun skip(n: Long): Long {
1342
+ val skipped = delegate.skip(n)
1343
+ if (skipped > 0L) advance(skipped)
1344
+ return skipped
1345
+ }
1346
+
1347
+ override fun available(): Int = delegate.available()
1348
+
1349
+ override fun close() = delegate.close()
1350
+
1351
+ private fun advance(count: Long) {
1352
+ read += count
1353
+ if (total > 0L) onRead((read.toDouble() / total.toDouble()).toFloat().coerceIn(0f, 1f))
1354
+ }
1355
+ }
1356
+
1357
+ /**
1358
+ * Reports the running total of bytes written through [delegate].
1359
+ *
1360
+ * The encoder counterpart of [CountingInputStream]: `Bitmap.compress` and `PdfDocument.writeTo`
1361
+ * both stream their output as they produce it, so the bytes arriving here are the only signal
1362
+ * either one gives about how far along it is.
1363
+ */
1364
+ private class CountingOutputStream(
1365
+ private val delegate: OutputStream,
1366
+ private val onWritten: (Long) -> Unit
1367
+ ) : OutputStream() {
1368
+ private var written = 0L
1369
+
1370
+ override fun write(b: Int) {
1371
+ delegate.write(b)
1372
+ advance(1L)
1373
+ }
1374
+
1375
+ override fun write(b: ByteArray) {
1376
+ delegate.write(b)
1377
+ advance(b.size.toLong())
1378
+ }
1379
+
1380
+ override fun write(b: ByteArray, off: Int, len: Int) {
1381
+ delegate.write(b, off, len)
1382
+ advance(len.toLong())
1383
+ }
1384
+
1385
+ override fun flush() = delegate.flush()
1386
+
1387
+ override fun close() = delegate.close()
1388
+
1389
+ private fun advance(count: Long) {
1390
+ written += count
1391
+ onWritten(written)
1392
+ }
1393
+ }
1394
+ }