Bootstrap Pilgrims of Katan: rebrand, Gitea CI and OTA
Build and Release APK / Build APK and publish release (push) Canceled after 0s

Import of ProLibertateGames (upstream main, GPL-3.0) rebranded for
Wallacenet and repointed at git.wallacenet.ca.

- Identity: applicationId ca.wallacenet.pilgrimsofkatan (+ .dev for the dev
  channel), launcher names "Pilgrims of Katan" / "Pilgrims of Katan DEV",
  neutral hexagon branding replacing the Wallace tartan. Uncial Antiqua keeps
  its OFL licence, and the GPL-3.0 LICENSE plus upstream attribution stay.
- OTA: Updater.kt now reads this repo's own Gitea releases
  (/api/v1/repos/devadmin/PilgrimsOfKatan/releases...) and BuildConfig.
  OTA_ENABLED is on; Gitea answers the same JSON shape the code already parsed.
- Build numbers: versionCode = CI run number + plgVersionRunOffset (1000),
  with the tag and versionName derived from the same figure. The offset is
  permanent, because Gitea Actions restarts run numbers at 1 and a reset would
  otherwise make Android reject the next build as a downgrade.
- CI: ci.yml publishes a dev prerelease from dev, release.yml a production
  release from main, both through scripts/publish-release.sh (Gitea REST API,
  runnable by hand until an Actions runner is registered).
- Docs: docs/CI.md records the channel, numbering and runner decisions;
  docs/README.md seeds the per-game RULES-* conventions; CLAUDE.md carries the
  agent ground rules.

Verified: ./gradlew assembleDebug and testDebugUnitTest green on JDK 17 /
compileSdk 34, both variants stamped, installing side by side under the new
identity.

Co-Authored-By: Paperclip <[email protected]>
This commit is contained in:
adrianoftyriel
2026-10-13 22:03:27 +00:00
co-authored by Paperclip
parent d27c2db894
commit fad4778ad8
32 changed files with 760 additions and 773 deletions
+37 -37
View File
@@ -1,15 +1,19 @@
name: CI 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.
# release.
# #
# On a push to dev it also publishes the build as a dev-channel prerelease, so # On a push to dev it also publishes the build as a dev-channel prerelease on
# the in-app updater can offer it to anyone who has switched to that channel. # this Gitea instance, so the in-app updater can offer it to anyone on the dev
# Prereleases are used deliberately: GitHub's "latest release" endpoint skips # channel. Prereleases are used deliberately: the "latest release" endpoint
# them, so a production-channel user never sees a dev build. Pull requests only # skips them, so a production-channel user never sees a dev build. Pull
# build and check — they never publish, which keeps a PR from a fork from # requests only build and check — they never publish, which keeps a PR from a
# shipping anything. # fork from shipping anything.
#
# These files live in .github/workflows/ on purpose: Gitea Actions reads that
# path as well as .gitea/workflows/, so one file serves both. Publishing goes
# through Gitea's REST API (the same routes the app's updater reads) rather
# than the `gh` CLI, which a runner image need not carry.
on: on:
push: push:
branches: [ dev ] branches: [ dev ]
@@ -17,7 +21,7 @@ on:
branches: [ main, dev ] branches: [ main, dev ]
workflow_dispatch: workflow_dispatch:
# Needed to publish the dev-channel prerelease below. # Needed by the job token to create the release and attach the APK.
permissions: permissions:
contents: write contents: write
@@ -37,10 +41,7 @@ jobs:
# <series>.0-dev: they are never published and must not claim a channel # <series>.0-dev: they are never published and must not claim a channel
# version. # version.
# #
# This condition has to stay in step with the publish steps' own. Stamping # This condition has to stay in step with the publish steps' own.
# a build the publish steps would ship, or shipping one this left
# unstamped, both end the same way — an APK claiming versionCode 1, which
# the updater can never see as an upgrade over anything.
PLG_CHANNEL: ${{ github.ref == 'refs/heads/dev' && 'dev' || '' }} PLG_CHANNEL: ${{ github.ref == 'refs/heads/dev' && 'dev' || '' }}
steps: steps:
- name: Checkout - name: Checkout
@@ -68,7 +69,7 @@ jobs:
- name: Upload debug APK - name: Upload debug APK
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: ProLibertateGames-debug-apk name: PilgrimsOfKatan-debug-apk
path: app/build/outputs/apk/debug/app-debug.apk path: app/build/outputs/apk/debug/app-debug.apk
if-no-files-found: error if-no-files-found: error
retention-days: 14 retention-days: 14
@@ -78,7 +79,7 @@ jobs:
if: always() if: always()
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: ProLibertateGames-reports name: PilgrimsOfKatan-reports
path: | path: |
app/build/reports/ app/build/reports/
if-no-files-found: ignore if-no-files-found: ignore
@@ -88,22 +89,27 @@ jobs:
# branch itself, after it has built, tested and linted clean above. # branch itself, after it has built, tested and linted clean above.
# #
# Gated on the branch alone rather than on the event, which is how # Gated on the branch alone rather than on the event, which is how
# release.yml has always gated main. A pull request is still excluded # release.yml gates main. A pull request is still excluded without
# without needing to name it: a PR build runs against refs/pull/N/merge, # needing to name it: a PR build runs against refs/pull/N/merge, never
# never refs/heads/dev, so a fork still cannot ship anything. # refs/heads/dev, so a fork still cannot ship anything.
- name: Stage dev APK - name: Stage dev APK
id: stage id: stage
if: github.ref == 'refs/heads/dev' if: github.ref == 'refs/heads/dev'
run: | run: |
set -euo pipefail set -euo pipefail
# The series comes from gradle.properties, which is the same file the # The series and the offset come from gradle.properties, which is
# build read when it stamped the APK — so the tag and what is inside # the same file the build read when it stamped the APK — so the tag
# it cannot name different versions. # and what is inside it cannot name different versions. The number
# is run number + offset, exactly the arithmetic build.gradle.kts
# did for versionCode.
series="$(sed -n 's/^plgVersionSeries=//p' gradle.properties)" series="$(sed -n 's/^plgVersionSeries=//p' gradle.properties)"
offset="$(sed -n 's/^plgVersionRunOffset=//p' gradle.properties)"
test -n "$series" test -n "$series"
version="v${series}.${{ github.run_number }}-dev" test -n "$offset"
stamp="$(( ${{ github.run_number }} + offset ))"
version="v${series}.${stamp}-dev"
src="app/build/outputs/apk/debug/app-debug.apk" src="app/build/outputs/apk/debug/app-debug.apk"
dest="ProLibertateGames-${version}.apk" dest="PilgrimsOfKatan-${version}.apk"
cp "$src" "$dest" cp "$src" "$dest"
echo "version=$version" >> "$GITHUB_OUTPUT" echo "version=$version" >> "$GITHUB_OUTPUT"
echo "apk=$dest" >> "$GITHUB_OUTPUT" echo "apk=$dest" >> "$GITHUB_OUTPUT"
@@ -111,17 +117,11 @@ jobs:
- name: Publish dev channel prerelease - name: Publish dev channel prerelease
if: github.ref == 'refs/heads/dev' if: github.ref == 'refs/heads/dev'
env: env:
GH_TOKEN: ${{ github.token }} # The job's built-in token: contents: write is declared above.
run: | GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
set -euo pipefail RELEASE_TAG: ${{ steps.stage.outputs.version }}
gh release create "${{ steps.stage.outputs.version }}" \ RELEASE_APK: ${{ steps.stage.outputs.apk }}
"${{ steps.stage.outputs.apk }}" \ RELEASE_KIND: prerelease
--repo "${{ github.repository }}" \ RELEASE_TARGET: ${{ github.sha }}
--target "${{ github.sha }}" \ # One script, also runnable by hand — see docs/CI.md.
--title "ProLibertateGames ${{ steps.stage.outputs.version }}" \ run: ./scripts/publish-release.sh
--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."
+33 -38
View File
@@ -1,13 +1,17 @@
name: Build and Release APK name: Build and Release APK
# Build an APK and publish it as a GitHub Release whenever commits land on main # Production-channel build. Runs on a push to main, or by hand
# (for example when a pull request is merged). Can also be run manually. # (workflow_dispatch), and publishes to this Gitea instance's releases — which
# is the channel the in-app updater reads. See docs/CI.md.
#
# Publishing goes through Gitea's REST API rather than the `gh` CLI, which a
# runner image need not carry.
on: on:
push: push:
branches: [ main ] branches: [ main ]
workflow_dispatch: workflow_dispatch:
# Allow the built-in token to create releases and upload the APK. # Needed by the job token to create the release and attach the APK.
permissions: permissions:
contents: write contents: write
@@ -19,14 +23,13 @@ concurrency:
jobs: jobs:
build: build:
name: Build APK and publish release name: Build APK and publish release
# workflow_dispatch can be aimed at any branch from the Actions UI. Releases # workflow_dispatch can be aimed at any branch from the Actions UI. The
# are what the in-app updater installs, so only main may produce one — # production stamp belongs to main alone — anything else is a no-op rather
# anything else is a no-op rather than shipping unreviewed code to everyone. # than shipping unreviewed code to everybody.
if: github.ref == 'refs/heads/main' if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: env:
# Stamps the APK as a production-channel build, versioned with the run # Stamps the APK as a production-channel build.
# number that matches the tag published below.
PLG_CHANNEL: production PLG_CHANNEL: production
steps: steps:
- name: Checkout - name: Checkout
@@ -42,52 +45,44 @@ jobs:
- name: Set up Android SDK - name: Set up Android SDK
uses: android-actions/setup-android@v4 uses: android-actions/setup-android@v4
- name: Build debug APK - name: Build production APK
run: ./gradlew assembleDebug --no-daemon --stacktrace run: ./gradlew assembleDebug --no-daemon --stacktrace
- name: Stage APK for release - name: Stage APK
id: stage id: stage
run: | run: |
set -euo pipefail set -euo pipefail
# The series comes from gradle.properties, which is the same file the # The series and the offset come from gradle.properties, which is
# build read when it stamped the APK — so the tag and what is inside # the same file the build read when it stamped the APK — so the tag
# it cannot name different versions. # and what is inside it cannot name different versions. The number
# is run number + offset, exactly the arithmetic build.gradle.kts
# did for versionCode.
series="$(sed -n 's/^plgVersionSeries=//p' gradle.properties)" series="$(sed -n 's/^plgVersionSeries=//p' gradle.properties)"
offset="$(sed -n 's/^plgVersionRunOffset=//p' gradle.properties)"
test -n "$series" test -n "$series"
version="v${series}.${{ github.run_number }}" test -n "$offset"
stamp="$(( ${{ github.run_number }} + offset ))"
version="v${series}.${stamp}"
src="app/build/outputs/apk/debug/app-debug.apk" src="app/build/outputs/apk/debug/app-debug.apk"
dest="ProLibertateGames-${version}.apk" dest="PilgrimsOfKatan-${version}.apk"
cp "$src" "$dest" cp "$src" "$dest"
# The same build again under a name that never changes. GitHub's
# /releases/latest/download/<name> redirect needs a constant file
# name, so this second copy is what makes a permanent download link
# possible — the versioned copy stays as the traceable one.
cp "$src" "ProLibertateGames.apk"
echo "version=$version" >> "$GITHUB_OUTPUT" echo "version=$version" >> "$GITHUB_OUTPUT"
echo "apk=$dest" >> "$GITHUB_OUTPUT" echo "apk=$dest" >> "$GITHUB_OUTPUT"
- name: Upload APK as workflow artifact - name: Upload APK as workflow artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: ProLibertateGames-apk name: PilgrimsOfKatan-apk
path: ${{ steps.stage.outputs.apk }} path: ${{ steps.stage.outputs.apk }}
if-no-files-found: error if-no-files-found: error
- name: Create GitHub release - name: Publish production release
env: env:
GH_TOKEN: ${{ github.token }} # The job's built-in token: contents: write is declared above.
run: | GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
set -euo pipefail RELEASE_TAG: ${{ steps.stage.outputs.version }}
gh release create "${{ steps.stage.outputs.version }}" \ RELEASE_APK: ${{ steps.stage.outputs.apk }}
"${{ steps.stage.outputs.apk }}" \ RELEASE_KIND: release
"ProLibertateGames.apk" \ RELEASE_TARGET: ${{ github.sha }}
--repo "${{ github.repository }}" \ # One script, also runnable by hand — see docs/CI.md.
--target "${{ github.sha }}" \ run: ./scripts/publish-release.sh
--title "ProLibertateGames ${{ steps.stage.outputs.version }}" \
--notes "Automated release built from \`${{ github.sha }}\` on push to \`main\`.
Download **${{ steps.stage.outputs.apk }}** below and install it on an Android device (you may need to allow *Install unknown apps*). This is a debug-signed build.
Both files below are the same build. \`ProLibertateGames.apk\` is a copy under a fixed name, so this link always fetches the newest release and can be bookmarked or shared:
https://github.com/${{ github.repository }}/releases/latest/download/ProLibertateGames.apk"
+74 -95
View File
@@ -1,114 +1,93 @@
# Working on this repo with Claude # Working on this repo (agent notes)
## Nothing a session does triggers CI. Dispatch it explicitly. Pilgrims of Katan — an Android collection of card and board games, playable
against AI or over Wi-Fi (LAN). Bootstrapped from
[adrianoftyriel/ProLibertateGames](https://github.com/adrianoftyriel/ProLibertateGames)
(GPL-3.0, kept as the `upstream` remote) for Wallacenet. Canonical repo:
<https://git.wallacenet.ca/devadmin/PilgrimsOfKatan>.
GitHub creates **no workflow run** for anything this sandbox does — pushing a ## Ground rules
branch, opening a pull request, or merging one. All of it goes through the same
integration, and GitHub suppresses event-triggered runs from it. The pull
request will say it was opened by the repo owner; that is only how the UI
attributes it, and it does not change the suppression.
This was measured, not assumed: - **Licences travel.** `LICENSE` is GPL-3.0 and must stay. The Uncial Antiqua
font ships with its OFL licence at `docs/licenses/UncialAntiqua-OFL.txt`;
never delete or edit either. Attribution to the upstream lives in the README.
- **No Catan trademarks or brand assets**, and none of the upstream's Wallace
clan branding (it was removed in the rebrand; see README → Branding). The
brand is: *Pilgrims of Katan*, `ca.wallacenet.pilgrimsofkatan` (+ `.dev`),
slate blue / dev green, hexagon emblem.
- **The existing catalogue is the regression safety net.** Keep every game
green; new games (Catan) arrive as separate tasks, not edits to old ones.
All 22 games are `available = true`.
- **Docs conventions**: every game gets `docs/RULES-<id>.md` (index and gaps in
`docs/README.md`); build/CI/release decisions go in `docs/CI.md`. Update the
docs when behaviour changes — they are the check against the rules code.
| Action | Result | ## Build
| --- | --- |
| Human pushes a Claude-authored commit (`d165e5ef`, `469c900e`) | CI runs |
| Session pushes `dev` / `main` (`43394b8`, `742add4`) | no run |
| Session opens a PR against `dev` (#1) | no run |
| Session calls `workflow_dispatch` | **runs** |
`workflow_dispatch` works because it creates a run directly instead of relying ```sh
on an event. It is the only mechanism available from a session, so every build export JAVA_HOME=... # JDK 17
has to be asked for by hand. export ANDROID_HOME=... # SDK with platform 34, build-tools 34
./gradlew assembleDebug # dev-channel APK (default)
PLG_CHANNEL=production ./gradlew assembleDebug # production APK
./gradlew testDebugUnitTest # JVM unit tests (rules/AI/net/score)
./gradlew lintDebug # Android lint (CI runs it)
```
## Verify before merging, without publishing On the Wallacenet project workspace a ready toolchain sits at
`../.toolchain/` (`jdk17/`, `android-sdk/`) — export `JAVA_HOME` and
`ANDROID_HOME` to it rather than installing again. Both variants must build:
they install side by side (different applicationId, launcher name, colour).
`ci.yml` builds, tests and lints on any ref, but its staging and publishing ## Channels and OTA
steps are gated on `github.ref == 'refs/heads/dev'`. Dispatching it on a
**feature branch** therefore gives the full check — build, unit tests, Android
lint — and publishes nothing.
So the working loop is: One flag — `isDevBuild` in `app/build.gradle.kts` — drives applicationId,
launcher name and brand colour together. Never set them individually.
1. Commit and push the feature branch. In-app OTA points at this repo's own Gitea releases
2. Dispatch `ci.yml` on that branch. Wait for green. This is the real review (`/api/v1/repos/devadmin/PilgrimsOfKatan/releases…`) and is **on**:
gate, and it is the only verification that exists (see below). `BuildConfig.OTA_ENABLED` is `true`. The channel is a decision already made and
3. Open the PR through the API and merge it there, so the change is reviewable recorded — `docs/CI.md` documents the endpoints, the build-number scheme, and
and the merge commit is honest about what happened. Neither step builds what publishing requires. Setting the flag to `false` is the kill switch.
anything.
4. Dispatch the target branch's workflow to actually build and publish:
`ci.yml` on `dev`, `release.yml` on `main`.
Skipping step 4 leaves a branch whose head was never built and no release. ## CI
## Merging is a shipping action `.github/workflows/` — Actions-compatible so the Gitea instance runs them.
`ci.yml`: build + test + lint + artifact, and a dev prerelease from `dev`.
`release.yml`: production release from `main`. Both publish through
`scripts/publish-release.sh` (Gitea REST API), which is also runnable by hand.
Both long-lived branches publish, so a merge is a release, not just an **No Actions runner is registered on the instance yet**, so the workflows are
integration: idle until one is — the local gradle commands above are the source of truth,
and CI green means those plus lint. Do not treat a workflow that has never run
as evidence.
- **`dev`** → `ci.yml` publishes a `v<series>.N-dev` **prerelease**, offered by the ## Code map
in-app updater to anyone on the Dev channel.
- **`main`** → `release.yml` publishes a **production** release, which the
updater installs for everyone on the Production channel.
Confirm before shipping to `main`. Note that `release.yml` runs `assembleDebug` - `game/` — rules engines and AI, **pure Kotlin, no Android types**, unit
only — no tests, no lint — so a `main` release is only as verified as the `dev` tested. `Catalog.kt` is the registry (id, title, blurb, options, `available`).
build it replays. - `net/` — wire protocol, LAN transport (mDNS + TCP), lobby, host-authoritative
match driver; `score/` the scorekeeper sheet, also pure.
- `ui/` — Compose screens; `ui/theme/Theme.kt` holds the brand palette
(`BrandField`/`BrandDeep`/`BrandAccent`) and table accents.
- `update/` — OTA policy (`UpdatePolicy.kt`, pure and tested) and the gated
fetch (`Updater.kt`).
- `settings/` — DataStore preferences.
## Gradle does not run in the sandbox Host is authoritative over the wire; clients send intents, host re-validates
against the rules. Keep `game/` free of Android imports — the JVM tests depend
on it.
`./gradlew` cannot resolve the Android plugin here: the proxy blocks ## Git
`dl.google.com` with a 403. No local build, no local test run, no local lint. A
dispatched CI run is the only way to find out whether a change compiles.
## A permanent fix, if you want one - Branches: `main` (production), `dev` (integration). Feature branches → PR
into `dev`.
- Commits by Paperclip agents end with
`Co-Authored-By: Paperclip <[email protected]>`.
- Pull upstream fixes with `git pull upstream` (the GitHub source); do not
point `origin` anywhere but this repo's Gitea URL.
The suppression is a property of the sandbox's credential, so it cannot be ## Legacy files
fixed from inside a session. Giving the environment a personal access token to
push with would make pushes and PRs behave normally for everyone. Until then,
the dispatch step above is the workaround.
## Bash output is filtered before it reaches the model `.claude/` and `.rtk/` are the upstream author's assistant tooling, harmless
and left as inherited. This file supersedes the upstream's CLAUDE.md (which
`.claude/settings.json` registers a `PreToolUse` hook on `Bash` that rewrites documented their GitHub sandbox; that history is still in git).
each command through [rtk](https://github.com/rtk-ai/rtk): `git status` runs as
`rtk git status`, and rtk compresses the output. Write commands normally — the
rewrite is transparent and costs nothing to invoke.
Measured on this repo rather than quoted from rtk's README:
| Command | Raw | Filtered | Saved |
| --- | --- | --- | --- |
| `git diff HEAD~3` | 521 KB | 28 KB | 95% |
| `grep -rn "fun " app/src/main` | 117 KB | 19 KB | 84% |
| `find . -name "*.kt"` | 7.7 KB | 1.7 KB | 79% |
| `git log -20` | 20 KB | 5.3 KB | 74% |
| `git diff --stat` | 4.0 KB | 4.0 KB | 1% |
| `ls` of a single package | 50 B | 62 B | −24% |
The saving is all in the large outputs, which is the point — a full diff of a
few commits is otherwise most of a context window. Already-compact output grows
slightly, because rtk adds a header; at those sizes it does not matter. `rtk
gain` reports what the current session has actually saved.
Two limits are worth knowing. The `Read`, `Grep` and `Glob` tools are not
`Bash`, so they never reach the hook and are unaffected; a deliberate `rg` or
`find` in a shell *is* filtered. And filtering never truncates a file: `cat`
becomes `rtk read`, which was checked byte-for-byte against this repo's longest
Kotlin source (`GameSetupScreen.kt`, 946 lines) and came back identical. Commit
messages survive the rewrite intact, quotes and colons included. To bypass
filtering for one command, run `rtk proxy <cmd>`.
## rtk is reinstalled at the start of every session
The container starts empty, so `.claude/hooks/session-start.sh` installs rtk
before the session does anything. It pins `RTK_VERSION`, because the installer
otherwise asks `api.github.com` for the latest tag and the proxy answers 403 for
every repository except this one — the lookup fails and takes the install with
it. Bump that variable by hand.
The hook is written not to matter when it fails. Outside the sandbox it installs
nothing and just prints the command to run, since dropping a binary into
someone's `~/.local/bin` uninvited is not its business; if the download fails it
says so and exits clean. With rtk absent the rewrite hook no-ops, and the only
consequence is verbose output.
+95 -113
View File
@@ -1,8 +1,14 @@
# Pro Libertate Games # Pilgrims of Katan
A collection of card and board games for Android, playable against the computer A collection of card and board games for Android, playable against the computer
or against other people over Wi-Fi (LAN). or against other people over Wi-Fi (LAN).
Derived from [ProLibertateGames](https://github.com/adrianoftyriel/ProLibertateGames)
by adrianoftyriel, used under the GNU GPL v3 — see [LICENSE](LICENSE). Game
rules documentation lives under [docs/](docs/README.md); build, CI and release
decisions live in [docs/CI.md](docs/CI.md). Catan itself arrives in later work;
this app ships the reference catalogue unchanged as the regression safety net.
## What works today ## What works today
The app shell is complete and every game in the catalogue is playable end to end: The app shell is complete and every game in the catalogue is playable end to end:
@@ -111,55 +117,52 @@ it: every rule above is a pure function and unit-tested on the JVM.
- **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. **There is no channel to - **Updates** — check on launch, or check on demand. The channel is this
choose**: a build updates from its own channel and no other. A production project's own Gitea releases ([docs/CI.md](docs/CI.md)), and **there is no
install is offered production builds from `main`; a dev install is offered dev channel to choose**: a build updates from its own channel and no other. The
builds from `dev`. The settings screen states which one is running rather than settings screen states which one is running rather than offering a switch.
offering a switch. `BuildConfig.OTA_ENABLED` is the kill switch, not a preference.
The check on launch **only ever speaks when there is a build to install**. It The check on launch **only ever speaks when there is a build to install**. It
runs behind the splash and waits for it to clear, and an app that is already runs behind the splash and waits for it to clear, and an app that is already
current — or a phone that cannot reach GitHub — says nothing, because opening current — or a phone that cannot reach the release server — says nothing,
the app was not a question. The button in Settings is a question, so it because opening the app was not a question. The button in Settings is a
answers every time, including "you're on the latest" and whatever went wrong. question, so it answers every time, including "you're on the latest" and
whatever went wrong.
Wanting both means installing both — they are separate apps and each updates Wanting both means installing both — they are separate apps and each updates
itself, see [Both channels on one device](#both-channels-on-one-device). itself, see [Both channels on one device](#both-channels-on-one-device).
## Branding ## Branding
The launcher icon is a single square of the Wallace tartan set on the bias, and Neutral, original, and deliberately *not* Catan's: no third-party marks or
the app opens on the same cloth carrying the name in a Celtic uncial hand for two trade dress ship in this app (rebrand scope: WALA-118). The launcher icon is a
seconds before fading into the menu. hexagonal ring around a solid core — a game tile, not any game's logo — drawn
as vector drawables so it stays sharp at every density and needs no PNGs. The
app opens on the brand field fading to its deep shade, the name set in a Celtic
uncial hand for two seconds before it fades into the menu.
A dev build wears **Wallace Hunting** instead — the same threadcount with green A dev build wears the **dev green** field where production wears **slate
where the clan sett has red — and installs as *Pro Libertate Games DEV*. That is blue**, and installs as *Pilgrims of Katan DEV*. That is what tells the two
what tells the two copies apart when both are on the phone; see copies apart when both are on the phone; see
[Both channels on one device](#both-channels-on-one-device). [Both channels on one device](#both-channels-on-one-device). The palette lives
in `ui/theme/Theme.kt` (`BrandField`, `BrandDeep`, `BrandAccent`) and the two
The tartan is drawn, not an image: `ui/theme/Tartan.kt` lays the sett down as channel colours are generated per build by `resValue` in `app/build.gradle.kts`
warp and then as weft at half opacity, which is what produces the blended — the icon vectors reference `@color/brand_field` rather than a literal, which
squares instead of a flat grid. The icon is the same construction as a vector is how one set of vectors serves both colourways.
drawable, so it stays sharp at every density and needs no PNGs.
Two honest caveats: Two honest caveats:
- **The sett is the published threadcount: `K/4 R32 K32 Y/4`.** The slashes are
pivots, so `WallaceSett` holds the half-sett and reflects it out to
`K4 R32 K32 Y4 K32 R32` — 136 threads, about half black, half red, with a
yellow line worth 3%. Two details define the pattern and are easy to get
backwards: the yellow overstripe runs down the *centre of a wide black band*,
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. 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. - **The typeface is bundled under a licence.** Uncial Antiqua, © 2011 Brian J.
Bonislawsky DBA Astigmatic, Reserved Font Name "Uncial Antiqua", used under Bonislawsky DBA Astigmatic, Reserved Font Name "Uncial Antiqua", used under
the SIL Open Font License 1.1. The full licence is at the SIL Open Font License 1.1. The full licence is at
[docs/licenses/UncialAntiqua-OFL.txt](docs/licenses/UncialAntiqua-OFL.txt) and [docs/licenses/UncialAntiqua-OFL.txt](docs/licenses/UncialAntiqua-OFL.txt) and
must stay with the font — the OFL requires it to be distributed alongside. must stay with the font — the OFL requires it to be distributed alongside.
- **The upstream's clan tartan is gone; its code is not.** The Wallace tartan
was the upstream's brand and left with the rebrand. The GPL-licensed history
that produced this app remains in git, and the source stays at
[adrianoftyriel/ProLibertateGames](https://github.com/adrianoftyriel/ProLibertateGames)
as the `upstream` remote.
## Screen sizes ## Screen sizes
@@ -183,92 +186,70 @@ smaller than a fingertip.
Requires JDK 17 and the Android SDK (compileSdk 34). Requires JDK 17 and the Android SDK (compileSdk 34).
A local build is a dev build: it installs as `org.prolibertate.games.dev` under A local build is a dev build: it installs as
the name *Pro Libertate Games DEV* in the hunting sett, so it goes on beside an `ca.wallacenet.pilgrimsofkatan.dev` under the name *Pilgrims of Katan DEV* on
installed release rather than over it. Set `PLG_CHANNEL=production` to build the the dev green field, so it goes on beside a production copy rather than over it.
production package instead. Set `PLG_CHANNEL=production` to build the production package
(`ca.wallacenet.pilgrimsofkatan`, slate blue).
## CI ## CI
Full decision record: [docs/CI.md](docs/CI.md).
- `.github/workflows/ci.yml` — builds, tests and lints on `dev` and on pull - `.github/workflows/ci.yml` — builds, tests and lints on `dev` and on pull
requests into `dev` or `main`. Its APK artifact is a dev build, versioned requests into `dev` or `main`, and publishes a dev prerelease from `dev`.
`<series>.0-dev` — the series being whatever `plgVersionSeries` says in - `.github/workflows/release.yml` — builds and publishes the production release
`gradle.properties`, currently **1.3**. on push to `main`, or by hand (`workflow_dispatch`).
- `.github/workflows/release.yml` — on push to `main`, builds the APK and - Both publish through [scripts/publish-release.sh](scripts/publish-release.sh)
publishes it as a GitHub Release tagged `v<series>.<run-number>`. The in-app updater against Gitea's REST API, which is also runnable by hand. **No Actions runner
reads that tag, which is why the APK's `versionCode` is the same run number. is registered on the instance yet**, so until one is, the local build is the
guaranteed path (`./gradlew assembleDebug && ./gradlew testDebugUnitTest`)
### Installing without the app and releases go out via the script.
The newest production build is always at this link, which never changes:
**https://github.com/adrianoftyriel/ProLibertateGames/releases/latest/download/ProLibertateGames.apk**
Every release carries the same APK twice — once named for its version, and once
as plain `ProLibertateGames.apk`, because GitHub's `/releases/latest/download/`
redirect needs a file name that stays put. The in-app updater deliberately picks
the versioned copy, so an installed build can always be traced back to its tag.
There is no equivalent link for the dev channel: `/releases/latest` skips
prereleases by design, and that is exactly what keeps a production install from
being handed a dev build. A dev build is reached by the in-app updater of a dev
install — which looks at the prerelease list rather than that endpoint — or from
the releases page.
### The version series ### The version series
A version is **`<series>.<run number>`**. The series is set by hand, once, in A version is **`<series>.<build number>`**. Both values are set by hand, once,
`gradle.properties`: in `gradle.properties`:
```properties ```properties
plgVersionSeries=1.3 plgVersionSeries=1.4
plgVersionRunOffset=1000
``` ```
Both workflows read that same property when they name a release tag, and the Both workflows read those same properties when they name a release tag, and the
build reads it when it stamps the APK, so the tag and what is inside it cannot build reads them when it stamps the APK, so the tag and what is inside it cannot
name different versions. Bumping a series is a one-line change to that file and name different versions. Changing either is a one-line change to that file and
nothing else. nothing else — but they are not equivalent:
The number after it is the CI run number and keeps climbing on its own, which is - The **series** is a name for people. Bumping it changes nothing about
what makes a series bump safe: `versionCode` is the run number *alone*, so ordering.
going from 1.0 to 1.1 never looks like a downgrade to Android and never - The **build number** is what the machines compare: `versionCode`, the tag's
interrupts an OTA update. The series is a name for people; the run number is trailing number, and the number in `versionName`, all the same figure —
what the machines compare. `CI run number + plgVersionRunOffset`. Because the offset never changes and
the run number only climbs, going from 1.4 to 1.5 never looks like a downgrade
to Android and never interrupts an OTA update.
The offset exists because Gitea Actions restarts run numbers at 1 for each
workflow: without a floor, the first release of a fresh instance would collide
with the `versionCode 1` every unstamped local build carries, and a counter
reset would make Android refuse the next build outright. Full reasoning in
[docs/CI.md](docs/CI.md).
### Update channels ### Update channels
Both pipelines publish, to two separate channels: `ci.yml` stamps builds of `dev` as dev-channel, `release.yml` stamps `main` as
production, so an APK's identity is decided where it is built. Publishing goes
to this project's Gitea releases, and the rules it keeps:
| Channel | Published by | Tag | Marked | - production comes only from `main` (`release.yml` refuses any other branch);
| --- | --- | --- | --- | - dev prereleases are marked so the "latest release" endpoint can never hand one
| Production | `release.yml`, push to `main` | `v<series>.<n>` | release | to a production install;
| Dev | `ci.yml`, any build of `dev` | `v<series>.<n>-dev` | prerelease | - pull requests build and check but never publish.
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.
**Both pipelines gate publishing on the branch rather than on what triggered
the build**, so a manual run from the Actions tab publishes exactly as a push
does — useful when a push cannot fire the workflow itself, which is the case
for anything pushed by an automation token. The safety property is unchanged,
because publishing still happens only after that same job has built, tested and
linted clean. A pull request is still excluded without having to be named: a PR
build runs against `refs/pull/<n>/merge`, never `refs/heads/dev`.
In `ci.yml` the `PLG_CHANNEL` stamp is gated on the same condition as the
publish steps, and the two have to stay in step. Stamping a build that then
ships, or shipping one that was left unstamped, both end with an APK claiming
`versionCode` 1 — which the updater can never see as an upgrade over anything.
`release.yml` 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` **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 is the build number of the workflow that produced it (run number + offset), and
separately. Dev is on 69 while production is on 40, so the numbers say nothing the two workflows count separately, so the numbers say nothing across the
across the divide. divide.
That is why **an install only ever updates within its own channel**, and why That is why **an install only ever updates within its own channel**, and why
there is no setting for it. The app reads the `-dev` suffix in its own there is no setting for it. The app reads the `-dev` suffix in its own
@@ -280,18 +261,18 @@ unit-tested on the JVM like the rules engines are.
### Both channels on one device ### Both channels on one device
A dev build installs under its own `applicationId`, `org.prolibertate.games.dev`, A dev build installs under its own `applicationId`,
so it is a separate app as far as Android is concerned and can sit beside the `ca.wallacenet.pilgrimsofkatan.dev`, so it is a separate app as far as Android
production copy rather than replacing it. Each channel still updates itself 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. normally, because an update within a channel keeps the same package.
Three things move together, all off `isDevBuild` in `app/build.gradle.kts`: Three things move together, all off `isDevBuild` in `app/build.gradle.kts`:
| | Production | Dev | | | Production | Dev |
| --- | --- | --- | | --- | --- | --- |
| `applicationId` | `org.prolibertate.games` | `org.prolibertate.games.dev` | | `applicationId` | `ca.wallacenet.pilgrimsofkatan` | `ca.wallacenet.pilgrimsofkatan.dev` |
| Launcher name | Pro Libertate Games | Pro Libertate Games DEV | | Launcher name | Pilgrims of Katan | Pilgrims of Katan DEV |
| Sett | Wallace (red) | Wallace Hunting (green) | | Brand field | slate blue | deep green |
Anything that is not an explicit `PLG_CHANNEL=production` build is a dev build, 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 so a local `assembleDebug` is a dev build too and installs alongside a release
@@ -301,11 +282,11 @@ 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, 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 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`, name and the colours reach the resources through `resValue`, so `app_name`,
`tartan_field`, `splash_background` and `ic_launcher_background` are generated `brand_field` and `splash_background` are generated by the build and are
by the build and are deliberately *not* in `res/values/` — the icon vectors deliberately *not* in `res/values/` — the icon vectors reference
reference `@color/tartan_field` rather than a literal, which is how one set of `@color/brand_field` rather than a literal, which is how one set of vectors
vectors serves both colourways. `BuildConfig.DEV_BUILD` carries the same flag serves both colourways. `BuildConfig.DEV_BUILD` carries the same flag into
into Kotlin, where `AppSett` picks the matching Compose sett for the splash. Kotlin, where `BrandField` picks the matching Compose colour for the splash.
Because they are separate apps, having both is a matter of installing both, and Because they are separate apps, having both is a matter of installing both, and
each then updates itself down its own line. Nothing in the app moves a phone each then updates itself down its own line. Nothing in the app moves a phone
@@ -319,7 +300,7 @@ for a signature mismatch.
## Rules ## Rules
The exact rules implemented, and the options that vary them, are documented per The exact rules implemented, and the options that vary them, are documented per
game: game (index: [docs/README.md](docs/README.md)):
- [Euchre](docs/RULES-euchre.md) - [Euchre](docs/RULES-euchre.md)
- [Sequence](docs/RULES-sequence.md) — **note the caveat about the board layout** - [Sequence](docs/RULES-sequence.md) — **note the caveat about the board layout**
@@ -367,7 +348,8 @@ game/ rules engines and AI — pure Kotlin, no Android, unit-tested
net/ wire protocol, LAN transport, lobby, match driver net/ wire protocol, LAN transport, lobby, match driver
score/ scorekeeper sheet — pure Kotlin, unit-tested — and its store score/ scorekeeper sheet — pure Kotlin, unit-tested — and its store
settings/ DataStore-backed preferences settings/ DataStore-backed preferences
update/ GitHub Releases OTA updater, and the channel rule — pure, tested update/ OTA updater against this repo's Gitea releases, and the
channel rule — pure, tested
ui/ Compose screens ui/ Compose screens
``` ```
+49 -26
View File
@@ -16,6 +16,20 @@ val buildChannel = System.getenv("PLG_CHANNEL").orEmpty()
val runNumber = (System.getenv("GITHUB_RUN_NUMBER") ?: "").toIntOrNull() ?: 0 val runNumber = (System.getenv("GITHUB_RUN_NUMBER") ?: "").toIntOrNull() ?: 0
val stampVersion = buildChannel.isNotEmpty() && runNumber > 0 val stampVersion = buildChannel.isNotEmpty() && runNumber > 0
// The fixed floor every stamped build number is measured from, in
// gradle.properties. Gitea Actions restarts its run numbers at 1 for each
// workflow, so an unstamped build (versionCode 1) and the first release of a
// fresh instance would otherwise collide — and once a phone has installed
// versionCode N, Android refuses anything smaller as a downgrade. Adding a
// permanent offset is what makes "the counter never resets" true by
// construction: it is read from one place, it never changes, and the tag,
// versionName and versionCode all get the same number (see docs/CI.md).
val runOffset = (project.findProperty("plgVersionRunOffset") as String?)?.toIntOrNull() ?: 0
// The stamped build number: what the release tag, the versionName and the
// versionCode are all named after.
val buildNumber = runNumber + runOffset
// The version series, from gradle.properties. Both workflows read the same // The version series, from gradle.properties. Both workflows read the same
// property when they name a release tag, so the tag and the APK inside it // property when they name a release tag, so the tag and the APK inside it
// cannot disagree about which series they belong to. // cannot disagree about which series they belong to.
@@ -26,35 +40,40 @@ val versionSeries = (project.findProperty("plgVersionSeries") as String?) ?: "1.
// //
// This one flag drives everything that makes a dev build a separate app rather // 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 // 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 // launcher name, and its own brand colour. They have to move together: a dev
// move together: a dev package wearing the production name and colours is // package wearing the production name and colours is exactly the confusion
// exactly the confusion having both installed is meant to avoid. // having both installed is meant to avoid.
val isDevBuild = buildChannel != "production" val isDevBuild = buildChannel != "production"
android { android {
// Deliberately left at the upstream source package: R and BuildConfig stay
// in org.prolibertate.games for both channels, so no source file has to
// care which applicationId it ended up installed as. The *installed*
// identity is the applicationId below — that is what the rebrand changes
// (WALA-118), not the JVM package names.
namespace = "org.prolibertate.games" namespace = "org.prolibertate.games"
compileSdk = 34 compileSdk = 34
defaultConfig { defaultConfig {
// A dev build installs under its own package, so a dev copy and a // 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 // production copy can sit side by side on one device instead of one
// replacing the other. The namespace above is deliberately left alone: // replacing the other.
// R and BuildConfig stay in org.prolibertate.games for both, so no applicationId = if (isDevBuild) "ca.wallacenet.pilgrimsofkatan.dev" else "ca.wallacenet.pilgrimsofkatan"
// source has to care which package it ended up installed as.
applicationId = if (isDevBuild) "org.prolibertate.games.dev" else "org.prolibertate.games"
minSdk = 24 minSdk = 24
targetSdk = 34 targetSdk = 34
// The -dev suffix in versionName is what the installed app reads to // The -dev suffix in versionName is what the installed app reads to
// know its own channel, so it must survive into the shipped APK. It // know its own channel, so it must survive into the shipped APK. It
// tracks isDevBuild rather than the stamp alone, so an unstamped local // tracks isDevBuild rather than the stamp alone, so an unstamped local
// build cannot end up claiming a channel its applicationId contradicts. // build cannot end up claiming a channel its applicationId contradicts.
// versionCode stays the run number and nothing else, so it keeps // versionCode is the stamped build number and nothing else, so it keeps
// climbing across a change of series: bumping 1.0 to 1.1 must not make // climbing across a change of series: bumping 1.0 to 1.1 must not make
// an update look like a downgrade to Android. // an update look like a downgrade to Android. An unstamped build is 1,
versionCode = if (stampVersion) runNumber else 1 // below every release, which is the honest answer for a build nobody
// stamped.
versionCode = if (stampVersion) buildNumber else 1
versionName = when { versionName = when {
stampVersion && buildChannel == "production" -> "$versionSeries.$runNumber" stampVersion && buildChannel == "production" -> "$versionSeries.$buildNumber"
stampVersion -> "$versionSeries.$runNumber-dev" stampVersion -> "$versionSeries.$buildNumber-dev"
isDevBuild -> "$versionSeries.0-dev" isDevBuild -> "$versionSeries.0-dev"
else -> "$versionSeries.0" else -> "$versionSeries.0"
} }
@@ -64,23 +83,27 @@ android {
resValue( resValue(
"string", "string",
"app_name", "app_name",
if (isDevBuild) "Pro Libertate Games DEV" else "Pro Libertate Games", if (isDevBuild) "Pilgrims of Katan DEV" else "Pilgrims of Katan",
) )
// The one colour that separates the two setts: Wallace red for // The one colour that separates the two installs: slate blue on
// production, Wallace Hunting green for dev. The icon vectors reference // production, deep green on dev. The launcher icon and the splash are
// this rather than a literal, and Tartan.kt picks the matching Compose // both built from it, so the drawer shows at a glance which of the two
// colour off DEV_BUILD below — the two have to stay in step or the // copies is which — the two have to stay in step or the icon and the
// launcher icon and the splash it opens on would be different cloth. // splash it opens on would be different brands.
resValue("color", "tartan_field", if (isDevBuild) "#1E7A3C" else "#E01B24") resValue("color", "brand_field", if (isDevBuild) "#2A6B54" else "#2B4A78")
// Shades of the same field, so a launch does not flash the wrong colour // A darker shade of the same hue, held behind the window for the
// before the tartan is drawn: splash_background is held behind the // instant before the splash draws, so a launch never flashes the wrong
// window for that instant, ic_launcher_background is the icon's flat // colour.
// fallback. resValue("color", "splash_background", if (isDevBuild) "#153B2F" else "#182A44")
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()) buildConfigField("boolean", "DEV_BUILD", isDevBuild.toString())
// In-app OTA updates, pointed at this project's own Gitea releases
// (docs/CI.md). True in this build — the channel exists. Flipping it
// false is the kill switch: every check then reports that and fetches
// nothing, with the machinery and its tests still compiled.
buildConfigField("boolean", "OTA_ENABLED", "true")
} }
signingConfigs { signingConfigs {
@@ -108,7 +131,7 @@ android {
buildFeatures { buildFeatures {
compose = true compose = true
// For DEV_BUILD, which is how the Compose tartan picks its sett. // For DEV_BUILD, which is how the Compose splash picks its brand colour.
buildConfig = true buildConfig = true
} }
+4 -2
View File
@@ -1,7 +1,9 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- OTA updates from GitHub Releases, plus LAN play over TCP sockets. --> <!-- LAN play over TCP sockets, plus hand-a-down permission for in-app
installs — the OTA updater installs releases published on this repo's
Gitea; see docs/CI.md. -->
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Network Service Discovery needs multicast to see hosts on the LAN. --> <!-- Network Service Discovery needs multicast to see hosts on the LAN. -->
@@ -23,7 +25,7 @@
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.ProLibertateGames"> android:theme="@style/Theme.PilgrimsOfKatan">
<!-- No fixed orientation: the UI adapts to portrait, landscape and <!-- No fixed orientation: the UI adapts to portrait, landscape and
tablet widths. configChanges keeps Compose state across rotation tablet widths. configChanges keeps Compose state across rotation
@@ -12,7 +12,7 @@ import org.prolibertate.games.score.ScorekeeperRepository
import org.prolibertate.games.settings.SettingsRepository import org.prolibertate.games.settings.SettingsRepository
import org.prolibertate.games.ui.AppEnv import org.prolibertate.games.ui.AppEnv
import org.prolibertate.games.ui.AppRoot import org.prolibertate.games.ui.AppRoot
import org.prolibertate.games.ui.theme.ProLibertateTheme import org.prolibertate.games.ui.theme.PilgrimsTheme
import org.prolibertate.games.update.Updater import org.prolibertate.games.update.Updater
import java.util.UUID import java.util.UUID
@@ -43,7 +43,7 @@ class MainActivity : ComponentActivity() {
) )
setContent { setContent {
ProLibertateTheme { PilgrimsTheme {
AppRoot(env) AppRoot(env)
} }
} }
@@ -308,6 +308,6 @@ class LanTransport(private val context: Context) : Transport {
} }
const val CONNECT_TIMEOUT_MS = 8_000 const val CONNECT_TIMEOUT_MS = 8_000
const val WIFI_LOCK_TAG = "ProLibertateGames:lan" const val WIFI_LOCK_TAG = "PilgrimsOfKatan:lan"
} }
} }
@@ -33,7 +33,7 @@ const val PURPOSE_SCOREKEEPER = "scorekeeper"
/** Service type and name advertised over mDNS. */ /** Service type and name advertised over mDNS. */
const val SERVICE_TYPE = "_plgames._tcp" const val SERVICE_TYPE = "_plgames._tcp"
const val SERVICE_NAME = "ProLibertateGames" const val SERVICE_NAME = "PilgrimsOfKatan"
val protocolJson: Json = Json { val protocolJson: Json = Json {
ignoreUnknownKeys = true ignoreUnknownKeys = true
@@ -49,7 +49,7 @@ fun LaunchUpdateCheck(env: AppEnv, visible: Boolean) {
} }
// Held back behind the splash, so an update prompt cannot appear over the // Held back behind the splash, so an update prompt cannot appear over the
// tartan before the app itself has. // splash before the app itself has.
val pending = release val pending = release
if (pending == null || dismissed || !visible) return if (pending == null || dismissed || !visible) return
@@ -4,7 +4,6 @@ import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -20,6 +19,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shadow import androidx.compose.ui.graphics.Shadow
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
@@ -30,8 +30,9 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import org.prolibertate.games.R import org.prolibertate.games.R
import org.prolibertate.games.ui.theme.TartanBackground import org.prolibertate.games.ui.theme.BrandAccent
import org.prolibertate.games.ui.theme.threadWidthFor import org.prolibertate.games.ui.theme.BrandDeep
import org.prolibertate.games.ui.theme.BrandField
/** How long the splash holds at full opacity before fading. */ /** How long the splash holds at full opacity before fading. */
private const val SPLASH_HOLD_MILLIS = 2_000L private const val SPLASH_HOLD_MILLIS = 2_000L
@@ -45,7 +46,11 @@ private const val SPLASH_FADE_MILLIS = 600
val CelticFontFamily = FontFamily(Font(R.font.uncial_antiqua)) val CelticFontFamily = FontFamily(Font(R.font.uncial_antiqua))
/** /**
* The launch screen: Wallace tartan under the name of the house. * The launch screen: the brand field under the name of the game.
*
* The gradient runs from the same hue as the window's `splash_background`
* held behind it, so the first frame and the splash are one brand in two
* shades rather than a colour change.
* *
* Drawn as an overlay rather than a separate destination, so the menu is * Drawn as an overlay rather than a separate destination, so the menu is
* already composed and ready behind it and the fade reveals a live screen * already composed and ready behind it and the fade reveals a live screen
@@ -70,30 +75,17 @@ fun SplashScreen(
onFinished() onFinished()
} }
BoxWithConstraints(modifier = modifier.fillMaxSize().alpha(alpha)) { BoxWithConstraints(
modifier = modifier
.fillMaxSize()
.alpha(alpha)
.background(Brush.verticalGradient(listOf(BrandField, BrandDeep))),
) {
// Read the constraints here and hold them in locals: inside Column the // Read the constraints here and hold them in locals: inside Column the
// implicit receiver becomes ColumnScope, and maxWidth/maxHeight are no // implicit receiver becomes ColumnScope, and maxWidth/maxHeight are no
// longer in scope. // longer in scope.
val shortestSide = minOf(maxWidth, maxHeight).value val shortestSide = minOf(maxWidth, maxHeight).value
// Weave scale is derived from the sett, not a fixed fraction, so a
// couple of full repeats are always on screen whatever the threadcount
// says and whatever the display is.
val threadWidth = threadWidthFor(across = shortestSide.dp, repeats = 2f)
val titleSize = (shortestSide * 0.13f).sp val titleSize = (shortestSide * 0.13f).sp
TartanBackground(
modifier = Modifier.fillMaxSize(),
threadWidth = threadWidth,
// On the bias, matching the icon.
rotationDegrees = 45f,
)
// A scrim keeps the lettering legible over the busiest part of the sett.
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.45f))
)
Column( Column(
modifier = Modifier modifier = Modifier
@@ -103,7 +95,7 @@ fun SplashScreen(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Text( Text(
text = "Pro Libertate", text = "Pilgrims",
style = TextStyle( style = TextStyle(
fontFamily = CelticFontFamily, fontFamily = CelticFontFamily,
fontSize = titleSize, fontSize = titleSize,
@@ -117,11 +109,11 @@ fun SplashScreen(
), ),
) )
Text( Text(
text = "Games", text = "of Katan",
style = TextStyle( style = TextStyle(
fontFamily = CelticFontFamily, fontFamily = CelticFontFamily,
fontSize = titleSize, fontSize = titleSize,
color = Color(0xFFF2C200), color = BrandAccent,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
shadow = Shadow( shadow = Shadow(
color = Color.Black, color = Color.Black,
@@ -19,7 +19,7 @@ import androidx.compose.ui.unit.dp
import org.prolibertate.games.ui.common.CARD_ASPECT import org.prolibertate.games.ui.common.CARD_ASPECT
import org.prolibertate.games.ui.theme.FeltGreenDark import org.prolibertate.games.ui.theme.FeltGreenDark
import org.prolibertate.games.ui.theme.Parchment import org.prolibertate.games.ui.theme.Parchment
import org.prolibertate.games.ui.theme.WallaceGold import org.prolibertate.games.ui.theme.AccentGold
/** /**
* The furniture every patience table needs. * The furniture every patience table needs.
@@ -48,7 +48,7 @@ fun TableSlot(
modifier = modifier modifier = modifier
.width(width) .width(width)
.border( .border(
border = BorderStroke(if (highlighted) 3.dp else 0.dp, WallaceGold), border = BorderStroke(if (highlighted) 3.dp else 0.dp, AccentGold),
shape = RoundedCornerShape(4.dp), shape = RoundedCornerShape(4.dp),
) )
.clickable { onClick() }, .clickable { onClick() },
@@ -48,7 +48,7 @@ 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
import org.prolibertate.games.ui.theme.Parchment import org.prolibertate.games.ui.theme.Parchment
import org.prolibertate.games.ui.theme.WallaceGold import org.prolibertate.games.ui.theme.AccentGold
private const val TITLE = "Klondike" private const val TITLE = "Klondike"
@@ -311,7 +311,7 @@ private fun Slot(
modifier = modifier modifier = modifier
.width(CARD_WIDTH) .width(CARD_WIDTH)
.border( .border(
border = BorderStroke(if (highlighted) 3.dp else 0.dp, WallaceGold), border = BorderStroke(if (highlighted) 3.dp else 0.dp, AccentGold),
shape = RoundedCornerShape(4.dp), shape = RoundedCornerShape(4.dp),
) )
.clickable { onClick() }, .clickable { onClick() },
@@ -41,8 +41,8 @@ import org.prolibertate.games.net.MatchController
import org.prolibertate.games.ui.ScreenScaffold 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.Parchment import org.prolibertate.games.ui.theme.Parchment
import org.prolibertate.games.ui.theme.WallaceGold import org.prolibertate.games.ui.theme.AccentGold
import org.prolibertate.games.ui.theme.WallaceRed import org.prolibertate.games.ui.theme.AccentRed
private const val TITLE = "Peg Solitaire" private const val TITLE = "Peg Solitaire"
@@ -214,15 +214,15 @@ private fun HoleView(
Box(modifier = modifier.size(size), contentAlignment = Alignment.Center) { Box(modifier = modifier.size(size), contentAlignment = Alignment.Center) {
val diameter = size * 0.72f val diameter = size * 0.72f
val ring = when { val ring = when {
selected -> WallaceGold selected -> AccentGold
isDestination -> WallaceGold isDestination -> AccentGold
else -> Parchment.copy(alpha = 0.35f) else -> Parchment.copy(alpha = 0.35f)
} }
Box( Box(
modifier = Modifier modifier = Modifier
.size(diameter) .size(diameter)
.background( .background(
color = if (filled) WallaceRed else FeltGreenDark, color = if (filled) AccentRed else FeltGreenDark,
shape = CircleShape, shape = CircleShape,
) )
.border( .border(
@@ -42,8 +42,8 @@ import org.prolibertate.games.game.yahtzee.scoreOf
import org.prolibertate.games.net.MatchController import org.prolibertate.games.net.MatchController
import org.prolibertate.games.ui.ScreenScaffold import org.prolibertate.games.ui.ScreenScaffold
import org.prolibertate.games.ui.theme.Parchment import org.prolibertate.games.ui.theme.Parchment
import org.prolibertate.games.ui.theme.WallaceGold import org.prolibertate.games.ui.theme.AccentGold
import org.prolibertate.games.ui.theme.WallaceRed import org.prolibertate.games.ui.theme.AccentRed
private const val TITLE = "Yahtzee" private const val TITLE = "Yahtzee"
@@ -183,7 +183,7 @@ private fun Die(face: Int?, held: Boolean, enabled: Boolean, onClick: () -> Unit
.background(if (face == null) Parchment.copy(alpha = 0.3f) else Parchment, RoundedCornerShape(8.dp)) .background(if (face == null) Parchment.copy(alpha = 0.3f) else Parchment, RoundedCornerShape(8.dp))
.border( .border(
width = if (held) 3.dp else 1.dp, width = if (held) 3.dp else 1.dp,
color = if (held) WallaceGold else WallaceRed.copy(alpha = 0.4f), color = if (held) AccentGold else AccentRed.copy(alpha = 0.4f),
shape = RoundedCornerShape(8.dp), shape = RoundedCornerShape(8.dp),
) )
.clickable(enabled = enabled) { onClick() }, .clickable(enabled = enabled) { onClick() },
@@ -193,7 +193,7 @@ private fun Die(face: Int?, held: Boolean, enabled: Boolean, onClick: () -> Unit
text = face?.toString() ?: "·", text = face?.toString() ?: "·",
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = WallaceRed, color = AccentRed,
) )
} }
} }
@@ -263,7 +263,7 @@ private fun CardRow(
}, },
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = if (written == null && open) WallaceRed else MaterialTheme.colorScheme.onSurface, color = if (written == null && open) AccentRed else MaterialTheme.colorScheme.onSurface,
) )
} }
} }
@@ -1,187 +0,0 @@
package org.prolibertate.games.ui.theme
import androidx.compose.foundation.Canvas
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
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)
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.
*
* Tartans are specified as a half-sett between two pivots and woven out to
* both sides, so `A B C` becomes `A B C B` — the pivot stripes are not
* doubled. Writing the data this way keeps it in the same form a threadcount
* is published in, instead of a flat list that has to be kept symmetric by
* hand.
*/
fun List<TartanStripe>.pivoted(): List<TartanStripe> =
if (size < 3) this else this + drop(1).dropLast(1).reversed()
/**
* The Wallace sett, from its published threadcount: **K/4 R32 K32 Y/4**.
*
* The slashes mark the pivots, so this is the half-sett and it reflects out to
* `K4 R32 K32 Y4 K32 R32` — 136 threads, roughly half black, half red, with a
* yellow line worth 3%. Two consequences that are easy to get backwards and
* that define the pattern:
*
* - the yellow overstripe runs down the **centre of a wide black band**, not
* 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<TartanStripe> = 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<TartanStripe> = wallaceSett(WallaceHuntingGreen)
/** The Wallace threadcount woven with [field] in place of the red. */
private fun wallaceSett(field: Color): List<TartanStripe> = listOf(
TartanStripe(WallaceBlack, 4),
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<TartanStripe> =
if (BuildConfig.DEV_BUILD) WallaceHuntingSett else WallaceSett
/** Threads in one full repeat of a sett. */
val List<TartanStripe>.threadsPerRepeat: Int get() = sumOf { it.threads }
/**
* The thread width that fits [repeats] repeats of [sett] across [across].
*
* Scale has to be derived from the sett rather than fixed, because changing a
* threadcount changes how many threads a repeat contains — a fixed width and a
* larger sett silently zooms in until the cloth is a couple of enormous blocks
* and stops reading as tartan at all.
*/
fun threadWidthFor(
sett: List<TartanStripe> = AppSett,
across: Dp,
repeats: Float = 2f,
): Dp = across / (sett.threadsPerRepeat * repeats)
/**
* Draws a tartan by laying the sett down vertically as warp, then across as
* weft at half opacity. The overlap is what produces the blended squares and
* the sense of a woven cloth rather than a printed grid — red warp under black
* weft comes out a darker red, exactly as it does on the loom.
*
* [rotationDegrees] turns the cloth on the bias. The stripes are drawn well
* past the edges of the canvas so the corners stay covered once it is turned.
*/
fun DrawScope.drawTartan(
sett: List<TartanStripe> = AppSett,
threadSize: Float,
rotationDegrees: Float = 0f,
) {
if (sett.isEmpty() || threadSize <= 0f) return
val repeat = sett.sumOf { it.threads } * threadSize
if (repeat <= 0f) return
// Turning a square by 45 degrees needs about 0.21 of its size spare on
// every edge; three quarters is generous enough for any angle.
val pad = if (rotationDegrees == 0f) 0f else size.maxDimension * 0.75f
val left = -pad
val top = -pad
val right = size.width + pad
val bottom = size.height + pad
rotate(degrees = rotationDegrees) {
// Warp: full-height bands.
var x = left - (left.mod(repeat))
while (x < right) {
var offset = x
for (stripe in sett) {
val width = stripe.threads * threadSize
drawRect(
color = stripe.color,
topLeft = Offset(offset, top),
size = Size(width, bottom - top),
)
offset += width
}
x += repeat
}
// Weft: full-width bands, half opacity so the warp shows through.
var y = top - (top.mod(repeat))
while (y < bottom) {
var offset = y
for (stripe in sett) {
val height = stripe.threads * threadSize
drawRect(
color = stripe.color.copy(alpha = 0.5f),
topLeft = Offset(left, offset),
size = Size(right - left, height),
)
offset += height
}
y += repeat
}
}
}
/**
* A tartan fill. [threadWidth] sets the weave scale, so the same cloth can be
* a fine weave on a phone and a coarse one on a tablet without redrawing it.
*/
@Composable
fun TartanBackground(
modifier: Modifier = Modifier,
sett: List<TartanStripe> = AppSett,
threadWidth: Dp = 2.dp,
rotationDegrees: Float = 0f,
) {
Canvas(modifier = modifier) {
drawTartan(
sett = sett,
threadSize = threadWidth.toPx(),
rotationDegrees = rotationDegrees,
)
}
}
@@ -6,6 +6,7 @@ import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import org.prolibertate.games.BuildConfig
/** Card-table green, which the board and table surfaces are drawn on. */ /** Card-table green, which the board and table surfaces are drawn on. */
val FeltGreen = Color(0xFF1B5E20) val FeltGreen = Color(0xFF1B5E20)
@@ -14,6 +15,31 @@ val Parchment = Color(0xFFF4EFE4)
val CardRed = Color(0xFFB3261E) val CardRed = Color(0xFFB3261E)
val CardBlack = Color(0xFF1C1B1F) val CardBlack = Color(0xFF1C1B1F)
/**
* The brand palette for Pilgrims of Katan — original and neutral, shared by
* every game in the catalogue. Slate blue on production, deep green on dev, so
* the two installs are told apart at a glance; both must stay in step with the
* resValues in app/build.gradle.kts or the icon and the splash it opens on
* would be different brands.
*
* The upstream shipped a clan tartan here. This project carries no clan or
* third-party trade dress (WALA-118).
*/
val BrandField: Color = if (BuildConfig.DEV_BUILD) Color(0xFF2A6B54) else Color(0xFF2B4A78)
val BrandDeep: Color = if (BuildConfig.DEV_BUILD) Color(0xFF153B2F) else Color(0xFF182A44)
/** The parchment accent: the emblem on the icon, the second line of the splash. */
val BrandAccent: Color = Color(0xFFEFD9A4)
/**
* Accents used *inside* a game table — a highlight, a held die, a danger mark.
* Deliberately channel-free: what a game shows must not change with which
* install it is running in. Values unchanged from the upstream, so the games
* themselves look exactly as they did.
*/
val AccentRed = Color(0xFFE01B24)
val AccentGold = Color(0xFFF2C518)
val TeamColours: List<Color> = listOf( val TeamColours: List<Color> = listOf(
Color(0xFF1565C0), // blue Color(0xFF1565C0), // blue
Color(0xFFC62828), // red Color(0xFFC62828), // red
@@ -39,7 +65,7 @@ private val LightColours = lightColorScheme(
) )
@Composable @Composable
fun ProLibertateTheme( fun PilgrimsTheme(
darkTheme: Boolean = isSystemInDarkTheme(), darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
@@ -5,13 +5,13 @@ package org.prolibertate.games.update
* *
* This is a property of the build, never a preference. The two channels are * This is a property of the build, never a preference. The two channels are
* published by different workflows and have entirely separate version * published by different workflows and have entirely separate version
* sequences — a dev build's versionCode is its CI run number, a production * sequences — a dev build's versionCode is its CI build number, a production
* build's is its release run number, and the two are unrelated. There is no * build's is its release build number, and the two are unrelated. There is no
* meaning to be had from comparing across them, so nothing does. * meaning to be had from comparing across them, so nothing does.
* *
* They also carry different applicationIds — a dev build installs as * They also carry different applicationIds — a dev build installs as
* `…games.dev` — so both can sit on one device, each updating itself down its * `…pilgrimsofkatan.dev` — so both can sit on one device, each updating
* own line and leaving the other alone. * itself down its own line and leaving the other alone.
*/ */
enum class UpdateChannel(val label: String, val blurb: String) { enum class UpdateChannel(val label: String, val blurb: String) {
PRODUCTION("Production", "Stable builds released from main"), PRODUCTION("Production", "Stable builds released from main"),
@@ -21,7 +21,7 @@ enum class UpdateChannel(val label: String, val blurb: String) {
/** Marks a tag or a version name as belonging to the dev channel. */ /** Marks a tag or a version name as belonging to the dev channel. */
const val DEV_SUFFIX = "-dev" const val DEV_SUFFIX = "-dev"
/** One published build, as read off a GitHub release. */ /** One published build, as read off a repository release. */
data class Release( data class Release(
val tag: String, val tag: String,
val versionCode: Int, val versionCode: Int,
@@ -49,14 +49,14 @@ fun channelOfVersionName(versionName: String): UpdateChannel =
* The channel a release belongs to. * The channel a release belongs to.
* *
* Two independent marks, and either is enough: the `-dev` suffix the workflow * Two independent marks, and either is enough: the `-dev` suffix the workflow
* puts on the tag, and GitHub's own prerelease flag. A release would have to * puts on the tag, and the release's own prerelease flag. A release would have
* lose both to be mistaken for the other channel. * to lose both to be mistaken for the other channel.
*/ */
fun channelOfTag(tag: String, prerelease: Boolean): UpdateChannel = fun channelOfTag(tag: String, prerelease: Boolean): UpdateChannel =
if (tag.endsWith(DEV_SUFFIX) || prerelease) UpdateChannel.DEV else UpdateChannel.PRODUCTION if (tag.endsWith(DEV_SUFFIX) || prerelease) UpdateChannel.DEV else UpdateChannel.PRODUCTION
/** /**
* The run number out of a `v<series>.<n>` tag, which is what an APK's * The build number out of a `v<series>.<n>` tag, which is what an APK's
* versionCode is. * versionCode is.
* *
* Only the trailing number is read. The series is a name for people, and * Only the trailing number is read. The series is a name for people, and
@@ -70,10 +70,10 @@ fun versionCodeOfTag(tag: String): Int? =
* nothing at all. * nothing at all.
* *
* Only an actual update is worth a word. Opening the app is not asking a * Only an actual update is worth a word. Opening the app is not asking a
* question, so a check that finds nothing — or that cannot reach GitHub at all * question, so a check that finds nothing — or that cannot reach the release
* — stays quiet; a phone on a train would otherwise be told off for it every * server at all — stays quiet; a phone on a train would otherwise be told off
* single launch. The button in Settings *is* asking a question, and reports * for it every single launch. The button in Settings *is* asking a question, and
* every answer including those two. * reports every answer including those two.
*/ */
fun launchOffer(verdict: UpdateVerdict): Release? = fun launchOffer(verdict: UpdateVerdict): Release? =
(verdict as? UpdateVerdict.Install)?.release (verdict as? UpdateVerdict.Install)?.release
@@ -10,17 +10,34 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import org.prolibertate.games.BuildConfig
import java.io.File import java.io.File
import java.net.HttpURLConnection import java.net.HttpURLConnection
import java.net.URL import java.net.URL
/** /**
* Over-the-air updates from the repository's GitHub Releases. * Over-the-air updates from the repository's releases.
* *
* Release tags are v<series>.<run-number> and the APK's versionCode is that same run * The channel is the Gitea instance this app ships from
* number, so comparing the two is enough to tell whether a newer build exists. * (`git.wallacenet.ca`), not GitHub. Gitea's release API answers with the
* The repository's releases must be publicly downloadable — no token is * same shape the updater reads — `tag_name`, `prerelease`, `draft`, and
* embedded in the app, and GitHub blocks anonymous access to private assets. * assets carrying `browser_download_url` — so this is the same code the
* upstream ran, pointed at `/api/v1/repos/...` routes instead of
* `api.github.com`, with `limit` where GitHub says `per_page`.
*
* Release tags are v<series>.<n>, where **n is the stamped build number the
* APK's versionCode was built from** (the CI run number plus the fixed
* `plgVersionRunOffset` — see docs/CI.md), so comparing the two is enough to
* tell whether a newer build exists.
*
* The repository and its releases are public: no token is embedded in the
* app, and every download is anonymous by design. If the repo were ever made
* private, every check would simply start failing rather than leaking
* anything.
*
* `BuildConfig.OTA_ENABLED` is a kill switch, not a channel decision — it is
* true in this build; setting it false makes every check report that and
* fetch nothing.
* *
* There is nothing to choose here. The channel comes from the running APK, so * There is nothing to choose here. The channel comes from the running APK, so
* a dev install looks only at dev builds and a production install only at * a dev install looks only at dev builds and a production install only at
@@ -35,6 +52,11 @@ class Updater(private val activity: Activity) {
* this phone is on, and the phone already knows. * this phone is on, and the phone already knows.
*/ */
suspend fun check(): UpdateVerdict = withContext(Dispatchers.IO) { suspend fun check(): UpdateVerdict = withContext(Dispatchers.IO) {
if (!BuildConfig.OTA_ENABLED) {
return@withContext UpdateVerdict.Refused(
"In-app updates are not enabled for this build yet."
)
}
try { try {
val channel = installedChannel() val channel = installedChannel()
verdictFor( verdictFor(
@@ -48,23 +70,28 @@ class Updater(private val activity: Activity) {
} }
/** /**
* Production reads GitHub's "latest release", which by definition skips * Production reads the "latest release", which by definition skips
* prereleases. Dev walks the release list for the newest prerelease, which * prereleases — Gitea implements it as the most recent non-prerelease,
* is what the CI pipeline publishes from the dev branch. * non-draft release, so the rule holds on this instance too.
*
* Dev walks the release list for the newest prerelease, which is what the
* CI pipeline publishes from the dev branch. The list is taken as the
* highest versionCode among the dev releases rather than the first one
* returned, so the answer does not depend on how the server chose to
* order the page.
*/ */
private fun fetchLatestRelease(channel: UpdateChannel): Release? = when (channel) { private fun fetchLatestRelease(channel: UpdateChannel): Release? = when (channel) {
UpdateChannel.PRODUCTION -> UpdateChannel.PRODUCTION ->
getJsonObject("https://api.github.com/repos/$OWNER/$REPO/releases/latest") getJsonObject("$API/releases/latest")
?.let { toRelease(it) } ?.let { toRelease(it) }
UpdateChannel.DEV -> UpdateChannel.DEV ->
getJsonArray("https://api.github.com/repos/$OWNER/$REPO/releases?per_page=30") getJsonArray("$API/releases?limit=30")
?.let { releases -> ?.let { releases ->
// GitHub returns newest first.
(0 until releases.length()) (0 until releases.length())
.map { releases.getJSONObject(it) } .mapNotNull { toRelease(releases.getJSONObject(it)) }
.firstOrNull { it.optBoolean("prerelease") && !it.optBoolean("draft") } .filter { it.channel == UpdateChannel.DEV }
?.let { toRelease(it) } .maxByOrNull { it.versionCode }
} }
} }
@@ -98,8 +125,8 @@ class Updater(private val activity: Activity) {
private fun openConnection(url: String): HttpURLConnection = private fun openConnection(url: String): HttpURLConnection =
(URL(url).openConnection() as HttpURLConnection).apply { (URL(url).openConnection() as HttpURLConnection).apply {
requestMethod = "GET" requestMethod = "GET"
setRequestProperty("Accept", "application/vnd.github+json") setRequestProperty("Accept", "application/json")
setRequestProperty("User-Agent", "ProLibertateGames-Updater") setRequestProperty("User-Agent", "PilgrimsOfKatan-Updater")
connectTimeout = 15_000 connectTimeout = 15_000
readTimeout = 15_000 readTimeout = 15_000
} }
@@ -175,7 +202,12 @@ class Updater(private val activity: Activity) {
activity.packageManager.getPackageInfo(activity.packageName, 0).versionName ?: "unknown" activity.packageManager.getPackageInfo(activity.packageName, 0).versionName ?: "unknown"
private companion object { private companion object {
const val OWNER = "adrianoftyriel" /** The Gitea instance that publishes this app's releases. */
const val REPO = "ProLibertateGames" const val SERVER = "https://git.wallacenet.ca"
const val OWNER = "devadmin"
const val REPO = "PilgrimsOfKatan"
/** Release routes, Gitea's `/api/v1` spelling of GitHub's API. */
const val API = "$SERVER/api/v1/repos/$OWNER/$REPO"
} }
} }
@@ -1,70 +1,16 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- <!--
A single square of the Wallace tartan, set on the bias. The flat field behind the launcher emblem: brand_field, slate blue on
production and deep green on dev, generated by the build (see resValue in
Generated from the published threadcount K/4 R32 K32 Y/4 — the same numbers app/build.gradle.kts) so the two installs are told apart in the app drawer
as WallaceSett in Tartan.kt. The slashes are pivots, so the half-sett by the icon alone.
reflects out to K4 R32 K32 Y4 K32 R32: the yellow overstripe runs down the
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
covered. One full repeat fills the adaptive icon's safe zone.
--> -->
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp" android:width="108dp"
android:height="108dp" android:height="108dp"
android:viewportWidth="108" android:viewportWidth="108"
android:viewportHeight="108"> android:viewportHeight="108">
<group <path
android:rotation="45" android:fillColor="@color/brand_field"
android:pivotX="54.0" android:pathData="M0,0h108v108h-108z" />
android:pivotY="54.0">
<path android:fillColor="@color/tartan_ground" android:pathData="M-90.00,-90.00h288.00v288.00h-288.00z" />
<path android:fillColor="@color/tartan_field" android:pathData="M-88.94,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="@color/tartan_field" android:pathData="M-69.88,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="@color/tartan_over" android:pathData="M-36.00,-90.00h2.12v288.00h-2.12z" />
<path android:fillColor="@color/tartan_field" android:pathData="M-16.94,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="@color/tartan_field" android:pathData="M2.12,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="@color/tartan_over" android:pathData="M36.00,-90.00h2.12v288.00h-2.12z" />
<path android:fillColor="@color/tartan_field" android:pathData="M55.06,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="@color/tartan_field" android:pathData="M74.12,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="@color/tartan_over" android:pathData="M108.00,-90.00h2.12v288.00h-2.12z" />
<path android:fillColor="@color/tartan_field" android:pathData="M127.06,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="@color/tartan_field" android:pathData="M146.12,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="@color/tartan_over" android:pathData="M180.00,-90.00h2.12v288.00h-2.12z" />
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,-105.88h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,-88.94h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,-72.00h288.00v2.12h-288.00z" />
<path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,-69.88h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,-52.94h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_over" android:fillAlpha="0.5" android:pathData="M-90.00,-36.00h288.00v2.12h-288.00z" />
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,-33.88h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,-16.94h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,0.00h288.00v2.12h-288.00z" />
<path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,2.12h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,19.06h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_over" android:fillAlpha="0.5" android:pathData="M-90.00,36.00h288.00v2.12h-288.00z" />
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,38.12h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,55.06h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,72.00h288.00v2.12h-288.00z" />
<path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,74.12h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,91.06h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_over" android:fillAlpha="0.5" android:pathData="M-90.00,108.00h288.00v2.12h-288.00z" />
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,110.12h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,127.06h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,144.00h288.00v2.12h-288.00z" />
<path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,146.12h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,163.06h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_over" android:fillAlpha="0.5" android:pathData="M-90.00,180.00h288.00v2.12h-288.00z" />
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,182.12h288.00v16.94h-288.00z" />
</group>
</vector> </vector>
@@ -1,15 +1,23 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- <!--
Deliberately empty. The icon is the cloth itself, so the adaptive icon's The brand emblem: a hexagonal ring around a solid hexagonal core, in
foreground layer carries nothing — anything drawn here would sit on top of the parchment on the brand field behind it (see ic_launcher_background).
tartan and break the "single square of tartan" idea.
The hexagon is a game tile, not any game's mark — deliberately generic
branding for Pilgrims of Katan, carrying no third-party logo or trade dress.
Everything sits inside the adaptive icon's safe zone (a centred 66dp circle
on this 108dp canvas) so no launcher mask can clip it.
--> -->
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp" android:width="108dp"
android:height="108dp" android:height="108dp"
android:viewportWidth="108" android:viewportWidth="108"
android:viewportHeight="108"> android:viewportHeight="108">
<!-- Ring and core in one even-odd path: outer hexagon, inner hexagon,
core hexagon — odd coverage fills, so the ring and the core are drawn
and the gap between them stays open to the field showing through. -->
<path <path
android:fillColor="#00000000" android:fillColor="@color/brand_accent"
android:pathData="M0,0h108v108h-108z" /> android:fillType="evenOdd"
android:pathData="M84,54 L69,79.98 L39,79.98 L24,54 L39,28.02 L69,28.02 Z M72,54 L63,69.59 L45,69.59 L36,54 L45,38.41 L63,38.41 Z M63,54 L58.5,61.79 L49.5,61.79 L45,54 L49.5,46.21 L58.5,46.21 Z" />
</vector> </vector>
+12 -60
View File
@@ -1,70 +1,22 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- <!--
A single square of the Wallace tartan, set on the bias. The legacy (pre-API 26) launcher icon: the same brand emblem as the adaptive
icon's foreground, over the same brand field, in the one layer those
launchers get.
Generated from the published threadcount K/4 R32 K32 Y/4 — the same numbers Field first, emblem second: two paths, because the emblem's ring relies on
as WallaceSett in Tartan.kt. The slashes are pivots, so the half-sett even-odd parity and a filled background inside the same path would flip it.
reflects out to K4 R32 K32 Y4 K32 R32: the yellow overstripe runs down the
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
covered. One full repeat fills the adaptive icon's safe zone.
--> -->
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp" android:width="48dp"
android:height="48dp" android:height="48dp"
android:viewportWidth="108" android:viewportWidth="108"
android:viewportHeight="108"> android:viewportHeight="108">
<group <path
android:rotation="45" android:fillColor="@color/brand_field"
android:pivotX="54.0" android:pathData="M0,0h108v108h-108z" />
android:pivotY="54.0"> <path
<path android:fillColor="@color/tartan_ground" android:pathData="M-90.00,-90.00h288.00v288.00h-288.00z" /> android:fillColor="@color/brand_accent"
<path android:fillColor="@color/tartan_field" android:pathData="M-88.94,-90.00h16.94v288.00h-16.94z" /> android:fillType="evenOdd"
<path android:fillColor="@color/tartan_field" android:pathData="M-69.88,-90.00h16.94v288.00h-16.94z" /> android:pathData="M84,54 L69,79.98 L39,79.98 L24,54 L39,28.02 L69,28.02 Z M72,54 L63,69.59 L45,69.59 L36,54 L45,38.41 L63,38.41 Z M63,54 L58.5,61.79 L49.5,61.79 L45,54 L49.5,46.21 L58.5,46.21 Z" />
<path android:fillColor="@color/tartan_over" android:pathData="M-36.00,-90.00h2.12v288.00h-2.12z" />
<path android:fillColor="@color/tartan_field" android:pathData="M-16.94,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="@color/tartan_field" android:pathData="M2.12,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="@color/tartan_over" android:pathData="M36.00,-90.00h2.12v288.00h-2.12z" />
<path android:fillColor="@color/tartan_field" android:pathData="M55.06,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="@color/tartan_field" android:pathData="M74.12,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="@color/tartan_over" android:pathData="M108.00,-90.00h2.12v288.00h-2.12z" />
<path android:fillColor="@color/tartan_field" android:pathData="M127.06,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="@color/tartan_field" android:pathData="M146.12,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="@color/tartan_over" android:pathData="M180.00,-90.00h2.12v288.00h-2.12z" />
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,-105.88h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,-88.94h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,-72.00h288.00v2.12h-288.00z" />
<path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,-69.88h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,-52.94h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_over" android:fillAlpha="0.5" android:pathData="M-90.00,-36.00h288.00v2.12h-288.00z" />
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,-33.88h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,-16.94h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,0.00h288.00v2.12h-288.00z" />
<path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,2.12h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,19.06h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_over" android:fillAlpha="0.5" android:pathData="M-90.00,36.00h288.00v2.12h-288.00z" />
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,38.12h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,55.06h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,72.00h288.00v2.12h-288.00z" />
<path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,74.12h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,91.06h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_over" android:fillAlpha="0.5" android:pathData="M-90.00,108.00h288.00v2.12h-288.00z" />
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,110.12h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,127.06h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,144.00h288.00v2.12h-288.00z" />
<path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,146.12h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,163.06h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_over" android:fillAlpha="0.5" android:pathData="M-90.00,180.00h288.00v2.12h-288.00z" />
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,182.12h288.00v16.94h-288.00z" />
</group>
</vector> </vector>
+12 -60
View File
@@ -1,70 +1,22 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- <!--
A single square of the Wallace tartan, set on the bias. The legacy (pre-API 26) launcher icon: the same brand emblem as the adaptive
icon's foreground, over the same brand field, in the one layer those
launchers get.
Generated from the published threadcount K/4 R32 K32 Y/4 — the same numbers Field first, emblem second: two paths, because the emblem's ring relies on
as WallaceSett in Tartan.kt. The slashes are pivots, so the half-sett even-odd parity and a filled background inside the same path would flip it.
reflects out to K4 R32 K32 Y4 K32 R32: the yellow overstripe runs down the
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
covered. One full repeat fills the adaptive icon's safe zone.
--> -->
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp" android:width="48dp"
android:height="48dp" android:height="48dp"
android:viewportWidth="108" android:viewportWidth="108"
android:viewportHeight="108"> android:viewportHeight="108">
<group <path
android:rotation="45" android:fillColor="@color/brand_field"
android:pivotX="54.0" android:pathData="M0,0h108v108h-108z" />
android:pivotY="54.0"> <path
<path android:fillColor="@color/tartan_ground" android:pathData="M-90.00,-90.00h288.00v288.00h-288.00z" /> android:fillColor="@color/brand_accent"
<path android:fillColor="@color/tartan_field" android:pathData="M-88.94,-90.00h16.94v288.00h-16.94z" /> android:fillType="evenOdd"
<path android:fillColor="@color/tartan_field" android:pathData="M-69.88,-90.00h16.94v288.00h-16.94z" /> android:pathData="M84,54 L69,79.98 L39,79.98 L24,54 L39,28.02 L69,28.02 Z M72,54 L63,69.59 L45,69.59 L36,54 L45,38.41 L63,38.41 Z M63,54 L58.5,61.79 L49.5,61.79 L45,54 L49.5,46.21 L58.5,46.21 Z" />
<path android:fillColor="@color/tartan_over" android:pathData="M-36.00,-90.00h2.12v288.00h-2.12z" />
<path android:fillColor="@color/tartan_field" android:pathData="M-16.94,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="@color/tartan_field" android:pathData="M2.12,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="@color/tartan_over" android:pathData="M36.00,-90.00h2.12v288.00h-2.12z" />
<path android:fillColor="@color/tartan_field" android:pathData="M55.06,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="@color/tartan_field" android:pathData="M74.12,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="@color/tartan_over" android:pathData="M108.00,-90.00h2.12v288.00h-2.12z" />
<path android:fillColor="@color/tartan_field" android:pathData="M127.06,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="@color/tartan_field" android:pathData="M146.12,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="@color/tartan_over" android:pathData="M180.00,-90.00h2.12v288.00h-2.12z" />
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,-105.88h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,-88.94h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,-72.00h288.00v2.12h-288.00z" />
<path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,-69.88h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,-52.94h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_over" android:fillAlpha="0.5" android:pathData="M-90.00,-36.00h288.00v2.12h-288.00z" />
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,-33.88h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,-16.94h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,0.00h288.00v2.12h-288.00z" />
<path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,2.12h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,19.06h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_over" android:fillAlpha="0.5" android:pathData="M-90.00,36.00h288.00v2.12h-288.00z" />
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,38.12h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,55.06h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,72.00h288.00v2.12h-288.00z" />
<path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,74.12h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,91.06h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_over" android:fillAlpha="0.5" android:pathData="M-90.00,108.00h288.00v2.12h-288.00z" />
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,110.12h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,127.06h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,144.00h288.00v2.12h-288.00z" />
<path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,146.12h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,163.06h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_over" android:fillAlpha="0.5" android:pathData="M-90.00,180.00h288.00v2.12h-288.00z" />
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,182.12h288.00v16.94h-288.00z" />
</group>
</vector> </vector>
+8 -8
View File
@@ -1,16 +1,16 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- <!--
The colours the sett shares between channels. The one that differs — The colours the brand shares between channels. The two that differ —
tartan_field, red on production and hunting green on dev — is generated by the brand_field (slate blue on production, deep green on dev) and its darker
build, along with splash_background and ic_launcher_background, which are shade splash_background — are generated by the build. See resValue in
shades of it. See resValue in app/build.gradle.kts. app/build.gradle.kts.
--> -->
<resources> <resources>
<color name="black">#FF000000</color> <color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color> <color name="white">#FFFFFFFF</color>
<!-- The ground the sett is woven on, and the overstripe down its centre. <!-- The parchment accent drawn on the brand field: the emblem on the
Same numbers as WallaceBlack and WallaceGold in Tartan.kt. --> launcher icon and the second line of the splash. Same number as the
<color name="tartan_ground">#0C0C0C</color> accent in Theme.kt. -->
<color name="tartan_over">#F2C518</color> <color name="brand_accent">#EFD9A4</color>
</resources> </resources>
+1 -1
View File
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- <!--
app_name is generated by the build rather than declared here, because it app_name is generated by the build rather than declared here, because it
differs by channel: a dev build installs as "Pro Libertate Games DEV" differs by channel: a dev build installs as "Pilgrims of Katan DEV"
alongside the production copy. See resValue in app/build.gradle.kts. alongside the production copy. See resValue in app/build.gradle.kts.
--> -->
<resources> <resources>
+1 -1
View File
@@ -2,7 +2,7 @@
<resources> <resources>
<!-- Compose supplies the real theme; this only needs to be a Material 3 <!-- Compose supplies the real theme; this only needs to be a Material 3
base with no action bar so the app draws its own top bars. --> base with no action bar so the app draws its own top bars. -->
<style name="Theme.ProLibertateGames" parent="android:Theme.Material.NoActionBar"> <style name="Theme.PilgrimsOfKatan" parent="android:Theme.Material.NoActionBar">
<item name="android:windowNoTitle">true</item> <item name="android:windowNoTitle">true</item>
<item name="android:windowContentOverlay">@null</item> <item name="android:windowContentOverlay">@null</item>
<item name="android:windowBackground">@color/splash_background</item> <item name="android:windowBackground">@color/splash_background</item>
@@ -22,7 +22,7 @@ class UpdatePolicyTest {
tag = tag, tag = tag,
versionCode = versionCode, versionCode = versionCode,
apkUrl = "https://example.invalid/$tag.apk", apkUrl = "https://example.invalid/$tag.apk",
apkName = "ProLibertateGames-$tag.apk", apkName = "PilgrimsOfKatan-$tag.apk",
channel = channel, channel = channel,
) )
@@ -129,7 +129,7 @@ class UpdatePolicyTest {
assertNull("an up-to-date app has no business interrupting", launchOffer(UpdateVerdict.UpToDate)) assertNull("an up-to-date app has no business interrupting", launchOffer(UpdateVerdict.UpToDate))
assertNull( assertNull(
"a phone with no network has nothing to apologise for", "a phone with no network has nothing to apologise for",
launchOffer(UpdateVerdict.Refused("Unable to resolve host api.github.com")), launchOffer(UpdateVerdict.Refused("Unable to resolve host git.wallacenet.ca")),
) )
// Including the refusal that this whole change exists to produce: being // Including the refusal that this whole change exists to produce: being
// shown the other channel's build is not a thing to be told about. // shown the other channel's build is not a thing to be told about.
+136
View File
@@ -0,0 +1,136 @@
# CI, releases, and OTA — decisions and how to build
Status as of the Pilgrims of Katan bootstrap (WALA-118).
## Decisions
1. **The release channel is this instance's own releases.** The app ships from
<https://git.wallacenet.ca/devadmin/PilgrimsOfKatan>, and the in-app updater
reads that repo's releases over Gitea's REST API. GitHub is not a channel for
this project; the only GitHub URLs left are the `upstream` remote and the
attribution link in the README.
The repo is **public on purpose**: the updater downloads anonymously, with no
token embedded in the APK. A private repo would make every check fail
(the API answers 404 to anonymous callers), not leak anything — but it would
also stop updates dead.
2. **CI lives in Actions-compatible workflows in `.github/workflows/`.**
Gitea Actions runs workflows from `.github/workflows/` (or `.gitea/workflows/`),
so these files serve both a GitHub-style runner and the Gitea instance.
Publishing goes through `scripts/publish-release.sh` — Gitea's REST API, the
same routes the app's updater reads — rather than the `gh` CLI, which a runner
image need not carry.
| Workflow | Trigger | Publishes |
| --- | --- | --- |
| `ci.yml` | push to `dev`, PRs into `dev`/`main`, manual | dev prerelease, from `dev` only |
| `release.yml` | push to `main`, manual | production release, from `main` only |
Pull requests build and check but never publish. Prereleases are marked so
the "latest release" endpoint — which skips them — can never hand a dev build
to a production install.
3. **A runner has to be registered before any of that executes.** As of the
bootstrap the instance has **no Actions runner**, so the workflows are
correct but idle: a Gitea runner (Docker or host) must be registered against
the instance and able to resolve `actions/checkout`,
`actions/setup-java`, `android-actions/setup-android` and
`actions/upload-artifact`. Until then the manual build below and the manual
publish script are the working paths.
4. **The local build is the guaranteed path.** Workflows need a runner that may
not exist yet; `./gradlew assembleDebug` + `./gradlew testDebugUnitTest`
work on any machine with JDK 17 and the Android SDK (compileSdk 34), and CI's
green means exactly these two commands plus lint.
## Build numbers, tags, and why updates never downgrade
An APK is **`<series>.<build number>`**, the release tag is **`v<series>.<build
number>`**, and the build number is what Android sees as `versionCode`:
```
build number = CI run number + plgVersionRunOffset (gradle.properties)
```
- `plgVersionSeries` (currently `1.4`) is the human-facing series, set by hand
in `gradle.properties`.
- `plgVersionRunOffset` (currently `1000`) is a **permanent floor**, also set by
hand in `gradle.properties`, set once at bootstrap and never changed.
Why the offset exists: Gitea Actions restarts run numbers at 1 for each
workflow, so without it the first release of a fresh instance would be build
`1` — the number an unstamped local build already carries — and any reset of
the counter would hand Android a *lower* `versionCode`, which it refuses as a
downgrade. With the offset, every stamped build starts at 1001 and only ever
climbs, because the run number is monotonic and the offset never moves. The
gradle build and both workflows read the same two properties, so the tag and
the APK inside it cannot name different versions.
The series is a name for people: bumping it changes nothing about ordering,
because `versionCode` never includes the series.
Unstamped builds (local, or a PR build) are `versionCode 1`, below every
release — the honest answer for a build nobody stamped.
## Manual build (works today, no runner needed)
```sh
# JDK 17, Android SDK with platform 34 + build-tools 34
export JAVA_HOME=... # a JDK 17 install
export ANDROID_HOME=... # SDK root (or set sdk.dir in local.properties)
./gradlew assembleDebug # dev-channel app (default: ca.wallacenet.pilgrimsofkatan.dev)
PLG_CHANNEL=production ./gradlew assembleDebug # production app (ca.wallacenet.pilgrimsofkatan)
./gradlew testDebugUnitTest # JVM unit tests
./gradlew lintDebug # Android lint (CI runs this too)
```
The two variants install side by side: different applicationId, different
launcher name (`Pilgrims of Katan DEV` vs `Pilgrims of Katan`), different brand
colour.
## Manual release (works today, no runner needed)
```sh
GITEA_TOKEN=… ./scripts/publish-release.sh v1.4.1001 app/build/outputs/apk/debug/app-debug.apk release "$(git rev-parse HEAD)"
```
The script takes `<tag> <apk> <prerelease|release> [target-sha]`, creates the
release through `POST /api/v1/repos/{owner}/{repo}/releases`, and attaches the
APK through `POST …/releases/{id}/assets` — which is the upload route Gitea's
Swagger declares for this instance (1.27.3). If the tag already exists it
publishes into the existing release instead of failing, so a retry is safe.
## What the in-app updater reads
`app/src/main/java/org/prolibertate/games/update/Updater.kt`:
| Channel | Endpoint |
| --- | --- |
| production | `GET /api/v1/repos/devadmin/PilgrimsOfKatan/releases/latest` |
| dev | `GET /api/v1/repos/devadmin/PilgrimsOfKatan/releases?limit=30` |
- `/releases/latest` is documented by Gitea as *the most recent non-prerelease,
non-draft release* — the same rule GitHub implements, so the production
channel keeps skipping dev builds.
- The dev path takes the highest `versionCode` among the prereleases rather
than whatever came first on the page, so it does not depend on server
ordering.
- The JSON shape is the one the code already parsed from GitHub: `tag_name`,
`prerelease`, `draft`, and assets carrying `browser_download_url`. Gitea says
`limit` where GitHub says `per_page`.
- Each asset must be named so it contains its tag —
`PilgrimsOfKatan-v1.4.1001.apk` for tag `v1.4.1001` — because the updater
picks the versioned APK out of the release's assets by that rule.
`BuildConfig.OTA_ENABLED` is a kill switch, not a channel decision: it is
`true`, and setting it `false` makes every check report that and fetch nothing.
`UpdatePolicy.kt` — channel rules, tag parsing, verdicts — is pure and stays
under test either way.
## Keystore
`app/debug.keystore` is committed deliberately and must never be rotated:
every build — local, CI, release — signs with it, so an OTA update installs
over the previous build instead of being rejected for a signature mismatch.
+45
View File
@@ -0,0 +1,45 @@
# docs/
Conventions for this repo's documentation. Seeded at bootstrap (WALA-118).
## Per-game rules: `RULES-<game>.md`
Every game in `GameCatalog` gets one file describing how *this app* implements
it — not a generic rule dump. The rules code
(`app/src/main/java/org/prolibertate/games/game/<game>/`) is the source of
truth; the doc exists so a human or an agent can check intent against
behaviour without reading Kotlin first.
Naming: the catalogue id, lowercase, e.g. `crazy8s` → `RULES-crazy8s.md`.
| Game (catalogue id) | Rules doc |
| --- | --- |
| Backgammon | [RULES-backgammon.md](RULES-backgammon.md) |
| Checkers | [RULES-checkers.md](RULES-checkers.md) |
| Chess | [RULES-chess.md](RULES-chess.md) |
| Crazy 8s | [RULES-crazy8s.md](RULES-crazy8s.md) |
| Cribbage | [RULES-cribbage.md](RULES-cribbage.md) |
| Euchre | [RULES-euchre.md](RULES-euchre.md) |
| FreeCell | — (TODO) |
| Golf | [RULES-golf.md](RULES-golf.md) |
| Hearts | — (TODO) |
| Kaiser | [RULES-kaiser.md](RULES-kaiser.md) |
| Klondike | — (TODO) |
| Mastermind | [RULES-mastermind.md](RULES-mastermind.md) |
| Morris | [RULES-morris.md](RULES-morris.md) |
| Peg solitaire | — (TODO) |
| Pirates | [RULES-pirates.md](RULES-pirates.md) |
| President | [RULES-president.md](RULES-president.md) |
| Pyramid | — (TODO) |
| Sequence | [RULES-sequence.md](RULES-sequence.md) |
| Spider | — (TODO) |
| Ta Yü | [RULES-tayu.md](RULES-tayu.md) |
| Wizard | [RULES-wizard.md](RULES-wizard.md) |
| Yahtzee | — (TODO) |
## Other docs
- [CI.md](CI.md) — build, CI, releases, OTA decisions.
- `licenses/` — third-party licences that must travel with the app
(Uncial Antiqua under the SIL OFL; the app itself is GPL-3.0, see `/LICENSE`).
- Repo-level agent notes live in [`CLAUDE.md`](../CLAUDE.md) at the root.
+8
View File
@@ -23,3 +23,11 @@ android.nonTransitiveRClass=true
# it keeps climbing on its own and an APK's versionCode never goes backwards # it keeps climbing on its own and an APK's versionCode never goes backwards
# when this changes. # when this changes.
plgVersionSeries=1.4 plgVersionSeries=1.4
# The permanent floor under every stamped build number: versionCode (and the
# release tag, and versionName) are `run number + this`. Gitea Actions starts
# run numbers at 1 for each workflow, so without it the first Gitea-era release
# would be build 1 — the same number an unstamped local build already carries —
# and any later reset would make Android reject the next build as a downgrade.
# Set once, at bootstrap, and never changed again: see docs/CI.md.
plgVersionRunOffset=1000
+96
View File
@@ -0,0 +1,96 @@
#!/bin/sh
# Publish a built APK to this repository's Gitea releases — the channel the
# in-app updater reads (docs/CI.md).
#
# The workflows call this, and so can a person: it is the whole release step,
# runnable by hand on any machine with curl, which matters because a Gitea
# Actions runner has to exist before the workflows can run at all.
#
# scripts/publish-release.sh <tag> <apk> <prerelease|release> [target-sha]
#
# Environment:
# GITEA_TOKEN required — a token that may create releases on the repo.
# GITEA_API API base, default https://git.wallacenet.ca/api/v1
# GITEA_REPO owner/name, default devadmin/PilgrimsOfKatan
#
# The four arguments can also arrive as RELEASE_TAG, RELEASE_APK,
# RELEASE_KIND and RELEASE_TARGET, which is how the workflows pass them.
set -eu
usage() {
sed -n '2,17p' "$0" | sed 's/^# \{0,1\}//' >&2
}
tag="${1:-${RELEASE_TAG:-}}"
apk="${2:-${RELEASE_APK:-}}"
kind="${3:-${RELEASE_KIND:-}}"
target="${4:-${RELEASE_TARGET:-${GITHUB_SHA:-HEAD}}}"
case "$kind" in
prerelease|release) ;;
*) usage; exit 2 ;;
esac
[ -n "$tag" ] || { usage; exit 2; }
[ -f "$apk" ] || { echo "publish-release: no such APK: ${apk:-<unset>}" >&2; exit 2; }
[ -n "${GITEA_TOKEN:-}" ] || { echo "publish-release: GITEA_TOKEN is not set" >&2; exit 2; }
api="${GITEA_API:-${GITHUB_API_URL:-https://git.wallacenet.ca/api/v1}}/repos/${GITEA_REPO:-${GITHUB_REPOSITORY:-devadmin/PilgrimsOfKatan}}"
apk_name="$(basename "$apk")"
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT INT TERM
# The tag and the file name are both derived from gradle.properties and a run
# number, so neither can carry a quote or a backslash into this JSON.
if [ "$kind" = "prerelease" ]; then
prerelease=true
note="Automated dev-channel build of ${target}. Newer than production and less tested — switch to the Dev update channel in Settings to be offered these. Published as a prerelease so production-channel installs never see it."
else
prerelease=false
note="Production-channel build of ${target}. Download ${apk_name} below and install it on an Android device (you may need to allow installs from unknown apps). It is signed with the repo's fixed keystore, which is what lets the in-app updater install over it."
fi
cat > "$tmp/release.json" <<JSON
{"tag_name":"${tag}","target_commitish":"${target}","name":"Pilgrims of Katan ${tag}","body":"${note}","prerelease":${prerelease},"draft":false}
JSON
code="$(curl -sS -o "$tmp/create.json" -w '%{http_code}' -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
--data @"$tmp/release.json" \
"$api/releases")"
# 422 is the usual answer when the tag already exists — a re-run of a
# workflow, or a second attempt by hand. Publishing into the release that is
# already there is the right thing to do, not a failure.
if [ "$code" != "201" ] && [ "$code" != "200" ]; then
code="$(curl -sS -o "$tmp/create.json" -w '%{http_code}' \
-H "Authorization: token ${GITEA_TOKEN}" \
"$api/releases/tags/${tag}")"
fi
if [ "$code" != "201" ] && [ "$code" != "200" ]; then
echo "publish-release: creating release ${tag} failed (HTTP ${code})" >&2
cat "$tmp/create.json" >&2
echo >&2
exit 1
fi
# Gitea answers compact JSON, and the release's own id is the first "id" in
# it — the author's object comes later in the document.
id="$(grep -o '"id":[0-9]*' "$tmp/create.json" | head -n1 | tr -dc '0-9' || true)"
[ -n "$id" ] || { echo "publish-release: no release id in response" >&2; cat "$tmp/create.json" >&2; echo >&2; exit 1; }
code="$(curl -sS -o "$tmp/asset.json" -w '%{http_code}' -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-F "attachment=@${apk}" \
"$api/releases/${id}/assets")"
if [ "$code" != "201" ] && [ "$code" != "200" ]; then
echo "publish-release: attaching ${apk_name} to release ${id} failed (HTTP ${code})" >&2
cat "$tmp/asset.json" >&2
echo >&2
exit 1
fi
echo "publish-release: ${tag} -> release ${id} on ${api#*/repos/}, asset ${apk_name}"
+1 -1
View File
@@ -14,5 +14,5 @@ dependencyResolutionManagement {
} }
} }
rootProject.name = "ProLibertateGames" rootProject.name = "PilgrimsOfKatan"
include(":app") include(":app")