Add dev/production update channels, clearer Euchre table, bolder Sequence highlight

Update channels
- ci.yml now publishes each green push to dev as a prerelease tagged
  v1.0.<n>-dev, so the dev channel has something to install. Workflow
  artifacts could not serve this: downloading one needs authentication,
  which the app does not have.
- Prereleases specifically, because GitHub's "latest release" endpoint
  skips them — a production install can never be handed a dev build.
  Pull requests build and check but never publish.
- Settings gains a channel picker. The two channels are numbered by
  separate workflow run counters, so versions are only compared within a
  channel; the app reads the -dev suffix in its own versionName to know
  where it stands, and treats a channel change as an explicit switch. The
  screen warns that such a switch can be a downgrade Android will refuse.
- PLG_RELEASE becomes PLG_CHANNEL, naming the channel rather than a flag.

Euchre
- Cards in a trick now sit on the side of the table belonging to whoever
  played them, rotated to face them, labelled You / Left / Partner /
  Right. Previously they were a flat row tagged with seat numbers, which
  made it hard to tell who had played what.
- Bidding says what it will actually do. As dealer the button reads "Pick
  it up" rather than "Order up", since the turn card comes to your hand,
  and a line of explanatory text spells out the consequence either way.

Sequence
- Playable squares get a much heavier outline, scaled off cell size and
  in a fixed amber that reads against a white card, a green corner and a
  chip alike. The old 2dp theme-coloured border was hard to see.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015qjfMiPUDshdKEUa6vxqzN
This commit is contained in:
Claude
2026-08-06 00:18:59 +00:00
parent 80ff178437
commit 1bcd2a951e
9 changed files with 417 additions and 109 deletions
+47 -3
View File
@@ -2,8 +2,14 @@ name: CI
# Pre-merge checks. Runs on the dev branch and on every pull request aimed at
# dev or main, so breakage is caught before it reaches main and triggers a
# release. Builds the same debug APK as the release pipeline, but publishes it
# only as a workflow artifact — no tag, no GitHub Release.
# release.
#
# On a push to dev it also publishes the build as a dev-channel prerelease, so
# the in-app updater can offer it to anyone who has switched to that channel.
# Prereleases are used deliberately: GitHub's "latest release" endpoint skips
# them, so a production-channel user never sees a dev build. Pull requests only
# build and check — they never publish, which keeps a PR from a fork from
# shipping anything.
on:
push:
branches: [ dev ]
@@ -11,8 +17,9 @@ on:
branches: [ main, dev ]
workflow_dispatch:
# Needed to publish the dev-channel prerelease below.
permissions:
contents: read
contents: write
# A newer push to the same branch or PR makes the in-flight run obsolete.
concurrency:
@@ -23,6 +30,11 @@ jobs:
build:
name: Build and check
runs-on: ubuntu-latest
env:
# Stamp the APK as a dev-channel build only when this is a real push to
# dev. Pull request builds stay unstamped at 1.0.0-dev, since they are
# never published and must not claim a channel version.
PLG_CHANNEL: ${{ (github.event_name == 'push' && github.ref == 'refs/heads/dev') && 'dev' || '' }}
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -64,3 +76,35 @@ jobs:
app/build/reports/
if-no-files-found: ignore
retention-days: 14
# Everything below publishes the dev channel, and only ever from a push
# to dev that has already built, tested and linted clean above.
- name: Stage dev APK
id: stage
if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
run: |
set -euo pipefail
version="v1.0.${{ github.run_number }}-dev"
src="app/build/outputs/apk/debug/app-debug.apk"
dest="ProLibertateGames-${version}.apk"
cp "$src" "$dest"
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "apk=$dest" >> "$GITHUB_OUTPUT"
- name: Publish dev channel prerelease
if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
gh release create "${{ steps.stage.outputs.version }}" \
"${{ steps.stage.outputs.apk }}" \
--repo "${{ github.repository }}" \
--target "${{ github.sha }}" \
--title "ProLibertateGames ${{ steps.stage.outputs.version }}" \
--prerelease \
--notes "Automated **dev channel** build from \`${{ github.sha }}\` on \`dev\`.
Preview build: newer than production, and less tested. Switch to the
Dev update channel in Settings to be offered these. Marked as a
prerelease so production-channel installs never see it."
+3 -4
View File
@@ -25,10 +25,9 @@ jobs:
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
env:
# Tells the build this is a real release, so the APK is stamped with the
# run number that matches the tag below. Absent everywhere else, which
# keeps dev and local builds at 1.0.0-dev.
PLG_RELEASE: "1"
# Stamps the APK as a production-channel build, versioned with the run
# number that matches the tag published below.
PLG_CHANNEL: production
steps:
- name: Checkout
uses: actions/checkout@v4
+29 -10
View File
@@ -48,9 +48,10 @@ them.
- **Sound** on/off.
- **Animation speed**, 0.5×–2×. One multiplier drives card movement and how long
the computer appears to think.
- **Updates** — check on launch, or check on demand. Updates are pulled from the
latest GitHub Release of this repository, which CI publishes on every push to
`main`.
- **Updates** — check on launch, or check on demand, from either of two
channels:
- **Production** — stable builds, published from `main`.
- **Dev** — preview builds, published from `dev` on every green CI run.
## Screen sizes
@@ -78,13 +79,31 @@ Requires JDK 17 and the Android SDK (compileSdk 34).
publishes it as a GitHub Release tagged `v1.0.<run-number>`. The in-app updater
reads that tag, which is why the APK's `versionCode` is the same run number.
**OTA updates follow `main`, and only `main`.** The updater asks GitHub for the
latest published Release; the release workflow is the only thing that creates
one, and it refuses to run off any branch but `main` — so a manual dispatch
aimed at `dev` does nothing rather than shipping unreviewed code to every
install. Only that workflow sets `PLG_RELEASE`, which is what stamps a real
`versionCode`; dev builds stay at `1.0.0-dev` so a side-loaded CI artifact can
never outrank a genuine release and suppress future updates.
### Update channels
Both pipelines publish, to two separate channels:
| Channel | Published by | Tag | Marked |
| --- | --- | --- | --- |
| Production | `release.yml`, push to `main` | `v1.0.<n>` | release |
| Dev | `ci.yml`, push to `dev` | `v1.0.<n>-dev` | prerelease |
Dev builds are published as **prereleases** deliberately: GitHub's "latest
release" endpoint skips prereleases, so a production-channel install can never
be handed a dev build even by accident. Pull requests build and check but never
publish, so a PR from a fork cannot ship anything.
`release.yml` additionally refuses to run off any branch but `main`, so a manual
dispatch aimed elsewhere does nothing rather than shipping unreviewed code.
**The two channels have independent version sequences** — a build's `versionCode`
is the run number of the workflow that produced it, and the two workflows count
separately. Versions are therefore only ever compared *within* a channel; the app
reads the `-dev` suffix in its own `versionName` to know which channel it is on,
and treats a change of channel as an explicit switch rather than an upgrade.
One consequence: switching channels can be a downgrade as far as Android is
concerned, in which case the install is refused until the current copy is
uninstalled. The settings screen says so when it applies.
The debug keystore is committed on purpose: every build is signed with the same
key, so an OTA update installs over the previous one instead of being rejected
+18 -19
View File
@@ -4,20 +4,17 @@ plugins {
id("org.jetbrains.kotlin.plugin.serialization")
}
// Only the release workflow stamps a real version. It sets PLG_RELEASE, and its
// GITHUB_RUN_NUMBER is the same number as the release tag it publishes
// (v1.0.<run-number>), so the shipped APK knows its own version and the in-app
// updater can compare against the latest published release.
// Which update channel this build belongs to, set by whichever workflow built
// it: release.yml stamps "production" from main, ci.yml stamps "dev" from the
// dev branch. Unset for a local build, which counts as dev.
//
// Everything else — a local build, or the dev CI pipeline — stays at 1.0.0-dev.
// GITHUB_RUN_NUMBER alone is NOT enough to key off: it counts per workflow, so
// the dev pipeline has its own independent sequence. A debug APK installed from
// a CI artifact would otherwise carry that counter as its versionCode, could
// outrank a genuine release, and would leave the updater reporting "you're on
// the latest version" indefinitely.
val isReleaseBuild = System.getenv("PLG_RELEASE") == "1"
val releaseRunNumber = (System.getenv("GITHUB_RUN_NUMBER") ?: "").toIntOrNull() ?: 0
val stampReleaseVersion = isReleaseBuild && releaseRunNumber > 0
// GITHUB_RUN_NUMBER alone is not enough to key off, because it counts per
// workflow — the two pipelines have entirely independent sequences. That is
// also why versionCode is only ever compared *within* a channel; see
// UpdateChannel for how switching between them is handled.
val buildChannel = System.getenv("PLG_CHANNEL").orEmpty()
val runNumber = (System.getenv("GITHUB_RUN_NUMBER") ?: "").toIntOrNull() ?: 0
val stampVersion = buildChannel.isNotEmpty() && runNumber > 0
android {
namespace = "org.prolibertate.games"
@@ -27,12 +24,14 @@ android {
applicationId = "org.prolibertate.games"
minSdk = 24
targetSdk = 34
// Dev builds sit at 1, below every release except the very first —
// a v1.0.1 release ties with a dev build and so is not offered as an
// update. Harmless, and preferable to versionCode 0, which Android
// documents as out of range.
versionCode = if (stampReleaseVersion) releaseRunNumber else 1
versionName = if (stampReleaseVersion) "1.0.$releaseRunNumber" else "1.0.0-dev"
// The -dev suffix in versionName is what the installed app reads to
// know its own channel, so it must survive into the shipped APK.
versionCode = if (stampVersion) runNumber else 1
versionName = when {
stampVersion && buildChannel == "production" -> "1.0.$runNumber"
stampVersion -> "1.0.$runNumber-dev"
else -> "1.0.0-dev"
}
}
signingConfigs {
@@ -10,6 +10,7 @@ import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import org.prolibertate.games.update.UpdateChannel
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
@@ -25,6 +26,8 @@ data class Settings(
val animationSpeed: Float = 1.0f,
val checkForUpdatesOnLaunch: Boolean = true,
val playerName: String = "Player",
/** Which builds the updater offers. Production unless deliberately changed. */
val updateChannel: UpdateChannel = UpdateChannel.PRODUCTION,
) {
/** Scales a nominal duration by the chosen speed. */
fun scaled(millis: Long): Long =
@@ -43,6 +46,7 @@ class SettingsRepository(private val context: Context) {
val SPEED = floatPreferencesKey("animation_speed")
val UPDATE_ON_LAUNCH = booleanPreferencesKey("check_updates_on_launch")
val NAME = stringPreferencesKey("player_name")
val CHANNEL = stringPreferencesKey("update_channel")
}
val settings: Flow<Settings> = context.dataStore.data.map { prefs ->
@@ -51,9 +55,16 @@ class SettingsRepository(private val context: Context) {
animationSpeed = prefs[Keys.SPEED] ?: 1.0f,
checkForUpdatesOnLaunch = prefs[Keys.UPDATE_ON_LAUNCH] ?: true,
playerName = prefs[Keys.NAME] ?: "Player",
updateChannel = prefs[Keys.CHANNEL]
?.let { stored -> runCatching { UpdateChannel.valueOf(stored) }.getOrNull() }
?: UpdateChannel.PRODUCTION,
)
}
suspend fun setUpdateChannel(channel: UpdateChannel) {
context.dataStore.edit { it[Keys.CHANNEL] = channel.name }
}
suspend fun setSoundEnabled(enabled: Boolean) {
context.dataStore.edit { it[Keys.SOUND] = enabled }
}
@@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Divider
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Slider
@@ -25,6 +26,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import org.prolibertate.games.settings.Settings
import org.prolibertate.games.update.UpdateChannel
import org.prolibertate.games.update.Updater
@Composable
@@ -98,11 +100,44 @@ fun SettingsScreen(
Text("Updates", fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleMedium)
Text(
text = "Installed: v${env.updater.installedVersionName()}. Updates come from " +
"the latest release published on the project's main branch.",
text = "Installed: v${env.updater.installedVersionName()} " +
"(${env.updater.installedChannel().label} channel).",
style = MaterialTheme.typography.bodySmall,
)
Column {
Text("Update channel", fontWeight = FontWeight.Bold)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
UpdateChannel.entries.forEach { channel ->
FilterChip(
selected = settings.updateChannel == channel,
onClick = {
scope.launch {
env.settingsRepository.setUpdateChannel(channel)
}
updateStatus = null
pendingRelease = null
},
label = { Text(channel.label) },
)
}
}
Text(
text = settings.updateChannel.blurb + ".",
style = MaterialTheme.typography.bodySmall,
)
if (settings.updateChannel != env.updater.installedChannel()) {
// The channels are numbered independently, so a switch can
// be a downgrade as far as Android is concerned.
Text(
text = "You're on a ${env.updater.installedChannel().label} build. " +
"Switching channels installs a different build rather than a " +
"newer one — if Android refuses it, uninstall this copy first.",
style = MaterialTheme.typography.bodySmall,
)
}
}
SettingRow(
title = "Check on launch",
subtitle = "Look for a newer release when the app starts",
@@ -117,16 +152,22 @@ fun SettingsScreen(
PrimaryAction(text = "Check for updates now") {
scope.launch {
updateStatus = "Checking…"
when (val result = env.updater.check()) {
updateStatus = "Checking the ${settings.updateChannel.label} channel…"
when (val result = env.updater.check(settings.updateChannel)) {
is Updater.Result.UpToDate -> {
updateStatus = "You're on the latest version."
updateStatus =
"You're on the latest ${settings.updateChannel.label} build."
pendingRelease = null
}
is Updater.Result.Available -> {
pendingRelease = result.release
updateStatus = "Version ${result.release.tag} is available."
updateStatus = if (result.isChannelSwitch) {
"${result.release.tag} is the current " +
"${result.release.channel.label} build."
} else {
"Version ${result.release.tag} is available."
}
}
is Updater.Result.Failed -> {
@@ -22,6 +22,8 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import org.prolibertate.games.game.euchre.CallTrump
import org.prolibertate.games.game.euchre.Discard
@@ -35,7 +37,6 @@ import org.prolibertate.games.game.euchre.teamOf
import org.prolibertate.games.net.MatchController
import org.prolibertate.games.ui.PrimaryAction
import org.prolibertate.games.ui.ScreenScaffold
import org.prolibertate.games.ui.common.CardBackView
import org.prolibertate.games.ui.common.PlayingCardView
import org.prolibertate.games.ui.theme.FeltGreenDark
@@ -83,7 +84,7 @@ fun EuchreScreen(
.background(FeltGreenDark),
contentAlignment = Alignment.Center,
) {
TrickArea(current, cardWidth)
TrickArea(current, localSeat, cardWidth)
}
notice?.let {
@@ -93,7 +94,7 @@ fun EuchreScreen(
when (current.phase) {
EuchrePhase.BID_ROUND_1,
EuchrePhase.BID_ROUND_2,
-> BiddingControls(current, legal) { controller.submit(it) }
-> BiddingControls(current, localSeat, legal) { controller.submit(it) }
EuchrePhase.HAND_OVER -> Text(
text = current.log.lastOrNull().orEmpty(),
@@ -146,40 +147,96 @@ private fun ScoreBar(state: EuchreState, localSeat: Int) {
}
}
/**
* Where a seat sits relative to you: 0 is you, then clockwise round the table.
* Euchre seats are numbered clockwise and partners sit opposite, so 2 is always
* your partner.
*/
private fun relativePosition(seat: Int, localSeat: Int): Int = ((seat - localSeat) % 4 + 4) % 4
private fun seatLabel(relative: Int): String = when (relative) {
0 -> "You"
1 -> "Left"
2 -> "Partner"
else -> "Right"
}
/**
* The table. Each card sits on the side of the player who played it and is
* turned to face them, so you can read a trick at a glance instead of matching
* seat numbers to cards.
*/
@Composable
private fun TrickArea(state: EuchreState, cardWidth: androidx.compose.ui.unit.Dp) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
private fun TrickArea(state: EuchreState, localSeat: Int, cardWidth: Dp) {
Box(modifier = Modifier.fillMaxSize().padding(4.dp)) {
// The turn card sits in the middle of the table during bidding.
state.upCard?.let { up ->
Text("Turned up", color = MaterialTheme.colorScheme.onPrimary)
PlayingCardView(card = up, width = cardWidth)
}
if (state.trick.isNotEmpty()) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
state.trick.forEach { played ->
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = "Seat ${played.seat}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimary,
)
PlayingCardView(card = played.card, width = cardWidth)
}
}
Column(
modifier = Modifier.align(Alignment.Center),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = "Turned up",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimary,
)
PlayingCardView(card = up, width = cardWidth)
}
} else if (state.upCard == null && state.phase == EuchrePhase.PLAYING) {
Text("Lead a card", color = MaterialTheme.colorScheme.onPrimary)
}
// Opponents' hands are only ever card counts on this device.
Row(
modifier = Modifier.padding(top = 12.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
state.handCounts.forEachIndexed { seat, count ->
if (seat != state.turn) return@forEachIndexed
repeat(count.coerceAtMost(5)) {
CardBackView(width = cardWidth * 0.4f)
}
if (state.upCard == null && state.trick.isEmpty() && state.phase == EuchrePhase.PLAYING) {
Text(
text = if (state.turn == localSeat) "Lead a card" else "Waiting…",
color = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.align(Alignment.Center),
)
}
state.trick.forEach { played ->
val relative = relativePosition(played.seat, localSeat)
val alignment = when (relative) {
0 -> Alignment.BottomCenter
1 -> Alignment.CenterStart
2 -> Alignment.TopCenter
else -> Alignment.CenterEnd
}
// Rotate to face the player who played it: the side seats end up
// landscape, the partner's card upside down from your side.
val rotation = when (relative) {
1 -> 90f
2 -> 180f
3 -> -90f
else -> 0f
}
Column(
modifier = Modifier.align(alignment),
horizontalAlignment = Alignment.CenterHorizontally,
) {
PlayingCardView(
card = played.card,
width = cardWidth,
modifier = Modifier.rotate(rotation),
)
Text(
text = seatLabel(relative),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimary,
)
}
}
// Who is on the clock, shown where they sit rather than as a seat number.
if (state.phase == EuchrePhase.PLAYING && state.trick.size < state.activeSeatCount) {
val waitingOn = relativePosition(state.turn, localSeat)
if (waitingOn != 0 && state.trick.none { it.seat == state.turn }) {
Text(
text = "${seatLabel(waitingOn)} to play",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.align(Alignment.BottomStart),
)
}
}
}
@@ -188,22 +245,43 @@ private fun TrickArea(state: EuchreState, cardWidth: androidx.compose.ui.unit.Dp
@Composable
private fun BiddingControls(
state: EuchreState,
localSeat: Int,
legal: List<EuchreMove>,
onMove: (EuchreMove) -> Unit,
) {
if (legal.isEmpty()) {
Text("Waiting for seat ${state.turn}…")
val waitingOn = relativePosition(state.turn, localSeat)
Text("Waiting for ${seatLabel(waitingOn).lowercase()} to bid…")
return
}
// Controls only render on your turn, so the seat on the clock is you —
// which means being the dealer is what decides whether ordering up hands
// the turn card to somebody else or to yourself.
val youAreDealer = state.turn == state.dealer
val upCard = state.upCard
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
text = if (state.phase == EuchrePhase.BID_ROUND_1) {
"Order up ${state.upCard?.label.orEmpty()}?"
} else {
"Name a suit"
text = when {
state.phase != EuchrePhase.BID_ROUND_1 -> "Name a suit for trump"
youAreDealer -> "Pick up ${upCard?.label.orEmpty()} for trump, or pass?"
else -> "Order up ${upCard?.label.orEmpty()} for trump, or pass?"
},
fontWeight = FontWeight.Bold,
)
if (state.phase == EuchrePhase.BID_ROUND_1 && upCard != null) {
Text(
text = if (youAreDealer) {
"Taking it makes ${upCard.suit.symbol} trump and puts that card " +
"in your hand — you'll then discard one."
} else {
"Ordering it up makes ${upCard.suit.symbol} trump and gives that " +
"card to the dealer."
},
style = MaterialTheme.typography.bodySmall,
)
}
Row(
modifier = Modifier.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(6.dp),
@@ -211,11 +289,16 @@ private fun BiddingControls(
legal.forEach { move ->
val label = when (move) {
is Pass -> "Pass"
is OrderUp -> if (move.alone) "Order up alone" else "Order up"
is CallTrump -> {
(if (move.alone) "Alone " else "") + move.suit.symbol
is OrderUp -> when {
youAreDealer && move.alone -> "Pick it up, alone"
youAreDealer -> "Pick it up"
move.alone -> "Order up alone"
else -> "Order up ${upCard?.suit?.symbol.orEmpty()}"
}
is CallTrump ->
if (move.alone) "${move.suit.symbol} alone" else "Make it ${move.suit.symbol}"
is Discard -> "Discard ${move.card.label}"
is PlayCard -> move.card.label
}
@@ -234,7 +317,7 @@ private fun HandRow(
state: EuchreState,
seat: Int,
legal: List<EuchreMove>,
cardWidth: androidx.compose.ui.unit.Dp,
cardWidth: Dp,
onPlay: (EuchreMove) -> Unit,
) {
val hand = state.hands.getOrNull(seat).orEmpty()
@@ -47,6 +47,13 @@ import org.prolibertate.games.ui.ScreenScaffold
import org.prolibertate.games.ui.theme.FeltGreenDark
import org.prolibertate.games.ui.theme.TeamColours
/**
* Outline for a square the selected card can be played on. Deliberately not a
* theme colour: it must contrast with both team chip colours and with the
* board in either light or dark mode.
*/
private val HighlightAmber = Color(0xFFFFC107)
/**
* The Sequence board.
*
@@ -191,8 +198,16 @@ private fun BoardCell(
.clip(RoundedCornerShape(size * 0.12f))
.background(if (card == null) MaterialTheme.colorScheme.primary else Color.White)
.border(
width = if (highlighted) 2.dp else 0.5.dp,
color = if (highlighted) MaterialTheme.colorScheme.tertiary else Color(0x33000000),
// Playable squares are outlined heavily rather than tinted: the
// outline has to read against a white card, a green corner and
// a chip sitting on top of it. Scaled off the cell so it stays
// visible on a small screen without swamping a large one.
width = if (highlighted) {
(size * 0.10f).coerceIn(3.dp, 6.dp)
} else {
0.5.dp
},
color = if (highlighted) HighlightAmber else Color(0x33000000),
shape = RoundedCornerShape(size * 0.12f),
)
.clickable { onClick() },
@@ -8,11 +8,29 @@ import android.provider.Settings
import androidx.core.content.FileProvider
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.net.HttpURLConnection
import java.net.URL
/**
* Which builds the updater offers.
*
* The two channels are published by different workflows and so have entirely
* separate version sequences — a dev build's versionCode is its CI run number,
* a production build's is its release run number, and the two are unrelated.
* Versions are therefore only ever compared *within* a channel; moving between
* them is treated as an explicit switch rather than an upgrade.
*/
enum class UpdateChannel(val label: String, val blurb: String) {
PRODUCTION("Production", "Stable builds released from main"),
DEV("Dev", "Preview builds from the dev branch — newer, less tested"),
}
/** Marks a version name as belonging to the dev channel. */
private const val DEV_SUFFIX = "-dev"
/**
* Over-the-air updates from the repository's GitHub Releases.
*
@@ -23,56 +41,135 @@ import java.net.URL
*/
class Updater(private val activity: Activity) {
data class Release(val tag: String, val versionCode: Int, val apkUrl: String, val apkName: String)
data class Release(
val tag: String,
val versionCode: Int,
val apkUrl: String,
val apkName: String,
val channel: UpdateChannel,
)
sealed interface Result {
data class Available(val release: Release) : Result
/**
* [isChannelSwitch] means this build is from a different channel than
* the one installed, so it is an explicit move rather than a newer
* version of the same line.
*/
data class Available(val release: Release, val isChannelSwitch: Boolean) : Result
data object UpToDate : Result
data class Failed(val reason: String) : Result
}
suspend fun check(): Result = withContext(Dispatchers.IO) {
suspend fun check(channel: UpdateChannel): Result = withContext(Dispatchers.IO) {
try {
val latest = fetchLatestRelease()
?: return@withContext Result.Failed("No published release found.")
if (latest.versionCode <= installedVersionCode()) {
Result.UpToDate
} else {
Result.Available(latest)
val latest = fetchLatestRelease(channel)
?: return@withContext Result.Failed(
"No ${channel.label.lowercase()} build has been published yet."
)
when {
// Different channel: version numbers are not comparable, so
// always offer it and let the user decide.
latest.channel != installedChannel() ->
Result.Available(latest, isChannelSwitch = true)
latest.versionCode > installedVersionCode() ->
Result.Available(latest, isChannelSwitch = false)
else -> Result.UpToDate
}
} catch (e: Exception) {
Result.Failed(e.message ?: "Update check failed.")
}
}
private fun fetchLatestRelease(): Release? {
val url = URL("https://api.github.com/repos/$OWNER/$REPO/releases/latest")
val connection = (url.openConnection() as HttpURLConnection).apply {
/**
* Production reads GitHub's "latest release", which by definition skips
* prereleases. Dev walks the release list for the newest prerelease, which
* is what the CI pipeline publishes from the dev branch.
*/
private fun fetchLatestRelease(channel: UpdateChannel): Release? = when (channel) {
UpdateChannel.PRODUCTION ->
getJsonObject("https://api.github.com/repos/$OWNER/$REPO/releases/latest")
?.let { toRelease(it) }
UpdateChannel.DEV ->
getJsonArray("https://api.github.com/repos/$OWNER/$REPO/releases?per_page=30")
?.let { releases ->
// GitHub returns newest first.
(0 until releases.length())
.map { releases.getJSONObject(it) }
.firstOrNull { it.optBoolean("prerelease") && !it.optBoolean("draft") }
?.let { toRelease(it) }
}
}
private fun toRelease(json: JSONObject): Release? {
val tag = json.optString("tag_name").ifBlank { return null }
// Tags are v1.0.<n> or v1.0.<n>-dev; the run number is the trailing part.
val versionCode = tag.substringAfterLast('.')
.removeSuffix(DEV_SUFFIX)
.toIntOrNull()
?: return null
val channel =
if (tag.endsWith(DEV_SUFFIX) || json.optBoolean("prerelease")) {
UpdateChannel.DEV
} else {
UpdateChannel.PRODUCTION
}
val assets = json.optJSONArray("assets") ?: return null
for (i in 0 until assets.length()) {
val asset = assets.getJSONObject(i)
val name = asset.optString("name")
if (name.endsWith(".apk")) {
return Release(
tag = tag,
versionCode = versionCode,
apkUrl = asset.getString("browser_download_url"),
apkName = name,
channel = channel,
)
}
}
return null
}
private fun openConnection(url: String): HttpURLConnection =
(URL(url).openConnection() as HttpURLConnection).apply {
requestMethod = "GET"
setRequestProperty("Accept", "application/vnd.github+json")
setRequestProperty("User-Agent", "ProLibertateGames-Updater")
connectTimeout = 15_000
readTimeout = 15_000
}
private fun getJsonObject(url: String): JSONObject? {
val connection = openConnection(url)
try {
if (connection.responseCode != 200) return null
val json = JSONObject(connection.inputStream.bufferedReader().use { it.readText() })
val tag = json.getString("tag_name")
val versionCode = tag.substringAfterLast('.').toIntOrNull() ?: return null
val assets = json.getJSONArray("assets")
for (i in 0 until assets.length()) {
val asset = assets.getJSONObject(i)
val name = asset.getString("name")
if (name.endsWith(".apk")) {
return Release(tag, versionCode, asset.getString("browser_download_url"), name)
}
}
return null
return JSONObject(connection.inputStream.bufferedReader().use { it.readText() })
} finally {
connection.disconnect()
}
}
private fun getJsonArray(url: String): JSONArray? {
val connection = openConnection(url)
try {
if (connection.responseCode != 200) return null
return JSONArray(connection.inputStream.bufferedReader().use { it.readText() })
} finally {
connection.disconnect()
}
}
/** Read back from the installed APK's own version name. */
fun installedChannel(): UpdateChannel =
if (installedVersionName().endsWith(DEV_SUFFIX)) UpdateChannel.DEV
else UpdateChannel.PRODUCTION
/** Downloads the APK and hands it to the system installer. */
suspend fun downloadAndInstall(release: Release): String? = withContext(Dispatchers.IO) {
try {