diff --git a/ScreenShots/iPad Air 13-inch/Chat.png b/ScreenShots/iPad Air 13-inch/Chat.png index e2c56c8..0d15042 100644 Binary files a/ScreenShots/iPad Air 13-inch/Chat.png and b/ScreenShots/iPad Air 13-inch/Chat.png differ diff --git a/ScreenShots/iPad Air 13-inch/Home.png b/ScreenShots/iPad Air 13-inch/Home.png index e823332..f899af3 100644 Binary files a/ScreenShots/iPad Air 13-inch/Home.png and b/ScreenShots/iPad Air 13-inch/Home.png differ diff --git a/ScreenShots/iPad Air 13-inch/Lobby.png b/ScreenShots/iPad Air 13-inch/Lobby.png index 2f100b4..4b06f0c 100644 Binary files a/ScreenShots/iPad Air 13-inch/Lobby.png and b/ScreenShots/iPad Air 13-inch/Lobby.png differ diff --git a/ScreenShots/iPad Air 13-inch/Start.png b/ScreenShots/iPad Air 13-inch/Start.png index ccab001..3b94a10 100644 Binary files a/ScreenShots/iPad Air 13-inch/Start.png and b/ScreenShots/iPad Air 13-inch/Start.png differ diff --git a/ScreenShots/iPad Air 13-inch/Transfers.png b/ScreenShots/iPad Air 13-inch/Transfers.png index 7e2ae5d..582ca89 100644 Binary files a/ScreenShots/iPad Air 13-inch/Transfers.png and b/ScreenShots/iPad Air 13-inch/Transfers.png differ diff --git a/ScreenShots/iPhone 17 Pro Max/Chat.png b/ScreenShots/iPhone 17 Pro Max/Chat.png index d4fdd85..4028fdf 100644 Binary files a/ScreenShots/iPhone 17 Pro Max/Chat.png and b/ScreenShots/iPhone 17 Pro Max/Chat.png differ diff --git a/ScreenShots/iPhone 17 Pro Max/Home.png b/ScreenShots/iPhone 17 Pro Max/Home.png index c4a0dc8..0621835 100644 Binary files a/ScreenShots/iPhone 17 Pro Max/Home.png and b/ScreenShots/iPhone 17 Pro Max/Home.png differ diff --git a/ScreenShots/iPhone 17 Pro Max/Lobby.png b/ScreenShots/iPhone 17 Pro Max/Lobby.png index d11668f..495f8fb 100644 Binary files a/ScreenShots/iPhone 17 Pro Max/Lobby.png and b/ScreenShots/iPhone 17 Pro Max/Lobby.png differ diff --git a/ScreenShots/iPhone 17 Pro Max/Start.png b/ScreenShots/iPhone 17 Pro Max/Start.png index cc7536f..3844d4f 100644 Binary files a/ScreenShots/iPhone 17 Pro Max/Start.png and b/ScreenShots/iPhone 17 Pro Max/Start.png differ diff --git a/ScreenShots/iPhone 17 Pro Max/Transfers.png b/ScreenShots/iPhone 17 Pro Max/Transfers.png index a63bd59..f80bc2a 100644 Binary files a/ScreenShots/iPhone 17 Pro Max/Transfers.png and b/ScreenShots/iPhone 17 Pro Max/Transfers.png differ diff --git a/mobile/scripts/generate-screenshots.mjs b/mobile/scripts/generate-screenshots.mjs index e1a8d0c..9e2a650 100644 --- a/mobile/scripts/generate-screenshots.mjs +++ b/mobile/scripts/generate-screenshots.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node import { execFileSync } from "node:child_process"; -import { existsSync, mkdirSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -16,8 +16,10 @@ const appPath = resolve( "Release-iphonesimulator", "mobile.app", ); +const productsPath = resolve(derivedDataPath, "Build", "Products", "Release-iphonesimulator"); const defaultOutputRoot = resolve(mobileRoot, "..", "ScreenShots"); const bundleId = "fr.negopoly.app"; +const screenshotStorageKey = "negopoly:screenshot-scene"; const launchWaitMs = 4500; const sceneWaitMs = 2200; @@ -159,19 +161,78 @@ function buildReleaseApp(buildDestinationUdid) { { stdio: "inherit" }, ); - if (!existsSync(appPath)) { - throw new Error(`Built app not found at ${appPath}`); - } + return resolveBuiltAppPath(); } -function installApp(udid) { +function resolveBuiltAppPath() { + if (existsSync(appPath)) { + return appPath; + } + + if (!existsSync(productsPath)) { + throw new Error(`Build products directory not found at ${productsPath}`); + } + + const candidates = readdirSync(productsPath) + .filter((entry) => entry.endsWith(".app")) + .filter((entry) => !entry.endsWith(".app.dSYM")) + .sort((left, right) => left.localeCompare(right)); + + if (candidates.length === 0) { + throw new Error(`Built app not found under ${productsPath}`); + } + + return join(productsPath, candidates[0]); +} + +function installApp(udid, builtAppPath) { runQuiet("xcrun", ["simctl", "terminate", udid, bundleId]); runQuiet("xcrun", ["simctl", "uninstall", udid, bundleId]); - run("xcrun", ["simctl", "install", udid, appPath]); + run("xcrun", ["simctl", "install", udid, builtAppPath]); +} + +function grantAppPermissions(udid) { + runQuiet("xcrun", ["simctl", "privacy", udid, "grant", "notifications", bundleId]); +} + +function getDataContainerPath(udid) { + return run("xcrun", ["simctl", "get_app_container", udid, bundleId, "data"]).trim(); +} + +function writeScreenshotScene(udid, scene) { + const containerPath = getDataContainerPath(udid); + const storageDir = join( + containerPath, + "Library", + "Application Support", + bundleId, + "RCTAsyncLocalStorage_V1", + ); + const manifestPath = join(storageDir, "manifest.json"); + + mkdirSync(storageDir, { recursive: true }); + + let manifest = {}; + if (existsSync(manifestPath)) { + try { + manifest = JSON.parse(readFileSync(manifestPath, "utf8")); + } catch { + manifest = {}; + } + } + + manifest[screenshotStorageKey] = scene; + writeFileSync(manifestPath, JSON.stringify(manifest), "utf8"); +} + +function launchApp(udid) { + run("xcrun", ["simctl", "launch", udid, bundleId]); } function openScene(udid, scene, waitMs) { - run("xcrun", ["simctl", "openurl", udid, `negopoly://screenshot/${scene}`]); + runQuiet("xcrun", ["simctl", "terminate", udid, bundleId]); + writeScreenshotScene(udid, scene); + launchApp(udid); sleep(waitMs); } @@ -186,14 +247,16 @@ function main() { ...target, simulator: resolveDevice(target.matchName, devices), })); + const builtAppPath = !options.skipBuild + ? buildReleaseApp(resolvedTargets[0].simulator.udid) + : resolveBuiltAppPath(); - if (!options.skipBuild) { - buildReleaseApp(resolvedTargets[0].simulator.udid); - } else if (!existsSync(appPath)) { - throw new Error(`Cannot use --skip-build because ${appPath} does not exist`); + if (options.skipBuild && !existsSync(builtAppPath)) { + throw new Error(`Cannot use --skip-build because ${builtAppPath} does not exist`); } mkdirSync(options.outputRoot, { recursive: true }); + console.log(`Using built app: ${builtAppPath}`); for (const target of resolvedTargets) { const { simulator } = target; @@ -203,7 +266,8 @@ function main() { mkdirSync(deviceOutputDir, { recursive: true }); ensureBooted(simulator.udid); setSimulatorAppearance(simulator.udid); - installApp(simulator.udid); + installApp(simulator.udid, builtAppPath); + grantAppPermissions(simulator.udid); for (const [index, scene] of scenes.entries()) { const targetPath = join(deviceOutputDir, scene.fileName); diff --git a/mobile/src/App.tsx b/mobile/src/App.tsx index f77e172..c55d3e3 100644 --- a/mobile/src/App.tsx +++ b/mobile/src/App.tsx @@ -64,7 +64,7 @@ function RootNavigationGate() { const lastTargetRef = useRef(null); const lastLinkRef = useRef(null); const pendingNotificationRef = useRef(null); - const pendingScreenshotRef = useRef(false); + const lastScreenshotSceneRef = useRef(null); const lastNotificationIdRef = useRef(null); const theme = useTheme(); const navigationTheme = getNavigationTheme(theme); @@ -72,7 +72,6 @@ function RootNavigationGate() { (url: string) => { const scene = extractScreenshotScene(url); if (!scene) return false; - pendingScreenshotRef.current = true; manager.activateScreenshotScene(scene); return true; }, @@ -150,12 +149,15 @@ function RootNavigationGate() { }, [manager.isBanker, manager.session, manager.sessionId, navReady, navigationRef]); const processPendingScreenshot = useCallback(() => { - if (!pendingScreenshotRef.current) return; + const screenshot = manager.screenshot; + if (!screenshot) { + lastScreenshotSceneRef.current = null; + return; + } + if (lastScreenshotSceneRef.current === screenshot.scene) return; if (!navReady || !navigationRef.isReady()) return; - const target = manager.screenshot?.navigationTarget; - if (!target) return; + const target = screenshot.navigationTarget; - pendingScreenshotRef.current = false; navigationRef.dispatch( CommonActions.reset({ index: 0, @@ -163,10 +165,24 @@ function RootNavigationGate() { { name: target.root, params: target.params, + state: target.state, }, ], }), ); + const followUp = target.followUp; + if (followUp) { + setTimeout(() => { + if (!navigationRef.isReady()) return; + navigationRef.dispatch( + CommonActions.navigate({ + name: followUp.name, + params: followUp.params, + }), + ); + }, 0); + } + lastScreenshotSceneRef.current = screenshot.scene; lastTargetRef.current = target.root; }, [manager.screenshot, navReady, navigationRef]); @@ -195,9 +211,7 @@ function RootNavigationGate() { ); useEffect(() => { - if (pendingScreenshotRef.current) { - return; - } + if (manager.screenshot) return; if (!navReady || !navigationRef.isReady()) return; const currentRoute = navigationRef.getCurrentRoute(); diff --git a/mobile/src/dev/screenshot-fixtures.ts b/mobile/src/dev/screenshot-fixtures.ts index 20a8676..eaac58b 100644 --- a/mobile/src/dev/screenshot-fixtures.ts +++ b/mobile/src/dev/screenshot-fixtures.ts @@ -2,6 +2,15 @@ import type { SessionSnapshot } from "../shared/types"; export type ScreenshotScene = "start" | "lobby" | "home" | "transfers" | "chat"; +export type ScreenshotNavigationState = { + index: number; + routes: Array<{ + name: string; + params?: Record; + state?: ScreenshotNavigationState; + }>; +}; + export type ScreenshotFixture = { scene: ScreenshotScene; sessionId: string; @@ -11,6 +20,11 @@ export type ScreenshotFixture = { navigationTarget: { root: "EntryLanding" | "Lobby" | "PlayerTabs"; params?: Record; + state?: ScreenshotNavigationState; + followUp?: { + name: "PlayerTabs"; + params: Record; + }; }; transferDraft?: { targetId: string; @@ -300,7 +314,14 @@ export function buildScreenshotFixture(scene: ScreenshotScene): ScreenshotFixtur session: createActiveSession(), navigationTarget: { root: "PlayerTabs", - params: { screen: "PlayerHome" }, + state: { + index: 0, + routes: [{ name: "PlayerHome" }], + }, + followUp: { + name: "PlayerTabs", + params: { screen: "PlayerHome" }, + }, }, }; } @@ -314,7 +335,14 @@ export function buildScreenshotFixture(scene: ScreenshotScene): ScreenshotFixtur session: createActiveSession(), navigationTarget: { root: "PlayerTabs", - params: { screen: "PlayerTransfers" }, + state: { + index: 0, + routes: [{ name: "PlayerTransfers" }], + }, + followUp: { + name: "PlayerTabs", + params: { screen: "PlayerTransfers" }, + }, }, transferDraft: { targetId: malikId, @@ -332,11 +360,32 @@ export function buildScreenshotFixture(scene: ScreenshotScene): ScreenshotFixtur session: createActiveSession(), navigationTarget: { root: "PlayerTabs", - params: { - screen: "PlayerChat", + state: { + index: 0, + routes: [ + { + name: "PlayerChat", + state: { + index: 1, + routes: [ + { name: "ChatList" }, + { + name: "ChatThread", + params: { chatId: "group-direct-malik" }, + }, + ], + }, + }, + ], + }, + followUp: { + name: "PlayerTabs", params: { - screen: "ChatThread", - params: { chatId: "group-direct-malik" }, + screen: "PlayerChat", + params: { + screen: "ChatThread", + params: { chatId: "group-direct-malik" }, + }, }, }, }, diff --git a/mobile/src/state/session.ts b/mobile/src/state/session.ts index 4ec6a8b..7016898 100644 --- a/mobile/src/state/session.ts +++ b/mobile/src/state/session.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { AppState, type AppStateStatus } from "react-native"; import AsyncStorage from "@react-native-async-storage/async-storage"; import type { JoinResponse, SessionPreview, SessionSnapshot } from "../shared/types"; @@ -20,6 +20,7 @@ import { } from "./connection"; const STORAGE_KEY = "negopoly:session"; +const SCREENSHOT_STORAGE_KEY = "negopoly:screenshot-scene"; type StoredSession = { sessionId: string; @@ -51,12 +52,34 @@ async function clearStoredSession() { await AsyncStorage.removeItem(STORAGE_KEY); } +async function readStoredScreenshotScene(): Promise { + try { + const raw = await AsyncStorage.getItem(SCREENSHOT_STORAGE_KEY); + return raw === "start" || + raw === "lobby" || + raw === "home" || + raw === "transfers" || + raw === "chat" + ? raw + : null; + } catch { + return null; + } +} + +async function clearStoredScreenshotScene() { + await AsyncStorage.removeItem(SCREENSHOT_STORAGE_KEY); +} + export function useSessionManager() { const [sessionId, setSessionId] = useState(""); const [sessionCode, setSessionCode] = useState(""); const [playerId, setPlayerId] = useState(""); const [session, setSession] = useState(null); const [screenshot, setScreenshot] = useState(null); + const [bootstrapReady, setBootstrapReady] = useState(false); + const [pushRegistrationReady, setPushRegistrationReady] = useState(false); + const [screenshotBootstrapPending, setScreenshotBootstrapPending] = useState(false); const [error, setError] = useState(null); const [connectionState, setConnectionState] = useState("idle"); @@ -318,25 +341,59 @@ export function useSessionManager() { screenshotRef.current = screenshot; }, [screenshot]); + useEffect(() => { + if (screenshot) { + setScreenshotBootstrapPending(false); + } + }, [screenshot]); + useEffect(() => { sessionRef.current = session; }, [session]); useEffect(() => { let mounted = true; - readStoredSession().then((stored) => { - if (!mounted || !stored || screenshotRef.current) return; - setSessionId(stored.sessionId); - setSessionCode(stored.sessionCode); - setPlayerId(stored.playerId); - }); + (async () => { + let enablePushRegistration = true; + try { + const screenshotScene = await readStoredScreenshotScene(); + if (!mounted) return; + if (screenshotScene) { + enablePushRegistration = false; + setScreenshotBootstrapPending(true); + await clearStoredScreenshotScene(); + if (!mounted) return; + activateScreenshotScene(screenshotScene); + return; + } + const stored = await readStoredSession(); + if (!mounted || !stored || screenshotRef.current) return; + setSessionId(stored.sessionId); + setSessionCode(stored.sessionCode); + setPlayerId(stored.playerId); + } finally { + if (mounted) { + setPushRegistrationReady(enablePushRegistration); + setBootstrapReady(true); + } + } + })(); return () => { mounted = false; }; }, []); useEffect(() => { - if (screenshot) return; + if ( + !bootstrapReady || + !pushRegistrationReady || + screenshot || + screenshotBootstrapPending || + !sessionId || + !playerId + ) { + return; + } let mounted = true; registerForPushNotificationsAsync().then((token) => { if (!mounted) return; @@ -345,7 +402,14 @@ export function useSessionManager() { return () => { mounted = false; }; - }, [screenshot]); + }, [ + bootstrapReady, + playerId, + pushRegistrationReady, + screenshot, + screenshotBootstrapPending, + sessionId, + ]); useEffect(() => { if (screenshot) return; @@ -423,9 +487,10 @@ export function useSessionManager() { }; }, [playerId, screenshot, sessionId]); - const activateScreenshotScene = useCallback((scene: ScreenshotScene) => { + function activateScreenshotScene(scene: ScreenshotScene) { const fixture = buildScreenshotFixture(scene); suppressReconnectRef.current = true; + setPushRegistrationReady(false); teardownConnection(); reconnectAttemptRef.current = 0; setReconnectAttempt(0); @@ -438,7 +503,7 @@ export function useSessionManager() { setSessionCode(fixture.sessionCode); setPlayerId(fixture.playerId); setSession(fixture.session); - }, []); + } async function registerPushTokenFor(targetSessionId: string, targetPlayerId: string) { if (!pushToken) return;