Reliable synchronization. One codebase. Six platforms.
Status: In Progress — This SDK is actively under development. APIs may change.
Kmos is a Kotlin Multiplatform SDK for building offline-first applications with reliable, correct synchronization. Write your sync logic once in commonMain — it runs everywhere.
┌──────────────────────────────────────────────────────────────────┐
│ YOUR APP │
├──────────────────────────────────────────────────────────────────┤
│ SyncRepository<T> │
│ (typed APIs) │
├──────────────────────────────────────────────────────────────────┤
│ Sync Client │
│ ┌─────────────┼─────────────┐ │
│ ▼ ▼ ▼ │
│ SyncEngine Retry Policy Conflict Resolver │
│ (entity-based) (backoff+jitter) (LWW/Custom) │
├──────────────────────────────────────────────────────────────────┤
│ StorageAdapter TransportAdapter │
│ (Room 3) (Ktor / REST API) │
└──────────────────────────────────────────────────────────────────┘
| Feature | Description |
|---|---|
| Offline-First | Sync runs on foreground, manual trigger, or optional interval |
| 6 Platforms | Android, iOS, JVM, Desktop, JS, WasmJS — one implementation (Web in progress) |
| Thread-Safe | Single-writer Channel-driven architecture — no locks needed |
| Entity-Based Ops | Operation tracking merged into SyncEntity — no separate queue |
| Conflict Resolution | Last-Write-Wins default, custom resolver support |
| Typed Mapping | SyncMapper<T> for clean domain ↔ entity conversion |
| Reactive | observeAll() re-emits on every storage change |
| Pluggable | Storage & transport adapters with contract test suites |
| Room 3 | KMP-native storage reference implementation |
| Backend-Agnostic | Implement SyncApiProtocol for any REST backend |
// build.gradle.kts
repositories {
maven { url = uri("https://jitpack.io") }
}
dependencies {
implementation("com.github.mohammadestk.kmos:sync-core:Tag")
implementation("com.github.mohammadestk.kmos:sync-storage:Tag")
implementation("com.github.mohammadestk.kmos:sync-network:Tag")
}Replace Tag with the desired version tag (e.g., v0.1.0).
val client = SyncClient.build(scope) {
storage(RoomStorageAdapter(database))
transport(KtorTransportAdapter(httpClient, baseUrl))
retry(ExponentialBackoffRetryPolicy())
syncOnForeground(true) // auto-sync on app foreground
syncInterval(5.minutes) // optional periodic sync
}// Option A: Using SyncMapper (recommended)
object TaskMapper : SyncMapper<Task> {
override fun toSyncEntity(value: Task) = value.toSyncEntity()
override fun fromSyncEntity(entity: SyncEntity) = entity.toTask()
}
val tasks: SyncRepository<Task> = client.repository(TaskMapper)
// Option B: Using lambdas
val tasks: SyncRepository<Task> = client.repository(
serialize = { it.toSyncEntity() },
deserialize = { it.toTask() },
)
// Observe all tasks (reactive — re-emits on storage changes)
tasks.observeAll().collect { taskList ->
// Update UI
}
// Create or update a task
tasks.upsert(Task(id = "1", title = "Buy milk"))
// Trigger manual sync
client.trigger()
// Handle failed operations
client.failedOperations.collect { failed ->
failed.forEach { entity ->
// Show retry button to user
}
}
// Or with typed access:
client.failedEntities(taskMapper).collect { failedTasks ->
// typed Task list
}| Component | Purpose |
|---|---|
SyncRepository<T> |
Typed CRUD interface, serializes domain models to SyncEntity |
SyncMapper<T> |
Maps domain objects to/from SyncEntity |
SyncClient |
Public entry point, builder DSL |
StorageAdapter |
Local persistence interface |
TransportAdapter |
Network transport interface |
RetryPolicy |
Exponential backoff with configurable dead-letter |
ConflictResolver |
LWW or custom merge logic |
SyncTrigger |
Lifecycle hook for sync timing |
User Action
│
▼
SyncRepository.upsert()
│
▼
StorageAdapter.write()
(sets pendingOperationType, operationId)
│
▼
TransportAdapter.push()
│
┌─────────┴─────────┐
▼ ▼
Success Failure
│ │
▼ ▼
StorageAdapter.write() RetryPolicy
(state = Synced) (backoff/retry)
Operations are tracked directly on SyncEntity — no separate queue:
| Field | Type | Purpose |
|---|---|---|
pendingOperationType |
OperationType? |
Create, Update, or Delete — null when synced |
operationId |
String? |
Idempotency key for the pending operation |
operationAttempt |
Int |
Retry counter, resets on success or dead-letter |
ExponentialBackoffRetryPolicy(
baseDelay = 1000.milliseconds, // Initial delay
maxDelay = 60_000.milliseconds, // Maximum delay cap
maxAttempts = 5, // Dead-letter threshold
jitterFactor = 0.3, // Randomness factor
)// Default: Last-Write-Wins
LastWriteWinsConflictResolver()
// Custom resolver
ConflictResolver<MyEntity> { local, remote ->
local.copy(
version = maxOf(local.version, remote.version),
data = merge(local.data, remote.data)
)
}// Recommended: use builder methods (auto-creates DefaultSyncTrigger)
val client = SyncClient.build(scope) {
storage(RoomStorageAdapter(database))
transport(KtorTransportAdapter(httpClient, baseUrl))
syncOnForeground(true)
syncInterval(5.minutes)
}
// Manual trigger
client.trigger()
// Or provide a custom trigger
val client = SyncClient.build(scope) {
storage(RoomStorageAdapter(database))
transport(KtorTransportAdapter(httpClient, baseUrl))
trigger(myCustomTrigger)
}The KtorTransportAdapter maps SDK operations to your backend's REST endpoints. Implement SyncApiProtocol to define your wire format:
class MyApiProtocol : SyncApiProtocol {
override fun pushUrl(op: SyncOperation): String = when (op.type) {
OperationType.Create -> "/api/v1/sync"
OperationType.Update -> "/api/v1/sync/${op.entityId}"
OperationType.Delete -> "/api/v1/sync/${op.entityId}"
}
override fun pullUrl(cursor: String?): String = buildString {
append("/api/v1/sync")
if (cursor != null) append("?cursor=$cursor")
}
override suspend fun buildPushRequest(op: SyncOperation): HttpRequestBuilder.() -> Unit = {
setBody(MyPushRequest(op.entityId, op.type.name, op.payload.decodeToString()))
}
override suspend fun parsePushResponse(response: HttpResponse): PushResult {
// Extract version from server response, handle conflicts, etc.
}
override suspend fun parsePullResponse(response: HttpResponse): PullResult {
// Parse response body into entities and pagination cursor
}
}Configure the transport adapter with your protocol:
val httpClient = HttpClient {
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
install(Logging) {
level = LogLevel.ALL
}
}
val transport = KtorTransportAdapter(
httpClient = httpClient,
baseUrl = "https://your-backend-api.com",
protocol = MyApiProtocol(),
)# Android
./gradlew :sample:androidApp:assembleDebug
# Desktop (JVM)
./gradlew :sample:desktopApp:run
# Web (Wasm — modern browsers) [in progress]
./gradlew :sample:webApp:wasmJsBrowserDevelopmentRun
# Web (JS — older browsers) [in progress]
./gradlew :sample:webApp:jsBrowserDevelopmentRun
# iOS — open sample/iosApp/ in Xcode# Core engine tests
./gradlew :sync-core:jvmTest
# Storage tests
./gradlew :sync-storage:jvmTest
# Network tests
./gradlew :sync-network:jvmTestkmos/
├── sync-core/ # Core engine, interfaces, models, SyncClient, DefaultSyncTrigger
├── sync-storage/ # Room 3 storage adapter
├── sync-network/ # Ktor transport adapter (restful-api.dev reference)
├── sync-testing/ # Test utilities (not published)
├── sample/ # Demo apps
│ ├── shared/ # Shared UI code
│ ├── androidApp/ # Android app
│ ├── desktopApp/ # Desktop app
│ ├── webApp/ # Web app (in progress)
│ └── iosApp/ # iOS app
├── specs/ # Design specifications
└── gradle/ # Build configuration
This is a deliberate design choice, not a limitation.
Sync in Kmos only runs while the app process is alive:
| Supported | Not Supported |
|---|---|
| Foreground sync | Background sync when app is killed |
| Manual trigger | OS-level scheduled sync |
| In-process interval | WorkManager / BGTaskScheduler |
Why?
- WorkManager (Android) has no equivalent on JVM/Desktop
- BGTaskScheduler (iOS) is budget-limited and unreliable by design
- JVM/Desktop has no OS-level background scheduler
Need "sync while closed"? Use server-push (silent notifications) to trigger foreground sync.
Copyright 2025 Mohammad Esteki
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.