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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user