diff --git a/app/src/main/kotlin/io/element/android/x/ElementXApplication.kt b/app/src/main/kotlin/io/element/android/x/ElementXApplication.kt index c1bed67d80..03f0b20429 100644 --- a/app/src/main/kotlin/io/element/android/x/ElementXApplication.kt +++ b/app/src/main/kotlin/io/element/android/x/ElementXApplication.kt @@ -18,6 +18,7 @@ package io.element.android.x import android.app.Application import androidx.startup.AppInitializer +import io.element.android.features.cachecleaner.api.CacheCleanerInitializer import io.element.android.libraries.di.DaggerComponentOwner import io.element.android.x.di.AppComponent import io.element.android.x.di.DaggerAppComponent @@ -34,6 +35,7 @@ class ElementXApplication : Application(), DaggerComponentOwner { AppInitializer.getInstance(this).apply { initializeComponent(CrashInitializer::class.java) initializeComponent(TracingInitializer::class.java) + initializeComponent(CacheCleanerInitializer::class.java) } logApplicationInfo() } diff --git a/features/cachecleaner/api/build.gradle.kts b/features/cachecleaner/api/build.gradle.kts new file mode 100644 index 0000000000..38788af301 --- /dev/null +++ b/features/cachecleaner/api/build.gradle.kts @@ -0,0 +1,29 @@ +/* + * 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. + */ + +plugins { + id("io.element.android-library") + alias(libs.plugins.anvil) +} + +android { + namespace = "io.element.android.features.cachecleaner.api" +} + +dependencies { + implementation(projects.libraries.architecture) + implementation(libs.androidx.startup) +} diff --git a/features/cachecleaner/api/src/main/kotlin/io/element/android/features/cachecleaner/api/CacheCleaner.kt b/features/cachecleaner/api/src/main/kotlin/io/element/android/features/cachecleaner/api/CacheCleaner.kt new file mode 100644 index 0000000000..cd26d87bf9 --- /dev/null +++ b/features/cachecleaner/api/src/main/kotlin/io/element/android/features/cachecleaner/api/CacheCleaner.kt @@ -0,0 +1,26 @@ +/* + * 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.cachecleaner.api + +interface CacheCleaner { + /** + * Clear the cache subdirs holding temporarily decrypted content (such as media and voice messages). + * + * Will fail silently in case of errors while deleting the files. + */ + fun clearCache() +} diff --git a/features/cachecleaner/api/src/main/kotlin/io/element/android/features/cachecleaner/api/CacheCleanerBindings.kt b/features/cachecleaner/api/src/main/kotlin/io/element/android/features/cachecleaner/api/CacheCleanerBindings.kt new file mode 100644 index 0000000000..6492f9e62d --- /dev/null +++ b/features/cachecleaner/api/src/main/kotlin/io/element/android/features/cachecleaner/api/CacheCleanerBindings.kt @@ -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.cachecleaner.api + +import com.squareup.anvil.annotations.ContributesTo +import io.element.android.libraries.di.AppScope + +@ContributesTo(AppScope::class) +interface CacheCleanerBindings { + fun cacheCleaner(): CacheCleaner +} diff --git a/features/cachecleaner/api/src/main/kotlin/io/element/android/features/cachecleaner/api/CacheCleanerInitializer.kt b/features/cachecleaner/api/src/main/kotlin/io/element/android/features/cachecleaner/api/CacheCleanerInitializer.kt new file mode 100644 index 0000000000..5cd17c8715 --- /dev/null +++ b/features/cachecleaner/api/src/main/kotlin/io/element/android/features/cachecleaner/api/CacheCleanerInitializer.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2022 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.cachecleaner.api + +import android.content.Context +import androidx.startup.Initializer +import io.element.android.libraries.architecture.bindings + +class CacheCleanerInitializer : Initializer { + override fun create(context: Context) { + context.bindings().cacheCleaner().clearCache() + } + + override fun dependencies(): List>> = emptyList() +} diff --git a/features/cachecleaner/impl/build.gradle.kts b/features/cachecleaner/impl/build.gradle.kts new file mode 100644 index 0000000000..c95619419b --- /dev/null +++ b/features/cachecleaner/impl/build.gradle.kts @@ -0,0 +1,41 @@ +/* + * 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. + */ + +plugins { + id("io.element.android-compose-library") + alias(libs.plugins.anvil) +} + +android { + namespace = "io.element.android.features.cachecleaner.impl" +} + +anvil { + generateDaggerFactories.set(true) +} + +dependencies { + implementation(projects.anvilannotations) + anvil(projects.anvilcodegen) + api(projects.features.cachecleaner.api) + implementation(projects.libraries.core) + implementation(projects.libraries.architecture) + + testImplementation(libs.test.junit) + testImplementation(libs.coroutines.test) + testImplementation(libs.test.truth) + testImplementation(projects.tests.testutils) +} diff --git a/features/cachecleaner/impl/src/main/kotlin/io/element/android/features/cachecleaner/impl/DefaultCacheCleaner.kt b/features/cachecleaner/impl/src/main/kotlin/io/element/android/features/cachecleaner/impl/DefaultCacheCleaner.kt new file mode 100644 index 0000000000..fc77a5934a --- /dev/null +++ b/features/cachecleaner/impl/src/main/kotlin/io/element/android/features/cachecleaner/impl/DefaultCacheCleaner.kt @@ -0,0 +1,59 @@ +/* + * 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.cachecleaner.impl + +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.features.cachecleaner.api.CacheCleaner +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.CacheDirectory +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import timber.log.Timber +import java.io.File +import javax.inject.Inject + +/** + * Default implementation of [CacheCleaner]. + */ +@ContributesBinding(AppScope::class) +class DefaultCacheCleaner @Inject constructor( + private val scope: CoroutineScope, + private val dispatchers: CoroutineDispatchers, + @CacheDirectory private val cacheDir: File, +) : CacheCleaner { + companion object { + val SUBDIRS_TO_CLEANUP = listOf("temp/media", "temp/voice") + } + + override fun clearCache() { + scope.launch(dispatchers.io) { + runCatching { + SUBDIRS_TO_CLEANUP.forEach { + File(cacheDir.path, it).apply { + if (exists()) { + if (!deleteRecursively()) error("Failed to delete recursively cache directory $this") + } + if (!mkdirs()) error("Failed to create cache directory $this") + } + } + }.onFailure { + Timber.e(it, "Failed to clear cache") + } + } + } +} diff --git a/features/cachecleaner/impl/src/test/kotlin/io/element/android/features/cachecleaner/impl/DefaultCacheCleanerTest.kt b/features/cachecleaner/impl/src/test/kotlin/io/element/android/features/cachecleaner/impl/DefaultCacheCleanerTest.kt new file mode 100644 index 0000000000..7fe70028ac --- /dev/null +++ b/features/cachecleaner/impl/src/test/kotlin/io/element/android/features/cachecleaner/impl/DefaultCacheCleanerTest.kt @@ -0,0 +1,71 @@ +/* + * 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.cachecleaner.impl + +import com.google.common.truth.Truth +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +class DefaultCacheCleanerTest { + @get:Rule + val temporaryFolder = TemporaryFolder() + + @Test + fun `calling clearCache actually removes file in the SUBDIRS_TO_CLEANUP list`() = runTest { + // Create temp subdirs and fill with 2 files each + DefaultCacheCleaner.SUBDIRS_TO_CLEANUP.forEach { + File(temporaryFolder.root, it).apply { + mkdirs() + File(this, "temp1").createNewFile() + File(this, "temp2").createNewFile() + } + } + + // Clear cache + aCacheCleaner().clearCache() + + // Check the files are gone but the sub dirs are not. + DefaultCacheCleaner.SUBDIRS_TO_CLEANUP.forEach { + File(temporaryFolder.root, it).apply { + Truth.assertThat(exists()).isTrue() + Truth.assertThat(isDirectory).isTrue() + Truth.assertThat(listFiles()).isEmpty() + } + } + } + + @Test + fun `clear cache fails silently`() = runTest { + // Set cache dir as unreadable, unwritable and unexecutable so that the deletion fails. + check(temporaryFolder.root.setReadable(false)) + check(temporaryFolder.root.setWritable(false)) + check(temporaryFolder.root.setExecutable(false)) + + aCacheCleaner().clearCache() + } + + private fun TestScope.aCacheCleaner() = DefaultCacheCleaner( + scope = this, + dispatchers = this.testCoroutineDispatchers(true), + cacheDir = temporaryFolder.root, + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessageMediaRepo.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessageMediaRepo.kt index a36eccc1d2..cc8bd1945f 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessageMediaRepo.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessageMediaRepo.kt @@ -89,7 +89,6 @@ class DefaultVoiceMessageMediaRepo @AssistedInject constructor( source = mediaSource, mimeType = mimeType, body = body, - useCache = false, ).mapCatching { it.use { mediaFile -> val dest = cachedFile.apply { parentFile?.mkdirs() }