Handle preference stores corruption by clearing them (#5086)

* Handle preference stores corruption by clearing them:
    - Use the centralised `PreferenceDataStoreFactory` instead of `preferences by`.
    - Add `DefaultPreferencesCorruptionHandlerFactory.replaceWithEmpty` to its `create(name)` method so all preference stores are cleared if they're corrupted.

* Add detekt rule to make sure we use `PreferenceDataStoreFactory` instead of `by preferencesDataStore`

* Remove `@SingleIn` annotations as the annotated class no longer have to be singletons
This commit is contained in:
Jorge Martin Espinosa 2025-08-22 08:59:06 +02:00 committed by GitHub
parent b54b74a198
commit 2f2e886e9f
30 changed files with 198 additions and 138 deletions

View file

@ -10,10 +10,14 @@ package io.element.android.x.initializer
import android.content.Context import android.content.Context
import androidx.startup.Initializer import androidx.startup.Initializer
import io.element.android.features.rageshake.impl.crash.VectorUncaughtExceptionHandler import io.element.android.features.rageshake.impl.crash.VectorUncaughtExceptionHandler
import io.element.android.features.rageshake.impl.di.RageshakeBindings
import io.element.android.libraries.architecture.bindings
class CrashInitializer : Initializer<Unit> { class CrashInitializer : Initializer<Unit> {
override fun create(context: Context) { override fun create(context: Context) {
VectorUncaughtExceptionHandler(context).activate() VectorUncaughtExceptionHandler(
context.bindings<RageshakeBindings>().preferencesCrashDataStore(),
).activate()
} }
override fun dependencies(): List<Class<out Initializer<*>>> = emptyList() override fun dependencies(): List<Class<out Initializer<*>>> = emptyList()

View file

@ -27,6 +27,7 @@ dependencies {
implementation(projects.appconfig) implementation(projects.appconfig)
implementation(projects.features.enterprise.api) implementation(projects.features.enterprise.api)
implementation(projects.libraries.core) implementation(projects.libraries.core)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui) implementation(projects.libraries.matrixui)

View file

@ -7,44 +7,39 @@
package io.element.android.features.lockscreen.impl.storage package io.element.android.features.lockscreen.impl.storage
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.lockscreen.impl.LockScreenConfig import io.element.android.features.lockscreen.impl.LockScreenConfig
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.preferences.api.store.PreferenceDataStoreFactory
import io.element.android.libraries.di.SingleIn
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import javax.inject.Inject import javax.inject.Inject
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "pin_code_store")
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class PreferencesLockScreenStore @Inject constructor( class PreferencesLockScreenStore @Inject constructor(
@ApplicationContext private val context: Context, preferenceDataStoreFactory: PreferenceDataStoreFactory,
private val lockScreenConfig: LockScreenConfig, private val lockScreenConfig: LockScreenConfig,
) : LockScreenStore { ) : LockScreenStore {
private val dataStore = preferenceDataStoreFactory.create("pin_code_store")
private val pinCodeKey = stringPreferencesKey("encoded_pin_code") private val pinCodeKey = stringPreferencesKey("encoded_pin_code")
private val remainingAttemptsKey = intPreferencesKey("remaining_pin_code_attempts") private val remainingAttemptsKey = intPreferencesKey("remaining_pin_code_attempts")
private val biometricUnlockKey = booleanPreferencesKey("biometric_unlock_enabled") private val biometricUnlockKey = booleanPreferencesKey("biometric_unlock_enabled")
override suspend fun getRemainingPinCodeAttemptsNumber(): Int { override suspend fun getRemainingPinCodeAttemptsNumber(): Int {
return context.dataStore.data.map { preferences -> return dataStore.data.map { preferences ->
preferences.getRemainingPinCodeAttemptsNumber() preferences.getRemainingPinCodeAttemptsNumber()
}.first() }.first()
} }
override suspend fun onWrongPin() { override suspend fun onWrongPin() {
context.dataStore.edit { preferences -> dataStore.edit { preferences ->
val current = preferences.getRemainingPinCodeAttemptsNumber() val current = preferences.getRemainingPinCodeAttemptsNumber()
val remaining = (current - 1).coerceAtLeast(0) val remaining = (current - 1).coerceAtLeast(0)
preferences[remainingAttemptsKey] = remaining preferences[remainingAttemptsKey] = remaining
@ -52,43 +47,43 @@ class PreferencesLockScreenStore @Inject constructor(
} }
override suspend fun resetCounter() { override suspend fun resetCounter() {
context.dataStore.edit { preferences -> dataStore.edit { preferences ->
preferences[remainingAttemptsKey] = lockScreenConfig.maxPinCodeAttemptsBeforeLogout preferences[remainingAttemptsKey] = lockScreenConfig.maxPinCodeAttemptsBeforeLogout
} }
} }
override suspend fun getEncryptedCode(): String? { override suspend fun getEncryptedCode(): String? {
return context.dataStore.data.map { preferences -> return dataStore.data.map { preferences ->
preferences[pinCodeKey] preferences[pinCodeKey]
}.first() }.first()
} }
override suspend fun saveEncryptedPinCode(pinCode: String) { override suspend fun saveEncryptedPinCode(pinCode: String) {
context.dataStore.edit { preferences -> dataStore.edit { preferences ->
preferences[pinCodeKey] = pinCode preferences[pinCodeKey] = pinCode
} }
} }
override suspend fun deleteEncryptedPinCode() { override suspend fun deleteEncryptedPinCode() {
context.dataStore.edit { preferences -> dataStore.edit { preferences ->
preferences.remove(pinCodeKey) preferences.remove(pinCodeKey)
} }
} }
override fun hasPinCode(): Flow<Boolean> { override fun hasPinCode(): Flow<Boolean> {
return context.dataStore.data.map { preferences -> return dataStore.data.map { preferences ->
preferences[pinCodeKey] != null preferences[pinCodeKey] != null
} }
} }
override fun isBiometricUnlockAllowed(): Flow<Boolean> { override fun isBiometricUnlockAllowed(): Flow<Boolean> {
return context.dataStore.data.map { preferences -> return dataStore.data.map { preferences ->
preferences[biometricUnlockKey] ?: false preferences[biometricUnlockKey] ?: false
} }
} }
override suspend fun setIsBiometricUnlockAllowed(isAllowed: Boolean) { override suspend fun setIsBiometricUnlockAllowed(isAllowed: Boolean) {
context.dataStore.edit { preferences -> dataStore.edit { preferences ->
preferences[biometricUnlockKey] = isAllowed preferences[biometricUnlockKey] = isAllowed
} }
} }

View file

@ -20,6 +20,7 @@ setupAnvil()
dependencies { dependencies {
implementation(projects.features.migration.api) implementation(projects.features.migration.api)
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.preferences.impl) implementation(projects.libraries.preferences.impl)
implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.datastore.preferences)
implementation(projects.features.rageshake.api) implementation(projects.features.rageshake.api)

View file

@ -7,27 +7,22 @@
package io.element.android.features.migration.impl package io.element.android.features.migration.impl
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.preferences.api.store.PreferenceDataStoreFactory
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import javax.inject.Inject import javax.inject.Inject
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "elementx_migration")
private val applicationMigrationVersion = intPreferencesKey("applicationMigrationVersion") private val applicationMigrationVersion = intPreferencesKey("applicationMigrationVersion")
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class DefaultMigrationStore @Inject constructor( class DefaultMigrationStore @Inject constructor(
@ApplicationContext context: Context, preferenceDataStoreFactory: PreferenceDataStoreFactory,
) : MigrationStore { ) : MigrationStore {
private val store = context.dataStore private val store = preferenceDataStoreFactory.create("elementx_migration")
override suspend fun setApplicationMigrationVersion(version: Int) { override suspend fun setApplicationMigrationVersion(version: Int) {
store.edit { prefs -> store.edit { prefs ->

View file

@ -33,6 +33,7 @@ dependencies {
implementation(projects.libraries.network) implementation(projects.libraries.network)
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
implementation(projects.libraries.designsystem) implementation(projects.libraries.designsystem)
implementation(projects.libraries.preferences.api)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
implementation(projects.libraries.sessionStorage.api) implementation(projects.libraries.sessionStorage.api)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
@ -56,6 +57,7 @@ dependencies {
testImplementation(projects.libraries.sessionStorage.implMemory) testImplementation(projects.libraries.sessionStorage.implMemory)
testImplementation(projects.libraries.sessionStorage.test) testImplementation(projects.libraries.sessionStorage.test)
testImplementation(projects.features.rageshake.test) testImplementation(projects.features.rageshake.test)
testImplementation(projects.libraries.preferences.test)
testImplementation(projects.tests.testutils) testImplementation(projects.tests.testutils)
testImplementation(projects.services.toolbox.test) testImplementation(projects.services.toolbox.test)
testImplementation(libs.network.mockwebserver) testImplementation(libs.network.mockwebserver)

View file

@ -7,32 +7,26 @@
package io.element.android.features.rageshake.impl.crash package io.element.android.features.rageshake.impl.crash
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.core.bool.orFalse import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.preferences.api.store.PreferenceDataStoreFactory
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import javax.inject.Inject import javax.inject.Inject
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "elementx_crash")
private val appHasCrashedKey = booleanPreferencesKey("appHasCrashed") private val appHasCrashedKey = booleanPreferencesKey("appHasCrashed")
private val crashDataKey = stringPreferencesKey("crashData") private val crashDataKey = stringPreferencesKey("crashData")
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class PreferencesCrashDataStore @Inject constructor( class PreferencesCrashDataStore @Inject constructor(
@ApplicationContext context: Context preferenceDataStoreFactory: PreferenceDataStoreFactory,
) : CrashDataStore { ) : CrashDataStore {
private val store = context.dataStore private val store = preferenceDataStoreFactory.create("elementx_crash")
override fun setCrashData(crashData: String) { override fun setCrashData(crashData: String) {
// Must block // Must block

View file

@ -7,7 +7,6 @@
package io.element.android.features.rageshake.impl.crash package io.element.android.features.rageshake.impl.crash
import android.content.Context
import android.os.Build import android.os.Build
import io.element.android.libraries.core.data.tryOrNull import io.element.android.libraries.core.data.tryOrNull
import timber.log.Timber import timber.log.Timber
@ -15,9 +14,8 @@ import java.io.PrintWriter
import java.io.StringWriter import java.io.StringWriter
class VectorUncaughtExceptionHandler( class VectorUncaughtExceptionHandler(
context: Context private val preferencesCrashDataStore: PreferencesCrashDataStore,
) : Thread.UncaughtExceptionHandler { ) : Thread.UncaughtExceptionHandler {
private val crashDataStore = PreferencesCrashDataStore(context)
private var previousHandler: Thread.UncaughtExceptionHandler? = null private var previousHandler: Thread.UncaughtExceptionHandler? = null
/** /**
@ -65,7 +63,7 @@ class VectorUncaughtExceptionHandler(
append(sw.buffer.toString()) append(sw.buffer.toString())
} }
Timber.e("FATAL EXCEPTION $bugDescription") Timber.e("FATAL EXCEPTION $bugDescription")
crashDataStore.setCrashData(bugDescription) preferencesCrashDataStore.setCrashData(bugDescription)
// Show the classical system popup // Show the classical system popup
previousHandler?.uncaughtException(thread, throwable) previousHandler?.uncaughtException(thread, throwable)
} }

View file

@ -0,0 +1,17 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.rageshake.impl.di
import com.squareup.anvil.annotations.ContributesTo
import io.element.android.features.rageshake.impl.crash.PreferencesCrashDataStore
import io.element.android.libraries.di.AppScope
@ContributesTo(AppScope::class)
interface RageshakeBindings {
fun preferencesCrashDataStore(): PreferencesCrashDataStore
}

View file

@ -7,31 +7,25 @@
package io.element.android.features.rageshake.impl.rageshake package io.element.android.features.rageshake.impl.rageshake
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.floatPreferencesKey import androidx.datastore.preferences.core.floatPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.core.bool.orFalse import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.preferences.api.store.PreferenceDataStoreFactory
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import javax.inject.Inject import javax.inject.Inject
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "elementx_rageshake")
private val enabledKey = booleanPreferencesKey("enabled") private val enabledKey = booleanPreferencesKey("enabled")
private val sensitivityKey = floatPreferencesKey("sensitivity") private val sensitivityKey = floatPreferencesKey("sensitivity")
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class PreferencesRageshakeDataStore @Inject constructor( class PreferencesRageshakeDataStore @Inject constructor(
@ApplicationContext context: Context preferenceDataStoreFactory: PreferenceDataStoreFactory,
) : RageshakeDataStore { ) : RageshakeDataStore {
private val store = context.dataStore private val store = preferenceDataStoreFactory.create("elementx_rageshake")
override fun isEnabled(): Flow<Boolean> { override fun isEnabled(): Flow<Boolean> {
return store.data.map { prefs -> return store.data.map { prefs ->

View file

@ -9,28 +9,28 @@ package io.element.android.features.rageshake.impl.crash
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.preferences.test.FakePreferenceDataStoreFactory
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
@RunWith(RobolectricTestRunner::class) @RunWith(RobolectricTestRunner::class)
class VectorUncaughtExceptionHandlerTest { class VectorUncaughtExceptionHandlerTest {
@Test @Test
fun `activate should change the default handler`() { fun `activate should change the default handler`() {
val sut = VectorUncaughtExceptionHandler(RuntimeEnvironment.getApplication()) val sut = VectorUncaughtExceptionHandler(PreferencesCrashDataStore(FakePreferenceDataStoreFactory()))
sut.activate() sut.activate()
assertThat(Thread.getDefaultUncaughtExceptionHandler()).isInstanceOf(VectorUncaughtExceptionHandler::class.java) assertThat(Thread.getDefaultUncaughtExceptionHandler()).isInstanceOf(VectorUncaughtExceptionHandler::class.java)
} }
@Test @Test
fun `uncaught exception`() = runTest { fun `uncaught exception`() = runTest {
val crashDataStore = PreferencesCrashDataStore(RuntimeEnvironment.getApplication()) val crashDataStore = PreferencesCrashDataStore(FakePreferenceDataStoreFactory())
assertThat(crashDataStore.appHasCrashed().first()).isFalse() assertThat(crashDataStore.appHasCrashed().first()).isFalse()
assertThat(crashDataStore.crashInfo().first()).isEmpty() assertThat(crashDataStore.crashInfo().first()).isEmpty()
val sut = VectorUncaughtExceptionHandler(RuntimeEnvironment.getApplication()) val sut = VectorUncaughtExceptionHandler(crashDataStore)
sut.uncaughtException(Thread(), AN_EXCEPTION) sut.uncaughtException(Thread(), AN_EXCEPTION)
assertThat(crashDataStore.appHasCrashed().first()).isTrue() assertThat(crashDataStore.appHasCrashed().first()).isTrue()
val crashInfo = crashDataStore.crashInfo().first() val crashInfo = crashDataStore.crashInfo().first()

View file

@ -54,3 +54,6 @@ com.squareup.anvil.kspContributingAnnotations=io.element.android.anvilannotation
# Only apply KSP to main sources # Only apply KSP to main sources
ksp.allow.all.target.configuration=false ksp.allow.all.target.configuration=false
# Used to prevent detekt from reusing invalid cached rules
detekt.use.worker.api=true

View file

@ -31,6 +31,7 @@ dependencies {
implementation(libs.androidx.activity.activity) implementation(libs.androidx.activity.activity)
implementation(libs.androidx.recyclerview) implementation(libs.androidx.recyclerview)
implementation(libs.androidx.exifinterface) implementation(libs.androidx.exifinterface)
implementation(libs.androidx.datastore.preferences)
api(libs.androidx.browser) api(libs.androidx.browser)
testImplementation(projects.tests.testutils) testImplementation(projects.tests.testutils)

View file

@ -0,0 +1,26 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.androidutils.preferences
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.emptyPreferences
object DefaultPreferencesCorruptionHandlerFactory {
/**
* Creates a [ReplaceFileCorruptionHandler] that will replace the corrupted preferences file with an empty preferences object.
*/
fun replaceWithEmpty(): ReplaceFileCorruptionHandler<Preferences> {
return ReplaceFileCorruptionHandler(
produceNewData = {
// If the preferences file is corrupted, we return an empty preferences object
emptyPreferences()
},
)
}
}

View file

@ -24,7 +24,9 @@ dependencies {
implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.datastore.preferences)
implementation(projects.appconfig) implementation(projects.appconfig)
implementation(projects.libraries.di) implementation(projects.libraries.di)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.core) implementation(projects.libraries.core)
implementation(projects.libraries.preferences.api)
implementation(libs.coroutines.core) implementation(libs.coroutines.core)
testImplementation(libs.test.junit) testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test) testImplementation(libs.coroutines.test)

View file

@ -7,30 +7,24 @@
package io.element.android.libraries.featureflag.impl package io.element.android.libraries.featureflag.impl
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.preferencesDataStore
import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.featureflag.api.Feature import io.element.android.libraries.featureflag.api.Feature
import io.element.android.libraries.preferences.api.store.PreferenceDataStoreFactory
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import javax.inject.Inject import javax.inject.Inject
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "elementx_featureflag")
/** /**
* Note: this will be used only in the nightly and in the debug build. * Note: this will be used only in the nightly and in the debug build.
*/ */
class PreferencesFeatureFlagProvider @Inject constructor( class PreferencesFeatureFlagProvider @Inject constructor(
@ApplicationContext context: Context,
private val buildMeta: BuildMeta, private val buildMeta: BuildMeta,
preferenceDataStoreFactory: PreferenceDataStoreFactory,
) : MutableFeatureFlagProvider { ) : MutableFeatureFlagProvider {
private val store = context.dataStore private val store = preferenceDataStoreFactory.create("elementx_featureflag")
override val priority = MEDIUM_PRIORITY override val priority = MEDIUM_PRIORITY

View file

@ -35,6 +35,7 @@ dependencies {
implementation(projects.libraries.troubleshoot.api) implementation(projects.libraries.troubleshoot.api)
implementation(projects.libraries.designsystem) implementation(projects.libraries.designsystem)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
implementation(projects.libraries.preferences.api)
implementation(projects.services.toolbox.api) implementation(projects.services.toolbox.api)
api(projects.libraries.permissions.api) api(projects.libraries.permissions.api)

View file

@ -7,28 +7,22 @@
package io.element.android.libraries.permissions.impl package io.element.android.libraries.permissions.impl
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.preferencesDataStore
import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.core.bool.orFalse import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.permissions.api.PermissionsStore import io.element.android.libraries.permissions.api.PermissionsStore
import io.element.android.libraries.preferences.api.store.PreferenceDataStoreFactory
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import javax.inject.Inject import javax.inject.Inject
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "permissions_store")
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class DefaultPermissionsStore @Inject constructor( class DefaultPermissionsStore @Inject constructor(
@ApplicationContext private val context: Context, preferenceDataStoreFactory: PreferenceDataStoreFactory,
) : PermissionsStore { ) : PermissionsStore {
private val store = context.dataStore private val store = preferenceDataStoreFactory.create("permissions_store")
override suspend fun setPermissionDenied(permission: String, value: Boolean) { override suspend fun setPermissionDenied(permission: String, value: Boolean) {
store.edit { prefs -> store.edit { prefs ->

View file

@ -7,28 +7,22 @@
package io.element.android.libraries.preferences.impl.store package io.element.android.libraries.preferences.impl.store
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.core.meta.BuildType import io.element.android.libraries.core.meta.BuildType
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.matrix.api.media.MediaPreviewValue import io.element.android.libraries.matrix.api.media.MediaPreviewValue
import io.element.android.libraries.matrix.api.tracing.LogLevel import io.element.android.libraries.matrix.api.tracing.LogLevel
import io.element.android.libraries.matrix.api.tracing.TraceLogPack import io.element.android.libraries.matrix.api.tracing.TraceLogPack
import io.element.android.libraries.preferences.api.store.AppPreferencesStore import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.preferences.api.store.PreferenceDataStoreFactory
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import javax.inject.Inject import javax.inject.Inject
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "elementx_preferences")
private val developerModeKey = booleanPreferencesKey("developerMode") private val developerModeKey = booleanPreferencesKey("developerMode")
private val customElementCallBaseUrlKey = stringPreferencesKey("elementCallBaseUrl") private val customElementCallBaseUrlKey = stringPreferencesKey("elementCallBaseUrl")
private val themeKey = stringPreferencesKey("theme") private val themeKey = stringPreferencesKey("theme")
@ -39,10 +33,10 @@ private val traceLogPacksKey = stringPreferencesKey("traceLogPacks")
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class DefaultAppPreferencesStore @Inject constructor( class DefaultAppPreferencesStore @Inject constructor(
@ApplicationContext context: Context,
private val buildMeta: BuildMeta, private val buildMeta: BuildMeta,
preferenceDataStoreFactory: PreferenceDataStoreFactory,
) : AppPreferencesStore { ) : AppPreferencesStore {
private val store = context.dataStore private val store = preferenceDataStoreFactory.create("elementx_preferences")
override suspend fun setDeveloperModeEnabled(enabled: Boolean) { override suspend fun setDeveloperModeEnabled(enabled: Boolean) {
store.edit { prefs -> store.edit { prefs ->

View file

@ -12,6 +12,7 @@ import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStore import androidx.datastore.preferences.preferencesDataStore
import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.androidutils.preferences.DefaultPreferencesCorruptionHandlerFactory
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.SingleIn import io.element.android.libraries.di.SingleIn
@ -27,7 +28,10 @@ class DefaultPreferencesDataStoreFactory @Inject constructor(
private val dataStoreHolders = ConcurrentHashMap<String, DataStoreHolder>() private val dataStoreHolders = ConcurrentHashMap<String, DataStoreHolder>()
private class DataStoreHolder(name: String) { private class DataStoreHolder(name: String) {
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = name) val Context.dataStore: DataStore<Preferences> by preferencesDataStore(
name = name,
corruptionHandler = DefaultPreferencesCorruptionHandlerFactory.replaceWithEmpty(),
)
} }
override fun create(name: String): DataStore<Preferences> { override fun create(name: String): DataStore<Preferences> {

View file

@ -7,12 +7,8 @@
package io.element.android.libraries.push.impl.store package io.element.android.libraries.push.impl.store
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import app.cash.sqldelight.coroutines.asFlow import app.cash.sqldelight.coroutines.asFlow
import app.cash.sqldelight.coroutines.mapToList import app.cash.sqldelight.coroutines.mapToList
import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesBinding
@ -20,29 +16,30 @@ import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.dateformatter.api.DateFormatter import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.api.DateFormatterMode import io.element.android.libraries.dateformatter.api.DateFormatterMode
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.preferences.api.store.PreferenceDataStoreFactory
import io.element.android.libraries.push.api.history.PushHistoryItem import io.element.android.libraries.push.api.history.PushHistoryItem
import io.element.android.libraries.push.impl.PushDatabase import io.element.android.libraries.push.impl.PushDatabase
import io.element.android.libraries.push.impl.store.DefaultPushDataStore.Companion.BATTERY_OPTIMIZATION_BANNER_STATE_DISMISSED
import io.element.android.libraries.push.impl.store.DefaultPushDataStore.Companion.BATTERY_OPTIMIZATION_BANNER_STATE_INIT
import io.element.android.libraries.push.impl.store.DefaultPushDataStore.Companion.BATTERY_OPTIMIZATION_BANNER_STATE_SHOW
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import javax.inject.Inject import javax.inject.Inject
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "push_store")
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class DefaultPushDataStore @Inject constructor( class DefaultPushDataStore @Inject constructor(
@ApplicationContext private val context: Context,
private val pushDatabase: PushDatabase, private val pushDatabase: PushDatabase,
private val dateFormatter: DateFormatter, private val dateFormatter: DateFormatter,
private val dispatchers: CoroutineDispatchers, private val dispatchers: CoroutineDispatchers,
preferencesFactory: PreferenceDataStoreFactory,
) : PushDataStore { ) : PushDataStore {
private val pushCounter = intPreferencesKey("push_counter") private val pushCounter = intPreferencesKey("push_counter")
private val dataStore = preferencesFactory.create("push_store")
/** /**
* Integer preference to track the state of the battery optimization banner. * Integer preference to track the state of the battery optimization banner.
* Possible values: * Possible values:
@ -52,24 +49,24 @@ class DefaultPushDataStore @Inject constructor(
*/ */
private val batteryOptimizationBannerState = intPreferencesKey("battery_optimization_banner_state") private val batteryOptimizationBannerState = intPreferencesKey("battery_optimization_banner_state")
override val pushCounterFlow: Flow<Int> = context.dataStore.data.map { preferences -> override val pushCounterFlow: Flow<Int> = dataStore.data.map { preferences ->
preferences[pushCounter] ?: 0 preferences[pushCounter] ?: 0
} }
@Suppress("UnnecessaryParentheses") @Suppress("UnnecessaryParentheses")
override val shouldDisplayBatteryOptimizationBannerFlow: Flow<Boolean> = context.dataStore.data.map { preferences -> override val shouldDisplayBatteryOptimizationBannerFlow: Flow<Boolean> = dataStore.data.map { preferences ->
(preferences[batteryOptimizationBannerState] ?: BATTERY_OPTIMIZATION_BANNER_STATE_INIT) == BATTERY_OPTIMIZATION_BANNER_STATE_SHOW (preferences[batteryOptimizationBannerState] ?: BATTERY_OPTIMIZATION_BANNER_STATE_INIT) == BATTERY_OPTIMIZATION_BANNER_STATE_SHOW
} }
suspend fun incrementPushCounter() { suspend fun incrementPushCounter() {
context.dataStore.edit { settings -> dataStore.edit { settings ->
val currentCounterValue = settings[pushCounter] ?: 0 val currentCounterValue = settings[pushCounter] ?: 0
settings[pushCounter] = currentCounterValue + 1 settings[pushCounter] = currentCounterValue + 1
} }
} }
suspend fun setBatteryOptimizationBannerState(newState: Int) { suspend fun setBatteryOptimizationBannerState(newState: Int) {
context.dataStore.edit { settings -> dataStore.edit { settings ->
val currentValue = settings[batteryOptimizationBannerState] ?: BATTERY_OPTIMIZATION_BANNER_STATE_INIT val currentValue = settings[batteryOptimizationBannerState] ?: BATTERY_OPTIMIZATION_BANNER_STATE_INIT
settings[batteryOptimizationBannerState] = when (currentValue) { settings[batteryOptimizationBannerState] = when (currentValue) {
BATTERY_OPTIMIZATION_BANNER_STATE_INIT, BATTERY_OPTIMIZATION_BANNER_STATE_INIT,
@ -106,7 +103,7 @@ class DefaultPushDataStore @Inject constructor(
override suspend fun reset() { override suspend fun reset() {
pushDatabase.pushHistoryQueries.removeAll() pushDatabase.pushHistoryQueries.removeAll()
context.dataStore.edit { dataStore.edit {
it.clear() it.clear()
} }
} }

View file

@ -27,6 +27,7 @@ dependencies {
implementation(projects.libraries.androidutils) implementation(projects.libraries.androidutils)
implementation(projects.libraries.core) implementation(projects.libraries.core)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
implementation(projects.libraries.preferences.api)
implementation(projects.libraries.pushstore.api) implementation(projects.libraries.pushstore.api)
implementation(libs.androidx.corektx) implementation(libs.androidx.corektx)
implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.datastore.preferences)
@ -38,6 +39,7 @@ dependencies {
testImplementation(libs.test.turbine) testImplementation(libs.test.turbine)
testImplementation(libs.coroutines.test) testImplementation(libs.coroutines.test)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.preferences.test)
testImplementation(projects.services.appnavstate.test) testImplementation(projects.services.appnavstate.test)
testImplementation(projects.libraries.pushstore.test) testImplementation(projects.libraries.pushstore.test)

View file

@ -13,6 +13,7 @@ import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.SingleIn import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.preferences.api.store.PreferenceDataStoreFactory
import io.element.android.libraries.pushstore.api.UserPushStore import io.element.android.libraries.pushstore.api.UserPushStore
import io.element.android.libraries.pushstore.api.UserPushStoreFactory import io.element.android.libraries.pushstore.api.UserPushStoreFactory
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
@ -22,6 +23,7 @@ import javax.inject.Inject
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class DefaultUserPushStoreFactory @Inject constructor( class DefaultUserPushStoreFactory @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val preferenceDataStoreFactory: PreferenceDataStoreFactory,
) : UserPushStoreFactory { ) : UserPushStoreFactory {
// We can have only one class accessing a single data store, so keep a cache of them. // We can have only one class accessing a single data store, so keep a cache of them.
private val cache = ConcurrentHashMap<SessionId, UserPushStore>() private val cache = ConcurrentHashMap<SessionId, UserPushStore>()
@ -29,7 +31,8 @@ class DefaultUserPushStoreFactory @Inject constructor(
return cache.getOrPut(userId) { return cache.getOrPut(userId) {
UserPushStoreDataStore( UserPushStoreDataStore(
context = context, context = context,
userId = userId userId = userId,
factory = preferenceDataStoreFactory,
) )
} }
} }

View file

@ -13,12 +13,12 @@ import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import androidx.datastore.preferences.preferencesDataStoreFile import androidx.datastore.preferences.preferencesDataStoreFile
import io.element.android.libraries.androidutils.hash.hash import io.element.android.libraries.androidutils.hash.hash
import io.element.android.libraries.core.bool.orFalse import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.core.bool.orTrue import io.element.android.libraries.core.bool.orTrue
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.preferences.api.store.PreferenceDataStoreFactory
import io.element.android.libraries.pushstore.api.UserPushStore import io.element.android.libraries.pushstore.api.UserPushStore
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
@ -31,6 +31,7 @@ import timber.log.Timber
class UserPushStoreDataStore( class UserPushStoreDataStore(
private val context: Context, private val context: Context,
userId: SessionId, userId: SessionId,
factory: PreferenceDataStoreFactory,
) : UserPushStore { ) : UserPushStore {
// Hash the sessionId to get rid of exotic chars and take only the first 16 chars. // Hash the sessionId to get rid of exotic chars and take only the first 16 chars.
// The risk of collision is not high. // The risk of collision is not high.
@ -49,28 +50,28 @@ class UserPushStoreDataStore(
} }
} }
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = preferenceName) private val store: DataStore<Preferences> = factory.create(preferenceName)
private val pushProviderName = stringPreferencesKey("pushProviderName") private val pushProviderName = stringPreferencesKey("pushProviderName")
private val currentPushKey = stringPreferencesKey("currentPushKey") private val currentPushKey = stringPreferencesKey("currentPushKey")
private val notificationEnabled = booleanPreferencesKey("notificationEnabled") private val notificationEnabled = booleanPreferencesKey("notificationEnabled")
private val ignoreRegistrationError = booleanPreferencesKey("ignoreRegistrationError") private val ignoreRegistrationError = booleanPreferencesKey("ignoreRegistrationError")
override suspend fun getPushProviderName(): String? { override suspend fun getPushProviderName(): String? {
return context.dataStore.data.first()[pushProviderName] return store.data.first()[pushProviderName]
} }
override suspend fun setPushProviderName(value: String) { override suspend fun setPushProviderName(value: String) {
context.dataStore.edit { store.edit {
it[pushProviderName] = value it[pushProviderName] = value
} }
} }
override suspend fun getCurrentRegisteredPushKey(): String? { override suspend fun getCurrentRegisteredPushKey(): String? {
return context.dataStore.data.first()[currentPushKey] return store.data.first()[currentPushKey]
} }
override suspend fun setCurrentRegisteredPushKey(value: String?) { override suspend fun setCurrentRegisteredPushKey(value: String?) {
context.dataStore.edit { store.edit {
if (value == null) { if (value == null) {
it.remove(currentPushKey) it.remove(currentPushKey)
} else { } else {
@ -80,11 +81,11 @@ class UserPushStoreDataStore(
} }
override fun getNotificationEnabledForDevice(): Flow<Boolean> { override fun getNotificationEnabledForDevice(): Flow<Boolean> {
return context.dataStore.data.map { it[notificationEnabled].orTrue() } return store.data.map { it[notificationEnabled].orTrue() }
} }
override suspend fun setNotificationEnabledForDevice(enabled: Boolean) { override suspend fun setNotificationEnabledForDevice(enabled: Boolean) {
context.dataStore.edit { store.edit {
it[notificationEnabled] = enabled it[notificationEnabled] = enabled
} }
} }
@ -94,17 +95,17 @@ class UserPushStoreDataStore(
} }
override fun ignoreRegistrationError(): Flow<Boolean> { override fun ignoreRegistrationError(): Flow<Boolean> {
return context.dataStore.data.map { it[ignoreRegistrationError].orFalse() } return store.data.map { it[ignoreRegistrationError].orFalse() }
} }
override suspend fun setIgnoreRegistrationError(ignore: Boolean) { override suspend fun setIgnoreRegistrationError(ignore: Boolean) {
context.dataStore.edit { store.edit {
it[ignoreRegistrationError] = ignore it[ignoreRegistrationError] = ignore
} }
} }
override suspend fun reset() { override suspend fun reset() {
context.dataStore.edit { store.edit {
it.clear() it.clear()
} }
// Also delete the file // Also delete the file

View file

@ -7,44 +7,40 @@
package io.element.android.libraries.pushstore.impl.clientsecret package io.element.android.libraries.pushstore.impl.clientsecret
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.preferences.api.store.PreferenceDataStoreFactory
import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecretStore import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecretStore
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import javax.inject.Inject import javax.inject.Inject
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "push_client_secret_store")
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class DataStorePushClientSecretStore @Inject constructor( class DataStorePushClientSecretStore @Inject constructor(
@ApplicationContext private val context: Context, preferenceDataStoreFactory: PreferenceDataStoreFactory,
) : PushClientSecretStore { ) : PushClientSecretStore {
private val dataStore = preferenceDataStoreFactory.create("push_client_secret_store")
override suspend fun storeSecret(userId: SessionId, clientSecret: String) { override suspend fun storeSecret(userId: SessionId, clientSecret: String) {
context.dataStore.edit { settings -> dataStore.edit { settings ->
settings[getPreferenceKeyForUser(userId)] = clientSecret settings[getPreferenceKeyForUser(userId)] = clientSecret
} }
} }
override suspend fun getSecret(userId: SessionId): String? { override suspend fun getSecret(userId: SessionId): String? {
return context.dataStore.data.first()[getPreferenceKeyForUser(userId)] return dataStore.data.first()[getPreferenceKeyForUser(userId)]
} }
override suspend fun resetSecret(userId: SessionId) { override suspend fun resetSecret(userId: SessionId) {
context.dataStore.edit { settings -> dataStore.edit { settings ->
settings.remove(getPreferenceKeyForUser(userId)) settings.remove(getPreferenceKeyForUser(userId))
} }
} }
override suspend fun getUserIdFromSecret(clientSecret: String): SessionId? { override suspend fun getUserIdFromSecret(clientSecret: String): SessionId? {
val keyValues = context.dataStore.data.first().asMap() val keyValues = dataStore.data.first().asMap()
val matchingKey = keyValues.keys.find { val matchingKey = keyValues.keys.find {
keyValues[it] == clientSecret keyValues[it] == clientSecret
} }

View file

@ -12,6 +12,7 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID_2 import io.element.android.libraries.matrix.test.A_SESSION_ID_2
import io.element.android.libraries.preferences.test.FakePreferenceDataStoreFactory
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
@ -92,5 +93,6 @@ class UserPushStoreDataStoreTest {
) = UserPushStoreDataStore( ) = UserPushStoreDataStore(
context = InstrumentationRegistry.getInstrumentation().context, context = InstrumentationRegistry.getInstrumentation().context,
userId = sessionId, userId = sessionId,
factory = FakePreferenceDataStoreFactory(),
) )
} }

View file

@ -23,6 +23,7 @@ dependencies {
implementation(projects.libraries.core) implementation(projects.libraries.core)
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
implementation(projects.libraries.designsystem) implementation(projects.libraries.designsystem)
implementation(projects.libraries.preferences.api)
implementation(projects.libraries.sessionStorage.api) implementation(projects.libraries.sessionStorage.api)
api(projects.services.analyticsproviders.api) api(projects.services.analyticsproviders.api)

View file

@ -7,27 +7,18 @@
package io.element.android.services.analytics.impl.store package io.element.android.services.analytics.impl.store
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.core.bool.orFalse import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.preferences.api.store.PreferenceDataStoreFactory
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import javax.inject.Inject import javax.inject.Inject
/**
* Also accessed via reflection by the instrumentation tests @see [im.vector.app.ClearCurrentSessionRule].
*/
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "vector_analytics")
/** /**
* Local storage for: * Local storage for:
* - user consent (Boolean); * - user consent (Boolean);
@ -46,44 +37,46 @@ interface AnalyticsStore {
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class DefaultAnalyticsStore @Inject constructor( class DefaultAnalyticsStore @Inject constructor(
@ApplicationContext private val context: Context preferenceDataStoreFactory: PreferenceDataStoreFactory,
) : AnalyticsStore { ) : AnalyticsStore {
private val userConsent = booleanPreferencesKey("user_consent") private val userConsent = booleanPreferencesKey("user_consent")
private val didAskUserConsent = booleanPreferencesKey("did_ask_user_consent") private val didAskUserConsent = booleanPreferencesKey("did_ask_user_consent")
private val analyticsId = stringPreferencesKey("analytics_id") private val analyticsId = stringPreferencesKey("analytics_id")
override val userConsentFlow: Flow<Boolean> = context.dataStore.data private val dataStore = preferenceDataStoreFactory.create("vector_analytics")
override val userConsentFlow: Flow<Boolean> = dataStore.data
.map { preferences -> preferences[userConsent].orFalse() } .map { preferences -> preferences[userConsent].orFalse() }
.distinctUntilChanged() .distinctUntilChanged()
override val didAskUserConsentFlow: Flow<Boolean> = context.dataStore.data override val didAskUserConsentFlow: Flow<Boolean> = dataStore.data
.map { preferences -> preferences[didAskUserConsent].orFalse() } .map { preferences -> preferences[didAskUserConsent].orFalse() }
.distinctUntilChanged() .distinctUntilChanged()
override val analyticsIdFlow: Flow<String> = context.dataStore.data override val analyticsIdFlow: Flow<String> = dataStore.data
.map { preferences -> preferences[analyticsId].orEmpty() } .map { preferences -> preferences[analyticsId].orEmpty() }
.distinctUntilChanged() .distinctUntilChanged()
override suspend fun setUserConsent(newUserConsent: Boolean) { override suspend fun setUserConsent(newUserConsent: Boolean) {
context.dataStore.edit { settings -> dataStore.edit { settings ->
settings[userConsent] = newUserConsent settings[userConsent] = newUserConsent
} }
} }
override suspend fun setDidAskUserConsent(newValue: Boolean) { override suspend fun setDidAskUserConsent(newValue: Boolean) {
context.dataStore.edit { settings -> dataStore.edit { settings ->
settings[didAskUserConsent] = newValue settings[didAskUserConsent] = newValue
} }
} }
override suspend fun setAnalyticsId(newAnalyticsId: String) { override suspend fun setAnalyticsId(newAnalyticsId: String) {
context.dataStore.edit { settings -> dataStore.edit { settings ->
settings[analyticsId] = newAnalyticsId settings[analyticsId] = newAnalyticsId
} }
} }
override suspend fun reset() { override suspend fun reset() {
context.dataStore.edit { dataStore.edit {
it.clear() it.clear()
} }
} }

View file

@ -0,0 +1,44 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.detektrules
import io.github.detekt.psi.fileName
import io.gitlab.arturbosch.detekt.api.CodeSmell
import io.gitlab.arturbosch.detekt.api.Config
import io.gitlab.arturbosch.detekt.api.Debt
import io.gitlab.arturbosch.detekt.api.Entity
import io.gitlab.arturbosch.detekt.api.Issue
import io.gitlab.arturbosch.detekt.api.Rule
import io.gitlab.arturbosch.detekt.api.Severity
import org.jetbrains.kotlin.psi.KtPropertyDelegate
class ByPreferencesDataStoreRule(config: Config) : Rule(config) {
override val issue: Issue = Issue(
id = "ByPreferencesDataStoreNotAllowed",
severity = Severity.Style,
description = "Avoid using `by preferencesDataStore(...)`, use `PreferenceDataStoreFactory.create(name)`instead.",
debt = Debt.FIVE_MINS,
)
override fun visitPropertyDelegate(delegate: KtPropertyDelegate) {
super.visitPropertyDelegate(delegate)
if (delegate.containingKtFile.fileName == "DefaultPreferencesDataStoreFactory.kt") {
// Skip the rule for the DefaultPreferencesDataStoreFactory implementation
return
}
if (delegate.text.startsWith("by preferencesDataStore")) {
report(CodeSmell(
issue = issue,
entity = Entity.from(delegate),
message = "Use `PreferenceDataStoreFactory.create(name)` instead of `by preferencesDataStore(...)`."
))
}
}
}

View file

@ -18,6 +18,7 @@ class ElementRuleSetProvider : RuleSetProvider {
id = ruleSetId, id = ruleSetId,
rules = listOf( rules = listOf(
RunCatchingRule(config), RunCatchingRule(config),
ByPreferencesDataStoreRule(config),
) )
) )
} }