057da00fd2
Uniform stock management for healthcare linen rooms: the coordinator app, the phone counter and the staff app, for your own server. Built from e2d6d42 on 2026-09-13. Licensed under the Functional Source License (FSL-1.1-ALv2).
127 lines
6.4 KiB
Bash
Executable File
127 lines
6.4 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Build the ThreadCount Staff app bundle.
|
|
#
|
|
# bash scripts/build-staff-aab.sh → android-staff/app/build/outputs/bundle/release/*.aab
|
|
# bash scripts/build-staff-aab.sh debug → a debug APK, for putting on a phone quickly
|
|
#
|
|
# Two things here are not boilerplate.
|
|
#
|
|
# **The barcode scanner is stripped after every sync.** Capacitor has no per-app plugin list: it
|
|
# scans package.json and wires every installed plugin into whichever native project it is syncing.
|
|
# The counter app needs @capacitor-mlkit/barcode-scanning; this one does not, and taking it means
|
|
# taking MLKit, CameraX, three native libraries and a CAMERA permission into an app that every
|
|
# clinical staff member in a hospital installs. Nothing a wearer does involves scanning, so the
|
|
# two generated gradle files and the generated plugin list get the plugin cut out of them again on
|
|
# the way past. All three are regenerated by `cap sync`, which is exactly why this lives in the
|
|
# build rather than in a one-off edit.
|
|
#
|
|
# **The toolchain is user-space.** JDK 17 and the SDK live under ~/.cache/ca-android — no sudo,
|
|
# no system Java, nothing installed outside the home directory.
|
|
set -euo pipefail
|
|
cd "$(dirname "$0")/.."
|
|
|
|
MODE=${1:-release}
|
|
export JAVA_HOME="${JAVA_HOME:-$HOME/.cache/ca-android/jdk}"
|
|
export ANDROID_HOME="${ANDROID_HOME:-$HOME/.cache/ca-android/sdk}"
|
|
export ANDROID_SDK_ROOT="$ANDROID_HOME"
|
|
|
|
[ -x "$JAVA_HOME/bin/java" ] || { echo "FATAL: no JDK at $JAVA_HOME"; exit 1; }
|
|
[ -d "$ANDROID_HOME/platforms" ] || { echo "FATAL: no Android SDK at $ANDROID_HOME"; exit 1; }
|
|
echo "sdk.dir=$ANDROID_HOME" > android-staff/local.properties
|
|
|
|
echo "==> syncing the staff shell into android-staff"
|
|
TC_APP=staff npx cap sync android
|
|
|
|
PLUGINS_JSON=android-staff/app/src/main/assets/capacitor.plugins.json
|
|
echo "==> removing the plugins Capacitor just put back"
|
|
# Both files carry a "DO NOT EDIT" banner because cap regenerates them; we edit the regenerated
|
|
# copy, every time, on purpose. Each plugin contributes two lines to settings.gradle (its include
|
|
# and its projectDir) and one to capacitor.build.gradle, and all three name the plugin, so a
|
|
# single match per file is enough.
|
|
for plugin in capacitor-mlkit-barcode-scanning capacitor-haptics; do
|
|
sed -i "/$plugin/d" android-staff/capacitor.settings.gradle
|
|
sed -i "/$plugin/d" android-staff/app/capacitor.build.gradle
|
|
done
|
|
# The third generated file is the list Capacitor reads at startup, and it names the stripped
|
|
# plugins by class. Leaving them in it is not harmless: PluginManager.loadPluginClasses() throws
|
|
# on the first class that is no longer in the bundle, BridgeActivity catches it and carries on
|
|
# with *no* plugins registered at all — so a plugin this app genuinely needs would silently never
|
|
# load, and it would look like a bug in the page.
|
|
#
|
|
# It used to be written out empty, which was correct only while every installed plugin was one
|
|
# this app strips. @capacitor/browser broke that: it is the thing that hands a tapped Privacy or
|
|
# Terms link to the phone's browser instead of loading it into a shell with no way back, and an
|
|
# empty list would have shipped it dead while package.json, the gradle files and the sync output
|
|
# all said it was there. So the array is filtered by package name rather than blanked, and a
|
|
# plugin added later survives unless it is named in the strip loop above.
|
|
python3 - "$PLUGINS_JSON" <<'PY'
|
|
import json, sys
|
|
p = sys.argv[1]
|
|
keep = [e for e in json.load(open(p)) if not any(k in e.get("pkg", "") for k in ("barcode-scanning", "haptics"))]
|
|
json.dump(keep, open(p, "w"), indent=2)
|
|
open(p, "a").write("\n")
|
|
print(" kept in the staff bundle: " + (", ".join(e["pkg"] for e in keep) or "(none)"))
|
|
PY
|
|
# The scanner brings MLKit, CameraX, three native libraries and a CAMERA permission; haptics
|
|
# brings VIBRATE. A wearer scans nothing and this app buzzes at nobody, so both go. The check is
|
|
# a hard failure rather than a warning: shipping an app to every nurse in a hospital that asks
|
|
# for the camera would be worth stopping a release over.
|
|
if grep -qE "mlkit|haptics" android-staff/capacitor.settings.gradle android-staff/app/capacitor.build.gradle \
|
|
"$PLUGINS_JSON"; then
|
|
echo "FATAL: a stripped plugin is still wired in — refusing to build."
|
|
exit 1
|
|
fi
|
|
|
|
if [ "$MODE" = "debug" ]; then
|
|
echo "==> assembling a debug APK"
|
|
( cd android-staff && ./gradlew --no-daemon assembleDebug )
|
|
find android-staff/app/build/outputs/apk -name "*.apk" -print
|
|
exit 0
|
|
fi
|
|
|
|
KEYS="$HOME/threadcount-keys/staff-keystore.properties"
|
|
[ -f "$KEYS" ] || echo "NOTE: $KEYS is missing — the bundle will be unsigned."
|
|
|
|
echo "==> building the release bundle"
|
|
( cd android-staff && ./gradlew --no-daemon bundleRelease )
|
|
|
|
AAB=$(find android-staff/app/build/outputs/bundle/release -name "*.aab" | head -1)
|
|
echo "==> $AAB"
|
|
|
|
# Prove the two things Play checks and the one thing this app promises.
|
|
echo "==> checks"
|
|
if unzip -l "$AAB" | grep -q "\.so$"; then
|
|
echo " ! native libraries present — the scanner strip did not work"
|
|
exit 1
|
|
else
|
|
echo " ✓ no native libraries (so no 16 KB alignment problem and no debug symbols to ship)"
|
|
fi
|
|
if unzip -p "$AAB" BUNDLE-METADATA/com.android.tools.build.obfuscation/proguard.map >/dev/null 2>&1; then
|
|
echo " ✓ mapping file bundled — Play can symbolicate a stack trace"
|
|
else
|
|
echo " ! no mapping file; Play will warn about deobfuscation"
|
|
fi
|
|
|
|
BT="$HOME/.cache/ca-android/bundletool.jar"
|
|
if [ -f "$BT" ]; then
|
|
# Only <uses-permission> counts. A permission name also appears as the `android:permission`
|
|
# guard on a component — androidx.profileinstaller puts DUMP on its receiver so that only the
|
|
# shell can broadcast to it — and matching those made this read as if the app asked for things
|
|
# it does not.
|
|
PERMS=$("$JAVA_HOME/bin/java" -jar "$BT" dump manifest --bundle "$AAB" 2>/dev/null \
|
|
| grep -oE '<uses-permission[^>]*android:name="[^"]+"' \
|
|
| grep -oE '"[^"]+"$' | tr -d '"' | sort -u)
|
|
echo " permissions: $(echo "$PERMS" | tr '\n' ' ')"
|
|
if echo "$PERMS" | grep -qE 'CAMERA|VIBRATE|RECORD_AUDIO|ACCESS_FINE_LOCATION'; then
|
|
echo " ! this app asks for more than it needs"
|
|
exit 1
|
|
fi
|
|
# Anything beyond INTERNET and the signature-level permission Capacitor defines for its own
|
|
# dynamic receivers is a plugin that got in without anyone deciding it should.
|
|
if echo "$PERMS" | grep -vqE 'android.permission.INTERNET|DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION'; then
|
|
echo " ! an unexpected permission is present"
|
|
exit 1
|
|
fi
|
|
echo " ✓ internet only"
|
|
fi
|