If you want to update this page or add new content, please submit a pull request to the Homepage.
Android SDK
The Yorkie Android SDK enables you to efficiently build collaborative applications. On the client side, you can create documents that automatically sync with remote peers with minimal effort.
If you want to install the SDK, refer to the Getting Started with Android SDK.
Client
Client is a normal client that communicates with the server. It holds documents and sends local changes to the server to synchronize with other replicas.
Creating a Client
Create a new client instance with the server host and configuration options.
val client = Client(host = "https://api.yorkie.dev",options = Client.Options(apiKey = "your-api-key",key = "your-client-key"))
The API key is used to identify the project in Yorkie. You can get the API key of the project you created in the Dashboard.
Activating a Client
Activate the client by registering it with the server. The server assigns a unique client ID that distinguishes different clients. After activation, the client is ready to attach documents and communicate with the server.
scope.launch {client.activateAsync().await()}
Client Status
isActive
Returns true if the client is currently activated, false otherwise. Use this to check if the client is ready to use.
if (client.isActive) {// Client is ready to use}
status
Monitor client status changes with a StateFlow. The status can be either Status.Activated (with client ID) or Status.Deactivated.
scope.launch {client.status.collect { status ->when (status) {is Client.Status.Activated -> {println("Client activated with ID: ${status.clientId}")}is Client.Status.Deactivated -> {println("Client is deactivated")}}}}
Attaching Documents
Attach a document to the client. Once attached, the client synchronizes the document with the server and other clients. You can set initial presence data and choose the synchronization mode.
Parameters:
document: The document to attachinitialPresence: Initial presence data (default: empty map)syncMode: Synchronization mode (default:Realtime)schema: Optional schema key for document validationdisableGC:Boolean(default:false) - declares that this attachment will not produce or consume tombstones. Use only withCounteror primitive workloads; misuse on a document that usesTree,Text, orArraydeletions leads to undefined GC behavior on this client.disablePresence:Boolean(default:false) - declares that this document does not produce, consume, or store presence.
val document = Document(Document.Key("my-doc"))scope.launch {client.attachDocument(document,initialPresence = mapOf("name" to "Alice", "color" to "blue"),syncMode = Client.SyncMode.Realtime).await()}
disableGC
Controls only the wire contract: the server skips minVV tracking and omits the response VersionVector for this client. It is distinct from any local-only Document GC pass. Honored on first attach only; the server persists the value and returns it on later attaches.
disablePresence
When true, the initial presence is not pushed and presence updates from document.updateAsync() are dropped. Honored on first attach only; the server persists the value and returns it on later attaches.
scope.launch {client.attachDocument(document, disableGC = true, disablePresence = true).await()}
Detaching Documents
Detach a document from the client. The client no longer synchronizes the document with the server. This is important for garbage collection to clean up CRDT tombstones.
Parameters:
document: The document to detachkeepalive: Iftrue, ensures the detach request completes even if the app terminates (optional, default:false)
scope.launch {client.detachDocument(document).await()}
Synchronizing Documents
Manually push local changes to the server and pull remote changes. This is useful when using Manual sync mode or when you need to force synchronization.
Parameters:
document: Specific document to sync. Ifnull, syncs all attached documents (optional)
// Sync a specific documentscope.launch {client.syncAsync(document).await()}// Sync all attached documentsscope.launch {client.syncAsync().await()}
Changing Synchronization Mode
Change the synchronization mode of an attached document. This allows you to dynamically control how the document synchronizes with the server.
Yorkie offers five SyncModes:
Client.SyncMode.Realtime: Local changes are automatically pushed to the server, and remote changes are pulled from the server.Client.SyncMode.RealtimePushOnly: Only push local changes automatically.Client.SyncMode.RealtimeSyncOff: Changes are not synchronized, but the watch stream remains active.Client.SyncMode.Polling: The sync loop runs without opening a watch stream; local changes are pushed and remote changes pulled on a fixed interval (Client.Options.documentPollInterval). Stream-less and suited to low-frequency updates.Client.SyncMode.Manual: Synchronization no longer occurs in real-time, and the watch stream is disconnected.
Parameters:
document: The document to updatesyncMode: The new synchronization mode
// Switch to manual sync modeclient.changeSyncMode(document, Client.SyncMode.Manual)// Switch back to realtime syncclient.changeSyncMode(document, Client.SyncMode.Realtime)
Broadcasting Messages
Broadcast a message to other clients subscribed to the document. This is useful for sending ephemeral messages that don't need to be stored in the document, such as notifications, alerts, or temporary states.
Parameters:
key: The key of the attached document or channel to broadcast totopic: The topic/channel of the messagepayload: The message payload as a string (can be JSON)options: Broadcast options including retry configuration (optional)
scope.launch {client.broadcast(key = document.getKey(),topic = "cursor-moved",payload = """{"x": 100, "y": 200}""").await()}
Removing Documents
Permanently remove a document from the server. This operation is irreversible and deletes the document for all clients.
Parameters:
document: The document to remove
scope.launch {client.removeDocument(document).await()}
Deactivating a Client
Deactivate the client and disconnect from the server. All attached documents are detached automatically.
Parameters:
keepalive: Iftrue, ensures the deactivation request completes even if the app terminates (optional, default:false)
scope.launch {client.deactivateAsync().await()}
Client Options
When creating a client, you can configure various options:
key: Client identifier (default: a random UUID generated for everyClient; see Client Identity)apiKey: API key for the Yorkie projectmetadata: Additional client metadata as key-value pairsfetchAuthToken: Optional function to fetch authentication tokens when neededsyncLoopDuration: Duration between sync loops (default: 50ms)reconnectStreamDelay: Delay before reconnecting after stream disconnect (default: 1000ms)channelHeartbeatInterval: How often the client refreshes a Channel session (default:5_000.milliseconds)documentPollInterval: Push-pull interval for documents attached withSyncMode.Polling, as a kotlinDuration(default:3_000.milliseconds, i.e. milliseconds, not seconds). Unused in other sync modes.watchFallbackDelay: How long a realtime document's Watch stream may stay silent before that document starts pulling atdocumentPollIntervalas if it werePolling(default:10_000.milliseconds;Duration.INFINITEdisables the fallback)watchHeartbeatInterval: Heartbeat interval requested for the Watch stream, since v0.7.25 (default:20_000.milliseconds; see Watch Stream Heartbeats)docStore: Backing store for Offline Persistence (default:null, which disables persistence)sessionLock: Single-active-session guard consulted only whendocStoreis set (default:NoopSessionLock)
val client = Client(host = "https://api.yorkie.dev",options = Client.Options(key = "my-client",apiKey = "your-api-key",metadata = mapOf("device" to "mobile"),syncLoopDuration = 100.milliseconds,reconnectStreamDelay = 2.seconds,documentPollInterval = 3_000.milliseconds))
Client Identity
A client carries two identifiers, and they are not interchangeable.
Client.requireClientId()(andStatus.Activated.clientId) is the session id, issued per activation. A new one is minted every time the client activates.Client.actorId(andClient.requireActorId(), which throws while the client is deactivated) is the stable actor, supplied by the server on activation and derived from the project and the client key. It is what the SDK stamps into the document's changes. Against a server older than v0.7.20, which never sends one, it equals the session id.
val client = Client(host = "https://api.yorkie.dev",options = Client.Options(apiKey = "your-api-key",key = myPersistedClientKey))scope.launch {client.activateAsync().await()val actor = client.actorId // null while deactivated}
The actor is stable only if the client key is. Client.Options.key defaults to a random UUID minted for every Client, so the actor changes on every launch. Persist an opaque random key once per signed-in user, pass the same one each time, and clear it on sign-out. Do not derive it from a user id, device id or email: the key is an identifier, not a credential, so a guessable key lets another client of the same project activate under the same actor. This matters most alongside Offline Persistence, where a document stored under one actor and resumed under another has its un-pushed changes dropped with reason ActorMismatch.
Since v0.7.19 the actor โ not the session id โ is what keys presence and identifies peers in presence events. An app that used Client.requireClientId() or Status.Activated.clientId to find itself among peers must use Client.actorId or Status.Activated.actorId. Because the actor also breaks ties between concurrent edits and is derived from the client key rather than being time-ordered, the winner of a tie no longer follows client creation order.
Watch Stream Heartbeats
Since v0.7.25, the client asks the server for a heartbeat on an idle Watch stream, and reconnects a stream that stays silent for three of the intervals the server advertises, so a half-open connection no longer goes unnoticed. The stream is aborted with YorkieException.Code.ErrWatchStreamIdle and reconnected after reconnectStreamDelay. Set the interval with Client.Options.watchHeartbeatInterval, a kotlin Duration that defaults to 20_000.milliseconds. A zero or negative value opts out, leaving the stream without an idle watchdog. The server clamps the request into its own bounds (5 to 60 seconds by default) and echoes the effective value back when the stream starts. Against a server older than v0.7.25 no heartbeats are sent, and the stream keeps the old fixed 5-minute timeout.
val client = Client(host = "https://api.yorkie.dev",options = Client.Options(apiKey = "your-api-key",watchHeartbeatInterval = 30.seconds))
This is separate from channelHeartbeatInterval, which is how often the client refreshes a channel session, whereas watchHeartbeatInterval is how often the server writes to the client's own Watch stream.
Document
Document is the primary data type in Yorkie, providing a JSON-like updating experience that makes it easy to represent your application's model.
A Document can be updated without being attached to the client, and its changes automatically propagate to other clients when the Document is attached to the Client or when the network is restored.
Creating a Document
Create a new document instance with a unique key. The document can be modified locally before being attached to a client.
Parameters:
key: Unique identifier for the documentoptions: Optional configuration (e.g., disable garbage collection)
val document = Document(Document.Key("my-document"))
The document key identifies the Document in Yorkie. It is a string that you can freely define, but it can only contain a-z, A-Z, 0-9, -, ., _, ~ and must be between 4 and 120 characters.
Document Options
Document.Options configures the document independently of the attach flags:
disableGC:Boolean(default:false) - disables garbage collection for the document.disablePresence:Boolean(default:false) - seeds the presence-free state before attach; the server-fixated value from the attach response takes precedence once attached.maxUndoDepth:Int(default:50) - how many entrieshistory.undo()can go back, and likewise forhistory.redo(). Since v0.7.24. Must be a positive integer; otherwise constructing the options throwsYorkieExceptionwith codeErrInvalidArgument. See History (Undo/Redo).
There is no enableDevtools option on Android.
val document = Document(Document.Key("my-doc"), Document.Options(disableGC = true, maxUndoDepth = 100))
Attaching a Document
Attach the document to a client. Once attached, the document synchronizes with the server and other clients. You can set initial presence and choose the sync mode.
scope.launch {client.attachDocument(document,initialPresence = mapOf("name" to "Alice", "color" to "blue"),syncMode = Client.SyncMode.Realtime).await()}
Since v0.7.25, when the client key is generated (no Client.Options.key), edits made to a Document before attachDocument are re-issued to the client's actor on attach. Positions, element ids or other identifiers taken from the Document before attaching no longer resolve afterwards; read them again once the attach returns. With an explicit Client.Options.key, pre-attach edits keep their original actor. A Document that has already synced is never re-issued.
Document Status
The document can be in one of three states:
DocStatus.Attached: Document is attached to a client and synchronizedDocStatus.Detached: Document is not attached to a clientDocStatus.Removed: Document has been permanently removed
when (document.status) {DocStatus.Attached -> println("Document is attached")DocStatus.Detached -> println("Document is detached")DocStatus.Removed -> println("Document is removed")}
Updating a Document
Update the document structure and/or presence. The updater function provides access to the document root and presence. Changes automatically synchronize with other clients when attached.
Parameters:
message: Optional description of the changeskipHistory: Iftrue, the change is not recorded on the undo/redo history stacks โ it still mutates the document, emits events, and syncs normally (optional, default:false)updater: Lambda function to modify the document root and/or presence
scope.launch {document.updateAsync("Add user data") { root, _ ->root.setNewObject("user")root.getAs<JsonObject>("user")["name"] = "Alice"root.getAs<JsonObject>("user")["age"] = 30}.await()}
Getting Document Content
getRoot()
Return a proxy of the document's root object that you can use to read values.
scope.launch {val root = document.getRoot()val user = root.getAs<JsonObject>("user")println("User name: ${user["name"]}")}
getValueByPath()
Get a value at a specific JSON path. The path must start with $, which represents the document root.
Parameters:
path: JSON path (e.g.,$.user.name,$.todos[0])
scope.launch {val userName = document.getValueByPath("$.user.name")println("User: $userName")}
toJson()
Convert the entire document to a JSON string representation.
val json = document.toJson()println("Document JSON: $json")
Subscribing to Document Events
Subscribe to document events to be notified when changes occur, either locally or from other clients.
Event Types:
Snapshot: Document snapshot received from serverLocalChange: Document changed by current clientRemoteChange: Document changed by another clientPresenceChanged: Presence data changed (see Presence section)SyncStatusChanged: Sync status changed (Synced,SyncFailed)StreamConnectionChanged: Connection status changed (Connected,Disconnected)DocumentStatusChanged: Document status changedBroadcast: Broadcast message receivedAuthError: Authentication error occurredWriteRejected: Server refused to store the pushed changes, since v0.7.25 (see Handling Rejected Writes)EpochMismatch: The document was compacted on the server and this client must detach and reattach to recoverLocalChangesDropped: Local changes were discarded without reaching the server (see When local changes are dropped)
scope.launch {document.events.collect { event ->when (event) {is Document.Event.Snapshot -> {// Update with data from the Yorkie Document}is Document.Event.LocalChange -> {println("Local change: ${event.changeInfo.message}")}is Document.Event.RemoteChange -> {println("Remote change from: ${event.changeInfo.actorID}")}else -> Unit}}}
Document.Event is a sealed interface. WriteRejected was added in v0.7.25, and Document.Event.Reason.WriteDenied and LogDiscontinuity were added to the LocalChangesDropped reasons in v0.7.25 and v0.7.20. A when over the events or over Reason that is used as an expression, or that lists every case, will not compile until it handles the new case; add an else branch so later additions do not break the build again.
When a change from another client cannot be applied, sync fails with a YorkieException whose code is ErrChangeApplyFailed. Its message names the document, the change and the operation that failed, and includes the original error. The change is not skipped: the server sends it again on the next sync, so the error repeats until the cause is fixed.
events(targetPath)
Subscribe to changes for a specific path in the document. Only events affecting the target path or its nested values are emitted.
Parameters:
targetPath: JSON path to subscribe to (e.g.,$.todos,$.user.name)
scope.launch {document.events("$.todos").collect { event ->// Events will be delivered when the root.todos or its nested values changeval todos = document.getValueByPath("$.todos")// Handle the change}}
If a client has not synchronized for a prolonged period and then makes a sync request, it might receive a Snapshot
event.
Ensure your application processes these snapshot events correctly to maintain document synchronization.
Handling Rejected Writes
Since v0.7.25, a push the server refuses with ErrDocumentSizeExceedsLimit or ErrChangeTooLarge is no longer retried, because resending the same changes cannot change the verdict. The document is parked: the sync loop skips it while the client's other documents keep syncing, pulls on it stop too, and its rejected changes stay applied locally but do not reach the server. Collect Document.Event.WriteRejected to tell the user, for example by switching the editor to read-only. It is published next to the SyncFailed sync status for the same failure, not instead of it.
scope.launch {document.events.collect { event ->if (event is Document.Event.WriteRejected) {// event.code: YorkieException.Code.ErrDocumentSizeExceedsLimit or ErrChangeTooLarge// event.reason: the server's message// event.method: Document.Event.AuthError.AuthErrorMethod.PushPull}}}
A bare client.syncAsync(document) does not recover a parked document: the rejected change is still the head of the queue, so the sync fails the same way and the event is published again. Recover with client.resyncAsync. Any push-pull that the server accepts un-parks the document.
Since v0.7.25, an ErrPermissionDenied on PushPull โ for example, an auth webhook that starts refusing this client's writes โ also publishes a Document.Event.AuthError with method PushPull, once per denial episode rather than once per sync-loop tick, and parks the document the same way. A denial on the Watch stream does not. An ErrUnauthenticated on PushPull publishes the same event while the token is refreshed and the call retried, and the event carries no error code, so tell the two apart by the reason your auth webhook returns before recovering. Recover with client.syncAsync(document) once the webhook is expected to accept writes again, or with client.resyncAsync.
Recovering from refused writes
When the server keeps refusing a document's writes โ an ErrPermissionDenied from an auth webhook, or a rejected write โ the refused changes stay in the queue and every later sync sends them again. client.resyncAsync(document, discardLocalChanges = true) discards them and re-anchors the same Document instance on the server's state, so your event collectors and any editor bound to the document survive. It re-attaches with the sync mode and disableGC option the document had, and with its current presence unless you pass initialPresence.
// Once the app has decided the server is refusing this document's writes:scope.launch {client.resyncAsync(document, discardLocalChanges = true).await().onSuccess { dropped ->dropped.forEach { change ->println("discarded ${change.operationCount} ops (${change.message})")}}}
discardLocalChanges must be true, otherwise the call throws YorkieException with code ErrInvalidArgument; it keeps the data loss visible at the call site. The returned list is also published as a Document.Event.LocalChangesDropped with reason Reason.WriteDenied. While the document is being re-anchored, document.isMintable() is false, document.history.canUndo() and canRedo() are false, and updateAsync, undoAsync and redoAsync fail with a YorkieException with code ErrRefused; an edit made before the discard is part of the discarded set. An explicit client.syncAsync(document) is refused while the document is resyncing, and so is a second resyncAsync or a detachDocument on it.
Failures are not uniform. A call with discardLocalChanges = false, on a document that is not attached, or on one already resyncing throws before anything is touched. If the server refuses the detach, resyncAsync resolves with Result.failure, the queue is restored, and the document is left attached exactly as it was, so the call can be retried. If the re-attach fails after an otherwise successful detach, the call throws, the queue is already discarded, and the document is left detached: attach it again yourself.
Offline Persistence
By default a Document lives only in memory: if the app is killed with changes that have not reached the server, those changes are gone. Client.Options.docStore gives the client somewhere to persist every document attached to it. attachDocument resumes a previously persisted document before contacting the server, the document is persisted again on every local change, local presence change and successful sync, and its un-pushed edits are replayed and pushed on the next successful sync. The persisted entry is removed once the document is detached, removed, or learned removed via a sync, and is kept across deactivateAsync so the next session can resume it. Offline persistence needs Yorkie server v0.7.20 or later.
val client = Client(host = "https://api.yorkie.dev",options = Client.Options(apiKey = "your-api-key",key = myPersistedClientKey,docStore = MyDurableDocStore()))
Surviving an app restart takes a durable store and a stable client key. The built-in MemoryDocStore keeps documents in a process-local map, so it recovers nothing once the process is gone; it is useful for trying the API out or for resuming within a single run, and nothing more. Implement DocStore over your own storage (a file, Room, DataStore) to survive restarts, and pass a Client.Options.key you persist. With the default random key, the next launch addresses an entry the previous one never wrote, so the un-pushed edits are lost silently and the previous entries are orphaned with nothing to reclaim them. The client logs an error when docStore is set without a key you supplied. See Client Identity.
Implementing a DocStore
A store holds, per document key, a base snapshot plus an append-only log of the changes made since it. That shape is what keeps writes cheap: appending costs the size of one change, which does not grow with the document, whereas re-serializing the whole document costs time proportional to its size โ and a document is at its largest exactly while it is being edited. The log is folded back into a fresh snapshot once it grows past a size-relative threshold.
interface DocStore {suspend fun load(docKey: String): StoredDoc?suspend fun saveSnapshot(docKey: String, bytes: ByteArray)suspend fun appendChange(docKey: String, change: StoredChange)suspend fun saveMeta(docKey: String, bytes: ByteArray)suspend fun remove(docKey: String)}
| Method | Contract |
|---|---|
load | Returns the stored StoredDoc (snapshot, meta and changes), whose changes must be ordered by ascending clientSeq, or null when the key is absent. |
saveSnapshot | Replaces the snapshot and atomically drops every appended change and any stored meta. This is compaction โ the new snapshot already contains those changes, and it embeds a newer header than meta holds. |
appendChange | Appends one local change. This is the hot path, so it must not rewrite the whole entry. It is an upsert keyed by clientSeq: re-appending a stored change replaces it, so a retried write is safe. |
saveMeta | Records the post-sync header. Leaves the snapshot alone, and leaves the log alone โ only compaction trims the log. |
remove | Removes everything persisted for the key. Removing an absent key succeeds. |
The bytes are opaque to the SDK, so an implementation is free to compress or encrypt what it is handed.
appendChange and saveMeta must be a no-op when nothing is stored for the key โ they must never create an entry. An entry holding a log with no snapshot under it cannot be restored. The client always writes a base snapshot before it appends.
The key passed to DocStore is built from the API key, the client key and the document key. Since v0.7.24, a / or % in any of them is escaped (%2F, %25), so two different identities can never share an entry or a session lock. A document stored by an earlier version under a key containing either character is not found after upgrading; push its changes before upgrading.
Sharing a store across processes
Two sessions resuming the same persisted document would both replay its log and both push, duplicating the work and minting colliding clientSeq values. Client.Options.sessionLock guards against that, and it is consulted only when docStore is set. acquire(name) returns a SessionLockHandle, or null when the name is held elsewhere; the attach then fails immediately with ErrDocumentOpenElsewhere instead of waiting. The default is NoopSessionLock, which grants every lease โ correct for a single process, which is how an Android app uses a store by default. Supply your own when a durable store is shared across processes, such as an app and a widget or background service.
val client = Client(host = "https://api.yorkie.dev",options = Client.Options(apiKey = "your-api-key",key = myPersistedClientKey,docStore = MyDurableDocStore(),sessionLock = MySessionLock()))
When local changes are dropped
Some resumes cannot be reconciled with the server at all. When that happens the SDK keeps what it can and publishes a Document.Event.LocalChangesDropped describing what was lost, so the app can surface the data loss or re-apply the changes on top of the re-anchored state.
scope.launch {document.events.collect { event ->if (event is Document.Event.LocalChangesDropped) {event.changes.forEach { change ->// change.id, change.message, change.operationCount, change.hasPresenceChangeprintln("dropped ${change.operationCount} ops (${event.reason})")}}}}
event.reason says why:
| Reason | Meaning |
|---|---|
EpochReanchor | The document was compacted server-side, so the resume re-anchored from a snapshot. |
DocumentPurged | The server no longer has the document the changes were made against. |
ActorMismatch | The stored document belongs to a different actor than the one resuming it. |
RestoreFailed | The stored bytes could not be decoded at all, so nothing was restored. |
WriteDenied | The server refused this client's writes, and client.resyncAsync discarded them to re-anchor the document. See Recovering from refused writes. |
LogDiscontinuity | The appended change log had a hole, so it could not be replayed. The snapshot restored normally; what was lost is the log written after it. |
Changing Synchronization Mode
Change how the document synchronizes with the server. See the Client Changing Synchronization Mode section for available modes and their descriptions.
// Enable automatic synchronization of both local and remote changesclient.changeSyncMode(document, Client.SyncMode.Realtime)// Only push local changes automaticallyclient.changeSyncMode(document, Client.SyncMode.RealtimePushOnly)// Synchronization turned off, but the watch stream remains activeclient.changeSyncMode(document, Client.SyncMode.RealtimeSyncOff)// Sync loop runs without a watch stream, pushing/pulling on a fixed intervalclient.changeSyncMode(document, Client.SyncMode.Polling)// Synchronization turned off, and the watch stream is disconnectedclient.changeSyncMode(document, Client.SyncMode.Manual)scope.launch {client.syncAsync(document).await() // Trigger synchronization manually}
Detaching a Document
Detach the document from the client. The document no longer synchronizes with the server. This is important for efficient garbage collection to remove CRDT tombstones.
scope.launch {client.detachDocument(document).await()}
History (Undo/Redo)
document.history provides local undo/redo over changes recorded from this client's updateAsync() calls. Use canUndo()/canRedo() to check stack state, and undoAsync()/redoAsync() to apply the reverse operations.
scope.launch {document.updateAsync { root, _ ->root["title"] = "Hello"}.await()if (document.history.canUndo()) {document.history.undoAsync().await()}if (document.history.canRedo()) {document.history.redoAsync().await()}}
Changes applied with updateAsync(skipHistory = true) are excluded from the history stacks, mirroring how remote changes bypass local history.
Limitations
- Maximum stack depth: 50 entries per stack by default; set
Document.Options.maxUndoDepth(since v0.7.24) to change it. EachupdateAsyncthat changes the document is one entry, and pushing beyond the depth drops the oldest. A reverse entry keeps a copy of the content it would restore, so a larger depth holds more memory. - Not available during update: calling
undoAsync()orredoAsync()inside anupdateAsyncupdater resolves the returnedDeferredwithResult.failureholding anIllegalStateException("Undo is not allowed during an update", or "Redo is not allowed during an update").canUndo()andcanRedo()arefalsewhile an update is running. - Refused while resyncing: between the discard and the re-anchor of
client.resyncAsync,canUndo()andcanRedo()arefalseandundoAsync()andredoAsync()fail with aYorkieExceptionwith codeErrRefused. - Local only: only the local client's own changes enter the stacks. Remote changes are applied to the Document but are never reverted by your
undoAsync().
val document = Document(Document.Key("my-document"), Document.Options(maxUndoDepth = 10))
Undo and redo affect only your own editing history, and the result still converges with concurrent edits from other clients.
Json Types
Yorkie provides several JSON-based data types that can be used to model your application's data. These types are CRDT-based, ensuring automatic conflict resolution in collaborative environments.
JsonObject
JsonObject is a key-value data structure similar to a JSON object or Kotlin Map. It can contain any JSON-compatible values, including other objects, arrays, primitives, and custom CRDT types.
Creating and Using JsonObject:
scope.launch {document.updateAsync { root, _ ->// Create nested objectsroot.setNewObject("user")root.getAs<JsonObject>("user")["name"] = "Alice"root.getAs<JsonObject>("user")["age"] = 30root.getAs<JsonObject>("user").setNewObject("address")val address = root.getAs<JsonObject>("user").getAs<JsonObject>("address")address["city"] = "Seoul"address["country"] = "South Korea"}.await()}
Accessing Values:
scope.launch {val root = document.getRoot()val user = root.getAs<JsonObject>("user")val name = user["name"] // "Alice"val age = user["age"] // 30// Check if key existsval hasEmail = user.has("email") // false// Get value or nullval email = user.getOrNull("email") // null}
Removing Values:
scope.launch {document.updateAsync { root, _ ->val user = root.getAs<JsonObject>("user")user.remove("age")}.await()}
JsonArray
JsonArray is an ordered collection of values similar to a JSON array or Kotlin List. It supports adding, removing, and accessing elements by index.
Creating and Using JsonArray:
scope.launch {document.updateAsync { root, _ ->// Create an array with primitivesroot.setNewArray("items").apply {put("Apple")put("Banana")put("Cherry")}// Create an array with nested structuresroot.setNewArray("users").apply {putNewObject().apply {set("name", "Alice")set("age", 30)}putNewObject().apply {set("name", "Bob")set("age", 25)}}}.await()}
Accessing and Modifying Elements:
scope.launch {document.updateAsync { root, _ ->val items = root.getAs<JsonArray>("items")// Access by indexval first = items[0] // "Apple"// Update valueitems[1] = "Blueberry"// Remove elementitems.removeAt(2)// Add at specific positionitems.insertAt(0, "Avocado")// Get array sizeval size = items.length}.await()}
Nested Arrays:
scope.launch {document.updateAsync { root, _ ->root.setNewArray("matrix").apply {putNewArray().apply {put(1)put(2)put(3)}putNewArray().apply {put(4)put(5)put(6)}}}.await()}
JsonText
JsonText is a specialized CRDT type for collaborative text editing. It supports text operations such as insert, delete, and styling, making it ideal for building rich text editors.
Creating and Editing Text:
scope.launch {document.updateAsync { root, _ ->root.setNewText("content")root.getAs<JsonText>("content").edit(0, 0, "Hello World")}.await()}
Since v0.7.24, JsonText.edit() throws YorkieException with code ErrInvalidArgument, which fails the whole updateAsync call, when fromIndex or toIndex falls between the two UTF-16 units of a surrogate pair (an emoji, for example) or when content holds a lone surrogate half. Indexes are counted in UTF-16 code units. The same applies to JsonTree text edits and splits.
Text Operations:
scope.launch {document.updateAsync { root, _ ->val text = root.getAs<JsonText>("content")// Insert text at positiontext.edit(0, 0, "Hello")// Replace text rangetext.edit(0, 5, "Hi") // Replace "Hello" with "Hi"// Delete text rangetext.edit(0, 2, "") // Delete "Hi"// Delete with helper methodtext.delete(0, 5)// Clear all texttext.clear()}.await()}
Styling Text:
scope.launch {document.updateAsync { root, _ ->val text = root.getAs<JsonText>("content")text.edit(0, 0, "Hello World")// Apply styles to text rangetext.style(0, 5, mapOf("bold" to "true"))text.style(6, 11, mapOf("italic" to "true", "color" to "blue"))}.await()}
Edit with Attributes:
scope.launch {document.updateAsync { root, _ ->val text = root.getAs<JsonText>("content")// Insert text with attributestext.edit(0, 0, "Bold text", mapOf("bold" to "true"))}.await()}
Getting Text Properties:
val text = document.getRoot().getAs<JsonText>("content")val length = text.lengthval textValue = text.toString()val values = text.values // List of TextWithAttributes
Text Selection and Presence:
For sharing text cursor positions between users, convert index positions to CRDT positions:
// Convert index range to position range (for presence)document.updateAsync { root, presence ->val text = root.getAs<JsonText>("content")val indexRange = text.edit(from, to, content)val posRange = text.indexRangeToPosRange(indexRange)posRange?.let {presence.put(mapOf("selection" to gson.toJson(posRange)))}}// Convert position range back to index rangedocument.events.filterIsInstance<Document.Event.PresenceChanged.Others>().collect { event ->if (event is Document.Event.PresenceChanged.Others.PresenceChanged) {val (clientID, presence) = event.changedval selectionJson = presence["selection"] as? StringselectionJson?.let {val posRange = gson.fromJson(it, TextPosStructRange::class.java)val indexRange = document.getRoot().getAs<JsonText>("content").posRangeToIndexRange(posRange)// Update editor selection UI}}}
JsonCounter
JsonCounter is a CRDT type that supports atomic increment and decrement operations, automatically resolving conflicts when multiple clients modify the counter simultaneously.
Creating and Using Counter:
scope.launch {document.updateAsync { root, _ ->// Create counter with initial valueroot.setNewCounter("likes", 0)root.setNewCounter("views", 100)}.await()}
Incrementing and Decrementing:
scope.launch {document.updateAsync { root, _ ->val likes = root.getAs<JsonCounter>("likes")// Increase by positive numberlikes.increase(1)likes.increase(5)// Decrease by negative numberlikes.increase(-2)}.await()}
Getting Counter Value:
val root = document.getRoot()val likes = root.getAs<JsonCounter>("likes")val count = likes.value // CounterValue (Int or Long)
Use Cases:
JsonCounter is ideal for:
- Like counts
- View counts
- Vote tallies
- Collaborative counters
- Any numeric value that multiple users might modify simultaneously
JsonTree
JsonTree is a CRDT-based tree structure designed for representing hierarchical document structures such as HTML or XML. It is ideal for building rich text editors with complex formatting (such as ProseMirror or Quill).
Creating a Tree:
import dev.yorkie.document.json.TreeBuilder.elementimport dev.yorkie.document.json.TreeBuilder.textscope.launch {document.updateAsync { root, _ ->root.setNewTree("content",element("doc") {element("p") {text { "Hello World" }}element("p") {text { "Second paragraph" }}})}.await()}
Tree with Attributes:
scope.launch {document.updateAsync { root, _ ->root.setNewTree("content",element("doc") {element("p") {text { "Bold text" }attr { "bold" to true }}element("a") {text { "Click here" }attr {"href" to "https://example.com""target" to "_blank"}}})}.await()}
Editing Tree:
scope.launch {document.updateAsync { root, _ ->val tree = root.getAs<JsonTree>("content")// Insert nodes at positiontree.edit(7, 7, // position rangeelement("p") {text { "New paragraph" }})// Delete nodestree.edit(0, 5) // Delete nodes in range}.await()}
Since v0.7.15, edit() and editByPath() throw YorkieException with code ErrInvalidArgument when the given range or path resolves to an invalid split position (previously a raw StringIndexOutOfBoundsException could surface).
Styling Tree Nodes:
scope.launch {document.updateAsync { root, _ ->val tree = root.getAs<JsonTree>("content")// Apply styles to nodes in rangetree.style(0, 5, mapOf("color" to "red", "fontSize" to "16"))}.await()}
Getting Tree Properties:
val root = document.getRoot()val tree = root.getAs<JsonTree>("content")val size = tree.sizeval rootNode = tree.rootTreeNodeval xml = tree.toXml() // Convert tree to XML string
Tree Structure:
JsonTree consists of two types of nodes:
ElementNode: Represents an element with a type, attributes, and childrenTextNode: Represents a text node with a value
Complex Tree Example:
scope.launch {document.updateAsync { root, _ ->root.setNewTree("article",element("doc") {element("h1") {text { "Article Title" }}element("p") {text { "This is " }element("strong") {text { "bold" }}text { " and this is " }element("em") {text { "italic" }}text { "." }}element("ul") {element("li") {text { "First item" }}element("li") {text { "Second item" }}}})}.await()}
Presence
Presence allows you to share the temporary state of online users in real-time. Unlike document data, presence information is not stored permanently and is only maintained while users are connected. This makes it ideal for showing user cursors, selections, online status, or any other ephemeral user state.
Setting Initial Presence
When attaching a document, you can set the initial presence for the client using the initialPresence parameter:
Function Signature:
fun attachDocument(document: Document,initialPresence: P = emptyMap(),syncMode: Client.SyncMode = Client.SyncMode.Realtime,schema: String? = null,disableGC: Boolean = false,disablePresence: Boolean = false): Deferred<OperationResult>
Parameters:
initialPresence: P- Initial presence data as a map of string key-value pairs (default: empty map)
Example:
scope.launch {client.attachDocument(document,initialPresence = mapOf("name" to "Alice","color" to "#FF5733","status" to "online","avatar" to "https://example.com/avatar.png")).await()}
Updating Presence
Update presence at any time using document.updateAsync(). The presence parameter in the updater function is a Presence instance.
Function Signature:
fun updateAsync(message: String? = null,skipHistory: Boolean = false,updater: suspend (root: JsonObject, presence: Presence) -> Unit): Deferred<OperationResult>
Parameters:
updater: suspend (root: JsonObject, presence: Presence) -> Unit- Lambda withpresenceparameter for updates
Example:
scope.launch {// Update presence only (no document changes)document.updateAsync { _, presence ->presence.put(mapOf("cursor" to "100,200","status" to "typing"))}.await()}
Merge Behavior:
Presence changes are merged, not replaced. Only the specified keys are updated.
Since v0.7.25, when the server advertises support for it, the SDK sends a presence update as a patch carrying only the changed top-level keys, rather than the whole presence. This is transparent to the app: presence.put and the presence events behave as before, and against a server older than v0.7.25 the SDK keeps sending the full presence.
// Initial presence: { "name": "Alice", "color": "blue" }scope.launch {document.updateAsync { _, presence ->presence.put(mapOf("status" to "typing"))}.await()}// Result: { "name": "Alice", "color": "blue", "status": "typing" }
Updating Multiple Keys:
scope.launch {document.updateAsync { _, presence ->presence.put(mapOf("cursor" to "200,300","selection" to "5,10","status" to "editing","lastActivity" to System.currentTimeMillis().toString()))}.await()}
Updating Document and Presence Together:
scope.launch {document.updateAsync { root, presence ->// Update documentroot.getAs<JsonText>("content").edit(0, 0, "Hello")// Update presencepresence.put(mapOf("cursor" to "5","status" to "typing"))}.await()}
Presence Events
Presence events notify you when users join, leave, or update their presence. These events are part of Document.Event.PresenceChanged:
MyPresence Events
Events related to the current client:
MyPresence.Initialized: Emitted when the presence state is initialized. Contains all currently online users and their presences.MyPresence.PresenceChanged: Emitted when the current client updates their own presence.
Others Events
Events related to other clients:
Others.Watched: Emitted when another client joins the document.Others.Unwatched: Emitted when another client leaves the document.Others.PresenceChanged: Emitted when another client updates their presence.
Subscribing to Presence Events
Subscribe to presence events before attaching the document to ensure you receive the initial state:
scope.launch {document.events.filterIsInstance<Document.Event.PresenceChanged>().collect { event ->when (event) {is Document.Event.PresenceChanged.MyPresence.Initialized -> {// Initial presence state loaded from serverval onlineUsers = event.initializedprintln("Online users: ${onlineUsers.size}")onlineUsers.forEach { (clientID, presence) ->println("${presence["name"]} is online")}}is Document.Event.PresenceChanged.Others.Watched -> {// Another client joinedval (clientID, presence) = event.changedprintln("${presence["name"]} joined")}is Document.Event.PresenceChanged.Others.Unwatched -> {// Another client leftval (clientID, presence) = event.changedprintln("${presence["name"]} left")}is Document.Event.PresenceChanged.Others.PresenceChanged -> {// Another client updated their presenceval (clientID, presence) = event.changedprintln("${presence["name"]} updated: ${presence["status"]}")}is Document.Event.PresenceChanged.MyPresence.PresenceChanged -> {// Current client's presence updatedval (clientID, presence) = event.changedprintln("My presence updated: $presence")}}}}
Channel
Channel is a lightweight presence counter for pub/sub use cases that don't need document-level CRDT sync, such as showing how many users are viewing a page or broadcasting ephemeral messages to a topic. A channel key is an opaque, freely-defined string; there is no hierarchical-key feature in the Android SDK.
Creating a Channel
val channel = Channel("my-channel-key")
Attaching a Channel
Attach the channel to the client. The isRealtime parameter controls whether the client watches for channel changes in realtime.
Parameters:
channel: The channel to attachisRealtime: Iftrue(default), starts watching for channel changes in realtime. Iffalse, uses manual sync mode where you must callclient.syncAsync(channel)to refresh the channel count.
scope.launch {client.attachChannel(channel, isRealtime = true).await()}// Manual sync mode: refresh the count yourselfscope.launch {client.attachChannel(channel, isRealtime = false).await()client.syncAsync(channel).await()}
Migration note: the deprecated Presence typealias and attachPresence(...) have been renamed to Channel and attachChannel(channel, isRealtime).
Tracking Session Count
Read the current online session count with getSessionCount(), or subscribe to channel.eventStream for ChannelEvent.Initialized and ChannelEvent.Changed updates.
val count = channel.getSessionCount()scope.launch {channel.eventStream.collect { event ->when (event) {is ChannelEvent.Initialized -> println("Initial session count: ${event.sessionCount}")is ChannelEvent.Changed -> println("Session count changed: ${event.sessionCount}")else -> {}}}}
getCount() is deprecated - use getSessionCount() instead.
Peeking a Channel
Read a channel's current session count without creating a session on the server. Use this when you only need to display the count without contributing to it or receiving broadcasts.
scope.launch {val result = client.peekChannel("my-channel-key").await()result.onSuccess { sessionCount ->println("Current session count: $sessionCount")}}
Broadcasting on a Channel
Publish a message to a channel's topic using client.broadcast(key, topic, payload), and receive messages via channel.eventStream's ChannelEvent.Broadcast event.
// Publishscope.launch {client.broadcast(key = channel.getKey(),topic = "chat",payload = """{"message": "hello"}""").await()}// Subscribescope.launch {channel.eventStream.collect { event ->if (event is ChannelEvent.Broadcast) {println("${event.actorID} sent on ${event.topic}: ${event.payload}")}}}
Detaching a Channel
Detach the channel from the client. This is local-only cleanup; the server reclaims the session via TTL.
scope.launch {client.detachChannel(channel).await()}
Reference
For details on how to use the Android SDK, please refer to Android SDK Reference.