Skip to main content

Android account API

SoftchatAccount is the Android product API. It owns one imported local authority and one identity-derived Rust SQLite database. Product code calls its cohesive groups directly:

account.profile account.conversations
account.messages account.settings
account.relays account.sync
account.media account.push
account.operations account.recovery
account.events account.support

account.incoming remains the system-adapter boundary for Firebase. account.startManagedTransport(context) owns the standard relay path; account.transport remains the low-level boundary for custom socket hosts. Compose code does not assemble events, recipients, relay filters, encryption envelopes, SQL, or retry policy.

Android continues to own Keystore, lifecycle, Ktor HTTP, WorkManager, content URIs, private files, notifications, and UI. The SDK-owned relay worker uses platform trust and validated-network hints. Every product query is bounded, every mutation is durable before its external effect, and each Flow reruns a complete query after the Rust revision changes.

Open an account

Operation contractaccount.open

Import one local authority and open its identity-derived Rust SQLite account.

Inputs and exact bounds
  • exactly 32 secret bytes
  • existing absolute app-private directory
  • at most one open handle for the canonical file in the process
Output
One account capability, canonical public key, and deterministic database filename.
Trust and authentication
Rust derives and verifies the database binding; the mutable caller buffer is cleared.
Stable errors
invalid_secret_key, invalid_account_operation, account_already_open, invalid_persistence_result · handling and retry rules
Ownership, lifecycle, and execution rules
Ownership
Rust owns protocol semantics, Android account SQLite/use-case state, and optional native relay I/O; the host owns account lifecycle, HTTP/media/background effects, files, and UI.
Retry
Retry only host transport or explicitly retryable session work; validation and authentication failures are permanent for the same input.
Lifecycle
Language ownership is automatic; explicitly cancel or erase only at the operation-specific authority boundary.
Concurrency
Serialize mutable session capabilities; immutable values follow the language type system.
Cancellation
The host owns async cancellation; use cancel, shutdown, close, or erase only where exposed.
Exact public symbols
AccountRuntimeHandle::openAccountRuntimeHandle::public_keyAccountRuntimeHandle::close_account
let account = AccountRuntimeHandle::open(secret_bytes, private_directory)?;
let author = account.public_key()?;
let filename = account.database_filename();
account.close_account();

Rust derives the account ID and filename from the authority. The caller cannot pair an arbitrary account ID with a secret. open clears the supplied mutable buffer on success and failure; close blocks new calls, waits for active calls, closes SQLite, erases in-memory authority, and completes active observations without reporting closure as a query failure. Direct calls after close fail.

Conversations and drafts

Operation contractaccount.conversations

Create, query, observe, archive, pin, read, and draft complete conversations.

Inputs and exact bounds
  • 1 through 256 unique remote member keys
  • page limit from 1 through 100
  • stable tuple cursor
  • at most 256 valid UTF-16 draft spans and 32 attachment operation references
Output
Complete stable conversation pages with subject, last message, unread state, and portable draft.
Trust and authentication
Protocol fields derive only from authenticated truth; archive, pin, read, and draft fields are explicit account state.
Stable errors
invalid_public_key, invalid_account_operation, invalid_persistence_result · handling and retry rules
Ownership, lifecycle, and execution rules
Ownership
Rust owns protocol semantics, Android account SQLite/use-case state, and optional native relay I/O; the host owns account lifecycle, HTTP/media/background effects, files, and UI.
Retry
Retry only host transport or explicitly retryable session work; validation and authentication failures are permanent for the same input.
Lifecycle
Language ownership is automatic; explicitly cancel or erase only at the operation-specific authority boundary.
Concurrency
Serialize mutable session capabilities; immutable values follow the language type system.
Cancellation
The host owns async cancellation; use cancel, shutdown, close, or erase only where exposed.
Exact public symbols
AccountRuntimeHandle::get_or_create_conversationAccountRuntimeHandle::conversation_pageAccountRuntimeHandle::save_conversation_draft
let conversation =
account.get_or_create_conversation(vec![bob_public_key], now)?;
let page = account.conversation_page(false, None, 50)?;
account.set_conversation_pinned(conversation.id.clone(), true, now)?;

Conversation pages already contain subject, last message, archive/pin state, tuple read boundary, forced-unread state, unread count, draft, and stable ordering. Render the returned value; do not issue a per-row query or maintain a second conversation cache.

Messages and commands

Operation contractaccount.messages

Query effective messages and execute atomic send, reply, forward, subject, reaction, edit, deletion, and typing use cases.

Inputs and exact bounds
  • command ID from 1 through 128 bytes
  • stored conversation or message ID
  • optional exact portable draft snapshot for compare-and-clear consumption
  • 1 through 100 distinct locally authored deletion targets from one conversation
  • message text at most 64 KiB
  • page limit from 1 through 100
Output
Effective message pages and idempotent recipient-complete outgoing operations.
Trust and authentication
Rust loads and authorizes every referenced parent, member set, relation, and active relay from authenticated storage.
Stable errors
command_conflict, invalid_account_operation, invalid_delivery_state, invalid_persistence_result · handling and retry rules
Ownership, lifecycle, and execution rules
Ownership
Rust owns protocol semantics, Android account SQLite/use-case state, and optional native relay I/O; the host owns account lifecycle, HTTP/media/background effects, files, and UI.
Retry
Retain the command ID to resolve uncertain message-command outcomes. Follow the text-message contract for supported replay and conflict cases.
Lifecycle
Calls require this account's open lifecycle. Reopen and query durable state after restart; previous handles and Flow collectors do not survive it.
Concurrency
Rust serializes account database access. Separate calls do not form one transaction or a shared snapshot; preserve command identity and draft ownership.
Cancellation
Cancelling a caller or collector does not establish whether a native command committed. Consult its durable result before repeating external effects.
Exact public symbols
AccountRuntimeHandle::send_messageAccountRuntimeHandle::message_pageAccountRuntimeHandle::edit_messageAccountRuntimeHandle::delete_messageAccountRuntimeHandle::delete_messages
account.save_conversation_draft(
conversation_id.clone(),
Some(displayed_draft.clone()),
now,
)?;
let result = account.send_message(
command_id,
conversation_id,
MessageContentInput {
text: "hello".into(),
attachments: Vec::new(),
emoji_tags: Vec::new(),
},
String::new(),
Some(displayed_draft),
now,
)?;
let page = account.message_page(conversation_id, None, 50)?;

Rust loads and authorizes the referenced stored state. The text-message contract owns command replay, eligible edits, deletion, effective queries and history guarantees. Its composer-draft rule defines the compare-and-clear behavior of draft_to_consume / draftToConsume; the recipe above persists the exact snapshot before submitting it. Other operation families retain their separate contracts and maturity status.

Replaying a committed reaction or forward command returns its original operation, including after the parent or source message has been deleted. Reaction replay does not toggle the reaction again. New toggles query the local reaction state independently of the bounded display list. Removing a reaction deletes all active local copies of that value in one operation, up to the protocol's 256-target deletion bound. A larger duplicate group returns InvalidAccountOperation before any deletion or revision change.

Media gallery queries exclude deleted messages and filter for attachments before applying the page limit. The requested attachment count is a target from 1 to 100: the final message's attachment group is returned in full, so its message cursor cannot skip remaining attachments on the next page. A page contains at most 31 additional items.

Exact event details

Operation contractaccount.events

Load exact authenticated protocol truth and Rust-owned relationship state for detail and debugging UI.

Inputs and exact bounds
  • one canonical event ID
  • optional relationship kind
  • page limit from 1 through 200 and non-negative offset
Output
An authenticated event node, directly related nodes, or exact canonical signed-event JSON.
Trust and authentication
Rust verifies and stores outer events and rumors, applies authorization and edit/deletion precedence, and never asks Android to reinterpret tags.
Stable errors
invalid_event_id, invalid_account_operation, invalid_persistence_result · handling and retry rules
Ownership, lifecycle, and execution rules
Ownership
Rust owns protocol semantics, Android account SQLite/use-case state, and optional native relay I/O; the host owns account lifecycle, HTTP/media/background effects, files, and UI.
Retry
Retry only host transport or explicitly retryable session work; validation and authentication failures are permanent for the same input.
Lifecycle
Language ownership is automatic; explicitly cancel or erase only at the operation-specific authority boundary.
Concurrency
Serialize mutable session capabilities; immutable values follow the language type system.
Cancellation
The host owns async cancellation; use cancel, shutdown, close, or erase only where exposed.
Exact public symbols
AccountRuntimeHandle::list_event_nodesAccountRuntimeHandle::event_json
let node = account
.list_event_nodes(None, event_id, String::new(), String::new(), String::new(), None, 1, 0)?
.into_iter()
.next();
let related = account.list_event_nodes(
None,
String::new(),
String::new(),
parent_event_id,
String::new(),
None,
50,
0,
)?;

Use this group for event-detail, reply-thread, reaction, and developer-inspection screens. get addresses the authenticated logical event. canonicalJson addresses an exact retained signed outer event. The returned node already contains authorized edit/deletion precedence and live reply count; do not parse tags or reconstruct those rules in Android.

No standalone accepted benchmark exists for this detail query yet. It uses one bounded SQLite query and one coarse FFI result; add a performance key before publishing numeric latency or throughput here.

Profile and follows

Operation contractaccount.profile

Observe effective private metadata and update profile fields or the complete follow list without lossy replacement.

Inputs and exact bounds
  • one key or at most 512 exact profile keys
  • typed three-state profile patch
  • at most 10,000 unique follows
  • stable command ID
Output
Ordered authenticated/local effective profile and follow views plus idempotent outgoing operations.
Trust and authentication
Rust preserves authenticated and unknown profile fields not changed by the patch.
Stable errors
command_conflict, invalid_user_metadata, invalid_contact_list, invalid_persistence_result · handling and retry rules
Ownership, lifecycle, and execution rules
Ownership
Rust owns protocol semantics, Android account SQLite/use-case state, and optional native relay I/O; the host owns account lifecycle, HTTP/media/background effects, files, and UI.
Retry
Retry only host transport or explicitly retryable session work; validation and authentication failures are permanent for the same input.
Lifecycle
Language ownership is automatic; explicitly cancel or erase only at the operation-specific authority boundary.
Concurrency
Serialize mutable session capabilities; immutable values follow the language type system.
Cancellation
The host owns async cancellation; use cancel, shutdown, close, or erase only where exposed.
Exact public symbols
AccountRuntimeHandle::profileAccountRuntimeHandle::profilesAccountRuntimeHandle::update_profileAccountRuntimeHandle::followAccountRuntimeHandle::unfollowAccountRuntimeHandle::replace_follows
let current = account.profile(account.public_key()?)?;
let visible = account.profiles(visible_public_keys)?;
let updated = account.update_profile(command_id, patch_json, now)?;
account.follow(command_id, contact, now)?;
account.unfollow(another_command_id, public_key, now)?;

Use ProfilePatch; do not construct a complete kind-0 replacement. Rust preserves authenticated fields and unknown JSON that the editing screen did not change. Resolve the exact keys needed by visible conversations/messages with profiles/getMany; the result preserves input order and avoids arbitrary profile pages, per-row native calls, or a platform cache. follow and unfollow read, modify, author, and enqueue the private list while Rust holds one account lock, so concurrent UI actions cannot lose an update. Complete replacement remains available for imports. Every form is author-bound, idempotent, and includes the account copy required for cross-device recovery.

Profile and conversation search use current metadata, including decoded JSON string values. Percent signs, underscores, quotes and backslashes in the query are literal text. Replaced profile fields and conversation subjects do not remain searchable through their historical versions. Case-insensitive matching follows SQLite's ASCII rules; non-ASCII characters remain literal, without Unicode case folding or accent normalization.

The atomic follow workload is also measured with structured diagnostics disabled, which is the default account-open path:

Account settings

Operation contractaccount.settings

Read and merge versioned cross-device product settings through explicit unchanged, clear, and set states.

Inputs and exact bounds
  • stable command ID
  • patch JSON at most 64 KiB
  • validated HTTPS references and bounded typed fields
Output
Complete effective settings and a durable private app-data synchronization operation.
Trust and authentication
Rust validates known fields and preserves unknown fields from newer schema versions.
Stable errors
command_conflict, invalid_account_settings, invalid_persistence_result · handling and retry rules
Ownership, lifecycle, and execution rules
Ownership
Rust owns protocol semantics, Android account SQLite/use-case state, and optional native relay I/O; the host owns account lifecycle, HTTP/media/background effects, files, and UI.
Retry
Retry only host transport or explicitly retryable session work; validation and authentication failures are permanent for the same input.
Lifecycle
Language ownership is automatic; explicitly cancel or erase only at the operation-specific authority boundary.
Concurrency
Serialize mutable session capabilities; immutable values follow the language type system.
Cancellation
The host owns async cancellation; use cancel, shutdown, close, or erase only where exposed.
Exact public symbols
AccountRuntimeHandle::account_settingsAccountRuntimeHandle::apply_account_settings_patch
let settings = account.account_settings()?;
let updated =
account.apply_account_settings_patch(command_id, patch_json, now)?;

Every field is a three-state patch: unchanged, clear, or set. Rust validates URLs and bounds, preserves fields from newer schema versions, stores the effective settings, and queues the private cross-device app-data update in the same coarse operation. Preview, timestamp selection and commit hold one account database guard, so concurrent patches to independent fields cannot erase one another. Read-state updates use the same serialization boundary for their full cross-device snapshot; a read cursor must match its stored message timestamp. Settings commands are identified by the bounded canonical caller patch. Replaying a committed command returns current settings without merging the patch again, creating another outgoing operation, or advancing the account revision, including after reopening. Committed read/unread commands likewise replay before authoring another snapshot, preserving later read state even when its current size would prevent creation of a new update.

Relay catalog

Operation contractaccount.relays

Own the durable relay catalog, one active selection, discovery reconciliation, and failover order.

Inputs and exact bounds
  • normalized secure WebSocket URL
  • bounded authenticated DNS results
  • one selected effective relay
Output
Complete catalog state and deterministic transport candidates.
Trust and authentication
DNS authentication state and user ownership remain explicit; Android does not invent relay policy.
Stable errors
invalid_relay_catalog, invalid_persistence_result · handling and retry rules
Ownership, lifecycle, and execution rules
Ownership
Rust owns protocol semantics, Android account SQLite/use-case state, and optional native relay I/O; the host owns account lifecycle, HTTP/media/background effects, files, and UI.
Retry
Retry only host transport or explicitly retryable session work; validation and authentication failures are permanent for the same input.
Lifecycle
Language ownership is automatic; explicitly cancel or erase only at the operation-specific authority boundary.
Concurrency
Serialize mutable session capabilities; immutable values follow the language type system.
Cancellation
The host owns async cancellation; use cancel, shutdown, close, or erase only where exposed.
Exact public symbols
AccountRuntimeHandle::relay_catalogAccountRuntimeHandle::add_account_relayAccountRuntimeHandle::set_account_active_relay
let catalog = account.relay_catalog()?;
account.add_account_relay("wss://relay.example".into(), now)?;
account.set_account_active_relay("wss://relay.example".into(), now)?;

Rust owns validation, discovery reconciliation, active selection, retry delay, and failover ordering. Android may report DNS/DNSSEC and connectivity results, but it opens only the endpoint requested by account.transport.

Route an incoming account event

Operation contractaccount.route-incoming

Verify one signed NIP-59 outer wrapper and select the exact retained account before decryption or ingestion.

Inputs and exact bounds
  • one bounded signed event JSON
  • durable kind 1059 or ephemeral kind 21059
  • exactly one canonical recipient p tag
Output
Verified outer event ID, canonical recipient public key, and envelope kind.
Trust and authentication
The route authenticates only the outer wrapper; inner content remains untrusted until the selected account ingests and authenticates every layer.
Stable errors
invalid_event_json, invalid_event_signature, invalid_event_kind, invalid_event_tag, invalid_nip59_envelope · handling and retry rules
Ownership, lifecycle, and execution rules
Ownership
Rust owns protocol semantics, Android account SQLite/use-case state, and optional native relay I/O; the host owns account lifecycle, HTTP/media/background effects, files, and UI.
Retry
Retry only host transport or explicitly retryable session work; validation and authentication failures are permanent for the same input.
Lifecycle
Language ownership is automatic; explicitly cancel or erase only at the operation-specific authority boundary.
Concurrency
Serialize mutable session capabilities; immutable values follow the language type system.
Cancellation
The host owns async cancellation; use cancel, shutdown, close, or erase only where exposed.
Exact public symbols
route_nip59_envelopeNip59EnvelopeRoute
let route = softchat::route_nip59_envelope(&event_json)?;
let recipient = route.recipient();
let envelope_kind = route.kind();

// Select the recipient account, then pass the original JSON through that
// account's full authenticated ingestion path.

Routing verifies the signed outer event, supported wrapper kind, and exactly one canonical recipient tag. It does not decrypt or trust the seal or rumor. Keep the exact original JSON and pass it to the selected account; display only changes returned after that account commits the fully authenticated envelope. If relay ingestion already committed the same logical rumor through another valid wrapper, ingestJson still returns one authenticated logical change for the push input. Deduplicate notification presentation with its stable message or reaction ID, not with ingestion order. An inactive-account lease must not change the selected UI account or start a second relay socket.

Transport, ingestion, and synchronization

Operation contractaccount.transport

Run one Rust-owned relay, delivery, ingestion, Noise, and synchronization state machine through either the managed native socket or correlated host actions.

Inputs and exact bounds
  • validated WSS or pinned Noise relay
  • bounded platform reachability hints
  • run and action IDs from 1 through 128 bytes for custom hosts
  • at most 150 incoming events and 512 KiB per ingestion call
Output
A redacted managed snapshot or bounded correlated system actions, complete connection/sync state, and authenticated committed changes.
Trust and authentication
Socket bytes and push JSON are untrusted until Rust validates and commits them; writes are not delivery, and a racing terminal relay acknowledgement remains authoritative.
Stable errors
unsupported_system_action, invalid_relay_session, invalid_delivery_state, invalid_sync_state, invalid_persistence_result · handling and retry rules
Ownership, lifecycle, and execution rules
Ownership
Rust owns protocol semantics, Android account SQLite/use-case state, and optional native relay I/O; the host owns account lifecycle, HTTP/media/background effects, files, and UI.
Retry
Retry only host transport or explicitly retryable session work; validation and authentication failures are permanent for the same input.
Lifecycle
Language ownership is automatic; explicitly cancel or erase only at the operation-specific authority boundary.
Concurrency
Serialize mutable session capabilities; immutable values follow the language type system.
Cancellation
The host owns async cancellation; use cancel, shutdown, close, or erase only where exposed.
Exact public symbols
ManagedAccountTransportAccountRuntimeHandle::start_account_transportAccountRuntimeHandle::apply_account_transport_resultAccountRuntimeHandle::reset_account_sync
let account = Arc::new(SoftchatAccount::open(secret, private_directory)?);
let transport = ManagedAccountTransport::new(Arc::clone(&account))?;
let state = transport.snapshot();
let delivered = transport.flush(30_000)?;
transport.shutdown();

Enable the managed-transport feature. The low-level start_account_transport / apply_account_transport_result API remains available for custom socket policy.

The managed worker owns WSS/TLS/Noise, action correlation, timeouts, reconnect, and shutdown on one native thread. Product mutations wake it immediately and a bounded periodic revision check provides correctness without callbacks. Its state contains no relay URL, frame, event ID, or upstream error text. Treat stop() as the barrier before account close, reset, or deletion. If it throws, retain the exact account and retry shutdown; the facade does not mark a failed teardown complete.

For a custom socket, each low-level action carries a run ID, action ID, and generation; echo all three in the result. Receiving a frame is not persistence and writing a frame is not delivery. account.incoming.ingestJson authenticates and commits bounded push events before UI or notification use.

Durable media

Operation contractaccount.media

Persist media intent and dependencies before Android executes bounded file and HTTP effects.

Inputs and exact bounds
  • at most 32 staged source fingerprints per message
  • optional exact portable draft snapshot for compare-and-clear consumption
  • authenticated stored message and attachment URL for message download
  • at most 512 unique canonical 64-character lowercase hexadecimal operation IDs; the observer requires at least one
  • stored conversation ID whose current icon has digest or encryption integrity
  • stable optional recovery cursor and page limit from 1 through 100
  • lease duration from 1 through 3,600 seconds
Output
Recoverable media operations with exact authenticated message or conversation-icon correlation, exclusive leases, final authenticated metadata, and gallery views.
Trust and authentication
Decrypted output remains private and untrusted until final Rust authentication succeeds; mutable conversation-icon URLs without integrity metadata are rejected.
Stable errors
invalid_media_operation, invalid_attachment_metadata, attachment_decryption_failed, invalid_persistence_result · handling and retry rules
Ownership, lifecycle, and execution rules
Ownership
Rust owns protocol semantics, Android account SQLite/use-case state, and optional native relay I/O; the host owns account lifecycle, HTTP/media/background effects, files, and UI.
Retry
Retry only host transport or explicitly retryable session work; validation and authentication failures are permanent for the same input.
Lifecycle
Language ownership is automatic; explicitly cancel or erase only at the operation-specific authority boundary.
Concurrency
Serialize mutable session capabilities; immutable values follow the language type system.
Cancellation
The host owns async cancellation; use cancel, shutdown, close, or erase only where exposed.
Exact public symbols
AccountRuntimeHandle::prepare_media_messageAccountRuntimeHandle::cancel_pending_media_messageAccountRuntimeHandle::prepare_media_downloadAccountRuntimeHandle::prepare_conversation_icon_downloadAccountRuntimeHandle::media_operations_by_idsAccountRuntimeHandle::recoverable_media_operationsAccountRuntimeHandle::restart_media_downloadAccountRuntimeHandle::claim_media_operationAccountRuntimeHandle::complete_media_operation
account.save_conversation_draft(
conversation_id.clone(),
Some(displayed_draft.clone()),
now,
)?;
let pending = account.prepare_media_message(
command_id,
conversation_id,
content,
String::new(),
sources,
Some(displayed_draft),
now,
)?;
let operations = account.media_operations(50)?;
let exact = account.media_operations_by_ids(tracked_operation_ids)?;
let mut cursor = None;
loop {
let page = account.recoverable_media_operations(cursor, 100)?;
for _operation in &page.operations {
// Resume the matching platform effect by operation.id.
}
cursor = page.next_cursor;
if cursor.is_none() {
break;
}
}

Android privately stages bytes before committing an operation. WorkManager claims a durable lease, streams encryption/decryption, and reports only final validated metadata or a redacted failure category. Decrypted bytes remain private and untrusted until final authentication succeeds; verified cache eviction restarts the same logical download. Each download carries its exact authenticated sourceMessageId, so UI and cache state are never correlated by URL alone. cancelPendingMessage atomically retires an unpublished or permanently failed message and cancels every unfinished Rust operation. Android then cancels the returned platform executors and removes their private staging; it never rewrites the aggregate state itself. Conversation-icon downloads are bound to the exact authenticated subject event that defined the effective icon. A later subject that changes only text or emoji inherits the icon and keeps that operation valid; a later explicit icon invalidates it. Android keeps the fallback avatar until prepareConversationIconDownload completes and its private cache manifest verifies. A public icon requires the downloaded-object x SHA-256; an ox pre-transform hash alone does not authenticate downloaded bytes. Encrypted icons may instead rely on final AEAD authentication. An exact retry of a completion that already committed returns the persisted result even if a newer subject now selects another icon. By contrast, a stale terminal icon download cannot be restarted into recoverable work; request the current icon through prepareConversationIconDownload instead. Startup executors follow recoverablePage.nextCursor to exhaustion. The stable ID cursor covers every non-terminal operation and is intentionally separate from the bounded newest-first list used by UI, so more than 100 later operations cannot strand an older staged source. Android rejects a noncanonical cursor before UniFFI marshalling. Host staging and visible download progress use operationsById (or its observer) in unique batches of at most 512 canonical 64-character lowercase hexadecimal IDs. Android validates every fixed-size ID before duplicate work or FFI marshalling. Missing IDs are omitted and results preserve caller order; this exact query prevents terminal state from disappearing merely because it fell outside the newest 100 operations. The exact single-operation query applies the same validation, while observeOperation performs it once when the flow is constructed rather than re-marshalling malformed caller input after every account revision.

Durable public asset replacement

Operation contractaccount.asset-replacement

Persist a profile-picture, profile-banner, or conversation-icon replacement and its upload dependency before Android starts file or HTTP work.

Inputs and exact bounds
  • command ID from 1 through 256 bytes
  • private staged source fingerprint from 1 through 512 bytes
  • stored conversation ID for an icon replacement
  • validated public attachment metadata without an encryption descriptor
Output
One idempotent public upload operation and, after completion, one durable profile or conversation-subject operation retaining the latest unrelated fields.
Trust and authentication
The selected source stays app-private while staged, but the uploaded object and resulting URL are intentionally public; Rust rejects encryption metadata and publishes only after the matching lease completes.
Stable errors
command_conflict, invalid_account_operation, invalid_media_operation, invalid_attachment_metadata, invalid_user_metadata, invalid_persistence_result · handling and retry rules
Ownership, lifecycle, and execution rules
Ownership
Rust owns protocol semantics, Android account SQLite/use-case state, and optional native relay I/O; the host owns account lifecycle, HTTP/media/background effects, files, and UI.
Retry
Retry only host transport or explicitly retryable session work; validation and authentication failures are permanent for the same input.
Lifecycle
Language ownership is automatic; explicitly cancel or erase only at the operation-specific authority boundary.
Concurrency
Serialize mutable session capabilities; immutable values follow the language type system.
Cancellation
The host owns async cancellation; use cancel, shutdown, close, or erase only where exposed.
Exact public symbols
AccountRuntimeHandle::prepare_asset_replacementAccountRuntimeHandle::pending_asset_replacementAccountRuntimeHandle::recover_pending_asset_replacements
let pending = account.prepare_asset_replacement(
command_id.clone(),
AssetReplacementTarget::ProfilePicture,
String::new(),
staged_source_fingerprint,
now,
)?;
assert_eq!(
pending.operation.protection,
AccountMediaProtection::Public,
);

// The platform claims and uploads the exact operation without encryption.
let lease = account
.claim_media_operation(
pending.operation.id.clone(),
lease_id,
now,
300,
)?
.expect("operation is claimable");
let completion = account.complete_media_operation(
lease.operation.id,
lease.lease_id,
public_attachment_metadata,
plaintext_byte_count,
now,
)?;
assert!(completion.completed_operation.is_some());

// Safe after a crash between upload completion and dependent publication.
account.recover_pending_asset_replacements(50, now)?;

Profile pictures, banners, and conversation icons are intentionally public objects: other clients receive their URL and optional dimensions, not attachment decryption material. The Android worker therefore copies the privately staged source into its upload body without attachment encryption and must complete the lease with AttachmentMetadata.encryption == null. Ordinary message attachments continue to use MediaProtection.ENCRYPTED.

The SDK inserts the asset intent and public upload operation in one transaction. Reusing the same command ID with the same target and source returns the same operation; changing any of those inputs produces command_conflict. Completion atomically marks the upload complete and authors the dependent profile or subject operation. For a conversation icon, Rust reloads the latest subject and emoji tags at completion time, so an edit made while the upload is running is not overwritten.

Only one valid lease may complete an operation. Report temporary failures with failRetryable, permanent failures with failPermanent, and user cancellation with cancel(pending.operation.id). A cancelled or permanently failed upload propagates to the aggregate asset state. If the process dies after upload completion but before publication, recoverReadyAssetReplacements finishes the exact dependent command idempotently.

This use case has no accepted standalone benchmark yet. It performs one bounded SQLite transaction at preparation and one at completion around Android-owned file and HTTP work; no numeric result is copied here until it is present in the accepted performance data.

Delivery operations

Operation contractaccount.operations

Inspect, retry, and safely cancel recipient-complete durable outgoing operations.

Inputs and exact bounds
  • stable command ID
  • page limit from 1 through 100
Output
Aggregate state plus independent terminal state for every recipient copy.
Trust and authentication
Only correlated relay acknowledgements advance delivery; a socket write never does.
Stable errors
invalid_delivery_state, invalid_account_operation, invalid_persistence_result · handling and retry rules
Ownership, lifecycle, and execution rules
Ownership
Rust owns protocol semantics, Android account SQLite/use-case state, and optional native relay I/O; the host owns account lifecycle, HTTP/media/background effects, files, and UI.
Retry
Retry only host transport or explicitly retryable session work; validation and authentication failures are permanent for the same input.
Lifecycle
Language ownership is automatic; explicitly cancel or erase only at the operation-specific authority boundary.
Concurrency
Serialize mutable session capabilities; immutable values follow the language type system.
Cancellation
The host owns async cancellation; use cancel, shutdown, close, or erase only where exposed.
Exact public symbols
AccountRuntimeHandle::operationAccountRuntimeHandle::operation_pageAccountRuntimeHandle::retry_operation
let operation = account.operation(command_id.clone())?;
let page = account.operation_page(50)?;
let retried = account.retry_operation(command_id)?;

Operation state is recipient-complete. A socket write does not mark delivery; every recipient copy reaches a terminal relay acknowledgement independently. Retry requeues only terminally rejected copies, and cancel affects only copies that are still safe to cancel.

Push registration

Operation contractaccount.push

Create the released private FCM or APNs token registration profile and durable delivery intent.

Inputs and exact bounds
  • stable installation-derived command ID
  • bounded platform token
  • bounded non-secret installation ID
Output
An idempotent private outgoing operation.
Trust and authentication
The platform supplies token and installation identity; Rust owns wire profile, recipients, encryption, and delivery.
Stable errors
command_conflict, invalid_account_operation, invalid_persistence_result · handling and retry rules
Ownership, lifecycle, and execution rules
Ownership
Rust owns protocol semantics, Android account SQLite/use-case state, and optional native relay I/O; the host owns account lifecycle, HTTP/media/background effects, files, and UI.
Retry
Retry only host transport or explicitly retryable session work; validation and authentication failures are permanent for the same input.
Lifecycle
Language ownership is automatic; explicitly cancel or erase only at the operation-specific authority boundary.
Concurrency
Serialize mutable session capabilities; immutable values follow the language type system.
Cancellation
The host owns async cancellation; use cancel, shutdown, close, or erase only where exposed.
Exact public symbols
AccountRuntimeHandle::register_push_token
let registration = account.register_push_token(
command_id,
PushPlatform::Fcm,
token,
installation_id,
now,
)?;

The installation store is Android-owned. Rust validates the token and creates the released private registration profile, recipient copies, and delivery intent. Reuse the stable installation-derived command ID for the same token.

Local contacts and resources

Operation contractaccount.contacts-resources

Store bounded account-local address-book labels, stickers, and link-preview cache values in the one Rust database.

Inputs and exact bounds
  • canonical public key
  • at most 200 unique sticker records
  • validated HTTP(S) URLs and bounded preview fields
Output
Observed local contacts and validated resource cache values.
Trust and authentication
These values are local or HTTP-derived and never impersonate authenticated protocol truth.
Stable errors
invalid_public_key, invalid_account_operation, invalid_persistence_result · handling and retry rules
Ownership, lifecycle, and execution rules
Ownership
Rust owns protocol semantics, Android account SQLite/use-case state, and optional native relay I/O; the host owns account lifecycle, HTTP/media/background effects, files, and UI.
Retry
Retry only host transport or explicitly retryable session work; validation and authentication failures are permanent for the same input.
Lifecycle
Language ownership is automatic; explicitly cancel or erase only at the operation-specific authority boundary.
Concurrency
Serialize mutable session capabilities; immutable values follow the language type system.
Cancellation
The host owns async cancellation; use cancel, shutdown, close, or erase only where exposed.
Exact public symbols
AccountRuntimeHandle::put_local_contactAccountRuntimeHandle::replace_stickersAccountRuntimeHandle::put_link_preview
account.put_local_contact(contact)?;
account.replace_stickers(stickers)?;
account.put_link_preview(preview)?;

Contacts are local labels, never authenticated identity. Stickers and link previews are bounded untrusted cache values. Keeping them in the one Rust database avoids a second protocol-adjacent Room or key-value store without changing their trust level.

Import a retired signed outbox

Operation contractaccount.recovery

Recover a bounded batch of already signed pending events from retired platform storage without importing its schema or projections.

Inputs and exact bounds
  • stable command ID
  • one non-empty JSON array at most 512 KiB
  • at most 512 complete signed events
Output
One idempotent durable outgoing operation using the account's active relay.
Trust and authentication
The host treats the old row as opaque; Rust verifies every event ID and signature and commits the complete batch or nothing.
Stable errors
command_conflict, invalid_account_operation, invalid_event_json, invalid_event_id, invalid_event_signature, invalid_event_kind, invalid_event_timestamp, invalid_event_tag, invalid_public_key, invalid_relay_catalog, invalid_persistence_result · handling and retry rules
Ownership, lifecycle, and execution rules
Ownership
Rust owns protocol semantics, Android account SQLite/use-case state, and optional native relay I/O; the host owns account lifecycle, HTTP/media/background effects, files, and UI.
Retry
Retry only host transport or explicitly retryable session work; validation and authentication failures are permanent for the same input.
Lifecycle
Language ownership is automatic; explicitly cancel or erase only at the operation-specific authority boundary.
Concurrency
Serialize mutable session capabilities; immutable values follow the language type system.
Cancellation
The host owns async cancellation; use cancel, shutdown, close, or erase only where exposed.
Exact public symbols
AccountRuntimeHandle::recover_signed_outbox_batch
let recovered = account.recover_signed_outbox_batch(
stable_legacy_row_id,
opaque_event_array_json,
now,
)?;

This is a narrow installed-upgrade boundary, not a general persistence adapter. Android may read an old outbox row from its retired database in read-only mode, apply host-side aggregate bounds, and pass the JSON array without decoding protocol fields. Rust bounds the complete input, verifies every event ID and signature, rejects duplicate events, selects the current active relay, and commits the whole outgoing operation atomically.

Keep the CommandId stable for the old row. Repeating the same row returns the existing operation; reusing that ID for different verified events returns command_conflict. Validation failures are permanent for that row. A database or active-relay failure is retryable only after its underlying condition changes. Retain the old database until the application has reported the per-row result and the product's rollback window has closed.

No accepted standalone benchmark exists for this one-shot migration method. It performs bounded signature verification followed by one SQLite transaction; numeric results will appear here only after a key is added to the accepted performance data.

Diagnostics and recovery

Operation contractaccount.support

Observe the durable revision and bounded redacted account health for explicit support and recovery flows.

Inputs and exact bounds
  • no protocol payload input
Output
Schema, revision, active relay, bounded counts, and database allocation.
Trust and authentication
Diagnostics omit keys, plaintext, ciphertext, raw events, attachment authority, and cryptographic detail.
Stable errors
account_closed, invalid_persistence_result · handling and retry rules
Ownership, lifecycle, and execution rules
Ownership
Rust owns protocol semantics, Android account SQLite/use-case state, and optional native relay I/O; the host owns account lifecycle, HTTP/media/background effects, files, and UI.
Retry
Retry only host transport or explicitly retryable session work; validation and authentication failures are permanent for the same input.
Lifecycle
Language ownership is automatic; explicitly cancel or erase only at the operation-specific authority boundary.
Concurrency
Serialize mutable session capabilities; immutable values follow the language type system.
Cancellation
The host owns async cancellation; use cancel, shutdown, close, or erase only where exposed.
Exact public symbols
AccountRuntimeHandle::account_diagnostics
let diagnostics = account.account_diagnostics()?;
let revision = diagnostics.revision;

Diagnostics contain bounded counts, schema/revision, active relay, and database allocation only. They never include plaintext, ciphertext, keys, event JSON, or attachment authority.

Structured account logging

Operation contractaccount.logging

Feed existing Android support and log-viewer UI with optional structured SDK state without synchronous callbacks from Rust.

Inputs and exact bounds
  • optional sink at account open
  • error, warning, information, or debug threshold
  • native drain batches of at most 64 events
Output
Asynchronous English semantic events with source emission times, bounded classifications and counters, five-second repeat summaries, a 256-record ring, and explicit overflow.
Trust and authentication
Only SDK-owned English summaries, finite subjects/directions/sources/reasons, operation and error codes, times, revisions, and counters are emitted; IDs, URLs, frames, paths, messages, keys, caller text, and ciphertext are forbidden.
Stable errors
account_closed, invalid_account_operation · handling and retry rules
Ownership, lifecycle, and execution rules
Ownership
Rust owns protocol semantics, Android account SQLite/use-case state, and optional native relay I/O; the host owns account lifecycle, HTTP/media/background effects, files, and UI.
Retry
Retry only host transport or explicitly retryable session work; validation and authentication failures are permanent for the same input.
Lifecycle
Language ownership is automatic; explicitly cancel or erase only at the operation-specific authority boundary.
Concurrency
Serialize mutable session capabilities; immutable values follow the language type system.
Cancellation
The host owns async cancellation; use cancel, shutdown, close, or erase only where exposed.
Exact public symbols
AccountRuntimeHandle::set_log_levelAccountRuntimeHandle::drain_log_events
account.set_log_level(AccountLogLevel::Info)?;

// Pull from a host-owned executor, never from a Rust operation callback.
let batch = account.drain_log_events(64)?;
for event in batch.events {
support_log.append(event.category, event.summary, event.operation, event.error_code);
}

The sink is optional. Without it there is no logging coroutine, timer, native crossing, or string formatting. With a sink, the facade polls once per second, even when all work comes from the native relay worker. Each tick makes at most four pulls of at most 64 records. The native ring retains at most 256 records; droppedCount reports overwritten records. A slow or throwing sink cannot fail an account operation. Closure flushes pending summaries and delivers the final bounded batches exactly once.

Messages, summaries, operation codes, reasons, enum labels, and fallback text are always English. Preserve SDK:<CATEGORY> tags in the existing viewer: LIFECYCLE, STORAGE, MESSAGING, PROFILE, RELAY, SYNCHRONIZATION, MEDIA, and RECOVERY. Applications format the fields rather than maintaining a second dictionary of operation meanings.

Reading an event

  • summary is the SDK's static English description. operation is the stable diagnostic code. Neither is supplied by a caller or relay.
  • direction is TX, RX, or LOCAL when known. A relay acknowledgement advances a TX publication even though its wire frame arrived over RX.
  • subject is the authenticated semantic family below. relation optionally identifies a reply or forward; attachment count is a numeric attribute.
  • source describes a proven boundary: LOCAL_COMMAND, RELAY_LIVE, RELAY_SYNC, INCOMING_API, or RECOVERY. The incoming JSON API cannot prove that Android received a push notification.
  • occurredAtMillis and lastOccurredAtMillis are local SDK emission times, never Nostr event timestamps. Preserve native sequence order through the bridge even when the wall clock changes or delivery is delayed.
  • Named counters distinguish logical events, durable outer records, known copies, duplicates, ephemeral observations, quarantine, delivery intents, sync progress, attachments, attempts, frames, and recovered items. There are at most eight distinct counter keys per record. Event/frame counters sum in a summary; delivery/sync/attempt snapshots retain the last observed values. A delivery snapshot describes the last affected operation, without exposing its identity. Legacy itemCount remains a generic item count.
  • errorCode is a stable SDK error; reasonCode is a separate static reason. Unknown external error categories become external_failure. Optional durationMicros remains compatible; it is not a network latency claim and a coalesced duration, when supplied, belongs to the last occurrence.
SubjectSupported diagnostic boundary
CHAT_MESSAGENew committed TX and authenticated RX, including replies, forwards, and attachments
SUBJECTConversation subject/icon history, TX and RX
REACTIONReaction history, TX and RX
EDITEdit history, TX and RX; storage does not prove effective UI replacement
DELETIONDeletion-request history, TX and RX; distinct from account clearing
TYPINGActual queued/observed ephemeral events, DEBUG; never durable message history
USER_METADATAPrivate profile update/history, TX and RX
FOLLOW_LISTPrivate contact replacement/history, TX and RX
READ_STATEOwn-account read-state synchronization, TX and RX; not recipient read receipts
APP_SETTINGSAccount settings synchronization, TX and RX
APPLICATION_DATAAuthenticated generic NIP-78 input; no arbitrary context names
PUSH_REGISTRATIONKnown local registration writer; acceptance does not prove push delivery
GENERIC_REPOSTRX compatibility retention at DEBUG; distinct from forwarding
UNKNOWNAuthenticated unsupported history at DEBUG and generic low-level publication

Direct signed kind-0 retention remains UNKNOWN in this account runtime. A standalone metadata reader does not widen account projection support.

State and volume contracts

message.queued, profile.update, profile.follows.replace, account_data.queued, and push.registration.queued describe a newly committed outgoing operation. Follow/unfollow use the common replacement milestone; identical command replay emits only DEBUG message.command.replayed. message.received, profile.received, and account_data.received require a new logical event and the final SQLite commit. Local sender copies do not produce a second RX event. New wrappers for an existing logical event and exact duplicates produce separate DEBUG counts, including notification replays. Typing contributes only to EPHEMERAL_EVENTS, never durable history counts.

delivery.socket_written is DEBUG. delivery.partial and delivery.accepted describe durable relay acknowledgements. Counters include sender-copy delivery intents and are not counts of recipients or messages. Rejected and retryable outcomes have their own severity and operation codes. Routine ephemeral publication acknowledgements remain DEBUG. Explicit retry and cancellation log only changed intents; cancellation never recalls accepted work. Diagnostic-only aggregate queries are indexed and cannot fail the operation.

Connected idle, successful no-work wake/poll, routine reads, and receive deadlines are silent at every level. Actual connection changes, synchronization start/checkpoint completion, and media transitions are INFO. Repeated DEBUG frames, typing, and replay details are immediate once, then summarized at most once per five seconds per finite class. Repeated input errors use the same bounded policy. Historical ingestion uses sync.progress summaries by semantic family rather than one INFO row per historical message.

Media emits prepared, lease-acquired, completion-committed, retry, failure, and cancellation milestones for actual writes. Dependency creation and readiness are separate milestones. A later publication failure cannot erase the earlier media.upload.completed record. SDK completion does not prove Android has published a verified cache file: Android owns HTTP status, stream/file effects, private completion manifests, and cache publication logs. The SDK logs NIP-98 authorization results without retaining any request data.

storage.local.changed reports actual draft, archive/pin, contact, sticker, and link-preview changes at DEBUG. Unchanged writes are silent. Optional checks run only with DEBUG collection and cannot change storage outcomes. recovery.started, recovery.completed, and recovery.failed describe nonempty pending-publication recovery. Completed items remain observable if a later item fails. Expired media leases and imported signed outbox batches report committed recovery counts. Empty scans and idempotent imports do not claim new recovery.

Rust tests cover the finite subject catalog, outer transaction rollback, duplicate/known-copy counts, partial and duplicate acknowledgements, typing at INFO, failure after media completion, recovery, redaction, quiet idle, and bounded summaries. Kotlin tests cover field mapping, native-only delivery, four-pull ticks, sink failures, and terminal handoff. Android tests own HTTP/file outcomes and formatting rather than repeating protocol validation.

Logs never contain text, emojis, keys, IDs, URLs, paths, filenames, hashes, tokens, headers, frames, ciphertext, or arbitrary exception/relay text. This is a bounded session support stream, not durable audit history or a cross-restart per-message trace registry. Keep DEBUG behind developer mode and non-error Logcat forwarding behind the application's existing toggle.

The accepted performance card below records the earlier follow benchmark. After warmup it repeats a fixed idempotent command; it measures facade/replay cost with an INFO sink, not the cost of encrypting and publishing a new event. The supplementary account-diagnostics comparison is documented in the Android performance audit.

Android ownership checklist

  • Keep the account secret only in an OS-protected credential store.
  • Keep one interactive account and reference-counted short-lived leases for exact inactive-account system work.
  • Call product groups directly; do not add a repository or converter layer.
  • Keep installed-upgrade readers narrow, read-only, bounded, and temporary; pass opaque signed outbox batches to account.recovery.
  • Start the managed transport from account lifecycle; execute only incoming, media leases, push registration, and any explicitly custom low-level transport from Android system adapters.
  • Feed optional structured diagnostics into the existing bounded viewer; never synchronously print raw relay or protocol values from the SDK callback.
  • Use bounded Flow queries in Compose and retain CommandId across a retry.
  • Keep content URIs, WorkManager IDs, socket handles, HTTP bodies, and cache paths out of portable SDK state; the managed transport exposes only redacted state.
  • Close the account before switch or deletion, and delete Android-owned media work/cache with the account.

The exact symbols are in the Android reference. The Rust SQLite and transaction rationale records the database alternatives and invariants.