Android image compression with a small Kotlin API. Resize and encode images from a File, content Uri, or ByteArray; receive a file, bytes, or progress events from a Flow.
- Android 11+ (
minSdk 30) - Kotlin coroutines: suspending calls for file and byte output; a
Flowfor coarse progress - JPEG, PNG, and WebP: bounded dimensions, configurable quality, and an optional strict byte limit
- Private by choice: retain a supported subset of JPEG EXIF or opt out for uploads
Add Maven Central to your app's repositories (alongside Google's Android repository):
// settings.gradle.kts
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
}Then add the dependency:
// app/build.gradle.kts
dependencies {
implementation("io.github.joelromanpr:tiny-compressor-ktx:1.1.0")
}See the v1.1.0 release notes for the changes in this version.
Call from a coroutine. Android's Photo Picker gives your app a readable content Uri without requesting broad media access.
import android.content.Context
import android.net.Uri
import com.joelromanpr.tinycompressor.ImageCompressor
import com.joelromanpr.tinycompressor.Options
import com.joelromanpr.tinycompressor.Source
import java.io.File
suspend fun prepareUpload(context: Context, pickedImage: Uri): File =
ImageCompressor.compress(
context = context,
source = Source.Uri(pickedImage),
options = Options(
maxWidth = 1600,
maxHeight = 1600,
quality = 82,
keepExif = false, // Do not copy camera details or GPS coordinates.
),
)The returned file is in the app's cache by default. Upload it or move it to durable app storage before the cache is cleared. You can write to a specific app-owned file with Options(destination = Destination.File(file)).
| Call | Result | When to use it |
|---|---|---|
ImageCompressor.compress(context, source, options) |
File |
Save, share, or upload a file. |
ImageCompressor.compressToByteArray(context, source, options) |
ByteArray |
An API needs bytes; the entire output fits comfortably in memory. destination is ignored. |
ImageCompressor.compressAsFlow(context, source, options) |
Flow<Progress> |
Show approximate stage and percent updates. The final Step.Done event contains the file. |
For example, to receive bytes without copying source EXIF:
val bytes = ImageCompressor.compressToByteArray(
context,
Source.Uri(pickedImage),
Options(keepExif = false),
)To observe progress, collect the flow in a lifecycle-aware coroutine:
ImageCompressor.compressAsFlow(context, Source.Uri(pickedImage))
.collect { progress ->
if (progress.step.isDone()) {
val outputFile = requireNotNull(progress.file)
// Use outputFile.
} else {
// Show progress.step and progress.percent (an estimate).
}
}A second compression should cancel the first job if its result is no longer needed. The demo app shows a picker, progress, error handling, and before/after previews.
| Option | Default | Notes |
|---|---|---|
maxWidth, maxHeight |
1280 each |
Preserve aspect ratio and avoid enlarging small images. |
format |
CompressFormat.JPEG |
PNG input stays PNG unless WebP is requested. See formats below. |
quality |
80 |
Used for lossy JPEG/WebP. PNG encoding ignores it. |
maxBytes |
null |
When set, adaptive encoding must fit the final output within the limit or throws IOException. |
keepExif |
true |
For JPEG output, copies a supported subset of source EXIF, including GPS if present. Use false for privacy-sensitive uploads. |
colorSpace |
ColorSpace.SRGB |
ColorSpace.DISPLAY_P3 is also available. |
destination |
Destination.Cache("default") |
File calls write under context.cacheDir/tinycompressor/default/ unless you provide Destination.File. |
- JPEG is usually a good choice for photos. It cannot preserve transparency.
- PNG preserves transparency. Its
qualitysetting does not change its lossless encoding. - WebP supports transparency. At quality
100, Android 11+ uses lossless WebP when no byte limit forces a lower quality. WithmaxBytes, adaptive encoding may switch to lossy WebP.
The decoder identifies the input format from its content for Source.File, Source.Uri, and Source.Bytes. PNG input stays PNG when JPEG or PNG is requested; request WebP to convert it. For other transparent inputs, choose PNG or WebP explicitly. The cache filename extension matches the actual output format. For Destination.File, use an extension that matches the resolved format. A known image extension that does not match throws IllegalArgumentException before writing; an extensionless path is allowed.
keepExif = true copies selected tags for JPEG output, not every metadata field. This includes camera details and GPS latitude/longitude when present. The image pixels are oriented during decode; the output orientation is normalized. Set keepExif = false before sharing or uploading images when source metadata is unnecessary.
maxBytes may reduce quality and dimensions. The limit applies to the final file, including copied EXIF. A target too small to satisfy causes an IOException; handle it like other input or output failures. Invalid or unreadable images and inaccessible destinations also fail with an exception. Coroutine cancellation propagates; do not convert it into a retry or a generic failure.
A picker Uri may not remain readable for a job that runs much later. For durable work, persist an eligible Photo Picker URI grant or copy the input to app-owned storage first. Write long-lived output to Destination.File instead of relying on the app cache. With WorkManager, retry only transient failures and let cancellation propagate. See Android's guidance on persisting picker access and long-running workers.
Open this repository in Android Studio and run the demo configuration on Android 11 or newer. To check a change locally:
./scripts/prepare_for_pr.shSee CHANGELOG.md for release notes and CONTRIBUTING.md for the contribution and release process.