Merge pull request #648 from vector-im/feature/bma/clearCache

Developer option to clear cache
This commit is contained in:
Benoit Marty
2023-06-26 12:06:26 +02:00
committed by GitHub
53 changed files with 690 additions and 106 deletions

View File

@@ -1,7 +0,0 @@
<component name="ProjectDictionaryState">
<dictionary name="bmarty">
<words>
<w>homeserver</w>
</words>
</dictionary>
</component>

View File

@@ -2,8 +2,10 @@
<dictionary name="shared">
<words>
<w>backstack</w>
<w>homeserver</w>
<w>kover</w>
<w>onboarding</w>
<w>showkase</w>
<w>textfields</w>
</words>
</dictionary>

View File

@@ -44,6 +44,7 @@ import io.element.android.appnav.root.RootPresenter
import io.element.android.appnav.root.RootView
import io.element.android.features.login.api.oidc.OidcAction
import io.element.android.features.login.api.oidc.OidcActionFlow
import io.element.android.features.preferences.api.CacheService
import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint
import io.element.android.libraries.architecture.BackstackNode
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
@@ -54,7 +55,9 @@ import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.parcelize.Parcelize
@@ -65,6 +68,7 @@ class RootFlowNode @AssistedInject constructor(
@Assisted val buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val authenticationService: MatrixAuthenticationService,
private val cacheService: CacheService,
private val matrixClientsHolder: MatrixClientsHolder,
private val presenter: RootPresenter,
private val bugReportEntryPoint: BugReportEntryPoint,
@@ -88,11 +92,19 @@ class RootFlowNode @AssistedInject constructor(
private fun observeLoggedInState() {
authenticationService.isLoggedIn()
.distinctUntilChanged()
.onEach { isLoggedIn ->
Timber.v("isLoggedIn=$isLoggedIn")
.combine(
cacheService.cacheIndex().onEach {
Timber.v("cacheIndex=$it")
matrixClientsHolder.removeAll()
}
) { isLoggedIn, cacheIdx -> isLoggedIn to cacheIdx }
.onEach { pair ->
val isLoggedIn = pair.first
val cacheIndex = pair.second
Timber.v("isLoggedIn=$isLoggedIn, cacheIndex=$cacheIndex")
if (isLoggedIn) {
tryToRestoreLatestSession(
onSuccess = { switchToLoggedInFlow(it) },
onSuccess = { switchToLoggedInFlow(it, cacheIndex) },
onFailure = { switchToNotLoggedInFlow() }
)
} else {
@@ -102,8 +114,8 @@ class RootFlowNode @AssistedInject constructor(
.launchIn(lifecycleScope)
}
private fun switchToLoggedInFlow(sessionId: SessionId) {
backstack.safeRoot(NavTarget.LoggedInFlow(sessionId))
private fun switchToLoggedInFlow(sessionId: SessionId, cacheIndex: Int) {
backstack.safeRoot(NavTarget.LoggedInFlow(sessionId, cacheIndex))
}
private fun switchToNotLoggedInFlow() {
@@ -163,7 +175,7 @@ class RootFlowNode @AssistedInject constructor(
object NotLoggedInFlow : NavTarget
@Parcelize
data class LoggedInFlow(val sessionId: SessionId) : NavTarget
data class LoggedInFlow(val sessionId: SessionId, val cacheIndex: Int) : NavTarget
@Parcelize
object BugReport : NavTarget
@@ -235,8 +247,9 @@ class RootFlowNode @AssistedInject constructor(
}
private suspend fun attachSession(sessionId: SessionId): LoggedInFlowNode {
val cacheIndex = cacheService.cacheIndex().first()
return attachChild {
backstack.newRoot(NavTarget.LoggedInFlow(sessionId))
backstack.newRoot(NavTarget.LoggedInFlow(sessionId, cacheIndex))
}
}
}

View File

@@ -29,9 +29,9 @@ class FakeAnalyticsService(
didAskUserConsent: Boolean = false
): AnalyticsService {
private var isEnabledFlow = MutableStateFlow(isEnabled)
private var didAskUserConsentFlow = MutableStateFlow(didAskUserConsent)
var capturedEvents = mutableListOf<VectorAnalyticsEvent>()
private val isEnabledFlow = MutableStateFlow(isEnabled)
private val didAskUserConsentFlow = MutableStateFlow(didAskUserConsent)
val capturedEvents = mutableListOf<VectorAnalyticsEvent>()
override fun getAvailableAnalyticsProviders(): List<AnalyticsProvider> = emptyList()

View File

@@ -23,7 +23,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
class FakeSeenInvitesStore : SeenInvitesStore {
private var existing = MutableStateFlow(emptySet<RoomId>())
private val existing = MutableStateFlow(emptySet<RoomId>())
private var provided: Set<RoomId>? = null
fun publishRoomIds(invites: Set<RoomId>) {

View File

@@ -21,7 +21,7 @@ import android.net.Uri
import androidx.core.net.toUri
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.messages.impl.timeline.util.FileExtensionExtractor
import io.element.android.features.messages.impl.timeline.util.FileSizeFormatter
import io.element.android.libraries.androidutils.filesize.FileSizeFormatter
import io.element.android.libraries.androidutils.file.getFileName
import io.element.android.libraries.androidutils.file.getFileSize
import io.element.android.libraries.androidutils.file.getMimeType

View File

@@ -25,8 +25,8 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.util.FileExtensionExtractor
import io.element.android.features.messages.impl.timeline.util.FileSizeFormatter
import io.element.android.features.messages.impl.timeline.util.toHtmlDocument
import io.element.android.libraries.androidutils.filesize.FileSizeFormatter
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType

View File

@@ -32,7 +32,7 @@ import io.element.android.features.messages.impl.timeline.factories.virtual.Time
import io.element.android.features.messages.impl.timeline.factories.virtual.TimelineItemVirtualFactory
import io.element.android.features.messages.impl.timeline.groups.TimelineItemGrouper
import io.element.android.features.messages.impl.timeline.util.FileExtensionExtractorWithoutValidation
import io.element.android.features.messages.timeline.FakeFileSizeFormatter
import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter
import io.element.android.libraries.dateformatter.test.FakeDaySeparatorFormatter
import io.element.android.libraries.eventformatter.api.TimelineEventFormatter
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem

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.api
import kotlinx.coroutines.flow.Flow
interface CacheService {
/**
* Returns a flow of the current cache index, can let the app to know when the
* cache has been cleared, for instance to restart the app.
* Will be a flow of Int, starting from 0, and incrementing each time the cache is cleared.
*/
fun cacheIndex(): Flow<Int>
}

View File

@@ -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)
@@ -39,6 +40,7 @@ dependencies {
implementation(projects.libraries.featureflag.api)
implementation(projects.libraries.featureflag.ui)
implementation(projects.libraries.elementresources)
implementation(projects.libraries.network)
implementation(projects.libraries.testtags)
implementation(projects.libraries.uiStrings)
implementation(projects.features.rageshake.api)
@@ -47,6 +49,7 @@ dependencies {
implementation(projects.features.logout.api)
implementation(libs.datetime)
implementation(libs.accompanist.placeholder)
implementation(libs.coil.compose)
api(projects.features.preferences.api)
ksp(libs.showkase.processor)
@@ -62,6 +65,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)
}

View File

@@ -14,25 +14,26 @@
* limitations under the License.
*/
package io.element.android.features.messages.impl.timeline.util
package io.element.android.features.preferences.impl
import android.content.Context
import android.text.format.Formatter
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.preferences.api.CacheService
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.SingleIn
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import javax.inject.Inject
interface FileSizeFormatter {
/**
* Formats a content size to be in the form of bytes, kilobytes, megabytes, etc.
*/
fun format(fileSize: Long): String
}
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class AndroidFileSizeFormatter @Inject constructor(@ApplicationContext private val context: Context) : FileSizeFormatter {
override fun format(fileSize: Long): String {
return Formatter.formatShortFileSize(context, fileSize)
class DefaultCacheService @Inject constructor() : CacheService {
private val cacheIndexState = MutableStateFlow(0)
override fun cacheIndex(): Flow<Int> {
return cacheIndexState
}
fun incrementCacheIndex() {
cacheIndexState.value++
}
}

View File

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

View File

@@ -18,12 +18,18 @@ 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.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
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 +42,8 @@ import javax.inject.Inject
class DeveloperSettingsPresenter @Inject constructor(
private val featureFlagService: FeatureFlagService,
private val computeCacheSizeUseCase: ComputeCacheSizeUseCase,
private val clearCacheUseCase: ClearCacheUseCase,
) : Presenter<DeveloperSettingsState> {
@Composable
@@ -47,6 +55,12 @@ class DeveloperSettingsPresenter @Inject constructor(
val enabledFeatures = remember {
mutableStateMapOf<String, Boolean>()
}
val cacheSize = remember {
mutableStateOf<Async<String>>(Async.Uninitialized)
}
val clearCacheAction = remember {
mutableStateOf<Async<Unit>>(Async.Uninitialized)
}
LaunchedEffect(Unit) {
FeatureFlags.values().forEach { feature ->
features[feature.key] = feature
@@ -55,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) {
@@ -64,11 +82,14 @@ class DeveloperSettingsPresenter @Inject constructor(
event.feature,
event.isEnabled
)
DeveloperSettingsEvents.ClearCache -> coroutineScope.clearCache(clearCacheAction)
}
}
return DeveloperSettingsState(
features = featureUiModels.toImmutableList(),
cacheSize = cacheSize.value,
clearCacheAction = clearCacheAction.value,
eventSink = ::handleEvents
)
}
@@ -103,6 +124,18 @@ class DeveloperSettingsPresenter @Inject constructor(
enabledFeatures[featureUiModel.key] = enabled
}
}
private fun CoroutineScope.computeCacheSize(cacheSize: MutableState<Async<String>>) = launch {
suspend {
computeCacheSizeUseCase()
}.execute(cacheSize)
}
private fun CoroutineScope.clearCache(clearCacheAction: MutableState<Async<Unit>>) = launch {
suspend {
clearCacheUseCase()
}.execute(clearCacheAction)
}
}

View File

@@ -16,10 +16,13 @@
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<FeatureUiModel>,
val cacheSize: Async<String>,
val clearCacheAction: Async<Unit>,
val eventSink: (DeveloperSettingsEvents) -> Unit
)

View File

@@ -17,16 +17,20 @@
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<DeveloperSettingsState> {
override val values: Sequence<DeveloperSettingsState>
get() = sequenceOf(
aDeveloperSettingsState(),
aDeveloperSettingsState().copy(clearCacheAction = Async.Loading()),
)
}
fun aDeveloperSettingsState() = DeveloperSettingsState(
features = aFeatureUiModelList(),
cacheSize = Async.Success("1.2 MB"),
clearCacheAction = Async.Uninitialized,
eventSink = {}
)

View File

@@ -16,11 +16,15 @@
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.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
import io.element.android.libraries.designsystem.components.preferences.PreferenceView
@@ -52,6 +56,20 @@ fun DeveloperSettingsView(
onClick = onOpenShowkase
)
}
val cache = state.cacheSize
PreferenceCategory(title = "Cache") {
PreferenceText(
title = "Clear cache",
icon = Icons.Default.Delete,
currentValue = cache.dataOrNull(),
loadingCurrentValue = state.cacheSize.isLoading() || state.clearCacheAction.isLoading(),
onClick = {
if (state.clearCacheAction.isLoading().not()) {
state.eventSink(DeveloperSettingsEvents.ClearCache)
}
}
)
}
}
}

View File

@@ -0,0 +1,62 @@
/*
* 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.
*/
@file:OptIn(ExperimentalCoilApi::class)
package io.element.android.features.preferences.impl.tasks
import android.content.Context
import coil.Coil
import coil.annotation.ExperimentalCoilApi
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.preferences.impl.DefaultCacheService
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 okhttp3.OkHttpClient
import javax.inject.Inject
import javax.inject.Provider
interface ClearCacheUseCase {
suspend operator fun invoke()
}
@ContributesBinding(SessionScope::class)
class DefaultClearCacheUseCase @Inject constructor(
@ApplicationContext private val context: Context,
private val matrixClient: MatrixClient,
private val coroutineDispatchers: CoroutineDispatchers,
private val defaultCacheIndexProvider: DefaultCacheService,
private val okHttpClient: Provider<OkHttpClient>,
) : ClearCacheUseCase {
override suspend fun invoke() = withContext(coroutineDispatchers.io) {
// Clear Matrix cache
matrixClient.clearCache()
// Clear Coil cache
Coil.imageLoader(context).let {
it.diskCache?.clear()
it.memoryCache?.clear()
}
// Clear OkHttp cache
okHttpClient.get().cache?.delete()
// Clear app cache
context.cacheDir.deleteRecursively()
// Ensure the app is restarted
defaultCacheIndexProvider.incrementCacheIndex()
}
}

View File

@@ -0,0 +1,48 @@
/*
* 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.filesize.FileSizeFormatter
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 operator fun invoke(): String
}
@ContributesBinding(SessionScope::class)
class DefaultComputeCacheSizeUseCase @Inject constructor(
@ApplicationContext private val context: Context,
private val matrixClient: MatrixClient,
private val coroutineDispatchers: CoroutineDispatchers,
private val fileSizeFormatter: FileSizeFormatter,
) : ComputeCacheSizeUseCase {
override suspend fun invoke(): String = withContext(coroutineDispatchers.io) {
var cumulativeSize = 0L
cumulativeSize += matrixClient.getCacheSize()
// - 4096 to not include the size fo the folder
cumulativeSize += (context.cacheDir.getSizeOfFiles() - 4096).coerceAtLeast(0)
fileSizeFormatter.format(cumulativeSize)
}
}

View File

@@ -20,6 +20,9 @@ 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.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
import kotlinx.coroutines.test.runTest
@@ -29,13 +32,17 @@ class DeveloperSettingsPresenterTest {
@Test
fun `present - ensures initial state is correct`() = runTest {
val presenter = DeveloperSettingsPresenter(
FakeFeatureFlagService()
FakeFeatureFlagService(),
FakeComputeCacheSizeUseCase(),
FakeClearCacheUseCase(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.features).isEmpty()
assertThat(initialState.clearCacheAction).isEqualTo(Async.Uninitialized)
assertThat(initialState.cacheSize).isEqualTo(Async.Uninitialized)
cancelAndIgnoreRemainingEvents()
}
}
@@ -43,7 +50,9 @@ class DeveloperSettingsPresenterTest {
@Test
fun `present - ensures feature list is loaded`() = runTest {
val presenter = DeveloperSettingsPresenter(
FakeFeatureFlagService()
FakeFeatureFlagService(),
FakeComputeCacheSizeUseCase(),
FakeClearCacheUseCase(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
@@ -58,7 +67,9 @@ class DeveloperSettingsPresenterTest {
@Test
fun `present - ensures state is updated when enabled feature event is triggered`() = runTest {
val presenter = DeveloperSettingsPresenter(
FakeFeatureFlagService()
FakeFeatureFlagService(),
FakeComputeCacheSizeUseCase(),
FakeClearCacheUseCase(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
@@ -74,4 +85,28 @@ class DeveloperSettingsPresenterTest {
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - clear cache`() = runTest {
val clearCacheUseCase = FakeClearCacheUseCase()
val presenter = DeveloperSettingsPresenter(
FakeFeatureFlagService(),
FakeComputeCacheSizeUseCase(),
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)
skipItems(1)
assertThat(awaitItem().clearCacheAction).isInstanceOf(Async.Success::class.java)
assertThat(clearCacheUseCase.executeHasBeenCalled).isTrue()
cancelAndIgnoreRemainingEvents()
}
}
}

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 invoke() = simulateLongTask {
executeHasBeenCalled = true
}
}

View File

@@ -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 invoke() = simulateLongTask {
"O kB"
}
}

View File

@@ -52,6 +52,7 @@ import java.io.OutputStreamWriter
import java.net.HttpURLConnection
import java.util.Locale
import javax.inject.Inject
import javax.inject.Provider
/**
* BugReporter creates and sends the bug reports.
@@ -62,7 +63,7 @@ class DefaultBugReporter @Inject constructor(
private val screenshotHolder: ScreenshotHolder,
private val crashDataStore: CrashDataStore,
private val coroutineDispatchers: CoroutineDispatchers,
private val okHttpClient: OkHttpClient,
private val okHttpClient: Provider<OkHttpClient>,
/*
private val activeSessionHolder: ActiveSessionHolder,
private val versionProvider: VersionProvider,
@@ -339,7 +340,7 @@ class DefaultBugReporter @Inject constructor(
// trigger the request
try {
mBugReportCall = okHttpClient.newCall(request)
mBugReportCall = okHttpClient.get().newCall(request)
response = mBugReportCall!!.execute()
responseCode = response.code
} catch (e: Exception) {

View File

@@ -17,9 +17,11 @@
package io.element.android.libraries.androidutils.file
import android.content.Context
import androidx.annotation.WorkerThread
import io.element.android.libraries.core.data.tryOrNull
import timber.log.Timber
import java.io.File
import java.util.Locale
import java.util.UUID
fun File.safeDelete() {
@@ -52,3 +54,99 @@ fun Context.createTmpFile(baseDir: File = cacheDir, extension: String? = null):
val suffix = extension?.let { ".$extension" }
return File.createTempFile(UUID.randomUUID().toString(), suffix, baseDir).apply { mkdirs() }
}
// Implementation should return true in case of success
typealias ActionOnFile = (file: File) -> Boolean
/* ==========================================================================================
* 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() }
}

View File

@@ -0,0 +1,52 @@
/*
* 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.filesize
import android.content.Context
import android.os.Build
import android.text.format.Formatter
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class AndroidFileSizeFormatter @Inject constructor(
@ApplicationContext private val context: Context,
) : FileSizeFormatter {
override fun format(fileSize: Long, useShortFormat: Boolean): String {
// Since Android O, the system considers that 1kB = 1000 bytes instead of 1024 bytes.
// We want to avoid that.
val normalizedSize = if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.N) {
fileSize
} else {
// First convert the size
when {
fileSize < 1024 -> fileSize
fileSize < 1024 * 1024 -> fileSize * 1000 / 1024
fileSize < 1024 * 1024 * 1024 -> fileSize * 1000 / 1024 * 1000 / 1024
else -> fileSize * 1000 / 1024 * 1000 / 1024 * 1000 / 1024
}
}
return if (useShortFormat) {
Formatter.formatShortFileSize(context, normalizedSize)
} else {
Formatter.formatFileSize(context, normalizedSize)
}
}
}

View File

@@ -14,12 +14,10 @@
* limitations under the License.
*/
package io.element.android.features.messages.timeline
import io.element.android.features.messages.impl.timeline.util.FileSizeFormatter
package io.element.android.libraries.androidutils.filesize
class FakeFileSizeFormatter : FileSizeFormatter {
override fun format(fileSize: Long): String {
override fun format(fileSize: Long, useShortFormat: Boolean): String {
return "$fileSize Bytes"
}
}

View File

@@ -0,0 +1,24 @@
/*
* 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.filesize
interface FileSizeFormatter {
/**
* Formats a content size to be in the form of bytes, kilobytes, megabytes, etc.
*/
fun format(fileSize: Long, useShortFormat: Boolean = true): String
}

View File

@@ -17,6 +17,7 @@
package io.element.android.libraries.designsystem.components.preferences
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -26,7 +27,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.progressSemantics
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.BugReport
@@ -55,7 +55,7 @@ fun PreferenceText(
tintColor: Color? = null,
onClick: () -> Unit = {},
) {
val minHeight = if (subtitle == null) preferenceMinHeightOnlyTitle else preferenceMinHeight
val minHeight = if (subtitle == null) preferenceMinHeightOnlyTitle else preferenceMinHeight
Box(
modifier = modifier
.fillMaxWidth()
@@ -69,9 +69,10 @@ fun PreferenceText(
.padding(vertical = preferencePaddingVertical)
) {
PreferenceIcon(icon = icon, tintColor = tintColor)
Column(modifier = Modifier
.weight(1f)
.align(Alignment.CenterVertically)
Column(
modifier = Modifier
.weight(1f)
.align(Alignment.CenterVertically)
) {
if (title != null) {
Text(
@@ -92,15 +93,24 @@ fun PreferenceText(
}
}
if (currentValue != null) {
Text(currentValue, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.secondary)
Spacer(Modifier.width(16.dp))
Text(
modifier = Modifier
.align(Alignment.CenterVertically)
.padding(horizontal = 16.dp),
text = currentValue,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.secondary,
)
} else if (loadingCurrentValue) {
CircularProgressIndicator(modifier = Modifier
.progressSemantics()
.size(20.dp), strokeWidth = 2.dp)
Spacer(Modifier.width(16.dp))
CircularProgressIndicator(
modifier = Modifier
.progressSemantics()
.padding(horizontal = 16.dp)
.size(20.dp)
.align(Alignment.CenterVertically),
strokeWidth = 2.dp
)
}
}
}
}
@@ -111,9 +121,39 @@ internal fun PreferenceTextPreview() = ElementThemedPreview { ContentToPreview()
@Composable
private fun ContentToPreview() {
PreferenceText(
title = "Title",
subtitle = "Some content",
icon = Icons.Default.BugReport,
)
Column(
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
PreferenceText(
title = "Title",
icon = Icons.Default.BugReport,
)
PreferenceText(
title = "Title",
subtitle = "Some content",
icon = Icons.Default.BugReport,
)
PreferenceText(
title = "Title",
subtitle = "Some content",
icon = Icons.Default.BugReport,
currentValue = "123",
)
PreferenceText(
title = "Title",
subtitle = "Some content",
icon = Icons.Default.BugReport,
loadingCurrentValue = true,
)
PreferenceText(
title = "Title",
icon = Icons.Default.BugReport,
currentValue = "123",
)
PreferenceText(
title = "Title",
icon = Icons.Default.BugReport,
loadingCurrentValue = true,
)
}
}

View File

@@ -50,6 +50,12 @@ interface MatrixClient : Closeable {
fun sessionVerificationService(): SessionVerificationService
fun pushersService(): PushersService
fun notificationService(): NotificationService
suspend fun getCacheSize(): Long
/**
* Will close the client and delete the cache data.
*/
suspend fun clearCache()
suspend fun logout()
suspend fun loadUserDisplayName(): Result<String>
suspend fun loadUserAvatarURLString(): Result<String?>

View File

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

View File

@@ -18,6 +18,8 @@
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
import io.element.android.libraries.matrix.api.core.ProgressCallback
@@ -336,15 +338,25 @@ class RustMatrixClient constructor(
client.destroy()
}
override suspend fun getCacheSize(): Long {
// Do not use client.userId since it can throw if client has been closed (during clear cache)
return baseDirectory.getCacheSize(userID = sessionId.value)
}
override suspend fun clearCache() {
close()
baseDirectory.deleteSessionDirectory(userID = sessionId.value, deleteCryptoDb = false)
}
override suspend fun logout() = withContext(dispatchers.io) {
try {
client.logout()
} catch (failure: Throwable) {
Timber.e(failure, "Fail to call logout on HS. Still delete local files.")
}
baseDirectory.deleteSessionDirectory(userID = client.userId())
sessionStore.removeSession(client.userId())
close()
baseDirectory.deleteSessionDirectory(userID = sessionId.value, deleteCryptoDb = true)
sessionStore.removeSession(sessionId.value)
}
override suspend fun loadUserDisplayName(): Result<String> = withContext(dispatchers.io) {
@@ -378,11 +390,48 @@ class RustMatrixClient constructor(
override fun roomMembershipObserver(): RoomMembershipObserver = roomMembershipObserver
private fun File.deleteSessionDirectory(userID: String): Boolean {
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, sanitisedUserID)
return sessionDirectory.deleteRecursively()
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 {
// Delete only the state.db file
sessionDirectory.listFiles().orEmpty()
.filter { it.name.contains("matrix-sdk-state") }
.forEach { file ->
Timber.w("Deleting file ${file.name}...")
file.safeDelete()
}
true
}
}
}

View File

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

View File

@@ -31,7 +31,7 @@ import kotlinx.coroutines.flow.flowOf
val A_OIDC_DATA = OidcDetails(url = "a-url")
class FakeAuthenticationService : MatrixAuthenticationService {
private var homeserver = MutableStateFlow<MatrixHomeServerDetails?>(null)
private val homeserver = MutableStateFlow<MatrixHomeServerDetails?>(null)
private var oidcError: Throwable? = null
private var oidcCancelError: Throwable? = null
private var loginError: Throwable? = null

View File

@@ -26,16 +26,17 @@ import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.matrix.api.MatrixClient
import okhttp3.OkHttpClient
import javax.inject.Inject
import javax.inject.Provider
class LoggedInImageLoaderFactory @Inject constructor(
@ApplicationContext private val context: Context,
private val matrixClient: MatrixClient,
private val okHttpClient: OkHttpClient,
private val okHttpClient: Provider<OkHttpClient>,
) : ImageLoaderFactory {
override fun newImageLoader(): ImageLoader {
return ImageLoader
.Builder(context)
.okHttpClient(okHttpClient)
.okHttpClient { okHttpClient.get() }
.components {
// Add gif support
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
@@ -54,12 +55,12 @@ class LoggedInImageLoaderFactory @Inject constructor(
class NotLoggedInImageLoaderFactory @Inject constructor(
@ApplicationContext private val context: Context,
private val okHttpClient: OkHttpClient,
private val okHttpClient: Provider<OkHttpClient>,
) : ImageLoaderFactory {
override fun newImageLoader(): ImageLoader {
return ImageLoader
.Builder(context)
.okHttpClient(okHttpClient)
.okHttpClient { okHttpClient.get() }
.build()
}
}

View File

@@ -43,6 +43,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.datetime.Clock
import kotlinx.datetime.TimeZone
import timber.log.Timber
import java.util.Locale
class RoomListScreen(
@@ -106,8 +107,10 @@ class RoomListScreen(
)
DisposableEffect(Unit) {
Timber.w("Start sync!")
matrixClient.startSync()
onDispose {
Timber.w("Stop sync!")
matrixClient.stopSync()
}
}