diff --git a/apps/native/App.tsx b/apps/native/App.tsx index 2b7ee52a3..f0e5220c4 100644 --- a/apps/native/App.tsx +++ b/apps/native/App.tsx @@ -17,7 +17,11 @@ import { toastConfig } from '@/features/student/scrap/components/Notification/To import { PointingFeedbackQueueWiring } from '@/features/student/problem/services/PointingFeedbackQueueWiring'; import { env } from '@utils'; import { initializeKakaoSDK } from '@react-native-kakao/core'; -import { navigationRef } from '@/services/navigation'; +import { + navigationRef, + handleNavigationReady, + handleNavigationStateChange, +} from '@/services/navigation'; initializeKakaoSDK(env.kakaoNativeAppKey); @@ -53,7 +57,11 @@ export default function App() { {isReady && ( - + diff --git a/apps/native/src/features/auth/login/hooks/useNativeOAuth.ts b/apps/native/src/features/auth/login/hooks/useNativeOAuth.ts index 14d5f32db..da737fb85 100644 --- a/apps/native/src/features/auth/login/hooks/useNativeOAuth.ts +++ b/apps/native/src/features/auth/login/hooks/useNativeOAuth.ts @@ -1,5 +1,5 @@ import { useState, useCallback, useEffect } from 'react'; -import { GoogleSignin } from '@react-native-google-signin/google-signin'; +import { GoogleSignin, statusCodes } from '@react-native-google-signin/google-signin'; import { login as kakaoLogin, logout as kakaoLogout } from '@react-native-kakao/user'; import * as AppleAuthentication from 'expo-apple-authentication'; import { useNavigation } from '@react-navigation/native'; @@ -25,6 +25,25 @@ type UseNativeOAuthReturn = OAuthState & { signOut: () => Promise; }; +const APPLE_CANCEL_CODE = 'ERR_REQUEST_CANCELED'; + +const createOAuthCancelError = (code: string) => { + const error = new Error('OAuth sign in cancelled'); + return Object.assign(error, { code }); +}; + +const getErrorCode = (error: unknown) => { + if (typeof error === 'object' && error !== null && 'code' in error) { + return String((error as { code: unknown }).code); + } + return null; +}; + +const isOAuthCancelError = (error: unknown) => { + const code = getErrorCode(error); + return code === APPLE_CANCEL_CODE || code === statusCodes.SIGN_IN_CANCELLED; +}; + const useNativeOAuth = (): UseNativeOAuthReturn => { const [state, setState] = useState({ loadingProvider: null, @@ -46,14 +65,17 @@ const useNativeOAuth = (): UseNativeOAuthReturn => { const getGoogleToken = async (): Promise => { await GoogleSignin.hasPlayServices(); - await GoogleSignin.signIn(); - const tokens = await GoogleSignin.getTokens(); - if (!tokens.idToken) { + const result = await GoogleSignin.signIn(); + if (result.type === 'cancelled') { + throw createOAuthCancelError(statusCodes.SIGN_IN_CANCELLED); + } + + if (!result.data.idToken) { throw new Error('Google ID token not found'); } - return tokens.idToken; + return result.data.idToken; }; const getKakaoToken = async (): Promise => { @@ -170,13 +192,8 @@ const useNativeOAuth = (): UseNativeOAuthReturn => { } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; - // Apple 로그인 취소는 에러로 처리하지 않음 - if ( - typeof error === 'object' && - error !== null && - 'code' in error && - (error as { code: string }).code === 'ERR_REQUEST_CANCELED' - ) { + // OAuth 취소는 에러로 처리하지 않음. + if (isOAuthCancelError(error)) { setState({ loadingProvider: null, error: null }); return; } diff --git a/apps/native/src/features/auth/login/screens/LoginScreen.tsx b/apps/native/src/features/auth/login/screens/LoginScreen.tsx index 937b3ea35..04afc1cd2 100644 --- a/apps/native/src/features/auth/login/screens/LoginScreen.tsx +++ b/apps/native/src/features/auth/login/screens/LoginScreen.tsx @@ -18,8 +18,9 @@ const LoginScreen = () => { const navigation = useNavigation(); const { isLoading, loadingProvider, error, signInWithProvider } = useNativeOAuth(); - const handleSocialButtonPress = async (provider: OAuthProvider) => { - await signInWithProvider(provider); + const handleSocialButtonPress = (provider: OAuthProvider) => { + // useNativeOAuth 의 error state 로 이미 사용자에게 노출되므로 여기서는 void. + void signInWithProvider(provider); }; const handleEmailButtonPress = () => { diff --git a/apps/native/src/features/student/problem/screens/PointingScreen.tsx b/apps/native/src/features/student/problem/screens/PointingScreen.tsx index 5613773ad..c5c23cb5e 100644 --- a/apps/native/src/features/student/problem/screens/PointingScreen.tsx +++ b/apps/native/src/features/student/problem/screens/PointingScreen.tsx @@ -49,8 +49,6 @@ const PointingScreen = ({ const pointings = useProblemSessionStore(useShallow(selectPointingsForPointing)); - if (pointings.length === 0) console.warn('[PointingScreen] empty pointings array'); - const { advanceMessage, advanceButtonLabel } = useMemo(() => { if (phase === 'MAIN_POINTINGS') { return { diff --git a/apps/native/src/features/student/problem/utils/serializeJSONToHTML.ts b/apps/native/src/features/student/problem/utils/serializeJSONToHTML.ts index e471a2031..18935fd7f 100644 --- a/apps/native/src/features/student/problem/utils/serializeJSONToHTML.ts +++ b/apps/native/src/features/student/problem/utils/serializeJSONToHTML.ts @@ -267,8 +267,16 @@ function serializeNode(node: JSONNode): string { export function serializeJSONToHTML(doc: JSONNode | string): string { let json: JSONNode; - if (typeof doc === 'string') json = JSON.parse(doc); - else json = doc; + if (typeof doc === 'string') { + try { + json = JSON.parse(doc); + } catch (e) { + const detail = e instanceof Error ? e.message : String(e); + throw new Error(`serializeJSONToHTML: invalid JSON input — ${detail}`); + } + } else { + json = doc; + } if (json.type !== 'doc') { throw new Error('root node must be type=doc'); diff --git a/apps/native/src/hooks/index.ts b/apps/native/src/hooks/index.ts index c4b7dc85d..c26164334 100644 --- a/apps/native/src/hooks/index.ts +++ b/apps/native/src/hooks/index.ts @@ -3,5 +3,4 @@ export { default as useDeepLinkHandler, handleDeepLink } from './useDeepLinkHand export { default as useFcmToken } from './useFcmToken'; export { default as useInvalidateStudyData } from './useInvalidateStudyData'; export { default as useLoadAssets } from './useLoadAssets'; -export { default as useSocialLoginCallback } from './useSocialLoginCallback'; export { default as useInvalidateAll } from './useInvalidateAll'; diff --git a/apps/native/src/hooks/useDeepLinkHandler.ts b/apps/native/src/hooks/useDeepLinkHandler.ts index 8228cdc5c..29d53ac07 100644 --- a/apps/native/src/hooks/useDeepLinkHandler.ts +++ b/apps/native/src/hooks/useDeepLinkHandler.ts @@ -1,10 +1,10 @@ -import { useEffect, useRef, useCallback } from 'react'; +import { useEffect, useRef } from 'react'; import { Platform, Alert, Dimensions } from 'react-native'; import messaging, { type FirebaseMessagingTypes } from '@react-native-firebase/messaging'; import * as Notifications from 'expo-notifications'; import { CommonActions } from '@react-navigation/native'; -import { navigationRef, isNavigationReady } from '@/services/navigation'; +import { navigationRef, waitForRouteRegistered } from '@/services/navigation'; import { parseDeepLinkUrl, isValidDeepLink } from '@/utils/deepLink'; import { getPublishDetailById } from '@/apis/controller/student/study'; import { useProblemSessionStore, getInitialScreenForPhase } from '@/stores'; @@ -32,18 +32,16 @@ const handleDeepLink = async (url: string | undefined | null) => { return false; } - // 네비게이션이 준비될 때까지 대기 (최대 3초) - const waitForNavigation = async (timeout = 3000): Promise => { - const startTime = Date.now(); - while (!isNavigationReady() && Date.now() - startTime < timeout) { - await new Promise((resolve) => setTimeout(resolve, 100)); - } - return isNavigationReady(); - }; - - const isReady = await waitForNavigation(); - if (!isReady) { - console.warn('[DeepLink] Navigation not ready, cannot handle deep link'); + // RootNavigator 가 sessionStatus 에 따라 단일 root screen 만 등록하므로, + // 콜드스타트 hydrating 단계에는 Splash 만 등록되어 'StudentApp' 으로의 navigate 가 + // silent no-op 이 된다. waitForRouteRegistered 는 navigation ready 와 auth hydration + // 후 StudentApp 등록을 단일 30s budget 으로 기다린다. (qna/publish 둘 다 StudentApp + // 안에서 처리되므로 동일 route 를 게이트로 사용.) + const studentAppReady = await waitForRouteRegistered('StudentApp'); + if (!studentAppReady) { + console.warn( + '[DeepLink] StudentApp route 미등록 — 알림이 unauthenticated 상태에 도착했을 가능성' + ); return false; } diff --git a/apps/native/src/hooks/useFcmToken.ts b/apps/native/src/hooks/useFcmToken.ts index e8507711b..80ddbafb5 100644 --- a/apps/native/src/hooks/useFcmToken.ts +++ b/apps/native/src/hooks/useFcmToken.ts @@ -25,8 +25,6 @@ Notifications.setNotificationHandler({ */ const useFcmToken = () => { const hasRegistered = useRef(false); - const notificationListener = useRef(null); - const responseListener = useRef(null); useEffect(() => { // 웹에서는 FCM을 사용하지 않음 @@ -55,7 +53,7 @@ const useFcmToken = () => { // 2. APNs 토큰 확인 (iOS 필수: 이게 없으면 FCM 토큰이 있어도 동작 안 함) if (Platform.OS === 'ios') { const apnsToken = await messaging().getAPNSToken(); - console.log('[FCM] APNs Token:', apnsToken); + if (__DEV__) console.log('[FCM] APNs Token:', apnsToken); if (!apnsToken) { console.error('[FCM] APNs Token is missing! Swizzling might be failed.'); // 여기서 APNs 토큰이 없다면 iOS 설정 문제(Capabilities 등)일 가능성이 큼 @@ -64,12 +62,12 @@ const useFcmToken = () => { // 3. FCM 토큰 가져오기 const token = await messaging().getToken(); - console.log('[FCM] Device FCM Token:', token); + if (__DEV__) console.log('[FCM] Device FCM Token:', token); if (token && !hasRegistered.current) { await postPushToken(token); hasRegistered.current = true; - console.log('[FCM] Token registered to server'); + if (__DEV__) console.log('[FCM] Token registered to server'); } } catch (error) { console.error('[FCM] Registration failed:', error); @@ -80,7 +78,9 @@ const useFcmToken = () => { // 4. 포그라운드 메시지 수신 (앱이 켜져 있을 때 로그 확인용) const unsubscribe = messaging().onMessage(async (remoteMessage) => { - console.log('[FCM] A new FCM message arrived!', JSON.stringify(remoteMessage)); + if (__DEV__) { + console.log('[FCM] A new FCM message arrived!', JSON.stringify(remoteMessage)); + } // 앱이 켜져 있을 때도 상단 알림을 띄우고 싶다면 expo-notifications 사용 await Notifications.scheduleNotificationAsync({ diff --git a/apps/native/src/hooks/useSocialLoginCallback.ts b/apps/native/src/hooks/useSocialLoginCallback.ts deleted file mode 100644 index 733d77b33..000000000 --- a/apps/native/src/hooks/useSocialLoginCallback.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { useEffect } from 'react'; -import * as Linking from 'expo-linking'; -import { Platform } from 'react-native'; - -import { setAccessToken, setRefreshToken } from '@utils'; -import { useAuthStore } from '@stores'; -import { useOnboardingStore } from '@features/student/onboarding/store/useOnboardingStore'; - -const shouldStartOnboarding = (flag?: string | string[] | null): boolean => { - if (flag === undefined || flag === null) { - return false; - } - - if (Array.isArray(flag)) { - return flag.some((value) => shouldStartOnboarding(value)); - } - - return flag.toLowerCase() === 'true'; -}; - -const useSocialLoginCallback = () => { - const { setSessionStatus, setRole } = useAuthStore(); - const startOnboarding = useOnboardingStore((state) => state.start); - const completeOnboarding = useOnboardingStore((state) => state.complete); - - useEffect(() => { - const handleUrl = (event: { url: string }) => { - const { url } = event; - - const parsed = Linking.parse(url); - - const isExpoGoScheme = parsed.scheme === 'exp' && parsed.path?.includes('auth/callback'); - const isPointerScheme = parsed.scheme === 'pointer' && parsed.path?.includes('auth/callback'); - const isWebPath = Platform.OS === 'web' && parsed.path?.includes('/auth/callback'); - - if (!isExpoGoScheme && !isPointerScheme && !isWebPath) return; - - const { success, isFirstLogin, accessToken, refreshToken } = parsed.queryParams ?? {}; - - if (!success || !accessToken) { - setSessionStatus('unauthenticated'); - return; - } - - setAccessToken(String(accessToken)); - if (refreshToken) setRefreshToken(String(refreshToken)); - - setRole('student'); - setSessionStatus('authenticated'); - - // isFirstLogin인 경우에만 온보딩, 아니면 바로 메인 홈으로 - if (shouldStartOnboarding(isFirstLogin)) { - startOnboarding(); - } else { - completeOnboarding(); - } - }; - - const sub = Linking.addEventListener('url', handleUrl); - - Linking.getInitialURL().then((url) => { - if (url) handleUrl({ url }); - }); - - return () => { - sub.remove(); - }; - }, [setSessionStatus, setRole, startOnboarding, completeOnboarding]); -}; - -export default useSocialLoginCallback; diff --git a/apps/native/src/navigation/RootNavigator.tsx b/apps/native/src/navigation/RootNavigator.tsx index cbca1bfbb..4cce3f567 100644 --- a/apps/native/src/navigation/RootNavigator.tsx +++ b/apps/native/src/navigation/RootNavigator.tsx @@ -7,7 +7,6 @@ import StudentNavigator from '@navigation/student/StudentNavigator'; import AuthNavigator from '@navigation/auth/AuthNavigator'; import { useAuthStore } from '@stores'; import { LoadingScreen } from '@components/common'; -import { useSocialLoginCallback } from '@hooks'; import { useSignupStore } from '@features/auth/signup/store/useSignupStore'; import { useOnboardingStore } from '@features/student/onboarding/store/useOnboardingStore'; @@ -25,8 +24,6 @@ const RootNavigator = () => { const step1Completed = useSignupStore((s) => s.step1Completed); const onboardingStatus = useOnboardingStore((s) => s.status); - useSocialLoginCallback(); - const getActiveScreen = () => { if ( sessionStatus === 'unknown' || diff --git a/apps/native/src/services/navigation/index.ts b/apps/native/src/services/navigation/index.ts index 438d157d3..9396495b4 100644 --- a/apps/native/src/services/navigation/index.ts +++ b/apps/native/src/services/navigation/index.ts @@ -1 +1,8 @@ -export { navigationRef, isNavigationReady } from './navigationRef'; +export { + navigationRef, + isNavigationReady, + waitForNavigationReady, + waitForRouteRegistered, + handleNavigationReady, + handleNavigationStateChange, +} from './navigationRef'; diff --git a/apps/native/src/services/navigation/navigationRef.ts b/apps/native/src/services/navigation/navigationRef.ts index 597b4dfd1..b661c4b3c 100644 --- a/apps/native/src/services/navigation/navigationRef.ts +++ b/apps/native/src/services/navigation/navigationRef.ts @@ -12,3 +12,110 @@ export const navigationRef = createNavigationContainerRef(); * 네비게이션이 준비되었는지 확인 */ export const isNavigationReady = () => navigationRef.isReady(); + +const readySubscribers = new Set<() => void>(); +const stateSubscribers = new Set<() => void>(); + +/** + * NavigationContainer 의 onReady prop 에 연결한다 (App.tsx). + * navigationRef 가 attach 된 시점에 호출되어 대기 중인 subscriber 들을 깨운다. + * + * snapshot 후 fan-out — 각 handler 가 finish() 안에서 자기 자신을 delete 하므로 + * 반복 중 mutation 을 피하면서도 leak 없이 정리된다. clear() 를 하지 않는 이유는 + * Fast Refresh / container 재mount 시 onReady 가 다시 발화될 수 있어, 그 사이 + * 등록된 subscriber 가 손실되지 않도록 함이다. + */ +export const handleNavigationReady = () => { + [...readySubscribers].forEach((cb) => cb()); +}; + +/** + * NavigationContainer 의 onStateChange prop 에 연결한다 (App.tsx). + * Root state 가 변할 때마다 stateSubscribers 를 깨워 콜드스타트 직후 root route + * (예: Splash → StudentApp) 가 교체되는 것을 감지한다. + */ +export const handleNavigationStateChange = () => { + [...stateSubscribers].forEach((cb) => cb()); +}; + +/** + * navigationRef 가 준비될 때까지 이벤트 기반으로 대기. + * + * - 이미 ready 상태면 즉시 resolve(true). + * - 그렇지 않으면 handleNavigationReady 가 호출될 때까지 module-level subscriber + * 에 등록 (busy-wait polling 제거). + * - timeoutMs 내에 ready 가 안 되면 resolve(false). 기본 30s — 저사양 기기의 + * 콜드 스타트 (>3s) 에서도 딥링크가 손실되지 않도록 여유 확보. + * + * NavigationContainer 가 mount 되기 전에 navigationRef.addListener 를 직접 + * 호출하면 throw 하므로, App 레벨 onReady prop 을 단일 fan-out 지점으로 사용. + */ +export const waitForNavigationReady = (timeoutMs = 30_000): Promise => { + if (navigationRef.isReady()) return Promise.resolve(true); + + return new Promise((resolve) => { + let settled = false; + let timer: ReturnType | undefined; + + const finish = (ready: boolean) => { + if (settled) return; + settled = true; + if (timer) clearTimeout(timer); + readySubscribers.delete(handler); + resolve(ready); + }; + + const handler = () => finish(navigationRef.isReady()); + + readySubscribers.add(handler); + timer = setTimeout(() => finish(false), timeoutMs); + }); +}; + +const isRouteRegistered = (routeName: keyof RootStackParamList): boolean => { + if (!navigationRef.isReady()) return false; + const state = navigationRef.getRootState(); + return state?.routes?.some((r) => r.name === routeName) ?? false; +}; + +/** + * 특정 root route 가 RootNavigator 에 등록될 때까지 이벤트 기반으로 대기. + * + * RootNavigator 가 sessionStatus 에 따라 단일 root screen 만 등록하는 구조 (Splash | + * Auth | StudentApp) 라, navigation ready 만 기다려서는 콜드스타트 직후 알림 딥링크가 + * 유실될 수 있다. 예) sessionStatus === 'hydrating' → Splash 만 등록 → StudentApp + * 으로 navigate 하려 해도 silent no-op. 이 helper 는 onReady + onStateChange 를 모두 + * 구독해, 원하는 route 가 등록될 때까지 기다린다. + * + * - 이미 등록되어 있으면 즉시 resolve(true). + * - timeoutMs 내에 등록 안 되면 resolve(false). (예: 사용자가 unauthenticated 인 + * 채로 알림을 받은 비정상 시나리오 — 이 경우 deep link 는 자연스럽게 폐기됨.) + */ +export const waitForRouteRegistered = ( + routeName: keyof RootStackParamList, + timeoutMs = 30_000 +): Promise => { + if (isRouteRegistered(routeName)) return Promise.resolve(true); + + return new Promise((resolve) => { + let settled = false; + let timer: ReturnType | undefined; + + const finish = (ok: boolean) => { + if (settled) return; + settled = true; + if (timer) clearTimeout(timer); + readySubscribers.delete(handler); + stateSubscribers.delete(handler); + resolve(ok); + }; + + const handler = () => { + if (isRouteRegistered(routeName)) finish(true); + }; + + readySubscribers.add(handler); + stateSubscribers.add(handler); + timer = setTimeout(() => finish(false), timeoutMs); + }); +}; diff --git a/packages/pointer-content-renderer/src/native/ContentWebView.tsx b/packages/pointer-content-renderer/src/native/ContentWebView.tsx index 473335ffe..7d4432b4c 100644 --- a/packages/pointer-content-renderer/src/native/ContentWebView.tsx +++ b/packages/pointer-content-renderer/src/native/ContentWebView.tsx @@ -1,19 +1,57 @@ import { forwardRef, useImperativeHandle, useState } from 'react'; import { View, ActivityIndicator } from 'react-native'; import WebView from 'react-native-webview'; -import type { WebViewSource } from 'react-native-webview/lib/WebViewTypes'; +import type { WebViewSource, ShouldStartLoadRequest } from 'react-native-webview/lib/WebViewTypes'; import type { ViewStyle, StyleProp, ImageRequireSource } from 'react-native'; import type { RNToWebViewMessage, UserAnswer, ContentMode } from '../types'; import { useContentBridge, type AnswerEventPayload } from './useContentBridge'; +// RN WebView 의 originWhitelist 미매칭 시 동작은 "차단" 이 아니라 외부 앱 +// (Linking) 으로의 fallback 이다. 따라서 originWhitelist 는 통과 layer 로 두고, +// 실제 navigation 정책은 onShouldStartLoadWithRequest 에서 deny-by-default 로 +// 강제한다. (CSS/JS subresource 는 originWhitelist 적용 대상이 아니므로 +// jsdelivr KaTeX CDN 은 두 layer 어느 쪽에서도 막히지 않고 정상 로드됨.) +const WEBVIEW_ORIGIN_WHITELIST = ['*']; + +// dev 빌드의 Metro bundler 자산 (`http://:8081/assets/...`) +// 만 좁게 허용. 외부 redirect 는 dev 에서도 차단된다. +const isMetroAssetUrl = (url: string): boolean => { + if (!__DEV__) return false; + try { + const parsed = new URL(url); + return ( + (parsed.protocol === 'http:' || parsed.protocol === 'https:') && + parsed.port === '8081' && + parsed.pathname.startsWith('/assets/') + ); + } catch { + return false; + } +}; + +const shouldAllowRequest = (request: ShouldStartLoadRequest): boolean => { + const { url } = request; + + if (url.startsWith('file://')) return true; + if (url.startsWith('about:blank')) return true; + if (isMetroAssetUrl(url)) return true; + + if (__DEV__) console.warn('[ContentWebView] blocked navigation:', url); + return false; +}; + /** - * Accepts: - * - `number` (Metro asset id from `require('...')`) — runtime WebView resolves via resolveAssetSource - * - `{ uri: string }` / `{ html: string }` — standard WebView sources + * 번들된 정적 HTML 자산 또는 inline HTML 만 지원한다. + * 외부 URL source ({ uri }) 은 의도적으로 받지 않는다. `originWhitelist` 는 + * 외부 앱 fallback 방지를 위한 통과 layer 이며, 실제 navigation 차단은 + * `shouldAllowRequest` 에서 deny-by-default 로 강제한다. + * + * - `number` (Metro asset id from `require('@assets/webview/content.html')`) + * - `{ html: string }` — 테스트/스토리북용 inline HTML */ -export type ContentWebViewHtmlSource = WebViewSource | ImageRequireSource; +export type ContentWebViewHtmlSource = ImageRequireSource | { html: string }; /** * Arguments for {@link ContentWebViewHandle.sendBookmarkResult}. @@ -109,7 +147,10 @@ export const ContentWebView = forwardRef {isLoading && ( - - - + + +