git.iko.soy/parseable-android
on master help sign in
Add multi-server instance management with switch and delete (#29)
d8e3b42iko
app/src/main/java/com/parseable/android/data/local/ParseableDatabase.kt+3−2
@@ -4,10 +4,11 @@ import androidx.room.Database
4import androidx.room.RoomDatabase
4import androidx.room.RoomDatabase
5
5
6@Database(
6@Database(
7− entities = [FavoriteStream::class],
7+ entities = [FavoriteStream::class, SavedServer::class],
8− version = 1,
8+ version = 2,
9 exportSchema = true,
9 exportSchema = true,
10)
10)
11abstract class ParseableDatabase : RoomDatabase() {
11abstract class ParseableDatabase : RoomDatabase() {
12 abstract fun favoriteStreamDao(): FavoriteStreamDao
12 abstract fun favoriteStreamDao(): FavoriteStreamDao
13+ abstract fun savedServerDao(): SavedServerDao
13}
14}
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.*
8import androidx.datastore.preferences.preferencesDataStore
8import androidx.datastore.preferences.preferencesDataStore
9import androidx.security.crypto.EncryptedSharedPreferences
9import androidx.security.crypto.EncryptedSharedPreferences
10import androidx.security.crypto.MasterKey
10import androidx.security.crypto.MasterKey
11+import com.parseable.android.data.local.SavedServer
12+import com.parseable.android.data.local.SavedServerDao
11import com.parseable.android.data.model.ServerConfig
13import com.parseable.android.data.model.ServerConfig
12import dagger.hilt.android.qualifiers.ApplicationContext
14import dagger.hilt.android.qualifiers.ApplicationContext
13import kotlinx.coroutines.Dispatchers
15import kotlinx.coroutines.Dispatchers
@@ -27,10 +29,12 @@ private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(na
27@Singleton
29@Singleton
28class SettingsRepository @Inject constructor(
30class SettingsRepository @Inject constructor(
29 @ApplicationContext private val context: Context,
31 @ApplicationContext private val context: Context,
32+ private val savedServerDao: SavedServerDao,
30) {
33) {
31 private val serverUrlKey = stringPreferencesKey("server_url")
34 private val serverUrlKey = stringPreferencesKey("server_url")
32 private val usernameKey = stringPreferencesKey("username")
35 private val usernameKey = stringPreferencesKey("username")
33 private val useTlsKey = booleanPreferencesKey("use_tls")
36 private val useTlsKey = booleanPreferencesKey("use_tls")
37+ private val activeServerIdKey = longPreferencesKey("active_server_id")
34
38
35 /** Guards concurrent reads/writes across DataStore and EncryptedSharedPreferences. */
39 /** Guards concurrent reads/writes across DataStore and EncryptedSharedPreferences. */
36 private val configMutex = Mutex()
40 private val configMutex = Mutex()
@@ -62,6 +66,8 @@ class SettingsRepository @Inject constructor(
62 )
66 )
63 }
67 }
64
68
69+ // ── Active server config (current connection) ──────────────────────
70+
65 val serverConfig: Flow<ServerConfig?> = context.dataStore.data
71 val serverConfig: Flow<ServerConfig?> = context.dataStore.data
66 .catch { e ->
72 .catch { e ->
67 Timber.e(e, "Failed to read DataStore")
73 Timber.e(e, "Failed to read DataStore")
@@ -87,6 +93,14 @@ class SettingsRepository @Inject constructor(
87 }
93 }
88 .flowOn(Dispatchers.IO)
94 .flowOn(Dispatchers.IO)
89
95
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+
90 suspend fun getSavedPassword(): ServerConfig? = serverConfig.first()
104 suspend fun getSavedPassword(): ServerConfig? = serverConfig.first()
91
105
92 suspend fun saveServerConfig(config: ServerConfig) {
106 suspend fun saveServerConfig(config: ServerConfig) {
@@ -109,13 +123,111 @@ class SettingsRepository @Inject constructor(
109 suspend fun clearConfig() {
123 suspend fun clearConfig() {
110 configMutex.withLock {
124 configMutex.withLock {
111 try {
125 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+ }
113 withContext(Dispatchers.IO) {
132 withContext(Dispatchers.IO) {
114− encryptedPrefs.edit().clear().apply()
133+ encryptedPrefs.edit().remove("password").apply()
115 }
134 }
116 } catch (e: Exception) {
135 } catch (e: Exception) {
117 Timber.e(e, "Failed to clear config")
136 Timber.e(e, "Failed to clear config")
118 }
137 }
119 }
138 }
120 }
139 }
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+ }
121}
233}
app/src/main/java/com/parseable/android/di/AppModule.kt+6
@@ -4,6 +4,7 @@ import android.content.Context
4import androidx.room.Room
4import androidx.room.Room
5import com.parseable.android.data.local.FavoriteStreamDao
5import com.parseable.android.data.local.FavoriteStreamDao
6import com.parseable.android.data.local.ParseableDatabase
6import com.parseable.android.data.local.ParseableDatabase
7+import com.parseable.android.data.local.SavedServerDao
7import dagger.Module
8import dagger.Module
8import dagger.Provides
9import dagger.Provides
9import dagger.hilt.InstallIn
10import dagger.hilt.InstallIn
@@ -26,4 +27,9 @@ object AppModule {
26 @Singleton
27 @Singleton
27 fun provideFavoriteStreamDao(db: ParseableDatabase): FavoriteStreamDao =
28 fun provideFavoriteStreamDao(db: ParseableDatabase): FavoriteStreamDao =
28 db.favoriteStreamDao()
29 db.favoriteStreamDao()
30+
31+ @Provides
32+ @Singleton
33+ fun provideSavedServerDao(db: ParseableDatabase): SavedServerDao =
34+ db.savedServerDao()
29}
35}
app/src/main/java/com/parseable/android/ui/navigation/NavGraph.kt+5
@@ -177,6 +177,11 @@ fun ParseableNavGraph(
177 launchSingleTop = true
177 launchSingleTop = true
178 }
178 }
179 },
179 },
180+ onServerSwitched = {
181+ navController.navigate(Routes.STREAMS) {
182+ popUpTo(0) { inclusive = true }
183+ }
184+ },
180 )
185 )
181 }
186 }
182
187
app/src/main/java/com/parseable/android/ui/screens/login/LoginViewModel.kt+1
@@ -157,6 +157,7 @@ class LoginViewModel @Inject constructor(
157 when (val aboutResult = repository.verifyServer()) {
157 when (val aboutResult = repository.verifyServer()) {
158 is ApiResult.Success -> {
158 is ApiResult.Success -> {
159 settingsRepository.saveServerConfig(config)
159 settingsRepository.saveServerConfig(config)
160+ settingsRepository.saveServer(config)
160 _state.update { it.copy(isLoading = false, loginSuccess = true, password = "") }
161 _state.update { it.copy(isLoading = false, loginSuccess = true, password = "") }
161 }
162 }
162 is ApiResult.Error -> {
163 is ApiResult.Error -> {
app/src/main/java/com/parseable/android/ui/screens/settings/SettingsScreen.kt+151
@@ -12,10 +12,12 @@ import androidx.compose.runtime.*
12import androidx.compose.ui.Alignment
12import androidx.compose.ui.Alignment
13import androidx.compose.ui.Modifier
13import androidx.compose.ui.Modifier
14import androidx.compose.ui.text.font.FontWeight
14import androidx.compose.ui.text.font.FontWeight
15+import androidx.compose.ui.text.style.TextOverflow
15import androidx.compose.ui.unit.dp
16import androidx.compose.ui.unit.dp
16import androidx.hilt.navigation.compose.hiltViewModel
17import androidx.hilt.navigation.compose.hiltViewModel
17import androidx.lifecycle.compose.collectAsStateWithLifecycle
18import androidx.lifecycle.compose.collectAsStateWithLifecycle
18import com.parseable.android.BuildConfig
19import com.parseable.android.BuildConfig
20+import com.parseable.android.data.local.SavedServer
19import kotlinx.serialization.json.contentOrNull
21import kotlinx.serialization.json.contentOrNull
20import kotlinx.serialization.json.jsonPrimitive
22import kotlinx.serialization.json.jsonPrimitive
21
23
@@ -24,9 +26,18 @@ import kotlinx.serialization.json.jsonPrimitive
24fun SettingsScreen(
26fun SettingsScreen(
25 onBack: () -> Unit,
27 onBack: () -> Unit,
26 onAboutClick: () -> Unit,
28 onAboutClick: () -> Unit,
29+ onServerSwitched: () -> Unit,
27 viewModel: SettingsViewModel = hiltViewModel(),
30 viewModel: SettingsViewModel = hiltViewModel(),
28) {
31) {
29 val state by viewModel.state.collectAsStateWithLifecycle()
32 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+ }
30
41
31 Scaffold(
42 Scaffold(
32 topBar = {
43 topBar = {
@@ -80,6 +91,17 @@ fun SettingsScreen(
80 }
91 }
81 }
92 }
82
93
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+
83 // Loading indicator for server data
105 // Loading indicator for server data
84 if (state.isLoading) {
106 if (state.isLoading) {
85 Box(
107 Box(
@@ -256,6 +278,135 @@ fun SettingsScreen(
256 }
278 }
257}
279}
258
280
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+
259@Composable
410@Composable
260private fun SettingsRow(label: String, value: String) {
411private fun SettingsRow(label: String, value: String) {
261 Row(
412 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
2
2
3import androidx.lifecycle.ViewModel
3import androidx.lifecycle.ViewModel
4import androidx.lifecycle.viewModelScope
4import androidx.lifecycle.viewModelScope
5+import com.parseable.android.data.local.SavedServer
5import com.parseable.android.data.model.AboutInfo
6import com.parseable.android.data.model.AboutInfo
6import com.parseable.android.data.model.ApiResult
7import com.parseable.android.data.model.ApiResult
7import com.parseable.android.data.repository.ParseableRepository
8import com.parseable.android.data.repository.ParseableRepository
@@ -27,6 +28,9 @@ data class SettingsState(
27 val users: List<String> = emptyList(),
28 val users: List<String> = emptyList(),
28 val isLoading: Boolean = false,
29 val isLoading: Boolean = false,
29 val error: String? = null,
30 val error: String? = null,
31+ val savedServers: List<SavedServer> = emptyList(),
32+ val activeServerId: Long? = null,
33+ val isSwitching: Boolean = false,
30)
34)
31
35
32@HiltViewModel
36@HiltViewModel
@@ -38,8 +42,22 @@ class SettingsViewModel @Inject constructor(
38 private val _state = MutableStateFlow(SettingsState())
42 private val _state = MutableStateFlow(SettingsState())
39 val state: StateFlow<SettingsState> = _state.asStateFlow()
43 val state: StateFlow<SettingsState> = _state.asStateFlow()
40
44
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+
41 init {
49 init {
42 load()
50 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+ }
43 }
61 }
44
62
45 fun load() {
63 fun load() {
@@ -96,4 +114,26 @@ class SettingsViewModel @Inject constructor(
96 }
114 }
97 }
115 }
98 }
116 }
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+ }
99}
139}