This is the template in Documenting architecture decisions - Michael Nygard. You can use adr-tools for managing the ADR files.
In each ADR file, write these sections:
What is the status, such as proposed, accepted, rejected, deprecated, superseded, etc.?
What is the issue that we're seeing that is motivating this decision or change?
What is the change that we're proposing and/or doing?
What becomes easier or more difficult to do because of this change?
Date: 2025-12-03
Accepted
We need to document architectural decisions made during the development of the project in Kalium. This will help us keep track of the reasoning behind our decisions and provide context for future developers working on the project.
We will use Architecture Decision Records in the code and as part of the review process. We will use the Lightway ADR template to keep the ADRs simple and easy to maintain.
docs/adr, to keep the architecture decision
records.Date: 2025-11-18
Accepted
The Kalium persistence layer previously used 10 separate tables to store different types of system message content:
This design led to several issues:
MessageDetailsView required a ton LEFT JOINs to fetch message data, causing performance overheadMessageDetailsView, making the view progressively larger and slower with each additionWe consolidated all 10 system message content tables into a single MessageSystemContent table using a discriminator pattern with generic typed columns.
CREATE TABLE MessageSystemContent (
message_id TEXT NOT NULL,
conversation_id TEXT NOT NULL,
content_type TEXT NOT NULL, -- Discriminator: 'MEMBER_CHANGE', 'FAILED_DECRYPT', etc.
-- Generic typed fields for flexible storage
text_1 TEXT, -- conversation_name, protocol, etc.
integer_1 INTEGER, -- message_timer, error_code, etc.
boolean_1 INTEGER, -- receipt_mode, is_apps_enabled, is_decryption_resolved
list_1 TEXT, -- member lists, domain lists
enum_1 TEXT, -- member_change_type, federation_type, legal_hold_type
blob_1 BLOB, -- unknown_encoded_data for failed decryption
FOREIGN KEY (message_id, conversation_id) REFERENCES Message(id, conversation_id),
PRIMARY KEY (message_id, conversation_id)
);
CREATE INDEX idx_system_content_type ON MessageSystemContent(content_type);
Each content type maps its specific fields to the generic columns:
| Content Type | Field Mapping | |--------------|---------------| | MEMBER_CHANGE | list_1=members, enum_1=type | | FAILED_DECRYPT | blob_1=data, boolean_1=resolved, integer_1=error_code | | CONVERSATION_RENAMED | text_1=name | | RECEIPT_MODE | boolean_1=enabled | | TIMER_CHANGED | integer_1=duration_ms | | FEDERATION_TERMINATED | list_1=domains, enum_1=type | | PROTOCOL_CHANGED | text_1=protocol | | LEGAL_HOLD | list_1=members, enum_1=type | | APPS_ENABLED | boolean_1=enabled |
The MessageDetailsView uses CASE statements to maintain semantic column names, ensuring no breaking changes to application code:
CASE WHEN SystemContent.content_type = 'MEMBER_CHANGE'
THEN SystemContent.list_1 END AS memberChangeList,
CASE WHEN SystemContent.content_type = 'MEMBER_CHANGE'
THEN SystemContent.enum_1 END AS memberChangeType,
This allows existing Kotlin code to continue working without modifications:
val message = messagesQueries.selectById(messageId, conversationId).executeAsOne()
val members = message.memberChangeList // Still works!
val changeType = message.memberChangeType // Still works!
The migration (120.sqm) performs these steps:
MessageSystemContent tableSchemaMigrationTest pattern for future migrationsinsertSystemMemberChange) hide generic field names from developersMessageDetailsView provides semantic column names, maintaining code readabilityWhen adding a new system message type:
content_type discriminator value (e.g., 'NEW_MESSAGE_TYPE')Messages.sqMessageDetailsView with CASE statements for the new type (requires migration)MessageInsertExtensionImpl.kt to use the new queryAlways query through MessageDetailsView to get semantic column names:
// Good - uses view with semantic names
val message = messagesQueries.selectById(messageId, conversationId).executeAsOne()
val name = message.conversationName
// Avoid - direct table access with generic names
// This makes code harder to understand
persistence/src/commonMain/db_user/migrations/120.sqmDate: 2025-11-18
Accepted
Database migrations in Kalium are critical operations that modify both schema structure and transform existing user data. Previously, migration testing was inconsistent and ad-hoc, with most migrations only tested through integration tests that ran the entire migration chain. This approach had several problems:
The consolidation of 10 system message content tables into a single table (Migration 120) highlighted the need for a robust testing framework that could:
We created the SchemaMigrationTest framework, a comprehensive testing infrastructure for database migrations that involve both schema changes and data transformations.
The framework consists of three main components:
SchemaMigrationTest.ktA base class that provides:
abstract class SchemaMigrationTest {
protected fun runMigrationTest(
schemaVersion: Int,
setupOldSchema: (SqlDriver) -> Unit,
migrationSql: () -> String,
verifyNewSchema: (SqlDriver) -> Unit
)
}
Key capabilities:
.db files stored in src/commonTest/kotlin/com/wire/kalium/persistence/schemas/Helper methods:
executeInsert(sql) - Insert data with simple SQLcountRows(tableName) - Get row count from a tablequerySingleValue(sql, mapper) - Query and extract a single valueexecuteQuery(sql, mapper) - General-purpose query executionPre-migration database schemas stored as SQLite .db files:
persistence/src/commonTest/kotlin/com/wire/kalium/persistence/schemas/
├── 124.db
└── ...
These files are snapshots of the actual schema before a migration runs, generated using:
./gradlew :persistence:generateCommonMainUserDatabaseInterface
cp persistence/src/commonMain/db_user/schemas/124.db \
persistence/src/commonTest/kotlin/com/wire/kalium/persistence/schemas/124.db
Individual test classes that extend SchemaMigrationTest and test specific migrations:
class Migration120Test : SchemaMigrationTest() {
@Test
fun testMemberChangeContentMigration() = runTest(dispatcher) {
runMigrationTest(
schemaVersion = 120,
setupOldSchema = { driver ->
// Insert test data into old schema
driver.executeInsert("""
INSERT INTO MessageMemberChangeContent
(message_id, conversation_id, member_change_list, member_change_type)
VALUES ('msg-1', 'conv-1', '["user1", "user2"]', 'ADDED')
""")
},
migrationSql = {
// Read actual migration SQL from file
File("src/commonMain/db_user/migrations/120.sqm").readText()
},
verifyNewSchema = { driver ->
// Verify the migrated data
val count = driver.countRows("MessageSystemContent")
assertEquals(1, count)
var contentType: String? = null
var list1: String? = null
driver.executeQuery(null, """
SELECT content_type, list_1
FROM MessageSystemContent
WHERE message_id = 'msg-1'
""".trimIndent(), { cursor ->
if (cursor.next().value) {
contentType = cursor.getString(0)
list1 = cursor.getString(1)
}
QueryResult.Unit
}, 0)
assertEquals("MEMBER_CHANGE", contentType)
assertEquals("""["user1", "user2"]""", list1)
// Verify old table was dropped
assertTableDoesNotExist(driver, "MessageMemberChangeContent")
}
)
}
}
We categorize migrations into two types, each with different testing approaches:
Migrations that only modify database structure without transforming existing data.
Example: Adding a new column with a default value
Testing approach: Use simpler unit tests (like EventMigration109Test style)
When to use:
Migrations that both modify database structure AND transform existing data.
Example: Consolidating multiple tables into one (Migration 120)
Testing approach: Use SchemaMigrationTest framework (this ADR)
When to use:
For Schema + Content migrations, each test class should include:
Example from Migration120Test:
The framework supports all SQLite data types:
TEXT:
driver.executeInsert("""
INSERT INTO Table (id, name)
VALUES ('id-1', 'Test Name')
""")
INTEGER (including booleans as 0/1):
driver.executeInsert("""
INSERT INTO Table (id, is_enabled)
VALUES ('id-1', 1) -- true
""")
BLOB:
val testData = byteArrayOf(0x01, 0x02, 0x03)
driver.execute(null, """
INSERT INTO Table (id, data) VALUES (?, ?)
""".trimIndent(), 2) {
bindString(0, "id-1")
bindBytes(1, testData)
}
Reading BLOB data:
var blob: ByteArray? = null
driver.executeQuery(null, "SELECT data FROM Table WHERE id = 'id-1'", { cursor ->
if (cursor.next().value == true) {
blob = cursor.getBytes(0)
}
}, 0)
.db files must be committed to the repository (typically 50-100 KB each)When writing migration tests:
Export schema files BEFORE running the migration
./gradlew :persistence:generateCommonMainUserDatabaseInterface
cp persistence/src/commonMain/db_user/schemas/124.db \
persistence/src/commonTest/kotlin/com/wire/kalium/persistence/schemas/124.db
Read migration SQL from actual .sqm files
private fun getMigration120Sql(): String {
return File("src/commonMain/db_user/migrations/120.sqm").readText()
}
This ensures tests always use the real migration SQL
Test each content type separately
@Test fun testMemberChangeContentMigration()
@Test fun testFailedToDecryptContentMigration()
@Test fun testConversationRenamedContentMigration()
Verify ALL fields, not just the happy path
// Verify used fields have correct values
assertEquals("MEMBER_CHANGE", contentType)
assertEquals("""["user1", "user2"]""", list1)
// Verify unused fields are NULL
assertNull(text1)
assertNull(integer1)
assertNull(blob1)
Always verify old tables are dropped
assertTableDoesNotExist(driver, "MessageMemberChangeContent")
Test with realistic data
Use descriptive test names
@Test fun testMemberChangeDataIntegrity_AllFieldsPreserved()
@Test fun testAllSystemMessageTypesNoDataLoss()
# Run all migration tests for migration 120
./gradlew :persistence:jvmTest --tests "*Migration120Test"
# Run a specific test
./gradlew :persistence:jvmTest --tests "*Migration120Test.testMemberChangeContentMigration"
# Run all schema migration tests
./gradlew :persistence:jvmTest --tests "*migration*"
Step 1: Export the schema file BEFORE applying the migration
./gradlew :persistence:generateCommonMainUserDatabaseInterface
cp persistence/src/commonMain/db_user/schemas/121.db \
persistence/src/commonTest/kotlin/com/wire/kalium/persistence/schemas/121.db
Step 2: Create the test class
package com.wire.kalium.persistence.dao.migration
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
class Migration121Test : SchemaMigrationTest() {
companion object {
private const val MESSAGE_ID = "test-message-id"
private const val CONVERSATION_ID = "test-conversation-id"
private const val MIGRATION_NAME = 121
}
private fun getMigration121Sql(): String {
val migrationFile = File("src/commonMain/db_user/migrations/$MIGRATION_NAME.sqm")
if (!migrationFile.exists()) {
error("Migration file not found: ${migrationFile.absolutePath}")
}
return migrationFile.readText()
}
@Test
fun testMyDataMigration() = runTest(dispatcher) {
runMigrationTest(
schemaVersion = MIGRATION_NAME,
setupOldSchema = { driver ->
// Insert test data into old schema
driver.executeInsert("""
INSERT INTO OldTable (id, value)
VALUES ('$MESSAGE_ID', 'test-value')
""")
},
migrationSql = { getMigration121Sql() },
verifyNewSchema = { driver ->
// Verify the migrated data
val count = driver.countRows("NewTable")
assertEquals(1, count)
var migratedValue: String? = null
driver.executeQuery(null, """
SELECT value FROM NewTable WHERE id = '$MESSAGE_ID'
""".trimIndent(), { cursor ->
if (cursor.next().value) {
migratedValue = cursor.getString(0)
}
QueryResult.Unit
}, 0)
assertEquals("test-value", migratedValue)
// Verify old table was dropped
assertTableDoesNotExist(driver, "OldTable")
}
)
}
}
Step 3: Run the test
./gradlew :persistence:jvmTest --tests "*Migration121Test"
Migration 120 consolidated 10 separate system message content tables into a single MessageSystemContent table. The test suite demonstrates comprehensive coverage with the following test types:
Each old table gets its own test to verify correct migration:
@Test
fun testMemberChangeContentMigration() = runTest(dispatcher) {
runMigrationTest(
schemaVersion = 120,
setupOldSchema = { driver ->
driver.executeInsert("""
INSERT INTO MessageMemberChangeContent
(message_id, conversation_id, member_change_list, member_change_type)
VALUES ('$MESSAGE_ID', '$CONVERSATION_ID', '["user1", "user2"]', 'ADDED')
""")
},
migrationSql = { getMigration120Sql() },
verifyNewSchema = { driver ->
val count = driver.countRows("MessageSystemContent")
assertEquals(1, count)
var contentType: String? = null
var list1: String? = null
var enum1: String? = null
driver.executeQuery(null, """
SELECT content_type, list_1, enum_1
FROM MessageSystemContent
WHERE message_id = '$MESSAGE_ID'
""".trimIndent(), { cursor ->
if (cursor.next().value) {
contentType = cursor.getString(0)
list1 = cursor.getString(1)
enum1 = cursor.getString(2)
}
QueryResult.Unit
}, 0)
assertEquals("MEMBER_CHANGE", contentType)
assertEquals("""["user1", "user2"]""", list1)
assertEquals("ADDED", enum1)
assertTableDoesNotExist(driver, "MessageMemberChangeContent")
}
)
}
Similar tests cover:
testFailedToDecryptContentMigration - Tests BLOB and error code migrationtestConversationRenamedContentMigration - Tests text field migrationtestReceiptModeContentMigration - Tests boolean field migration from two separate tablestestTimerChangedContentMigration - Tests integer field migrationtestFederationTerminatedContentMigration - Tests list and enum migrationtestProtocolChangedContentMigration - Tests enum-only migrationtestLegalHoldContentMigration - Tests complex list with enumstestAppsEnabledChangedContentMigration - Tests boolean field migrationtestMultipleSystemMessagesMigration - Tests migrating all types togethertestIndexCreation - Verifies indexes are created correctlyVerify ALL fields are correctly migrated, including NULL values for unused fields:
@Test
fun testMemberChangeDataIntegrity_AllFieldsPreserved() = runTest(dispatcher) {
runMigrationTest(
schemaVersion = 120,
setupOldSchema = { driver ->
driver.executeInsert("""
INSERT INTO MessageMemberChangeContent
(message_id, conversation_id, member_change_list, member_change_type)
VALUES ('$MESSAGE_ID', '$CONVERSATION_ID',
'["user1", "user2"]', 'FEDERATION_REMOVED')
""")
},
migrationSql = { getMigration120Sql() },
verifyNewSchema = { driver ->
var messageId: String? = null
var conversationId: String? = null
var contentType: String? = null
var text1: String? = null
var integer1: Long? = null
var boolean1: Long? = null
var list1: String? = null
var enum1: String? = null
var blob1: ByteArray? = null
driver.executeQuery(null, """
SELECT message_id, conversation_id, content_type,
text_1, integer_1, boolean_1, list_1, enum_1, blob_1
FROM MessageSystemContent
WHERE message_id = '$MESSAGE_ID'
""".trimIndent(), { cursor ->
if (cursor.next().value) {
messageId = cursor.getString(0)
conversationId = cursor.getString(1)
contentType = cursor.getString(2)
text1 = cursor.getString(3)
integer1 = cursor.getLong(4)
boolean1 = cursor.getLong(5)
list1 = cursor.getString(6)
enum1 = cursor.getString(7)
blob1 = cursor.getBytes(8)
}
QueryResult.Unit
}, 0)
// Verify PKs
assertEquals(MESSAGE_ID, messageId)
assertEquals(CONVERSATION_ID, conversationId)
// Verify content type
assertEquals("MEMBER_CHANGE", contentType)
// Verify used fields
assertEquals("""["user1", "user2"]""", list1)
assertEquals("FEDERATION_REMOVED", enum1)
// Verify unused fields are NULL
assertNull(text1)
assertNull(integer1)
assertNull(boolean1)
assertNull(blob1)
}
)
}
Similar comprehensive tests for:
testFailedDecryptDataIntegrity_AllFieldsPreservedtestConversationRenamedDataIntegrity_AllFieldsPreservedtestAllReceiptModeTypesDataIntegritytestFederationTerminatedDataIntegrity_ComplexListPreservedtestLegalHoldDataIntegrity_ComplexMemberListtestAllSystemMessageTypesNoDataLoss - Tests all 10 types together with full field verificationTotal: 19+ test cases covering every migration scenario, edge case, and data integrity requirement.
Error: Schema file not found: /com/wire/kalium/persistence/schemas/119.db
Solution: Make sure the schema file exists at:
persistence/src/commonTest/kotlin/com/wire/kalium/persistence/schemas/124.db
Generate it with:
./gradlew :persistence:generateCommonMainUserDatabaseInterface
cp persistence/src/commonMain/db_user/schemas/124.db \
persistence/src/commonTest/kotlin/com/wire/kalium/persistence/schemas/124.db
Error: Failed to execute migration statement: ...
Solution:
Error: table MessageSystemContent already exists
Solution: Make sure your migration SQL uses CREATE TABLE IF NOT EXISTS or the schema file is from before the migration. The schema file should be generated BEFORE you write the migration SQL.
// Wrong - string binding for BLOB
driver.execute(null, "INSERT INTO Table (data) VALUES (?)", 1) {
bindString(0, testData.toString()) // Wrong!
}
// Correct - bytes binding for BLOB
driver.execute(null, "INSERT INTO Table (data) VALUES (?)", 1) {
bindBytes(0, testData) // Correct!
}
SQLite doesn't have a native boolean type. Use INTEGER with 0/1:
// Insert
driver.executeInsert("INSERT INTO Table (is_enabled) VALUES (1)") // true
// Query - returns Long, not Boolean
val isEnabled: Long? = cursor.getLong(0) // 0 or 1
assertEquals(1L, isEnabled) // true
Potential enhancements to the framework:
persistence/src/jvmTest/kotlin/com/wire/kalium/persistence/dao/migration/Migration120Test.kt - Reference implementation with 19+ test casespersistence/src/jvmTest/kotlin/com/wire/kalium/persistence/dao/migration/SchemaMigrationTest.kt - Framework implementationpersistence/src/commonMain/db_user/migrations/124.sqm - Actual migration SQLDate: 2025-11-27
Accepted
The Kalium codebase had grown organically with modules at the root level (e.g., :common, :data, :persistence, :network, :backup, :calling, :cells). This flat structure made it difficult to understand module relationships, enforce architectural boundaries, and maintain clear separation of concerns. As the codebase scaled, we needed a more organized module hierarchy that would:
We reorganized modules into a hierarchical structure with clear architectural boundaries:
Core Layer (:core:*) - Foundation modules:
:core:common (was :common):core:data (was :data):core:cryptography (was :cryptography):core:logger (was :logger):core:util (was :util)Data Layer (:data:*) - Data access and infrastructure:
:data:network (was :network):data:network-model (was :network-model):data:network-util (was :network-util):data:link-preview-fetch (isolated transport for untrusted link preview URLs):data:persistence (was :persistence):data:persistence-test (was :persistence-test):data:protobuf (was :protobuf):data:data-mappers (new module for transformations)Domain Layer (:domain:*) - Business logic boundaries:
:domain:backup (was :backup):domain:calling (was :calling):domain:cells (was :cells):domain:conversation-history (new):domain:messaging:sending (new, extracted from logic):domain:messaging:receiving (new, extracted from logic)Test Layer (:test:*) - Testing utilities:
:test:mocks (was :mocks):test:data-mocks (new):test:benchmarks (was :benchmarks):test:tango-tests (was :tango-tests)Sample/Tools Layer (:sample:*, :tools:*):
:sample:cli (was :cli):sample:samples (was :samples):tools:testservice (was :testservice):tools:monkeys (was :monkeys):tools:backup-verification (new):tools:protobuf-codegen (was :protobuf-codegen)All module references were updated throughout the codebase including build files, CI workflows, documentation, and the dependency graph visualization.
Benefits:
:data:network vs just :network)Trade-offs:
projects.core.common instead of projects.common)Technical Changes:
implementation(projects.*) references in build files:cli:assemble → :sample:cli:assemble)Date: 2025-12-03
Accepted
On Android clients with a large number of conversations and heavy message history, the conversation
list was slow to load.
Profiling showed a significant delay during the COUNT phase executed before the Paging source loads
items.
The existing COUNT query used the ConversationDetails view, which performs many joins, user
metadata checks, visibility rules, and sorting-related logic.
This resulted in:
Most of this logic is needed for the conversation list itself, but not for the COUNT used by the Paging library. Paging only requires an upper bound, not the exact filtered set.
Use a lightweight COUNT that operates directly on the Conversation table, but only when:
searchQuery is empty (no text search filtering required)The new countConversations query is equivalent to the core filters used by the real paging SELECT
and matches:
archiveddeleted_locallyHowever, it intentionally does not perform the additional visibility logic from
ConversationDetails, such as:
These rules remain enforced by the actual SELECT used for loading pages.
As a result, the lightweight COUNT may return a superset of rows, but this is acceptable because the
Paging source uses the full SELECT query for real data.
The COUNT only provides a high-level upper bound.
Below is the lightweight COUNT query introduced:
SELECT COUNT(*)
FROM Conversation
WHERE
type IS NOT 'SELF'
AND CASE
WHEN :conversationFilter = 'ALL' THEN 1 = 1
WHEN :conversationFilter = 'GROUPS' THEN (type = 'GROUP' AND is_channel = 0)
WHEN :conversationFilter = 'ONE_ON_ONE' THEN type = 'ONE_ON_ONE'
WHEN :conversationFilter = 'CHANNELS' THEN (type = 'GROUP' AND is_channel = 1)
ELSE 1 = 0
END
AND archived = :fromArchive
AND deleted_locally = 0
AND (
protocol IN ('PROTEUS','MIXED')
OR (
protocol = 'MLS'
AND (
:strict_mls = 0
OR mls_group_state IN ('ESTABLISHED','PENDING_AFTER_RESET')
)
)
);
Date: 2025-12-23
Accepted
The Kalium logic module serves as the main SDK entry point that client applications interact with. Without explicit visibility modifiers, it was difficult to distinguish between public API surface (intended for consumers) and internal implementation details. This led to several issues:
CryptoTransactionProvider were being directly accessed by sample applications, bypassing proper API abstractionsKotlin's explicitApi() mode enforces that all public declarations have explicit visibility modifiers and return types, forcing intentional decisions about API surface.
We enabled explicitApi() mode for the :logic module and adopted an internal-first migration strategy:
internal visibility modifiers to all declarations lacking explicit visibility (~3,449 declarations)internal modifiers from interface members (1,278 instances), as they inherit visibility from the interfacepublic when consumer modules failed to compile, ensuring minimal API surfacecryptoTransactionProvider), created public wrapper methods in appropriate scopes (e.g., DebugScope.refillKeyPackages(), DebugScope.generateEvents())Created Python scripts for automated migration to handle:
internal modifiers while skipping override functionsinternal from interface membersUsed @InternalKaliumApi opt-in annotation for:
CryptoTransactionProvider are now properly encapsulated behind public wrappersCosts:
For consumers experiencing compilation errors:
@OptIn(InternalKaliumApi::class) if the internal API is intentionally marked with @InternalKaliumApiDate: 2026-01-16
Accepted
Kalium's public APIs extensively used Either<CoreFailure, Unit> for error handling. This created
poor Swift interop:
Either types translate poorly to Swift/OjbC, resulting in complex, unidiomatic APIsReplace Either<CoreFailure, Unit> with non-generic, action-specific sealed classes for public
APIs:
public sealed class MessageOperationResult {
public data object Success : MessageOperationResult()
public data class Failure(val error: CoreFailure) : MessageOperationResult()
@OptIn(ExperimentalObjCRefinement::class)
@HiddenFromObjC
@Suppress("konsist.kaliumLogicModuleShouldNotExposeEitherTypesInPublicAPI")
public fun toEither(): Either<CoreFailure, Unit> = ...
}
Key principles:
MessageOperationResult, FetchConversationResult)toEither() bridge function for internal Kotlin code, for backward compatibility with
Android and internals. Specifically for MessageOperationResult, covering most of our Message
sending APIs.@HiddenFromObjC to avoid exposing the bridge function to Objective-C/Swift consumers.Easier:
More difficult:
.toEither() bridge calls in internal code, use as needed.Future pattern:
toEither() for backward compatibility@HiddenFromObjC to avoid exposing to Swift/ObjCDate: 2026-04-23
Accepted
Kalium currently has two complementary benchmark surfaces for the persistence layer and they leave a gap that matters in practice:
:test:benchmarks — JMH-based JVM benchmarks. These run on the JVM with
userDatabaseBuilder(..., passphrase = null, ...), i.e. plain SQLite. They give us a JVM baseline but they do not exercise the SQLCipher code path
that production Android clients actually hit.
An @Ignored message DAO benchmark test in :data:persistence commonTest. On Android it does
go through the encrypted path (BaseDatabaseTest.encryptedDBSecret → SupportOpenHelperFactory
→ libsqlcipher), but it measures with measureTime { ... } and lives alongside correctness
tests. There is no warmup, no iteration control, no output stability check, and no JSON report.
It is effectively a developer-only affordance you have to un-ignore by hand.
The question we cannot answer today is: what is the real cost of message insert/select on a device
with production-equivalent encryption enabled? We have a regression-prone piece of code
(MessageDAO on SQLCipher) and no repeatable measurement for it.
Introduce androidx.benchmark (microbenchmark, JUnit4 variant) as the measurement framework for
on-device DAO benchmarks, and add a dedicated module :test:android-benchmarks to host those
benchmarks. Message scenarios run in both runners from the same fixtures, so we have a direct
JVM-plain vs Android-encrypted comparison.
Key shape:
New module :test:android-benchmarks, a plain com.android.library module (not KMP). Our
existing kalium.library convention plugin is KMP-Android, which isn't a great fit for
androidx.benchmark today; keeping this module classic keeps the benchmark plugin happy and
confines the non-standard build settings.
Release-only instrumented tests: testBuildType = "release", debuggable = false,
minifyEnabled = false. This is a hard requirement of androidx.benchmark — debuggable builds
are refused (or produce meaningless numbers).
Runner: androidx.benchmark.junit4.AndroidBenchmarkRunner (replaces the default
AndroidJUnitRunner for this module only).
Encryption path: benchmarks build the user DB via userDatabaseBuilder with a fixed raw
UserDBSecret. libsqlcipher is loaded and SupportOpenHelperFactory drives the connection —
this is the production path.
Storage: the production database location from PlatformDatabaseData(context).
BenchmarkDbRule deletes it before each case and nukes it afterwards to keep runs clean.
Scenarios: message insert, paging, mark-read, asset status and concurrent read/write, the
conversation list, and message timelines. The module README
lists them. Message data comes from test/benchmark-fixtures, which both runners compile.
Dependencies added (to gradle/libs.versions.toml):
androidx-benchmark version key, on the 1.5 line: 1.4.x targets the old AGP
TestedExtension DSL and is incompatible with AGP 9.0androidx.benchmark:benchmark-junit4 libraryandroidx.benchmark Gradle pluginandroidx.test.ext:junit (adds AndroidJUnit4 runner; not previously in the catalog)Run command:
./gradlew :test:android-benchmarks:connectedReleaseAndroidTest
Results land in build/outputs/connected_android_test_additional_output/.../benchmarkData.json.
Out of scope for this ADR: CI integration, iOS/XCTest measurement, and folding into
:test:benchmarks as a KMP target. Any of those come as separate ADRs
once this module proves its worth.
Module dependency direction: :test:android-benchmarks depends on :data:persistence. It is a
leaf test module — nothing depends on it. It does not violate the existing layering rules.
Easier:
:data:persistence's
instrumented test run (which is meant for correctness and should stay debug-build).More difficult:
org.jetbrains.kotlinx.benchmark for JMH,
androidx.benchmark for on-device). Developers need to know which one to use for which surface.
Guidance: JVM-level → JMH; Android-device → androidx.benchmark.build.gradle.kts to maintain, and libsqlcipher/libsodium packaging rules
have to be mirrored here.Date: 2026-06-15
Accepted
Kalium is consumed as an SDK by external Android, JVM, iOS, and multiplatform clients. Those consumers need two guarantees when upgrading:
ADR 6 already introduced explicitApi() for :logic so public declarations are intentional.
However, explicit API mode does not detect whether a public declaration changed in a binary
incompatible way after it was published. A change can be source-compatible but still break already
compiled Kotlin/JVM consumers, for example by changing a return type, changing constructor
parameters on a public data class, or adding default parameters to a public function.
Kalium also has a tag-triggered generate-changelog.yml workflow that generates release notes from
commits. That is useful for release automation, but it does not force pull requests that change the
consumer-facing API to include migration guidance. Release notes generated only from commit messages
are not enough for SDK consumers.
Kotlin Gradle Plugin 2.2.0 and newer includes built-in ABI validation through
kotlin { abiValidation() }, with checkKotlinAbi and updateKotlinAbi Gradle tasks. Kalium is on
Kotlin 2.3.0, so this can be adopted without adding the older
org.jetbrains.kotlinx.binary-compatibility-validator plugin.
Adopt a public API and ABI governance workflow for published Kalium artifacts.
Kalium will distinguish between:
Stable public APIs should preserve binary and source compatibility within the supported release line. Breaking changes should go through a deprecation cycle where possible, and should be reserved for major releases or release lines that explicitly allow breaking changes.
APIs annotated with Kalium opt-in markers such as @InternalKaliumApi, @DebugKaliumApi, and
@DelicateKaliumApi have the stability guarantees described by those annotations and their KDoc.
These annotations must be used deliberately, not as a way to avoid documenting consumer-impacting
changes.
Use Kotlin Gradle Plugin's built-in ABI validation instead of adding the standalone binary compatibility validator plugin.
The initial rollout should cover the published artifact surface, starting with :logic because it
is the main SDK entry point and already uses explicitApi(). Additional published modules should be
added as their public surface is classified.
Expected Gradle shape:
kotlin {
abiValidation {
enabled.set(true)
filters {
exclude {
annotatedWith.add("com.wire.kalium.util.InternalKaliumApi")
annotatedWith.add("com.wire.kalium.util.DebugKaliumApi")
}
}
}
}
If the Kotlin Gradle Plugin version still requires the experimental opt-in for this DSL, the module
build file should opt in to org.jetbrains.kotlin.gradle.dsl.abi.ExperimentalAbiValidation.
CI must run:
./gradlew checkKotlinAbi
For the initial :logic-only rollout, CI runs the scoped task:
./gradlew :logic:checkKotlinAbi
The unqualified root task should be used once additional KMP modules have been classified and their ABI tasks can run without compiling unsupported or unpublished targets.
Developers must run this locally before finishing public API changes. If the ABI change is intentional and accepted, update the checked-in ABI dumps with:
./gradlew updateKotlinAbi
For the initial :logic-only rollout, update the checked-in ABI dumps with:
./gradlew :logic:updateKotlinAbi
ABI dump updates are reviewable API changes. Reviewers should treat them like source changes, not generated noise.
Any pull request that changes public API or ABI must include consumer-facing release notes.
CI should enforce this by requiring one of the following when public ABI dumps change, or when the pull request is labelled as an API-impacting change:
no-changelog-needed label with a reviewer-accepted reason.internal-only label and the ABI/API gate confirms no published
consumer surface changed.Direct edits to a single CHANGELOG.md on every pull request should be avoided because they cause
frequent merge conflicts. Prefer a fragment-based workflow:
changelog.d/
1234-added-backup-status-api.md
1235-deprecated-old-login-flow.md
Each fragment should be short and consumer-focused:
### Added
- Added `BackupStatus.InProgress` for observing backup progress.
### Migration
No action required unless consumers exhaustively match `BackupStatus`.
### Compatibility
ABI: additive.
Source: additive.
Behavior: no behavior change.
The release workflow should collect accepted fragments into release notes or CHANGELOG.md, then
clear or archive consumed fragments. The existing tag-triggered generated changelog may still be
used, but consumer-facing API notes must not depend only on generated commit summaries.
Pull requests that affect public APIs should include:
Reviewers should block public API changes that:
Easier:
explicitApi(), opt-in API annotations, and
generated release automation.More difficult:
Future work:
:logic.checkKotlinAbi.changelog.d/, no-changelog-needed, and
internal-only.References:
Date: 2026-08-03
Accepted
ADR 9 requires consumer-facing changelog fragments for public API, ABI, and behavior changes. Its
example fragment format used one Markdown file with internal ### Added, ### Migration, and
### Compatibility sections.
That format was easy to author but required custom parsing during release. The first collector concatenated fragments directly, which produced duplicated section headings. It also rendered compatibility as a detached section, making it unclear which compatibility note belonged to which change.
The existing release workflow must remain a two-step process:
Use Towncrier to render consumer-facing changelog fragments, while keeping the generated Git changelog as the second part of the release body.
Fragments will use native Towncrier filenames:
changelog.d/1234-backup-status-api.added.md
Supported suffixes are:
.added.md.changed.md.deprecated.md.removed.md.fixed.md.security.mdDo not use separate .compatibility.md or .migration.md types. Compatibility and migration
information belongs inline under the change it describes:
Added `BackupStatus.InProgress` for observing backup progress.
- ABI: additive
- Source: additive
- Behavior: no behavior change unless consumers exhaustively match `BackupStatus`.
- Migration: no action required unless consumers exhaustively match `BackupStatus`.
The release wrapper should only select fragments for BASE_REF...HEAD_REF, run
towncrier build --draft --version "$CURRENT_TAG" in a temporary directory, and prepend the output
to the Git changelog. It should not parse changelog Markdown.
Towncrier is pinned in scripts/changelog-requirements.txt so Renovate can manage updates through
the pip_requirements manager.
Easier:
More difficult:
### headings.This ADR supersedes only the changelog fragment format shown in ADR 9. ADR 9's API, ABI, review, and changelog requirement policy remains in force.
Date: 2026-09-22
Accepted
On Apple platforms the app, the Notification Service Extension (NSE) and the share extension are separate processes that use the same account stores in the App Group: SQLite, CoreCrypto and the shared Keychain. Two processes must never use one account's native resources at the same time.
The NSE runs for seconds under tight memory limits. It cannot log in, register a client or migrate stores, and it must leave the app's event queue in a state the app can continue from. SQL and CoreCrypto are separate stores, so a notification's effects cannot commit atomically across both.
The Apple app and extensions share one exclusive per-account lease for the entire
lifetime of account database, network and crypto resources. The lock is a
persistent flock file outside the removable account directory, named using the
versioned hash of the qualified account identity. Never unlink it during logout:
other processes must keep locking the same inode. One blocked account must not
prevent unrelated accounts from closing.
An extension holding the lease can be suspended with its host, so the app's wait for
it stays cancellable, also when reopening after a background release, and gives up
after 15 seconds with AccountBusyException; the app keeps what it shows and retries.
The holder writes its process name, pid and time into the lock file body; locking
never reads it, and the app logs it when it gives up waiting.
The ownership primitive lives in :domain:messaging:receiving. Existing SQLite
opening stays in persistence, app session construction stays in logic, and the
Swift facade lives in the iOS-only :nse module. Shared mapping and business rules
remain below logic; app UI, live workers and platform integrations keep their
existing owners. There is no generic opener bundle or common runtime coordinator.
The app awaits open; synchronous getters return only active cached sessions.
Readiness is durable account state. The app revokes it before registration,
credential replacement, logout and session removal, and publishes it only after
successful preparation: after registration and whenever its sync reaches live.
Opening, resuming and releasing an account leave it unchanged. A terminal account
event found by the NSE also writes a per-client invalidation marker that keeps
readiness unreadable until a different client publishes it. Successful preparation
verifies the client, keys, crypto stores and supported configuration before
publishing a versioned readiness sidecar; a failed check revokes it. That sidecar
permits the NSE to open a prepared account without opening/migrating a global
database. Tokens and keys stay in the shared Keychain configuration.
Only verified preparation publishes, so an interrupted registration stays unready until a later preparation finds the completed client. An app that dies, also before its sync is live or during a background release, leaves the last published readiness. The NSE refuses a changed client or MLS registration, an unusable cipher suite and incompatible stores, and uses the server configuration of the last preparation. A future crypto replacement must revoke readiness first and prepare only from a fresh app scope. Logout serializes mutation and cleanup against the originating session so stale work cannot reach a reopened account. Hosts must quiesce their own account operations and observers before releasing ownership. Background release keeps the app session but first stops sync and closes its WebSocket client, while no reopen can interleave; sync restarts only after the app owns the account again. Legacy push skips clients the backend serves over WebSocket, so an open socket would hide new events from the NSE. The native socket and the backend see the close asynchronously, so events in that short window may still reach the closing socket instead of APNs. A message send keeps its crypto transaction until its request ends; on a bad network that can outlast the time iOS gives the app in the background, and stopping sync can wait behind it. If the release still waits after 1 second, it fails the account's HTTP requests as offline until it starts closing. iOS has suspended the app about 4.8 seconds after it entered the background, so the release must end well before that. A failed MLS commit request counts as rejected, so CoreCrypto drops the commit even if the backend already accepted it. The client then lags the group until its next incoming message (wrong epoch, then rejoin) or its next send (stale message, then epoch check and rejoin), the same as when the process dies during that request.
NSE opening retries a busy lease for up to 3 seconds, which covers the app's background handoff, and waits for another attempt of the same process to finish: that attempt presents only what it received. If the account stays busy, the NSE asks the host for a generic alert unless the host reports that the foreground app presents alerts. Missing or incompatible readiness, such as after an app update until the app's sync is live, also asks for one unless the app presents alerts, and so does storage it cannot read. Opening then reads compatible readiness, checks existing store files and credentials, and opens owned resources. It never logs in, registers a client or initializes missing stores to repair preparation. SQLite opens without creation or migration. For an encrypted account, the NSE reads the app's existing raw database key (on iOS with its salt) from the shared Keychain; both the read-only schema probe and every pooled connection are keyed before reading pages. Missing or invalid keys never trigger generation. The iOS 32-byte readable header and WAL keep shared databases recognizable during suspension; macOS uses SQLCipher's default encrypted header. CoreCrypto's API still opens or creates/migrates its store, so readiness is a practical guard, not a stricter native contract.
Cancellation stops new work and drains resource users before network, crypto and SQLite close. Unlock only after successful cleanup. A failed native free may mark its handle destroyed before releasing the resource, so a later no-op close is not proof of cleanup. Preserve that failure and retain the runtime/lease until process exit. App live calling teardown first cancels and joins Kotlin callback work, then destroys the AVS handle and waits for its native shutdown callback before disposing the callback reference and releasing account ownership. A cancelled or incomplete shutdown retains the handle for a later close attempt. An exception during native creation leaves ownership uncertain and retains the account lease until process exit. Extension processes are reused, so an extension that retained a lease this way ends its process once it has delivered its result, and the kernel releases the lease. The NSE event-only AVS API instead retains one account/client handle and callback context for the process lifetime. Its request callbacks are attached only for the serialized start/process/end batch; this retained context owns no database, crypto, network resource or account lease. These rules prevent simultaneous native access and use-after-close.
The app and NSE share Events in insertion order. Convert app live rows to pending
before NSE processing. Store each complete REST page and its fetch cursor in one
SQLite transaction; only the last non-transient event advances the existing app
cursor. Resume already stored pending work before fetching more. No separate queue,
REST ordering table or notification acknowledgement store is added.
The app processes Events strictly in order. The NSE may defer some message-only
notifications and process later independent ones, so it can acknowledge rows out
of order; see conversation isolation and deferral.
Any other failure fails the attempt.
For each notification, the NSE uses the app's Proteus-then-MLS nesting order:
Required failures throw out of the native callback; returning an error value would
commit CoreCrypto. Restore the attempt's cancellation context inside its native
NonCancellable wrapper, and await unwind before releasing resources. NSE attempts
rethrow a callback error only after CoreCrypto confirms the abort; native abort and
commit errors take precedence. The app retains its gathered-batch boundary; it does
not adopt the NSE executor.
These are separate stores. SQL can commit before native crypto, and MLS can commit before Proteus. Retry decrypts again, reapplies required fields and uses canonical message IDs/native duplicate handling. MLS payloads covered by a recovery receipt are not decrypted again; covered Proteus payloads are decrypted to advance the session, but their effects are skipped. A stored system message acknowledges only its own content, not the notification or every adjacent write. Reset, recreate-after-wipe and deleted-peer guards remain where losing metadata or a native group would destroy replay inputs.
There are no notification receipts, general outcome journals or startup recovery mode. Besides the call signal journal and the account invalidation marker, narrow durable records exist only where replay would otherwise lose inputs:
The NSE requires the prepared current schema version and never migrates SQLite.
Only the Apple NSE enables this scan. It walks every pending row in insertion order. A notification is isolatable when every payload is a Proteus or MLS new message in a known, non-self conversation. Its dependencies are those conversations, the Proteus sender sessions and the MLS groups.
An isolatable notification is deferred when, before persistence, it fails with no
network, a federation failure, a 5xx response, or MLS WrongEpoch,
UnmergedPendingGroup, CommitForMissingProposal, ConversationNotFound or
OrphanWelcome, and every payload it decoded before the failure can cross a gap.
Otherwise the failure fails the attempt. On deferral the native transactions roll
back, a 30-second retry hint is stored in Metadata (nse-deferred-v1:<eventId>,
removed on acknowledgement) and the scan continues. Later notifications that share
a dependency with a deferred one are skipped. check/error failures, SQL errors
and native commit errors never defer.
After a deferral, a later notification can be processed only when every decoded
payload can cross the gap: Text, Knock, Location, Asset, Composite, Poll, Multipart,
Ignored and Unknown content, or Creation, System, Profiles, DecryptionFailure,
Proposal and ClearProposalTimer payloads. Any other payload, or a non-isolatable
notification, is an ordering barrier and stops the scan. Results stay SUCCESS
and silent:
DEFERRED_CONVERSATION: the target was deferred or skipped.DEFERRED_ORDERING_BARRIER: the scan stopped at a barrier, or the target was
already processed while other rows remain pending.DEFERRED_PRESENTATION_POLICY: the target was processed after a deferral. Alert
fields are cleared, AVS is not evaluated and only callSignalsPending is set.Encrypted remote mute and in-call emoji are NSE no-ops but still pass through common decryption and replay handling, and count as conversation dependencies. Typing is skipped at DTO dispatch and adds no dependency.
For every push the NSE runs the app's legacy catch-up protocol
(EventIngestion) before processing, like the Android app's one-shot sync. It opens
the WebSocket, stores the pending REST pages fetched once it is open, then closes
and joins the socket at live, so the socket is never open while the NSE processes.
Pages are fetched only after the socket opens, so no event falls between a page and
the socket. As in the app, catching up needs the socket: if it fails or does not
reach live within the catch-up budget, the catch-up fails, and nothing is paged
without it. Catch-up gets 60% of the attempt's budget; the isolated scan
then processes the stored queue to its end, without fetching, and stops before the
next event at 85%. Barriers and app handoffs stop the scan. Calls are evaluated only
with a complete prefix. Unread messages use the unnotified-message rules below. When
catch-up cannot finish, or fails or stops processing before the target with nothing
to show, the result asks the host for a generic alert: unseen events may remain.
There is no REST-only receiving mode.
A client registered for consumable notifications has no REST stream to page. Its catch-up consumes its own session: each event is stored before it is acknowledged, on that open session only, and the session is live when the backend confirms its marker. Events after the marker stay unacknowledged and are delivered again. A storage or acknowledgement failure ends catch-up rather than skipping the event. A missed-notification signal stops catch-up without acknowledging it, so the app's next connection receives it and recovers with slow sync; the host shows a generic alert. No Kalium caller registers this capability yet.
Required permission/member writes, sender authorization, crypto integrity and physical cleanup remain enforced. Exact access-change, promotion and connection- warning history is best effort: after SQL commit/native abort, the original no-change input may be gone and retry may add an extra system message. This does not justify blocking the entire notification and its tail.
An access event before an unknown-conversation fetch initially has no row to update. On retry it updates the now-existing row, following the app's direct field flow; access may differ from the first attempt's fetched snapshot. No prior-state journal or extra fetch is added solely to reproduce that snapshot.
Repeated text identities use existing canonical message/sender checks instead of a blanket batch rejection. Typing, remote mute and in-call reactions are transient app UI/call state; the owned NSE has no active call or live observers and ignores those signals. Unknown event types, property keys/values and feature settings follow the app's no-op policy. Required undecodable work retains an explicit unsupported boundary.
Preview metadata and quotes are durable. Optional image downloads run within the owned attempt, validate encryption inputs/hash/CBC and cache successful files. Download/image failure leaves text usable; cancellation still unwinds the attempt. Unreferenced temporary files are cleaned, and required deletion keeps stored paths until file cleanup succeeds. No preview queue or detached worker is added.
Use existing durable app consumers: pending deliveries, proposal timers/native pending proposals, expiration metadata, asset/Cells records, client-change rows and configuration notification flags. The NSE sends no optional proposal commit, starts no expiration timer and creates no replacement consumer. Native proposal responses must be consumed by the app's existing scheduler.
When the app must run the effect itself, the NSE stops, returns SUCCESS with
appWorkPending and leaves the event and later queue in Events. The app's
incremental sync runs the matching consumer before gathering:
ACCOUNT_INVALIDATION: a stored pending row removes this client or deletes the
account. The NSE checks this before processing any row, so earlier rows stay
pending too. It writes the invalidation marker and revokes readiness without
deleting stores; the app runs the same offline scan and logs out with the
matching reason instead of processing the queue.LEGAL_HOLD: a user's legal hold is enabled or disabled, app legal-hold
transitions are pending, or a membership change or fetched profile disagrees with
the stored legal-hold status. The app recovers its transition journals, then
processes the events normally; for enable and disable it refreshes the user's
clients first. Every later attempt stops at the same event until the app runs, so a
push whose event waits behind it asks for a generic alert unless the app presents
alerts. The self user's legal-hold request is a single stored value, which the NSE
writes as the app does.The Apple app's pre-sync cleanup also retries pending MLS group wipes queued by local removal.
Presentation is selected from final canonical state. Actions/system messages alone are silent. Read/mute/archive/availability, verification, expiration and host privacy settings determine alert/body eligibility.
An attempt can process messages whose own pushes never present them: an earlier push
found the account busy or ran out of time, APNs kept only the latest push while the
device was offline, or pushes arrived out of order. Like the Android app, the NSE
selects these from existing unread state rather than its own record: unread messages
newer than their conversation's last_notified_date (Notification.sq), rechecked
with the receiving eligibility rules. Only an attempt that processed every event
through its target, without deferral or app handoff, presents them; a later complete
attempt presents what a deferral held back. The request keeps its own alert, or shows
the newest unnotified message when it has none, and returns at most ten others for
the host to post. The NSE then advances each candidate conversation's date, so
interruption before the host posts can lose those alerts but a later attempt cannot
repeat them. Messages older than six hours are marked without an alert.
While it runs, the app maintains the same date as the Android app does: it marks each conversation notified through the newest message it presented, and marks the open conversation even when it shows no alert. Otherwise the NSE alerts unread messages the app received; the age bound limits those, such as after an upgrade. The NSE registers alerted messages and returns delivered identifiers whose message is now deleted or read, for the host to remove; updating delivered alerts remains optional. The host owns the single system completion callback.
The NSE collects committed calling signals through the target, evaluates one AVS event-only batch after sync and returns its callbacks for the host to report to CallKit. Committed signals are retained in the account's call signal journal for replay through the full app engine, which owns ringing and termination. Besides storing committed signals, the NSE writes only a once-per-signal missed-call claim. AVS exposes no message or session identity, so identical callback references cannot distinguish separate real calls. As with ordinary alerts, interruption after processing can lose presentation; exactly-once reporting is not claimed.
The NSE receives a bounded, known set of event families; the NSE README lists them with the dependencies that keep the rest pending. Destructive MLS replay cannot be enabled by discarding its required effects.
Known gaps follow from the decisions above:
Other error, so the NSE cannot turn either into a failed-message row.Date: 2026-10-13
Accepted. Amended 2026-10-25: uploads have a budget and give the account up when the app needs it.
The iOS share extension must send a message to a conversation the user picks; later, notification actions may send replies, read receipts and delivery receipts. Sending requires a live account: Proteus needs current recipient clients and sessions, and MLS needs the current epoch, otherwise the backend rejects the message (Proteus 412, MLS 409 stale message).
ADR 11 gives extensions an exclusive per-account lease and a
slim runtime without CoreLogic, and the NSE can catch up to live over the WebSocket (bounded
incremental sync). Message sending, however,
lives in :logic (MessageSenderImpl and its envelope, session, MLS, failure and legal-hold
collaborators), coupled to :logic repositories and SyncManager.
An extension cannot launch its containing app, has no reliable background time after it is dismissed, and runs under tight memory limits. The backend sends no push for events it delivers over an open legacy WebSocket.
Extensions send through a slim sender in :domain:messaging:sending, below :logic, composed from
the same implementation the app uses. :logic keeps its public use cases and adapts its repositories
to the sender's narrow ports; extensions adapt the slim runtime's DAOs and APIs. There is one sending
implementation, and no CoreLogic in extensions.
While an extension sends, it keeps the account live: it holds the lease, keeps the WebSocket open, and ingests and processes incoming events, so MLS commits and client changes arrive during the send. Because the backend sends no push for events delivered over that socket, the extension owns their presentation: processed messages owe alerts (ADR 11), and the extension posts them before it releases the account. Before the hold ends it closes and joins the socket.
As the app's sender waits for live, an extension sends only once the socket is live. Pages are fetched only after the socket opens, so no event falls between a page and the socket. When the socket does not reach live within its budget, the extension does not send and stores no message. Like the Android app's one-shot sync, nothing is paged after the socket closes: a frame it delivered but had not stored got no push and is not alerted until the next sync stores it (ADR 11).
The sender never weakens a consent or verification rule to finish in an extension. When sending would need app-only work (a legal-hold change that requires consent, an unacknowledged verification change, an MLS group the client must join or reset, pending proposals that need a commit, a stale epoch, a slow sync), the extension keeps the message as failed and asks the user to open Wire.
The extension processes stored events before it sends. The sender recovers from a stale epoch inside its crypto transaction, and processing events there would deadlock, so the extension declines that recovery instead of waiting for live.
The first share extension sends text, including links as text, to one conversation chosen from the app's conversation list components.
Files follow in the same extension. Uploads can outlive a dismissed extension, so the share sheet stays open while they run: the extension encrypts and uploads every file before it holds the account live, reports progress, and stops when the user cancels. Uploading needs the account's tokens, so the extension holds the account meanwhile, but not its socket, and the live hold's budget starts after the uploads. Holding the account keeps it from the app and the NSE, so uploads have a budget of their own, enough at about 1 Mbit/s and at most five minutes, and they stop when the app needs the account: the app posts a Darwin notification when it enters the foreground or a call arrives, and the extension stops when its host leaves the foreground, because iOS then suspends it with the account held. The extension then releases the account and hands the share to the app (ADR 13), which uploads the files again. The same applies once the hold's socket is live, while it catches up; the connection itself is not interrupted. Once the extension stored a message, the send finishes within its budget: stopping it then could send the message twice. Nothing is stored until every upload succeeded; each file then becomes an uploaded asset message, so a message that fails to send is retried in the app without uploading again. The extension follows the app's file rules: file sharing, allowed types and size limits. Cells files use the app's Cells transport as unpublished drafts. One multipart message is stored with its caption before publication. Before storage, cancellation or rejection cancels those draft versions. After storage, the message owns publication and retry; a failed publish keeps the message retryable. Publication checks server state so retrying a sent-but-unacknowledged publish does not publish the same version again. This path takes explicit files and never reads or consumes the conversation's composer draft.
The app's sending was extracted step by step, keeping its behaviour unchanged and its tests covering
the moved code through the app's adapters: Proteus envelope creation (MessageEnvelopeCreator),
session establishment (SessionEstablisher), MLS message creation (MLSMessageCreator), send-failure
handling (MessageSendFailureHandler) and the sending orchestration (MessageSenderImpl), each with
narrow ports. Extension adapters over the slim runtime provide a bounded hold that keeps the socket
open, processes incoming events and posts unnotified message alerts through the host. The share
extension sends text and files.
The app and extensions share one sending path, so changes to it affect both. Notification actions and receipts can reuse the slim sender later. The hold still needs validation on signed devices (memory, time and push suppression while the socket is open).
Date: 2026-10-17
Accepted
ADR 11 lets one process at a time own an account: the app while it is in the foreground or in a call, otherwise the NSE or the share extension, briefly. ADR 12 lets the share extension send by owning the account and keeping it live.
When the app owns the account, the share extension cannot. This is a normal situation: the user shares from inside Wire to Wire, shares from another app during a Wire call, or uses iPad split view with Wire in the foreground. Today the sheet says "Wire is busy" before the user can pick a conversation, because listing conversations also opens the account.
The extension must not act as a second owner. It may not write the account database, use CoreCrypto, refresh tokens, upload, or open a WebSocket while the app owns the account. A pending message written behind the app would not be sent either: on iOS, Kalium does not schedule pending messages, and the app's queries see another process's writes only when it takes the account back.
iOS does not let an app hide its own share extension from its share sheet: excludedActivityTypes
applies only to system activities.
When the app owns the account, the share extension hands the share to the app. The app sends it with its own use cases, as if the user had sent it from the conversation.
The outbox. Each account has an outbox in the App Group, outside the account directory and named
with ADR 11's account hash. An entry is a directory with a request and the extension's own copies of
the files. The request names the conversation, the text, each file's name, type and media size, and one
message ID per item. The extension writes the entry under staging/ and publishes it by renaming it into
ready/. A rename within one file system is atomic, so the app never sees a partial entry. The
extension then posts a Darwin notification. Entries are protected until first unlock, because the app
may import during a call while the device is locked, and are excluded from backups.
Claiming. The app claims an entry by renaming it from ready/ to claimed/, which also tells the
extension that the app has it. The app claims only while it owns the account, whether or not it is live
yet: when notified, and when it takes an account. Within the app, one importer at a time serves an
account. It claims on every notification, even while an earlier import waits for the account to catch up, and
imports one share at a time.
Importing. The app imports files first, then the text, as ADR 12 sends them. Each item keeps its message ID from the request, and the app skips an item whose message already exists. A crash or a background handoff during an import therefore stores nothing twice: the next import finishes the entry. Catching up can change the rules, so the app first waits until the account caught up, then checks the conversation with the extension's own rules (ADR 12's share blocks and file rules). If a rule now refuses it, the app stores nothing. The import stores each message itself; only sending goes on without it, so an import cancelled before a message is stored stores and sends nothing. Stored messages belong to the app: they upload, send, fail and retry in the conversation like messages from the composer. The app then writes a result for the extension (stored, or the reason it refused) and removes the entry.
The picker. The extension cannot list conversations while the app owns the account, and it must not open the app's database. It asks the app with a Darwin notification. For each account it owns, the app writes a snapshot: the account name, its file rules, and the conversations the extension may share to, built by the same code as the extension's own list. The extension deletes any old snapshot before it asks, and deletes each snapshot once read. A snapshot holds conversation names, so it is written only on request, protected while the device is locked, and excluded from backups. The app answers these requests on their own, so a share being imported never keeps a sheet waiting for its list.
The sheet. When the app does not answer, the account is busy for another reason, usually the NSE. Then the extension retries opening the account itself until its budget ends. If the account becomes busy between listing and sending, the extension retries sending for a moment, then hands the share off. It also hands the share off when the app wants the account while the extension uploads, or when uploading takes too long (ADR 12). The sheet waits a few seconds for the app to claim the share, and a few more for its result. If the app writes a refusal in time, the sheet shows the reason. Otherwise the sheet says Wire is sending the share, or, if the app has not claimed it, that Wire will send it when Wire is opened. It also marks the entry as unwatched: from then on, the app tells the user with a local notification if it refuses the share. The app writes its result before it looks for the mark, and the sheet marks before it looks one last time for the result, so at least one of them sees the result; only one takes it. The sheet offers no cancel while it waits.
Not a second lock. A lock that only the app holds would tell the extension that the app owns the account. It would not tell it that the app will take the share, and checking two locks one after the other races with the app releasing them. The app's claim answers both.
Kalium's share module holds the outbox (entries, claims, results and snapshots), which the extension and
the app both use, and the app's importer and snapshot writer, which take the app's sending as ports.
The app runs them with serveShareExtension, which stores each item through the composer's text and
asset use cases under the item's message ID. The iOS app posts and observes the Darwin
notifications and runs the importer in each account's foreground and call work; the share extension
shows the snapshot picker, hands the share to the app, waits for its answer and falls back as described
above.
Only the app imports. A share left in the outbox waits for the app to own the account live: the share extension and the NSE do not send other shares, which keeps their memory and time budgets unchanged. Entries and snapshots add plaintext copies in the App Group, protected like the account's own files and removed once imported or read. A share that a rule refuses at import was shown as allowed in the picker; the sheet reports it if the app's answer arrives in time, and the app reports it otherwise.
Date: 2026-09-05
Accepted
The existing message timeline uses numeric offsets over MessageDetailsView and returns full
Message.Standalone objects. MessageExtensionsImpl.getPagingSource uses
AsyncQueryPagingSource. Each load counts messages and selects from MessageDetailsView with
ORDER BY date DESC LIMIT ... OFFSET .... The view includes full sender details, asset credentials,
unknown and failed-decryption payloads, quotes, and collection aggregates. Multipart attachments
then require one additional query per multipart message.
The Android app requests 20 messages per page, 60 initially, and prefetches at a distance of 30. Its mappers use only part of each full message, but also need enough information for every supported bubble, system event, status, quote, reaction, and attachment. Some system events and delivery failures cause additional user queries during per-message mapping. Android also uses placeholders and numeric positions for unread and searched-message navigation.
These are code findings, not measured performance results.
Add a separate API that uses keyset pagination over message keys and a dedicated MessageListItem
model containing the data needed to render the timeline. The new path avoids deep-offset traversal
and unused message details. See the API guide for session lifetime,
anchors, and adoption.
creation_date DESC, id DESC. Use the exact stored timestamp and
SQLite's ID ordering in the cursor. The ID resolves timestamp ties deterministically.MessageListItem contract. Keep rendering data and references for actions.
Resolve full details through existing message and asset APIs when an action needs them.
Do not construct full messages and discard fields afterward.The active path is chronological. PendingMessagesPagingSource and pending-specific SQL exist,
but getPagerForConversation does not use them. The keyset API preserves the active ordering.
A future pending-first mode needs a separate ordering contract.
Client adoption waits for these gates. Release gates are semantic parity, no offsets or per-load total count in the new timeline path, indexed cursor traversal, page-scoped child queries, and lower measured data/allocation cost on the mixed-content fixtures. Deep-page timing must not grow with the number of earlier pages; investigate filter selectivity and query plans if it does. First-load and write regressions need an explicit explanation before adoption. Set device-specific latency and memory thresholds from a recorded baseline rather than a predicted percentage improvement.
The message timeline benchmarks cover offset/full entities, keyset page assembly, public list mapping, and around-message loads on the JVM and Android. An isolated keyset/full-view control still needs coverage. Device measurements also need the iOS production driver, cold/warm runs, p50/p95 query and end-to-end mapping time, SQL statement count, mapped object count, allocated bytes, retained heap, index size, write cost, and frame timing while scrolling and applying incoming messages.
Adding only a cursor to MessageDetailsView would reduce offset work while retaining unnecessary
data and per-message attachment queries. A smaller projection with offsets would retain deep-page
traversal. The keyset API addresses both costs.
A permanently materialized timeline table would require extra writes and synchronization when users, receipts, reactions, quotes, or attachments change. Start with indexed reads from existing tables and page-scoped batch queries. Consider persisted summaries only if measurements show a remaining problem that needs them.
Keyset pages do not provide arbitrary global row numbers. Android must migrate navigation, scroll-to-latest, and refresh anchoring together with paging. Equal-timestamp messages gain a defined order that the existing query does not specify. Relevant writes can still move messages; generation invalidation and anchor restoration handle that case.
Support and test both pagination APIs during the initial adoption period. Target deprecation of the offset timeline API a few months after the new API ships, with the exact release recorded in the migration tracking issue. Deprecation keeps the old API available and working; it does not remove it. Decide removal separately after consumers migrate and the supported compatibility window closes, following ADR 9. Do not add an offset-to-cursor adapter that scans history or silently route existing callers through the new contract. Full-message detail APIs remain supported. Search-result lists, media galleries, exports, and remote history cursors remain separate work.
SQLite documents the deep-offset cost and indexed tuple alternative in its
scrolling-window example.
Android Paging supports custom keys and refresh anchoring through
PagingSource.
Date: 2026-09-04
Accepted
The existing conversation-list flow is a public compatibility surface built on a wide joined view, a count query, and offset pagination. Deep loads still scan or sort preceding rows, unread aggregation is not restricted to the requested page, and the returned conversation details contain substantially more data than the list renders.
Add a separate common Kalium conversation-list flow and leave the legacy flow, models, configuration, and behavior unchanged. The new public API will return a dedicated sealed ConversationListItem hierarchy for the four currently paginated sources: all conversations, groups, channels, and one-to-one conversations; archive mode and contains-search remain supported, while favorites and custom folders remain on their existing flows.
The conversation list guide describes the public contract.
Order eligible conversations only by stored Conversation.last_modified_date descending and qualified conversation ID ascending. Do not order by name, unread state, pending-connection update time, or joinable calls, and do not change the existing writers of last_modified_date. A public ConversationListPosition will contain the old activity time and conversation-ID tie-breaker so the app can restore the same approximate ordering neighborhood without following a conversation that later moved.
Use a cursor-keyed Paging source with no count, placeholders, or arbitrary jumps. Normal loading appends older items; prepend and centered refresh are also supported so an invalidated or restored deep viewport can reach both newer and older items. Android will continue to own pixel-offset and query-identity persistence.
For each load direction, use an AS MATERIALIZED CTE to select the eligible page keys and apply the keyset boundary, ordering, and limit there. Hydrate only those keys with the narrow row projection, saved-draft or last-message preview, and five page-local unread counters. This requires SQLite 3.35 or newer; bundling a modern SQLite binary for the unencrypted Android path and supporting older SQLite runtimes are outside this decision.
Reuse the existing conversation-list membership, order, last-message, and unread invalidation keys, together with the MessageDraft table notification key. Draft writes invalidate loaded pages so previews update without rebuilding the pager on return. Folder changes and call changes do not invalidate the new Paging source. Joinable-call state remains an Android-observed overlay rather than part of the Kalium page or its ordering.
Add a global composite recency index and benchmark a second type-prefixed recency index. Ship the type-prefixed index only if it materially improves sparse filtered sources without increasing end-to-end message-write median or p95 latency by more than 10 percent.
Connection.last_update_date; they use the stored conversation activity time like every other list item.Date: 2026-10-06
Accepted
Polls are currently possible only through services or bots that send composite
messages with buttons. Composite messages store a text body and a list of
buttons. ButtonAction tells the composite author which button a user picked,
and ButtonActionConfirmation from the author marks the self user's choice as
selected. The bot owns the result. Clients store only their own selection, so
they cannot show counts, percentages, or who voted for what.
Bot polls must keep working for existing service integrations.
Add native poll content to Kalium as a separate message type. Do not extend
composite messages. Bot polls keep COMPOSITE, ButtonContent, ButtonAction,
and ButtonActionConfirmation unchanged.
Add two GenericMessage oneof entries:
Poll poll = 31, a regular timeline message with the question, ordered
options (stable id and text), allow_multiple_answers, and the standard
expects_read_confirmation and legal_hold_status flags. The message id is
the poll id.PollVote pollVote = 32, a signaling message with the poll message id and
the sender's complete current selection. An empty selection retracts the vote.Both are sent through the normal end-to-end encrypted pipeline. No bot or server takes part in voting or tallying.
Votes are never anonymous. Every client receives the sender of each encrypted vote and needs it to keep one current vote per user. A setting that only hides names in the UI would suggest an anonymity guarantee that the protocol does not give, so there is no such setting.
A Poll is sent with unknownStrategy = DISCARD_AND_WARN. Clients without poll
support show an unsupported-message notice instead of silently dropping it.
PollVote keeps the default IGNORE. The ephemeral message format cannot carry
polls, so polls are never self-deleting. Clients should not offer poll creation
while self-deleting messages are active.
PollContent stores the question, allow_multiple_answers, and the options
as JSON encoded in Kotlin. Options never change after creation. Kotlin
encoding means option text is never escaped by SQL string concatenation.PollVoteContent stores the latest vote of each user, keyed by conversation,
poll, and voter. A vote replaces the stored vote only if it is not older, so
out-of-order delivery cannot bring back an old selection. A retraction is
stored as an empty selection for the same reason.PollMessageDAO.recordVote checks this in the same transaction that
stores the vote. Invalid votes and votes for unknown polls are dropped and never
buffered, so a client's result only depends on the history it has.POLL counts as a last-message type. Conversation lists, previews, and
notifications show the poll question.MessageListContent.Poll, and a new vote invalidates the open timeline.SendPollMessageUseCase validates 2 to 10 distinct, non-blank options and a
non-blank question. It persists the poll and sends it like a location message,
including the pending-message retry.
SendPollVoteUseCase stores the self user's vote before sending it, so results
update at once. If sending fails, it restores the previous vote, unless a newer
vote replaced it in the meantime, and returns a failure so the UI can tell the
user.
A text message can quote a poll. A quote carries the SHA-256 of the quoted message, so a reader can check that the person replying saw the same content. Proteus encrypts a copy for each recipient, so a sender could show recipients different options under one poll id. The poll hash therefore covers everything that is fixed at creation, in this order:
FE FF;allow_multiple_answers, as one byte, 1 or 0;Each string is UTF-16BE, preceded by its length in bytes as a 4-byte big-endian
integer, so two different polls never encode to the same bytes. Votes are not
part of the hash. QuotedMessageDetails.Poll gives the UI the question. Clients
without poll support cannot verify the quote and show it as unavailable, like a
quote of any message they cannot read.
Poll creation and voting stay end-to-end encrypted and work without bots. Every client derives the same result from the votes it has received. A client that missed the original poll ignores later votes for it.
Older clients show an unsupported-message notice for native polls. Bot polls behave as before.
The UI decides how to present the results. It must not suggest that votes are anonymous.
An application may need to finish an asset message send after its conversation screen closes and keep its account available until sending or cancellation has finished. Kalium exposes a tracked upload handle with an identity for each attempt, the persisted message identity, terminal completion and cancellation for that attempt. Completion follows both the asset upload and the final message send, including removal of the message-visibility monitor and cancellation cleanup; the existing scheduler remains available for callers that only need confirmation of local persistence.
A caller cancelled before receiving a handle waits for any started attempt to stop; cancellation marks the persisted message as failed so it remains retryable. Retries recheck the current file-sharing policy, retain the persisted message identity and reuse its remote asset when uploading already succeeded. Tracked attempts disable automatic pending-message resend so an application retry has one owner; the existing scheduler retains its configured resend behavior. Progress reports actual lifecycle phases because the current upload repository exposes no transferred-byte count.
Persisted-message retries can use RetryFailedMessageAndAwaitUseCase.invoke to keep upload and final send in the caller's structured lifetime. Cancellation leaves unfinished messages retryable; an already successful send is not rewritten as failed.
Cells uses its existing coordinator with a cancellation barrier that joins the selected worker and transfer cleanup. Attachment drafts opt into a session-owned registry: accepted operations wait for an application owner to claim them before preparation starts. Cancellation captures a particular transfer attempt, so delayed cleanup cannot cancel a later retry of the same node. Terminal outcomes are explicit; disappearance from the active upload map is not evidence of success. Android callers that do not opt in retain the existing scheduling path.