Files
fusion/packages/mobile/scripts/patch-android-manifest.ts
gsxdsm aa6d21eeca 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>
2026-07-05 11:57:36 -07:00

91 lines
4.4 KiB
TypeScript

/**
* 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`);
}
}