Files
paseo/docs/android.md
T
Mohamed Boudra 4f7813d3aa Keep the tapped line in place when chat disclosures expand or collapse, with the native timeline on LegendList (#8717)
* Keep a disclosure's trigger in place on web

Expanding or collapsing a turn summary, tool-call group, tool call details
or todo badge now holds the row that owns the trigger. Rows inside the
timeline get a disclosure context; each toggle runs its state change through
`useDisclosureHold()`, and the viewport's new `holdRow(rowId, apply)` replaces
`holdReadingPosition()`.

On web the hold pins the reading anchor to that row, stops following output,
and resumes following when the change leaves the list at its end. Native keeps
its previous behaviour (detach, then apply) until the anchored toggle lands.

* Render the native chat timeline with LegendList in forward order

The native timeline was an inverted FlatList. Disclosure toggles moved their
trigger, and a cold open settled visibly. It is now a forward-order LegendList
that stays virtualized, and one owner moves the scroll offset at a time:

- Before placement: LegendList's initial scroll, behind its hidden reveal.
- While following: the end pin (native-end-pin.tsx). This is a zero-height first
  child at the bottom of the content, which native maintainVisibleContentPosition
  keeps in place. A content shrink or growth never scrolls from JS; only a
  viewport resize or an explicit jump does.
- During a disclosure hold and while detached: LegendList MVCP with
  shouldRestorePosition (native-disclosure-hold.ts), so a toggle keeps its
  trigger at the same y and paints one layout change.
- Every newly mounted row starts at a per-kind estimate (new-row-estimates.ts).

PaseoScrollView defers React Native's content clamp while MVCP is set. A shrink
below an unmoved anchor no longer draws a frame past the end (RN #58578,
PR #58695). VisibleContentPositionClampTest covers this.

LegendList 3.7.1 patch, one "Paseo patch" comment per hunk:
- The content height comes from React state on the new architecture.
- Rows get a per-row size estimate.
- The MVCP anchor lock runs on native too. Upstream gated it to web in d24a9d93
  without a native reason. Without the lock, the size pass for prepended history
  re-anchors on a prepended row and moves the rows on screen. Native ends the
  lock when its row leaves the view, not on the 300 ms clock.
- Header and footer reveal together with the rows.
- Only lists that keep their end preserve the initial end.
- A remeasure within one pixel plus the 1/8 dp floor of the known size is
  noise. Upstream compares the raw size with the floored one, so the live
  turn's assistant row (1380 -> 1381 px) read as a resize and moved every row
  a frame after the reveal.
- The Android silent initial scroll retry sends its 1 dp nudge and the return
  to the target in one task. Upstream returns on the next frame, but its 16 ms
  check can finish the scroll first and cancel that frame, so the list revealed
  3 px short of its end and moved into place a frame later.
- The top-padding shrink hold applies on iOS only.

Android, emulator. Evidence is in .artifacts/legend-impl.
- J1, J2 at the end, J3, J4, J5, J6, J7: pass.
- J2 while streaming: not reachable on the Ten second stream fixture; its turns
  never fold.
- J8: no blank frames over 10 fresh flings, at host load 32-37.

J4: still. Before these two hunks, 6 of 7 cold opens moved every row one frame
after the reveal. After them, 9 valid cold opens show no shift. 5 of those
were traced through the nudge path. Runs that hit the dev client's startup
SIGSEGV in MountingCoordinator::pullTransaction were left out. That crash is
in the emulator's dropbox from before this work.

* Revise a history row when its turn summary line changes

LegendList renders a row again only when its item changes. The forward-order
timeline in d1f2ae352 folds a finished turn into its host user message, but the
host's item does not change when the fold appears, so a turn that folded live
lost its summary line until something else revised the row.

collapseTurns now reports the hosts whose summary line appeared, changed or went
away since the previous fold (updatedHostIds), and the history row revision
treats them as changed content.

* Stop following the end while a user scroll owns the viewport

The bottom anchor controller kept sticky-bottom for the whole of a drag and its momentum and only
decided at the end. While it is sticky the native list mounts the end pin, so a drag started during
a stream had every new line move the rows under the finger: on the iOS simulator the offset jumped
by each line's height (+22, +35, +22 pt) mid-drag, and Android does the same with the same code.

beginUserScroll now detaches. endUserScroll resumes following when the gesture ends at the end
(scrolling there without animation) and otherwise stays detached and drops any pending anchor, as
before. The re-stick is not animated: on Android a line that mounts during a smooth scroll cancels
it, and the list then followed 40 pt short of the end until the controller detached it.

Measured after the change, frame by frame:
- iOS and Android, drag up mid-stream: the content tracks the finger and momentum with no line
  steps; the stream grows below; tapping the pill re-sticks and follows again.
- A short drag at the end, mid-stream: following resumes; the release lands on the end in one
  frame (the drag distance, here 8 pt on iOS and 21 dp on Android).

A line already mounting when the drag begins can still move the rows once in its first frame,
because the detach reaches native one commit after the touch.

* Correct the iOS scroll view under maintainVisibleContentPosition

Phase 2 of the LegendList timeline. The native timeline relies on maintainVisibleContentPosition
for the end pin and the disclosure hold, and React Native 0.86 on iOS gets two cases wrong.
modules/paseo-scroll now registers PaseoScrollViewComponentView, a subclass of
RCTScrollViewComponentView, for the iOS scroll view, like PaseoScrollView on Android.

- A mount that removes its own anchor: detaching removes the end pin in the same mount that opens
  the tapped disclosure, and the offset jumped by the removed view's frame. The subclass keeps the
  offset when the anchor's tag is no longer registered, the check React Native 0.88 adds in
  https://github.com/react/react-native/pull/57294. That PR's extra superview check is not copied
  because it skips anchors detached by removeClippedSubviews
  (https://github.com/react/react-native/issues/58910).
- A shrink at the end: UIKit clamps the offset to the new end during the mount, then React Native
  adds the anchor's movement, so a turn folding while following landed above the end
  (https://github.com/react/react-native/issues/58578). The subclass restores the pre-mount offset
  for the adjustment and clamps after it, as
  https://github.com/react/react-native/pull/58695 does.

The dev client was rebuilt with the module. Verified on the iPhone 16 Pro simulator, frame by frame:
the summary far up and the latest turn's summary while following each keep the trigger at 0 px
with one layout change per toggle, the todo card the same, the cold open lands still, older
history pages keep the visible rows in place, and the keyboard, send-time shrink and streaming
stay pinned to the end.

Freshly mounted markdown on iOS paints one frame after its layout, because enriched-markdown
renders asynchronously (https://github.com/software-mansion/enriched-markdown/issues/1131). A
synchronous first render removed that frame but raised fling frame time at p90 and p99, so it is
not part of this change.

docs/agent-stream-disclosure.md now describes the clamp correction on both platforms.

* Keep the rows under the finger at a drag's start and its release

Two one-frame row movements remained around a user scroll while the timeline streams.

A drag's first frames. The end pin stays mounted until JS stops following, one round trip after
the drag begins, so a line mounting in those frames scrolled the rows under the finger by its
height. Both scroll view subclasses now skip the content-position correction while the user drags
and the anchor is the end pin, which they recognise by its native ID ("paseo-end-pin"). Android
overrides scrollToPreservingMomentum, the position keeper's only scroll call, from the begin-drag
event until the finger lifts; iOS skips the adjustment while the scroll view is dragging. The skip
is limited to the pin: LegendList's own anchoring for the detached list also uses the native
correction, and a history page landing mid-drag needs it. It starts at the drag rather than at
touch-down: a tap or long press while following would otherwise leave the list stranded short of
its end, still following, once the finger lifts.

The release. A gesture ending within 32 pt of the end re-stuck with an instant scroll, a snap of up
to 56 px. Following now resumes only when the finger lifts at or past the end, or momentum stops
there, within 1 px, the distance web resumes at (strategy-web.tsx). Anything else stays detached with the
pill. The check runs when the gesture is decided, so output that arrived below meanwhile counts. A
release past the end counts as at the end: on iOS it bounces back, and a line landing during the
bounce scrolls in as following would. endUserScroll takes isAtEnd, like releaseDisclosure.

Tests: VisibleContentPositionClampTest gains growth at the end during a drag (red before the
change: the offset moved by the 120 px growth, 352 -> 472) and growth without a touch (the pin
still follows); the module suite passes on emulator-5558, 11 tests.
bottom-anchor-controller.test.ts: a release near but short of the end stays detached.

Device, frame scans while streaming (dev clients rebuilt):
- Android, 8 slow drags with a pill tap between: no frame moves against the finger once a drag
  starts; the stream follows between drags. Three short releases 50 px short of the end stay put
  until the pill; three drags back past the end resume following.
- iOS simulator, 6 drags: no line step during any drag or its momentum. Two short releases stay
  detached with no snap; three drags past the end resume following within 0.5 s of the bounce.

* Document the LegendList timeline and remove what the inverted list left

The native section of docs/agent-stream-disclosure.md described the FlatList hold (anchor
neighbour, native child index, frame spacing, render window). It now covers why the timeline is
forward order, who owns the offset in each state, the hold and its sync layout trigger, user
scroll and release, draw distance numbers, the LegendList patch and the scroll view corrections.

Draw distance stays at 2000. The spike measured it on a chat with a 15,000 px turn expanded: 500
blanked the viewport 1.0 to 1.5 s per fling and 2000 not at all. A re-measure on the collapsed chat
(500, 1000, 1500, 2000, interleaved) blanked at no value and its jank followed host load, which
stayed between 19 and 40, so it could not separate 1000 or 1500 from 2000.

The patch file now opens with a header listing its eight hunks by purpose and linking
LegendApp/legend-list#593, which reports each one upstream. The markdown doc records the iOS
first-paint delay (software-mansion/enriched-markdown#1131).

Removed with the inverted list: PaseoScrollView's child-reveal override (a no-op without the
inverting transform) and ScrollSelectionTest, the invertedWrapper style, and the inverted-list
passages in android.md, native-text-selection.md, mobile-testing.md, floating-panels.md and
agent-stream-performance.md. model.test, layout.test and footer-spacing.test still asserted the
inverted native order and failed; they now assert forward order, and footer-spacing uses the
real strategies instead of an inverted stub.

* fix(app): show a reply's footer when the next prompt arrives on native

LegendList re-renders a mounted row only when its item or extraData changes.
When a new prompt arrives, the previous reply's footer moves from the live
auxiliary slot into that reply's layout item (completedFooter). The reply's
StreamItem keeps its identity, and renderItem is a stable event, so the row never
rendered its new layout and the footer (copy, timing, fork) disappeared until the
row remounted. FlatList had the same gap.

The history renderer is now LegendList's extraData. Its identity changes with the
history layout map, and StreamRow's memo limits the pass to rows whose layout
item changed.

* test(app): run the web disclosure hold cases in a real browser

The three hold cases simulated layout in jsdom: hand-written row heights,
scroll clamping and resize callbacks. They could pass while Chromium paints
the trigger somewhere else. They now run in Vitest browser mode with real
layout, ResizeObserver and scroll clamping. The two hold cases fail when the
hold is bypassed.

The browser project pre-bundles @tanstack/react-virtual, so its first import no
longer reloads the test mid-run. The lucide stub gains ArrowDown, which
pane-find imports.

* fix(app): let a drag end a disclosure hold on native

A drag detached the bottom controller but kept the disclosure hold. Every item
size change restarts the hold's quiet frames, including a streaming row's, so
the hold can outlive the toggle. While it lasts, shouldRestorePosition accepts
only the held row. Once the drag scrolls that row away, a history page arriving
mid-gesture gets no anchor, and the rows the reader was on move by the page's
height.

The drag now cancels the hold without releasing it. The controller keeps no hold
state of its own beyond detached, and the gesture's end decides whether following
resumes.

* perf(app): keep the scroll-to-bottom button's state out of the timeline

Opening a tool call or turn at the end of the chat now holds its trigger in
place, so the timeline detaches and the scroll-to-bottom button appears. That
visibility was useState in AgentStreamView, so showing the button re-rendered
the whole timeline view for a change only the button reads. The viewport now
writes near-bottom into a per-agent store that only the button subscribes to.

* fix(app): show the scroll-to-bottom button on native whenever following has stopped

A gesture now resumes following only when it ends within 1 px of the end, but
the button still hid within 32 px. A release 2-32 px short of the end left the
list detached with no button to return. While detached the button now hides
only at the end; while following it keeps the 32 px tolerance so content
growth that the offset trails for a frame does not flash it. The button also
updates when following starts or stops without a scroll.

* fix(app): stop the timeline jumping when the keyboard opens or closes on native

The keyboard's settled height was committed to the list after the motion, as
paddingTop on Android and contentInset.top on iOS, to keep the top of the
history reachable under the translated dock. While following, the end pin
absorbed it in the same mount. While detached or holding a disclosure, nothing
did: Android's native anchor is LegendList's ScrollAdjust view, which padding
does not move, and LegendList compensates a padding change only on iOS. Every
row jumped by the keyboard height 0.2-1 s after the keyboard settled, and back
after it closed. On send, the same commit raced the jump to the end.

The keyboard now only translates the list with the composer. The cost: while
the keyboard is open, the composer viewport clips the top keyboard-height of
the list, so the start of a short conversation is hidden until it closes.

* chore(app): describe the native timeline as LegendList in stream comments

* fix(app): keep a disclosure's trigger in place when its row is cut off at the top on native

LegendList's idsInView starts at the first fully visible row. When a turn host's prompt bubble
sat above the viewport, a disclosure hold found no anchor in the data pass (shouldRestorePosition
rejects every other row), and the size pass anchored on the row below the host instead, so the
trigger moved by the host's size change. The patch appends the partly visible rows to idsInView
after the fully visible ones, so the held row anchors while every pass that does not restrict the
anchor keeps the first fully visible row.

The browser test renders the patched native build, which vitest now aliases instead of the
package's unpatched web build.

* fix(app): let a turn's summary line collapse again after it opens

The React Compiler cached StreamRow's `renderStreamItem(layoutItem)` on the layout item and the
renderer. The revised item that useRevisedHistoryRows hands a row whose display state changed only
fed the row id, so the row reused its cached content. Opening a turn usually inserts its rows
directly below the host, which also gives the host a new layout item. When another row sits
between them, such as a compaction marker, the host kept its layout item, its summary line stayed
collapsed, and every later tap asked to open the turn again.

The renderer now takes the item it renders, so the compiler's cache keys on the revision. StreamRow
moves to its own module, which the tests compile the way Expo does.

* test(app): alias the patched LegendList build in browser tests only

Unit tests load @legendapp/list outside Vite, so the native build's react-native import skipped
the react-native-web alias and reached React Native's Flow source, failing every unit suite that
imports the native strategy.

* fix(app): keep a size-only disclosure's trigger in place when its row is cut off at the top on native

LegendList's prepareMVCP picks the anchor for a size change with no data change as the first row
in view, without asking shouldRestorePosition. A disclosure that only grows its own row (a todo
card) while that row's top was above the viewport therefore held the row below it, and the trigger
moved by the row's size change. The patch now applies shouldRestorePosition to size changes as it
already does to data changes.

* test(app): render the timeline row revision test in a real browser

The repo's unit tests do not mount components in JSDOM. The test still compiles stream-row.tsx with
the React Compiler, so it fails when a revised row ignores the item it was handed.
2026-11-04 17:21:31 +07:00

17 KiB

Android

App variants

Controlled by APP_VARIANT in packages/app/app.config.js (vanilla Expo, no custom Gradle plugin):

Variant App name Package ID
production Paseo sh.paseo
development Paseo Debug sh.paseo.debug

EAS profiles: development, production, and production-apk in packages/app/eas.json.

development uses Android debug.

Version codes

packages/app/native-release-version.js is the single definition of native and F-Droid version-code math. Do not re-derive these numbers anywhere else — a drifted copy produces changelog files that match no published APK, and nothing fails loudly.

The base version code comes from the package version:

major * 1_000_000 + minor * 1_000 + patch

Prerelease metadata is ignored, so 0.1.102-beta.1 and 0.1.102 both produce 1102. The same value is used as the iOS buildNumber because packages/app/eas.json uses EAS's local app version source. Do not re-enable EAS remote version counters or Android autoIncrement; F-Droid and other source-based builders need the native build number to be visible in the repo.

The formula reserves three digits each for minor and patch. If either reaches 1000, change the formula before cutting that release.

Prerequisites (local dev)

Local Android builds run on macOS (or Linux) and need the Android toolchain, pinned in .tool-versions (java 21, android-sdk 21.0) and wired up by .mise.toml (which derives ANDROID_HOME and the command-line tool paths from the android-sdk entry). With mise:

mise install        # java 21 + android-sdk 21.0 command-line tools

Pin a real android-sdk version, not latest. The mise android-sdk plugin's latest resolved to the ancient 1.0 bundle, whose sdkmanager (3.6.0) predates the emulator package and fails with Failed to find package emulator. 21.0 ships a current sdkmanager. If you bump it, update only the version in .tool-versions; .mise.toml derives its paths from that tool entry.

mise install only lays down the command-line tools. Install the rest and create an emulator. On Apple Silicon:

sdkmanager --licenses
sdkmanager "platform-tools" "emulator" "platforms;android-35" "build-tools;35.0.0" \
           "system-images;android-35;google_apis;arm64-v8a"
avdmanager create avd -n paseo -k "system-images;android-35;google_apis;arm64-v8a" -d pixel_7
emulator @paseo     # start it; leave running

On an Intel Mac, use the x86_64 system image:

sdkmanager --licenses
sdkmanager "platform-tools" "emulator" "platforms;android-35" "build-tools;35.0.0" \
           "system-images;android-35;google_apis;x86_64"
avdmanager create avd -n paseo -k "system-images;android-35;google_apis;x86_64" -d pixel_7
emulator @paseo     # start it; leave running

Gradle auto-fetches the platform/build-tools it needs once licenses are accepted, so adjust android-35 only if it asks for a different level.

Local build + install

The react-native-svg@15.15.4 patch prevents header fades retaining their old painted size after rotation. Rebuild native Android after changing it; Metro reloads cannot update this code. Keep the resize hunks until upstream invalidates descendant geometry on canvas resize. Its instrumentation regression renders real gradient pixels across growing and shrinking layouts, including a nested group. From packages/app/android, run it on your selected emulator:

ANDROID_SERIAL=emulator-5562 ./gradlew :react-native-svg:connectedDebugAndroidTest \
  -Pandroid.testInstrumentationRunnerArguments.class=com.horcrux.svg.SvgResizeTest \
  --no-daemon --max-workers=2 -Dorg.gradle.parallel=false

The locked expo-modules-core@57.0.23 needs the patch installed by npm install. It backports Expo's Activity-result launcher fix: changing font size or another unhandled configuration can otherwise disable image selection until the app process restarts. Rebuild the native app after changing this patch; a Metro reload cannot update it. Remove the patch when the installed expo-modules-core includes the fix.

Run its native regression tests from packages/app/android after prebuild:

./gradlew :expo-modules-core:testDebugUnitTest \
  --tests expo.modules.kotlin.activityresult.AppContextActivityResultRegistryRecreationTest \
  --no-daemon --max-workers=1 -Dorg.gradle.parallel=false

From repo root:

npm run build:apk       # Universal dogfood APK: 32/64-bit ARM and x86
npm run build:apk:arm64 # Smaller dogfood APK for ARM64 phones
npm run dev:android     # Build/install the debug app and start development
npm run dev:ios         # Build/install the iOS development app

build:apk writes artifacts/paseo-dogfood-universal.apk; build:apk:arm64 writes artifacts/paseo-dogfood-arm64.apk. Neither command launches or installs on a device. Both enable PASEO_WATCHDOG_BUILD=1, the production React profiling renderer, and Android's shell-profileable flag. These builds keep the production package ID (sh.paseo) and release optimizations. Use this APK for personal dogfooding. Android system-trace spans require the separate PASEO_PROFILE_BUILD=1 flag; watchdog recording does not send duplicate spans to that sink by default. Normal store/release workflows leave diagnostics disabled.

The older android, android:development, and ios commands remain aliases for development. android:production still builds and installs a release through Expo; use build:apk when you need an artifact. android:clear removes the generated Android project.

Run npm run test:android --workspace=@getpaseo/app explicitly with a local Android toolchain to prebuild Android and run native module unit tests. Ordinary CI does not prebuild Android or run these tests.

Diagnostic logs without ADB

In a dogfood build, open Android's Files app → Downloads → Paseo diagnostics and share all retained .jsonl files. No app screen, storage permission, or attached debugger is required. The recorder retains eight files of about 5 MiB each across reconnects and app restarts. Rotation replaces the oldest files, so collect them soon after reproducing a freeze. Uninstall/reinstall can leave older Downloads files that the new installation cannot manage.

Each file identifies its build and session. Events include application-transport frame sizes and message/request IDs, connection transitions, parse/validation/dispatch spans, named React subtree render timings, and foreground JS heartbeat gaps. Payloads, passwords, and message contents are excluded; connection identifiers include host addresses. Each size has a unit: bytes when already available, otherwise UTF-16 code units for text. These are client transport observations, after relay decryption where applicable, not packet arrival times.

The native recorder flags a heartbeat overdue by more than 500 ms (250 ms expected interval). A heartbeat gap bounds the unresponsive interval; it is not an exact blocked-stack duration. Backgrounding suspends detection. js.stall.started can be written while JS remains blocked; js.stall.recovered records the gap when JS resumes. Recovery can appear without a start event when JS resumes before the next native sampling tick. Process death leaves earlier files intact, but queued/unflushed records can be lost. A missing recovery event alone does not prove a crash.

react.render.slow records subtree rendering taking at least 16 ms; one-second summaries include faster renders. Nested profiler timings overlap: don't add parent and child times. React's render measurements do not measure native layout/paint or the complete commit phase. recorder.dropped means the bounded queue overflowed; missing events in that interval must not be treated as absence of activity. Storage failure stops recording rather than blocking the app.

For an ordinary release APK with system trace markers only:

PASEO_PROFILE_BUILD=1 npm run android:production

Those markers emit only while a system trace records sh.paseo (perfetto -a sh.paseo ...); they do not produce Downloads files.

Running on an emulator against a worktree daemon

npm run android builds and installs the dev client, but two connections have to reach your Mac from inside the emulator — Metro (the JS bundle) and the Paseo daemon — and the emulator does not share the host's loopback: localhost inside the emulator is the emulator itself. Reach the host at 10.0.2.2 (the standard AVD's host alias) for both:

REACT_NATIVE_PACKAGER_HOSTNAME=10.0.2.2 \
  EXPO_PUBLIC_LOCAL_DAEMON=10.0.2.2:$PASEO_SERVICE_DAEMON_PORT \
  npm run android
  • REACT_NATIVE_PACKAGER_HOSTNAME=10.0.2.2 — without it, Expo bakes your Mac's LAN IP into the dev client's Metro URL, which the emulator can't route to, and the app dies with Failed to connect to /<lan-ip>:8081 before any JS loads.
  • EXPO_PUBLIC_LOCAL_DAEMON=10.0.2.2:<port> — the client's daemon endpoint (packages/app/src/runtime/host-runtime.ts); when unset it defaults to localhost:6767, the production daemon. Use $PASEO_SERVICE_DAEMON_PORT for a worktree daemon running as a Paseo service, or 6768 for a standalone npm run dev:server. It is inlined into the JS bundle at Metro bundle time, so set it on the build command and clear the Metro cache (npx expo start -c) if a change doesn't take.

Alternative — adb reverse + localhost (if 10.0.2.2 misbehaves):

adb reverse tcp:8081 tcp:8081
adb reverse tcp:$PASEO_SERVICE_DAEMON_PORT tcp:$PASEO_SERVICE_DAEMON_PORT
REACT_NATIVE_PACKAGER_HOSTNAME=localhost \
  EXPO_PUBLIC_LOCAL_DAEMON=localhost:$PASEO_SERVICE_DAEMON_PORT \
  npm run android

This is the Android counterpart of the iOS local-simulator flow in development.md: on iOS the simulator shares the Mac's loopback so localhost:<port> works directly; on Android you need 10.0.2.2 or adb reverse.

Scroll view corrections

modules/paseo-scroll replaces React Native's vertical scroll view with PaseoScrollView, which keeps React Native's scroll manager interface and corrects maintainVisibleContentPosition for the chat timeline. agent-stream-disclosure.md lists the corrections and the React Native issues they work around. Its instrumentation test, VisibleContentPositionClampTest, mounts through React Native's own mounting manager:

cd packages/app/android
./gradlew :paseo-scroll:connectedDebugAndroidTest --no-daemon --max-workers=2 -Dorg.gradle.parallel=false

Register this package before React Native's core package through its Expo config plugin. Normal Android builds use the prebuilt react-android library, so patching Java under node_modules does not change the shipped scroll view. Keep this behavior in the app's compiled native module.

F-Droid / source-only Android builds

F-Droid builds should set PASEO_FDROID_BUILD=1 when running Expo prebuild:

cd packages/app
PASEO_FDROID_BUILD=1 APP_VARIANT=production npx expo prebuild --platform android --clean --non-interactive
cd android
PASEO_FDROID_BUILD=1 ./gradlew assembleRelease --no-daemon --max-workers=1 -Dorg.gradle.parallel=false

The flag must be present for both prebuild and Gradle because Gradle starts Metro for the release bundle. Keep the source build serial and daemon-free as shown above: compiling every Expo module can exhaust memory when Gradle workers run in parallel. The profile enables source-built Expo modules, excludes the proprietary camera, Firebase notification, and Expo development-client native modules, disables Gradle dependency metadata, and substitutes JavaScript stubs for camera and notifications. The resulting app supports direct and pasted-link pairing but not QR scanning or push notifications.

For a single-ABI APK, pass React Native's architecture property to Gradle:

PASEO_FDROID_BUILD=1 ./gradlew assembleRelease \
  -PreactNativeArchitectures=arm64-v8a \
  --no-daemon --max-workers=1 -Dorg.gradle.parallel=false

Supported values are armeabi-v7a, arm64-v8a, x86, and x86_64. The F-Droid profile filters native libraries to that ABI and changes the APK version code to baseVersionCode * 10 + abiSuffix, where the suffixes are ordered 1 through 4 in that same sequence. F-Droid metadata should use four build blocks with VercodeOperation entries 10 * %c + 1 through 10 * %c + 4 and pass the matching reactNativeArchitectures value in each build command. Builds without a single architecture keep the base version code.

Keep the excluded npm packages installed. Normal builds use them, while the F-Droid profile removes only their Android native modules and config plugins. Paseo always applies expo-gradle-jvmargs with -Xmx4096m and -XX:MaxMetaspaceSize=1024m so local Expo prebuilds have enough Gradle heap whether they use precompiled AARs or source-built Expo modules.

The EAS production-apk profile uses the large Android resource class. Release builds compile the native ABIs and run Hermes bundling in the same Gradle invocation; the default worker can exhaust its remaining memory and kill Hermes with exit code 137 even when Gradle's own heap is correctly sized.

F-Droid store metadata

F-Droid reads the store listing from fastlane/metadata/android/<locale>/ at the repo root. This location provides the best compatibility with the F-Droid release process.

fastlane/metadata/android/
├── en-US/                      (F-Droid fallback locale, mandatory)
│   ├── title.txt               (<=50 chars)
│   ├── short_description.txt   (<=80 chars)
│   ├── full_description.txt    (<=4000 chars, limited HTML)
│   ├── images/
│   │   ├── icon.png            (512x512)
│   │   ├── featureGraphic.png  (1024x500)
│   │   ├── phoneScreenshots/   (1.png, 2.png, ... linked to the store renders)
│   │   └── tenInchScreenshots/ (1.png, 2.png, ... linked to the store renders)
│   └── changelogs/             (generated — see below)
├── ja/
└── zh-CN/

Locale directories generally match packages/app/src/i18n/locales.ts, but note that en becomes en-US.

Google Play reads its screenshots from the same folders; see Store screenshots.

F-Droid changelogs are generated from CHANGELOG.md. Run npm run fdroid:changelogs; npm run fdroid:changelogs:check verifies without writing. It is wired into the npm version lifecycle, so a release picks it up automatically and git add -A stages the result.

One changelog must be generated per-ABI-split, so each version will create four identical version-coded entries. F-Droid caps changelogs at 500 characters, so the generator strips some content and adds a link to the full notes.

Stable sync fails loudly if CHANGELOG.md has no entry for the version being cut. That is intentional — the release checklist requires the entry to be committed first, so an abort here means the checklist was skipped.

Because the generator runs off the version in package.json, it must run before the tag is created: fdroidserver only reads metadata from the tag it builds, so the file for version N has to exist in the commit N points at.

Beta releases are an explicit no-op: they do not create or rewrite F-Droid changelog files. Stable releases and promotions generate the four ABI entries from their final changelog.

React version lockstep

Keep react and react-dom pinned to the React version embedded by the current react-native release. React Native 0.81.x embeds react-native-renderer 19.1.0, so packages/app must use React 19.1.0. Bumping React to a newer patch can build successfully but crash at JS startup on Android with Incompatible React versions, leaving the app on the native splash screen.

Screenshots

adb exec-out screencap -p > screenshot.png

Cloud build + submit (EAS)

Stable tag pushes like v0.1.0 trigger:

  • The EAS GitHub app on Expo servers, using packages/app/.eas/workflows/release-mobile.yml (iOS + Android production builds and distribution).
  • .github/workflows/android-apk-release.yml on GitHub Actions (APK asset on GitHub Release).

iOS distributes the same build to external TestFlight testers and App Store review; see Mobile builds (EAS) for the review paths. Android auto-submits to the Play Store via EAS-managed credentials.

Beta tags like v0.1.1-beta.1 trigger the GitHub APK workflow and EAS Release iOS Beta. They publish a GitHub prerelease APK and distribute iOS through TestFlight without submitting to production stores.

android-v* tags also trigger only the GitHub APK workflow — useful when you want to ship an APK without going through stores. The GitHub APK workflow supports workflow_dispatch with an existing tag input so you can rebuild without cutting a new tag.

Useful commands

cd packages/app

# Recent builds
npx eas build:list --limit 10 --non-interactive --json | jq '.[] | {platform, status, appVersion, gitCommitHash}'

# Inspect a build (the printed `Logs` URL opens the build's Expo dashboard page,
# which has a Submissions section showing the auto-submit to the Play Store).
npx eas build:view <build-id>

The Play Console (Internal testing → Production tracks) is the final confirmation that the binary reached the store.

See docs/release.md for the full mobile-build babysitting flow.