From 3c2064f30c06462108040483d290e91a1c6b75cc Mon Sep 17 00:00:00 2001 From: xXJsonDeruloXx Date: Sat, 3 Oct 2026 00:06:09 -0400 Subject: [PATCH 1/2] Add Preview update history --- .../com/droiddeck/launcher/ui/UpdatesPage.kt | 64 ++++++++++- .../droiddeck/launcher/update/AppUpdates.kt | 83 +++++++++++++- app/src/main/res/values-es/strings.xml | 9 ++ app/src/main/res/values/strings.xml | 9 ++ .../launcher/update/AppUpdatesTest.kt | 104 +++++++++++++++++- tools/release/update_catalog.py | 54 ++++++++- tools/tests/test_update_catalog.py | 49 +++++++++ 7 files changed, 363 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/UpdatesPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/UpdatesPage.kt index de4363430..a3f4e5285 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/UpdatesPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/UpdatesPage.kt @@ -116,12 +116,12 @@ internal fun UpdatesPage(s: FrontEndState, a: FrontEndActions, modifier: Modifie StatusPanel(s, u, ua, me) Box(Modifier.height(18.dp)) ChannelLabel() - ChannelPicker(u, ua) + ChannelPicker(u, ua, me) } else { // The label sits over both columns, so the status card lines up with the first channel. ChannelLabel() Row(horizontalArrangement = Arrangement.spacedBy(18.dp)) { - Box(Modifier.weight(1f)) { ChannelPicker(u, ua) } + Box(Modifier.weight(1f)) { ChannelPicker(u, ua, me) } Box(Modifier.weight(1.15f)) { StatusPanel(s, u, ua, me) } } } @@ -260,7 +260,7 @@ private fun ChannelLabel() { } @Composable -private fun ChannelPicker(u: UpdatesState, ua: UpdatesActions) { +private fun ChannelPicker(u: UpdatesState, ua: UpdatesActions, me: AppUpdates.Installed) { val colors = MaterialTheme.colorScheme val catalog = u.catalog val tests = catalog?.tests.orEmpty() @@ -273,6 +273,7 @@ private fun ChannelPicker(u: UpdatesState, ua: UpdatesActions) { Icons.Outlined.Bolt, stringResource(R.string.upd_preview), stringResource(R.string.upd_preview_hint), catalog?.preview?.let { ago(it.publishedAt) }, u.follow.channel == Channel.NIGHTLY, ) { ua.onFollow(Follow(Channel.NIGHTLY)) } + PreviewHistory(catalog, me) ChannelCard( Icons.Outlined.Science, stringResource(R.string.upd_tests), stringResource(R.string.upd_tests_hint), if (tests.isEmpty()) stringResource(R.string.upd_tests_none) else pluralStringResource(R.plurals.upd_tests_count, tests.size, tests.size), @@ -286,6 +287,63 @@ private fun ChannelPicker(u: UpdatesState, ua: UpdatesActions) { } } +/** Compact, read-only history under Preview; only the latest Preview build remains installable. */ +@Composable +private fun PreviewHistory(catalog: AppUpdates.Catalog?, me: AppUpdates.Installed) { + val history = catalog?.let { AppUpdates.previewHistory(it, me) } ?: return + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val key = catalog?.recentPreviews?.firstOrNull()?.commit + var expanded by rememberSaveable(key) { mutableStateOf(false) } + val heading = when (history.kind) { + AppUpdates.PreviewHistoryKind.CURRENT -> stringResource(R.string.upd_preview_current) + AppUpdates.PreviewHistoryKind.BEHIND -> pluralStringResource( + R.plurals.upd_preview_behind, history.buildsBehind, history.buildsBehind, + ) + AppUpdates.PreviewHistoryKind.RECENT -> pluralStringResource( + R.plurals.upd_preview_recent, history.changes.size, history.changes.size, + ) + } + Column(Modifier.fillMaxWidth().padding(start = 66.dp, end = 14.dp, bottom = 5.dp)) { + Text(heading, fontSize = 11.5.sp, fontWeight = FontWeight.Medium, color = colors.onSurfaceVariant) + if (history.changes.isNotEmpty()) { + val visible = if (expanded) history.changes else history.changes.take(3) + Column(Modifier.padding(top = 3.dp), verticalArrangement = Arrangement.spacedBy(3.dp)) { + visible.forEach { change -> + Column { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + changeTitle(change.title.ifBlank { change.commit.take(7) }), + fontSize = 12.5.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, + maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f), + ) + Text(ago(change.publishedAt), fontSize = 11.sp, color = colors.onSurfaceVariant) + } + if (change.summary.isNotBlank()) Text( + change.summary, fontSize = 11.5.sp, color = colors.onSurfaceVariant, + maxLines = 1, overflow = TextOverflow.Ellipsis, + ) + } + } + } + if (history.changes.size > 3 || expanded) { + val src = remember { MutableInteractionSource() } + val hot = rememberHot(src) + val toggle = { expanded = !expanded } + Text( + if (expanded) stringResource(R.string.upd_less) else stringResource(R.string.upd_more), + fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = pal.signal, + modifier = Modifier.padding(top = 1.dp).offset(x = (-6).dp).paneItem("preview:history:more") + .clip(Shape12).glideBorder(hot, Shape12, pal.signal) + .hoverable(src).clickable(interactionSource = src, indication = null, role = Role.Button, onClick = toggle) + .controllerConfirm(onClick = toggle) + .padding(horizontal = 6.dp, vertical = 4.dp), + ) + } + } + } +} + @Composable private fun ChannelCard( icon: ImageVector, title: String, hint: String, latest: String?, selected: Boolean, diff --git a/app/src/main/java/com/droiddeck/launcher/update/AppUpdates.kt b/app/src/main/java/com/droiddeck/launcher/update/AppUpdates.kt index fe0498b62..8e8376f81 100644 --- a/app/src/main/java/com/droiddeck/launcher/update/AppUpdates.kt +++ b/app/src/main/java/com/droiddeck/launcher/update/AppUpdates.kt @@ -54,11 +54,19 @@ object AppUpdates { val apk: Apk?, ) + class PreviewChange( + val commit: String, + val title: String, + val summary: String, + val publishedAt: Long, + ) + class Catalog( val stable: Release?, val preview: Release?, val tests: List, val checkedAt: Long, + val recentPreviews: List = emptyList(), ) class Installed( @@ -68,6 +76,15 @@ object AppUpdates { val versionCode: Int, val updatable: Boolean, val committedAt: Long = 0L, + val ciBuild: Boolean = BuildConfig.CI_BUILD, + ) + + enum class PreviewHistoryKind { CURRENT, BEHIND, RECENT } + + class PreviewHistory( + val kind: PreviewHistoryKind, + val buildsBehind: Int, + val changes: List, ) enum class Offer { @@ -146,6 +163,37 @@ object AppUpdates { r.commit.startsWith(me.commit, ignoreCase = true) } + /** Recent Preview changes relative to a CI-installed commit, when its position is known. */ + fun previewHistory(catalog: Catalog, me: Installed = installed()): PreviewHistory? { + if (!me.ciBuild || catalog.recentPreviews.isEmpty()) return null + val stable = catalog.stable + if ( + me.pr != 0 || stable?.let { isRunning(it, me) } == true || + stable?.version?.let { compareVersions(me.version, it) <= 0 } == true + ) { + return PreviewHistory(PreviewHistoryKind.RECENT, 0, catalog.recentPreviews) + } + + val matching = catalog.recentPreviews.indices.filter { + matchesInstalledCommit(catalog.recentPreviews[it].commit, me.commit) + } + if (matching.size != 1) { + return PreviewHistory(PreviewHistoryKind.RECENT, 0, catalog.recentPreviews) + } + val index = matching.single() + return if (index == 0) { + PreviewHistory(PreviewHistoryKind.CURRENT, 0, emptyList()) + } else { + PreviewHistory(PreviewHistoryKind.BEHIND, index, catalog.recentPreviews.take(index)) + } + } + + private fun matchesInstalledCommit(published: String, installed: String): Boolean { + if (!Regex("^[0-9a-fA-F]{7,40}$").matches(installed)) return false + return published.equals(installed, ignoreCase = true) || + (installed.length < 40 && published.startsWith(installed, ignoreCase = true)) + } + /** Why Android cannot install this published build over [me], before downloading anything. */ fun installBlock(r: Release, me: Installed = installed()): String? = when { r.apk == null -> "This build has no download for this copy of DroidDeck." @@ -218,6 +266,7 @@ object AppUpdates { ) } val tests = root.optJSONArray("tests") ?: JSONArray() + val recentPreviews = root.optJSONArray("recentPreviews") ?: JSONArray() return Catalog( root.optJSONObject("stable")?.let { readPublishedRelease(it, packageName) }, root.optJSONObject("preview")?.let { readPublishedRelease(it, packageName) }, @@ -226,9 +275,20 @@ object AppUpdates { .filter { it.pr > 0 } .sortedByDescending { it.publishedAt }, checkedAt, + (0 until recentPreviews.length()) + .mapNotNull { recentPreviews.optJSONObject(it)?.let(::readPublishedPreviewChange) } + .sortedByDescending { it.publishedAt } + .distinctBy { it.commit.lowercase() } + .take(10), ) } + private fun readPublishedPreviewChange(o: JSONObject): PreviewChange? { + val commit = o.optString("commit") + if (!Regex("^[0-9a-fA-F]{40}$").matches(commit)) return null + return PreviewChange(commit.lowercase(), o.optString("title"), o.optString("summary"), o.optLong("publishedAt")) + } + private fun readPublishedRelease(o: JSONObject, packageName: String): Release { val commit = o.getString("commit") if (!Regex("^[0-9a-fA-F]{7,40}$").matches(commit)) { @@ -358,23 +418,42 @@ object AppUpdates { }, ) - private fun writeCatalog(c: Catalog): JSONObject = JSONObject() + internal fun writeCatalog(c: Catalog): JSONObject = JSONObject() .put("stable", c.stable?.let(::writeRelease)) .put("preview", c.preview?.let(::writeRelease)) .put("tests", JSONArray().apply { c.tests.forEach { put(writeRelease(it)) } }) + .put("recentPreviews", JSONArray().apply { c.recentPreviews.forEach { put(writePreviewChange(it)) } }) .put("checkedAt", c.checkedAt) - private fun readCachedCatalog(o: JSONObject): Catalog { + private fun writePreviewChange(c: PreviewChange): JSONObject = JSONObject() + .put("commit", c.commit) + .put("title", c.title) + .put("summary", c.summary) + .put("publishedAt", c.publishedAt) + + internal fun readCachedCatalog(o: JSONObject): Catalog { val tests = o.optJSONArray("tests") ?: JSONArray() val preview = o.optJSONObject("preview") ?: o.optJSONObject("nightly") + val recentPreviews = o.optJSONArray("recentPreviews") ?: JSONArray() return Catalog( o.optJSONObject("stable")?.let(::readCachedRelease), preview?.let(::readCachedRelease), (0 until tests.length()).map { readCachedRelease(tests.getJSONObject(it)) }, o.optLong("checkedAt"), + (0 until recentPreviews.length()) + .mapNotNull { recentPreviews.optJSONObject(it)?.let(::readCachedPreviewChange) } + .sortedByDescending { it.publishedAt } + .distinctBy { it.commit.lowercase() } + .take(10), ) } + private fun readCachedPreviewChange(o: JSONObject): PreviewChange? { + val commit = o.optString("commit") + if (!Regex("^[0-9a-fA-F]{40}$").matches(commit)) return null + return PreviewChange(commit.lowercase(), o.optString("title"), o.optString("summary"), o.optLong("publishedAt")) + } + private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) } diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 5be0da17a..4d89059f5 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -598,6 +598,7 @@ CANAL DE ACTUALIZACIÓN Versiones probadas, para la mayoría Las correcciones más recientes, antes que Stable + Al día Versiones de prueba Prueba una corrección antes de su publicación Ninguna por ahora @@ -610,6 +611,14 @@ %1$d para probar %1$d para probar + + Cambios desde tu versión · %1$d versión atrás + Cambios desde tu versión · %1$d versiones atrás + + + Cambios recientes · %1$d versión + Cambios recientes · %1$d versiones + Un ajuste de Android por cambiar Android detiene las apps que inician muchos procesos en segundo plano, y Steam inicia decenas. Hasta que esto cambie, los juegos se cierran sin mostrar ningún error. Solucionarlo por mí diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 61200faa6..388577820 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -647,6 +647,7 @@ %1$s · %2$s Preview Newest fixes, before Stable + Up to date Test builds Try a fix before it\'s released None right now @@ -660,6 +661,14 @@ %1$d to try %1$d to try + + Changes since your build · %1$d build behind + Changes since your build · %1$d builds behind + + + Recent changes · %1$d build + Recent changes · %1$d builds + One Android setting to change Steam Android stops apps that start many background processes, and Steam starts dozens. Until this changes, games close without an error. diff --git a/app/src/test/java/com/droiddeck/launcher/update/AppUpdatesTest.kt b/app/src/test/java/com/droiddeck/launcher/update/AppUpdatesTest.kt index c3c315aa6..ada2ff3be 100644 --- a/app/src/test/java/com/droiddeck/launcher/update/AppUpdatesTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/update/AppUpdatesTest.kt @@ -5,9 +5,12 @@ import com.droiddeck.launcher.update.AppUpdates.Channel import com.droiddeck.launcher.update.AppUpdates.Follow import com.droiddeck.launcher.update.AppUpdates.Installed import com.droiddeck.launcher.update.AppUpdates.Offer +import com.droiddeck.launcher.update.AppUpdates.PreviewChange +import com.droiddeck.launcher.update.AppUpdates.PreviewHistoryKind import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull +import org.json.JSONArray import org.json.JSONObject import org.junit.Assert.assertTrue import org.junit.Assert.fail @@ -63,7 +66,17 @@ class AppUpdatesTest { pr: Int = 0, version: String = "0.2.0", versionCode: Int = 9, - ) = Installed(commit, pr, version, versionCode, updatable = true) + ciBuild: Boolean = true, + ) = Installed(commit, pr, version, versionCode, updatable = true, ciBuild = ciBuild) + + private fun change(commit: String, title: String, publishedAt: Long) = + PreviewChange(commit, title, "Summary for $title", publishedAt) + + private val recentPreview = listOf( + change("1".repeat(40), "Newest", 300L), + change("2".repeat(40), "Middle", 200L), + change("3".repeat(40), "Oldest", 100L), + ) @Test fun theStableBuildIsCurrentOnStable() { @@ -122,6 +135,76 @@ class AppUpdatesTest { assertNotNull(AppUpdates.installBlock(missing, me("eeeeeee"))) } + @Test + fun previewHistoryCountsOnlyAnUnambiguousPublishedPreviewCommit() { + val withHistory = AppUpdates.Catalog(stable, release("1111111"), emptyList(), 0L, recentPreview) + val result = AppUpdates.previewHistory(withHistory, me("2".repeat(40), version = "0.3.0"))!! + assertEquals(PreviewHistoryKind.BEHIND, result.kind) + assertEquals(1, result.buildsBehind) + assertEquals(listOf("Newest"), result.changes.map { it.title }) + } + + @Test + fun currentPreviewHasNoRedundantChangeRows() { + val withHistory = AppUpdates.Catalog(stable, release("1111111"), emptyList(), 0L, recentPreview) + val result = AppUpdates.previewHistory(withHistory, me("1".repeat(40), version = "0.3.0"))!! + assertEquals(PreviewHistoryKind.CURRENT, result.kind) + assertTrue(result.changes.isEmpty()) + } + + @Test + fun missingOrAmbiguousPreviewCommitsUseNeutralRecentHistory() { + val withHistory = AppUpdates.Catalog(stable, release("1111111"), emptyList(), 0L, recentPreview) + assertEquals( + PreviewHistoryKind.RECENT, + AppUpdates.previewHistory(withHistory, me("9".repeat(40), version = "0.3.0"))!!.kind, + ) + + val ambiguous = listOf( + change("abcdef0" + "1".repeat(33), "First", 300L), + change("abcdef0" + "2".repeat(33), "Second", 200L), + ) + val ambiguousCatalog = AppUpdates.Catalog(stable, release("abcdef0"), emptyList(), 0L, ambiguous) + val result = AppUpdates.previewHistory(ambiguousCatalog, me("abcdef0", version = "0.3.0"))!! + assertEquals(PreviewHistoryKind.RECENT, result.kind) + assertEquals(2, result.changes.size) + } + + @Test + fun prStableAndLocalBuildsDoNotGetPreviewDistance() { + val withHistory = AppUpdates.Catalog(stable, release("1111111"), emptyList(), 0L, recentPreview) + assertEquals( + PreviewHistoryKind.RECENT, + AppUpdates.previewHistory(withHistory, me("2".repeat(40), pr = 93, version = "0.3.0"))!!.kind, + ) + assertEquals( + PreviewHistoryKind.RECENT, + AppUpdates.previewHistory(withHistory, me(stable.commit))!!.kind, + ) + assertNull(AppUpdates.previewHistory(withHistory, me("2".repeat(40), version = "0.3.0", ciBuild = false))) + assertEquals( + PreviewHistoryKind.RECENT, + AppUpdates.previewHistory(withHistory, me("2".repeat(40), version = "0.1.0"))!!.kind, + ) + } + + @Test + fun cachedPreviewHistorySurvivesRoundTrip() { + val withHistory = AppUpdates.Catalog(stable, release("1111111"), emptyList(), 123L, recentPreview) + val cached = AppUpdates.readCachedCatalog(AppUpdates.writeCatalog(withHistory)) + assertEquals(123L, cached.checkedAt) + assertEquals(3, cached.recentPreviews.size) + assertEquals("Newest", cached.recentPreviews.first().title) + assertEquals(recentPreview.first().commit, cached.recentPreviews.first().commit) + } + + @Test + fun oldCachedCatalogWithoutPreviewHistoryRemainsReadable() { + val cached = AppUpdates.readCachedCatalog(JSONObject().put("tests", JSONArray()).put("checkedAt", 123L)) + assertEquals(123L, cached.checkedAt) + assertTrue(cached.recentPreviews.isEmpty()) + } + private fun publishedCatalog(signer: String = BuildConfig.RELEASE_SIGNER): JSONObject { val raw = """ { @@ -169,6 +252,25 @@ class AppUpdatesTest { assertEquals(9, parsed.preview!!.apk!!.versionCode) assertNull(parsed.preview!!.version) assertEquals(123L, parsed.checkedAt) + assertTrue(parsed.recentPreviews.isEmpty()) + } + + @Test + fun publishedCatalogReadsOptionalPreviewHistoryAndDeduplicates() { + val first = "a".repeat(40) + val newest = "b".repeat(40) + val raw = publishedCatalog().put( + "recentPreviews", + JSONArray() + .put(JSONObject().put("commit", first).put("title", "Older").put("summary", "Old").put("publishedAt", 1L)) + .put(JSONObject().put("commit", newest).put("title", "Newest").put("summary", "New").put("publishedAt", 2L)) + .put(JSONObject().put("commit", first).put("title", "Duplicate").put("summary", "Again").put("publishedAt", 3L)) + .put(JSONObject().put("commit", "shortsha").put("title", "Invalid")), + ) + val parsed = AppUpdates.readPublishedCatalog(raw, "com.droiddeck.launcher", 123L) + assertEquals(2, parsed.recentPreviews.size) + assertEquals("Duplicate", parsed.recentPreviews.first().title) + assertEquals(newest, parsed.recentPreviews.last().commit) } @Test diff --git a/tools/release/update_catalog.py b/tools/release/update_catalog.py index 67b800ba2..e7c71f89b 100755 --- a/tools/release/update_catalog.py +++ b/tools/release/update_catalog.py @@ -229,6 +229,50 @@ def release_entry( } +def preview_history_entry(release: dict, preview: dict, variants: list[tuple[str, str]]): + """Return compact history metadata for one complete, signed main build.""" + tag = release.get("tag_name") or "" + tag_match = MAIN_TAG.fullmatch(tag) + commit = (preview.get("commit") or "").lower() + expected_packages = {package for package, _ in variants} + apks = preview.get("apks") or {} + if ( + release.get("draft") + or not release.get("prerelease") + or not tag_match + or not re.fullmatch(r"[0-9a-f]{40}", commit) + or not commit.startswith(tag_match.group(1)) + or set(apks) != expected_packages + or any( + not SHA256.fullmatch((apk.get("sha256") or "").lower()) + or not SHA256.fullmatch((apk.get("signerSha256") or "").lower()) + for apk in apks.values() + ) + ): + return None + return { + "commit": commit, + "title": preview["title"], + "summary": preview["summary"][:400], + "publishedAt": preview["publishedAt"], + } + + +def recent_previews(entries: list[dict], limit: int = 10) -> list[dict]: + """Keep the newest distinct Preview commits, newest first.""" + result = [] + seen = set() + for entry in sorted(entries, key=lambda item: item["publishedAt"], reverse=True): + commit = entry["commit"].lower() + if commit in seen: + continue + seen.add(commit) + result.append({**entry, "commit": commit}) + if len(result) >= limit: + break + return result + + def release_body_value(body: str, key: str): match = re.search(rf"(?mi)^\s*{re.escape(key)}:\s*`?([^\s`]+)", body or "") return match.group(1) if match else None @@ -276,6 +320,7 @@ def build_catalog(source: GitHub, ci: GitHub, source_repo: str, ci_repo: str) -> releases = ci.get(f"/repos/{ci_repo}/releases?per_page=100") previews = [] + preview_history = [] tests = [] for release in releases: if release.get("draft"): @@ -291,9 +336,11 @@ def build_catalog(source: GitHub, ci: GitHub, source_repo: str, ci_repo: str) -> except ApiError: continue title, summary = ci_description(release) - previews.append( - release_entry(release, commit, 0, None, code, variants, signer, title, summary) - ) + preview = release_entry(release, commit, 0, None, code, variants, signer, title, summary) + previews.append(preview) + history_entry = preview_history_entry(release, preview, variants) + if history_entry: + preview_history.append(history_entry) continue pr_match = PR_TAG.fullmatch(tag) @@ -356,6 +403,7 @@ def build_catalog(source: GitHub, ci: GitHub, source_repo: str, ci_repo: str) -> "generatedAt": int(time.time() * 1000), "stable": stable, "preview": previews[0] if previews else None, + "recentPreviews": recent_previews(preview_history), "tests": tests, } diff --git a/tools/tests/test_update_catalog.py b/tools/tests/test_update_catalog.py index 2e8e125bc..3a0fc3de3 100644 --- a/tools/tests/test_update_catalog.py +++ b/tools/tests/test_update_catalog.py @@ -6,6 +6,8 @@ from tools.release.update_catalog import ( parse_gradle, parse_variants, published_millis, + preview_history_entry, + recent_previews, release_body_value, stable_app_release, ) @@ -87,6 +89,53 @@ pubg com.tencent.ig -pubg published_millis(release, prefer_updated=True), ) + def test_preview_history_keeps_only_complete_signed_main_releases(self): + variants = [("com.droiddeck.launcher", ""), ("com.example.variant", "-variant")] + commit = "a" * 40 + entry = { + "commit": commit, + "title": "Fix Steam resume", + "summary": "Steam stays open after resuming.", + "publishedAt": 123, + "apks": { + package: {"sha256": "a" * 64, "signerSha256": "b" * 64} + for package, _ in variants + }, + } + release = {"tag_name": "main-aaaaaaa", "draft": False, "prerelease": True} + self.assertEqual( + { + "commit": commit, + "title": "Fix Steam resume", + "summary": "Steam stays open after resuming.", + "publishedAt": 123, + }, + preview_history_entry(release, entry, variants), + ) + + for invalid_release in ( + {**release, "draft": True}, + {**release, "prerelease": False}, + {**release, "tag_name": "pr-42"}, + ): + self.assertIsNone(preview_history_entry(invalid_release, entry, variants)) + self.assertIsNone(preview_history_entry(release, {**entry, "commit": "a" * 12}, variants)) + self.assertIsNone(preview_history_entry(release, {**entry, "apks": {"com.droiddeck.launcher": {}}}, variants)) + unsigned = {**entry, "apks": {package: {"sha256": "a" * 64} for package, _ in variants}} + self.assertIsNone(preview_history_entry(release, unsigned, variants)) + + def test_preview_history_is_newest_first_deduplicated_and_limited(self): + entries = [ + {"commit": f"{i:040x}", "title": str(i), "summary": "", "publishedAt": i} + for i in range(12) + ] + entries.append({**entries[-1], "title": "duplicate", "publishedAt": 99}) + recent = recent_previews(entries) + self.assertEqual(10, len(recent)) + self.assertEqual("duplicate", recent[0]["title"]) + self.assertEqual("000000000000000000000000000000000000000a", recent[1]["commit"]) + self.assertEqual(1, len([item for item in recent if item["commit"].endswith("000b")])) + def test_stable_is_the_newest_app_release_not_github_latest(self): def release(tag, at, *assets, **flags): return {"tag_name": tag, "published_at": at, "assets": [{"name": a} for a in assets], **flags} From 3f3751804f4efb9711ee1ee90d2f86be0de1f98d Mon Sep 17 00:00:00 2001 From: xXJsonDeruloXx Date: Sat, 3 Oct 2026 13:27:09 -0400 Subject: [PATCH 2/2] Move Preview history below the update action --- .../java/com/droiddeck/launcher/ui/UpdatesPage.kt | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/UpdatesPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/UpdatesPage.kt index a3f4e5285..14138e912 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/UpdatesPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/UpdatesPage.kt @@ -116,12 +116,12 @@ internal fun UpdatesPage(s: FrontEndState, a: FrontEndActions, modifier: Modifie StatusPanel(s, u, ua, me) Box(Modifier.height(18.dp)) ChannelLabel() - ChannelPicker(u, ua, me) + ChannelPicker(u, ua) } else { // The label sits over both columns, so the status card lines up with the first channel. ChannelLabel() Row(horizontalArrangement = Arrangement.spacedBy(18.dp)) { - Box(Modifier.weight(1f)) { ChannelPicker(u, ua, me) } + Box(Modifier.weight(1f)) { ChannelPicker(u, ua) } Box(Modifier.weight(1.15f)) { StatusPanel(s, u, ua, me) } } } @@ -202,6 +202,9 @@ private fun StatusPanel(s: FrontEndState, u: UpdatesState, ua: UpdatesActions, m if (s.sessionRunning && (offered || offer == Offer.AHEAD)) { Text(stringResource(R.string.upd_stop_session), fontSize = 12.5.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 8.dp)) } + if (u.follow.channel == Channel.NIGHTLY) { + PreviewHistory(catalog, me) + } } } @@ -260,7 +263,7 @@ private fun ChannelLabel() { } @Composable -private fun ChannelPicker(u: UpdatesState, ua: UpdatesActions, me: AppUpdates.Installed) { +private fun ChannelPicker(u: UpdatesState, ua: UpdatesActions) { val colors = MaterialTheme.colorScheme val catalog = u.catalog val tests = catalog?.tests.orEmpty() @@ -273,7 +276,6 @@ private fun ChannelPicker(u: UpdatesState, ua: UpdatesActions, me: AppUpdates.In Icons.Outlined.Bolt, stringResource(R.string.upd_preview), stringResource(R.string.upd_preview_hint), catalog?.preview?.let { ago(it.publishedAt) }, u.follow.channel == Channel.NIGHTLY, ) { ua.onFollow(Follow(Channel.NIGHTLY)) } - PreviewHistory(catalog, me) ChannelCard( Icons.Outlined.Science, stringResource(R.string.upd_tests), stringResource(R.string.upd_tests_hint), if (tests.isEmpty()) stringResource(R.string.upd_tests_none) else pluralStringResource(R.plurals.upd_tests_count, tests.size, tests.size), @@ -287,7 +289,7 @@ private fun ChannelPicker(u: UpdatesState, ua: UpdatesActions, me: AppUpdates.In } } -/** Compact, read-only history under Preview; only the latest Preview build remains installable. */ +/** Read-only Preview history below the status card action; only the latest build is installable. */ @Composable private fun PreviewHistory(catalog: AppUpdates.Catalog?, me: AppUpdates.Installed) { val history = catalog?.let { AppUpdates.previewHistory(it, me) } ?: return @@ -304,7 +306,7 @@ private fun PreviewHistory(catalog: AppUpdates.Catalog?, me: AppUpdates.Installe R.plurals.upd_preview_recent, history.changes.size, history.changes.size, ) } - Column(Modifier.fillMaxWidth().padding(start = 66.dp, end = 14.dp, bottom = 5.dp)) { + Column(Modifier.fillMaxWidth().padding(top = 18.dp)) { Text(heading, fontSize = 11.5.sp, fontWeight = FontWeight.Medium, color = colors.onSurfaceVariant) if (history.changes.isNotEmpty()) { val visible = if (expanded) history.changes else history.changes.take(3)