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