1package soy.iko.opencode.data.repo
3import android.content.Context
4import android.content.SharedPreferences
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.sync.Mutex
15import kotlinx.coroutines.sync.withLock
16import kotlinx.coroutines.flow.Flow
17import kotlinx.coroutines.flow.map
18import kotlinx.coroutines.withContext
19import kotlinx.serialization.Serializable
20import kotlinx.serialization.builtins.ListSerializer
22private val Context.dataStore by preferencesDataStore(name = "server_profiles")
25 * Persists server profiles. Non-secret fields go to DataStore (as a JSON list);
26 * the Basic-auth password is stored separately in EncryptedSharedPreferences, keyed
27 * by profile id, and merged back in on read.
29open class ProfileStore private constructor(
30 private val appContext: Context?,
31 @Suppress("unused") private val testMode: Boolean,
33 constructor(context: Context) : this(context.applicationContext, false)
34 protected constructor() : this(null, true)
36 private val profilesKey = stringPreferencesKey("profiles_json")
38 /** Guards [migrateFallbackPasswords] so it runs at most once per process, not on every
39 * DataStore emission. The mutex serializes concurrent callers so a second collector waits
40 * for the migration's writes to land (rather than skipping ahead and reading empty secure
41 * prefs mid-migration); [migrationDone] is only set true *after* the writes complete. */
42 private val migrationMutex = Mutex()
43 @Volatile private var migrationDone = false
45 private val securePrefs: SharedPreferences? by lazy {
46 val ctx = appContext ?: return@lazy null
48 val masterKey = MasterKey.Builder(ctx)
49 .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
51 EncryptedSharedPreferences.create(
55 EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
56 EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
59 Log.e("ProfileStore", "Encrypted prefs unavailable; passwords will not be persisted", it)
64 /** True when EncryptedSharedPreferences could not be initialized. */
65 open val securePrefsUnavailable: Boolean get() = securePrefs == null
67 /** Plaintext fallback used only when encrypted prefs are unavailable, so the user
68 * can still connect — but passwords won't survive an app reinstall. */
69 private val fallbackPrefs: SharedPreferences by lazy {
70 appContext!!.getSharedPreferences("server_secrets_fallback", Context.MODE_PRIVATE)
73 private fun prefsForPasswords(): SharedPreferences =
74 securePrefs ?: fallbackPrefs
76 /** Migrate any plaintext passwords from the fallback prefs to secure prefs,
77 * then clear the fallback. Runs at most once per process — subsequent calls
78 * are no-ops — so DataStore re-emissions don't repeatedly scan SharedPreferences. */
79 private suspend fun migrateFallbackPasswords() {
80 if (migrationDone) return
81 migrationMutex.withLock {
82 // Re-check inside the lock: a concurrent caller may have completed the migration
83 // while we were waiting. Serializing here (rather than a bare compareAndSet that
84 // marks done before doing the work) ensures a second collector doesn't read the
85 // secure store before the first caller's write has populated it.
86 if (migrationDone) return
87 val secure = securePrefs
89 // No secure store to migrate into; nothing to do, and no point retrying.
93 withContext(Dispatchers.IO) {
94 val fallbackKeys = fallbackPrefs.all.keys.filter { it.startsWith("pw_") }
95 if (fallbackKeys.isNotEmpty()) {
96 // Write passwords to the secure store FIRST, then remove them from the
97 // plaintext fallback. SharedPreferences.apply() persists to disk
98 // asynchronously, so removing from fallback before the secure batch lands
99 // could lose the password if the process dies between the two writes.
100 // Writing secure first makes the worst case a harmless duplicate (both
101 // stores hold it; the migration is idempotent) rather than data loss.
102 secure.edit().apply {
103 for (key in fallbackKeys) {
104 val pw = fallbackPrefs.getString(key, null) ?: continue
108 fallbackPrefs.edit().apply {
109 for (key in fallbackKeys) remove(key)
113 // Only mark done once the writes are in the secure store's in-memory cache (apply()
114 // updates it synchronously), so the next collector past the mutex reads real values.
119 /** Stored shape on DataStore (everything except the secret password). */
121 private data class StoredProfile(
125 val username: String? = null,
126 val lastUsed: Long = 0,
129 open val profiles: Flow<List<ServerProfile>> = appContext?.dataStore?.data?.map { prefs ->
130 // Migrate any orphaned plaintext passwords to secure prefs on first load.
131 migrateFallbackPasswords()
132 val json = prefs[profilesKey] ?: return@map emptyList()
133 val stored = runCatching {
134 OpencodeJson.decodeFromString(ListSerializer(StoredProfile.serializer()), json)
135 }.onFailure { Log.w("ProfileStore", "Failed to decode stored profiles, ignoring", it) }
136 .getOrDefault(emptyList())
137 .sortedByDescending { it.lastUsed }
138 // Batch all password lookups into a single IO dispatch instead of one
139 // withContext round-trip per profile (N dispatches → 1). DataStore already
140 // runs this map block on IO, but each passwordFor() call still pays the
141 // suspend + redispatch overhead.
142 val pwPrefs = prefsForPasswords()
143 stored.map { it.toProfile(pwPrefs) }
144 } ?: kotlinx.coroutines.flow.flowOf(emptyList())
146 private fun StoredProfile.toProfile(pwPrefs: SharedPreferences): ServerProfile {
147 val pw = username?.let { pwPrefs.getString(passwordKey(id), null) }
148 return ServerProfile(
158 open suspend fun resolve(profile: ServerProfile): ServerProfile {
159 val pw = if (profile.hasAuth) {
160 withContext(Dispatchers.IO) { prefsForPasswords().getString(passwordKey(profile.id), null) }
164 return profile.copy(password = pw)
167 open suspend fun save(profile: ServerProfile) {
168 // Write the password first so that if the DataStore write fails, we're left
169 // with an orphaned password (harmless, cleaned up on next save/delete) rather
170 // than a profile with no password (which would break authentication).
171 withContext(Dispatchers.IO) {
172 prefsForPasswords().edit().apply {
173 val pw = profile.password
174 if (!profile.username.isNullOrBlank() && !pw.isNullOrEmpty()) {
175 putString(passwordKey(profile.id), pw)
177 remove(passwordKey(profile.id))
181 appContext!!.dataStore.edit { prefs ->
182 val current = prefs[profilesKey]?.let {
183 runCatching { OpencodeJson.decodeFromString(ListSerializer(StoredProfile.serializer()), it) }
184 .getOrDefault(emptyList())
186 // If the incoming profile has lastUsed=0 (e.g. ServerEditViewModel.save()
187 // couldn't read the existing value due to a DataStore timeout), preserve
188 // the existing nonzero lastUsed so saving an edit doesn't reset the
189 // profile's sort position in the server list.
190 val preservedLastUsed = if (profile.lastUsed == 0L) {
191 current.firstOrNull { it.id == profile.id }?.lastUsed ?: 0L
195 val stored = StoredProfile(
197 label = profile.label,
198 baseUrl = profile.baseUrl,
199 username = profile.username?.takeIf { it.isNotBlank() },
200 lastUsed = preservedLastUsed,
202 val updated = current.filterNot { it.id == profile.id } + stored
203 prefs[profilesKey] = OpencodeJson.encodeToString(ListSerializer(StoredProfile.serializer()), updated)
207 open suspend fun delete(id: String) {
208 // Delete the profile from DataStore first, then the password. If the process
209 // dies between the two, an orphaned password remains (harmless, cleaned up on
210 // next save/delete) rather than a profile with no password.
211 appContext!!.dataStore.edit { prefs ->
212 val current = prefs[profilesKey]?.let {
213 runCatching { OpencodeJson.decodeFromString(ListSerializer(StoredProfile.serializer()), it) }
214 .getOrDefault(emptyList())
216 prefs[profilesKey] = OpencodeJson.encodeToString(
217 ListSerializer(StoredProfile.serializer()),
218 current.filterNot { it.id == id },
221 // Clean up the password in both secure and fallback prefs so no orphaned
222 // plaintext password lingers if secure prefs were temporarily unavailable.
223 withContext(Dispatchers.IO) {
224 val key = passwordKey(id)
225 securePrefs?.edit()?.remove(key)?.apply()
226 fallbackPrefs.edit().remove(key).apply()
230 private fun passwordKey(id: String) = "pw_$id"
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list