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
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ dependencies {
implementation(projects.core.network)
implementation(projects.core.navigation)
implementation(projects.core.ui)
implementation(projects.core.datastore)
implementation(projects.core.util)
implementation(projects.data)
implementation(projects.domain)
Expand Down
13 changes: 13 additions & 0 deletions app/src/main/java/com/yapp/twix/di/AppModule.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.yapp.twix.di

import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import org.koin.dsl.module

val appModule =
module {
single<CoroutineScope> {
CoroutineScope(SupervisorJob() + Dispatchers.IO)
}
}
3 changes: 3 additions & 0 deletions app/src/main/java/com/yapp/twix/di/InitKoin.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.yapp.twix.di

import android.content.Context
import com.twix.data.di.dataModule
import com.twix.datastore.di.dataStoreModule
import com.twix.network.di.networkModule
import org.koin.android.ext.koin.androidContext
import org.koin.core.context.startKoin
Expand All @@ -21,6 +22,8 @@ fun initKoin(
addAll(networkModule)
addAll(dataModule)
add(uiModule)
add(dataStoreModule)
add(appModule)
},
)
}
Expand Down
1 change: 1 addition & 0 deletions core/datastore/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/build
16 changes: 16 additions & 0 deletions core/datastore/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
plugins {
alias(libs.plugins.twix.android.library)
alias(libs.plugins.twix.koin)
alias(libs.plugins.serialization)
}

android {
namespace = "com.twix.datastore"
}

dependencies {
implementation(projects.core.token)

implementation(libs.androidx.datastore)
implementation(libs.kotlinx.serialization.json)
}
Empty file.
21 changes: 21 additions & 0 deletions core/datastore/proguard-rules.pro
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html

# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}

# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable

# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
4 changes: 4 additions & 0 deletions core/datastore/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

</manifest>
60 changes: 60 additions & 0 deletions core/datastore/src/main/java/com/twix/datastore/AuthConfigure.kt
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Json 인스턴스를 매번 생성하지 말고 미리 선언해놓고 사용하면 좋을 거 같아요! 보통 Json 미리 만들어 둘 때

private val json = Json {
    ignoreUnknownKeys = true
}

이렇게 모르는 키는 무시해서 Exception 발생 안하게 하기도 해요. 추가 설정 필요한 거 있으면 더 추가해도 됩니다!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

좋은 생각인거 같아 !

리뷰 반영 커밋 : https://github.com/YAPP-Github/Twix-
Android/pull/59/commits/773011e5c9b140d39d62bdeb77f9e6d436d30c69

Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package com.twix.datastore

import androidx.datastore.core.Serializer
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import java.io.InputStream
import java.io.OutputStream

@Serializable
internal data class AuthConfigure(
val accessToken: String =
"eyJhbGciOiJIUzM4NCJ9." +
"eyJzdWIiOiIxIiwidHlwZSI6ImFjY2VzcyIsImlhdCI6MTc3MDI0NzM0NCwiZXhwIjoxNzcwODUyMTQ0fQ." +
"67rDscm8BeayYFA1gfcEMliEdEh8-HTUyE5TwmAT8Ef8ZvtaWczxpMNZqI5htiek",
val refreshToken: String =
"eyJhbGciOiJIUzM4NCJ9." +
"eyJzdWIiOiIxIiwidHlwZSI6InJlZnJlc2giLCJpYXQiOjE3NzAyNDczNDQsImV4cCI6MTc3MDg1MjE0NH0." +
"zgUYdR6onyeY5EaH2_pWLs1rjNLf8m8ZeXsY7Cbk99a_2tzR0rDBZO_hdGTnorRL",
)

internal object AuthConfigureSerializer : Serializer<AuthConfigure> {
private val json =
Json {
ignoreUnknownKeys = true
isLenient = true
}

override val defaultValue: AuthConfigure
get() = AuthConfigure()

override suspend fun readFrom(input: InputStream): AuthConfigure =
try {
withContext(Dispatchers.IO) {
json.decodeFromString(
deserializer = AuthConfigure.serializer(),
string = input.readBytes().decodeToString(),
)
}
} catch (e: SerializationException) {
defaultValue
}
Comment on lines +34 to +44
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

여기에서는 IO 디스패처를 사용하지 않는 이유가 있나요?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

앗.. 단순 실수 ^_^... 꼼꼼하게 봐줘서 고마워 !

리뷰 반영 커밋 : 31b2b9b


override suspend fun writeTo(
t: AuthConfigure,
output: OutputStream,
) {
withContext(Dispatchers.IO) {
output.write(
json
.encodeToString(
serializer = AuthConfigure.serializer(),
value = t,
).encodeToByteArray(),
)
}
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

토큰을 매번 처음부터 읽지 않고 StateIn 사용해서 StateFlow로 캐싱하면 더 좋을 거 같아요! stateIn 쓰려면 스코프가 있어야 하는데 Koin으로 주입받거나 내부에서 SupervisorJob 기반으로 스코프 만들어서 쓰면 될 것 같습니다.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

너무 좋은 아이디어인거 같아 ! 단순 값을 읽어오는걸 메서드로 만드는 것도 프로퍼티로
만들고 싶었는데 방법을 못찾았었거든... 🥲 현수가 제안해준대로 고민해서 수정해봤어 !

리뷰 반영 커밋 : 8d13943 67e6acc

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@Serializable
internal data class AuthTokens(
    val accessToken: String = "",
    val refreshToken: String = "",
)

이미 만들어두신 AuthConfigure 사용해도 되고 이런 거 추가해서 캐싱할 때 access, refresh 둘 다 들고 있으면 두번 읽지 않게 구현할 수 있을 것 같아요

Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package com.twix.datastore

import android.content.Context
import androidx.datastore.core.DataStore
import com.twix.token.TokenProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn

class AuthTokenProvider(
context: Context,
scope: CoroutineScope,
) : TokenProvider {
private val dataStore: DataStore<AuthConfigure> = context.authDataStore

private val tokenState =
dataStore.data
.stateIn(
scope = scope,
started = SharingStarted.Eagerly,
initialValue = AuthConfigure(),
)

override val accessToken: String
get() = tokenState.value.accessToken

override val refreshToken: String
get() = tokenState.value.refreshToken

override suspend fun saveToken(
accessToken: String,
refreshToken: String,
) {
dataStore.updateData {
it.copy(accessToken = accessToken, refreshToken = refreshToken)
}
}

override suspend fun clear() {
dataStore.updateData {
it.copy(accessToken = "", refreshToken = "")
}
}
}
10 changes: 10 additions & 0 deletions core/datastore/src/main/java/com/twix/datastore/DataStore.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.twix.datastore

import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.dataStore

internal val Context.authDataStore: DataStore<AuthConfigure> by dataStore(
fileName = "auth-configure.json",
serializer = AuthConfigureSerializer,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.twix.datastore.di

import android.content.Context
import com.twix.datastore.AuthTokenProvider
import com.twix.token.TokenProvider
import kotlinx.coroutines.CoroutineScope
import org.koin.dsl.module

val dataStoreModule =
module {
single<TokenProvider> { AuthTokenProvider(get<Context>(), get<CoroutineScope>()) }
}
1 change: 1 addition & 0 deletions core/network/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ android {
}

dependencies {
implementation(projects.core.token)
implementation(projects.core.result)

implementation(libs.bundles.ktor)
Expand Down
93 changes: 81 additions & 12 deletions core/network/src/main/java/com/twix/network/HttpClientProvider.kt
Original file line number Diff line number Diff line change
@@ -1,21 +1,30 @@
package com.twix.network

import com.twix.network.model.request.RefreshRequest
import com.twix.network.service.AuthService
import com.twix.token.TokenProvider
import io.ktor.client.HttpClient
import io.ktor.client.HttpClientConfig
import io.ktor.client.plugins.HttpTimeout
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.ANDROID
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.HttpRequestBuilder
import io.ktor.http.ContentType
import io.ktor.http.contentType
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json

internal object HttpClientProvider {
fun createHttpClient(
tokenProvider: TokenProvider,
authService: Lazy<AuthService>,
baseUrl: String,
isDebug: Boolean,
): HttpClient =
Expand All @@ -26,18 +35,7 @@ internal object HttpClientProvider {
configureLogging(isDebug)
configureTimeout()
configureDefaultRequest(baseUrl)

// TODO : 토큰 관련 기능 구현 후 적용
// install(Auth) {
// bearer {
// loadTokens {
// BearerTokens(
// accessToken = "",
// refreshToken = "",
// )
// }
// }
// }
configureAuth(tokenProvider, authService, baseUrl)
}

private fun HttpClientConfig<*>.configureContentNegotiation(isDebug: Boolean) {
Expand Down Expand Up @@ -81,6 +79,77 @@ internal object HttpClientProvider {
}
}

/**
* Ktor [Auth] Bearer 플러그인을 설정한다.
*
* ### 역할
* - 모든 요청에 AccessToken 자동 첨부
* - 401 발생 시 RefreshToken으로 토큰 재발급
* - 재발급 성공 시 TokenProvider에 토큰 갱신 후 재요청
*
* ### 왜 Lazy<AuthService> 인가?
* HttpClient ↔ AuthService 간 순환 의존성을 방지하기 위함.
*
* AuthService 내부에서 HttpClient를 사용하고,
* HttpClient 설정 시 AuthService가 필요하기 때문에
* 즉시 생성하면 DI 순환참조가 발생한다.
*
* Lazy 로 지연 생성하여 실제 refresh 시점에만 인스턴스를 획득한다.
*
*/
private fun HttpClientConfig<*>.configureAuth(
tokenProvider: TokenProvider,
lazyAuthService: Lazy<AuthService>,
baseUrl: String,
) {
install(Auth) {
bearer {
cacheTokens = false

/**
* [loadTokens]
* - 현재 저장된 access/refresh token을 읽어 Authorization 헤더에 추가
* */
loadTokens {
val accessToken = tokenProvider.accessToken
val refreshToken = tokenProvider.refreshToken
BearerTokens(accessToken, refreshToken)
}

/**
* [refreshTokens]
* - 401 Unauthorized 발생 시 호출
* - AuthService.refresh() API 호출
* - 새 토큰을 발급받아 TokenProvider에 저장
* - BearerTokens 반환 → 원 요청 자동 재시도
* **/
refreshTokens {
val refreshToken = tokenProvider.refreshToken
val request = RefreshRequest(refreshToken)
val apiService = lazyAuthService.value
val (newAccessToken, newRefreshToken) = apiService.refresh(request)

tokenProvider.saveToken(newAccessToken, newRefreshToken)
BearerTokens(newAccessToken, newRefreshToken)
}

/**
* [sendWithoutRequest]
* - refresh API 호출 시에는 토큰을 붙이지 않음
* - refresh 요청 자체가 다시 refresh 되는 무한 루프 방지 목적
* **/
sendWithoutRequest { request: HttpRequestBuilder ->
val requestUrl: String = request.url.toString()

requestUrl.startsWith(baseUrl) &&
!requestUrl.endsWith(AUTH_REFRESH_ENDPOINT)
}
}
}
}

private const val SANITIZE_HEADER = "Authorization"
private const val TIMEOUT_MILLIS = 30_000L

private const val AUTH_REFRESH_ENDPOINT = "/auth/refresh"
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package com.twix.network.di

import com.twix.network.service.AuthService
import com.twix.network.service.OnboardingService
import com.twix.network.service.createAuthService
import com.twix.network.service.createOnboardingService
import de.jensklingenberg.ktorfit.Ktorfit
import org.koin.dsl.module
Expand All @@ -10,4 +12,7 @@ internal val apiServiceModule =
single<OnboardingService> {
get<Ktorfit>().createOnboardingService()
}
single<AuthService> {
get<Ktorfit>().createAuthService()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ internal val httpClientModule =

single<HttpClient> {
HttpClientProvider.createHttpClient(
tokenProvider = get(),
authService = lazy { get() },
baseUrl = BuildConfig.BASE_URL,
isDebug = BuildConfig.DEBUG,
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.twix.network.model.request

import kotlinx.serialization.Serializable

@Serializable
data class LoginRequest(
val code: String,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.twix.network.model.request

import kotlinx.serialization.Serializable

@Serializable
data class RefreshRequest(
val refreshToken: String,
)
Loading