Skip to content

Squeezer: Add HEIC/HEIF image compression#2423

Draft
d4rken wants to merge 7 commits into
mainfrom
feat/squeezer-heic
Draft

Squeezer: Add HEIC/HEIF image compression#2423
d4rken wants to merge 7 commits into
mainfrom
feat/squeezer-heic

Conversation

@d4rken
Copy link
Copy Markdown
Member

@d4rken d4rken commented May 11, 2026

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

  • New ImageEncoder interface with BitmapCompressEncoder (JPEG/WebP — also fixes a pre-existing fail-silent bug where bitmap.compress()'s false return value was ignored) and HeifWriterEncoder via androidx.heifwriter:1.1.0. Factory routes by mime.
  • API 28+ floor. The library declares minSdk 28 so the app manifest carries a tools:overrideLibrary="androidx.heifwriter" (project minSdk stays at 26). Runtime is gated by CompressibleImage.isHeicEncodingSupported(); the settings toggle and the scanner gate both key off it so the feature can't half-activate on pre-P devices.
  • EXIF preservation: androidx.exifinterface:1.4.2 cannot reliably read real-world HEIF EXIF (verified on a libheif-encoded Pixel HEIC — Make/DateTime/latLong all returned null). Replaced the read path with a direct ISOBMFF parser (HeifExifExtractor) that walks ftyp → meta → iinf → iloc, finds the Exif item, and feeds its bytes to HeifWriter.addExifData() as JPEG-APP1-form. Bypasses ExifInterface for HEIF entirely. ~270 LOC of pure-Kotlin parser, handles infe v2/v3 and iloc v0/v1/v2 with variable-size offset/length/base_offset/index fields.
  • Multi-image safeguard: MediaScanner.processImageCandidate() queries MediaMetadataRetriever.METADATA_KEY_IMAGE_COUNT for HEIC files. If the count is > 1 (Live Photo siblings, Motion Photo, multi-image stack), the file is excluded from scan results — never destroyed.
  • Not preserved: HDR gain maps, depth/portrait maps, ICC profiles, XMP. These are separate HEIF auxiliary items that a decoded Bitmap can't carry. Documented in the toggle description.
  • ComparisonDialog.newInstance(... isWebp: Boolean ...) widened to mimeType: String. HEIC samples short-circuit the inline Bitmap.compress preview since that path can't produce HEIF bytes.
  • MimeTypeTool fallback for .heic/.heif when MimeTypeMap.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.

d4rken added 3 commits May 11, 2026 13:05
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).
@d4rken d4rken added enhancement New feature, request, improvement or optimization c: Media Squeeze File/Media compression tool labels May 11, 2026
d4rken added 4 commits May 11, 2026 16:07
…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.
@d4rken d4rken marked this pull request as draft May 12, 2026 09:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

c: Media Squeeze File/Media compression tool enhancement New feature, request, improvement or optimization

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant