ThreadCount Community edition

Uniform stock management for healthcare linen rooms: the coordinator app, the phone counter and the staff app, for your own server. Built from 8685140 on 2026-09-13. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
ThreadCount
2026-09-13 11:09:20 +10:00
commit 822c0b7c0b
406 changed files with 47742 additions and 0 deletions
+126
View File
@@ -0,0 +1,126 @@
#!/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