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.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")
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.
27class ProfileStore(context: Context) {
29 private val appContext = context.applicationContext
30 private val profilesKey = stringPreferencesKey("profiles_json")
32 private val securePrefs: SharedPreferences by lazy {
34 val masterKey = MasterKey.Builder(appContext)
35 .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
37 EncryptedSharedPreferences.create(
41 EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
42 EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
45 Log.e("ProfileStore", "Encrypted prefs unavailable, falling back to plaintext", it)
46 appContext.getSharedPreferences("server_secrets_fallback", Context.MODE_PRIVATE)
50 /** Stored shape on DataStore (everything except the secret password). */
52 private data class StoredProfile(
56 val username: String? = null,
57 val lastUsed: Long = 0,
60 val profiles: Flow<List<ServerProfile>> = appContext.dataStore.data.map { prefs ->
61 val json = prefs[profilesKey] ?: return@map emptyList()
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() }
70 private suspend fun StoredProfile.toProfile(): ServerProfile {
71 val pw = username?.let { passwordFor(id) }
82 suspend fun resolve(profile: ServerProfile): ServerProfile {
83 val pw = if (profile.hasAuth) passwordFor(profile.id) else null
84 return profile.copy(password = pw)
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())
96 val stored = StoredProfile(
98 label = profile.label,
99 baseUrl = profile.baseUrl,
100 username = profile.username?.takeIf { it.isNotBlank() },
101 lastUsed = profile.lastUsed,
103 val updated = current.filterNot { it.id == profile.id } + stored
104 prefs[profilesKey] = OpencodeJson.encodeToString(ListSerializer(StoredProfile.serializer()), updated)
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)
114 remove(passwordKey(profile.id))
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())
126 prefs[profilesKey] = OpencodeJson.encodeToString(
127 ListSerializer(StoredProfile.serializer()),
128 current.filterNot { it.id == id },
131 withContext(Dispatchers.IO) {
132 securePrefs.edit().remove(passwordKey(id)).apply()
136 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