Android 15 quietly changed the rules for native code. Since November 2025, apps targeting API 35 on
Google Play must support 16 KB memory pages, and every bundled .so has to be 16 KB-aligned —
including the ones inside your third-party libraries. An unaligned native lib doesn't warn; it
crashes on 16 KB-page devices.
That sent me looking at how my apps compress images. The most popular option, Luban 2, is
excellent — but it ships libjpeg-turbo, a native encoder. The original Luban and its fork
AdvancedLuban were pure-JVM… and abandoned.
So I revived that lineage as PixelDiet: a pure-JVM, zero-native Android image compressor.
What "pure-JVM" buys you
-
No
.so→ no 16 KB page-size risk. Nothing to align, no NDK, no per-ABI builds. - Tiny footprint. The release AAR is ~44 KB.
-
Honest guarantee. A CI step unzips the AAR and fails the build if it ever contains a
.so. The claim is enforced, not promised.
The trade-off, stated plainly: you give up libjpeg-turbo's raw encode speed. In exchange you get a
dependency that can't break on a platform page-size change and won't bloat your APK.
What I kept, and what I modernized
The valuable part of Luban is its WeChat-Moments-style "gear" sizing strategy — I ported that
math faithfully (and unit-tested it). Around it, everything is new:
-
Kotlin coroutines core:
suspend get(),getFirst(), andasFlow()for progress. -
Java-friendly
OnCompressListenerpath backed byDispatchers.IO— no RxJava. -
Scoped-storage safe inputs:
File,content://Uri,InputStream,Bitmap. - androidx ExifInterface orientation handling read from streams.
- WebP lossy/lossless output (smaller than JPEG), plus JPEG/PNG.
val out = PixelDiet.with(context)
.load(uri)
.format(OutputFormat.WEBP_LOSSY)
.getFirst()
The feature Luban 2 removed
Luban 2 dropped setMaxSize. If your code relied on "compress to under N KB," upgrading silently
breaks it. PixelDiet brings it back as hardCap(kb) — and makes it a real guarantee with a
two-phase quality-loop + resize-fallback, so the output is actually ≤ your target on any gear:
PixelDiet.with(context).load(uri).hardCap(200).getFirst() // ≤ 200 KB, guaranteed
Does it actually compress?
From the sample app: an 863 KB photo → 78 KB (−91%) in ~223 ms (Custom gear → PNG). With Smart
gear + WebP + a 300 KB cap: 863 KB → 250.8 KB (−71%).
The part nobody writes about: licensing
PixelDiet is a fork, and I'm explicit about that. Luban and AdvancedLuban are Apache-2.0, which
permits forking, renaming, and redistribution — but it has conditions: keep the original copyright
headers, ship LICENSE + a NOTICE stating your changes, and don't imply endorsement. The
compression strategy is Curzibn's and shaohui's work; my contribution is the modern API, the
scoped-storage/EXIF fixes, the native-free guarantee, and ongoing maintenance. Presenting that
honestly is both the legal requirement and the more credible story.
Try it
JitPack:
implementation("com.github.basheerpaliyathu.PixelDiet:pixeldiet:0.1.0")
Code, sample app, and the full comparison table: https://github.com/basheerpaliyathu/PixelDiet
If you maintain an Android app targeting API 35, it's worth auditing your dependencies for bundled
.so files this week. You might be one transitive dependency away from a 16 KB crash.
Top comments (0)