diff --git a/.gitignore b/.gitignore index 695c0b2ce..e47c98ab2 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,4 @@ build/ app/.idea/ /composeApp/google-services.json /iosApp/iosApp/GoogleService-Info.plist +/iosApp/Configuration/Config.xcconfig diff --git a/README.md b/README.md index ab3f389f7..b47af334d 100644 --- a/README.md +++ b/README.md @@ -1,88 +1,85 @@ -### ๐Ÿšง Suwiki๋Š” ํ˜„์žฌ ๋ฆฌํŒฉํ† ๋ง ์ž‘์—… ์ค‘์ž…๋‹ˆ๋‹ค. +# ChukChukHaksa (์ฒ™์ฒ™ํ•™์‚ฌ) -# Suwiki +์ˆ˜์›๋Œ€ํ•™๊ต ์žฌํ•™์ƒ์„ ์œ„ํ•œ ์‹œ๊ฐ„ํ‘œ & ๊ฐ•์˜ํ‰๊ฐ€ ์„œ๋น„์Šค์ž…๋‹ˆ๋‹ค. +Kotlin Multiplatform (Android + iOS) ๊ธฐ๋ฐ˜์œผ๋กœ ๊ฐœ๋ฐœ๋˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. -# Introduce +## Tech Stack -**Suwiki(์ˆ˜์œ„ํ‚ค)** ๋Š” ์ˆ˜์›๋Œ€ํ•™๊ต ์žฌํ•™์ƒ๋“ค์˜ ๋ถˆํŽธํ•จ์„ ํ•ด๊ฒฐํ•˜๊ธฐ ์œ„ํ•ด ๋งŒ๋“ค์–ด์ง„ ์‹œ๊ฐ„ํ‘œ & ๊ฐ•์˜ํ‰๊ฐ€ ์„œ๋น„์Šค์ž…๋‹ˆ๋‹ค. ๋Œ€๋žต **43**๊ฐœ์˜ ๋ชจ๋“ˆ๋กœ ์ด๋ฃจ์–ด์ ธ ์žˆ์œผ๋ฉฐ Clean Architecture, Mvi, Hilt, Coroutine, Flow, Jetpack -Compose ๋“ฑ์˜ ๊ธฐ์ˆ ์ด ์ ์šฉ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค. +| Category | Stack | +|----------|-------| +| Language | Kotlin 2.2, Swift | +| UI | Compose Multiplatform 1.10 | +| Architecture | MVI + Clean Architecture | +| DI | Koin 4.1 | +| Database | Room 2.7 (KMP) | +| Remote | Firebase (GitLive KMP) | +| Widget | Glance (Android), WidgetKit (iOS) | -2023๋…„ 12์›” 13์ผ ๊ธฐ์ค€ **2039**๋ช…์˜ ์œ ์ €๊ฐ€ ์‚ฌ์šฉ ์ค‘์ž…๋‹ˆ๋‹ค. +## Project Structure -# Architecture +๋‹จ์ผ `composeApp` ๋ชจ๋“ˆ ๊ตฌ์กฐ: -Suwiki๋Š” Mvi ๊ธฐ๋ฐ˜์˜ Clean Architecture ๊ตฌ์กฐ๋กœ ์„ค๊ณ„๋˜์–ด ์žˆ์œผ๋ฉฐ ํฌ๊ฒŒ core, data, domain, feature ๋ชจ๋“ˆ๋กœ ๊ตฌ์„ฑ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค. -presentation layer์—์„œ๋Š” UDF(๋‹จ๋ฐฉํ–ฅ ๋ฐ์ดํ„ฐ ํ๋ฆ„) ํŒจํ„ด์„ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. +``` +com.chukchukhaksa.mobile/ +โ”œโ”€โ”€ common/ # ๋””์ž์ธ์‹œ์Šคํ…œ, ํ™•์žฅํ•จ์ˆ˜, KMP ํ”Œ๋žซํผ ์ฝ”๋“œ +โ”œโ”€โ”€ data/ # Repository ๊ตฌํ˜„ +โ”œโ”€โ”€ di/ # Koin DI ๋ชจ๋“ˆ +โ”œโ”€โ”€ domain/ # UseCase, Repository ์ธํ„ฐํŽ˜์ด์Šค +โ”œโ”€โ”€ local/ # Room DB, DataStore +โ”œโ”€โ”€ presentation/ # ํ™”๋ฉด (MVI ํŒจํ„ด) +โ”œโ”€โ”€ remote/ # Firebase ๋ฐ์ดํ„ฐ์†Œ์Šค +โ””โ”€โ”€ widget/ # ์œ„์ ฏ (Android Glance / iOS WidgetKit) +``` -> ์œ ์ง€ ๋ณด์ˆ˜๋ฅผ ํ•ด๊ฐ€๋ฉฐ ๋ชจ๋“ˆ์ด ๊ณผํ•˜๊ฒŒ ๋‚˜๋ˆ„์–ด์ ธ ์žˆ๋‹ค๋ฉด ํ†ตํ•ฉ์„, ์ถ”๊ฐ€๋กœ ๋‚˜๋ˆ ์•ผํ•œ๋‹ค๋ฉด ๋ถ„๋ฆฌํ•  ์ƒ๊ฐ์ž…๋‹ˆ๋‹ค. Suwiki Architecture๋Š” ๊ณ„์† ๋ณ€๊ฒฝ๋  ์˜ˆ์ •์ด๋ฉฐ ํ”ผ๋“œ๋ฐฑ์ด๋‚˜ ์งˆ๋ฌธ์ด ์žˆ๋‹ค๋ฉด koownij@kakao.com์œผ๋กœ ์–ธ์ œ๋“ ์ง€ ์—ฐ๋ฝ ์ฃผ์„ธ์š” :) +## Setup -## Module +### 1. ํ”„๋กœ์ ํŠธ ํด๋ก  -### 40๊ฐœ๊ฐ€ ๋„˜๋Š” ๋ชจ๋“ˆ๋กœ ๋‚˜๋ˆˆ ์ด์œ  +```bash +git clone https://github.com/your-org/cchaksa-kmp.git +cd cchaksa-kmp +``` -#### 1. Suwiki๋Š” ์ตœ์†Œ 5๋…„ ์ด์ƒ์˜ ์„œ๋น„์Šค๋ฅผ ๋ชฉํ‘œ๋กœ ํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. +### 2. API Key ์„ค์ • -์˜ค๋žœ ์‹œ๊ฐ„์ด ์ง€๋‚œ ํ›„ ์ฝ”๋“œ๋ฅผ ๋‹ค์‹œ ๋ณด์•˜์„ ๋•Œ, ๊ด€์‹ฌ์‚ฌ๋ณ„๋กœ ๋ชจ๋“ˆ์ด ๋ถ„๋ฆฌ๋˜์–ด ์žˆ๋‹ค๋ฉด ์œ ์ง€ ๋ณด์ˆ˜๊ฐ€ ์‰ฌ์šธ๊ฑฐ๋ผ ํŒ๋‹จํ–ˆ๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค. +ํ‚ค ๊ฐ’์€ [Notion](https://www.notion.so/30a1a2480da180408962e123dc1482ca?source=copy_link)์—์„œ ํ™•์ธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. -#### 2. ์ธ์›์ด ๊ณ„์† ๋ณ€๊ฒฝ๋ฉ๋‹ˆ๋‹ค. +#### Android -๊ณ ์ •๋œ ์ธ์›์ด ๊ฐœ๋ฐœ์„ ํ•˜๋Š” ๊ฒƒ์ด ์•„๋‹ˆ๋ฉฐ ์ˆ˜์›๋Œ€ํ•™๊ต ํ›„๋ฐฐ๋“ค์—๊ฒŒ ํ”„๋กœ์ ํŠธ๋ฅผ ๋„˜๊ฒจ์ฃผ๋ฉฐ ์ด์–ด๋‚˜๊ฐˆ ์ƒ๊ฐ์ž…๋‹ˆ๋‹ค. ๋ชจ๋“ˆ ๋ถ„๋ฆฌ๊ฐ€ ๋ช…ํ™•ํ•˜๋‹ค๋ฉด ์ƒˆ๋กœ์šด ์ธ์›์ด ์ฝ”๋“œ๋ฅผ ๋ณ€๊ฒฝํ•ด๋„ ์•ˆ์ „ํ•˜๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค. +`local.properties`์— ์ถ”๊ฐ€ (gitignore ๋จ): -#### 3. ์ƒˆ๋กœ์šด Feature๊ฐ€ ์ถ”๊ฐ€๋  ๊ฐ€๋Šฅ์„ฑ์ด ํฝ๋‹ˆ๋‹ค. +```properties +KAKAO_NATIVE_APP_KEY=๋…ธ์…˜์—์„œ_ํ™•์ธํ•œ_ํ‚ค_๊ฐ’ +``` -์ƒˆ๋กœ์šด ๊ธฐ๋Šฅ์ด ์ถ”๊ฐ€๋˜๋”๋ผ๋„ ๋ชจ๋“ˆ ๋ถ„๋ฆฌ๊ฐ€ ๋˜์–ด์žˆ๊ธฐ ๋•Œ๋ฌธ์— ๊ธฐ์กด ์ฝ”๋“œ์— ์˜ํ–ฅ์„ ์ฃผ๊ธฐ ์–ด๋ ต์Šต๋‹ˆ๋‹ค. +#### iOS -#### 4. ๋ฉ€ํ‹ฐ ๋ชจ๋“ˆ ๋‹จ์  ๊ทน๋ณต +`iosApp/Configuration/Config.xcconfig`์— ์ถ”๊ฐ€ (gitignore ๋จ): -40๊ฐœ์˜ ๋ชจ๋“ˆ๋กœ ๋ถ„๋ฆฌํ–ˆ๊ธฐ ๋•Œ๋ฌธ์— ์˜คํžˆ๋ ค ๋ณต์žกํ•ด๋ณด์ผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ํ•˜์ง€๋งŒ ๋ฌธ์„œํ™”๋ฅผ ํ†ตํ•ด ๋ชจ๋“ˆ๊ฐ„์˜ ์˜์กด์„ฑ๊ณผ ๋ชจ๋“ˆ์ด ์–ด๋–ค ์—ญํ• ์„ ํ•˜๋Š”์ง€ ์ž์„ธํžˆ ์ ์–ด๋†“๋Š”๋‹ค๋ฉด ํ•ด๊ฒฐํ•  ์ˆ˜ ์žˆ๋‹ค ์ƒ๊ฐํ–ˆ์Šต๋‹ˆ๋‹ค. +``` +KAKAO_NATIVE_APP_KEY=๋…ธ์…˜์—์„œ_ํ™•์ธํ•œ_ํ‚ค_๊ฐ’ +``` -### ๋ชจ๋“ˆํ™” ๊ธฐ์ค€ +### 3. Build -#### 1. Clean Architecture์˜ Layer์™€ ์•ฑ์˜ Feature ๋‹จ์œ„๋กœ ๋ถ„๋ฆฌ +```bash +# Android +./gradlew assembleDebug -ex) ์‹œ๊ฐ„ํ‘œ, ๊ฐ•์˜ํ‰๊ฐ€ +# iOS (Simulator) +./gradlew linkDebugFrameworkIosSimulatorArm64 +# ์ดํ›„ Xcode์—์„œ iosApp.xcworkspace ์—ด๊ณ  ๋นŒ๋“œ +``` -`domain:timetable`, `data:timetable`, `feature:timetable` -`domain:lectureevaluation`, `data:lectureevaluation`, `feature:lectureevaluatioin` +## Architecture -#### 2. ๋ชจ๋“ˆ์˜ ํŠน์ • ๋ถ€๋ถ„์ด ์—ฌ๋Ÿฌ ๊ณณ์—์„œ ์‚ฌ์šฉ๋œ๋‹ค๋ฉด ๋ถ„๋ฆฌ +MVI + Clean Architecture ํŒจํ„ด์„ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. -ex) ๊ฐ•์˜ํ‰๊ฐ€ Create/Read/Update/Delete ๊ธฐ๋Šฅ ์ค‘ Create/Update/Delete ๋ถ€๋ถ„์ด `feature:myinfo`, `feature:lectureevaluation`์—์„œ ์‚ฌ์šฉ๋˜๋Š” ๊ฒฝ์šฐ +``` +Presentation (MVI) โ†’ Domain (UseCase) โ†’ Data (Repository) + โ†“ โ†“ + ViewModel + State/SideEffect Local (Room/DataStore) + Remote (Firebase) +``` -๊ฐ•์˜ํ‰๊ฐ€ Read ๊ธฐ๋Šฅ๊ณผ Create/Update/Delete ๊ธฐ๋Šฅ์„ ๋ถ„๋ฆฌํ•ฉ๋‹ˆ๋‹ค. -> `feature:lectureevaluation:viewer`, `feature:lectureevaluation:editor` - -### ๋ชจ๋“ˆ ์„ค๋ช… - -feature, data, domain, local, remote ๋ชจ๋“ˆ์ด ๊ฐ€์ง€๋Š” ์˜์กด์„ฑ์€ ์•„๋ž˜์™€ ๊ฐ™์Šต๋‹ˆ๋‹ค. (core ๋ชจ๋“ˆ ๋“ฑ ์ผ๋ถ€ ๋ชจ๋“ˆ์˜ ์˜์กด์„ฑ์€ ์‚ฌ์ง„์—์„œ ์ œ์™ธํ•จ) -![entire_module_graph.png](docs/entire_module_graph.png) - -#### core - -์ด๋ฆ„์—์„œ ์œ ์ถ”ํ•  ์ˆ˜ ์žˆ๋“ฏ ๋งŽ์€ ๋ชจ๋“ˆ์—์„œ ์žฌ์‚ฌ์šฉํ•˜๋Š” ํ•ต์‹ฌ ์ฝ”๋“œ์ž…๋‹ˆ๋‹ค. core ๋ชจ๋“ˆ๊ฐ„์˜ ์˜์กด์„ฑ์€ ๊ฐ€์งˆ ์ˆ˜ ์žˆ์œผ๋‚˜ core ๋ชจ๋“ˆ์ด ๋‹ค๋ฅธ ๋ชจ๋“ˆ(feature, data, domain, local, remote)์˜ ์˜์กด์„ฑ์„ ๊ฐ€์ง€๋ฉด ์•ˆ๋ฉ๋‹ˆ๋‹ค. - -![core_module.png](docs/core_module.png) - -| ์ด๋ฆ„ | ์—ญํ•  | -|:-----------------:|:-----------------------------------------------------:| -| core:android | ์•ˆ๋“œ๋กœ์ด๋“œ ์˜์กด์„ฑ์„ ๊ฐ€์ง€๋Š” ๋ชจ๋“ˆ๋“ค์ด ๊ณตํ†ต์ ์œผ๋กœ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋Š” ์ฝ”๋“œ๊ฐ€ ์กด์žฌํ•ฉ๋‹ˆ๋‹ค. | -| core:common | ๋ชจ๋“  ๋ชจ๋“ˆ์—์„œ ๊ณตํ†ต์ ์œผ๋กœ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋Š” ์ฝ”๋“œ๊ฐ€ ์กด์žฌํ•ฉ๋‹ˆ๋‹ค. | -| core:database | Local Database(Room), Jetpack - DataStore | -| core:designsystem | Component, Theme, Color, Typography | -| core:network | Retrofit2 ๊ด€๋ จ ์ฝ”๋“œ๊ฐ€ ์กด์žฌํ•ฉ๋‹ˆ๋‹ค. | -| core:ui | ui์—์„œ ๊ณตํ†ต์œผ๋กœ ์‚ฌ์šฉํ•˜๋Š” ์ฝ”๋“œ๊ฐ€ ์กด์žฌํ•ฉ๋‹ˆ๋‹ค. ์˜ˆ๋ฅผ ๋“ค๋ฉด Modifier์˜ ํ™•์žฅํ•จ์ˆ˜๊ฐ€ ์žˆ์Šต๋‹ˆ๋‹ค. | -| core:security | ํŠน์ • ๊ฐ’์„ ์•”ํ˜ธํ™”ํ•ด์ฃผ๋Š” ๋ชจ๋“ˆ์ž…๋‹ˆ๋‹ค. Jwt๋ฅผ ์•”ํ˜ธํ™”ํ•ฉ๋‹ˆ๋‹ค. | -| core:model | ๊ณตํ†ต์ ์œผ๋กœ ์‚ฌ์šฉํ•˜๋Š” Model์ด ์ •์˜๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค. | - -#### feature - -๋ชจ๋“  feature ๋ชจ๋“ˆ์€ `feature:navigation` ๋ชจ๋“ˆ์— ์˜์กดํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. - -#### ๊ทธ ์™ธ - -| ์ด๋ฆ„ | ์—ญํ•  | -|:-------------------:|:----------------------------------------:| -| app | ๋ ˆ๊ฑฐ์‹œ ์ฝ”๋“œ์ž…๋‹ˆ๋‹ค. | -| app:compose | ์ƒˆ๋กญ๊ฒŒ ๋ฆฌํŒฉํ† ๋ง ์ค‘์ธ ๋ชจ๋“ˆ์ž…๋‹ˆ๋‹ค. | -| data, local, remote | Clean Architecture์—์„œ์˜ data Layer | -| domain | Clean Architecture์—์„œ์˜ domain Layer | -| feature | Clean Architecture์—์„œ์˜ presentation Layer | +๊ฐ ํ™”๋ฉด์€ `[Feature]State`, `[Feature]SideEffect`, `[Feature]ViewModel` ๊ตฌ์กฐ๋ฅผ ๋”ฐ๋ฆ…๋‹ˆ๋‹ค. diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 2ef28eabd..61a087483 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -1,4 +1,12 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import java.util.Properties + +val localProperties = Properties().apply { + val localPropertiesFile = rootProject.file("local.properties") + if (localPropertiesFile.exists()) { + load(localPropertiesFile.inputStream()) + } +} plugins { alias(libs.plugins.kotlinMultiplatform) @@ -49,6 +57,7 @@ kotlin { implementation(libs.glance.appwidget) implementation(libs.glance.material3) implementation(libs.compose.runtime) + implementation(libs.kakao.sdk.v2.user) } iosMain.dependencies { } @@ -82,6 +91,7 @@ kotlin { implementation(libs.kmp.firebase.crashlytics) implementation(libs.kmp.firebase.analytics) implementation(libs.napier) + implementation(libs.kinappbrowser) } commonTest.dependencies { implementation(libs.kotlin.test) @@ -104,6 +114,9 @@ android { targetSdk = libs.versions.android.targetSdk.get().toInt() versionCode = 51 versionName = "3.0.1" + + buildConfigField("String", "KAKAO_NATIVE_APP_KEY", "\"${localProperties["KAKAO_NATIVE_APP_KEY"] ?: ""}\"") + manifestPlaceholders["KAKAO_NATIVE_APP_KEY"] = localProperties["KAKAO_NATIVE_APP_KEY"] ?: "" } packaging { resources { diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml index c08aea488..0545b895a 100644 --- a/composeApp/src/androidMain/AndroidManifest.xml +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -19,6 +19,18 @@ + + + + + + + + diff --git a/composeApp/src/androidMain/kotlin/com/chukchukhaksa/mobile/ChukChukHaksaApplication.kt b/composeApp/src/androidMain/kotlin/com/chukchukhaksa/mobile/ChukChukHaksaApplication.kt index 6920a181b..f1d7f59df 100644 --- a/composeApp/src/androidMain/kotlin/com/chukchukhaksa/mobile/ChukChukHaksaApplication.kt +++ b/composeApp/src/androidMain/kotlin/com/chukchukhaksa/mobile/ChukChukHaksaApplication.kt @@ -3,6 +3,7 @@ package com.chukchukhaksa.mobile import android.app.Application import com.chukchukhaksa.mobile.common.kmp.isDebug import com.chukchukhaksa.mobile.di.initKoin +import com.kakao.sdk.common.KakaoSdk import com.google.firebase.Firebase import com.google.firebase.initialize import io.github.aakira.napier.DebugAntilog @@ -22,5 +23,6 @@ class ChukChukHaksaApplication : Application() { } Firebase.initialize(this) + KakaoSdk.init(this, BuildConfig.KAKAO_NATIVE_APP_KEY) } } \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/chukchukhaksa/mobile/common/kmp/KakaoSignInClient.android.kt b/composeApp/src/androidMain/kotlin/com/chukchukhaksa/mobile/common/kmp/KakaoSignInClient.android.kt new file mode 100644 index 000000000..fbdb15e7d --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/chukchukhaksa/mobile/common/kmp/KakaoSignInClient.android.kt @@ -0,0 +1,65 @@ +package com.chukchukhaksa.mobile.common.kmp + +import android.content.Context +import com.kakao.sdk.auth.model.OAuthToken +import com.kakao.sdk.common.model.ClientError +import com.kakao.sdk.common.model.ClientErrorCause +import com.kakao.sdk.user.UserApiClient +import kotlinx.coroutines.suspendCancellableCoroutine +import java.util.UUID +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +actual class KakaoSignInClient { + + actual suspend fun signIn(context: Any?): KakaoSignInResult { + val androidContext = context as? Context + ?: throw IllegalStateException("Android Context is required for Kakao Login") + + val nonce = UUID.randomUUID().toString() + + return suspendCancellableCoroutine { continuation -> + val callback: (OAuthToken?, Throwable?) -> Unit = callback@{ token, error -> + if (error != null) { + continuation.resumeWithException(error) + return@callback + } + val idToken = token?.idToken + if (idToken == null) { + continuation.resumeWithException( + IllegalStateException("id_token์ด null์ž…๋‹ˆ๋‹ค. ์นด์นด์˜ค ์ฝ˜์†”์—์„œ OpenID Connect๋ฅผ ํ™œ์„ฑํ™”ํ•˜์„ธ์š”.") + ) + return@callback + } + continuation.resume(KakaoSignInResult(idToken = idToken, nonce = nonce)) + } + + if (UserApiClient.instance.isKakaoTalkLoginAvailable(androidContext)) { + UserApiClient.instance.loginWithKakaoTalk( + context = androidContext, + nonce = nonce, + ) { token, error -> + if (error != null) { + if (error is ClientError && error.reason == ClientErrorCause.Cancelled) { + UserApiClient.instance.loginWithKakaoAccount( + context = androidContext, + nonce = nonce, + callback = callback, + ) + return@loginWithKakaoTalk + } + callback(null, error) + return@loginWithKakaoTalk + } + callback(token, null) + } + } else { + UserApiClient.instance.loginWithKakaoAccount( + context = androidContext, + nonce = nonce, + callback = callback, + ) + } + } + } +} diff --git a/composeApp/src/androidMain/kotlin/com/chukchukhaksa/mobile/di/AndroidModule.kt b/composeApp/src/androidMain/kotlin/com/chukchukhaksa/mobile/di/AndroidModule.kt index d65aa2d43..8e52333e5 100644 --- a/composeApp/src/androidMain/kotlin/com/chukchukhaksa/mobile/di/AndroidModule.kt +++ b/composeApp/src/androidMain/kotlin/com/chukchukhaksa/mobile/di/AndroidModule.kt @@ -1,5 +1,6 @@ package com.chukchukhaksa.mobile.di +import com.chukchukhaksa.mobile.common.kmp.KakaoSignInClient import com.chukchukhaksa.mobile.local.database.openlecture.database.OpenLectureDatabaseFactory import com.chukchukhaksa.mobile.local.database.openmajor.database.OpenMajorDatabaseFactory import com.chukchukhaksa.mobile.local.database.timetable.database.TimetableDatabaseFactory @@ -12,4 +13,5 @@ actual val platformModule = module { single { OpenMajorDatabaseFactory(androidApplication()) } single { OpenLectureDatabaseFactory(androidApplication()) } single { ChukChukHaksaDataStoreFactory(androidApplication()) } + factory { KakaoSignInClient() } } diff --git a/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/common/kmp/KakaoSignInClient.kt b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/common/kmp/KakaoSignInClient.kt new file mode 100644 index 000000000..9b1120cce --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/common/kmp/KakaoSignInClient.kt @@ -0,0 +1,10 @@ +package com.chukchukhaksa.mobile.common.kmp + +data class KakaoSignInResult( + val idToken: String, + val nonce: String, +) + +expect class KakaoSignInClient { + suspend fun signIn(context: Any? = null): KakaoSignInResult +} diff --git a/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/di/DomainModules.kt b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/di/DomainModules.kt index 978945670..2253a6dde 100644 --- a/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/di/DomainModules.kt +++ b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/di/DomainModules.kt @@ -1,6 +1,7 @@ package com.chukchukhaksa.mobile.di import com.chukchukhaksa.mobile.domain.auth.usecase.AppleLoginUseCase +import com.chukchukhaksa.mobile.domain.auth.usecase.KakaoLoginUseCase import com.chukchukhaksa.mobile.domain.config.usecase.CheckNeedForceUpdateUseCase import com.chukchukhaksa.mobile.domain.timetable.usecase.DeleteTimetableCellUseCase import com.chukchukhaksa.mobile.domain.timetable.usecase.DeleteTimetableUseCase @@ -44,4 +45,5 @@ val domainModule = module { // Auth use cases factory { AppleLoginUseCase(get()) } + factory { KakaoLoginUseCase(get()) } } diff --git a/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/auth/usecase/KakaoLoginUseCase.kt b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/auth/usecase/KakaoLoginUseCase.kt new file mode 100644 index 000000000..80542f1f0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/auth/usecase/KakaoLoginUseCase.kt @@ -0,0 +1,14 @@ +package com.chukchukhaksa.mobile.domain.auth.usecase + +import com.chukchukhaksa.mobile.common.kmp.KakaoSignInClient +import com.chukchukhaksa.mobile.common.kmp.KakaoSignInResult +import com.chukchukhaksa.mobile.domain.common.runCatchingIgnoreCancelled + +class KakaoLoginUseCase( + private val kakaoSignInClient: KakaoSignInClient, +) { + suspend operator fun invoke(context: Any? = null): Result = + runCatchingIgnoreCancelled { + kakaoSignInClient.signIn(context) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/presentation/timetable/timetable/component/WebViewGuideScreen.kt b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/presentation/timetable/timetable/component/WebViewGuideScreen.kt index ef7becf4d..48ae21076 100644 --- a/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/presentation/timetable/timetable/component/WebViewGuideScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/presentation/timetable/timetable/component/WebViewGuideScreen.kt @@ -32,8 +32,9 @@ import com.chukchukhaksa.mobile.common.designsystem.theme.Purple600 import com.chukchukhaksa.mobile.common.kmp.Platform import com.chukchukhaksa.mobile.common.kmp.getPlatform import com.chukchukhaksa.mobile.common.ui.cchClickable +import com.chukchukhaksa.mobile.common.provider.LocalAppContext import com.chukchukhaksa.mobile.domain.auth.usecase.AppleLoginUseCase -import io.github.aakira.napier.Napier +import com.chukchukhaksa.mobile.domain.auth.usecase.KakaoLoginUseCase import kotlinx.coroutines.launch import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.ui.tooling.preview.Preview @@ -83,6 +84,9 @@ fun WebViewGuideScreen() { Spacer(modifier = Modifier.height(16.dp)) AppleLoginButton() } + + Spacer(modifier = Modifier.height(16.dp)) + KakaoLoginButton() } Text( @@ -113,20 +117,33 @@ private fun AppleLoginButton( enable = !isLoading, textStyle = CchTheme.typography.bodyMdStrong, onClick = { - Napier.d(tag = "AppleLogin") { ">>> ๋ฒ„ํŠผ ํด๋ฆญ๋จ" } scope.launch { - Napier.d(tag = "AppleLogin") { ">>> ์ฝ”๋ฃจํ‹ด ์‹œ์ž‘" } isLoading = true appleLoginUseCase() - .onSuccess { result -> - Napier.d(tag = "AppleLogin") { ">>> ์„ฑ๊ณต - identityToken: ${result.identityToken.take(5)}..." } - Napier.d(tag = "AppleLogin") { ">>> authorizationCode: ${result.authorizationCode.take(5)}..." } - } - .onFailure { error -> - Napier.e(tag = "AppleLogin", throwable = error) { ">>> ์‹คํŒจ: ${error.message}" } - } isLoading = false - Napier.d(tag = "AppleLogin") { ">>> ์ฝ”๋ฃจํ‹ด ์ข…๋ฃŒ" } + } + }, + ) +} + +@Composable +private fun KakaoLoginButton( + kakaoLoginUseCase: KakaoLoginUseCase = koinInject(), +) { + val context = LocalAppContext.current + val scope = rememberCoroutineScope() + var isLoading by remember { mutableStateOf(false) } + + CchBasicButton( + modifier = Modifier.padding(horizontal = 38.dp), + text = if (isLoading) "๋กœ๊ทธ์ธ ์ค‘..." else "์นด์นด์˜ค๋กœ ๋กœ๊ทธ์ธ", + enable = !isLoading, + textStyle = CchTheme.typography.bodyMdStrong, + onClick = { + scope.launch { + isLoading = true + kakaoLoginUseCase(context) + isLoading = false } }, ) diff --git a/composeApp/src/iosMain/kotlin/com/chukchukhaksa/mobile/MainViewController.kt b/composeApp/src/iosMain/kotlin/com/chukchukhaksa/mobile/MainViewController.kt index 9c022dc9d..a2387654f 100644 --- a/composeApp/src/iosMain/kotlin/com/chukchukhaksa/mobile/MainViewController.kt +++ b/composeApp/src/iosMain/kotlin/com/chukchukhaksa/mobile/MainViewController.kt @@ -1,10 +1,19 @@ package com.chukchukhaksa.mobile import androidx.compose.ui.window.ComposeUIViewController +import com.chukchukhaksa.mobile.common.kmp.KakaoLoginBridge import com.chukchukhaksa.mobile.di.initKoin -fun MainViewController() = ComposeUIViewController( +fun MainViewController( + kakaoLoginBridge: KakaoLoginBridge, +) = ComposeUIViewController( configure = { - initKoin() + initKoin { + modules( + org.koin.dsl.module { + factory { kakaoLoginBridge } + } + ) + } } -) { App() } \ No newline at end of file +) { App() } diff --git a/composeApp/src/iosMain/kotlin/com/chukchukhaksa/mobile/common/kmp/KakaoLoginBridge.kt b/composeApp/src/iosMain/kotlin/com/chukchukhaksa/mobile/common/kmp/KakaoLoginBridge.kt new file mode 100644 index 000000000..2ed5d7232 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/chukchukhaksa/mobile/common/kmp/KakaoLoginBridge.kt @@ -0,0 +1,21 @@ +package com.chukchukhaksa.mobile.common.kmp + +data class KakaoOAuthUrlInfo(val url: String, val codeVerifier: String) + +class KakaoTalkCancelledException : Exception("KakaoTalk login was cancelled by user") + +interface KakaoLoginBridge { + fun isKakaoTalkAvailable(): Boolean + fun loginWithKakaoTalk( + nonce: String, + onSuccess: (idToken: String) -> Unit, + onFailure: (Throwable) -> Unit, + ) + fun buildOAuthUrl(nonce: String): KakaoOAuthUrlInfo + fun exchangeCodeForToken( + code: String, + codeVerifier: String, + onSuccess: (idToken: String) -> Unit, + onFailure: (Throwable) -> Unit, + ) +} diff --git a/composeApp/src/iosMain/kotlin/com/chukchukhaksa/mobile/common/kmp/KakaoOAuthRedirectHandler.kt b/composeApp/src/iosMain/kotlin/com/chukchukhaksa/mobile/common/kmp/KakaoOAuthRedirectHandler.kt new file mode 100644 index 000000000..8e76ae94e --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/chukchukhaksa/mobile/common/kmp/KakaoOAuthRedirectHandler.kt @@ -0,0 +1,62 @@ +package com.chukchukhaksa.mobile.common.kmp + +import kotlinx.coroutines.CompletableDeferred +import platform.Foundation.NSURLComponents + +object KakaoOAuthRedirectHandler { + + private var pendingDeferred: CompletableDeferred? = null + + fun prepare(): CompletableDeferred { + pendingDeferred?.cancel() + val deferred = CompletableDeferred() + pendingDeferred = deferred + return deferred + } + + fun handleRedirectUrl(url: String): Boolean { + val deferred = pendingDeferred ?: return false + + val components = NSURLComponents(string = url) ?: run { + deferred.completeExceptionally(IllegalStateException("Invalid redirect URL: $url")) + pendingDeferred = null + return true + } + + val queryItems = components.queryItems.orEmpty() + + val error = queryItems + .filterIsInstance() + .firstOrNull { it.name == "error" } + ?.value + + if (error != null) { + val description = queryItems + .filterIsInstance() + .firstOrNull { it.name == "error_description" } + ?.value ?: error + deferred.completeExceptionally(IllegalStateException("OAuth error: $description")) + pendingDeferred = null + return true + } + + val code = queryItems + .filterIsInstance() + .firstOrNull { it.name == "code" } + ?.value + + if (code != null) { + deferred.complete(code) + } else { + deferred.completeExceptionally(IllegalStateException("No authorization code in redirect URL")) + } + + pendingDeferred = null + return true + } + + fun cancel() { + pendingDeferred?.cancel() + pendingDeferred = null + } +} diff --git a/composeApp/src/iosMain/kotlin/com/chukchukhaksa/mobile/common/kmp/KakaoSignInClient.ios.kt b/composeApp/src/iosMain/kotlin/com/chukchukhaksa/mobile/common/kmp/KakaoSignInClient.ios.kt new file mode 100644 index 000000000..503619fd3 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/chukchukhaksa/mobile/common/kmp/KakaoSignInClient.ios.kt @@ -0,0 +1,83 @@ +package com.chukchukhaksa.mobile.common.kmp + +import dev.yjyoon.kinappbrowser.KInAppBrowser +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import platform.Foundation.NSUUID +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +actual class KakaoSignInClient( + private val bridge: KakaoLoginBridge, +) { + actual suspend fun signIn(context: Any?): KakaoSignInResult { + val nonce = NSUUID().UUIDString() + + return if (bridge.isKakaoTalkAvailable()) { + try { + signInWithKakaoTalk(nonce) + } catch (e: KakaoTalkCancelledException) { + signInWithInAppBrowser(nonce) + } + } else { + signInWithInAppBrowser(nonce) + } + } + + private suspend fun signInWithKakaoTalk(nonce: String): KakaoSignInResult { + return suspendCancellableCoroutine { continuation -> + bridge.loginWithKakaoTalk( + nonce = nonce, + onSuccess = { idToken -> + continuation.resume(KakaoSignInResult(idToken = idToken, nonce = nonce)) + }, + onFailure = { error -> + continuation.resumeWithException(error) + }, + ) + } + } + + private suspend fun signInWithInAppBrowser(nonce: String): KakaoSignInResult { + val oauthInfo = bridge.buildOAuthUrl(nonce) + val deferred = KakaoOAuthRedirectHandler.prepare() + + try { + KInAppBrowser.open(oauthInfo.url) + + val code = deferred.await() + + withContext(Dispatchers.Main) { + KInAppBrowser.close() + } + + return exchangeCodeForToken(code, oauthInfo.codeVerifier, nonce) + } catch (e: Exception) { + KakaoOAuthRedirectHandler.cancel() + withContext(Dispatchers.Main) { + KInAppBrowser.close() + } + throw e + } + } + + private suspend fun exchangeCodeForToken( + code: String, + codeVerifier: String, + nonce: String, + ): KakaoSignInResult { + return suspendCancellableCoroutine { continuation -> + bridge.exchangeCodeForToken( + code = code, + codeVerifier = codeVerifier, + onSuccess = { idToken -> + continuation.resume(KakaoSignInResult(idToken = idToken, nonce = nonce)) + }, + onFailure = { error -> + continuation.resumeWithException(error) + }, + ) + } + } +} diff --git a/composeApp/src/iosMain/kotlin/com/chukchukhaksa/mobile/di/IosModule.kt b/composeApp/src/iosMain/kotlin/com/chukchukhaksa/mobile/di/IosModule.kt index 573a38feb..456cf064e 100644 --- a/composeApp/src/iosMain/kotlin/com/chukchukhaksa/mobile/di/IosModule.kt +++ b/composeApp/src/iosMain/kotlin/com/chukchukhaksa/mobile/di/IosModule.kt @@ -1,6 +1,7 @@ package com.chukchukhaksa.mobile.di import com.chukchukhaksa.mobile.common.kmp.AppleSignInClient +import com.chukchukhaksa.mobile.common.kmp.KakaoSignInClient import com.chukchukhaksa.mobile.local.database.openlecture.database.OpenLectureDatabaseFactory import com.chukchukhaksa.mobile.local.database.openmajor.database.OpenMajorDatabaseFactory import com.chukchukhaksa.mobile.local.database.timetable.database.TimetableDatabaseFactory @@ -14,4 +15,5 @@ actual val platformModule single { OpenLectureDatabaseFactory() } single { ChukChukHaksaDataStoreFactory() } factory { AppleSignInClient() } + factory { KakaoSignInClient(get()) } } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d6e3677f7..abed87307 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -33,6 +33,8 @@ napier = "2.7.1" glance = "1.1.1" composeRuntime = "1.10.0" +kakao-sdk = "2.20.6" +kinappbrowser = "1.0.0" [libraries] @@ -85,6 +87,10 @@ napier = { group = "io.github.aakira", name = "napier", version.ref = "napier" } glance-appwidget = { group = "androidx.glance", name = "glance-appwidget", version.ref = "glance" } glance-material3 = { group = "androidx.glance", name = "glance-material3", version.ref = "glance" } +kakao-sdk-v2-user = { group = "com.kakao.sdk", name = "v2-user", version.ref = "kakao-sdk" } + +kinappbrowser = { group = "dev.yjyoon", name = "kinappbrowser", version.ref = "kinappbrowser" } + [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } androidLibrary = { id = "com.android.library", version.ref = "agp" } diff --git a/iosApp/Configuration/Config.xcconfig b/iosApp/Configuration/Config.xcconfig deleted file mode 100644 index 852e48382..000000000 --- a/iosApp/Configuration/Config.xcconfig +++ /dev/null @@ -1,7 +0,0 @@ -TEAM_ID= - -PRODUCT_NAME=ChukChukHaksa -PRODUCT_BUNDLE_IDENTIFIER=com.chukchukhaksa.moblie.ChukChukHaksa$(TEAM_ID) - -CURRENT_PROJECT_VERSION=1 -MARKETING_VERSION=1.0 \ No newline at end of file diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index f21c333d2..f9097e7cb 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -7,6 +7,9 @@ objects = { /* Begin PBXBuildFile section */ + 499266BD2F44825A001C8119 /* KakaoSDKAuth in Frameworks */ = {isa = PBXBuildFile; productRef = 499266BC2F44825A001C8119 /* KakaoSDKAuth */; }; + 499266BF2F44825A001C8119 /* KakaoSDKCommon in Frameworks */ = {isa = PBXBuildFile; productRef = 499266BE2F44825A001C8119 /* KakaoSDKCommon */; }; + 499266C12F44825A001C8119 /* KakaoSDKUser in Frameworks */ = {isa = PBXBuildFile; productRef = 499266C02F44825A001C8119 /* KakaoSDKUser */; }; 9B49E59B2DEF0C7300F3EF63 /* FirebaseAnalyticsWithoutAdIdSupport in Frameworks */ = {isa = PBXBuildFile; productRef = 9B49E59A2DEF0C7300F3EF63 /* FirebaseAnalyticsWithoutAdIdSupport */; }; 9B49E59D2DEF0C7300F3EF63 /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = 9B49E59C2DEF0C7300F3EF63 /* FirebaseCrashlytics */; }; 9BBB8DC42DE8BBBF00DB2EE0 /* FirebaseAnalytics in Frameworks */ = {isa = PBXBuildFile; productRef = 9BBB8DC32DE8BBBF00DB2EE0 /* FirebaseAnalytics */; }; @@ -51,7 +54,10 @@ 9BBB8DC62DE8BBBF00DB2EE0 /* FirebaseDatabase in Frameworks */, 9B49E59D2DEF0C7300F3EF63 /* FirebaseCrashlytics in Frameworks */, 9B49E59B2DEF0C7300F3EF63 /* FirebaseAnalyticsWithoutAdIdSupport in Frameworks */, + 499266C12F44825A001C8119 /* KakaoSDKUser in Frameworks */, 9BBB8DC42DE8BBBF00DB2EE0 /* FirebaseAnalytics in Frameworks */, + 499266BF2F44825A001C8119 /* KakaoSDKCommon in Frameworks */, + 499266BD2F44825A001C8119 /* KakaoSDKAuth in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -101,6 +107,9 @@ 9BBB8DC52DE8BBBF00DB2EE0 /* FirebaseDatabase */, 9B49E59A2DEF0C7300F3EF63 /* FirebaseAnalyticsWithoutAdIdSupport */, 9B49E59C2DEF0C7300F3EF63 /* FirebaseCrashlytics */, + 499266BC2F44825A001C8119 /* KakaoSDKAuth */, + 499266BE2F44825A001C8119 /* KakaoSDKCommon */, + 499266C02F44825A001C8119 /* KakaoSDKUser */, ); productName = iosApp; productReference = B9DA97B12DC1472C00A4DA20 /* ChukChukHaksa.app */; @@ -132,6 +141,7 @@ minimizedProjectReferenceProxies = 1; packageReferences = ( 9BBB8DC22DE8BBBF00DB2EE0 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */, + 499266BB2F44825A001C8119 /* XCRemoteSwiftPackageReference "kakao-ios-sdk" */, ); preferredProjectObjectVersion = 77; productRefGroup = B9DA97B22DC1472C00A4DA20 /* Products */; @@ -436,6 +446,14 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ + 499266BB2F44825A001C8119 /* XCRemoteSwiftPackageReference "kakao-ios-sdk" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/kakao/kakao-ios-sdk"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.27.2; + }; + }; 9BBB8DC22DE8BBBF00DB2EE0 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/firebase/firebase-ios-sdk"; @@ -447,6 +465,21 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + 499266BC2F44825A001C8119 /* KakaoSDKAuth */ = { + isa = XCSwiftPackageProductDependency; + package = 499266BB2F44825A001C8119 /* XCRemoteSwiftPackageReference "kakao-ios-sdk" */; + productName = KakaoSDKAuth; + }; + 499266BE2F44825A001C8119 /* KakaoSDKCommon */ = { + isa = XCSwiftPackageProductDependency; + package = 499266BB2F44825A001C8119 /* XCRemoteSwiftPackageReference "kakao-ios-sdk" */; + productName = KakaoSDKCommon; + }; + 499266C02F44825A001C8119 /* KakaoSDKUser */ = { + isa = XCSwiftPackageProductDependency; + package = 499266BB2F44825A001C8119 /* XCRemoteSwiftPackageReference "kakao-ios-sdk" */; + productName = KakaoSDKUser; + }; 9B49E59A2DEF0C7300F3EF63 /* FirebaseAnalyticsWithoutAdIdSupport */ = { isa = XCSwiftPackageProductDependency; package = 9BBB8DC22DE8BBBF00DB2EE0 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; diff --git a/iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 37592b95e..cb4d3cdf3 100644 --- a/iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "c63c63846d9c539229e96de38d6af51417e28c0ee9a0bc48bd0f0f19d923c329", + "originHash" : "3c0196a0861bedd6a846bc0509c3076f248bb8b4daf8cdcdc79e5ecc5fb9fda8", "pins" : [ { "identity" : "abseil-cpp-binary", @@ -10,6 +10,15 @@ "version" : "1.2024072200.0" } }, + { + "identity" : "alamofire", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Alamofire/Alamofire.git", + "state" : { + "revision" : "3f99050e75bbc6fe71fc323adabb039756680016", + "version" : "5.11.1" + } + }, { "identity" : "app-check", "kind" : "remoteSourceControl", @@ -82,6 +91,15 @@ "version" : "101.0.0" } }, + { + "identity" : "kakao-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kakao/kakao-ios-sdk", + "state" : { + "revision" : "5978979157a5a0521c9c56fd0156aec794caa21c", + "version" : "2.27.2" + } + }, { "identity" : "leveldb", "kind" : "remoteSourceControl", diff --git a/iosApp/iosApp/ContentView.swift b/iosApp/iosApp/ContentView.swift index ee6d8a4b3..32f9dd159 100644 --- a/iosApp/iosApp/ContentView.swift +++ b/iosApp/iosApp/ContentView.swift @@ -4,7 +4,7 @@ import ComposeApp struct ComposeView: UIViewControllerRepresentable { func makeUIViewController(context: Context) -> UIViewController { - MainViewControllerKt.MainViewController() + MainViewControllerKt.MainViewController(kakaoLoginBridge: KakaoLoginBridgeImpl()) } func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index 11845e1da..5aa633b31 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -4,5 +4,22 @@ CADisableMinimumFrameDurationOnPhone + KAKAO_NATIVE_APP_KEY + $(KAKAO_NATIVE_APP_KEY) + LSApplicationQueriesSchemes + + kakaokompassauth + kakaolink + kakaoplus + + CFBundleURLTypes + + + CFBundleURLSchemes + + kakao$(KAKAO_NATIVE_APP_KEY) + + + diff --git a/iosApp/iosApp/KakaoLoginBridgeImpl.swift b/iosApp/iosApp/KakaoLoginBridgeImpl.swift new file mode 100644 index 000000000..57ac9718f --- /dev/null +++ b/iosApp/iosApp/KakaoLoginBridgeImpl.swift @@ -0,0 +1,105 @@ +import Foundation +import ComposeApp +import KakaoSDKCommon +import KakaoSDKAuth +import KakaoSDKUser +import CryptoKit + +class KakaoLoginBridgeImpl: KakaoLoginBridge { + + func isKakaoTalkAvailable() -> Bool { + return UserApi.isKakaoTalkLoginAvailable() + } + + func loginWithKakaoTalk( + nonce: String, + onSuccess: @escaping (String) -> Void, + onFailure: @escaping (KotlinThrowable) -> Void + ) { + UserApi.shared.loginWithKakaoTalk(nonce: nonce) { oauthToken, error in + if let error = error { + let sdkError = error as NSError + if sdkError.domain == "com.kakao.sdk", sdkError.code == -1 { + onFailure(KakaoTalkCancelledException()) + return + } + onFailure(KotlinThrowable(message: error.localizedDescription)) + return + } + guard let idToken = oauthToken?.idToken else { + onFailure(KotlinThrowable( + message: "id_token์ด null์ž…๋‹ˆ๋‹ค. ์นด์นด์˜ค ์ฝ˜์†”์—์„œ OpenID Connect๋ฅผ ํ™œ์„ฑํ™”ํ•˜์„ธ์š”." + )) + return + } + onSuccess(idToken) + } + } + + func buildOAuthUrl(nonce: String) -> KakaoOAuthUrlInfo { + let codeVerifier = generateCodeVerifier() + let codeChallenge = generateCodeChallenge(from: codeVerifier) + + guard let appKey = Bundle.main.object(forInfoDictionaryKey: "KAKAO_NATIVE_APP_KEY") as? String else { + fatalError("KAKAO_NATIVE_APP_KEY not found in Info.plist") + } + + let redirectUri = "kakao\(appKey)://oauth" + + var components = URLComponents(string: "https://kauth.kakao.com/oauth/authorize")! + components.queryItems = [ + URLQueryItem(name: "response_type", value: "code"), + URLQueryItem(name: "client_id", value: appKey), + URLQueryItem(name: "redirect_uri", value: redirectUri), + URLQueryItem(name: "scope", value: "openid"), + URLQueryItem(name: "nonce", value: nonce), + URLQueryItem(name: "code_challenge", value: codeChallenge), + URLQueryItem(name: "code_challenge_method", value: "S256"), + ] + + return KakaoOAuthUrlInfo(url: components.url!.absoluteString, codeVerifier: codeVerifier) + } + + func exchangeCodeForToken( + code: String, + codeVerifier: String, + onSuccess: @escaping (String) -> Void, + onFailure: @escaping (KotlinThrowable) -> Void + ) { + AuthApi.shared.token(code: code, codeVerifier: codeVerifier) { oauthToken, error in + if let error = error { + onFailure(KotlinThrowable(message: error.localizedDescription)) + return + } + guard let idToken = oauthToken?.idToken else { + onFailure(KotlinThrowable( + message: "id_token์ด null์ž…๋‹ˆ๋‹ค. ์นด์นด์˜ค ์ฝ˜์†”์—์„œ OpenID Connect๋ฅผ ํ™œ์„ฑํ™”ํ•˜์„ธ์š”." + )) + return + } + onSuccess(idToken) + } + } + + // MARK: - PKCE Helpers + + private func generateCodeVerifier() -> String { + var bytes = [UInt8](repeating: 0, count: 32) + _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + return Data(bytes) + .base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + + private func generateCodeChallenge(from verifier: String) -> String { + let data = Data(verifier.utf8) + let hash = SHA256.hash(data: data) + return Data(hash) + .base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } +} diff --git a/iosApp/iosApp/iOSApp.swift b/iosApp/iosApp/iOSApp.swift index c4de34f14..57783c49a 100644 --- a/iosApp/iosApp/iOSApp.swift +++ b/iosApp/iosApp/iOSApp.swift @@ -1,17 +1,31 @@ import Firebase import SwiftUI import ComposeApp +import KakaoSDKCommon +import KakaoSDKAuth @main struct iOSApp: App { init() { FirebaseApp.configure() NapierKt.initializeNapier() + + guard let kakaoAppKey = Bundle.main.object(forInfoDictionaryKey: "KAKAO_NATIVE_APP_KEY") as? String else { + fatalError("KAKAO_NATIVE_APP_KEY not found in Info.plist. Check Config.xcconfig.") + } + KakaoSDK.initSDK(appKey: kakaoAppKey) } var body: some Scene { WindowGroup { ContentView() + .onOpenURL { url in + if KakaoOAuthRedirectHandler.shared.handleRedirectUrl(url: url.absoluteString) { + // Handled by in-app browser OAuth flow + } else if AuthApi.isKakaoTalkLoginUrl(url) { + _ = AuthController.handleOpenUrl(url: url) + } + } } } } diff --git a/settings.gradle.kts b/settings.gradle.kts index a100939b2..ac83c6c34 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -26,6 +26,7 @@ dependencyResolutionManagement { } mavenCentral() maven { url = uri("https://jitpack.io") } + maven { url = uri("https://devrepo.kakao.com/nexus/content/groups/public/") } } }