Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions composeApp/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,10 @@ kotlin {
implementation(libs.glance.material3)
implementation(libs.compose.runtime)
implementation(libs.kakao.sdk.v2.user)
implementation(libs.ktor.client.okhttp)
}
iosMain.dependencies {
implementation(libs.ktor.client.darwin)
}
commonMain.dependencies {
implementation(compose.runtime)
Expand Down Expand Up @@ -92,6 +94,11 @@ kotlin {
implementation(libs.kmp.firebase.analytics)
implementation(libs.napier)
implementation(libs.kinappbrowser)

implementation(libs.ktor.client.core)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.ktor.client.logging)
}
commonTest.dependencies {
implementation(libs.kotlin.test)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.chukchukhaksa.mobile.common.kmp

import io.ktor.client.engine.HttpClientEngineFactory
import io.ktor.client.engine.okhttp.OkHttp

actual val httpClientEngineFactory: HttpClientEngineFactory<*> = OkHttp
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ actual class KakaoSignInClient {
?: throw IllegalStateException("Android Context is required for Kakao Login")

val nonce = UUID.randomUUID().toString()
val hashedNonce = sha256Hex(nonce)

return suspendCancellableCoroutine { continuation ->
val callback: (OAuthToken?, Throwable?) -> Unit = callback@{ token, error ->
Expand All @@ -37,13 +38,13 @@ actual class KakaoSignInClient {
if (UserApiClient.instance.isKakaoTalkLoginAvailable(androidContext)) {
UserApiClient.instance.loginWithKakaoTalk(
context = androidContext,
nonce = nonce,
nonce = hashedNonce,
) { token, error ->
if (error != null) {
if (error is ClientError && error.reason == ClientErrorCause.Cancelled) {
UserApiClient.instance.loginWithKakaoAccount(
context = androidContext,
nonce = nonce,
nonce = hashedNonce,
callback = callback,
)
return@loginWithKakaoTalk
Expand All @@ -56,7 +57,7 @@ actual class KakaoSignInClient {
} else {
UserApiClient.instance.loginWithKakaoAccount(
context = androidContext,
nonce = nonce,
nonce = hashedNonce,
callback = callback,
)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.chukchukhaksa.mobile.common.kmp

import java.security.MessageDigest

actual fun sha256Hex(input: String): String {
val bytes = MessageDigest.getInstance("SHA-256").digest(input.toByteArray())
return bytes.joinToString("") { "%02x".format(it) }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.chukchukhaksa.mobile.common.kmp

import io.ktor.client.engine.HttpClientEngineFactory

expect val httpClientEngineFactory: HttpClientEngineFactory<*>
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package com.chukchukhaksa.mobile.common.kmp

expect fun sha256Hex(input: String): String
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.chukchukhaksa.mobile.data.auth.datasource

import com.chukchukhaksa.mobile.domain.auth.model.SignInResult

interface RemoteAuthDataSource {
suspend fun signIn(idToken: String, nonce: String): SignInResult
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.chukchukhaksa.mobile.data.auth.di

import com.chukchukhaksa.mobile.data.auth.datasource.RemoteAuthDataSource
import com.chukchukhaksa.mobile.data.auth.repository.AuthRepositoryImpl
import com.chukchukhaksa.mobile.domain.auth.repository.AuthRepository
import com.chukchukhaksa.mobile.remote.auth.RemoteAuthDataSourceImpl
import org.koin.dsl.module

val authRepositoryModule = module {
single<RemoteAuthDataSource> { RemoteAuthDataSourceImpl(get()) }
single<AuthRepository> { AuthRepositoryImpl(get()) }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.chukchukhaksa.mobile.data.auth.repository

import com.chukchukhaksa.mobile.data.auth.datasource.RemoteAuthDataSource
import com.chukchukhaksa.mobile.domain.auth.model.SignInResult
import com.chukchukhaksa.mobile.domain.auth.repository.AuthRepository

class AuthRepositoryImpl(
private val remoteAuthDataSource: RemoteAuthDataSource,
) : AuthRepository {

override suspend fun signIn(idToken: String, nonce: String): SignInResult {
return remoteAuthDataSource.signIn(idToken = idToken, nonce = nonce)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,5 +45,5 @@ val domainModule = module {

// Auth use cases
factory { AppleLoginUseCase(get()) }
factory { KakaoLoginUseCase(get()) }
factory { KakaoLoginUseCase(get(), get()) }
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.chukchukhaksa.mobile.di

import com.chukchukhaksa.mobile.data.auth.di.authRepositoryModule
import com.chukchukhaksa.mobile.data.config.di.appConfigRepositoryModule
import com.chukchukhaksa.mobile.data.openlecture.di.openLectureRepositoryModule
import com.chukchukhaksa.mobile.data.openmajor.di.openMajorRepositoryModule
Expand All @@ -13,6 +14,7 @@ import com.chukchukhaksa.mobile.local.datasource.timetable.di.localTimetableData
import com.chukchukhaksa.mobile.local.datastore.di.dataStoreModule
import com.chukchukhaksa.mobile.remote.config.remoteAppConfigDataSourceModule
import com.chukchukhaksa.mobile.remote.di.firebaseDatabaseModule
import com.chukchukhaksa.mobile.remote.di.httpClientModule
import com.chukchukhaksa.mobile.remote.timetable.remoteOpenLectureDataSourceModule
import org.koin.core.context.startKoin
import org.koin.dsl.KoinAppDeclaration
Expand All @@ -36,8 +38,10 @@ fun initKoin(config: KoinAppDeclaration? = null) {
openLectureRepositoryModule,
presentationModule,
firebaseDatabaseModule,
httpClientModule,
remoteAppConfigDataSourceModule,
appConfigRepositoryModule,
authRepositoryModule,
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.chukchukhaksa.mobile.domain.auth.model

data class SignInResult(
val accessToken: String,
val refreshToken: String,
val isPortalLinked: Boolean,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.chukchukhaksa.mobile.domain.auth.repository

import com.chukchukhaksa.mobile.domain.auth.model.SignInResult

interface AuthRepository {
suspend fun signIn(idToken: String, nonce: String): SignInResult
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
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.auth.model.SignInResult
import com.chukchukhaksa.mobile.domain.auth.repository.AuthRepository
import com.chukchukhaksa.mobile.domain.common.runCatchingIgnoreCancelled

class KakaoLoginUseCase(
private val kakaoSignInClient: KakaoSignInClient,
private val authRepository: AuthRepository,
) {
suspend operator fun invoke(context: Any? = null): Result<KakaoSignInResult> =
suspend operator fun invoke(context: Any? = null): Result<SignInResult> =
runCatchingIgnoreCancelled {
kakaoSignInClient.signIn(context)
val kakaoResult = kakaoSignInClient.signIn(context)
authRepository.signIn(
idToken = kakaoResult.idToken,
nonce = kakaoResult.nonce,
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.chukchukhaksa.mobile.remote.auth

import com.chukchukhaksa.mobile.data.auth.datasource.RemoteAuthDataSource
import com.chukchukhaksa.mobile.domain.auth.model.SignInResult
import com.chukchukhaksa.mobile.remote.auth.model.SignInRequest
import com.chukchukhaksa.mobile.remote.auth.model.SignInResponse
import com.chukchukhaksa.mobile.remote.common.ApiResponse
import com.chukchukhaksa.mobile.remote.common.getDataOrThrow
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.post
import io.ktor.client.request.setBody

class RemoteAuthDataSourceImpl(
private val httpClient: HttpClient,
) : RemoteAuthDataSource {

override suspend fun signIn(idToken: String, nonce: String): SignInResult {
val response = httpClient.post("users/signin") {
setBody(SignInRequest(idToken = idToken, nonce = nonce))
}.body<ApiResponse<SignInResponse>>()

return response.getDataOrThrow().toSignInResult()
Comment thread
lluke0 marked this conversation as resolved.
}
}

private fun SignInResponse.toSignInResult() = SignInResult(
accessToken = accessToken,
refreshToken = refreshToken,
isPortalLinked = isPortalLinked,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.chukchukhaksa.mobile.remote.auth.model

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Serializable
data class SignInRequest(
@SerialName("id_token") val idToken: String,
val nonce: String,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.chukchukhaksa.mobile.remote.auth.model

import kotlinx.serialization.Serializable

@Serializable
data class SignInResponse(
val accessToken: String,
val refreshToken: String,
val isPortalLinked: Boolean,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.chukchukhaksa.mobile.remote.common

class ApiException(
val code: String,
override val message: String,
) : RuntimeException(message)
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.chukchukhaksa.mobile.remote.common

import kotlinx.serialization.Serializable

@Serializable
data class ApiResponse<T>(
val success: Boolean,
val data: T? = null,
val error: ApiError? = null,
)

@Serializable
data class ApiError(
val code: String,
val message: String,
)

fun <T> ApiResponse<T>.getDataOrThrow(): T {
return data ?: throw ApiException(
code = error?.code.orEmpty(),
message = error?.message ?: "์•Œ ์ˆ˜ ์—†๋Š” ์—๋Ÿฌ๊ฐ€ ๋ฐœ์ƒํ–ˆ์–ด์š”.",
)
}
Comment thread
lluke0 marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package com.chukchukhaksa.mobile.remote.di

import com.chukchukhaksa.mobile.common.kmp.httpClientEngineFactory
import com.chukchukhaksa.mobile.common.kmp.isDebug
import io.github.aakira.napier.Napier
import io.ktor.client.HttpClient
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.defaultRequest
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logger
import io.ktor.client.plugins.logging.Logging
import io.ktor.client.plugins.observer.ResponseObserver
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.contentType
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import org.koin.dsl.module

private val prettyJson = Json { prettyPrint = true }

private fun String.prettyPrintJson(): String = try {
val element = prettyJson.decodeFromString<JsonElement>(this)
prettyJson.encodeToString(JsonElement.serializer(), element)
} catch (_: Exception) {
this
}

val httpClientModule = module {
single {
HttpClient(httpClientEngineFactory) {
install(HttpTimeout) {
requestTimeoutMillis = 15_000
connectTimeoutMillis = 10_000
socketTimeoutMillis = 15_000
}

install(ContentNegotiation) {
json(
Json {
ignoreUnknownKeys = true
isLenient = true
},
)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

install(Logging) {
logger = object : Logger {
override fun log(message: String) {
Napier.d(message, tag = "HttpClient")
}
}
level = if (isDebug) LogLevel.HEADERS else LogLevel.NONE
sanitizeHeader { header ->
header.equals(HttpHeaders.Authorization, ignoreCase = true) ||
header.equals(HttpHeaders.Cookie, ignoreCase = true) ||
header.equals(HttpHeaders.SetCookie, ignoreCase = true)
}
}

if (isDebug) {
install(ResponseObserver) {
onResponse { response ->
val contentType = response.headers[HttpHeaders.ContentType]
if (contentType?.contains("application/json", ignoreCase = true) != true) return@onResponse

val body = response.bodyAsText()
if (body.isNotBlank()) {
val preview = body.take(4096)
Napier.d(
"RESPONSE BODY:\n${preview.prettyPrintJson()}",
tag = "HttpClient",
)
Comment on lines +73 to +76

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

๐Ÿงน Nitpick | ๐Ÿ”ต Trivial

์‘๋‹ต ๋ณธ๋ฌธ ์ž˜๋ฆผ ์—ฌ๋ถ€๋ฅผ ๋กœ๊ทธ ๋ฉ”์‹œ์ง€์— ํ‘œ์‹œํ•˜๋ฉด ์ข‹๊ฒ ์Šต๋‹ˆ๋‹ค.

ํ˜„์žฌ ๋กœ๊ทธ ๋ฉ”์‹œ์ง€๊ฐ€ "RESPONSE BODY"๋กœ ๊ณ ์ •๋˜์–ด ์žˆ์–ด 4096์ž ์ดˆ๊ณผ ์‹œ ์ž˜๋ ธ๋Š”์ง€ ์•Œ๊ธฐ ์–ด๋ ต์Šต๋‹ˆ๋‹ค. ๋””๋ฒ„๊น… ์‹œ ํ˜ผ๋™์„ ์ค„์ด๊ธฐ ์œ„ํ•ด ์ž˜๋ฆผ ์—ฌ๋ถ€๋ฅผ ํ‘œ์‹œํ•˜๋Š” ๊ฒƒ์„ ๊ณ ๋ คํ•ด ์ฃผ์„ธ์š”.

โœจ ์ œ์•ˆ ์ˆ˜์ •์•ˆ
                         val body = response.bodyAsText()
                         if (body.isNotBlank()) {
+                            val isTruncated = body.length > 4096
                             val preview = body.take(4096)
+                            val label = if (isTruncated) "RESPONSE BODY (truncated ${body.length} -> 4096)" else "RESPONSE BODY"
                             Napier.d(
-                                "RESPONSE BODY:\n${preview.prettyPrintJson()}",
+                                "$label:\n${preview.prettyPrintJson()}",
                                 tag = "HttpClient",
                             )
                         }
๐Ÿ“ Committable suggestion

โ€ผ๏ธ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
Napier.d(
"RESPONSE BODY:\n${preview.prettyPrintJson()}",
tag = "HttpClient",
)
if (isDebug) {
install(ResponseObserver) {
onResponse { response ->
val contentType = response.headers[io.ktor.http.HttpHeaders.ContentType]
if (contentType?.contains("application/json", ignoreCase = true) != true) return@onResponse
val body = response.bodyAsText()
if (body.isNotBlank()) {
val isTruncated = body.length > 4096
val preview = body.take(4096)
val label = if (isTruncated) "RESPONSE BODY (truncated ${body.length} -> 4096)" else "RESPONSE BODY"
Napier.d(
"$label:\n${preview.prettyPrintJson()}",
tag = "HttpClient",
)
}
}
}
}
๐Ÿค– Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/di/HttpClientModule.kt`
around lines 73 - 76, The log currently always prints "RESPONSE
BODY:\n${preview.prettyPrintJson()}" via Napier.d in HttpClientModule.kt; change
it so you compute the JSON payload string from preview.prettyPrintJson() and if
its length exceeds 4096 (or the chosen max) append or replace with a clear
truncated indicator (e.g., "...[TRUNCATED, length=NNNN]") and include the
original length in the message, then pass that final string to Napier.d so logs
show whether the response was cut off; update the Napier.d call site (the code
using preview.prettyPrintJson()) to use this truncated-aware string.

}
}
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

defaultRequest {
url("https://api.cchaksa.com/api/")
contentType(ContentType.Application.Json)
}
}
}
}
Loading
Loading