Raw
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