Add clear cache action in the developer settings (#643)

This commit is contained in:
Benoit Marty 2023-06-20 12:52:20 +02:00 committed by Benoit Marty
parent 67c4df3b88
commit bf80c251aa
13 changed files with 172 additions and 6 deletions

View file

@ -62,6 +62,7 @@ dependencies {
testImplementation(projects.features.logout.impl) testImplementation(projects.features.logout.impl)
testImplementation(projects.features.analytics.test) testImplementation(projects.features.analytics.test)
testImplementation(projects.features.analytics.impl) testImplementation(projects.features.analytics.impl)
testImplementation(projects.tests.testutils)
androidTestImplementation(libs.test.junitext) androidTestImplementation(libs.test.junitext)
} }

View file

@ -20,4 +20,5 @@ import io.element.android.libraries.featureflag.ui.model.FeatureUiModel
sealed interface DeveloperSettingsEvents { sealed interface DeveloperSettingsEvents {
data class UpdateEnabledFeature(val feature: FeatureUiModel, val isEnabled: Boolean) : DeveloperSettingsEvents data class UpdateEnabledFeature(val feature: FeatureUiModel, val isEnabled: Boolean) : DeveloperSettingsEvents
object ClearCache: DeveloperSettingsEvents
} }

View file

@ -18,12 +18,17 @@ package io.element.android.features.preferences.impl.developer
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.key import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.snapshots.SnapshotStateMap 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.Presenter
import io.element.android.libraries.architecture.execute
import io.element.android.libraries.core.bool.orFalse import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.featureflag.api.Feature import io.element.android.libraries.featureflag.api.Feature
import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlagService
@ -36,6 +41,7 @@ import javax.inject.Inject
class DeveloperSettingsPresenter @Inject constructor( class DeveloperSettingsPresenter @Inject constructor(
private val featureFlagService: FeatureFlagService, private val featureFlagService: FeatureFlagService,
private val clearCacheUseCase: ClearCacheUseCase,
) : Presenter<DeveloperSettingsState> { ) : Presenter<DeveloperSettingsState> {
@Composable @Composable
@ -47,6 +53,9 @@ class DeveloperSettingsPresenter @Inject constructor(
val enabledFeatures = remember { val enabledFeatures = remember {
mutableStateMapOf<String, Boolean>() mutableStateMapOf<String, Boolean>()
} }
val clearCacheAction = remember {
mutableStateOf<Async<Unit>>(Async.Uninitialized)
}
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
FeatureFlags.values().forEach { feature -> FeatureFlags.values().forEach { feature ->
features[feature.key] = feature features[feature.key] = feature
@ -64,11 +73,13 @@ class DeveloperSettingsPresenter @Inject constructor(
event.feature, event.feature,
event.isEnabled event.isEnabled
) )
DeveloperSettingsEvents.ClearCache -> coroutineScope.clearCache(clearCacheAction)
} }
} }
return DeveloperSettingsState( return DeveloperSettingsState(
features = featureUiModels.toImmutableList(), features = featureUiModels.toImmutableList(),
clearCacheAction = clearCacheAction.value,
eventSink = ::handleEvents eventSink = ::handleEvents
) )
} }
@ -103,6 +114,12 @@ class DeveloperSettingsPresenter @Inject constructor(
enabledFeatures[featureUiModel.key] = enabled enabledFeatures[featureUiModel.key] = enabled
} }
} }
private fun CoroutineScope.clearCache(clearCacheAction: MutableState<Async<Unit>>) = launch {
suspend {
clearCacheUseCase.execute()
}.execute(clearCacheAction)
}
} }

View file

@ -16,10 +16,12 @@
package io.element.android.features.preferences.impl.developer 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 io.element.android.libraries.featureflag.ui.model.FeatureUiModel
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
data class DeveloperSettingsState( data class DeveloperSettingsState constructor(
val features: ImmutableList<FeatureUiModel>, val features: ImmutableList<FeatureUiModel>,
val clearCacheAction: Async<Unit>,
val eventSink: (DeveloperSettingsEvents) -> Unit val eventSink: (DeveloperSettingsEvents) -> Unit
) )

View file

@ -17,16 +17,19 @@
package io.element.android.features.preferences.impl.developer package io.element.android.features.preferences.impl.developer
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.featureflag.ui.model.aFeatureUiModelList import io.element.android.libraries.featureflag.ui.model.aFeatureUiModelList
open class DeveloperSettingsStateProvider : PreviewParameterProvider<DeveloperSettingsState> { open class DeveloperSettingsStateProvider : PreviewParameterProvider<DeveloperSettingsState> {
override val values: Sequence<DeveloperSettingsState> override val values: Sequence<DeveloperSettingsState>
get() = sequenceOf( get() = sequenceOf(
aDeveloperSettingsState(), aDeveloperSettingsState(),
aDeveloperSettingsState().copy(clearCacheAction = Async.Loading()),
) )
} }
fun aDeveloperSettingsState() = DeveloperSettingsState( fun aDeveloperSettingsState() = DeveloperSettingsState(
features = aFeatureUiModelList(), features = aFeatureUiModelList(),
clearCacheAction = Async.Uninitialized,
eventSink = {} eventSink = {}
) )

View file

@ -16,11 +16,14 @@
package io.element.android.features.preferences.impl.developer 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.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter 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.PreferenceCategory
import io.element.android.libraries.designsystem.components.preferences.PreferenceText import io.element.android.libraries.designsystem.components.preferences.PreferenceText
import io.element.android.libraries.designsystem.components.preferences.PreferenceView import io.element.android.libraries.designsystem.components.preferences.PreferenceView
@ -52,6 +55,18 @@ fun DeveloperSettingsView(
onClick = onOpenShowkase 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)
}
}
)
}
} }
} }

View file

@ -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()
}
}

View file

@ -20,6 +20,8 @@ import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow import app.cash.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat 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.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@ -29,13 +31,15 @@ class DeveloperSettingsPresenterTest {
@Test @Test
fun `present - ensures initial state is correct`() = runTest { fun `present - ensures initial state is correct`() = runTest {
val presenter = DeveloperSettingsPresenter( val presenter = DeveloperSettingsPresenter(
FakeFeatureFlagService() FakeFeatureFlagService(),
FakeClearCacheUseCase()
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.features).isEmpty() assertThat(initialState.features).isEmpty()
assertThat(initialState.clearCacheAction).isEqualTo(Async.Uninitialized)
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
} }
} }
@ -43,7 +47,8 @@ class DeveloperSettingsPresenterTest {
@Test @Test
fun `present - ensures feature list is loaded`() = runTest { fun `present - ensures feature list is loaded`() = runTest {
val presenter = DeveloperSettingsPresenter( val presenter = DeveloperSettingsPresenter(
FakeFeatureFlagService() FakeFeatureFlagService(),
FakeClearCacheUseCase()
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
@ -58,7 +63,8 @@ class DeveloperSettingsPresenterTest {
@Test @Test
fun `present - ensures state is updated when enabled feature event is triggered`() = runTest { fun `present - ensures state is updated when enabled feature event is triggered`() = runTest {
val presenter = DeveloperSettingsPresenter( val presenter = DeveloperSettingsPresenter(
FakeFeatureFlagService() FakeFeatureFlagService(),
FakeClearCacheUseCase()
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
@ -74,4 +80,25 @@ class DeveloperSettingsPresenterTest {
cancelAndIgnoreRemainingEvents() 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()
}
}
} }

View file

@ -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
}
}

View file

@ -50,6 +50,7 @@ interface MatrixClient : Closeable {
fun sessionVerificationService(): SessionVerificationService fun sessionVerificationService(): SessionVerificationService
fun pushersService(): PushersService fun pushersService(): PushersService
fun notificationService(): NotificationService fun notificationService(): NotificationService
suspend fun clearCache()
suspend fun logout() suspend fun logout()
suspend fun loadUserDisplayName(): Result<String> suspend fun loadUserDisplayName(): Result<String>
suspend fun loadUserAvatarURLString(): Result<String?> suspend fun loadUserAvatarURLString(): Result<String?>

View file

@ -32,6 +32,7 @@ dependencies {
// api(projects.libraries.rustsdk) // api(projects.libraries.rustsdk)
implementation(libs.matrix.sdk) implementation(libs.matrix.sdk)
implementation(projects.libraries.di) implementation(projects.libraries.di)
implementation(projects.libraries.androidutils)
implementation(projects.services.toolbox.api) implementation(projects.services.toolbox.api)
api(projects.libraries.matrix.api) api(projects.libraries.matrix.api)
implementation(libs.dagger) implementation(libs.dagger)

View file

@ -18,6 +18,7 @@
package io.element.android.libraries.matrix.impl 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.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.ProgressCallback import io.element.android.libraries.matrix.api.core.ProgressCallback
@ -336,6 +337,10 @@ class RustMatrixClient constructor(
client.destroy() client.destroy()
} }
override suspend fun clearCache() {
baseDirectory.deleteSessionDirectory(userID = client.userId(), deleteCryptoDb = false)
}
override suspend fun logout() = withContext(dispatchers.io) { override suspend fun logout() = withContext(dispatchers.io) {
try { try {
client.logout() client.logout()
@ -378,11 +383,29 @@ class RustMatrixClient constructor(
override fun roomMembershipObserver(): RoomMembershipObserver = roomMembershipObserver 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 _ // Rust sanitises the user ID replacing invalid characters with an _
val sanitisedUserID = userID.replace(":", "_") val sanitisedUserID = userID.replace(":", "_")
val sessionDirectory = File(this, sanitisedUserID) 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
}
} }
} }

View file

@ -102,6 +102,9 @@ class FakeMatrixClient(
override fun stopSync() = Unit override fun stopSync() = Unit
override suspend fun clearCache() {
}
override suspend fun logout() { override suspend fun logout() {
delay(100) delay(100)
logoutFailure?.let { throw it } logoutFailure?.let { throw it }