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: ({
|
TaskDetailContent: ({
|
||||||
task,
|
task,
|
||||||
onBackToBoard,
|
onBackToBoard,
|
||||||
|
onOpenDetail,
|
||||||
}: {
|
}: {
|
||||||
task: { id: string; title?: string };
|
task: { id: string; title?: string };
|
||||||
onBackToBoard?: () => void;
|
onBackToBoard?: () => void;
|
||||||
|
onOpenDetail?: (task: { id: string; title: string }) => void;
|
||||||
}) => (
|
}) => (
|
||||||
<div data-testid="task-detail-main-panel-content">
|
<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}
|
{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>
|
<h2>{task.title ?? task.id}</h2>
|
||||||
</div>
|
</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") {
|
async function renderAppAndWait(expectedTestId: string = "board-view") {
|
||||||
const result = render(<App />);
|
const result = render(<App />);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -318,6 +335,7 @@ async function renderAppAndWait(expectedTestId: string = "board-view") {
|
|||||||
describe("Task detail mobile swipe-back", () => {
|
describe("Task detail mobile swipe-back", () => {
|
||||||
const originalPushState = window.history.pushState;
|
const originalPushState = window.history.pushState;
|
||||||
const originalReplaceState = window.history.replaceState;
|
const originalReplaceState = window.history.replaceState;
|
||||||
|
const originalBack = window.history.back;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@@ -345,11 +363,87 @@ describe("Task detail mobile swipe-back", () => {
|
|||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
window.history.pushState = vi.fn();
|
window.history.pushState = vi.fn();
|
||||||
window.history.replaceState = vi.fn();
|
window.history.replaceState = vi.fn();
|
||||||
|
window.history.back = vi.fn();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
window.history.pushState = originalPushState;
|
window.history.pushState = originalPushState;
|
||||||
window.history.replaceState = originalReplaceState;
|
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 () => {
|
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 () => {
|
it("dismisses the list-mobile task detail on mobile popstate", async () => {
|
||||||
const task = makeTask("FN-1", "Mobile List Detail");
|
const task = makeTask("FN-1", "Mobile List Detail");
|
||||||
mockUseTasks.mockImplementation(() => ({
|
mockUseTasks.mockImplementation(() => ({
|
||||||
@@ -505,6 +673,52 @@ describe("Task detail mobile swipe-back", () => {
|
|||||||
expect(screen.queryByRole("dialog", { name: "Nested Task" })).toBeNull();
|
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 () => {
|
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");
|
const task = makeTask("FN-1", "Parent Task");
|
||||||
mockUseTasks.mockImplementation(() => ({
|
mockUseTasks.mockImplementation(() => ({
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export interface UseNavigationHistoryResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SELF_POP_FALLBACK_CLEAR_MS = 1_000;
|
const SELF_POP_FALLBACK_CLEAR_MS = 1_000;
|
||||||
|
const FUSION_NATIVE_BACK_EVENT = "fusion:native-back";
|
||||||
|
|
||||||
export const NavigationHistoryContext = createContext<UseNavigationHistoryResult | null>(null);
|
export const NavigationHistoryContext = createContext<UseNavigationHistoryResult | null>(null);
|
||||||
|
|
||||||
@@ -173,6 +174,18 @@ export function useNavigationHistory(
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === "undefined") return;
|
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) => {
|
const handlePopState = (event: PopStateEvent) => {
|
||||||
if (!enabledRef.current) return;
|
if (!enabledRef.current) return;
|
||||||
|
|
||||||
@@ -214,8 +227,10 @@ export function useNavigationHistory(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
window.addEventListener(FUSION_NATIVE_BACK_EVENT, handleNativeBack);
|
||||||
window.addEventListener("popstate", handlePopState);
|
window.addEventListener("popstate", handlePopState);
|
||||||
return () => {
|
return () => {
|
||||||
|
window.removeEventListener(FUSION_NATIVE_BACK_EVENT, handleNativeBack);
|
||||||
window.removeEventListener("popstate", handlePopState);
|
window.removeEventListener("popstate", handlePopState);
|
||||||
if (selfPopClearTimerRef.current !== null) {
|
if (selfPopClearTimerRef.current !== null) {
|
||||||
window.clearTimeout(selfPopClearTimerRef.current);
|
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";
|
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 = {
|
const state = {
|
||||||
activeProfileId: null as string | null,
|
activeProfileId: null as string | null,
|
||||||
profiles: [] as Array<{ id: string; name: string; serverUrl: string; authToken?: string | null; createdAt: string; updatedAt: string; lastUsedAt?: 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.activeProfileId = null;
|
||||||
state.profiles = [];
|
state.profiles = [];
|
||||||
scanner.scanConnection.mockClear();
|
scanner.scanConnection.mockClear();
|
||||||
|
capacitorState.isNativePlatform.mockReturnValue(false);
|
||||||
|
capacitorState.backButtonListener = undefined;
|
||||||
|
vi.clearAllMocks();
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
it("emits state updates to subscribers", async () => {
|
it("emits state updates to subscribers", async () => {
|
||||||
const { MobileNativeShellBridge } = await import("../plugins/native-shell.js");
|
const { MobileNativeShellBridge } = await import("../plugins/native-shell.js");
|
||||||
const bridge = new MobileNativeShellBridge(scanner as never);
|
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");
|
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,
|
PushNotificationManagerOptions,
|
||||||
} from "./plugins/push-notifications.js";
|
} from "./plugins/push-notifications.js";
|
||||||
export { ShareManager } from "./plugins/share.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 { buildMobileShellHandoff } from "./plugins/shell-handoff.js";
|
||||||
export { QrScanner, parseQrConnectionPayload } from "./plugins/qr-scanner.js";
|
export { QrScanner, parseQrConnectionPayload } from "./plugins/qr-scanner.js";
|
||||||
export {
|
export {
|
||||||
@@ -83,6 +87,7 @@ export function installMobileShellBridge(
|
|||||||
): MobileNativeShellBridge {
|
): MobileNativeShellBridge {
|
||||||
const bridge = new MobileNativeShellBridge();
|
const bridge = new MobileNativeShellBridge();
|
||||||
(target as Window & { fusionShell?: MobileNativeShellBridge }).fusionShell = bridge;
|
(target as Window & { fusionShell?: MobileNativeShellBridge }).fusionShell = bridge;
|
||||||
|
void bridge.initializeNativeBackButton();
|
||||||
return bridge;
|
return bridge;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Capacitor } from "@capacitor/core";
|
||||||
import type {
|
import type {
|
||||||
FusionShellApi,
|
FusionShellApi,
|
||||||
ShellConnectionProfile,
|
ShellConnectionProfile,
|
||||||
@@ -14,11 +15,111 @@ import {
|
|||||||
import { QrScanner, type QrScanResult } from "./qr-scanner.js";
|
import { QrScanner, type QrScanResult } from "./qr-scanner.js";
|
||||||
|
|
||||||
type Listener = (state: ShellConnectionState) => void;
|
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 {
|
export class MobileNativeShellBridge implements FusionShellApi {
|
||||||
private listeners = new Set<Listener>();
|
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> {
|
private async buildState(): Promise<ShellConnectionState> {
|
||||||
const persisted = await loadShellProfiles();
|
const persisted = await loadShellProfiles();
|
||||||
|
|||||||
Reference in New Issue
Block a user