Merge dev: update channels, clearer Euchre table, bolder Sequence highlight

Publishes a new production release so installs on v1.0.1 are offered an
OTA upgrade.

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:54:47 +00:00
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 # 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 # 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 # release.
# only as a workflow artifact — no tag, no GitHub 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: on:
push: push:
branches: [ dev ] branches: [ dev ]
@@ -11,8 +17,9 @@ on:
branches: [ main, dev ] branches: [ main, dev ]
workflow_dispatch: workflow_dispatch:
# Needed to publish the dev-channel prerelease below.
permissions: permissions:
contents: read contents: write
# A newer push to the same branch or PR makes the in-flight run obsolete. # A newer push to the same branch or PR makes the in-flight run obsolete.
concurrency: concurrency:
@@ -23,6 +30,11 @@ jobs:
build: build:
name: Build and check name: Build and check
runs-on: ubuntu-latest 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: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -64,3 +76,35 @@ jobs:
app/build/reports/ app/build/reports/
if-no-files-found: ignore if-no-files-found: ignore
retention-days: 14 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' if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: env:
# Tells the build this is a real release, so the APK is stamped with the # Stamps the APK as a production-channel build, versioned with the run
# run number that matches the tag below. Absent everywhere else, which # number that matches the tag published below.
# keeps dev and local builds at 1.0.0-dev. PLG_CHANNEL: production
PLG_RELEASE: "1"
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
+29 -10
View File
@@ -48,9 +48,10 @@ them.
- **Sound** on/off. - **Sound** on/off.
- **Animation speed**, 0.5×–2×. One multiplier drives card movement and how long - **Animation speed**, 0.5×–2×. One multiplier drives card movement and how long
the computer appears to think. the computer appears to think.
- **Updates** — check on launch, or check on demand. Updates are pulled from the - **Updates** — check on launch, or check on demand, from either of two
latest GitHub Release of this repository, which CI publishes on every push to channels:
`main`. - **Production** — stable builds, published from `main`.
- **Dev** — preview builds, published from `dev` on every green CI run.
## Screen sizes ## 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 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. 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 ### Update channels
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 Both pipelines publish, to two separate channels:
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 | Channel | Published by | Tag | Marked |
`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. | 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 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 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") id("org.jetbrains.kotlin.plugin.serialization")
} }
// Only the release workflow stamps a real version. It sets PLG_RELEASE, and its // Which update channel this build belongs to, set by whichever workflow built
// GITHUB_RUN_NUMBER is the same number as the release tag it publishes // it: release.yml stamps "production" from main, ci.yml stamps "dev" from the
// (v1.0.<run-number>), so the shipped APK knows its own version and the in-app // dev branch. Unset for a local build, which counts as dev.
// updater can compare against the latest published release.
// //
// 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, because it counts per
// GITHUB_RUN_NUMBER alone is NOT enough to key off: it counts per workflow, so // workflow — the two pipelines have entirely independent sequences. That is
// the dev pipeline has its own independent sequence. A debug APK installed from // also why versionCode is only ever compared *within* a channel; see
// a CI artifact would otherwise carry that counter as its versionCode, could // UpdateChannel for how switching between them is handled.
// outrank a genuine release, and would leave the updater reporting "you're on val buildChannel = System.getenv("PLG_CHANNEL").orEmpty()
// the latest version" indefinitely. val runNumber = (System.getenv("GITHUB_RUN_NUMBER") ?: "").toIntOrNull() ?: 0
val isReleaseBuild = System.getenv("PLG_RELEASE") == "1" val stampVersion = buildChannel.isNotEmpty() && runNumber > 0
val releaseRunNumber = (System.getenv("GITHUB_RUN_NUMBER") ?: "").toIntOrNull() ?: 0
val stampReleaseVersion = isReleaseBuild && releaseRunNumber > 0
android { android {
namespace = "org.prolibertate.games" namespace = "org.prolibertate.games"
@@ -27,12 +24,14 @@ android {
applicationId = "org.prolibertate.games" applicationId = "org.prolibertate.games"
minSdk = 24 minSdk = 24
targetSdk = 34 targetSdk = 34
// Dev builds sit at 1, below every release except the very first — // The -dev suffix in versionName is what the installed app reads to
// a v1.0.1 release ties with a dev build and so is not offered as an // know its own channel, so it must survive into the shipped APK.
// update. Harmless, and preferable to versionCode 0, which Android versionCode = if (stampVersion) runNumber else 1
// documents as out of range. versionName = when {
versionCode = if (stampReleaseVersion) releaseRunNumber else 1 stampVersion && buildChannel == "production" -> "1.0.$runNumber"
versionName = if (stampReleaseVersion) "1.0.$releaseRunNumber" else "1.0.0-dev" stampVersion -> "1.0.$runNumber-dev"
else -> "1.0.0-dev"
}
} }
signingConfigs { signingConfigs {
@@ -10,6 +10,7 @@ import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import org.prolibertate.games.update.UpdateChannel
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings") private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
@@ -25,6 +26,8 @@ data class Settings(
val animationSpeed: Float = 1.0f, val animationSpeed: Float = 1.0f,
val checkForUpdatesOnLaunch: Boolean = true, val checkForUpdatesOnLaunch: Boolean = true,
val playerName: String = "Player", 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. */ /** Scales a nominal duration by the chosen speed. */
fun scaled(millis: Long): Long = fun scaled(millis: Long): Long =
@@ -43,6 +46,7 @@ class SettingsRepository(private val context: Context) {
val SPEED = floatPreferencesKey("animation_speed") val SPEED = floatPreferencesKey("animation_speed")
val UPDATE_ON_LAUNCH = booleanPreferencesKey("check_updates_on_launch") val UPDATE_ON_LAUNCH = booleanPreferencesKey("check_updates_on_launch")
val NAME = stringPreferencesKey("player_name") val NAME = stringPreferencesKey("player_name")
val CHANNEL = stringPreferencesKey("update_channel")
} }
val settings: Flow<Settings> = context.dataStore.data.map { prefs -> 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, animationSpeed = prefs[Keys.SPEED] ?: 1.0f,
checkForUpdatesOnLaunch = prefs[Keys.UPDATE_ON_LAUNCH] ?: true, checkForUpdatesOnLaunch = prefs[Keys.UPDATE_ON_LAUNCH] ?: true,
playerName = prefs[Keys.NAME] ?: "Player", 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) { suspend fun setSoundEnabled(enabled: Boolean) {
context.dataStore.edit { it[Keys.SOUND] = enabled } 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.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Divider import androidx.compose.material3.Divider
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Slider import androidx.compose.material3.Slider
@@ -25,6 +26,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.prolibertate.games.settings.Settings import org.prolibertate.games.settings.Settings
import org.prolibertate.games.update.UpdateChannel
import org.prolibertate.games.update.Updater import org.prolibertate.games.update.Updater
@Composable @Composable
@@ -98,11 +100,44 @@ fun SettingsScreen(
Text("Updates", fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleMedium) Text("Updates", fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleMedium)
Text( Text(
text = "Installed: v${env.updater.installedVersionName()}. Updates come from " + text = "Installed: v${env.updater.installedVersionName()} " +
"the latest release published on the project's main branch.", "(${env.updater.installedChannel().label} channel).",
style = MaterialTheme.typography.bodySmall, 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( SettingRow(
title = "Check on launch", title = "Check on launch",
subtitle = "Look for a newer release when the app starts", subtitle = "Look for a newer release when the app starts",
@@ -117,16 +152,22 @@ fun SettingsScreen(
PrimaryAction(text = "Check for updates now") { PrimaryAction(text = "Check for updates now") {
scope.launch { scope.launch {
updateStatus = "Checking…" updateStatus = "Checking the ${settings.updateChannel.label} channel…"
when (val result = env.updater.check()) { when (val result = env.updater.check(settings.updateChannel)) {
is Updater.Result.UpToDate -> { is Updater.Result.UpToDate -> {
updateStatus = "You're on the latest version." updateStatus =
"You're on the latest ${settings.updateChannel.label} build."
pendingRelease = null pendingRelease = null
} }
is Updater.Result.Available -> { is Updater.Result.Available -> {
pendingRelease = result.release 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 -> { is Updater.Result.Failed -> {
@@ -22,6 +22,8 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight 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 androidx.compose.ui.unit.dp
import org.prolibertate.games.game.euchre.CallTrump import org.prolibertate.games.game.euchre.CallTrump
import org.prolibertate.games.game.euchre.Discard 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.net.MatchController
import org.prolibertate.games.ui.PrimaryAction import org.prolibertate.games.ui.PrimaryAction
import org.prolibertate.games.ui.ScreenScaffold 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.common.PlayingCardView
import org.prolibertate.games.ui.theme.FeltGreenDark import org.prolibertate.games.ui.theme.FeltGreenDark
@@ -83,7 +84,7 @@ fun EuchreScreen(
.background(FeltGreenDark), .background(FeltGreenDark),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
TrickArea(current, cardWidth) TrickArea(current, localSeat, cardWidth)
} }
notice?.let { notice?.let {
@@ -93,7 +94,7 @@ fun EuchreScreen(
when (current.phase) { when (current.phase) {
EuchrePhase.BID_ROUND_1, EuchrePhase.BID_ROUND_1,
EuchrePhase.BID_ROUND_2, EuchrePhase.BID_ROUND_2,
-> BiddingControls(current, legal) { controller.submit(it) } -> BiddingControls(current, localSeat, legal) { controller.submit(it) }
EuchrePhase.HAND_OVER -> Text( EuchrePhase.HAND_OVER -> Text(
text = current.log.lastOrNull().orEmpty(), text = current.log.lastOrNull().orEmpty(),
@@ -146,40 +147,96 @@ private fun ScoreBar(state: EuchreState, localSeat: Int) {
} }
} }
@Composable /**
private fun TrickArea(state: EuchreState, cardWidth: androidx.compose.ui.unit.Dp) { * Where a seat sits relative to you: 0 is you, then clockwise round the table.
Column(horizontalAlignment = Alignment.CenterHorizontally) { * Euchre seats are numbered clockwise and partners sit opposite, so 2 is always
state.upCard?.let { up -> * your partner.
Text("Turned up", color = MaterialTheme.colorScheme.onPrimary) */
PlayingCardView(card = up, width = cardWidth) 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"
} }
if (state.trick.isNotEmpty()) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { /**
state.trick.forEach { played -> * The table. Each card sits on the side of the player who played it and is
Column(horizontalAlignment = Alignment.CenterHorizontally) { * 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, 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 ->
Column(
modifier = Modifier.align(Alignment.Center),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text( Text(
text = "Seat ${played.seat}", text = "Turned up",
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimary, color = MaterialTheme.colorScheme.onPrimary,
) )
PlayingCardView(card = played.card, width = cardWidth) 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. if (state.upCard == null && state.trick.isEmpty() && state.phase == EuchrePhase.PLAYING) {
Row( Text(
modifier = Modifier.padding(top = 12.dp), text = if (state.turn == localSeat) "Lead a card" else "Waiting…",
horizontalArrangement = Arrangement.spacedBy(4.dp), color = MaterialTheme.colorScheme.onPrimary,
) { modifier = Modifier.align(Alignment.Center),
state.handCounts.forEachIndexed { seat, count -> )
if (seat != state.turn) return@forEachIndexed
repeat(count.coerceAtMost(5)) {
CardBackView(width = cardWidth * 0.4f)
} }
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 @Composable
private fun BiddingControls( private fun BiddingControls(
state: EuchreState, state: EuchreState,
localSeat: Int,
legal: List<EuchreMove>, legal: List<EuchreMove>,
onMove: (EuchreMove) -> Unit, onMove: (EuchreMove) -> Unit,
) { ) {
if (legal.isEmpty()) { if (legal.isEmpty()) {
Text("Waiting for seat ${state.turn}…") val waitingOn = relativePosition(state.turn, localSeat)
Text("Waiting for ${seatLabel(waitingOn).lowercase()} to bid…")
return 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)) { Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text( Text(
text = if (state.phase == EuchrePhase.BID_ROUND_1) { text = when {
"Order up ${state.upCard?.label.orEmpty()}?" state.phase != EuchrePhase.BID_ROUND_1 -> "Name a suit for trump"
} else { youAreDealer -> "Pick up ${upCard?.label.orEmpty()} for trump, or pass?"
"Name a suit" else -> "Order up ${upCard?.label.orEmpty()} for trump, or pass?"
}, },
fontWeight = FontWeight.Bold, 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( Row(
modifier = Modifier.horizontalScroll(rememberScrollState()), modifier = Modifier.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(6.dp), horizontalArrangement = Arrangement.spacedBy(6.dp),
@@ -211,11 +289,16 @@ private fun BiddingControls(
legal.forEach { move -> legal.forEach { move ->
val label = when (move) { val label = when (move) {
is Pass -> "Pass" is Pass -> "Pass"
is OrderUp -> if (move.alone) "Order up alone" else "Order up" is OrderUp -> when {
is CallTrump -> { youAreDealer && move.alone -> "Pick it up, alone"
(if (move.alone) "Alone " else "") + move.suit.symbol 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 Discard -> "Discard ${move.card.label}"
is PlayCard -> move.card.label is PlayCard -> move.card.label
} }
@@ -234,7 +317,7 @@ private fun HandRow(
state: EuchreState, state: EuchreState,
seat: Int, seat: Int,
legal: List<EuchreMove>, legal: List<EuchreMove>,
cardWidth: androidx.compose.ui.unit.Dp, cardWidth: Dp,
onPlay: (EuchreMove) -> Unit, onPlay: (EuchreMove) -> Unit,
) { ) {
val hand = state.hands.getOrNull(seat).orEmpty() 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.FeltGreenDark
import org.prolibertate.games.ui.theme.TeamColours 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. * The Sequence board.
* *
@@ -191,8 +198,16 @@ private fun BoardCell(
.clip(RoundedCornerShape(size * 0.12f)) .clip(RoundedCornerShape(size * 0.12f))
.background(if (card == null) MaterialTheme.colorScheme.primary else Color.White) .background(if (card == null) MaterialTheme.colorScheme.primary else Color.White)
.border( .border(
width = if (highlighted) 2.dp else 0.5.dp, // Playable squares are outlined heavily rather than tinted: the
color = if (highlighted) MaterialTheme.colorScheme.tertiary else Color(0x33000000), // 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), shape = RoundedCornerShape(size * 0.12f),
) )
.clickable { onClick() }, .clickable { onClick() },
@@ -8,11 +8,29 @@ import android.provider.Settings
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import java.io.File import java.io.File
import java.net.HttpURLConnection import java.net.HttpURLConnection
import java.net.URL 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. * Over-the-air updates from the repository's GitHub Releases.
* *
@@ -23,56 +41,135 @@ import java.net.URL
*/ */
class Updater(private val activity: Activity) { 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 { 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 object UpToDate : Result
data class Failed(val reason: String) : 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 { try {
val latest = fetchLatestRelease() val latest = fetchLatestRelease(channel)
?: return@withContext Result.Failed("No published release found.") ?: return@withContext Result.Failed(
if (latest.versionCode <= installedVersionCode()) { "No ${channel.label.lowercase()} build has been published yet."
Result.UpToDate )
} else {
Result.Available(latest) 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) { } catch (e: Exception) {
Result.Failed(e.message ?: "Update check failed.") Result.Failed(e.message ?: "Update check failed.")
} }
} }
private fun fetchLatestRelease(): Release? { /**
val url = URL("https://api.github.com/repos/$OWNER/$REPO/releases/latest") * Production reads GitHub's "latest release", which by definition skips
val connection = (url.openConnection() as HttpURLConnection).apply { * 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" requestMethod = "GET"
setRequestProperty("Accept", "application/vnd.github+json") setRequestProperty("Accept", "application/vnd.github+json")
setRequestProperty("User-Agent", "ProLibertateGames-Updater") setRequestProperty("User-Agent", "ProLibertateGames-Updater")
connectTimeout = 15_000 connectTimeout = 15_000
readTimeout = 15_000 readTimeout = 15_000
} }
private fun getJsonObject(url: String): JSONObject? {
val connection = openConnection(url)
try { try {
if (connection.responseCode != 200) return null if (connection.responseCode != 200) return null
val json = JSONObject(connection.inputStream.bufferedReader().use { it.readText() }) return 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
} finally { } finally {
connection.disconnect() 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. */ /** Downloads the APK and hands it to the system installer. */
suspend fun downloadAndInstall(release: Release): String? = withContext(Dispatchers.IO) { suspend fun downloadAndInstall(release: Release): String? = withContext(Dispatchers.IO) {
try { try {