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 attach
  • initialPresence: Initial presence data (default: empty map)
  • syncMode: Synchronization mode (default: Realtime)
  • schema: Optional schema key for document validation
  • disableGC: Boolean (default: false) - declares that this attachment will not produce or consume tombstones. Use only with Counter or primitive workloads; misuse on a document that uses Tree, Text, or Array deletions 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 detach
  • keepalive: If true, 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. If null, syncs all attached documents (optional)
// Sync a specific document
scope.launch {
client.syncAsync(document).await()
}
// Sync all attached documents
scope.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 update
  • syncMode: The new synchronization mode
// Switch to manual sync mode
client.changeSyncMode(document, Client.SyncMode.Manual)
// Switch back to realtime sync
client.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 to
  • topic: The topic/channel of the message
  • payload: 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: If true, 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 every Client; see Client Identity)
  • apiKey: API key for the Yorkie project
  • metadata: Additional client metadata as key-value pairs
  • fetchAuthToken: Optional function to fetch authentication tokens when needed
  • syncLoopDuration: 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 with SyncMode.Polling, as a kotlin Duration (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 at documentPollInterval as if it were Polling (default: 10_000.milliseconds; Duration.INFINITE disables 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 when docStore is 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() (and Status.Activated.clientId) is the session id, issued per activation. A new one is minted every time the client activates.
  • Client.actorId (and Client.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 document
  • options: 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 entries history.undo() can go back, and likewise for history.redo(). Since v0.7.24. Must be a positive integer; otherwise constructing the options throws YorkieException with code ErrInvalidArgument. 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 synchronized
  • DocStatus.Detached: Document is not attached to a client
  • DocStatus.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 change
  • skipHistory: If true, 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 server
  • LocalChange: Document changed by current client
  • RemoteChange: Document changed by another client
  • PresenceChanged: Presence data changed (see Presence section)
  • SyncStatusChanged: Sync status changed (Synced, SyncFailed)
  • StreamConnectionChanged: Connection status changed (Connected, Disconnected)
  • DocumentStatusChanged: Document status changed
  • Broadcast: Broadcast message received
  • AuthError: Authentication error occurred
  • WriteRejected: 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 recover
  • LocalChangesDropped: 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 change
val 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)
}
MethodContract
loadReturns the stored StoredDoc (snapshot, meta and changes), whose changes must be ordered by ascending clientSeq, or null when the key is absent.
saveSnapshotReplaces 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.
appendChangeAppends 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.
saveMetaRecords the post-sync header. Leaves the snapshot alone, and leaves the log alone โ€” only compaction trims the log.
removeRemoves 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.hasPresenceChange
println("dropped ${change.operationCount} ops (${event.reason})")
}
}
}
}

event.reason says why:

ReasonMeaning
EpochReanchorThe document was compacted server-side, so the resume re-anchored from a snapshot.
DocumentPurgedThe server no longer has the document the changes were made against.
ActorMismatchThe stored document belongs to a different actor than the one resuming it.
RestoreFailedThe stored bytes could not be decoded at all, so nothing was restored.
WriteDeniedThe server refused this client's writes, and client.resyncAsync discarded them to re-anchor the document. See Recovering from refused writes.
LogDiscontinuityThe 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 changes
client.changeSyncMode(document, Client.SyncMode.Realtime)
// Only push local changes automatically
client.changeSyncMode(document, Client.SyncMode.RealtimePushOnly)
// Synchronization turned off, but the watch stream remains active
client.changeSyncMode(document, Client.SyncMode.RealtimeSyncOff)
// Sync loop runs without a watch stream, pushing/pulling on a fixed interval
client.changeSyncMode(document, Client.SyncMode.Polling)
// Synchronization turned off, and the watch stream is disconnected
client.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. Each updateAsync that 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() or redoAsync() inside an updateAsync updater resolves the returned Deferred with Result.failure holding an IllegalStateException ("Undo is not allowed during an update", or "Redo is not allowed during an update"). canUndo() and canRedo() are false while an update is running.
  • Refused while resyncing: between the discard and the re-anchor of client.resyncAsync, canUndo() and canRedo() are false and undoAsync() and redoAsync() fail with a YorkieException with code ErrRefused.
  • 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 objects
root.setNewObject("user")
root.getAs<JsonObject>("user")["name"] = "Alice"
root.getAs<JsonObject>("user")["age"] = 30
root.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 exists
val hasEmail = user.has("email") // false
// Get value or null
val 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 primitives
root.setNewArray("items").apply {
put("Apple")
put("Banana")
put("Cherry")
}
// Create an array with nested structures
root.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 index
val first = items[0] // "Apple"
// Update value
items[1] = "Blueberry"
// Remove element
items.removeAt(2)
// Add at specific position
items.insertAt(0, "Avocado")
// Get array size
val 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 position
text.edit(0, 0, "Hello")
// Replace text range
text.edit(0, 5, "Hi") // Replace "Hello" with "Hi"
// Delete text range
text.edit(0, 2, "") // Delete "Hi"
// Delete with helper method
text.delete(0, 5)
// Clear all text
text.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 range
text.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 attributes
text.edit(0, 0, "Bold text", mapOf("bold" to "true"))
}.await()
}

Getting Text Properties:

val text = document.getRoot().getAs<JsonText>("content")
val length = text.length
val 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 range
document.events.filterIsInstance<Document.Event.PresenceChanged.Others>().collect { event ->
if (event is Document.Event.PresenceChanged.Others.PresenceChanged) {
val (clientID, presence) = event.changed
val selectionJson = presence["selection"] as? String
selectionJson?.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 value
root.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 number
likes.increase(1)
likes.increase(5)
// Decrease by negative number
likes.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.element
import dev.yorkie.document.json.TreeBuilder.text
scope.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 position
tree.edit(
7, 7, // position range
element("p") {
text { "New paragraph" }
}
)
// Delete nodes
tree.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 range
tree.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.size
val rootNode = tree.rootTreeNode
val 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 children
  • TextNode: 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 with presence parameter 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 document
root.getAs<JsonText>("content").edit(0, 0, "Hello")
// Update presence
presence.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 server
val onlineUsers = event.initialized
println("Online users: ${onlineUsers.size}")
onlineUsers.forEach { (clientID, presence) ->
println("${presence["name"]} is online")
}
}
is Document.Event.PresenceChanged.Others.Watched -> {
// Another client joined
val (clientID, presence) = event.changed
println("${presence["name"]} joined")
}
is Document.Event.PresenceChanged.Others.Unwatched -> {
// Another client left
val (clientID, presence) = event.changed
println("${presence["name"]} left")
}
is Document.Event.PresenceChanged.Others.PresenceChanged -> {
// Another client updated their presence
val (clientID, presence) = event.changed
println("${presence["name"]} updated: ${presence["status"]}")
}
is Document.Event.PresenceChanged.MyPresence.PresenceChanged -> {
// Current client's presence updated
val (clientID, presence) = event.changed
println("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 attach
  • isRealtime: If true (default), starts watching for channel changes in realtime. If false, uses manual sync mode where you must call client.syncAsync(channel) to refresh the channel count.
scope.launch {
client.attachChannel(channel, isRealtime = true).await()
}
// Manual sync mode: refresh the count yourself
scope.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.

// Publish
scope.launch {
client.broadcast(
key = channel.getKey(),
topic = "chat",
payload = """{"message": "hello"}"""
).await()
}
// Subscribe
scope.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.