1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
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"