From 5686911cdf62d753692ce93f5b6ffe873093643a Mon Sep 17 00:00:00 2001 From: jinukeu Date: Tue, 17 Feb 2026 20:13:14 +0900 Subject: [PATCH 1/2] =?UTF-8?q?chore:=20README=20=ED=98=84=ED=96=89?= =?UTF-8?q?=ED=99=94,=20Kakao=20iOS=20SDK=20=EC=B6=94=EA=B0=80=20=EB=B0=8F?= =?UTF-8?q?=20Config.xcconfig=20gitignore=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README.md를 현재 KMP 프로젝트에 맞게 전면 수정 (API Key 설정 가이드 포함) - Kakao iOS SDK SPM 패키지 추가 (KakaoSDKAuth, KakaoSDKCommon, KakaoSDKUser) - Config.xcconfig를 gitignore에 추가하고 git 추적 해제 (API 키 보호) Co-Authored-By: Claude Opus 4.6 --- .gitignore | 1 + README.md | 125 +++++++++--------- iosApp/Configuration/Config.xcconfig | 7 - iosApp/iosApp.xcodeproj/project.pbxproj | 33 +++++ .../xcshareddata/swiftpm/Package.resolved | 20 ++- 5 files changed, 114 insertions(+), 72 deletions(-) delete mode 100644 iosApp/Configuration/Config.xcconfig 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/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", From 02c0094b9f9b895ed974c3d77b284745e0825706 Mon Sep 17 00:00:00 2001 From: jinukeu Date: Wed, 18 Feb 2026 14:59:20 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20=EC=B9=B4=EC=B9=B4=EC=98=A4=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(Android/iOS)=20=EB=B0=8F=20local.properties=20?= =?UTF-8?q?=EB=A1=9C=EB=94=A9=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Android/iOS 카카오 로그인 SDK 연동 (KakaoSignInClient expect/actual) - KakaoLoginUseCase 추가 및 DI 모듈 등록 - build.gradle.kts에서 local.properties를 명시적으로 로드하도록 수정 (project.properties는 gradle.properties만 읽어 앱 키가 빈 문자열로 빌드되는 문제 해결) - iOS: KakaoTalk 앱 로그인 + InAppBrowser PKCE 폴백 지원 - 디버그 로그 정리 Co-Authored-By: Claude Opus 4.6 --- composeApp/build.gradle.kts | 13 +++ .../src/androidMain/AndroidManifest.xml | 12 ++ .../mobile/ChukChukHaksaApplication.kt | 2 + .../common/kmp/KakaoSignInClient.android.kt | 65 +++++++++++ .../chukchukhaksa/mobile/di/AndroidModule.kt | 2 + .../mobile/common/kmp/KakaoSignInClient.kt | 10 ++ .../chukchukhaksa/mobile/di/DomainModules.kt | 2 + .../domain/auth/usecase/KakaoLoginUseCase.kt | 14 +++ .../timetable/component/WebViewGuideScreen.kt | 39 +++++-- .../mobile/MainViewController.kt | 15 ++- .../mobile/common/kmp/KakaoLoginBridge.kt | 21 ++++ .../common/kmp/KakaoOAuthRedirectHandler.kt | 62 +++++++++++ .../common/kmp/KakaoSignInClient.ios.kt | 83 ++++++++++++++ .../com/chukchukhaksa/mobile/di/IosModule.kt | 2 + gradle/libs.versions.toml | 6 + iosApp/iosApp/ContentView.swift | 2 +- iosApp/iosApp/Info.plist | 17 +++ iosApp/iosApp/KakaoLoginBridgeImpl.swift | 105 ++++++++++++++++++ iosApp/iosApp/iOSApp.swift | 14 +++ settings.gradle.kts | 1 + 20 files changed, 472 insertions(+), 15 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/com/chukchukhaksa/mobile/common/kmp/KakaoSignInClient.android.kt create mode 100644 composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/common/kmp/KakaoSignInClient.kt create mode 100644 composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/auth/usecase/KakaoLoginUseCase.kt create mode 100644 composeApp/src/iosMain/kotlin/com/chukchukhaksa/mobile/common/kmp/KakaoLoginBridge.kt create mode 100644 composeApp/src/iosMain/kotlin/com/chukchukhaksa/mobile/common/kmp/KakaoOAuthRedirectHandler.kt create mode 100644 composeApp/src/iosMain/kotlin/com/chukchukhaksa/mobile/common/kmp/KakaoSignInClient.ios.kt create mode 100644 iosApp/iosApp/KakaoLoginBridgeImpl.swift 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/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/") } } }