I need help migrating a Ditto Kotlin/Android application from v4 to v5. Focus on these critical changes:
PACKAGE RENAME:
Replace all `live.ditto.*` imports with `com.ditto.kotlin.*`.
You must replace your definition in your gradle file(s):
```kotlin
//v5
ditto-kotlin = { module = "com.ditto:ditto-kotlin-android", version.ref = "ditto" }
```
```kotlin
//v4
live-ditto = { group = "live.ditto", name = "ditto", version.ref = "ditto" }
```
Also update the `implementation(libs.live.ditto)` dependency reference in your app's `build.gradle.kts`:
```kotlin
// v5
implementation(libs.ditto.kotlin)
// v4
implementation(libs.live.ditto)
```
Full import mapping:
- `live.ditto.*` → `com.ditto.kotlin.*`
- `live.ditto.transports.*` → `com.ditto.kotlin.transports.*` (Note: `DittoSyncPermissions` is now in `com.ditto.kotlin.transports`)
- `live.ditto.android.*` → `com.ditto.kotlin.*` (Android-specific classes merged into main package)
- NEW: `com.ditto.kotlin.serialization.*` for `DittoCborSerializable`, `DittoJsonSerializable`, and `toDittoCbor()` extensions
- Use IDE Find & Replace: `live.ditto` → `com.ditto.kotlin` project-wide
Minimum Supported Android Version:
- Ditto SDK v5 requires `minSdk = 24` (Android 7.0 Nougat). If your project targets `minSdk = 23`, you must raise it in `build.gradle.kts`.
DittoInitializer (Android context setup):
- v5 automatically handles Android context via AndroidX App Startup through the SDK's own manifest merger. You do NOT need to manually add DittoInitializer to your AndroidManifest.xml. Remove any manual DittoInitializer manifest entries if present.
INITIALIZATION:
Replace `Ditto(dependencies, identity)` constructor and `Ditto.open(DittoAndroidConfig)` with `DittoFactory.create(DittoConfig(...))`
- `DittoFactory.create()` is NOT suspend — no coroutine needed
- `DittoConfig(databaseId, connect, ...)` replaces `DittoAndroidConfig(context, databaseId, connect, ...)` — no `Context` parameter; Android context handled internally via `DittoInitializer` (AndroidX Startup)
- `DittoConfig.Connect.Server(url: String)` replaces `DittoConfigConnect.Server(url: URI)` — URL is now a `String`, not `java.net.URI`; class is nested inside `DittoConfig`
- `DittoConfig.Connect.SmallPeersOnly(privateKey?)` replaces `DittoConfigConnect.SmallPeersOnly(privateKey?)`
- `DittoConfig` properties are now `val` (immutable); `persistenceDirectory` is `String?` instead of `File?`
- Remove `DefaultAndroidDittoDependencies`, `AndroidDittoDependencies`, `DittoDependencies`, `DittoBase` — all removed
- Remove `DittoIdentity` sealed class and all subclasses (`OnlinePlayground`, `OnlineWithAuthentication`, `OfflinePlayground`, `SharedKey`, `Manual`) — all removed
- Remove `UpdateTransportConfig` websocket URL additions (cloud URL inferred from `DittoConfig.Connect.Server.url`)
- Remove `DisableSyncWithV3()`, `RunGarbageCollection()`, `GetTransportDiagnostics()` — all removed
- Remove `DQL_STRICT_MODE = false` workaround (now the v5 default)
- If your v4 app relied on `DQL_STRICT_MODE = true` (v4 default), you MUST set it before `sync.start()`:
`ditto.store.execute("ALTER SYSTEM SET DQL_STRICT_MODE = true")`
SYNC:
- `ditto.startSync()` → `ditto.sync.start()`
- `ditto.stopSync()` → `ditto.sync.stop()`
- `ditto.isSyncActive` → `ditto.sync.isActive`
- `ditto.sync.registerSubscription(query, args)` — args can be `Map<String, Any?>` (plain map) or `DittoCborSerializable.Dictionary`
AUTHENTICATION — CRITICAL SYNTAX RULES:
`auth.expirationHandler` is a Kotlin PROPERTY. You MUST use property assignment (`=`), NOT a method call.
There is NO `setExpirationHandler()` method — it does not exist and will not compile.
The lambda IS a suspend lambda — you call `auth.login()` directly inside it without launching a new coroutine.
CORRECT syntax:
```kotlin
ditto.auth?.let { auth ->
auth.expirationHandler = { ditto, timeUntilExpiration ->
// This lambda body IS suspend — call login() directly here, no coroutine needed
ditto.auth?.login(token = "your-token", provider = DittoAuthenticationProvider.development())
}
}
```
FORBIDDEN patterns that will NOT compile or will fail at runtime:
```kotlin
// ERROR: No such method exists
auth.setExpirationHandler { ditto, t -> ... }
// ERROR: Missing `=`; this calls the property as a function
auth.expirationHandler { ditto, t -> ... }
// WRONG: Never launch a new coroutine inside the handler — the lambda IS already suspend
auth.expirationHandler = { ditto, t ->
CoroutineScope(Dispatchers.IO).launch { ditto.auth?.login(...) }
}
```
Other authentication rules:
- Handler receives `(ditto: Ditto, timeUntilExpiration: Double)` — note: param is now `Ditto`, not `DittoBase`
- Called when `timeUntilExpiration == 0.0` (initial auth or expired) or `> 0` (token expiring soon — refresh)
- `ditto.auth` is nullable — use `?.let { }` guard; it is null when using `DittoConfig.Connect.SmallPeersOnly`
- You MUST set `expirationHandler` BEFORE calling `ditto.sync.start()` when using `DittoConfig.Connect.Server`; failing to set it causes `DittoException.AuthenticationException` with reason `ExpirationHandlerMissing`
- Use `DittoAuthenticationProvider.development()` for playground tokens
- Use `DittoAuthenticationProvider.custom("provider-name")` for custom providers
- `auth.login(token, provider)` is now suspend — no callback parameter
- `auth.observeStatus()` returns `StateFlow<DittoAuthenticationStatus>` — was callback-based
- `auth.logout(cleanupFn: ((Ditto) -> Unit)?)` — param type changed from `DittoBase` to `Ditto`
- Remove `DittoAuthenticationCallback`, `DittoLoginCallback`, `DittoLoginCompletionCallback`, `DittoAuthenticationStatusDidChangeCallback`, `DittoLogoutCleanupFn` — all removed
- Remove `loginWithToken()`, `loginWithCredentials()` — deprecated and removed
STORE EXECUTE — CRITICAL BREAKING CHANGE FOR READS:
In v4, `store.execute(query, args)` returned `DittoQueryResult` directly.
In v5, `store.execute(query, args)` returns NOTHING (Unit). Calling `.items` on the result IS A COMPILE ERROR.
FORBIDDEN v4 pattern that will NOT compile in v5:
```kotlin
// ERROR: store.execute() returns Unit in v5, not DittoQueryResult
val item = store.execute("SELECT * FROM tasks WHERE _id = :id", mapOf("_id" to id)).items.first()
```
CORRECT v5 patterns for reads:
```kotlin
// Option A — handler form (RECOMMENDED): result is auto-closed after lambda returns
val item = ditto.store.execute(
"SELECT * FROM tasks WHERE _id = :id",
mapOf("_id" to id)
) { result -> result.items.firstOrNull() }
// Option B — executeRaw: same as v4 behavior, but YOU must close the result
val result = ditto.store.executeRaw("SELECT * FROM tasks WHERE _id = :id", mapOf("_id" to id))
val item = result.items.firstOrNull()
result.close() // REQUIRED — resource leak if omitted
```
For WRITES (INSERT/UPDATE/DELETE), fire-and-forget is correct — no handler needed:
```kotlin
// Correct for writes — returns nothing, that's expected
ditto.store.execute("UPDATE tasks SET done = :done WHERE _id = :id", mapOf("done" to true, "id" to id))
```
Other store.execute rules:
- Replace `store.execute(query, args): DittoQueryResult` with new overloads:
- Fire-and-forget (writes): `store.execute(query, args)` — discards result
- With handler (reads): `store.execute(query, args) { result -> result.items.toList() }` — result auto-closed
- Raw (manual): `store.executeRaw(query, args): DittoQueryResult` — caller must close
- All are `suspend` functions
- Arguments accept `Map<String, Any?>` (plain Kotlin map — recommended), `DittoCborSerializable.Dictionary`, or `@Serializable` types
- Remove `store.executeBlocking()` — use `runBlocking { store.execute() }` if needed
- `DittoQueryResultItem.value` is now `DittoCborSerializable.Dictionary` instead of `Map<String, Any>` — the cast `doc.value["field"] as SomeType` will throw at runtime; use typed accessors: `.stringOrNull`, `.intOrNull`, `.longOrNull`, `.doubleOrNull`, `.booleanOrNull`, `.listOrNull`, `.dictionaryOrNull`, `.attachmentTokenOrNull`, `.isNull`
- Or use `item.jsonString()` to get a JSON string and deserialize from JSON (recommended for complex objects)
OBSERVERS:
Three patterns replace the v4 callback model:
1. `registerObserver(query, args) { result -> ... }` — handler is now `suspend`; returns `DittoStoreObserver`; supports optional `DittoDiff` second parameter
2. `observe(query, args) { result -> transform }` — NEW; returns `Flow<T>`; auto-cancelled with scope; supports optional `DittoDiff`
3. `collect(query, args) { result -> ... }` — NEW; suspend function providing natural backpressure via coroutine suspension
- Replace `runOnUiThread { ... }` with `withContext(Dispatchers.Main) { ... }` inside suspend handlers
- Replace `DittoSignalNext` / `signalNext` backpressure with `collect()` method
- Remove `DittoChangeHandler`, `DittoChangeHandlerWithNextSignal`, `DittoSignalNext`, `DittoSignalNextCallback` — all removed
- When using `observe()` in `lifecycleScope`, observer is auto-cancelled — no manual `.close()` needed
LEGACY COLLECTION API — COMPLETELY REMOVED:
All collection-based query APIs are removed. Use DQL queries via `store.execute()` instead:
- `store.collection("x").find(query).exec()` → `store.execute("SELECT * FROM x WHERE ...", args) { result -> result.items }`
- `store.collection("x").upsert(value)` → `store.execute("INSERT INTO x DOCUMENTS (:doc) ON ID CONFLICT DO UPDATE", mapOf("doc" to value))`
- `store.collection("x").findById(id).update { doc -> doc["field"].set(val) }` → `store.execute("UPDATE x SET field = :val WHERE _id = :id", mapOf("val" to val, "id" to id))`
- `store.collection("x").findById(id).remove()` → `store.execute("DELETE FROM x WHERE _id = :id", mapOf("id" to id))`
- `store.collection("x").findById(id).evict()` → `store.execute("EVICT FROM x WHERE _id = :id", mapOf("id" to id))`
- Counter: `doc["count"].counter.increment(1.0)` → `store.execute("UPDATE x APPLY count PN_INCREMENT BY :n WHERE _id = :id", mapOf("n" to 1, "id" to id))`
- `store.collection("x").find(q).observeLocal { docs, event -> ... }` → `store.observe(dqlQuery, args) { result -> ... }.collect { ... }`
- `store.collection("x").find(q).subscribe()` → `ditto.sync.registerSubscription(dqlQuery, args)`
- Remove: `DittoCollection`, `DittoDocument`, `DittoMutableDocument`, `DittoDocumentId`, `DittoDocumentPath`, `DittoDocumentIdPath`, `DittoMutableDocumentPath`, `DittoMutableCounter`, `DittoMutableRegister`, `DittoPendingCursorOperation`, `DittoPendingIdSpecificOperation`, `DittoPendingCollectionsOperation`, `DittoLiveQuery`, `DittoLiveQueryEvent`, `DittoSingleDocumentLiveQueryEvent`, `DittoSubscription` (legacy), `DittoWriteTransaction`, `DittoScopedWriteTransaction`, `DittoWriteTransactionResult`, `DittoUpdateResult`, `DittoWriteStrategy`, `DittoSortDirection`, `DittoCollectionsEvent`, `DittoLiveQueryMove`
TRANSACTIONS:
- `store.transaction { txn -> DittoTransactionCompletionAction.Commit }` → `store.transaction { txn -> DittoTransaction.Result.Commit(value) }` — now generic, returns a value
- `DittoTransactionCompletionAction.Abort` → `DittoTransaction.Result.Rollback`
- `store.write(block)` → `store.transaction { ... }` — write transactions removed
- Remove `transactionBlocking()`, `transactionReturningVoid()` — use `runBlocking { store.transaction {} }` if needed
TRANSPORT CONFIG:
Changed from mutable data classes to immutable builder pattern:
- `ditto.updateTransportConfig { config -> config.peerToPeer.bluetoothLe.enabled = true }` →
`ditto.updateTransportConfig { config -> config.peerToPeer { bluetoothLe { enabled = true } } }`
- Builder uses DSL-style nested blocks
- `enableAllPeerToPeer()` — removed; configure each transport explicitly
- `DittoAdvertisementPower`: `VERY_LOW`→`VeryLow`, `LOW`→`Low`, `MEDIUM`→`Medium`, `HIGH`→`High`
- `DittoAdvertisementFrequency`: `LOW`→`Low`, `MEDIUM`→`Medium`, `HIGH`→`High`
- `DittoBluetoothLeConfig.maxOutgoing`, `.mtuRequest` — removed
- `DittoHttpListenConfig.staticContentPath` — removed
- `DittoGlobalConfig.syncGroupLong`, `.routingHintLong` — removed; use `UInt` directly
- Nested class renames: `DittoPeerToPeer`→`DittoTransportConfig.PeerToPeer`, `DittoBluetoothLeConfig`→`PeerToPeer.BluetoothLE`, `DittoLanConfig`→`PeerToPeer.Lan`, `DittoWifiAwareConfig`→`PeerToPeer.WifiAware`, `DittoConnect`→`Connect`, `DittoListen`→`Listen`, `DittoTcpListenConfig`→`Listen.TcpConfig`, `DittoHttpListenConfig`→`Listen.HttpConfig`, `DittoGlobalConfig`→`Global`
TRANSPORT CONDITION:
- `ditto.callback = object : DittoCallback { override fun transportConditionDidChange(...) }` →
`ditto.transportCondition.collect { event -> /* event.condition, event.subsystem */ }`
- `ditto.transportCondition` is a `Flow<DittoTransportConditionEvent>`
- Remove `DittoCallback`, `DittoTransportConditionChangedCallback` — replaced by Flow
PRESENCE:
- `presence.observe(handler)` returning `DittoPresenceObserver` → `presence.observe()` returning `Flow<DittoPresenceGraph>` — collect in `lifecycleScope`
- `DittoPresenceGraph.json()` → `DittoPresenceGraph.serializeToJson()`
- `presence.peerMetadata: Map<String, Any?>` → `presence.peerMetadata: DittoJsonSerializable.ObjectValue` — use `getPeerMetadataMap()` for plain Map or `setPeerMetadata(Map)` to set
- `presence.connectionRequestHandlerJava` — removed; use `presence.connectionRequestHandler` fun interface
- `DittoConnectionRequest.peerKeyString` → `DittoConnectionRequest.peerKey`
- Remove `DittoPresenceObserver`, `DittoPresenceObserverCallback`, `DittoPeersObserver` — use Flow
PEER & CONNECTION:
- `DittoPeer.peerKeyString` → `DittoPeer.peerKey`
- `DittoPeer.peerKey: DittoPeerKey` (ByteArray) — removed; use `peerKey: String`
- `DittoPeer.isConnectedToDittoCloud` → `DittoPeer.isConnectedToDittoServer`
- `DittoPeer.os: String?` → `DittoPeer.os: DittoPeerOs?` (enum: `Generic`, `Ios`, `Tvos`, `Android`, `Linux`, `Windows`, `MacOS`)
- `DittoPeer.peerMetadata: Map<String, Any?>` → `DittoJsonSerializable.ObjectValue`
- `DittoPeer.identityServiceMetadata: Map<String, Any?>` → `DittoJsonSerializable.ObjectValue`
- `DittoPeer.queryOverlapGroup` — removed
- `DittoConnection.peer1/peer2` simplified to `String` (peer key); `approximateDistanceInMeters` removed
- Remove `DittoPeerKey` typealias, `toKeyString()`, `decodePeerKey()`, `DittoRemotePeer`
ATTACHMENTS:
- `store.fetchAttachment(token, callback)` → `suspend store.fetchAttachment(token, onFetchProgress?)` — now suspend, returns `DittoAttachmentFetchResult`
- `DittoAttachmentFetchEvent` (Progress/Completed/Deleted) → `DittoAttachmentFetchResult` (Completed/Deleted); progress via `onFetchProgress: suspend (ULong, ULong) -> Unit` parameter
- `store.newAttachment(path, metadata)` — metadata accepts `Map<String, Any?>` or `DittoCborSerializable.Dictionary` (changed from `Map<String, String>`)
- Remove `DittoAttachmentFetchEventType` enum, `DittoCollection.newAttachment()`, `DittoCollection.fetchAttachment()` (deprecated)
ERROR HANDLING:
- `DittoError` sealed class → `DittoException` sealed class
- All subclasses renamed: `DittoError.StoreError` → `DittoException.StoreException`, `AuthenticationError` → `AuthenticationException`, `FatalError` → `FatalException`, etc.
- All reason classes renamed: `StoreErrorReason` → `StoreExceptionReason`, `AuthenticationErrorReason` → `AuthenticationExceptionReason`, etc.
- Removed deprecated: `FilesystemError`, `InternalError`, `LockedWorkingDirectoryError`
- NEW exception types: `SmallPeerInfoException`, `DataStreamsException`, `DittoPanicException`
LOGGING:
- `DittoLogger.enabled` → `DittoLogger.isEnabled`
- `DittoLogLevel.DEBUG` → `DittoLogLevel.Debug` (PascalCase for all: `Error`, `Warning`, `Info`, `Debug`, `Verbose`)
- `DittoLogger.setCustomLogCallback(callback)` / `unsetCustomLogCallback()` → `DittoLogger.observeLogEvents(): Flow<DittoLogEvent>` — collect in coroutine scope
- `DittoLogger.exportToFileBlocking()` return type changed from `BigInteger` to `ULong`
- Remove `DittoLogger.emojiLogLevelHeadingsEnabled`, `DittoLogCallback` interface
DISK USAGE:
- `DiskUsage` → `DittoDiskUsage`; `DiskUsageItem` → `DittoDiskUsageItem`; `FileSystemType` → `DittoFileSystemType`
- `diskUsage.exec` → `diskUsage.item`
- `DiskUsageItem.sizeInBytes: Int` → `DittoDiskUsageItem.sizeInBytes: Long`
- `diskUsage.observe(handler): Closeable` → `diskUsage.observe(): Flow<DittoDiskUsageItem>`
- `FileSystemType.DIRECTORY/FILE/SYMLINK` → `DittoFileSystemType.Directory/File/Symlink`
- Remove `DiskUsageCallback` interface
OTHER DITTO CLASS CHANGES:
- `ditto.appId` → removed (use `DittoConfig.databaseId`)
- `ditto.siteId` → removed
- `ditto.persistenceDirectory` → removed (use `ditto.absolutePersistenceDirectory`)
- `ditto.sdkVersion` → removed (use `Ditto.VERSION` companion const)
- `ditto.activated` → `ditto.isActivated`
- `ditto.observePeers()` / `observePeersV2()` → removed (use `ditto.presence.observe()`)
- `ditto.smallPeerInfo.syncScope` → removed; `DittoSmallPeerInfoSyncScope` enum removed
- Classes implement `DittoResource` (extends `AutoCloseable`) instead of `java.io.Closeable`
BEFORE (V4):
```kotlin
import live.ditto.*
import live.ditto.android.*
import live.ditto.transports.*
// Initialize
val androidDeps = DefaultAndroidDittoDependencies(context)
val ditto = Ditto(
androidDeps,
DittoIdentity.OnlinePlayground(
dependencies = androidDeps,
appId = "my-app-id",
token = "my-token",
customAuthUrl = "https://my-app-id.cloud.ditto.live"
)
)
ditto.updateTransportConfig { config ->
config.connect.websocketUrls.add("wss://my-app-id.cloud.ditto.live")
}
ditto.store.execute("ALTER SYSTEM SET DQL_STRICT_MODE = false")
ditto.disableSyncWithV3()
ditto.startSync()
// Register observer with callback
val observer = ditto.store.registerObserver(
"SELECT * FROM cars WHERE color = :color",
mapOf("color" to "red")
) { result ->
runOnUiThread { updateUI(result.items) }
}
// Presence
val presenceObserver = ditto.presence.observe { graph ->
graph.remotePeers.forEach { println(it.peerKeyString) }
}
// Transport condition
ditto.callback = object : DittoCallback {
override fun transportConditionDidChange(condition, subsystem) {
println("$subsystem: $condition")
}
}
// Error handling
try {
ditto.sync.start()
} catch (e: DittoError) {
when (e) {
is DittoError.StoreError -> println(e.reason)
else -> println(e.message)
}
}
// Cleanup
observer.close()
presenceObserver.close()
```
AFTER (V5):
```kotlin
import com.ditto.kotlin.*
import com.ditto.kotlin.transports.*
import com.ditto.kotlin.serialization.*
// Initialize
val ditto = DittoFactory.create(
DittoConfig(
databaseId = "my-app-id",
connect = DittoConfig.Connect.Server("https://my-app-id.cloud.ditto.live"),
)
)
// Authentication
ditto.auth?.let { auth ->
auth.expirationHandler = { dittoInstance, timeUntilExpiration ->
dittoInstance.auth?.login(
token = "my-token",
provider = DittoAuthenticationProvider.development(),
)
}
}
ditto.sync.start()
// Register observer — handler is now suspend
val observer = ditto.store.registerObserver(
"SELECT * FROM cars WHERE color = :color",
mapOf("color" to "red")
) { result ->
withContext(Dispatchers.Main) { updateUI(result.items) }
}
// OR use observe() Flow — auto-cancelled with lifecycleScope
lifecycleScope.launch {
ditto.store.observe(
"SELECT * FROM cars WHERE color = :color",
mapOf("color" to "red")
) { result ->
result.items.map { it.jsonString() }
}.collect { cars ->
updateUI(cars)
}
}
// Presence — Flow-based
lifecycleScope.launch {
ditto.presence.observe().collect { graph ->
graph.remotePeers.forEach { println(it.peerKey) }
}
}
// Transport condition — Flow-based
lifecycleScope.launch {
ditto.transportCondition.collect { event ->
println("${event.subsystem}: ${event.condition}")
}
}
// Error handling
try {
ditto.sync.start()
} catch (e: DittoException) {
when (e) {
is DittoException.StoreException -> println(e.reason)
else -> println(e.message)
}
}
// Cleanup — observer still needs manual close if not using Flow
observer.close()
// Flow-based observers auto-cancel with lifecycleScope — no cleanup needed
```
DQL MIGRATION EXAMPLES:
```kotlin
// Query — v4: store.collection("cars").find("color == 'blue'").exec()
val cars = ditto.store.execute(
"SELECT * FROM cars WHERE color = :color",
mapOf("color" to "blue")
) { result -> result.items.toList() }
// Insert — v4: store.collection("cars").upsert(mapOf("_id" to id, "color" to "blue"))
ditto.store.execute(
"INSERT INTO cars DOCUMENTS (:doc) ON ID CONFLICT DO UPDATE",
mapOf("doc" to mapOf("_id" to id, "color" to "blue"))
)
// Update — v4: store.collection("cars").findById(id).update { doc -> doc["color"].set("green") }
ditto.store.execute(
"UPDATE cars SET color = :color WHERE _id = :id",
mapOf("color" to "green", "id" to id)
)
// Delete — v4: store.collection("cars").findById(id).remove()
ditto.store.execute(
"DELETE FROM cars WHERE _id = :id",
mapOf("id" to id)
)
// Evict — v4: store.collection("cars").findById(id).evict()
ditto.store.execute(
"EVICT FROM cars WHERE _id = :id",
mapOf("id" to id)
)
// Counter — v4: doc["viewCount"].counter.increment(1.0)
ditto.store.execute(
"UPDATE cars APPLY viewCount PN_INCREMENT BY :n WHERE _id = :id",
mapOf("n" to 1, "id" to id)
)
// Subscribe — v4: store.collection("cars").find("color == 'red'").subscribe()
val sub = ditto.sync.registerSubscription(
"SELECT * FROM cars WHERE color = :color",
mapOf("color" to "red")
)
```
CHECKLIST:
- Replace ALL `live.ditto.*` imports with `com.ditto.kotlin.*`; add `serialization.*` and `transports.*` imports
- Replace `Ditto(dependencies, identity)` / `Ditto.open()` / `Ditto.openSync()` with `DittoFactory.create(DittoConfig(...))`
- Remove `DefaultAndroidDittoDependencies`, `DittoIdentity`, `DittoAndroidConfig` usage
- Replace `DittoConfigConnect.Server(URI(...))` with `DittoConfig.Connect.Server("url")`
- Replace `startSync()` / `stopSync()` with `sync.start()` / `sync.stop()`
- Set `auth.expirationHandler` suspend lambda for authentication
- Replace `DittoAuthenticationCallback` interface with expiration handler
- Replace callback-based `login()` with `suspend auth.login(token, provider)`
- Replace collection-based queries with DQL via `store.execute()`
- Update `store.execute()` calls: use handler for reads, fire-and-forget for writes, `executeRaw()` for manual lifecycle
- Convert observer callbacks to suspend handlers, or use `observe()` Flow, or `collect()` for backpressure
- Replace `runOnUiThread` with `withContext(Dispatchers.Main)` in suspend handlers
- Update transport config to builder DSL pattern
- Replace `ditto.callback` with `ditto.transportCondition` Flow
- Replace `presence.observe(handler)` with `presence.observe()` Flow
- Replace `DittoError` catches with `DittoException`; update all subclass names (`StoreError` → `StoreException`, etc.)
- Replace `peerKeyString` with `peerKey`; `isConnectedToDittoCloud` with `isConnectedToDittoServer`
- Replace callback-based `fetchAttachment()` with suspend version returning `DittoAttachmentFetchResult`
- Rename `DiskUsage` → `DittoDiskUsage`, `DiskUsageItem` → `DittoDiskUsageItem`; update `diskUsage.exec` → `diskUsage.item`
- Update `DittoLogger.enabled` → `.isEnabled`; `DittoLogLevel.DEBUG` → `.Debug`
- Replace `setCustomLogCallback()` with `observeLogEvents()` Flow
- Remove `DittoTransportConfig.enableAllPeerToPeer()` — configure each transport explicitly
- Remove `disableSyncWithV3()`, `runGarbageCollection()`, `getTransportDiagnostics()`
- Set `DQL_STRICT_MODE = true` BEFORE `sync.start()` if your v4 app relied on the v4 default
- Replace `store.write(block)` with `store.transaction { DittoTransaction.Result.Commit(value) }`
- Update `DittoAttachment.metadata` / `DittoAttachmentToken.metadata` type from `Map<String, String>` to `Map<String, Any?>` (or `DittoCborSerializable.Dictionary`)
MANDATORY FINAL STEP — BUILD AND FIX ALL ERRORS:
After completing ALL code changes above, you MUST attempt to build the project and fix every compilation error before declaring the migration complete.
Run: `./gradlew assembleDebug` (or build in Android Studio)
Do NOT stop when the IDE shows no red underlines — actually build the project. Fix every error before finishing.
Most common build errors and their fixes:
- `Unresolved reference: live.ditto` → missed an import or usage of a v4 class; replace with `com.ditto.kotlin`
- `Unresolved reference: DittoSyncPermissions` → it moved to `com.ditto.kotlin.transports`; update import to `import com.ditto.kotlin.transports.DittoSyncPermissions`
- `Unresolved reference: items` on a `store.execute()` call → you used fire-and-forget (returns Unit); switch to handler form: `store.execute(query, args) { result -> result.items.toList() }`
- `Type mismatch: inferred type is Unit but X was expected` on store.execute result → same fix as above
- `Unresolved reference: setExpirationHandler` → use property assignment: `auth.expirationHandler = { ditto, t -> ... }`
- `Suspension functions can be called only within coroutine body` inside expirationHandler → the lambda IS already suspend; do not wrap in `launch {}`; call `auth.login()` directly
- `Unresolved reference: DittoError` → replace with `DittoException`
- `Unresolved reference: startSync` / `stopSync` / `isSyncActive` → replace with `sync.start()` / `sync.stop()` / `sync.isActive`
- `Unresolved reference: DefaultAndroidDittoDependencies` → remove entirely; no replacement needed
- Cast `doc.value["field"] as SomeType` throws `ClassCastException` at runtime → use `item.jsonString()` + JSON deserialization, or typed accessors (`.stringOrNull`, `.intOrNull`, etc.)
- `error: <identifier> expected` in generated BuildConfig.java with double-quoted values like `""value""` → this is a pre-existing project issue where your .env file uses shell-style quoted values (`KEY="value"`) that Java's `Properties.load()` preserves literally; fix by stripping surrounding quotes from property values before passing them to `BuildConfigField`, e.g.: `val rawValue = prop[key]?.toString()?.trim('"') ?: ""`
Work through the codebase systematically. Show me each file's changes and confirm a successful build at the end.