From 2a7d252a4e79facc5426666a784c21755c8ac68d Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 20 Jun 2023 13:41:04 +0200 Subject: [PATCH] Display cache size in the developer settings (#643) --- features/preferences/impl/build.gradle.kts | 1 + .../developer/DeveloperSettingsPresenter.kt | 16 ++ .../impl/developer/DeveloperSettingsState.kt | 1 + .../DeveloperSettingsStateProvider.kt | 1 + .../impl/developer/DeveloperSettingsView.kt | 7 +- .../impl/tasks/ComputeCacheSizeUseCase.kt | 45 ++++++ .../DeveloperSettingsPresenterTest.kt | 16 +- .../impl/tasks/FakeComputeCacheSizeUseCase.kt | 25 ++++ .../libraries/androidutils/file/FileUtils.kt | 137 ++++++++++++++++++ .../libraries/matrix/api/MatrixClient.kt | 1 + .../libraries/matrix/impl/RustMatrixClient.kt | 41 +++++- .../libraries/matrix/test/FakeMatrixClient.kt | 4 + 12 files changed, 283 insertions(+), 12 deletions(-) create mode 100644 features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/ComputeCacheSizeUseCase.kt create mode 100644 features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/tasks/FakeComputeCacheSizeUseCase.kt create mode 100644 libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/FileUtils.kt diff --git a/features/preferences/impl/build.gradle.kts b/features/preferences/impl/build.gradle.kts index bf8f04b61b..e0ecbe9ddd 100644 --- a/features/preferences/impl/build.gradle.kts +++ b/features/preferences/impl/build.gradle.kts @@ -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) 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 cb4a5d1ec9..ffcbb92ba9 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 @@ -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 class DeveloperSettingsPresenter @Inject constructor( private val featureFlagService: FeatureFlagService, + private val computeCacheSizeUseCase: ComputeCacheSizeUseCase, private val clearCacheUseCase: ClearCacheUseCase, ) : Presenter { @@ -53,6 +55,9 @@ class DeveloperSettingsPresenter @Inject constructor( val enabledFeatures = remember { mutableStateMapOf() } + val cacheSize = remember { + mutableStateOf>(Async.Uninitialized) + } val clearCacheAction = remember { mutableStateOf>(Async.Uninitialized) } @@ -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( return DeveloperSettingsState( features = featureUiModels.toImmutableList(), + cacheSizeInBytes = cacheSize.value, clearCacheAction = clearCacheAction.value, eventSink = ::handleEvents ) @@ -115,6 +125,12 @@ class DeveloperSettingsPresenter @Inject constructor( } } + private fun CoroutineScope.computeCacheSize(cacheSize: MutableState>) = launch { + suspend { + computeCacheSizeUseCase.execute() + }.execute(cacheSize) + } + private fun CoroutineScope.clearCache(clearCacheAction: MutableState>) = launch { suspend { clearCacheUseCase.execute() 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 392f83357f..7d9bfed714 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 @@ -22,6 +22,7 @@ import kotlinx.collections.immutable.ImmutableList data class DeveloperSettingsState constructor( val features: ImmutableList, + val cacheSizeInBytes: Async, 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 f600edd602..92fb248142 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 @@ -30,6 +30,7 @@ open class DeveloperSettingsStateProvider : PreviewParameterProvider 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() } +} 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 77e4bf810b..479e3169d9 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 getCacheSize(): Long suspend fun clearCache() suspend fun logout() suspend fun loadUserDisplayName(): Result 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 e6e49b5feb..03d18abba5 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.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( 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( } 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( override fun roomMembershipObserver(): RoomMembershipObserver = roomMembershipObserver - private fun File.deleteSessionDirectory( + private suspend fun File.getCacheSize( userID: String, - deleteCryptoDb: Boolean = false, - ): Boolean { + 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, sanitisedUserID) - return if (deleteCryptoDb) { + 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 = withContext(dispatchers.io) { + // Rust sanitises the user ID replacing invalid characters with an _ + val sanitisedUserID = userID.replace(":", "_") + val sessionDirectory = File(this@deleteSessionDirectory, sanitisedUserID) + if (deleteCryptoDb) { // Delete the folder and all its content sessionDirectory.deleteRecursively() } else { 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 85a3ea3a5c..eb5e4624d6 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,10 @@ class FakeMatrixClient( override fun stopSync() = Unit + override suspend fun getCacheSize(): Long { + return 0 + } + override suspend fun clearCache() { }