diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2aa346c..ef71f9b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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." diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fed4e80..f00587b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/README.md b/README.md index 8f68e4f..780e783 100644 --- a/README.md +++ b/README.md @@ -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.`. 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.` | release | +| Dev | `ci.yml`, push to `dev` | `v1.0.-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 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index de1c796..d1027e3 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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.), 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 { diff --git a/app/src/main/java/org/prolibertate/games/settings/SettingsRepository.kt b/app/src/main/java/org/prolibertate/games/settings/SettingsRepository.kt index c27af5d..64b9375 100644 --- a/app/src/main/java/org/prolibertate/games/settings/SettingsRepository.kt +++ b/app/src/main/java/org/prolibertate/games/settings/SettingsRepository.kt @@ -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 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 = 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 } } diff --git a/app/src/main/java/org/prolibertate/games/ui/SettingsScreen.kt b/app/src/main/java/org/prolibertate/games/ui/SettingsScreen.kt index cd7a41c..a85bf6d 100644 --- a/app/src/main/java/org/prolibertate/games/ui/SettingsScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/SettingsScreen.kt @@ -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 -> { diff --git a/app/src/main/java/org/prolibertate/games/ui/game/EuchreScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/EuchreScreen.kt index 489b05b..fc4d513 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/EuchreScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/EuchreScreen.kt @@ -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, 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, - cardWidth: androidx.compose.ui.unit.Dp, + cardWidth: Dp, onPlay: (EuchreMove) -> Unit, ) { val hand = state.hands.getOrNull(seat).orEmpty() diff --git a/app/src/main/java/org/prolibertate/games/ui/game/SequenceScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/SequenceScreen.kt index be00e6f..6843f97 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/SequenceScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/SequenceScreen.kt @@ -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() }, diff --git a/app/src/main/java/org/prolibertate/games/update/Updater.kt b/app/src/main/java/org/prolibertate/games/update/Updater.kt index 028fc78..3f353b9 100644 --- a/app/src/main/java/org/prolibertate/games/update/Updater.kt +++ b/app/src/main/java/org/prolibertate/games/update/Updater.kt @@ -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. or v1.0.-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 {