Kotlin Multiplatform core library: shared UI, utilities, data, and permissions for Android, iOS, Desktop, and Web.
Deveng Core KMP is a KMP library that provides:
- UI & theming — Compose components (buttons, dialogs, pickers, navigation, OTP, option lists, etc.) with a customizable theme and typography system.
- Data & domain — Key-value storage (e.g. device identifier); generic temp file storage (repository interface + impl,
TempFileItem,TempStorageDirProvider,TempFileOpsincore.domain.temp/core.data.temp); pagination models and loaders; camera/capture (SharedImage,CameraManager) with expect/actual per platform. - Utilities — Platform detection and config, date/time formatting, image resize/compress, string formatting, logging, dispatchers, clipboard, dial, maps, share, location.
- Permissions — Unified permission API (camera, gallery, location, Bluetooth, contacts, notifications, motion) with Android and iOS implementations.
| Platform | Support |
|---|---|
| Android | ✅ |
| iOS | ✅ (device + simulator) |
| Desktop | ✅ (JVM) |
| Web | ✅ (Wasm/JS) |
Add the dependency to your module (Kotlin DSL):
dependencies {
implementation("global.deveng:core-kmp:VERSION")
}Replace VERSION with the latest release or the version from the repo.
- Quick Start
- Library Modules
- UI Components & Theming
- Utilities
- Data & Domain
- Camera
- Permissions
- Pagination
- Sample App
- Building
- License
UI — Wrap your composables in AppTheme and use shared components:
AppTheme {
CustomButton(text = "Click me", onClick = { })
}Platform & utils — Use MultiPlatformUtils (construct with Context on Android; no-arg on other platforms) for platform config and common actions:
val utils = MultiPlatformUtils(context) // Android; use no-arg constructor on iOS/Desktop/Wasm
val config = utils.getPlatformConfig() // platform, language, uuid, deviceName, packageVersionName
utils.copyToClipBoard("text")
utils.openUrl("https://example.com")The deveng-core module is organized as follows:
| Area | Description |
|---|---|
| UI & theming | ComponentTheme, AppTheme, typography; buttons, dialogs, text fields, date/range pickers, OTP, navigation menus, option lists, chips, search, progress, scrollbars, JSON viewer, etc. |
| Data | DeviceInfoStorage (e.g. device identifier); generic temp file storage — TempFileRepository, TempFileRepositoryImpl, TempStorageDirProvider, TempFileOps, TempFileItem (domain in core.domain.temp, impl in core.data.temp); use one TempStorageDirProvider per use case (e.g. camera path); PagedListResponse, BasePaginatedResponse for paginated API responses. |
| Domain | PagedList for pagination; camera (see Camera) — rememberCameraKState, DefaultCameraPreview, CameraPreviewView, ImageCaptureResult.Success(byteArray, bitmap); image save & EXIF via core.util.image.PhotoSaveUtils. |
| Utilities | Platform, PlatformConfig, MultiPlatformUtils (dial, clipboard, open URL, maps, share, location); date/time formatting; ImageSizeProcessor and ImageProcessingProfile; StringFormatter, CustomLogger, CustomDispatchers. |
| Permissions | Permission enum, PermissionsController, PermissionState; Android and iOS implementations. |
| Pagination (UI) | PaginatedFlowLoader, PaginatedListView, page state. |
Components work out of the box with default colors and Urbanist font:
AppTheme {
CustomButton(text = "Button", onClick = { })
CustomAlertDialog(
isDialogVisible = true,
title = "Title",
description = "Description",
positiveButtonText = "OK",
onPositiveButtonClick = { }
)
}Create a ComponentTheme to override component colors:
val theme = ComponentTheme(
button = ButtonTheme(
containerColor = Color.Blue,
contentColor = Color.White
),
alertDialog = AlertDialogTheme(
headerColor = Color.White,
titleColor = Color.Black
)
)
AppTheme(componentTheme = theme) {
// Components use your custom colors
}Change the default font (Urbanist) globally:
// System fonts
val theme = ComponentTheme(
typography = TypographyTheme(
fontFamily = FontFamily.SansSerif
)
)
// Custom font files
val customFont = FontFamily(
Font(resource = Res.font.my_font_regular, weight = FontWeight(400)),
Font(resource = Res.font.my_font_bold, weight = FontWeight(700))
)
val theme = ComponentTheme(
typography = TypographyTheme(fontFamily = customFont)
)Override theme values for specific components:
AppTheme(componentTheme = theme) {
CustomButton(
text = "Special Button",
containerColor = Color.Red, // Overrides theme
textStyle = BoldTextStyle().copy(fontSize = 20.sp)
)
}ComponentTheme
├── typography: TypographyTheme
│ └── fontFamily: FontFamily? (default: Urbanist)
├── button: ButtonTheme
│ ├── containerColor, contentColor
│ ├── disabledContainerColor, disabledContentColor
│ └── defaultTextStyle
├── alertDialog: AlertDialogTheme
│ ├── headerColor, bodyColor
│ ├── titleColor, descriptionColor
│ ├── positiveButtonColor, negativeButtonColor
│ └── titleTextStyle, descriptionTextStyle, buttonTextStyle
├── surface: SurfaceTheme
│ └── defaultColor, defaultContentColor
└── dialogHeader: DialogHeaderTheme
└── titleColor, iconTint, titleTextStyle
Available functions: RegularTextStyle() (400), MediumTextStyle() (500), SemiBoldTextStyle() (600), BoldTextStyle() (700). All use the font family from ComponentTheme.typography.fontFamily (default: Urbanist).
Text(text = "Text", style = RegularTextStyle())
Text(text = "Custom", style = BoldTextStyle().copy(fontSize = 24.sp, color = Color.Blue))
CustomButton(text = "Button", textStyle = SemiBoldTextStyle().copy(fontSize = 18.sp), onClick = { })- Actions:
CustomButton,CustomIconButton,LabeledSwitch - Dialogs:
CustomAlertDialog,CustomDialog,CustomDialogHeader,CustomDialogBody - Inputs:
CustomTextField,SearchField,PickerField,CustomDatePicker,CustomDateRangePicker,OtpView - Layout:
CustomHeader,RoundedSurface,LabeledSlot,Slot,ChipItem - Lists & selection:
OptionItemList,OptionItemLazyListDialog,OptionItemMultiSelectLazyListDialog,CustomDropDownMenu - Navigation:
NavigationMenu(horizontal, expanded, collapsed) - Feedback:
RatingRow,ProgressIndicatorBars,JsonViewer - Swipe stack:
SwipeCards,SwipeCardsState,rememberSwipeCardsState,SwipeCardsScope; optional left/right/revert icon buttons; usependingRevertKeywhen the list updates so the revert button stays visible (see SwipeCards) - Scrolling:
PaginatedListView,ScrollbarWithScrollState,ScrollbarWithLazyListState
@Composable
fun MyApp() {
val theme = ComponentTheme(
typography = TypographyTheme(fontFamily = FontFamily.SansSerif),
button = ButtonTheme(
containerColor = Color(0xFF1976D2),
contentColor = Color.White,
defaultTextStyle = SemiBoldTextStyle().copy(fontSize = 18.sp)
),
alertDialog = AlertDialogTheme(
headerColor = Color.White,
titleColor = Color.Black,
titleTextStyle = BoldTextStyle().copy(fontSize = 20.sp)
)
)
AppTheme(componentTheme = theme) {
Column {
CustomButton(text = "Primary Action", onClick = { })
CustomAlertDialog(
isDialogVisible = showDialog,
title = "Confirm",
description = "Are you sure?",
positiveButtonText = "Yes",
negativeButtonText = "No",
onPositiveButtonClick = { showDialog = false },
onNegativeButtonClick = { showDialog = false },
onDismissRequest = { showDialog = false }
)
}
}
}Tinder-style swipeable card stack: SwipeCards, SwipeCardsState, rememberSwipeCardsState, SwipeCardsScope (items / itemsIndexed). Use showSwipeButtons with negativeButtonIcon, positiveButtonIcon, revertButtonIcon for overlay buttons. Revert is normally driven by internal state (state.canRevert, popLastSwipe, animateBackSwipe). When the list content changes (e.g. you remove an item after committing a previous swipe), the internal revert state is lost and the built-in revert button would disappear. Pass pendingRevertKey with the pending item's key (same id you use in items/itemsIndexed) so the revert button stays visible and a click calls onRevert(pendingRevertKey).
- Define theme once — Create
ComponentThemeat app level. - Use theme for consistency — Override only when needed.
- Font hierarchy — Titles: 20–24sp (Bold), Body: 14–16sp (Regular), Buttons: 16–18sp (SemiBold).
- Accessibility — Ensure sufficient color contrast and readable font sizes.
- Custom fonts — Include all weights (400, 500, 600, 700) your app uses.
Platform & actions — MultiPlatformUtils (use Context on Android; no-arg on iOS/Desktop/Wasm):
val utils = MultiPlatformUtils(context)
val config = utils.getPlatformConfig() // platform, systemLanguage, uuid, deviceName, packageVersionName
utils.copyToClipBoard("Copied text")
utils.openUrl("https://example.com")
utils.openMapsWithLocation(41.0082, 28.9784)
utils.shareText("Share this")
val (lat, lng) = utils.getCurrentLocation() ?: (0.0 to 0.0)Date/time — Format and selectable dates:
val formatted = formatDateTime(localDateTime, isDaily = false) // e.g. "20.02.2025"
val dateString = localDate.format(dotLocalDateFormat)
val selectableDates = CustomSelectableDates(...) // for date picker constraintsImage processing — Resize and compress image bytes:
val processor = ImageSizeProcessor()
val compressed = processor.resizeAndCompressBytes(
inputBytes = imageBytes,
profile = ImageProcessingProfile.MEDIUM // or targetMaxSizePx + quality
)
val bitmap = imageBytes.toImageBitmap()String & logging:
val cleaned = StringFormatter().formatInput(input, clearNonNumeric = true)
CustomLogger.isLoggingEnabled = true
CustomLogger.log("Debug message")Device storage — Persist a generated device identifier:
val storage = DeviceInfoStorageImpl(settings) // Settings from russhwolf/settings
var uuid = storage.getGeneratedPlatformIdentifier()
if (uuid == null) {
uuid = UUID.randomUUID().toString()
storage.setGeneratedPlatformIdentifier(uuid)
}Pagination — Map API response to domain model:
@Serializable
data class MyItem(val id: String, val name: String)
val response: PagedListResponse<MyItemDto> = api.getPage(page, size)
val pagedList = response.mapItems { dto -> MyItem(dto.id, dto.name) }
// pagedList.items, pagedList.hasNextPage, pagedList.totalPageCount, etc.Camera — See Camera for photo capture and video recording with the core module.
The core module provides a full camera implementation for Android, iOS, and Desktop (Compose-first API). Capture returns in-memory bytes and an optional bitmap; the app is responsible for saving the photo (including adding location EXIF and notifying the Gallery on Android). On WASM/JS the camera is unsupported (no-op / error state).
Step 1 — Camera UI and capture
Use rememberCameraKState and DefaultCameraPreview for a ready-to-use camera screen (flash, switch camera, zoom, tap-to-focus, capture button, thumbnail). Capture does not write to disk; you receive ImageCaptureResult.Success(byteArray, bitmap).
import core.domain.camera.compose.rememberCameraKState
import core.domain.camera.compose.DefaultCameraPreview
import core.domain.camera.state.CameraConfiguration
import core.domain.camera.state.CameraKState
import core.domain.camera.enums.CameraLens
import core.domain.camera.enums.FlashMode
import core.domain.camera.enums.AspectRatio
import core.domain.camera.result.ImageCaptureResult
import core.util.image.PhotoSaveUtils
import core.util.image.SavePhotoResult
@Composable
fun CameraScreen() {
val config = remember {
CameraConfiguration(
cameraLens = CameraLens.BACK,
flashMode = FlashMode.OFF,
aspectRatio = AspectRatio.RATIO_16_9,
)
}
val cameraState by rememberCameraKState(config = config, setupPlugins = { })
when (val state = cameraState) {
is CameraKState.Initializing -> CircularProgressIndicator()
is CameraKState.Ready -> {
DefaultCameraPreview(
controller = state.controller,
onImageCaptured = { result ->
when (result) {
is ImageCaptureResult.Success -> {
// Step 2: add location EXIF (optional), then save — see below
saveCapturedPhoto(result.byteArray, result.bitmap)
}
is ImageCaptureResult.Error ->
println("Error: ${result.exception.message}")
else -> { }
}
},
onGalleryClick = { /* open gallery */ },
onLastPhotoClick = { bitmap -> /* open viewer */ },
initialThumbnailBitmap = null,
)
}
is CameraKState.Error -> Text("Camera error: ${state.message}")
}
}Step 2 — Save photo and optional location EXIF
Use PhotoSaveUtils (core.util.image) to add GPS EXIF and write the file. On Android, call PhotoSaveUtils.setApplicationContext(applicationContext) once (e.g. in Activity.onCreate) so saved photos are notified to MediaStore and appear in the system Gallery.
// In Activity.onCreate (Android only):
PhotoSaveUtils.setApplicationContext(applicationContext)
// When you have capture bytes and optional location:
fun saveCapturedPhoto(byteArray: ByteArray, thumbnail: ImageBitmap?) {
val path = getNewPhotoSavePath() // your app provides the path, e.g. Pictures/MyApp/IMG_xxx.jpg
val (lat, lon) = getCurrentLocation() ?: (null to null)
val bytesToSave = if (lat != null && lon != null) {
PhotoSaveUtils.addLocationExif(byteArray, lat, lon)
} else {
byteArray
}
when (val saveResult = PhotoSaveUtils.savePhoto(bytesToSave, path)) {
is SavePhotoResult.Success -> println("Saved: ${saveResult.path}")
is SavePhotoResult.Error -> println("Save failed: ${saveResult.exception.message}")
}
}Camera temp storage (stack / pre-upload)
To persist captured photos in temp storage (e.g. for a stack-and-swipe flow before upload), use CameraTempPhotoRepository (core.domain.camera.temp) and TempPhotoItem. Register CameraTempDirProvider and CameraTempFileOps in your DI (Koin); core provides default implementations per platform (AndroidCameraTempDirProvider, DesktopCameraTempDirProvider, IosCameraTempDirProvider). Use PhotoSaveUtils.imageBytesWithNormalOrientation(byteArray) before saving so orientation is correct. The repository interface lives in core.domain.camera.temp; the implementation and file/dir abstractions in core.data.camera.temp (domain + data split).
Step 3 — Provide the save path
Your app decides where to save (e.g. app-specific directory or public Pictures). Example for a path under Pictures:
// Example: platform-specific function that returns a new file path
expect fun getNewPhotoSavePath(): String
// Android actual: e.g. Environment.getExternalStoragePublicDirectory(DIRECTORY_PICTURES) + "/MyApp/IMG_${timestamp}.jpg"| API | Description |
|---|---|
PhotoSaveUtils.setApplicationContext(context: Any?) |
Call once on Android with application context so saved photos are scanned and appear in the system Gallery. No-op on other platforms. |
PhotoSaveUtils.savePhoto(imageBytes, targetPath): SavePhotoResult |
Writes JPEG/PNG bytes to targetPath, creates parent directories. Returns SavePhotoResult.Success(path) or SavePhotoResult.Error(exception). |
PhotoSaveUtils.addLocationExif(imageBytes, latitude, longitude): ByteArray |
Returns a new byte array with GPS EXIF tags set (decimal degrees). Preserves existing EXIF (e.g. orientation). Implemented on Android; other platforms return bytes unchanged. |
Flow: capture → addLocationExif (if you have location) → savePhoto → optionally use thumbnail from ImageCaptureResult.Success.bitmap for UI.
- Thumbnail (bottom-left): Shows the last captured photo or an optional
initialThumbnailBitmap. Tapping it callsonLastPhotoClick(bitmap)with the displayed bitmap. - Gallery icon: Shown only when there is no thumbnail (no initial bitmap and no capture yet).
For a custom layout, use CameraPreviewView and call the controller:
when (val state = cameraState) {
is CameraKState.Ready -> {
val controller = state.controller
CameraPreviewView(controller = controller, modifier = Modifier.fillMaxSize())
Button(
onClick = {
scope.launch {
when (val result = controller.takePictureToFile()) {
is ImageCaptureResult.Success ->
saveCapturedPhoto(result.byteArray, result.bitmap)
is ImageCaptureResult.Error ->
println("Error: ${result.exception.message}")
else -> { }
}
}
},
modifier = Modifier.align(Alignment.BottomCenter),
) {
Text("Capture")
}
}
// ...
}takePictureToFile()— ReturnsImageCaptureResult.Success(byteArray, bitmap). Does not write to disk; the app saves usingPhotoSaveUtils.savePhoto.ImageCaptureResult.Success—byteArray: JPEG/PNG bytes;bitmap: optional decoded bitmap for thumbnails. UsePhotoSaveUtils.addLocationExifandPhotoSaveUtils.savePhototo persist.
| Platform | Setup |
|---|---|
| Android | Camera and storage permissions in core's manifest. Call PhotoSaveUtils.setApplicationContext(applicationContext) so saved photos appear in Gallery. |
| iOS | Add usage descriptions in Info.plist: NSCameraUsageDescription, NSPhotoLibraryAddUsageDescription, NSMicrophoneUsageDescription (for video). |
| Desktop | No extra setup. |
| WASM | Camera not supported; rememberCameraKState returns an error state. |
Camera: core.domain.camera.* (compose, state, controller, result, enums, permissions, video).
Image save & EXIF: core.util.image (PhotoSaveUtils, SavePhotoResult).
Check and request runtime permissions (Android/iOS):
val factory = rememberPermissionsControllerFactory()
val permissionsController = factory.createPermissionsController()
// Check state
val state = permissionsController.getPermissionState(Permission.CAMERA)
if (state != PermissionState.Granted) {
try {
permissionsController.providePermission(Permission.CAMERA)
} catch (e: DeniedAlwaysException) {
permissionsController.openAppSettings()
} catch (e: RequestCanceledException) {
// User canceled
}
}
val hasLocation = permissionsController.isPermissionGranted(Permission.LOCATION)Load paged data with PaginatedFlowLoader and show in PaginatedListView:
val loader = remember(scope, pageSize) {
PaginatedFlowLoader(
initialKey = 0,
scope = scope,
pageSize = 20,
pageSource = { page, size ->
val response = api.fetchPage(page, size)
PageResult(items = response.items, hasNextPage = response.hasNextPage)
},
getNextKey = { page, _ -> page + 1 }
)
}
LaunchedEffect(Unit) { loader.reset() }
val state by loader.state.collectAsState()
PaginatedListView(
state = state,
onScrollReachNextPageThreshold = { loader.loadNextPage() },
onSwipeAtListsEnd = { loader.reset() },
itemSlot = { item -> ItemRow(item) }
)
// Refresh or new filters
loader.updatePageSource(newKey, newPageSource, reload = true)The sample app in sample/composeApp demonstrates theming, main UI components, and multiplatform run targets. Open the project in Android Studio and run the composeApp configuration for Android, iOS, Desktop, or Wasm.
git clone https://github.com/Deveng-Group/deveng-core-kmp.git
cd deveng-core-kmpOpen in Android Studio or build from the command line:
./gradlew :deveng-core:build
./gradlew :sample:composeApp:assembleDebug # Android sampleLicensed under the Apache License, Version 2.0. See the LICENSE file for details.