diff --git a/app/pdf-viewer.tsx b/app/pdf-viewer.tsx index 378c719..e92517f 100644 --- a/app/pdf-viewer.tsx +++ b/app/pdf-viewer.tsx @@ -10,8 +10,8 @@ import * as NavigationBar from "expo-navigation-bar"; import * as Sharing from "expo-sharing"; import * as Sentry from "@sentry/react-native"; import { Stack, useLocalSearchParams } from "expo-router"; -import { useEffect, useRef, useState } from "react"; -import { Dimensions, FlatList, Platform, StyleSheet, TouchableOpacity, View } from "react-native"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { AppState, Dimensions, FlatList, Platform, StyleSheet, TouchableOpacity, View } from "react-native"; import Pdf, { PdfRef, TableContent } from "react-native-pdf"; import { cn } from "@/lib/utils"; @@ -46,6 +46,7 @@ export default function PdfViewerScreen() { const [showTocModal, setShowTocModal] = useState(false); const [showViewModeModal, setShowViewModeModal] = useState(false); const [isSharing, setIsSharing] = useState(false); + const [pdfKey, setPdfKey] = useState(0); useEffect(() => { if (Platform.OS === "android") { @@ -59,6 +60,19 @@ export default function PdfViewerScreen() { }; }, []); + const appStateRef = useRef(AppState.currentState ?? "active"); + const handleAppStateChange = useCallback((nextAppState: string) => { + if (Platform.OS === "android" && appStateRef.current !== "active" && nextAppState === "active") { + setPdfKey((k) => k + 1); + } + appStateRef.current = nextAppState as typeof appStateRef.current; + }, []); + + useEffect(() => { + const subscription = AppState.addEventListener("change", handleAppStateChange); + return () => subscription.remove(); + }, [handleAppStateChange]); + useEffect( () => () => { if (!urlRedirectId || !productFileId) return; @@ -128,7 +142,7 @@ export default function PdfViewerScreen() { }} /> void) | null = null; +const mockRemove = jest.fn(); +jest.spyOn(AppState, "addEventListener").mockImplementation((_type, callback) => { + appStateCallback = callback as (state: string) => void; + return { remove: mockRemove } as ReturnType; +}); + +jest.mock("expo-navigation-bar", () => ({ + setVisibilityAsync: jest.fn(), + setBehaviorAsync: jest.fn(), +})); + +jest.mock("expo-sharing", () => ({ + isAvailableAsync: jest.fn(), + shareAsync: jest.fn(), +})); + +jest.mock("expo-file-system", () => ({ + File: { downloadFileAsync: jest.fn() }, + Paths: { cache: "/tmp" }, +})); + +jest.mock("expo-router", () => ({ + Stack: { Screen: () => null }, + useLocalSearchParams: () => ({ uri: "https://example.com/test.pdf", title: "Test" }), +})); + +jest.mock("@/lib/auth-context", () => ({ + useAuth: () => ({ accessToken: "token" }), +})); + +jest.mock("@/lib/media-location", () => ({ + updateMediaLocation: jest.fn(), +})); + +jest.mock("@/components/use-ref-to-latest", () => ({ + useRefToLatest: (val: unknown) => ({ current: val }), +})); + +jest.mock("@/components/icon", () => ({ + LineIcon: () => null, + SolidIcon: () => null, +})); + +jest.mock("@/components/ui/screen", () => ({ + Screen: ({ children }: { children: React.ReactNode }) => children, +})); + +jest.mock("@/components/ui/sheet", () => ({ + Sheet: () => null, + SheetContent: () => null, + SheetHeader: () => null, + SheetTitle: () => null, +})); + +jest.mock("@/components/ui/text", () => ({ + Text: ({ children }: { children: React.ReactNode }) => children, +})); + +const mockPdfComponent = jest.fn((_props: Record) => null); +jest.mock("react-native-pdf", () => { + const React = require("react"); + return { + __esModule: true, + default: React.forwardRef((props: Record, ref: unknown) => { + mockPdfComponent(props); + return null; + }), + }; +}); + +import PdfViewerScreen from "@/app/pdf-viewer"; + +describe("PdfViewerScreen - background resume remount", () => { + beforeEach(() => { + appStateCallback = null; + mockPdfComponent.mockClear(); + jest.clearAllMocks(); + }); + + it("registers an AppState change listener", () => { + Platform.OS = "android"; + render(); + expect(AppState.addEventListener).toHaveBeenCalledWith("change", expect.any(Function)); + }); + + it("remounts Pdf component when Android app returns from background", () => { + Platform.OS = "android"; + render(); + + const renderCountBefore = mockPdfComponent.mock.calls.length; + + act(() => appStateCallback!("background")); + act(() => appStateCallback!("active")); + + const renderCountAfter = mockPdfComponent.mock.calls.length; + expect(renderCountAfter).toBeGreaterThan(renderCountBefore); + }); + + it("does not remount Pdf component on iOS when returning from background", () => { + Platform.OS = "ios"; + render(); + + const renderCountBefore = mockPdfComponent.mock.calls.length; + + act(() => appStateCallback!("background")); + act(() => appStateCallback!("active")); + + const renderCountAfter = mockPdfComponent.mock.calls.length; + expect(renderCountAfter).toBe(renderCountBefore); + }); + + it("cleans up AppState listener on unmount", () => { + Platform.OS = "android"; + const { unmount } = render(); + unmount(); + expect(mockRemove).toHaveBeenCalled(); + }); +});