A modular Android media SDK built with Kotlin. Each module is independently installable — add only what your app needs.
| I want to… | Module |
|---|---|
| Let the user pick a photo from gallery or camera | imagepicker |
| Crop a photo after picking | imagecropper |
| Let the user pick images, videos, audio, or documents | mediapicker |
| Resize or re-encode an image (no UI) | imagecompressor |
| Record audio inside the app | audiorecorder |
| Compress a video (no UI) | videocompressor |
| Show a fullscreen swipe-through preview of media | mediapreviewer |
| Use multiple modules and manage versions in one place | mediakit-bom |
| Artifact | What it does |
|---|---|
imagepicker |
Gallery and camera image picking with lifecycle-safe activity result handling |
imagecropper |
Touch crop UI with aspect ratio lock, shape mask, rotate/flip, and configurable output. Migrating from ArthurHub? See MIGRATION.md. |
mediapicker |
Unified storage picker for images, videos, audio, and documents |
imagecompressor |
Coroutine-based image resize and re-encode — no ActivityResultCaller required |
audiorecorder |
In-app audio recording with a waveform visualisation and timer |
videocompressor |
Coroutine-based video compression — no ActivityResultCaller required |
mediapreviewer |
Fullscreen swipe-between-items preview for images, video, and audio |
mediakit-bom |
Bill of Materials — import once to align all module versions automatically |
The BOM pins all module versions so you never have version mismatches.
// settings.gradle.kts
dependencyResolutionManagement {
repositories {
mavenCentral()
}
}// app/build.gradle.kts
dependencies {
implementation(platform("io.github.akshayashokcode:mediakit-bom:1.0.0"))
// Add only the modules you need — no version number required
implementation("io.github.akshayashokcode:imagepicker")
implementation("io.github.akshayashokcode:imagecropper")
implementation("io.github.akshayashokcode:mediapicker")
implementation("io.github.akshayashokcode:imagecompressor")
implementation("io.github.akshayashokcode:audiorecorder")
implementation("io.github.akshayashokcode:videocompressor")
implementation("io.github.akshayashokcode:mediapreviewer")
}dependencies {
implementation("io.github.akshayashokcode:imagepicker:1.0.0")
implementation("io.github.akshayashokcode:imagecropper:1.0.0") // includes imagepicker transitively
implementation("io.github.akshayashokcode:mediapicker:1.0.0")
implementation("io.github.akshayashokcode:imagecompressor:1.0.0")
implementation("io.github.akshayashokcode:audiorecorder:1.0.0")
implementation("io.github.akshayashokcode:videocompressor:1.0.0")
implementation("io.github.akshayashokcode:mediapreviewer:1.0.0")
}class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Must be constructed before setContent
val picker = ImagePicker.with(this, this)
.source(MediaSource.Gallery)
.onResult { result ->
when (result) {
is ImagePickerResult.Success -> { /* use result.uri */ }
is ImagePickerResult.Cancelled -> { }
is ImagePickerResult.Error -> { /* show result.message */ }
else -> Unit
}
}
setContent {
MyTheme {
Button(onClick = { picker.launch() }) { Text("Pick Image") }
}
}
}
}Compose API (no onCreate-before-setContent restriction):
@OptIn(ExperimentalMediaKitApi::class)
@Composable
fun MyScreen() {
val picker = rememberImagePicker(source = MediaSource.Gallery) { result -> }
Button(onClick = { picker.launch() }) { Text("Pick Image") }
}val picker = ImagePicker.with(this, this)
.source(MediaSource.Both) // shows a dialog: Gallery / Camera
.onResult { result -> }val picker = ImagePicker.with(this, this)
.source(MediaSource.Gallery)
.crop(MediaKitCropProvider()) // requires imagecropper module
.onResult { result ->
// result.uri is the cropped image URI
}val picker = ImagePicker.with(this, this)
.source(MediaSource.Gallery)
.crop(
MediaKitCropProvider(
CropperOptions(
aspectRatios = listOf(AspectRatio.Free, AspectRatio.Square, AspectRatio.SixteenNine),
showRotateButtons = true,
showFlipButtons = true,
cropShape = CropShape.Circle,
outputFormat = OutputFormat.PNG,
maxOutputWidth = 2048,
maxOutputHeight = 2048
)
)
)
.onResult { result -> }val picker = ImagePicker.with(this, this)
.source(MediaSource.Camera) // CAMERA permission requested automatically
.onResult { result ->
when (result) {
is ImagePickerResult.SuccessWithBitmap -> {
// result.uri — content URI
// result.bitmap — orientation-corrected bitmap
}
else -> Unit
}
}// Construct before setContent
val picker = MediaPicker.with(this, this)
.onResult { result ->
when (result) {
is MediaPickerResult.Success -> { /* result.item: MediaItem */ }
is MediaPickerResult.MultipleSuccess -> { /* result.items: List<MediaItem> */ }
is MediaPickerResult.Cancelled -> { }
is MediaPickerResult.Error -> { }
}
}
// At launch time — set options and launch
picker.mediaTypes(MediaType.Image, MediaType.Video).allowMultiple(true).launch()With restrictions:
MediaPicker.with(this, this)
.mediaTypes(MediaType.Image, MediaType.Video)
.restrictMimeTypes("image/gif", "video/webm")
.restrictExtensions("apk")
.allowMultiple(true)
.onResult { result -> }
.onError { error -> }
.launch()Compose API:
@OptIn(ExperimentalMediaKitApi::class)
@Composable
fun MyScreen() {
val picker = rememberMediaPicker(
MediaType.Image, MediaType.Video,
allowMultiple = true
) { result -> }
Button(onClick = { picker.launch() }) { Text("Pick Media") }
}ImageCompressor does not need ActivityResultCaller — construct it anywhere, including inside a Composable.
Callback-based:
ImageCompressor.with(context)
.source(uri)
.options(CompressionOptions(maxWidth = 1920, quality = 85))
.onResult { result ->
when (result) {
is ImageCompressionResult.Success -> {
// result.uri — compressed file in app cacheDir
// result.originalSizeBytes / result.compressedSizeBytes
}
is ImageCompressionResult.Error -> { }
else -> Unit
}
}
.compressAsync()Suspend API (inside a coroutine or LaunchedEffect):
val result = ImageCompressor.with(context)
.source(uri)
.options(CompressionOptions(maxWidth = 1920, quality = 85))
.compress() // suspend fun — runs on Dispatchers.IOAudioRecorder launches a built-in recording screen with waveform and timer. Requires ActivityResultCaller.
val recorder = AudioRecorder.with(this, this)
.options(
AudioRecorderOptions(
maxDurationSeconds = 120,
format = AudioOutputFormat.AAC_M4A,
showWaveform = true
)
)
.onResult { result ->
when (result) {
is AudioRecorderResult.Success -> {
// result.uri — recorded audio URI
// result.durationMs
}
is AudioRecorderResult.Cancelled -> { }
is AudioRecorderResult.Error -> { }
}
}
// In a click handler:
recorder.launch() // RECORD_AUDIO permission requested automaticallyVideoCompressor does not need ActivityResultCaller. Call cancel() to abort in-progress compression.
Callback-based:
val compressor = VideoCompressor.with(context)
.source(uri)
.options(VideoCompressionOptions(maxWidth = 1280, maxHeight = 720, videoBitrateBps = 2_000_000))
.onProgress { percent -> /* update progress bar */ }
.onResult { result ->
when (result) {
is VideoCompressionResult.Success -> {
// result.uri — compressed video URI in app cacheDir
// result.originalSizeBytes / result.compressedSizeBytes
}
is VideoCompressionResult.Cancelled -> { }
is VideoCompressionResult.Error -> { }
}
}
compressor.compressAsync() // returns Job — observe or cancel laterSuspend API:
val result = VideoCompressor.with(context)
.source(uri)
.options(VideoCompressionOptions(maxWidth = 1280))
.compress() // suspend fun — runs on Dispatchers.IOCancel in-progress compression:
compressor.cancel()MediaPreviewer.with(context, this)
.items(
listOf(
MediaPreviewItem.Image(imageUri),
MediaPreviewItem.Video(videoUri),
MediaPreviewItem.Audio(audioUri)
)
)
.options(PreviewOptions(showShareButton = true, zoomEnabled = true))
.launch()Use the crop UI directly in your own layout without the picker flow:
<com.akshayashokcode.imagecropper.CropperView
android:id="@+id/cropperView"
android:layout_width="match_parent"
android:layout_height="match_parent" />cropperView.setImageBitmap(bitmap)
val cropped: Bitmap? = cropperView.getCroppedImage()class MyCropProvider : ImageCropProvider {
override fun createLauncher(
context: Context,
caller: ActivityResultCaller,
callback: (ImagePickerResult) -> Unit
): CropLauncher {
// register your activity result launcher and return CropLauncher
}
}
val picker = ImagePicker.with(this, this)
.source(MediaSource.Gallery)
.crop(MyCropProvider())
.onResult { result -> }MediaSource.Gallery // system photo picker
MediaSource.Camera // camera capture (CAMERA permission requested automatically)
MediaSource.Both // shows a dialog: Gallery / Camerasealed class ImagePickerResult {
data class Success(val uri: Uri) // gallery pick or crop output
data class SuccessWithBitmap(val uri: Uri, val bitmap: Bitmap) // camera capture (orientation-corrected)
data object Cancelled
data class Error(val message: String)
}sealed class ImagePickerException {
object PermissionDenied
object AppNotFound
object FileCreationFailed
object InvalidUri
object DecodingFailed
object FileDeletionFailed
object IntentFailed
class Unknown(message: String?, cause: Throwable?)
}Migrating from
com.github.ArthurHub:android-image-cropper? See MIGRATION.md.
CropperOptions(
aspectRatios: List<AspectRatio> = listOf(AspectRatio.Free),
lockAspectRatio: Boolean = false,
cropShape: CropShape = CropShape.Rectangle,
showRotateButtons: Boolean = false,
showFlipButtons: Boolean = false,
outputFormat: OutputFormat = OutputFormat.JPEG(),
maxOutputWidth: Int = 0, // 0 = no limit
maxOutputHeight: Int = 0,
minOutputWidth: Int = 100,
minOutputHeight: Int = 100
)AspectRatio.Free // unconstrained (default)
AspectRatio.Square // 1:1
AspectRatio.FourThree // 4:3
AspectRatio.SixteenNine // 16:9
AspectRatio.ThreeTwo // 3:2
AspectRatio.FiveFour // 5:4
AspectRatio.Ratio(width, height) // customCropShape.Rectangle // default
CropShape.Circle // circular mask — output has transparent cornersOutputFormat.JPEG(quality: Int = 90) // default
OutputFormat.PNG // lossless; use with Circle crop for transparency
OutputFormat.WebP(quality: Int = 90)sealed class MediaType {
object Image
object Video
object Audio
object Document // PDF, Word, Excel, plain text
object All // default — all of the above
}| Types requested | Android API used |
|---|---|
| Image only | PickVisualMedia(ImageOnly) |
| Video only | PickVisualMedia(VideoOnly) |
| Image + Video | PickVisualMedia(ImageAndVideo) |
| Audio / Document / mixed | OpenDocument(mimeTypes) |
| All | OpenDocument("*/*") |
sealed class MediaPickerResult {
data class Success(val item: MediaItem)
data class MultipleSuccess(val items: List<MediaItem>)
data object Cancelled
data class Error(val message: String)
}
sealed class MediaItem {
data class Image(val uri: Uri, val mimeType: String)
data class Video(val uri: Uri, val mimeType: String, val durationMs: Long)
data class Audio(val uri: Uri, val mimeType: String, val durationMs: Long, val displayName: String)
data class Document(val uri: Uri, val mimeType: String, val displayName: String, val sizeBytes: Long)
data class Unknown(val uri: Uri, val mimeType: String)
}sealed class MediaPickerException {
object AppNotFound
object InvalidUri
object RestrictedFile // blocked by restrictMimeTypes / restrictExtensions
class Unknown(message: String, cause: Throwable?)
}CompressionOptions(
maxWidth: Int = 1920,
maxHeight: Int = 1920,
quality: Int = 85, // JPEG/WebP quality 0–100
format: Bitmap.CompressFormat = JPEG,
maxFileSizeBytes: Long? = null, // iterates quality down to 30 to hit target
preserveExif: Boolean = false
)sealed class ImageCompressionResult {
data class Success(val uri: Uri, val originalSizeBytes: Long, val compressedSizeBytes: Long)
data object Cancelled
data class Error(val message: String)
}sealed class ImageCompressionException {
object InvalidSource
object DecodingFailed
object EncodingFailed
object FileCreationFailed
class Unknown(message: String, cause: Throwable?)
}AudioRecorderOptions(
maxDurationSeconds: Int = 0, // 0 = no limit
format: AudioOutputFormat = AudioOutputFormat.AAC_M4A,
showWaveform: Boolean = true
)sealed class AudioRecorderResult {
data class Success(val uri: Uri, val durationMs: Long)
data object Cancelled
data class Error(val message: String)
}VideoCompressionOptions(
maxWidth: Int = 1280,
maxHeight: Int = 720,
videoBitrateBps: Int = 2_000_000,
frameRate: Int = 30
)sealed class VideoCompressionResult {
data class Success(val uri: Uri, val originalSizeBytes: Long, val compressedSizeBytes: Long)
data object Cancelled
data class Error(val message: String)
}sealed class MediaPreviewItem {
data class Image(val uri: Uri)
data class Video(val uri: Uri)
data class Audio(val uri: Uri)
}PreviewOptions(
showShareButton: Boolean = false,
zoomEnabled: Boolean = true
)Modules that open system pickers or activities must register ActivityResultLauncher before the activity reaches STARTED. Construct them in Activity.onCreate before setContent.
| Module | Needs ActivityResultCaller? |
|---|---|
imagepicker |
Yes — construct in onCreate before setContent |
imagecropper |
Yes (via imagepicker) |
mediapicker |
Yes — construct in onCreate before setContent |
audiorecorder |
Yes — construct in onCreate before setContent |
imagecompressor |
No — construct anywhere, including inside a Composable |
videocompressor |
No — construct anywhere, including inside a Composable |
mediapreviewer |
No — uses startActivity directly |
Compose workaround — use rememberImagePicker / rememberMediaPicker instead of the builder API to remove the onCreate restriction entirely (annotated @ExperimentalMediaKitApi).
MediaKit-android/
├── imagepicker/ ImagePicker, ImageCropProvider interface
├── imagecropper/ CropperView, CropperActivity, MediaKitCropProvider
├── mediapicker/ MediaPicker, unified type + restriction picker
├── imagecompressor/ ImageCompressor, coroutine-based resize/re-encode
├── audiorecorder/ AudioRecorder, RecorderActivity, waveform view
├── videocompressor/ VideoCompressor, MediaCodec-based transcoder
├── mediapreviewer/ MediaPreviewer, fullscreen swipe preview
├── mediakit-core/ Shared utilities (PermissionLauncher, TempFileManager)
├── mediakit-bom/ Bill of Materials
└── sample-app/ Demo Compose app — Image / Media / Compress / Record tabs
- Modular — each artifact is independently publishable and usable
- Lightweight — no third-party image loading dependencies
- Lifecycle-safe — all
registerForActivityResultcalls happen beforeonStart - Kotlin-first — sealed results, fluent builder, coroutine support
- Extensible — implement
ImageCropProviderto plug in any crop library
com.github.ArthurHub:android-image-cropper is deprecated and unmaintained. MediaKit's imagecropper is a drop-in replacement published on Maven Central (no JitPack required).
Dependency swap:
// Remove
implementation("com.github.ArthurHub:android-image-cropper:2.8.0")
// Add
implementation("io.github.akshayashokcode:imagepicker:1.0.0")
implementation("io.github.akshayashokcode:imagecropper:1.0.0")API comparison:
// ArthurHub — onActivityResult era
CropImage.activity()
.setCropShape(CropImageView.CropShape.OVAL)
.setAspectRatio(1, 1)
.setFixAspectRatio(true)
.setOutputCompressQuality(90)
.setMaxResultSize(2048, 2048)
.start(this)
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == CropImage.CROP_IMAGE_ACTIVITY_REQUEST_CODE) {
val result = CropImage.getActivityResult(data)
if (resultCode == RESULT_OK) { val uri = result.uri }
}
}// MediaKit — lifecycle-safe, sealed results, no onActivityResult
val picker = ImagePicker.with(this, this)
.source(MediaSource.Gallery)
.crop(
MediaKitCropProvider(
CropperOptions(
cropShape = CropShape.Circle,
aspectRatios = listOf(AspectRatio.Square),
lockAspectRatio = true,
outputFormat = OutputFormat.JPEG(quality = 90),
maxOutputWidth = 2048,
maxOutputHeight = 2048
)
)
)
.onResult { result ->
when (result) {
is ImagePickerResult.Success -> { val uri = result.uri }
is ImagePickerResult.Cancelled -> { }
is ImagePickerResult.Error -> { }
else -> Unit
}
}See MIGRATION.md for the full options mapping, FileProvider differences, and standalone CropperView migration.
- minSdk 24 (Android 7.0+)
- Kotlin
See CONTRIBUTING.md.
MIT — see LICENSE.