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) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7257-android-back-task-detail.md
Normal file
7
.changeset/fn-7257-android-back-task-detail.md
Normal file
@@ -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.
|
||||
@@ -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;
|
||||
}) => (
|
||||
<div data-testid="task-detail-main-panel-content">
|
||||
{onBackToBoard ? <button type="button" data-testid="task-detail-back-to-board" onClick={onBackToBoard}>Back to board</button> : null}
|
||||
{task.id === "FN-1" && onOpenDetail ? (
|
||||
<button type="button" data-testid="task-detail-open-nested" onClick={() => onOpenDetail({ id: "FN-2", title: "Nested Main Panel Task" })}>
|
||||
Open nested
|
||||
</button>
|
||||
) : null}
|
||||
<h2>{task.title ?? task.id}</h2>
|
||||
</div>
|
||||
),
|
||||
@@ -307,6 +314,16 @@ function dispatchPopState(state: Record<string, unknown> | 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(<App />);
|
||||
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(() => ({
|
||||
|
||||
@@ -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<UseNavigationHistoryResult | null>(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);
|
||||
|
||||
@@ -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<typeof vi.fn>;
|
||||
addListener: ReturnType<typeof vi.fn>;
|
||||
backButtonRemove: ReturnType<typeof vi.fn>;
|
||||
exitApp: ReturnType<typeof vi.fn>;
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<void> };
|
||||
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<AppModule["App"] | null> {
|
||||
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<Listener>();
|
||||
|
||||
constructor(private readonly qrScanner: QrScanner = new QrScanner()) {}
|
||||
constructor(
|
||||
private readonly qrScanner: QrScanner = new QrScanner(),
|
||||
private readonly androidBackButtonManager: AndroidBackButtonManager = new AndroidBackButtonManager(),
|
||||
) {}
|
||||
|
||||
initializeNativeBackButton(): Promise<void> {
|
||||
return this.androidBackButtonManager.initialize();
|
||||
}
|
||||
|
||||
destroy(): Promise<void> {
|
||||
return this.androidBackButtonManager.destroy();
|
||||
}
|
||||
|
||||
private async buildState(): Promise<ShellConnectionState> {
|
||||
const persisted = await loadShellProfiles();
|
||||
|
||||
Reference in New Issue
Block a user