1package soy.iko.opencode.data.repo
3import kotlinx.coroutines.flow.first
4import kotlinx.serialization.Serializable
5import kotlinx.serialization.decodeFromString
6import kotlinx.serialization.encodeToString
7import kotlinx.serialization.json.Json
8import soy.iko.opencode.data.model.ServerProfile
10/** A server entry in a backup. [password] is present only when the user opted to include it. */
12data class BackupServer(
16 val username: String? = null,
17 val password: String? = null,
18 val requireHttps: Boolean = false,
19 val certPin: String? = null,
22/** App preferences in a backup. */
24data class BackupSettings(
25 // Every field is defaulted (to SettingsStore's own defaults) so a backup written by an older
26 // app version — one whose settings block predates a field added later — still decodes. Without
27 // defaults a missing field throws MissingFieldException in import() BEFORE the server-restore
28 // loop, aborting the ENTIRE restore (servers/pins/archives included) despite the best-effort
29 // contract. ignoreUnknownKeys only tolerates EXTRA keys, not missing required ones.
30 val themeMode: String = ThemeMode.SYSTEM.name,
31 val dynamicColor: Boolean = true,
32 val sendOnEnter: Boolean = true,
33 val appLock: Boolean = false,
34 val chatTextScale: Float = SettingsStore.DEFAULT_CHAT_TEXT_SCALE,
35 val codeWrap: Boolean = false,
38/** The full backup document. [version] guards against future format changes. */
42 val servers: List<BackupServer> = emptyList(),
43 val settings: BackupSettings? = null,
44 val pinned: List<String> = emptyList(),
45 val archived: List<String> = emptyList(),
49 * Exports/imports the app's servers, settings, and per-session flags as a single JSON
50 * document (via the Storage Access Framework in the UI). Passwords are only written when the
51 * user explicitly opts in, since the exported file is plaintext and leaves the app's encrypted
55 private val profileStore: ProfileStore,
56 private val settingsStore: SettingsStore,
57 private val sessionPrefsStore: SessionPrefsStore,
59 private val json = Json { prettyPrint = true; ignoreUnknownKeys = true; encodeDefaults = true }
61 /** Build the backup JSON. Reads the current profiles/settings/flags. */
62 suspend fun export(includePasswords: Boolean): String {
63 val servers = profileStore.profiles.first().map {
68 username = it.username,
69 password = if (includePasswords) it.password else null,
70 requireHttps = it.requireHttps,
74 val settings = BackupSettings(
75 themeMode = settingsStore.themeMode.first().name,
76 dynamicColor = settingsStore.dynamicColor.first(),
77 sendOnEnter = settingsStore.sendOnEnter.first(),
78 appLock = settingsStore.appLock.first(),
79 chatTextScale = settingsStore.chatTextScale.first(),
80 codeWrap = settingsStore.codeWrap.first(),
82 val data = BackupData(
85 pinned = sessionPrefsStore.pinned.first().toList(),
86 archived = sessionPrefsStore.archived.first().toList(),
88 return json.encodeToString(data)
91 /** Apply a backup document. Throws on malformed JSON so the caller can report it. Servers
92 * are upserted (existing ids overwritten); settings and flags are restored best-effort. */
93 suspend fun import(text: String) {
94 val data = json.decodeFromString<BackupData>(text)
95 for (s in data.servers) {
96 val username = s.username?.takeIf { it.isNotBlank() }
97 val incomingPassword = s.password?.takeIf { it.isNotEmpty() }
98 // A backup exported *without* passwords carries null. This is an upsert, so don't let
99 // that null wipe a password already stored for this server id — preserve the existing
100 // secret when the backup didn't include one (only the backup explicitly carrying a
101 // password should overwrite it). Re-importing a password-less backup would otherwise
102 // silently break auth for every matching server.
103 val password = incomingPassword ?: username?.let {
104 profileStore.resolve(
105 ServerProfile(id = s.id, label = s.label, baseUrl = s.baseUrl, username = it),
116 requireHttps = s.requireHttps,
117 certPin = s.certPin?.takeIf { it.isNotBlank() },
121 data.settings?.let { settings ->
122 // Parse the enum defensively (a bad name from an edited backup shouldn't abort the
123 // import) WITHOUT wrapping the suspend setThemeMode call — wrapping it would swallow
124 // a CancellationException and break structured concurrency.
125 ThemeMode.entries.find { it.name == settings.themeMode }?.let { settingsStore.setThemeMode(it) }
126 settingsStore.setDynamicColor(settings.dynamicColor)
127 settingsStore.setSendOnEnter(settings.sendOnEnter)
128 settingsStore.setAppLock(settings.appLock)
129 settingsStore.setChatTextScale(settings.chatTextScale)
130 settingsStore.setCodeWrap(settings.codeWrap)
132 data.pinned.forEach { sessionPrefsStore.setPinned(it, true) }
133 data.archived.forEach { sessionPrefsStore.setArchived(it, true) }
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