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
401and signs out by revoking the session. See Sign-in. - One session, many audiences. The
Idclient 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
StateFlowstreams for UI binding. See Reactive. - Typed REST clients for mail, scheduling and the Account Center.
Modules
| Module | Holds |
|---|---|
core | models, errors, configuration, retry primitives (Jitter, Circuit), memory helpers |
crypto | Keystore provider, AES-256-GCM Envelope, Guard, TLS pinning, DPoP keys |
net | HTTP clients (Id, Login, Center, Mail, Scheduling, Events, Dpop, Oidc) |
store | ObjectBox entities and batch transactions |
sync | mailbox, contact and schedule sync engines; the outbox |
state | repositories that expose StateFlow |
sdk | the Aduki facade |
android | Android 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
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
Providerreaches 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 theandroidmodule,minSdk26: 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:
| Module | Maven Coordinate | Purpose |
|---|---|---|
| SDK Facade | pro.aduki:sdk:0.4.1-beta | The Aduki entry point |
| State | pro.aduki:state:0.4.1-beta | Repositories exposing StateFlow |
| Sync | pro.aduki:sync:0.4.1-beta | RFC 7162 CONDSTORE synchronizer & Outbox |
| Store | pro.aduki:store:0.4.1-beta | ObjectBox entities |
| Net | pro.aduki:net:0.4.1-beta | HTTP clients (Aduki ID, mail, scheduling) |
| Crypto | pro.aduki:crypto:0.4.1-beta | Keystore provider, AES-256-GCM cipher, TLS pinning, DPoP keys |
| Core | pro.aduki:core:0.4.1-beta | models, 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:
Messagefields changed.snippetis nowpreview,dateis nowreceivedAt, andfromis split intofromNameandfromEmail. The new fields arethreadId,keywords,hasAttachment,sentAtandmodseq. 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)expectsrawto be the JSON send request.client.mail.sendbuilds it for you. Entries queued by 0.1.x are still dispatched. Workerrejects permanent failures. It now accepts anonRejectedcallback for actions the server refused for good.- New HTTP layer. Wire
HttpMailboxTransport(client.mailApi)andHttpDispatcher(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
| Method | Parameter | Type | Default | Description |
|---|---|---|---|---|
key | key | String | "" | API key, sent as Authorization: Key <key> (only on this path; every other credential is Bearer) (see API keys). |
token | token | String | "" | Pre-existing JWT access token for user sessions. |
endpoint | endpoint | String | Endpoints.REST | Base REST API URL. |
secure | enabled | Boolean | true | Allows TLS pinning on the default HTTP client when pins are configured. It does not pin anything by itself and does not encrypt local storage. |
pins | pins | Pin... | none | Opt-in TLS pins per host (TLS); the SDK ships no pins. |
timeout | seconds | Long | 15 | OkHttp socket connect, read, and write timeout in seconds. |
http | client | OkHttpClient | null | Optional custom OkHttpClient instance. |
identity | identity | String | https://id.aduki.pro/v1 | Aduki ID base used to renew and revoke sign-ins. |
dpop | key | Key | none | Device 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" } }
| Part | Class | What it does |
|---|---|---|
| Device key (K5) | keystore.AndroidKey | non-extractable P-256 key in the Android Keystore; implements the Key of DPoP (ES256) |
| Accounts (K4) | account.Accounts, AdukiAuthenticator, SwitcherState | the 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, RedirectActivity | Sign in with Aduki through a Custom Tab, redirect handling |
| Passkeys (K6) | passkey.PasskeyFlow, CredentialManagerAuthenticator | the 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
| Property | Interactive sign-in | API key |
|---|---|---|
| Use | apps with human users | workers, tests, daemons |
| Credentials | address + password + second factor, at Aduki ID | an opaque key issued at Aduki Mail |
| HTTP header | Authorization: Bearer <access token> (DPoP <token> when bound) | Authorization: Key <key> |
| Lifetime | 10-minute access token (EdDSA JWT per audience), single-use rotating refresh token | until revoked at the server |
| Renewal | automatic on 401 | none |
| Sign-out | DELETE /v1/sessions/{hex} at Aduki ID | revoke 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
- Sign-in:
Aduki.login. - Aduki ID client:
Id, one sign-in with many audiences. - Token lifecycle: renewal, sign-out, the rights stream.
- DPoP: binding tokens to a device key.
- OIDC client: “Sign in with Aduki” for third-party apps.
- Account Center & unlock.
- Passkeys: register and sign in with passkeys (JVM protocol & Android Credential Manager).
- API keys.
- Two-factor TOTP (deprecated).
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
| Parameter | Type | Default | Description |
|---|---|---|---|
handle | String | — | Full address, e.g. ada@aduki.me. |
password | String | — | Account password. |
code | String? | null | Current authenticator code. |
endpoint | String | Endpoints.REST | Mail REST base. |
identity | String | Endpoints.ID | Aduki ID base, https://id.aduki.pro/v1. |
backup | String? | null | A backup code, instead of code. |
dpop | Key? | null | A 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.429while rate-limited (codeholds 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, validexpiresseconds (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
| Method | Does |
|---|---|
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.Unauthorizedis thrown; sign in again. - A
2xxthat fails validation (a missing field) still spent the token: the rotated token it did carry is kept (AdukiException.Auth.refresh). - Network failures and
429/5xxleave 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.jktequals the key’s thumbprint) is sent asAuthorization: DPoP <token>with a proof carryingath, 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 randomjti, andnoncewhen the server asked for one. - A
DPoP-Noncefrom the server is remembered per host; a401or400carrying 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.
| Method | Does |
|---|---|
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).
| Call | Endpoint | Returns |
|---|---|---|
Center.link | POST /links | Switcher — center token plus linked accounts |
Center.fetch | GET /center | Switcher? (null when no center exists) |
Center.unlink | DELETE /links | Boolean |
Center.register | POST /credentials | credential id |
Center.revoke | DELETE /credentials/{id} | Boolean (false when already gone) |
Center.unlock | POST /credentials/{id}/challenges then /credentials/{id}/unlock | List<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.
- Keep the private key in the platform keystore. The SDK only asks the
Key(or asigncallback) for a signature and never sees the private part. - Register it with
Center.register(client, identity, bearer, device, key, code, center)(a fresh TOTP step-up code is required;center = trueregisters it for the Account Center instead of one account). The call sendsalgand the public key:ES256sends the 65-byte uncompressed point0x04 || x || y(x and y left-padded to 32 bytes) in base64url without padding;EdDSAthe 32-byte key. It checks that the server answered with the samealg. A P-256 key sent withoutalg: "ES256"is422at Aduki ID, so the old overloadregister(..., public: ByteArray, ...)is for Ed25519 only. - To unlock, call
Center.unlock(client, identity, credential, key)(or thesigncallback 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-byter || sform:Softwarealready returns it andAndroidKeyconverts the Keystore’s DER withcrypto.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.
| Ceremony | Begin | Finish | Result |
|---|---|---|---|
Register (step-up or code) | beginRegistration(client, identity, bearer, label?, code?) | finishRegistration(..., challenge, registrationResponseJson) | Registered |
| Step-up | beginStepUp(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.totpcalls 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
| Parameter | Type | Description |
|---|---|---|
code | String | exactly 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.
| Area | What exists | Page |
|---|---|---|
| Key storage | Provider: an AES-256 master key (alias aduki_master) in the Android Keystore, with a software fallback off Android | Keystore |
| Encryption | Envelope: AES-256-GCM with a random 12-byte IV per message | Cipher |
| Memory | wipe(), Guard: overwrite secrets after use | Sanitization |
| Transport | Opt-in SPKI certificate pinning with your own pins (none shipped), TLS 1.3/1.2 connection spec | TLS |
| Device keys | dpop.Key: sign-only device keys for DPoP | DPoP |
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
AccountManagerfor 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)
}
| Method | Does |
|---|---|
get | returns the key under alias, creating it if missing |
create | generates a new AES-256 key (GCM, no padding, encrypt and decrypt purposes) |
has | whether the alias exists |
remove | deletes 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
Pinis a host and one or moresha256/<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 thePinis created. - A mismatch aborts the handshake with
SSLPeerUnverifiedExceptionbefore any header is sent. specs()returns aConnectionSpecrestricted 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 inaduki-index.keysnext 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 andSealing.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/byPhonescan 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
| Field | Meaning |
|---|---|
hex | Server id; a msg_<millis> placeholder until an outbox send completes. |
mailbox, uid | Mailbox hex and the message’s UID in it (changes on a move). |
threadId | Conversation id (the message’s own id when it has no thread). |
fromName, fromEmail, to | Parsed sender and recipients. |
preview | Start of the body text, from the server. |
flags | Bitmask of \\Seen, \\Answered, \\Flagged, \\Deleted, \\Draft. |
keywords | Every other server flag or keyword, space-separated. |
hasAttachment | The message has attachments. |
receivedAt, sentAt | Arrival time and Date: header, epoch millis (UTC). |
modseq | The server MODSEQ of the last change applied. |
dirty | A 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
| Field | Type | Annotation | Description |
|---|---|---|---|
id | Long | @Id | Local ObjectBox primary key. |
hex | String | @Index | Unique mailbox folder identifier hex. |
name | String | — | User-facing folder display title (e.g. "Inbox", "Work"). |
role | String | — | Special-use role attribute: "inbox", "sent", "trash", "archive", "drafts". |
uidnext | Long | — | Predicted next IMAP UID assigned to incoming mail. |
uidvalidity | Long | — | Server mailbox generation token. Mismatches trigger full local re-seed. |
modseq | Long | — | Highest 64-bit CONDSTORE sequence counter committed locally. |
exists | Int | — | Total message count in the folder. |
unseen | Int | — | 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.
| Entity | Indexed fields |
|---|---|
Message | hex, mailbox, uid, threadId, flags, receivedAt |
Contact | hex, emailIndex, phoneIndex |
Mailbox | hex |
Outbox | hex, action, created |
Sync | target |
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
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
to | List<String> | Yes | — | List of recipient email addresses (e.g. listOf("partner@example.com")). Joined internally as comma-delimited string for FlatBuffers storage. |
subject | String | Yes | — | Email subject line. |
body | String | Yes | — | Email body text (UTF-8). |
mailbox | String | No | "outbox" | Mailbox folder identifier to which the draft/queued message is initially written. |
Return Value
- Type:
Message - Description: The persisted
Messageentity containing assignedhex, snippet preview (first 120 chars), timestamp, anddirty = true.
Concurrency & Execution
- Thread Context: Executes within caller’s coroutine dispatcher (recommended
Dispatchers.IO). - Persistence: Writes
Messageand journals anOutboxmutation (action = "send") in an atomic ObjectBox transaction. Immediately signalsWorker.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
| Parameter | Type | Required | Description |
|---|---|---|---|
hex | String | Yes | Message hex identifier. |
flag | Int | Yes | Bitmask integer to XOR toggle against the message’s current flags. |
Bitmask Constants (Message.Companion)
| Constant | Value | Description |
|---|---|---|
Message.SEEN | 1 (1 << 0) | Read / Unread status. |
Message.ANSWERED | 2 (1 << 1) | Replied to status. |
Message.FLAGGED | 4 (1 << 2) | Starred / High priority flag. |
Message.DELETED | 8 (1 << 3) | Deleted / Scheduled for purge. |
Message.DRAFT | 16 (1 << 4) | Unsent draft message. |
move
Relocates a message to a destination mailbox folder.
suspend fun move(hex: String, dest: String)
Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
hex | String | Yes | Unique message hexadecimal identifier. |
dest | String | Yes | Destination 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
| Parameter | Type | Required | Description |
|---|---|---|---|
hex | String | Yes | Unique 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 ofMessageentities. Returnsnullif 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 ofMailboxentities includingunseenandexistscounts.
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).
| Method | Endpoint | Returns |
|---|---|---|
inbox(after, limit) | GET /v1/user/mail/inbox | Listing<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/changes | MailChanges |
flags(hex, add, remove) | PATCH /v1/user/mail/{hex}/flags | — |
move(hex, mailbox) | PATCH /v1/user/mail/{hex}/mailbox | Moved (same id, new UID) |
delete(hex) | DELETE /v1/user/mail/{hex} | — |
send(to, subject, text, cc, from, idempotencyKey) | POST /v1/user/mail/send | server id of the sent copy |
mailboxes(page, limit) | GET /v1/user/mailbox | Listing<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
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
tenant | String | No | "" | Passed to the engine; the server takes the tenant from the credential. If blank, defaults to client.me()?.tenant. |
Return Value
- Type:
Boolean - Description:
truewhen the sync ran and was merged;falsewhen no storage is configured. Network and protocol errors throwAdukiException.
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:etagpair 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. Returnsnullif contact repository is uninitialized.
search
Runs a case-insensitive substring search across name, email, and phone properties.
fun search(query: String): StateFlow<List<Contact>>?
Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
query | String | Yes | Search 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
| Parameter | Type | Required | Description |
|---|---|---|---|
hex | String | Yes | Contact unique identifier hex. |
Return Value
- Type:
Contact?— The matched contact entity, ornullif 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 (0for 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
| Call | Route |
|---|---|
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 isAdukiException.Api(status, kind, message, details, retryAfter):kindis the stable dotted identifier (entity.conflict,sod.refused,rate.limited,tenant.syncing, …),detailsthe raw JSON object text,retryAftertheRetry-Afterseconds. Branch onkindanddetails, 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()andsettings()return theETag; pass it asversionto the edit so a stale write is409 entity.conflictinstead of a lost update.- Lists take
limit1 to 100 and the opaqueaftercursor; 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 acurrency; 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
403or the same404.
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
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
mailboxes | List<String> | No | listOf("inbox") | List of mailbox hex identifiers or names to synchronize. |
Return Value
- Type:
Boolean - Description: Returns
trueif all targeted mailboxes and the address book synced cleanly;falseif 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>
| Parameter | Meaning |
|---|---|
mailbox | Mailbox hex. |
since | The MODSEQ from the previous response; 0 for a first sync. |
uidvalidity | The UIDVALIDITY last seen. Omitted on a first sync. |
limit | Page 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 pastmodseq.Mailbox.synckeeps fetching, committing each page, until it isfalse.reset: the client’s UIDVALIDITY is stale. The transport refetches fromsince=0and 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
action | Payload (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 toggle | PATCH /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— Returnstrueif active / foreground;falseif paused / backgrounded. Defaults totrueupon 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
listenwithfalse.
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:
Adukiinstalls an internal listener that launches a coroutine (SupervisorJob + Dispatchers.IO) callingclient.sync.flush()upon resume. - Listeners: Fires all callbacks registered via
listenwithtrue.
listen
Registers a callback lambda to receive state transition events (true for resumed, false for paused).
fun listen(listener: (Boolean) -> Unit)
- Thread-Safety: Uses
CopyOnWriteArrayListto 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) │
└──────────────────────────────┘└─────────────────────────────┘
- State flows in one direction only: from ObjectBox through hot
StateFlowstreams directly to the UI. - UI never modifies in-memory state directly: interactions trigger mutations that write atomically to the database.
- Database emissions update the UI: ObjectBox data change observers trigger
StateFlowupdates 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
- Always Supply
key = { it.hex }: Without a stable key, inserting a single message at index 0 forces Compose to recreate all visible rows. Withkey = { it.hex }, Compose can keep the other rows and animate the insertion. - Use
collectAsStateWithLifecycle(): Stops collecting while the screen is in the background. - 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:
| Credential | Header |
|---|---|
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
| Parameter | Type | Default | Description |
|---|---|---|---|
threshold | Int | 5 | Number of consecutive exceptions before the circuit trips from CLOSED to OPEN. |
timeout | Long | 15_000 | Cooldown 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
| State | Request Execution Behavior |
|---|---|
CLOSED | Normal execution. All requests execute directly. Failures increment an atomic counter; any success resets the failure count to 0. |
OPEN | Fail-fast mode. Calls to execute { ... } immediately throw AdukiException.CircuitOpen without executing the block or waking the radio modem. |
HALF_OPEN | Canary 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
| Property | Type | Access | Description |
|---|---|---|---|
apiKey | String | val | Static API key string (if initialized via API key). |
token | String | val | Initial JWT access token string (if initialized via token). |
options | Options | val | Immutable client network and endpoint options. |
session | Session | val | Reactive state repository for active tokens and resolved user identity. |
lifecycle | Lifecycle | val | Application foreground/background lifecycle coordinator. |
mail | Mail | val | Sub-service for message sending, flag updates, moves, and mailbox observations. |
contacts | Contacts | val | Sub-service for address book synchronization, contact retrieval, and substring search. |
sync | Sync | val | Sub-service for CONDSTORE/MODSEQ folder delta sync and outbox flushing. |
scheduling | Scheduling | val | Appointments, services, slots and booking (Scheduling). |
mailApi | Mail (net) | val | Typed 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 tohttps://mail.aduki.pro/v1).identity: Aduki ID base (defaults tohttps://id.aduki.pro/v1).
- Return Type:
Aduki— holding the access token, refresh token and session, withIdentityresolved. - Throws:
AdukiException.Unauthorizedon a wrong password or second factor;AdukiException.Networkotherwise.
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 ofapiKeyortokenis 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 fromsession.identity.valueif already resolved; otherwise queriesGET /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—trueif 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—trueif 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—trueif 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 sologout()can be retried (or callsession.clear()). Also stopswatchRights.
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"): Messagesuspend 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 = ""): Booleanfun 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")): Booleansuspend fun flush(): Intfun 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
| Exception | Raised when | Typical recovery |
|---|---|---|
Unauthorized | Aduki 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_grant | sign in again |
Auth | the 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 |
Api | An 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 retryAfter | branch on e.kind; wait e.retryAfter seconds if rate limited; refresh on 409 entity.conflict |
Denied | The person refused an authorization (OAuth/device authorization grant access_denied) | user opted out; abort flow |
Expired | A device code or authorization expired (expired_token or local deadline) | start a new authorization flow |
Network | transport 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 |
Protocol | a response that cannot be parsed, a missing mandatory field, or an OIDC check failing (state, issuer, id token) | report it; do not retry blindly |
CircuitOpen | a Circuit you use is open | wait for the cooldown |
Storage, Sync | declared for local-store and sync failures | the SDK does not raise them yet |
Sign-in messages start with Aduki ID’s error kind where it sent one, for
example auth.factor: ....
3. Recommended Handling Patterns
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})")
}
}