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:
@@ -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."
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
@Composable
|
||||||
private fun TrickArea(state: EuchreState, cardWidth: androidx.compose.ui.unit.Dp) {
|
private fun TrickArea(state: EuchreState, localSeat: Int, cardWidth: Dp) {
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
Box(modifier = Modifier.fillMaxSize().padding(4.dp)) {
|
||||||
|
|
||||||
|
// The turn card sits in the middle of the table during bidding.
|
||||||
state.upCard?.let { up ->
|
state.upCard?.let { up ->
|
||||||
Text("Turned up", color = MaterialTheme.colorScheme.onPrimary)
|
Column(
|
||||||
PlayingCardView(card = up, width = cardWidth)
|
modifier = Modifier.align(Alignment.Center),
|
||||||
}
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
if (state.trick.isNotEmpty()) {
|
) {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
|
||||||
state.trick.forEach { played ->
|
|
||||||
Column(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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user