Add persistent account provider history to app preferences

This commit is contained in:
Jenna Vassar
2026-08-03 09:07:48 -07:00
parent 50ff04ac88
commit 21471e07fb
4 changed files with 126 additions and 0 deletions
@@ -38,6 +38,20 @@ interface AppPreferencesStore {
suspend fun setTracingLogLevel(logLevel: LogLevel) suspend fun setTracingLogLevel(logLevel: LogLevel)
fun getTracingLogLevelFlow(): Flow<LogLevel> fun getTracingLogLevelFlow(): Flow<LogLevel>
/**
* The list of account providers (homeserver URLs) the user has previously authenticated
* against, most recent first. Used to default the account provider and to power autocomplete
* suggestions during sign-in. Stored locally only; never synced across devices.
*/
fun getHomeserverHistoryFlow(): Flow<List<String>>
/**
* Add [url] to the front of the account provider history (see [getHomeserverHistoryFlow]).
* The value is normalised (trimmed + lowercased); existing case-insensitive duplicates are
* moved to the front rather than duplicated, and the list is capped in size. No-op for blanks.
*/
suspend fun addHomeserverToHistory(url: String)
suspend fun setTracingLogPacks(targets: Set<TraceLogPack>) suspend fun setTracingLogPacks(targets: Set<TraceLogPack>)
fun getTracingLogPacksFlow(): Flow<Set<TraceLogPack>> fun getTracingLogPacksFlow(): Flow<Set<TraceLogPack>>
@@ -8,6 +8,7 @@
package io.element.android.libraries.preferences.impl.store package io.element.android.libraries.preferences.impl.store
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
@@ -36,6 +37,7 @@ private val timelineMediaPreviewValueKey = stringPreferencesKey("timelineMediaPr
private val liveLocationMinimumDistanceUpdateKey = intPreferencesKey("liveLocationMinimumDistanceUpdate") private val liveLocationMinimumDistanceUpdateKey = intPreferencesKey("liveLocationMinimumDistanceUpdate")
private val logLevelKey = stringPreferencesKey("logLevel") private val logLevelKey = stringPreferencesKey("logLevel")
private val traceLogPacksKey = stringPreferencesKey("traceLogPacks") private val traceLogPacksKey = stringPreferencesKey("traceLogPacks")
private val homeserverHistoryKey = stringPreferencesKey("homeserverHistory")
private val messageSoundUriKey = stringPreferencesKey("notificationMessageSoundUri") private val messageSoundUriKey = stringPreferencesKey("notificationMessageSoundUri")
private val messageSoundChannelVersionKey = intPreferencesKey("notificationMessageSoundChannelVersion") private val messageSoundChannelVersionKey = intPreferencesKey("notificationMessageSoundChannelVersion")
private val messageSoundDisplayNameKey = stringPreferencesKey("notificationMessageSoundDisplayName") private val messageSoundDisplayNameKey = stringPreferencesKey("notificationMessageSoundDisplayName")
@@ -43,6 +45,10 @@ private val callRingtoneUriKey = stringPreferencesKey("notificationCallRingtoneU
private val callRingtoneChannelVersionKey = intPreferencesKey("notificationCallRingtoneChannelVersion") private val callRingtoneChannelVersionKey = intPreferencesKey("notificationCallRingtoneChannelVersion")
private val callRingtoneDisplayNameKey = stringPreferencesKey("notificationCallRingtoneDisplayName") private val callRingtoneDisplayNameKey = stringPreferencesKey("notificationCallRingtoneDisplayName")
// URLs never contain a newline, so it is a safe delimiter to persist an ordered list in a single String.
private const val HOMESERVER_HISTORY_DELIMITER = "\n"
private const val MAX_HOMESERVER_HISTORY_SIZE = 10
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class DefaultAppPreferencesStore( class DefaultAppPreferencesStore(
private val buildMeta: BuildMeta, private val buildMeta: BuildMeta,
@@ -168,6 +174,20 @@ class DefaultAppPreferencesStore(
} }
} }
override fun getHomeserverHistoryFlow(): Flow<List<String>> {
return store.data.map { prefs -> prefs.readHomeserverHistory() }
}
override suspend fun addHomeserverToHistory(url: String) {
val normalized = url.trim().lowercase()
if (normalized.isEmpty()) return
store.edit { prefs ->
val updated = (listOf(normalized) + prefs.readHomeserverHistory().filter { it != normalized })
.take(MAX_HOMESERVER_HISTORY_SIZE)
prefs[homeserverHistoryKey] = updated.joinToString(HOMESERVER_HISTORY_DELIMITER)
}
}
override fun getMessageSoundFlow(): Flow<NotificationSound> { override fun getMessageSoundFlow(): Flow<NotificationSound> {
return store.data.map { prefs -> NotificationSound.fromStored(prefs[messageSoundUriKey]) } return store.data.map { prefs -> NotificationSound.fromStored(prefs[messageSoundUriKey]) }
} }
@@ -242,6 +262,13 @@ class DefaultAppPreferencesStore(
} }
} }
private fun Preferences.readHomeserverHistory(): List<String> {
return this[homeserverHistoryKey]
?.split(HOMESERVER_HISTORY_DELIMITER)
?.filter { it.isNotEmpty() }
?: emptyList()
}
private fun BuildMeta.defaultLogLevel(): LogLevel { private fun BuildMeta.defaultLogLevel(): LogLevel {
return when (buildType) { return when (buildType) {
BuildType.DEBUG -> LogLevel.TRACE BuildType.DEBUG -> LogLevel.TRACE
@@ -54,4 +54,74 @@ class DefaultAppPreferencesStoreTest {
assertThat(store.getLiveLocationMinimumDistanceInMetersUpdateFlow().first()).isEqualTo(25) assertThat(store.getLiveLocationMinimumDistanceInMetersUpdateFlow().first()).isEqualTo(25)
} }
@Test
fun `homeserver history is empty by default`() = runTest {
val store = createStore()
assertThat(store.getHomeserverHistoryFlow().first()).isEmpty()
}
@Test
fun `adding homeservers keeps the most recent first`() = runTest {
val store = createStore()
store.addHomeserverToHistory("https://matrix.org")
store.addHomeserverToHistory("https://example.com")
assertThat(store.getHomeserverHistoryFlow().first())
.containsExactly("https://example.com", "https://matrix.org")
.inOrder()
}
@Test
fun `adding an existing homeserver moves it to the front without duplicating`() = runTest {
val store = createStore()
store.addHomeserverToHistory("https://matrix.org")
store.addHomeserverToHistory("https://example.com")
store.addHomeserverToHistory("https://matrix.org")
assertThat(store.getHomeserverHistoryFlow().first())
.containsExactly("https://matrix.org", "https://example.com")
.inOrder()
}
@Test
fun `homeservers are normalised and deduplicated case-insensitively`() = runTest {
val store = createStore()
store.addHomeserverToHistory(" HTTPS://Matrix.ORG ")
store.addHomeserverToHistory("https://matrix.org")
assertThat(store.getHomeserverHistoryFlow().first())
.containsExactly("https://matrix.org")
}
@Test
fun `blank homeservers are ignored`() = runTest {
val store = createStore()
store.addHomeserverToHistory(" ")
assertThat(store.getHomeserverHistoryFlow().first()).isEmpty()
}
@Test
fun `homeserver history is capped to the most recent entries`() = runTest {
val store = createStore()
repeat(15) { index ->
store.addHomeserverToHistory("https://server$index.org")
}
val history = store.getHomeserverHistoryFlow().first()
assertThat(history).hasSize(10)
assertThat(history.first()).isEqualTo("https://server14.org")
assertThat(history.last()).isEqualTo("https://server5.org")
}
private fun createStore() = DefaultAppPreferencesStore(
buildMeta = buildMeta,
preferenceDataStoreFactory = FakePreferenceDataStoreFactory(),
)
} }
@@ -16,6 +16,7 @@ import io.element.android.libraries.preferences.api.store.NotificationSound
import io.element.android.libraries.preferences.api.store.NotificationSoundChannelConfig import io.element.android.libraries.preferences.api.store.NotificationSoundChannelConfig
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.flow.updateAndGet import kotlinx.coroutines.flow.updateAndGet
class InMemoryAppPreferencesStore( class InMemoryAppPreferencesStore(
@@ -27,6 +28,7 @@ class InMemoryAppPreferencesStore(
liveLocationMinimumDistanceUpdate: Int = 10, liveLocationMinimumDistanceUpdate: Int = 10,
logLevel: LogLevel = LogLevel.INFO, logLevel: LogLevel = LogLevel.INFO,
traceLogPacks: Set<TraceLogPack> = emptySet(), traceLogPacks: Set<TraceLogPack> = emptySet(),
homeserverHistory: List<String> = emptyList(),
messageSound: NotificationSound = NotificationSound.SystemDefault, messageSound: NotificationSound = NotificationSound.SystemDefault,
messageSoundChannelVersion: Int = 0, messageSoundChannelVersion: Int = 0,
messageSoundDisplayName: String? = null, messageSoundDisplayName: String? = null,
@@ -40,6 +42,7 @@ class InMemoryAppPreferencesStore(
private val liveLocationMinimumDistanceUpdate = MutableStateFlow(liveLocationMinimumDistanceUpdate) private val liveLocationMinimumDistanceUpdate = MutableStateFlow(liveLocationMinimumDistanceUpdate)
private val logLevel = MutableStateFlow(logLevel) private val logLevel = MutableStateFlow(logLevel)
private val tracingLogPacks = MutableStateFlow(traceLogPacks) private val tracingLogPacks = MutableStateFlow(traceLogPacks)
private val homeserverHistory = MutableStateFlow(homeserverHistory)
private val hideInviteAvatars = MutableStateFlow(hideInviteAvatars) private val hideInviteAvatars = MutableStateFlow(hideInviteAvatars)
private val timelineMediaPreviewValue = MutableStateFlow(timelineMediaPreviewValue) private val timelineMediaPreviewValue = MutableStateFlow(timelineMediaPreviewValue)
private val messageSound = MutableStateFlow(messageSound) private val messageSound = MutableStateFlow(messageSound)
@@ -117,6 +120,18 @@ class InMemoryAppPreferencesStore(
return tracingLogPacks return tracingLogPacks
} }
override fun getHomeserverHistoryFlow(): Flow<List<String>> {
return homeserverHistory
}
override suspend fun addHomeserverToHistory(url: String) {
val normalized = url.trim().lowercase()
if (normalized.isEmpty()) return
homeserverHistory.update { current ->
(listOf(normalized) + current.filter { it != normalized }).take(10)
}
}
override fun getMessageSoundFlow(): Flow<NotificationSound> { override fun getMessageSoundFlow(): Flow<NotificationSound> {
return messageSound return messageSound
} }