git.iko.soy/parseable-android
on master help sign in
Add multi-server instance management with switch and delete (#29)

Allows users to save multiple Parseable server connections and switch between them from the Settings screen. Each server's credentials are stored securely via Room (metadata) and EncryptedSharedPreferences (passwords with unique per-server keys). - Add SavedServer Room entity and DAO for persisting server list - Extend SettingsRepository with saveServer, switchToServer, deleteServer - Update LoginViewModel to persist servers on successful login - Add "Saved Servers" card in Settings with switch/delete actions - Clear only active connection on logout, preserving saved server list - Navigate back to streams list after switching servers https://claude.ai/code/session_011NHqjdNprRJeKJNNpMzTNc Co-authored-by: Claude <noreply@anthropic.com>

commitd8e3b4261df1df1e48c76a6beab7fa01a530b6c5 authoriko <mail@iko.soy> dateThu 19 Feb 2026, 12:30 (7 months ago) parent0b724a2
9 files changed+361−4
app/src/main/java/com/parseable/android/data/local/ParseableDatabase.kt+3−2 app/src/main/java/com/parseable/android/data/local/SavedServer.kt+16 app/src/main/java/com/parseable/android/data/local/SavedServerDao.kt+25 app/src/main/java/com/parseable/android/data/repository/SettingsRepository.kt+114−2 app/src/main/java/com/parseable/android/di/AppModule.kt+6 app/src/main/java/com/parseable/android/ui/navigation/NavGraph.kt+5 app/src/main/java/com/parseable/android/ui/screens/login/LoginViewModel.kt+1 app/src/main/java/com/parseable/android/ui/screens/settings/SettingsScreen.kt+151 app/src/main/java/com/parseable/android/ui/screens/settings/SettingsViewModel.kt+40
app/src/main/java/com/parseable/android/data/local/ParseableDatabase.kt+3−2
@@ -4,10 +4,11 @@ import androidx.room.Database
44import androidx.room.RoomDatabase
55
66@Database(
7− entities = [FavoriteStream::class],
8− version = 1,
7+ entities = [FavoriteStream::class, SavedServer::class],
8+ version = 2,
99 exportSchema = true,
1010)
1111abstract class ParseableDatabase : RoomDatabase() {
1212 abstract fun favoriteStreamDao(): FavoriteStreamDao
13+ abstract fun savedServerDao(): SavedServerDao
1314}
app/src/main/java/com/parseable/android/data/local/SavedServer.ktnew file+16
@@ -0,0 +1,16 @@
1+package com.parseable.android.data.local
2+
3+import androidx.room.Entity
4+import androidx.room.PrimaryKey
5+
6+@Entity(tableName = "saved_servers")
7+data class SavedServer(
8+ @PrimaryKey(autoGenerate = true)
9+ val id: Long = 0,
10+ val serverUrl: String,
11+ val username: String,
12+ val useTls: Boolean = true,
13+ /** Key into EncryptedSharedPreferences for this server's password. */
14+ val passwordKey: String,
15+ val addedAt: Long = System.currentTimeMillis(),
16+)
app/src/main/java/com/parseable/android/data/local/SavedServerDao.ktnew file+25
@@ -0,0 +1,25 @@
1+package com.parseable.android.data.local
2+
3+import androidx.room.Dao
4+import androidx.room.Insert
5+import androidx.room.OnConflictStrategy
6+import androidx.room.Query
7+import kotlinx.coroutines.flow.Flow
8+
9+@Dao
10+interface SavedServerDao {
11+ @Query("SELECT * FROM saved_servers ORDER BY addedAt DESC")
12+ fun getAll(): Flow<List<SavedServer>>
13+
14+ @Query("SELECT * FROM saved_servers WHERE id = :id")
15+ suspend fun getById(id: Long): SavedServer?
16+
17+ @Query("SELECT * FROM saved_servers WHERE serverUrl = :url AND username = :username LIMIT 1")
18+ suspend fun findByUrlAndUsername(url: String, username: String): SavedServer?
19+
20+ @Insert(onConflict = OnConflictStrategy.REPLACE)
21+ suspend fun insert(server: SavedServer): Long
22+
23+ @Query("DELETE FROM saved_servers WHERE id = :id")
24+ suspend fun deleteById(id: Long)
25+}
app/src/main/java/com/parseable/android/data/repository/SettingsRepository.kt+114−2
@@ -8,6 +8,8 @@ import androidx.datastore.preferences.core.*
88import androidx.datastore.preferences.preferencesDataStore
99import androidx.security.crypto.EncryptedSharedPreferences
1010import androidx.security.crypto.MasterKey
11+import com.parseable.android.data.local.SavedServer
12+import com.parseable.android.data.local.SavedServerDao
1113import com.parseable.android.data.model.ServerConfig
1214import dagger.hilt.android.qualifiers.ApplicationContext
1315import kotlinx.coroutines.Dispatchers
@@ -27,10 +29,12 @@ private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(na
2729@Singleton
2830class SettingsRepository @Inject constructor(
2931 @ApplicationContext private val context: Context,
32+ private val savedServerDao: SavedServerDao,
3033) {
3134 private val serverUrlKey = stringPreferencesKey("server_url")
3235 private val usernameKey = stringPreferencesKey("username")
3336 private val useTlsKey = booleanPreferencesKey("use_tls")
37+ private val activeServerIdKey = longPreferencesKey("active_server_id")
3438
3539 /** Guards concurrent reads/writes across DataStore and EncryptedSharedPreferences. */
3640 private val configMutex = Mutex()
@@ -62,6 +66,8 @@ class SettingsRepository @Inject constructor(
6266 )
6367 }
6468
69+ // ── Active server config (current connection) ──────────────────────
70+
6571 val serverConfig: Flow<ServerConfig?> = context.dataStore.data
6672 .catch { e ->
6773 Timber.e(e, "Failed to read DataStore")
@@ -87,6 +93,14 @@ class SettingsRepository @Inject constructor(
8793 }
8894 .flowOn(Dispatchers.IO)
8995
96+ val activeServerId: Flow<Long?> = context.dataStore.data
97+ .catch { e ->
98+ Timber.e(e, "Failed to read DataStore")
99+ emit(emptyPreferences())
100+ }
101+ .map { prefs -> prefs[activeServerIdKey] }
102+ .flowOn(Dispatchers.IO)
103+
90104 suspend fun getSavedPassword(): ServerConfig? = serverConfig.first()
91105
92106 suspend fun saveServerConfig(config: ServerConfig) {
@@ -109,13 +123,111 @@ class SettingsRepository @Inject constructor(
109123 suspend fun clearConfig() {
110124 configMutex.withLock {
111125 try {
112− context.dataStore.edit { it.clear() }
126+ context.dataStore.edit { prefs ->
127+ prefs.remove(serverUrlKey)
128+ prefs.remove(usernameKey)
129+ prefs.remove(useTlsKey)
130+ prefs.remove(activeServerIdKey)
131+ }
113132 withContext(Dispatchers.IO) {
114− encryptedPrefs.edit().clear().apply()
133+ encryptedPrefs.edit().remove("password").apply()
115134 }
116135 } catch (e: Exception) {
117136 Timber.e(e, "Failed to clear config")
118137 }
119138 }
120139 }
140+
141+ // ── Multi-server management ────────────────────────────────────────
142+
143+ val savedServers: Flow<List<SavedServer>> = savedServerDao.getAll()
144+
145+ /**
146+ * Persist the current server connection as a saved server entry.
147+ * If a server with the same URL and username already exists, updates it.
148+ * Returns the server ID.
149+ */
150+ suspend fun saveServer(config: ServerConfig): Long {
151+ return configMutex.withLock {
152+ val existing = savedServerDao.findByUrlAndUsername(config.serverUrl, config.username)
153+ val passwordKey = existing?.passwordKey ?: "server_pwd_${System.currentTimeMillis()}"
154+ val server = SavedServer(
155+ id = existing?.id ?: 0,
156+ serverUrl = config.serverUrl,
157+ username = config.username,
158+ useTls = config.useTls,
159+ passwordKey = passwordKey,
160+ addedAt = existing?.addedAt ?: System.currentTimeMillis(),
161+ )
162+ withContext(Dispatchers.IO) {
163+ encryptedPrefs.edit().putString(passwordKey, config.password).apply()
164+ }
165+ val id = savedServerDao.insert(server)
166+
167+ // Update active server ID
168+ context.dataStore.edit { prefs ->
169+ prefs[activeServerIdKey] = id
170+ }
171+ id
172+ }
173+ }
174+
175+ /**
176+ * Switch to a previously saved server. Loads its credentials into the
177+ * active config (DataStore + encrypted password).
178+ */
179+ suspend fun switchToServer(serverId: Long): ServerConfig? {
180+ return configMutex.withLock {
181+ val server = savedServerDao.getById(serverId) ?: return@withLock null
182+ val password = withContext(Dispatchers.IO) {
183+ try {
184+ encryptedPrefs.getString(server.passwordKey, null)
185+ } catch (e: Exception) {
186+ Timber.e(e, "Failed to read server password")
187+ null
188+ }
189+ } ?: return@withLock null
190+
191+ val config = ServerConfig(
192+ serverUrl = server.serverUrl,
193+ username = server.username,
194+ password = password,
195+ useTls = server.useTls,
196+ )
197+
198+ // Write to active config
199+ context.dataStore.edit { prefs ->
200+ prefs[serverUrlKey] = config.serverUrl
201+ prefs[usernameKey] = config.username
202+ prefs[useTlsKey] = config.useTls
203+ prefs[activeServerIdKey] = serverId
204+ }
205+ withContext(Dispatchers.IO) {
206+ encryptedPrefs.edit().putString("password", config.password).apply()
207+ }
208+ config
209+ }
210+ }
211+
212+ /**
213+ * Remove a saved server and its stored password.
214+ * If the deleted server was the active one, the active server ID is cleared.
215+ */
216+ suspend fun deleteServer(serverId: Long) {
217+ configMutex.withLock {
218+ val server = savedServerDao.getById(serverId) ?: return@withLock
219+ withContext(Dispatchers.IO) {
220+ encryptedPrefs.edit().remove(server.passwordKey).apply()
221+ }
222+ savedServerDao.deleteById(serverId)
223+
224+ // Clear active server reference if it pointed at the deleted server
225+ val currentActiveId = context.dataStore.data.first()[activeServerIdKey]
226+ if (currentActiveId == serverId) {
227+ context.dataStore.edit { prefs ->
228+ prefs.remove(activeServerIdKey)
229+ }
230+ }
231+ }
232+ }
121233}
app/src/main/java/com/parseable/android/di/AppModule.kt+6
@@ -4,6 +4,7 @@ import android.content.Context
44import androidx.room.Room
55import com.parseable.android.data.local.FavoriteStreamDao
66import com.parseable.android.data.local.ParseableDatabase
7+import com.parseable.android.data.local.SavedServerDao
78import dagger.Module
89import dagger.Provides
910import dagger.hilt.InstallIn
@@ -26,4 +27,9 @@ object AppModule {
2627 @Singleton
2728 fun provideFavoriteStreamDao(db: ParseableDatabase): FavoriteStreamDao =
2829 db.favoriteStreamDao()
30+
31+ @Provides
32+ @Singleton
33+ fun provideSavedServerDao(db: ParseableDatabase): SavedServerDao =
34+ db.savedServerDao()
2935}
app/src/main/java/com/parseable/android/ui/navigation/NavGraph.kt+5
@@ -177,6 +177,11 @@ fun ParseableNavGraph(
177177 launchSingleTop = true
178178 }
179179 },
180+ onServerSwitched = {
181+ navController.navigate(Routes.STREAMS) {
182+ popUpTo(0) { inclusive = true }
183+ }
184+ },
180185 )
181186 }
182187
app/src/main/java/com/parseable/android/ui/screens/login/LoginViewModel.kt+1
@@ -157,6 +157,7 @@ class LoginViewModel @Inject constructor(
157157 when (val aboutResult = repository.verifyServer()) {
158158 is ApiResult.Success -> {
159159 settingsRepository.saveServerConfig(config)
160+ settingsRepository.saveServer(config)
160161 _state.update { it.copy(isLoading = false, loginSuccess = true, password = "") }
161162 }
162163 is ApiResult.Error -> {
app/src/main/java/com/parseable/android/ui/screens/settings/SettingsScreen.kt+151
@@ -12,10 +12,12 @@ import androidx.compose.runtime.*
1212import androidx.compose.ui.Alignment
1313import androidx.compose.ui.Modifier
1414import androidx.compose.ui.text.font.FontWeight
15+import androidx.compose.ui.text.style.TextOverflow
1516import androidx.compose.ui.unit.dp
1617import androidx.hilt.navigation.compose.hiltViewModel
1718import androidx.lifecycle.compose.collectAsStateWithLifecycle
1819import com.parseable.android.BuildConfig
20+import com.parseable.android.data.local.SavedServer
1921import kotlinx.serialization.json.contentOrNull
2022import kotlinx.serialization.json.jsonPrimitive
2123
@@ -24,9 +26,18 @@ import kotlinx.serialization.json.jsonPrimitive
2426fun SettingsScreen(
2527 onBack: () -> Unit,
2628 onAboutClick: () -> Unit,
29+ onServerSwitched: () -> Unit,
2730 viewModel: SettingsViewModel = hiltViewModel(),
2831) {
2932 val state by viewModel.state.collectAsStateWithLifecycle()
33+ val switchEvent by viewModel.switchEvent.collectAsStateWithLifecycle()
34+
35+ LaunchedEffect(switchEvent) {
36+ if (switchEvent != null) {
37+ viewModel.consumeSwitchEvent()
38+ onServerSwitched()
39+ }
40+ }
3041
3142 Scaffold(
3243 topBar = {
@@ -80,6 +91,17 @@ fun SettingsScreen(
8091 }
8192 }
8293
94+ // Saved servers
95+ if (state.savedServers.size > 1) {
96+ SavedServersCard(
97+ servers = state.savedServers,
98+ activeServerId = state.activeServerId,
99+ isSwitching = state.isSwitching,
100+ onSwitch = viewModel::switchToServer,
101+ onDelete = viewModel::deleteServer,
102+ )
103+ }
104+
83105 // Loading indicator for server data
84106 if (state.isLoading) {
85107 Box(
@@ -256,6 +278,135 @@ fun SettingsScreen(
256278 }
257279}
258280
281+@Composable
282+private fun SavedServersCard(
283+ servers: List<SavedServer>,
284+ activeServerId: Long?,
285+ isSwitching: Boolean,
286+ onSwitch: (Long) -> Unit,
287+ onDelete: (Long) -> Unit,
288+) {
289+ var serverToDelete by remember { mutableStateOf<SavedServer?>(null) }
290+
291+ Card(
292+ modifier = Modifier.fillMaxWidth(),
293+ elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
294+ ) {
295+ Column(modifier = Modifier.padding(16.dp)) {
296+ Row(verticalAlignment = Alignment.CenterVertically) {
297+ Icon(
298+ Icons.Filled.SwapHoriz,
299+ contentDescription = "Saved servers",
300+ tint = MaterialTheme.colorScheme.primary,
301+ )
302+ Spacer(modifier = Modifier.width(8.dp))
303+ Text(
304+ text = "Saved Servers (${servers.size})",
305+ style = MaterialTheme.typography.titleMedium,
306+ fontWeight = FontWeight.Bold,
307+ )
308+ if (isSwitching) {
309+ Spacer(modifier = Modifier.width(8.dp))
310+ CircularProgressIndicator(
311+ modifier = Modifier.size(16.dp),
312+ strokeWidth = 2.dp,
313+ )
314+ }
315+ }
316+ Spacer(modifier = Modifier.height(12.dp))
317+ servers.forEach { server ->
318+ val isActive = server.id == activeServerId
319+ SavedServerRow(
320+ server = server,
321+ isActive = isActive,
322+ enabled = !isSwitching,
323+ onSwitch = { onSwitch(server.id) },
324+ onDelete = { serverToDelete = server },
325+ )
326+ }
327+ }
328+ }
329+
330+ serverToDelete?.let { server ->
331+ AlertDialog(
332+ onDismissRequest = { serverToDelete = null },
333+ title = { Text("Remove Server") },
334+ text = {
335+ Text("Remove ${server.username}@${server.serverUrl} from saved servers?")
336+ },
337+ confirmButton = {
338+ Button(
339+ onClick = {
340+ onDelete(server.id)
341+ serverToDelete = null
342+ },
343+ colors = ButtonDefaults.buttonColors(
344+ containerColor = MaterialTheme.colorScheme.error,
345+ ),
346+ ) {
347+ Text("Remove")
348+ }
349+ },
350+ dismissButton = {
351+ TextButton(onClick = { serverToDelete = null }) {
352+ Text("Cancel")
353+ }
354+ },
355+ )
356+ }
357+}
358+
359+@Composable
360+private fun SavedServerRow(
361+ server: SavedServer,
362+ isActive: Boolean,
363+ enabled: Boolean,
364+ onSwitch: () -> Unit,
365+ onDelete: () -> Unit,
366+) {
367+ Row(
368+ modifier = Modifier
369+ .fillMaxWidth()
370+ .padding(vertical = 4.dp),
371+ verticalAlignment = Alignment.CenterVertically,
372+ ) {
373+ Icon(
374+ if (isActive) Icons.Filled.RadioButtonChecked else Icons.Filled.RadioButtonUnchecked,
375+ contentDescription = if (isActive) "Active" else "Inactive",
376+ tint = if (isActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
377+ modifier = Modifier.size(20.dp),
378+ )
379+ Spacer(modifier = Modifier.width(8.dp))
380+ Column(modifier = Modifier.weight(1f)) {
381+ Text(
382+ text = server.serverUrl,
383+ style = MaterialTheme.typography.bodyMedium,
384+ fontWeight = if (isActive) FontWeight.Bold else FontWeight.Normal,
385+ maxLines = 1,
386+ overflow = TextOverflow.Ellipsis,
387+ )
388+ Text(
389+ text = server.username,
390+ style = MaterialTheme.typography.bodySmall,
391+ color = MaterialTheme.colorScheme.onSurfaceVariant,
392+ )
393+ }
394+ if (!isActive) {
395+ TextButton(onClick = onSwitch, enabled = enabled) {
396+ Text("Switch")
397+ }
398+ IconButton(onClick = onDelete, enabled = enabled) {
399+ Icon(
400+ Icons.Filled.Delete,
401+ contentDescription = "Remove server",
402+ tint = MaterialTheme.colorScheme.error,
403+ modifier = Modifier.size(20.dp),
404+ )
405+ }
406+ }
407+ }
408+}
409+
259410@Composable
260411private fun SettingsRow(label: String, value: String) {
261412 Row(
app/src/main/java/com/parseable/android/ui/screens/settings/SettingsViewModel.kt+40
@@ -2,6 +2,7 @@ package com.parseable.android.ui.screens.settings
22
33import androidx.lifecycle.ViewModel
44import androidx.lifecycle.viewModelScope
5+import com.parseable.android.data.local.SavedServer
56import com.parseable.android.data.model.AboutInfo
67import com.parseable.android.data.model.ApiResult
78import com.parseable.android.data.repository.ParseableRepository
@@ -27,6 +28,9 @@ data class SettingsState(
2728 val users: List<String> = emptyList(),
2829 val isLoading: Boolean = false,
2930 val error: String? = null,
31+ val savedServers: List<SavedServer> = emptyList(),
32+ val activeServerId: Long? = null,
33+ val isSwitching: Boolean = false,
3034)
3135
3236@HiltViewModel
@@ -38,8 +42,22 @@ class SettingsViewModel @Inject constructor(
3842 private val _state = MutableStateFlow(SettingsState())
3943 val state: StateFlow<SettingsState> = _state.asStateFlow()
4044
45+ /** Emits the server ID after a successful switch so the screen can navigate. */
46+ private val _switchEvent = MutableStateFlow<Long?>(null)
47+ val switchEvent: StateFlow<Long?> = _switchEvent.asStateFlow()
48+
4149 init {
4250 load()
51+ viewModelScope.launch {
52+ settingsRepository.savedServers.collect { servers ->
53+ _state.update { it.copy(savedServers = servers) }
54+ }
55+ }
56+ viewModelScope.launch {
57+ settingsRepository.activeServerId.collect { id ->
58+ _state.update { it.copy(activeServerId = id) }
59+ }
60+ }
4361 }
4462
4563 fun load() {
@@ -96,4 +114,26 @@ class SettingsViewModel @Inject constructor(
96114 }
97115 }
98116 }
117+
118+ fun switchToServer(serverId: Long) {
119+ viewModelScope.launch {
120+ _state.update { it.copy(isSwitching = true) }
121+ val config = settingsRepository.switchToServer(serverId)
122+ if (config != null) {
123+ repository.configure(config)
124+ _switchEvent.value = serverId
125+ }
126+ _state.update { it.copy(isSwitching = false) }
127+ }
128+ }
129+
130+ fun consumeSwitchEvent() {
131+ _switchEvent.value = null
132+ }
133+
134+ fun deleteServer(serverId: Long) {
135+ viewModelScope.launch {
136+ settingsRepository.deleteServer(serverId)
137+ }
138+ }
99139}