
Headless, plug-and-play SDK offering networking, auth, HTTP cache, offline queue, logging, telemetry and MVI contracts; modular feature modules, dependency registry, and three integration paths including full offline-first with host SQL.
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).
| KmpSDK provides (infrastructure) | Host app provides (your code) |
|---|---|
KmpSdk.init, registry, modules |
Feature modules, DTOs, use cases |
| Ktor client, auth plugin, error parsing | ViewModels, screens, navigation |
| Optional offline queue, HTTP cache, sync helpers | Path C only: your SQL schema, local/remote sources, repos |
MviViewModel, DataState, message bus |
Toast/snackbar/alert UI |
Use one path per feature (e.g. login = Path A, product catalog = Path C).
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 A — Online only | Path B — SDK HTTP cache | Path C — Full offline-first | |
|---|---|---|---|
| Best for | Login, forms, one-shot screens | Lists that can show last fetch offline | Feeds, catalogs, field apps |
| Your SQL tables | Not required | Not required | Required (AppDatabase.sq) |
| Local data source | Not required | Not required | Required |
| Remote + sync repository | Not required | Not required | Required (or installRestListFeature) |
| Typical API | KmpSdk.networkClient.get/post |
Same as A |
BaseSyncRepository, bindSyncList
|
| README steps | 1–4, then Path A example | 1–4 + Path B init | 1–10 (full guide) |
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).
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) // modules without SQL are fine
}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).
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 // or true if mutations should queue
install(ProductListModule)
}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.
shared module| Steps | Applies to |
|---|---|
| 1–4 | All paths (dependency, init, resolve, feature module) |
| 5–10 | Path C only — SQL, local, remote, repository, use case, ViewModel |
| 11–20 | Optional/advanced (auth, cache, offline queue, config, v1.4) |
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.
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 | kmp-sdk |
| Version |
1.0.0 (see GitHub Releases) |
Example (shared/build.gradle.kts):
kotlin {
sourceSets {
commonMain.dependencies {
implementation("in.co.niteshkukreja:kmp-sdk:[GitHub Releases]")
}
}
}Call KmpSdk.init once at app startup. Install only the feature modules you need.
Example — Android (Application.onCreate):
import com.kmpsdk.KmpSdk
import com.kmpsdk.init
import com.kmpsdk.core.config.SdkProfile
import com.kmpsdk.core.logger.LogLevel
import com.kmpsdk.core.telemetry.KmpSdkTelemetry
import com.yourapp.feature.user.UserFeatureModule
class App : Application() {
override fun onCreate() {
super.onCreate()
KmpSdk.init(this) {
profile = if (BuildConfig.DEBUG) SdkProfile.DEVELOPMENT else SdkProfile.STAGING
baseUrl = "https://api.example.com"
logLevel = LogLevel.DEBUG
enableRequestLogging = true
autoSyncOnReconnect = true
validateOnStartup = true
install(UserFeatureModule)
}
KmpSdk.telemetry.addListener { event -> /* analytics */ }
}
}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) }
}After init, use KmpSdk.get<T>() to resolve registered types.
Example:
val getUsers = KmpSdk.get<GetUsersUseCase>()
val userRepo = KmpSdk.get<UserRepository>()Group registrations per domain (User, Product, Order…) in a KmpSdkModule.
KmpSdk.networkClient (see minimal module example).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)
}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 0
);
selectAllUsers:
SELECT * FROM user_entity;
upsertUser:
INSERT OR REPLACE INTO user_entity(id, name, email, updated_at, synced_at, is_dirty)
VALUES ?;Example — Android driver (AppDatabaseDriverFactory.android.kt):
actual fun createAppDatabaseDriver(): SqlDriver {
val context = KmpSdkAndroid.requireContext()
return AndroidSqliteDriver(AppDatabase.Schema, context, "host_app.db")
}Use SqlDelightListLocalDataSource to avoid boilerplate.
Example (UserLocalDataSource.kt):
class UserLocalDataSource(
private val database: AppDatabase,
) : LocalListDataSource<User> {
private val store = SqlDelightListLocalDataSource(
observeRows = {
database.appDatabaseQueries.selectAllUsers()
.asFlow()
.mapToList(Dispatchers.Default)
},
toDomain = { it.toDomain() },
countRows = { database.appDatabaseQueries.countUsers().executeAsOne() },
replaceRows = { entities ->
database.transaction {
database.appDatabaseQueries.deleteAllUsers()
entities.forEach { database.appDatabaseQueries.upsertUser(/* … */) }
}
},
)
override fun observeAll() = store.observeAll()
override suspend fun count() = store.count()
suspend fun replaceAll(entities: List<User_entity>) = store.replaceAll(entities)
}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")
}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()) {
is KmpSdkResult.Success -> {
localDataSource.replaceAll(result.data.map { it.toEntity() })
KmpSdkResult.Success(Unit)
}
is KmpSdkResult.Failure -> result
}
},
connectivityMonitor = ctx.connectivityMonitor,
syncPolicy = ctx.config.syncPolicy,
logger = ctx.logger,
), UserRepositorySync policy options:
| Policy | Behaviour |
|---|---|
STALE_WHILE_REVALIDATE |
Show SQL cache; refresh when online (default) |
CACHE_FIRST |
Prefer local; refresh when online |
NETWORK_FIRST |
Require network; fail when offline |
Thin wrapper over the repository.
Example (GetUsersUseCase.kt):
class GetUsersUseCase(
private val repository: UserRepository,
) {
fun observe() = repository.observeUsers()
suspend fun refresh() = repository.refreshUsers()
}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,
)
override fun dispatch(intent: UserListIntent) {
super.dispatch(intent)
if (intent == UserListIntent.Refresh) {
usersController.refreshNow(showLoading = true)
}
}
}Factory:
fun createUserListViewModel(scope: CoroutineScope) = UserListViewModel(
scope = scope,
getUsersUseCase = KmpSdk.get(),
userRepository = KmpSdk.get(),
)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
// map UserListState → SwiftUI
}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.
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 ->
// call your refresh API
KmpSdkResult.Success(TokenPair(newAccessToken, refreshToken))
}
install(UserFeatureModule)
}
// After login
KmpSdk.sessionManager.login("access-token", "refresh-token")
// Listen for session events
KmpSdk.sessionManager.events.collect { event ->
when (event) {
is SessionEvent.SessionExpired -> navigateToLogin()
is SessionEvent.LoggedOut -> navigateToLogin()
else -> Unit
}
}401/403 responses automatically trigger token refresh and one retry when a handler is configured.
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 // e.g. 422
val rawJson = result.responseBody // raw body
val apiError = result.error.apiErrorOrNull // typed parse
val emailErr = result.error.fieldErrors["email"]
}
}Enabled by default (enableHttpCache = true). Offline GET falls back to cache.
Example:
// Cached automatically
networkClient.get<List<UserDto>>("/users")
// Skip cache for this call
networkClient.get<UserDto>("/users/1", useCache = false)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.
When autoSyncOnReconnect = true, network restore runs full sync (offline HTTP queue + registered sync targets + pending domain actions).
Example — manual sync:
KmpSdk.syncCoordinator.syncAll()
// Or via debugger helper
KmpSdk.debugger.triggerFullSync()Register sync targets in your feature module:
registry.registerSyncTarget("users", userRepository)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 ->
// list grows in SQL as pages append
}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.
Inspect SDK state from your own debug menu.
Example:
val snapshot = KmpSdk.debugger.snapshot()
// snapshot.isOnline, snapshot.pendingOfflineRequests, snapshot.sessionState, …
KmpSdk.debugger.inspectOfflineQueue()
KmpSdk.debugger.clearOfflineQueue()
KmpSdk.debugger.purgeCache()Build your own debug screen in the host app using KmpSdk.debugger APIs.
Example — all common init options (core + v1.4):
KmpSdk.init(this) {
// Core
baseUrl = "https://api.example.com"
logLevel = LogLevel.INFO
enableRequestLogging = true
enableResponseBodyLogging = false
enableCurlLogging = false
defaultCacheTtlMillis = 300_000
offlineReplayStrategy = OfflineReplayStrategy.PRIORITY
maxOfflineRetries = 3
syncPolicy = SyncPolicy.STALE_WHILE_REVALIDATE
enableHttpCache = true
autoSyncOnReconnect = true
autoRefreshOnObserve = false
queueMutationsWhenOffline = true
auth { enabled = true; useSecureTokenStore = true }
tokenRefreshHandler = myRefreshHandler
// v1.4
profile = SdkProfile.ENTERPRISE
environmentName = "staging"
environments {
dev { baseUrl = "https://dev.api.com"; enableCurlLogging = true }
staging { baseUrl = "https://staging.api.com" }
prod { baseUrl = "https://api.com"; enableRequestLogging = false }
}
remoteConfig { fetchRemoteConfigMap() }
enableRequestDeduplication = true
enableRateLimitBackoff = true
maxRateLimitRetries = 3
certificatePins = listOf("api.example.com/abcdef1234567890=")
backgroundSyncIntervalMillis = 900_000
validateOnStartup = true
install(UserFeatureModule)
}
// After init — telemetry (not part of init DSL)
KmpSdk.telemetry.addListener { event -> /* analytics */ }Each item includes why it exists and one example. Cross-reference Step 20 for a single init block with all flags.
| Feature | Section |
|---|---|
| SDK profiles | SDK profiles |
| Multi-environment init | Multi-environment init |
| Startup validation | Startup validation |
| Telemetry hooks | Telemetry hooks |
| Multi-tenant switching | Multi-tenant switching |
| Remote config | Remote config block |
| REST list installer | REST list installer |
| REST mutation use case | REST mutation use case |
| Dirty record sync | Dirty record sync |
| Offline domain actions | Offline domain actions |
| Request deduplication | Request deduplication |
| Rate-limit backoff | Rate-limit backoff |
| Certificate pinning | Certificate pinning |
| File upload helper | File upload helper |
| Background sync scheduler | Background sync scheduler |
| Feature generator CLI | Feature generator CLI |
| Migration helper | Migration helper |
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.
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)
}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.
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
}
}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"),
)Why: Tune cache TTL / flags from server without app update.
KmpSdk.init(this) {
baseUrl = "https://api.example.com"
remoteConfig { fetchRemoteConfigMap() }
install(UserFeatureModule)
}
// Read values after init (RemoteConfigStore applies supported keys to config)
val ttl = KmpSdk.remoteConfig.getLong("default_cache_ttl_millis")
val flag = KmpSdk.remoteConfig.getBoolean("feature_x_enabled", default = false)
KmpSdk.remoteConfig.values.collect { map -> /* react to updates */ }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) // register installRestListFeature inside module
}
// Inside KmpSdkModule.register (after UserLocalDataSource exists):
installRestListFeature(
RestListFeatureConfig<User, UserDto>(
name = "users",
path = "/users",
observeLocal = { userLocal.observeAll() },
countLocal = { userLocal.count() },
replaceLocal = { dtos -> userLocal.replaceAll(dtos.map { it.toEntity() }) },
),
)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"))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(record: UserEntity) = remote.update(record)
override suspend fun markClean(id: String) = local.markClean(id)
})Why: Queue business actions separately from raw HTTP replay.
// Register handler once (e.g. in feature module or after init)
KmpSdk.offlineActions.registerHandler("FAVORITE_POST") { payload ->
networkClient.post("/posts/${payload.entityId}/favorite") {
setJsonBodyWithOfflineCapture(payload.payloadJson)
}
}
// Enqueue when offline or for deferred work
KmpSdk.offlineActions.enqueue(
actionType = "FAVORITE_POST",
entityId = postId,
payloadJson = """{"postId":"$postId"}""",
)
// Replay runs automatically during background sync / full sync; manual:
KmpSdk.offlineActions.replayPending()Why: Prevent duplicate in-flight GETs from list + detail screens.
KmpSdk.init(this) {
enableRequestDeduplication = true
baseUrl = "https://api.example.com"
}
// GET /users called twice concurrently → one network callWhy: Handle 429/503 automatically.
KmpSdk.init(this) {
enableRateLimitBackoff = true
maxRateLimitRetries = 3
baseUrl = "https://api.example.com"
}Why: Enterprise security without custom OkHttp setup.
KmpSdk.init(this) {
certificatePins = listOf("api.example.com/abcdef1234567890=")
baseUrl = "https://api.example.com"
}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"),
),
)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"
}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.featureSee tools/feature-generator/README.md.
Why: Safe SQLDelight schema upgrades in host apps.
See tools/migration-helper/README.md.
| API | Purpose |
|---|---|
KmpSdk.init { } |
Initialize SDK + install modules |
KmpSdk.validate() |
Startup health check |
KmpSdk.get<T>() |
Resolve registered dependency |
KmpSdk.networkClient |
Ktor HTTP client + cache + dedup |
KmpSdk.sessionManager |
Login / logout / token refresh |
KmpSdk.syncCoordinator |
Full sync orchestration |
KmpSdk.dirtySyncCoordinator |
Push dirty SQL records |
KmpSdk.offlineExecutor |
Queue/replay offline HTTP |
KmpSdk.offlineActions |
Domain offline action queue |
KmpSdk.tenantManager |
Runtime tenant/base URL switch |
KmpSdk.remoteConfig |
Server-driven config values |
KmpSdk.telemetry |
Analytics/diagnostics hooks |
KmpSdk.messageEventBus |
Toast/snackbar event stream |
KmpSdk.debugger |
Diagnostics snapshot & actions |
KmpSdk.connectivityMonitor |
Online/offline status |
KmpSdk.scope |
Shared coroutine scope |
KmpSDK/
├── kmp-sdk/ # Published SDK (headless)
│ └── com/kmpsdk/
│ ├── KmpSdk.kt # Global entry point
│ ├── KmpSdkInitBuilder.kt
│ ├── core/ # Config, auth, DI, messaging, connectivity
│ ├── data/ # Network, cache, offline, SQLDelight, repos
│ ├── domain/ # Errors, contracts, pagination, sync policy
│ ├── presentation/ # MVI, DataState, binders (no UI widgets)
│ ├── debug/ # Headless debug API
│ └── platform/ # Swift Flow bridges
├── tools/
│ ├── feature-generator/ # YAML → feature scaffold CLI
│ └── migration-helper/ # SQLDelight migration guide
└── settings.gradle.kts
| Layer | Package | Responsibility |
|---|---|---|
| Core | core.* |
Init, config, auth, registry, logging, message bus |
| Domain | domain.* |
Errors, repository contracts, sync policy |
| Data | data.* |
Network, cache, offline queue, base repositories |
| Presentation | presentation.* |
MVI base, DataState, bindSyncList (no widgets) |
First: pick a path in Choose your integration path.
KmpSdk.networkClient
XxxFeatureModule; install in KmpSdk.init
Same as Path A; set enableHttpCache = true and SyncPolicy.NETWORK_FIRST in init.
AppDatabase.sq
SqlDelightListLocalDataSource or paginated variant)networkClient.get/post/…) — or use installRestListFeature / RestMutationUseCase for standard RESTBaseSyncRepository or BasePaginatedRepository) unless using installRestListFeature
XxxFeatureModule and registerSyncTarget
install(XxxFeatureModule) in KmpSdk.init
bindSyncList or PaginatedListController
Path C shortcuts (v1.4): run tools/feature-generator/generate.py to scaffold steps 3–7; register offline action handlers with KmpSdk.offlineActions.registerHandler.
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"Run these from the repo root to confirm the SDK compiles, packages, and tests pass.
Step 1 — Android compile
# Android
./gradlew :kmp-sdk:compileDebugKotlinAndroid# Windows
.\gradlew.bat :kmp-sdk:compileDebugKotlinAndroidStep 2 — iOS compile (simulator)
Requires a Mac with Xcode for this target.
# iOS (simulator)
./gradlew :kmp-sdk:compileKotlinIosSimulatorArm64# Windows (skipped automatically if iOS targets are unavailable)
.\gradlew.bat :kmp-sdk:compileKotlinIosSimulatorArm64Step 3 — Full release AAR
# Full release AAR
./gradlew :kmp-sdk:assembleRelease# Windows
.\gradlew.bat :kmp-sdk:assembleReleaseOutput: kmp-sdk/build/outputs/aar/
Step 4 — Unit tests
# Unit tests
./gradlew :kmp-sdk:cleanTest :kmp-sdk:allTests# Windows
.\gradlew.bat :kmp-sdk:cleanTest :kmp-sdk:allTestsStep 5 — Publish to Maven Central (maintainer)
.\gradlew.bat :kmp-sdk:publishToMavenLocal
.\gradlew.bat :kmp-sdk:publishToMavenCentralSee Publishing to Maven Central for GPG + Sonatype setup.
To produce an Xcode framework bundle instead of compile-only:
./gradlew :kmp-sdk:linkDebugFrameworkIosSimulatorArm64Output: kmp-sdk/build/xcode-frameworks
Your app stays in control of UX; KmpSDK handles infrastructure.
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).
| KmpSDK provides (infrastructure) | Host app provides (your code) |
|---|---|
KmpSdk.init, registry, modules |
Feature modules, DTOs, use cases |
| Ktor client, auth plugin, error parsing | ViewModels, screens, navigation |
| Optional offline queue, HTTP cache, sync helpers | Path C only: your SQL schema, local/remote sources, repos |
MviViewModel, DataState, message bus |
Toast/snackbar/alert UI |
Use one path per feature (e.g. login = Path A, product catalog = Path C).
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 A — Online only | Path B — SDK HTTP cache | Path C — Full offline-first | |
|---|---|---|---|
| Best for | Login, forms, one-shot screens | Lists that can show last fetch offline | Feeds, catalogs, field apps |
| Your SQL tables | Not required | Not required | Required (AppDatabase.sq) |
| Local data source | Not required | Not required | Required |
| Remote + sync repository | Not required | Not required | Required (or installRestListFeature) |
| Typical API | KmpSdk.networkClient.get/post |
Same as A |
BaseSyncRepository, bindSyncList
|
| README steps | 1–4, then Path A example | 1–4 + Path B init | 1–10 (full guide) |
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).
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) // modules without SQL are fine
}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).
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 // or true if mutations should queue
install(ProductListModule)
}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.
shared module| Steps | Applies to |
|---|---|
| 1–4 | All paths (dependency, init, resolve, feature module) |
| 5–10 | Path C only — SQL, local, remote, repository, use case, ViewModel |
| 11–20 | Optional/advanced (auth, cache, offline queue, config, v1.4) |
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.
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 | kmp-sdk |
| Version |
1.0.0 (see GitHub Releases) |
Example (shared/build.gradle.kts):
kotlin {
sourceSets {
commonMain.dependencies {
implementation("in.co.niteshkukreja:kmp-sdk:[GitHub Releases]")
}
}
}Call KmpSdk.init once at app startup. Install only the feature modules you need.
Example — Android (Application.onCreate):
import com.kmpsdk.KmpSdk
import com.kmpsdk.init
import com.kmpsdk.core.config.SdkProfile
import com.kmpsdk.core.logger.LogLevel
import com.kmpsdk.core.telemetry.KmpSdkTelemetry
import com.yourapp.feature.user.UserFeatureModule
class App : Application() {
override fun onCreate() {
super.onCreate()
KmpSdk.init(this) {
profile = if (BuildConfig.DEBUG) SdkProfile.DEVELOPMENT else SdkProfile.STAGING
baseUrl = "https://api.example.com"
logLevel = LogLevel.DEBUG
enableRequestLogging = true
autoSyncOnReconnect = true
validateOnStartup = true
install(UserFeatureModule)
}
KmpSdk.telemetry.addListener { event -> /* analytics */ }
}
}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) }
}After init, use KmpSdk.get<T>() to resolve registered types.
Example:
val getUsers = KmpSdk.get<GetUsersUseCase>()
val userRepo = KmpSdk.get<UserRepository>()Group registrations per domain (User, Product, Order…) in a KmpSdkModule.
KmpSdk.networkClient (see minimal module example).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)
}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 0
);
selectAllUsers:
SELECT * FROM user_entity;
upsertUser:
INSERT OR REPLACE INTO user_entity(id, name, email, updated_at, synced_at, is_dirty)
VALUES ?;Example — Android driver (AppDatabaseDriverFactory.android.kt):
actual fun createAppDatabaseDriver(): SqlDriver {
val context = KmpSdkAndroid.requireContext()
return AndroidSqliteDriver(AppDatabase.Schema, context, "host_app.db")
}Use SqlDelightListLocalDataSource to avoid boilerplate.
Example (UserLocalDataSource.kt):
class UserLocalDataSource(
private val database: AppDatabase,
) : LocalListDataSource<User> {
private val store = SqlDelightListLocalDataSource(
observeRows = {
database.appDatabaseQueries.selectAllUsers()
.asFlow()
.mapToList(Dispatchers.Default)
},
toDomain = { it.toDomain() },
countRows = { database.appDatabaseQueries.countUsers().executeAsOne() },
replaceRows = { entities ->
database.transaction {
database.appDatabaseQueries.deleteAllUsers()
entities.forEach { database.appDatabaseQueries.upsertUser(/* … */) }
}
},
)
override fun observeAll() = store.observeAll()
override suspend fun count() = store.count()
suspend fun replaceAll(entities: List<User_entity>) = store.replaceAll(entities)
}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")
}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()) {
is KmpSdkResult.Success -> {
localDataSource.replaceAll(result.data.map { it.toEntity() })
KmpSdkResult.Success(Unit)
}
is KmpSdkResult.Failure -> result
}
},
connectivityMonitor = ctx.connectivityMonitor,
syncPolicy = ctx.config.syncPolicy,
logger = ctx.logger,
), UserRepositorySync policy options:
| Policy | Behaviour |
|---|---|
STALE_WHILE_REVALIDATE |
Show SQL cache; refresh when online (default) |
CACHE_FIRST |
Prefer local; refresh when online |
NETWORK_FIRST |
Require network; fail when offline |
Thin wrapper over the repository.
Example (GetUsersUseCase.kt):
class GetUsersUseCase(
private val repository: UserRepository,
) {
fun observe() = repository.observeUsers()
suspend fun refresh() = repository.refreshUsers()
}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,
)
override fun dispatch(intent: UserListIntent) {
super.dispatch(intent)
if (intent == UserListIntent.Refresh) {
usersController.refreshNow(showLoading = true)
}
}
}Factory:
fun createUserListViewModel(scope: CoroutineScope) = UserListViewModel(
scope = scope,
getUsersUseCase = KmpSdk.get(),
userRepository = KmpSdk.get(),
)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
// map UserListState → SwiftUI
}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.
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 ->
// call your refresh API
KmpSdkResult.Success(TokenPair(newAccessToken, refreshToken))
}
install(UserFeatureModule)
}
// After login
KmpSdk.sessionManager.login("access-token", "refresh-token")
// Listen for session events
KmpSdk.sessionManager.events.collect { event ->
when (event) {
is SessionEvent.SessionExpired -> navigateToLogin()
is SessionEvent.LoggedOut -> navigateToLogin()
else -> Unit
}
}401/403 responses automatically trigger token refresh and one retry when a handler is configured.
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 // e.g. 422
val rawJson = result.responseBody // raw body
val apiError = result.error.apiErrorOrNull // typed parse
val emailErr = result.error.fieldErrors["email"]
}
}Enabled by default (enableHttpCache = true). Offline GET falls back to cache.
Example:
// Cached automatically
networkClient.get<List<UserDto>>("/users")
// Skip cache for this call
networkClient.get<UserDto>("/users/1", useCache = false)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.
When autoSyncOnReconnect = true, network restore runs full sync (offline HTTP queue + registered sync targets + pending domain actions).
Example — manual sync:
KmpSdk.syncCoordinator.syncAll()
// Or via debugger helper
KmpSdk.debugger.triggerFullSync()Register sync targets in your feature module:
registry.registerSyncTarget("users", userRepository)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 ->
// list grows in SQL as pages append
}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.
Inspect SDK state from your own debug menu.
Example:
val snapshot = KmpSdk.debugger.snapshot()
// snapshot.isOnline, snapshot.pendingOfflineRequests, snapshot.sessionState, …
KmpSdk.debugger.inspectOfflineQueue()
KmpSdk.debugger.clearOfflineQueue()
KmpSdk.debugger.purgeCache()Build your own debug screen in the host app using KmpSdk.debugger APIs.
Example — all common init options (core + v1.4):
KmpSdk.init(this) {
// Core
baseUrl = "https://api.example.com"
logLevel = LogLevel.INFO
enableRequestLogging = true
enableResponseBodyLogging = false
enableCurlLogging = false
defaultCacheTtlMillis = 300_000
offlineReplayStrategy = OfflineReplayStrategy.PRIORITY
maxOfflineRetries = 3
syncPolicy = SyncPolicy.STALE_WHILE_REVALIDATE
enableHttpCache = true
autoSyncOnReconnect = true
autoRefreshOnObserve = false
queueMutationsWhenOffline = true
auth { enabled = true; useSecureTokenStore = true }
tokenRefreshHandler = myRefreshHandler
// v1.4
profile = SdkProfile.ENTERPRISE
environmentName = "staging"
environments {
dev { baseUrl = "https://dev.api.com"; enableCurlLogging = true }
staging { baseUrl = "https://staging.api.com" }
prod { baseUrl = "https://api.com"; enableRequestLogging = false }
}
remoteConfig { fetchRemoteConfigMap() }
enableRequestDeduplication = true
enableRateLimitBackoff = true
maxRateLimitRetries = 3
certificatePins = listOf("api.example.com/abcdef1234567890=")
backgroundSyncIntervalMillis = 900_000
validateOnStartup = true
install(UserFeatureModule)
}
// After init — telemetry (not part of init DSL)
KmpSdk.telemetry.addListener { event -> /* analytics */ }Each item includes why it exists and one example. Cross-reference Step 20 for a single init block with all flags.
| Feature | Section |
|---|---|
| SDK profiles | SDK profiles |
| Multi-environment init | Multi-environment init |
| Startup validation | Startup validation |
| Telemetry hooks | Telemetry hooks |
| Multi-tenant switching | Multi-tenant switching |
| Remote config | Remote config block |
| REST list installer | REST list installer |
| REST mutation use case | REST mutation use case |
| Dirty record sync | Dirty record sync |
| Offline domain actions | Offline domain actions |
| Request deduplication | Request deduplication |
| Rate-limit backoff | Rate-limit backoff |
| Certificate pinning | Certificate pinning |
| File upload helper | File upload helper |
| Background sync scheduler | Background sync scheduler |
| Feature generator CLI | Feature generator CLI |
| Migration helper | Migration helper |
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.
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)
}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.
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
}
}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"),
)Why: Tune cache TTL / flags from server without app update.
KmpSdk.init(this) {
baseUrl = "https://api.example.com"
remoteConfig { fetchRemoteConfigMap() }
install(UserFeatureModule)
}
// Read values after init (RemoteConfigStore applies supported keys to config)
val ttl = KmpSdk.remoteConfig.getLong("default_cache_ttl_millis")
val flag = KmpSdk.remoteConfig.getBoolean("feature_x_enabled", default = false)
KmpSdk.remoteConfig.values.collect { map -> /* react to updates */ }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) // register installRestListFeature inside module
}
// Inside KmpSdkModule.register (after UserLocalDataSource exists):
installRestListFeature(
RestListFeatureConfig<User, UserDto>(
name = "users",
path = "/users",
observeLocal = { userLocal.observeAll() },
countLocal = { userLocal.count() },
replaceLocal = { dtos -> userLocal.replaceAll(dtos.map { it.toEntity() }) },
),
)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"))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(record: UserEntity) = remote.update(record)
override suspend fun markClean(id: String) = local.markClean(id)
})Why: Queue business actions separately from raw HTTP replay.
// Register handler once (e.g. in feature module or after init)
KmpSdk.offlineActions.registerHandler("FAVORITE_POST") { payload ->
networkClient.post("/posts/${payload.entityId}/favorite") {
setJsonBodyWithOfflineCapture(payload.payloadJson)
}
}
// Enqueue when offline or for deferred work
KmpSdk.offlineActions.enqueue(
actionType = "FAVORITE_POST",
entityId = postId,
payloadJson = """{"postId":"$postId"}""",
)
// Replay runs automatically during background sync / full sync; manual:
KmpSdk.offlineActions.replayPending()Why: Prevent duplicate in-flight GETs from list + detail screens.
KmpSdk.init(this) {
enableRequestDeduplication = true
baseUrl = "https://api.example.com"
}
// GET /users called twice concurrently → one network callWhy: Handle 429/503 automatically.
KmpSdk.init(this) {
enableRateLimitBackoff = true
maxRateLimitRetries = 3
baseUrl = "https://api.example.com"
}Why: Enterprise security without custom OkHttp setup.
KmpSdk.init(this) {
certificatePins = listOf("api.example.com/abcdef1234567890=")
baseUrl = "https://api.example.com"
}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"),
),
)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"
}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.featureSee tools/feature-generator/README.md.
Why: Safe SQLDelight schema upgrades in host apps.
See tools/migration-helper/README.md.
| API | Purpose |
|---|---|
KmpSdk.init { } |
Initialize SDK + install modules |
KmpSdk.validate() |
Startup health check |
KmpSdk.get<T>() |
Resolve registered dependency |
KmpSdk.networkClient |
Ktor HTTP client + cache + dedup |
KmpSdk.sessionManager |
Login / logout / token refresh |
KmpSdk.syncCoordinator |
Full sync orchestration |
KmpSdk.dirtySyncCoordinator |
Push dirty SQL records |
KmpSdk.offlineExecutor |
Queue/replay offline HTTP |
KmpSdk.offlineActions |
Domain offline action queue |
KmpSdk.tenantManager |
Runtime tenant/base URL switch |
KmpSdk.remoteConfig |
Server-driven config values |
KmpSdk.telemetry |
Analytics/diagnostics hooks |
KmpSdk.messageEventBus |
Toast/snackbar event stream |
KmpSdk.debugger |
Diagnostics snapshot & actions |
KmpSdk.connectivityMonitor |
Online/offline status |
KmpSdk.scope |
Shared coroutine scope |
KmpSDK/
├── kmp-sdk/ # Published SDK (headless)
│ └── com/kmpsdk/
│ ├── KmpSdk.kt # Global entry point
│ ├── KmpSdkInitBuilder.kt
│ ├── core/ # Config, auth, DI, messaging, connectivity
│ ├── data/ # Network, cache, offline, SQLDelight, repos
│ ├── domain/ # Errors, contracts, pagination, sync policy
│ ├── presentation/ # MVI, DataState, binders (no UI widgets)
│ ├── debug/ # Headless debug API
│ └── platform/ # Swift Flow bridges
├── tools/
│ ├── feature-generator/ # YAML → feature scaffold CLI
│ └── migration-helper/ # SQLDelight migration guide
└── settings.gradle.kts
| Layer | Package | Responsibility |
|---|---|---|
| Core | core.* |
Init, config, auth, registry, logging, message bus |
| Domain | domain.* |
Errors, repository contracts, sync policy |
| Data | data.* |
Network, cache, offline queue, base repositories |
| Presentation | presentation.* |
MVI base, DataState, bindSyncList (no widgets) |
First: pick a path in Choose your integration path.
KmpSdk.networkClient
XxxFeatureModule; install in KmpSdk.init
Same as Path A; set enableHttpCache = true and SyncPolicy.NETWORK_FIRST in init.
AppDatabase.sq
SqlDelightListLocalDataSource or paginated variant)networkClient.get/post/…) — or use installRestListFeature / RestMutationUseCase for standard RESTBaseSyncRepository or BasePaginatedRepository) unless using installRestListFeature
XxxFeatureModule and registerSyncTarget
install(XxxFeatureModule) in KmpSdk.init
bindSyncList or PaginatedListController
Path C shortcuts (v1.4): run tools/feature-generator/generate.py to scaffold steps 3–7; register offline action handlers with KmpSdk.offlineActions.registerHandler.
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"Run these from the repo root to confirm the SDK compiles, packages, and tests pass.
Step 1 — Android compile
# Android
./gradlew :kmp-sdk:compileDebugKotlinAndroid# Windows
.\gradlew.bat :kmp-sdk:compileDebugKotlinAndroidStep 2 — iOS compile (simulator)
Requires a Mac with Xcode for this target.
# iOS (simulator)
./gradlew :kmp-sdk:compileKotlinIosSimulatorArm64# Windows (skipped automatically if iOS targets are unavailable)
.\gradlew.bat :kmp-sdk:compileKotlinIosSimulatorArm64Step 3 — Full release AAR
# Full release AAR
./gradlew :kmp-sdk:assembleRelease# Windows
.\gradlew.bat :kmp-sdk:assembleReleaseOutput: kmp-sdk/build/outputs/aar/
Step 4 — Unit tests
# Unit tests
./gradlew :kmp-sdk:cleanTest :kmp-sdk:allTests# Windows
.\gradlew.bat :kmp-sdk:cleanTest :kmp-sdk:allTestsStep 5 — Publish to Maven Central (maintainer)
.\gradlew.bat :kmp-sdk:publishToMavenLocal
.\gradlew.bat :kmp-sdk:publishToMavenCentralSee Publishing to Maven Central for GPG + Sonatype setup.
To produce an Xcode framework bundle instead of compile-only:
./gradlew :kmp-sdk:linkDebugFrameworkIosSimulatorArm64Output: kmp-sdk/build/xcode-frameworks
Your app stays in control of UX; KmpSDK handles infrastructure.