Skip to content

Application lifecycle

What an application built on SecureMessageClient has to call, and when. Every call is explicit: the library has no background scheduler, no timer and no implicit maintenance. The compiled, runnable version of the minimal path is samples/jvm-e2e (run by ./gradlew verifyPublication).

Each item is marked:

  • Required: needed for correct messaging.
  • Feature: needed only if the application offers that feature.
  • Policy: optional maintenance; the application decides when and how often.

Setup

When Call Kind Notes
Every launch construct SecureMessageClient(localAddress, storage, KodiumProtocolEngine(), transport, preKeyConfiguration, clock) Required Storage: SqlDelightClientStorage.open(driver, keyProvider) in production (see operating-the-server.md); InMemoryClientStorage only for tests and examples. Transport: KtorSecureMessageTransport(baseUrl) or your own SecureMessageTransport.
Every launch initialize() Required Creates the identity, the device authentication key and prekeys on first launch; afterwards tops up one-time prekeys, rotates the signed prekey by age and deletes expired ones. Local only, safe to repeat. Fails closed (InconsistentStorage) if an initialized storage lost its device authentication key: see recovery below.
First launch (then harmless) registerDevice() Required Registers the device authentication public key; the server's host application must authorize a new device (S1, DeviceRegistrationNotAuthorized otherwise). A different key for the address is a DeviceRegistrationConflict, never a reset.
First launch and after every initialize() that changed prekeys publishPreKeys() Required Uploads identity key, signed prekey and one-time prekeys. Idempotent; call it again after a failure. Each first contact consumes one server-side one-time prekey; when none is left, X3DH runs without one. Call initialize() + publishPreKeys() regularly (policy) to replenish them.

Operations before initialize() throw SecureMessageClientException.NotInitialized.

Sending

Call Kind Notes
send(recipient, plaintext) Required Creates a session on first contact (fetches the recipient's bundle), stores the message as pending outbound with the new ratchet state in one transaction, then hands it to the transport. Returns SentMessage (the LogicalMessageId). Throws MessageNotSent if the hand-off failed; the message stays pending.
retryPendingMessages(recipient) Required (when to call is policy) Re-encrypts pending messages for that recipient in order and resends them. Needed after a failed hand-off, a lost envelope, a lost ACK or a session collision. The library never retries on its own; choose the trigger (app start, reconnect, timer, user action).
pendingMessages(afterSequence, limit, recipient) / pendingMessageCount(recipient) Policy Cursor pagination (max 100 per page) over messages not yet acknowledged.
abandonPendingMessage(message) Feature The application gives up on one message: removes it locally, sends nothing. It is not a recall: an already delivered envelope can still arrive and its late ACK is ignored.

A pending message ends only by the recipient's ACK or an explicit abandon; transport success is not delivery.

Receiving

Call Kind Notes
receive() Required Drains this device's mailbox (signed request). Pass the envelopes to decrypt one at a time, in the returned order.
decrypt(envelope) Required Returns a ReceiveResult: Delivery (new application message, stored as pending inbound, not yet acknowledged), Acknowledgement (one of your messages was finalized by the recipient), AlreadyCommitted / AlreadyDiscarded (a retry; the ACK is sent again). A second decrypt of the same message before the commit returns the same Delivery.
commitReceivedMessage(message) Required Call after the application applied the message durably (for example, stored it in its own database). Moves it to processed in one transaction and sends the ACK. The ACK means "finalized by the receiving application".
discardReceivedMessage(message, reason) Feature Finalizes a message the application will never apply (MessageDiscardReason, a closed enum, never sent to the sender). Also sends the ACK.
pendingReceivedMessages(afterSequence, limit, sender) / pendingReceivedMessageCount(sender) Required after a restart Messages decrypted but not yet committed or discarded, for example because the app crashed in between. Process them like a new Delivery. Pending inbound messages never expire.
pruneProcessedMessages(ProcessedInboundRetentionPolicy(maxAge)) Policy Removes old processed-message tombstones (committed and discarded). A pruned ID is no longer recognized as a duplicate, so choose maxAge longer than any sender could still retry. No default, never automatic.

Delivery is at least once, not exactly once. Apply messages idempotently, keyed by sender and LogicalMessageId, and commit only after the application side effect is durable.

Identity trust

Call Kind Notes
(implicit) Required The first successful contact pins the peer's identity key (TOFU).
SecureMessageClientException.IdentityChanged Required Thrown when a peer presents a different identity key. Nothing changes. The application must show this to the user.
acceptRemoteIdentityChange(exception.change) Feature Replaces the pin with exactly the presented key, only after the user decided to. Never accept automatically. Pending outbound messages survive and can be retried.
safetyNumber(remote), compareSafetyNumber(remote, scanned) Feature Manual verification (display 60 digits or a QR payload). Comparing changes nothing.
markRemoteIdentityVerified(safetyNumber) / markRemoteIdentityUnverified(remote), remoteIdentityTrust(remote) Feature Local verification state per pin. A new or replaced pin is always unverified.

Device authentication (server credentials)

Call Kind Notes
deviceAuthenticationHealth(policy) Policy Read-only summary: Healthy, RotationDue, RotationPending, RecoveryPending, ActiveKeyMissing, Unregistered, Inconsistent. Use it to decide which of the calls below to offer.
rotateDeviceAuthenticationKeyIfNeeded(policy) Policy Routine rotation when the key is older than DeviceAuthenticationRotationPolicy.maxKeyAge. No default policy; never automatic. Lower-level steps: deviceAuthenticationRotationStatus, evaluateDeviceAuthenticationRotation, rotateDeviceAuthenticationKey, resolveDeviceAuthenticationRotation, cancelDeviceAuthenticationRotation.
prepareDeviceAuthenticationRecovery(authorizer) → transfer → authorizeDeviceRecovery(request) on the other device → transfer → completeDeviceAuthenticationRecovery(authorization) Feature Recovery of a lost device authentication key authorized by another registered device of the same user. Transfer the request and authorization with DeviceRecoveryCodec (QR code, etc.). resolveDeviceAuthenticationRecovery / cancelDeviceAuthenticationRecovery after a lost response or to give up. Recovers server authentication only, never the messaging identity.

Offline recovery key (last-device recovery)

Call Kind Notes
createLastDeviceRecoveryKey() → back up offline (LastDeviceRecoveryKey.encode()) → registerLastDeviceRecoveryKey(key) Feature The only way for a user's last device to recover its server credentials. The client never stores the key; the application must have the user keep it offline.
recoverLastDevice(key) (or prepareLastDeviceRecovery + completeLastDeviceRecovery), resolveLastDeviceRecovery, cancelLastDeviceRecovery Feature Replaces the lost device authentication key using the offline key.
lastDeviceRecoveryKeyStatus(), rotateLastDeviceRecoveryKey(current, new), revokeLastDeviceRecoveryKey(current) Feature Key lifecycle; both need a registered device and the current offline key. Back up the new key before rotating.
requestLastDeviceRecoveryKeyReset(), lastDeviceRecoveryKeyResetStatus(), completeLastDeviceRecoveryKeyReset(newKey), cancelLastDeviceRecoveryKeyReset(reset) Feature Delayed replacement of a lost offline key; only possible if the server host configured a RecoveryKeyResetPolicy. The holder of the current offline key can see and veto a reset without any device: lastDeviceRecoveryKeyResetStatusByRecoveryKey(key), cancelLastDeviceRecoveryKeyResetByRecoveryKey(key, reset).
recoveryKeyResetAwareness() Policy Read-only: is a reset pending or eligible? Poll it at a time the application chooses and show it to the user. A reset that nobody cancels replaces the key after the delay.

Advanced

rotateSignedPreKey() rotates the signed prekey immediately (initialize() does it by age). currentPreKeyBundle(), publicOneTimePreKeys() and remoteIdentityKey(remote) expose public key material for diagnostics and custom transports. Declarations marked @InternalKSecureMessageApi are for KSecureMessage's own modules; applications do not need them.

Shutdown and restart

Nothing needs to be flushed: every state change is committed in a storage transaction before a call returns. On shutdown, stop calling the client and close the storage driver the application owns. After a restart, call initialize(), then process pendingReceivedMessages and (by policy) retryPendingMessages.

Not provided

No background retry, polling, push notifications, automatic key rotation, automatic pruning, sealed sender, groups, attachments or read receipts.