diff --git a/app.config.ts b/app.config.ts index add7427..69d8ec3 100644 --- a/app.config.ts +++ b/app.config.ts @@ -73,5 +73,13 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ // ===== 기타 ===== scheme: 'frontend', - plugins: ['expo-router'], + plugins: [ + 'expo-router', + [ + 'expo-notifications', + { + mode: 'development', + }, + ], + ], }); diff --git a/app/index.tsx b/app/index.tsx index 9ed26c4..c5e9abf 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -12,6 +12,7 @@ import CalendarPage from '@/components/CalendarPage'; import SettingsSheet from '@/components/SettingsSheet'; import LoginScreen from '@/components/LoginScreen'; import { useAuth } from '@/providers/AuthProvider'; +import { registerForPushNotificationsAsync } from '@/services/pushNotifications'; import { ProfileApiError, updateUserProfile } from '@/services/userProfileService'; const HAS_ONBOARDED_KEY = 'hasOnboarded'; @@ -61,6 +62,21 @@ export default function Home() { load(); }, []); + useEffect(() => { + const registerPushToken = async () => { + try { + const token = await registerForPushNotificationsAsync(); + if (token) { + console.log('Expo push token:', token); + } + } catch (error) { + console.warn('Failed to register for push notifications', error); + } + }; + + registerPushToken(); + }, []); + useEffect(() => { if (isSignedIn) return; setIsSettingsOpen(false); @@ -88,6 +104,10 @@ export default function Home() { setNeedsOnboarding(true); }, []); + const handleSignInSuccess = useCallback(() => { + setNeedsProfileSetup(true); + }, []); + const handleOnboardingComplete = () => { setNeedsOnboarding(false); void persistHasOnboarded(); @@ -148,7 +168,12 @@ export default function Home() { } if (!isSignedIn) { - return ; + return ( + + ); } const showOnboarding = needsOnboarding && !needsProfileSetup; diff --git a/frontend/ios/Pods/Target Support Files/Pods-app/ExpoModulesProvider.swift b/frontend/ios/Pods/Target Support Files/Pods-app/ExpoModulesProvider.swift deleted file mode 100644 index a907c16..0000000 --- a/frontend/ios/Pods/Target Support Files/Pods-app/ExpoModulesProvider.swift +++ /dev/null @@ -1,52 +0,0 @@ -/** - * Automatically generated by expo-modules-autolinking. - * - * This autogenerated class provides a list of classes of native Expo modules, - * but only these that are written in Swift and use the new API for creating Expo modules. - */ - -import ExpoModulesCore -import Expo -import ExpoAsset -import EXConstants -import ExpoFileSystem -import ExpoFont -import ExpoKeepAwake -import ExpoLinking -import ExpoHead - -@objc(ExpoModulesProvider) -public class ExpoModulesProvider: ModulesProvider { - public override func getModuleClasses() -> [AnyModule.Type] { - return [ - ExpoFetchModule.self, - AssetModule.self, - ConstantsModule.self, - FileSystemModule.self, - FileSystemLegacyModule.self, - FontLoaderModule.self, - FontUtilsModule.self, - KeepAwakeModule.self, - ExpoLinkingModule.self, - ExpoHeadModule.self, - LinkPreviewNativeModule.self - ] - } - - public override func getAppDelegateSubscribers() -> [ExpoAppDelegateSubscriber.Type] { - return [ - FileSystemBackgroundSessionHandler.self, - LinkingAppDelegateSubscriber.self, - ExpoHeadAppDelegateSubscriber.self - ] - } - - public override func getReactDelegateHandlers() -> [ExpoReactDelegateHandlerTupleType] { - return [ - ] - } - - public override func getAppCodeSignEntitlements() -> AppCodeSignEntitlements { - return AppCodeSignEntitlements.from(json: #"{}"#) - } -} diff --git a/package.json b/package.json index cdd7cee..d9ab30e 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,9 @@ "buffer": "^6.0.3", "expo": "~54.0.29", "expo-constants": "^18.0.12", + "expo-device": "~8.0.10", "expo-font": "^14.0.10", + "expo-notifications": "~0.32.15", "expo-router": "^6.0.19", "expo-status-bar": "~3.0.9", "nativewind": "^4.2.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ab75d84..5f22612 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,9 +32,15 @@ importers: expo-constants: specifier: ^18.0.12 version: 18.0.12(expo@54.0.29)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0)) + expo-device: + specifier: ~8.0.10 + version: 8.0.10(expo@54.0.29) expo-font: specifier: ^14.0.10 version: 14.0.10(expo@54.0.29)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + expo-notifications: + specifier: ~0.32.15 + version: 0.32.15(expo@54.0.29)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) expo-router: specifier: ^6.0.19 version: 6.0.19(@expo/metro-runtime@6.1.2)(@types/react@19.1.17)(expo-constants@18.0.12)(expo-linking@8.0.10)(expo@54.0.29)(react-dom@19.2.3(react@19.1.0))(react-native-gesture-handler@2.29.1(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-reanimated@4.2.0(react-native-worklets@0.7.1(@babel/core@7.28.5)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-screens@4.18.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) @@ -844,6 +850,9 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} + '@ide/backoff@1.0.0': + resolution: {integrity: sha512-F0YfUDjvT+Mtt/R4xdl2X0EYCHMMiJqNLdxHD++jDT5ydEFIyqbCHh51Qx2E211dgZprPKhV7sHmnXKpLuvc5g==} + '@isaacs/balanced-match@4.0.1': resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} engines: {node: 20 || >=22} @@ -1522,6 +1531,9 @@ packages: asap@2.0.6: resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + assert@2.1.0: + resolution: {integrity: sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==} + async-function@1.0.0: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} @@ -1600,6 +1612,9 @@ packages: peerDependencies: '@babel/core': ^7.0.0 + badgin@1.2.3: + resolution: {integrity: sha512-NQGA7LcfCpSzIbGRbkgjgdWkjy7HI+Th5VLxTJfW5EeaAf3fnS+xWQaQOCYiny+q6QSvxqoSO04vCx+4u++EJw==} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -2114,6 +2129,11 @@ packages: exec-async@2.2.0: resolution: {integrity: sha512-87OpwcEiMia/DeiKFzaQNBNFeN3XkkpYIh9FyOqq5mS2oKv3CBE67PXoEKcr6nodWdXNogTiQ0jE2NGuoffXPw==} + expo-application@7.0.8: + resolution: {integrity: sha512-qFGyxk7VJbrNOQWBbE09XUuGuvkOgFS9QfToaK2FdagM2aQ+x3CvGV2DuVgl/l4ZxPgIf3b/MNh9xHpwSwn74Q==} + peerDependencies: + expo: '*' + expo-asset@12.0.11: resolution: {integrity: sha512-pnK/gQ5iritDPBeK54BV35ZpG7yeW5DtgGvJHruIXkyDT9BCoQq3i0AAxfcWG/e4eiRmTzAt5kNVYFJi48uo+A==} peerDependencies: @@ -2127,6 +2147,11 @@ packages: expo: '*' react-native: '*' + expo-device@8.0.10: + resolution: {integrity: sha512-jd5BxjaF7382JkDMaC+P04aXXknB2UhWaVx5WiQKA05ugm/8GH5uaz9P9ckWdMKZGQVVEOC8MHaUADoT26KmFA==} + peerDependencies: + expo: '*' + expo-file-system@19.0.21: resolution: {integrity: sha512-s3DlrDdiscBHtab/6W1osrjGL+C2bvoInPJD7sOwmxfJ5Woynv2oc+Fz1/xVXaE/V7HE/+xrHC/H45tu6lZzzg==} peerDependencies: @@ -2162,6 +2187,13 @@ packages: react: '*' react-native: '*' + expo-notifications@0.32.15: + resolution: {integrity: sha512-gnJcauheC2S0Wl0RuJaFkaBRVzCG011j5hlG0TEbsuOCPBuB/F30YEk8yurK8Psv+zHkVfeiJ5AC+nL0LWk0WA==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + expo-router@6.0.19: resolution: {integrity: sha512-XK+vwpyEmGGamUM/S7+LwtjuBzbTm8VY401h8SOs8Tv1mrLl+7QNvAkk5lJiXgUVKaAIEXl8GxkWhF7mxBUzyg==} peerDependencies: @@ -2514,6 +2546,10 @@ packages: invariant@2.2.4: resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} + is-arguments@1.2.0: + resolution: {integrity: sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==} + engines: {node: '>= 0.4'} + is-array-buffer@3.0.5: resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} engines: {node: '>= 0.4'} @@ -2586,6 +2622,10 @@ packages: resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} engines: {node: '>= 0.4'} + is-nan@1.3.2: + resolution: {integrity: sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==} + engines: {node: '>= 0.4'} + is-negative-zero@2.0.3: resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} engines: {node: '>= 0.4'} @@ -3256,6 +3296,10 @@ packages: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} + object-is@1.1.6: + resolution: {integrity: sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==} + engines: {node: '>= 0.4'} + object-keys@1.1.1: resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} engines: {node: '>= 0.4'} @@ -4155,6 +4199,10 @@ packages: engines: {node: '>=14.17'} hasBin: true + ua-parser-js@0.7.41: + resolution: {integrity: sha512-O3oYyCMPYgNNHuO7Jjk3uacJWZF8loBgwrfd/5LE/HyZ3lUIOdniQ7DNXJcIgZbwioZxk0fLfI4EVnetdiX5jg==} + hasBin: true + unbox-primitive@1.1.0: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} @@ -4235,6 +4283,9 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + util@0.12.5: + resolution: {integrity: sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==} + utils-merge@1.0.1: resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} engines: {node: '>= 0.4.0'} @@ -5432,6 +5483,8 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} + '@ide/backoff@1.0.0': {} + '@isaacs/balanced-match@4.0.1': {} '@isaacs/brace-expansion@5.0.0': @@ -6255,6 +6308,14 @@ snapshots: asap@2.0.6: {} + assert@2.1.0: + dependencies: + call-bind: 1.0.8 + is-nan: 1.3.2 + object-is: 1.1.6 + object.assign: 4.1.7 + util: 0.12.5 + async-function@1.0.0: {} async-limiter@1.0.1: {} @@ -6398,6 +6459,8 @@ snapshots: babel-plugin-jest-hoist: 29.6.3 babel-preset-current-node-syntax: 1.2.0(@babel/core@7.28.5) + badgin@1.2.3: {} + balanced-match@1.0.2: {} base64-js@1.5.1: {} @@ -6985,6 +7048,10 @@ snapshots: exec-async@2.2.0: {} + expo-application@7.0.8(expo@54.0.29): + dependencies: + expo: 54.0.29(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.19)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + expo-asset@12.0.11(expo@54.0.29)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): dependencies: '@expo/image-utils': 0.8.8 @@ -7004,6 +7071,11 @@ snapshots: transitivePeerDependencies: - supports-color + expo-device@8.0.10(expo@54.0.29): + dependencies: + expo: 54.0.29(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.19)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + ua-parser-js: 0.7.41 + expo-file-system@19.0.21(expo@54.0.29)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0)): dependencies: expo: 54.0.29(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.19)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) @@ -7045,6 +7117,21 @@ snapshots: react: 19.1.0 react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0) + expo-notifications@0.32.15(expo@54.0.29)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): + dependencies: + '@expo/image-utils': 0.8.8 + '@ide/backoff': 1.0.0 + abort-controller: 3.0.0 + assert: 2.1.0 + badgin: 1.2.3 + expo: 54.0.29(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.19)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + expo-application: 7.0.8(expo@54.0.29) + expo-constants: 18.0.12(expo@54.0.29)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0)) + react: 19.1.0 + react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0) + transitivePeerDependencies: + - supports-color + expo-router@6.0.19(@expo/metro-runtime@6.1.2)(@types/react@19.1.17)(expo-constants@18.0.12)(expo-linking@8.0.10)(expo@54.0.29)(react-dom@19.2.3(react@19.1.0))(react-native-gesture-handler@2.29.1(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-reanimated@4.2.0(react-native-worklets@0.7.1(@babel/core@7.28.5)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-screens@4.18.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): dependencies: '@expo/metro-runtime': 6.1.2(expo@54.0.29)(react-dom@19.2.3(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) @@ -7417,6 +7504,11 @@ snapshots: dependencies: loose-envify: 1.4.0 + is-arguments@1.2.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + is-array-buffer@3.0.5: dependencies: call-bind: 1.0.8 @@ -7491,6 +7583,11 @@ snapshots: is-map@2.0.3: {} + is-nan@1.3.2: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + is-negative-zero@2.0.3: {} is-number-object@1.1.1: @@ -8355,6 +8452,11 @@ snapshots: object-inspect@1.13.4: {} + object-is@1.1.6: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + object-keys@1.1.1: {} object.assign@4.1.7: @@ -9369,6 +9471,8 @@ snapshots: typescript@5.9.3: {} + ua-parser-js@0.7.41: {} + unbox-primitive@1.1.0: dependencies: call-bound: 1.0.4 @@ -9434,6 +9538,14 @@ snapshots: util-deprecate@1.0.2: {} + util@0.12.5: + dependencies: + inherits: 2.0.4 + is-arguments: 1.2.0 + is-generator-function: 1.1.2 + is-typed-array: 1.1.15 + which-typed-array: 1.1.19 + utils-merge@1.0.1: {} uuid@7.0.3: {} diff --git a/src/components/CalendarPage.tsx b/src/components/CalendarPage.tsx index 3268817..dac4bc0 100644 --- a/src/components/CalendarPage.tsx +++ b/src/components/CalendarPage.tsx @@ -282,7 +282,7 @@ const CalendarPage: React.FC = ({ const insets = useSafeAreaInsets(); return ( - + = ({ onScroll={Animated.event([{ nativeEvent: { contentOffset: { y: scrollY } } }], { useNativeDriver: true, })} + contentContainerStyle={{ + paddingBottom: 40, // πŸ‘ˆ μ›ν•˜λŠ” 만큼 + }} > - - {year} - + {year} = ({ > {month} - - μ›” - + μ›” - + {WEEKDAY_HANJA[weekday]} - + {fortune?.lunarDate || '음λ ₯ --'} @@ -404,7 +400,7 @@ const CalendarPage: React.FC = ({ {day} - + {loading ? ( @@ -413,7 +409,7 @@ const CalendarPage: React.FC = ({ ) : fortune ? ( - + " {fortune.overview} " ) : ( @@ -435,11 +431,11 @@ const CalendarPage: React.FC = ({ - + - + 였늘의 μš΄μ„Έ @@ -459,8 +455,6 @@ const CalendarPage: React.FC = ({ )} - - @@ -480,9 +474,9 @@ const FortuneItem: React.FC = ({ icon, label, value, isLast }) - {label} + {label} - {value} + {value} ); diff --git a/src/components/LoginScreen.tsx b/src/components/LoginScreen.tsx index dad5858..dfda5d6 100644 --- a/src/components/LoginScreen.tsx +++ b/src/components/LoginScreen.tsx @@ -9,9 +9,10 @@ type AuthMode = 'signIn' | 'signUp'; interface Props { onSignUpSuccess?: () => void; + onSignInSuccess?: () => void; } -const LoginScreen: React.FC = ({ onSignUpSuccess }) => { +const LoginScreen: React.FC = ({ onSignUpSuccess, onSignInSuccess }) => { const { signIn, signUp, isLoading, error, clearError } = useAuth(); const [mode, setMode] = useState('signIn'); const [username, setUsername] = useState(''); @@ -47,6 +48,7 @@ const LoginScreen: React.FC = ({ onSignUpSuccess }) => { } try { await signIn(usernameTrimmed, password); + onSignInSuccess?.(); } catch (err) { if (err instanceof AuthError && err.code === 'UserNotConfirmedException') { clearError(); diff --git a/src/services/authService.ts b/src/services/authService.ts index f737089..6cccd62 100644 --- a/src/services/authService.ts +++ b/src/services/authService.ts @@ -1,6 +1,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import { AuthenticationDetails, + CognitoRefreshToken, CognitoUser, CognitoUserAttribute, CognitoUserPool, @@ -26,6 +27,7 @@ export interface AuthTokens { refreshToken: string; accessTokenExp: number; idTokenExp: number; + refreshTokenExp?: number; } export interface SignUpResult { @@ -37,6 +39,7 @@ export interface SignUpResult { const AUTH_TOKENS_KEY = 'authTokens'; const AUTH_LAST_USER_KEY = 'authLastUser'; const TOKEN_EXPIRY_LEEWAY_MS = 60_000; +const REFRESH_TOKEN_EXPIRY_MS = 5 * 24 * 60 * 60 * 1000; const AUTH_ERROR_MESSAGES: Record = { UsernameExistsException: '이미 κ°€μž…λœ μ•„μ΄λ””μž…λ‹ˆλ‹€. λ‘œκ·ΈμΈν•΄μ£Όμ„Έμš”.', @@ -53,6 +56,8 @@ const AUTH_ERROR_MESSAGES: Record = { let cachedTokens: AuthTokens | null = null; let cachedUser: string | null = null; let cacheLoaded = false; +let refreshPromise: Promise | null = null; +let authSessionVersion = 0; const getErrorCode = (error: unknown) => { if (!error || typeof error !== 'object') return undefined; @@ -81,13 +86,26 @@ const getUserPool = () => { }); }; -const mapSessionTokens = (session: CognitoUserSession): AuthTokens => ({ - accessToken: session.getAccessToken().getJwtToken(), - idToken: session.getIdToken().getJwtToken(), - refreshToken: session.getRefreshToken().getToken(), - accessTokenExp: session.getAccessToken().getExpiration() * 1000, - idTokenExp: session.getIdToken().getExpiration() * 1000, -}); +const mapSessionTokens = ( + session: CognitoUserSession, + previousTokens?: AuthTokens | null, +): AuthTokens => { + const refreshToken = + session.getRefreshToken().getToken() || previousTokens?.refreshToken || ''; + const refreshTokenExp = + previousTokens?.refreshToken === refreshToken && previousTokens.refreshTokenExp + ? previousTokens.refreshTokenExp + : Date.now() + REFRESH_TOKEN_EXPIRY_MS; + + return { + accessToken: session.getAccessToken().getJwtToken(), + idToken: session.getIdToken().getJwtToken(), + refreshToken, + accessTokenExp: session.getAccessToken().getExpiration() * 1000, + idTokenExp: session.getIdToken().getExpiration() * 1000, + refreshTokenExp, + }; +}; const loadCache = async () => { if (cacheLoaded) return; @@ -129,6 +147,20 @@ const saveLastUser = async (username: string | null) => { const isExpired = (exp: number) => Date.now() > exp - TOKEN_EXPIRY_LEEWAY_MS; +const isTokenValid = (exp?: number) => Boolean(exp && !isExpired(exp)); + +const hasValidAccessToken = (tokens: AuthTokens | null) => + Boolean(tokens?.accessToken && isTokenValid(tokens.accessTokenExp)); + +const hasValidIdToken = (tokens: AuthTokens | null) => + Boolean(tokens?.idToken && isTokenValid(tokens.idTokenExp)); + +const shouldRefreshTokens = (tokens: AuthTokens | null) => + Boolean(tokens && (!hasValidAccessToken(tokens) || !hasValidIdToken(tokens))); + +const isRefreshTokenExpired = (tokens: AuthTokens | null) => + Boolean(tokens?.refreshTokenExp && isExpired(tokens.refreshTokenExp)); + const pickToken = (tokens: AuthTokens | null) => { if (!tokens) return null; const useIdToken = COGNITO_CONFIG.TOKEN_USE === 'id'; @@ -138,6 +170,60 @@ const pickToken = (tokens: AuthTokens | null) => { return token; }; +const REFRESH_TOKEN_FAILURE_CODES = new Set([ + 'NotAuthorizedException', + 'InvalidParameterException', + 'UserNotFoundException', +]); + +const shouldClearSessionOnRefreshError = (error: unknown) => { + const code = getErrorCode(error); + return code ? REFRESH_TOKEN_FAILURE_CODES.has(code) : false; +}; + +const refreshTokens = async () => { + await loadCache(); + if (!cachedTokens?.refreshToken || !cachedUser) return null; + if (refreshPromise) return refreshPromise; + + const refreshTokenValue = cachedTokens.refreshToken; + const username = cachedUser; + const sessionVersion = authSessionVersion; + + const promise = new Promise((resolve, reject) => { + const user = new CognitoUser({ + Username: username, + Pool: getUserPool(), + }); + const refreshToken = new CognitoRefreshToken({ RefreshToken: refreshTokenValue }); + + user.refreshSession(refreshToken, async (error, session) => { + if (error || !session) { + reject(buildAuthError(error, 'μ„Έμ…˜ 갱신에 μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€.')); + return; + } + if (sessionVersion !== authSessionVersion) { + resolve(null); + return; + } + try { + const tokens = mapSessionTokens(session, cachedTokens); + await saveTokens(tokens); + resolve(tokens); + } catch (err) { + reject(err); + } + }); + }); + + refreshPromise = promise; + promise.finally(() => { + refreshPromise = null; + }); + + return promise; +}; + export const getStoredTokens = async () => { await loadCache(); return cachedTokens; @@ -150,8 +236,31 @@ export const getLastUsername = async () => { export const getAuthToken = async () => { await loadCache(); - const storedToken = pickToken(cachedTokens); - if (storedToken) return storedToken; + const storedTokens = cachedTokens; + const storedToken = pickToken(storedTokens); + const needsRefresh = shouldRefreshTokens(storedTokens); + + if (storedToken && !needsRefresh) return storedToken; + + if (storedTokens?.refreshToken && cachedUser) { + if (isRefreshTokenExpired(storedTokens)) { + await signOut(); + return API_CONFIG.AUTH_TOKEN || null; + } + try { + const refreshedTokens = await refreshTokens(); + const refreshedToken = pickToken(refreshedTokens); + if (refreshedToken) return refreshedToken; + } catch (error) { + if (shouldClearSessionOnRefreshError(error)) { + await signOut(); + return API_CONFIG.AUTH_TOKEN || null; + } + } + + if (storedToken) return storedToken; + } + return API_CONFIG.AUTH_TOKEN || null; }; @@ -265,5 +374,6 @@ export const resendSignUpCode = async (username: string) => { }; export const signOut = async () => { + authSessionVersion += 1; await Promise.all([saveTokens(null), saveLastUser(null)]); }; diff --git a/src/services/pushNotifications.ts b/src/services/pushNotifications.ts new file mode 100644 index 0000000..223a7ab --- /dev/null +++ b/src/services/pushNotifications.ts @@ -0,0 +1,45 @@ +import Constants from 'expo-constants'; +import * as Device from 'expo-device'; +import * as Notifications from 'expo-notifications'; +import { Platform } from 'react-native'; + +export const registerForPushNotificationsAsync = async (): Promise => { + if (Platform.OS === 'web') { + console.info('Push notifications are not supported on web.'); + return null; + } + + if (!Device.isDevice) { + console.warn('Push notifications require a physical device.'); + return null; + } + + if (Platform.OS === 'android') { + await Notifications.setNotificationChannelAsync('default', { + name: 'default', + importance: Notifications.AndroidImportance.DEFAULT, + }); + } + + const { status: existingStatus } = await Notifications.getPermissionsAsync(); + let finalStatus = existingStatus; + if (existingStatus !== 'granted') { + const { status } = await Notifications.requestPermissionsAsync(); + finalStatus = status; + } + + if (finalStatus !== 'granted') { + console.warn('Push notification permission not granted.'); + return null; + } + + const projectId = + Constants.expoConfig?.extra?.eas?.projectId ?? Constants.easConfig?.projectId; + if (!projectId) { + console.warn('Expo projectId is missing; unable to request push token.'); + return null; + } + + const token = (await Notifications.getExpoPushTokenAsync({ projectId })).data; + return token; +};