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)
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>)
fun getTracingLogPacksFlow(): Flow<Set<TraceLogPack>>
@@ -8,6 +8,7 @@
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.edit
import androidx.datastore.preferences.core.intPreferencesKey
@@ -36,6 +37,7 @@ private val timelineMediaPreviewValueKey = stringPreferencesKey("timelineMediaPr
private val liveLocationMinimumDistanceUpdateKey = intPreferencesKey("liveLocationMinimumDistanceUpdate")
private val logLevelKey = stringPreferencesKey("logLevel")
private val traceLogPacksKey = stringPreferencesKey("traceLogPacks")
private val homeserverHistoryKey = stringPreferencesKey("homeserverHistory")
private val messageSoundUriKey = stringPreferencesKey("notificationMessageSoundUri")
private val messageSoundChannelVersionKey = intPreferencesKey("notificationMessageSoundChannelVersion")
private val messageSoundDisplayNameKey = stringPreferencesKey("notificationMessageSoundDisplayName")
@@ -43,6 +45,10 @@ private val callRingtoneUriKey = stringPreferencesKey("notificationCallRingtoneU
private val callRingtoneChannelVersionKey = intPreferencesKey("notificationCallRingtoneChannelVersion")
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)
class DefaultAppPreferencesStore(
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> {
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 {
return when (buildType) {
BuildType.DEBUG -> LogLevel.TRACE
@@ -54,4 +54,74 @@ class DefaultAppPreferencesStoreTest {
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 kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.flow.updateAndGet
class InMemoryAppPreferencesStore(
@@ -27,6 +28,7 @@ class InMemoryAppPreferencesStore(
liveLocationMinimumDistanceUpdate: Int = 10,
logLevel: LogLevel = LogLevel.INFO,
traceLogPacks: Set<TraceLogPack> = emptySet(),
homeserverHistory: List<String> = emptyList(),
messageSound: NotificationSound = NotificationSound.SystemDefault,
messageSoundChannelVersion: Int = 0,
messageSoundDisplayName: String? = null,
@@ -40,6 +42,7 @@ class InMemoryAppPreferencesStore(
private val liveLocationMinimumDistanceUpdate = MutableStateFlow(liveLocationMinimumDistanceUpdate)
private val logLevel = MutableStateFlow(logLevel)
private val tracingLogPacks = MutableStateFlow(traceLogPacks)
private val homeserverHistory = MutableStateFlow(homeserverHistory)
private val hideInviteAvatars = MutableStateFlow(hideInviteAvatars)
private val timelineMediaPreviewValue = MutableStateFlow(timelineMediaPreviewValue)
private val messageSound = MutableStateFlow(messageSound)
@@ -117,6 +120,18 @@ class InMemoryAppPreferencesStore(
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> {
return messageSound
}