Squeezer: Add HEIC/HEIF image compression#2423
Draft
d4rken wants to merge 7 commits into
Draft
Conversation
Adds HEIC/HEIF as a third image format in Squeezer alongside JPEG/WebP. Encoding goes through androidx.heifwriter.HeifWriter, gated to API 28+. Encoder layer is refactored into ImageEncoder + ImageEncoderFactory with BitmapCompressEncoder (JPEG/WebP) and HeifWriterEncoder (HEIC/HEIF). The scanner skips multi-image HEIF files via MediaMetadataRetriever.METADATA_KEY_IMAGE_COUNT so Apple Live Photos and Samsung Motion Photos are protected from silent data loss. The toggle defaults off and is hidden on pre-P devices via CompressibleImage.isHeicEncodingSupported(). androidx.exifinterface does not support writing EXIF to HEIF (write remains JPEG/PNG/WebP-only as of 1.4.2). ImageCompressor skips the applyExif step for HEIC; the toggle description states EXIF, HDR gain maps, depth, and Live/Motion siblings cannot be preserved. Filesystem mtime is still preserved by FileTransaction. Also fixes a pre-existing bug where Bitmap.compress's boolean return value was ignored. Verified on Pixel 8 (API 36): a 1.8 MB single-image HEIC compressed to 1.6 MB at q=80, output is a valid HEIF, mtime preserved.
Adds HeifExifBlockBuilder that produces the JPEG-APP1-form EXIF block expected by HeifWriter.addExifData(). Implementation: read source EXIF via ExifInterface, write a 1x1 temp JPEG, round-trip the whitelisted tags through ExifInterface (which can write JPEG), then extract the APP1 segment bytes. ExifInterface 1.4.2 still refuses to write EXIF directly to HEIF files (write support remains JPEG/PNG/WebP-only). HeifWriter.addExifData is the supported AOSP path for embedding EXIF at HEIF encode time; this commit wires it up. ImageEncoder.encode() now takes an optional exifData ByteArray parameter. JPEG/WebP encoder ignores it (post-encode ExifPreserver path is unchanged). HEIC encoder passes it to writer.addExifData(0, bytes, 0, bytes.size) between start() and addBitmap(). Preserved tags: Make, Model, Orientation, Software, Artist, Copyright, ImageDescription, DateTime / DateTimeOriginal / DateTimeDigitized + Offset/Subsec variants, FNumber, ApertureValue, ExposureTime, ExposureBias/Mode/Program, ISO (legacy + PhotographicSensitivity), FocalLength, FocalLengthIn35mmFilm, Flash, MeteringMode, WhiteBalance, SceneCaptureType, full GPS sub-IFD (lat/lon/alt/timestamp/datestamp/direction/speed). Verified on Pixel 8: a HEIC with DateTime / DateTimeOriginal / Make / Model survives the round-trip; file size shrinks by ~12.5% at q=80; file mtime preserved by FileTransaction. Multi-image skip and pre-P toggle hiding still in place. Settings description updated: 'Date, location, and other EXIF metadata are kept. HDR gain maps, depth data, and Live/Motion Photo siblings cannot be preserved; multi-image files are skipped.'
… parser androidx.exifinterface 1.4.2 cannot reliably read EXIF from real-world HEIF files. Verified on-device with a libheif-encoded Pixel HEIC: ExifInterface.getAttribute returned null for Make, Model, DateTime, and GPS. The previous HeifExifBlockBuilder appeared to work only because the synthetic pillow-heif test sample happened to include a non-standard 'Exif ' preamble inside its HEIF Exif item that ExifInterface's parser accidentally recognized. Replace HeifExifBlockBuilder with HeifExifExtractor, which parses the ISOBMFF box tree directly (ftyp -> meta -> iinf to find the Exif item ID, -> iloc to locate its bytes, -> read raw bytes from mdat). Skip the 4-byte exif_tiff_header_offset, ensure the output starts with 'Exif ' as HeifWriter.addExifData expects. Supports infe v2/v3, iloc v0/v1/v2 with variable-size offset/length/base_offset/index fields. Handles largesize (size==1) and 'extends to EOF' (size==0) box headers. About 200 LOC, no NDK. Verified on Pixel 8 with a libheif-encoded HEIC carrying real Pixel 8 camera metadata: Make, Model, DateTime, DateTimeOriginal, ISO, FNumber, GPS lat/lon/altitude all preserved bit-for-bit, file shrinks 3.69 MB -> 911 kB at q=80. Test fixture is a 720-byte libheif HEIC bundled in src/test/resources/. Deletes HeifExifBlockBuilder and its temp-JPEG-roundtrip approach (which only worked on synthetic pillow-heif samples).
…iption The toggle is hidden on pre-P devices via CompressibleImage.isHeicEncodingSupported, so users who can read the description are already on a supported device. The note added no actionable information.
Two safety fixes for HEIC handling: 1) MediaScanner.hasMultipleHeifImages() now fails closed. When MediaMetadataRetriever can't open the file or METADATA_KEY_IMAGE_COUNT is unreadable, the file is treated as multi-image and excluded from results. The previous fail-open behavior risked silently destroying auxiliary images (Live Photo siblings, depth maps, HDR gain maps) if the retriever happened to error. Cost of a false-positive skip is a missed compression; cost of a false-negative is user data loss. 2) HeifExifExtractor now reads construction_method (lower 4 bits of the 2-byte field after item_id in iloc v1/v2) and concatenates all extents. Method 0 (file_offset) and method 1 (idat_offset, reading the optional idat box) are supported; method 2 (item_offset) is explicitly logged and skipped rather than misread. Previously the parser ignored construction_method entirely, treating baseOffset + first extent_offset as an absolute file offset — which would have produced wrong bytes for HEIFs using idat or item construction, and lost data for items split across multiple extents. Added an 8 MB sanity cap on total Exif item size to bound allocation against malformed iloc entries.
…ctor Previously HeifExifExtractor returned ByteArray? and ImageCompressor treated null uniformly: HEIC files with no EXIF and HEIC files whose EXIF couldn't be parsed both ended up compressed without metadata. The first case is correct (faithful round-trip); the second is silent data loss of date/location/camera tags. Replace the return type with a sealed Result: NoExif (legitimate empty), Extracted(bytes), Unsupported(reason). Unsupported covers: malformed ISOBMFF, missing required boxes, construction_method=2, idat construction without an idat box, out-of-bounds extents, total length over the 8 MB sanity cap, TIFF header not recognized. ImageCompressor for HEIC now: NoExif -> compress without EXIF (correct), Extracted -> embed via HeifWriter.addExifData, Unsupported -> throw IOException so FileTransaction aborts the replace and the source file is left intact. A user who would otherwise lose metadata silently now sees the file in the failed-count bucket and the original is untouched. Tests updated to assert against the sealed type. Garbage/missing files now produce Unsupported rather than null.
…querade as NoExif
findItemIdByType previously returned Int? — null meant either 'iinf has no matching item' (legitimate) or 'iinf/infe parse failed midway' (should be Unsupported). The parent parse() mapped null uniformly to Result.NoExif, which silently downgraded malformed input to the metadata-free compression path.
Replace the return type with sealed ItemLookup { NotPresent, Found(itemId), ParseError(reason) }. readInfeItemId (renamed readInfeItem) now also distinguishes 'not the type we want' (NotPresent) from 'unsupported infe version' (ParseError) — the latter could conceal the Exif item we're after, so failing closed is the right move. ParseError propagates from findItemIdByType to parse() which maps it to Result.Unsupported, and ImageCompressor aborts.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What changed
Squeezer can now compress HEIC/HEIF photos — the format Samsung and Apple cameras use by default. Until now, those files were skipped entirely; only JPEG and WebP were squeezed.
Date, location, and other camera metadata (Make, Model, ISO, exposure, GPS, etc.) survive the compression. Live Photo / Motion Photo siblings and other multi-image HEIC files are detected and skipped so nothing gets silently destroyed. The toggle is off by default and only appears on Android 9+ (where the HEIC encoder exists).
Technical Context
ImageEncoderinterface withBitmapCompressEncoder(JPEG/WebP — also fixes a pre-existing fail-silent bug wherebitmap.compress()'sfalsereturn value was ignored) andHeifWriterEncoderviaandroidx.heifwriter:1.1.0. Factory routes by mime.tools:overrideLibrary="androidx.heifwriter"(project minSdk stays at 26). Runtime is gated byCompressibleImage.isHeicEncodingSupported(); the settings toggle and the scanner gate both key off it so the feature can't half-activate on pre-P devices.androidx.exifinterface:1.4.2cannot reliably read real-world HEIF EXIF (verified on a libheif-encoded Pixel HEIC —Make/DateTime/latLongall returnednull). Replaced the read path with a direct ISOBMFF parser (HeifExifExtractor) that walksftyp → meta → iinf → iloc, finds the Exif item, and feeds its bytes toHeifWriter.addExifData()as JPEG-APP1-form. Bypasses ExifInterface for HEIF entirely. ~270 LOC of pure-Kotlin parser, handlesinfev2/v3 andilocv0/v1/v2 with variable-size offset/length/base_offset/index fields.MediaScanner.processImageCandidate()queriesMediaMetadataRetriever.METADATA_KEY_IMAGE_COUNTfor HEIC files. If the count is> 1(Live Photo siblings, Motion Photo, multi-image stack), the file is excluded from scan results — never destroyed.Bitmapcan't carry. Documented in the toggle description.ComparisonDialog.newInstance(... isWebp: Boolean ...)widened tomimeType: String. HEIC samples short-circuit the inlineBitmap.compresspreview since that path can't produce HEIF bytes.MimeTypeToolfallback for.heic/.heifwhenMimeTypeMap.getSingleton()returns null on older devices.On-device verification (Pixel 8 / Android 16): a libheif-encoded HEIC carrying real Pixel 8 camera metadata round-trips Make/Model/DateTime/DateTimeOriginal/ISO/FNumber and the full GPS sub-IFD (10 entries including lat/lon/altitude/timestamp/direction) bit-for-bit; file shrinks 3.69 MB → 911 kB at q=80. Multi-image HEIC sample is correctly excluded at scan time.