Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/release-notes-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -57,4 +58,5 @@ jobs:
path: |
release-body.md
short-notes.txt
highlights.json
retention-days: 7
53 changes: 51 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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).
Expand Down Expand Up @@ -114,23 +116,36 @@ 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: |
VERSION_CODE=$(grep versionCode app/build.gradle | head -1 | grep -oE '[0-9]+' | head -1)
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}}' \
--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

Expand All @@ -153,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}"
100 changes: 50 additions & 50 deletions app/src/main/java/com/winlator/star/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<UpdateManager.UpdateInfo?>(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<UpdateManager.UpdateInfo?>(null) }
var pillVisible by remember { mutableStateOf(false) }
var highlightsFor by remember { mutableStateOf<UpdateManager.UpdateInfo?>(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
}
}
}
Expand Down Expand Up @@ -697,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(
Expand Down Expand Up @@ -777,20 +779,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,
Expand All @@ -803,6 +793,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
}
}
Expand All @@ -818,6 +831,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
Expand All @@ -836,32 +862,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
Expand Down
61 changes: 61 additions & 0 deletions app/src/main/java/com/winlator/star/core/HttpUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<Boolean> 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<Boolean> onDownloadComplete) {
final DownloadProgressDialog dialog = new DownloadProgressDialog(activity);
final AtomicBoolean interruptRef = new AtomicBoolean();
Expand Down
Loading
Loading