react-native-ensys-camera-x 1.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CustomCamera.podspec +20 -0
- package/LICENSE +20 -0
- package/README.md +833 -0
- package/android/build.gradle +135 -0
- package/android/consumer-rules.pro +47 -0
- package/android/src/main/AndroidManifest.xml +72 -0
- package/android/src/main/java/android/print/PdfPrint.java +51 -0
- package/android/src/main/java/com/customcamera/AnimatedImageViewManager.kt +199 -0
- package/android/src/main/java/com/customcamera/AudioPlayerView.kt +322 -0
- package/android/src/main/java/com/customcamera/AudioRecorder.kt +506 -0
- package/android/src/main/java/com/customcamera/BarcodeScannerActivity.kt +361 -0
- package/android/src/main/java/com/customcamera/BlurBackgroundView.kt +199 -0
- package/android/src/main/java/com/customcamera/CacheCleanupManager.kt +386 -0
- package/android/src/main/java/com/customcamera/CameraAspectRatioMode.kt +77 -0
- package/android/src/main/java/com/customcamera/CameraAsset.kt +211 -0
- package/android/src/main/java/com/customcamera/CameraTheme.kt +479 -0
- package/android/src/main/java/com/customcamera/CompressionCacheStore.kt +504 -0
- package/android/src/main/java/com/customcamera/CompressionFileNameUtils.kt +7 -0
- package/android/src/main/java/com/customcamera/CompressionProfile.kt +195 -0
- package/android/src/main/java/com/customcamera/CustomCameraActivity.kt +7291 -0
- package/android/src/main/java/com/customcamera/CustomCameraFileProvider.kt +9 -0
- package/android/src/main/java/com/customcamera/CustomCameraModule.kt +2479 -0
- package/android/src/main/java/com/customcamera/CustomCameraPackage.kt +53 -0
- package/android/src/main/java/com/customcamera/DisplayUtils.kt +9 -0
- package/android/src/main/java/com/customcamera/DocumentPreviewView.kt +846 -0
- package/android/src/main/java/com/customcamera/DrawingView.kt +214 -0
- package/android/src/main/java/com/customcamera/EmojiPickerData.kt +228 -0
- package/android/src/main/java/com/customcamera/FilterStripAdapter.kt +158 -0
- package/android/src/main/java/com/customcamera/GifSupport.kt +215 -0
- package/android/src/main/java/com/customcamera/GlobalAudioPlayer.kt +320 -0
- package/android/src/main/java/com/customcamera/ImageExporter.kt +1394 -0
- package/android/src/main/java/com/customcamera/ImageFormatSupport.kt +163 -0
- package/android/src/main/java/com/customcamera/Localization.kt +206 -0
- package/android/src/main/java/com/customcamera/MediaAdapters.kt +758 -0
- package/android/src/main/java/com/customcamera/MediaCacheManager.kt +782 -0
- package/android/src/main/java/com/customcamera/MediaExportFormats.kt +201 -0
- package/android/src/main/java/com/customcamera/MediaFileIo.kt +310 -0
- package/android/src/main/java/com/customcamera/MediaMetadataStore.kt +494 -0
- package/android/src/main/java/com/customcamera/MediaPreviewEditor.kt +14865 -0
- package/android/src/main/java/com/customcamera/MediaPublisher.kt +423 -0
- package/android/src/main/java/com/customcamera/MediaStorageWorkflow.kt +722 -0
- package/android/src/main/java/com/customcamera/MediaStoreRepository.kt +378 -0
- package/android/src/main/java/com/customcamera/MediaThumbnailView.kt +286 -0
- package/android/src/main/java/com/customcamera/MediaUtils.kt +37 -0
- package/android/src/main/java/com/customcamera/NativeVideoViewManager.kt +845 -0
- package/android/src/main/java/com/customcamera/OfficeDocumentConverter.kt +2791 -0
- package/android/src/main/java/com/customcamera/PendingCaptureCrops.kt +88 -0
- package/android/src/main/java/com/customcamera/PowerPointPreview.kt +4077 -0
- package/android/src/main/java/com/customcamera/RecentPickerStore.kt +74 -0
- package/android/src/main/java/com/customcamera/SecureFileEraser.kt +411 -0
- package/android/src/main/java/com/customcamera/SecureKeyModule.kt +88 -0
- package/android/src/main/java/com/customcamera/SecureMediaDeleter.kt +493 -0
- package/android/src/main/java/com/customcamera/StickerPickerData.kt +107 -0
- package/android/src/main/java/com/customcamera/SvgDrawable.kt +175 -0
- package/android/src/main/java/com/customcamera/SvgIconViewManager.kt +77 -0
- package/android/src/main/java/com/customcamera/TextEntryController.kt +553 -0
- package/android/src/main/java/com/customcamera/ThumbnailLoader.kt +455 -0
- package/android/src/main/java/com/customcamera/UploadService.kt +909 -0
- package/android/src/main/java/com/customcamera/VideoEditUtils.kt +210 -0
- package/android/src/main/java/com/customcamera/VideoRotationTranscoder.kt +1992 -0
- package/android/src/main/java/com/customcamera/gif/AnimatedGifEncoder.kt +473 -0
- package/android/src/main/java/com/customcamera/gif/GifDecoder.kt +690 -0
- package/android/src/main/java/com/customcamera/gif/GifFramesDrawable.kt +160 -0
- package/android/src/main/java/com/customcamera/gif/GifHttp.kt +112 -0
- package/android/src/main/java/com/customcamera/gif/GifImageLoader.kt +186 -0
- package/android/src/main/java/com/customcamera/gif/GifModels.kt +74 -0
- package/android/src/main/java/com/customcamera/gif/GifProvider.kt +49 -0
- package/android/src/main/java/com/customcamera/gif/GifProviders.kt +27 -0
- package/android/src/main/java/com/customcamera/gif/GifRepository.kt +124 -0
- package/android/src/main/java/com/customcamera/gif/GifSafety.kt +33 -0
- package/android/src/main/java/com/customcamera/gif/GiphyGifProvider.kt +146 -0
- package/android/src/main/java/com/customcamera/gif/LZWEncoder.kt +258 -0
- package/android/src/main/java/com/customcamera/gif/NeuQuant.kt +435 -0
- package/android/src/main/java/com/customcamera/gif/TenorGifProvider.kt +147 -0
- package/android/src/main/res/drawable/bg_brush_selected.xml +5 -0
- package/android/src/main/res/drawable/bg_color_swatch.xml +5 -0
- package/android/src/main/res/drawable/bg_editor_toolbar.xml +6 -0
- package/android/src/main/res/drawable/bg_icon_circle.xml +5 -0
- package/android/src/main/res/drawable/bg_icon_circle_light.xml +5 -0
- package/android/src/main/res/drawable/bg_mode_tab_active.xml +6 -0
- package/android/src/main/res/drawable/bg_pill.xml +6 -0
- package/android/src/main/res/drawable/bg_recording_dot.xml +6 -0
- package/android/src/main/res/drawable/bg_selection_badge.xml +8 -0
- package/android/src/main/res/drawable/bg_send_button.xml +5 -0
- package/android/src/main/res/drawable/bg_shutter_inner.xml +5 -0
- package/android/src/main/res/drawable/bg_shutter_recording.xml +6 -0
- package/android/src/main/res/drawable/bg_shutter_ring.xml +8 -0
- package/android/src/main/res/drawable/bg_shutter_video_idle.xml +5 -0
- package/android/src/main/res/drawable/bg_thumbnail.xml +9 -0
- package/android/src/main/res/drawable/ic_aspect_ratio.xml +17 -0
- package/android/src/main/res/drawable/ic_audio.xml +10 -0
- package/android/src/main/res/drawable/ic_back.xml +10 -0
- package/android/src/main/res/drawable/ic_brush_medium.xml +12 -0
- package/android/src/main/res/drawable/ic_brush_thick.xml +12 -0
- package/android/src/main/res/drawable/ic_brush_thin.xml +12 -0
- package/android/src/main/res/drawable/ic_check.xml +17 -0
- package/android/src/main/res/drawable/ic_chevron_down.xml +10 -0
- package/android/src/main/res/drawable/ic_chevron_up.xml +13 -0
- package/android/src/main/res/drawable/ic_clear_drawing.xml +10 -0
- package/android/src/main/res/drawable/ic_close.xml +10 -0
- package/android/src/main/res/drawable/ic_cover.xml +9 -0
- package/android/src/main/res/drawable/ic_crop.xml +10 -0
- package/android/src/main/res/drawable/ic_delete.xml +9 -0
- package/android/src/main/res/drawable/ic_document.xml +10 -0
- package/android/src/main/res/drawable/ic_done.xml +10 -0
- package/android/src/main/res/drawable/ic_download.xml +10 -0
- package/android/src/main/res/drawable/ic_emoji.xml +10 -0
- package/android/src/main/res/drawable/ic_filter.xml +10 -0
- package/android/src/main/res/drawable/ic_flash_auto.xml +7 -0
- package/android/src/main/res/drawable/ic_flash_off.xml +23 -0
- package/android/src/main/res/drawable/ic_flash_on.xml +16 -0
- package/android/src/main/res/drawable/ic_flip.xml +15 -0
- package/android/src/main/res/drawable/ic_gallery.xml +10 -0
- package/android/src/main/res/drawable/ic_gif.xml +24 -0
- package/android/src/main/res/drawable/ic_lock.xml +19 -0
- package/android/src/main/res/drawable/ic_lock_open.xml +22 -0
- package/android/src/main/res/drawable/ic_marker.xml +10 -0
- package/android/src/main/res/drawable/ic_more_vert.xml +18 -0
- package/android/src/main/res/drawable/ic_overlay_close.xml +19 -0
- package/android/src/main/res/drawable/ic_overlay_resize.xml +18 -0
- package/android/src/main/res/drawable/ic_pause.xml +10 -0
- package/android/src/main/res/drawable/ic_photo_mode.xml +10 -0
- package/android/src/main/res/drawable/ic_play.xml +10 -0
- package/android/src/main/res/drawable/ic_redo.xml +10 -0
- package/android/src/main/res/drawable/ic_rotate.xml +9 -0
- package/android/src/main/res/drawable/ic_send.xml +10 -0
- package/android/src/main/res/drawable/ic_share.xml +10 -0
- package/android/src/main/res/drawable/ic_stat_upload.xml +10 -0
- package/android/src/main/res/drawable/ic_sticker.xml +24 -0
- package/android/src/main/res/drawable/ic_text.xml +10 -0
- package/android/src/main/res/drawable/ic_text_align_center.xml +11 -0
- package/android/src/main/res/drawable/ic_text_align_left.xml +11 -0
- package/android/src/main/res/drawable/ic_text_align_right.xml +11 -0
- package/android/src/main/res/drawable/ic_text_bg.xml +11 -0
- package/android/src/main/res/drawable/ic_trim.xml +23 -0
- package/android/src/main/res/drawable/ic_undo.xml +10 -0
- package/android/src/main/res/drawable/ic_video_mode.xml +10 -0
- package/android/src/main/res/drawable/ic_video_note.xml +13 -0
- package/android/src/main/res/drawable/ic_volume_off.xml +9 -0
- package/android/src/main/res/drawable/ic_volume_on.xml +9 -0
- package/android/src/main/res/drawable/sticker_heart.xml +9 -0
- package/android/src/main/res/drawable/sticker_smile.xml +9 -0
- package/android/src/main/res/drawable/sticker_star.xml +9 -0
- package/android/src/main/res/drawable/sticker_thumbsup.xml +9 -0
- package/android/src/main/res/values/colors.xml +34 -0
- package/android/src/main/res/values/ids.xml +15 -0
- package/android/src/main/res/values/strings.xml +15 -0
- package/android/src/main/res/values/styles.xml +20 -0
- package/android/src/main/res/xml/file_paths.xml +34 -0
- package/ios/CustomCamera.h +5 -0
- package/ios/CustomCamera.mm +214 -0
- package/lib/module/AudioPlayerContext.js +233 -0
- package/lib/module/AudioPlayerContext.js.map +1 -0
- package/lib/module/CameraPreview.js +304 -0
- package/lib/module/CameraPreview.js.map +1 -0
- package/lib/module/NativeAnimatedImageView.js +41 -0
- package/lib/module/NativeAnimatedImageView.js.map +1 -0
- package/lib/module/NativeAudioPlayerView.js +33 -0
- package/lib/module/NativeAudioPlayerView.js.map +1 -0
- package/lib/module/NativeBlurView.js +35 -0
- package/lib/module/NativeBlurView.js.map +1 -0
- package/lib/module/NativeCustomCamera.js +128 -0
- package/lib/module/NativeCustomCamera.js.map +1 -0
- package/lib/module/NativeCustomCamera.web.js +143 -0
- package/lib/module/NativeCustomCamera.web.js.map +1 -0
- package/lib/module/NativeCustomVideoView.js +5 -0
- package/lib/module/NativeCustomVideoView.js.map +1 -0
- package/lib/module/NativeDocumentPreviewView.js +156 -0
- package/lib/module/NativeDocumentPreviewView.js.map +1 -0
- package/lib/module/NativeMediaThumbnailView.js +110 -0
- package/lib/module/NativeMediaThumbnailView.js.map +1 -0
- package/lib/module/NativeSvgIcon.js +32 -0
- package/lib/module/NativeSvgIcon.js.map +1 -0
- package/lib/module/ResponsivePreviewContainer.js +171 -0
- package/lib/module/ResponsivePreviewContainer.js.map +1 -0
- package/lib/module/accessibility.js +61 -0
- package/lib/module/accessibility.js.map +1 -0
- package/lib/module/config.js +352 -0
- package/lib/module/config.js.map +1 -0
- package/lib/module/cpuOptimization.js +296 -0
- package/lib/module/cpuOptimization.js.map +1 -0
- package/lib/module/db/DatabaseManager.js +191 -0
- package/lib/module/db/DatabaseManager.js.map +1 -0
- package/lib/module/db/MediaMapper.js +284 -0
- package/lib/module/db/MediaMapper.js.map +1 -0
- package/lib/module/db/MediaRepository.js +242 -0
- package/lib/module/db/MediaRepository.js.map +1 -0
- package/lib/module/db/index.js +41 -0
- package/lib/module/db/index.js.map +1 -0
- package/lib/module/db/migrations.js +180 -0
- package/lib/module/db/migrations.js.map +1 -0
- package/lib/module/db/react-native-sqlcipher.d.js +2 -0
- package/lib/module/db/react-native-sqlcipher.d.js.map +1 -0
- package/lib/module/db/secureKey.js +87 -0
- package/lib/module/db/secureKey.js.map +1 -0
- package/lib/module/db/types.js +57 -0
- package/lib/module/db/types.js.map +1 -0
- package/lib/module/db/useMediaDatabase.js +123 -0
- package/lib/module/db/useMediaDatabase.js.map +1 -0
- package/lib/module/defaultIcons.js +2259 -0
- package/lib/module/defaultIcons.js.map +1 -0
- package/lib/module/deviceCapabilities.js +408 -0
- package/lib/module/deviceCapabilities.js.map +1 -0
- package/lib/module/errors.js +267 -0
- package/lib/module/errors.js.map +1 -0
- package/lib/module/iconConfig.js +796 -0
- package/lib/module/iconConfig.js.map +1 -0
- package/lib/module/icons.js +514 -0
- package/lib/module/icons.js.map +1 -0
- package/lib/module/index.js +251 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/jest-globals.d.js +2 -0
- package/lib/module/jest-globals.d.js.map +1 -0
- package/lib/module/library/MediaLibrary.js +107 -0
- package/lib/module/library/MediaLibrary.js.map +1 -0
- package/lib/module/library/MediaLibraryScreen.js +2597 -0
- package/lib/module/library/MediaLibraryScreen.js.map +1 -0
- package/lib/module/library/components/AudioPreview.js +74 -0
- package/lib/module/library/components/AudioPreview.js.map +1 -0
- package/lib/module/library/components/DocumentPreview.js +331 -0
- package/lib/module/library/components/DocumentPreview.js.map +1 -0
- package/lib/module/library/components/ExpandableFab.js +343 -0
- package/lib/module/library/components/ExpandableFab.js.map +1 -0
- package/lib/module/library/components/FileTiles.js +251 -0
- package/lib/module/library/components/FileTiles.js.map +1 -0
- package/lib/module/library/components/ImagePreview.js +70 -0
- package/lib/module/library/components/ImagePreview.js.map +1 -0
- package/lib/module/library/components/MediaMetadataHeader.js +175 -0
- package/lib/module/library/components/MediaMetadataHeader.js.map +1 -0
- package/lib/module/library/components/RecordAudioScreen.js +816 -0
- package/lib/module/library/components/RecordAudioScreen.js.map +1 -0
- package/lib/module/library/components/VideoPreview.js +453 -0
- package/lib/module/library/components/VideoPreview.js.map +1 -0
- package/lib/module/library/components/ViewerCounter.js +55 -0
- package/lib/module/library/components/ViewerCounter.js.map +1 -0
- package/lib/module/library/compressionEstimate.js +507 -0
- package/lib/module/library/compressionEstimate.js.map +1 -0
- package/lib/module/library/fileMetadata.js +196 -0
- package/lib/module/library/fileMetadata.js.map +1 -0
- package/lib/module/library/index.js +42 -0
- package/lib/module/library/index.js.map +1 -0
- package/lib/module/library/libraryIconRegistry.js +69 -0
- package/lib/module/library/libraryIconRegistry.js.map +1 -0
- package/lib/module/library/libraryIcons.js +858 -0
- package/lib/module/library/libraryIcons.js.map +1 -0
- package/lib/module/library/localizedFormat.js +181 -0
- package/lib/module/library/localizedFormat.js.map +1 -0
- package/lib/module/library/palette.js +225 -0
- package/lib/module/library/palette.js.map +1 -0
- package/lib/module/library/responsive.js +53 -0
- package/lib/module/library/responsive.js.map +1 -0
- package/lib/module/locales.js +416 -0
- package/lib/module/locales.js.map +1 -0
- package/lib/module/localization.js +1064 -0
- package/lib/module/localization.js.map +1 -0
- package/lib/module/logger.js +20 -0
- package/lib/module/logger.js.map +1 -0
- package/lib/module/lowMemoryCache.js +206 -0
- package/lib/module/lowMemoryCache.js.map +1 -0
- package/lib/module/mediaHistory.js +33 -0
- package/lib/module/mediaHistory.js.map +1 -0
- package/lib/module/mediaStorageService.js +402 -0
- package/lib/module/mediaStorageService.js.map +1 -0
- package/lib/module/memory.js +79 -0
- package/lib/module/memory.js.map +1 -0
- package/lib/module/nativeActions.js +307 -0
- package/lib/module/nativeActions.js.map +1 -0
- package/lib/module/nativeComponentSupport.js +61 -0
- package/lib/module/nativeComponentSupport.js.map +1 -0
- package/lib/module/networkPerformance.js +24 -0
- package/lib/module/networkPerformance.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/performance.js +104 -0
- package/lib/module/performance.js.map +1 -0
- package/lib/module/performanceMonitor.js +155 -0
- package/lib/module/performanceMonitor.js.map +1 -0
- package/lib/module/permissions.js +370 -0
- package/lib/module/permissions.js.map +1 -0
- package/lib/module/rendering.js +40 -0
- package/lib/module/rendering.js.map +1 -0
- package/lib/module/security.js +18 -0
- package/lib/module/security.js.map +1 -0
- package/lib/module/state.js +266 -0
- package/lib/module/state.js.map +1 -0
- package/lib/module/storage.js +400 -0
- package/lib/module/storage.js.map +1 -0
- package/lib/module/theme.js +390 -0
- package/lib/module/theme.js.map +1 -0
- package/lib/module/types.js +4 -0
- package/lib/module/types.js.map +1 -0
- package/lib/module/utils.js +115 -0
- package/lib/module/utils.js.map +1 -0
- package/lib/module/validation.js +288 -0
- package/lib/module/validation.js.map +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/src/AudioPlayerContext.d.ts +27 -0
- package/lib/typescript/src/AudioPlayerContext.d.ts.map +1 -0
- package/lib/typescript/src/CameraPreview.d.ts +13 -0
- package/lib/typescript/src/CameraPreview.d.ts.map +1 -0
- package/lib/typescript/src/NativeAnimatedImageView.d.ts +33 -0
- package/lib/typescript/src/NativeAnimatedImageView.d.ts.map +1 -0
- package/lib/typescript/src/NativeAudioPlayerView.d.ts +12 -0
- package/lib/typescript/src/NativeAudioPlayerView.d.ts.map +1 -0
- package/lib/typescript/src/NativeBlurView.d.ts +12 -0
- package/lib/typescript/src/NativeBlurView.d.ts.map +1 -0
- package/lib/typescript/src/NativeCustomCamera.d.ts +167 -0
- package/lib/typescript/src/NativeCustomCamera.d.ts.map +1 -0
- package/lib/typescript/src/NativeCustomCamera.web.d.ts +47 -0
- package/lib/typescript/src/NativeCustomCamera.web.d.ts.map +1 -0
- package/lib/typescript/src/NativeCustomVideoView.d.ts +30 -0
- package/lib/typescript/src/NativeCustomVideoView.d.ts.map +1 -0
- package/lib/typescript/src/NativeDocumentPreviewView.d.ts +48 -0
- package/lib/typescript/src/NativeDocumentPreviewView.d.ts.map +1 -0
- package/lib/typescript/src/NativeMediaThumbnailView.d.ts +54 -0
- package/lib/typescript/src/NativeMediaThumbnailView.d.ts.map +1 -0
- package/lib/typescript/src/NativeSvgIcon.d.ts +25 -0
- package/lib/typescript/src/NativeSvgIcon.d.ts.map +1 -0
- package/lib/typescript/src/ResponsivePreviewContainer.d.ts +92 -0
- package/lib/typescript/src/ResponsivePreviewContainer.d.ts.map +1 -0
- package/lib/typescript/src/accessibility.d.ts +20 -0
- package/lib/typescript/src/accessibility.d.ts.map +1 -0
- package/lib/typescript/src/config.d.ts +145 -0
- package/lib/typescript/src/config.d.ts.map +1 -0
- package/lib/typescript/src/cpuOptimization.d.ts +63 -0
- package/lib/typescript/src/cpuOptimization.d.ts.map +1 -0
- package/lib/typescript/src/db/DatabaseManager.d.ts +48 -0
- package/lib/typescript/src/db/DatabaseManager.d.ts.map +1 -0
- package/lib/typescript/src/db/MediaMapper.d.ts +77 -0
- package/lib/typescript/src/db/MediaMapper.d.ts.map +1 -0
- package/lib/typescript/src/db/MediaRepository.d.ts +66 -0
- package/lib/typescript/src/db/MediaRepository.d.ts.map +1 -0
- package/lib/typescript/src/db/index.d.ts +23 -0
- package/lib/typescript/src/db/index.d.ts.map +1 -0
- package/lib/typescript/src/db/migrations.d.ts +31 -0
- package/lib/typescript/src/db/migrations.d.ts.map +1 -0
- package/lib/typescript/src/db/secureKey.d.ts +10 -0
- package/lib/typescript/src/db/secureKey.d.ts.map +1 -0
- package/lib/typescript/src/db/types.d.ts +115 -0
- package/lib/typescript/src/db/types.d.ts.map +1 -0
- package/lib/typescript/src/db/useMediaDatabase.d.ts +41 -0
- package/lib/typescript/src/db/useMediaDatabase.d.ts.map +1 -0
- package/lib/typescript/src/defaultIcons.d.ts +139 -0
- package/lib/typescript/src/defaultIcons.d.ts.map +1 -0
- package/lib/typescript/src/deviceCapabilities.d.ts +92 -0
- package/lib/typescript/src/deviceCapabilities.d.ts.map +1 -0
- package/lib/typescript/src/errors.d.ts +95 -0
- package/lib/typescript/src/errors.d.ts.map +1 -0
- package/lib/typescript/src/iconConfig.d.ts +176 -0
- package/lib/typescript/src/iconConfig.d.ts.map +1 -0
- package/lib/typescript/src/icons.d.ts +217 -0
- package/lib/typescript/src/icons.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +104 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/lib/typescript/src/library/MediaLibrary.d.ts +57 -0
- package/lib/typescript/src/library/MediaLibrary.d.ts.map +1 -0
- package/lib/typescript/src/library/MediaLibraryScreen.d.ts +51 -0
- package/lib/typescript/src/library/MediaLibraryScreen.d.ts.map +1 -0
- package/lib/typescript/src/library/components/AudioPreview.d.ts +6 -0
- package/lib/typescript/src/library/components/AudioPreview.d.ts.map +1 -0
- package/lib/typescript/src/library/components/DocumentPreview.d.ts +10 -0
- package/lib/typescript/src/library/components/DocumentPreview.d.ts.map +1 -0
- package/lib/typescript/src/library/components/ExpandableFab.d.ts +39 -0
- package/lib/typescript/src/library/components/ExpandableFab.d.ts.map +1 -0
- package/lib/typescript/src/library/components/FileTiles.d.ts +8 -0
- package/lib/typescript/src/library/components/FileTiles.d.ts.map +1 -0
- package/lib/typescript/src/library/components/ImagePreview.d.ts +5 -0
- package/lib/typescript/src/library/components/ImagePreview.d.ts.map +1 -0
- package/lib/typescript/src/library/components/MediaMetadataHeader.d.ts +16 -0
- package/lib/typescript/src/library/components/MediaMetadataHeader.d.ts.map +1 -0
- package/lib/typescript/src/library/components/RecordAudioScreen.d.ts +8 -0
- package/lib/typescript/src/library/components/RecordAudioScreen.d.ts.map +1 -0
- package/lib/typescript/src/library/components/VideoPreview.d.ts +20 -0
- package/lib/typescript/src/library/components/VideoPreview.d.ts.map +1 -0
- package/lib/typescript/src/library/components/ViewerCounter.d.ts +13 -0
- package/lib/typescript/src/library/components/ViewerCounter.d.ts.map +1 -0
- package/lib/typescript/src/library/compressionEstimate.d.ts +313 -0
- package/lib/typescript/src/library/compressionEstimate.d.ts.map +1 -0
- package/lib/typescript/src/library/fileMetadata.d.ts +33 -0
- package/lib/typescript/src/library/fileMetadata.d.ts.map +1 -0
- package/lib/typescript/src/library/index.d.ts +40 -0
- package/lib/typescript/src/library/index.d.ts.map +1 -0
- package/lib/typescript/src/library/libraryIconRegistry.d.ts +35 -0
- package/lib/typescript/src/library/libraryIconRegistry.d.ts.map +1 -0
- package/lib/typescript/src/library/libraryIcons.d.ts +85 -0
- package/lib/typescript/src/library/libraryIcons.d.ts.map +1 -0
- package/lib/typescript/src/library/localizedFormat.d.ts +49 -0
- package/lib/typescript/src/library/localizedFormat.d.ts.map +1 -0
- package/lib/typescript/src/library/palette.d.ts +104 -0
- package/lib/typescript/src/library/palette.d.ts.map +1 -0
- package/lib/typescript/src/library/responsive.d.ts +15 -0
- package/lib/typescript/src/library/responsive.d.ts.map +1 -0
- package/lib/typescript/src/locales.d.ts +103 -0
- package/lib/typescript/src/locales.d.ts.map +1 -0
- package/lib/typescript/src/localization.d.ts +1051 -0
- package/lib/typescript/src/localization.d.ts.map +1 -0
- package/lib/typescript/src/logger.d.ts +4 -0
- package/lib/typescript/src/logger.d.ts.map +1 -0
- package/lib/typescript/src/lowMemoryCache.d.ts +64 -0
- package/lib/typescript/src/lowMemoryCache.d.ts.map +1 -0
- package/lib/typescript/src/mediaHistory.d.ts +3 -0
- package/lib/typescript/src/mediaHistory.d.ts.map +1 -0
- package/lib/typescript/src/mediaStorageService.d.ts +155 -0
- package/lib/typescript/src/mediaStorageService.d.ts.map +1 -0
- package/lib/typescript/src/memory.d.ts +21 -0
- package/lib/typescript/src/memory.d.ts.map +1 -0
- package/lib/typescript/src/nativeActions.d.ts +174 -0
- package/lib/typescript/src/nativeActions.d.ts.map +1 -0
- package/lib/typescript/src/nativeComponentSupport.d.ts +30 -0
- package/lib/typescript/src/nativeComponentSupport.d.ts.map +1 -0
- package/lib/typescript/src/networkPerformance.d.ts +12 -0
- package/lib/typescript/src/networkPerformance.d.ts.map +1 -0
- package/lib/typescript/src/performance.d.ts +32 -0
- package/lib/typescript/src/performance.d.ts.map +1 -0
- package/lib/typescript/src/performanceMonitor.d.ts +45 -0
- package/lib/typescript/src/performanceMonitor.d.ts.map +1 -0
- package/lib/typescript/src/permissions.d.ts +132 -0
- package/lib/typescript/src/permissions.d.ts.map +1 -0
- package/lib/typescript/src/rendering.d.ts +9 -0
- package/lib/typescript/src/rendering.d.ts.map +1 -0
- package/lib/typescript/src/security.d.ts +6 -0
- package/lib/typescript/src/security.d.ts.map +1 -0
- package/lib/typescript/src/state.d.ts +102 -0
- package/lib/typescript/src/state.d.ts.map +1 -0
- package/lib/typescript/src/storage.d.ts +117 -0
- package/lib/typescript/src/storage.d.ts.map +1 -0
- package/lib/typescript/src/theme.d.ts +15 -0
- package/lib/typescript/src/theme.d.ts.map +1 -0
- package/lib/typescript/src/types.d.ts +802 -0
- package/lib/typescript/src/types.d.ts.map +1 -0
- package/lib/typescript/src/utils.d.ts +50 -0
- package/lib/typescript/src/utils.d.ts.map +1 -0
- package/lib/typescript/src/validation.d.ts +52 -0
- package/lib/typescript/src/validation.d.ts.map +1 -0
- package/package.json +204 -0
- package/react-native.config.js +17 -0
- package/src/AudioPlayerContext.tsx +220 -0
- package/src/CameraPreview.tsx +352 -0
- package/src/NativeAnimatedImageView.tsx +71 -0
- package/src/NativeAudioPlayerView.tsx +39 -0
- package/src/NativeBlurView.tsx +40 -0
- package/src/NativeCustomCamera.ts +305 -0
- package/src/NativeCustomCamera.web.ts +192 -0
- package/src/NativeCustomVideoView.ts +30 -0
- package/src/NativeDocumentPreviewView.tsx +196 -0
- package/src/NativeMediaThumbnailView.tsx +167 -0
- package/src/NativeSvgIcon.tsx +46 -0
- package/src/ResponsivePreviewContainer.tsx +227 -0
- package/src/accessibility.ts +80 -0
- package/src/config.ts +406 -0
- package/src/cpuOptimization.ts +365 -0
- package/src/db/DatabaseManager.ts +212 -0
- package/src/db/MediaMapper.ts +377 -0
- package/src/db/MediaRepository.ts +313 -0
- package/src/db/index.ts +73 -0
- package/src/db/migrations.ts +222 -0
- package/src/db/react-native-sqlcipher.d.ts +76 -0
- package/src/db/secureKey.ts +100 -0
- package/src/db/types.ts +123 -0
- package/src/db/useMediaDatabase.ts +191 -0
- package/src/defaultIcons.tsx +2390 -0
- package/src/deviceCapabilities.ts +438 -0
- package/src/errors.ts +350 -0
- package/src/iconConfig.tsx +962 -0
- package/src/icons.tsx +788 -0
- package/src/index.tsx +763 -0
- package/src/jest-globals.d.ts +9 -0
- package/src/library/MediaLibrary.tsx +141 -0
- package/src/library/MediaLibraryScreen.tsx +3003 -0
- package/src/library/components/AudioPreview.tsx +73 -0
- package/src/library/components/DocumentPreview.tsx +361 -0
- package/src/library/components/ExpandableFab.tsx +342 -0
- package/src/library/components/FileTiles.tsx +252 -0
- package/src/library/components/ImagePreview.tsx +72 -0
- package/src/library/components/MediaMetadataHeader.tsx +208 -0
- package/src/library/components/RecordAudioScreen.tsx +845 -0
- package/src/library/components/VideoPreview.tsx +478 -0
- package/src/library/components/ViewerCounter.tsx +71 -0
- package/src/library/compressionEstimate.ts +662 -0
- package/src/library/fileMetadata.ts +212 -0
- package/src/library/index.ts +66 -0
- package/src/library/libraryIconRegistry.tsx +86 -0
- package/src/library/libraryIcons.tsx +928 -0
- package/src/library/localizedFormat.ts +207 -0
- package/src/library/palette.ts +281 -0
- package/src/library/responsive.ts +49 -0
- package/src/locales.ts +381 -0
- package/src/localization.ts +1947 -0
- package/src/logger.ts +20 -0
- package/src/lowMemoryCache.ts +218 -0
- package/src/mediaHistory.ts +42 -0
- package/src/mediaStorageService.ts +546 -0
- package/src/memory.ts +102 -0
- package/src/nativeActions.ts +422 -0
- package/src/nativeComponentSupport.ts +60 -0
- package/src/networkPerformance.ts +30 -0
- package/src/performance.ts +137 -0
- package/src/performanceMonitor.ts +219 -0
- package/src/permissions.ts +403 -0
- package/src/rendering.ts +47 -0
- package/src/security.ts +19 -0
- package/src/state.ts +333 -0
- package/src/storage.ts +453 -0
- package/src/theme.tsx +467 -0
- package/src/types.ts +946 -0
- package/src/utils.ts +177 -0
- package/src/validation.ts +372 -0
|
@@ -0,0 +1,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
|
+
}
|