-
Notifications
You must be signed in to change notification settings - Fork 0
release 1.5.2 #22
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
release 1.5.2 #22
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,8 +3,8 @@ import { Stack } from 'expo-router'; | |
| import * as SplashScreen from 'expo-splash-screen'; | ||
| import { StatusBar } from 'expo-status-bar'; | ||
| import { getTrackingPermissionsAsync, requestTrackingPermissionsAsync } from 'expo-tracking-transparency'; | ||
| import { useCallback, useEffect, useState } from 'react'; | ||
| import { Platform } from 'react-native'; | ||
| import { useCallback, useEffect, useRef, useState } from 'react'; | ||
| import { InteractionManager, Platform } from 'react-native'; | ||
| import 'react-native-get-random-values'; | ||
| import 'react-native-reanimated'; | ||
| import { SafeAreaProvider } from 'react-native-safe-area-context'; | ||
|
|
@@ -15,8 +15,11 @@ import { ForceUpdateDialog } from '@/components/force-update-dialog'; | |
| import { MixpanelProvider } from '@/contexts/mixpanel-context'; | ||
| import { SubscribedClubsProvider } from '@/contexts/subscribed-clubs-context'; | ||
| import { useFcm } from '@/hooks/use-fcm'; | ||
| import { runAppBootstrap } from '@/services/app-bootstrap.service'; | ||
| import { checkForceUpdateRequired } from '@/services/force-update.service'; | ||
| import { BootstrapResult, runAppBootstrap } from '@/services/app-bootstrap.service'; | ||
| import { | ||
| getCachedForceUpdateRequired, | ||
| refreshForceUpdateRequired, | ||
| } from '@/services/force-update.service'; | ||
|
|
||
| // 네이티브 스플래시 화면을 자동으로 숨기지 않도록 설정 | ||
| // 이것은 앱이 로드되자마자 실행되어야 합니다 | ||
|
|
@@ -30,84 +33,153 @@ export const unstable_settings = { | |
|
|
||
| type BootstrapStatus = 'idle' | 'running' | 'success' | 'failed'; | ||
|
|
||
| const EMPTY_SUBSCRIBED_CLUB_IDS: string[] = []; | ||
|
|
||
| export default function RootLayout() { | ||
| const [appIsReady, setAppIsReady] = useState(false); | ||
| const [showSplash, setShowSplash] = useState(true); | ||
| const [forceUpdateRequired, setForceUpdateRequired] = useState(false); | ||
| const [forceUpdateChecked, setForceUpdateChecked] = useState(false); | ||
| const [bootstrapStatus, setBootstrapStatus] = useState<BootstrapStatus>('idle'); | ||
| const [bootstrapErrorMessage, setBootstrapErrorMessage] = useState<string | undefined>(undefined); | ||
| const [subscribedClubsRefreshKey, setSubscribedClubsRefreshKey] = useState(0); | ||
| const [bootstrapResult, setBootstrapResult] = useState<BootstrapResult | null>(null); | ||
| const bootstrapStatusRef = useRef<BootstrapStatus>('idle'); | ||
| const nativeSplashHiddenRef = useRef(false); | ||
|
|
||
| const bootstrapSucceeded = bootstrapStatus === 'success'; | ||
| const shouldBlockSplash = forceUpdateRequired || !bootstrapSucceeded; | ||
|
|
||
| // 강제 업데이트가 필요한 경우(또는 체크 전)에는 FCM 권한 프롬프트/핸들러 설정이 뜨지 않도록 비활성화 | ||
| useFcm(forceUpdateChecked && !forceUpdateRequired && bootstrapSucceeded); | ||
|
|
||
| useEffect(() => { | ||
| bootstrapStatusRef.current = bootstrapStatus; | ||
| }, [bootstrapStatus]); | ||
|
|
||
| const runBootstrapSequence = useCallback(async () => { | ||
| if (bootstrapStatusRef.current === 'running') { | ||
| return; | ||
| } | ||
|
|
||
| bootstrapStatusRef.current = 'running'; | ||
| setBootstrapStatus('running'); | ||
| setBootstrapErrorMessage(undefined); | ||
|
|
||
| const { subscribedClubCount } = await runAppBootstrap(); | ||
| setSubscribedClubsRefreshKey((prev) => prev + 1); | ||
| const result = await runAppBootstrap(); | ||
| setBootstrapResult(result); | ||
| bootstrapStatusRef.current = 'success'; | ||
| setBootstrapStatus('success'); | ||
| console.log('✅ 부트스트랩 완료 - 구독 동아리 수:', subscribedClubCount); | ||
| console.log('✅ 부트스트랩 완료', { | ||
| subscribedClubCount: result.subscribedClubIds.length, | ||
| timings: result.timings, | ||
| }); | ||
| }, []); | ||
|
|
||
| const handleBootstrapFailure = useCallback((error: unknown) => { | ||
| console.warn('❌ 앱 초기화 중 오류:', error); | ||
| bootstrapStatusRef.current = 'failed'; | ||
| setBootstrapStatus('failed'); | ||
| setBootstrapErrorMessage(getUserFriendlyBootstrapMessage(error)); | ||
| }, []); | ||
|
|
||
| const refreshForceUpdateInBackground = useCallback(() => { | ||
| refreshForceUpdateRequired() | ||
| .then((required) => { | ||
| setForceUpdateRequired(required); | ||
|
|
||
| if (!required && bootstrapStatusRef.current === 'idle') { | ||
| runBootstrapSequence().catch(handleBootstrapFailure); | ||
| } | ||
| }) | ||
| .catch((error) => { | ||
| console.warn('⚠️ 강제 업데이트 백그라운드 갱신 실패:', error); | ||
| }); | ||
| }, [handleBootstrapFailure, runBootstrapSequence]); | ||
|
|
||
| useEffect(() => { | ||
| let cancelled = false; | ||
|
|
||
| async function prepare() { | ||
| try { | ||
| console.log('📱 앱 초기화 시작...'); | ||
|
|
||
| // 1) 강제 업데이트 체크 (Remote Config) | ||
| const required = await checkForceUpdateRequired(); | ||
| // 1) 강제 업데이트 캐시 판정 (Remote Config fetch는 백그라운드에서 갱신) | ||
| const required = await getCachedForceUpdateRequired(); | ||
| if (cancelled) { | ||
| return; | ||
| } | ||
|
|
||
| setForceUpdateRequired(required); | ||
| setForceUpdateChecked(true); | ||
| refreshForceUpdateInBackground(); | ||
|
|
||
| if (required) { | ||
| console.log('⛔️ 강제 업데이트 필요: 부트스트랩 중단'); | ||
| console.log('⛔️ 캐시 기준 강제 업데이트 필요: 부트스트랩 대기'); | ||
| return; | ||
| } | ||
|
|
||
| // 2) 강제 업데이트가 아닐 때만 ATT 요청 | ||
| await requestTrackingPermissionOnLaunch(); | ||
|
|
||
| // 3) Access Token -> FCM -> 구독 목록 -> Mixpanel 순서 부트스트랩 | ||
| // 2) Access Token -> FCM/구독 목록/Mixpanel 병렬 부트스트랩 | ||
| await runBootstrapSequence(); | ||
|
|
||
| // 최소 로딩 시간 보장 (너무 빨리 사라지지 않도록) | ||
| await new Promise(resolve => setTimeout(resolve, 500)); | ||
| console.log('✅ 앱 초기화 완료'); | ||
| } catch (e) { | ||
| console.warn('❌ 앱 초기화 중 오류:', e); | ||
| setBootstrapStatus('failed'); | ||
| setBootstrapErrorMessage(getUserFriendlyBootstrapMessage(e)); | ||
| } finally { | ||
| // 앱 준비 완료 | ||
| setAppIsReady(true); | ||
| if (!cancelled) { | ||
| handleBootstrapFailure(e); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| prepare(); | ||
| }, [runBootstrapSequence]); | ||
|
|
||
| // 앱이 준비되면 네이티브 스플래시를 숨기고 커스텀 스플래시 시작 | ||
| return () => { | ||
| cancelled = true; | ||
| }; | ||
| }, [handleBootstrapFailure, refreshForceUpdateInBackground, runBootstrapSequence]); | ||
|
|
||
| // 커스텀 스플래시가 렌더된 다음 네이티브 스플래시를 바로 숨긴다. | ||
| useEffect(() => { | ||
| if (appIsReady) { | ||
| console.log('🎨 앱 준비 완료, 네이티브 스플래시 숨기고 커스텀 스플래시 표시'); | ||
| // 약간의 지연을 두어 커스텀 스플래시가 먼저 렌더링되도록 함 | ||
| setTimeout(() => { | ||
| SplashScreen.hideAsync().catch(() => { | ||
| console.warn('⚠️ 네이티브 스플래시가 이미 숨겨짐'); | ||
| }); | ||
| }, 100); | ||
| if (!showSplash || nativeSplashHiddenRef.current) { | ||
| return; | ||
| } | ||
| }, [appIsReady]); | ||
|
|
||
| const animationFrame = requestAnimationFrame(() => { | ||
| nativeSplashHiddenRef.current = true; | ||
| console.log('🎨 커스텀 스플래시 렌더 완료, 네이티브 스플래시 숨김', { | ||
| nativeSplashHidden: Date.now(), | ||
| }); | ||
| SplashScreen.hideAsync().catch(() => { | ||
| console.warn('⚠️ 네이티브 스플래시가 이미 숨겨짐'); | ||
| }); | ||
| }); | ||
|
|
||
| return () => { | ||
| cancelAnimationFrame(animationFrame); | ||
| }; | ||
| }, [showSplash]); | ||
|
|
||
| useEffect(() => { | ||
| if (!bootstrapSucceeded || forceUpdateRequired || showSplash || Platform.OS !== 'ios') { | ||
| return; | ||
| } | ||
|
|
||
| let timeoutId: ReturnType<typeof setTimeout> | undefined; | ||
| const interactionTask = InteractionManager.runAfterInteractions(() => { | ||
| timeoutId = setTimeout(() => { | ||
| requestTrackingPermissionAfterLaunch(); | ||
| }, 250); | ||
| }); | ||
|
|
||
| return () => { | ||
| interactionTask.cancel(); | ||
| if (timeoutId) { | ||
| clearTimeout(timeoutId); | ||
| } | ||
| }; | ||
| }, [bootstrapSucceeded, forceUpdateRequired, showSplash]); | ||
|
Comment on lines
+158
to
+176
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ATT 요청이 부트스트랩 완료 뒤로 밀려 순서 계약을 깨고 있습니다. Line 159의 가드 때문에 ATT는 As per coding guidelines, 🤖 Prompt for AI Agents |
||
|
|
||
| const onFinishSplash = useCallback(() => { | ||
| // 커스텀 스플래시 애니메이션이 완료되면 | ||
| console.log('🎭 커스텀 스플래시 종료, 메인 화면으로 전환'); | ||
| console.log('🎭 커스텀 스플래시 종료, 메인 화면으로 전환', { | ||
| customSplashDismissed: Date.now(), | ||
| }); | ||
| if (shouldBlockSplash) { | ||
| console.log('⛔️ 스플래시 유지:', { forceUpdateRequired, bootstrapStatus }); | ||
| return; | ||
|
|
@@ -124,24 +196,28 @@ export default function RootLayout() { | |
| await runBootstrapSequence(); | ||
| } catch (error) { | ||
| console.warn('❌ 부트스트랩 재시도 실패:', error); | ||
| setBootstrapStatus('failed'); | ||
| setBootstrapErrorMessage(getUserFriendlyBootstrapMessage(error)); | ||
| handleBootstrapFailure(error); | ||
| } | ||
| }, [bootstrapStatus, runBootstrapSequence]); | ||
| }, [bootstrapStatus, handleBootstrapFailure, runBootstrapSequence]); | ||
|
|
||
| if (__DEV__) { | ||
| console.log('🔄 RootLayout 렌더링', { | ||
| showSplash, | ||
| appIsReady, | ||
| bootstrapStatus, | ||
| forceUpdateRequired, | ||
| }); | ||
| } | ||
|
|
||
| const initialSubscribedClubIds = | ||
| bootstrapResult?.subscribedClubIds ?? EMPTY_SUBSCRIBED_CLUB_IDS; | ||
|
|
||
| return ( | ||
| <SafeAreaProvider> | ||
| <MixpanelProvider> | ||
| <SubscribedClubsProvider refreshKey={subscribedClubsRefreshKey}> | ||
| <MixpanelProvider | ||
| initialSessionId={bootstrapResult?.sessionId} | ||
| initialReady={bootstrapSucceeded} | ||
| > | ||
| <SubscribedClubsProvider initialClubIds={initialSubscribedClubIds}> | ||
| <ThemeProvider value={DefaultTheme}> | ||
| <Stack> | ||
| <Stack.Screen name="(tabs)" options={{ headerShown: false }} /> | ||
|
|
@@ -157,7 +233,7 @@ export default function RootLayout() { | |
|
|
||
| {/* 부트스트랩 오류 다이얼로그 (재시도 가능) */} | ||
| <BootstrapErrorDialog | ||
| visible={showSplash && appIsReady && !forceUpdateRequired && bootstrapStatus === 'failed'} | ||
| visible={showSplash && !forceUpdateRequired && bootstrapStatus === 'failed'} | ||
| message={bootstrapErrorMessage} | ||
| isRetrying={bootstrapStatus === 'running'} | ||
| onRetry={handleRetryBootstrap} | ||
|
|
@@ -166,7 +242,7 @@ export default function RootLayout() { | |
| {/* 커스텀 스플래시 스크린 */} | ||
| {showSplash && ( | ||
| <CustomSplashScreen | ||
| isReady={appIsReady} | ||
| isReady={bootstrapSucceeded} | ||
| onFinish={onFinishSplash} | ||
| blockFinish={shouldBlockSplash} | ||
| /> | ||
|
|
@@ -178,7 +254,7 @@ export default function RootLayout() { | |
| ); | ||
| } | ||
|
|
||
| async function requestTrackingPermissionOnLaunch() { | ||
| async function requestTrackingPermissionAfterLaunch() { | ||
| if (Platform.OS !== 'ios') { | ||
| return; | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
캐시 판정만으로
runAppBootstrap()을 열어 두면 신규 강제 업데이트를 우회합니다.Line 106은 활성/기본값 캐시만 보고,
false이면 Line 121에서 바로 부트스트랩을 시작합니다. 이 상태에서 서버의min_supported_version이 방금 올라간 경우에는refreshForceUpdateRequired()가true를 돌려주기 전에 토큰 생성, FCM 등록, 구독 동기화, Mixpanel 초기화가 먼저 실행됩니다. 강제 업데이트를 진입 게이트로 쓰는 흐름이면 fresh fetch 결과가 확정될 때까지 이 단계들은 보류되어야 합니다.As per coding guidelines,
app/_layout.tsxmust execute bootstrap in order:Firebase Remote Config forced update check → iOS ATT permission request → access token retrieval/creation → FCM token registration → subscription sync → Mixpanel initialization.🤖 Prompt for AI Agents