diff --git a/features/preferences/impl/build.gradle.kts b/features/preferences/impl/build.gradle.kts index 631884fe47..bf8f04b61b 100644 --- a/features/preferences/impl/build.gradle.kts +++ b/features/preferences/impl/build.gradle.kts @@ -62,6 +62,7 @@ dependencies { testImplementation(projects.features.logout.impl) testImplementation(projects.features.analytics.test) testImplementation(projects.features.analytics.impl) + testImplementation(projects.tests.testutils) androidTestImplementation(libs.test.junitext) } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsEvents.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsEvents.kt index b79484592f..bb3879b129 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsEvents.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsEvents.kt @@ -20,4 +20,5 @@ import io.element.android.libraries.featureflag.ui.model.FeatureUiModel sealed interface DeveloperSettingsEvents { data class UpdateEnabledFeature(val feature: FeatureUiModel, val isEnabled: Boolean) : DeveloperSettingsEvents + object ClearCache: DeveloperSettingsEvents } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt index 9f9cd636eb..cb4a5d1ec9 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt @@ -18,12 +18,17 @@ package io.element.android.features.preferences.impl.developer import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.snapshots.SnapshotStateMap +import io.element.android.features.preferences.impl.tasks.ClearCacheUseCase +import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.architecture.execute import io.element.android.libraries.core.bool.orFalse import io.element.android.libraries.featureflag.api.Feature import io.element.android.libraries.featureflag.api.FeatureFlagService @@ -36,6 +41,7 @@ import javax.inject.Inject class DeveloperSettingsPresenter @Inject constructor( private val featureFlagService: FeatureFlagService, + private val clearCacheUseCase: ClearCacheUseCase, ) : Presenter { @Composable @@ -47,6 +53,9 @@ class DeveloperSettingsPresenter @Inject constructor( val enabledFeatures = remember { mutableStateMapOf() } + val clearCacheAction = remember { + mutableStateOf>(Async.Uninitialized) + } LaunchedEffect(Unit) { FeatureFlags.values().forEach { feature -> features[feature.key] = feature @@ -64,11 +73,13 @@ class DeveloperSettingsPresenter @Inject constructor( event.feature, event.isEnabled ) + DeveloperSettingsEvents.ClearCache -> coroutineScope.clearCache(clearCacheAction) } } return DeveloperSettingsState( features = featureUiModels.toImmutableList(), + clearCacheAction = clearCacheAction.value, eventSink = ::handleEvents ) } @@ -103,6 +114,12 @@ class DeveloperSettingsPresenter @Inject constructor( enabledFeatures[featureUiModel.key] = enabled } } + + private fun CoroutineScope.clearCache(clearCacheAction: MutableState>) = launch { + suspend { + clearCacheUseCase.execute() + }.execute(clearCacheAction) + } } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsState.kt index 53ff80967e..392f83357f 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsState.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsState.kt @@ -16,10 +16,12 @@ package io.element.android.features.preferences.impl.developer +import io.element.android.libraries.architecture.Async import io.element.android.libraries.featureflag.ui.model.FeatureUiModel import kotlinx.collections.immutable.ImmutableList -data class DeveloperSettingsState( +data class DeveloperSettingsState constructor( val features: ImmutableList, + val clearCacheAction: Async, val eventSink: (DeveloperSettingsEvents) -> Unit ) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsStateProvider.kt index f69f73e6e5..f600edd602 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsStateProvider.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsStateProvider.kt @@ -17,16 +17,19 @@ package io.element.android.features.preferences.impl.developer import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.Async import io.element.android.libraries.featureflag.ui.model.aFeatureUiModelList open class DeveloperSettingsStateProvider : PreviewParameterProvider { override val values: Sequence get() = sequenceOf( aDeveloperSettingsState(), + aDeveloperSettingsState().copy(clearCacheAction = Async.Loading()), ) } fun aDeveloperSettingsState() = DeveloperSettingsState( features = aFeatureUiModelList(), + clearCacheAction = Async.Uninitialized, eventSink = {} ) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt index 697081c397..9bd1a4331c 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt @@ -16,11 +16,14 @@ package io.element.android.features.preferences.impl.developer +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter +import io.element.android.libraries.architecture.isLoading import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory import io.element.android.libraries.designsystem.components.preferences.PreferenceText import io.element.android.libraries.designsystem.components.preferences.PreferenceView @@ -52,6 +55,18 @@ fun DeveloperSettingsView( onClick = onOpenShowkase ) } + PreferenceCategory(title = "Cache") { + PreferenceText( + title = "Clear cache", + icon = Icons.Default.Delete, + loadingCurrentValue = state.clearCacheAction.isLoading(), + onClick = { + if (state.clearCacheAction.isLoading().not()) { + state.eventSink(DeveloperSettingsEvents.ClearCache) + } + } + ) + } } } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/ClearCacheUseCase.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/ClearCacheUseCase.kt new file mode 100644 index 0000000000..4ccfbb6b81 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/ClearCacheUseCase.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.preferences.impl.tasks + +import android.content.Context +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.di.ApplicationContext +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.MatrixClient +import kotlinx.coroutines.withContext +import javax.inject.Inject + +interface ClearCacheUseCase { + suspend fun execute() +} + +@ContributesBinding(SessionScope::class) +class DefaultClearCacheUseCase @Inject constructor( + @ApplicationContext private val context: Context, + private val matrixClient: MatrixClient, + private val coroutineDispatchers: CoroutineDispatchers, +) : ClearCacheUseCase { + override suspend fun execute() = withContext(coroutineDispatchers.io) { + matrixClient.stopSync() + matrixClient.clearCache() + context.cacheDir.deleteRecursively() + matrixClient.startSync() + } +} diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt index 6b7c8c2df4..5710289217 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt @@ -20,6 +20,8 @@ import app.cash.molecule.RecompositionClock import app.cash.molecule.moleculeFlow import app.cash.turbine.test import com.google.common.truth.Truth.assertThat +import io.element.android.features.preferences.impl.tasks.FakeClearCacheUseCase +import io.element.android.libraries.architecture.Async import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import kotlinx.coroutines.test.runTest @@ -29,13 +31,15 @@ class DeveloperSettingsPresenterTest { @Test fun `present - ensures initial state is correct`() = runTest { val presenter = DeveloperSettingsPresenter( - FakeFeatureFlagService() + FakeFeatureFlagService(), + FakeClearCacheUseCase() ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() }.test { val initialState = awaitItem() assertThat(initialState.features).isEmpty() + assertThat(initialState.clearCacheAction).isEqualTo(Async.Uninitialized) cancelAndIgnoreRemainingEvents() } } @@ -43,7 +47,8 @@ class DeveloperSettingsPresenterTest { @Test fun `present - ensures feature list is loaded`() = runTest { val presenter = DeveloperSettingsPresenter( - FakeFeatureFlagService() + FakeFeatureFlagService(), + FakeClearCacheUseCase() ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -58,7 +63,8 @@ class DeveloperSettingsPresenterTest { @Test fun `present - ensures state is updated when enabled feature event is triggered`() = runTest { val presenter = DeveloperSettingsPresenter( - FakeFeatureFlagService() + FakeFeatureFlagService(), + FakeClearCacheUseCase() ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -74,4 +80,25 @@ class DeveloperSettingsPresenterTest { cancelAndIgnoreRemainingEvents() } } + + @Test + fun `present - clear cache`() = runTest { + val clearCacheUseCase = FakeClearCacheUseCase() + val presenter = DeveloperSettingsPresenter( + FakeFeatureFlagService(), + clearCacheUseCase + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + assertThat(clearCacheUseCase.executeHasBeenCalled).isFalse() + initialState.eventSink(DeveloperSettingsEvents.ClearCache) + val stateAfterEvent = awaitItem() + assertThat(stateAfterEvent.clearCacheAction).isInstanceOf(Async.Loading::class.java) + assertThat(awaitItem().clearCacheAction).isInstanceOf(Async.Success::class.java) + assertThat(clearCacheUseCase.executeHasBeenCalled).isTrue() + } + } } diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/tasks/FakeClearCacheUseCase.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/tasks/FakeClearCacheUseCase.kt new file mode 100644 index 0000000000..f5bc83c443 --- /dev/null +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/tasks/FakeClearCacheUseCase.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.preferences.impl.tasks + +import io.element.android.tests.testutils.simulateLongTask + +class FakeClearCacheUseCase : ClearCacheUseCase { + var executeHasBeenCalled = false + private set + + override suspend fun execute() = simulateLongTask { + executeHasBeenCalled = true + } +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt index 4a018e18da..77e4bf810b 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt @@ -50,6 +50,7 @@ interface MatrixClient : Closeable { fun sessionVerificationService(): SessionVerificationService fun pushersService(): PushersService fun notificationService(): NotificationService + suspend fun clearCache() suspend fun logout() suspend fun loadUserDisplayName(): Result suspend fun loadUserAvatarURLString(): Result diff --git a/libraries/matrix/impl/build.gradle.kts b/libraries/matrix/impl/build.gradle.kts index 1ac5b4b507..5709b5a6d7 100644 --- a/libraries/matrix/impl/build.gradle.kts +++ b/libraries/matrix/impl/build.gradle.kts @@ -32,6 +32,7 @@ dependencies { // api(projects.libraries.rustsdk) implementation(libs.matrix.sdk) implementation(projects.libraries.di) + implementation(projects.libraries.androidutils) implementation(projects.services.toolbox.api) api(projects.libraries.matrix.api) implementation(libs.dagger) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt index 268e09c764..e6e49b5feb 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt @@ -18,6 +18,7 @@ package io.element.android.libraries.matrix.impl +import io.element.android.libraries.androidutils.file.safeDelete import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.ProgressCallback @@ -336,6 +337,10 @@ class RustMatrixClient constructor( client.destroy() } + override suspend fun clearCache() { + baseDirectory.deleteSessionDirectory(userID = client.userId(), deleteCryptoDb = false) + } + override suspend fun logout() = withContext(dispatchers.io) { try { client.logout() @@ -378,11 +383,29 @@ class RustMatrixClient constructor( override fun roomMembershipObserver(): RoomMembershipObserver = roomMembershipObserver - private fun File.deleteSessionDirectory(userID: String): Boolean { + private fun File.deleteSessionDirectory( + userID: String, + deleteCryptoDb: Boolean = false, + ): Boolean { // Rust sanitises the user ID replacing invalid characters with an _ val sanitisedUserID = userID.replace(":", "_") val sessionDirectory = File(this, sanitisedUserID) - return sessionDirectory.deleteRecursively() + return if (deleteCryptoDb) { + // Delete the folder and all its content + sessionDirectory.deleteRecursively() + } else { + // Delete only the state.db file + listOf( + "matrix-sdk-state.sqlite3", + "matrix-sdk-state.sqlite3-shm", + "matrix-sdk-state.sqlite3-wal", + ).map { fileName -> + File(sessionDirectory, fileName) + }.forEach { file -> + file.safeDelete() + } + true + } } } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt index 8b38a74457..85a3ea3a5c 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt @@ -102,6 +102,9 @@ class FakeMatrixClient( override fun stopSync() = Unit + override suspend fun clearCache() { + } + override suspend fun logout() { delay(100) logoutFailure?.let { throw it }