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 ๋ชจ๋ ๋ฑ ์ผ๋ถ ๋ชจ๋์ ์์กด์ฑ์ ์ฌ์ง์์ ์ ์ธํจ)
-
-
-#### core
-
-์ด๋ฆ์์ ์ ์ถํ ์ ์๋ฏ ๋ง์ ๋ชจ๋์์ ์ฌ์ฌ์ฉํ๋ ํต์ฌ ์ฝ๋์
๋๋ค. core ๋ชจ๋๊ฐ์ ์์กด์ฑ์ ๊ฐ์ง ์ ์์ผ๋ core ๋ชจ๋์ด ๋ค๋ฅธ ๋ชจ๋(feature, data, domain, local, remote)์ ์์กด์ฑ์ ๊ฐ์ง๋ฉด ์๋ฉ๋๋ค.
-
-
-
-| ์ด๋ฆ | ์ญํ |
-|:-----------------:|:-----------------------------------------------------:|
-| 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/") }
}
}