Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
5cf580d
fix(native): FCM 토큰/페이로드 console.log __DEV__ 가드
sterdsterd May 3, 2026
085c804
fix(pointer-content-renderer): ContentWebView origin/navigation 차단
sterdsterd May 3, 2026
a710f07
chore(pointer-content-renderer): KaTeX/kopubbatang CDN SRI hash 추가
sterdsterd May 3, 2026
b28b356
fix(native): useSocialLoginCallback OAuth URL scheme 핸들러 제거
sterdsterd May 3, 2026
84dbe95
fix(native): useDeepLinkHandler 이벤트 기반 대기로 전환
sterdsterd May 3, 2026
fc57af2
fix(native): useFcmToken 미사용 listener ref 제거
sterdsterd May 3, 2026
54e7a62
fix(native): LoginScreen 소셜 로그인 핸들러 unhandled rejection 차단
sterdsterd May 3, 2026
cf8bea0
fix(native): serializeJSONToHTML JSON.parse 페일세이프
sterdsterd May 3, 2026
eae6783
fix(native): PointingScreen render body 사이드이펙트 제거
sterdsterd May 3, 2026
67f5085
chore(native): useDeepLinkHandler 미사용 useCallback import 제거
sterdsterd May 3, 2026
e5fe03a
fix(native): handleNavigationReady subscriber clear 제거
sterdsterd May 3, 2026
0b7155e
style(pointer-content-renderer): ContentWebView import 한 줄로 정리
sterdsterd May 3, 2026
ec1c1e4
fix(pointer-content-renderer): dev 모드에서 Metro http 자산 허용
sterdsterd May 3, 2026
963d91a
fix(pointer-content-renderer): ContentWebView source 타입 좁힘 + data: 차단
sterdsterd May 3, 2026
57ce344
refactor(pointer-content-renderer): WebView navigation policy 를 hook …
sterdsterd May 3, 2026
b558ed0
fix(native): 콜드스타트 딥링크 유실 — auth hydration 후 route 등록까지 대기
sterdsterd May 3, 2026
a5e3033
style(native): useDeepLinkHandler console.warn 줄바꿈
sterdsterd May 3, 2026
0e4bf71
fix(native): address PR review cleanup
sterdsterd May 4, 2026
da16657
fix(native): avoid duplicate deep link ready wait
sterdsterd May 4, 2026
510810d
fix(native): handle Google sign-in cancellation
sterdsterd May 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions apps/native/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -53,7 +57,11 @@ export default function App() {
<GestureHandlerRootView style={{ flex: 1 }}>
<SafeAreaProvider>
{isReady && (
<NavigationContainer ref={navigationRef} theme={navigationTheme}>
<NavigationContainer
ref={navigationRef}
theme={navigationTheme}
onReady={handleNavigationReady}
onStateChange={handleNavigationStateChange}>
<StatusBar style='dark' />
<RootNavigator />
<Toast config={toastConfig} />
Expand Down
41 changes: 29 additions & 12 deletions apps/native/src/features/auth/login/hooks/useNativeOAuth.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -25,6 +25,25 @@ type UseNativeOAuthReturn = OAuthState & {
signOut: () => Promise<void>;
};

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<OAuthState>({
loadingProvider: null,
Expand All @@ -46,14 +65,17 @@ const useNativeOAuth = (): UseNativeOAuthReturn => {

const getGoogleToken = async (): Promise<string> => {
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<string> => {
Expand Down Expand Up @@ -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;
}
Expand Down
5 changes: 3 additions & 2 deletions apps/native/src/features/auth/login/screens/LoginScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@ const LoginScreen = () => {
const navigation = useNavigation<NavigationProp>();
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 = () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
1 change: 0 additions & 1 deletion apps/native/src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
26 changes: 12 additions & 14 deletions apps/native/src/hooks/useDeepLinkHandler.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -32,18 +32,16 @@ const handleDeepLink = async (url: string | undefined | null) => {
return false;
}

// 네비게이션이 준비될 때까지 대기 (최대 3초)
const waitForNavigation = async (timeout = 3000): Promise<boolean> => {
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 상태에 도착했을 가능성'
);
Comment on lines +40 to +44
return false;
}

Expand Down
12 changes: 6 additions & 6 deletions apps/native/src/hooks/useFcmToken.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,6 @@ Notifications.setNotificationHandler({
*/
const useFcmToken = () => {
const hasRegistered = useRef(false);
const notificationListener = useRef<Notifications.EventSubscription | null>(null);
const responseListener = useRef<Notifications.EventSubscription | null>(null);

useEffect(() => {
// 웹에서는 FCM을 사용하지 않음
Expand Down Expand Up @@ -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 등)일 가능성이 큼
Expand All @@ -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);
Expand All @@ -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({
Expand Down
71 changes: 0 additions & 71 deletions apps/native/src/hooks/useSocialLoginCallback.ts

This file was deleted.

3 changes: 0 additions & 3 deletions apps/native/src/navigation/RootNavigator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -25,8 +24,6 @@ const RootNavigator = () => {
const step1Completed = useSignupStore((s) => s.step1Completed);
const onboardingStatus = useOnboardingStore((s) => s.status);

useSocialLoginCallback();

const getActiveScreen = () => {
if (
sessionStatus === 'unknown' ||
Expand Down
9 changes: 8 additions & 1 deletion apps/native/src/services/navigation/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,8 @@
export { navigationRef, isNavigationReady } from './navigationRef';
export {
navigationRef,
isNavigationReady,
waitForNavigationReady,
waitForRouteRegistered,
handleNavigationReady,
handleNavigationStateChange,
} from './navigationRef';
Loading
Loading