From a763e7d77a7848f787ee4c6525b57c46aa8923bd Mon Sep 17 00:00:00 2001 From: The412Banner Date: Sat, 10 Oct 2026 22:43:55 -0400 Subject: [PATCH 1/3] Update notice as a slide-in pill, highlights pop-up and download card The amber banner under the top bar is gone. When a newer release is found, a pill slides out of the right edge (icon, "Update available", version and size, Dismiss / View) and settles 10 dp short of the edge with the theme-accent outline the dialogs use. Dismiss slides it back off for this session only; the old Skip persisted the version for good and left Settings as the only way back. View slides it off and opens a pop-up with the release's quick highlights (five short lines, title plus one sentence, "Full notes" for the GitHub page), Later / Update now. Update now, from the pop-up, Settings or About, runs the download as a card over the home screen: progress, bytes of total, speed, time left, Cancel, and Retry / Release page on failure. UpdateManager publishes the download as a StateFlow and no longer calls the dialog-creating HttpUtils.download overload, so the first-launch setup screen can no longer appear during an update. HttpUtils gains downloadToFile(), a byte-level progress download with no UI of its own. update.json carries three new optional fields the app reads from this build on and older builds ignore: highlights (from comments in the notes file, else the first bullet list after the lead paragraph), size (bytes per flavor, for the pill and the download card) and publishedAt. release_notes.py writes highlights.json; release.yml and the notes check carry it through and print it in the job summary. Settings lists the highlights in place of the one-line notes when the release has them. --- .github/workflows/release-notes-check.yml | 2 + .github/workflows/release.yml | 16 +- .../java/com/winlator/star/MainActivity.kt | 93 ++--- .../com/winlator/star/core/HttpUtils.java | 61 +++ .../com/winlator/star/core/UpdateManager.kt | 95 ++++- .../java/com/winlator/star/ui/UpdateCards.kt | 356 ++++++++++++++++++ .../star/ui/screens/SettingsScreen.kt | 17 +- scripts/release_notes.py | 60 ++- 8 files changed, 643 insertions(+), 57 deletions(-) create mode 100644 app/src/main/java/com/winlator/star/ui/UpdateCards.kt diff --git a/.github/workflows/release-notes-check.yml b/.github/workflows/release-notes-check.yml index 1c96ee4f6..0a5a3b935 100644 --- a/.github/workflows/release-notes-check.yml +++ b/.github/workflows/release-notes-check.yml @@ -48,6 +48,7 @@ jobs: [ "$PRE" = "true" ] && ARGS+=(--prerelease) python3 scripts/release_notes.py "${ARGS[@]}" { echo "### In-app update line"; echo; cat short-notes.txt; echo; + echo "### In-app update highlights"; echo; echo '```json'; cat highlights.json; echo '```'; echo; echo "### Release description"; echo; cat release-body.md; } >> "$GITHUB_STEP_SUMMARY" - name: Upload it @@ -57,4 +58,5 @@ jobs: path: | release-body.md short-notes.txt + highlights.json retention-days: 7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9b9c87d58..63be39246 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -58,6 +58,7 @@ jobs: [ "${{ inputs.make_prerelease }}" = "true" ] && ARGS+=(--prerelease) python3 scripts/release_notes.py "${ARGS[@]}" { echo "### In-app update line"; echo; cat short-notes.txt; echo; + echo "### In-app update highlights"; echo; echo '```json'; cat highlights.json; echo '```'; echo; echo "### Release description"; echo; cat release-body.md; } >> "$GITHUB_STEP_SUMMARY" - name: Keep it for the publish job @@ -67,6 +68,7 @@ jobs: path: | release-body.md short-notes.txt + highlights.json retention-days: 7 # Phase 1 — build all 4 flavors in parallel (reusable matrix build). @@ -114,6 +116,9 @@ jobs: # prerelease=false below, only stable cuts ever change it. # The short line comes from the notes job (update-summary comment, the release_notes # input, or the notes' lead paragraph), read from a file so no quoting can break it. + # highlights (five short lines for the update pop-up), size (bytes per flavor, shown on + # the pill and the download card) and publishedAt are read by builds from 3.1.6 on; + # older builds ignore them. env: RELEASE_TITLE: ${{ inputs.release_title }} run: | @@ -121,16 +126,23 @@ jobs: N="${{ inputs.release_number }}" NOTES="$(cat notes/short-notes.txt)" [ -z "$NOTES" ] && NOTES="$RELEASE_TITLE" + [ -s notes/highlights.json ] || echo '[]' > notes/highlights.json + size() { stat -c %s "dist/Bannerlator-${N}-$1.apk" 2>/dev/null || echo 0; } jq -n \ --argjson vc "$VERSION_CODE" \ --arg vn "$VERSION" \ --arg notes "$NOTES" \ + --slurpfile hl notes/highlights.json \ + --arg date "$(date -u +%Y-%m-%d)" \ + --argjson sstd "$(size standard)" --argjson slud "$(size ludashi)" \ + --argjson spbg "$(size pubg)" --argjson satu "$(size antutu)" \ --arg std "Bannerlator-${N}-standard.apk" \ --arg lud "Bannerlator-${N}-ludashi.apk" \ --arg pbg "Bannerlator-${N}-pubg.apk" \ --arg atu "Bannerlator-${N}-antutu.apk" \ - '{versionCode:$vc, versionName:$vn, notes:$notes, minSupported:1, - apk:{"com.winlator.banner":$std,"com.ludashi.benchmark":$lud,"com.tencent.ig":$pbg,"com.antutu.ABenchMark":$atu}}' \ + '{versionCode:$vc, versionName:$vn, notes:$notes, highlights:$hl[0], publishedAt:$date, minSupported:1, + apk:{"com.winlator.banner":$std,"com.ludashi.benchmark":$lud,"com.tencent.ig":$pbg,"com.antutu.ABenchMark":$atu}, + size:{"com.winlator.banner":$sstd,"com.ludashi.benchmark":$slud,"com.tencent.ig":$spbg,"com.antutu.ABenchMark":$satu}}' \ > update.json echo "Generated update.json:"; cat update.json diff --git a/app/src/main/java/com/winlator/star/MainActivity.kt b/app/src/main/java/com/winlator/star/MainActivity.kt index 96ef8ae68..879197c42 100644 --- a/app/src/main/java/com/winlator/star/MainActivity.kt +++ b/app/src/main/java/com/winlator/star/MainActivity.kt @@ -643,18 +643,21 @@ private fun AppShell( val isBigPicture = currentRoute == Screen.BigPicture.route val isFullBleed = isBigPicture - // In-app update banner: only when a newer stable exists, notify is on, and - // this version wasn't skipped. - var bannerUpdate by remember { mutableStateOf(null) } - var bannerDismissed by remember { mutableStateOf(false) } + // In-app update pill: slides in from the right edge when a newer release exists, notify is on, + // and the user has not dismissed this version during this session. View slides it out, then the + // highlights dialog opens; Update now there (or in Settings / About) runs the download card. + var pillUpdate by remember { mutableStateOf(null) } + var pillVisible by remember { mutableStateOf(false) } + var highlightsFor by remember { mutableStateOf(null) } LaunchedEffect(Unit) { UpdateManager.check(context) { info -> (context as? MainActivity)?.runOnUiThread { if (info != null && info.isNewer && UpdateManager.isNotifyEnabled(context) && - info.versionCode != UpdateManager.skippedVersionCode(context) + info.versionCode != UpdateManager.dismissedThisSession ) { - bannerUpdate = info + pillUpdate = info + pillVisible = true } } } @@ -777,20 +780,8 @@ private fun AppShell( else -> innerPadding } CompositionLocalProvider(LocalTopBarOverlayInset provides if (barOverlay) innerPadding.calculateTopPadding() else 0.dp) { - Column(modifier = Modifier.padding(contentPadding)) { - val upd = bannerUpdate - if (upd != null && !bannerDismissed && !isFullBleed) { - UpdateBanner( - versionName = upd.versionName, - onUpdate = { - (context as? MainActivity)?.let { UpdateManager.downloadAndInstall(it, upd) {} } - }, - onDismiss = { - bannerDismissed = true - UpdateManager.skipVersion(context, upd.versionCode) - }, - ) - } + Box(modifier = Modifier.padding(contentPadding)) { + Column(modifier = Modifier.fillMaxSize()) { AppNavGraph( navController = navController, startRoute = startRoute, @@ -803,6 +794,29 @@ private fun AppShell( com.winlator.star.ui.UnpackProgressPill() } } + // Update pill: floats top-end over the screen content, 30dp under the top bar, 10dp + // short of the edge it slid in from. Hidden in Big Picture like the rest of the chrome. + val upd = pillUpdate + if (upd != null && !isFullBleed) { + com.winlator.star.ui.UpdatePill( + info = upd, + visible = pillVisible, + onDismiss = { + pillVisible = false + UpdateManager.dismissedThisSession = upd.versionCode + }, + onView = { + pillVisible = false + UpdateManager.dismissedThisSession = upd.versionCode + scope.launch { + kotlinx.coroutines.delay(com.winlator.star.ui.UPDATE_PILL_EXIT_MS) + highlightsFor = upd + } + }, + modifier = Modifier.align(Alignment.TopEnd).padding(top = 30.dp, end = 10.dp), + ) + } + } // end Box } // end LocalTopBarOverlayInset } } @@ -818,6 +832,19 @@ private fun AppShell( if (showAboutDialog) { AboutDialog(onDismiss = onDismissAboutDialog) } + + highlightsFor?.let { info -> + com.winlator.star.ui.UpdateHighlightsDialog( + info = info, + onLater = { highlightsFor = null }, + onUpdate = { + highlightsFor = null + (context as? MainActivity)?.let { UpdateManager.downloadAndInstall(it, info) {} } + }, + ) + } + // The download card, whichever button started the download (pill, highlights, Settings, About). + com.winlator.star.ui.UpdateHost() } @Composable @@ -836,32 +863,6 @@ private fun AllFilesAccessDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) { ) } -@Composable -private fun UpdateBanner(versionName: String, onUpdate: () -> Unit, onDismiss: () -> Unit) { - val ink = androidx.compose.ui.graphics.Color(0xFF1A1A2E) - Row( - modifier = Modifier - .fillMaxWidth() - .background(androidx.compose.ui.graphics.Color(0xFFFFC107)) - .padding(start = 12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "Update available — V $versionName", - color = ink, - fontWeight = FontWeight.SemiBold, - fontSize = 13.sp, - modifier = Modifier.weight(1f), - ) - TextButton(onClick = onUpdate) { - Text("Update", color = ink, fontWeight = FontWeight.Bold) - } - TextButton(onClick = onDismiss) { - Text("Skip", color = ink) - } - } -} - @Composable private fun AboutDialog(onDismiss: () -> Unit) { val context = LocalContext.current diff --git a/app/src/main/java/com/winlator/star/core/HttpUtils.java b/app/src/main/java/com/winlator/star/core/HttpUtils.java index 8a66c9905..719b3c19a 100644 --- a/app/src/main/java/com/winlator/star/core/HttpUtils.java +++ b/app/src/main/java/com/winlator/star/core/HttpUtils.java @@ -157,6 +157,67 @@ private static void downloadAsync(String url, File destination, AtomicBoolean in } } + /** Byte-level progress for {@link #downloadToFile}: {@code total} is -1 when the server sent no length. */ + public interface ProgressListener { + void onProgress(long bytes, long total); + } + + /** + * Download {@code url} to {@code destination} with no UI of its own. Progress is reported in + * bytes on the download thread; {@code onComplete} gets true only when the whole body was + * written and nobody set {@code interruptRef}. A failed or cancelled download leaves no + * partial file behind. Follows one more redirect by hand for the cases HttpURLConnection + * refuses to follow itself (a scheme change on the way to the asset host). + */ + public static void downloadToFile(final String url, final File destination, final AtomicBoolean interruptRef, + final ProgressListener onProgress, final Callback onComplete) { + Executors.newSingleThreadExecutor().execute(() -> { + boolean ok = false; + try { + interruptRef.set(false); + HttpURLConnection connection = (HttpURLConnection)(new URL(url)).openConnection(); + connection.setRequestProperty("User-Agent", "Bannerlator"); + int code = connection.getResponseCode(); + if (code / 100 == 3) { + String next = connection.getHeaderField("Location"); + connection.disconnect(); + if (next == null) { onComplete.call(false); return; } + connection = (HttpURLConnection)(new URL(next)).openConnection(); + connection.setRequestProperty("User-Agent", "Bannerlator"); + code = connection.getResponseCode(); + } + if (code != HttpURLConnection.HTTP_OK) { + onComplete.call(false); + return; + } + long total = connection.getContentLengthLong(); + try (InputStream inStream = new BufferedInputStream(connection.getInputStream(), StreamUtils.BUFFER_SIZE); + OutputStream outStream = new FileOutputStream(destination)) { + byte[] buffer = new byte[64 * 1024]; + long done = 0; + long lastReport = 0; + int n; + while ((n = inStream.read(buffer)) != -1 && !interruptRef.get()) { + outStream.write(buffer, 0, n); + done += n; + long now = System.currentTimeMillis(); + if (onProgress != null && (now - lastReport >= 100 || done == total)) { + lastReport = now; + onProgress.onProgress(done, total); + } + } + ok = !interruptRef.get() && (total < 0 || done == total); + if (ok && onProgress != null) onProgress.onProgress(done, total < 0 ? done : total); + } + } + catch (Exception e) { + ok = false; + } + if (!ok && destination.isFile()) destination.delete(); + onComplete.call(ok); + }); + } + public static void download(final Activity activity, final String url, final File destination, final Callback onDownloadComplete) { final DownloadProgressDialog dialog = new DownloadProgressDialog(activity); final AtomicBoolean interruptRef = new AtomicBoolean(); diff --git a/app/src/main/java/com/winlator/star/core/UpdateManager.kt b/app/src/main/java/com/winlator/star/core/UpdateManager.kt index 86a7fa2ae..c7799fe18 100644 --- a/app/src/main/java/com/winlator/star/core/UpdateManager.kt +++ b/app/src/main/java/com/winlator/star/core/UpdateManager.kt @@ -9,8 +9,11 @@ import android.provider.Settings import androidx.core.content.FileProvider import androidx.preference.PreferenceManager import com.winlator.star.BuildConfig +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import org.json.JSONObject import java.io.File +import java.util.concurrent.atomic.AtomicBoolean /** * In-app updater for Bannerlator. @@ -25,6 +28,9 @@ import java.io.File * "versionCode": 26, * "versionName": "1.8", * "notes": "What changed…", + * "highlights": [ {"title": "Small updates", "text": "…"} ], // optional, up to 5 + * "size": { "com.winlator.banner": 557000000, … }, // optional, bytes per flavor + * "publishedAt": "2026-10-10", // optional * "minSupported": 1, * "apk": { * "com.winlator.banner": "Bannerlator-1.8-standard.apk", @@ -57,6 +63,9 @@ object UpdateManager { private const val PREF_INCLUDE_PRE = "update_include_prereleases" private const val CACHE_NAME = "update_latest.json" + /** One line of the release's quick highlights: a short bold title and one plain sentence. */ + data class Highlight(val title: String, val text: String) + data class UpdateInfo( val versionCode: Int, val versionName: String, @@ -64,11 +73,46 @@ object UpdateManager { val apkName: String?, /** Resolved download URL for this flavor's APK on the matched release. */ val apkUrl: String?, + /** Up to five quick highlights from the release notes; empty on releases cut before they existed. */ + val highlights: List = emptyList(), + /** Download size of this flavor's APK in bytes, or -1 when the release did not say. */ + val apkSize: Long = -1L, + /** Release date as published, "2026-10-10", or "" when the release did not say. */ + val publishedAt: String = "", ) { /** True when the released build is newer than the installed one. */ val isNewer: Boolean get() = versionCode > BuildConfig.VERSION_CODE } + // ── Session state the update UI observes ───────────────────────────── + /** + * Version the user dismissed with the pill this session. Unlike the old banner's Skip it is + * not persisted: the pill comes back next launch, and Settings keeps offering the update. + */ + @Volatile var dismissedThisSession: Int = 0 + + /** Progress of the one update download that can run at a time; the UI renders it as a card. */ + sealed class DownloadState { + object Idle : DownloadState() + data class Downloading(val info: UpdateInfo, val bytes: Long, val total: Long, val bytesPerSec: Long) : DownloadState() + data class Failed(val info: UpdateInfo, val reason: String) : DownloadState() + /** Handed to the system installer; the card closes. */ + data class Installing(val info: UpdateInfo) : DownloadState() + } + private val _download = MutableStateFlow(DownloadState.Idle) + val download: StateFlow get() = _download + private val interrupt = AtomicBoolean() + + fun cancelDownload() { + interrupt.set(true) + _download.value = DownloadState.Idle + } + + /** Clear a Failed or Installing card. */ + fun clearDownloadState() { + if (_download.value !is DownloadState.Downloading) _download.value = DownloadState.Idle + } + private fun prefs(ctx: Context) = PreferenceManager.getDefaultSharedPreferences(ctx) // ── Preferences ────────────────────────────────────────────────────── @@ -193,12 +237,24 @@ object UpdateManager { val apkName = o.optJSONObject("apk") ?.optString(BuildConfig.APPLICATION_ID, null) ?.takeIf { it.isNotBlank() } + val highlights = ArrayList() + o.optJSONArray("highlights")?.let { arr -> + for (i in 0 until minOf(arr.length(), 5)) { + val h = arr.optJSONObject(i) ?: continue + val title = h.optString("title", "").trim() + val text = h.optString("text", "").trim() + if (title.isNotEmpty() || text.isNotEmpty()) highlights.add(Highlight(title, text)) + } + } UpdateInfo( versionCode = o.getInt("versionCode"), versionName = o.optString("versionName", ""), notes = o.optString("notes", ""), apkName = apkName, apkUrl = apkName?.let(resolveApk), + highlights = highlights, + apkSize = o.optJSONObject("size")?.optLong(BuildConfig.APPLICATION_ID, -1L) ?: -1L, + publishedAt = o.optString("publishedAt", ""), ) } catch (_: Exception) { null @@ -234,10 +290,11 @@ object UpdateManager { // ── Download + install ─────────────────────────────────────────────── /** - * Downloads the flavor-matched APK (shows the app's standard - * [DownloadProgressDialog]) and launches the package installer. - * If the "install unknown apps" grant is missing, routes the user to that - * settings screen first and returns without downloading. + * Downloads the flavor-matched APK and launches the package installer. Progress is published + * on [download]; the app shell renders it as the update card. This deliberately does NOT use + * the dialog-creating HttpUtils.download overload, so the first-launch setup screen can never + * appear during an update. If the "install unknown apps" grant is missing, routes the user to + * that settings screen first and returns without downloading. */ fun downloadAndInstall(activity: Activity, info: UpdateInfo, onDone: (Boolean) -> Unit) { val url = info.apkUrl @@ -254,6 +311,7 @@ object UpdateManager { onDone(false) return } + if (_download.value is DownloadState.Downloading) return // one at a time; the card is already up val dir = File(activity.externalCacheDir, "update").apply { mkdirs() } // Prune any previously-downloaded installers before fetching the new one. // The installer filename embeds the version, so without this every update @@ -261,9 +319,32 @@ object UpdateManager { // until the OS cache-clears — one user hit ~10 GB of stale APKs this way. pruneUpdateDir(dir) val apk = File(dir, info.apkName ?: "Bannerlator-update.apk") - HttpUtils.download(activity, url, apk) { ok -> - if (ok) install(activity, apk) else AppUtils.showToast(activity, "Update download failed") - onDone(ok) + _download.value = DownloadState.Downloading(info, 0L, info.apkSize, 0L) + val startedAt = System.currentTimeMillis() + var lastAt = startedAt + var lastBytes = 0L + var rate = 0L + HttpUtils.downloadToFile(url, apk, interrupt, { bytes, total -> + val now = System.currentTimeMillis() + val dt = now - lastAt + if (dt >= 500) { + val inst = (bytes - lastBytes) * 1000 / dt + // Smooth the readout so it does not flicker with every burst. + rate = if (rate == 0L) inst else (rate * 3 + inst) / 4 + lastAt = now; lastBytes = bytes + } + if (_download.value is DownloadState.Downloading) { + _download.value = DownloadState.Downloading(info, bytes, if (total > 0) total else info.apkSize, rate) + } + }) { ok -> + activity.runOnUiThread { + when { + interrupt.get() -> _download.value = DownloadState.Idle + ok -> { _download.value = DownloadState.Installing(info); install(activity, apk) } + else -> _download.value = DownloadState.Failed(info, "The download did not finish. Check the connection and try again.") + } + onDone(ok && !interrupt.get()) + } } } diff --git a/app/src/main/java/com/winlator/star/ui/UpdateCards.kt b/app/src/main/java/com/winlator/star/ui/UpdateCards.kt new file mode 100644 index 000000000..b95493d46 --- /dev/null +++ b/app/src/main/java/com/winlator/star/ui/UpdateCards.kt @@ -0,0 +1,356 @@ +package com.winlator.star.ui + +import android.app.Activity +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.SystemUpdate +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.winlator.star.BuildConfig +import com.winlator.star.core.StringUtils +import com.winlator.star.core.UpdateManager +import com.winlator.star.core.UpdateManager.DownloadState +import com.winlator.star.core.UpdateManager.UpdateInfo +import java.util.Locale + +/* + * The in-app update UI, three pieces that share one look (rounded, accent outline, raised card): + * + * 1. [UpdatePill] — slides out of the right edge under the top bar when a newer + * release is found. "Update available", Dismiss / View. + * 2. [UpdateHighlightsDialog] — the quick highlights of the release (five short lines, not the + * GitHub page). Later / Update now. + * 3. [UpdateDownloadCard] — progress with size, speed and time left, Cancel; Retry on failure. + * Driven by [UpdateManager.download], hosted once by [UpdateHost]. + * + * The download card replaces the first-launch setup screen that updates used to borrow from + * HttpUtils.download(activity, …); UpdateManager no longer calls that overload at all. + */ + +/** Green = install / safe action, the same convention as the Settings and About update buttons. */ +private val UpdateGreen = Color(0xFF4CAF50) +private const val SLIDE_MS = 380 + +/** How long the pill takes to leave the screen; callers wait this long before opening the dialog. */ +const val UPDATE_PILL_EXIT_MS = SLIDE_MS.toLong() + +private fun fmtSize(bytes: Long): String = when { + bytes <= 0 -> "" + bytes >= 1L shl 30 -> String.format(Locale.ENGLISH, "%.2f GB", bytes / (1024.0 * 1024 * 1024)) + bytes >= 1L shl 20 -> String.format(Locale.ENGLISH, "%.1f MB", bytes / (1024.0 * 1024)) + else -> String.format(Locale.ENGLISH, "%.0f KB", bytes / 1024.0) +} + +@Composable +private fun accentBorder(width: Float = 1f) = BorderStroke(width.dp, MaterialTheme.colorScheme.primary) + +// ── 1. Pill ──────────────────────────────────────────────────────────────────────────────── + +/** + * Slide-in notice that a newer release exists. Place it top-end over the screen content; it + * animates in from past the right edge and out the same way. [visible] is owned by the caller + * so Dismiss and View can both slide it out before acting. + */ +@Composable +fun UpdatePill( + info: UpdateInfo, + visible: Boolean, + onDismiss: () -> Unit, + onView: () -> Unit, + modifier: Modifier = Modifier, +) { + AnimatedVisibility( + visible = visible, + modifier = modifier, + enter = slideInHorizontally(tween(SLIDE_MS)) { it + 48 } + fadeIn(tween(SLIDE_MS / 2)), + exit = slideOutHorizontally(tween(SLIDE_MS)) { it + 48 } + fadeOut(tween(SLIDE_MS / 2)), + ) { + Surface( + shape = RoundedCornerShape(26.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + border = accentBorder(1.5f), + shadowElevation = 8.dp, + ) { + Row( + modifier = Modifier.height(52.dp).padding(start = 8.dp, end = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Box( + modifier = Modifier.size(36.dp).background(MaterialTheme.colorScheme.primary.copy(alpha = 0.18f), CircleShape), + contentAlignment = Alignment.Center, + ) { + Icon(Icons.Filled.SystemUpdate, null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp)) + } + Column { + Text("Update available", color = MaterialTheme.colorScheme.onSurface, fontSize = 12.5.sp, fontWeight = FontWeight.Bold, maxLines = 1) + val size = fmtSize(info.apkSize) + Text( + "V ${info.versionName}" + (if (size.isNotEmpty()) " · $size" else ""), + color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 10.5.sp, maxLines = 1, + ) + } + PillButton("Dismiss", MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.colorScheme.onSurface, onDismiss) + PillButton("View", UpdateGreen, Color.White, onView) + } + } + } +} + +@Composable +private fun PillButton(label: String, bg: Color, fg: Color, onClick: () -> Unit) { + Button( + onClick = onClick, + colors = ButtonDefaults.buttonColors(containerColor = bg, contentColor = fg), + contentPadding = PaddingValues(horizontal = 11.dp, vertical = 0.dp), + modifier = Modifier.height(30.dp), + shape = RoundedCornerShape(15.dp), + ) { Text(label, fontSize = 11.5.sp, fontWeight = FontWeight.SemiBold) } +} + +// ── 2. Highlights ────────────────────────────────────────────────────────────────────────── + +/** + * What's new, as the release's quick highlights (title + one sentence each, five at most). + * Releases cut before highlights existed show their one-line notes instead. + */ +@Composable +fun UpdateHighlightsDialog(info: UpdateInfo, onLater: () -> Unit, onUpdate: () -> Unit) { + val context = LocalContext.current + Dialog(onDismissRequest = onLater) { + UpdateCardSurface { + Column(modifier = Modifier.verticalScroll(rememberScrollState())) { + CardHeader( + title = "What's new in ${info.versionName}", + subtitle = listOf( + "V ${BuildConfig.VERSION_NAME} → V ${info.versionName}", + fmtSize(info.apkSize), + info.publishedAt, + ).filter { it.isNotEmpty() }.joinToString(" · "), + ) + Spacer(Modifier.height(14.dp)) + Text( + "HIGHLIGHTS", color = MaterialTheme.colorScheme.primary, fontSize = 11.sp, + fontWeight = FontWeight.Bold, letterSpacing = 0.6.sp, + ) + Spacer(Modifier.height(8.dp)) + if (info.highlights.isNotEmpty()) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + info.highlights.forEach { h -> + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Box( + modifier = Modifier.padding(top = 5.dp).size(7.dp) + .background(MaterialTheme.colorScheme.primary, CircleShape), + ) + Column { + if (h.title.isNotEmpty()) { + Text(h.title, color = MaterialTheme.colorScheme.onSurface, fontSize = 13.sp, fontWeight = FontWeight.SemiBold) + } + if (h.text.isNotEmpty()) { + Text(h.text, color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 12.5.sp, lineHeight = 17.sp) + } + } + } + } + } + } else { + Text( + info.notes.ifBlank { "Bug fixes and improvements." }, + color = MaterialTheme.colorScheme.onSurface, fontSize = 12.5.sp, lineHeight = 17.sp, + ) + } + Spacer(Modifier.height(14.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + "Your games, containers and settings are kept.", + color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 11.sp, + modifier = Modifier.weight(1f), + ) + TextButton( + onClick = { (context as? Activity)?.let { UpdateManager.openReleasesPage(it) } }, + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp), + modifier = Modifier.height(28.dp), + ) { Text("Full notes ↗", fontSize = 11.5.sp, color = MaterialTheme.colorScheme.primary) } + } + Spacer(Modifier.height(10.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + CardButton("Later", MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.colorScheme.onSurface, onLater, Modifier.weight(1f)) + CardButton("Update now", UpdateGreen, Color.White, onUpdate, Modifier.weight(1f)) + } + } + } + } +} + +// ── 3. Download ──────────────────────────────────────────────────────────────────────────── + +/** + * Drop this once into the app shell. It renders the download card whenever an update download is + * running or has failed, whichever button started it (pill, highlights, Settings or About). + */ +@Composable +fun UpdateHost() { + val context = LocalContext.current + val state by UpdateManager.download.collectAsState() + when (val s = state) { + is DownloadState.Downloading -> UpdateDownloadCard(s) + is DownloadState.Failed -> UpdateFailedCard( + s, + onRetry = { + UpdateManager.clearDownloadState() + (context as? Activity)?.let { UpdateManager.downloadAndInstall(it, s.info) {} } + }, + onReleasePage = { + UpdateManager.clearDownloadState() + (context as? Activity)?.let { UpdateManager.openReleasesPage(it) } + }, + onClose = { UpdateManager.clearDownloadState() }, + ) + else -> {} + } +} + +@Composable +private fun UpdateDownloadCard(s: DownloadState.Downloading) { + Dialog( + onDismissRequest = {}, + properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false), + ) { + UpdateCardSurface { + Column { + CardHeader(title = "Updating to V ${s.info.versionName}", subtitle = "Downloading the update…") + Spacer(Modifier.height(14.dp)) + val known = s.total > 0 + if (known) { + LinearProgressIndicator( + progress = { (s.bytes.toFloat() / s.total).coerceIn(0f, 1f) }, + modifier = Modifier.fillMaxWidth().height(6.dp), + color = UpdateGreen, + trackColor = MaterialTheme.colorScheme.surfaceContainerHighest, + ) + } else { + LinearProgressIndicator( + modifier = Modifier.fillMaxWidth().height(6.dp), + color = UpdateGreen, + trackColor = MaterialTheme.colorScheme.surfaceContainerHighest, + ) + } + Spacer(Modifier.height(6.dp)) + Row { + val done = fmtSize(s.bytes).ifEmpty { "0 MB" } + val left = if (known) " of ${fmtSize(s.total)}" else "" + val speed = if (s.bytesPerSec > 0) " · ${fmtSize(s.bytesPerSec)}/s" else "" + Text(done + left + speed, color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 11.sp, modifier = Modifier.weight(1f)) + if (known && s.bytesPerSec > 0) { + val remainMs = (s.total - s.bytes) * 1000 / s.bytesPerSec + Text("about ${StringUtils.humanDuration(remainMs)} left", color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 11.sp) + } + } + Spacer(Modifier.height(12.dp)) + Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth()) { + CardButton("Cancel", MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.colorScheme.onSurface, { UpdateManager.cancelDownload() }) + } + } + } + } +} + +@Composable +private fun UpdateFailedCard(s: DownloadState.Failed, onRetry: () -> Unit, onReleasePage: () -> Unit, onClose: () -> Unit) { + Dialog(onDismissRequest = onClose) { + UpdateCardSurface { + Column { + CardHeader(title = "Update to V ${s.info.versionName} failed", subtitle = s.reason, tint = MaterialTheme.colorScheme.error) + Spacer(Modifier.height(14.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + CardButton("Release page", MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.colorScheme.onSurface, onReleasePage, Modifier.weight(1f)) + CardButton("Retry", UpdateGreen, Color.White, onRetry, Modifier.weight(1f)) + } + } + } + } +} + +// ── shared pieces ────────────────────────────────────────────────────────────────────────── + +@Composable +private fun UpdateCardSurface(content: @Composable () -> Unit) { + Surface( + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + modifier = Modifier.fillMaxWidth().border(1.dp, MaterialTheme.colorScheme.primary, RoundedCornerShape(16.dp)), + ) { + Box(modifier = Modifier.padding(16.dp)) { content() } + } +} + +@Composable +private fun CardHeader(title: String, subtitle: String, tint: Color = MaterialTheme.colorScheme.primary) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Box( + modifier = Modifier.size(40.dp).background(tint.copy(alpha = 0.18f), RoundedCornerShape(10.dp)), + contentAlignment = Alignment.Center, + ) { + Icon(Icons.Filled.SystemUpdate, null, tint = tint, modifier = Modifier.size(22.dp)) + } + Column(modifier = Modifier.weight(1f)) { + Text(title, color = MaterialTheme.colorScheme.onSurface, fontSize = 15.sp, fontWeight = FontWeight.Bold, lineHeight = 18.sp) + if (subtitle.isNotEmpty()) { + Text(subtitle, color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 12.sp, lineHeight = 16.sp, maxLines = 3, overflow = TextOverflow.Ellipsis) + } + } + } +} + +@Composable +private fun CardButton(label: String, bg: Color, fg: Color, onClick: () -> Unit, modifier: Modifier = Modifier) { + Button( + onClick = onClick, + colors = ButtonDefaults.buttonColors(containerColor = bg, contentColor = fg), + shape = RoundedCornerShape(18.dp), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 0.dp), + modifier = modifier.heightIn(min = 36.dp).height(36.dp), + ) { Text(label, fontSize = 13.sp, fontWeight = FontWeight.SemiBold) } +} diff --git a/app/src/main/java/com/winlator/star/ui/screens/SettingsScreen.kt b/app/src/main/java/com/winlator/star/ui/screens/SettingsScreen.kt index 7fa644f9d..b45e98699 100644 --- a/app/src/main/java/com/winlator/star/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/com/winlator/star/ui/screens/SettingsScreen.kt @@ -563,7 +563,22 @@ fun SettingsScreen(onSaved: () -> Unit = {}) { "Update available", color = Color(0xFFFFC107), fontSize = 13.sp, // intentional: amber = update-available status, semantic not themeable fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(bottom = 4.dp) ) - if (latest.notes.isNotBlank()) { + if (latest.highlights.isNotEmpty()) { + // The release's quick highlights (same list as the update pop-up). + Column(verticalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.padding(bottom = 6.dp)) { + latest.highlights.forEach { h -> + Text( + buildString { + append("• ") + if (h.title.isNotEmpty()) append(h.title) + if (h.title.isNotEmpty() && h.text.isNotEmpty()) append(": ") + append(h.text) + }, + color = MaterialTheme.colorScheme.onSurface, fontSize = 12.sp, + ) + } + } + } else if (latest.notes.isNotBlank()) { Text( latest.notes, color = MaterialTheme.colorScheme.onSurface, fontSize = 12.sp, modifier = Modifier.padding(bottom = 6.dp) diff --git a/scripts/release_notes.py b/scripts/release_notes.py index 29be74cfb..77443cc32 100644 --- a/scripts/release_notes.py +++ b/scripts/release_notes.py @@ -19,8 +19,14 @@ The update.json line comes from an optional comment in the notes file, else --fallback, else the notes' bold lead paragraph (plain text, trimmed). + +highlights.json (the quick highlights the in-app update pop-up lists, five at most) comes from + comments when the notes carry any, else from the +first bullet list after the lead paragraph: each "- **Title:** sentence" bullet becomes one +highlight (plain text, links and markup stripped). Pre-releases without a notes file get none. """ import argparse +import json import os import re import subprocess @@ -138,10 +144,54 @@ def prev_nonblank(i): def plain(markdown): text = re.sub(r"\[([^\]]+)\]\([^)]*\)", r"\1", markdown) text = re.sub(r"<[^>]+>", "", text) - text = re.sub(r"[*_`]", "", text) + # Markup only: emphasis markers and code ticks, never the underscores inside a word (x86_64). + text = re.sub(r"[*`]", "", text) + text = re.sub(r"(?", re.S) +BULLET = re.compile(r"^\s*[-*]\s+(.*)$") +BOLD_LEAD = re.compile(r"^\*\*(.+?)\*\*\s*[:\u2014\u2013-]?\s*(.*)$", re.S) + + +def split_highlight(text): + """'**Title:** rest' -> (Title, rest); 'Title | rest' -> (Title, rest); else ('', text).""" + m = BOLD_LEAD.match(text.strip()) + if m: + return plain(m.group(1)).rstrip(":").strip(), plain(m.group(2)) + if "|" in text: + title, rest = text.split("|", 1) + return plain(title), plain(rest) + return "", plain(text) + + +def highlights(body): + items = [split_highlight(m) for m in HIGHLIGHT_COMMENT.findall(body)] + if not items: + # The first bullet list after the bold lead paragraph, before any heading. + seen_lead = False + for para in re.split(r"\n\s*\n", body): + p = para.strip() + if not seen_lead: + seen_lead = p.startswith("**") + continue + if p.startswith("#"): + break + lines = [BULLET.match(l) for l in p.splitlines()] + if lines and all(lines): + items = [split_highlight(m.group(1)) for m in lines] + break + out = [] + for title, text in items: + if text: + text = text[0].upper() + text[1:] + if title or text: + out.append({"title": title, "text": text}) + return out[:MAX_HIGHLIGHTS] + + def short_notes(body, fallback, title): m = re.search(r"", body, re.S) if m: @@ -168,6 +218,7 @@ def main(): ap.add_argument("--title", default="") ap.add_argument("--out-body", default="release-body.md") ap.add_argument("--out-short", default="short-notes.txt") + ap.add_argument("--out-highlights", default="highlights.json") a = ap.parse_args() ver = a.release_number @@ -210,6 +261,13 @@ def main(): with open(a.out_short, "w", encoding="utf-8") as f: f.write(short + "\n") print("update.json notes: " + short) + hl = highlights(body) if os.path.isfile(notes_path) else [] + with open(a.out_highlights, "w", encoding="utf-8") as f: + json.dump(hl, f, ensure_ascii=False, indent=2) + f.write("\n") + print("update.json highlights: %d" % len(hl)) + for h in hl: + print(" - %s%s%s" % (h["title"], ": " if h["title"] and h["text"] else "", h["text"])) if __name__ == "__main__": From b02d58e5e0bfba4c24d6712fdd16c6ae71ed0574 Mon Sep 17 00:00:00 2001 From: The412Banner Date: Sat, 10 Oct 2026 22:50:16 -0400 Subject: [PATCH 2/3] Drop the banner's hold on the see-through top bar; the pill floats over the screen --- app/src/main/java/com/winlator/star/MainActivity.kt | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/winlator/star/MainActivity.kt b/app/src/main/java/com/winlator/star/MainActivity.kt index 879197c42..5673ed820 100644 --- a/app/src/main/java/com/winlator/star/MainActivity.kt +++ b/app/src/main/java/com/winlator/star/MainActivity.kt @@ -700,10 +700,9 @@ private fun AppShell( } // The Games tab's XMB view asks for a see-through top bar; the screen is then laid out under the - // bar so its backdrop runs to the top. Games route only, and not while the update banner shows - // (it sits between the bar and the screen). - val barOverlay = currentRoute == Screen.Games.route && topBarTransparentState.value && - !isFullBleed && !(bannerUpdate != null && !bannerDismissed) + // bar so its backdrop runs to the top. Games route only. (The update pill floats over the screen + // content, so unlike the old banner it never has to switch this off.) + val barOverlay = currentRoute == Screen.Games.route && topBarTransparentState.value && !isFullBleed CompositionLocalProvider(LocalTopBarActions provides topBarActionsState, LocalTopBarTransparent provides topBarTransparentState) { ModalNavigationDrawer( From d96ad95193f059699fda193530a1b7d873db3bdf Mon Sep 17 00:00:00 2001 From: The412Banner Date: Sat, 10 Oct 2026 23:39:35 -0400 Subject: [PATCH 3/3] Updater: 6 h check cooldown and raw manifest branch, no more counted polls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit update.json was read from the release-asset URL, so GitHub counted every in-app check as a download of the release: 846,893 of the repo's 992,611 "downloads" measured on 2026-09-18 were polling, and the checks fired on every launch, every Settings open and every About dialog with no throttle. App: check(ctx, force) answers automatic checks from the cache for six hours after the last successful one; only the Settings button and the prerelease toggle force a network check. The cache is per channel, so flipping the toggle can no longer serve the other channel's answer. The stable channel reads raw.githubusercontent.com/…/manifest/update.json and the prerelease channel …/manifest/update-pre.json, which count nothing and have no per-IP API limit; each falls back to the old path (release asset / GitHub API walk) until the branch exists. Manifests may carry an absolute apkUrl map, preferred over resolving the apk name against releases/latest, which also fixes a cached prerelease resolving its APK against the wrong release. CI: update.json gains the apkUrl map; after the release is created, release.yml commits update-pre.json (every cut) and update.json (stable cuts) to the orphan manifest branch. The release asset stays attached for installs older than this code. --- .github/workflows/release.yml | 37 +++++++ .../com/winlator/star/core/UpdateManager.kt | 102 ++++++++++++++---- .../star/ui/screens/SettingsScreen.kt | 4 +- 3 files changed, 120 insertions(+), 23 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 63be39246..8812ce2fc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -140,8 +140,11 @@ jobs: --arg lud "Bannerlator-${N}-ludashi.apk" \ --arg pbg "Bannerlator-${N}-pubg.apk" \ --arg atu "Bannerlator-${N}-antutu.apk" \ + --arg base "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/download/${{ inputs.release_tag }}" \ '{versionCode:$vc, versionName:$vn, notes:$notes, highlights:$hl[0], publishedAt:$date, minSupported:1, apk:{"com.winlator.banner":$std,"com.ludashi.benchmark":$lud,"com.tencent.ig":$pbg,"com.antutu.ABenchMark":$atu}, + apkUrl:{"com.winlator.banner":($base+"/"+$std),"com.ludashi.benchmark":($base+"/"+$lud), + "com.tencent.ig":($base+"/"+$pbg),"com.antutu.ABenchMark":($base+"/"+$atu)}, size:{"com.winlator.banner":$sstd,"com.ludashi.benchmark":$slud,"com.tencent.ig":$spbg,"com.antutu.ABenchMark":$satu}}' \ > update.json echo "Generated update.json:"; cat update.json @@ -165,3 +168,37 @@ jobs: # Only a stable cut becomes "latest"; pre-releases never do, so the # default (toggle-off) updater keeps pointing at the last stable. make_latest: ${{ inputs.make_prerelease != 'true' }} + + - name: Publish the manifest branch + # The app (3.1.6 on) reads update.json / update-pre.json raw from the orphan `manifest` + # branch: no download counter (the release asset above counted every check as a download) + # and no API rate limit. update-pre.json is written on every cut, update.json on stable + # cuts only, so the prerelease toggle and the stable channel keep their meaning. The + # release asset stays attached for installs older than this code. Runs after the release + # exists, so the absolute apkUrl entries never point at a release that is not there yet. + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ inputs.release_tag }} + PRE: ${{ inputs.make_prerelease }} + run: | + set -e + mkdir manifest-wt && cd manifest-wt + git init -q + git config user.name "The412Banner" + git config user.email "the412banner@users.noreply.github.com" + git remote add origin "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" + if git fetch -q --depth 1 origin manifest 2>/dev/null; then + git checkout -q -b manifest FETCH_HEAD + else + git checkout -q --orphan manifest + printf '%s\n' "# Bannerlator update manifests" "" \ + "Written by release.yml on every cut; read raw by the in-app updater." \ + "- update.json: the latest STABLE release" \ + "- update-pre.json: the latest release of any kind (prerelease channel)" > README.md + fi + cp ../update.json update-pre.json + [ "$PRE" = "true" ] || cp ../update.json update.json + git add -A + git commit -q -m "manifest: ${TAG}" || { echo "manifest unchanged"; exit 0; } + git push -q origin manifest + echo "manifest branch updated for ${TAG}" diff --git a/app/src/main/java/com/winlator/star/core/UpdateManager.kt b/app/src/main/java/com/winlator/star/core/UpdateManager.kt index c7799fe18..87c776929 100644 --- a/app/src/main/java/com/winlator/star/core/UpdateManager.kt +++ b/app/src/main/java/com/winlator/star/core/UpdateManager.kt @@ -32,6 +32,7 @@ import java.util.concurrent.atomic.AtomicBoolean * "size": { "com.winlator.banner": 557000000, … }, // optional, bytes per flavor * "publishedAt": "2026-10-10", // optional * "minSupported": 1, + * "apkUrl": { "com.winlator.banner": "https://github.com/…/releases/download/1.8/Bannerlator-1.8-standard.apk", … }, // optional, absolute * "apk": { * "com.winlator.banner": "Bannerlator-1.8-standard.apk", * "com.ludashi.benchmark": "Bannerlator-1.8-ludashi.apk", @@ -43,6 +44,15 @@ import java.util.concurrent.atomic.AtomicBoolean object UpdateManager { private const val REPO = "The412Banner/Bannerlator" const val RELEASES_PAGE = "https://github.com/$REPO/releases/latest" + // The manifests live on the orphan `manifest` branch, served raw: reading them there counts + // nothing (the release-asset URL below bumped update.json's download_count on every check, + // which was 85% of the repo's "downloads") and has no per-IP API limit. release.yml writes + // update.json on stable cuts and update-pre.json on every cut. + private const val MANIFEST_URL = + "https://raw.githubusercontent.com/$REPO/manifest/update.json" + private const val MANIFEST_PRE_URL = + "https://raw.githubusercontent.com/$REPO/manifest/update-pre.json" + // Fallbacks for before the manifest branch exists (first release from this code publishes it). private const val UPDATE_JSON_URL = "https://github.com/$REPO/releases/latest/download/update.json" private fun assetUrl(name: String) = @@ -50,6 +60,8 @@ object UpdateManager { // Lists ALL releases newest-first, prereleases included (releases/latest skips them). private const val API_RELEASES_URL = "https://api.github.com/repos/$REPO/releases?per_page=30" + /** Automatic checks (launch, About, opening Settings) reuse the last answer this long. */ + private const val CHECK_COOLDOWN_MS = 6L * 60 * 60 * 1000 // Reuse the FileProvider already declared in the manifest. The authority is keyed // to the per-flavor applicationId (${applicationId}.tileprovider) so the standard, @@ -61,7 +73,10 @@ object UpdateManager { private const val PREF_SKIP = "update_skip_version" private const val PREF_LAST_CHECK = "update_last_check" private const val PREF_INCLUDE_PRE = "update_include_prereleases" + // One cache per channel: stable and prerelease used to share a file, so flipping the toggle + // could serve the other channel's answer offline. private const val CACHE_NAME = "update_latest.json" + private const val CACHE_PRE_NAME = "update_latest_pre.json" /** One line of the release's quick highlights: a short bold title and one plain sentence. */ data class Highlight(val title: String, val text: String) @@ -135,21 +150,56 @@ object UpdateManager { // ── Network check ──────────────────────────────────────────────────── /** - * Fetch the latest stable metadata off the main thread. [onResult] is + * Fetch the latest release metadata off the main thread. [onResult] is * invoked on a background thread (callers must marshal to the UI thread). - * On network failure, falls back to the last cached result so the banner - * still works offline. + * Automatic checks ([force] = false: launch, About, opening Settings) reuse + * the cached answer for [CHECK_COOLDOWN_MS] after a successful check, so an + * app opened twenty times a day asks GitHub once or twice. On network + * failure, falls back to the last cached result so the pill still works offline. */ - fun check(ctx: Context, onResult: (UpdateInfo?) -> Unit) { - if (isIncludePrereleases(ctx)) checkViaApi(ctx, onResult) - else checkStable(ctx, onResult) + fun check(ctx: Context, force: Boolean = false, onResult: (UpdateInfo?) -> Unit) { + val pre = isIncludePrereleases(ctx) + if (!force) { + // Cooldown: the last successful check is fresh enough, answer from the cache without + // touching the network. Only the Settings button and the prerelease toggle force. + val age = System.currentTimeMillis() - lastCheck(ctx) + if (age in 0 until CHECK_COOLDOWN_MS) { + val cached = loadCached(ctx, pre) + if (cached != null) { + onResult(cached) + return + } + } + } + if (pre) checkPre(ctx, onResult) else checkStable(ctx, onResult) } - /** Stable-only path: releases/latest only ever resolves to a non-prerelease. */ + /** Stable channel: the raw manifest, else the release asset until the manifest branch exists. */ private fun checkStable(ctx: Context, onResult: (UpdateInfo?) -> Unit) { - HttpUtils.download(UPDATE_JSON_URL) { body -> - val info = body?.let { parseUpdateJson(it) { n -> assetUrl(n) } } - finish(ctx, body, info, onResult) + HttpUtils.download(MANIFEST_URL) { raw -> + val fromRaw = raw?.let { parseUpdateJson(it) { n -> assetUrl(n) } } + if (fromRaw != null) { + finish(ctx, raw, fromRaw, false, onResult) + return@download + } + HttpUtils.download(UPDATE_JSON_URL) { body -> + val info = body?.let { parseUpdateJson(it) { n -> assetUrl(n) } } + finish(ctx, body, info, false, onResult) + } + } + } + + /** Prerelease channel: the raw every-cut manifest, else the GitHub API walk. */ + private fun checkPre(ctx: Context, onResult: (UpdateInfo?) -> Unit) { + HttpUtils.download(MANIFEST_PRE_URL) { raw -> + // No name→URL fallback here: a prerelease's APK is not under releases/latest, so the + // manifest has to carry absolute apkUrl entries (release.yml writes them). + val fromRaw = raw?.let { parseUpdateJson(it) { null } } + if (fromRaw != null && fromRaw.apkUrl != null) { + finish(ctx, raw, fromRaw, true, onResult) + return@download + } + checkViaApi(ctx, onResult) } } @@ -169,20 +219,21 @@ object UpdateManager { } HttpUtils.download(picked.updateJsonUrl) { body -> val info = body?.let { uj -> parseUpdateJson(uj) { n -> picked.assets[n] } } - finish(ctx, body, info, onResult) + finish(ctx, body, info, true, onResult) } } } private fun finish( - ctx: Context, body: String?, info: UpdateInfo?, onResult: (UpdateInfo?) -> Unit, + ctx: Context, body: String?, info: UpdateInfo?, pre: Boolean, onResult: (UpdateInfo?) -> Unit, ) { if (info != null && body != null) { - cache(ctx, body) + cache(ctx, body, pre) + // Only a successful check starts the cooldown; a failed one retries next time. prefs(ctx).edit().putLong(PREF_LAST_CHECK, System.currentTimeMillis()).apply() onResult(info) } else { - onResult(loadCached(ctx)) + onResult(loadCached(ctx, pre)) } } @@ -237,6 +288,10 @@ object UpdateManager { val apkName = o.optJSONObject("apk") ?.optString(BuildConfig.APPLICATION_ID, null) ?.takeIf { it.isNotBlank() } + // Absolute URL when the manifest carries one (manifest-branch era); else resolve the name. + val absoluteUrl = o.optJSONObject("apkUrl") + ?.optString(BuildConfig.APPLICATION_ID, null) + ?.takeIf { it.isNotBlank() } val highlights = ArrayList() o.optJSONArray("highlights")?.let { arr -> for (i in 0 until minOf(arr.length(), 5)) { @@ -251,7 +306,7 @@ object UpdateManager { versionName = o.optString("versionName", ""), notes = o.optString("notes", ""), apkName = apkName, - apkUrl = apkName?.let(resolveApk), + apkUrl = absoluteUrl ?: apkName?.let(resolveApk), highlights = highlights, apkSize = o.optJSONObject("size")?.optLong(BuildConfig.APPLICATION_ID, -1L) ?: -1L, publishedAt = o.optString("publishedAt", ""), @@ -260,14 +315,19 @@ object UpdateManager { null } - private fun cache(ctx: Context, body: String) = try { - File(ctx.cacheDir, CACHE_NAME).writeText(body) + private fun cacheFile(ctx: Context, pre: Boolean) = File(ctx.cacheDir, if (pre) CACHE_PRE_NAME else CACHE_NAME) + + private fun cache(ctx: Context, body: String, pre: Boolean) = try { + cacheFile(ctx, pre).writeText(body) } catch (_: Exception) { } - private fun loadCached(ctx: Context): UpdateInfo? = try { - val f = File(ctx.cacheDir, CACHE_NAME) - // Best-effort offline read; APK download needs network to re-resolve anyway. - if (f.isFile) parseUpdateJson(f.readText()) { n -> assetUrl(n) } else null + private fun loadCached(ctx: Context, pre: Boolean): UpdateInfo? = try { + val f = cacheFile(ctx, pre) + // Best-effort offline read; APK download needs network to re-resolve anyway. A cached + // prerelease body without absolute URLs cannot resolve its APK (it is not under + // releases/latest), so it comes back with apkUrl = null and the button falls back to + // the releases page. + if (f.isFile) parseUpdateJson(f.readText()) { n -> if (pre) null else assetUrl(n) } else null } catch (_: Exception) { null } diff --git a/app/src/main/java/com/winlator/star/ui/screens/SettingsScreen.kt b/app/src/main/java/com/winlator/star/ui/screens/SettingsScreen.kt index b45e98699..a9a48c7b1 100644 --- a/app/src/main/java/com/winlator/star/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/com/winlator/star/ui/screens/SettingsScreen.kt @@ -595,7 +595,7 @@ fun SettingsScreen(onSaved: () -> Unit = {}) { Button( onClick = { checkingUpdate = true - UpdateManager.check(context) { info -> + UpdateManager.check(context, force = true) { info -> activity?.runOnUiThread { updateInfo = info checkingUpdate = false @@ -623,7 +623,7 @@ fun SettingsScreen(onSaved: () -> Unit = {}) { UpdateManager.setIncludePrereleases(context, it) // Re-check immediately so the readout reflects the new setting. checkingUpdate = true - UpdateManager.check(context) { info -> + UpdateManager.check(context, force = true) { info -> activity?.runOnUiThread { updateInfo = info; checkingUpdate = false } } })