From 7939395c3cbae2f51f2d32ddbf365890450a5265 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 22:07:32 +0000 Subject: [PATCH] Install dev and production side by side, in the hunting sett A dev build now installs under its own applicationId, org.prolibertate.games.dev, so it is a separate app to Android and sits beside the production copy instead of replacing it. Each channel still updates itself normally, because an update within a channel keeps the same package. Three things move together off one isDevBuild flag, because a dev package wearing the production name and colours is exactly the mix-up that having both installed is meant to prevent: - applicationId gets a .dev suffix; - the launcher label becomes "Pro Libertate Games DEV"; - the cloth becomes Wallace Hunting, the same threadcount with green where the clan sett has red. The name and the colours reach the resources through resValue, so app_name, tartan_field, splash_background and ic_launcher_background are generated by the build rather than declared in res/values. The icon vectors reference @color/tartan_field instead of a literal, which is how one set of vectors serves both colourways without a second copy of the paths. BuildConfig.DEV_BUILD carries the same flag into Kotlin, where AppSett picks the Compose sett for the splash, and WallaceHuntingSett is derived from the same threadcount builder as WallaceSett so the two cannot drift. versionName now tracks isDevBuild rather than the CI stamp alone, so an unstamped local build cannot claim a channel its applicationId contradicts. Separate packages also mean a channel switch can no longer be refused as a downgrade; the settings copy and the README said it could, and now say what actually happens. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JDuAayWXLRoUNTsfE79Mg1 --- README.md | 53 +++++++++++- app/build.gradle.kts | 49 ++++++++++- .../java/org/prolibertate/games/ui/AppRoot.kt | 6 +- .../prolibertate/games/ui/SettingsScreen.kt | 10 ++- .../org/prolibertate/games/ui/theme/Tartan.kt | 48 +++++++++-- .../org/prolibertate/games/update/Updater.kt | 7 +- .../res/drawable/ic_launcher_background.xml | 82 ++++++++++--------- app/src/main/res/mipmap/ic_launcher.xml | 82 ++++++++++--------- app/src/main/res/mipmap/ic_launcher_round.xml | 82 ++++++++++--------- app/src/main/res/values/colors.xml | 15 +++- app/src/main/res/values/strings.xml | 6 +- 11 files changed, 303 insertions(+), 137 deletions(-) diff --git a/README.md b/README.md index db40f69..e088d63 100644 --- a/README.md +++ b/README.md @@ -51,12 +51,20 @@ them. - **Production** — stable builds, published from `main`. - **Dev** — preview builds, published from `dev` on every green CI run. + The two install as separate apps, so both can be kept on one device — see + [Both channels on one device](#both-channels-on-one-device). + ## Branding The launcher icon is a single square of the Wallace tartan set on the bias, and the app opens on the same cloth carrying the name in a Celtic uncial hand for two seconds before fading into the menu. +A dev build wears **Wallace Hunting** instead — the same threadcount with green +where the clan sett has red — and installs as *Pro Libertate Games DEV*. That is +what tells the two copies apart when both are on the phone; see +[Both channels on one device](#both-channels-on-one-device). + The tartan is drawn, not an image: `ui/theme/Tartan.kt` lays the sett down as warp and then as weft at half opacity, which is what produces the blended squares instead of a flat grid. The icon is the same construction as a vector @@ -72,7 +80,9 @@ Two honest caveats: and the narrow black guard sits *between two red blocks*. The icon is generated from the same numbers, so the icon and the splash cannot drift. Weave scale is derived from the sett rather than fixed, so changing the - threadcount rescales both instead of silently zooming in. + threadcount rescales both instead of silently zooming in. The hunting + colourway is the same threadcount passed a different field colour, not a + second copy of the numbers, so it cannot fall out of step either. - **The typeface is bundled under a licence.** Uncial Antiqua, © 2011 Brian J. Bonislawsky DBA Astigmatic, Reserved Font Name "Uncial Antiqua", used under the SIL Open Font License 1.1. The full licence is at @@ -101,6 +111,11 @@ smaller than a fingertip. Requires JDK 17 and the Android SDK (compileSdk 34). +A local build is a dev build: it installs as `org.prolibertate.games.dev` under +the name *Pro Libertate Games DEV* in the hunting sett, so it goes on beside an +installed release rather than over it. Set `PLG_CHANNEL=production` to build the +production package instead. + ## CI - `.github/workflows/ci.yml` — builds, tests and lints on `dev` and on pull @@ -161,9 +176,39 @@ 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. + +### Both channels on one device + +A dev build installs under its own `applicationId`, `org.prolibertate.games.dev`, +so it is a separate app as far as Android is concerned and can sit beside the +production copy rather than replacing it. Each channel still updates itself +normally, because an update within a channel keeps the same package. + +Three things move together, all off `isDevBuild` in `app/build.gradle.kts`: + +| | Production | Dev | +| --- | --- | --- | +| `applicationId` | `org.prolibertate.games` | `org.prolibertate.games.dev` | +| Launcher name | Pro Libertate Games | Pro Libertate Games DEV | +| Sett | Wallace (red) | Wallace Hunting (green) | + +Anything that is not an explicit `PLG_CHANNEL=production` build is a dev build, +so a local `assembleDebug` is a dev build too and installs alongside a release +without uninstalling anything. + +They have to move together. A dev package wearing the production name and +colours is precisely the mix-up that having both installed is meant to prevent, +which is why one flag drives all three rather than each being set by hand. The +name and the colours reach the resources through `resValue`, so `app_name`, +`tartan_field`, `splash_background` and `ic_launcher_background` are generated +by the build and are deliberately *not* in `res/values/` — the icon vectors +reference `@color/tartan_field` rather than a literal, which is how one set of +vectors serves both colourways. `BuildConfig.DEV_BUILD` carries the same flag +into Kotlin, where `AppSett` picks the matching Compose sett for the splash. + +Switching channels in Settings therefore installs the other channel's app +alongside this one and leaves this one in place; the settings screen says so +when the selected channel is not the installed one. The debug keystore is committed on purpose: every build is signed with the same key, so an OTA update installs over the previous one instead of being rejected diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 94c7eeb..94b98c4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -16,22 +16,63 @@ val buildChannel = System.getenv("PLG_CHANNEL").orEmpty() val runNumber = (System.getenv("GITHUB_RUN_NUMBER") ?: "").toIntOrNull() ?: 0 val stampVersion = buildChannel.isNotEmpty() && runNumber > 0 +// Anything that is not an explicit production build is a dev build: ci.yml +// stamps "dev", and a local build with nothing set counts as dev too. +// +// This one flag drives everything that makes a dev build a separate app rather +// than a replacement for the production one — its own applicationId, its own +// launcher name, and the hunting sett in place of the clan sett. They have to +// move together: a dev package wearing the production name and colours is +// exactly the confusion having both installed is meant to avoid. +val isDevBuild = buildChannel != "production" + android { namespace = "org.prolibertate.games" compileSdk = 34 defaultConfig { - applicationId = "org.prolibertate.games" + // A dev build installs under its own package, so a dev copy and a + // production copy can sit side by side on one device instead of one + // replacing the other. The namespace above is deliberately left alone: + // R and BuildConfig stay in org.prolibertate.games for both, so no + // source has to care which package it ended up installed as. + applicationId = if (isDevBuild) "org.prolibertate.games.dev" else "org.prolibertate.games" minSdk = 24 targetSdk = 34 // The -dev suffix in versionName is what the installed app reads to - // know its own channel, so it must survive into the shipped APK. + // know its own channel, so it must survive into the shipped APK. It + // tracks isDevBuild rather than the stamp alone, so an unstamped local + // build cannot end up claiming a channel its applicationId contradicts. versionCode = if (stampVersion) runNumber else 1 versionName = when { stampVersion && buildChannel == "production" -> "1.0.$runNumber" stampVersion -> "1.0.$runNumber-dev" - else -> "1.0.0-dev" + isDevBuild -> "1.0.0-dev" + else -> "1.0.0" } + + // The launcher label. With both copies on the phone the app drawer shows + // two entries, and this is what names them apart. + resValue( + "string", + "app_name", + if (isDevBuild) "Pro Libertate Games DEV" else "Pro Libertate Games", + ) + + // The one colour that separates the two setts: Wallace red for + // production, Wallace Hunting green for dev. The icon vectors reference + // this rather than a literal, and Tartan.kt picks the matching Compose + // colour off DEV_BUILD below — the two have to stay in step or the + // launcher icon and the splash it opens on would be different cloth. + resValue("color", "tartan_field", if (isDevBuild) "#1E7A3C" else "#E01B24") + // Shades of the same field, so a launch does not flash the wrong colour + // before the tartan is drawn: splash_background is held behind the + // window for that instant, ic_launcher_background is the icon's flat + // fallback. + resValue("color", "splash_background", if (isDevBuild) "#0D2E17" else "#4A0B10") + resValue("color", "ic_launcher_background", if (isDevBuild) "#145C2D" else "#B3121B") + + buildConfigField("boolean", "DEV_BUILD", isDevBuild.toString()) } signingConfigs { @@ -59,6 +100,8 @@ android { buildFeatures { compose = true + // For DEV_BUILD, which is how the Compose tartan picks its sett. + buildConfig = true } composeOptions { diff --git a/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt b/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt index acde3b5..fd6f842 100644 --- a/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt +++ b/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt @@ -30,8 +30,10 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import org.prolibertate.games.R import org.prolibertate.games.game.GameCatalog import org.prolibertate.games.game.GameCategory import org.prolibertate.games.game.GameDescriptor @@ -196,7 +198,9 @@ fun MainMenuScreen( Scaffold( topBar = { TopAppBar( - title = { Text("Pro Libertate Games") }, + // The installed name rather than a literal, so a dev build says + // so in its own title bar as well as in the app drawer. + title = { Text(stringResource(R.string.app_name)) }, actions = { TextButton(onClick = onSettings) { Text("Settings") } }, ) } diff --git a/app/src/main/java/org/prolibertate/games/ui/SettingsScreen.kt b/app/src/main/java/org/prolibertate/games/ui/SettingsScreen.kt index 5e072a7..ebfea7e 100644 --- a/app/src/main/java/org/prolibertate/games/ui/SettingsScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/SettingsScreen.kt @@ -139,12 +139,14 @@ fun SettingsScreen( 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. + // Each channel installs under its own package, so the other + // one arrives as a second app rather than replacing this + // one — no downgrade for Android to refuse. 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.", + "The two channels install as separate apps, so this adds the " + + "${settings.updateChannel.label} build alongside this one and " + + "leaves it in place.", style = MaterialTheme.typography.bodySmall, ) } diff --git a/app/src/main/java/org/prolibertate/games/ui/theme/Tartan.kt b/app/src/main/java/org/prolibertate/games/ui/theme/Tartan.kt index af4aaa2..21e96a1 100644 --- a/app/src/main/java/org/prolibertate/games/ui/theme/Tartan.kt +++ b/app/src/main/java/org/prolibertate/games/ui/theme/Tartan.kt @@ -10,6 +10,7 @@ import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.rotate import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import org.prolibertate.games.BuildConfig /** One band of a tartan sett, measured in threads. */ data class TartanStripe(val color: Color, val threads: Int) @@ -18,6 +19,15 @@ val WallaceRed = Color(0xFFE01B24) val WallaceBlack = Color(0xFF0C0C0C) val WallaceGold = Color(0xFFF2C518) +/** + * The green the hunting sett puts where the clan sett puts red. + * + * Must match `tartan_field` for a dev build in app/build.gradle.kts, which is + * what the launcher icon is woven from — otherwise the icon and the splash it + * opens on would be visibly different cloth. + */ +val WallaceHuntingGreen = Color(0xFF1E7A3C) + /** * Expands a half-sett into a full repeat by reflecting it. * @@ -42,14 +52,42 @@ fun List.pivoted(): List = * between red and black; * - the narrow black guard sits **between two red blocks**, splitting the red * into pairs. + * + * The threadcount itself lives in [wallaceSett], because the hunting colourway + * below is the same cloth in a different colour and must not drift from it. */ -val WallaceSett: List = listOf( +val WallaceSett: List = wallaceSett(WallaceRed) + +/** + * Wallace Hunting: the same threadcount woven in green. + * + * Hunting setts are the clan cloth in muted, outdoor colours, and for Wallace + * that is the one substitution — green for the red — with the black ground and + * the yellow overstripe untouched. Deriving it from the same builder rather + * than writing the numbers out twice is what keeps that true: a change to the + * threadcount cannot land in one colourway and miss the other. + */ +val WallaceHuntingSett: List = wallaceSett(WallaceHuntingGreen) + +/** The Wallace threadcount woven with [field] in place of the red. */ +private fun wallaceSett(field: Color): List = listOf( TartanStripe(WallaceBlack, 4), - TartanStripe(WallaceRed, 32), + TartanStripe(field, 32), TartanStripe(WallaceBlack, 32), TartanStripe(WallaceGold, 4), ).pivoted() +/** + * The cloth this build wears: the clan sett on production, the hunting sett on + * dev. + * + * A dev build installs as its own app beside the production one, so it has to + * be recognisable at a glance in the app drawer and on the splash — the green + * says which of the two copies just opened without anything having to be read. + */ +val AppSett: List = + if (BuildConfig.DEV_BUILD) WallaceHuntingSett else WallaceSett + /** Threads in one full repeat of a sett. */ val List.threadsPerRepeat: Int get() = sumOf { it.threads } @@ -62,7 +100,7 @@ val List.threadsPerRepeat: Int get() = sumOf { it.threads } * and stops reading as tartan at all. */ fun threadWidthFor( - sett: List = WallaceSett, + sett: List = AppSett, across: Dp, repeats: Float = 2f, ): Dp = across / (sett.threadsPerRepeat * repeats) @@ -77,7 +115,7 @@ fun threadWidthFor( * past the edges of the canvas so the corners stay covered once it is turned. */ fun DrawScope.drawTartan( - sett: List = WallaceSett, + sett: List = AppSett, threadSize: Float, rotationDegrees: Float = 0f, ) { @@ -135,7 +173,7 @@ fun DrawScope.drawTartan( @Composable fun TartanBackground( modifier: Modifier = Modifier, - sett: List = WallaceSett, + sett: List = AppSett, threadWidth: Dp = 2.dp, rotationDegrees: Float = 0f, ) { diff --git a/app/src/main/java/org/prolibertate/games/update/Updater.kt b/app/src/main/java/org/prolibertate/games/update/Updater.kt index fbfa3f4..4d4f2c5 100644 --- a/app/src/main/java/org/prolibertate/games/update/Updater.kt +++ b/app/src/main/java/org/prolibertate/games/update/Updater.kt @@ -22,6 +22,10 @@ import java.net.URL * 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. + * + * The two also carry different applicationIds — a dev build installs as + * `…games.dev` — so a switch installs the other channel beside this one instead + * of over it, and both can be kept on the same device. */ enum class UpdateChannel(val label: String, val blurb: String) { PRODUCTION("Production", "Stable builds released from main"), @@ -53,7 +57,8 @@ class Updater(private val activity: Activity) { /** * [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. + * version of the same line — and, because the channels have separate + * applicationIds, it lands as a second app rather than an upgrade. */ data class Available(val release: Release, val isChannelSwitch: Boolean) : Result diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml index 0b06f99..f4ce5b0 100644 --- a/app/src/main/res/drawable/ic_launcher_background.xml +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -8,6 +8,12 @@ centre of a wide black band, and the narrow black guard splits the red into pairs. + The stripes reference @color/tartan_field rather than a literal, because the + same threadcount is woven in two colourways: Wallace red on a production + build, Wallace Hunting green on a dev one, so the two installs are told apart + in the app drawer by the icon alone. The build generates that colour; see + resValue in app/build.gradle.kts. + Warp is opaque, weft laid over it at half opacity, which is what gives the blended squares rather than a flat grid. Paths run well past the viewport because the group is rotated 45 degrees; the overspill keeps the corners @@ -22,43 +28,43 @@ android:rotation="45" android:pivotX="54.0" android:pivotY="54.0"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/mipmap/ic_launcher.xml b/app/src/main/res/mipmap/ic_launcher.xml index 22102a1..5dc5e35 100644 --- a/app/src/main/res/mipmap/ic_launcher.xml +++ b/app/src/main/res/mipmap/ic_launcher.xml @@ -8,6 +8,12 @@ centre of a wide black band, and the narrow black guard splits the red into pairs. + The stripes reference @color/tartan_field rather than a literal, because the + same threadcount is woven in two colourways: Wallace red on a production + build, Wallace Hunting green on a dev one, so the two installs are told apart + in the app drawer by the icon alone. The build generates that colour; see + resValue in app/build.gradle.kts. + Warp is opaque, weft laid over it at half opacity, which is what gives the blended squares rather than a flat grid. Paths run well past the viewport because the group is rotated 45 degrees; the overspill keeps the corners @@ -22,43 +28,43 @@ android:rotation="45" android:pivotX="54.0" android:pivotY="54.0"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/mipmap/ic_launcher_round.xml b/app/src/main/res/mipmap/ic_launcher_round.xml index 22102a1..5dc5e35 100644 --- a/app/src/main/res/mipmap/ic_launcher_round.xml +++ b/app/src/main/res/mipmap/ic_launcher_round.xml @@ -8,6 +8,12 @@ centre of a wide black band, and the narrow black guard splits the red into pairs. + The stripes reference @color/tartan_field rather than a literal, because the + same threadcount is woven in two colourways: Wallace red on a production + build, Wallace Hunting green on a dev one, so the two installs are told apart + in the app drawer by the icon alone. The build generates that colour; see + resValue in app/build.gradle.kts. + Warp is opaque, weft laid over it at half opacity, which is what gives the blended squares rather than a flat grid. Paths run well past the viewport because the group is rotated 45 degrees; the overspill keeps the corners @@ -22,43 +28,43 @@ android:rotation="45" android:pivotX="54.0" android:pivotY="54.0"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 97c6599..338becd 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -1,9 +1,16 @@ + - #B3121B #FF000000 #FFFFFFFF - - #4A0B10 + + + #0C0C0C + #F2C518 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1eff924..41e3314 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,4 +1,8 @@ + - Pro Libertate Games