b2d29f7db265
feat: changelog button in Vault settings updates group (v2.10.22)
- time
- 2026-08-04 15:05 UTC · 2 months ago
- machine
- koh2
- parent
- 65202b932d76
↓ peach-android-b2d29f7d.tar.gzatlas at this save
files changed6 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 −0app/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 }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 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 ───────────────────────────── */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. */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 }