Native Shell (Tauri)

The Rust shell tells the webview four things a browser cannot know, and arbitrates the Android back gesture

Everything the mobile app does runs in the webview. The native shell exists to tell it four things a browser cannot know — the safe-area insets, the keyboard height, the lifecycle phase, and that the user pressed back — and to act on the one answer it sends back.

Diagram

Native Shell

📱 Native
iOS / Android

📤 Emitter::emit

🔔 4 Events

🌐 Webview

💬 back_gesture_result

🚦 Gate arbitrates

Quick Start

  1. Enable in a mobile build

    The praisonai-mobile npm workspace already carries the Tauri config, so a mobile build needs no extra setup here.

  2. Run the desktop dev binary

    cargo tauri dev runs src/main.rs, the desktop-only dev binary. iOS and Android enter through the mobile_entry_point in lib.rs instead.

    bash
    cd src-tauri && cargo tauri dev
  3. Run the shell tests

    npm run test:rust runs the 14 Rust tests that pin the arbitration and the contract.

    bash
    npm run test:rust   # → cd src-tauri && cargo test

    The Node cross-language contract test lives at tools/shell-seam.test.mjs and greps the same event strings out of both languages.


The shell contract — four events + two commands

Four events go native → web, and two commands come web → native — a press answer and a standing back-declaration. Every string below is pinned by src-tauri/tests/contract.rs on the Rust side and tools/shell-seam.test.mjs on the TypeScript side — a rename on either side breaks the shell silently.

DirectionName (string literal)Rust constantPayloadNotes
Native → Websafe-area-changedEVT_SAFE_AREAInsets object. A payload carrying even one edge is applied, not discarded. A payload with no edges at all = “re-read the CSS” (not “every inset is zero”). Emitted on Resized and ScaleFactorChanged with a deliberately empty payload — on iOS and desktop Tauri does not expose the insets, so the WebView re-reads the CSS env() variables it has already recomputed. On Android that re-read is suppressed once nativeInsetsSeen is true (see the Android window-insets bridge), because env() there is the display cutout only and re-reading it after a rotation would drop the status/navigation bars back to 0.Consumed by coerceInsets in adapters/src/tauri/shell.ts
Native → Webkeyboard-heightEVT_KEYBOARDNumber (px)0 is a value, not an absence. Must fire through the show/hide transition, not only at endpoints — or the composer teleports while the keyboard slides
Native → WeblifecycleEVT_LIFECYCLE`”active” \“inactive” \“background”`Unrecognised phase is dropped by TS, never defaulted (defaulting to active would resume the render loop while the app is suspended)
Native → Webback-gestureEVT_BACK(no payload)The handler takes no argument
Web → Nativeback_gesture_resultCMD_BACK_RESULT{ handled: boolean }Answered via bridge.invoke; failures are swallowed — see arbitration below (no capability entry — app commands via invoke_handler are always reachable)
Web → Nativeback_gesture_can_go_backCMD_BACK_CAN_GO_BACK{ canGoBack: boolean }Standing declaration, sent on route-stack change (not in reply to a press); read by the watchdog when an answer never arrives. Default is false, so a bundle that never loaded still lets back leave the app. See arbitration below

On iOS and desktop the Tauri shell falls back to visualViewport when the native keyboard-height event has not fired yet, and hands over to native the moment one arrives. On Android visualViewport reports nothing while enableEdgeToEdge() is on, so the handover is triggered by the Android window-insets bridge (NATIVE_INSETS_GLOBAL, which carries both channels) instead. The on_window_event handler emits lifecycle and safe-area-changed on device; the keyboard-height Tauri event is still not natively emitted anywhere. See Shell & Adapters → The three-source keyboard model.

The capability file src-tauri/capabilities/default.json grants what this seam needs: core:event:default, so the webview can subscribe via plugin:event|listen, plus opener:allow-open-url and opener:allow-default-urls so openExternal actually reaches the OS. Without the opener:* scope every http(s)/mailto/tel URL is still refused. The back_gesture_result command is deliberately not listed — an app command registered through invoke_handler is always reachable and has no ACL entry to grant; naming one that does not exist fails tauri-build before the crate compiles.

json
{
  "$schema": "../gen/schemas/mobile-schema.json",
  "identifier": "default",
  "windows": ["main"],
  "platforms": ["android", "iOS", "macOS", "windows", "linux"],
  "permissions": [
    "core:event:default",
    "opener:allow-open-url",
    "opener:allow-default-urls"
  ]
}

The platforms array is explicit because every permission here exists on all five — the desktop dev build must not fail on it. A mobile-only grant belongs in its own capability scoped to its platform.


Presence detection — both, not either

Before any of the events above can fire, the bridge has to decide whether the Tauri host is actually there. isPresent() in adapters/src/tauri/bridge.ts returns true only when the __TAURI_INTERNALS__ global carries both invoke and transformCallback.

__TAURI_INTERNALS__ carriesisPresent()Why
both invoke and transformCallbacktrueA Tauri version this bridge speaks
only one of the two functionsfalseA half-present global is a version this bridge does not speak — treat as absent
neitherfalseNo host

The check is && / not / || — “both, not either”, explicitly. A half-present global read as present makes the shell call transformCallback inside listen, which throws, is caught, and turns every shell subscription silently into a no-op.


Insets and keyboard

The shell writes safe-area insets to the #root element and the keyboard height to the composer’s own screen as CSS custom properties, and keeps them current for the life of the app.

Custom propertyWritten onSourceWhen it updates
--inset-top#rootinsets.topmount + every onInsetsChanged
--inset-bottom#rootinsets.bottommount + every onInsetsChanged
--inset-left / --inset-right#rootinsets.left / insets.rightmount + every onInsetsChanged
--keyboard-heightcomposer screenkeyboardHeightPxmount + every onKeyboardHeightChanged

Top comes from top and bottom from bottom — the two were swapped at one point, and a regression test now pins the mapping.

Coalescing rules for insets

coerceInsets distinguishes a partial payload from an empty one, and the Tauri shell dedupes across all four edges.

Native emitsShell doesWhy
Every edge present, values changedApply all four, publish onceThe normal path
Only the edges that changed (partial payload)Apply the payload, publish — the guard returns null only when every edge is absentAn adapter that discards the whole event because a key is missing leaves the composer under the home indicator after a rotation
No edges at all (empty payload)Re-read the CSS snapshot; do not zero the insetsZeroing slides the composer under the home indicator
An identical four-edge payloadDo not republish (Tauri only)Every frame of a rotation would otherwise relayout
An identical payload except for the right edgePublish — the right edge countsA landscape notch appearing on the right otherwise deduped away as “no change”, and content sat under it

The Android window-insets bridge {#the-android-window-insets-bridge}

On Android none of the web APIs can see the system bars or the software keyboard, so a native → web global (NATIVE_INSETS_GLOBAL) carries both. It is the Android-only source that feeds the shell’s insets and keyboard height.

Why it exists

MainActivity.onCreate calls enableEdgeToEdge(), which sets decorFitsSystemWindows = false. With that, the window is never resized by the system bars or the IME, so no web API observes them:

Web APIWith the IME shown, edge-to-edge on
env(safe-area-inset-*)The display cutout only — not the status bar, not the navigation bar, never the keyboard
window.innerHeightThe full viewport
visualViewport.heightThe full viewport
navigator.virtualKeyboard.boundingRect0×0

Measured on an Android 15 emulator (Pixel-class, 1080×2400 @ 420 dpi): with a portrait cutout, env(safe-area-inset-top) read 49px — the cutout at dpr 2.625 — while the 24 CSS-px status bar contributed 0. Rotated to landscape, that same 49 px moved to left and top became 0 even though the status bar was still along the top edge. With the IME shown and the textarea focused, every keyboard read was 0, so readKeyboardHeight returned 0 and the composer painted underneath the keyboard.

The values exist only in native WindowInsetsCompat. MainActivity reads them and pushes them across.

The seam

One string, pinned on both sides by tools/shell-seam.test.mjs:

SideSymbolValue
Kotlin (MainActivity.kt)INSETS_GLOBAL__praisonaiNativeInsets
TypeScript (adapters/src/tauri/shell.ts)NATIVE_INSETS_GLOBAL__praisonaiNativeInsets

A rename on either side is otherwise silent — evaluateJavascript calls window.<name> && window.<name>(...), so a name the shell does not install is a legal no-op, and the app lays out as though the phone had no bars and no keyboard.

The payload

ts
export interface NativeInsetsPayload {
  readonly top?: unknown;
  readonly right?: unknown;
  readonly bottom?: unknown;
  readonly left?: unknown;
  readonly keyboard?: unknown;
}

Every field is optional and every field is coerced through toPx, because the payload crosses an evaluateJavascript string boundary and a NaN reaching a CSS length silently drops the whole declaration.

FieldWhat Kotlin sends
top / right / bottom / leftgetInsets(systemBars() or displayCutout()), folded into one “do not paint here” number per edge
keyboardgetInsets(ime()).bottom

The cutout is folded into the edges because on devices whose cutout is deeper than the status bar — every emulator AVD with a punch hole, and any such phone in landscape — the bars alone are too small.

The animation callback

setOnApplyWindowInsetsListener fires once at each end of the IME transition, so on its own the composer teleports between endpoints. WindowInsetsAnimationCompat.Callback (registered with DISPATCH_MODE_CONTINUE_ON_SUBTREE) runs onProgress every frame in between, which is what makes the composer slide with the keyboard instead of jumping.

The retry-until-acknowledged loop

The very first requestApplyInsets evaluates window.<global> && window.<global>(...) against a document that has no global yet — a legal no-op — and Android has no reason to redispatch, so without a retry the app sat at zero insets until the user rotated the phone.

ConstantValueMeaning
PUSH_RETRY_MS120Delay between pushes
MAX_PUSH_ATTEMPTS125Ceiling on retries (~15 s total)

The loop pushes until the injected JS returns "true" — the acknowledgement that the global ran — then stops.

Diagram

yes

no, page not booted

yes

no ~15 s elapsed

📤 evaluateJavascript push

🔍 returned ‘true’?

✅ stop — page acknowledged

⏱️ attempt ≤ 125?

💤 wait 120 ms

🛑 give up

The handover in the shell

When NATIVE_INSETS_GLOBAL fires, the shell publishes both insets and keyboard, retires the visualViewport listener (it reports nothing on Android anyway), and sets nativeInsetsSeen = true. From then on, a subsequent empty safe-area-changed payload is dropped rather than re-read from env() — re-reading would silently drop the status and navigation bars back to 0. The global is installed on view, not globalThis, so a test drives it with a fake window and no phone.


Back-gesture arbitration

Android presses back, Rust asks the webview whether it wants it, and if the webview says no — or cannot answer in time — Rust lets the system act. To decide the timeout case correctly, the webview also declares, out of band, whether it could take the next press.

The in-repo tauri-plugin-back-gesture

The Android back callback is owned end-to-end by an in-repo Rust plugin, tauri-plugin-back-gesture, not by Tauri’s own AppPlugin.

Tauri’s AppPlugin already installs an OnBackPressedCallback, but it routes the press to JavaScript plugin listeners via Plugin.trigger — a completely separate channel that reaches neither Tauri’s event registry nor Rust. Wiring it that way would fire nothing, with no error anywhere. Arbitration also has to run in Rust: the webview’s answer is fire-and-forget (bridge.invoke swallows every rejection into null), so only Rust can time it out.

The press travels Kotlin → Channel → Rust → emit:

BackGesturePlugin.kt   OnBackPressedCallback, registered in `load` not `init`
                       so it is always newer than Tauri's, and the dispatcher
                       consults the most recently added callback first
  -> trigger(EVENT)    the base Plugin class's own `registerListener` command,
                       handed a Rust-owned tauri::ipc::Channel
  -> Rust closure      commands::on_back_pressed
  -> Emitter::emit     "back-gesture" — what the webview actually hears
  -> back_gesture_result(handled), or a 400ms watchdog if silence — and the
                       watchdog reads the standing back_gesture_can_go_back
                       declaration instead of treating silence as "declined"
  -> defer()           at the task root, moveTaskToBack(true) — the task goes
                       behind the launcher and the process stays warm; above the
                       root, re-dispatch with the callback disabled so the SYSTEM
                       default runs beneath it

Two channels feed one gesture: a press that has already happened (answered in reply, or timed out) and a standing declaration sent ahead of any press whenever the route stack changes.

Diagram

Two channels, one gesture

declare

state

answers in time

silence

handled or can_go_back

declined and cannot go back

🧭 route change

📌 back_gesture_can_go_back

🚦 Gate.can_go_back

📱 OS back press

⚙️ on_back_pressed

📤 emit back-gesture

🌐 webview handler

💬 back_gesture_result

⏰ 400 ms watchdog

🙈 app keeps the press

⬇️ fall_back

The Kotlin callback is registered in load(webView) — not init() — so it is always newer than Tauri’s own callback. Android’s OnBackPressedDispatcher consults the most recently added enabled callback first, and load runs once the WebView exists, after every plugin’s init, so this callback always wins. Registered in init it would win only when the plugin store happened to initialise it after Tauri’s.

The public Rust API, from src-tauri/plugins/back-gesture/src/lib.rs:

rust
pub const PLUGIN_NAME: &str = "back-gesture";
pub enum Error { Platform(String) }
pub struct BackGesture<R: Runtime> { ... }
impl<R: Runtime> BackGesture<R> {
    pub fn fall_back(&self) -> Result<(), Error>;
}
pub trait BackGestureExt<R: Runtime> {
    fn back_gesture(&self) -> &BackGesture<R>;
}
pub fn init<R: Runtime>(
    on_press: impl Fn(&AppHandle<R>) + Send + Sync + 'static,
) -> TauriPlugin<R>;
ConstantValuePinned by
PLUGIN_NAME"back-gesture"src-tauri/tests/contract.rs
Java package / PLUGIN_IDENTIFIERai.praison.mobile.backgestureKotlin package
Kotlin event name / EVT_PRESSED"pressed"must match Rust

The state diagram below is what the arbitration inside that plugin’s Rust closure does with each press.

Diagram

press

declare_can_go_back true/false

declare_can_go_back true/false

answered handled=true

answered handled=false

timed_out and can_go_back=false

timed_out and can_go_back=true

press while pending

answered AFTER timeout

Idle

Pending

⚙️ fall_back

🙈 Ignore

🚫 Drop

declare_can_go_back is a dotted self-loop, not an edge between states: it sets out-of-band state (Gate::can_go_back, default false) that timed_out reads. It never changes whether a press is pending — only which way the watchdog resolves silence.

Four failure modes shape the Gate in src-tauri/src/shell/back.rs.

  1. The answer may never come. bridge.invoke on the TS side swallows every rejection into null, so silence is indistinguishable from success. Without a watchdog (ANSWER_TIMEOUT_MS = 400), a bundle that failed to load leaves a back button that does nothing forever — worse than one that exits.
  1. There is no correlation id. The webview sends { handled } and nothing else. Two presses close together produce two answers Rust cannot tell apart — the second could pop an activity the first decided to keep. Dropping while pending is the only correct option available on this side.
  2. A late answer must not act twice. If the watchdog fires and the app has backgrounded, an answer arriving after must be ignored, not sent back again. It is the bug this design is most likely to ship, and has its own test in src-tauri/tests/back_gesture.rs.
  3. Slow is not dead. The round trip is not bounded by anything this crate controls: the emit reaches the webview on the platform’s UI thread, and the handler runs on the thread that is painting. On an Android 15 emulator the same press was answered in 0.7 s once and 5.4 s the next time, both far past the watchdog. Treating that silence as “the app does not want this press” sent an app the user was actively using to the background — back on the Settings screen left the app entirely, while its own router had already popped back to the chat. So the webview declares, in advance and out of band, whether it can go back (Gate::declare_can_go_back), and the watchdog only lets the platform act when the app has said it cannot.

The Gate returns an Action rather than performing it, so the decision is testable and the side effect lives at the edge.

MethodReturnsMeaning
Gate::new()GateCreate in Idle, can_go_back = false
press()`Action::Ask \Action::Drop`Ask webview, or drop if already pending
answered(handled: bool)`Action::Ignore \Action::FallBack`Ignore if late; fall back if handled=false
timed_out()`Action::FallBack \Action::Ignore`Fall back once when the app has declared it cannot go back; ignore otherwise (or if a stray)
declare_can_go_back(canGoBack: bool)()Set the standing answer; read by timed_out when a press is unanswered
can_go_back()boolRead back the last declaration (default false)
is_pending()boolTest hook

The fallback itself differs per platform, from src-tauri/src/commands.rs.

PlatformFallback actionRationale
Androiddefer() branches on isTaskRoot — moveTaskToBack(true) at the root, else re-dispatch with our callback disabledOn Android 12+ the system-default path backgrounds a root activity, but only when the system dispatches the press. Re-dispatching it ourselves at the root walks the app-level path to finishAfterTransition(), killing the process. moveTaskToBack(true) sends the task behind the launcher and keeps the process warm; above the root, re-dispatch runs whatever sits beneath.
iOSNo-opAn iOS app must never terminate itself — App Review rejection, reads as a crash
Desktop (dev)No-opThe desktop main.rs is a dev binary only

Who declares, and when

The standing setCanGoBack declaration has one router-side caller and one hard rule for when it is false.

CallerDeclaresWhy
attachBackGesture in ui/src/router.tsbackDecision(stack).consumed on attach, on every stack change (via router.subscribe), and false on detachSame pure decision the press handler makes, so the declaration and the answer never disagree; false on detach because a torn-down view’s router speaks for nothing
The crash handler in app/src/main.tsfalse before repainting the fatal screenA dead webview must not swallow the gesture
The Tauri adapter in adapters/src/tauri/shell.tsSends on change; suppresses no-op re-sends but always sends the first value — even falseThe first send overwrites whatever the previous page declared after a reload; a chat-to-chat move keeps the answer true and is deduped away
The web adapter in adapters/src/web/shell.tsDocumented no-opBrowser popstate is answered synchronously in the same task — there is no bridge to race with

Live regions and screen-reader announcements

The DOM layer joins every polite announcement produced in one render pass into a single write, so an answer that finishes inside one tick still reaches the live region. The pure announce() function may return several utterances at once; assigning them one at a time overwrote all but the last.

Diagram

📤 publish tick

🧠 announce – pure

🔊 join polite → one write

❗ join assertive → one write

🗣️ aria-live=polite

🗣️ aria-live=assertive

Each region is assigned once per pass, with every utterance of that politeness joined by a space. A short answer completing inside one interval produced ["The capital of France is Paris.", "Response complete"]; assigning per item left a screen-reader user hearing only “Response complete.” — exactly the failure announce.ts rule 4 exists to prevent, reintroduced where the pure function meets the DOM.

ts
// main.ts — join once per region, never assign per item.
const polites = spoken.announcements.filter((a) => a.politeness !== "assertive");
if (polites.length > 0) polite.textContent = polites.map((a) => a.text).join(" ");

Where the empty-chat panel lives in the DOM

The empty-chat panel (Empty Chat) is a sibling of .transcript, never a child.

RuleSourceWhat breaks if it regresses
The panel is a sibling of .transcript, not a childemptyPanel appended to the screen, not the transcriptThree code paths do transcript.textContent = "" (New chat, Open chat, delete-the-open-chat) and applyOps places rows by children[index]; a child panel would be displaced or silently deleted.
The screen carries data-emptyscreen.dataset["empty"] = view.kind in paintEmptyStateWritten as "welcome" or "needs-key" matching the current EmptyStateKind. .screen[data-empty] .transcript { flex: 0 0 auto } stops both children of the flex column claiming flex: 1 and splitting the screen.
The panel toggles via the hidden attribute.empty-state[hidden] { display: none }The rule overrides the display: flex layout, exactly as .screen[hidden] does. Never toggle via style.display.
Safe-area insets add to the gutter.empty-state uses --inset-left / --inset-rightThe insets are added to the gutter rather than replacing it; the topbar’s --inset-top handling is untouched.
Colours are tokens only--ink, --soft, --accentAll three have dark-scheme values, so the panel follows the device theme with no rule of its own. A hardcoded colour would be invisible on the dark ground.

The region id EMPTY_TITLE_ID ("empty-state-title") is a single constant shared by the heading’s id and the panel’s aria-labelledby, so the two cannot drift — see i18n & A11y → Empty-chat region.


Lifecycle mapping decision

Tauri surfaces suspend/resume/focus and ShellPort declares three phases, so phase_for in src-tauri/src/shell/lifecycle.rs maps five window events between them.

Tauri WindowEventphase_for returnsEmitted after Tracker observes
Suspended"background"Yes — first observation after any other phase
Resumed"active"Yes — first observation after any other phase
Focused(true)"active"Suppressed when it immediately follows Resumed (identical phase)
Focused(false)"inactive"Suppressed when it immediately follows Suspended (background → inactive would lie about direction)
(anything else)dropped—

inactive is now emitted, from Focused(false) — a system dialog over the activity, or a window blur on the desktop dev build — except when suppressed after Suspended. active comes from both Resumed and focus gain, because a control-centre dismissal on iOS is didBecomeActive with no willEnterForeground before it.

The Tracker — why raw phases go through a filter

The lifecycle emitter runs each phase through Tracker in src-tauri/src/shell/lifecycle.rs before it emits, because the platforms deliver focus and suspension in an order that would otherwise announce inactive after background, or active twice. State lives in `pub struct LifecycleState(pub Mutex

) in shell/mod.rs, manage`d on the app.

Suppressed sequenceWhy
Android: onPause → onWindowFocusChanged(false)The focus-change would report the app as having come up to inactive while it is already backgrounded. Emit background once and drop the trailing inactive.
Android: onResume → onWindowFocusChanged(true)Both events say active; emit once.
Any repeated phaseThe current phase is remembered; a duplicate is dropped.

Pinned by android_home_press_announces_background_once_and_not_inactive_after_it and android_return_announces_active_once_for_resume_plus_focus.


Platform floors

The mobile build sets its platform minimums for the first time in tauri.conf.json.

PlatformMinimum
iOS16.0 (bundle.iOS.minimumSystemVersion)
AndroidAPI 26 (bundle.android.minSdkVersion)
Bundle identifierai.praison.mobile
Product namePraisonAI

These floors also drive the CSP, the WebView bundle target, and the store version story — see Shipping to iOS and Android.


Panic handling — why release does not set panic = "abort"

The release profile deliberately leaves panic = "abort" unset, unlike the desktop crate.

panic = "abort" is deliberately NOT set (unlike the desktop crate). mobile_entry_point wraps the app in catch_unwind so a panic prints and aborts cleanly instead of unwinding across the JNI/ObjC boundary, which is undefined behaviour. abort turns a readable message into a bare SIGABRT — on a phone with no console, that is the difference between a crash you can read and one you cannot.

toml
[profile.release]
codegen-units = 1
lto = true
opt-level = "s"
strip = "debuginfo"
# panic = "abort" -- deliberately NOT set (see above)

The same reasoning keeps the mobile_entry_point attribute on run(): the macro expands to the JNI symbol on Android and start_app on iOS, so renaming run breaks the entry point.


Best Practices

Never rename an event string on one side only

The seam fails silently — the webview simply stops receiving an event and lays out as though the phone had no notch, keyboard, or lifecycle. Both contract.rs and shell-seam.test.mjs guard the five constants; run npm run test:rust and the Node cross-language test before landing any change to them.

Emit keyboard-height continuously through show/hide

Emitting only 0 → 340 teleports the composer instead of tracking the slide. Fire keyboard-height through the whole transition, not just at its endpoints.

Dedupe insets across all four edges, including right

sameInsets must compare top, right, bottom, and left. Leaving right out makes a landscape notch appearing on the right invisible to the shell — the payload is deduped away as “no change” and content sits under the notch.

Do not emit an unrecognised lifecycle phase

TypeScript drops an unknown phase silently — better to add the phase on both sides than to hope a default kicks in. Defaulting to active would resume the render loop on a suspended app.

Do not use Plugin.trigger for shell events

The TypeScript subscribes to Tauri’s event registry, which only Emitter::emit reaches. Plugin.trigger hits a separate channel and fails with no error.

Do not use Plugin.trigger for back-gesture from a Tauri plugin either

Even from inside the in-repo tauri-plugin-back-gesture, the press is routed through registerListener + a Rust-owned tauri::ipc::Channel → Emitter::emit, never Plugin.trigger, for the same reason: Plugin.trigger reaches JS plugin listeners, not Tauri’s event registry or Rust.

Do not lower the answer-timeout below 250 ms

The floor is a compile-time assertion in shell::back, not a runtime test, so lowering it stops the crate compiling. Too short a timeout falls back while a slow handler is still deciding.

Keep false as the safe default for setCanGoBack

A shell that has never been told otherwise must behave as though false was declared: a bundle that never loaded declared nothing, and back must still be able to leave the app. The router declares on attach, on every stack change, and false on detach; the crash screen declares false before repainting. Never default the standing state to true.

Background a root activity — never re-dispatch it

At the task root, defer() calls moveTaskToBack(true); re-dispatching the press ourselves walks the app-level path to finishAfterTransition(), which kills the process and turns the next return into a cold start with the transcript lost. Only re-dispatch above the root.

Keep the mobile_entry_point attribute on run()

Do not rename run. The macro expands to the JNI/ObjC entry point on Android/iOS, and the CLI resolves it by that exact name.


Related