From 9fb706944ebeae7ccc6af7433a525159e83ea8c1 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 29 Jun 2026 20:50:14 -0700 Subject: [PATCH] FN-7257: dismiss task details on Android Back Route Android Back through Fusion's mobile navigation stack before native fallback. - Dispatch a cancelable native-back event from the Capacitor shell before browser-history or app-exit fallback. - Handle the native-back event in dashboard navigation history so mobile task details dismiss like browser Back and swipe-back. - Cover Android Back behavior across native shell and task-detail navigation tests. - Add a patch changeset for the published CLI/mobile bundle. Files changed: .changeset/fn-7257-android-back-task-detail.md | 7 + .../__tests__/TaskDetail.swipe-back.test.tsx | 214 +++++++++++++++++++++ .../dashboard/app/hooks/useNavigationHistory.ts | 15 ++ packages/mobile/src/__tests__/native-shell.test.ts | 151 ++++++++++++++- packages/mobile/src/index.ts | 7 +- packages/mobile/src/plugins/native-shell.ts | 103 +++++++++- 6 files changed, 494 insertions(+), 3 deletions(-) Fusion-Task-Id: FN-7257 Fusion-Task-Lineage: 90d0bc32-8968-4876-a5b4-51b34888d480 Co-authored-by: Fusion (runfusion.ai) --- .../fn-7257-android-back-task-detail.md | 7 + .../__tests__/TaskDetail.swipe-back.test.tsx | 214 ++++++++++++++++++ .../app/hooks/useNavigationHistory.ts | 15 ++ .../mobile/src/__tests__/native-shell.test.ts | 151 +++++++++++- packages/mobile/src/index.ts | 7 +- packages/mobile/src/plugins/native-shell.ts | 103 ++++++++- 6 files changed, 494 insertions(+), 3 deletions(-) create mode 100644 .changeset/fn-7257-android-back-task-detail.md diff --git a/.changeset/fn-7257-android-back-task-detail.md b/.changeset/fn-7257-android-back-task-detail.md new file mode 100644 index 0000000000..6063a1bb61 --- /dev/null +++ b/.changeset/fn-7257-android-back-task-detail.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Dismiss mobile task details when Android Back is pressed before falling back to app exit. +category: fix +dev: Routes Capacitor Android Back through the dashboard navigation-history stack via a cancelable native event. diff --git a/packages/dashboard/app/components/__tests__/TaskDetail.swipe-back.test.tsx b/packages/dashboard/app/components/__tests__/TaskDetail.swipe-back.test.tsx index c42551a7f5..53b47a0e0d 100644 --- a/packages/dashboard/app/components/__tests__/TaskDetail.swipe-back.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskDetail.swipe-back.test.tsx @@ -186,12 +186,19 @@ vi.mock("../../components/TaskDetailModal", () => ({ TaskDetailContent: ({ task, onBackToBoard, + onOpenDetail, }: { task: { id: string; title?: string }; onBackToBoard?: () => void; + onOpenDetail?: (task: { id: string; title: string }) => void; }) => (
{onBackToBoard ? : null} + {task.id === "FN-1" && onOpenDetail ? ( + + ) : null}

{task.title ?? task.id}

), @@ -307,6 +314,16 @@ function dispatchPopState(state: Record | null) { }); } +function dispatchNativeAndroidBack(): boolean { + let handled = false; + act(() => { + const event = new CustomEvent("fusion:native-back", { cancelable: true, detail: { source: "android-back" } }); + window.dispatchEvent(event); + handled = event.defaultPrevented; + }); + return handled; +} + async function renderAppAndWait(expectedTestId: string = "board-view") { const result = render(); await waitFor(() => { @@ -318,6 +335,7 @@ async function renderAppAndWait(expectedTestId: string = "board-view") { describe("Task detail mobile swipe-back", () => { const originalPushState = window.history.pushState; const originalReplaceState = window.history.replaceState; + const originalBack = window.history.back; beforeEach(() => { vi.clearAllMocks(); @@ -345,11 +363,87 @@ describe("Task detail mobile swipe-back", () => { localStorage.clear(); window.history.pushState = vi.fn(); window.history.replaceState = vi.fn(); + window.history.back = vi.fn(); }); afterEach(() => { window.history.pushState = originalPushState; window.history.replaceState = originalReplaceState; + window.history.back = originalBack; + }); + + it("dismisses the board main-panel task detail on native Android Back", async () => { + const task = makeTask("FN-1", "Board Detail"); + mockUseTasks.mockImplementation(() => ({ + tasks: [task], + createTask: mockCreateTask, + moveTask: vi.fn(), + deleteTask: vi.fn(), + mergeTask: vi.fn(), + retryTask: vi.fn(), + updateTask: vi.fn(), + duplicateTask: vi.fn(), + archiveTask: vi.fn(), + unarchiveTask: vi.fn(), + archiveAllDone: vi.fn(), + refreshTasks: vi.fn(), + })); + + await renderAppAndWait("board-view"); + fireEvent.click(screen.getByTestId("open-task-FN-1")); + + await waitFor(() => { + expect(screen.getByTestId("task-detail-main-panel-content")).toBeInTheDocument(); + }); + + expect(dispatchNativeAndroidBack()).toBe(true); + expect(window.history.back).toHaveBeenCalledTimes(1); + expect(screen.getByTestId("task-detail-main-panel-content")).toBeInTheDocument(); + dispatchPopState({ navIndex: 0 }); + + await waitFor(() => { + expect(screen.queryByTestId("task-detail-main-panel-content")).toBeNull(); + expect(screen.getByTestId("board-view")).toBeInTheDocument(); + }); + }); + + it("restores the previous board main-panel detail on native Android Back", async () => { + const task = makeTask("FN-1", "Parent Main Panel Task"); + mockUseTasks.mockImplementation(() => ({ + tasks: [task, makeTask("FN-2", "Nested Main Panel Task")], + createTask: mockCreateTask, + moveTask: vi.fn(), + deleteTask: vi.fn(), + mergeTask: vi.fn(), + retryTask: vi.fn(), + updateTask: vi.fn(), + duplicateTask: vi.fn(), + archiveTask: vi.fn(), + unarchiveTask: vi.fn(), + archiveAllDone: vi.fn(), + refreshTasks: vi.fn(), + })); + + await renderAppAndWait("board-view"); + fireEvent.click(screen.getByTestId("open-task-FN-1")); + await waitFor(() => { + expect(screen.getByRole("heading", { name: "Parent Main Panel Task" })).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByTestId("task-detail-open-nested")); + await waitFor(() => { + expect(screen.getByRole("heading", { name: "Nested Main Panel Task" })).toBeInTheDocument(); + }); + expect(window.history.pushState).toHaveBeenCalledTimes(2); + + expect(dispatchNativeAndroidBack()).toBe(true); + expect(screen.getByRole("heading", { name: "Nested Main Panel Task" })).toBeInTheDocument(); + dispatchPopState({ navIndex: 1 }); + + await waitFor(() => { + expect(screen.getByRole("heading", { name: "Parent Main Panel Task" })).toBeInTheDocument(); + }); + expect(screen.queryByRole("heading", { name: "Nested Main Panel Task" })).toBeNull(); }); it("dismisses the board main-panel task detail on mobile popstate", async () => { @@ -384,6 +478,80 @@ describe("Task detail mobile swipe-back", () => { }); }); + it("dismisses the list-mobile task detail on native Android Back", async () => { + const task = makeTask("FN-1", "Mobile List Detail"); + mockUseTasks.mockImplementation(() => ({ + tasks: [task], + createTask: mockCreateTask, + moveTask: vi.fn(), + deleteTask: vi.fn(), + mergeTask: vi.fn(), + retryTask: vi.fn(), + updateTask: vi.fn(), + duplicateTask: vi.fn(), + archiveTask: vi.fn(), + unarchiveTask: vi.fn(), + archiveAllDone: vi.fn(), + refreshTasks: vi.fn(), + })); + localStorage.setItem("kb-dashboard-view-mode", "project"); + localStorage.setItem(scopedKey("kb-dashboard-task-view", DEFAULT_PROJECT_ID), "list"); + + await renderAppAndWait("list-view"); + fireEvent.click(screen.getByTestId("list-open-FN-1")); + + await waitFor(() => { + expect(screen.getByTestId("task-detail-modal")).toBeInTheDocument(); + expect(screen.getByTestId("task-detail-mobile-header-mode")).toHaveTextContent("back"); + }); + + expect(dispatchNativeAndroidBack()).toBe(true); + expect(window.history.back).toHaveBeenCalledTimes(1); + expect(screen.getByTestId("task-detail-modal")).toBeInTheDocument(); + dispatchPopState({ navIndex: 0 }); + + await waitFor(() => { + expect(screen.queryByTestId("task-detail-modal")).toBeNull(); + expect(screen.getByTestId("list-view")).toBeInTheDocument(); + }); + }); + + it("does not swallow native Android Back when no Fusion nav entry exists", async () => { + await renderAppAndWait("board-view"); + + expect(dispatchNativeAndroidBack()).toBe(false); + expect(window.history.back).not.toHaveBeenCalled(); + }); + + it("leaves desktop browser behavior unchanged for native Back events without task-detail history", async () => { + mockUseViewportMode.mockReturnValue("desktop"); + const task = makeTask("FN-1", "Desktop List Detail"); + mockUseTasks.mockImplementation(() => ({ + tasks: [task], + createTask: mockCreateTask, + moveTask: vi.fn(), + deleteTask: vi.fn(), + mergeTask: vi.fn(), + retryTask: vi.fn(), + updateTask: vi.fn(), + duplicateTask: vi.fn(), + archiveTask: vi.fn(), + unarchiveTask: vi.fn(), + archiveAllDone: vi.fn(), + refreshTasks: vi.fn(), + })); + localStorage.setItem("kb-dashboard-view-mode", "project"); + localStorage.setItem(scopedKey("kb-dashboard-task-view", DEFAULT_PROJECT_ID), "list"); + + await renderAppAndWait("list-view"); + fireEvent.click(screen.getByTestId("list-open-FN-1")); + + expect(dispatchNativeAndroidBack()).toBe(false); + expect(window.history.pushState).not.toHaveBeenCalled(); + expect(window.history.back).not.toHaveBeenCalled(); + expect(screen.queryByTestId("task-detail-modal")).toBeNull(); + }); + it("dismisses the list-mobile task detail on mobile popstate", async () => { const task = makeTask("FN-1", "Mobile List Detail"); mockUseTasks.mockImplementation(() => ({ @@ -505,6 +673,52 @@ describe("Task detail mobile swipe-back", () => { expect(screen.queryByRole("dialog", { name: "Nested Task" })).toBeNull(); }); + it("consumes repeated native Android Back events while nested mobile detail entries exist", async () => { + const task = makeTask("FN-1", "Parent Task"); + mockUseTasks.mockImplementation(() => ({ + tasks: [task, makeTask("FN-2", "Nested Task")], + createTask: mockCreateTask, + moveTask: vi.fn(), + deleteTask: vi.fn(), + mergeTask: vi.fn(), + retryTask: vi.fn(), + updateTask: vi.fn(), + duplicateTask: vi.fn(), + archiveTask: vi.fn(), + unarchiveTask: vi.fn(), + archiveAllDone: vi.fn(), + refreshTasks: vi.fn(), + })); + localStorage.setItem("kb-dashboard-view-mode", "project"); + localStorage.setItem(scopedKey("kb-dashboard-task-view", DEFAULT_PROJECT_ID), "list"); + + await renderAppAndWait("list-view"); + fireEvent.click(screen.getByTestId("list-open-FN-1")); + await waitFor(() => { + expect(screen.getByRole("dialog", { name: "Parent Task" })).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByTestId("task-detail-open-nested")); + await waitFor(() => { + expect(screen.getByRole("dialog", { name: "Nested Task" })).toBeInTheDocument(); + }); + + expect(dispatchNativeAndroidBack()).toBe(true); + expect(screen.getByRole("dialog", { name: "Nested Task" })).toBeInTheDocument(); + dispatchPopState({ navIndex: 1 }); + await waitFor(() => { + expect(screen.getByRole("dialog", { name: "Parent Task" })).toBeInTheDocument(); + }); + + expect(dispatchNativeAndroidBack()).toBe(true); + dispatchPopState({ navIndex: 0 }); + await waitFor(() => { + expect(screen.queryByTestId("task-detail-modal")).toBeNull(); + expect(screen.getByTestId("list-view")).toBeInTheDocument(); + }); + expect(window.history.back).toHaveBeenCalledTimes(2); + }); + it("pops multiple mobile detail entries back to the list target on a rapid Android-style pop", async () => { const task = makeTask("FN-1", "Parent Task"); mockUseTasks.mockImplementation(() => ({ diff --git a/packages/dashboard/app/hooks/useNavigationHistory.ts b/packages/dashboard/app/hooks/useNavigationHistory.ts index 0524aff946..371d995eb5 100644 --- a/packages/dashboard/app/hooks/useNavigationHistory.ts +++ b/packages/dashboard/app/hooks/useNavigationHistory.ts @@ -41,6 +41,7 @@ export interface UseNavigationHistoryResult { } const SELF_POP_FALLBACK_CLEAR_MS = 1_000; +const FUSION_NATIVE_BACK_EVENT = "fusion:native-back"; export const NavigationHistoryContext = createContext(null); @@ -173,6 +174,18 @@ export function useNavigationHistory( useEffect(() => { if (typeof window === "undefined") return; + /* + FNXC:TaskDetailAndroidBack 2026-06-29-20:40: + The mobile shell dispatches a cancelable native Back event before applying its fallback. Prevent it only when Fusion owns at least one nav entry, then route through history.back() so modal, nested, snapshot-only, hydrated, and main-panel task-detail dismissals reuse the same popstate stack semantics as browser Back and swipe-back. + */ + const handleNativeBack = (event: Event) => { + if (!enabledRef.current) return; + if (stackRef.current.length === 0) return; + + event.preventDefault(); + window.history.back(); + }; + const handlePopState = (event: PopStateEvent) => { if (!enabledRef.current) return; @@ -214,8 +227,10 @@ export function useNavigationHistory( } }; + window.addEventListener(FUSION_NATIVE_BACK_EVENT, handleNativeBack); window.addEventListener("popstate", handlePopState); return () => { + window.removeEventListener(FUSION_NATIVE_BACK_EVENT, handleNativeBack); window.removeEventListener("popstate", handlePopState); if (selfPopClearTimerRef.current !== null) { window.clearTimeout(selfPopClearTimerRef.current); diff --git a/packages/mobile/src/__tests__/native-shell.test.ts b/packages/mobile/src/__tests__/native-shell.test.ts index af362cf9cf..999ca392eb 100644 --- a/packages/mobile/src/__tests__/native-shell.test.ts +++ b/packages/mobile/src/__tests__/native-shell.test.ts @@ -1,6 +1,46 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { buildMobileShellHandoff } from "../plugins/shell-handoff.js"; +type BackButtonListener = (event: { canGoBack: boolean }) => void; + +const capacitorState = vi.hoisted(() => { + const state: { + isNativePlatform: ReturnType; + addListener: ReturnType; + backButtonRemove: ReturnType; + exitApp: ReturnType; + backButtonListener?: BackButtonListener; + } = { + isNativePlatform: vi.fn(() => false), + addListener: vi.fn(), + backButtonRemove: vi.fn(async () => {}), + exitApp: vi.fn(async () => {}), + backButtonListener: undefined, + }; + + state.addListener.mockImplementation(async (eventName: string, callback: BackButtonListener) => { + if (eventName === "backButton") { + state.backButtonListener = callback; + } + return { remove: state.backButtonRemove }; + }); + + return state; +}); + +vi.mock("@capacitor/core", () => ({ + Capacitor: { + isNativePlatform: capacitorState.isNativePlatform, + }, +})); + +vi.mock("@capacitor/app", () => ({ + App: { + addListener: capacitorState.addListener, + exitApp: capacitorState.exitApp, + }, +})); + const state = { activeProfileId: null as string | null, profiles: [] as Array<{ id: string; name: string; serverUrl: string; authToken?: string | null; createdAt: string; updatedAt: string; lastUsedAt?: string | null }>, @@ -39,9 +79,16 @@ describe("MobileNativeShellBridge", () => { state.activeProfileId = null; state.profiles = []; scanner.scanConnection.mockClear(); + capacitorState.isNativePlatform.mockReturnValue(false); + capacitorState.backButtonListener = undefined; + vi.clearAllMocks(); vi.resetModules(); }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + it("emits state updates to subscribers", async () => { const { MobileNativeShellBridge } = await import("../plugins/native-shell.js"); const bridge = new MobileNativeShellBridge(scanner as never); @@ -110,4 +157,106 @@ describe("MobileNativeShellBridge", () => { await expect(bridge.setDesktopMode("local")).rejects.toThrow("Desktop mode is not supported"); }); + + it("Android Back: skips Capacitor backButton registration on non-native platforms", async () => { + const { AndroidBackButtonManager } = await import("../plugins/native-shell.js"); + const manager = new AndroidBackButtonManager(); + + await manager.initialize(); + + expect(capacitorState.isNativePlatform).toHaveBeenCalledTimes(1); + expect(capacitorState.addListener).not.toHaveBeenCalled(); + expect(capacitorState.backButtonListener).toBeUndefined(); + + await manager.destroy(); + + expect(capacitorState.backButtonRemove).not.toHaveBeenCalled(); + }); + + it("Android Back: registers and removes the native backButton listener on native platforms", async () => { + capacitorState.isNativePlatform.mockReturnValue(true); + const { AndroidBackButtonManager } = await import("../plugins/native-shell.js"); + const manager = new AndroidBackButtonManager(); + + await manager.initialize(); + + expect(capacitorState.addListener).toHaveBeenCalledWith("backButton", expect.any(Function)); + + await manager.destroy(); + + expect(capacitorState.backButtonRemove).toHaveBeenCalledTimes(1); + }); + + it("Android Back: does not duplicate native listener registration across repeated initialization", async () => { + capacitorState.isNativePlatform.mockReturnValue(true); + const { AndroidBackButtonManager } = await import("../plugins/native-shell.js"); + const manager = new AndroidBackButtonManager(); + + await manager.initialize(); + await manager.initialize(); + + expect(capacitorState.addListener).toHaveBeenCalledTimes(1); + + await manager.destroy(); + + expect(capacitorState.backButtonRemove).toHaveBeenCalledTimes(1); + }); + + it("Android Back: dispatches a cancelable browser event before native fallback", async () => { + capacitorState.isNativePlatform.mockReturnValue(true); + const mockWindow = new EventTarget() as Window & typeof globalThis; + const back = vi.fn(); + Object.defineProperty(mockWindow, "history", { + value: { back }, + configurable: true, + }); + vi.stubGlobal("window", mockWindow); + const nativeBackListener = vi.fn((event: Event) => event.preventDefault()); + mockWindow.addEventListener("fusion:native-back", nativeBackListener as EventListener); + const { AndroidBackButtonManager } = await import("../plugins/native-shell.js"); + const manager = new AndroidBackButtonManager(); + + await manager.initialize(); + capacitorState.backButtonListener?.({ canGoBack: false }); + + expect(nativeBackListener).toHaveBeenCalledTimes(1); + expect(back).not.toHaveBeenCalled(); + expect(capacitorState.exitApp).not.toHaveBeenCalled(); + }); + + it("Android Back: preserves browser-history fallback when Fusion does not handle it", async () => { + capacitorState.isNativePlatform.mockReturnValue(true); + const mockWindow = new EventTarget() as Window & typeof globalThis; + const back = vi.fn(); + Object.defineProperty(mockWindow, "history", { + value: { back }, + configurable: true, + }); + vi.stubGlobal("window", mockWindow); + const { AndroidBackButtonManager } = await import("../plugins/native-shell.js"); + const manager = new AndroidBackButtonManager(); + + await manager.initialize(); + capacitorState.backButtonListener?.({ canGoBack: true }); + + expect(back).toHaveBeenCalledTimes(1); + expect(capacitorState.exitApp).not.toHaveBeenCalled(); + }); + + it("Android Back: preserves native exit fallback when no history can go back", async () => { + capacitorState.isNativePlatform.mockReturnValue(true); + const mockWindow = new EventTarget() as Window & typeof globalThis; + Object.defineProperty(mockWindow, "history", { + value: { back: vi.fn() }, + configurable: true, + }); + vi.stubGlobal("window", mockWindow); + const { AndroidBackButtonManager } = await import("../plugins/native-shell.js"); + const manager = new AndroidBackButtonManager(); + + await manager.initialize(); + capacitorState.backButtonListener?.({ canGoBack: false }); + + expect(capacitorState.exitApp).toHaveBeenCalledTimes(1); + }); }); diff --git a/packages/mobile/src/index.ts b/packages/mobile/src/index.ts index 13c471885d..1b4bd4d35b 100644 --- a/packages/mobile/src/index.ts +++ b/packages/mobile/src/index.ts @@ -21,7 +21,11 @@ export type { PushNotificationManagerOptions, } from "./plugins/push-notifications.js"; export { ShareManager } from "./plugins/share.js"; -export { MobileNativeShellBridge } from "./plugins/native-shell.js"; +export { + AndroidBackButtonManager, + FUSION_NATIVE_BACK_EVENT, + MobileNativeShellBridge, +} from "./plugins/native-shell.js"; export { buildMobileShellHandoff } from "./plugins/shell-handoff.js"; export { QrScanner, parseQrConnectionPayload } from "./plugins/qr-scanner.js"; export { @@ -83,6 +87,7 @@ export function installMobileShellBridge( ): MobileNativeShellBridge { const bridge = new MobileNativeShellBridge(); (target as Window & { fusionShell?: MobileNativeShellBridge }).fusionShell = bridge; + void bridge.initializeNativeBackButton(); return bridge; } diff --git a/packages/mobile/src/plugins/native-shell.ts b/packages/mobile/src/plugins/native-shell.ts index e12a26ab6e..b15e3b8c93 100644 --- a/packages/mobile/src/plugins/native-shell.ts +++ b/packages/mobile/src/plugins/native-shell.ts @@ -1,3 +1,4 @@ +import { Capacitor } from "@capacitor/core"; import type { FusionShellApi, ShellConnectionProfile, @@ -14,11 +15,111 @@ import { import { QrScanner, type QrScanResult } from "./qr-scanner.js"; type Listener = (state: ShellConnectionState) => void; +type AppModule = typeof import("@capacitor/app"); +type AppBackButtonListenerEvent = { canGoBack: boolean }; +type AppListenerHandle = { remove: () => Promise }; + +export const FUSION_NATIVE_BACK_EVENT = "fusion:native-back"; + +export class AndroidBackButtonManager { + private initialized = false; + private listenerHandle?: AppListenerHandle; + private appPlugin: AppModule["App"] | null = null; + + /* + FNXC:TaskDetailAndroidBack 2026-06-29-20:40: + Native Android Back must first offer the dashboard's shared navigation-history stack a cancelable browser event so every task-detail surface dismisses through the same invariant as swipe/browser popstate. If the dashboard does not prevent the event, preserve Capacitor's native fallback by going back in ordinary browser history or exiting the app. + */ + async initialize(): Promise { + if (this.initialized) { + return; + } + + this.initialized = true; + + if (!Capacitor.isNativePlatform()) { + return; + } + + const app = await this.loadAppPlugin(); + if (!app) { + return; + } + + this.listenerHandle = await app.addListener("backButton", (event) => { + this.handleBackButton(event); + }); + } + + async destroy(): Promise { + if (this.listenerHandle) { + try { + await this.listenerHandle.remove(); + } catch (error) { + console.warn("Failed to remove backButton listener", error); + } + this.listenerHandle = undefined; + } + + this.initialized = false; + } + + private handleBackButton(event: AppBackButtonListenerEvent): void { + if (this.dispatchNativeBackEvent()) { + return; + } + + const win = globalThis.window; + if (event.canGoBack && win?.history && typeof win.history.back === "function") { + win.history.back(); + return; + } + + void this.appPlugin?.exitApp(); + } + + private dispatchNativeBackEvent(): boolean { + const win = globalThis.window; + if (!win || typeof win.dispatchEvent !== "function") { + return false; + } + + const event = new CustomEvent(FUSION_NATIVE_BACK_EVENT, { + cancelable: true, + detail: { source: "android-back" }, + }); + win.dispatchEvent(event); + return event.defaultPrevented; + } + + private async loadAppPlugin(): Promise { + try { + if (!this.appPlugin) { + this.appPlugin = (await import("@capacitor/app")).App; + } + return this.appPlugin; + } catch (error) { + console.warn("Failed to load Capacitor App plugin for Android Back", error); + return null; + } + } +} export class MobileNativeShellBridge implements FusionShellApi { private listeners = new Set(); - constructor(private readonly qrScanner: QrScanner = new QrScanner()) {} + constructor( + private readonly qrScanner: QrScanner = new QrScanner(), + private readonly androidBackButtonManager: AndroidBackButtonManager = new AndroidBackButtonManager(), + ) {} + + initializeNativeBackButton(): Promise { + return this.androidBackButtonManager.initialize(); + } + + destroy(): Promise { + return this.androidBackButtonManager.destroy(); + } private async buildState(): Promise { const persisted = await loadShellProfiles();