FN-7583: route Android back gesture through fusion:native-back so task detail returns to board

Fixes the Android predictive back-gesture (edge swipe) not returning from a task detail view to the board, even though the hardware/legacy Back button worked correctly.

- Patch the generated Android manifest post-sync to set android:enableOnBackInvokedCallback="true" on the <application> tag, opting into AndroidX's OnBackPressedDispatcher for predictive-back gesture completion (mitigating ionic-team/capacitor-plugins#2418 via the disableBackButtonHandler toggle shipped in @capacitor/app@7.1.0).
- Wire the patch script into Capacitor's capacitor:sync:after npm-script hook so it runs automatically after every cap sync (and therefore after cap run android / build:mobile).
- Ensure both the back gesture and the hardware Back button funnel through the same AndroidBackButtonManager backButton listener, dispatching the shared fusion:native-back event consumed by the dashboard's nav-history stack.
- Add regression coverage: a unit test for the manifest patch script's idempotent opt-in behavior, and a dashboard test asserting the task detail view returns to the board on fusion:native-back.
- Document the Android manifest patch rationale and the unchanged dashboard-side invariant in packages/mobile/README.md.

Files changed:
 .../__tests__/TaskDetail.swipe-back.test.tsx       |  13 +++
 packages/mobile/README.md                          |  31 +++++++
 packages/mobile/capacitor.config.ts                |  13 +++
 packages/mobile/package.json                       |   2 +
 packages/mobile/scripts/patch-android-manifest.ts  |  90 ++++++++++++++++++
 packages/mobile/src/__tests__/native-shell.test.ts | 102 +++++++++++++++++++++
 6 files changed, 251 insertions(+)

Fusion-Task-Id: FN-7583

Fusion-Task-Lineage: ad0cb02c-6e49-448a-8c93-607cd5ae657b

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-05 11:57:36 -07:00
parent 0f1cd0a36a
commit aa6d21eeca
6 changed files with 251 additions and 0 deletions

View File

@@ -1,5 +1,18 @@
/**
* Focused regression coverage for mobile task-detail swipe-back behavior.
*
* FNXC:TaskDetailAndroidBack 2026-07-05-11:45:
* FN-7583 diagnosed the Android back-GESTURE regression as native-delivery-only: the
* generated AndroidManifest.xml never opted into `android:enableOnBackInvokedCallback`,
* so AndroidX's dispatcher didn't route the predictive-back gesture to
* `@capacitor/app`'s registered callback (fixed via `packages/mobile/scripts/
* patch-android-manifest.ts`). The dashboard-side dismissal invariant covered below
* (board main-panel / list-mobile / modal / nested detail, via both `popstate` and
* `dispatchNativeAndroidBack()`) was already correct and required NO change for this
* fix — once the gesture reaches the native `backButton` listener, it dispatches the
* exact same `fusion:native-back` event the hardware Back button already used, so this
* suite's existing coverage continues to prove the shared invariant for gesture, button,
* and browser Back alike.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";

View File

@@ -14,6 +14,37 @@ Mobile uses a shell-level onboarding flow for first-run connection setup before
Native wrappers are isolated under `src/plugins/native-shell.ts`, `src/plugins/connection-profiles.ts`, and `src/plugins/qr-scanner.ts` so dashboard code never calls vendor-specific APIs directly.
## Android Back: hardware button and predictive-back GESTURE both dismiss task detail
`AndroidBackButtonManager` (`src/plugins/native-shell.ts`) subscribes to `@capacitor/app`'s
`backButton` event and dispatches a single cancelable `fusion:native-back` browser event.
The dashboard's shared nav-history stack (`useNavigationHistory`) consumes that event to
dismiss the top task-detail surface (board main-panel, list-mobile, modal, and nested
detail) and return to the board/previous detail — the exact same invariant the browser
`popstate`/swipe-back path already uses. If nothing handles the event (`!defaultPrevented`),
Capacitor's own fallback runs unchanged: `history.back()` when `canGoBack`, else `exitApp()`.
**FN-7583:** the Android system back **gesture** (predictive back / edge swipe, Android
13+, default-prominent at this project's `targetSdk 35`) previously did not reach that
listener, even though the hardware/legacy Back button did — the app never opted the
generated manifest into Android's predictive-back framework
(`android:enableOnBackInvokedCallback="true"`), so `OnBackPressedDispatcher` silently
dropped the gesture-completion callback while still delivering button presses via the
ordinary `Window`/`Activity` path. Because the native `android/` project is generated by
`cap sync` and is git-ignored, the fix lives in tracked source:
`scripts/patch-android-manifest.ts` idempotently adds that manifest attribute to the
generated `AndroidManifest.xml`, and is wired into Capacitor's own `capacitor:sync:after`
npm-script hook (see `package.json`) so it runs automatically on every `cap sync` —
including `build:mobile`, `dev:android`, and `cap run android` (which syncs first). No
dashboard-side change was needed: once the gesture reaches the native `backButton`
listener, gesture and button converge on the identical `fusion:native-back` dispatch.
Manual invocation (e.g. after a bare `npx cap sync` outside `build:mobile`):
```bash
pnpm --filter @fusion/mobile patch:android-manifest
```
### Regression coverage locked by tests
`packages/mobile/src/__tests__/connection-profiles.test.ts`, `native-shell.test.ts`, and `qr-scanner.test.ts` now lock these contracts:

View File

@@ -22,6 +22,19 @@ const config: CapacitorConfig = {
detectViewportFitCoverChanges: false,
initialViewportFitCover: false,
},
// FNXC:TaskDetailAndroidBack 2026-07-05-11:40:
// FN-7583: keep the @capacitor/app native backButton handler ENABLED (the default,
// pinned explicitly below). `AndroidBackButtonManager` in `src/plugins/native-shell.ts`
// relies on the plugin's "backButton" event to dispatch the shared `fusion:native-back`
// event that the dashboard's nav-history stack consumes for both the hardware Back
// button and the Android 13+ predictive-back GESTURE (once the gesture is actually
// delivered — see `scripts/patch-android-manifest.ts` for the manifest opt-in that
// makes that so). Setting `disableBackButtonHandler: true` would stop the plugin from
// emitting "backButton" entirely, breaking BOTH the button and the gesture routing —
// never flip this without replacing `AndroidBackButtonManager`'s dispatch seam too.
App: {
disableBackButtonHandler: false,
},
},
server: {
url: liveReloadEnabled

View File

@@ -19,6 +19,8 @@
"dev:ios": "tsx scripts/live-reload.ts --platform ios",
"dev:android": "tsx scripts/live-reload.ts --platform android",
"build:mobile": "pnpm --filter @fusion/dashboard build && npx cap sync",
"patch:android-manifest": "npx tsx scripts/patch-android-manifest.ts",
"capacitor:sync:after": "npx tsx scripts/patch-android-manifest.ts",
"test": "vitest run --silent=passed-only --reporter=dot",
"typecheck": "tsc --noEmit"
},

View File

@@ -0,0 +1,90 @@
/**
* FNXC:TaskDetailAndroidBack 2026-07-05-11:40:
* FN-7583: the Android system back GESTURE (predictive back / edge swipe, Android 13+,
* default-prominent at this project's `targetSdk 35`) was not reaching the same
* `@capacitor/app` `OnBackPressedCallback` ("backButton" JS event) that the hardware/
* legacy Back button already routes through — even though both the button and the
* gesture are supposed to funnel through AndroidX's `OnBackPressedDispatcher`. Per
* Android's predictive-back docs (https://developer.android.com/guide/navigation/custom-back/predictive-back-gesture)
* and the confirmed upstream defect (ionic-team/capacitor-plugins#2418, mitigated by
* ionic-team/capacitor-plugins#2390's `disableBackButtonHandler` toggle shipped in
* `@capacitor/app@7.1.0`), an app must opt in via `android:enableOnBackInvokedCallback="true"`
* on the manifest `<application>` tag for the gesture-completion callback to reliably
* reach a registered `OnBackPressedCallback`. Without that opt-in, the OS falls back to
* "legacy" gesture dispatch, which silently drops the callback invocation for the
* gesture while the hardware Back button (delivered via the ordinary Window/Activity
* input path) keeps working — exactly the divergence this task reported.
*
* The native `android/` project is generated by `cap sync` and is git-ignored (see
* AGENTS.md / `.gitignore`), so durable changes cannot live there directly. Instead this
* script idempotently patches the generated manifest immediately after every sync, and
* is wired into Capacitor's own `capacitor:sync:after` npm-script hook (see
* `package.json`), which the Capacitor CLI runs automatically after `cap sync` — and
* therefore after `cap run android` (which syncs first) and `build:mobile` too.
*
* This keeps a SINGLE dismissal invariant: gesture and button both end up delivered to
* `AndroidBackButtonManager`'s `backButton` listener in
* `packages/mobile/src/plugins/native-shell.ts`, which dispatches the cancelable
* `fusion:native-back` event consumed by the dashboard's shared nav-history stack.
*/
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const ENABLE_ATTR = 'android:enableOnBackInvokedCallback="true"';
export function patchManifestSource(xml: string): { changed: boolean; xml: string } {
const applicationTagMatch = xml.match(/<application\b[^>]*>/);
if (!applicationTagMatch) {
return { changed: false, xml };
}
const applicationTag = applicationTagMatch[0];
if (applicationTag.includes("android:enableOnBackInvokedCallback")) {
// Already opted in (idempotent — do not duplicate on repeat `cap sync` runs).
return { changed: false, xml };
}
const patchedTag = applicationTag.replace(/>$/, ` ${ENABLE_ATTR}>`);
const patchedXml = xml.slice(0, applicationTagMatch.index) + patchedTag + xml.slice((applicationTagMatch.index ?? 0) + applicationTag.length);
return { changed: true, xml: patchedXml };
}
export function resolveManifestPath(mobilePackageDir: string): string {
return join(mobilePackageDir, "android", "app", "src", "main", "AndroidManifest.xml");
}
export function patchAndroidManifest(mobilePackageDir: string): { patched: boolean; skipped: boolean; path: string } {
const manifestPath = resolveManifestPath(mobilePackageDir);
if (!existsSync(manifestPath)) {
// No Android platform added yet (e.g. iOS-only sync) — safe no-op, not an error.
return { patched: false, skipped: true, path: manifestPath };
}
const original = readFileSync(manifestPath, "utf8");
const { changed, xml } = patchManifestSource(original);
if (changed) {
writeFileSync(manifestPath, xml, "utf8");
}
return { patched: changed, skipped: false, path: manifestPath };
}
function isMainModule(): boolean {
return process.argv[1] === fileURLToPath(import.meta.url);
}
if (isMainModule()) {
const mobilePackageDir = dirname(fileURLToPath(import.meta.url)).replace(/[/\\]scripts$/, "");
const result = patchAndroidManifest(mobilePackageDir);
if (result.skipped) {
console.log(`[patch-android-manifest] no android/ project at ${result.path}; skipping (nothing to patch yet)`);
} else if (result.patched) {
console.log(`[patch-android-manifest] added android:enableOnBackInvokedCallback="true" to ${result.path}`);
} else {
console.log(`[patch-android-manifest] ${result.path} already opts into enableOnBackInvokedCallback; no change`);
}
}

View File

@@ -1,5 +1,9 @@
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { buildMobileShellHandoff } from "../plugins/shell-handoff.js";
import { patchAndroidManifest, patchManifestSource } from "../../scripts/patch-android-manifest.js";
type BackButtonListener = (event: { canGoBack: boolean }) => void;
@@ -259,4 +263,102 @@ describe("MobileNativeShellBridge", () => {
expect(capacitorState.exitApp).toHaveBeenCalledTimes(1);
});
/*
FNXC:TaskDetailAndroidBack 2026-07-05-11:45:
FN-7583 — the Android back GESTURE (predictive back / edge swipe, Android 13+) reached
the OS but was never delivered to `AndroidBackButtonManager`'s `backButton` listener
because the generated AndroidManifest.xml never opted into
`android:enableOnBackInvokedCallback="true"`. The raw OS gesture cannot be dispatched
from a unit test, so these tests drive the actual seam the fix introduced
(`patch-android-manifest.ts`) and assert it converges on the exact contract the button
path above already proves: once the manifest opts in, gesture-completion delivery uses
the SAME `OnBackPressedCallback` -> "backButton" -> `dispatchNativeBackEvent()` ->
`fusion:native-back` chain, with the same cancelable-event and empty-stack fallback
semantics. This test fails against the pre-fix tree (no `patch-android-manifest.ts`
module, so the import above throws) and passes after.
*/
describe("Android Back: gesture-delivery manifest opt-in (patch-android-manifest)", () => {
let workDir: string;
beforeEach(() => {
workDir = mkdtempSync(join(tmpdir(), "fusion-fn7583-manifest-"));
});
afterEach(() => {
rmSync(workDir, { recursive: true, force: true });
});
function writeManifest(xml: string): string {
const manifestDir = join(workDir, "android", "app", "src", "main");
mkdirSync(manifestDir, { recursive: true });
const manifestPath = join(manifestDir, "AndroidManifest.xml");
writeFileSync(manifestPath, xml, "utf8");
return manifestPath;
}
const baseManifest = `<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.fusion.mobile">
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name">
<activity android:name=".MainActivity" />
</application>
</manifest>
`;
it("opts the generated manifest into predictive-back gesture delivery (the seam the gesture needs)", () => {
const manifestPath = writeManifest(baseManifest);
const result = patchAndroidManifest(workDir);
expect(result.patched).toBe(true);
expect(result.skipped).toBe(false);
const patchedXml = readFileSync(manifestPath, "utf8");
expect(patchedXml).toMatch(/<application[^>]*android:enableOnBackInvokedCallback="true"[^>]*>/);
});
it("is idempotent across repeated cap sync runs (does not duplicate the attribute)", () => {
writeManifest(baseManifest);
const first = patchAndroidManifest(workDir);
const second = patchAndroidManifest(workDir);
expect(first.patched).toBe(true);
expect(second.patched).toBe(false);
const manifestPath = join(workDir, "android", "app", "src", "main", "AndroidManifest.xml");
const xml = readFileSync(manifestPath, "utf8");
expect(xml.match(/android:enableOnBackInvokedCallback/g)).toHaveLength(1);
});
it("leaves an already-opted-in manifest untouched", () => {
const alreadyOptedIn = baseManifest.replace(
'<application\n android:allowBackup="true"',
'<application\n android:enableOnBackInvokedCallback="true"\n android:allowBackup="true"',
);
writeManifest(alreadyOptedIn);
const result = patchAndroidManifest(workDir);
expect(result.patched).toBe(false);
const xml = readFileSync(join(workDir, "android", "app", "src", "main", "AndroidManifest.xml"), "utf8");
expect(xml.match(/android:enableOnBackInvokedCallback/g)).toHaveLength(1);
});
it("no-ops safely (does not throw) when no android/ project has been added yet", () => {
const result = patchAndroidManifest(workDir);
expect(result.skipped).toBe(true);
expect(result.patched).toBe(false);
});
it("patchManifestSource preserves the rest of the manifest verbatim aside from the opt-in attribute", () => {
const { changed, xml } = patchManifestSource(baseManifest);
expect(changed).toBe(true);
expect(xml).toContain('<activity android:name=".MainActivity" />');
expect(xml).toContain('android:label="@string/app_name"');
});
});
});