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
11 changes: 11 additions & 0 deletions composeApp/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,16 @@ kotlin {
implementation(libs.androidx.activity.compose)
// Android용 Ktor 엔진
implementation(libs.ktor.client.okhttp)
// Datastore
implementation(libs.datastore)
implementation(libs.datastore.preferences)
}
iosMain.dependencies {
// iOS, MacOS용 Ktor 엔진
implementation(libs.ktor.client.darwin)
// Datastore
implementation(libs.datastore)
implementation(libs.datastore.preferences)
}
nativeMain.dependencies {
// iOS, MacOS용 Ktor 엔진
Expand All @@ -84,6 +90,8 @@ kotlin {
// Ktor 핵심 클라이언트
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.logging)
implementation(libs.ktor.client.auth)
implementation(libs.ktor.client.auth)
// JSON 직렬화를 위해
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
Expand All @@ -106,6 +114,9 @@ kotlin {
implementation(libs.kotlinx.coroutinesSwing)
// 데스크톱(JVM)용 Ktor 엔진
implementation(libs.ktor.client.okhttp)
// Datastore
implementation(libs.datastore)
implementation(libs.datastore.preferences)
}
wasmJsMain.dependencies {
implementation(libs.ktor.client.cio)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package org.whosin.client.datastore

import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import org.whosin.client.core.datastore.DATA_STORE_FILE_NAME
import org.whosin.client.core.datastore.createDataStore

fun createDataStore(context: Context): DataStore<Preferences> {
return createDataStore {
context.filesDir.resolve(DATA_STORE_FILE_NAME).absolutePath
}
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
package org.whosin.client.di

import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import io.ktor.client.engine.HttpClientEngine
import io.ktor.client.engine.okhttp.OkHttp
import org.koin.android.ext.koin.androidContext
import org.koin.core.module.Module
import org.koin.dsl.module
import org.whosin.client.core.datastore.TokenManager
import org.whosin.client.core.datastore.TokenManagerImpl
import org.whosin.client.datastore.createDataStore

actual val platformModule: Module
get() = module {
single<HttpClientEngine> { OkHttp.create() }
single<DataStore<Preferences>> { createDataStore(androidContext())}
single<TokenManager> { TokenManagerImpl(get()) }
}
3 changes: 3 additions & 0 deletions composeApp/src/commonMain/kotlin/org/whosin/client/App.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import androidx.compose.ui.Modifier
import androidx.navigation.compose.rememberNavController
import org.jetbrains.compose.ui.tooling.preview.Preview
import org.whosin.client.core.navigation.WhosInNavGraph
import org.whosin.client.presentation.dummy.DummyScreen
import org.whosin.client.presentation.dummy.TokenTestScreen
import ui.theme.WhosInTheme


Expand All @@ -25,5 +27,6 @@ fun App() {
// Test용으로 남겨둔 코드, 추후 삭제 예정
// 확인하려면 위의 코드는 주석처리하고 실행
// DummyScreen()
// TokenTestScreen()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package org.whosin.client.core.datastore

interface TokenManager {
suspend fun getAccessToken(): String?
suspend fun getRefreshToken(): String?
suspend fun saveTokens(accessToken: String, refreshToken: String)
suspend fun clearToken()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package org.whosin.client.core.datastore

import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.flow.first

class TokenManagerImpl(
private val dataStore: DataStore<Preferences>
): TokenManager {
private val accessKey = stringPreferencesKey("access_token")
private val refreshKey = stringPreferencesKey("refresh_token")

override suspend fun getAccessToken(): String? = dataStore.data.first()[accessKey]
override suspend fun getRefreshToken(): String? = dataStore.data.first()[refreshKey]

override suspend fun saveTokens(accessToken: String, refreshToken: String) {
dataStore.edit {
it[accessKey] = accessToken
it[refreshKey] = refreshToken
}
}

override suspend fun clearToken() {
dataStore.edit { it.clear() }
}
Comment on lines +15 to +27
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

DataStore 읽기 실패 시 앱이 크래시됩니다

현재 dataStore.data.first() 호출에서 발생하는 IOException을 잡아주지 않아 복구 불가능한 크래시로 이어집니다. 공식 가이드처럼 emptyPreferences()를 emit하도록 잡아주세요.

 import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.emptyPreferences
 import androidx.datastore.preferences.core.stringPreferencesKey
 import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.catch
+import java.io.IOException
@@
-    override suspend fun getAccessToken(): String? = dataStore.data.first()[accessKey]
-    override suspend fun getRefreshToken(): String? = dataStore.data.first()[refreshKey]
+    private val dataFlow = dataStore.data.catch { error ->
+        if (error is IOException) {
+            emit(emptyPreferences())
+        } else {
+            throw error
+        }
+    }
+
+    override suspend fun getAccessToken(): String? = dataFlow.first()[accessKey]
+    override suspend fun getRefreshToken(): String? = dataFlow.first()[refreshKey]
📝 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
override suspend fun getAccessToken(): String? = dataStore.data.first()[accessKey]
override suspend fun getRefreshToken(): String? = dataStore.data.first()[refreshKey]
override suspend fun saveTokens(accessToken: String, refreshToken: String) {
dataStore.edit {
it[accessKey] = accessToken
it[refreshKey] = refreshToken
}
}
override suspend fun clearToken() {
dataStore.edit { it.clear() }
}
private val dataFlow = dataStore.data.catch { error ->
if (error is IOException) {
emit(emptyPreferences())
} else {
throw error
}
}
override suspend fun getAccessToken(): String? = dataFlow.first()[accessKey]
override suspend fun getRefreshToken(): String? = dataFlow.first()[refreshKey]
override suspend fun saveTokens(accessToken: String, refreshToken: String) {
dataStore.edit {
it[accessKey] = accessToken
it[refreshKey] = refreshToken
}
}
override suspend fun clearToken() {
dataStore.edit { it.clear() }
}
🤖 Prompt for AI Agents
In
composeApp/src/commonMain/kotlin/org/whosin/client/core/datastore/TokenManagerImpl.kt
around lines 15 to 27, the direct calls to dataStore.data.first() can throw
IOException and crash the app; change the reads to use dataStore.data.catch { if
(it is IOException) emit(emptyPreferences()) else throw it } .first() and then
access the keys (e.g., dataStore.data.catch { ... }.first()[accessKey]) so that
on IOException emptyPreferences() is emitted instead of propagating the
exception; keep saveTokens and clearToken as-is.

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package org.whosin.client.core.datastore

import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import okio.Path.Companion.toPath

fun createDataStore(producePath: () -> String): DataStore<Preferences> {
return PreferenceDataStoreFactory.createWithPath(
produceFile = { producePath().toPath() }
)
}

internal const val DATA_STORE_FILE_NAME = "prefs.preferences_pb"
Original file line number Diff line number Diff line change
@@ -1,24 +1,35 @@
package org.whosin.client.core.network

import io.ktor.client.HttpClient
import io.ktor.client.HttpClientConfig
import io.ktor.client.call.body
import io.ktor.client.engine.HttpClientEngine
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.HttpTimeoutConfig
import io.ktor.client.plugins.auth.Auth
import io.ktor.client.plugins.auth.providers.BearerTokens
import io.ktor.client.plugins.auth.providers.bearer
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.request.post
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.contentType
import io.ktor.http.encodedPath
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
import org.whosin.client.BuildKonfig
import org.whosin.client.core.datastore.TokenManager
import org.whosin.client.data.dto.request.ReissueTokenRequestDto
import org.whosin.client.data.dto.response.TokenDto

object HttpClientFactory {
val BASE_URL = BuildKonfig.BASE_URL
fun create(engine: HttpClientEngine): HttpClient {
fun create(
engine: HttpClientEngine,
tokenManager: TokenManager
): HttpClient {
return HttpClient(engine) {
install(ContentNegotiation) {
json(
Expand All @@ -33,13 +44,64 @@ object HttpClientFactory {
socketTimeoutMillis = 20_000L
requestTimeoutMillis = 20_000L
}
install(Auth){
bearer {
loadTokens {
val accessToken = tokenManager.getAccessToken() ?: "no_token"
val refreshToken = tokenManager.getRefreshToken() ?: "no_token"
BearerTokens(accessToken = accessToken, refreshToken = refreshToken)
}
sendWithoutRequest { request ->
val host = "https://"+request.url.host+"/"
val path = request.url.encodedPath
val pathWithNoAuth = listOf(
"jokes",
"users/signup",
"users/find-password",
"auth/login",
"auth/email",
"auth/email/validation"
)
// 결과가 true면 Authorization 헤더 추가, false면 제거
if(host != BASE_URL){
println("External API - No Auth")
false
}else{
// pathWithNoAuth에 있는 경로에는 Authorization 헤더 제외
val isNoAuthPath = pathWithNoAuth.any { noAuthPath ->
path.startsWith(noAuthPath) || path.contains(noAuthPath)
}
println("isNoAuthPath: $isNoAuthPath")
!isNoAuthPath
}
}
refreshTokens {
val rt = tokenManager.getRefreshToken() ?: "no_token"
val response = client.post("member/reissue"){
setBody {
ReissueTokenRequestDto(
refreshToken = rt
)
}
markAsRefreshTokenRequest()
}.body<TokenDto>()
tokenManager.saveTokens(
accessToken = response.accessToken,
refreshToken = response.refreshToken
)
val accessToken = response.accessToken
val refreshToken = response.refreshToken
BearerTokens(accessToken,refreshToken)
}
}
}
install(Logging){
logger = object : Logger {
override fun log(message: String) {
println(message)
}
}
level = LogLevel.BODY
level = LogLevel.ALL
}
defaultRequest {
contentType(ContentType.Application.Json)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package org.whosin.client.data.dto.request

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

@Serializable
data class ReissueTokenRequestDto(
@SerialName("refreshToken")
val refreshToken: String
)
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import org.whosin.client.data.repository.DummyRepository
import org.whosin.client.data.repository.ClubRepository
import org.whosin.client.data.repository.MemberRepository
import org.whosin.client.presentation.dummy.DummyViewModel
import org.whosin.client.presentation.dummy.TokenTestViewModel
import org.whosin.client.presentation.auth.login.viewmodel.LoginViewModel
import org.whosin.client.presentation.home.HomeViewModel
import org.whosin.client.presentation.mypage.MyPageViewModel
Expand All @@ -26,7 +27,7 @@ fun appModule() = listOf(
expect val platformModule: Module

val httpClientModule = module {
single{ HttpClientFactory.create(get()) }
single{ HttpClientFactory.create(get(), get()) }
}

val dataSourceModule = module {
Expand All @@ -47,4 +48,5 @@ val viewModelModule = module {
viewModelOf(::HomeViewModel)
viewModelOf(::MyPageViewModel)
viewModelOf(::DummyViewModel) // TODO: 이후에 삭제 예정
viewModelOf(::TokenTestViewModel) // TODO: 이후에 삭제 예정
}
Loading