Browse Source

Display cache size in the developer settings (#643)

feature/julioromano/geocoding_api
Benoit Marty 1 year ago committed by Benoit Marty
parent
commit
2a7d252a4e
  1. 1
      features/preferences/impl/build.gradle.kts
  2. 16
      features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt
  3. 1
      features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsState.kt
  4. 1
      features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsStateProvider.kt
  5. 7
      features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt
  6. 45
      features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/ComputeCacheSizeUseCase.kt
  7. 16
      features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt
  8. 25
      features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/tasks/FakeComputeCacheSizeUseCase.kt
  9. 137
      libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/FileUtils.kt
  10. 1
      libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt
  11. 39
      libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt
  12. 4
      libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt

1
features/preferences/impl/build.gradle.kts

@ -32,6 +32,7 @@ anvil { @@ -32,6 +32,7 @@ anvil {
dependencies {
implementation(projects.anvilannotations)
anvil(projects.anvilcodegen)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.core)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)

16
features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt

@ -26,6 +26,7 @@ import androidx.compose.runtime.remember @@ -26,6 +26,7 @@ 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.features.preferences.impl.tasks.ComputeCacheSizeUseCase
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.execute
@ -41,6 +42,7 @@ import javax.inject.Inject @@ -41,6 +42,7 @@ import javax.inject.Inject
class DeveloperSettingsPresenter @Inject constructor(
private val featureFlagService: FeatureFlagService,
private val computeCacheSizeUseCase: ComputeCacheSizeUseCase,
private val clearCacheUseCase: ClearCacheUseCase,
) : Presenter<DeveloperSettingsState> {
@ -53,6 +55,9 @@ class DeveloperSettingsPresenter @Inject constructor( @@ -53,6 +55,9 @@ class DeveloperSettingsPresenter @Inject constructor(
val enabledFeatures = remember {
mutableStateMapOf<String, Boolean>()
}
val cacheSize = remember {
mutableStateOf<Async<Long>>(Async.Uninitialized)
}
val clearCacheAction = remember {
mutableStateOf<Async<Unit>>(Async.Uninitialized)
}
@ -64,6 +69,10 @@ class DeveloperSettingsPresenter @Inject constructor( @@ -64,6 +69,10 @@ class DeveloperSettingsPresenter @Inject constructor(
}
val featureUiModels = createUiModels(features, enabledFeatures)
val coroutineScope = rememberCoroutineScope()
// Compute cache size each time the clear cache action value is changed
LaunchedEffect(clearCacheAction.value) {
computeCacheSize(cacheSize)
}
fun handleEvents(event: DeveloperSettingsEvents) {
when (event) {
@ -79,6 +88,7 @@ class DeveloperSettingsPresenter @Inject constructor( @@ -79,6 +88,7 @@ class DeveloperSettingsPresenter @Inject constructor(
return DeveloperSettingsState(
features = featureUiModels.toImmutableList(),
cacheSizeInBytes = cacheSize.value,
clearCacheAction = clearCacheAction.value,
eventSink = ::handleEvents
)
@ -115,6 +125,12 @@ class DeveloperSettingsPresenter @Inject constructor( @@ -115,6 +125,12 @@ class DeveloperSettingsPresenter @Inject constructor(
}
}
private fun CoroutineScope.computeCacheSize(cacheSize: MutableState<Async<Long>>) = launch {
suspend {
computeCacheSizeUseCase.execute()
}.execute(cacheSize)
}
private fun CoroutineScope.clearCache(clearCacheAction: MutableState<Async<Unit>>) = launch {
suspend {
clearCacheUseCase.execute()

1
features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsState.kt

@ -22,6 +22,7 @@ import kotlinx.collections.immutable.ImmutableList @@ -22,6 +22,7 @@ import kotlinx.collections.immutable.ImmutableList
data class DeveloperSettingsState constructor(
val features: ImmutableList<FeatureUiModel>,
val cacheSizeInBytes: Async<Long>,
val clearCacheAction: Async<Unit>,
val eventSink: (DeveloperSettingsEvents) -> Unit
)

1
features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsStateProvider.kt

@ -30,6 +30,7 @@ open class DeveloperSettingsStateProvider : PreviewParameterProvider<DeveloperSe @@ -30,6 +30,7 @@ open class DeveloperSettingsStateProvider : PreviewParameterProvider<DeveloperSe
fun aDeveloperSettingsState() = DeveloperSettingsState(
features = aFeatureUiModelList(),
cacheSizeInBytes = Async.Success(0L),
clearCacheAction = Async.Uninitialized,
eventSink = {}
)

7
features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt

@ -23,6 +23,7 @@ import androidx.compose.ui.Modifier @@ -23,6 +23,7 @@ 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.Async
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
@ -55,11 +56,15 @@ fun DeveloperSettingsView( @@ -55,11 +56,15 @@ fun DeveloperSettingsView(
onClick = onOpenShowkase
)
}
val cache = state.cacheSizeInBytes
PreferenceCategory(title = "Cache") {
PreferenceText(
title = "Clear cache",
icon = Icons.Default.Delete,
loadingCurrentValue = state.clearCacheAction.isLoading(),
currentValue = if (cache is Async.Success) {
"${cache.state} bytes"
} else null,
loadingCurrentValue = state.cacheSizeInBytes.isLoading() || state.clearCacheAction.isLoading(),
onClick = {
if (state.clearCacheAction.isLoading().not()) {
state.eventSink(DeveloperSettingsEvents.ClearCache)

45
features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/ComputeCacheSizeUseCase.kt

@ -0,0 +1,45 @@ @@ -0,0 +1,45 @@
/*
* 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.androidutils.file.getSizeOfFiles
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 ComputeCacheSizeUseCase {
suspend fun execute(): Long
}
@ContributesBinding(SessionScope::class)
class DefaultComputeCacheSizeUseCase @Inject constructor(
@ApplicationContext private val context: Context,
private val matrixClient: MatrixClient,
private val coroutineDispatchers: CoroutineDispatchers,
) : ComputeCacheSizeUseCase {
override suspend fun execute(): Long = withContext(coroutineDispatchers.io) {
var cumulativeSize = 0L
cumulativeSize += matrixClient.getCacheSize()
cumulativeSize += context.cacheDir.getSizeOfFiles()
cumulativeSize
}
}

16
features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt

@ -21,6 +21,7 @@ import app.cash.molecule.moleculeFlow @@ -21,6 +21,7 @@ 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.features.preferences.impl.tasks.FakeComputeCacheSizeUseCase
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
@ -32,7 +33,8 @@ class DeveloperSettingsPresenterTest { @@ -32,7 +33,8 @@ class DeveloperSettingsPresenterTest {
fun `present - ensures initial state is correct`() = runTest {
val presenter = DeveloperSettingsPresenter(
FakeFeatureFlagService(),
FakeClearCacheUseCase()
FakeComputeCacheSizeUseCase(),
FakeClearCacheUseCase(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
@ -40,6 +42,7 @@ class DeveloperSettingsPresenterTest { @@ -40,6 +42,7 @@ class DeveloperSettingsPresenterTest {
val initialState = awaitItem()
assertThat(initialState.features).isEmpty()
assertThat(initialState.clearCacheAction).isEqualTo(Async.Uninitialized)
assertThat(initialState.cacheSizeInBytes).isEqualTo(Async.Uninitialized)
cancelAndIgnoreRemainingEvents()
}
}
@ -48,7 +51,8 @@ class DeveloperSettingsPresenterTest { @@ -48,7 +51,8 @@ class DeveloperSettingsPresenterTest {
fun `present - ensures feature list is loaded`() = runTest {
val presenter = DeveloperSettingsPresenter(
FakeFeatureFlagService(),
FakeClearCacheUseCase()
FakeComputeCacheSizeUseCase(),
FakeClearCacheUseCase(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
@ -64,7 +68,8 @@ class DeveloperSettingsPresenterTest { @@ -64,7 +68,8 @@ class DeveloperSettingsPresenterTest {
fun `present - ensures state is updated when enabled feature event is triggered`() = runTest {
val presenter = DeveloperSettingsPresenter(
FakeFeatureFlagService(),
FakeClearCacheUseCase()
FakeComputeCacheSizeUseCase(),
FakeClearCacheUseCase(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
@ -86,7 +91,8 @@ class DeveloperSettingsPresenterTest { @@ -86,7 +91,8 @@ class DeveloperSettingsPresenterTest {
val clearCacheUseCase = FakeClearCacheUseCase()
val presenter = DeveloperSettingsPresenter(
FakeFeatureFlagService(),
clearCacheUseCase
FakeComputeCacheSizeUseCase(),
clearCacheUseCase,
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
@ -97,8 +103,10 @@ class DeveloperSettingsPresenterTest { @@ -97,8 +103,10 @@ class DeveloperSettingsPresenterTest {
initialState.eventSink(DeveloperSettingsEvents.ClearCache)
val stateAfterEvent = awaitItem()
assertThat(stateAfterEvent.clearCacheAction).isInstanceOf(Async.Loading::class.java)
skipItems(1)
assertThat(awaitItem().clearCacheAction).isInstanceOf(Async.Success::class.java)
assertThat(clearCacheUseCase.executeHasBeenCalled).isTrue()
cancelAndIgnoreRemainingEvents()
}
}
}

25
features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/tasks/FakeComputeCacheSizeUseCase.kt

@ -0,0 +1,25 @@ @@ -0,0 +1,25 @@
/*
* 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 FakeComputeCacheSizeUseCase : ComputeCacheSizeUseCase {
override suspend fun execute() = simulateLongTask {
0L
}
}

137
libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/FileUtils.kt

@ -0,0 +1,137 @@ @@ -0,0 +1,137 @@
/*
* 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.libraries.androidutils.file
import android.content.Context
import androidx.annotation.WorkerThread
import timber.log.Timber
import java.io.File
import java.util.Locale
// Implementation should return true in case of success
typealias ActionOnFile = (file: File) -> Boolean
/* ==========================================================================================
* Delete
* ========================================================================================== */
fun deleteAllFiles(root: File) {
Timber.v("Delete ${root.absolutePath}")
recursiveActionOnFile(root, ::deleteAction)
}
private fun deleteAction(file: File): Boolean {
if (file.exists()) {
Timber.v("deleteFile: $file")
return file.delete()
}
return true
}
/* ==========================================================================================
* Log
* ========================================================================================== */
fun lsFiles(context: Context) {
Timber.v("Content of cache dir:")
recursiveActionOnFile(context.cacheDir, ::logAction)
Timber.v("Content of files dir:")
recursiveActionOnFile(context.filesDir, ::logAction)
}
private fun logAction(file: File): Boolean {
if (file.isDirectory) {
Timber.v(file.toString())
} else {
Timber.v("$file ${file.length()} bytes")
}
return true
}
/* ==========================================================================================
* Private
* ========================================================================================== */
/**
* Return true in case of success.
*/
private fun recursiveActionOnFile(file: File, action: ActionOnFile): Boolean {
if (file.isDirectory) {
file.list()?.forEach {
val result = recursiveActionOnFile(File(file, it), action)
if (!result) {
// Break the loop
return false
}
}
}
return action.invoke(file)
}
/**
* Get the file extension of a fileUri or a filename.
*
* @param fileUri the fileUri (can be a simple filename)
* @return the file extension, in lower case, or null is extension is not available or empty
*/
fun getFileExtension(fileUri: String): String? {
var reducedStr = fileUri
if (reducedStr.isNotEmpty()) {
// Remove fragment
reducedStr = reducedStr.substringBeforeLast('#')
// Remove query
reducedStr = reducedStr.substringBeforeLast('?')
// Remove path
val filename = reducedStr.substringAfterLast('/')
// Contrary to method MimeTypeMap.getFileExtensionFromUrl, we do not check the pattern
// See https://stackoverflow.com/questions/14320527/android-should-i-use-mimetypemap-getfileextensionfromurl-bugs
if (filename.isNotEmpty()) {
val dotPos = filename.lastIndexOf('.')
if (0 <= dotPos) {
val ext = filename.substring(dotPos + 1)
if (ext.isNotBlank()) {
return ext.lowercase(Locale.ROOT)
}
}
}
}
return null
}
/* ==========================================================================================
* Size
* ========================================================================================== */
@WorkerThread
fun File.getSizeOfFiles(): Long {
return walkTopDown()
.onEnter {
Timber.v("Get size of ${it.absolutePath}")
true
}
.sumOf { it.length() }
}

1
libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt

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

39
libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt

@ -18,6 +18,7 @@ @@ -18,6 +18,7 @@
package io.element.android.libraries.matrix.impl
import io.element.android.libraries.androidutils.file.getSizeOfFiles
import io.element.android.libraries.androidutils.file.safeDelete
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.MatrixClient
@ -337,8 +338,12 @@ class RustMatrixClient constructor( @@ -337,8 +338,12 @@ class RustMatrixClient constructor(
client.destroy()
}
override suspend fun getCacheSize(): Long {
return baseDirectory.getCacheSize(userID = client.userId())
}
override suspend fun clearCache() {
baseDirectory.deleteSessionDirectory(userID = client.userId(), deleteCryptoDb = false)
baseDirectory.deleteSessionDirectory(userID = client.userId())
}
override suspend fun logout() = withContext(dispatchers.io) {
@ -347,7 +352,7 @@ class RustMatrixClient constructor( @@ -347,7 +352,7 @@ class RustMatrixClient constructor(
} catch (failure: Throwable) {
Timber.e(failure, "Fail to call logout on HS. Still delete local files.")
}
baseDirectory.deleteSessionDirectory(userID = client.userId())
baseDirectory.deleteSessionDirectory(userID = client.userId(), deleteCryptoDb = true)
sessionStore.removeSession(client.userId())
close()
}
@ -383,14 +388,36 @@ class RustMatrixClient constructor( @@ -383,14 +388,36 @@ class RustMatrixClient constructor(
override fun roomMembershipObserver(): RoomMembershipObserver = roomMembershipObserver
private fun File.deleteSessionDirectory(
private suspend fun File.getCacheSize(
userID: String,
includeCryptoDb: Boolean = false,
): Long = withContext(dispatchers.io) {
// Rust sanitises the user ID replacing invalid characters with an _
val sanitisedUserID = userID.replace(":", "_")
val sessionDirectory = File(this@getCacheSize, sanitisedUserID)
if (includeCryptoDb) {
sessionDirectory.getSizeOfFiles()
} else {
listOf(
"matrix-sdk-state.sqlite3",
"matrix-sdk-state.sqlite3-shm",
"matrix-sdk-state.sqlite3-wal",
).map { fileName ->
File(sessionDirectory, fileName)
}.sumOf { file ->
file.length()
}
}
}
private suspend fun File.deleteSessionDirectory(
userID: String,
deleteCryptoDb: Boolean = false,
): Boolean {
): Boolean = withContext(dispatchers.io) {
// Rust sanitises the user ID replacing invalid characters with an _
val sanitisedUserID = userID.replace(":", "_")
val sessionDirectory = File(this, sanitisedUserID)
return if (deleteCryptoDb) {
val sessionDirectory = File(this@deleteSessionDirectory, sanitisedUserID)
if (deleteCryptoDb) {
// Delete the folder and all its content
sessionDirectory.deleteRecursively()
} else {

4
libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt

@ -102,6 +102,10 @@ class FakeMatrixClient( @@ -102,6 +102,10 @@ class FakeMatrixClient(
override fun stopSync() = Unit
override suspend fun getCacheSize(): Long {
return 0
}
override suspend fun clearCache() {
}

Loading…
Cancel
Save