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
+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 {