diff --git a/app.json b/app.json index 16486f8..88f4926 100644 --- a/app.json +++ b/app.json @@ -77,6 +77,10 @@ [ "expo-build-properties", { + "android": { + "enableMinifyInReleaseBuilds": true, + "enableShrinkResourcesInReleaseBuilds": true + }, "ios": { "useFrameworks": "static", "buildReactNativeFromSource": true, @@ -97,7 +101,8 @@ ], "expo-tracking-transparency", "./plugins/withFcmNotificationColorFix", - "./plugins/withAndroidReleaseSigning" + "./plugins/withAndroidReleaseSigning", + "./plugins/withIosPrebuildFixes" ], "experiments": { "typedRoutes": true, diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index 4f3b026..bb7a7c8 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -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"; @@ -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, diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index a32e779..9057aef 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -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 ; + return ( + + + + ); } return setWebViewFailed(true)} />; diff --git a/app/(tabs)/more.tsx b/app/(tabs)/more.tsx index 42954c4..30d66b1 100644 --- a/app/(tabs)/more.tsx +++ b/app/(tabs)/more.tsx @@ -1,7 +1,8 @@ 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'; @@ -162,4 +163,3 @@ const VersionItem = styled.View` const VersionValue = styled(MoaText)` color: #888888; `; - diff --git a/app/_layout.tsx b/app/_layout.tsx index 978d29f..8f3cbb4 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -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,14 +33,17 @@ 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('idle'); const [bootstrapErrorMessage, setBootstrapErrorMessage] = useState(undefined); - const [subscribedClubsRefreshKey, setSubscribedClubsRefreshKey] = useState(0); + const [bootstrapResult, setBootstrapResult] = useState(null); + const bootstrapStatusRef = useRef('idle'); + const nativeSplashHiddenRef = useRef(false); const bootstrapSucceeded = bootstrapStatus === 'success'; const shouldBlockSplash = forceUpdateRequired || !bootstrapSucceeded; @@ -45,69 +51,135 @@ export default function RootLayout() { // 강제 업데이트가 필요한 경우(또는 체크 전)에는 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 | undefined; + const interactionTask = InteractionManager.runAfterInteractions(() => { + timeoutId = setTimeout(() => { + requestTrackingPermissionAfterLaunch(); + }, 250); + }); + + return () => { + interactionTask.cancel(); + if (timeoutId) { + clearTimeout(timeoutId); + } + }; + }, [bootstrapSucceeded, forceUpdateRequired, showSplash]); 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 ( - - + + @@ -157,7 +233,7 @@ export default function RootLayout() { {/* 부트스트랩 오류 다이얼로그 (재시도 가능) */} @@ -178,7 +254,7 @@ export default function RootLayout() { ); } -async function requestTrackingPermissionOnLaunch() { +async function requestTrackingPermissionAfterLaunch() { if (Platform.OS !== 'ios') { return; } diff --git a/app/webview/[slug].tsx b/app/webview/[slug].tsx index cd406ae..e2bfed3 100644 --- a/app/webview/[slug].tsx +++ b/app/webview/[slug].tsx @@ -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"; diff --git a/components/custom-splash-screen.tsx b/components/custom-splash-screen.tsx index 8137253..28f63a3 100644 --- a/components/custom-splash-screen.tsx +++ b/components/custom-splash-screen.tsx @@ -5,9 +5,10 @@ import MoadongIcon from '@/assets/icons/ic-moadong.svg'; import { LinearGradient } from 'expo-linear-gradient'; -import { useCallback, useEffect, useRef } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { StyleSheet, View } from 'react-native'; import Animated, { + cancelAnimation, Easing, runOnJS, useAnimatedStyle, @@ -25,20 +26,42 @@ interface CustomSplashScreenProps { blockFinish?: boolean; } +type IntroAnimationStep = 'logoOpacity' | 'logoScale' | 'textOpacity' | 'textTranslateY'; + +const INTRO_ANIMATION_STEP_COUNT = 4; +const MIN_SPLASH_VISIBLE_DURATION_MS = 700; +const SPLASH_FADE_OUT_DURATION_MS = 300; + export function CustomSplashScreen({ onFinish, isReady, blockFinish = false }: CustomSplashScreenProps) { const logoScale = useSharedValue(0.3); const logoOpacity = useSharedValue(0); const textOpacity = useSharedValue(0); const textTranslateY = useSharedValue(20); const fadeOutOpacity = useSharedValue(1); + const [introCompleted, setIntroCompleted] = useState(false); const hasAnimatedOnce = useRef(false); + const hasStartedFadeOut = useRef(false); + const animationStartedAt = useRef(Date.now()); + const completedIntroAnimationSteps = useRef(new Set()); + + const markIntroAnimationStepComplete = useCallback((step: IntroAnimationStep) => { + if (completedIntroAnimationSteps.current.has(step)) { + return; + } + + completedIntroAnimationSteps.current.add(step); + + if (completedIntroAnimationSteps.current.size === INTRO_ANIMATION_STEP_COUNT) { + setIntroCompleted(true); + } + }, []); const triggerFadeOut = useCallback((delayMs: number) => { fadeOutOpacity.value = withDelay( delayMs, withTiming( 0, - { duration: 300, easing: Easing.in(Easing.ease) }, + { duration: SPLASH_FADE_OUT_DURATION_MS, easing: Easing.in(Easing.ease) }, (finished) => { if (finished) { runOnJS(onFinish)(); @@ -48,43 +71,93 @@ export function CustomSplashScreen({ onFinish, isReady, blockFinish = false }: C ); }, [fadeOutOpacity, onFinish]); - const startAnimation = useCallback((shouldBlockFinish: boolean) => { - logoOpacity.value = withTiming(1, { - duration: 500, - easing: Easing.out(Easing.ease), - }); + const startAnimation = useCallback(() => { + animationStartedAt.current = Date.now(); + + logoOpacity.value = withTiming( + 1, + { + duration: 500, + easing: Easing.out(Easing.ease), + }, + (finished) => { + if (finished) { + runOnJS(markIntroAnimationStepComplete)('logoOpacity'); + } + } + ); - logoScale.value = withSpring(1, { - damping: 12, - stiffness: 120, - mass: 0.7, - }); + logoScale.value = withSpring( + 1, + { + damping: 12, + stiffness: 120, + mass: 0.7, + }, + (finished) => { + if (finished) { + runOnJS(markIntroAnimationStepComplete)('logoScale'); + } + } + ); textOpacity.value = withDelay( 300, - withTiming(1, { duration: 400, easing: Easing.out(Easing.ease) }) + withTiming( + 1, + { duration: 400, easing: Easing.out(Easing.ease) }, + (finished) => { + if (finished) { + runOnJS(markIntroAnimationStepComplete)('textOpacity'); + } + } + ) ); textTranslateY.value = withDelay( 300, - withTiming(0, { duration: 400, easing: Easing.out(Easing.ease) }) + withTiming( + 0, + { duration: 400, easing: Easing.out(Easing.ease) }, + (finished) => { + if (finished) { + runOnJS(markIntroAnimationStepComplete)('textTranslateY'); + } + } + ) ); + }, [ + logoOpacity, + logoScale, + markIntroAnimationStepComplete, + textOpacity, + textTranslateY, + ]); - if (!shouldBlockFinish) { - triggerFadeOut(2200); + useEffect(() => { + if (!hasAnimatedOnce.current) { + hasAnimatedOnce.current = true; + startAnimation(); } - }, [logoOpacity, logoScale, textOpacity, textTranslateY, triggerFadeOut]); + }, [startAnimation]); useEffect(() => { - if (!isReady) return; + if (blockFinish) { + hasStartedFadeOut.current = false; + cancelAnimation(fadeOutOpacity); + fadeOutOpacity.value = 1; + return; + } - if (!hasAnimatedOnce.current) { - hasAnimatedOnce.current = true; - startAnimation(blockFinish); - } else if (!blockFinish) { - triggerFadeOut(0); + if (!isReady || !introCompleted || hasStartedFadeOut.current) { + return; } - }, [isReady, blockFinish, startAnimation, triggerFadeOut]); + + hasStartedFadeOut.current = true; + const elapsedMs = Date.now() - animationStartedAt.current; + const remainingMinimumMs = Math.max(0, MIN_SPLASH_VISIBLE_DURATION_MS - elapsedMs); + triggerFadeOut(remainingMinimumMs); + }, [isReady, introCompleted, blockFinish, fadeOutOpacity, triggerFadeOut]); const logoAnimatedStyle = useAnimatedStyle(() => ({ opacity: logoOpacity.value, @@ -182,4 +255,3 @@ const styles = StyleSheet.create({ fontWeight: '500', }, }); - diff --git a/components/permission-dialog.tsx b/components/permission-dialog.tsx index 6c89736..a41e3e5 100644 --- a/components/permission-dialog.tsx +++ b/components/permission-dialog.tsx @@ -5,7 +5,7 @@ import { MoaText } from '@/components/moa-text'; import { USER_EVENT } from '@/constants/eventname'; import { Colors } from '@/constants/theme'; -import { useMixpanelTrack } from '@/hooks'; +import { useMixpanelTrack } from '@/hooks/use-mixpanel-track'; import React, { useEffect } from 'react'; import { Linking, Modal, TouchableOpacity } from 'react-native'; import styled from 'styled-components/native'; @@ -139,4 +139,3 @@ const ConfirmButton = styled(TouchableOpacity)` const ConfirmButtonText = styled(MoaText)` color: ${Colors.light.tint}; `; - diff --git a/components/ui/column.tsx b/components/ui/column.tsx index a44f886..ae31f77 100644 --- a/components/ui/column.tsx +++ b/components/ui/column.tsx @@ -9,6 +9,13 @@ export interface ColumnProps extends ViewProps { wrap?: 'nowrap' | 'wrap' | 'wrap-reverse'; } +type StyledColumnProps = { + gap: number; + align: NonNullable; + justify: NonNullable; + wrap: NonNullable; +}; + /** * 세로 방향 레이아웃 컴포넌트 * @@ -40,15 +47,10 @@ export function Column({ ); } -const StyledColumn = styled.View<{ - gap: number; - align: string; - justify: string; - wrap: string; -}>` +const StyledColumn = styled.View` flex-direction: column; - align-items: ${props => props.align}; - justify-content: ${props => props.justify}; - flex-wrap: ${props => props.wrap}; - gap: ${props => props.gap}px; + align-items: ${(props: StyledColumnProps) => props.align}; + justify-content: ${(props: StyledColumnProps) => props.justify}; + flex-wrap: ${(props: StyledColumnProps) => props.wrap}; + gap: ${(props: StyledColumnProps) => props.gap}px; `; diff --git a/components/ui/row.tsx b/components/ui/row.tsx index a0fa87b..e0ccbe1 100644 --- a/components/ui/row.tsx +++ b/components/ui/row.tsx @@ -9,6 +9,13 @@ export interface RowProps extends ViewProps { wrap?: 'nowrap' | 'wrap' | 'wrap-reverse'; } +type StyledRowProps = { + gap: number; + align: NonNullable; + justify: NonNullable; + wrap: NonNullable; +}; + /** * 가로 방향 레이아웃 컴포넌트 * @@ -40,15 +47,10 @@ export function Row({ ); } -const StyledRow = styled.View<{ - gap: number; - align: string; - justify: string; - wrap: string; -}>` +const StyledRow = styled.View` flex-direction: row; - align-items: ${props => props.align}; - justify-content: ${props => props.justify}; - flex-wrap: ${props => props.wrap}; - gap: ${props => props.gap}px; + align-items: ${(props: StyledRowProps) => props.align}; + justify-content: ${(props: StyledRowProps) => props.justify}; + flex-wrap: ${(props: StyledRowProps) => props.wrap}; + gap: ${(props: StyledRowProps) => props.gap}px; `; diff --git a/contexts/mixpanel-context.tsx b/contexts/mixpanel-context.tsx index 5ae3952..7a47d9d 100644 --- a/contexts/mixpanel-context.tsx +++ b/contexts/mixpanel-context.tsx @@ -1,6 +1,5 @@ import { getJwtSubject, getStoredAccessToken } from '@/services/auth-token-storage'; -import { identifyMixpanel } from '@/utils/mixpanel'; -import AsyncStorage from '@react-native-async-storage/async-storage'; +import { getOrCreateMixpanelSessionId, identifyMixpanel } from '@/utils/mixpanel'; import React, { createContext, useContext, useEffect, useState } from 'react'; interface MixpanelContextType { @@ -20,42 +19,10 @@ export const useMixpanelContext = () => { interface MixpanelProviderProps { children: React.ReactNode; + initialSessionId?: string; + initialReady?: boolean; } -const SESSION_ID_KEY = '@moadong_session_id'; - -/** - * 랜덤 세션 ID 생성 - * 앱 최초 실행 시 1회만 생성하고 이후에는 저장된 값 사용 - */ -const generateSessionId = (): string => { - const timestamp = Date.now().toString(36); - const randomStr = Math.random().toString(36).substring(2, 15); - return `moadong_${timestamp}_${randomStr}`; -}; - -/** - * AsyncStorage에서 session_id 가져오기 또는 생성 - */ -const getOrCreateSessionId = async (): Promise => { - try { - const storedSessionId = await AsyncStorage.getItem(SESSION_ID_KEY); - - if (storedSessionId) { - return storedSessionId; - } - - const newSessionId = generateSessionId(); - await AsyncStorage.setItem(SESSION_ID_KEY, newSessionId); - console.log('[MixpanelProvider] 새 Session ID 생성:', newSessionId); - return newSessionId; - } catch (error) { - console.error('[MixpanelProvider] Session ID 로드/저장 실패:', error); - // 에러 시 임시 ID 생성 (저장 안 함) - return generateSessionId(); - } -}; - async function getMixpanelDistinctId(sessionId: string): Promise { const accessToken = await getStoredAccessToken(); if (accessToken) { @@ -68,14 +35,27 @@ async function getMixpanelDistinctId(sessionId: string): Promise { return sessionId; } -export const MixpanelProvider: React.FC = ({ children }) => { - const [sessionId, setSessionId] = useState(''); - const [isLoading, setIsLoading] = useState(true); +export const MixpanelProvider: React.FC = ({ + children, + initialSessionId, + initialReady, +}) => { + const usesBootstrapState = initialReady !== undefined; + const [sessionId, setSessionId] = useState(initialSessionId ?? ''); + const [isLoading, setIsLoading] = useState( + usesBootstrapState ? !initialReady : true, + ); useEffect(() => { + if (usesBootstrapState) { + setSessionId(initialSessionId ?? ''); + setIsLoading(!initialReady); + return; + } + const initializeMixpanel = async () => { try { - const id = await getOrCreateSessionId(); + const id = await getOrCreateMixpanelSessionId(); setSessionId(id); const distinctId = await getMixpanelDistinctId(id); @@ -91,7 +71,7 @@ export const MixpanelProvider: React.FC = ({ children }) }; initializeMixpanel(); - }, []); + }, [initialReady, initialSessionId, usesBootstrapState]); return ( diff --git a/contexts/subscribed-clubs-context.tsx b/contexts/subscribed-clubs-context.tsx index 4daccc0..4d4f0bf 100644 --- a/contexts/subscribed-clubs-context.tsx +++ b/contexts/subscribed-clubs-context.tsx @@ -36,14 +36,20 @@ const SubscribedClubsContext = createContext([]); +export function SubscribedClubsProvider({ + children, + initialClubIds, + refreshKey = 0, +}: SubscribedClubsProviderProps) { + const usesBootstrapState = initialClubIds !== undefined; + const [subscribedClubIds, setSubscribedClubIds] = useState(initialClubIds ?? []); const [loading, setLoading] = useState(false); /** @@ -171,8 +177,13 @@ export function SubscribedClubsProvider({ children, refreshKey = 0 }: Subscribed * 초기 데이터 로드 */ useEffect(() => { + if (usesBootstrapState) { + setSubscribedClubIds(initialClubIds ?? []); + return; + } + loadSubscribedClubs(); - }, [loadSubscribedClubs, refreshKey]); + }, [initialClubIds, loadSubscribedClubs, refreshKey, usesBootstrapState]); const value: SubscribedClubsContextType = { subscribedClubIds, @@ -199,4 +210,3 @@ export function useSubscribedClubsContext(): SubscribedClubsContextType { } return context; } - diff --git a/ios/app.xcodeproj/project.pbxproj b/ios/app.xcodeproj/project.pbxproj index 009cb08..871139c 100644 --- a/ios/app.xcodeproj/project.pbxproj +++ b/ios/app.xcodeproj/project.pbxproj @@ -7,31 +7,31 @@ objects = { /* Begin PBXBuildFile section */ - 0FF7848F61AA47FAA6C76782 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 77AE80BD50D547E1A8A56CC6 /* GoogleService-Info.plist */; }; 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; 3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; }; - 44EF9C0703654D6F5A657A3A /* Pods_app.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 037983D3A2BD448B8BE103E8 /* Pods_app.framework */; }; - 92B9A9B164FF5EF0323F1E31 /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7ADA102A06D5EB446FB6A0A9 /* ExpoModulesProvider.swift */; }; + 4EB67CDF4FA2FA6DB261C2AF /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F04226BB632D1877217E4BF /* ExpoModulesProvider.swift */; }; + 51E8237EA68C9F421821F3F2 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = FFC3E90B83FE5881E7191A28 /* PrivacyInfo.xcprivacy */; }; + 7418872E76C24C9D99DBCDBA /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 129B2FC68CE94DD4AF3E8F46 /* GoogleService-Info.plist */; }; + 82AA7221D96D78101B265EA1 /* Pods_app.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A2FE34E4E96E79AB979AE79F /* Pods_app.framework */; }; BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; }; - C3E8B42E42522C1805BC6F18 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = A3AFB8CC99CA24AB73835436 /* PrivacyInfo.xcprivacy */; }; F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11748412D0307B40044C1D9 /* AppDelegate.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ - 037983D3A2BD448B8BE103E8 /* Pods_app.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_app.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 129B2FC68CE94DD4AF3E8F46 /* GoogleService-Info.plist */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = text.plist.xml; name = "GoogleService-Info.plist"; path = "app/GoogleService-Info.plist"; sourceTree = ""; }; 13B07F961A680F5B00A75B9A /* app.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = app.app; sourceTree = BUILT_PRODUCTS_DIR; }; 13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = app/Images.xcassets; sourceTree = ""; }; 13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = app/Info.plist; sourceTree = ""; }; - 1E8B6F5BC9FA6562BD53A21F /* Pods-app.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-app.release.xcconfig"; path = "Target Support Files/Pods-app/Pods-app.release.xcconfig"; sourceTree = ""; }; - 77AE80BD50D547E1A8A56CC6 /* GoogleService-Info.plist */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = text.plist.xml; name = "GoogleService-Info.plist"; path = "app/GoogleService-Info.plist"; sourceTree = ""; }; - 7ADA102A06D5EB446FB6A0A9 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-app/ExpoModulesProvider.swift"; sourceTree = ""; }; - A3AFB8CC99CA24AB73835436 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; name = PrivacyInfo.xcprivacy; path = app/PrivacyInfo.xcprivacy; sourceTree = ""; }; + 29529F95C3B27A8A28148768 /* Pods-app.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-app.release.xcconfig"; path = "Target Support Files/Pods-app/Pods-app.release.xcconfig"; sourceTree = ""; }; + 5F04226BB632D1877217E4BF /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-app/ExpoModulesProvider.swift"; sourceTree = ""; }; + 9603A13841B849497BEFA438 /* Pods-app.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-app.debug.xcconfig"; path = "Target Support Files/Pods-app/Pods-app.debug.xcconfig"; sourceTree = ""; }; + A2FE34E4E96E79AB979AE79F /* Pods_app.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_app.framework; sourceTree = BUILT_PRODUCTS_DIR; }; AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = app/SplashScreen.storyboard; sourceTree = ""; }; BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = ""; }; ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; F11748412D0307B40044C1D9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = app/AppDelegate.swift; sourceTree = ""; }; F11748442D0722820044C1D9 /* app-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "app-Bridging-Header.h"; path = "app/app-Bridging-Header.h"; sourceTree = ""; }; - FC924C491C52003CCFC65F38 /* Pods-app.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-app.debug.xcconfig"; path = "Target Support Files/Pods-app/Pods-app.debug.xcconfig"; sourceTree = ""; }; + FFC3E90B83FE5881E7191A28 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; name = PrivacyInfo.xcprivacy; path = app/PrivacyInfo.xcprivacy; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -39,7 +39,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 44EF9C0703654D6F5A657A3A /* Pods_app.framework in Frameworks */, + 82AA7221D96D78101B265EA1 /* Pods_app.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -55,8 +55,8 @@ 13B07FB51A68108700A75B9A /* Images.xcassets */, 13B07FB61A68108700A75B9A /* Info.plist */, AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */, - 77AE80BD50D547E1A8A56CC6 /* GoogleService-Info.plist */, - A3AFB8CC99CA24AB73835436 /* PrivacyInfo.xcprivacy */, + 129B2FC68CE94DD4AF3E8F46 /* GoogleService-Info.plist */, + FFC3E90B83FE5881E7191A28 /* PrivacyInfo.xcprivacy */, ); name = app; sourceTree = ""; @@ -65,19 +65,17 @@ isa = PBXGroup; children = ( ED297162215061F000B7C4FE /* JavaScriptCore.framework */, - 037983D3A2BD448B8BE103E8 /* Pods_app.framework */, + A2FE34E4E96E79AB979AE79F /* Pods_app.framework */, ); name = Frameworks; sourceTree = ""; }; - 7B483D38F1EB58EF70E93FE8 /* Pods */ = { + 3E02BF4B97A12C72A249A00C /* ExpoModulesProviders */ = { isa = PBXGroup; children = ( - FC924C491C52003CCFC65F38 /* Pods-app.debug.xcconfig */, - 1E8B6F5BC9FA6562BD53A21F /* Pods-app.release.xcconfig */, + 956CE7A84C8CBD77BFF65CED /* app */, ); - name = Pods; - path = Pods; + name = ExpoModulesProviders; sourceTree = ""; }; 832341AE1AAA6A7D00B99B32 /* Libraries */ = { @@ -94,8 +92,8 @@ 832341AE1AAA6A7D00B99B32 /* Libraries */, 83CBBA001A601CBA00E9B192 /* Products */, 2D16E6871FA4F8E400B85C8A /* Frameworks */, - 7B483D38F1EB58EF70E93FE8 /* Pods */, - EE09FE91477B80C913C55DE8 /* ExpoModulesProviders */, + F66767058A3B50A3480FF459 /* Pods */, + 3E02BF4B97A12C72A249A00C /* ExpoModulesProviders */, ); indentWidth = 2; sourceTree = ""; @@ -110,29 +108,31 @@ name = Products; sourceTree = ""; }; - BB2F792B24A3F905000567C9 /* Supporting */ = { + 956CE7A84C8CBD77BFF65CED /* app */ = { isa = PBXGroup; children = ( - BB2F792C24A3F905000567C9 /* Expo.plist */, + 5F04226BB632D1877217E4BF /* ExpoModulesProvider.swift */, ); - name = Supporting; - path = app/Supporting; + name = app; sourceTree = ""; }; - EE09FE91477B80C913C55DE8 /* ExpoModulesProviders */ = { + BB2F792B24A3F905000567C9 /* Supporting */ = { isa = PBXGroup; children = ( - FEDE40ACF6BA5FFEF4F9A2E0 /* app */, + BB2F792C24A3F905000567C9 /* Expo.plist */, ); - name = ExpoModulesProviders; + name = Supporting; + path = app/Supporting; sourceTree = ""; }; - FEDE40ACF6BA5FFEF4F9A2E0 /* app */ = { + F66767058A3B50A3480FF459 /* Pods */ = { isa = PBXGroup; children = ( - 7ADA102A06D5EB446FB6A0A9 /* ExpoModulesProvider.swift */, + 9603A13841B849497BEFA438 /* Pods-app.debug.xcconfig */, + 29529F95C3B27A8A28148768 /* Pods-app.release.xcconfig */, ); - name = app; + name = Pods; + path = Pods; sourceTree = ""; }; /* End PBXGroup section */ @@ -143,15 +143,15 @@ buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "app" */; buildPhases = ( 08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */, - 87DD351A4DF46AD393C4EAA4 /* [Expo] Configure project */, + FE867D8A5FA752A269599C43 /* [Expo] Configure project */, 13B07F871A680F5B00A75B9A /* Sources */, 13B07F8C1A680F5B00A75B9A /* Frameworks */, 13B07F8E1A680F5B00A75B9A /* Resources */, 00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */, 800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */, - A2E0F80BF351A6826D2E6743 /* [CP] Embed Pods Frameworks */, - 18634755B0FACEB20FE6C4F3 /* [CP-User] [RNFB] Core Configuration */, - 83972AB9ECA9DF424A3C857B /* [CP-User] [RNFB] Crashlytics Configuration */, + 8EECECEFB71129A522FE163D /* [CP] Embed Pods Frameworks */, + 1D0C50A2BD995DC812AD2D44 /* [CP-User] [RNFB] Core Configuration */, + 97ACC5400792088F8397C15F /* [CP-User] [RNFB] Crashlytics Configuration */, ); buildRules = ( ); @@ -201,8 +201,8 @@ BB2F792D24A3F905000567C9 /* Expo.plist in Resources */, 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */, 3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */, - 0FF7848F61AA47FAA6C76782 /* GoogleService-Info.plist in Resources */, - C3E8B42E42522C1805BC6F18 /* PrivacyInfo.xcprivacy in Resources */, + 7418872E76C24C9D99DBCDBA /* GoogleService-Info.plist in Resources */, + 51E8237EA68C9F421821F3F2 /* PrivacyInfo.xcprivacy in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -248,7 +248,7 @@ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; - 18634755B0FACEB20FE6C4F3 /* [CP-User] [RNFB] Core Configuration */ = { + 1D0C50A2BD995DC812AD2D44 /* [CP-User] [RNFB] Core Configuration */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; buildActionMask = 2147483647; @@ -338,7 +338,25 @@ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-app/Pods-app-resources.sh\"\n"; showEnvVarsInLog = 0; }; - 83972AB9ECA9DF424A3C857B /* [CP-User] [RNFB] Crashlytics Configuration */ = { + 8EECECEFB71129A522FE163D /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-app/Pods-app-frameworks.sh", + "${PODS_XCFRAMEWORKS_BUILD_DIR}/hermes-engine/Pre-built/hermes.framework/hermes", + ); + name = "[CP] Embed Pods Frameworks"; + outputPaths = ( + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/hermes.framework", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-app/Pods-app-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + 97ACC5400792088F8397C15F /* [CP-User] [RNFB] Crashlytics Configuration */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; buildActionMask = 2147483647; @@ -353,7 +371,7 @@ shellPath = /bin/sh; shellScript = "#!/usr/bin/env bash\n#\n# Copyright (c) 2016-present Invertase Limited & Contributors\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this library except in compliance with the License.\n# You may obtain a copy of the License at\n#\n# http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\nset -e\n\nif [[ ${PODS_ROOT} ]]; then\n echo \"info: Exec FirebaseCrashlytics Run from Pods\"\n \"${PODS_ROOT}/FirebaseCrashlytics/run\"\nelse\n echo \"info: Exec FirebaseCrashlytics Run from framework\"\n \"${PROJECT_DIR}/FirebaseCrashlytics.framework/run\"\nfi\n"; }; - 87DD351A4DF46AD393C4EAA4 /* [Expo] Configure project */ = { + FE867D8A5FA752A269599C43 /* [Expo] Configure project */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; buildActionMask = 2147483647; @@ -377,24 +395,6 @@ shellPath = /bin/sh; shellScript = "# This script configures Expo modules and generates the modules provider file.\nbash -l -c \"./Pods/Target\\ Support\\ Files/Pods-app/expo-configure-project.sh\"\n"; }; - A2E0F80BF351A6826D2E6743 /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-app/Pods-app-frameworks.sh", - "${PODS_XCFRAMEWORKS_BUILD_DIR}/hermes-engine/Pre-built/hermes.framework/hermes", - ); - name = "[CP] Embed Pods Frameworks"; - outputPaths = ( - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/hermes.framework", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-app/Pods-app-frameworks.sh\"\n"; - showEnvVarsInLog = 0; - }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -403,7 +403,7 @@ buildActionMask = 2147483647; files = ( F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */, - 92B9A9B164FF5EF0323F1E31 /* ExpoModulesProvider.swift in Sources */, + 4EB67CDF4FA2FA6DB261C2AF /* ExpoModulesProvider.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -412,7 +412,7 @@ /* Begin XCBuildConfiguration section */ 13B07F941A680F5B00A75B9A /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = FC924C491C52003CCFC65F38 /* Pods-app.debug.xcconfig */; + baseConfigurationReference = 9603A13841B849497BEFA438 /* Pods-app.debug.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; @@ -449,7 +449,7 @@ }; 13B07F951A680F5B00A75B9A /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 1E8B6F5BC9FA6562BD53A21F /* Pods-app.release.xcconfig */; + baseConfigurationReference = 29529F95C3B27A8A28148768 /* Pods-app.release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; diff --git a/plugins/ios-prebuild-support/IDEWorkspaceChecks.plist b/plugins/ios-prebuild-support/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/plugins/ios-prebuild-support/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/plugins/ios-prebuild-support/ci_post_clone.sh b/plugins/ios-prebuild-support/ci_post_clone.sh new file mode 100644 index 0000000..cbf127f --- /dev/null +++ b/plugins/ios-prebuild-support/ci_post_clone.sh @@ -0,0 +1,134 @@ +#!/bin/sh + +set -eu + +SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) +REPO_ROOT=$(cd "$SCRIPT_DIR/../.." && pwd) + +cd "$REPO_ROOT" + +prepend_homebrew_paths() { + if [ -d /opt/homebrew/bin ]; then + PATH="/opt/homebrew/bin:$PATH" + fi + + if [ -d /usr/local/bin ]; then + PATH="/usr/local/bin:$PATH" + fi + + export PATH +} + +install_with_homebrew() { + FORMULA="$1" + + if ! command -v brew >/dev/null 2>&1; then + echo "Homebrew is required to install $FORMULA, but brew is not available." >&2 + exit 1 + fi + + if ! brew list "$FORMULA" >/dev/null 2>&1; then + brew install "$FORMULA" + fi +} + +ensure_node() { + prepend_homebrew_paths + + if command -v node >/dev/null 2>&1 && command -v npm >/dev/null 2>&1; then + export NODE_BINARY="$(command -v node)" + return + fi + + NODE_FORMULA="${NODE_FORMULA:-node@22}" + + echo "Node.js/npm not found in PATH. Installing $NODE_FORMULA with Homebrew..." + install_with_homebrew "$NODE_FORMULA" + + NODE_PREFIX="$(brew --prefix "$NODE_FORMULA" 2>/dev/null || true)" + if [ -n "$NODE_PREFIX" ]; then + PATH="$NODE_PREFIX/bin:$PATH" + export PATH + fi + + if ! command -v node >/dev/null 2>&1 || ! command -v npm >/dev/null 2>&1; then + echo "Failed to make Node.js/npm available after installing $NODE_FORMULA." >&2 + exit 1 + fi + + export NODE_BINARY="$(command -v node)" +} + +ensure_cocoapods() { + prepend_homebrew_paths + + if command -v pod >/dev/null 2>&1; then + return + fi + + echo "CocoaPods not found in PATH. Installing cocoapods with Homebrew..." + install_with_homebrew cocoapods +} + +ensure_node +node --version +npm --version +npm ci + +decode_google_service_info_plist() { + if printf '%s' "$GOOGLE_SERVICE_INFO_PLIST_BASE64" | base64 --decode > GoogleService-Info.plist 2>/dev/null; then + return + fi + + if printf '%s' "$GOOGLE_SERVICE_INFO_PLIST_BASE64" | base64 -D > GoogleService-Info.plist 2>/dev/null; then + return + fi + + if command -v openssl >/dev/null 2>&1 && + printf '%s' "$GOOGLE_SERVICE_INFO_PLIST_BASE64" | openssl base64 -d -A > GoogleService-Info.plist 2>/dev/null; then + return + fi + + echo "Failed to decode GOOGLE_SERVICE_INFO_PLIST_BASE64" >&2 + exit 1 +} + +if [ -n "${GOOGLE_SERVICE_INFO_PLIST_BASE64:-}" ]; then + decode_google_service_info_plist +elif [ -n "${GOOGLE_SERVICE_INFO_PLIST:-}" ]; then + printf '%s' "$GOOGLE_SERVICE_INFO_PLIST" > GoogleService-Info.plist +else + echo "Missing GOOGLE_SERVICE_INFO_PLIST_BASE64 or GOOGLE_SERVICE_INFO_PLIST for iOS archive" >&2 + exit 1 +fi + +mkdir -p ios/app +cp GoogleService-Info.plist ios/app/GoogleService-Info.plist + +: "${EXPO_PUBLIC_BASE_URL:?Missing EXPO_PUBLIC_BASE_URL for iOS archive}" +: "${EXPO_PUBLIC_WEBVIEW_URL:?Missing EXPO_PUBLIC_WEBVIEW_URL for iOS archive}" +: "${EXPO_PUBLIC_MIXPANEL_TOKEN:?Missing EXPO_PUBLIC_MIXPANEL_TOKEN for iOS archive}" + +{ + echo "EXPO_PUBLIC_BASE_URL=${EXPO_PUBLIC_BASE_URL}" + echo "EXPO_PUBLIC_WEBVIEW_URL=${EXPO_PUBLIC_WEBVIEW_URL}" + echo "EXPO_PUBLIC_MIXPANEL_TOKEN=${EXPO_PUBLIC_MIXPANEL_TOKEN}" +} > .env + +APS_ENVIRONMENT="${IOS_APS_ENVIRONMENT:-development}" +ENTITLEMENTS_FILE="${IOS_ENTITLEMENTS_FILE:-ios/app/app.entitlements}" + +if [ -f "$ENTITLEMENTS_FILE" ]; then + /usr/libexec/PlistBuddy -c "Set :aps-environment $APS_ENVIRONMENT" "$ENTITLEMENTS_FILE" 2>/dev/null || + /usr/libexec/PlistBuddy -c "Add :aps-environment string $APS_ENVIRONMENT" "$ENTITLEMENTS_FILE" +fi + +cd ios +ensure_cocoapods +pod install --deployment + +PODS_RELEASE_XCCONFIG="Pods/Target Support Files/Pods-app/Pods-app.release.xcconfig" +if [ ! -f "$PODS_RELEASE_XCCONFIG" ]; then + echo "Missing $PODS_RELEASE_XCCONFIG after pod install. Xcode archive cannot continue without CocoaPods base configuration." >&2 + exit 1 +fi diff --git a/plugins/ios-prebuild-support/xcode.env b/plugins/ios-prebuild-support/xcode.env new file mode 100644 index 0000000..a274d65 --- /dev/null +++ b/plugins/ios-prebuild-support/xcode.env @@ -0,0 +1,46 @@ +# This `.xcode.env` file is versioned and is used to source the environment +# used when running script phases inside Xcode. +# To customize your local environment, you can create an `.xcode.env.local` +# file that is not versioned. + +# NODE_BINARY variable contains the PATH to the node executable. +# +# Customize the NODE_BINARY variable here. +# For example, to use nvm with brew, add the following line +# . "$(brew --prefix nvm)/nvm.sh" --no-use +NODE_FORMULA="${NODE_FORMULA:-node@22}" + +if [ -z "${NODE_BINARY:-}" ]; then + if command -v node >/dev/null 2>&1; then + export NODE_BINARY="$(command -v node)" + else + NODE_BINARY_CANDIDATE="" + + if command -v brew >/dev/null 2>&1; then + NODE_PREFIX="$(brew --prefix "$NODE_FORMULA" 2>/dev/null || true)" + if [ -n "$NODE_PREFIX" ]; then + NODE_BINARY_CANDIDATE="$NODE_PREFIX/bin/node" + fi + fi + + if [ -z "$NODE_BINARY_CANDIDATE" ] || [ ! -x "$NODE_BINARY_CANDIDATE" ]; then + for NODE_BINARY_CANDIDATE in \ + "/opt/homebrew/opt/$NODE_FORMULA/bin/node" \ + "/usr/local/opt/$NODE_FORMULA/bin/node" \ + /opt/homebrew/bin/node \ + /usr/local/bin/node; do + if [ -x "$NODE_BINARY_CANDIDATE" ]; then + break + fi + done + fi + + if [ -x "$NODE_BINARY_CANDIDATE" ]; then + export PATH="$(dirname "$NODE_BINARY_CANDIDATE"):$PATH" + export NODE_BINARY="$NODE_BINARY_CANDIDATE" + else + echo "error: Node.js was not found. Run ci_post_clone.sh or install Node.js." >&2 + exit 1 + fi + fi +fi diff --git a/plugins/withIosPrebuildFixes.js b/plugins/withIosPrebuildFixes.js new file mode 100644 index 0000000..fd4fa57 --- /dev/null +++ b/plugins/withIosPrebuildFixes.js @@ -0,0 +1,215 @@ +const fs = require('fs'); +const path = require('path'); +const { withFinalizedMod, withPodfile, withXcodeProject } = require('@expo/config-plugins'); + +const SUPPORT_DIR = path.join(__dirname, 'ios-prebuild-support'); + +const RNFB_SCRIPT_PHASE_NAMES = new Set([ + '[CP-User] [RNFB] Core Configuration', + '[CP-User] [RNFB] Crashlytics Configuration', +]); + +const RNFB_PODFILE_BLOCK = `RNFB_SCRIPT_PHASES = [ + '[CP-User] [RNFB] Core Configuration', + '[CP-User] [RNFB] Crashlytics Configuration', +].freeze + +def configure_rnfb_script_phases(installer) + installer.aggregate_targets.each do |aggregate_target| + project = aggregate_target.user_project + next unless project + + updated = false + + project.targets.each do |target| + next unless target.respond_to?(:shell_script_build_phases) + + target.shell_script_build_phases.each do |phase| + next unless RNFB_SCRIPT_PHASES.include?(phase.name) + + phase.always_out_of_date = '1' + updated = true + end + end + + project.save if updated + end +end +`; + +const PODFILE_BUILD_FIXES_BLOCK = `def set_xcconfig_build_setting(path, key, value) + return unless path && File.exist?(path) + + contents = File.read(path) + setting = "#{key} = #{value}" + + updated_contents = + if contents.match?(/^#{Regexp.escape(key)}\\s*=/) + contents.gsub(/^#{Regexp.escape(key)}\\s*=.*$/, setting) + else + "#{setting}\\n#{contents}" + end + + File.write(path, updated_contents) if updated_contents != contents +end + +def configure_fmt_for_xcode_26(installer) + fmt_target = installer.pods_project.targets.find { |target| target.name == 'fmt' } + return unless fmt_target + + fmt_target.build_configurations.each do |config| + config.build_settings['CLANG_CXX_LANGUAGE_STANDARD'] = 'c++17' + set_xcconfig_build_setting( + config.base_configuration_reference&.real_path, + 'CLANG_CXX_LANGUAGE_STANDARD', + 'c++17' + ) + end +end + +def normalize_pods_deployment_target(installer, deployment_target) + installer.pods_project.targets.each do |target| + target.build_configurations.each do |config| + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = deployment_target + end + end +end +`; + +const CCACHE_FUNCTION_END = ` podfile_properties['apple.ccacheEnabled'] == 'true' +end + +`; + +const GENERATED_PLATFORM_LINE = "platform :ios, podfile_properties['ios.deploymentTarget'] || '15.1'"; +const DEPLOYMENT_TARGET_LINES = `ios_deployment_target = podfile_properties['ios.deploymentTarget'] || '15.1' +platform :ios, ios_deployment_target`; + +const POST_INSTALL_ANCHOR = ` react_native_post_install( + installer, + config[:reactNativePath], + :mac_catalyst_enabled => false, + :ccache_enabled => ccache_enabled?(podfile_properties), + )`; + +const POST_INSTALL_FIXES = `${POST_INSTALL_ANCHOR} + + configure_fmt_for_xcode_26(installer) + normalize_pods_deployment_target(installer, ios_deployment_target)`; + +const POST_INTEGRATE_BLOCK = `post_integrate do |installer| + configure_rnfb_script_phases(installer) +end +`; + +function insertAfter(source, anchor, insertion) { + if (!source.includes(anchor)) { + throw new Error(`Unable to find expected Podfile anchor: ${anchor.split('\n')[0]}`); + } + + return source.replace(anchor, `${anchor}${insertion}`); +} + +function applyPodfileFixes(source) { + let contents = source; + + if (!contents.includes('RNFB_SCRIPT_PHASES = [')) { + contents = insertAfter(contents, CCACHE_FUNCTION_END, `${RNFB_PODFILE_BLOCK}\n`); + } + + if (!contents.includes(DEPLOYMENT_TARGET_LINES)) { + contents = contents.replace(GENERATED_PLATFORM_LINE, DEPLOYMENT_TARGET_LINES); + } + + if (!contents.includes('def configure_fmt_for_xcode_26(installer)')) { + contents = insertAfter(contents, `${DEPLOYMENT_TARGET_LINES}\n\n`, `${PODFILE_BUILD_FIXES_BLOCK}\n`); + } + + if (!contents.includes(' configure_fmt_for_xcode_26(installer)')) { + contents = contents.replace(POST_INSTALL_ANCHOR, POST_INSTALL_FIXES); + } + + if (!contents.includes('post_integrate do |installer|')) { + contents = `${contents.trimEnd()}\n\n${POST_INTEGRATE_BLOCK}`; + } + + return contents; +} + +function normalizeXcodeString(value) { + return String(value || '') + .replace(/^"/, '') + .replace(/"$/, '') + .replace(/\\"/g, '"'); +} + +function applyXcodeProjectFixes(project) { + const shellScriptBuildPhases = project.hash?.project?.objects?.PBXShellScriptBuildPhase; + + for (const phase of Object.values(shellScriptBuildPhases || {})) { + if (!phase || typeof phase !== 'object' || phase.isa !== 'PBXShellScriptBuildPhase') { + continue; + } + + const phaseName = normalizeXcodeString(phase.name); + if (RNFB_SCRIPT_PHASE_NAMES.has(phaseName)) { + phase.alwaysOutOfDate = 1; + } + } + + return project; +} + +function copySupportFile(sourceName, destinationPath, mode) { + fs.mkdirSync(path.dirname(destinationPath), { recursive: true }); + fs.copyFileSync(path.join(SUPPORT_DIR, sourceName), destinationPath); + + if (mode) { + fs.chmodSync(destinationPath, mode); + } +} + +function removeAppTestsFromScheme(schemeContents) { + return schemeContents.replace(/\n\s*/g, (block) => { + if (block.includes('BuildableName = "appTests.xctest"') || block.includes('BlueprintName = "appTests"')) { + return ''; + } + + return block; + }); +} + +function applyFinalizedFileFixes(platformProjectRoot) { + copySupportFile('xcode.env', path.join(platformProjectRoot, '.xcode.env')); + copySupportFile('ci_post_clone.sh', path.join(platformProjectRoot, 'ci_scripts', 'ci_post_clone.sh'), 0o755); + copySupportFile( + 'IDEWorkspaceChecks.plist', + path.join(platformProjectRoot, 'app.xcworkspace', 'xcshareddata', 'IDEWorkspaceChecks.plist') + ); + + const schemePath = path.join(platformProjectRoot, 'app.xcodeproj', 'xcshareddata', 'xcschemes', 'app.xcscheme'); + if (fs.existsSync(schemePath)) { + const schemeContents = fs.readFileSync(schemePath, 'utf8'); + fs.writeFileSync(schemePath, removeAppTestsFromScheme(schemeContents), 'utf8'); + } +} + +module.exports = function withIosPrebuildFixes(config) { + config = withPodfile(config, (config) => { + config.modResults.contents = applyPodfileFixes(config.modResults.contents); + return config; + }); + + config = withXcodeProject(config, (config) => { + config.modResults = applyXcodeProjectFixes(config.modResults); + return config; + }); + + return withFinalizedMod(config, [ + 'ios', + (config) => { + applyFinalizedFileFixes(config.modRequest.platformProjectRoot); + return config; + }, + ]); +}; diff --git a/services/app-bootstrap.service.ts b/services/app-bootstrap.service.ts index 1cce1d0..fdcf007 100644 --- a/services/app-bootstrap.service.ts +++ b/services/app-bootstrap.service.ts @@ -2,30 +2,74 @@ import { ensureAccessToken } from './auth-token.service'; import { getJwtSubject } from './auth-token-storage'; import { getFcmToken, initializeFcm, sendFcmTokenToServer } from './fcm.service'; import { fetchSubscribedClubIdsByAccessToken, saveSubscribedClubIdsToStorage } from './subscription.service'; -import { identifyMixpanel } from '@/utils/mixpanel'; +import { getOrCreateMixpanelSessionId, identifyMixpanel } from '@/utils/mixpanel'; -export async function runAppBootstrap(): Promise<{ subscribedClubCount: number }> { - const accessToken = await ensureAccessToken(); +export type BootstrapTimings = Record; + +export interface BootstrapResult { + accessToken: string; + subject: string; + sessionId: string; + subscribedClubIds: string[]; + timings: BootstrapTimings; +} +async function registerFcmToken(): Promise { await initializeFcm({ strict: false, promptForPermission: false }); const fcmToken = await getFcmToken(); if (fcmToken) { await sendFcmTokenToServer(fcmToken); } +} +async function syncSubscribedClubIds(): Promise { const subscribedClubIds = await fetchSubscribedClubIdsByAccessToken(); await saveSubscribedClubIdsToStorage(subscribedClubIds); + return subscribedClubIds; +} - const subject = getJwtSubject(accessToken); - if (!subject) { - throw new Error('JWT에서 식별 가능한 사용자 ID를 찾을 수 없습니다.'); - } - +async function initializeMixpanelIdentity(subject: string): Promise { + const sessionId = await getOrCreateMixpanelSessionId(); const identified = await identifyMixpanel(`user:${subject}`); if (!identified) { throw new Error('Mixpanel 초기화에 실패했습니다.'); } - return { subscribedClubCount: subscribedClubIds.length }; + return sessionId; } +export async function runAppBootstrap(): Promise { + const timings: BootstrapTimings = { + bootstrapStart: Date.now(), + }; + + const accessToken = await ensureAccessToken(); + timings.accessTokenReady = Date.now(); + + const subject = getJwtSubject(accessToken); + if (!subject) { + throw new Error('JWT에서 식별 가능한 사용자 ID를 찾을 수 없습니다.'); + } + + const [subscribedClubIds, sessionId] = await Promise.all([ + syncSubscribedClubIds().finally(() => { + timings.subscriptionsReady = Date.now(); + }), + initializeMixpanelIdentity(subject).finally(() => { + timings.mixpanelReady = Date.now(); + }), + registerFcmToken().finally(() => { + timings.fcmReady = Date.now(); + }), + ]); + + timings.bootstrapEnd = Date.now(); + + return { + accessToken, + subject, + sessionId, + subscribedClubIds, + timings, + }; +} diff --git a/services/auth-token-storage.ts b/services/auth-token-storage.ts index 3a6c774..0d9220a 100644 --- a/services/auth-token-storage.ts +++ b/services/auth-token-storage.ts @@ -3,6 +3,9 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; const ACCESS_TOKEN_KEY = '@access_token'; const AUTH_SUBJECT_KEY = '@auth_subject'; +let cachedAccessToken: string | null | undefined; +let cachedAuthSubject: string | null | undefined; + function generateUuidV4(): string { const template = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'; return template.replace(/[xy]/g, (char) => { @@ -13,31 +16,45 @@ function generateUuidV4(): string { } export async function getStoredAccessToken(): Promise { + if (cachedAccessToken !== undefined) { + return cachedAccessToken; + } + try { - return await AsyncStorage.getItem(ACCESS_TOKEN_KEY); + cachedAccessToken = await AsyncStorage.getItem(ACCESS_TOKEN_KEY); + return cachedAccessToken; } catch (error) { console.error('❌ Access Token 조회 실패:', error); + cachedAccessToken = null; return null; } } export async function saveAccessToken(token: string): Promise { + cachedAccessToken = token; await AsyncStorage.setItem(ACCESS_TOKEN_KEY, token); } export async function getOrCreateAuthSubject(): Promise { + if (cachedAuthSubject !== undefined && cachedAuthSubject !== null) { + return cachedAuthSubject; + } + try { const stored = await AsyncStorage.getItem(AUTH_SUBJECT_KEY); if (stored) { + cachedAuthSubject = stored; return stored; } const subject = generateUuidV4(); + cachedAuthSubject = subject; await AsyncStorage.setItem(AUTH_SUBJECT_KEY, subject); return subject; } catch (error) { console.warn('⚠️ auth subject 저장 실패, 임시 값 사용:', error); - return generateUuidV4(); + cachedAuthSubject = generateUuidV4(); + return cachedAuthSubject; } } @@ -70,4 +87,3 @@ export function getJwtSubject(accessToken: string): string | null { return null; } } - diff --git a/services/auth-token.service.ts b/services/auth-token.service.ts index 8d4efd1..254b401 100644 --- a/services/auth-token.service.ts +++ b/services/auth-token.service.ts @@ -16,27 +16,28 @@ type IssueAccessTokenPayload = { }; function extractAccessToken(response: IssueAccessTokenResponse): string | null { - if (!response || typeof response !== 'object') { + const payload = response as any; + if (!payload || typeof payload !== 'object') { return null; } const directToken = - 'accessToken' in response && typeof response.accessToken === 'string' - ? response.accessToken - : 'token' in response && typeof response.token === 'string' - ? response.token + typeof payload.accessToken === 'string' + ? payload.accessToken + : typeof payload.token === 'string' + ? payload.token : null; if (directToken) { return directToken; } - if ('data' in response && response.data && typeof response.data === 'object') { - if (typeof response.data.accessToken === 'string') { - return response.data.accessToken; + if (payload.data && typeof payload.data === 'object') { + if (typeof payload.data.accessToken === 'string') { + return payload.data.accessToken; } - if (typeof response.data.token === 'string') { - return response.data.token; + if (typeof payload.data.token === 'string') { + return payload.data.token; } } @@ -68,4 +69,3 @@ export async function ensureAccessToken(): Promise { return issueAccessToken(); } - diff --git a/services/force-update.service.ts b/services/force-update.service.ts index 9dd29fb..7ecbbd1 100644 --- a/services/force-update.service.ts +++ b/services/force-update.service.ts @@ -14,7 +14,10 @@ import { firebaseConfig } from '@/constants/firebase-config'; type FirebaseApp = any; +const MIN_SUPPORTED_VERSION_KEY = 'min_supported_version'; + let firebaseAppPromise: Promise | null = null; +let remoteConfigSetupPromise: Promise | null = null; const ensureFirebaseApp = (): Promise => { if (firebaseAppPromise) { @@ -40,6 +43,30 @@ const ensureFirebaseApp = (): Promise => { return firebaseAppPromise; }; +const ensureRemoteConfigSetup = async (): Promise => { + if (remoteConfigSetupPromise) { + return remoteConfigSetupPromise; + } + + remoteConfigSetupPromise = (async () => { + await ensureFirebaseApp(); + + await remoteConfig().setDefaults({ + [MIN_SUPPORTED_VERSION_KEY]: '', + }); + + await remoteConfig().setConfigSettings({ + fetchTimeMillis: 5000, + minimumFetchIntervalMillis: __DEV__ ? 0 : 60 * 60 * 1000, // 1h + }); + })().catch((error) => { + remoteConfigSetupPromise = null; + throw error; + }); + + return remoteConfigSetupPromise; +}; + function getNativeAppVersion(): string | undefined { const version = Application.nativeApplicationVersion ?? @@ -65,65 +92,80 @@ function isVersionLessThan(current: string, min: string): boolean { return false; } -export async function checkForceUpdateRequired(): Promise { +function evaluateForceUpdateRequired(options: { + mode: 'cached' | 'refresh'; + fetchedRemotely?: boolean; +}): boolean { + const rcMinVersion = remoteConfig().getValue(MIN_SUPPORTED_VERSION_KEY); + const minSupportedVersion = rcMinVersion.asString(); + + const currentVersion = getNativeAppVersion(); + const versionBlocked = + !!currentVersion && + !!minSupportedVersion && + isVersionLessThan(currentVersion, minSupportedVersion); + + const required = versionBlocked; + + console.log('🧩 Remote Config (force update)', { + mode: options.mode, + fetchedRemotely: options.fetchedRemotely, + keys: { + [MIN_SUPPORTED_VERSION_KEY]: { + valueRaw: minSupportedVersion, + source: rcMinVersion.getSource?.() ?? 'unknown', + }, + }, + app: { + currentVersion, + versionBlocked, + }, + required, + lastFetchStatus: remoteConfig().lastFetchStatus, + fetchTimeMillis: remoteConfig().fetchTimeMillis, + }); + + return required; +} + +export async function getCachedForceUpdateRequired(): Promise { // 네이티브 앱(iOS/Android)만 대상. 웹/기타 플랫폼은 false 처리. if (Platform.OS !== 'ios' && Platform.OS !== 'android') { return false; } try { - console.log('🧪 Remote Config(force update) 시작'); - await ensureFirebaseApp(); - - await remoteConfig().setDefaults({ - // 권장: 최소 지원 버전(예: "1.2.0") - min_supported_version: '', - }); + console.log('🧪 Remote Config(force update) 캐시 판정 시작'); + await ensureRemoteConfigSetup(); + return evaluateForceUpdateRequired({ mode: 'cached' }); + } catch (error) { + console.warn('⚠️ 강제 업데이트 캐시 체크 실패 (force update):', error); + return false; + } +} - await remoteConfig().setConfigSettings({ - fetchTimeMillis: 5000, - minimumFetchIntervalMillis: __DEV__ ? 0 : 60 * 60 * 1000, // 1h - }); +export async function refreshForceUpdateRequired(): Promise { + // 네이티브 앱(iOS/Android)만 대상. 웹/기타 플랫폼은 false 처리. + if (Platform.OS !== 'ios' && Platform.OS !== 'android') { + return false; + } + try { + console.log('🧪 Remote Config(force update) 백그라운드 갱신 시작'); + await ensureRemoteConfigSetup(); // fetch 실패해도 기존 활성값/기본값으로 판단 가능하도록 catch const fetchedRemotely = await remoteConfig().fetchAndActivate().catch((error) => { console.warn('⚠️ Remote Config fetchAndActivate 실패 (force update):', error); return false; }); - const rcMinVersion = remoteConfig().getValue('min_supported_version'); - const minSupportedVersion = rcMinVersion.asString(); - - const currentVersion = getNativeAppVersion(); - const versionBlocked = - !!currentVersion && - !!minSupportedVersion && - isVersionLessThan(currentVersion, minSupportedVersion); - - const required = versionBlocked; - - // 디버깅 로그: Remote Config에서 무엇을 받았는지 확인 - console.log('🧩 Remote Config (force update)', { - fetchedRemotely, - keys: { - min_supported_version: { - valueRaw: minSupportedVersion, - source: rcMinVersion.getSource?.() ?? 'unknown', - }, - }, - app: { - currentVersion, - versionBlocked, - }, - required, - lastFetchStatus: remoteConfig().lastFetchStatus, - fetchTimeMillis: remoteConfig().fetchTimeMillis, - }); - - return required; + return evaluateForceUpdateRequired({ mode: 'refresh', fetchedRemotely }); } catch (error) { - console.warn('⚠️ 강제 업데이트 체크 실패 (force update):', error); + console.warn('⚠️ 강제 업데이트 백그라운드 갱신 실패 (force update):', error); return false; } } +export async function checkForceUpdateRequired(): Promise { + return refreshForceUpdateRequired(); +} diff --git a/ui/club-detail/club-detail-screen.tsx b/ui/club-detail/club-detail-screen.tsx index df92b89..fb39dd0 100644 --- a/ui/club-detail/club-detail-screen.tsx +++ b/ui/club-detail/club-detail-screen.tsx @@ -2,9 +2,10 @@ import { MoaImage } from "@/components/moa-image"; import { MoaText } from "@/components/moa-text"; import { PermissionDialog } from "@/components/permission-dialog"; import { USER_EVENT } from "@/constants/eventname"; -import { useMixpanelContext } from "@/contexts"; +import { useMixpanelContext } from "@/contexts/mixpanel-context"; import { useSubscribedClubsContext } from "@/contexts/subscribed-clubs-context"; -import { useMixpanelTrack, useWebViewMessageHandler } from "@/hooks"; +import { useMixpanelTrack } from "@/hooks/use-mixpanel-track"; +import { useWebViewMessageHandler } from "@/hooks/use-webview-message-handler"; import { Ionicons } from "@expo/vector-icons"; import { appendSessionId, getWebViewUserAgent } from "@/utils/webview"; import { useLocalSearchParams, useRouter } from "expo-router"; diff --git a/ui/home/components/banner.tsx b/ui/home/components/banner.tsx index 69f26a5..25cdd42 100644 --- a/ui/home/components/banner.tsx +++ b/ui/home/components/banner.tsx @@ -1,7 +1,7 @@ import { MoaImage } from "@/components/moa-image"; import { USER_EVENT } from "@/constants/eventname"; import { BorderRadius, Spacing } from "@/constants/theme"; -import { useMixpanelTrack } from "@/hooks"; +import { useMixpanelTrack } from "@/hooks/use-mixpanel-track"; import { publicApi } from "@/services/api"; import { BannerProps, HomeBannerItem } from "@/ui/home/model/banner"; import { useRouter } from "expo-router"; diff --git a/ui/home/components/club-list.tsx b/ui/home/components/club-list.tsx index 682d3f1..5a8a3e0 100644 --- a/ui/home/components/club-list.tsx +++ b/ui/home/components/club-list.tsx @@ -4,7 +4,7 @@ import { MoaText } from '@/components/moa-text'; import { Club } from '@/types/club.types'; -import { ClubCard } from '@/ui/home/components'; +import { ClubCard } from '@/ui/home/components/club-card'; import React, { ReactElement, RefObject } from 'react'; import { FlatList, RefreshControl, TouchableOpacity } from 'react-native'; import styled from 'styled-components/native'; diff --git a/ui/home/home-screen.tsx b/ui/home/home-screen.tsx index fa438c2..a1f5214 100644 --- a/ui/home/home-screen.tsx +++ b/ui/home/home-screen.tsx @@ -8,17 +8,16 @@ import { useRouter } from 'expo-router'; import { CategoryType } from '@/components/icon'; import { PermissionDialog } from '@/components/permission-dialog'; 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 { Club } from '@/types/club.types'; -import { - Banner, - CategoryFilter, - ClubList, - MainHeader, - Tab, - TabType -} from '@/ui/home/components'; -import { useClubs, useSubscribedClubs } from '@/ui/home/hook'; +import { Banner } from '@/ui/home/components/banner'; +import { CategoryFilter } from '@/ui/home/components/category-filter'; +import { ClubList } from '@/ui/home/components/club-list'; +import { MainHeader } from '@/ui/home/components/main-header'; +import { Tab, TabType } from '@/ui/home/components/tab'; +import { useClubs } from '@/ui/home/hook/use-clubs'; +import { useSubscribedClubs } from '@/ui/home/hook/use-subscribed-clubs'; export function HomeScreen() { // SafeArea insets @@ -246,4 +245,4 @@ const CategorySection = styled.View` margin-bottom: 20px; `; -export default HomeScreen; \ No newline at end of file +export default HomeScreen; diff --git a/ui/home/home-webview-screen.tsx b/ui/home/home-webview-screen.tsx index c6da484..587a2be 100644 --- a/ui/home/home-webview-screen.tsx +++ b/ui/home/home-webview-screen.tsx @@ -1,4 +1,4 @@ -import { useMixpanelContext } from '@/contexts'; +import { useMixpanelContext } from '@/contexts/mixpanel-context'; import { useSubscribedClubsContext } from '@/contexts/subscribed-clubs-context'; import { appendSessionId, getWebViewUserAgent } from '@/utils/webview'; import { useRouter } from 'expo-router'; @@ -27,6 +27,12 @@ export function HomeWebViewScreen({ onError }: HomeWebViewScreenProps) { const url = sessionLoading ? null : appendSessionId(BASE_URL, sessionId); + useEffect(() => { + if (url) { + console.log('[StartupTiming] homeWebViewSourceReady', Date.now()); + } + }, [url]); + const sendMessage = useCallback((data: object) => { webViewRef.current?.injectJavaScript( `window.dispatchEvent(new MessageEvent('message', { data: ${JSON.stringify(JSON.stringify(data))} })); true;`, @@ -90,6 +96,7 @@ export function HomeWebViewScreen({ onError }: HomeWebViewScreenProps) { ); const handleLoadEnd = useCallback(() => { + console.log('[StartupTiming] homeWebViewLoadEnd', Date.now()); setLoaded(true); }, []); diff --git a/ui/subscribe/components/empty-state.tsx b/ui/subscribe/components/empty-state.tsx index 74d73ca..27926c3 100644 --- a/ui/subscribe/components/empty-state.tsx +++ b/ui/subscribe/components/empty-state.tsx @@ -6,7 +6,7 @@ import { MoaImage } from '@/components/moa-image'; import { MoaText } from '@/components/moa-text'; import { USER_EVENT } from '@/constants/eventname'; import { MainColors } from '@/constants/theme'; -import { useMixpanelTrack } from '@/hooks'; +import { useMixpanelTrack } from '@/hooks/use-mixpanel-track'; import { useRouter } from 'expo-router'; import React from 'react'; import { TouchableOpacity } from 'react-native'; @@ -87,4 +87,3 @@ const HomeButton = styled(TouchableOpacity)` const ButtonText = styled(MoaText)` color: #FFFFFF; `; - diff --git a/ui/subscribe/subscribe-screen.tsx b/ui/subscribe/subscribe-screen.tsx index 7cb9e07..dc40b57 100644 --- a/ui/subscribe/subscribe-screen.tsx +++ b/ui/subscribe/subscribe-screen.tsx @@ -5,7 +5,8 @@ import { MoaText } from '@/components/moa-text'; import { PermissionDialog } from '@/components/permission-dialog'; 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 { Club } from '@/types/club.types'; import { useRouter } from 'expo-router'; import React, { RefObject, useCallback, useRef, useState } from 'react'; @@ -203,4 +204,3 @@ const RetryButtonText = styled(MoaText)` `; export default SubscribeScreen; - diff --git a/utils/mixpanel.ts b/utils/mixpanel.ts index 72a5b3a..2a80ecb 100644 --- a/utils/mixpanel.ts +++ b/utils/mixpanel.ts @@ -1,9 +1,35 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; import { Mixpanel } from 'mixpanel-react-native'; +const SESSION_ID_KEY = '@moadong_session_id'; + let mixpanelInstance: Mixpanel | null = null; let isInitialized = false; let initializationPromise: Promise | null = null; +const generateSessionId = (): string => { + const timestamp = Date.now().toString(36); + const randomStr = Math.random().toString(36).substring(2, 15); + return `moadong_${timestamp}_${randomStr}`; +}; + +export const getOrCreateMixpanelSessionId = async (): Promise => { + try { + const storedSessionId = await AsyncStorage.getItem(SESSION_ID_KEY); + if (storedSessionId) { + return storedSessionId; + } + + const newSessionId = generateSessionId(); + await AsyncStorage.setItem(SESSION_ID_KEY, newSessionId); + console.log('[Mixpanel] 새 Session ID 생성:', newSessionId); + return newSessionId; + } catch (error) { + console.error('[Mixpanel] Session ID 로드/저장 실패:', error); + return generateSessionId(); + } +}; + export const getMixpanel = async (): Promise => { if (mixpanelInstance && isInitialized) { return mixpanelInstance;