git.iko.soy/opencode-android
on e37d1bb help sign in
118 lines 4.7 kB history raw
1package soy.iko.opencode.data.repo
3import android.content.Context
4import android.content.SharedPreferences
5import androidx.datastore.preferences.core.edit
6import androidx.datastore.preferences.core.stringPreferencesKey
7import androidx.datastore.preferences.preferencesDataStore
8import androidx.security.crypto.EncryptedSharedPreferences
9import androidx.security.crypto.MasterKey
10import soy.iko.opencode.data.model.ServerProfile
11import soy.iko.opencode.data.network.OpencodeJson
12import kotlinx.coroutines.flow.Flow
13import kotlinx.coroutines.flow.map
14import kotlinx.serialization.Serializable
15import kotlinx.serialization.builtins.ListSerializer
17private val Context.dataStore by preferencesDataStore(name = "server_profiles")
19/**
20 * Persists server profiles. Non-secret fields go to DataStore (as a JSON list);
21 * the Basic-auth password is stored separately in EncryptedSharedPreferences, keyed
22 * by profile id, and merged back in on read.
23 */
24class ProfileStore(context: Context) {
26 private val appContext = context.applicationContext
27 private val profilesKey = stringPreferencesKey("profiles_json")
29 private val securePrefs: SharedPreferences by lazy {
30 val masterKey = MasterKey.Builder(appContext)
31 .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
32 .build()
33 EncryptedSharedPreferences.create(
34 appContext,
35 "server_secrets",
36 masterKey,
37 EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
38 EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
39 )
40 }
42 /** Stored shape on DataStore (everything except the secret password). */
43 @Serializable
44 private data class StoredProfile(
45 val id: String,
46 val label: String,
47 val baseUrl: String,
48 val username: String? = null,
49 val lastUsed: Long = 0,
50 )
52 val profiles: Flow<List<ServerProfile>> = appContext.dataStore.data.map { prefs ->
53 val json = prefs[profilesKey] ?: return@map emptyList()
54 runCatching {
55 OpencodeJson.decodeFromString(ListSerializer(StoredProfile.serializer()), json)
56 }.getOrDefault(emptyList())
57 .sortedByDescending { it.lastUsed }
58 .map { it.toProfile() }
59 }
61 private fun StoredProfile.toProfile() = ServerProfile(
62 id = id,
63 label = label,
64 baseUrl = baseUrl,
65 username = username,
66 password = username?.let { passwordFor(id) },
67 lastUsed = lastUsed,
68 )
70 /** Resolve a single profile with its password loaded, for opening a connection. */
71 suspend fun resolve(profile: ServerProfile): ServerProfile =
72 profile.copy(password = if (profile.hasAuth) passwordFor(profile.id) else null)
74 fun passwordFor(id: String): String? = securePrefs.getString(passwordKey(id), null)
76 suspend fun save(profile: ServerProfile) {
77 appContext.dataStore.edit { prefs ->
78 val current = prefs[profilesKey]?.let {
79 runCatching { OpencodeJson.decodeFromString(ListSerializer(StoredProfile.serializer()), it) }
80 .getOrDefault(emptyList())
81 } ?: emptyList()
82 val stored = StoredProfile(
83 id = profile.id,
84 label = profile.label,
85 baseUrl = profile.baseUrl,
86 username = profile.username?.takeIf { it.isNotBlank() },
87 lastUsed = profile.lastUsed,
88 )
89 val updated = current.filterNot { it.id == profile.id } + stored
90 prefs[profilesKey] = OpencodeJson.encodeToString(ListSerializer(StoredProfile.serializer()), updated)
91 }
92 // Secret goes to encrypted storage (or is cleared when auth removed).
93 securePrefs.edit().apply {
94 val pw = profile.password
95 if (!profile.username.isNullOrBlank() && !pw.isNullOrEmpty()) {
96 putString(passwordKey(profile.id), pw)
97 } else {
98 remove(passwordKey(profile.id))
99 }
100 }.apply()
101 }
103 suspend fun delete(id: String) {
104 appContext.dataStore.edit { prefs ->
105 val current = prefs[profilesKey]?.let {
106 runCatching { OpencodeJson.decodeFromString(ListSerializer(StoredProfile.serializer()), it) }
107 .getOrDefault(emptyList())
108 } ?: emptyList()
109 prefs[profilesKey] = OpencodeJson.encodeToString(
110 ListSerializer(StoredProfile.serializer()),
111 current.filterNot { it.id == id },
112 )
113 }
114 securePrefs.edit().remove(passwordKey(id)).apply()
115 }
117 private fun passwordKey(id: String) = "pw_$id"