First Commit

This commit is contained in:
2025-12-18 16:28:50 +07:00
commit 8c3e4f491f
9974 changed files with 396488 additions and 0 deletions
@@ -0,0 +1,28 @@
import extension.setupDependencyInjection
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023, 2024 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.
*/
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.libraries.preferences.impl"
}
setupDependencyInjection()
dependencies {
api(projects.libraries.preferences.api)
implementation(libs.androidx.datastore.preferences)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.di)
implementation(projects.libraries.core)
implementation(projects.libraries.matrix.api)
}
@@ -0,0 +1,158 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-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.preferences.impl.store
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.core.meta.BuildType
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.TraceLogPack
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.map
private val developerModeKey = booleanPreferencesKey("developerMode")
private val customElementCallBaseUrlKey = stringPreferencesKey("elementCallBaseUrl")
private val themeKey = stringPreferencesKey("theme")
private val hideInviteAvatarsKey = booleanPreferencesKey("hideInviteAvatars")
private val timelineMediaPreviewValueKey = stringPreferencesKey("timelineMediaPreviewValue")
private val logLevelKey = stringPreferencesKey("logLevel")
private val traceLogPacksKey = stringPreferencesKey("traceLogPacks")
@ContributesBinding(AppScope::class)
class DefaultAppPreferencesStore(
private val buildMeta: BuildMeta,
preferenceDataStoreFactory: PreferenceDataStoreFactory,
) : AppPreferencesStore {
private val store = preferenceDataStoreFactory.create("elementx_preferences")
override suspend fun setDeveloperModeEnabled(enabled: Boolean) {
store.edit { prefs ->
prefs[developerModeKey] = enabled
}
}
override fun isDeveloperModeEnabledFlow(): Flow<Boolean> {
return store.data.map { prefs ->
// disabled by default on release and nightly, enabled by default on debug
prefs[developerModeKey] ?: (buildMeta.buildType == BuildType.DEBUG)
}
}
override suspend fun setCustomElementCallBaseUrl(string: String?) {
store.edit { prefs ->
if (string != null) {
prefs[customElementCallBaseUrlKey] = string
} else {
prefs.remove(customElementCallBaseUrlKey)
}
}
}
override fun getCustomElementCallBaseUrlFlow(): Flow<String?> {
return store.data.map { prefs ->
prefs[customElementCallBaseUrlKey]
}
}
override suspend fun setTheme(theme: String) {
store.edit { prefs ->
prefs[themeKey] = theme
}
}
override fun getThemeFlow(): Flow<String?> {
return store.data.map { prefs ->
prefs[themeKey]
}
}
@Deprecated("Use MediaPreviewService instead. Kept only for migration.")
override fun getHideInviteAvatarsFlow(): Flow<Boolean?> {
return store.data.map { prefs ->
prefs[hideInviteAvatarsKey]
}
}
@Deprecated("Use MediaPreviewService instead. Kept only for migration.")
override suspend fun setHideInviteAvatars(hide: Boolean?) {
store.edit { prefs ->
if (hide != null) {
prefs[hideInviteAvatarsKey] = hide
} else {
prefs.remove(hideInviteAvatarsKey)
}
}
}
@Deprecated("Use MediaPreviewService instead. Kept only for migration.")
override suspend fun setTimelineMediaPreviewValue(mediaPreviewValue: MediaPreviewValue?) {
store.edit { prefs ->
if (mediaPreviewValue != null) {
prefs[timelineMediaPreviewValueKey] = mediaPreviewValue.name
} else {
prefs.remove(timelineMediaPreviewValueKey)
}
}
}
@Deprecated("Use MediaPreviewService instead. Kept only for migration.")
override fun getTimelineMediaPreviewValueFlow(): Flow<MediaPreviewValue?> {
return store.data.map { prefs ->
prefs[timelineMediaPreviewValueKey]?.let { MediaPreviewValue.valueOf(it) }
}
}
override suspend fun setTracingLogLevel(logLevel: LogLevel) {
store.edit { prefs ->
prefs[logLevelKey] = logLevel.name
}
}
override fun getTracingLogLevelFlow(): Flow<LogLevel> {
return store.data.map { prefs ->
prefs[logLevelKey]?.let { LogLevel.valueOf(it) } ?: buildMeta.defaultLogLevel()
}
}
override suspend fun setTracingLogPacks(targets: Set<TraceLogPack>) {
val value = targets.joinToString(",") { it.key }
store.edit { prefs ->
prefs[traceLogPacksKey] = value
}
}
override fun getTracingLogPacksFlow(): Flow<Set<TraceLogPack>> {
return store.data.map { prefs ->
prefs[traceLogPacksKey]
?.split(",")
?.mapNotNull { value -> TraceLogPack.entries.find { it.key == value } }
?.toSet()
?: emptySet()
}
}
override suspend fun reset() {
store.edit { it.clear() }
}
}
private fun BuildMeta.defaultLogLevel(): LogLevel {
return when (buildType) {
BuildType.DEBUG -> LogLevel.TRACE
BuildType.NIGHTLY -> LogLevel.DEBUG
BuildType.RELEASE -> LogLevel.INFO
}
}
@@ -0,0 +1,45 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.preferences.impl.store
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStore
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn
import io.element.android.libraries.androidutils.preferences.DefaultPreferencesCorruptionHandlerFactory
import io.element.android.libraries.di.annotations.ApplicationContext
import io.element.android.libraries.preferences.api.store.PreferenceDataStoreFactory
import java.util.concurrent.ConcurrentHashMap
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class DefaultPreferencesDataStoreFactory(
@ApplicationContext private val context: Context,
) : PreferenceDataStoreFactory {
private val dataStoreHolders = ConcurrentHashMap<String, DataStoreHolder>()
private class DataStoreHolder(name: String) {
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(
name = name,
corruptionHandler = DefaultPreferencesCorruptionHandlerFactory.replaceWithEmpty(),
)
}
override fun create(name: String): DataStore<Preferences> {
val holder = dataStoreHolders.getOrPut(name) {
DataStoreHolder(name)
}
return with(holder) {
context.dataStore
}
}
}
@@ -0,0 +1,108 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.preferences.impl.store
import android.content.Context
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStoreFile
import io.element.android.libraries.androidutils.file.safeDelete
import io.element.android.libraries.androidutils.hash.hash
import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import java.io.File
class DefaultSessionPreferencesStore(
context: Context,
sessionId: SessionId,
@SessionCoroutineScope sessionCoroutineScope: CoroutineScope,
) : SessionPreferencesStore {
companion object {
fun storeFile(context: Context, sessionId: SessionId): File {
val hashedUserId = sessionId.value.hash().take(16)
return context.preferencesDataStoreFile("session_${hashedUserId}_preferences")
}
}
private val sharePresenceKey = booleanPreferencesKey("sharePresence")
private val sendPublicReadReceiptsKey = booleanPreferencesKey("sendPublicReadReceipts")
private val renderReadReceiptsKey = booleanPreferencesKey("renderReadReceipts")
private val sendTypingNotificationsKey = booleanPreferencesKey("sendTypingNotifications")
private val renderTypingNotificationsKey = booleanPreferencesKey("renderTypingNotifications")
private val skipSessionVerification = booleanPreferencesKey("skipSessionVerification")
private val compressImages = booleanPreferencesKey("compressMedia")
private val compressMediaPreset = stringPreferencesKey("compressMediaPreset")
private val dataStoreFile = storeFile(context, sessionId)
private val store = PreferenceDataStoreFactory.create(
scope = sessionCoroutineScope,
migrations = listOf(
SessionPreferencesStoreMigration(
sharePresenceKey,
sendPublicReadReceiptsKey,
)
),
) { dataStoreFile }
override suspend fun setSharePresence(enabled: Boolean) {
update(sharePresenceKey, enabled)
// Also update all the other settings
setSendPublicReadReceipts(enabled)
setRenderReadReceipts(enabled)
setSendTypingNotifications(enabled)
setRenderTypingNotifications(enabled)
}
override fun isSharePresenceEnabled(): Flow<Boolean> {
return get(sharePresenceKey) { true }
}
override suspend fun setSendPublicReadReceipts(enabled: Boolean) = update(sendPublicReadReceiptsKey, enabled)
override fun isSendPublicReadReceiptsEnabled(): Flow<Boolean> = get(sendPublicReadReceiptsKey) { true }
override suspend fun setRenderReadReceipts(enabled: Boolean) = update(renderReadReceiptsKey, enabled)
override fun isRenderReadReceiptsEnabled(): Flow<Boolean> = get(renderReadReceiptsKey) { true }
override suspend fun setSendTypingNotifications(enabled: Boolean) = update(sendTypingNotificationsKey, enabled)
override fun isSendTypingNotificationsEnabled(): Flow<Boolean> = get(sendTypingNotificationsKey) { true }
override suspend fun setRenderTypingNotifications(enabled: Boolean) = update(renderTypingNotificationsKey, enabled)
override fun isRenderTypingNotificationsEnabled(): Flow<Boolean> = get(renderTypingNotificationsKey) { true }
override suspend fun setSkipSessionVerification(skip: Boolean) = update(skipSessionVerification, skip)
override fun isSessionVerificationSkipped(): Flow<Boolean> = get(skipSessionVerification) { false }
override suspend fun setOptimizeImages(compress: Boolean) = update(compressImages, compress)
override fun doesOptimizeImages(): Flow<Boolean> = get(compressImages) { true }
override suspend fun setVideoCompressionPreset(preset: VideoCompressionPreset) = update(compressMediaPreset, preset.name)
override fun getVideoCompressionPreset(): Flow<VideoCompressionPreset> = get(compressMediaPreset) { VideoCompressionPreset.STANDARD.name }
.map { tryOrNull { VideoCompressionPreset.valueOf(it) } ?: VideoCompressionPreset.STANDARD }
override suspend fun clear() {
dataStoreFile.safeDelete()
}
private suspend fun <T> update(key: Preferences.Key<T>, value: T) {
store.edit { prefs -> prefs[key] = value }
}
private fun <T> get(key: Preferences.Key<T>, default: () -> T): Flow<T> {
return store.data.map { prefs -> prefs[key] ?: default() }
}
}
@@ -0,0 +1,48 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.preferences.impl.store
import android.content.Context
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn
import io.element.android.libraries.di.annotations.ApplicationContext
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import io.element.android.libraries.preferences.api.store.SessionPreferencesStoreFactory
import io.element.android.libraries.sessionstorage.api.observer.SessionListener
import io.element.android.libraries.sessionstorage.api.observer.SessionObserver
import kotlinx.coroutines.CoroutineScope
import java.util.concurrent.ConcurrentHashMap
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class DefaultSessionPreferencesStoreFactory(
@ApplicationContext private val context: Context,
sessionObserver: SessionObserver,
) : SessionPreferencesStoreFactory {
private val cache = ConcurrentHashMap<SessionId, DefaultSessionPreferencesStore>()
init {
sessionObserver.addListener(object : SessionListener {
override suspend fun onSessionDeleted(userId: String, wasLastSession: Boolean) {
val sessionPreferences = cache.remove(SessionId(userId))
sessionPreferences?.clear()
}
})
}
override fun get(sessionId: SessionId, sessionCoroutineScope: CoroutineScope): SessionPreferencesStore = cache.getOrPut(sessionId) {
DefaultSessionPreferencesStore(context, sessionId, sessionCoroutineScope)
}
override fun remove(sessionId: SessionId) {
cache.remove(sessionId)
}
}
@@ -0,0 +1,32 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.preferences.impl.store
import dev.zacsweers.metro.BindingContainer
import dev.zacsweers.metro.ContributesTo
import dev.zacsweers.metro.Provides
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import kotlinx.coroutines.CoroutineScope
@BindingContainer
@ContributesTo(SessionScope::class)
object SessionPreferencesModule {
@Provides
fun providesSessionPreferencesStore(
defaultSessionPreferencesStoreFactory: DefaultSessionPreferencesStoreFactory,
sessionId: SessionId,
@SessionCoroutineScope sessionCoroutineScope: CoroutineScope,
): SessionPreferencesStore {
return defaultSessionPreferencesStoreFactory
.get(sessionId, sessionCoroutineScope)
}
}
@@ -0,0 +1,31 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.preferences.impl.store
import androidx.datastore.core.DataMigration
import androidx.datastore.preferences.core.Preferences
class SessionPreferencesStoreMigration(
private val sharePresenceKey: Preferences.Key<Boolean>,
private val sendPublicReadReceiptsKey: Preferences.Key<Boolean>,
) : DataMigration<Preferences> {
override suspend fun cleanUp() = Unit
override suspend fun shouldMigrate(currentData: Preferences): Boolean {
return currentData[sharePresenceKey] == null
}
override suspend fun migrate(currentData: Preferences): Preferences {
// If sendPublicReadReceiptsKey was false, consider that sharing presence is false.
val defaultValue = currentData[sendPublicReadReceiptsKey] ?: true
return currentData.toMutablePreferences().apply {
set(sharePresenceKey, defaultValue)
}.toPreferences()
}
}