SimplySync Docs GitHub ↗

Native clients (Swift & Kotlin)

The engine and React bindings are TypeScript, but the protocol isn't — it's a few hundred lines of standard crypto over a tiny HTTP wire format. So a fully native app can join the same sync network without a JavaScript runtime. Two clean-room ports live in the repo and are byte-verified against the canonical @simplysync/protocol:

Port Path Shape
Swift (SimplySyncKit + engine) ports/swift Foundation + CryptoKit + SQLite3. Ships a near-complete @simplysync/engine port (schema, SQLite, reactive queries) on top of the Kit/ protocol core; also runs on macOS.
Kotlin / JVM ports/kotlin Pure JVM (no Android framework deps). The protocol core that powers the Simply Finance Android build.

This page is the cross-language reference: why interop works, what each port covers, the exact API surface, and — importantly — the one place identity derivation diverges, so you know which secrets sync across which clients.

Why a native client can interoperate

Everything rests on the same guarantee the rest of these docs are built on: the relay only ever sees ciphertext. It never holds keys or plaintext, so a native client doesn't have to reimplement the engine — it only has to speak the same bytes on the wire. Concretely, three things must match the TypeScript implementation exactly:

  • Identity — derive ownerId, relayAuth (the bearer token), the AES data key, and the streamKey from one secret. Auto-detected from an sf1_ recovery key (HKDF-SHA256) or a 24-word BIP39 phrase (SLIP-21), exactly like deriveIdentity.
  • Envelope — AES-256-GCM with the canonical-JSON header as additional authenticated data, base64url on the wire. See the encrypted envelope.
  • TransportPOST / GET /v1/events, Authorization: Bearer <relayAuth>, an integer-free keyset cursor. See the relay wire protocol.

Get those three byte-identical and a native client and a TypeScript client that share a secret become the same owner and converge — through any of the wire-identical relays.

What's ported, what stays app-owned

Both ports include the protocol core (identity, envelope, HLC, …) and a port of the engine layer — the local SQLite store, the reactive query cache, the sync loop — so each gives the same schema/mutations/queries DX as the React version. (Re-read the three layers for the split.)

Layer TypeScript Swift Kotlin
Protocol (identity, envelope, HLC, canonical JSON, BIP39, base64url, relay client) @simplysync/protocol PortedKit/ Portedcom.simplysync.kotlin
Engine (schema, SQLite store, outbox, sync loop, reactive queries) @simplysync/engine PortedEngine/ (guide) Ported — same package (guide)
Relay relays/* Unchanged Unchanged

So on the server you port nothing, and both native clients get the full engine DX. The reactive layer is idiomatic per platform — SwiftUI LiveQuery (ObservableObject) in Swift, a StateFlow in Kotlin — over the same SQLite store and sync loop.

The Swift port — SimplySyncKit

Kit/ is a clean-room Swift port of @simplysync/protocol built on **Foundation

  • CryptoKit only** — no UIKit, no third-party packages — so it also compiles and runs on macOS and is unit-testable without a simulator. It ships inside the SimplySyncDemo SwiftUI app (onboarding + a synced todo list).

For the step-by-step API — derive identity → build a payload → encrypt → push / pull → apply — see the Client API → Swift guide.

File Role
Base64URL.swift RFC 4648 §5 base64url (no padding) — the only wire encoding.
Crypto.swift HMAC-SHA256/512, HKDF (deriveBytes), SLIP-21, AES-256-GCM, SHA-256, random bytes.
CanonicalJSON.swift JSON.stringify-compatible canonical form — the AES-GCM AAD and plaintext bytes.
BIP39.swift / BIP39Wordlist.swift Mnemonic ⇄ entropy + the 2048-word English list.
Identity.swift IdentityDeriversf1_ (HKDF) and BIP39 (SLIP-21) derivation.
Envelope.swift SyncEnvelopeSyncPayload (encrypt / decrypt).
HLC.swift The Hybrid Logical Clock string + a monotonic HLCClock.
RelayClient.swift push / pull against /v1/events over URLSession (async/await).
// Derive once from a recovery key or a 24-word phrase (auto-detected).
let identity = try IdentityDeriver.derive(from: recoveryKeyOrPhrase)
let clock = HLCClock(deviceId: deviceId)
let relay = RelayClient(baseURL: url, ownerId: identity.ownerId, relayAuth: identity.relayAuth)

// Encrypt a row mutation and push it.
let payload = SyncPayload(
    schemaVersion: 1, table: "todo", op: "upsert",
    rowId: rowId, hlc: clock.next(), value: ["title": .string("Buy milk")]
)
let envelope = try Envelope.encrypt(
    identity: identity, eventId: payload.hlc,
    deviceId: deviceId, createdAt: isoNow, payload: payload
)
_ = try await relay.push([envelope])

// Pull + decrypt; apply last-writer-wins with HLC.compare.
let pulled = try await relay.pull(after: cursor)
for env in pulled.events {
    let row = try Envelope.decrypt(identity: identity, envelope: env)
    // overwrite local row only if HLC.compare(localUpdatedAt, row.hlc) <= 0
}

Above Kit/, Engine/ ports @simplysync/engine: a typed schema, a real SQLite store, typed mutations, and reactive LiveQuery results — so the example above is the low-level path, and most app code uses the engine instead (see the Client API → Swift guide). Run the demo from Xcode against a local relay (bun run relay:go), then ⋯ → Show recovery key and paste the same key into a second simulator, the web playground, or the Expo app to watch them converge. Full run/verify steps are in the Swift README.

Not ported (by design): binary blobs, the encrypted DB export/import (snapshot) format, and legacy import. Conflict resolution is row-level last-writer-wins by HLC; delete is a soft delete (isDeleted).

The Kotlin / JVM port

The Kotlin port is pure JVM — no Android framework dependencies — so the crypto unit-tests without an emulator and the same artifact is reusable from any Android app. It's the protocol core behind the Simply Finance Android build.

For the step-by-step API — including snapshot crypto and the phrase-interop caveat — see the Client API → Kotlin guide.

File Role
Base64Url.kt RFC 4648 §5 base64url (no padding).
CanonicalJson.kt Deterministic, JSON.stringify-compatible canonical JSON (the AAD/plaintext bytes).
Crypto.kt HMAC-SHA256/512, HKDF deriveBytes, SLIP-21, AES-256-GCM, PBKDF2 (minSdk-24 safe).
Bip39.kt / Bip39Wordlist.kt BIP39 decode/validate and mnemonic generation.
Identity.kt IdentityDeriver — see the interop boundary.
Hlc.kt Hlc string + a thread-safe HlcClock (next / seed / observe).
Envelope.kt SyncEnvelope (AES-256-GCM, ct‖tag) ⇄ SyncPayload.
RelayClient.kt POST / GET /v1/events over OkHttp; suspend (coroutines).
SnapshotCrypto.kt Encrypted DB-snapshot envelope (recovery-phrase and passphrase keyings).
val identity = IdentityDeriver.derive(recoveryPhraseOrKey)   // 24-word BIP39 or sf1_ key
val clock = HlcClock(deviceId)
val relay = RelayClient(baseUrl, identity.ownerId, identity.relayAuth)

// Encrypt a row mutation and push it.
val payload = SyncPayload(1, "todos", "upsert", rowId, clock.next(), columns)
val envelope = Envelope.encrypt(identity, payload.hlc, deviceId, createdAtIso, payload)
relay.push(listOf(envelope))

// Pull + decrypt; last-writer-wins by Hlc.compare.
val pulled = relay.pull(after = cursor)
pulled.events.forEach { Envelope.decrypt(identity, it) }

This module is library-only — no UI. Above the protocol core it ships a port of @simplysync/engine: a typed schema, a SQLite store, typed mutations, and reactive StateFlow queries (the example above is the low-level path; most app code uses the engine — see the Client API → Kotlin guide). The default JdbcSqliteDriver keeps the module pure-JVM and emulator-free for tests; an Android app injects its own android.database-backed driver. Treat the protocol core as pinned: update it wholesale, don't fork it, or clients stop being able to sync with each other.

Extra over Swift: SnapshotCrypto ports the encrypted DB-snapshot format for relay-free device switches — AES-256-GCM keyed either by the recovery phrase (deriveBackupKeyBytes, SLIP-21 ["SimplySync","BackupKey"]) or by a passphrase (PBKDF2-HMAC-SHA256, default 600k iterations). Not ported: binary blobs.

Identity derivation & the interop boundary

This is the one subtlety to get right. Two secret encodings derive an identity, and they interoperate differently:

Secret Scheme TypeScript Swift Kotlin
sf1_ recovery key HKDF-SHA256, labels owner-id · data-key · relay-auth · stream-key
24-word BIP39 phrase SLIP-21 path prefix SimplySync SimplySync Evolu
  • sf1_ recovery keys interoperate everywhere. All three derive ownerId / dataKey / relayAuth / streamKey with byte-identical HKDF labels, so a key generated on any client resolves to the same owner on every other.
  • BIP39 phrases are where Kotlin diverges. TypeScript and Swift both expand a phrase under the SLIP-21 prefix "SimplySync". The Kotlin port deliberately pins "Evolu" instead, because its consumer (Simply Finance Android) migrated from Evolu and a phrase must keep resolving to the owner the existing data and relay already use. A regression test (SyncKitTest.identityUsesEvoluPaths…) locks this — rebranding the path would orphan every existing user's synced data.

Consequence: the same 24-word phrase derives the same owner on TypeScript + Swift, but a different owner on Kotlin. To sync a Kotlin client with a TS/Swift client, share an sf1_ recovery key (identical on all three), not a phrase — or reconcile the SLIP-21 prefix first. The envelope, relay protocol, and sf1_ derivation are otherwise byte-identical across all three.

Everything below the secret — the AES-GCM envelope, canonical JSON, the streamId grouping key, the HLC encoding, the wire protocol — is shared verbatim, so once two clients agree on an owner they converge regardless of language.

Cross-language primitive map

The same concept, three names. Useful when reading one port against another or against the protocol source.

Concept TypeScript Swift Kotlin
Derive identity deriveIdentity(secret) IdentityDeriver.derive(from:) IdentityDeriver.derive(...)
New recovery key createRecoveryKey() IdentityDeriver.createRecoveryKey() IdentityDeriver.createRecoveryKey()
New mnemonic createMnemonic() Bip39.generate()
Encrypt event encryptPayload(...) Envelope.encrypt(identity:…) Envelope.encrypt(identity, …)
Decrypt event decryptEnvelope(...) Envelope.decrypt(identity:envelope:) Envelope.decrypt(identity, envelope)
HLC make / compare createHlc / compareHlc HLC.make / HLC.compare Hlc.make / Hlc.compare
Monotonic clock engine-internal HLCClock.next() HlcClock.next()
Push / pull engine sync loop RelayClient.push / .pull RelayClient.push / .pull
Encrypted DB snapshot snapshot-crypto.ts — (not ported) SnapshotCrypto
Define a schema { table: { col: Type } } Schema([...]) Schema(mapOf(...))
Create the engine createSync(deps)(schema, cfg) createSync(deps)(schema, cfg) createSync(deps)(schema, cfg)
Write a row engine.insert(...) engine.insert(...) engine.insert(...)
Reactive query useQuery(q) engine.liveQuery(q)LiveQuery engine.liveQuery(q)StateFlow

Verification — these aren't "should match," they're checked

Both ports are tested against vectors generated by the real TypeScript protocol, so drift is caught, not hoped against.

Swift — host-side, no simulator needed:

cd ports/swift
# 1) crypto byte-compatibility vs @simplysync/protocol (verify/vectors.json)
swiftc SimplySyncDemo/Kit/*.swift verify/main.swift -o /tmp/ssverify && /tmp/ssverify
# 2) live end-to-end against a running relay (push → pull → decrypt)
swiftc SimplySyncDemo/Kit/*.swift verify/itest/main.swift -o /tmp/ssintegration
/tmp/ssintegration http://localhost:4100

Kotlin — the JUnit suite covers the canonical BIP39 zero-entropy vector, the Evolu identity-path regression guard, envelope round-trips, SnapshotCrypto, HLC ordering, canonical JSON, and PBKDF2:

cd ports/kotlin
./gradlew test

Parity rules — what must stay byte-identical

If you change a port (or add a third language), these are the load-bearing contracts. Break any one and decryption fails or owners drift:

  • Canonical JSON. Keys sorted to match JS localeCompare; it's both the AES-GCM plaintext and the AAD. Keep row/column names lowercase-ASCII so the ordering matches across runtimes.
  • The AAD is the header. {version, ownerId, eventId, deviceId, createdAt, streamId?} as canonical JSON. A relay can't rebind a ciphertext to a different event/owner without GCM failing on decrypt.
  • Derivation labels & paths. The HKDF labels and SLIP-21 paths are part of the contract — see the interop boundary. Add a new vector, never edit an existing one.
  • Fixed-width HLC. base36(millis,10)-base36(counter,4)-deviceId, so plain string order is causal order and eventId = hlc keeps relay compaction sound.
  • AES-256-GCM, 12-byte random nonce per event, ciphertext stored as ct‖tag, everything base64url (no padding) on the wire.
  • Sync & the relay — the envelope, the wire protocol, and the threat model these ports implement.
  • How it works — the on-device store + clock the engine layer (the part you write natively) owns.
  • Security — the end-to-end-encryption claim, walked through the source.