A robust, crash-safe Android image compression library built in Kotlin β designed as a modern, more resilient alternative to Zelory Compressor.
(Left to Right: Original vs ImageSqueeze vs Zelory)
| Feature | Zelory | ImageSqueeze |
|---|---|---|
| Coroutine-based async | β | β |
| DSL configuration | β | β |
| Resolution constraint | β | β |
| Quality constraint | β | β |
| File size constraint | β | β |
| Output format (JPEG/WEBP/PNG) | β | β |
Typed error handling (sealed class) |
β | β |
| Error type enum for programmatic action | β | β |
| OOM-safe bitmap decoding | Partial | β |
FileNotFoundException guard |
β | β |
FileAlreadyExistsException guard |
β | β |
ENOSPC (disk full) guard |
β | β |
| Corrupt / unsupported image guard | β | β |
| EXIF rotation handling | β | β |
| Safe working-file cache pattern | β | β |
| Synchronous API | β | β |
Kotlin File extension functions |
β | β |
ImageSqueeze handles all five known crash scenarios from Zelory:
FileNotFoundExceptionβ source file missing, deleted, or inaccessible (Scoped Storage)FileAlreadyExistsExceptionβ destination file cannot be overwritten/deletedENOSPC(No space left on device) β device storage fullOutOfMemoryErrorβ image too large for available heap memoryBitmapFactory.decodeFilereturns null β corrupt or unsupported image format
Instead of crashing, every error is returned as a typed SqueezeResult.Error that you can handle however you like (show a Toast, log analytics, retry, etc.).
ImageSqueeze is officially published on Maven Central. You do not need to add any custom repositories. Simply add the dependency to your app module:
Kotlin DSL (build.gradle.kts)
dependencies {
implementation("io.github.kevinmf1:imagesqueeze:1.1.0")
}Groovy (build.gradle)
dependencies {
implementation 'io.github.kevinmf1:imagesqueeze:1.1.0'
}Alternatively, you can get it via JitPack.
Step 1. Add the JitPack repository:
Kotlin DSL (settings.gradle.kts)
dependencyResolutionManagement {
repositories {
// ...
maven { url = uri("https://jitpack.io") }
}
}Groovy (build.gradle / settings.gradle)
// In settings.gradle (AGP 7+)
dependencyResolutionManagement {
repositories {
// ...
maven { url 'https://jitpack.io' }
}
}
// Or in root build.gradle (older projects)
allprojects {
repositories {
// ...
maven { url 'https://jitpack.io' }
}
}Step 2. Add the dependency to your module:
Kotlin DSL (build.gradle.kts)
dependencies {
implementation("com.github.kevinmf1:ImageSqueeze:v1.1.0")
}Groovy (build.gradle)
dependencies {
implementation 'com.github.kevinmf1:ImageSqueeze:v1.1.0'
}Replace
1.1.0with the latest release tag.
If you prefer to include it as a local module:
Kotlin DSL
// settings.gradle.kts
include(":imagesqueeze")
// app/build.gradle.kts
dependencies {
implementation(project(":imagesqueeze"))
}Groovy
// settings.gradle
include ':imagesqueeze'
// app/build.gradle
dependencies {
implementation project(':imagesqueeze')
}import vinz.android.imagesqueeze.ImageSqueeze
import vinz.android.imagesqueeze.SqueezeResult
lifecycleScope.launch {
val result = ImageSqueeze.compress(context, sourceFile)
when (result) {
is SqueezeResult.Success -> {
val compressedFile = result.file
// Upload, display, or save the compressed file
}
is SqueezeResult.Error -> {
Log.e("Compress", result.message)
// Show user-friendly message, log analytics, etc.
}
}
}val result = ImageSqueeze.compress(context, sourceFile) {
resolution(1280, 720) // Max width x height
quality(85) // Starting JPEG quality (1β100)
size(500_000L) // Target max file size in bytes (500 KB)
format(Bitmap.CompressFormat.WEBP) // Output format
}For an even more concise API, use the Kotlin extension functions:
import vinz.android.imagesqueeze.extensions.squeeze
val result = sourceFile.squeeze(context) {
resolution(1024, 1024)
quality(80)
size(1_000_000L) // 1 MB
}By default, the compressed file is saved to the app's cache directory and runs on Dispatchers.IO. You can easily override the destination file and the Coroutines Dispatcher:
import kotlinx.coroutines.Dispatchers
val destination = File(getExternalFilesDir(null), "compressed_photo.jpg")
val result = ImageSqueeze.compress(
context = context,
source = sourceFile,
destination = destination,
dispatcher = Dispatchers.Default // Run on CPU-bound thread instead of IO
) {
quality(75)
}Yes, ImageSqueeze is fully compatible with Jetpack Compose natively because it is written cleanly in Kotlin Coroutines without any view-layer dependencies. Since Jetpack UI recompositions must remain non-blocking, you can easily use it alongside rememberCoroutineScope:
@Composable
fun CompressImageScreen(originalFile: File) {
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
var compressedResult by remember { mutableStateOf<SqueezeResult?>(null) }
Button(onClick = {
coroutineScope.launch {
// Compress off the main thread smoothly
compressedResult = originalFile.squeeze(context) {
resolution(1024, 1024)
quality(80)
}
}
}) {
Text("Compress Now")
}
// Handle states
when (val res = compressedResult) {
is SqueezeResult.Success -> Text("Saved ${res.file.length()} bytes!")
is SqueezeResult.Error -> Text("Error: ${res.message}")
null -> Text("Waiting to compress...")
}
}If you need to compress on a background thread you manage yourself:
// On your own background thread
val result = ImageSqueeze.compressSync(context, sourceFile) {
quality(80)
}All configuration is done through the CompressionConfig DSL block:
| Method | Type | Default | Description |
|---|---|---|---|
resolution(w, h) |
Int, Int |
612 Γ 816 |
Maximum output width and height |
quality(q) |
Int |
80 |
Starting JPEG/WEBP encode quality (1β100) |
size(bytes) |
Long |
1,000,000 (1 MB) |
Maximum output file size in bytes |
format(fmt) |
Bitmap.CompressFormat |
JPEG |
Output format: JPEG, WEBP, PNG |
isForDisplay |
Boolean |
false |
If true, produces a 100Γ100 thumbnail |
minQuality |
Int |
10 |
Minimum quality floor during iterative size reduction |
val result = ImageSqueeze.compress(context, sourceFile) {
resolution(1920, 1080)
quality(90)
size(2_000_000L) // 2 MB
format(Bitmap.CompressFormat.WEBP)
minQuality = 20 // Don't go below quality 20
}ImageSqueeze never throws unhandled exceptions. Every failure is captured and returned as a SqueezeResult.Error with three useful properties:
sealed class SqueezeResult {
data class Success(val file: File) : SqueezeResult()
data class Error(
val errorType: SqueezeError, // Typed enum for programmatic branching
val exception: Throwable?, // Original exception for logging/debugging
val message: String // Human-readable message
) : SqueezeResult()
}SqueezeError |
When It Occurs |
|---|---|
FILE_NOT_FOUND |
Source file does not exist or was deleted |
NOT_READABLE |
Source file exists but cannot be read (permissions) |
FILE_EMPTY |
Source file is 0 bytes |
NO_DISK_SPACE |
Less than 10 MB of free storage available |
DECODE_FAILED |
BitmapFactory returned null (corrupt/unsupported format) |
OUT_OF_MEMORY |
Image too large to decode into memory |
COPY_FAILED |
Could not write the result to the destination |
UNKNOWN |
Any other unexpected error |
when (result) {
is SqueezeResult.Success -> {
uploadToServer(result.file)
}
is SqueezeResult.Error -> {
when (result.errorType) {
SqueezeError.FILE_NOT_FOUND -> showRetakePhotoDialog()
SqueezeError.NO_DISK_SPACE -> showClearStoragePrompt()
SqueezeError.OUT_OF_MEMORY -> showLowerResolutionHint()
SqueezeError.DECODE_FAILED -> showUnsupportedFormatMessage()
else -> {
logToAnalytics(result.exception)
showGenericError(result.message)
}
}
}
}ImageSqueeze is split into clean, focused modules for readability and maintainability:
imagesqueeze/
βββ ImageSqueeze.kt # Public API entry point
βββ CompressionConfig.kt # DSL configuration class
βββ SqueezeResult.kt # Sealed result wrapper (Success / Error)
βββ SqueezeError.kt # Error type enum
βββ core/
β βββ CompressorCore.kt # Internal compression engine
βββ utils/
β βββ FileUtil.kt # File I/O, disk space checks, safe copy/delete
β βββ ImageUtil.kt # Bitmap decoding, EXIF rotation, safe recycle
βββ extensions/
βββ FileExt.kt # Kotlin File extension functions
ImageSqueeze follows a battle-tested pipeline inspired by Zelory's approach, with added safety at every step:
- Validate β Check source file exists, is readable, and is non-empty
- Disk space guard β Verify at least 10 MB of free storage
- Copy to cache β Work on a safe copy to prevent source corruption
- Decode with
inSampleSizeβ Downsample using power-of-2 scaling to prevent OOM - EXIF rotation β Read orientation tag and rotate bitmap accordingly
- Iterative quality reduction β Encode at starting quality, then step down by 10 until the file size constraint is met or
minQualityis reached - Safe write β Atomic write with temp file fallback if destination cannot be deleted
- Cleanup β Delete working files from cache
ImageSqueeze ships with a comprehensive test suite to ensure reliability before every release. All tests pass with 0 failures.
Run with:
./gradlew :imagesqueeze:testDebugUnitTest| Test Class | Tests | Scenarios Covered |
|---|---|---|
CompressionConfigTest |
9 | Default values, resolution(), format(), quality(), size(), minQuality, isForDisplay, full DSL block |
SqueezeResultTest |
6 | Success data access, Error with exception, Error with null exception, when exhaustiveness |
SqueezeErrorTest |
4 | All 8 enum values present, valueOf() lookup, exhaustive when matching |
FileUtilTest |
16 | formatFileSize (zero, negative, B, KB, MB, GB, fractional), safeCopyFile (same path, new dest, overwrite, nested dirs, missing source, source preservation), safeDelete (exists, non-existent) |
CompressorCoreTest |
8 | FILE_NOT_FOUND, FILE_EMPTY, English error messages, non-null exception, suspend vs sync parity |
ImageSqueezeTest |
7 | Public API error paths (compress/compressSync), DSL config passthrough, default destination |
| Total | 50 |
Run with:
./gradlew :imagesqueeze:connectedDebugAndroidTest| Scenario | Tests | What is verified |
|---|---|---|
| Full compression pipeline | 10 | Valid JPEG β Success, custom quality, custom resolution, size constraint, PNG format, display thumbnail, auto-create dirs, source preservation, output β€ original, default cache destination |
| Error handling | 4 | Non-existent file β FILE_NOT_FOUND, empty file β FILE_EMPTY, corrupt file β DECODE_FAILED, English-only messages |
| Extension functions | 2 | File.squeeze() success path + error path |
compressSync() |
2 | Sync API with default and full DSL config |
| Total | 21 |
Combined: 71 tests covering configuration, validation guards, error handling, the full compression pipeline, and the public API surface.
- Min SDK: 21 (Android 5.0 Lollipop)
- Kotlin: 1.9+
- Coroutines:
kotlinx-coroutines-android
Copyright 2025 Kevin Malik Fajar
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Contributions are welcome! Please feel free to submit a Pull Request.
- Fork the repository
- Create your feature branch (
git checkout -b feature/amazing-feature) - Commit your changes (
git commit -m 'Add some amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
- Inspired by Zelory Compressor β the original idea and compression pipeline
- Built with Timber for clean logging

