git.iko.soy/opencode-android
on 3e723a1 help sign in
137 lines 5.5 kB history raw
1package soy.iko.opencode.data.repo
3import android.content.Context
4import android.content.SharedPreferences
5import android.util.Log
6import androidx.datastore.preferences.core.edit
7import androidx.datastore.preferences.core.stringPreferencesKey
8import androidx.datastore.preferences.preferencesDataStore
9import androidx.security.crypto.EncryptedSharedPreferences
10import androidx.security.crypto.MasterKey
11import soy.iko.opencode.data.model.ServerProfile
12import soy.iko.opencode.data.network.OpencodeJson
13import kotlinx.coroutines.Dispatchers
14import kotlinx.coroutines.flow.Flow
15import kotlinx.coroutines.flow.map
16import kotlinx.coroutines.withContext
17import kotlinx.serialization.Serializable
18import kotlinx.serialization.builtins.ListSerializer
20private val Context.dataStore by preferencesDataStore(name = "server_profiles")
22/**
23 * Persists server profiles. Non-secret fields go to DataStore (as a JSON list);
24 * the Basic-auth password is stored separately in EncryptedSharedPreferences, keyed
25 * by profile id, and merged back in on read.
26 */
27class ProfileStore(context: Context) {
29 private val appContext = context.applicationContext
30 private val profilesKey = stringPreferencesKey("profiles_json")
32 private val securePrefs: SharedPreferences by lazy {
33 runCatching {
34 val masterKey = MasterKey.Builder(appContext)
35 .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
36 .build()
37 EncryptedSharedPreferences.create(
38 appContext,
39 "server_secrets",
40 masterKey,
41 EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
42 EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
43 )
44 }.getOrElse {
45 Log.e("ProfileStore", "Encrypted prefs unavailable, falling back to plaintext", it)
46 appContext.getSharedPreferences("server_secrets_fallback", Context.MODE_PRIVATE)
47 }
48 }
50 /** Stored shape on DataStore (everything except the secret password). */
51 @Serializable
52 private data class StoredProfile(
53 val id: String,
54 val label: String,
55 val baseUrl: String,
56 val username: String? = null,
57 val lastUsed: Long = 0,
58 )
60 val profiles: Flow<List<ServerProfile>> = appContext.dataStore.data.map { prefs ->
61 val json = prefs[profilesKey] ?: return@map emptyList()
62 runCatching {
63 OpencodeJson.decodeFromString(ListSerializer(StoredProfile.serializer()), json)
64 }.onFailure { Log.w("ProfileStore", "Failed to decode stored profiles, ignoring", it) }
65 .getOrDefault(emptyList())
66 .sortedByDescending { it.lastUsed }
67 .map { it.toProfile() }
68 }
70 private suspend fun StoredProfile.toProfile(): ServerProfile {
71 val pw = username?.let { passwordFor(id) }
72 return ServerProfile(
73 id = id,
74 label = label,
75 baseUrl = baseUrl,
76 username = username,
77 password = pw,
78 lastUsed = lastUsed,
79 )
80 }
82 suspend fun resolve(profile: ServerProfile): ServerProfile {
83 val pw = if (profile.hasAuth) passwordFor(profile.id) else null
84 return profile.copy(password = pw)
85 }
87 private suspend fun passwordFor(id: String): String? =
88 withContext(Dispatchers.IO) { securePrefs.getString(passwordKey(id), null) }
90 suspend fun save(profile: ServerProfile) {
91 appContext.dataStore.edit { prefs ->
92 val current = prefs[profilesKey]?.let {
93 runCatching { OpencodeJson.decodeFromString(ListSerializer(StoredProfile.serializer()), it) }
94 .getOrDefault(emptyList())
95 } ?: emptyList()
96 val stored = StoredProfile(
97 id = profile.id,
98 label = profile.label,
99 baseUrl = profile.baseUrl,
100 username = profile.username?.takeIf { it.isNotBlank() },
101 lastUsed = profile.lastUsed,
102 )
103 val updated = current.filterNot { it.id == profile.id } + stored
104 prefs[profilesKey] = OpencodeJson.encodeToString(ListSerializer(StoredProfile.serializer()), updated)
105 }
106 // Secret goes to encrypted storage (or is cleared when auth removed).
107 // EncryptedSharedPreferences encrypts on the calling thread, so move to IO.
108 withContext(Dispatchers.IO) {
109 securePrefs.edit().apply {
110 val pw = profile.password
111 if (!profile.username.isNullOrBlank() && !pw.isNullOrEmpty()) {
112 putString(passwordKey(profile.id), pw)
113 } else {
114 remove(passwordKey(profile.id))
115 }
116 }.apply()
117 }
118 }
120 suspend fun delete(id: String) {
121 appContext.dataStore.edit { prefs ->
122 val current = prefs[profilesKey]?.let {
123 runCatching { OpencodeJson.decodeFromString(ListSerializer(StoredProfile.serializer()), it) }
124 .getOrDefault(emptyList())
125 } ?: emptyList()
126 prefs[profilesKey] = OpencodeJson.encodeToString(
127 ListSerializer(StoredProfile.serializer()),
128 current.filterNot { it.id == id },
129 )
130 }
131 withContext(Dispatchers.IO) {
132 securePrefs.edit().remove(passwordKey(id)).apply()
133 }
134 }
136 private fun passwordKey(id: String) = "pw_$id"