b2d29f7db265

feat: changelog button in Vault settings updates group (v2.10.22)

'changelog' now sits next to 'check for updates' and expands Kepr's full release history inline, newest first, as far back as Kepr retains releases. The installed version is tagged 'current'; releases without notes say so. Loading and retry-on-failure states included. - parseReleaseHistory + ReleaseNote in UpdateManifest (unit-tested) - UpdateRepository.fetchHistory reuses the existing HISTORY_URL fetch; the what's-new accumulator now builds on it instead of its own JSON walk - UpdateViewModel loads history on demand, once per session

time
2026-08-04 15:05 UTC · 2 months ago
machine
koh2
parent
65202b932d76
6 files VERSION+1 −1app/src/main/java/com/peachpasswords/ui/settings/SettingsCardBodies.kt+54 −5app/src/main/java/com/peachpasswords/ui/updates/UpdateScreen.kt+25 −0app/src/main/java/com/peachpasswords/updates/UpdateManifest.kt+17 −0app/src/main/java/com/peachpasswords/updates/UpdateRepository.kt+33 −32app/src/test/java/com/peachpasswords/updates/UpdateManifestTest.kt+16 −0
VERSION+1 −1
@@ -1 +1 @@1-2.10.211+2.10.22

view file

app/src/main/java/com/peachpasswords/ui/settings/SettingsCardBodies.kt+54 −5
@@ -69,8 +69,10 @@6969 import com.peachpasswords.ui.theme.ACCENT_COLORS7070 import com.peachpasswords.ui.theme.PeachSpacing7171 import com.peachpasswords.ui.theme.PeachType72+import com.peachpasswords.ui.updates.ChangelogPhase7273 import com.peachpasswords.ui.updates.UpdatePhase7374 import com.peachpasswords.ui.updates.UpdateViewModel75+import com.peachpasswords.updates.compareVersions7476 import androidx.hilt.navigation.compose.hiltViewModel7577 import com.peachpasswords.vault.VaultSettings7678 import com.peachpasswords.vault.VaultSnapshot@@ -536,6 +538,8 @@536538     val vm: UpdateViewModel = hiltViewModel()537539     val available by vm.available.collectAsState()538540     val state by vm.state.collectAsState()541+    val changelog by vm.changelog.collectAsState()542+    var changelogOpen by remember { mutableStateOf(false) }539543 540544     RowLine(fg) {541545         Name("Version", fg)@@ -559,11 +563,56 @@559563                 modifier = Modifier.padding(top = 2.dp),560564             )561565         }562-        if (available != null) {563-            SubtleButton("tap to install", fg) { onNavigate(PeachNav.Update) }564-        } else {565-            SubtleButton(if (state.phase == UpdatePhase.Checking) "checking" else "check for updates", fg) {566-                vm.check()566+        Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {567+            SubtleButton("changelog", fg) {568+                changelogOpen = !changelogOpen569+                if (changelogOpen) vm.loadChangelog()570+            }571+            if (available != null) {572+                SubtleButton("tap to install", fg) { onNavigate(PeachNav.Update) }573+            } else {574+                SubtleButton(if (state.phase == UpdatePhase.Checking) "checking" else "check for updates", fg) {575+                    vm.check()576+                }577+            }578+        }579+    }580+    if (changelogOpen) {581+        when (changelog.phase) {582+            ChangelogPhase.Idle -> {}583+            ChangelogPhase.Loading -> Text(584+                "Loading changelog…",585+                color = fg.copy(alpha = 0.68f),586+                fontFamily = PeachType.Body,587+                fontSize = 11.sp,588+                modifier = Modifier.padding(vertical = 8.dp),589+            )590+            ChangelogPhase.Failed -> RowLine(fg) {591+                Name("Couldn't reach the update server", fg)592+                SubtleButton("retry", fg) { vm.loadChangelog() }593+            }594+            ChangelogPhase.Loaded -> Column(Modifier.fillMaxWidth()) {595+                changelog.entries.forEachIndexed { index, entry ->596+                    Column(Modifier.fillMaxWidth().padding(vertical = 8.dp)) {597+                        Row(verticalAlignment = Alignment.CenterVertically) {598+                            Text("v${entry.version}", color = fg, fontFamily = PeachType.Mono, fontSize = 11.sp)599+                            if (compareVersions(entry.version, vm.currentVersion) == 0) {600+                                Text("  current", color = fg.copy(alpha = 0.5f), fontFamily = PeachType.Mono, fontSize = 9.sp)601+                            }602+                        }603+                        Text(604+                            entry.notes ?: "No notes for this release.",605+                            color = fg.copy(alpha = 0.72f),606+                            fontFamily = PeachType.Body,607+                            fontSize = 11.sp,608+                            lineHeight = 16.sp,609+                            modifier = Modifier.padding(top = 3.dp),610+                        )611+                    }612+                    if (index < changelog.entries.lastIndex) {613+                        Box(Modifier.fillMaxWidth().height(1.dp).background(fg.copy(alpha = 0.08f)))614+                    }615+                }567616             }568617         }569618     }

view file

app/src/main/java/com/peachpasswords/ui/updates/UpdateScreen.kt+25 −0
@@ -52,6 +52,7 @@5252 import com.peachpasswords.ui.theme.peachColors5353 import com.peachpasswords.updates.ChecksumMismatchException5454 import com.peachpasswords.updates.ReleaseManifest55+import com.peachpasswords.updates.ReleaseNote5556 import com.peachpasswords.updates.UpdateRepository5657 import dagger.hilt.android.lifecycle.HiltViewModel5758 import kotlinx.coroutines.flow.MutableStateFlow@@ -73,6 +74,13 @@7374     val explainInstallPermission: Boolean = false,7475 )7576 77+enum class ChangelogPhase { Idle, Loading, Loaded, Failed }78+79+data class ChangelogUiState(80+    val phase: ChangelogPhase = ChangelogPhase.Idle,81+    val entries: List<ReleaseNote> = emptyList(),82+)83+7684 @HiltViewModel7785 class UpdateViewModel @Inject constructor(7886     private val updates: UpdateRepository,@@ -89,6 +97,23 @@8997             ?: UpdateUiState(),9098     )9199     val state: StateFlow<UpdateUiState> = _state.asStateFlow()100+101+    private val _changelog = MutableStateFlow(ChangelogUiState())102+    /** Full Kepr release history for the settings changelog, loaded on demand. */103+    val changelog: StateFlow<ChangelogUiState> = _changelog.asStateFlow()104+105+    fun loadChangelog() {106+        if (_changelog.value.phase == ChangelogPhase.Loading || _changelog.value.phase == ChangelogPhase.Loaded) return107+        _changelog.value = ChangelogUiState(phase = ChangelogPhase.Loading)108+        viewModelScope.launch {109+            val entries = updates.fetchHistory()110+            _changelog.value = if (entries == null) {111+                ChangelogUiState(phase = ChangelogPhase.Failed)112+            } else {113+                ChangelogUiState(phase = ChangelogPhase.Loaded, entries = entries)114+            }115+        }116+    }92117 93118     fun consumeOpenRequest() = updates.consumeOpenRequest()94119 

view file

app/src/main/java/com/peachpasswords/updates/UpdateManifest.kt+17 −0
@@ -1,6 +1,7 @@11 package com.peachpasswords.updates22 33 import org.bouncycastle.crypto.digests.Blake3Digest4+import org.json.JSONArray45 import org.json.JSONObject56 import java.io.File67 import java.io.InputStream@@ -246,6 +247,22 @@246247 internal fun JSONObject.optTextOrNull(key: String): String? {247248     if (!has(key) || isNull(key)) return null248249     return optString(key).trim().takeIf { it.isNotEmpty() }250+}251+252+/** One published release from Kepr's `releases/history` endpoint. */253+data class ReleaseNote(val version: String, val notes: String?)254+255+/**256+ * Parse Kepr's `GET /api/<repo>/releases/history` response: a JSON array of257+ * `{version, notes}` objects, newest first. Notes stay null when a release258+ * shipped none, so the UI decides what to show for it.259+ */260+fun parseReleaseHistory(json: String): List<ReleaseNote> {261+    val arr = JSONArray(json)262+    return (0 until arr.length()).map { i ->263+        val obj = arr.getJSONObject(i)264+        ReleaseNote(obj.getString("version"), obj.optTextOrNull("notes"))265+    }249266 }250267 251268 /* ─────────────────────────────  version compare  ───────────────────────────── */

view file

app/src/main/java/com/peachpasswords/updates/UpdateRepository.kt+33 −32
@@ -20,7 +20,6 @@2020 import kotlinx.coroutines.SupervisorJob2121 import kotlinx.coroutines.launch2222 import kotlinx.coroutines.withContext23-import org.json.JSONArray2423 import okhttp3.OkHttpClient2524 import okhttp3.Request2625 import java.io.File@@ -407,39 +406,41 @@407406             ?.takeIf { isNewerVersion(it.version, currentVersion) }408407 409408     /**410-     * Fetch release history from Kepr and build accumulated notes for every411-     * version newer than [lastSeen] up to and including [currentVersion].412-     * Returns null on network failure (caller keeps the fallback).409+     * Every published release from Kepr, newest first, or null on network or410+     * parse failure. The settings changelog renders this directly; the411+     * post-update "what's new" accumulates a slice of it.413412      */414-    private suspend fun fetchAccumulatedNotes(lastSeen: String, current: String): String? =415-        withContext(Dispatchers.IO) {416-            runCatching {417-                val request = Request.Builder()418-                    .url(HISTORY_URL)419-                    .header("User-Agent", USER_AGENT)420-                    .build()421-                client.newCall(request).execute().use { response ->422-                    if (!response.isSuccessful) return@runCatching null423-                    val arr = JSONArray(response.body?.string().orEmpty())424-                    // History is newest-first; collect versions in range.425-                    val inRange = mutableListOf<Pair<String, String>>()426-                    for (i in 0 until arr.length()) {427-                        val obj = arr.getJSONObject(i)428-                        val ver = obj.getString("version")429-                        val notes = obj.optString("notes", "")430-                        if (ver == lastSeen) break431-                        if (notes.isNotBlank()) inRange.add(ver to notes)432-                        if (ver == current) break433-                    }434-                    if (inRange.isEmpty()) return@runCatching null435-                    // Reverse to chronological order (oldest first).436-                    inRange.reverse()437-                    inRange.joinToString("\n\n") { (ver, notes) ->438-                        "## v$ver\n\n$notes"439-                    }440-                }441-            }.getOrNull()413+    suspend fun fetchHistory(): List<ReleaseNote>? = withContext(Dispatchers.IO) {414+        runCatching {415+            val request = Request.Builder()416+                .url(HISTORY_URL)417+                .header("User-Agent", USER_AGENT)418+                .build()419+            client.newCall(request).execute().use { response ->420+                if (!response.isSuccessful) return@runCatching null421+                parseReleaseHistory(response.body?.string().orEmpty())422+            }423+        }.getOrNull()424+    }425+426+    /**427+     * Build accumulated notes for every version newer than [lastSeen] up to and428+     * including [current]. Returns null on network failure (caller keeps the429+     * fallback).430+     */431+    private suspend fun fetchAccumulatedNotes(lastSeen: String, current: String): String? {432+        // History is newest-first; collect versions in range.433+        val inRange = mutableListOf<ReleaseNote>()434+        for (entry in fetchHistory() ?: return null) {435+            if (entry.version == lastSeen) break436+            if (!entry.notes.isNullOrBlank()) inRange.add(entry)437+            if (entry.version == current) break442438         }439+        if (inRange.isEmpty()) return null440+        // Reverse to chronological order (oldest first).441+        inRange.reverse()442+        return inRange.joinToString("\n\n") { "## v${it.version}\n\n${it.notes}" }443+    }443444 444445     companion object {445446         /** Kepr repo name for this app; the generic release API is keyed on it. */

view file

app/src/test/java/com/peachpasswords/updates/UpdateManifestTest.kt+16 −0
@@ -380,4 +380,20 @@380380             ),381381         )382382     }383+384+    @Test385+    fun `release history parses newest-first with optional notes`() {386+        // Kepr's `releases/history` shape: newest first, notes optional/blank.387+        val body = """388+            [{"version":"2.10.21","notes":"Fixed things."},389+             {"version":"2.10.20","notes":""},390+             {"version":"2.10.19"}]391+        """.trimIndent()392+        val history = parseReleaseHistory(body)393+        assertEquals(3, history.size)394+        assertEquals("2.10.21", history[0].version)395+        assertEquals("Fixed things.", history[0].notes)396+        assertNull(history[1].notes)397+        assertNull(history[2].notes)398+    }383399 }

view file