| 1 | #!/usr/bin/env bash |
| 2 | # build-android.sh |
| 3 | # |
| 4 | # Full Play Store pipeline for Siti AI (Android): |
| 5 | # 1. Clean — rm -rf src-tauri/gen/android |
| 6 | # 2. Init — cargo tauri android init (prod Android config) |
| 7 | # 3. Icons — pnpm gim |
| 8 | # 4. Override — copy src-tauri/gen-override/android/* into gen/android/ |
| 9 | # 5. Keystore — patch storeFile path in gen/android/keystore.properties |
| 10 | # 6. Build — cargo tauri android build --aab |
| 11 | # 7. Verify — assert the merged manifest still launches and reaches the net |
| 12 | # |
| 13 | # The build produces a signed universal .aab ready to upload to the Play |
| 14 | # Console. versionCode is injected at init/build time as YYYYMMDDhh (UTC) via a |
| 15 | # second -c flag, so tauri.prod-android.conf.json is never mutated. |
| 16 | # |
| 17 | # Every configurable value has a hardcoded default that can be overridden by |
| 18 | # setting the corresponding environment variable before invoking the script. |
| 19 | # KEYSTORE_PATH has no default and is read from an untracked .env.playstore if |
| 20 | # present — copy .env.playstore.example to get started. |
| 21 | # |
| 22 | # Usage: |
| 23 | # ./scripts/build-android.sh # full pipeline |
| 24 | # ./scripts/build-android.sh clean # rm gen/android only |
| 25 | # ./scripts/build-android.sh init # android init only |
| 26 | # ./scripts/build-android.sh icons # generate icons only |
| 27 | # ./scripts/build-android.sh override # copy gen-override files only |
| 28 | # ./scripts/build-android.sh keystore # patch keystore.properties only |
| 29 | # ./scripts/build-android.sh build # android build only |
| 30 | # ./scripts/build-android.sh verify # check the merged manifest only |
| 31 | # |
| 32 | # make psmobile # full pipeline via Makefile |
| 33 | # make psmobile-build |
| 34 | |
| 35 | set -euo pipefail |
| 36 | |
| 37 | # ── Paths ───────────────────────────────────────────────────────────────────── |
| 38 | |
| 39 | REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" |
| 40 | ANDROID_CONFIG="${REPO_ROOT}/src-tauri/tauri.prod-android.conf.json" |
| 41 | GEN_ANDROID_DIR="${REPO_ROOT}/src-tauri/gen/android" |
| 42 | GEN_OVERRIDE_DIR="${REPO_ROOT}/src-tauri/gen-override/android" |
| 43 | KEYSTORE_PROPERTIES="${GEN_ANDROID_DIR}/keystore.properties" |
| 44 | |
| 45 | # Auto-load the untracked .env.playstore (upload keystore path) so |
| 46 | # `make psmobile` works without the caller exporting it first. Mirrors how |
| 47 | # build-ios.sh / build-macos.sh / build-visionos.sh load .env.appstore. See |
| 48 | # .env.playstore.example for the expected keys. |
| 49 | # |
| 50 | # Sourcing assigns unconditionally, which would let the file quietly beat an |
| 51 | # inline `KEYSTORE_PATH=… make psmobile`. Stash the caller's value first and put |
| 52 | # it back afterwards, so an explicit override always wins over the file. |
| 53 | KEYSTORE_PATH_FROM_CALLER="${KEYSTORE_PATH:-}" |
| 54 | |
| 55 | if [[ -f "${REPO_ROOT}/.env.playstore" ]]; then |
| 56 | set -a |
| 57 | # shellcheck disable=SC1091 |
| 58 | . "${REPO_ROOT}/.env.playstore" |
| 59 | set +a |
| 60 | fi |
| 61 | |
| 62 | if [[ -n "$KEYSTORE_PATH_FROM_CALLER" ]]; then |
| 63 | KEYSTORE_PATH="$KEYSTORE_PATH_FROM_CALLER" |
| 64 | fi |
| 65 | |
| 66 | # ── Overridable config ──────────────────────────────────────────────────────── |
| 67 | |
| 68 | # Absolute path to the upload keystore (.jks). Required — no default, so that no |
| 69 | # personal path is baked into the public repo. Set it in .env.playstore (copy |
| 70 | # .env.playstore.example), or export it before running this script. |
| 71 | # |
| 72 | # Note this is the *only* way to point the build at a keystore: do_keystore |
| 73 | # rewrites storeFile= in gen/android/keystore.properties from this value, so |
| 74 | # editing that file by hand has no effect. |
| 75 | KEYSTORE_PATH="${KEYSTORE_PATH:?set KEYSTORE_PATH in .env.playstore (see .env.playstore.example) or export it}" |
| 76 | |
| 77 | # versionCode: YYYYMMDDhh (UTC) — same UTC timestamp base as the iOS bundleVersion |
| 78 | # Integer form of the date+hour; fits the Play Store 32-bit limit (≤ 2,100,000,000) through 2099 |
| 79 | # Monotonically increasing → satisfies Play Store requirement |
| 80 | VERSION_CODE="$(date -u +"%Y%m%d%H")" |
| 81 | VERSION_CODE_JSON="{\"bundle\":{\"android\":{\"versionCode\":${VERSION_CODE}}}}" |
| 82 | |
| 83 | # ── Helpers ─────────────────────────────────────────────────────────────────── |
| 84 | |
| 85 | step() { echo ""; echo "── $* ──────────────────────────────────────────────"; } |
| 86 | ok() { echo "✅ $*"; } |
| 87 | fail() { echo "❌ $*" >&2; exit 1; } |
| 88 | |
| 89 | # ── Steps ───────────────────────────────────────────────────────────────────── |
| 90 | |
| 91 | do_clean() { |
| 92 | step "1/7 Clean gen/android" |
| 93 | |
| 94 | echo "🗑 rm -rf ${GEN_ANDROID_DIR}" |
| 95 | rm -rf "${GEN_ANDROID_DIR}" |
| 96 | ok "Cleaned" |
| 97 | } |
| 98 | |
| 99 | do_init() { |
| 100 | step "2/7 Android init" |
| 101 | |
| 102 | [[ -f "$ANDROID_CONFIG" ]] || fail "Android config not found: $ANDROID_CONFIG" |
| 103 | |
| 104 | echo "🏷 versionCode → ${VERSION_CODE}" |
| 105 | echo "📂 config → ${ANDROID_CONFIG}" |
| 106 | echo "" |
| 107 | |
| 108 | cargo tauri android init \ |
| 109 | -c "${ANDROID_CONFIG}" \ |
| 110 | -c "${VERSION_CODE_JSON}" |
| 111 | |
| 112 | ok "Android project initialised → ${GEN_ANDROID_DIR}" |
| 113 | } |
| 114 | |
| 115 | do_icons() { |
| 116 | step "3/7 Generate icons" |
| 117 | |
| 118 | pnpm gim |
| 119 | ok "Icons generated" |
| 120 | } |
| 121 | |
| 122 | do_override() { |
| 123 | step "4/7 Apply gen-override/android" |
| 124 | |
| 125 | # Nothing to do if the override directory contains only .keep files. |
| 126 | OVERRIDE_COUNT="$(find "${GEN_OVERRIDE_DIR}" -not -name '.keep' -not -type d | wc -l | tr -d ' ')" |
| 127 | |
| 128 | if [[ "$OVERRIDE_COUNT" -eq 0 ]]; then |
| 129 | echo "ℹ️ No override files — skipping." |
| 130 | return |
| 131 | fi |
| 132 | |
| 133 | echo "📋 Copying ${OVERRIDE_COUNT} file(s) from gen-override/android → gen/android" |
| 134 | |
| 135 | rsync -av \ |
| 136 | --exclude='.keep' \ |
| 137 | "${GEN_OVERRIDE_DIR}/" \ |
| 138 | "${GEN_ANDROID_DIR}/" |
| 139 | |
| 140 | ok "Overrides applied" |
| 141 | } |
| 142 | |
| 143 | do_keystore() { |
| 144 | step "5/7 Patch keystore.properties" |
| 145 | |
| 146 | [[ -f "$KEYSTORE_PROPERTIES" ]] || fail "keystore.properties not found: ${KEYSTORE_PROPERTIES}\n Run 'override' step first." |
| 147 | [[ -f "$KEYSTORE_PATH" ]] || fail "Keystore file not found: ${KEYSTORE_PATH}\n Set KEYSTORE_PATH env var to the correct .jks path." |
| 148 | |
| 149 | echo "🔑 storeFile → ${KEYSTORE_PATH}" |
| 150 | |
| 151 | # Replace the storeFile= line in-place, leaving all other keys untouched. |
| 152 | sed -i '' "s|^storeFile=.*|storeFile=${KEYSTORE_PATH}|" "${KEYSTORE_PROPERTIES}" |
| 153 | |
| 154 | ok "Patched → ${KEYSTORE_PROPERTIES}" |
| 155 | } |
| 156 | |
| 157 | do_build() { |
| 158 | step "6/7 Build" |
| 159 | |
| 160 | [[ -f "$ANDROID_CONFIG" ]] || fail "Android config not found: $ANDROID_CONFIG" |
| 161 | |
| 162 | echo "🏷 versionCode → ${VERSION_CODE}" |
| 163 | echo "📦 config → ${ANDROID_CONFIG}" |
| 164 | echo "" |
| 165 | |
| 166 | cargo tauri android build \ |
| 167 | --aab \ |
| 168 | -c "${ANDROID_CONFIG}" \ |
| 169 | -c "${VERSION_CODE_JSON}" |
| 170 | |
| 171 | AAB_PATH="${GEN_ANDROID_DIR}/app/build/outputs/bundle/universalRelease/app-universal-release.aab" |
| 172 | if [[ -f "$AAB_PATH" ]]; then |
| 173 | ok "Build complete → ${AAB_PATH}" |
| 174 | else |
| 175 | ok "Build complete (locate the .aab under ${GEN_ANDROID_DIR}/app/build/outputs/bundle/)" |
| 176 | fi |
| 177 | } |
| 178 | |
| 179 | do_verify() { |
| 180 | step "7/7 Verify merged manifest" |
| 181 | |
| 182 | # Guards the failure mode that shipped in 1.0.1: an override file copied over |
| 183 | # app/src/main/AndroidManifest.xml replaced Tauri's generated manifest instead |
| 184 | # of merging with it, so the released .aab had no MAIN/LAUNCHER filter (the |
| 185 | # Play Store offered Install but no Open, and no icon appeared in the drawer) |
| 186 | # and no INTERNET permission (no model could ever download). Both are silent |
| 187 | # at build time — nothing fails, the .aab just cannot be launched — so assert |
| 188 | # on the post-merge manifest that actually goes into the bundle. |
| 189 | MERGED_MANIFEST="$(find "${GEN_ANDROID_DIR}/app/build/intermediates" \ |
| 190 | -path '*bundle_manifest*universalRelease*' -name 'AndroidManifest.xml' \ |
| 191 | -print -quit 2>/dev/null || true)" |
| 192 | |
| 193 | [[ -n "$MERGED_MANIFEST" ]] || fail "No merged release manifest found — run the 'build' step first." |
| 194 | |
| 195 | echo "🔍 ${MERGED_MANIFEST#"${REPO_ROOT}/"}" |
| 196 | |
| 197 | # "<what to look for>|<what its absence breaks>" |
| 198 | REQUIRED=( |
| 199 | 'android.intent.action.MAIN|no launcher entry — Play Store shows Install but no Open' |
| 200 | 'android.intent.category.LAUNCHER|no icon in the app drawer' |
| 201 | 'android.permission.INTERNET|model downloads fail' |
| 202 | 'android:icon=|no launcher icon' |
| 203 | 'android:label=|no app name under the icon' |
| 204 | ) |
| 205 | |
| 206 | MISSING=0 |
| 207 | for entry in "${REQUIRED[@]}"; do |
| 208 | needle="${entry%%|*}" |
| 209 | consequence="${entry#*|}" |
| 210 | if grep -qF "$needle" "$MERGED_MANIFEST"; then |
| 211 | echo " ✓ ${needle}" |
| 212 | else |
| 213 | echo " ✗ ${needle} — ${consequence}" >&2 |
| 214 | MISSING=1 |
| 215 | fi |
| 216 | done |
| 217 | |
| 218 | [[ "$MISSING" -eq 0 ]] || fail "Merged manifest is missing required entries (see above). |
| 219 | Almost always caused by a file in gen-override/android/app/src/main/ |
| 220 | replacing a generated file rather than adding to it. Manifest additions |
| 221 | belong in a build-type source set such as app/src/release/, which AGP's |
| 222 | manifest merger folds into the generated one." |
| 223 | |
| 224 | ok "Manifest is launchable" |
| 225 | } |
| 226 | |
| 227 | # ── Entry point ─────────────────────────────────────────────────────────────── |
| 228 | |
| 229 | STEP="${1:-all}" |
| 230 | |
| 231 | case "$STEP" in |
| 232 | clean) do_clean ;; |
| 233 | init) do_init ;; |
| 234 | icons) do_icons ;; |
| 235 | override) do_override ;; |
| 236 | keystore) do_keystore ;; |
| 237 | build) do_build ;; |
| 238 | verify) do_verify ;; |
| 239 | all) |
| 240 | do_clean |
| 241 | do_init |
| 242 | do_icons |
| 243 | do_override |
| 244 | do_keystore |
| 245 | do_build |
| 246 | do_verify |
| 247 | echo "" |
| 248 | echo "🎉 Pipeline complete: Siti AI Android .aab is ready for Play Store upload." |
| 249 | ;; |
| 250 | *) |
| 251 | echo "Usage: $0 [clean|init|icons|override|keystore|build|verify|all]" >&2 |
| 252 | exit 1 |
| 253 | ;; |
| 254 | esac |