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
account.openImport 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
- Rust
- Android
AccountRuntimeHandle::openAccountRuntimeHandle::public_keyAccountRuntimeHandle::close_accountSoftchatAccount.openSoftchatAccount.publicKeySoftchatAccount.close- Rust
- Android
let account = AccountRuntimeHandle::open(secret_bytes, private_directory)?;
let author = account.public_key()?;
let filename = account.database_filename();
account.close_account();
val account = credentialStore.withSecret(reference) { temporarySecret ->
SoftchatAccount.open(
secretKey = temporarySecret,
databaseDirectory = context.noBackupFilesDir,
)
}
val author: PublicKey = account.publicKey
val filename: String = account.databaseFilename
// Close before account switch or deletion.
account.close()
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.
- Rust
- Android
Conversations and drafts
account.conversationsCreate, 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
- Rust
- Android
AccountRuntimeHandle::get_or_create_conversationAccountRuntimeHandle::conversation_pageAccountRuntimeHandle::save_conversation_draftAccountConversations.getOrCreateAccountConversations.observeAccountConversations.saveDraft- Rust
- Android
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)?;
val conversation = account.conversations.getOrCreate(listOf(bobPublicKey))
val page: Flow<ConversationPage> =
account.conversations.observe(archived = false, limit = 50)
account.conversations.setPinned(conversation.id, true)
account.conversations.setArchived(conversation.id, false)
account.conversations.markUnread(conversation.id)
account.conversations.saveDraft(
conversation.id,
ConversationDraft(
text = composerText,
replyTo = repliedMessageId,
),
)
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.
- Rust
- Android
Messages and commands
account.messagesQuery 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
- Rust
- Android
AccountRuntimeHandle::send_messageAccountRuntimeHandle::message_pageAccountRuntimeHandle::edit_messageAccountRuntimeHandle::delete_messageAccountRuntimeHandle::delete_messagesAccountMessages.sendAccountMessages.observeAccountMessages.editAccountMessages.deleteAccountMessages.deleteMany- Rust
- Android
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)?;
val commandId = CommandId.random() // retain this value while retrying
val displayedDraft = ConversationDraft(
text = "hello",
replyTo = repliedMessageId,
)
account.conversations.saveDraft(conversation.id, displayedDraft)
val sent = account.messages.send(
commandId = commandId,
conversationId = conversation.id,
content = MessageContent(text = "hello"),
replyTo = repliedMessageId,
draftToConsume = displayedDraft,
)
val messages = account.messages.observe(conversation.id, limit = 50)
account.messages.toggleReaction(
CommandId.random(),
message.id,
Reaction("👍"),
)
account.messages.edit(
CommandId.random(),
message.id,
MessageContent(text = "replacement text"),
)
account.messages.forward(CommandId.random(), message.id, destination.id)
account.messages.delete(CommandId.random(), message.id)
val retainedBatchCommand = CommandId.random() // reuse this exact value after a transient failure
account.messages.deleteMany(
commandId = retainedBatchCommand,
messageIds = selectedMessages.map(Message::id),
)
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.
- Rust
- Android
Exact event details
account.eventsLoad 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
- Rust
- Android
AccountRuntimeHandle::list_event_nodesAccountRuntimeHandle::event_jsonAccountEvents.getAccountEvents.observeAccountEvents.relatedToAccountEvents.canonicalJson- Rust
- Android
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,
)?;
val eventId = NostrEventId.parse(savedEventId)
val detail: AccountEventNode? = account.events.get(eventId)
val reactions: List<AccountEventNode> =
account.events.relatedTo(
eventId = eventId,
kind = ProjectionKind.REACTION,
)
val exactOuterJson = detail?.let {
account.events.canonicalJson(it.projection.outerEvent.id)
}
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
account.profileObserve 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
- Rust
- Android
AccountRuntimeHandle::profileAccountRuntimeHandle::profilesAccountRuntimeHandle::update_profileAccountRuntimeHandle::followAccountRuntimeHandle::unfollowAccountRuntimeHandle::replace_followsAccountProfiles.observeAccountProfiles.observeManyAccountProfiles.updateAccountProfiles.followAccountProfiles.unfollowAccountProfiles.replaceFollows- Rust
- Android
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)?;
val profile = account.profile.observe()
val visibleProfiles = account.profile.observeMany(visiblePublicKeys)
val mention = "nostr:${profile.value.publicKey.encodeNpub()}"
account.profile.update(
CommandId.random(),
ProfilePatch(
displayName = SettingPatch.Set("Alice"),
about = SettingPatch.Clear,
),
)
account.profile.follow(CommandId.random(), Follow(newPublicKey))
account.profile.unfollow(CommandId.random(), removedPublicKey)
// Use only when an import or editor intentionally replaces the whole list.
account.profile.replaceFollows(CommandId.random(), importedFollows)
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.
- Rust
- Android
The atomic follow workload is also measured with structured diagnostics disabled, which is the default account-open path:
- Rust
- Android
Account settings
account.settingsRead 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
- Rust
- Android
AccountRuntimeHandle::account_settingsAccountRuntimeHandle::apply_account_settings_patchAccountSettingsService.observeAccountSettingsService.apply- Rust
- Android
let settings = account.account_settings()?;
let updated =
account.apply_account_settings_patch(command_id, patch_json, now)?;
val settings: Flow<AccountSettings> = account.settings.observe()
account.settings.apply(
CommandId.random(),
AccountSettingsPatch(
notificationContentPrivacy =
SettingPatch.Set(NotificationContentPrivacy.SENDER),
imageUploadQuality = SettingPatch.Set(UploadQuality.HIGH),
typingIndicatorsEnabled = SettingPatch.Set(false),
),
)
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
account.relaysOwn 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
- Rust
- Android
AccountRuntimeHandle::relay_catalogAccountRuntimeHandle::add_account_relayAccountRuntimeHandle::set_account_active_relayAccountRelays.observeAccountRelays.addAccountRelays.setActive- Rust
- Android
let catalog = account.relay_catalog()?;
account.add_account_relay("wss://relay.example".into(), now)?;
account.set_account_active_relay("wss://relay.example".into(), now)?;
val relays: Flow<List<RelayCatalogEntry>> = account.relays.observe()
account.relays.add("wss://relay.example")
account.relays.setActive("wss://relay.example")
account.relays.exclude("wss://old-relay.example")
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
account.route-incomingVerify 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
- Rust
- Android
route_nip59_envelopeNip59EnvelopeRouteSoftchat.routeIncomingEventIncomingRoute- Rust
- Android
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.
val route = Softchat.routeIncomingEvent(eventJson)
val reference = AccountReference(route.recipient.hex)
accountCoordinator.withAccount(reference) { account ->
val committed = account.incoming.ingestJson(listOf(eventJson))
renderNotifications(committed.changes)
}
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
account.transportRun 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
- Rust
- Android
ManagedAccountTransportAccountRuntimeHandle::start_account_transportAccountRuntimeHandle::apply_account_transport_resultAccountRuntimeHandle::reset_account_syncSoftchatAccount.startManagedTransportManagedAccountTransport.stateManagedAccountTransport.flushManagedAccountTransport.forceResyncAccountTransport.startAccountTransport.apply- Rust
- Android
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.
val transport = account.startManagedTransport(context)
transport.state.collect { state ->
renderConnection(state.connectionState)
renderSync(state.synchronization)
}
val delivered = transport.flush(timeoutMillis = 30_000)
// Keep authenticated history; rebuild synchronization checkpoints.
transport.forceResync()
// Stop before switching accounts; account.close() is the fallback.
transport.stop()
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
account.mediaPersist 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
- Rust
- Android
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_operationAccountMedia.prepareMessageAccountMedia.cancelPendingMessageAccountMedia.prepareDownloadAccountMedia.prepareConversationIconDownloadAccountMedia.operationsByIdAccountMedia.observeOperationsByIdAccountMedia.recoverablePageAccountMedia.restartDownloadAccountMedia.claimAccountMedia.complete- Rust
- Android
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;
}
}
val staged = androidMedia.stage(selectedUris)
val displayedDraft = ConversationDraft(
text = caption,
replyTo = repliedMessageId,
)
account.conversations.saveDraft(conversation.id, displayedDraft)
val pending = account.media.prepareMessage(
commandId = CommandId.random(),
conversationId = conversation.id,
content = MessageContent(text = caption),
sources = staged.map { MediaPreparation(it.sourceFingerprint) },
replyTo = repliedMessageId,
draftToConsume = displayedDraft,
)
androidMedia.bindAndSchedule(account, pending, staged)
// Later, in a permanent-failure or explicit user-cancel handler:
val failed = account.media.pendingMessage(pending.commandId)
if (failed?.state == PendingMediaMessageState.FAILED) {
val cancelled = account.media.cancelPendingMessage(failed.commandId)
// Cancel host-owned executor work and delete private staging for the
// returned attachments only after the Rust transaction succeeds.
androidMedia.removeCancelled(cancelled.attachments)
}
// Download metadata is resolved from an authenticated stored message.
val download = account.media.prepareDownload(message.id, attachment.url)
check(download.sourceMessageId == message.id)
val exactDownloads = account.media.operationsById(trackedOperationIds)
val observedDownloads = account.media.observeOperationsById(trackedOperationIds)
// Conversation icons use the same authenticated cache boundary. Public bytes
// require the downloaded-object `x` hash; encrypted bytes use final AEAD.
val iconDownload = account.media.prepareConversationIconDownload(conversation.id)
check(iconDownload.conversationId == conversation.id)
var cursor: String? = null
do {
val page = account.media.recoverablePage(cursor = cursor, limit = 100)
page.operations.forEach { operation ->
// Match operation.id to private staging and enqueue its WorkManager effect.
}
cursor = page.nextCursor
} while (cursor != null)
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
account.asset-replacementPersist 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
- Rust
- Android
AccountRuntimeHandle::prepare_asset_replacementAccountRuntimeHandle::pending_asset_replacementAccountRuntimeHandle::recover_pending_asset_replacementsAccountMedia.prepareProfilePictureAccountMedia.prepareProfileBannerAccountMedia.prepareConversationIconAccountMedia.pendingAssetReplacementAccountMedia.recoverReadyAssetReplacements- Rust
- Android
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)?;
// Copy the selected content URI into app-private staging first.
val staged = androidMedia.stage(selectedUri)
val commandId = CommandId.random() // persist/reuse while retrying this action
val pending = account.media.prepareProfilePicture(
commandId = commandId,
source = MediaPreparation(staged.sourceFingerprint),
)
check(pending.operation.protection == MediaProtection.PUBLIC)
// Bind the staged file to pending.operation.id, then enqueue WorkManager.
androidMedia.bindAndSchedule(account, pending, staged)
// The same pattern has typed entry points for the other supported targets.
account.media.prepareProfileBanner(commandId, MediaPreparation(fingerprint))
account.media.prepareConversationIcon(
commandId,
conversation.id,
MediaPreparation(fingerprint),
)
// On process start, after reopening the account:
account.media.recoverReadyAssetReplacements()
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
account.operationsInspect, 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
- Rust
- Android
AccountRuntimeHandle::operationAccountRuntimeHandle::operation_pageAccountRuntimeHandle::retry_operationAccountOperations.observeAccountOperations.retryAccountOperations.cancel- Rust
- Android
let operation = account.operation(command_id.clone())?;
let page = account.operation_page(50)?;
let retried = account.retry_operation(command_id)?;
val delivery: Flow<OperationSnapshot> =
account.operations.observe(commandId)
val pending = account.operations.observe(limit = 50)
account.operations.retry(commandId)
account.operations.cancel(commandId)
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
account.pushCreate 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
- Rust
- Android
AccountRuntimeHandle::register_push_tokenAccountPush.register- Rust
- Android
let registration = account.register_push_token(
command_id,
PushPlatform::Fcm,
token,
installation_id,
now,
)?;
account.push.register(
commandId = installationStore.commandForPushToken(fcmToken),
platform = PushPlatform.FCM,
token = fcmToken,
installationId = installationStore.id(),
)
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
account.contacts-resourcesStore 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
- Rust
- Android
AccountRuntimeHandle::put_local_contactAccountRuntimeHandle::replace_stickersAccountRuntimeHandle::put_link_previewAccountContacts.putAccountResources.replaceStickersAccountResources.putLinkPreview- Rust
- Android
account.put_local_contact(contact)?;
account.replace_stickers(stickers)?;
account.put_link_preview(preview)?;
account.contacts.put(
LocalContact(
publicKey = bobPublicKey,
name = "Bob",
updatedAtMillis = System.currentTimeMillis(),
),
)
val contacts = account.contacts.observe(limit = 100)
account.resources.replaceStickers(stickers)
account.resources.putLinkPreview(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
account.recoveryRecover 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
- Rust
- Android
AccountRuntimeHandle::recover_signed_outbox_batchAccountRecovery.importSignedOutboxBatch- Rust
- Android
let recovered = account.recover_signed_outbox_batch(
stable_legacy_row_id,
opaque_event_array_json,
now,
)?;
val recovered = account.recovery.importSignedOutboxBatch(
commandId = stableLegacyRowCommandId,
eventBatchJson = opaqueEventArrayJson,
)
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
account.supportObserve 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
- Rust
- Android
AccountRuntimeHandle::account_diagnosticsAccountSupport.observeDiagnosticsAccountSupport.observeRevisionSoftchatAccount.refresh- Rust
- Android
let diagnostics = account.account_diagnostics()?;
let revision = diagnostics.revision;
val diagnostics: Flow<AccountDiagnostics> =
account.support.observeDiagnostics()
val revisions: Flow<Long> = account.support.observeRevision()
// Use after a platform wake hint from another process or lifecycle owner.
account.refresh()
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
account.loggingFeed 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
- Rust
- Android
AccountRuntimeHandle::set_log_levelAccountRuntimeHandle::drain_log_eventsSoftchatLoggingSoftchatLogSinkSoftchatLogBatchSoftchatLogEventAccountSupport.setLogLevel- Rust
- Android
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);
}
val logging = SoftchatLogging(
minimumLevel = SoftchatLogLevel.INFO,
sink = SoftchatLogSink { batch ->
batch.events.forEach { event ->
appLog.add(
tag = "SDK:${event.category}",
level = event.level,
message = buildString {
append(event.summary) // SDK-owned English, never translated.
append(" operation=${event.operation} sequence=${event.sequence}")
event.direction?.let { append(" direction=$it") }
event.subject?.let { append(" subject=$it") }
event.errorCode?.let { append(" error=${it.value}") }
event.counters.forEach { append(" ${it.kind}=${it.value}") }
},
)
}
if (batch.droppedCount > 0uL) {
appLog.add("SDK:LIFECYCLE", "dropped=${batch.droppedCount}")
}
},
)
val account = SoftchatAccount.open(secret, privateDirectory, logging)
// Developer mode may opt into debug; null disables native collection.
account.support.setLogLevel(SoftchatLogLevel.DEBUG)
account.support.setLogLevel(null)
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
summaryis the SDK's static English description.operationis the stable diagnostic code. Neither is supplied by a caller or relay.directionisTX,RX, orLOCALwhen known. A relay acknowledgement advances a TX publication even though its wire frame arrived over RX.subjectis the authenticated semantic family below.relationoptionally identifies a reply or forward; attachment count is a numeric attribute.sourcedescribes a proven boundary:LOCAL_COMMAND,RELAY_LIVE,RELAY_SYNC,INCOMING_API, orRECOVERY. The incoming JSON API cannot prove that Android received a push notification.occurredAtMillisandlastOccurredAtMillisare local SDK emission times, never Nostr event timestamps. Preserve nativesequenceorder through the bridge even when the wall clock changes or delivery is delayed.- Named
countersdistinguish 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. LegacyitemCountremains a generic item count. errorCodeis a stable SDK error;reasonCodeis a separate static reason. Unknown external error categories becomeexternal_failure. OptionaldurationMicrosremains compatible; it is not a network latency claim and a coalesced duration, when supplied, belongs to the last occurrence.
| Subject | Supported diagnostic boundary |
|---|---|
CHAT_MESSAGE | New committed TX and authenticated RX, including replies, forwards, and attachments |
SUBJECT | Conversation subject/icon history, TX and RX |
REACTION | Reaction history, TX and RX |
EDIT | Edit history, TX and RX; storage does not prove effective UI replacement |
DELETION | Deletion-request history, TX and RX; distinct from account clearing |
TYPING | Actual queued/observed ephemeral events, DEBUG; never durable message history |
USER_METADATA | Private profile update/history, TX and RX |
FOLLOW_LIST | Private contact replacement/history, TX and RX |
READ_STATE | Own-account read-state synchronization, TX and RX; not recipient read receipts |
APP_SETTINGS | Account settings synchronization, TX and RX |
APPLICATION_DATA | Authenticated generic NIP-78 input; no arbitrary context names |
PUSH_REGISTRATION | Known local registration writer; acceptance does not prove push delivery |
GENERIC_REPOST | RX compatibility retention at DEBUG; distinct from forwarding |
UNKNOWN | Authenticated 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.
- Rust
- Android
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-leveltransportfrom 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
Flowqueries in Compose and retainCommandIdacross 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.