~ knotless Search/
↑↓ move↵ open⇧↵ all results

grain.social/grain-android

1 collaborator
0
1
main
339 lines · 13.2 KB Raw History
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"