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>
91 lines
4.4 KiB
TypeScript
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`);
|
|
}
|
|
}
|