Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 6 additions & 1 deletion app.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,10 @@
[
"expo-build-properties",
{
"android": {
"enableMinifyInReleaseBuilds": true,
"enableShrinkResourcesInReleaseBuilds": true
},
"ios": {
"useFrameworks": "static",
"buildReactNativeFromSource": true,
Expand All @@ -97,7 +101,8 @@
],
"expo-tracking-transparency",
"./plugins/withFcmNotificationColorFix",
"./plugins/withAndroidReleaseSigning"
"./plugins/withAndroidReleaseSigning",
"./plugins/withIosPrebuildFixes"
],
"experiments": {
"typedRoutes": true,
Expand Down
8 changes: 6 additions & 2 deletions app/(tabs)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ import HomeIcon from "@/assets/icons/ic_home.svg";
import { HapticTab } from "@/components/haptic-tab";
import { USER_EVENT } from "@/constants/eventname";
import { Colors } from "@/constants/theme";
import { useMixpanelTrack } from "@/hooks";
import { useColorScheme } from "@/hooks/use-color-scheme";
import { useMixpanelTrack } from "@/hooks/use-mixpanel-track";
import { Tabs } from "expo-router";
import React from "react";
import React, { useEffect } from "react";
import { Image } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";

Expand All @@ -17,6 +17,10 @@ export default function TabLayout() {
const TAB_BAR_BASE_HEIGHT = 56;
const TAB_BAR_BASE_PADDING_VERTICAL = 6;

useEffect(() => {
console.log('[StartupTiming] tabsMounted', Date.now());
}, []);

const handleTabPress = (tabName: string) => {
trackEvent(USER_EVENT.BOTTOM_TAB_CLICKED, {
tab: tabName,
Expand Down
11 changes: 8 additions & 3 deletions app/(tabs)/index.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import { HomeScreen } from '@/ui/home/home-screen';
import { HomeWebViewScreen } from '@/ui/home/home-webview-screen';
import React, { useState } from 'react';
import React, { Suspense, lazy, useState } from 'react';

const LazyHomeScreen = lazy(() => import('@/ui/home/home-screen'));

export default function HomeTab() {
const [webViewFailed, setWebViewFailed] = useState(false);

if (webViewFailed) {
return <HomeScreen />;
return (
<Suspense fallback={null}>
<LazyHomeScreen />
</Suspense>
);
}

return <HomeWebViewScreen onError={() => setWebViewFailed(true)} />;
Expand Down
4 changes: 2 additions & 2 deletions app/(tabs)/more.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import MoadongIcon from '@/assets/icons/ic-moadong.svg';
import { MoaText } from '@/components/moa-text';
import { PAGE_VIEW_EVENT, USER_EVENT } from '@/constants/eventname';
import { useMixpanelTrack, useTrackScreenView } from '@/hooks';
import { useMixpanelTrack } from '@/hooks/use-mixpanel-track';
import { useTrackScreenView } from '@/hooks/use-track-screen-view';
import { Ionicons } from '@expo/vector-icons';
import Constants from 'expo-constants';
import { useRouter } from 'expo-router';
import React from 'react';
import { TouchableOpacity, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import styled from 'styled-components/native';

Check warning on line 12 in app/(tabs)/more.tsx

View workflow job for this annotation

GitHub Actions / android-check

Using exported name 'styled' as identifier for default import

Check warning on line 12 in app/(tabs)/more.tsx

View workflow job for this annotation

GitHub Actions / android-check

Using exported name 'styled' as identifier for default import

interface MenuItem {
id: string;
Expand Down Expand Up @@ -118,7 +119,7 @@
flex: 1;
`;

const MenuItem = styled(TouchableOpacity)`

Check warning on line 122 in app/(tabs)/more.tsx

View workflow job for this annotation

GitHub Actions / android-check

'MenuItem' is already defined

Check warning on line 122 in app/(tabs)/more.tsx

View workflow job for this annotation

GitHub Actions / android-check

'MenuItem' is already defined
flex-direction: row;
align-items: center;
justify-content: space-between;
Expand Down Expand Up @@ -162,4 +163,3 @@
const VersionValue = styled(MoaText)`
color: #888888;
`;

168 changes: 122 additions & 46 deletions app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';

// 네이티브 스플래시 화면을 자동으로 숨기지 않도록 설정
// 이것은 앱이 로드되자마자 실행되어야 합니다
Expand All @@ -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();
Comment on lines +105 to 121
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

캐시 판정만으로 runAppBootstrap()을 열어 두면 신규 강제 업데이트를 우회합니다.

Line 106은 활성/기본값 캐시만 보고, false이면 Line 121에서 바로 부트스트랩을 시작합니다. 이 상태에서 서버의 min_supported_version이 방금 올라간 경우에는 refreshForceUpdateRequired()true를 돌려주기 전에 토큰 생성, FCM 등록, 구독 동기화, Mixpanel 초기화가 먼저 실행됩니다. 강제 업데이트를 진입 게이트로 쓰는 흐름이면 fresh fetch 결과가 확정될 때까지 이 단계들은 보류되어야 합니다.

As per coding guidelines, app/_layout.tsx must 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
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/_layout.tsx` around lines 105 - 121, The code currently uses
getCachedForceUpdateRequired() and immediately starts runBootstrapSequence() if
that cached value is false, which can let a newly-enforced server
min_supported_version be bypassed; update the logic so bootstrap is gated on a
fresh Remote Config check: call and await refreshForceUpdateRequired() (or an
explicit freshFetchForceUpdate function) after checking the cache, use its
returned boolean to setForceUpdateRequired and setForceUpdateChecked, and only
proceed to runBootstrapSequence() when that fresh check returns false; keep
refreshForceUpdateInBackground() for periodic updates but do not start
runBootstrapSequence() until the fresh check completes—refer to
getCachedForceUpdateRequired, refreshForceUpdateInBackground,
refreshForceUpdateRequired, setForceUpdateRequired, setForceUpdateChecked, and
runBootstrapSequence to locate where to change the flow.


// 최소 로딩 시간 보장 (너무 빨리 사라지지 않도록)
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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

ATT 요청이 부트스트랩 완료 뒤로 밀려 순서 계약을 깨고 있습니다.

Line 159의 가드 때문에 ATT는 bootstrapSucceededshowSplash === false 이후에만 요청됩니다. 지금 흐름에서는 access token 처리, FCM 등록, 구독 동기화, Mixpanel 초기화가 모두 ATT보다 먼저 실행되므로 iOS 첫 실행의 추적 동의 상태와 초기화 결과가 어긋납니다. ATT를 루트 bootstrap 단계로 되돌리고, 완료 전까지 현재 스플래시 블로킹을 유지하는 쪽이 안전합니다.

As per coding guidelines, app/_layout.tsx must implement bootstrap in this order: (1) Firebase Remote Config forced update check, (2) iOS ATT permission request, (3) access token retrieval/creation, (4) FCM token registration, (5) subscribed clubs list sync, (6) Mixpanel analytics initialization. Block UI with custom splash screen until bootstrap completes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/_layout.tsx` around lines 158 - 176, The ATT request is gated behind the
useEffect that waits for bootstrapSucceeded and showSplash false, causing ATT to
run after access-token/FCM/sync/Mixpanel; move ATT into the root bootstrap flow
so it runs as step (2) before access-token/FCM/sync/Mixpanel and while splash
remains blocking. Concretely, remove or relocate the call to
requestTrackingPermissionAfterLaunch from the InteractionManager-based effect
and invoke a new/renamed function (e.g.,
requestTrackingPermissionDuringBootstrap) from the main bootstrap sequence in
app/_layout.tsx immediately after the remote-config/forced-update check and
before the access token retrieval/creation code paths (the code currently gated
by bootstrapSucceeded), ensure the bootstrap promise awaits its completion, and
keep showSplash true (or the existing splash blocker) until that bootstrap
promise (including ATT) resolves so startup order follows the required (1)
remote-config forced update, (2) ATT, (3) access token, (4) FCM registration,
(5) subscribed clubs sync, (6) Mixpanel init.


const onFinishSplash = useCallback(() => {
// 커스텀 스플래시 애니메이션이 완료되면
console.log('🎭 커스텀 스플래시 종료, 메인 화면으로 전환');
console.log('🎭 커스텀 스플래시 종료, 메인 화면으로 전환', {
customSplashDismissed: Date.now(),
});
if (shouldBlockSplash) {
console.log('⛔️ 스플래시 유지:', { forceUpdateRequired, bootstrapStatus });
return;
Expand All @@ -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 }} />
Expand All @@ -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}
Expand All @@ -166,7 +242,7 @@ export default function RootLayout() {
{/* 커스텀 스플래시 스크린 */}
{showSplash && (
<CustomSplashScreen
isReady={appIsReady}
isReady={bootstrapSucceeded}
onFinish={onFinishSplash}
blockFinish={shouldBlockSplash}
/>
Expand All @@ -178,7 +254,7 @@ export default function RootLayout() {
);
}

async function requestTrackingPermissionOnLaunch() {
async function requestTrackingPermissionAfterLaunch() {
if (Platform.OS !== 'ios') {
return;
}
Expand Down
4 changes: 2 additions & 2 deletions app/webview/[slug].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
*/

import { MoaText } from "@/components/moa-text";
import { useMixpanelContext } from "@/contexts";
import { useWebViewMessageHandler } from "@/hooks";
import { useMixpanelContext } from "@/contexts/mixpanel-context";
import { useWebViewMessageHandler } from "@/hooks/use-webview-message-handler";
import { appendSessionId, getWebViewUserAgent } from "@/utils/webview";
import { Ionicons } from "@expo/vector-icons";
import { useLocalSearchParams, useRouter } from "expo-router";
Expand All @@ -13,7 +13,7 @@
import { ActivityIndicator, TouchableOpacity } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { WebView } from "react-native-webview";
import styled from "styled-components/native";

Check warning on line 16 in app/webview/[slug].tsx

View workflow job for this annotation

GitHub Actions / android-check

Using exported name 'styled' as identifier for default import

Check warning on line 16 in app/webview/[slug].tsx

View workflow job for this annotation

GitHub Actions / android-check

Using exported name 'styled' as identifier for default import

const webviewUrl =
process.env.EXPO_PUBLIC_WEBVIEW_URL || "https://develop.moadong.com";
Expand Down
Loading
Loading