git.iko.soy/opencode-android
on 06fd41b help sign in
135 lines 6.1 kB history raw
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. */
11@Serializable
12data class BackupServer(
13 val id: String,
14 val label: String,
15 val baseUrl: String,
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. */
23@Serializable
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. */
39@Serializable
40data class BackupData(
41 val version: Int = 1,
42 val servers: List<BackupServer> = emptyList(),
43 val settings: BackupSettings? = null,
44 val pinned: List<String> = emptyList(),
45 val archived: List<String> = emptyList(),
48/**
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
52 * store.
53 */
54class BackupManager(
55 private val profileStore: ProfileStore,
56 private val settingsStore: SettingsStore,
57 private val sessionPrefsStore: SessionPrefsStore,
58) {
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 {
64 BackupServer(
65 id = it.id,
66 label = it.label,
67 baseUrl = it.baseUrl,
68 username = it.username,
69 password = if (includePasswords) it.password else null,
70 requireHttps = it.requireHttps,
71 certPin = it.certPin,
72 )
73 }
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(),
81 )
82 val data = BackupData(
83 servers = servers,
84 settings = settings,
85 pinned = sessionPrefsStore.pinned.first().toList(),
86 archived = sessionPrefsStore.archived.first().toList(),
87 )
88 return json.encodeToString(data)
89 }
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),
106 ).password
107 }
108 profileStore.save(
109 ServerProfile(
110 id = s.id,
111 label = s.label,
112 baseUrl = s.baseUrl,
113 username = username,
114 password = password,
115 lastUsed = 0,
116 requireHttps = s.requireHttps,
117 certPin = s.certPin?.takeIf { it.isNotBlank() },
118 ),
119 )
120 }
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)
131 }
132 data.pinned.forEach { sessionPrefsStore.setPinned(it, true) }
133 data.archived.forEach { sessionPrefsStore.setArchived(it, true) }
134 }