Android SDK

Through Yorkie Android SDK, you can efficiently build collaborative applications. On the client-side implementation, you can create Documents that are automatically synced 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 can communicate with the server. It has Documents and sends changes of the Document from local to the server to synchronize with other replicas in remote.

Creating a Client

Create a new client instance with the server host and configuration options.

val client = Client(
host = "your-yorkie-server-url",
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

Activates the client by registering it with the server. The server assigns a unique client ID that is used to distinguish 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

Attaches a document to the client. Once attached, the client will synchronize 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
val document = Document(Document.Key("my-doc"))
scope.launch {
client.attachAsync(
document,
initialPresence = mapOf("name" to "Alice", "color" to "blue"),
syncMode = Client.SyncMode.Realtime
).await()
}

Detaching Documents

Detaches a document from the client. The client will no longer synchronize 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.detachAsync(document).await()
}

Synchronizing Documents

Manually pushes local changes to the server and pulls remote changes. 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

Changes the synchronization mode of an attached document. This allows you to dynamically control how the document synchronizes with the server.

Yorkie offers four 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.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

Broadcasts 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 (e.g., notifications, alerts, or temporary states).

Parameters:

  • document: The document 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(
document,
topic = "cursor-moved",
payload = """{"x": 100, "y": 200}"""
).await()
}

Removing Documents

Permanently removes a document from the server. This operation is irreversible and will delete the document for all clients.

Parameters:

  • document: The document to remove
scope.launch {
client.removeAsync(document).await()
}

Deactivating a Client

Deactivates the client and disconnects from the server. All attached documents will be 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: random UUID)
  • 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)
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
)
)

Document

Document is a primary data type in Yorkie, which provides 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 are automatically propagated to other clients when the Document is attached to the Client or when the network is restored.

Creating a Document

Creates 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 is used to identify the Document in Yorkie. It is a string that can be freely defined by the user. However, it is allowed to use only a-z, A-Z, 0-9, -, ., _, ~ and must be between 4-120 characters.

Attaching a Document

Attaches the document to a client. Once attached, the document will be synchronized with the server and other clients. You can set initial presence and choose the sync mode.

scope.launch {
client.attachAsync(
document,
initialPresence = mapOf("name" to "Alice", "color" to "blue"),
syncMode = Client.SyncMode.Realtime
).await()
}

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

Updates the document structure and/or presence. The updater function provides access to the document root and presence. Changes are automatically synchronized with other clients when attached.

Parameters:

  • message: Optional description of the change
  • 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()

Returns 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()

Gets 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()

Converts 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
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}")
}
}
}
}

events(targetPath)

Subscribe to changes for a specific path in the document. Only events affecting the target path or its nested values will be 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.

Changing Synchronization Mode

Changes 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)
// 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

Detaches the document from the client. The document will no longer be synchronized with the server. This is important for efficient garbage collection to remove CRDT tombstones.

scope.launch {
client.detachAsync(document).await()
}

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 like 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()
}

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. It automatically resolves 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 like HTML or XML. It's ideal for building rich text editors with complex formatting (e.g., ProseMirror, 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()
}

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 is a feature that 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 attachAsync(
document: Document,
initialPresence: P = emptyMap(),
syncMode: Client.SyncMode = Client.SyncMode.Realtime,
schema: String? = null
): Deferred<OperationResult>

Parameters:

  • initialPresence: P - Initial presence data as a map of string key-value pairs (default: empty map)

Example:

scope.launch {
client.attachAsync(
document,
initialPresence = mapOf(
"name" to "Alice",
"color" to "#FF5733",
"status" to "online",
"avatar" to "https://example.com/avatar.png"
)
).await()
}

Updating Presence

You can 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,
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:

// 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")
}
}
}
}

Reference

For details on how to use the Android SDK, please refer to Android SDK Reference.