Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Aduki Kotlin SDK

The Aduki Kotlin SDK is a client library for the Aduki platform. It signs people in at Aduki ID, reads and sends mail, syncs contacts and appointments, and keeps working offline.

The SDK is a set of Gradle modules built as plain Kotlin/JVM libraries. The group is pro.aduki; the Android-specific parts (Keystore access, Account Manager) use the Android framework when present and fall back to software implementations on a plain JVM.

What it does

  • Sign-in at Aduki ID. Address, password and second factor produce a 10-minute access token per audience and a rotating refresh token. The SDK renews on 401 and signs out by revoking the session. See Sign-in.
  • One session, many audiences. The Id client caches one access token per audience (mail, id, …). See Aduki ID client.
  • Rights stream. Subscribes to the mail event stream and renews the token when the account’s rights change. See Token lifecycle.
  • DPoP. Binds sign-ins to a device key (RFC 9449). See DPoP.
  • Sign in with Aduki (OIDC). An OpenID Connect relying-party client with PKCE for apps that use Aduki ID as their identity provider. See OIDC client.
  • Offline-first mail. Local storage in ObjectBox, an outbox that journals every change before it is sent, and incremental sync over GET /v1/user/mail/changes. See Mail, Sync and Outbox.
  • Reactive state. Repositories expose Kotlin StateFlow streams for UI binding. See Reactive.
  • Typed REST clients for mail, scheduling and the Account Center.

Modules

ModuleHolds
coremodels, errors, configuration, retry primitives (Jitter, Circuit), memory helpers
cryptoKeystore provider, AES-256-GCM Envelope, Guard, TLS pinning, DPoP keys
netHTTP clients (Id, Login, Center, Mail, Scheduling, Events, Dpop, Oidc)
storeObjectBox entities and batch transactions
syncmailbox, contact and schedule sync engines; the outbox
staterepositories that expose StateFlow
sdkthe Aduki facade
androidAndroid Keystore DPoP key, Account Center authenticator, Custom Tabs sign-in, Credential Manager

Status

Version 0.4.0 is published on Maven Central under the pro.aduki namespace.

Android platform integrations (AndroidKey, AdukiAuthenticator, SignIn, PasskeyFlow) are provided in the optional pro.aduki:android module. They are verified with unit and Robolectric tests, and on an Android 14 emulator with software keys; verification on physical devices with hardware-backed Keystores, StrongBox, and live Credential Manager providers is pending device testing.

In the local store, personal text columns are sealed with AES-256-GCM (Vault), and contacts carry keyed blind indexes (emailIndex, phoneIndex) for exact lookups; structural metadata and flags remain in cleartext, and the ObjectBox database file itself is not encrypted.

Where to start

  1. Installation
  2. Configuration
  3. Sign-in
  4. Mail
  5. Aduki reference

Installation

This guide walks you through integrating the Aduki Android Kotlin SDK into your Android application using Gradle Kotlin DSL (.gradle.kts).


1. Prerequisites

  • JDK: 17 (the build and the CI use Java 17).
  • Kotlin: 2.0.21.
  • ObjectBox: 4.0.3, with its Gradle plugin in the app that stores data.
  • Android: the shared modules are plain JVM libraries (the Keystore Provider reaches Android classes by reflection and falls back to software keys elsewhere). The Android parts (Keystore DPoP key, account authenticator, Custom Tabs sign-in, Credential Manager) are in the android module, minSdk 26: see Android module.

2. Configure Repositories

The SDK uses the Maven group pro.aduki. The release workflow publishes to Maven Central and to GitHub Packages (https://maven.pkg.github.com/adukilabs/aduki-kt). Check the repository’s releases page for the current published version. mavenCentral() is enough for the Central artifacts:

// settings.gradle.kts
dependencyResolutionManagement {
    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
    repositories {
        google()
        mavenCentral()
        
        // Optional JitPack mirror:
        // maven { url = uri("https://jitpack.io") }

        // Optional GitHub Packages repository:
        // maven {
        //     url = uri("https://maven.pkg.github.com/adukilabs/aduki-kt")
        //     credentials {
        //         username = providers.gradleProperty("gpr.user").orNull ?: System.getenv("GITHUB_ACTOR")
        //         password = providers.gradleProperty("gpr.key").orNull ?: System.getenv("GITHUB_TOKEN")
        //     }
        // }
    }
}

3. Apply the ObjectBox Plugin

In your project-level build.gradle.kts:

plugins {
    id("io.objectbox") version "4.0.3" apply false
}

In your application module app/build.gradle.kts:

plugins {
    alias(libs.plugins.android.application)
    alias(libs.plugins.kotlin.android)
    id("io.objectbox") // Generates the entity bindings at compile time
}

4. Add Dependencies

Add the Aduki SDK to app/build.gradle.kts:

dependencies {
    // Aduki SDK facade
    implementation("pro.aduki:sdk:0.4.1-beta")

    // Or via JitPack mirror:
    // implementation("com.github.adukilabs.aduki-kt:sdk:v0.4.1-beta")

    // ObjectBox persistence
    implementation("io.objectbox:objectbox-kotlin:4.0.3")
    implementation("io.objectbox:objectbox-android:4.0.3")

    // Network
    implementation("com.squareup.okhttp3:okhttp:4.12.0")

    // Kotlin Coroutines
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
}

Granular Module Artifacts

If your application only needs specific subsystems, you can import individual modules:

ModuleMaven CoordinatePurpose
SDK Facadepro.aduki:sdk:0.4.1-betaThe Aduki entry point
Statepro.aduki:state:0.4.1-betaRepositories exposing StateFlow
Syncpro.aduki:sync:0.4.1-betaRFC 7162 CONDSTORE synchronizer & Outbox
Storepro.aduki:store:0.4.1-betaObjectBox entities
Netpro.aduki:net:0.4.1-betaHTTP clients (Aduki ID, mail, scheduling)
Cryptopro.aduki:crypto:0.4.1-betaKeystore provider, AES-256-GCM cipher, TLS pinning, DPoP keys
Corepro.aduki:core:0.4.1-betamodels, errors, Jitter, Circuit, memory helpers

5. Upgrading from 0.1.x

Version 0.3.0 also renames the packages to pro.aduki.*, the client to Aduki and the error type to AdukiException, and the Maven group to pro.aduki (it was io.github.adukilabs). There are no compatibility shims.

0.2.0 was a breaking release:

  • Message fields changed. snippet is now preview, date is now receivedAt, and from is split into fromName and fromEmail. The new fields are threadId, keywords, hasAttachment, sentAt and modseq. The local store refills from the server on the next sync; the local store is rebuilt by a fresh sync.
  • Outbox payloads are JSON. Manager.send(msg, raw) expects raw to be the JSON send request. client.mail.send builds it for you. Entries queued by 0.1.x are still dispatched.
  • Worker rejects permanent failures. It now accepts an onRejected callback for actions the server refused for good.
  • New HTTP layer. Wire HttpMailboxTransport(client.mailApi) and HttpDispatcher(client.mailApi, …) into the sync engine and the outbox worker.

6. ProGuard / R8 Rules

ObjectBox and OkHttp need minimal ProGuard configuration. Add the following to app/proguard-rules.pro:

# ObjectBox entity preservation
-keepclassmembers class * {
    @io.objectbox.annotation.Entity <fields>;
}
-keep class io.objectbox.** { *; }
-dontwarn io.objectbox.**

# OkHttp 4 Rules
-dontwarn okhttp3.**
-dontwarn okio.**

Configuration & Initialization Reference

The Aduki Android SDK is configured either interactively via Aduki.login(...) or fluently via Aduki.builder().


1. Configuration Data Models

Options Data Class

Network and runtime parameters are held in the immutable Options data class:

package pro.aduki.core.config

data class Options(
    val endpoint: String = Endpoints.REST,
    val identity: String = Endpoints.ID,
    val timeoutSeconds: Long = 15,
    val secure: Boolean = true,
    val pins: List<Pin> = emptyList() // TLS pins; none by default
)

Endpoints Constant Object

package pro.aduki.core.config

object Endpoints {
    const val REST = "https://mail.aduki.pro/v1"
    const val ID = "https://id.aduki.pro/v1"
    const val AUDIENCE = "mail"
}

2. Builder Method Signatures

package pro.aduki.sdk

class Builder {
    fun key(key: String): Builder
    fun token(token: String): Builder
    fun endpoint(endpoint: String): Builder
    fun identity(identity: String): Builder
    fun dpop(key: pro.aduki.crypto.dpop.Key): Builder
    fun secure(enabled: Boolean): Builder
    fun pins(vararg pins: Pin): Builder
    fun timeout(seconds: Long): Builder
    fun http(client: OkHttpClient): Builder
    fun manager(manager: Manager): Builder
    fun worker(worker: Worker): Builder
    fun mail(repo: MailRepo): Builder
    fun contacts(repo: ContactRepo): Builder
    fun scheduling(repo: AppointmentRepo, engine: ScheduleEngine? = null): Builder
    fun engines(mailbox: MailboxEngine, contact: ContactEngine): Builder
    fun build(): Aduki
}

Parameters & Defaults

MethodParameterTypeDefaultDescription
keykeyString""API key, sent as Authorization: Key <key> (only on this path; every other credential is Bearer) (see API keys).
tokentokenString""Pre-existing JWT access token for user sessions.
endpointendpointStringEndpoints.RESTBase REST API URL.
secureenabledBooleantrueAllows TLS pinning on the default HTTP client when pins are configured. It does not pin anything by itself and does not encrypt local storage.
pinspinsPin...noneOpt-in TLS pins per host (TLS); the SDK ships no pins.
timeoutsecondsLong15OkHttp socket connect, read, and write timeout in seconds.
httpclientOkHttpClientnullOptional custom OkHttpClient instance.
identityidentityStringhttps://id.aduki.pro/v1Aduki ID base used to renew and revoke sign-ins.
dpopkeyKeynoneDevice key; binds sign-ins to it (DPoP).

3. Initialization Examples

Interactive Human Login

val client = Aduki.login(
    handle = "alice@aduki.me",
    password = password,
    code = "482019" // authenticator code; Aduki ID requires a second factor
)

Headless Worker via API Key

val client = Aduki.builder()
    .key(apiKey) // sent as Authorization: Key
    .endpoint("https://mail.aduki.pro/v1")
    .timeout(30)
    .secure(true)
    .build()

4. Dependency Injection (Hilt / Dagger)

package com.example.adukiapp.di

import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import pro.aduki.sdk.Aduki
import javax.inject.Singleton

@Module
@InstallIn(SingletonComponent::class)
object AdukiModule {

    @Provides
    @Singleton
    fun provideAduki(): Aduki {
        return Aduki.builder()
            .key(BuildConfig.ADUKI_API_KEY)
            .endpoint(BuildConfig.ADUKI_ENDPOINT)
            .secure(true)
            .timeout(20)
            .build()
    }
}

Android module

pro.aduki:android is the one Android library of the SDK (an AAR, minSdk 26, compileSdk 34). The other modules stay plain JVM libraries; this one holds what needs Android classes: the Keystore device key, the Account Center authenticator, Custom Tabs sign-in and Credential Manager.

dependencies {
    implementation("pro.aduki:android:0.4.0")
}

In your app manifest placeholders set the scheme of your registered redirect (scheme:/callback):

android { defaultConfig { manifestPlaceholders["adukiRedirectScheme"] = "com.example.app" } }
PartClassWhat it does
Device key (K5)keystore.AndroidKeynon-extractable P-256 key in the Android Keystore; implements the Key of DPoP (ES256)
Accounts (K4)account.Accounts, AdukiAuthenticator, SwitcherStatethe linked accounts of an Account Center as pro.aduki.account entries in AccountManager; reconcile with the center; sealed tokens
Sign-in (K7)signin.SignIn, PendingStore, RedirectActivitySign in with Aduki through a Custom Tab, redirect handling
Passkeys (K6)passkey.PasskeyFlow, CredentialManagerAuthenticatorthe passkey ceremonies through Credential Manager

Device key

val key = AndroidKey.open()                 // creates "aduki_dpop" on first use
val aduki = Aduki.builder().dpop(key) /* ... */
key.protection().level                      // "software", "trusted_environment" or "strongbox"
key.extractable()                           // false

open(alias, strongBox = true) asks for StrongBox and falls back to the normal keystore when the device has none; read protection() for what you got. The signature is converted from the Keystore’s DER to the fixed 64-byte r || s of JWS (crypto.dpop.Der). Deleting the alias (AndroidKey.delete) makes the thumbprint, and every session bound to it, unusable.

Accounts

Accounts.reconcile(switcher) makes the entries of a center match what Aduki ID lists: new accounts are added, accounts the center no longer lists (a tenant links = deny, a forced unlink, the person’s own unlink) are removed together with their tokens. Accounts.refresh(client, identity, bearer, current) does Center.fetch and reconciles. Access and refresh tokens are sealed with the Vault before they reach AccountManager; the center token is kept in its own sealed file and is never put in an account entry. SwitcherState.of(accounts, active) gives the rows a switcher shows (lock for unlock = self, “sign in again”, instant switch).

Register how the authenticator refreshes a stale access token, and which activity adds an account:

Authenticator.refresher = { entry, refresh -> /* one DPoP-bound call to Aduki ID, or null when the session ended */ }

Sign-in

val flow = SignIn(Oidc(http, issuer, clientId, "com.example.app:/callback", dpop = Dpop(key)),
                  "com.example.app:/callback", PendingStore(context, Vault.platform()))
SignInConfig.flow = flow
SignInConfig.done = { result -> /* a Grant or the failure, on a background thread */ }
thread { flow.start(context) }              // discovery is a network call: not on the main thread

RedirectActivity (declared in the library manifest) receives the redirect. It accepts only the exact scheme, authority and path of your registered redirect, needs the state of a sign-in this app started, spends it (a redirect works once), refuses it after ten minutes, and never logs the URL. The pending sign-in (PKCE verifier) is stored sealed, so it survives Android killing the app while the browser is open.

Passkeys

PasskeyFlow(client, identity, CredentialManagerAuthenticator(activity)) has register, stepUp and signIn. A dismissed sheet is PasskeyFailure.Cancelled (nothing is sent), a missing passkey NoPasskey, anything else Platform. The site must publish a Digital Asset Links file naming your package and signing certificate. Never run against a real Credential Manager provider yet.

Build

export JAVA_HOME=/path/to/jdk17 ANDROID_HOME=/path/to/android-sdk
./gradlew test -Paduki.android=true          # JVM modules and the Robolectric tests
./gradlew :android:connectedDebugAndroidTest -Paduki.android=true   # emulator or phone

Without -Paduki.android=true (or ADUKI_ANDROID=1) only the JVM modules build, on JDK 17 to 22, and Google’s Maven repository is never contacted. The Android modules need JDK 17 to 22 as well, AGP 8.13.2 and Gradle 9.5.1.

Verification state

Robolectric tests run in ./gradlew test; the Keystore tests run on an Android 14 emulator without secure hardware (software-backed keys). Hardware-backed keys, StrongBox, Credential Manager with a real provider and the real Aduki ID are not verified: see guide/ANDROID-TESTS.md in the repository.

Device credentials (center unlock): Aduki ID accepts ES256 (PR 35), and Accounts.registerDevice / Accounts.unlockWithDevice use a separate Keystore key (AndroidKey.DEVICE) for it, see Account Center. That glue is UNVERIFIED: it was written without the Android SDK, so it has not been compiled, and nothing ran on a device or against a real Aduki ID.

Authentication Overview

The SDK supports two kinds of credential: a person’s sign-in at Aduki ID, and a static API key for headless clients.

Credentials

PropertyInteractive sign-inAPI key
Useapps with human usersworkers, tests, daemons
Credentialsaddress + password + second factor, at Aduki IDan opaque key issued at Aduki Mail
HTTP headerAuthorization: Bearer <access token> (DPoP <token> when bound)Authorization: Key <key>
Lifetime10-minute access token (EdDSA JWT per audience), single-use rotating refresh tokenuntil revoked at the server
Renewalautomatic on 401none
Sign-outDELETE /v1/sessions/{hex} at Aduki IDrevoke the key

Tokens and sessions are held in memory (client.session). The SDK does not write them to disk; persisting the refresh token between launches is up to the app (restore it with Id.adopt or session.update).

In this section

Session lifecycle

stateDiagram-v2
    [*] --> Unauthenticated
    Unauthenticated --> ActiveSession: Aduki.login(handle, password, code)
    Unauthenticated --> Unauthenticated: 401 / error
    ActiveSession --> ActiveSession: 401 or refresh(): tokens rotated
    ActiveSession --> Unauthenticated: refresh refused (sign in again)
    ActiveSession --> Unauthenticated: logout()

Core types

data class Tokens(
    val token: String = "",    // access token (JWT)
    val refresh: String = "",  // single-use refresh token
    val expires: String = "",  // access-token lifetime in seconds
    val session: String = ""   // Aduki ID session hex, used to sign out
)

data class Identity(
    val user: String = "",
    val tenant: String = "",
    val owner: Boolean = false,
    val scopes: List<String> = emptyList(),  // only if the server returns them
    val tier: String = ""                    // only if the server returns it
)

Identity is read from mail’s GET /v1/user by client.me().

Sign-in

People sign in at Aduki ID, not at mail. The SDK posts the address, password and second factor to Aduki ID, asks for an access token with audience mail, and sends that token to the mail API as Authorization: Bearer <token>.

Aduki.login

suspend fun Aduki.Companion.login(
    handle: String,
    password: String,
    code: String? = null,
    endpoint: String = Endpoints.REST,
    identity: String = Endpoints.ID,
    backup: String? = null,
    dpop: pro.aduki.crypto.dpop.Key? = null
): Aduki
ParameterTypeDefaultDescription
handleString—Full address, e.g. ada@aduki.me.
passwordString—Account password.
codeString?nullCurrent authenticator code.
endpointStringEndpoints.RESTMail REST base.
identityStringEndpoints.IDAduki ID base, https://id.aduki.pro/v1.
backupString?nullA backup code, instead of code.
dpopKey?nullA device key: the session is bound to it (DPoP).

Returns a client holding the access token, the refresh token and the session, with the account (me()) already resolved.

Throws

  • AdukiException.Unauthorized: wrong password, inactive account, or a missing or wrong second factor. The message starts with Aduki ID’s error kind, e.g. auth.factor: ... when no second factor was given.
  • AdukiException.Network: anything else, e.g. 429 while rate-limited (code holds the status).

Wire format

POST /v1/sessions HTTP/1.1
Host: id.aduki.pro
Content-Type: application/json

{ "handle": "ada@aduki.me", "password": "…", "code": "123456", "audience": "mail" }
{
  "success": true,
  "data": {
    "session": "00000000000000ab",
    "access": "eyJhbGciOiJFZERTQSIs…",
    "refresh": "…",
    "expires": 600
  }
}
  • access: EdDSA JWT for mail, valid expires seconds (10 minutes).
  • refresh: single-use; every renewal returns a new one.
  • session: the session hex, used to sign out.

These land in Tokens(token, refresh, expires, session) on client.session.tokens.

Flow

sequenceDiagram
    participant App
    participant SDK
    participant ID as Aduki ID
    participant Mail

    App->>SDK: Aduki.login(handle, password, code)
    SDK->>ID: POST /v1/sessions {…, audience: mail}
    ID-->>SDK: {session, access, refresh, expires}
    SDK->>Mail: GET /v1/user (Bearer access)
    Mail-->>SDK: account
    SDK-->>App: Aduki

Example

val client = Aduki.login(
    handle = "ada@aduki.me",
    password = password,
    code = "123456"
)

Accounts without an authenticator are refused with auth.factor; set one up in Aduki ID’s Account Center first.

Aduki ID client

pro.aduki.net.http.Id is the client for Aduki ID’s sign-in routes. One sign-in gives access tokens for several audiences (mail, id, and others as products add them), all derived from one rotating refresh token. Aduki uses it for renewal and sign-out; you can also use it directly.

val id = Id(client = okhttp, base = "https://id.aduki.pro/v1")

client must carry no credentials of its own. base defaults to Endpoints.ID.

Methods

MethodDoes
signIn(handle, password, code, backup, audience = "mail")POST /v1/sessions; caches the token for audience; returns Tokens
adopt(tokens, audience = "mail")takes over an existing sign-in, e.g. restored from your own storage
token(audience)a valid access token for audience, renewed when missing or expired (30 s early)
rotate(audience, stale)renews now unless another caller already replaced stale; returns the current Tokens
call(audience, build)sends the request build(token) makes; on 401 renews once and sends once more, never loops
refreshToken()the refresh token to persist after each renewal (blank once spent)
session(), signedIn()the session hex; whether a refresh token is held
signOut()revokes the session at Aduki ID and forgets every token; returns whether it was revoked. When revocation fails the rotated refresh token is kept for a retry
clear()forgets everything locally without calling Aduki ID

Guarantees

  • Renewals are serialized. A refresh token is single use; presenting a spent one revokes the session, so each token is sent at most once.
  • A refresh token Aduki ID refuses (spent, expired, revoked) is dropped and AdukiException.Unauthorized is thrown; sign in again.
  • A 2xx that fails validation (a missing field) still spent the token: the rotated token it did carry is kept (AdukiException.Auth.refresh).
  • Network failures and 429/5xx leave the token unspent, so a retry is safe.

Persisting a session

The SDK keeps tokens in memory. To survive a restart, store id.refreshToken() and the session hex (id.session()) after each renewal in the app’s own secure storage, then restore with id.adopt(Tokens(...)) or client.session.update(tokens).

Token lifecycle

An Aduki ID sign-in gives a 10-minute access token for mail, a single-use refresh token and a session. The SDK renews the access token itself; apps only sign out.

Automatic renewal

When mail answers 401 to a request sent with a Bearer token, the SDK renews once and retries the request:

POST /v1/tokens HTTP/1.1
Host: id.aduki.pro
Content-Type: application/json

{ "refresh": "…", "audience": "mail" }
{ "success": true, "data": { "access": "…", "refresh": "…", "expires": 600 } }

Renewals are serialized through the Id client. Refresh tokens rotate, and presenting a spent one makes Aduki ID revoke the whole session, so concurrent requests that fail together share one renewal, and a refresh token that Aduki ID refused is dropped and never presented again. API keys are never renewed.

refresh

suspend fun Aduki.refresh(): Boolean

Renews ahead of time. Returns false when there is no refresh token or Aduki ID refused it (sign in again).

logout

suspend fun Aduki.logout(): Boolean

Revokes the session at Aduki ID, which ends every token issued from it, and clears client.session. Aduki ID only accepts its own tokens for this, so the SDK first swaps the refresh token for an id-audience token:

POST /v1/tokens            { "refresh": "…", "audience": "id" }
DELETE /v1/sessions/{hex}  Authorization: Bearer <id token>

Returns false if the session could not be revoked. The swap spent the old refresh token, so the SDK then keeps the session with the rotated one: call logout() again to retry, or session.clear() to forget it locally. Clients built from an API key have no session and return false.

A client passed to Builder.http(...) gets the same renewal unless it has its own authenticator. Aduki ID calls use its connection settings without its interceptors, so they never carry the mail token.

Session state

client.session.tokens      // StateFlow<Tokens?>
client.session.token()     // current access token
client.session.refresh()   // current refresh token

Tokens.session is kept across renewals.

Rights stream

When an account’s rights change (a role edit, a revoked grant, an unlink), Aduki Mail sends event: rights with {"@type": "Rights", "epoch": N} on its JMAP EventSource (a key rotation sends {"@type": "Rights", "keys": true}), and then ends the stream. The access token should be renewed at once, before the next request is refused with auth.stale.

The SDK can do this for you:

client.watchRights()    // subscribe to {mail host}/jmap/eventsource
// ...
client.unwatchRights()  // also done by logout()

watchRights(host = <REST endpoint without /v1>) opens GET {host}/jmap/eventsource?types=*&ping=30 on the authenticated connection and renews the token on every rights event. After a rights event the stream is reopened immediately; after a failure it reconnects with decorrelated jitter backoff (1 s up to 60 s). Calling it again replaces the previous subscription.

If your app receives the same payload another way (web push, FCM), hand the body to client.rights(payload); it renews for {"@type": "Rights", ...} and ignores anything else, returning whether a token was renewed.

scope.launch { client.rights(data) }

The push only saves a round trip. A stale token is refused and then renewed by the 401 path whether or not a push arrived. The stream source exists for audience mail only; a client with no mail audience needs its own product’s event.

DPoP (device-bound tokens)

DPoP (RFC 9449) binds a session to a device key: each request carries a signed proof that only the holder of the key can make. Binding is optional at Aduki ID.

val key = Software.p256()                 // pro.aduki.crypto.dpop.Software
val client = Aduki.login(handle, password, code = code, dpop = key)
// or
val client = Aduki.builder().token(token).dpop(key).build()

Keys

pro.aduki.crypto.dpop.Key is a sign-only interface:

interface Key {
    val alg: String                     // "ES256" or "EdDSA"
    val jwk: Map<String, String>        // public members only, never `d`
    fun sign(input: ByteArray): ByteArray
    fun thumbprint(): String            // RFC 7638, stored by Aduki ID as cnf.jkt
}

Software.p256() (ES256) and Software.ed25519() (EdDSA) generate keys in process memory using the JDK. Hardware keystores generate P-256 keys, so use ES256 for hardware-backed keys. On Android, use pro.aduki.android.keystore.AndroidKey from the android module (pro.aduki:android), which implements Key using the Android Keystore.

What the SDK sends

pro.aduki.net.http.Dpop is an OkHttp interceptor installed by builder().dpop(key) and login(dpop = key):

  • A request with no Authorization (sign-in, refresh) carries a proof with no access-token hash. Aduki ID binds the session to the key.
  • A request whose access token is bound to this key (its cnf.jkt equals the key’s thumbprint) is sent as Authorization: DPoP <token> with a proof carrying ath, the SHA-256 hash of the token.
  • Other credentials (API keys, unbound tokens) are left unchanged.
  • Each proof has htm, htu (without query or fragment), iat, a fresh random jti, and nonce when the server asked for one.
  • A DPoP-Nonce from the server is remembered per host; a 401 or 400 carrying a new nonce is retried once with it.

The private key is never read; Key only signs.

Android device key

On Android use pro.aduki.android.keystore.AndroidKey as the Key: a non-extractable P-256 Keystore key (ES256, JWS r || s signatures). Aduki ID’s gate::dpop accepts ES256. Verified on an Android 14 emulator (software-backed): proofs verify with the public JWK. Hardware backing is reported by AndroidKey.protection() and not verified here.

OIDC client (“Sign in with Aduki”)

pro.aduki.net.http.Oidc is an OpenID Connect relying-party client for apps that use Aduki ID as their identity provider. It runs the authorization code flow with PKCE S256 (mandatory at Aduki ID; there is no plain).

val oidc = Oidc(
    client = okhttp,                       // no credentials of its own
    issuer = "https://id.aduki.pro",       // the Aduki ID origin
    id = "my-client-id",
    redirect = "myapp://callback",
    secret = null                          // null: public client (PKCE only)
)

secret selects client_secret_basic (default) or, with post = true, client_secret_post. Pass dpop = Dpop(key) to bind tokens to a device key.

Flow

val begin = oidc.begin(listOf("openid", "profile", "offline_access"))
// 1. Open begin.url in a browser (Custom Tabs on Android). The browser step is yours.
// 2. When the browser lands on your redirect URI, pass that URI:
val grant = oidc.finish(begin, callbackUri)
grant.claims      // validated id token claims
grant.access      // access token
grant.refresh     // rotating refresh token (with offline_access)

Keep begin (its state, nonce and PKCE verifier) until the redirect.

MethodDoes
discover()fetches /.well-known/openid-configuration; the issuer must match exactly and S256 must be offered
begin(scope)fresh state, nonce and verifier; returns the authorization URL. scope must contain openid
finish(begin, callback)checks error, state and iss (RFC 9207), exchanges the code, validates the id token
refresh(refreshToken, scope)renews; the refresh token rotates, store the new one. A scope may only narrow
userinfo(grant)userinfo claims; sent with the DPoP scheme when the token is bound
revoke(token)revokes a token (a refresh token takes its family)

| startDevice(scope) | device authorization grant (RFC 8628), step 1: returns a DeviceCode with userCode, verificationUri and the polling interval | | pollDevice(code) | one poll: Pending, SlowDown(next) (interval plus 5 s) or Done(grant) | | awaitDevice(code) | polls until the person decides; see below |

An authorization code is single use: a failed exchange cannot be retried; start a new begin.

Id token validation

EdDSA signatures only, verified against the provider’s JWKS by kid (one refetch on an unknown kid); iss, aud, azp, exp and iat (30 s skew), nonce, sub and at_hash are checked. Failures throw AdukiException.Protocol; access_denied, invalid_grant, invalid_client and invalid_token map to AdukiException.Unauthorized; other HTTP failures to AdukiException.Network.

Device authorization grant (RFC 8628)

For televisions, consoles and command-line tools with no browser. The client must be allowed to use the grant at Aduki ID (device: true on the client).

val oidc = Oidc(http, "https://id.aduki.pro", "my-client", redirect = "unused")
val code = oidc.startDevice(listOf("openid", "offline_access"))
show(code.userCode, code.verificationUri)          // or a QR of code.verificationUriComplete
val grant = oidc.awaitDevice(code)                  // blocks; run it off the main thread

awaitDevice waits interval seconds before each poll, adds 5 seconds on every slow_down, never waits past the local deadline (expires_in), retries up to three consecutive network failures, 429 and 5xx answers, and validates the id token like finish. Outcomes: AdukiException.Denied (the person refused), AdukiException.Expired (deadline, expired_token or the cancelled callback), AdukiException.Unauthorized (invalid_grant, invalid_client), AdukiException.Network (other failures). pollDevice is the single-step form for callers that run their own timer. The redirect argument of Oidc is not used by this grant.

Android: Custom Tabs

pro.aduki.android.signin.SignIn launches begin().url in a Custom Tab and completes the sign-in from the redirect (RedirectActivity), accepting only your registered redirect and a state it started. See Android module. Not run against a real browser or Aduki ID yet.

Account Center & Device Unlock

The calls are blocking (run them off the main thread). Each takes an HTTP client and the Aduki ID base, https://id.aduki.pro/v1.

pro.aduki.net.http.Center talks to Aduki ID’s Account Center (ADK-AUTH-002 §5) and device-bound unlock (ADK-AUTH-001 §9).

CallEndpointReturns
Center.linkPOST /linksSwitcher — center token plus linked accounts
Center.fetchGET /centerSwitcher? (null when no center exists)
Center.unlinkDELETE /linksBoolean
Center.registerPOST /credentialscredential id
Center.revokeDELETE /credentials/{id}Boolean (false when already gone)
Center.unlockPOST /credentials/{id}/challenges then /credentials/{id}/unlockList<Unlocked>

Device unlock

A device credential is Ed25519 (EdDSA) or ECDSA P-256 (ES256, what the Android Keystore, StrongBox and the Secure Enclave hold). The algorithm and the public key encoding come from the Key you pass; they are never guessed by the server.

  1. Keep the private key in the platform keystore. The SDK only asks the Key (or a sign callback) for a signature and never sees the private part.
  2. Register it with Center.register(client, identity, bearer, device, key, code, center) (a fresh TOTP step-up code is required; center = true registers it for the Account Center instead of one account). The call sends alg and the public key: ES256 sends the 65-byte uncompressed point 0x04 || x || y (x and y left-padded to 32 bytes) in base64url without padding; EdDSA the 32-byte key. It checks that the server answered with the same alg. A P-256 key sent without alg: "ES256" is 422 at Aduki ID, so the old overload register(..., public: ByteArray, ...) is for Ed25519 only.
  3. To unlock, call Center.unlock(client, identity, credential, key) (or the sign callback form). The server issues a single-use nonce (valid for 2 minutes, {nonce, expires}); the key signs the raw nonce bytes (not the base64url text). ES256 uses SHA-256 and the 64-byte r || s form: Software already returns it and AndroidKey converts the Keystore’s DER with crypto.dpop.Der.toRaw(der, 32). S is not normalised: low-S and high-S are both accepted. The server returns tier-1 sessions for every account that allows center unlock.

Errors: every unlock failure (bad signature, wrong key, spent, expired or foreign nonce) is the same 401 auth.invalid, thrown as AdukiException.Unauthorized; the call is one attempt and does not retry, and the next call fetches a fresh nonce. A refused attempt does not spend the nonce at the server, a successful one does. 404 (AdukiException.Network, code 404) means the credential is revoked or lapsed: sign in fully. 422 at registration (Network, code 422) is a refused key or label, or an old Aduki ID that does not know ES256. A signature that is not 64 bytes (for example a DER signature) is refused locally with IllegalArgumentException and is never sent.

A credential works for 60 days after the full authentication that registered it. Once that window lapses, the user must sign in with their password again.

Status: built against the contract of Aduki ID PR 35 and tested on the JVM against a fake server with an independent verifier. Not yet run against a real Aduki ID.

Android authenticator

The android module keeps the linked accounts as separate AccountManager entries under the type pro.aduki.account (Accounts.reconcile, AdukiAuthenticator; see Android module). When Aduki ID removes a link (tenant links = deny, forced unlink), Accounts.refresh drops the entry and its tokens. The center token is stored in a separate sealed file and sent to Aduki ID only.

Accounts.registerDevice and Accounts.unlockWithDevice use AndroidKey.open(AndroidKey.DEVICE) (P-256, ES256) as the signer of this flow and store each unlocked session. This glue is UNVERIFIED: it could not be compiled or run without the Android SDK (see guide/ANDROID-TESTS.md).

Passkeys (protocol side)

pro.aduki.net.http.Passkeys is the HTTP side of WebAuthn at Aduki ID: the begin and verify calls of the three ceremonies, plus list and remove. It runs on a plain JVM. The authenticator step is handled by the platform (Android Credential Manager): on Android, use PasskeyFlow and CredentialManagerAuthenticator from the android module (pro.aduki:android).

Each ceremony is two calls. begin* returns a Challenge (ceremony, expires, options); give challenge.optionsJson to the platform API; pass the JSON the platform returns to the matching finish* call. A challenge is spent by the first answer, right or wrong, and lasts five minutes.

CeremonyBeginFinishResult
Register (step-up or code)beginRegistration(client, identity, bearer, label?, code?)finishRegistration(..., challenge, registrationResponseJson)Registered
Step-upbeginStepUp(client, identity, bearer)finishStepUp(..., challenge, authenticationResponseJson)Unix seconds; renew the access token to get the new stp
Sign in (no account named)beginSignIn(client, identity)finishSignIn(client, identity, challenge, authenticationResponseJson, audience, device)Tokens

list(client, identity, bearer) returns your passkeys (Item, never key material); remove(client, identity, bearer, passkey, code?) removes one.

Passkeys.credential(json, registration) is what the finish calls use: it checks that the platform JSON is a public-key credential with clientDataJSON and either attestationObject (registration) or authenticatorData and signature (assertion), and keeps only the fields Aduki ID reads. A malformed credential throws IllegalArgumentException before any request is made.

Errors: every failed verification is AdukiException.Unauthorized (401 auth.invalid, uniform). Registering and removing need a recent step-up or a fresh authenticator code; otherwise the server answers 403 auth.stepup, which Passkeys.needsStepUp(e) recognises (step up, then retry). A malformed server answer is AdukiException.Protocol. For a session bound to a device key, use an HTTP client with the DPoP interceptor.

Android: Credential Manager

pro.aduki.android.passkey.PasskeyFlow runs begin, the platform step and finish for register, stepUp and signIn; CredentialManagerAuthenticator hands challenge.optionsJson to CreatePublicKeyCredentialRequest / GetPublicKeyCredentialOption and returns the platform’s response JSON. See Android module. Request shaping and error mapping are unit tested; a real passkey ceremony needs a device with a credential provider and is not verified.

Static API Keys Reference

API keys give machine-to-machine access for background workers, tests and daemons, without interactive credentials.


1. Key format

The server (Aduki Mail) accepts Authorization: Bearer <token> or Authorization: Key <token> for both Aduki ID tokens and API keys and tells them apart by the shape of the token. The SDK therefore does not inspect the token: a credential supplied through Aduki.Builder.key(...) is sent as Key <key>, everything else (token(...), a signed-in session, a refreshed token) as Bearer <token>. Keys are issued and revoked in the Aduki admin console; the SDK does not validate their format.


2. Builder Initialization

API keys are configured via Aduki.Builder.key(String):

package pro.aduki.sdk

val client = Aduki.builder()
    .key(apiKey)
    .endpoint("https://mail.aduki.pro/v1")
    .timeout(30)
    .secure(true)
    .build()

Builder Method Signatures

fun Aduki.Builder.key(key: String): Aduki.Builder
  • key: String: API key string. Must not be blank if no JWT token is provided.
  • Validation: When build() is invoked, the builder enforces:
    require(apiKey.isNotBlank() || token.isNotBlank()) {
        "Either API key or JWT token must not be blank"
    }
    

3. Network Protocol Details

REST HTTP Header

On all outbound HTTP requests, the SDK interceptor attaches the Key scheme header when the credential came from key(...):

GET /v1/user HTTP/1.1
Host: mail.aduki.pro
Authorization: Key <your key>
Accept: application/json

4. Resolving the account

With an API key, client.me() calls GET /v1/user to verify the key and read its account and tenant:

val identity: Identity? = client.me()
println("User: ${identity?.user}, tenant: ${identity?.tenant}")

Identity.scopes and Identity.tier are filled only when the server returns them; do not rely on them. API keys are never renewed.

Two-factor TOTP (deprecated)

Deprecated. Sign-in moved to Aduki ID, which owns second factors: set up an authenticator in the Account Center and pass its code to Aduki.login(code = ...). Aduki.totp calls mail’s /v1/user/totp, which is going away.

@Deprecated("Second factors are managed in Aduki ID's Account Center.")
suspend fun Aduki.totp(code: String): Boolean
ParameterTypeDescription
codeStringexactly 6 digits; anything else throws IllegalArgumentException before any request

Sends PATCH /v1/user/totp with the code as a JSON string ("123456") and returns whether the response was successful. A 401 throws AdukiException.Unauthorized.

Device Security Overview

The crypto module provides building blocks for protecting secrets on a device. This page states what the SDK does today; each page below gives the exact API.

AreaWhat existsPage
Key storageProvider: an AES-256 master key (alias aduki_master) in the Android Keystore, with a software fallback off AndroidKeystore
EncryptionEnvelope: AES-256-GCM with a random 12-byte IV per messageCipher
Memorywipe(), Guard: overwrite secrets after useSanitization
TransportOpt-in SPKI certificate pinning with your own pins (none shipped), TLS 1.3/1.2 connection specTLS
Device keysdpop.Key: sign-only device keys for DPoPDPoP

What is not done for you

  • The SDK does not encrypt the local ObjectBox database as a whole; it seals personal text columns (Vault) and leaves structural metadata (ids, flags, timestamps, counters) in the clear. The Android Keystore part ran on an emulator (software-backed keys); hardware backing is unverified.
  • Access and refresh tokens are held in memory only; the core SDK never writes them to disk. The optional Android module stores them sealed (Vault) in AccountManager for the account switcher.
  • The SDK does not request StrongBox; the Keystore key is generated with the platform default (hardware-backed where the device provides it).

Keystore

pro.aduki.crypto.keystore.Provider creates and fetches AES-256 keys in the Android Keystore.

class Provider(private val type: String = "AndroidKeyStore") {
    companion object { const val MASTER = "aduki_master" }

    fun get(alias: String = MASTER): SecretKey
    fun create(alias: String): SecretKey
    fun has(alias: String): Boolean
    fun remove(alias: String)
}
MethodDoes
getreturns the key under alias, creating it if missing
creategenerates a new AES-256 key (GCM, no padding, encrypt and decrypt purposes)
haswhether the alias exists
removedeletes the alias

Behaviour

  • On Android the key is generated with KeyGenParameterSpec (reached by reflection, so the module also loads on a plain JVM). It does not ask for StrongBox; whether the key is hardware-backed depends on the device.
  • Where the Android Keystore is not available (unit tests, a plain JVM), the provider falls back to an in-process software key cached by alias. Such a key does not survive the process, so data encrypted with it cannot be read after a restart.
val provider = Provider()
val key: SecretKey = provider.get(Provider.MASTER)
provider.remove(Provider.MASTER)   // e.g. on sign-out

Device key (DPoP)

pro.aduki.android.keystore.AndroidKey (module android, see Android module) is the P-256 signing key for DPoP: generated in the Keystore, PrivateKey.getEncoded() is null, it signs ES256. protection() returns what the platform reports (software, trusted_environment, strongbox).

Verified on

An Android 14 (API 34) x86_64 emulator, software CPU: Provider creates keys in the real AndroidKeyStore (not the fallback), getEncoded() is null, KeyInfo.getKeySize() is 256, and a caller-supplied IV is refused, which is why Envelope stores the IV the cipher generates (B14; 300 encryptions, no repeated IV). That emulator holds its keys in software (isInsideSecureHardware false): hardware backing and StrongBox are not verified.

Provider.get still falls back to a process-lifetime software key when the Keystore throws; on a device, check has(alias) after get if you must be sure a key is Keystore-held.

Envelope cipher

pro.aduki.crypto.cipher.Envelope is AES-256-GCM authenticated encryption.

object Envelope {
    fun encrypt(plain: ByteArray, key: SecretKey): ByteArray
    fun decrypt(cipherText: ByteArray, key: SecretKey): ByteArray
}

Format

encrypt returns IV (12 bytes) || ciphertext || tag (16 bytes). The IV is the one the cipher generates (a fresh random 12 bytes on every call; the Android Keystore refuses a caller-supplied IV, so the cipher is initialised without one and Cipher.getIV() is stored). The layout is unchanged, so blobs written earlier still decrypt. The transformation is AES/GCM/NoPadding with a 128-bit tag.

decrypt requires at least 28 bytes (IllegalArgumentException otherwise) and throws javax.crypto.AEADBadTagException when the data or tag was modified or the key is wrong.

val key = Provider().get()
val sealed = Envelope.encrypt("secret".toByteArray(), key)
val plain = Envelope.decrypt(sealed, key)

Memory sanitization

Helpers that overwrite sensitive buffers once they are no longer needed. This reduces how long a secret sits in memory; the JVM may still have copied it, so treat it as hygiene rather than a guarantee.

wipe

package pro.aduki.core.memory

fun ByteArray.wipe()
fun CharArray.wipe()
fun IntArray.wipe()
fun LongArray.wipe()

inline fun <R> withWipedBytes(size: Int, block: (ByteArray) -> R): R   // allocates, wipes afterwards
inline fun <R> withWipedChars(chars: CharArray, block: (CharArray) -> R): R
val derived = withWipedChars(password.toCharArray()) { chars -> deriveKey(chars) }
// `chars` is zeroed here

Guard

package pro.aduki.crypto.sanitizer

class Guard(val bytes: ByteArray) : Closeable        // close() wipes `bytes`
inline fun <R> withGuard(bytes: ByteArray, block: (Guard) -> R): R
Guard(secretBytes).use { guard ->
    sign(guard.bytes)
}   // secretBytes is zeroed

TLS and certificate pinning

Pinning is opt-in. The SDK ships no pins and no default pinned host: with no configuration the default HTTP client uses the platform trust store only. pro.aduki.crypto.tls.Pinning turns the pins you configure into an OkHttp CertificatePinner.

object Pinning {
    fun pinner(pins: List<Pin>): CertificatePinner?   // null when pins is empty
    fun specs(): List<ConnectionSpec>
}
val client = Aduki.builder()
    .key(apiKey)
    .pins(
        Pin("mail.aduki.pro", "sha256/<current key hash>", "sha256/<backup key hash>"),
        Pin("id.aduki.pro", "sha256/<current key hash>", "sha256/<backup key hash>"),
    )
    .build()
  • A Pin is a host and one or more sha256/<base64> hashes of the certificate’s SubjectPublicKeyInfo. Give a current and a backup hash so a key rotation does not lock your app out. A malformed hash is rejected when the Pin is created.
  • A mismatch aborts the handshake with SSLPeerUnverifiedException before any header is sent.
  • specs() returns a ConnectionSpec restricted to TLS 1.3 and TLS 1.2. The SDK does not install it by itself.

Getting a hash

Take the hash from the live certificate chain of the host, never from a document:

openssl s_client -connect mail.aduki.pro:443 -servername mail.aduki.pro </dev/null 2>/dev/null \
  | openssl x509 -pubkey -noout \
  | openssl pkey -pubin -outform der \
  | openssl dgst -sha256 -binary | base64

Prefix the output with sha256/. Repeat for the key you will rotate to (the backup). This pins the leaf key; a CA-issued certificate that renews with a new key will break pinned clients, so pin a key you control and keep a backup.

When the SDK applies it

The default HTTP client of Aduki (and its Aduki ID client) installs the pinner when pins are configured and secure(true) (the default). A client you pass with Builder.http(client) is used as given; add your own certificatePinner. Aduki.login(..., pins = ...) accepts the same pins for the sign-in call.

What is verified

The unit tests check the pinner against a local TLS server (MockWebServer with a generated certificate): the matching pin connects, a wrong pin aborts the handshake, a pin for another host does not interfere. No pin has been checked against a production host: those hosts were not reachable on 443 when this was written, so the SDK defines none.

Sealed columns (Vault)

pro.aduki.crypto.cipher.Vault seals values with AES-256-GCM (Envelope) under keys held by a Provider. The SDK uses it for the sensitive payload columns of the local database. It is not full-database encryption.

What is sealed, what is not

Sealed (every personal text column):

  • Message: subject, fromName, fromEmail, to, preview, blob.
  • Contact: name, email, phone, company, vcard.
  • Appointment: location, notes. Mailbox: name. Service: name, description.
  • The outbox payload (the content of a message waiting to be sent).

In the clear, because they are structural and sync and queries need them: ids and hex values, UIDs, UIDVALIDITY, MODSEQ and other sequence numbers, flags and keywords, counts (exists, unseen, size), timestamps, mailbox role, appointment status, method and times, tenant, host, service and slug ids, ctag/etag values and sync tokens. These can reveal when and how much you use the app, not what was said or to whom.

The database file as a whole is not encrypted: ObjectBox 4.0.3 has no encryption option, and the file relies on the operating system (Android file-based encryption). Do not describe the SDK as encrypting the database. Old pages of a database rewritten in place may still hold older values until the file is compacted.

Migrating a database that held plaintext

Sealing.reseal(store) rewrites every row sealed, but ObjectBox (LMDB) is copy-on-write: the pages that held the old plaintext rows can stay in the file, and the SDK does not scrub or compact them. If the plaintext must not remain on disk, do not reseal in place: delete the old database directory (the data.mdb and lock.mdb files and aduki-index.keys next to them) and let the SDK sync into a fresh, sealed-from-the-start database. Mail, contacts and schedule are server state that sync again; unsent outbox entries exist only in the local file, so flush the outbox (or accept losing it) before deleting. A database created with a vault installed from the first run never held plaintext.

Searching sealed columns

  • Exact lookup (Contact.byEmail, Contact.byPhone): a keyed blind index. The index column holds <index key id>:<hex HMAC-SHA256> of the normalised value (lower-cased trimmed address; phone reduced to digits and +) under a per-field key derived by HKDF-Expand from a random seed. The seed lives in aduki-index.keys next to the database, sealed under the vault, because Android Keystore keys cannot be read out to derive from. Equal values give equal index values (equality is visible); the value itself is not recoverable or guessable without the seed. vault.rotateIndex() starts a new index key and Sealing.reseal(store) rebuilds every index under it.
  • Free text (Contact.search, sorting by name): done in memory over decrypted rows. Cost: every contact is read from the database and opened on each change of the address book, linear in its size. Nothing searchable is persisted in the clear.
  • Without a vault (or before a store is opened) byEmail/byPhone scan the rows.

Format and rotation

0xAD 0x4B | version (1) | key id (4) | IV (12) | ciphertext | tag (16); text is stored as aduki:1: plus base64. Key id n is the Provider alias aduki_data_n. rotate() creates a new key and makes it current; values sealed under older keys still open while those keys exist, and every rewrite seals under the current key. Sealing.reseal(store) rewrites all rows and rebuilds the indexes. A tampered value, an unknown key id or an unknown version throws SealException; the SDK never returns damaged data. Values not in this format are read as legacy plaintext and sealed when next written.

Turning it on

val client = Aduki.builder().key(apiKey).secureStore(Provider()).build()
val store = Factory.build(dir)   // or Factory.build(dir, Vault(Provider()))

On Android, Vault.platform() (the Android Keystore Provider) is used when secureStore is not called. On a plain JVM nothing is sealed unless you ask, and a Provider there keeps keys in memory only, so data written by one process cannot be read by the next: use it for tests. The vault is process-wide and must be installed before the database is read; with none installed, sealed values cannot be read.

Verified and not verified

Tested on the JVM with the software Provider and with a test double that, like the Keystore, refuses a caller-supplied IV: round trip, the database file holds no plaintext of a sealed column, tampering and unknown keys fail, rotation reads old keys, legacy plaintext rows upgrade. Also run on an Android 14 emulator with the real AndroidKeyStore provider (software-backed, no secure hardware): keys are created in the Keystore (not the fallback), the Keystore-generated IV is used and never repeated, sealed text and bytes round trip, rotation keeps old values readable, tampering fails, the blind-index seed file is sealed, and keys and sealed data survive a force-stop of the app. Not verified: hardware-backed keys and StrongBox, reinstall and backup behaviour, other Android versions.

Storage

Local data is stored with ObjectBox 4.0.3. The store module holds the entities, a factory that opens a BoxStore, and a Batch helper for transactions. The sync engines, the outbox and the repositories read and write through it.

val store: BoxStore = Factory.build(context.filesDir)   // pro.aduki.store.box.Factory

Factory.create(dir) returns a BoxStoreBuilder for the directory; Factory.build(dir) builds it.

Encryption

The database file as a whole is not encrypted: ObjectBox 4.0.3 has no encryption option, so the file relies on the platform (file-based encryption on Android). The SDK additionally seals every personal text column with AES-256-GCM (Vault): message subject, sender, recipients, preview and blob; contact name, e-mail, phone, company and vCard; appointment location and notes; mailbox and service names; the outbox payload. Ids, flags, timestamps, counters and roles stay in the clear so sync works; contact lookups use keyed blind indexes and free-text search runs in memory. Sealing is tested on a plain JVM and on the real Android Keystore of an API 34 emulator (software-backed); hardware backing is unverified.

Pages

Entities

The SDK’s local data models are ObjectBox entities (pro.aduki.store.entities). Besides the four below the module holds Appointment, Service and Slot for scheduling.


1. Message Entity

package pro.aduki.store.entities

@Entity
data class Message(
    @Id var id: Long = 0,
    @Index var hex: String = "",
    @Index var mailbox: String = "",
    @Index var uid: Long = 0,
    @Index var threadId: String = "",
    var subject: String = "",
    var fromName: String = "",
    var fromEmail: String = "",
    var to: String = "",            // comma-separated addresses
    var preview: String = "",       // was `snippet` before 0.2.0
    var blob: String = "",
    var size: Long = 0,
    @Index var flags: Int = 0,      // SEEN=1, ANSWERED=2, FLAGGED=4, DELETED=8, DRAFT=16
    var keywords: String = "",      // other server flags, space-separated ("$Junk $Forwarded")
    var hasAttachment: Boolean = false,
    @Index var receivedAt: Long = 0, // was `date` before 0.2.0; epoch millis UTC
    var sentAt: Long = 0,
    var modseq: Long = 0,
    var created: Long = 0,
    var dirty: Boolean = false
) {
    fun seen(): Boolean
    fun flagged(): Boolean
    fun deleted(): Boolean           // `\Deleted`: hidden from lists and unread counts
    fun toggle(flag: Int)
    fun recipients(): List<String>
    fun sender(): String             // fromName, else fromEmail

    companion object {
        fun bits(flags: List<String>): Int        // server flags -> bitmask
        fun keywords(flags: List<String>): String // the non-system flags
        fun flagName(bit: Int): String?           // SEEN -> "\Seen"
    }
}

Field Definitions

FieldMeaning
hexServer id; a msg_<millis> placeholder until an outbox send completes.
mailbox, uidMailbox hex and the message’s UID in it (changes on a move).
threadIdConversation id (the message’s own id when it has no thread).
fromName, fromEmail, toParsed sender and recipients.
previewStart of the body text, from the server.
flagsBitmask of \\Seen, \\Answered, \\Flagged, \\Deleted, \\Draft.
keywordsEvery other server flag or keyword, space-separated.
hasAttachmentThe message has attachments.
receivedAt, sentAtArrival time and Date: header, epoch millis (UTC).
modseqThe server MODSEQ of the last change applied.
dirtyA local change is waiting in the outbox; sync keeps local flags and mailbox.

2. Mailbox Entity

Tracks mailbox folder metadata and RFC 7162 CONDSTORE sequence state.

package pro.aduki.store.entities

import io.objectbox.annotation.Entity
import io.objectbox.annotation.Id
import io.objectbox.annotation.Index

@Entity
data class Mailbox(
    @Id var id: Long = 0,
    @Index var hex: String = "",
    var name: String = "",
    var role: String = "", // inbox, sent, trash, archive, drafts
    var uidnext: Long = 0,
    var uidvalidity: Long = 0,
    var modseq: Long = 0,
    var exists: Int = 0,
    var unseen: Int = 0
)

Field Definitions

FieldTypeAnnotationDescription
idLong@IdLocal ObjectBox primary key.
hexString@IndexUnique mailbox folder identifier hex.
nameString—User-facing folder display title (e.g. "Inbox", "Work").
roleString—Special-use role attribute: "inbox", "sent", "trash", "archive", "drafts".
uidnextLong—Predicted next IMAP UID assigned to incoming mail.
uidvalidityLong—Server mailbox generation token. Mismatches trigger full local re-seed.
modseqLong—Highest 64-bit CONDSTORE sequence counter committed locally.
existsInt—Total message count in the folder.
unseenInt—Unread message count badge.

3. Contact Entity

Stores address book contacts.

package pro.aduki.store.entities

import io.objectbox.annotation.Entity
import io.objectbox.annotation.Id
import io.objectbox.annotation.Index

@Entity
data class Contact(
    @Id var id: Long = 0,
    @Index var hex: String = "",
    @Index var name: String = "",
    @Index var email: String = "",
    var phone: String = "",
    var company: String = "",
    var vcard: String = "",
    var ctag: String = "",
    var updated: Long = 0
)

4. Outbox Entity

Persists pending offline write-ahead actions for reliable background transmission.

package pro.aduki.store.entities

import io.objectbox.annotation.Entity
import io.objectbox.annotation.Id
import io.objectbox.annotation.Index

@Entity
data class Outbox(
    @Id var id: Long = 0,
    @Index var hex: String = "",    // the message the action is about
    @Index var action: String = "", // "send", "flag", "move", "delete"
    var payload: ByteArray = byteArrayOf(),
    @Index var created: Long = System.currentTimeMillis(),
    var attempts: Int = 0,
    var nextRetry: Long = 0
)

5. Sync Entity

Persists synchronization cursor tokens and generation timestamps.

package pro.aduki.store.entities

import io.objectbox.annotation.Entity
import io.objectbox.annotation.Id
import io.objectbox.annotation.Index

@Entity
data class Sync(
    @Id var id: Long = 0,
    @Index var target: String = "", // "contacts", "inbox", etc.
    var token: String = "",
    var timestamp: Long = 0
)

Indexes and queries

Fields marked @Index get an ObjectBox index, and the generator produces property descriptors (Message_, Contact_) for queries.

EntityIndexed fields
Messagehex, mailbox, uid, threadId, flags, receivedAt
Contacthex, emailIndex, phoneIndex
Mailboxhex
Outboxhex, action, created
Synctarget

Note on Sealed Columns: Personal text columns (Contact.name, Contact.email, Contact.phone, Contact.company, Contact.vcard, Message.subject, Message.preview, etc.) are sealed with AES-256-GCM under the Vault. They cannot be queried via plaintext database indexes. Exact lookups on contacts use keyed blind indexes (emailIndex, phoneIndex), and substring/autocomplete searches run in memory over decrypted rows.

Query examples

// A page of a mailbox, newest first
val query = messageBox.query(Message_.mailbox.equal(mailboxHex))
    .orderDesc(Message_.receivedAt)
    .build()
val page: List<Message> = query.find(0, 50)
query.close()

// Exact contact lookup via keyed blind index
val emailBlindIndex = Sealing.index("email", "ada@example.com")
val contact = contactBox.query(Contact_.emailIndex.equal(emailBlindIndex))
    .build()
    .findFirst()

// Free-text and substring search across contacts (in-memory over decrypted rows)
// Exposes a reactive StateFlow updated on address book changes:
val searchResults: StateFlow<List<Contact>>? = client.contacts.search("ali")

Always close() a query you built, or use query.use { }.

Transactions

pro.aduki.store.queries.Batch runs several writes in one ObjectBox transaction: they commit together or not at all.

class Batch(private val store: BoxStore) {
    fun <T> tx(block: () -> T): T
    fun run(block: () -> Unit)
}
val batch = Batch(boxStore)

val count = batch.tx {
    messageBox.put(newMessages)
    mailbox.modseq = highestModseq
    mailboxBox.put(mailbox)
    outboxBox.remove(actionId)
    newMessages.size
}

If the block throws, every write inside it is rolled back.

The sync engines and the outbox use transactions the same way: applying a page of mailbox changes (messages and the mailbox’s modseq) is one transaction, and an outbox action is journaled in the same transaction as the local change it describes.

ObjectBox serializes write transactions and lets readers run concurrently with a writer; see the ObjectBox documentation for its transaction semantics.

Mail Service Reference

The Mail service (client.mail) provides high-level APIs for composing, querying, flagging, and managing email messages and mailbox folders backed by the local ObjectBox store.


1. Class & Method Signatures

package pro.aduki.sdk

class Mail internal constructor(...) {
    suspend fun send(
        to: List<String>,
        subject: String,
        body: String,
        mailbox: String = "outbox"
    ): Message

    suspend fun flag(hex: String, flag: Int)

    suspend fun move(hex: String, dest: String)

    suspend fun remove(hex: String)

    fun observe(mailboxHex: String): StateFlow<List<Message>>?

    fun mailboxes(): StateFlow<List<Mailbox>>?

    fun unread(mailboxHex: String): StateFlow<Int>?
}

2. Detailed Method Specifications

send

Enqueues an outbound email message for optimistic local storage and immediate background network dispatch.

suspend fun send(
    to: List<String>,
    subject: String,
    body: String,
    mailbox: String = "outbox"
): Message

Parameters

ParameterTypeRequiredDefaultDescription
toList<String>Yes—List of recipient email addresses (e.g. listOf("partner@example.com")). Joined internally as comma-delimited string for FlatBuffers storage.
subjectStringYes—Email subject line.
bodyStringYes—Email body text (UTF-8).
mailboxStringNo"outbox"Mailbox folder identifier to which the draft/queued message is initially written.

Return Value

  • Type: Message
  • Description: The persisted Message entity containing assigned hex, snippet preview (first 120 chars), timestamp, and dirty = true.

Concurrency & Execution

  • Thread Context: Executes within caller’s coroutine dispatcher (recommended Dispatchers.IO).
  • Persistence: Writes Message and journals an Outbox mutation (action = "send") in an atomic ObjectBox transaction. Immediately signals Worker.drain() to begin network dispatch.

flag

Optimistically toggles a flag bitmask on an existing message and queues a flag sync mutation.

suspend fun flag(hex: String, flag: Int)

Parameters

ParameterTypeRequiredDescription
hexStringYesMessage hex identifier.
flagIntYesBitmask integer to XOR toggle against the message’s current flags.

Bitmask Constants (Message.Companion)

ConstantValueDescription
Message.SEEN1 (1 << 0)Read / Unread status.
Message.ANSWERED2 (1 << 1)Replied to status.
Message.FLAGGED4 (1 << 2)Starred / High priority flag.
Message.DELETED8 (1 << 3)Deleted / Scheduled for purge.
Message.DRAFT16 (1 << 4)Unsent draft message.

move

Relocates a message to a destination mailbox folder.

suspend fun move(hex: String, dest: String)

Parameters

ParameterTypeRequiredDescription
hexStringYesUnique message hexadecimal identifier.
destStringYesDestination mailbox identifier (e.g. "archive", "trash", "sent").

remove

Deletes a message from local storage and queues a remote purge mutation.

suspend fun remove(hex: String)

Parameters

ParameterTypeRequiredDescription
hexStringYesUnique message hexadecimal identifier.

observe

Returns a hot, reactive StateFlow providing real-time emissions whenever messages in a mailbox change.

fun observe(mailboxHex: String): StateFlow<List<Message>>?
  • Parameters: mailboxHex: String — Mailbox identifier (e.g., "inbox").
  • Return Type: StateFlow<List<Message>>? — Emits sorted list of Message entities. Returns null if local store is uninitialized.

mailboxes

Returns a hot StateFlow observing the full collection of mailboxes and CONDSTORE sequence counters.

fun mailboxes(): StateFlow<List<Mailbox>>?
  • Return Type: StateFlow<List<Mailbox>>? — List of Mailbox entities including unseen and exists counts.

unread

Returns a hot StateFlow observing the count of unread messages (flags & SEEN == 0) for a mailbox.

fun unread(mailboxHex: String): StateFlow<Int>?
  • Parameters: mailboxHex: String — Mailbox identifier.
  • Return Type: StateFlow<Int>? — Reactive unread badge counter.

3. Data Models

Message Entity

package pro.aduki.store.entities

@Entity
data class Message(
    @Id var id: Long = 0,
    @Index var hex: String = "",
    @Index var mailbox: String = "",
    @Index var uid: Long = 0,
    @Index var threadId: String = "",
    var subject: String = "",
    var fromName: String = "",
    var fromEmail: String = "",
    var to: String = "",            // comma-separated addresses
    var preview: String = "",       // was `snippet` before 0.2.0
    var blob: String = "",
    var size: Long = 0,
    @Index var flags: Int = 0,      // SEEN=1, ANSWERED=2, FLAGGED=4, DELETED=8, DRAFT=16
    var keywords: String = "",      // other server flags, space-separated ("$Junk $Forwarded")
    var hasAttachment: Boolean = false,
    @Index var receivedAt: Long = 0, // was `date` before 0.2.0; epoch millis UTC
    var sentAt: Long = 0,
    var modseq: Long = 0,
    var created: Long = 0,
    var dirty: Boolean = false
) {
    fun seen(): Boolean
    fun flagged(): Boolean
    fun deleted(): Boolean           // `\Deleted`: hidden from lists and unread counts
    fun toggle(flag: Int)
    fun recipients(): List<String>
    fun sender(): String             // fromName, else fromEmail

    companion object {
        fun bits(flags: List<String>): Int        // server flags -> bitmask
        fun keywords(flags: List<String>): String // the non-system flags
        fun flagName(bit: Int): String?           // SEEN -> "\Seen"
    }
}

Mailbox Entity

package pro.aduki.store.entities

@Entity
data class Mailbox(
    @Id var id: Long = 0,
    @Index var hex: String = "",
    var name: String = "",
    var role: String = "", // inbox, sent, trash, archive, drafts
    var uidnext: Long = 0,
    var uidvalidity: Long = 0,
    var modseq: Long = 0,
    var exists: Int = 0,
    var unseen: Int = 0
)

4. REST Client: client.mailApi

pro.aduki.net.http.Mail is the typed client for the Aduki REST mail endpoints, on the client’s authenticated connection. Its calls are blocking, so run them off the main thread. The sync engine and the outbox use it through HttpMailboxTransport and HttpDispatcher (see Sync Engine and Offline Outbox).

MethodEndpointReturns
inbox(after, limit)GET /v1/user/mail/inboxListing<MessageRow> (cursor in next)
folder(mailbox, page, limit)GET /v1/user/mail/folder/{hex}Listing<MessageRow>
thread(thread, page, limit)GET /v1/user/mail/thread/{thread}Listing<MessageRow>
changes(mailbox, since, uidvalidity, limit)GET /v1/user/mail/changesMailChanges
flags(hex, add, remove)PATCH /v1/user/mail/{hex}/flags—
move(hex, mailbox)PATCH /v1/user/mail/{hex}/mailboxMoved (same id, new UID)
delete(hex)DELETE /v1/user/mail/{hex}—
send(to, subject, text, cc, from, idempotencyKey)POST /v1/user/mail/sendserver id of the sent copy
mailboxes(page, limit)GET /v1/user/mailboxListing<MailboxRow>

Errors are AdukiException.Auth (401/403), AdukiException.Network (transport failures, and other HTTP errors with code), and AdukiException.Protocol (an unparseable response). The DTOs (MessageRow, MailChanges, FlagUpdate, MailboxRow, Listing, Moved, Address) live in pro.aduki.core.models.

Contacts Service Reference

The Contacts service (client.contacts) provides address book synchronization, contact lookups, and substring search across names, emails, and phone numbers.


1. Class & Method Signatures

package pro.aduki.sdk

class Contacts internal constructor(...) {
    suspend fun sync(tenant: String = ""): Boolean

    fun observe(): StateFlow<List<Contact>>?

    fun search(query: String): StateFlow<List<Contact>>?

    fun get(hex: String): Contact?
}

2. Detailed Method Specifications

sync

Reads the address book over REST and reconciles it with the local store.

suspend fun sync(tenant: String = ""): Boolean

sync needs a storage: pass one with Aduki.builder().contactStorage(storage) (with ObjectBox, pro.aduki.sync.engine.Contact.storage(boxStore)), or your own engine with engines(...). Without either it returns false.

Parameters

ParameterTypeRequiredDefaultDescription
tenantStringNo""Passed to the engine; the server takes the tenant from the credential. If blank, defaults to client.me()?.tenant.

Return Value

  • Type: Boolean
  • Description: true when the sync ran and was merged; false when no storage is configured. Network and protocol errors throw AdukiException.

How it syncs (full-list reconcile)

The server has no incremental contacts route over REST, so the transport (pro.aduki.sync.http.HttpContactTransport, on client.contactsApi) reads GET /v1/user/contacts?limit=200 page by page (cursor after = the previous next) with scope contacts:read, then:

  • stores a digest of every hex:etag pair as the sync token; if it equals the stored one the run changes nothing;
  • otherwise merges every row and removes local contacts the server no longer lists.

Limitations, all from the list route: a row has no vCard (the server has no GET /user/contacts/{hex}), so vcard stays empty unless you set it, and an existing local vcard is kept; only the first e-mail address and phone are kept; there is no modification time, so updated is the creation time. The server’s JMAP Contact/changes could make this incremental; it is not used yet. Cost grows with the size of the address book, one request per 200 contacts.

GET /v1/user/contacts?limit=200&after=<next> HTTP/1.1
Authorization: Bearer <access token>
{"success": true, "data": {"items": [
  {"hex": "...", "etag": "...", "name": "Alice Bob", "emails": ["alice@aduki.pro"],
   "phones": null, "groups": ["friends"], "created": "2026-09-26T09:02:20.658460", "total": 1}
], "total": 1, "next": "..."}}

observe

Returns a hot, reactive StateFlow emitting the full list of address book contacts sorted alphabetically by name.

fun observe(): StateFlow<List<Contact>>?
  • Return Type: StateFlow<List<Contact>>? — Stream populated by an ObjectBox live query. Returns null if contact repository is uninitialized.

Runs a case-insensitive substring search across name, email, and phone properties.

fun search(query: String): StateFlow<List<Contact>>?

Parameters

ParameterTypeRequiredDescription
queryStringYesSearch term (e.g. "alice" or "555").

Return Value

  • Type: StateFlow<List<Contact>>? — Reactive stream updating as contacts are added or modified matching the query.

get

Looks up synchronously of a contact by its unique hexadecimal identifier.

fun get(hex: String): Contact?

Parameters

ParameterTypeRequiredDescription
hexStringYesContact unique identifier hex.

Return Value

  • Type: Contact? — The matched contact entity, or null if not found in local ObjectBox storage.

3. Data Model: Contact Entity

package pro.aduki.store.entities

import io.objectbox.annotation.Entity
import io.objectbox.annotation.Id
import io.objectbox.annotation.Index

@Entity
data class Contact(
    @Id var id: Long = 0,
    @Index var hex: String = "",
    @Index var name: String = "",
    @Index var email: String = "",
    var phone: String = "",
    var company: String = "",
    var vcard: String = "",
    var ctag: String = "",
    var updated: Long = 0
)
  • id: Long: Local ObjectBox 64-bit record ID (0 for new inserts).
  • hex: String: Globally unique contact hexadecimal ID (indexed).
  • name: String: Contact full display name (indexed).
  • email: String: Contact primary email address (indexed).
  • phone: String: Contact phone number.
  • company: String: Organization affiliation.
  • vcard: String: Raw RFC 6350 vCard v4.0 representation.
  • ctag: String: Upstream synchronization generation cursor.
  • updated: Long: Milliseconds timestamp of last modification.

Scheduling Service Reference

The Scheduling service (client.scheduling) provides high-level operations for appointments, meeting templates, slot availability computation, and public booking.


1. Class & Method Signatures

package pro.aduki.sdk

class Scheduling internal constructor(...) {
    val appointments: StateFlow<List<Appointment>>?
    val upcoming: Flow<List<Appointment>>?

    suspend fun sync(): Int
    fun get(hex: String): Appointment?

    suspend fun create(
        service: String,
        start: String,
        end: String,
        timezone: String = "UTC",
        notes: String = "",
        location: String = ""
    ): AppointmentRecord?

    suspend fun cancel(hex: String, reason: String = ""): Boolean
    suspend fun slots(start: String, end: String, service: String = ""): List<Slot>
    suspend fun services(): List<ServiceRecord>

    suspend fun book(
        slug: String,
        start: String,
        end: String,
        guestName: String,
        guestEmail: String,
        answers: Map<String, String> = emptyMap()
    ): AppointmentRecord?
}

2. Usage Examples

Reactive Appointment Stream

Bind directly to Jetpack Compose or Kotlin ViewModel:

// In ViewModel
val upcomingAppointments: StateFlow<List<Appointment>> = 
    client.scheduling.upcoming
        ?.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
        ?: MutableStateFlow(emptyList())

// In Compose
@Composable
fun AppointmentsScreen(viewModel: SchedulingViewModel) {
    val appointments by viewModel.upcomingAppointments.collectAsState()

    LazyColumn {
        items(appointments) { appt ->
            AppointmentCard(
                title = appt.notes.ifBlank { "Meeting" },
                time = formatTime(appt.start),
                status = appt.status
            )
        }
    }
}

Free / Busy Availability Lookups

Compute available booking slots across a specific date window:

val slots = client.scheduling.slots(
    service = "srv_consultation",
    start = "2026-10-15T09:00:00Z",
    end = "2026-10-15T17:00:00Z"
)

slots.forEach { slot ->
    println("Bookable window: ${slot.start} -> ${slot.end}")
}

Public Guest Booking

Book an appointment on a public slug without authentication:

val booking = client.scheduling.book(
    slug = "architecture-consult",
    start = "2026-10-15T14:00:00Z",
    end = "2026-10-15T14:30:00Z",
    guestName = "Jane Doe",
    guestEmail = "jane@customer.com",
    answers = mapOf("company" to "Acme Corp")
)

Space client (JVM, draft)

pro.aduki.net.http.Space is a client for the Aduki Space REST API (https://space.aduki.pro/v1, audience space). It is a library class in the net module: it is not yet reachable from Aduki, has no local store, and has never run against a real Space server (fake-server tests only). The Space DTOs are still Draft; the shapes used here are read from the Space handlers.

Credentials

Space accepts only DPoP-bound access tokens. Give the Id client and the Space client the same HTTP client with the DPoP interceptor and sign in for audience space; every call is then sent as Authorization: DPoP <token> with a proof. A 401 renews the token once and retries once; a second 401 is AdukiException.Unauthorized.

val http = OkHttpClient.Builder().addInterceptor(Dpop(key)).build()
val id = Id(http)
id.signIn("ada@acme.example", password, code = totp, audience = "space")
val space = Space(http, id)
val page = space.tasks(status = "open", limit = 20)

The calls are blocking; run them off the main thread.

What it covers

CallRoute
call(method, path, query, body, idempotency, ifMatch)any route; returns Reply(status, data, etag)
list(path, query, limit, after), all(path, query, size)keyset lists {items, next}; all is a lazy sequence over the pages
tasks(...), task(hex), addTask, editTask(hex, changes, version)/tasks, /tasks/{hex}
checkins(task), addCheckin(task, body, minutes, proof)/tasks/{hex}/checkins
tickets(...), addTicket, messages(ticket), reply(ticket, body, internal)/tickets, /tickets/{hex}/messages
search(q, entity, limit)/search (ids by query: {collection, id})
settings(), editSettings(changes, version)/settings

Anything else (invoices, payments, payroll, nodes, portal, …) is reachable through call and list with the documented path and body; typed helpers for it wait for the DTOs to settle.

Rules the client follows

  • Envelope {success, data}; a failure is AdukiException.Api(status, kind, message, details, retryAfter): kind is the stable dotted identifier (entity.conflict, sod.refused, rate.limited, tenant.syncing, …), details the raw JSON object text, retryAfter the Retry-After seconds. Branch on kind and details, not the message.
  • Creating calls send an Idempotency-Key (a new UUID unless you pass one; pass your own to retry safely). A key that is not a UUID is refused before any request.
  • task() and settings() return the ETag; pass it as version to the edit so a stale write is 409 entity.conflict instead of a lost update.
  • Lists take limit 1 to 100 and the opaque after cursor; there is no offset and no total. A cursor is bound to its route and filters: reuse it only with the same ones.
  • Requests are strict: the server refuses unknown query parameters and body fields (422 input.invalid), so send only documented fields. Ids are sixteen lowercase hex digits (checked before the request). Money is integer minor units with a currency; durations are minutes.
  • Which routes a caller may use is Aduki ID’s: the token carries the rights bits; a refused or invisible entity is 403 or the same 404.

Not built

A Space entry on Aduki and a local store or sync for Space data; typed models for invoices, payments and payroll; the realtime channel (Sockudo) and upload tickets for Store; retry on tenant.syncing (Retry-After: 2) is left to the caller.

Sync Service Reference (CONDSTORE / MODSEQ)

The Sync service (client.sync) orchestrates incremental delta synchronization across email folders and address book contacts using RFC 7162 CONDSTORE / MODSEQ extensions.


1. Class & Method Signatures

package pro.aduki.sdk

class Sync internal constructor(...) {
    suspend fun all(mailboxes: List<String> = listOf("inbox")): Boolean

    suspend fun flush(): Int

    fun pending(): Int
}

2. Detailed Method Specifications

all

Executes an incremental delta sync across specified mailbox folders and subsequently syncs contacts for the active tenant.

suspend fun all(mailboxes: List<String> = listOf("inbox")): Boolean

Parameters

ParameterTypeRequiredDefaultDescription
mailboxesList<String>NolistOf("inbox")List of mailbox hex identifiers or names to synchronize.

Return Value

  • Type: Boolean
  • Description: Returns true if all targeted mailboxes and the address book synced cleanly; false if any mailbox delta failed.

flush

Immediately drains all queued offline outbox mutations over the active network transport.

suspend fun flush(): Int
  • Return Value: Int — Number of outbox mutations successfully processed and drained.
  • Execution: Invokes Worker.drain(). If network is disconnected or the circuit breaker is open, returns early without throwing.

pending

Returns the count of unsynced offline mutations currently queued in the local outbox journal.

fun pending(): Int
  • Return Value: Int — Count of pending outbox actions (send, flag, move, remove).

3. Wire Protocol: GET /v1/user/mail/changes

HttpMailboxTransport (pro.aduki.sync.http) implements MailboxTransport over the REST face of CONDSTORE/QRESYNC:

val engine = Mailbox(boxStore, HttpMailboxTransport(client.mailApi))
engine.sync(mailboxHex)

HTTP Request

GET /v1/user/mail/changes?mailbox=M0X1A2B3C4D5E6F&since=11500&uidvalidity=1790413341&limit=500 HTTP/1.1
Authorization: Bearer <access token>
ParameterMeaning
mailboxMailbox hex.
sinceThe MODSEQ from the previous response; 0 for a first sync.
uidvalidityThe UIDVALIDITY last seen. Omitted on a first sync.
limitPage size (default 500, at most 5000).

HTTP Response (200 OK)

{
  "mailbox": "M0X1A2B3C4D5E6F",
  "uidvalidity": 1790413341,
  "modseq": 11556,
  "reset": false,
  "more": false,
  "created": [
    {
      "hex": "M0GDB6EC798DDC2F3460", "uid": 4, "subject": "Lunch on Friday?",
      "sender": "bob@example.com", "size": 2048, "flags": [], "thread": "T0X9F8E7D6C5B4A",
      "spam": null, "internaldate": "2026-09-26T09:02:20.658460",
      "preview": "Are you free for lunch on Friday?",
      "from": {"name": "Bob Example", "email": "bob@example.com"},
      "to": [{"name": "Alice", "email": "alice@aduki.test"}],
      "has_attachment": true, "mailbox": {"hex": "M0X1A2B3C4D5E6F", "name": "INBOX"},
      "total": 0, "modseq": 11556
    }
  ],
  "updated": [{"hex": "M0GE32946DDC56C14639", "uid": 2, "flags": ["\\Seen", "\\Flagged"], "modseq": 11553}],
  "vanished": [3]
}
  • created: messages new to this mailbox, including ones moved in from another mailbox (they keep their id; the engine reuses the local row).
  • updated: flag changes on messages the client already has.
  • vanished: UIDs expunged or moved away.
  • more: another page waits past modseq. Mailbox.sync keeps fetching, committing each page, until it is false.
  • reset: the client’s UIDVALIDITY is stale. The transport refetches from since=0 and the engine replaces the local copy.
  • Timestamps are UTC without an offset.

4. Reconcile & Merge Algorithm

flowchart TD
    Start[Receive Mailbox Changes] --> CheckUID{Server uidvalidity == Local uidvalidity?}
    CheckUID -- No --> Invalidate[Wipe local messages for mailbox & full resync]
    CheckUID -- Yes --> Merge[Reconciliation Transaction]
    Merge --> Purge[Delete vanished UIDs]
    Merge --> Update[Upsert modified messages]
    Update --> CheckDirty{Is local message dirty?}
    CheckDirty -- Yes --> Preserve[Preserve local dirty flags & outbox journal]
    CheckDirty -- No --> Apply[Apply server state directly]
    Apply --> StoreModseq[Commit new mailbox.modseq]
    StoreModseq --> End[Finish Atomic Tx]

5. Data Model: Sync Cursor Entity

package pro.aduki.store.entities

import io.objectbox.annotation.Entity
import io.objectbox.annotation.Id
import io.objectbox.annotation.Index

@Entity
data class Sync(
    @Id var id: Long = 0,
    @Index var target: String = "", // e.g. "contacts", "inbox"
    var token: String = "",
    var timestamp: Long = 0
)

Offline Outbox & Retry Engine Reference

Every change is journaled in the local store in the same transaction as the local edit, and sent later; a network drop, crash or restart does not lose it.


1. Outbox Architecture & Execution Loop

sequenceDiagram
    autonumber
    participant App as Client Call (send, flag, move, remove)
    participant Store as ObjectBox Store
    participant Worker as Outbox Worker
    participant Server as Aduki REST API

    App->>Store: Atomic Tx: Write Message (dirty) + Insert Outbox entry
    Store-->>App: Return optimistic Message
    App->>Worker: drain()
    activate Worker
    Worker->>Store: Next pending entry (oldest first)
    Worker->>Server: HttpDispatcher: REST call (send carries Idempotency-Key)
    alt Transient failure (network, 5xx, 408, 409, 429)
        Worker->>Store: attempts++, nextRetry = now + jitter
        Worker->>Worker: Stop: later entries wait (order is preserved)
    else Rejected for good (other 4xx)
        Worker->>Store: Drop entry, clear dirty (next sync restores server state)
        Worker->>App: onRejected(entry, error)
    else Success
        Worker->>Store: Delete entry, clear dirty (send: adopt the server id)
    end
    deactivate Worker

2. Core Method Signatures

Manager

package pro.aduki.sync.outbox

class Manager(storage: Storage) {
    constructor(store: BoxStore)

    fun send(msg: Message, raw: ByteArray): Outbox   // raw = JSON send request
    fun flag(hex: String, flag: Int): Outbox          // toggles locally, journals the result
    fun move(hex: String, dest: String): Outbox
    fun remove(hex: String): Outbox
    fun enqueue(action: String, payload: ByteArray, hex: String = ""): Outbox
    fun pending(): List<Outbox>
    fun complete(id: Long, hex: String? = null)
    fun rename(old: String, new: String)              // placeholder id -> server id
    fun fail(id: Long, delay: Long)
}

Worker and Dispatcher

fun interface Dispatcher {
    suspend fun dispatch(action: Outbox)
}

class Rejected(message: String, val code: Int? = null, cause: Throwable? = null) : Exception(message, cause)

class Worker(
    manager: Manager,
    dispatcher: Dispatcher,
    jitter: Jitter = Jitter(),
    onRejected: (Outbox, Rejected) -> Unit = { _, _ -> }
) {
    suspend fun drain(): Int
}

HttpDispatcher

The REST implementation (pro.aduki.sync.http):

val manager = Manager(boxStore)
val dispatcher = HttpDispatcher(
    api = client.mailApi,
    lookup = { hex -> messageBox.query(Message_.hex.equal(hex)).build().use { it.findFirst() } },
    rename = manager::rename,
    fallback = null // or a Dispatcher for non-mail actions such as "appointment_cancel"
)
val worker = Worker(manager, dispatcher, onRejected = { entry, e -> /* tell the user */ })

3. Data Model: Outbox Entity

@Entity
data class Outbox(
    @Id var id: Long = 0,
    @Index var hex: String = "",
    @Index var action: String = "", // "send", "flag", "move", "delete", ...
    var payload: ByteArray = byteArrayOf(),
    @Index var created: Long = System.currentTimeMillis(),
    var attempts: Int = 0,
    var nextRetry: Long = 0
)

Actions, Payloads and Endpoints

actionPayload (UTF-8 JSON)Endpoint
send{"to": [...], "cc": [...], "subject": "...", "text": "...", "from"?: "..."}POST /v1/user/mail/send
flag{"hex": "...", "flag": 4, "set": true} — the resulting state, not a togglePATCH /v1/user/mail/{hex}/flags (add or remove)
move{"hex": "...", "mailbox": "<mailbox hex>"}PATCH /v1/user/mail/{hex}/mailbox
delete{"hex": "..."}DELETE /v1/user/mail/{hex}

Entries journaled by 0.1.x ("hex:flag", "hex:dest", a bare hex, or the raw body for send) are still dispatched.

A flag, move or delete of a message the server no longer has (404) completes: there is nothing left to do.


4. Idempotency & Replay Protection

A send whose response is lost (timeout, dropped connection) is retried with the same key, and the server replays the first result instead of sending again:

POST /v1/user/mail/send HTTP/1.1
Authorization: Bearer <access token>
Idempotency-Key: outbox-42-1790413340658
Content-Type: application/json

{"to":["bob@example.com"],"cc":[],"subject":"Lunch","text":"Friday?"}

The key is outbox-<entry id>-<created>, stable across retries and app restarts. While the first attempt is still running, a retry gets 409 and is retried later. After the send, the local placeholder message (msg_<millis>) takes the server’s id from the response.


5. Decorrelated Jitter Retry Backoff

Each retry waits min(max, random(base, 3 x previous)) (Amazon’s Decorrelated Jitter), so retries from many devices do not synchronise.

package pro.aduki.core.retry

class Jitter(val base: Long = 100, val max: Long = 30_000) {
    fun next(attempt: Int = 0): Long   // next delay in ms
    fun reset()
    fun current(): Long
}

Lifecycle Management Reference

The Lifecycle object tracks whether the app is in the foreground and tells interested parties when that changes. The one thing the SDK does itself on a change is to flush the outbox when the app resumes.


1. Class & Method Signatures

package pro.aduki.sdk

class Lifecycle {
    fun active(): Boolean
    fun pause()
    fun resume()
    fun listen(listener: (Boolean) -> Unit)
    fun remove(listener: (Boolean) -> Unit)
}

Methods on Aduki

Direct facade shortcuts are available on Aduki:

fun Aduki.pause()   // Delegates to lifecycle.pause()
fun Aduki.resume()  // Delegates to lifecycle.resume()

2. Detailed Method Specifications

active

Queries whether the SDK considers the application currently foregrounded and active.

fun active(): Boolean
  • Return Type: Boolean — Returns true if active / foreground; false if paused / backgrounded. Defaults to true upon initialization.

pause

Marks the SDK as backgrounded and calls the listeners with false. The SDK does not stop any task by itself; use a listener to pause your own work.

fun pause()
  • Listeners: Fires all callbacks registered via listen with false.

resume

Marks the SDK as foregrounded and calls the listeners with true; Aduki has a listener that launches sync.flush() on resume.

fun resume()
  • Automatic Flush Trigger: Aduki installs an internal listener that launches a coroutine (SupervisorJob + Dispatchers.IO) calling client.sync.flush() upon resume.
  • Listeners: Fires all callbacks registered via listen with true.

listen

Registers a callback lambda to receive state transition events (true for resumed, false for paused).

fun listen(listener: (Boolean) -> Unit)
  • Thread-Safety: Uses CopyOnWriteArrayList to ensure safe concurrent iteration and registration.

3. Production Android Architecture Integration

The recommended integration utilizes AndroidX ProcessLifecycleOwner:

package com.example.adukiapp

import android.app.Application
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import pro.aduki.sdk.Aduki

class App : Application(), DefaultLifecycleObserver {

    lateinit var client: Aduki
        private set

    override fun onCreate() {
        super.onCreate()

        client = Aduki.builder()
            .key(BuildConfig.ADUKI_API_KEY)
            .build()

        ProcessLifecycleOwner.get().lifecycle.addObserver(this)
    }

    override fun onStart(owner: LifecycleOwner) {
        // App enters foreground — flush pending outbox actions and resume sync
        client.resume()
    }

    override fun onStop(owner: LifecycleOwner) {
        // App enters background
        client.pause()
    }
}

Unidirectional Data Flow (UDF)

The SDK is built around unidirectional data flow: the UI observes state, and changes go through the local store:

┌─────────────────────────────────────────────────────────────┐
│                    Jetpack Compose UI                       │
│    (Renders StateFlow; dispatches user action intents)      │
└──────────────┬──────────────────────────────▲───────────────┘
               │ User Action                  │ Reactive StateFlow
               ▼                              │
┌──────────────────────────────┐              │
│       Mutation Engine        │              │
│  (Marks dirty, inserts into  │              │
│   ObjectBox Outbox journal)  │              │
└──────────────┬───────────────┘              │
               │ ACID Transaction             │
               ▼                              │
┌─────────────────────────────────────────────┴───────────────┐
│                 ObjectBox Local Database                    │
│      (Single source of truth) │
└──────────────┬──────────────────────────────▲───────────────┘
               │ Background Dispatch          │ Reconcile Sync
               ▼                              │
┌──────────────────────────────┐┌─────────────┴───────────────┐
│        Outbox Worker         ││      CONDSTORE/MODSEQ       │
│  (Dispatches queued network  ││         Sync Engine         │
│     mutations with jitter)   ││    (Pulls delta updates)    │
└──────────────────────────────┘└─────────────────────────────┘
  1. State flows in one direction only: from ObjectBox through hot StateFlow streams directly to the UI.
  2. UI never modifies in-memory state directly: interactions trigger mutations that write atomically to the database.
  3. Database emissions update the UI: ObjectBox data change observers trigger StateFlow updates reactively.

Jetpack Compose UI Binding Reference

Because the Aduki Android SDK exposes standard Kotlin StateFlow primitives backed by ObjectBox live queries, integrating with Jetpack Compose needs no adapter code: collect the flows with collectAsStateWithLifecycle().


1. ViewModel Implementation

package com.example.adukiapp.ui

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import pro.aduki.sdk.Aduki
import pro.aduki.store.entities.Message

class MailboxViewModel(
    private val client: Aduki,
    private val mailboxHex: String = "inbox"
) : ViewModel() {

    // StateFlow fed by an ObjectBox query observer
    val messages: StateFlow<List<Message>> = client.mail
        .observe(mailboxHex) ?: MutableStateFlow(emptyList())

    val unread: StateFlow<Int> = client.mail
        .unread(mailboxHex) ?: MutableStateFlow(0)

    fun toggleFlag(hex: String) {
        viewModelScope.launch {
            client.mail.flag(hex, Message.FLAGGED)
        }
    }

    fun markSeen(hex: String) {
        viewModelScope.launch {
            client.mail.flag(hex, Message.SEEN)
        }
    }

    fun delete(hex: String) {
        viewModelScope.launch {
            client.mail.remove(hex)
        }
    }
}

2. High-Performance Composable List

package com.example.adukiapp.ui

import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MailboxScreen(viewModel: MailboxViewModel) {
    // Collect lifecycle-aware: pauses collection when Activity/Fragment is stopped
    val messages by viewModel.messages.collectAsStateWithLifecycle()
    val unreadCount by viewModel.unread.collectAsStateWithLifecycle()

    Scaffold(
        topBar = {
            TopAppBar(title = { Text("Inbox ($unreadCount unread)") })
        }
    ) { paddingValues ->
        LazyColumn(modifier = Modifier.padding(paddingValues)) {
            // Stable keying: 'key = { it.hex }' prevents whole-list recomposition on flag update
            items(
                items = messages,
                key = { message -> message.hex }
            ) { message ->
                MessageItemRow(
                    message = message,
                    onFlagClicked = { viewModel.toggleFlag(message.hex) },
                    onItemClicked = { viewModel.markSeen(message.hex) }
                )
            }
        }
    }
}

3. Recomposition Best Practices

  1. Always Supply key = { it.hex }: Without a stable key, inserting a single message at index 0 forces Compose to recreate all visible rows. With key = { it.hex }, Compose can keep the other rows and animate the insertion.
  2. Use collectAsStateWithLifecycle(): Stops collecting while the screen is in the background.
  3. Immutability: Each emission is a freshly read list; treat the entities in it as read-only.

Network transports

HTTP (OkHttp)

All typed clients (Id, Login, Center, Mail, Scheduling, Oidc) use OkHttp 4.12.0. pro.aduki.net.http.Client.create(key, timeout, pinner, apiKey) builds the base client (an idle connection pool of 8 for 5 minutes, the given timeout on connect, read and write, retry on connection failure); Aduki adds the Authorization header, the 401 renewal authenticator, TLS pinning (see TLS) and, when configured, the DPoP interceptor.

The scheme of the Authorization header follows the credential:

CredentialHeader
supplied through Builder.key(...)Key <key>
bound access token (with DPoP configured)DPoP <token> plus a DPoP proof header
anything else (token(...), sessions)Bearer <token>

The SDK never guesses from the token prefix; the server accepts either scheme for both kinds of credential.

Mail’s /v1 responses use one envelope, {"success": true, "data": ...} or {"success": false, "error": {"status", "kind", "message"}}; the clients read it for you and map failures to AdukiException. Sends carry an Idempotency-Key header (see Outbox).

Timeouts come from Builder.timeout(seconds) (default 15) and apply to connect, read and write.

Mail event stream

Events reads GET {mail host}/jmap/eventsource (Server-Sent Events) for the rights event. See Token lifecycle.

The SDK has no gRPC transport: the grpc. host was dropped from the platform (D-HOST-5) and the Channel and Credentials classes were removed (a breaking change in this 0.x SDK; see the CHANGELOG).

Circuit Breaker & Fault Tolerance Reference

pro.aduki.core.retry.Circuit is a small three-state circuit breaker for fail-fast behaviour during outages. It is a utility: the SDK’s own clients do not wrap their calls in it, so use it around your own calls (for example a custom Dispatcher for the outbox).


1. Class & Method Signatures

package pro.aduki.core.retry

import pro.aduki.core.errors.AdukiException

class Circuit(
    private val threshold: Int = 5,
    private val timeout: Long = 15_000
) {
    enum class State {
        CLOSED,
        OPEN,
        HALF_OPEN
    }

    fun state(): State
    fun success()
    fun fail()
    inline fun <T> execute(block: () -> T): T
}

Parameters

ParameterTypeDefaultDescription
thresholdInt5Number of consecutive exceptions before the circuit trips from CLOSED to OPEN.
timeoutLong15_000Cooldown duration in milliseconds before an OPEN circuit transitions to HALF_OPEN for a canary probe.

2. State Machine Specification

stateDiagram-v2
    [*] --> CLOSED
    CLOSED --> OPEN: 5 consecutive failures (threshold reached)
    OPEN --> HALF_OPEN: 15 seconds elapsed (timeout)
    HALF_OPEN --> CLOSED: Canary request succeeds (success())
    HALF_OPEN --> OPEN: Canary request fails (fail())

State Behaviors

StateRequest Execution Behavior
CLOSEDNormal execution. All requests execute directly. Failures increment an atomic counter; any success resets the failure count to 0.
OPENFail-fast mode. Calls to execute { ... } immediately throw AdukiException.CircuitOpen without executing the block or waking the radio modem.
HALF_OPENCanary probing mode. Allows a single execution. If it succeeds, circuit resets to CLOSED. If it fails, circuit immediately returns to OPEN.

3. Usage & Exception Handling Example

val circuit = Circuit(threshold = 5, timeout = 15_000L)

try {
    val response = circuit.execute {
        transport.dispatch(payload)
    }
} catch (e: AdukiException.CircuitOpen) {
    // Immediate fail-fast: device is offline or remote service is unresponsive
    // Outbox actions remain safely stored in local ObjectBox database
    showOfflineStatusBanner()
} catch (e: Exception) {
    // Network or transport error — circuit counted this as a failure
    Log.w("Aduki", "Call failed: ${e.message}")
}

Aduki API Reference

Aduki is the entry point of the SDK (pro.aduki.sdk.Aduki).


1. Class Signature & Properties

package pro.aduki.sdk

class Aduki internal constructor(
    val apiKey: String = "",
    val token: String = "",
    val options: Options,
    val session: Session = Session(),
    val lifecycle: Lifecycle = Lifecycle(),
    private val httpClient: OkHttpClient? = null,
    manager: Manager? = null,
    worker: Worker? = null,
    mailRepo: MailRepo? = null,
    contactRepo: ContactRepo? = null,
    appointmentRepo: AppointmentRepo? = null,
    mailboxEngine: MailboxEngine? = null,
    contactEngine: ContactEngine? = null,
    scheduleEngine: ScheduleEngine? = null,
    private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
    private val dpop: Dpop? = null
)

Public Member Properties

PropertyTypeAccessDescription
apiKeyStringvalStatic API key string (if initialized via API key).
tokenStringvalInitial JWT access token string (if initialized via token).
optionsOptionsvalImmutable client network and endpoint options.
sessionSessionvalReactive state repository for active tokens and resolved user identity.
lifecycleLifecyclevalApplication foreground/background lifecycle coordinator.
mailMailvalSub-service for message sending, flag updates, moves, and mailbox observations.
contactsContactsvalSub-service for address book synchronization, contact retrieval, and substring search.
syncSyncvalSub-service for CONDSTORE/MODSEQ folder delta sync and outbox flushing.
schedulingSchedulingvalAppointments, services, slots and booking (Scheduling).
mailApiMail (net)valTyped REST mail client on the authenticated connection.

2. Factory & Builder Methods

Aduki.Companion.login

Signs in at Aduki ID for a mail token. See Sign-in.

suspend fun Aduki.Companion.login(
    handle: String,
    password: String,
    code: String? = null,
    endpoint: String = Endpoints.REST,
    identity: String = Endpoints.ID,
    backup: String? = null,
    dpop: Key? = null
): Aduki
  • Parameters:
    • handle: Full address, e.g. ada@aduki.me.
    • password: Account password.
    • code / backup: Authenticator code or backup code.
    • dpop: optional device key to bind the session to (DPoP).
    • endpoint: Mail REST base (defaults to https://mail.aduki.pro/v1).
    • identity: Aduki ID base (defaults to https://id.aduki.pro/v1).
  • Return Type: Aduki — holding the access token, refresh token and session, with Identity resolved.
  • Throws: AdukiException.Unauthorized on a wrong password or second factor; AdukiException.Network otherwise.

Aduki.Companion.builder

Creates a fluent Builder instance for custom client configuration.

fun Aduki.Companion.builder(): Aduki.Builder

Builder Methods

class Builder {
    fun key(key: String): Builder
    fun token(token: String): Builder
    fun endpoint(endpoint: String): Builder
    fun identity(identity: String): Builder
    fun dpop(key: Key): Builder
    fun secure(enabled: Boolean): Builder
    fun pins(vararg pins: Pin): Builder
    fun timeout(seconds: Long): Builder
    fun http(client: OkHttpClient): Builder
    fun manager(manager: Manager): Builder
    fun worker(worker: Worker): Builder
    fun mail(repo: MailRepo): Builder
    fun contacts(repo: ContactRepo): Builder
    fun scheduling(repo: AppointmentRepo, engine: ScheduleEngine? = null): Builder
    fun engines(mailbox: MailboxEngine, contact: ContactEngine): Builder
    fun build(): Aduki
}
  • Validation in build(): Enforces that at least one of apiKey or token is non-blank (require(apiKey.isNotBlank() || token.isNotBlank())).

3. Session & Lifecycle Methods

me

Resolves and returns the authenticated user and tenant identity profile.

suspend fun me(): Identity?
  • Return Type: Identity? — User ID hex, tenant hex, owner status, scopes, and tier. Returns cached instance from session.identity.value if already resolved; otherwise queries GET /v1/user. Scopes and tier are not part of that response and stay empty.

totp

Deprecated. Second factors are managed in Aduki ID’s Account Center; mail’s /v1/user/totp is going away.

suspend fun totp(code: String): Boolean
  • Parameters: code: String — the 6-digit code.
  • Return Type: Boolean — true if the server confirms it.

refresh

Renews the access token at Aduki ID (POST /v1/tokens) ahead of time. The SDK also does this by itself on a 401.

suspend fun refresh(): Boolean
  • Return Type: Boolean — true if tokens were successfully rotated and updated in session state.

logout

Terminates the session remotely and wipes local credentials.

suspend fun logout(): Boolean
  • Return Type: Boolean — true if Aduki ID revoked the session (DELETE /v1/sessions/{hex}); the local session is then cleared. When revocation fails the session is kept with the rotated refresh token so logout() can be retried (or call session.clear()). Also stops watchRights.

rights, watchRights, unwatchRights

Renew the access token on a rights push, or subscribe to the mail event stream that sends them. See Token lifecycle.

suspend fun rights(payload: String): Boolean
fun watchRights(host: String = <REST endpoint without /v1>)
fun unwatchRights()

pause

Notifies the SDK that the host application entered the background (see Lifecycle).

fun pause()

resume

Notifies the SDK that the host application entered the foreground and flushes pending outbox mutations.

fun resume()

4. Complete Sub-Services Index

client.mail (Mail)

  • suspend fun send(to: List<String>, subject: String, body: String, mailbox: String = "outbox"): Message
  • suspend fun flag(hex: String, flag: Int)
  • suspend fun move(hex: String, dest: String)
  • suspend fun remove(hex: String)
  • fun observe(mailboxHex: String): StateFlow<List<Message>>?
  • fun mailboxes(): StateFlow<List<Mailbox>>?
  • fun unread(mailboxHex: String): StateFlow<Int>?

client.contacts (Contacts)

  • suspend fun sync(tenant: String = ""): Boolean
  • fun observe(): StateFlow<List<Contact>>?
  • fun search(query: String): StateFlow<List<Contact>>?
  • fun get(hex: String): Contact?

client.sync (Sync)

  • suspend fun all(mailboxes: List<String> = listOf("inbox")): Boolean
  • suspend fun flush(): Int
  • fun pending(): Int

Error Handling & Exception Reference

Errors raised by the SDK’s clients are subclasses of the sealed AdukiException.


1. Sealed Exception Class Hierarchy

package pro.aduki.core.errors

sealed class AdukiException(
    message: String,
    cause: Throwable? = null
) : Exception(message, cause) {

    class Network(message: String, cause: Throwable? = null, val code: Int? = null) :
        AdukiException(message, cause)

    /** An auth response that could not be used; [refresh] carries a rotated refresh token it did include. */
    class Auth(message: String, cause: Throwable? = null, val refresh: String = "") :
        AdukiException(message, cause)

    class Unauthorized(message: String, cause: Throwable? = null) :
        AdukiException(message, cause)

    /**
     * A failure envelope from an Aduki service (HTTP status, stable dotted kind, e.g. entity.conflict, rate.limited, sod.refused, raw details JSON, retryAfter seconds).
     */
    class Api(
        val status: Int,
        val kind: String,
        message: String,
        val details: String? = null,
        val retryAfter: Long? = null
    ) : AdukiException("$kind: $message")

    /** The user refused an authorization (device grant access_denied). */
    class Denied(message: String, cause: Throwable? = null) :
        AdukiException(message, cause)

    /** A device code or authorization expired (expired_token or local deadline). */
    class Expired(message: String, cause: Throwable? = null) :
        AdukiException(message, cause)

    class Storage(message: String, cause: Throwable? = null) :
        AdukiException(message, cause)

    class Sync(message: String, cause: Throwable? = null) :
        AdukiException(message, cause)

    class Protocol(message: String, cause: Throwable? = null) :
        AdukiException(message, cause)

    class CircuitOpen(message: String = "Circuit breaker is open") :
        AdukiException(message)
}

2. Exception types

ExceptionRaised whenTypical recovery
UnauthorizedAduki ID refused a sign-in, refresh or unlock (wrong password, missing or wrong second factor, spent, expired or revoked refresh token); a signed-out Id has no refresh token; an OIDC error such as invalid_grantsign in again
Auththe mail or scheduling API answered 401/403; an API key was refused; an Aduki ID 2xx lacked a required field (refresh then holds the rotated token, if any)renew the token (client.refresh()), or fix the account’s rights
ApiAn Aduki service returned a failure envelope with an HTTP status, stable dotted kind (e.g. entity.conflict, rate.limited, sod.refused), raw details JSON, and optional retryAfterbranch on e.kind; wait e.retryAfter seconds if rate limited; refresh on 409 entity.conflict
DeniedThe person refused an authorization (OAuth/device authorization grant access_denied)user opted out; abort flow
ExpiredA device code or authorization expired (expired_token or local deadline)start a new authorization flow
Networktransport failure (timeout, DNS, TLS), or an HTTP error not covered above; code holds the status when there was a response (e.g. 429)retry later; the outbox does this for queued changes
Protocola response that cannot be parsed, a missing mandatory field, or an OIDC check failing (state, issuer, id token)report it; do not retry blindly
CircuitOpena Circuit you use is openwait for the cooldown
Storage, Syncdeclared for local-store and sync failuresthe SDK does not raise them yet

Sign-in messages start with Aduki ID’s error kind where it sent one, for example auth.factor: ....


Error handling in a ViewModel

viewModelScope.launch(Dispatchers.IO) {
    try {
        client.sync.all()
    } catch (e: AdukiException.Auth) {
        // The SDK already renews once on a 401; a second refusal lands here.
        // Step 1: Try an explicit renewal
        val refreshed = client.refresh()
        if (!refreshed) {
            // Step 2: Refresh token is dead — clear session and redirect to login
            client.logout()
            _navEvents.emit(NavDestination.Login)
        }
    } catch (e: AdukiException.CircuitOpen) {
        // Only if you wrap calls in a Circuit: fail fast while the service is down
    } catch (e: AdukiException.Api) {
        // Structured service error: inspect kind and retryAfter
        if (e.kind == "rate.limited" && e.retryAfter != null) {
            Log.w("Aduki", "Rate limited; retry after ${e.retryAfter}s")
        } else {
            Log.e("Aduki", "API failure: ${e.kind} (${e.status}) - ${e.message}")
        }
    } catch (e: AdukiException.Network) {
        // Network blip — Outbox already handles queueing
        Log.w("Aduki", "Network synchronization deferred: ${e.message} (HTTP ${e.code})")
    }
}