First Commit

This commit is contained in:
2025-12-18 16:28:50 +07:00
commit 8c3e4f491f
9974 changed files with 396488 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
plugins {
id("io.element.android-compose-library")
}
android {
namespace = "io.element.android.features.migration.api"
}
dependencies {
implementation(projects.libraries.architecture)
}
@@ -0,0 +1,23 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
interface MigrationEntryPoint {
@Composable
fun present(): MigrationState
@Composable
fun Render(
state: MigrationState,
modifier: Modifier,
)
}
@@ -0,0 +1,15 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.api
import io.element.android.libraries.architecture.AsyncData
data class MigrationState(
val migrationAction: AsyncData<Unit>,
)
+41
View File
@@ -0,0 +1,41 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
plugins {
id("io.element.android-compose-library")
}
android {
namespace = "io.element.android.features.migration.impl"
}
setupDependencyInjection()
dependencies {
implementation(projects.features.announcement.api)
implementation(projects.features.migration.api)
implementation(projects.libraries.architecture)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.preferences.impl)
implementation(libs.androidx.datastore.preferences)
implementation(projects.features.rageshake.api)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.sessionStorage.api)
implementation(projects.libraries.uiStrings)
testCommonDependencies(libs)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.sessionStorage.test)
testImplementation(projects.libraries.preferences.test)
testImplementation(projects.features.announcement.test)
testImplementation(projects.features.rageshake.test)
}
@@ -0,0 +1,33 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.migration.impl
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.api.MigrationEntryPoint
import io.element.android.features.api.MigrationState
@ContributesBinding(AppScope::class)
class DefaultMigrationEntryPoint(
private val migrationPresenter: MigrationPresenter,
) : MigrationEntryPoint {
@Composable
override fun present(): MigrationState = migrationPresenter.present()
@Composable
override fun Render(
state: MigrationState,
modifier: Modifier,
) = MigrationView(
migrationState = state,
modifier = modifier,
)
}
@@ -0,0 +1,38 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.migration.impl
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.preferences.api.store.PreferenceDataStoreFactory
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
private val applicationMigrationVersion = intPreferencesKey("applicationMigrationVersion")
@ContributesBinding(AppScope::class)
class DefaultMigrationStore(
preferenceDataStoreFactory: PreferenceDataStoreFactory,
) : MigrationStore {
private val store = preferenceDataStoreFactory.create("elementx_migration")
override suspend fun setApplicationMigrationVersion(version: Int) {
store.edit { prefs ->
prefs[applicationMigrationVersion] = version
}
}
override fun applicationMigrationVersion(): Flow<Int> {
return store.data.map { prefs ->
prefs[applicationMigrationVersion] ?: -1
}
}
}
@@ -0,0 +1,74 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.migration.impl
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
import io.element.android.features.api.MigrationState
import io.element.android.features.migration.impl.migrations.AppMigration
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import timber.log.Timber
@SingleIn(AppScope::class)
@Inject
class MigrationPresenter(
private val migrationStore: MigrationStore,
migrations: Set<@JvmSuppressWildcards AppMigration>,
) : Presenter<MigrationState> {
private val orderedMigrations = migrations.sortedBy { it.order }
private val lastMigration: Int = orderedMigrations.lastOrNull()?.order ?: 0
private var isFreshInstall = false
@Composable
override fun present(): MigrationState {
val migrationStoreVersion by remember {
migrationStore.applicationMigrationVersion()
}.collectAsState(initial = null)
var migrationAction: AsyncData<Unit> by remember { mutableStateOf(AsyncData.Uninitialized) }
// Uncomment this block to run the migration everytime
// LaunchedEffect(Unit) {
// Timber.d("Resetting migration version to 0")
// migrationStore.setApplicationMigrationVersion(0)
// }
LaunchedEffect(migrationStoreVersion) {
val migrationValue = migrationStoreVersion ?: return@LaunchedEffect
if (migrationValue == -1) {
Timber.d("Fresh install, or previous installed application did not have the migration mechanism.")
isFreshInstall = true
}
if (migrationValue == lastMigration) {
Timber.d("Current app migration version: $migrationValue. No migration needed.")
migrationAction = AsyncData.Success(Unit)
return@LaunchedEffect
}
migrationAction = AsyncData.Loading(Unit)
val nextMigration = orderedMigrations.firstOrNull { it.order > migrationValue }
if (nextMigration != null) {
Timber.d("Current app migration version: $migrationValue. Applying migration: ${nextMigration.order}")
nextMigration.migrate(isFreshInstall)
migrationStore.setApplicationMigrationVersion(nextMigration.order)
}
}
return MigrationState(
migrationAction = migrationAction,
)
}
}
@@ -0,0 +1,27 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.migration.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.api.MigrationState
import io.element.android.libraries.architecture.AsyncData
internal class MigrationStateProvider : PreviewParameterProvider<MigrationState> {
override val values: Sequence<MigrationState>
get() = sequenceOf(
aMigrationState(),
aMigrationState(migrationAction = AsyncData.Loading(Unit)),
)
}
internal fun aMigrationState(
migrationAction: AsyncData<Unit> = AsyncData.Uninitialized,
) = MigrationState(
migrationAction = migrationAction,
)
@@ -0,0 +1,26 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.migration.impl
import kotlinx.coroutines.flow.Flow
interface MigrationStore {
/**
* Return of flow of the current value for application migration version.
* If the value is not set, it will emit 0.
* If the emitted value is lower than the current application migration version, it means
* that a migration should occur, and at the end [setApplicationMigrationVersion] should be called.
*/
fun applicationMigrationVersion(): Flow<Int>
/**
* Set the application migration version, typically after a migration has been done.
*/
suspend fun setApplicationMigrationVersion(version: Int)
}
@@ -0,0 +1,57 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.migration.impl
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.features.api.MigrationState
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun MigrationView(
migrationState: MigrationState,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
CircularProgressIndicator()
if (migrationState.migrationAction.isLoading()) {
Text(text = stringResource(id = CommonStrings.common_please_wait))
}
}
}
}
@PreviewsDayNight
@Composable
internal fun MigrationViewPreview(
@PreviewParameter(MigrationStateProvider::class) state: MigrationState,
) = ElementPreview {
MigrationView(
migrationState = state,
)
}
@@ -0,0 +1,14 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.migration.impl.migrations
interface AppMigration {
val order: Int
suspend fun migrate(isFreshInstall: Boolean)
}
@@ -0,0 +1,29 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.migration.impl.migrations
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesIntoSet
import dev.zacsweers.metro.Inject
import io.element.android.features.rageshake.api.logs.LogFilesRemover
/**
* Remove existing logs from the device to remove any leaks of sensitive data.
*/
@ContributesIntoSet(AppScope::class)
@Inject
class AppMigration01(
private val logFilesRemover: LogFilesRemover,
) : AppMigration {
override val order: Int = 1
override suspend fun migrate(isFreshInstall: Boolean) {
logFilesRemover.perform()
}
}
@@ -0,0 +1,42 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.migration.impl.migrations
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesIntoSet
import dev.zacsweers.metro.Inject
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.preferences.api.store.SessionPreferencesStoreFactory
import io.element.android.libraries.sessionstorage.api.SessionStore
import kotlinx.coroutines.coroutineScope
/**
* This migration sets the skip session verification preference to true for all existing sessions.
* This way we don't force existing users to verify their session again.
*/
@ContributesIntoSet(AppScope::class)
@Inject
class AppMigration02(
private val sessionStore: SessionStore,
private val sessionPreferenceStoreFactory: SessionPreferencesStoreFactory,
) : AppMigration {
override val order: Int = 2
override suspend fun migrate(isFreshInstall: Boolean) {
coroutineScope {
for (session in sessionStore.getAllSessions()) {
val sessionId = SessionId(session.userId)
val preferences = sessionPreferenceStoreFactory.get(sessionId, this)
preferences.setSkipSessionVerification(true)
// This session preference store must be ephemeral since it's not created with the right coroutine scope
sessionPreferenceStoreFactory.remove(sessionId)
}
}
}
}
@@ -0,0 +1,28 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.migration.impl.migrations
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesIntoSet
import dev.zacsweers.metro.Inject
/**
* This performs the same operation as [AppMigration01], since we need to clear the local logs again.
*/
@ContributesIntoSet(AppScope::class)
@Inject
class AppMigration03(
private val migration01: AppMigration01,
) : AppMigration {
override val order: Int = 3
override suspend fun migrate(isFreshInstall: Boolean) {
migration01.migrate(isFreshInstall)
}
}
@@ -0,0 +1,34 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.migration.impl.migrations
import android.content.Context
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesIntoSet
import dev.zacsweers.metro.Inject
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.di.annotations.ApplicationContext
/**
* Remove notifications.bin file, used to store notification data locally.
*/
@ContributesIntoSet(AppScope::class)
@Inject
class AppMigration04(
@ApplicationContext private val context: Context,
) : AppMigration {
companion object {
internal const val NOTIFICATION_FILE_NAME = "notifications.bin"
}
override val order: Int = 4
override suspend fun migrate(isFreshInstall: Boolean) {
runCatchingExceptions { context.getDatabasePath(NOTIFICATION_FILE_NAME).delete() }
}
}
@@ -0,0 +1,35 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.migration.impl.migrations
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesIntoSet
import dev.zacsweers.metro.Inject
import io.element.android.libraries.di.BaseDirectory
import io.element.android.libraries.sessionstorage.api.SessionStore
import java.io.File
@ContributesIntoSet(AppScope::class)
@Inject
class AppMigration05(
private val sessionStore: SessionStore,
@BaseDirectory private val baseDirectory: File,
) : AppMigration {
override val order: Int = 5
override suspend fun migrate(isFreshInstall: Boolean) {
val allSessions = sessionStore.getAllSessions()
for (session in allSessions) {
if (session.sessionPath.isEmpty()) {
val sessionPath = File(baseDirectory, session.userId.replace(':', '_')).absolutePath
sessionStore.updateData(session.copy(sessionPath = sessionPath))
}
}
}
}
@@ -0,0 +1,54 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.migration.impl.migrations
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesIntoSet
import dev.zacsweers.metro.Inject
import io.element.android.libraries.di.CacheDirectory
import io.element.android.libraries.sessionstorage.api.SessionStore
import java.io.File
/**
* Create the cache directory for the existing sessions.
*/
@ContributesIntoSet(AppScope::class)
@Inject
class AppMigration06(
private val sessionStore: SessionStore,
@CacheDirectory private val cacheDirectory: File,
) : AppMigration {
override val order: Int = 6
override suspend fun migrate(isFreshInstall: Boolean) {
val allSessions = sessionStore.getAllSessions()
for (session in allSessions) {
if (session.cachePath.isEmpty()) {
val sessionFile = File(session.sessionPath)
val sessionFolder = sessionFile.name
val cachePath = File(cacheDirectory, sessionFolder)
sessionStore.updateData(session.copy(cachePath = cachePath.absolutePath))
// Move existing cache files
listOf(
"matrix-sdk-event-cache.sqlite3",
"matrix-sdk-event-cache.sqlite3-shm",
"matrix-sdk-event-cache.sqlite3-wal",
).map { fileName ->
File(sessionFile, fileName)
}.takeIf { files ->
files.all { it.exists() }
}?.forEach { cacheFile ->
val targetFile = File(cachePath, cacheFile.name)
cacheFile.copyTo(targetFile)
cacheFile.delete()
}
}
}
}
}
@@ -0,0 +1,31 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.migration.impl.migrations
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesIntoSet
import dev.zacsweers.metro.Inject
import io.element.android.features.rageshake.api.logs.LogFilesRemover
/**
* Delete the previous log files.
*/
@ContributesIntoSet(AppScope::class)
@Inject
class AppMigration07(
private val logFilesRemover: LogFilesRemover,
) : AppMigration {
override val order: Int = 7
override suspend fun migrate(isFreshInstall: Boolean) {
logFilesRemover.perform { file ->
file.name.startsWith("logs-")
}
}
}
@@ -0,0 +1,32 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.migration.impl.migrations
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesIntoSet
import dev.zacsweers.metro.Inject
import io.element.android.features.announcement.api.Announcement
import io.element.android.features.announcement.api.AnnouncementService
/**
* Ensure the new notification sound banner is displayed, but only on application upgrade.
*/
@ContributesIntoSet(AppScope::class)
@Inject
class AppMigration08(
private val announcementService: AnnouncementService,
) : AppMigration {
override val order: Int = 8
override suspend fun migrate(isFreshInstall: Boolean) {
if (!isFreshInstall) {
announcementService.showAnnouncement(Announcement.NewNotificationSound)
}
}
}
@@ -0,0 +1,26 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.migration.impl
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
class InMemoryMigrationStore(
initialApplicationMigrationVersion: Int = 0
) : MigrationStore {
private val applicationMigrationVersion = MutableStateFlow(initialApplicationMigrationVersion)
override suspend fun setApplicationMigrationVersion(version: Int) {
applicationMigrationVersion.value = version
}
override fun applicationMigrationVersion(): Flow<Int> {
return applicationMigrationVersion
}
}
@@ -0,0 +1,122 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.migration.impl
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.migration.impl.migrations.AppMigration
import io.element.android.libraries.architecture.AsyncData
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.consumeItemsUntilPredicate
import io.element.android.tests.testutils.lambda.LambdaOneParamRecorder
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class MigrationPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@Test
fun `present - run all migrations on fresh installation, and last version should be stored`() = runTest {
val migrations = (1..10).map { order ->
FakeAppMigration(order = order)
}
val store = InMemoryMigrationStore(initialApplicationMigrationVersion = -1)
val presenter = createPresenter(
migrationStore = store,
migrations = migrations.toSet(),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.migrationAction).isEqualTo(AsyncData.Uninitialized)
skipItems(migrations.size)
awaitItem().also { state ->
assertThat(state.migrationAction).isEqualTo(AsyncData.Success(Unit))
}
assertThat(store.applicationMigrationVersion().first()).isEqualTo(migrations.maxOf { it.order })
}
for (migration in migrations) {
migration.migrateLambda.assertions().isCalledOnce().with(value(true))
}
}
@Test
fun `present - no migration should occurs if ApplicationMigrationVersion is the last one`() = runTest {
val migrations = (1..10).map {
FakeAppMigration(
order = it,
migrateLambda = lambdaRecorder<Boolean, Unit> { lambdaError() },
)
}
val store = InMemoryMigrationStore(migrations.maxOf { it.order })
val presenter = createPresenter(
migrationStore = store,
migrations = migrations.toSet(),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.migrationAction).isEqualTo(AsyncData.Uninitialized)
awaitItem().also { state ->
assertThat(state.migrationAction).isEqualTo(AsyncData.Success(Unit))
}
}
}
@Test
fun `present - testing all migrations`() = runTest {
val store = InMemoryMigrationStore(0)
val migrations = (1..10).map { FakeAppMigration(it) }
val presenter = createPresenter(
migrationStore = store,
migrations = migrations.toSet(),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.migrationAction).isEqualTo(AsyncData.Uninitialized)
awaitItem().also { state ->
assertThat(state.migrationAction).isEqualTo(AsyncData.Loading(Unit))
}
consumeItemsUntilPredicate { it.migrationAction is AsyncData.Success }
assertThat(store.applicationMigrationVersion().first()).isEqualTo(migrations.maxOf { it.order })
for (migration in migrations) {
migration.migrateLambda.assertions().isCalledOnce().with(value(false))
}
}
}
}
private fun createPresenter(
migrationStore: MigrationStore = InMemoryMigrationStore(0),
migrations: Set<AppMigration> = setOf(FakeAppMigration(1)),
) = MigrationPresenter(
migrationStore = migrationStore,
migrations = migrations,
)
private class FakeAppMigration(
override val order: Int,
val migrateLambda: LambdaOneParamRecorder<Boolean, Unit> = lambdaRecorder<Boolean, Unit> { },
) : AppMigration {
override suspend fun migrate(isFreshInstall: Boolean) {
migrateLambda(isFreshInstall)
}
}
@@ -0,0 +1,25 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.migration.impl.migrations
import io.element.android.features.rageshake.test.logs.FakeLogFilesRemover
import kotlinx.coroutines.test.runTest
import org.junit.Test
class AppMigration01Test {
@Test
fun `test migration`() = runTest {
val logsFileRemover = FakeLogFilesRemover()
val migration = AppMigration01(logsFileRemover)
migration.migrate(true)
logsFileRemover.performLambda.assertions().isCalledOnce()
}
}
@@ -0,0 +1,43 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.migration.impl.migrations
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.preferences.test.FakeSessionPreferencesStoreFactory
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
import io.element.android.libraries.sessionstorage.test.aSessionData
import io.element.android.tests.testutils.lambda.lambdaRecorder
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Test
class AppMigration02Test {
@Test
fun `test migration`() = runTest {
val sessionStore = InMemorySessionStore(
initialList = listOf(aSessionData()),
)
val sessionPreferencesStore = InMemorySessionPreferencesStore(isSessionVerificationSkipped = false)
val sessionPreferencesStoreFactory = FakeSessionPreferencesStoreFactory(
getLambda = lambdaRecorder { _, _ -> sessionPreferencesStore },
removeLambda = lambdaRecorder { _ -> }
)
val migration = AppMigration02(sessionStore = sessionStore, sessionPreferenceStoreFactory = sessionPreferencesStoreFactory)
migration.migrate(true)
// We got the session preferences store
sessionPreferencesStoreFactory.getLambda.assertions().isCalledOnce()
// We changed the settings for the skipping the session verification
assertThat(sessionPreferencesStore.isSessionVerificationSkipped().first()).isTrue()
// We removed the session preferences store from cache
sessionPreferencesStoreFactory.removeLambda.assertions().isCalledOnce()
}
}
@@ -0,0 +1,25 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.migration.impl.migrations
import io.element.android.features.rageshake.test.logs.FakeLogFilesRemover
import kotlinx.coroutines.test.runTest
import org.junit.Test
class AppMigration03Test {
@Test
fun `test migration`() = runTest {
val logsFileRemover = FakeLogFilesRemover()
val migration = AppMigration03(migration01 = AppMigration01(logsFileRemover))
migration.migrate(true)
logsFileRemover.performLambda.assertions().isCalledOnce()
}
}
@@ -0,0 +1,37 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.migration.impl.migrations
import androidx.test.platform.app.InstrumentationRegistry
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class AppMigration04Test {
@Test
fun `test migration`() = runTest {
val context = InstrumentationRegistry.getInstrumentation().context
// Create fake temporary file at the path to be deleted
val file = context.getDatabasePath(AppMigration04.NOTIFICATION_FILE_NAME)
file.parentFile?.mkdirs()
file.createNewFile()
assertThat(file.exists()).isTrue()
val migration = AppMigration04(context)
migration.migrate(true)
// Check that the file has been deleted
assertThat(file.exists()).isFalse()
}
}
@@ -0,0 +1,51 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.migration.impl.migrations
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
import io.element.android.libraries.sessionstorage.test.aSessionData
import kotlinx.coroutines.test.runTest
import org.junit.Test
import java.io.File
class AppMigration05Test {
@Test
fun `empty session path should be set to an expected path`() = runTest {
val sessionStore = InMemorySessionStore(
initialList = listOf(
aSessionData(
sessionId = A_SESSION_ID.value,
sessionPath = "",
)
)
)
val migration = AppMigration05(sessionStore = sessionStore, baseDirectory = File("/a/path"))
migration.migrate(true)
val storedData = sessionStore.getSession(A_SESSION_ID.value)!!
assertThat(storedData.sessionPath).isEqualTo("/a/path/${A_SESSION_ID.value.replace(':', '_')}")
}
@Test
fun `non empty session path should not be impacted by the migration`() = runTest {
val sessionStore = InMemorySessionStore(
initialList = listOf(
aSessionData(
sessionId = A_SESSION_ID.value,
sessionPath = "/a/path/existing",
)
)
)
val migration = AppMigration05(sessionStore = sessionStore, baseDirectory = File("/a/path"))
migration.migrate(true)
val storedData = sessionStore.getSession(A_SESSION_ID.value)!!
assertThat(storedData.sessionPath).isEqualTo("/a/path/existing")
}
}
@@ -0,0 +1,52 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.migration.impl.migrations
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
import io.element.android.libraries.sessionstorage.test.aSessionData
import kotlinx.coroutines.test.runTest
import org.junit.Test
import java.io.File
class AppMigration06Test {
@Test
fun `empty cache path should be set to an expected path`() = runTest {
val sessionStore = InMemorySessionStore(
initialList = listOf(
aSessionData(
sessionId = A_SESSION_ID.value,
sessionPath = "/a/path/to/a/session/AN_ID",
cachePath = "",
)
)
)
val migration = AppMigration06(sessionStore = sessionStore, cacheDirectory = File("/a/path/cache"))
migration.migrate(true)
val storedData = sessionStore.getSession(A_SESSION_ID.value)!!
assertThat(storedData.cachePath).isEqualTo("/a/path/cache/AN_ID")
}
@Test
fun `non empty cache path should not be impacted by the migration`() = runTest {
val sessionStore = InMemorySessionStore(
initialList = listOf(
aSessionData(
sessionId = A_SESSION_ID.value,
cachePath = "/a/path/existing",
)
)
)
val migration = AppMigration05(sessionStore = sessionStore, baseDirectory = File("/a/path/cache"))
migration.migrate(true)
val storedData = sessionStore.getSession(A_SESSION_ID.value)!!
assertThat(storedData.cachePath).isEqualTo("/a/path/existing")
}
}
@@ -0,0 +1,31 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.migration.impl.migrations
import com.google.common.truth.Truth.assertThat
import io.element.android.features.rageshake.test.logs.FakeLogFilesRemover
import io.element.android.tests.testutils.lambda.lambdaRecorder
import kotlinx.coroutines.test.runTest
import org.junit.Test
import java.io.File
class AppMigration07Test {
@Test
fun `test migration`() = runTest {
val performLambda = lambdaRecorder<(File) -> Boolean, Unit> { predicate ->
// Test the predicate
assertThat(predicate(File("logs-0433.log.gz"))).isTrue()
assertThat(predicate(File("logs.2024-08-01-20.log.gz"))).isFalse()
}
val logsFileRemover = FakeLogFilesRemover(performLambda = performLambda)
val migration = AppMigration07(logsFileRemover)
migration.migrate(true)
performLambda.assertions().isCalledOnce()
}
}
@@ -0,0 +1,43 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.migration.impl.migrations
import com.google.common.truth.Truth.assertThat
import io.element.android.features.announcement.api.Announcement
import io.element.android.features.rageshake.test.logs.FakeAnnouncementService
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Test
class AppMigration08Test {
@Test
fun `migration on fresh install should not invoke the AnnouncementService`() = runTest {
val service = FakeAnnouncementService(
showAnnouncementResult = { lambdaError() },
)
val migration = AppMigration08(service)
migration.migrate(isFreshInstall = true)
assertThat(service.announcementsToShowFlow().first()).isEmpty()
}
@Test
fun `migration on upgrade should invoke the AnnouncementService`() = runTest {
val showAnnouncementResult = lambdaRecorder<Announcement, Unit> { }
val service = FakeAnnouncementService(
showAnnouncementResult = showAnnouncementResult,
)
val migration = AppMigration08(service)
migration.migrate(isFreshInstall = false)
showAnnouncementResult.assertions().isCalledOnce()
.with(value(Announcement.NewNotificationSound))
}
}