diff --git a/.github/workflows/ios_build.yml b/.github/workflows/ios_build.yml index 6b83c9c..64b69cc 100644 --- a/.github/workflows/ios_build.yml +++ b/.github/workflows/ios_build.yml @@ -11,6 +11,7 @@ on: - development - preview - production + - stage jobs: build: runs-on: macos-26 diff --git a/app.config.ts b/app.config.ts index a9d1520..136cd5b 100644 --- a/app.config.ts +++ b/app.config.ts @@ -1,7 +1,9 @@ import { ExpoConfig } from 'expo/config'; const APP_ENV = process.env.EXPO_PUBLIC_APP_ENV || 'production'; -const appName = APP_ENV === 'development' ? 'KONECT D' : 'KONECT'; +const API_ENV = process.env.EXPO_PUBLIC_API_ENV || APP_ENV; +const appName = + APP_ENV === 'development' ? 'KONECT D' : API_ENV === 'development' ? 'KONECT S' : 'KONECT'; const packageName = APP_ENV === 'development' ? 'com.bcsdlab.konect.dev' : 'com.bcsdlab.konect'; const googleServicesFile = APP_ENV === 'development' ? './google-services-debug.json' : './google-services.json'; @@ -20,7 +22,7 @@ const config: ExpoConfig = { supportsTablet: true, usesAppleSignIn: true, bundleIdentifier: packageName, - buildNumber: '1010500', + buildNumber: '1010604', infoPlist: { ITSAppUsesNonExemptEncryption: false, }, diff --git a/app/_layout.tsx b/app/_layout.tsx index 281c9eb..0d1aacf 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -4,8 +4,9 @@ import { AppState } from 'react-native'; import { getForceUpdate, appVersion, versionToNumber } from '../services/forceupdate'; import * as Notifications from 'expo-notifications'; import { registerForPushNotificationsAsync, shouldRecheckPermission } from '../services/notifications'; -import CookieManager from '@react-native-cookies/cookies'; -import { storePushToken } from '../utils/pushTokenStore'; +import { storePushToken, initPushTokenStore, getStoredToken } from '../utils/pushTokenStore'; +import { getAccessToken } from '../services/nativeAuthStore'; +import { registerPushToken } from '../services/pushTokenApi'; Notifications.setNotificationHandler({ handleNotification: async () => ({ @@ -16,14 +17,6 @@ Notifications.setNotificationHandler({ }), }); -function addTokenToCookie(token: string) { - CookieManager.set('https://agit.gg', { - name: 'EXPO_PUSH_TOKEN', - value: token, - domain: '.agit.gg', - path: '/', - }); -} export default function RootLayout() { const { replace } = useRouter(); @@ -34,11 +27,28 @@ export default function RootLayout() { }, []); useEffect(() => { - const handleToken = (token?: string) => { + initPushTokenStore(); + }, []); + + useEffect(() => { + let tokenObtained = false; + let permissionDenied = false; + + const handleToken = async (token?: string) => { if (token) { - addTokenToCookie(token); - storePushToken(token); + tokenObtained = true; + permissionDenied = false; + await storePushToken(token); console.log('Expo Push Token:', token); + + const accessToken = await getAccessToken(); + if (accessToken) { + registerPushToken(token).catch((e) => + console.error('자동 푸시 토큰 등록 실패:', e) + ); + } + } else { + permissionDenied = true; } }; @@ -47,7 +57,10 @@ export default function RootLayout() { .catch((error: any) => console.error(error)); const subscription = AppState.addEventListener('change', (nextAppState) => { - if (nextAppState === 'active' && shouldRecheckPermission()) { + if (nextAppState !== 'active') return; + + const fromSettings = shouldRecheckPermission(); + if (fromSettings || (!tokenObtained && !permissionDenied)) { registerForPushNotificationsAsync() .then(handleToken) .catch((error: any) => console.error(error)); diff --git a/app/webview/[path].tsx b/app/webview/[path].tsx index d47b783..a26eaa7 100644 --- a/app/webview/[path].tsx +++ b/app/webview/[path].tsx @@ -10,15 +10,19 @@ import { AppStateStatus, } from 'react-native'; import { Slot, useLocalSearchParams } from 'expo-router'; -import { WebView } from 'react-native-webview'; +import { WebView, WebViewMessageEvent } from 'react-native-webview'; import { SafeAreaView } from 'react-native-safe-area-context'; import CookieManager from '@react-native-cookies/cookies'; import { generateUserAgent } from '../../utils/userAgent'; import { ShouldStartLoadRequest } from 'react-native-webview/lib/WebViewTypes'; import { webUrl } from '../../constants/constants'; -import { onPushToken } from '../../utils/pushTokenStore'; +import { getStoredToken } from '../../utils/pushTokenStore'; +import { saveAccessToken, clearAccessToken } from '../../services/nativeAuthStore'; +import { registerPushToken, unregisterPushToken } from '../../services/pushTokenApi'; const ALLOWED_URL_SCHEMES = ['kakaotalk', 'nidlogin']; +const ALLOWED_ORIGINS = [new URL(webUrl).origin]; + const userAgent = generateUserAgent(); const handleOnShouldStartLoadWithRequest = ({ url }: ShouldStartLoadRequest) => { @@ -42,37 +46,63 @@ const handleOnShouldStartLoadWithRequest = ({ url }: ShouldStartLoadRequest) => export default function Index() { const webViewRef = useRef(null); const canGoBackRef = useRef(false); - const pageLoadedRef = useRef(false); - const pendingTokenRef = useRef(null); const local = useLocalSearchParams(); - const injectPushToken = useCallback((token: string) => { - const safeToken = JSON.stringify(token); - webViewRef.current?.injectJavaScript(` - window.dispatchEvent(new MessageEvent('message', { - data: JSON.stringify({ type: 'PUSH_TOKEN', token: ${safeToken} }) - })); - true; - `); - }, []); + const handleMessage = useCallback(async (event: WebViewMessageEvent) => { + const origin = event.nativeEvent.url; + if (!origin || !ALLOWED_ORIGINS.some((allowed) => origin.startsWith(allowed))) { + return; + } - useEffect(() => { - return onPushToken((token) => { - if (pageLoadedRef.current) { - injectPushToken(token); - } else { - pendingTokenRef.current = token; - } - }); - }, [injectPushToken]); + try { + const data = JSON.parse(event.nativeEvent.data); + const { type } = data; - const handleLoadEnd = useCallback(() => { - pageLoadedRef.current = true; - if (pendingTokenRef.current) { - injectPushToken(pendingTokenRef.current); - pendingTokenRef.current = null; + if (type === 'LOGIN_COMPLETE') { + const { accessToken } = data; + if (!accessToken) return; + + await saveAccessToken(accessToken); + console.log('LOGIN_COMPLETE: accessToken 저장 완료'); + + const pushToken = getStoredToken(); + if (pushToken) { + try { + await registerPushToken(pushToken); + console.log('푸시 토큰 백엔드 등록 완료'); + webViewRef.current?.injectJavaScript( + `window.dispatchEvent(new CustomEvent('NOTIFICATION_STATUS', { detail: { registered: true } }));true;` + ); + } catch (e) { + console.error('푸시 토큰 등록 실패:', e); + webViewRef.current?.injectJavaScript( + `window.dispatchEvent(new CustomEvent('NOTIFICATION_STATUS', { detail: { registered: false } }));true;` + ); + } + } + } else if (type === 'TOKEN_REFRESH') { + const { accessToken } = data; + if (accessToken) { + await saveAccessToken(accessToken); + console.log('TOKEN_REFRESH: accessToken 갱신 완료'); + } + } else if (type === 'LOGOUT') { + const pushToken = getStoredToken(); + if (pushToken) { + try { + await unregisterPushToken(pushToken); + console.log('푸시 토큰 백엔드 삭제 완료'); + } catch (e) { + console.error('푸시 토큰 삭제 실패:', e); + } + } + await clearAccessToken(); + console.log('LOGOUT: accessToken 삭제 완료'); + } + } catch { + // JSON 파싱 실패 등 무시 } - }, [injectPushToken]); + }, []); useEffect(() => { if (Platform.OS === 'android') { @@ -121,7 +151,7 @@ export default function Index() { onShouldStartLoadWithRequest={handleOnShouldStartLoadWithRequest} originWhitelist={['*']} startInLoadingState - onLoadEnd={handleLoadEnd} + onMessage={handleMessage} /> ); diff --git a/constants/constants.ts b/constants/constants.ts index 2beee39..b08029f 100644 --- a/constants/constants.ts +++ b/constants/constants.ts @@ -1,5 +1,6 @@ const APP_ENV = process.env.EXPO_PUBLIC_APP_ENV || 'production'; +const API_ENV = process.env.EXPO_PUBLIC_API_ENV || APP_ENV; export const apiUrl = - APP_ENV === 'development' ? 'https://api.stage.agit.gg' : 'https://api.agit.gg'; -export const webUrl = APP_ENV === 'development' ? 'https://stage.agit.gg' : 'https://agit.gg'; + API_ENV === 'development' ? 'https://api.stage.agit.gg' : 'https://api.agit.gg'; +export const webUrl = API_ENV === 'development' ? 'https://stage.agit.gg' : 'https://agit.gg'; diff --git a/eas.json b/eas.json index 347dbef..f483e58 100644 --- a/eas.json +++ b/eas.json @@ -13,6 +13,16 @@ "resourceClass": "m-medium" } }, + "stage": { + "distribution": "store", + "env": { + "EXPO_PUBLIC_APP_ENV": "production", + "EXPO_PUBLIC_API_ENV": "development" + }, + "ios": { + "resourceClass": "m-medium" + } + }, "preview": { "distribution": "internal", "android": { diff --git a/services/nativeAuthStore.ts b/services/nativeAuthStore.ts new file mode 100644 index 0000000..75399aa --- /dev/null +++ b/services/nativeAuthStore.ts @@ -0,0 +1,15 @@ +import * as SecureStore from 'expo-secure-store'; + +const ACCESS_TOKEN_KEY = 'ACCESS_TOKEN'; + +export async function saveAccessToken(token: string): Promise { + await SecureStore.setItemAsync(ACCESS_TOKEN_KEY, token); +} + +export async function getAccessToken(): Promise { + return SecureStore.getItemAsync(ACCESS_TOKEN_KEY); +} + +export async function clearAccessToken(): Promise { + await SecureStore.deleteItemAsync(ACCESS_TOKEN_KEY); +} diff --git a/services/pushTokenApi.ts b/services/pushTokenApi.ts new file mode 100644 index 0000000..7815ccd --- /dev/null +++ b/services/pushTokenApi.ts @@ -0,0 +1,55 @@ +import { apiUrl } from '../constants/constants'; +import { getAccessToken } from './nativeAuthStore'; + +const REQUEST_TIMEOUT_MS = 10_000; + +function fetchWithTimeout(url: string, options: RequestInit): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + + return fetch(url, { ...options, signal: controller.signal }).finally(() => + clearTimeout(timeoutId) + ); +} + +export async function registerPushToken(pushToken: string): Promise { + const accessToken = await getAccessToken(); + if (!accessToken) { + console.warn('registerPushToken: accessToken 없음, 등록 생략'); + return; + } + + const res = await fetchWithTimeout(`${apiUrl}/notifications/tokens`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ token: pushToken }), + }); + + if (!res.ok) { + throw new Error(`registerPushToken failed: ${res.status}`); + } +} + +export async function unregisterPushToken(pushToken: string): Promise { + const accessToken = await getAccessToken(); + if (!accessToken) { + console.warn('unregisterPushToken: accessToken 없음, 삭제 생략'); + return; + } + + const res = await fetchWithTimeout(`${apiUrl}/notifications/tokens`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ token: pushToken }), + }); + + if (!res.ok) { + throw new Error(`unregisterPushToken failed: ${res.status}`); + } +} diff --git a/utils/pushTokenStore.ts b/utils/pushTokenStore.ts index 160af38..d92b177 100644 --- a/utils/pushTokenStore.ts +++ b/utils/pushTokenStore.ts @@ -1,12 +1,26 @@ +import * as SecureStore from 'expo-secure-store'; + +const PUSH_TOKEN_KEY = 'PUSH_TOKEN'; + let _token: string | null = null; const _callbacks: ((token: string) => void)[] = []; -export const storePushToken = (token: string) => { +export async function initPushTokenStore(): Promise { + const saved = await SecureStore.getItemAsync(PUSH_TOKEN_KEY); + if (saved) { + _token = saved; + } +} + +export const storePushToken = async (token: string) => { _token = token; + await SecureStore.setItemAsync(PUSH_TOKEN_KEY, token); _callbacks.forEach((cb) => cb(token)); _callbacks.length = 0; }; +export const getStoredToken = (): string | null => _token; + export const onPushToken = (cb: (token: string) => void): (() => void) => { if (_token) { cb(_token);