1// Jetpack Security (androidx.security:security-crypto) was deprecated wholesale in 1.1.0 with
2// no first-party drop-in replacement; we intentionally keep using EncryptedSharedPreferences /
3// MasterKey for the encrypted password store (on-disk-compatible, still functional). Suppress
4// the expected deprecation warnings here so the build log stays clean until a future migration.
5@file:Suppress("DEPRECATION")
7package soy.iko.opencode.data.repo
9import android.content.Context
10import android.content.SharedPreferences
11import android.util.Log
12import androidx.datastore.preferences.core.edit
13import androidx.datastore.preferences.core.stringPreferencesKey
14import androidx.datastore.preferences.preferencesDataStore
15import androidx.security.crypto.EncryptedSharedPreferences
16import androidx.security.crypto.MasterKey
17import soy.iko.opencode.data.model.ServerProfile
18import soy.iko.opencode.data.network.OpencodeJson
19import kotlinx.coroutines.Dispatchers
20import kotlinx.coroutines.sync.Mutex
21import kotlinx.coroutines.sync.withLock
22import kotlinx.coroutines.flow.Flow
23import kotlinx.coroutines.flow.flowOn
24import kotlinx.coroutines.flow.map
25import kotlinx.coroutines.withContext
26import kotlinx.serialization.Serializable
27import kotlinx.serialization.builtins.ListSerializer
29private val Context.dataStore by preferencesDataStore(name = "server_profiles")
32 * Persists server profiles. Non-secret fields go to DataStore (as a JSON list);
33 * the Basic-auth password is stored separately in EncryptedSharedPreferences, keyed
34 * by profile id, and merged back in on read.
36open class ProfileStore private constructor(
37 private val appContext: Context?,
38 @Suppress("unused") private val testMode: Boolean,
40 constructor(context: Context) : this(context.applicationContext, false)
41 protected constructor() : this(null, true)
43 private val profilesKey = stringPreferencesKey("profiles_json")
45 /** Guards [migrateFallbackPasswords] so it runs at most once per process, not on every
46 * DataStore emission. The mutex serializes concurrent callers so a second collector waits
47 * for the migration's writes to land (rather than skipping ahead and reading empty secure
48 * prefs mid-migration); [migrationDone] is only set true *after* the writes complete. */
49 private val migrationMutex = Mutex()
50 @Volatile private var migrationDone = false
52 private val securePrefs: SharedPreferences? by lazy {
53 val ctx = appContext ?: return@lazy null
55 val masterKey = MasterKey.Builder(ctx)
56 .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
58 EncryptedSharedPreferences.create(
62 EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
63 EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
66 Log.e("ProfileStore", "Encrypted prefs unavailable; passwords will not be persisted", it)
71 /** True when EncryptedSharedPreferences could not be initialized. */
72 open val securePrefsUnavailable: Boolean get() = securePrefs == null
74 /** Plaintext fallback used only when encrypted prefs are unavailable, so the user
75 * can still connect — but passwords won't survive an app reinstall. */
76 private val fallbackPrefs: SharedPreferences by lazy {
77 appContext!!.getSharedPreferences("server_secrets_fallback", Context.MODE_PRIVATE)
80 private fun prefsForPasswords(): SharedPreferences =
81 securePrefs ?: fallbackPrefs
83 /** Migrate any plaintext passwords from the fallback prefs to secure prefs,
84 * then clear the fallback. Runs at most once per process — subsequent calls
85 * are no-ops — so DataStore re-emissions don't repeatedly scan SharedPreferences. */
86 private suspend fun migrateFallbackPasswords() {
87 if (migrationDone) return
88 migrationMutex.withLock {
89 // Re-check inside the lock: a concurrent caller may have completed the migration
90 // while we were waiting. Serializing here (rather than a bare compareAndSet that
91 // marks done before doing the work) ensures a second collector doesn't read the
92 // secure store before the first caller's write has populated it.
93 if (migrationDone) return
94 val secure = securePrefs
96 // No secure store to migrate into; nothing to do, and no point retrying.
100 withContext(Dispatchers.IO) {
101 val fallbackKeys = fallbackPrefs.all.keys.filter { it.startsWith("pw_") }
102 if (fallbackKeys.isNotEmpty()) {
103 // Write passwords to the secure store FIRST, then remove them from the
104 // plaintext fallback. SharedPreferences.apply() persists to disk
105 // asynchronously, so removing from fallback before the secure batch lands
106 // could lose the password if the process dies between the two writes.
107 // Writing secure first makes the worst case a harmless duplicate (both
108 // stores hold it; the migration is idempotent) rather than data loss.
109 secure.edit().apply {
110 for (key in fallbackKeys) {
111 val pw = fallbackPrefs.getString(key, null) ?: continue
115 fallbackPrefs.edit().apply {
116 for (key in fallbackKeys) remove(key)
120 // Only mark done once the writes are in the secure store's in-memory cache (apply()
121 // updates it synchronously), so the next collector past the mutex reads real values.
126 /** Stored shape on DataStore (everything except the secret password). */
128 private data class StoredProfile(
132 val username: String? = null,
133 val lastUsed: Long = 0,
134 // Non-secret TLS options; default to prior behavior for profiles saved before these
135 // fields existed (deserialization uses these defaults for missing keys).
136 val requireHttps: Boolean = false,
137 val certPin: String? = null,
140 open val profiles: Flow<List<ServerProfile>> = appContext?.dataStore?.data?.map { prefs ->
141 // Migrate any orphaned plaintext passwords to secure prefs on first load.
142 migrateFallbackPasswords()
143 val json = prefs[profilesKey] ?: return@map emptyList()
144 val stored = runCatching {
145 OpencodeJson.decodeFromString(ListSerializer(StoredProfile.serializer()), json)
146 }.onFailure { Log.w("ProfileStore", "Failed to decode stored profiles, ignoring", it) }
147 .getOrDefault(emptyList())
148 .sortedByDescending { it.lastUsed }
149 // Batch all password lookups into a single IO dispatch instead of one
150 // withContext round-trip per profile (N dispatches → 1). DataStore already
151 // runs this map block on IO, but each passwordFor() call still pays the
152 // suspend + redispatch overhead.
153 val pwPrefs = prefsForPasswords()
154 stored.map { it.toProfile(pwPrefs) }
156 // The .map above runs in the *collector's* context, and every consumer collects on
157 // viewModelScope (Main). Without this, kotlinx JSON decode + EncryptedSharedPreferences
158 // AES decryption would run on the UI thread on each emission. flowOn moves that work to IO.
159 ?.flowOn(Dispatchers.IO)
160 ?: kotlinx.coroutines.flow.flowOf(emptyList())
162 private fun StoredProfile.toProfile(pwPrefs: SharedPreferences): ServerProfile {
163 val pw = username?.let { pwPrefs.getString(passwordKey(id), null) }
164 return ServerProfile(
171 requireHttps = requireHttps,
176 open suspend fun resolve(profile: ServerProfile): ServerProfile {
177 val pw = if (profile.hasAuth) {
178 withContext(Dispatchers.IO) { prefsForPasswords().getString(passwordKey(profile.id), null) }
182 return profile.copy(password = pw)
185 open suspend fun save(profile: ServerProfile) {
186 // Write the password first so that if the DataStore write fails, we're left
187 // with an orphaned password (harmless, cleaned up on next save/delete) rather
188 // than a profile with no password (which would break authentication).
189 withContext(Dispatchers.IO) {
190 prefsForPasswords().edit().apply {
191 val pw = profile.password
192 if (!profile.username.isNullOrBlank() && !pw.isNullOrEmpty()) {
193 putString(passwordKey(profile.id), pw)
195 remove(passwordKey(profile.id))
199 appContext!!.dataStore.edit { prefs ->
200 val current = prefs[profilesKey]?.let {
201 runCatching { OpencodeJson.decodeFromString(ListSerializer(StoredProfile.serializer()), it) }
202 .getOrDefault(emptyList())
204 // If the incoming profile has lastUsed=0 (e.g. ServerEditViewModel.save()
205 // couldn't read the existing value due to a DataStore timeout), preserve
206 // the existing nonzero lastUsed so saving an edit doesn't reset the
207 // profile's sort position in the server list.
208 val preservedLastUsed = if (profile.lastUsed == 0L) {
209 current.firstOrNull { it.id == profile.id }?.lastUsed ?: 0L
213 val stored = StoredProfile(
215 label = profile.label,
216 baseUrl = profile.baseUrl,
217 username = profile.username?.takeIf { it.isNotBlank() },
218 lastUsed = preservedLastUsed,
219 requireHttps = profile.requireHttps,
220 certPin = profile.certPin?.takeIf { it.isNotBlank() },
222 val updated = current.filterNot { it.id == profile.id } + stored
223 prefs[profilesKey] = OpencodeJson.encodeToString(ListSerializer(StoredProfile.serializer()), updated)
227 open suspend fun delete(id: String) {
228 // Delete the profile from DataStore first, then the password. If the process
229 // dies between the two, an orphaned password remains (harmless, cleaned up on
230 // next save/delete) rather than a profile with no password.
231 appContext!!.dataStore.edit { prefs ->
232 val current = prefs[profilesKey]?.let {
233 runCatching { OpencodeJson.decodeFromString(ListSerializer(StoredProfile.serializer()), it) }
234 .getOrDefault(emptyList())
236 prefs[profilesKey] = OpencodeJson.encodeToString(
237 ListSerializer(StoredProfile.serializer()),
238 current.filterNot { it.id == id },
241 // Clean up the password in both secure and fallback prefs so no orphaned
242 // plaintext password lingers if secure prefs were temporarily unavailable.
243 withContext(Dispatchers.IO) {
244 val key = passwordKey(id)
245 securePrefs?.edit()?.remove(key)?.apply()
246 fallbackPrefs.edit().remove(key).apply()
250 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