.aab` (+ `.apk` for sideloading) to a
+ GitHub Release and reads the bundle back (versionName, permissions, signature).
+4. **Create the app** in Play Console (name, Czech, free, app) and upload that `.aab` on
+ *Testing → Closed testing → Create release*. The first upload enrolls **Play App
+ Signing** automatically (Google keeps the real key; ours is the upload key).
+5. **Store listing** — paste this file: name, short/full description, icon, feature
+ graphic, screenshots, category, contact, privacy URL. Then *App content*: data safety,
+ content rating, target audience, ads, declarations (tables above).
+6. **Roll out** the closed test; after the 14-day/12-tester bar, *Production → Create
+ release* with the same `.aab` (or the next tag).
+
+### Optional: API upload so future releases need no console visit
+
+Play Console → *Setup → API access* → link a Google Cloud project → create a service
+account with **Release manager** on this app → download its JSON → repo secret
+`PLAY_SERVICE_ACCOUNT_JSON`. From then on every `android-v*` tag lands on the internal
+track by itself; promote to production in the console (or change the workflow's `track`
+input). The first `.aab` must still go through the console (step 4).
+
+## Self-check before the first upload (airplane-mode test on a real phone)
+
+- Install the `.apk` from the GitHub Release (Settings → allow this source).
+- Airplane mode on → open app → nearest masses render from the bundled data.
+- Deny location → city picker works; allow → list re-sorts.
+- Church detail → *Připomenout* → notification permission prompt → reminder appears in
+ the shade at the scheduled time.
+- Share → Android share sheet; *Do kalendáře* → .ics opens in Google Calendar.
+- Rotate, back gesture (Android 13+ predictive back returns to the list, not out of the app).
diff --git a/e2e/store-shots.spec.ts b/e2e/store-shots.spec.ts
index bc624e0..01c859a 100644
--- a/e2e/store-shots.spec.ts
+++ b/e2e/store-shots.spec.ts
@@ -14,9 +14,13 @@ import { FIXED_NOW, PRAGUE, mockData } from './fixtures'
const FASTLANE_CS = 'ios/App/fastlane/screenshots/cs'
+// Google Play accepts phone screenshots only between 16:9 and 9:16, so the
+// iPhone 6.9" shots (2.17:1) can't be reused — the Android entry renders the
+// classic 1080×1920 (360×640 @3) into store-assets/android/phone/.
const DEVICES = [
- { name: 'iphone-6.9', viewport: { width: 440, height: 956 }, dsf: 3 },
- { name: 'ipad-13', viewport: { width: 1032, height: 1376 }, dsf: 2 },
+ { name: 'iphone-6.9', store: 'ios', viewport: { width: 440, height: 956 }, dsf: 3 },
+ { name: 'ipad-13', store: 'ios', viewport: { width: 1032, height: 1376 }, dsf: 2 },
+ { name: 'phone', store: 'android', viewport: { width: 360, height: 640 }, dsf: 3 },
]
for (const d of DEVICES) {
@@ -34,7 +38,7 @@ for (const d of DEVICES) {
// the first-run intro guide must not overlay the store shots (step 1 uses
// real bundled data, so mockData's seed hasn't run yet)
await page.addInitScript(() => localStorage.setItem('bohosluzby:introSeen', '1'))
- const dir = `store-assets/ios/${d.name}`
+ const dir = `store-assets/${d.store}/${d.name}`
// 1 — MAP hero (the centerpiece — leads the listing). REAL bundled data
// (no mock) at a plain-green Sunday 07:00 local (05:00 UTC): the whole
// day's masses are still ahead, so nearly every church shows a vibrant
@@ -68,10 +72,13 @@ for (const d of DEVICES) {
// Sync into deliver's upload dir (fastlane/screenshots/cs/) with the
// device-prefixed names deliver expects — so store-assets and the upload
- // source never diverge again.
- mkdirSync(FASTLANE_CS, { recursive: true })
- for (const f of readdirSync(dir)) {
- cpSync(`${dir}/${f}`, `${FASTLANE_CS}/${d.name}-${f}`)
+ // source never diverge again. (Android's upload source IS store-assets —
+ // the release workflow reads it directly.)
+ if (d.store === 'ios') {
+ mkdirSync(FASTLANE_CS, { recursive: true })
+ for (const f of readdirSync(dir)) {
+ cpSync(`${dir}/${f}`, `${FASTLANE_CS}/${d.name}-${f}`)
+ }
}
})
})
diff --git a/scripts/android-keystore.sh b/scripts/android-keystore.sh
new file mode 100755
index 0000000..5842948
--- /dev/null
+++ b/scripts/android-keystore.sh
@@ -0,0 +1,55 @@
+#!/usr/bin/env bash
+# One-time: create the Android UPLOAD keystore for Kam na mši and store it as
+# GitHub Actions secrets for .github/workflows/release-android.yml.
+#
+# scripts/android-keystore.sh # generates + sets secrets via `gh`
+# scripts/android-keystore.sh --print # generates + prints the values instead
+#
+# Google Play App Signing keeps the real signing key on Google's side; this is
+# only the upload key, and Google can reset it if it is ever lost. Still: keep
+# the .jks + password in your password manager (1Password: "kam-na-msi android
+# upload key"). The file is written OUTSIDE the repo and never committed.
+set -euo pipefail
+
+REPO="${REPO:-Chartres/bohosluzby}"
+ALIAS="${ALIAS:-upload}"
+OUT_DIR="${OUT_DIR:-$HOME/.config/kam-na-msi}"
+KS="$OUT_DIR/android-upload.jks"
+mkdir -p "$OUT_DIR"; chmod 700 "$OUT_DIR"
+
+if [ -f "$KS" ]; then
+ echo "keystore already exists: $KS (delete it first if you really want a new one)"; exit 1
+fi
+
+command -v keytool >/dev/null || { echo "keytool not found — install a JDK (brew install --cask temurin)"; exit 1; }
+
+PASS="$(openssl rand -base64 30 | tr -d '/+=' | cut -c1-32)"
+keytool -genkeypair -v \
+ -keystore "$KS" -storepass "$PASS" -keypass "$PASS" \
+ -alias "$ALIAS" -keyalg RSA -keysize 4096 -validity 10000 \
+ -dname "CN=Kam na mši, O=Pavol Dravecký, L=Praha, C=CZ" >/dev/null
+chmod 600 "$KS"
+B64="$(base64 < "$KS" | tr -d '\n')"
+
+echo "keystore: $KS"
+echo "alias: $ALIAS"
+echo "SHA-256 (paste into Play Console if it asks for the upload certificate):"
+keytool -list -v -keystore "$KS" -storepass "$PASS" -alias "$ALIAS" | grep -E "SHA256:" | head -1
+
+if [ "${1:-}" = "--print" ] || ! command -v gh >/dev/null; then
+ echo
+ echo "Set these four secrets on $REPO (Settings → Secrets and variables → Actions):"
+ echo " ANDROID_KEYSTORE_BASE64 = (base64 of $KS — see $OUT_DIR/android-upload.b64)"
+ echo " ANDROID_KEYSTORE_PASSWORD = $PASS"
+ echo " ANDROID_KEY_ALIAS = $ALIAS"
+ echo " ANDROID_KEY_PASSWORD = $PASS"
+ printf '%s' "$B64" > "$OUT_DIR/android-upload.b64"; chmod 600 "$OUT_DIR/android-upload.b64"
+ exit 0
+fi
+
+printf '%s' "$B64" | gh secret set ANDROID_KEYSTORE_BASE64 --repo "$REPO"
+printf '%s' "$PASS" | gh secret set ANDROID_KEYSTORE_PASSWORD --repo "$REPO"
+printf '%s' "$ALIAS"| gh secret set ANDROID_KEY_ALIAS --repo "$REPO"
+printf '%s' "$PASS" | gh secret set ANDROID_KEY_PASSWORD --repo "$REPO"
+echo "secrets set on $REPO. Password (store it in 1Password now): $PASS"
+echo "Next: git tag android-v1.2.0 && git push origin android-v1.2.0 → signed .aab on the GitHub Release."
diff --git a/scripts/play-graphics.mjs b/scripts/play-graphics.mjs
new file mode 100644
index 0000000..e362a01
--- /dev/null
+++ b/scripts/play-graphics.mjs
@@ -0,0 +1,47 @@
+// Google Play graphics from the same sources as the app icon (resources/icon.png):
+// store-assets/android/icon-512.png 512×512 (Play's hi-res icon)
+// store-assets/android/feature-graphic.png 1024×500 (required by Play)
+// Deterministic, rendered with the repo's Playwright Chromium — same idea as the
+// flywheel og-image generator. Re-run after a brand/tagline change.
+// node scripts/play-graphics.mjs (PW_CHROMIUM=/path/to/chromium in sandboxes)
+import { chromium } from '@playwright/test'
+import { mkdirSync, readFileSync } from 'node:fs'
+
+const OUT = 'store-assets/android'
+mkdirSync(OUT, { recursive: true })
+const icon = `data:image/png;base64,${readFileSync('resources/icon.png').toString('base64')}`
+
+const feature = `
+
+Kam na mši
Nejbližší mše svatá podle vaší polohy — kterou ještě stihnete. Celá ČR, offline.
+zdarma · bez reklam · bez registrace`
+
+const iconPage = `
`
+
+const browser = await chromium.launch({
+ ...(process.env.PW_CHROMIUM ? { executablePath: process.env.PW_CHROMIUM } : {}),
+})
+try {
+ const p1 = await browser.newPage({ viewport: { width: 1024, height: 500 }, deviceScaleFactor: 1 })
+ await p1.setContent(feature, { waitUntil: 'load' })
+ await p1.screenshot({ path: `${OUT}/feature-graphic.png`, type: 'png' })
+ const p2 = await browser.newPage({ viewport: { width: 512, height: 512 }, deviceScaleFactor: 1 })
+ await p2.setContent(iconPage, { waitUntil: 'load' })
+ await p2.screenshot({ path: `${OUT}/icon-512.png`, type: 'png' })
+ console.log(`wrote ${OUT}/feature-graphic.png (1024×500) + ${OUT}/icon-512.png (512×512)`)
+} finally {
+ await browser.close()
+}
diff --git a/store-assets/android/feature-graphic.png b/store-assets/android/feature-graphic.png
new file mode 100644
index 0000000..9cc56f4
Binary files /dev/null and b/store-assets/android/feature-graphic.png differ
diff --git a/store-assets/android/icon-512.png b/store-assets/android/icon-512.png
new file mode 100644
index 0000000..54b00a7
Binary files /dev/null and b/store-assets/android/icon-512.png differ
diff --git a/store-assets/android/whatsnew/whatsnew-cs-CZ b/store-assets/android/whatsnew/whatsnew-cs-CZ
new file mode 100644
index 0000000..a4818df
--- /dev/null
+++ b/store-assets/android/whatsnew/whatsnew-cs-CZ
@@ -0,0 +1 @@
+První vydání pro Android: nejbližší mše svaté podle polohy, mapa kostelů, pořad bohoslužeb na dnes, zítra i neděli, offline data pro celou ČR, připomínky a export do kalendáře. Zdarma, bez reklam, bez registrace.