Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/*
* Wire
* Copyright (C) 2026 Wire Swiss GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see http://www.gnu.org/licenses/.
*/
package com.wire.android.session

import com.wire.android.di.KaliumCoreLogic
import com.wire.kalium.logic.CoreLogic
import com.wire.kalium.logic.PrepareUserSessionResult
import com.wire.kalium.logic.UserSessionPreparationFailure
import com.wire.kalium.logic.UserSessionPreparationState
import com.wire.kalium.logic.data.user.UserId
import com.wire.kalium.logic.feature.UserSessionScope
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
import kotlinx.coroutines.flow.Flow

/**
* The Android-side boundary for every operation that needs a user database.
*
* This class intentionally does not start or cache a second migration operation. Each caller
* delegates to Kalium, which single-flights concurrent preparation for the physical database.
*/
@SingleIn(AppScope::class)
class UserSessionPreparationGate @Inject constructor(
@KaliumCoreLogic private val coreLogic: CoreLogic,
) {
suspend fun prepare(userId: UserId): AppUserSessionPreparationResult =
coreLogic.prepareUserSession(userId).toAppResult()

fun observe(userId: UserId): Flow<UserSessionPreparationState> =
coreLogic.observeUserSessionPreparation(userId)
}

sealed interface AppUserSessionPreparationResult {
data class Ready(val sessionScope: UserSessionScope) : AppUserSessionPreparationResult

data class Failed(
val reason: UserSessionPreparationFailure,
val canRetry: Boolean,
) : AppUserSessionPreparationResult
}

internal fun PrepareUserSessionResult.toAppResult(): AppUserSessionPreparationResult = when (this) {
is PrepareUserSessionResult.Success -> AppUserSessionPreparationResult.Ready(sessionScope)
is PrepareUserSessionResult.Failure -> AppUserSessionPreparationResult.Failed(
reason = reason,
canRetry = reason is UserSessionPreparationFailure.InsufficientStorage ||
reason is UserSessionPreparationFailure.TemporarilyUnavailable,
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/*
* Wire
* Copyright (C) 2026 Wire Swiss GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see http://www.gnu.org/licenses/.
*/
package com.wire.android.session

import com.wire.kalium.logic.CoreLogic
import com.wire.kalium.logic.PrepareUserSessionResult
import com.wire.kalium.logic.UserSessionPreparationFailure
import com.wire.kalium.logic.data.user.UserId
import com.wire.kalium.logic.feature.UserSessionScope
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.async
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertSame
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test

class UserSessionPreparationGateTest {

@Test
fun givenKaliumReturnsReadySession_whenPreparing_thenReturnedScopeIsReused() = runTest {
val coreLogic = mockk<CoreLogic>()
val sessionScope = mockk<UserSessionScope>()
coEvery { coreLogic.prepareUserSession(USER_ID) } returns success(sessionScope)

val result = UserSessionPreparationGate(coreLogic).prepare(USER_ID)

assertSame(sessionScope, (result as AppUserSessionPreparationResult.Ready).sessionScope)
}

@Test
fun givenConcurrentAppCallers_whenPreparing_thenBothAwaitKaliumSharedResult() = runTest {
val coreLogic = mockk<CoreLogic>()
val sessionScope = mockk<UserSessionScope>()
val kaliumResult = CompletableDeferred<PrepareUserSessionResult>()
coEvery { coreLogic.prepareUserSession(USER_ID) } coAnswers { kaliumResult.await() }
val gate = UserSessionPreparationGate(coreLogic)

val foreground = async { gate.prepare(USER_ID) }
val background = async { gate.prepare(USER_ID) }
runCurrent()
coVerify(exactly = 2) { coreLogic.prepareUserSession(USER_ID) }

kaliumResult.complete(success(sessionScope))

assertSame(
sessionScope,
(foreground.await() as AppUserSessionPreparationResult.Ready).sessionScope,
)
assertSame(
sessionScope,
(background.await() as AppUserSessionPreparationResult.Ready).sessionScope,
)
}

@Test
fun givenPublicPreparationFailures_whenMapping_thenRetryabilityIsExplicit() {
val failures = listOf(
UserSessionPreparationFailure.InsufficientStorage to true,
UserSessionPreparationFailure.TemporarilyUnavailable to true,
UserSessionPreparationFailure.ApplicationUpdateRequired to false,
UserSessionPreparationFailure.SupportRequired to false,
)

failures.forEach { (reason, expectedCanRetry) ->
val result = failure(reason).toAppResult() as AppUserSessionPreparationResult.Failed
assertEquals(reason, result.reason)
if (expectedCanRetry) assertTrue(result.canRetry) else assertFalse(result.canRetry)
}
}

private fun success(sessionScope: UserSessionScope): PrepareUserSessionResult.Success =
mockk<PrepareUserSessionResult.Success>().also {
every { it.sessionScope } returns sessionScope
}

private fun failure(reason: UserSessionPreparationFailure): PrepareUserSessionResult.Failure =
mockk<PrepareUserSessionResult.Failure>().also {
every { it.reason } returns reason
}

private companion object {
val USER_ID = UserId("user", "wire.test")
}
}
2 changes: 1 addition & 1 deletion kalium
Submodule kalium updated 29 files
+9 −0 changelog.d/user-session-preparation-api.added.md
+41 −1 data/persistence/src/androidMain/kotlin/com/wire/kalium/persistence/db/UserDatabase.kt
+42 −2 data/persistence/src/appleMain/kotlin/com/wire/kalium/persistence/db/UserDatabase.kt
+58 −0 data/persistence/src/commonMain/kotlin/com/wire/kalium/persistence/db/UserDatabaseBuilder.kt
+62 −0 data/persistence/src/commonTest/kotlin/com/wire/kalium/persistence/db/UserDatabaseMigrationObserverTest.kt
+18 −0 data/persistence/src/jsMain/kotlin/com/wire/kalium/persistence/db/UserDatabase.kt
+40 −1 data/persistence/src/jvmMain/kotlin/com/wire/kalium/persistence/db/UserDatabase.kt
+368 −0 docs/adr/0011-explicit-user-database-migration-lifecycle.md
+1 −15 domain/userstorage/build.gradle.kts
+49 −8 domain/userstorage/src/androidMain/kotlin/com/wire/kalium/userstorage/di/PlatformUserStorageProvider.kt
+49 −8 domain/userstorage/src/appleMain/kotlin/com/wire/kalium/userstorage/di/PlatformUserStorageProvider.kt
+103 −0 domain/userstorage/src/commonMain/kotlin/com/wire/kalium/userstorage/di/UserStoragePreparation.kt
+136 −10 domain/userstorage/src/commonMain/kotlin/com/wire/kalium/userstorage/di/UserStorageProvider.kt
+45 −8 domain/userstorage/src/jvmMain/kotlin/com/wire/kalium/userstorage/di/PlatformUserStorageProvider.kt
+242 −1 domain/userstorage/src/jvmTest/kotlin/com/wire/kalium/userstorage/di/UserStorageProviderTest.kt
+68 −0 logic/api/jvm/logic.api
+70 −0 logic/api/logic.klib.api
+5 −19 logic/src/androidMain/kotlin/com/wire/kalium/logic/feature/UserSessionScope.kt
+26 −1 logic/src/androidMain/kotlin/com/wire/kalium/logic/feature/UserSessionScopeProviderImpl.kt
+37 −15 logic/src/androidMain/kotlin/com/wire/kalium/logic/sync/WrapperWorker.kt
+5 −2 logic/src/appleMain/kotlin/com/wire/kalium/logic/feature/UserSessionScope.kt
+19 −7 logic/src/appleMain/kotlin/com/wire/kalium/logic/feature/UserSessionScopeProviderImpl.kt
+13 −0 logic/src/commonMain/kotlin/com/wire/kalium/logic/CoreLogicCommon.kt
+95 −0 logic/src/commonMain/kotlin/com/wire/kalium/logic/UserSessionPreparation.kt
+2 −7 logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/UserSessionScope.kt
+76 −6 logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/UserSessionScopeProvider.kt
+50 −0 logic/src/commonTest/kotlin/com/wire/kalium/logic/UserSessionPreparationTest.kt
+4 −1 logic/src/jvmMain/kotlin/com/wire/kalium/logic/feature/UserSessionScope.kt
+21 −10 logic/src/jvmMain/kotlin/com/wire/kalium/logic/feature/UserSessionScopeProviderImpl.kt
Loading