set quiet # Application ids. # # `debug` carries an `applicationIdSuffix` of `.debug`, so a debug install sits # alongside a release one as a second, identically-named "Grain" in the # launcher with its own data and its own signed-out session. That's the right # default for an emulator and the wrong one for a phone you actually use, so # every recipe here is explicit about which id it is touching. `just phone` # ships the release variant, which shares the app id *and* the upload-key # signature with a sideloaded install and therefore replaces it in place — # keeping one Grain on the device, still signed in. app_id := "social.grain" debug_app_id := "social.grain.debug" # `next`: debug against next.grain.social, beside the other two. next_app_id := "social.grain.next" launcher := app_id + "/social.grain.MainActivity" debug_launcher := debug_app_id + "/social.grain.MainActivity" # adb isn't on PATH by default on macOS; find it the way Studio does. android_home := env_var_or_default("ANDROID_HOME", env_var_or_default("ANDROID_SDK_ROOT", home_directory() + "/Library/Android/sdk")) adb := android_home + "/platform-tools/adb" emulator_bin := android_home + "/emulator/emulator" # The AVD `just boot` starts. Override with $GRAIN_AVD to use another. avd := env_var_or_default("GRAIN_AVD", "Pixel_3a_API_34_extension_level_7_arm64-v8a") # Seconds `just boot` waits for a cold boot. Generous because it is a cold boot # every time — a snapshot is what a wedged emulator would be restored from — # and because a busy machine can take several minutes over it. boot_timeout := env_var_or_default("GRAIN_BOOT_TIMEOUT", "420") # Default: list available recipes default: just --list # MARK: - Devices # List attached devices and emulators devices: {{adb}} devices -l # Resolve the device to act on: $ANDROID_SERIAL if set, otherwise the only one # attached. Refuses to guess when several are connected — picking for you is how # a build ends up on the wrong device. # # Written for bash 3.2, which is what macOS ships: no mapfile, no readarray. [private] serial: #!/usr/bin/env bash set -eu if [ -n "${ANDROID_SERIAL:-}" ]; then echo "$ANDROID_SERIAL"; exit 0; fi found=$({{adb}} devices | awk 'NR>1 && $2=="device" {print $1}') count=$(printf '%s' "$found" | grep -c . || true) if [ "$count" -eq 0 ]; then echo "No device attached. Plug one in or boot an emulator." >&2 exit 1 fi if [ "$count" -eq 1 ]; then echo "$found"; exit 0; fi # One wirelessly-paired phone shows up twice — once under its mDNS name and # once as ip:port — so count distinct hardware serials, not adb transports, # before deciding this is ambiguous. hardware="" for candidate in $found; do id=$({{adb}} -s "$candidate" shell getprop ro.serialno 2>/dev/null | tr -d '\r' || true) hardware="$hardware$id\n" done distinct=$(printf "$hardware" | sort -u | grep -c . || true) if [ "$distinct" -le 1 ]; then echo "$found" | head -1 exit 0 fi echo "Several devices attached; set ANDROID_SERIAL to choose:" >&2 echo "$found" | sed 's/^/ /' >&2 exit 1 # Boot an emulator with enough headroom for an instrumented run boot: #!/usr/bin/env bash # Idempotent: if one is already answering, this does nothing. Otherwise it # recycles whatever is there and starts a fresh one. # # The extra memory and cores are the point. The AVD is configured with # `hw.cpu.ncore = 1` and `hw.ramSize = 1536`, and that is not enough to # finish `connectedDebugAndroidTest` under JaCoCo: partway through, the # guest's `adbd` stops answering, and because the transport is still up # `adb devices` keeps reporting `device` while AGP refuses the run with # "Unknown API Level". Passed as flags rather than written into config.ini, # so launching the same AVD from Studio is unaffected. set -eu # `adb shell` against a wedged adbd blocks forever, and every blocked client # stays on the host — a few hundred of them take the load average with them. # So the probe runs in the background and is killed if it doesn't answer, # which also makes "wedged" and "not booted yet" the same answer here. probe() { local out pid waited answer out=$(mktemp) {{adb}} -s emulator-5554 shell getprop sys.boot_completed >"$out" 2>/dev/null & pid=$! waited=0 while kill -0 "$pid" 2>/dev/null && [ "$waited" -lt 8 ]; do sleep 1 waited=$((waited + 1)) done kill -9 "$pid" 2>/dev/null || true wait "$pid" 2>/dev/null || true answer=$(tr -d '\r' <"$out") rm -f "$out" [ "$answer" = "1" ] } if probe; then echo "Emulator already up." exit 0 fi echo "Recycling the emulator..." # Every emulator, not just this AVD: the process to kill is the qemu one # behind the `emulator` wrapper, and it doesn't reliably carry the AVD name # in its argv. Fine here — the project has one AVD — but worth knowing if a # second one is ever running alongside. pkill -9 -f qemu-system >/dev/null 2>&1 || true # Restarting the adb server is the only thing that clears its stale view of # a killed emulator — but it would also drop a wirelessly-paired phone, # which is expensive to get back. Only safe when nothing else is attached. others=$({{adb}} devices 2>/dev/null | awk 'NR>1 && $1 != "" && $1 !~ /^emulator-/ {print $1}' || true) if [ -z "$others" ]; then pkill -x adb >/dev/null 2>&1 || true sleep 2 {{adb}} start-server >/dev/null 2>&1 || true else echo "Another device is attached; leaving the adb server alone." >&2 fi nohup {{emulator_bin}} -avd {{avd}} \ -memory 4096 -cores 4 \ -no-snapshot -no-boot-anim -no-audio \ >/tmp/grain-emulator.log 2>&1 & disown || true # Bounded by the clock, not by a number of attempts: `adb shell` blocks # until the device comes online, so an attempt costs anywhere between no # time and the probe's own timeout, and counting them budgets nothing. echo "Waiting for {{avd}} to boot (log: /tmp/grain-emulator.log)..." deadline=$(( $(date +%s) + {{boot_timeout}} )) while [ "$(date +%s)" -lt "$deadline" ]; do if probe; then # Gradle's device connection is made by the daemon, which is still # holding a handle to the emulator that just died. Left alone it # reports "No compatible devices connected" against a device adb is # perfectly happy with. ./gradlew --stop >/dev/null 2>&1 || true echo "Emulator ready." exit 0 fi sleep 5 done echo "Emulator never came back; see /tmp/grain-emulator.log" >&2 exit 1 # MARK: - Build # Compile the debug variant build: ./gradlew :app:assembleDebug # Compile the release variant (R8 + the upload key from keystore.properties) build-release: ./gradlew :app:assembleRelease # Remove build outputs clean: ./gradlew clean # MARK: - Install # Build and install to a real device, replacing the Grain already on it phone: build-release #!/usr/bin/env bash # Release rather than debug on purpose: it keeps the app id and the # upload-key signature of a sideloaded install, so this upgrades in place # and the signed-in session survives. Slower than a debug build (R8 runs) # and not debuggable — use `just emulator` for the fast loop. set -eu serial=$(just serial) echo "Installing the release build on $serial..." {{adb}} -s "$serial" install -r app/build/outputs/apk/release/app-release.apk {{adb}} -s "$serial" shell am start -n {{launcher}} >/dev/null echo "Installed and launched {{app_id}}." # Build and install the debug variant, as social.grain.debug emulator: build #!/usr/bin/env bash # Intended for an emulator, where a second app costs nothing. On a phone # that already has Grain this leaves you with two identical icons — # `just phone` is what you want there. set -eu serial=$(just serial) echo "Installing the debug build on $serial..." {{adb}} -s "$serial" install -r app/build/outputs/apk/debug/app-debug.apk {{adb}} -s "$serial" shell am start -n {{debug_launcher}} >/dev/null echo "Installed and launched {{debug_app_id}}." # Build and install the next variant (next.grain.social), as social.grain.next emulator-next: #!/usr/bin/env bash set -eu ./gradlew :app:assembleNext serial=$(just serial) echo "Installing the next build on $serial..." {{adb}} -s "$serial" install -r app/build/outputs/apk/next/app-next.apk {{adb}} -s "$serial" shell am start -n {{next_app_id}}/social.grain.MainActivity >/dev/null echo "Installed and launched {{next_app_id}}." # Wipe app data without uninstalling — signs out and clears every cache reset: #!/usr/bin/env bash set -eu serial=$(just serial) {{adb}} -s "$serial" shell pm clear {{app_id}} >/dev/null 2>&1 && echo "Cleared {{app_id}}." || true {{adb}} -s "$serial" shell pm clear {{debug_app_id}} >/dev/null 2>&1 && echo "Cleared {{debug_app_id}}." || true # Remove a stray debug install, leaving only the release app uninstall-debug: #!/usr/bin/env bash set -eu serial=$(just serial) {{adb}} -s "$serial" uninstall {{debug_app_id}} && echo "Removed {{debug_app_id}}." # Show which Grain packages are on the device installed: #!/usr/bin/env bash set -eu serial=$(just serial) {{adb}} -s "$serial" shell pm list packages | grep -i grain || echo "No Grain install found." # MARK: - Run # Tail the running app's logs — whichever variant is up logs: #!/usr/bin/env bash set -eu serial=$(just serial) pid=$({{adb}} -s "$serial" shell pidof {{app_id}} 2>/dev/null || true) if [ -z "$pid" ]; then pid=$({{adb}} -s "$serial" shell pidof {{debug_app_id}} 2>/dev/null || true) fi if [ -z "$pid" ]; then echo "Grain isn't running on $serial." >&2; exit 1; fi {{adb}} -s "$serial" logcat --pid="$(echo $pid | tr -d '\r')" # Open a deep link, e.g. just link https://grain.social/profile/alice.grain.social link url: #!/usr/bin/env bash # Deliberately unconstrained by package: this is testing whether the # manifest's intent filters claim the link at all, and pinning it to one # app id would answer a different question. set -eu serial=$(just serial) {{adb}} -s "$serial" shell am start -a android.intent.action.VIEW \ -c android.intent.category.BROWSABLE -d '{{url}}' # MARK: - Basemap # Regenerate the MapLibre style assets from the Protomaps theme package basemap-style: #!/usr/bin/env bash # Committed output: the app builds without node, and a theme bump is a # reviewable diff rather than a runtime surprise. set -eu cd tools/basemap npm install --silent npm run generate --silent # MARK: - Test # JVM unit tests test: ./gradlew :app:testDebugUnitTest # Instrumented tests on whatever is attached (phone or emulator) test-device: #!/usr/bin/env bash set -eu ANDROID_SERIAL=$(just serial) ./gradlew :app:connectedDebugAndroidTest # Instrumented tests on the emulator, booting one first if need be test-emulator: boot #!/usr/bin/env bash # Pinned to emulator-5554 rather than going through `just serial`, so this # still means "the emulator" when a phone happens to be plugged in too. set -eu ANDROID_SERIAL=emulator-5554 ./gradlew :app:connectedDebugAndroidTest # Coverage over both test suites, on the emulator coverage: boot #!/usr/bin/env bash # The JVM half carries almost all of it — the view models, the API layer and # every Compose screen run under Robolectric — so `coverage-jvm` is the # number to watch day to day. This adds the instrumented half, which is what # the handful of tests that need a real device contribute: live-response # decoding, the launcher icon, ICU grapheme counts. # # Goes through `boot` because this is the run that needs the headroom: the # app APK is instrumented too, so every branch in it carries a JaCoCo probe. set -eu ./gradlew -Pcoverage :app:testDebugUnitTest ANDROID_SERIAL=emulator-5554 ./gradlew -Pcoverage :app:connectedDebugAndroidTest ./gradlew -Pcoverage :app:coverageReport # Coverage from the JVM unit tests alone — no device, about a minute coverage-jvm: #!/usr/bin/env bash # The honest everyday number: Robolectric runs the screens as well as the # units, so this reads within a point or two of the merged report. set -eu ./gradlew -Pcoverage :app:testDebugUnitTest :app:coverageReport # Tests plus the coverage gate — no device needed coverage-check: #!/usr/bin/env bash # The gate CI runs. Both tasks in one invocation on purpose: the # verification reads whatever execution data is on disk, so running it # against a stale `.exec` from an earlier run would check the wrong tree. set -eu ./gradlew -Pcoverage :app:testDebugUnitTest :app:coverageVerification # Everything CI would run check: coverage-check build-release # MARK: - Release # Build the Play bundle (bump versionCode in app/build.gradle.kts first — see RELEASE.md) bundle: ./gradlew bundleRelease echo "Bundle at app/build/outputs/bundle/release/app-release.aab"