KmpSDK
Headless, plug-and-play Kotlin Multiplatform SDK for Android and iOS.
KmpSDK gives your host app ready-made infrastructure — networking, auth, optional caching/offline sync, logging, and MVI contracts. You own all UI (Compose, SwiftUI, XML, etc.).
Not every screen needs SQL, local data sources, or sync repositories. Pick an integration path per feature (see below).
What you get vs what you build
Choose your integration path
Use one path per feature (e.g. login = Path A, product catalog = Path C).
Quick decision
Need this feature's data in YOUR SQL when the device is offline?
YES → Path C (full offline-first)
NO → Is showing the last API response offline OK?
YES → Path B (network-first + SDK HTTP cache)
NO → Path A (online-only)
Path comparison
SDK internal storage (all paths)
KmpSdk.init always opens the SDK database (api_cache, offline_queue, offline_action). That is separate from your app tables. You control behaviour with init flags (see Path B init and Step 20).
Path A — Online only (no your SQL)
You write: use case or ViewModel calling KmpSdk.networkClient, plus UI.
You do not write: AppDatabase table, LocalDataSource, or BaseSyncRepository for this feature.
Example init:
KmpSdk.init(this) {
baseUrl = "https://api.example.com"
syncPolicy = SyncPolicy.NETWORK_FIRST
enableHttpCache = false
queueMutationsWhenOffline = false
install(AuthFeatureModule)
}
Example use case:
class GetAboutUseCase {
suspend fun load(): KmpSdkResult<AboutDto> =
KmpSdk.networkClient.get("/about")
}
Example feature module (minimal):
object AboutFeatureModule : KmpSdkModule {
override fun register(registry: KmpSdkRegistry) {
registry.register<GetAboutUseCase> { GetAboutUseCase() }
}
}
Wire loading/error/state in your ViewModel (standard StateFlow / DataState).
Path B — Network-first + SDK HTTP cache
Same app code as Path A for the feature (no your SQL). Offline GET may return the last cached HTTP body from the SDK api_cache table.
Example init:
KmpSdk.init(this) {
baseUrl = "https://api.example.com"
syncPolicy = SyncPolicy.NETWORK_FIRST
enableHttpCache = true
queueMutationsWhenOffline = false
install(ProductListModule)
}
Path C — Full offline-first (your SQL)
Use when the feature must read/write your persisted entities offline.
Follow Steps 5–10 below (SQL → local → remote → repository → use case → ViewModel).
Shortcuts: SqlDelightListLocalDataSource, installRestListFeature (no custom repository class, but local lambdas still required), RestMutationUseCase, feature generator CLI.
Prerequisites
- JDK 17+
- Android Studio / Xcode for platform apps
- Kotlin Multiplatform project with a
shared module
Step-by-step integration guide
| Steps | Applies to |
|---|
| 1–4 | All paths (dependency, init, resolve, feature module) |
New to the SDK? Start with Path A. Move to Path C only when you need offline data in your database.
Step 20 lists every init flag in one place (core + v1.4).
The v1.4 — Rich SDK additions section below Step 20 documents profiles, telemetry, REST installers, dirty sync, tools, and other advanced features.
Step 1 — Add the dependency
Add these repositories in your host app settings.gradle.kts:
pluginManagement {
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
google()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
mavenLocal() is used when testing a SDK build published with publishToMavenLocal. After Maven Central publish, mavenCentral() is enough for users.
Add kmp-sdk to your host shared module:
implementation("in.co.niteshkukreja:kmp-sdk:[GitHub Releases]")
Maven coordinates:
| Field | Value |
|---|
| Group | in.co.niteshkukreja |
| Artifact |
Example (shared/build.gradle.kts):
kotlin {
sourceSets {
commonMain.dependencies {
implementation("in.co.niteshkukreja:kmp-sdk:[GitHub Releases]")
}
}
}
Step 2 — Initialize the SDK
Call KmpSdk.init once at app startup. Install only the feature modules you need.
Example — Android (Application.onCreate):
Example — iOS / common only (no Android Context):
KmpSdk.init {
baseUrl = "https://api.example.com"
logLevel = LogLevel.INFO
install(UserFeatureModule)
}
Example — Advanced (custom config object):
val config = KmpSdkConfig(baseUrl = "https://api.example.com")
KmpSdk.init(config = config) {
register<OrderRepository> { ctx -> OrderRepositoryImpl(ctx) }
}
Step 3 — Resolve dependencies anywhere
After init, use KmpSdk.get<T>() to resolve registered types.
Example:
val getUsers = KmpSdk.get<GetUsersUseCase>()
val userRepo = KmpSdk.get<UserRepository>()
Step 4 — Create a feature module
Group registrations per domain (User, Product, Order…) in a KmpSdkModule.
- Path A/B: register use cases that call
KmpSdk.networkClient (see minimal module example).
- Path C: register database, repositories, and use cases (example below).
Example (UserFeatureModule.kt):
object UserFeatureModule : KmpSdkModule {
override fun register(registry: KmpSdkRegistry) {
registry.register<AppDatabase> {
AppDatabase(createAppDatabaseDriver())
}
registry.register<UserRepository> { ctx ->
UserRepositoryImpl(
localDataSource = UserLocalDataSource(registry.resolve()),
remoteDataSource = UserRemoteDataSource(ctx.networkClient),
ctx = ctx,
)
}
registry.register<GetUsersUseCase> {
GetUsersUseCase(registry.resolve())
}
registry.registerSyncTarget("users", registry.resolve<UserRepository>())
}
}
Then install it in init:
KmpSdk.init(this) {
baseUrl = "https://api.example.com"
install(UserFeatureModule)
}
Step 5 — Define your SQLDelight schema (host DB)
Path C only. Skip Steps 5–10 if this feature uses Path A or B.
Your app tables live in your AppDatabase.sq — not in the SDK database.
Example (AppDatabase.sq):
CREATE TABLE user_entity (
id TEXT NOT NULL PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL,
updated_at INTEGER NOT NULL,
synced_at INTEGER,
is_dirty INTEGER NOT NULL DEFAULT
);
selectAllUsers:
user_entity;
upsertUser:
REPLACE user_entity(id, name, email, updated_at, synced_at, is_dirty)
?;
Example — Android driver (AppDatabaseDriverFactory.android.kt):
actual fun createAppDatabaseDriver(): SqlDriver {
val context = KmpSdkAndroid.requireContext()
return AndroidSqliteDriver(AppDatabase.Schema, context, "host_app.db")
}
Step 6 — Create a local data source
Use SqlDelightListLocalDataSource to avoid boilerplate.
Example (UserLocalDataSource.kt):
Step 7 — Create a remote data source
Use KmpNetworkClient from the SDK — no direct Ktor dependency needed in the host module.
Example (UserRemoteDataSource.kt):
class UserRemoteDataSource(
private val networkClient: KmpNetworkClient,
) : RemoteListDataSource<UserDto> {
override suspend fun fetchAll(): KmpSdkResult<List<UserDto>> =
networkClient.get("/users")
}
Step 8 — Create a sync repository
Extend BaseSyncRepository for offline-first list sync.
Example (UserRepositoryImpl.kt):
class UserRepositoryImpl(
private val localDataSource: UserLocalDataSource,
private val remoteDataSource: UserRemoteDataSource,
ctx: KmpSdkContext,
) : BaseSyncRepository<User>(
tag = "UserRepository",
observeLocal = { localDataSource.observeAll() },
countLocal = { localDataSource.count() },
syncRemote = {
when (val result = remoteDataSource.fetchAll()) {
KmpSdkResult.Success -> {
localDataSource.replaceAll(result..map { it.toEntity() })
KmpSdkResult.Success()
}
KmpSdkResult.Failure -> result
}
},
connectivityMonitor = ctx.connectivityMonitor,
syncPolicy = ctx.config.syncPolicy,
logger = ctx.logger,
), UserRepository
Sync policy options:
| Policy | Behaviour |
|---|
STALE_WHILE_REVALIDATE | Show SQL cache; refresh when online (default) |
|
Step 9 — Create a use case
Thin wrapper over the repository.
Example (GetUsersUseCase.kt):
class GetUsersUseCase(
private val repository: UserRepository,
) {
fun observe() = repository.observeUsers()
suspend fun refresh() = repository.refreshUsers()
}
Step 10 — Build a ViewModel (headless MVI)
Use bindSyncList to wire observe + refresh + error handling in one call.
Example (UserListViewModel.kt):
class UserListViewModel(
scope: CoroutineScope,
getUsersUseCase: GetUsersUseCase,
userRepository: UserRepository,
) : MviViewModel<UserListState, UserListIntent, UserListEffect>(
initialState = UserListState(),
reducer = UserListReducer(),
scope = scope,
) {
private val usersController = bindSyncList(
scope = scope,
stateUpdater = { state, users -> state.copy(users = users) },
observe = getUsersUseCase::observe,
refresh = getUsersUseCase::refresh,
countLocal = userRepository::countLocal,
messageNotifier = KmpSdk.messageNotifier,
config = KmpSdk.config,
connectivityMonitor = KmpSdk.connectivityMonitor,
)
{
.dispatch(intent)
(intent == UserListIntent.Refresh) {
usersController.refreshNow(showLoading = )
}
}
}
Factory:
fun createUserListViewModel(scope: CoroutineScope) = UserListViewModel(
scope = scope,
getUsersUseCase = KmpSdk.get(),
userRepository = KmpSdk.get(),
)
Step 11 — Render state in your UI (client-owned)
Map DataState to your platform UI. The SDK does not ship widgets.
Example — Android Compose:
when (val users = state.users) {
is DataState.Loading -> CircularProgressIndicator()
is DataState.Success -> Text(users.data.joinToString { it.name })
is DataState.Failure -> Text(users.toErrorMessage())
is DataState.NoNetwork -> Text("Offline")
is DataState.Idle -> Unit
}
Example — iOS SwiftUI (bridge Kotlin state):
KmpSdk.shared.messageEventBus.events
.asObservableEvents()
.observe(scope: KmpSdk.shared.scope) { event in
print("[SDK] \(event.message)")
}
viewModel.state.asObservable().observe(scope: KmpSdk.shared.scope) { state in
}
Step 12 — Show messages (event bus)
SDK emits events; you show toast/snackbar/alert.
Example — collect in Android:
lifecycleScope.launch {
KmpSdk.messageEventBus.events.collect { event ->
Snackbar.make(rootView, event.message, Snackbar.LENGTH_SHORT).show()
}
}
Wire a Lifecycle-aware collector in your Activity/Fragment, or a dedicated presenter class in your app module.
Step 13 — Add authentication
Enable auth in init and provide a token refresh handler.
Example:
KmpSdk.init(this) {
baseUrl = "https://api.example.com"
auth {
enabled = true
useSecureTokenStore = true
}
tokenRefreshHandler = TokenRefreshHandler { refreshToken ->
KmpSdkResult.Success(TokenPair(newAccessToken, refreshToken))
}
install(UserFeatureModule)
}
KmpSdk.sessionManager.login("access-token", )
KmpSdk.sessionManager.events.collect { event ->
(event) {
SessionEvent.SessionExpired -> navigateToLogin()
SessionEvent.LoggedOut -> navigateToLogin()
->
}
}
401/403 responses automatically trigger token refresh and one retry when a handler is configured.
Step 14 — Handle API errors
Non-2xx responses map to KmpSdkError with HTTP metadata.
Example:
when (val result = userRepository.refreshUsers()) {
is KmpSdkResult.Success -> Unit
is KmpSdkResult.Failure -> {
val status = result.httpStatusCode
val rawJson = result.responseBody
val apiError = result.error.apiErrorOrNull
val emailErr = result.error.fieldErrors["email"]
}
}
Step 15 — Use HTTP cache (GET)
Enabled by default (enableHttpCache = true). Offline GET falls back to cache.
Example:
networkClient.get<List<UserDto>>("/users")
networkClient.get<UserDto>("/users/1", useCache = false)
Step 16 — Queue offline mutations
POST/PUT/PATCH/DELETE are queued when offline if queueMutationsWhenOffline = true.
Example:
networkClient.post(
path = "/users",
offlineBody = """{"name":"Jane"}""",
offlineHeaders = mapOf("Authorization" to "Bearer $token"),
) {
setJsonBody(payload, networkClient.json)
}
Or use the executor directly:
KmpSdk.offlineExecutor.executeOrQueue(
payload = OfflineRequestPayload(
method = "POST",
url = "/users",
body = """{"name":"Jane"}""",
),
) {
networkClient.post("/users") { setBody(payload) }
}
Queue replays automatically when connectivity returns.
Step 17 — Sync on reconnect
When autoSyncOnReconnect = true, network restore runs full sync (offline HTTP queue + registered sync targets + pending domain actions).
Example — manual sync:
KmpSdk.syncCoordinator.syncAll()
KmpSdk.debugger.triggerFullSync()
Register sync targets in your feature module:
registry.registerSyncTarget("users", userRepository)
Step 18 — Paginated lists
Use BasePaginatedRepository, SqlDelightPaginatedLocalDataSource, and PaginatedListController for page-based APIs.
Example — load pages:
val getProducts = KmpSdk.get<GetProductsUseCase>()
getProducts.loadInitial(pageSize = 10)
getProducts.loadMore()
getProducts.observe().collect { products ->
}
Example — paginated ViewModel binder:
val productsController = PaginatedListController(
scope = scope,
repository = productRepository,
onStateChange = { paginatedState ->
setState { it.copy(products = paginatedState) }
},
)
productsController.start()
productsController.loadMore()
Use SqlDelightPaginatedLocalDataSource for local storage with replaceAll + appendAll.
Step 19 — Debug & diagnostics (headless)
Inspect SDK state from your own debug menu.
Example:
val snapshot = KmpSdk.debugger.snapshot()
KmpSdk.debugger.inspectOfflineQueue()
KmpSdk.debugger.clearOfflineQueue()
KmpSdk.debugger.purgeCache()
Build your own debug screen in the host app using KmpSdk.debugger APIs.
Step 20 — Full configuration reference
Example — all common init options (core + v1.4):
v1.4 — Rich SDK additions
Each item includes why it exists and one example. Cross-reference Step 20 for a single init block with all flags.
SDK profiles
Why: Sensible defaults per environment without tuning 20 flags.
KmpSdk.init(this) {
profile = SdkProfile.ENTERPRISE
baseUrl = "https://api.example.com"
install(UserFeatureModule)
}
Profiles: DEVELOPMENT, STAGING, PRODUCTION, ENTERPRISE.
Multi-environment init
Why: Dev/stage/prod configs in one place.
KmpSdk.init(this) {
environmentName = "staging"
environments {
dev { baseUrl = "https://dev.api.com"; enableCurlLogging = true }
staging { baseUrl = "https://staging.api.com" }
prod { baseUrl = "https://api.com"; enableRequestLogging = false }
}
install(UserFeatureModule)
}
Startup validation
Why: Fail fast in debug when config/modules are wrong.
val result = KmpSdk.validate()
result.issues.forEach { println("${it.level}: ${it.message}") }
Enabled by default via validateOnStartup = true.
Telemetry hooks
Why: Pipe API/sync/session events to Firebase, Datadog, etc. without wrapping every repo.
KmpSdk.telemetry.addListener { event ->
when (event) {
is TelemetryEvent.ApiCallCompleted -> analytics.log("api", event.path)
is TelemetryEvent.SyncCompleted -> analytics.log("sync", event.refreshedRepos.toString())
else -> Unit
}
}
Multi-tenant switching
Why: B2B apps swap API base URL at runtime.
KmpSdk.tenantManager.switchTenant(
tenantId = "acme",
baseUrl = "https://acme.api.example.com",
headers = mapOf("X-Tenant" to "acme"),
)
Remote config block
Why: Tune cache TTL / flags from server without app update.
KmpSdk.init(this) {
baseUrl = "https://api.example.com"
remoteConfig { fetchRemoteConfigMap() }
install(UserFeatureModule)
}
val ttl = KmpSdk.remoteConfig.getLong("default_cache_ttl_millis")
val flag = KmpSdk.remoteConfig.getBoolean("feature_x_enabled", default = false)
KmpSdk.remoteConfig.values.collect { map -> }
REST list installer (no custom repository class)
Why: Standard list APIs without writing a custom Repository implementation class.
Path: Path C — you still provide local observe/count/replace (SQL or in-memory store you own). For online-only lists, use Path A with networkClient.get instead.
KmpSdk.init(this) {
baseUrl = "https://api.example.com"
install(UserFeatureModule)
}
installRestListFeature(
RestListFeatureConfig<User, UserDto>(
name = "users",
path = "/users",
observeLocal = { userLocal.observeAll() },
countLocal = { userLocal.count() },
replaceLocal = { dtos -> userLocal.replaceAll(dtos.map { it.toEntity() }) },
),
)
REST mutation use case
Why: Standard POST/PUT/PATCH/DELETE with optional offline queue (Path B/C).
val createUser = RestMutationUseCase.create<CreateUserBody>(
networkClient = KmpSdk.networkClient,
path = "/users",
method = HttpMethod.Post,
onSuccess = { userRepository.refresh() },
)
createUser.execute(CreateUserBody(name = "Jane"))
Dirty record sync
Why: Push local SQL edits marked is_dirty = 1 without custom outbox code.
KmpSdk.dirtySyncCoordinator.syncDirty(object : DirtySyncTarget<UserEntity> {
override suspend fun loadDirty() = local.getDirtyUsers()
override suspend fun push = remote.update(record)
= local.markClean(id)
})
Offline domain actions
Why: Queue business actions separately from raw HTTP replay.
KmpSdk.offlineActions.registerHandler("FAVORITE_POST") { payload ->
networkClient.post("/posts/${payload.entityId}/favorite") {
setJsonBodyWithOfflineCapture(payload.payloadJson)
}
}
KmpSdk.offlineActions.enqueue(
actionType = "FAVORITE_POST",
entityId = postId,
payloadJson = """{"postId":"$postId"}""",
)
KmpSdk.offlineActions.replayPending()
Request deduplication
Why: Prevent duplicate in-flight GETs from list + detail screens.
KmpSdk.init(this) {
enableRequestDeduplication = true
baseUrl = "https://api.example.com"
}
Rate-limit backoff
Why: Handle 429/503 automatically.
KmpSdk.init(this) {
enableRateLimitBackoff = true
maxRateLimitRetries = 3
baseUrl = "https://api.example.com"
}
Certificate pinning (Android)
Why: Enterprise security without custom OkHttp setup.
KmpSdk.init(this) {
certificatePins = listOf("api.example.com/abcdef1234567890=")
baseUrl = "https://api.example.com"
}
File upload helper
Why: Multipart uploads without Ktor boilerplate in host module.
KmpSdk.networkClient.uploadMultipart<UploadResponse>(
MultipartUploadRequest(
path = "/upload",
parts = listOf(FileUploadPart("file", "photo.jpg", imageBytes, "image/jpeg")),
fields = mapOf("userId" to "123"),
),
)
Background sync scheduler
Why: Refresh data periodically, not only on reconnect. Each tick runs syncAll() and replays pending offline domain actions.
KmpSdk.init(this) {
backgroundSyncIntervalMillis = 15 * 60 * 1000L
autoSyncOnReconnect = true
baseUrl = "https://api.example.com"
}
Feature generator CLI
Why: New entity scaffold in seconds.
python tools/feature-generator/generate.py \
--config tools/feature-generator/examples/order.yaml \
--output shared/src/commonMain/kotlin \
--package com.yourapp.feature
See tools/feature-generator/README.md.
Migration helper
Why: Safe SQLDelight schema upgrades in host apps.
See tools/migration-helper/README.md.
Quick reference — KmpSdk globals (updated)
Project structure
KmpSDK/
├── kmp-sdk/
│ └── com/kmpsdk/
│ ├── KmpSdk.kt
│ ├── KmpSdkInitBuilder.kt
│ ├── core/
│ ├── data/
│ ├── domain/
│ ├── presentation/
│ ├── debug/
│ └── platform/
├── tools/
│ ├── feature-generator/
│ └── migration-helper/
└── settings.gradle.kts
SDK layers
Adding a new feature (checklist)
First: pick a path in Choose your integration path.
Path A — Online only
- Create DTO (+ mapper if needed)
- Create use case using
KmpSdk.networkClient
- Register in
XxxFeatureModule; install in KmpSdk.init
- Build ViewModel + platform UI
Path B — Network-first + SDK cache
Same as Path A; set enableHttpCache = true and SyncPolicy.NETWORK_FIRST in init.
Path C — Full offline-first
Path C shortcuts (v1.4): run tools/feature-generator/generate.py to scaffold steps 3–7; register offline action handlers with KmpSdk.offlineActions.registerHandler.
Build
Requires JDK 17+ (Android Studio’s bundled JBR works).
Windows: use .\gradlew.bat instead of ./gradlew.
gradlew.bat auto-detects Android Studio’s JDK. If you still see a Java 8 error, copy gradle/jdk.home.example → gradle/jdk.home and set your JDK path, or run:
$env:JAVA_HOME = "C:\Program Files\Android\Android Studio\jbr"
Verify SDK build (step by step)
Run these from the repo root to confirm the SDK compiles, packages, and tests pass.
Step 1 — Android compile
./gradlew :kmp-sdk:compileDebugKotlinAndroid
# Windows
.\gradlew.bat :kmp-sdk:compileDebugKotlinAndroid
Step 2 — iOS compile (simulator)
Requires a Mac with Xcode for this target.
./gradlew :kmp-sdk:compileKotlinIosSimulatorArm64
# Windows (skipped automatically if iOS targets are unavailable)
.\gradlew.bat :kmp-sdk:compileKotlinIosSimulatorArm64
Step 3 — Full release AAR
./gradlew :kmp-sdk:assembleRelease
# Windows
.\gradlew.bat :kmp-sdk:assembleRelease
Output: kmp-sdk/build/outputs/aar/
Step 4 — Unit tests
./gradlew :kmp-sdk:cleanTest :kmp-sdk:allTests
# Windows
.\gradlew.bat :kmp-sdk:cleanTest :kmp-sdk:allTests
Step 5 — Publish to Maven Central (maintainer)
.\gradlew.bat :kmp-sdk:publishToMavenLocal
.\gradlew.bat :kmp-sdk:publishToMavenCentral
See Publishing to Maven Central for GPG + Sonatype setup.
iOS framework (optional, Mac only)
To produce an Xcode framework bundle instead of compile-only:
./gradlew :kmp-sdk:linkDebugFrameworkIosSimulatorArm64
Output: kmp-sdk/build/xcode-frameworks
What is NOT in the SDK
- Compose / SwiftUI / Material components
- Shared screens, themes, navigation
- Platform toast/snackbar implementations
- Hardcoded domain entities (User, Product, etc.) — those belong in your host app
Your app stays in control of UX; KmpSDK handles infrastructure.