Skip to content

Commit 6058d6d

Browse files
authored
Merge branch 'develop' into feature/#33-write_routine
2 parents 7c186cf + c5e853f commit 6058d6d

27 files changed

Lines changed: 1130 additions & 36 deletions

File tree

app/src/main/java/com/threegap/bitnagil/MainNavHost.kt

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@ import androidx.compose.runtime.Composable
44
import androidx.compose.ui.Modifier
55
import androidx.navigation.compose.NavHost
66
import androidx.navigation.compose.composable
7+
import androidx.navigation.toRoute
78
import com.threegap.bitnagil.presentation.home.HomeScreen
89
import com.threegap.bitnagil.presentation.intro.IntroScreenContainer
910
import com.threegap.bitnagil.presentation.login.LoginScreenContainer
1011
import com.threegap.bitnagil.presentation.splash.SplashScreenContainer
1112
import com.threegap.bitnagil.presentation.terms.TermsAgreementScreenContainer
13+
import com.threegap.bitnagil.presentation.webview.BitnagilWebViewScreen
1214

1315
@Composable
1416
fun MainNavHost(
@@ -42,6 +44,22 @@ fun MainNavHost(
4244

4345
composable<Route.TermsAgreement> {
4446
TermsAgreementScreenContainer(
47+
navigateToTermsOfService = {
48+
navigator.navController.navigate(
49+
Route.WebView(
50+
title = "약관 동의",
51+
url = "https://complex-wombat-99f.notion.site/2025-7-20-236f4587491d8071833adfaf8115bce2",
52+
),
53+
)
54+
},
55+
navigateToPrivacyPolicy = {
56+
navigator.navController.navigate(
57+
Route.WebView(
58+
title = "약관 동의",
59+
url = "https://complex-wombat-99f.notion.site/2025-07-20-236f4587491d80308016eb810692d18b",
60+
),
61+
)
62+
},
4563
navigateToOnBoarding = { },
4664
navigateToBack = { navigator.navController.popBackStack() },
4765
)
@@ -50,5 +68,14 @@ fun MainNavHost(
5068
composable<Route.Home> {
5169
HomeScreen()
5270
}
71+
72+
composable<Route.WebView> {
73+
val webViewRoute = it.toRoute<Route.WebView>()
74+
BitnagilWebViewScreen(
75+
title = webViewRoute.title,
76+
url = webViewRoute.url,
77+
onBackClick = { navigator.navController.popBackStack() },
78+
)
79+
}
5380
}
5481
}

app/src/main/java/com/threegap/bitnagil/Route.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,10 @@ sealed interface Route {
1818

1919
@Serializable
2020
data object Home : Route
21+
22+
@Serializable
23+
data class WebView(
24+
val title: String,
25+
val url: String,
26+
) : Route
2127
}

app/src/main/java/com/threegap/bitnagil/di/core/NetworkModule.kt

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
11
package com.threegap.bitnagil.di.core
22

3+
import android.content.Context
4+
import android.content.Intent
35
import com.threegap.bitnagil.BuildConfig
6+
import com.threegap.bitnagil.MainActivity
47
import com.threegap.bitnagil.datastore.auth.storage.AuthTokenDataStore
58
import com.threegap.bitnagil.network.auth.AuthInterceptor
9+
import com.threegap.bitnagil.network.auth.TokenAuthenticator
10+
import com.threegap.bitnagil.network.token.ReissueService
611
import com.threegap.bitnagil.network.token.TokenProvider
712
import dagger.Module
813
import dagger.Provides
914
import dagger.hilt.InstallIn
15+
import dagger.hilt.android.qualifiers.ApplicationContext
1016
import dagger.hilt.components.SingletonComponent
1117
import kotlinx.coroutines.flow.firstOrNull
1218
import kotlinx.serialization.json.Json
@@ -56,11 +62,37 @@ object NetworkModule {
5662

5763
@Provides
5864
@Singleton
59-
fun provideTokenStore(dataStore: AuthTokenDataStore): TokenProvider =
65+
fun provideTokenProvider(dataStore: AuthTokenDataStore): TokenProvider =
6066
object : TokenProvider {
6167
override suspend fun getAccessToken(): String? = dataStore.tokenFlow.firstOrNull()?.accessToken
68+
69+
override suspend fun getRefreshToken(): String? = dataStore.tokenFlow.firstOrNull()?.refreshToken
70+
71+
override suspend fun saveTokens(accessToken: String, refreshToken: String) =
72+
dataStore.updateAuthToken(accessToken, refreshToken)
73+
74+
override suspend fun clearTokens() = dataStore.clearAuthToken()
6275
}
6376

77+
@Provides
78+
@Singleton
79+
fun provideTokenAuthenticator(
80+
tokenProvider: TokenProvider,
81+
reissueService: ReissueService,
82+
@ApplicationContext context: Context,
83+
): TokenAuthenticator = TokenAuthenticator(
84+
tokenProvider = tokenProvider,
85+
reissueService = reissueService,
86+
onTokenExpired = {
87+
val intent = Intent(context, MainActivity::class.java).apply {
88+
flags = Intent.FLAG_ACTIVITY_NEW_TASK or
89+
Intent.FLAG_ACTIVITY_CLEAR_TASK or
90+
Intent.FLAG_ACTIVITY_CLEAR_TOP
91+
}
92+
context.startActivity(intent)
93+
},
94+
)
95+
6496
@Provides
6597
@Singleton
6698
@Auth
@@ -73,9 +105,11 @@ object NetworkModule {
73105
fun provideAuthOkHttpClient(
74106
httpLoggingInterceptor: HttpLoggingInterceptor,
75107
@Auth authInterceptor: Interceptor,
108+
tokenAuthenticator: TokenAuthenticator,
76109
): OkHttpClient = OkHttpClient.Builder()
77110
.addInterceptor(authInterceptor)
78111
.addInterceptor(httpLoggingInterceptor)
112+
.authenticator(tokenAuthenticator)
79113
.connectTimeout(10L, TimeUnit.SECONDS)
80114
.writeTimeout(30L, TimeUnit.SECONDS)
81115
.readTimeout(30L, TimeUnit.SECONDS)

app/src/main/java/com/threegap/bitnagil/di/data/ServiceModule.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import com.threegap.bitnagil.data.auth.service.AuthService
44
import com.threegap.bitnagil.data.onboarding.service.OnBoardingService
55
import com.threegap.bitnagil.data.writeroutine.service.WriteRoutineService
66
import com.threegap.bitnagil.di.core.Auth
7+
import com.threegap.bitnagil.di.core.NoneAuth
8+
import com.threegap.bitnagil.network.token.ReissueService
79
import dagger.Module
810
import dagger.Provides
911
import dagger.hilt.InstallIn
@@ -29,4 +31,10 @@ object ServiceModule {
2931
@Singleton
3032
fun providerWriteRoutineService(@Auth retrofit: Retrofit): WriteRoutineService =
3133
retrofit.create(WriteRoutineService::class.java)
34+
35+
@Provides
36+
@Singleton
37+
fun provideReissueService(@NoneAuth retrofit: Retrofit): ReissueService =
38+
retrofit.create(ReissueService::class.java)
39+
3240
}

core/designsystem/src/main/java/com/threegap/bitnagil/designsystem/color/BitnagilColors.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ data class BitnagilColors(
2929
val coolGray30: Color = CoolGray30,
3030
val coolGray20: Color = CoolGray20,
3131
val coolGray10: Color = CoolGray10,
32+
val coolGray7: Color = CoolGray7,
33+
val coolGray5: Color = CoolGray5,
3234
val navy900: Color = Navy900,
3335
val navy800: Color = Navy800,
3436
val navy700: Color = Navy700,
Lines changed: 70 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,68 @@
11
package com.threegap.bitnagil.network.auth
22

3+
import com.threegap.bitnagil.network.token.ReissueService
4+
import com.threegap.bitnagil.network.token.TokenProvider
35
import kotlinx.coroutines.runBlocking
6+
import kotlinx.coroutines.sync.Mutex
7+
import kotlinx.coroutines.sync.withLock
48
import okhttp3.Authenticator
59
import okhttp3.Request
610
import okhttp3.Response
711
import okhttp3.Route
812

9-
class TokenAuthenticator : Authenticator {
13+
class TokenAuthenticator(
14+
private val tokenProvider: TokenProvider,
15+
private val reissueService: ReissueService,
16+
private val onTokenExpired: (() -> Unit)? = null,
17+
) : Authenticator {
18+
19+
private val authMutex = Mutex()
1020

1121
override fun authenticate(route: Route?, response: Response): Request? {
12-
if (response.code != UNAUTHORIZED) return null
13-
if (responseCount(response) >= MAX_RETRY) return null
22+
if (!shouldRetry(response)) return null
1423

15-
// 재발급 api 연결 시 수정 예정입니다.(현재 코드는 임시)
16-
val newAccessToken = runBlocking {}
24+
val currentToken = runBlocking { tokenProvider.getAccessToken() }
25+
val authHeader = response.request.header(AUTHORIZATION)
26+
val requestToken = authHeader?.takeIf { it.startsWith("$TOKEN_PREFIX ") }
27+
?.substring("$TOKEN_PREFIX ".length)
1728

18-
return response.request.newBuilder()
19-
.header(AUTHORIZATION, "$BEARER $newAccessToken")
20-
.build()
29+
if (!currentToken.isNullOrBlank() && !requestToken.isNullOrBlank() && currentToken != requestToken) {
30+
return buildRequestWithToken(response.request, currentToken)
31+
}
32+
33+
return runBlocking {
34+
authMutex.withLock { refreshAndRetry(response) }
35+
}
36+
}
37+
38+
private suspend fun refreshAndRetry(response: Response): Request? {
39+
val refreshToken = tokenProvider.getRefreshToken()
40+
41+
if (refreshToken.isNullOrBlank()) {
42+
handleTokenExpiration()
43+
return null
44+
}
45+
46+
return runCatching {
47+
reissueService.reissueToken(refreshToken)
48+
}.fold(
49+
onSuccess = { baseResponse ->
50+
if (baseResponse.data != null && baseResponse.code == SUCCESS_CODE) {
51+
tokenProvider.saveTokens(
52+
accessToken = baseResponse.data.accessToken,
53+
refreshToken = baseResponse.data.refreshToken,
54+
)
55+
buildRequestWithToken(response.request, baseResponse.data.accessToken)
56+
} else {
57+
handleTokenExpiration()
58+
null
59+
}
60+
},
61+
onFailure = {
62+
handleTokenExpiration()
63+
null
64+
},
65+
)
2166
}
2267

2368
private fun responseCount(response: Response): Int {
@@ -30,10 +75,26 @@ class TokenAuthenticator : Authenticator {
3075
return count
3176
}
3277

78+
private fun shouldRetry(response: Response): Boolean {
79+
return response.code == UNAUTHORIZED && responseCount(response) < MAX_RETRY
80+
}
81+
82+
private fun buildRequestWithToken(originalRequest: Request, token: String): Request {
83+
return originalRequest.newBuilder()
84+
.header(AUTHORIZATION, "$TOKEN_PREFIX $token")
85+
.build()
86+
}
87+
88+
private suspend fun handleTokenExpiration() {
89+
tokenProvider.clearTokens()
90+
onTokenExpired?.invoke()
91+
}
92+
3393
companion object {
3494
private const val UNAUTHORIZED = 401
3595
private const val MAX_RETRY = 2
3696
private const val AUTHORIZATION = "Authorization"
37-
private const val BEARER = "Bearer"
97+
private const val TOKEN_PREFIX = "Bearer"
98+
private const val SUCCESS_CODE = "CO000"
3899
}
39100
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package com.threegap.bitnagil.network.model
2+
3+
import kotlinx.serialization.SerialName
4+
import kotlinx.serialization.Serializable
5+
6+
@Serializable
7+
data class AuthToken(
8+
@SerialName("accessToken")
9+
val accessToken: String,
10+
@SerialName("refreshToken")
11+
val refreshToken: String,
12+
@SerialName("role") // todo: 제거 예정
13+
val role: String,
14+
)
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package com.threegap.bitnagil.network.token
2+
3+
import com.threegap.bitnagil.network.model.AuthToken
4+
import com.threegap.bitnagil.network.model.BaseResponse
5+
import retrofit2.http.Header
6+
import retrofit2.http.POST
7+
8+
interface ReissueService {
9+
@POST("/api/v1/auth/token/reissue")
10+
suspend fun reissueToken(
11+
@Header("Refresh-Token") refreshToken: String,
12+
): BaseResponse<AuthToken>
13+
}

core/network/src/main/java/com/threegap/bitnagil/network/token/TokenProvider.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,7 @@ package com.threegap.bitnagil.network.token
22

33
interface TokenProvider {
44
suspend fun getAccessToken(): String?
5+
suspend fun getRefreshToken(): String?
6+
suspend fun saveTokens(accessToken: String, refreshToken: String)
7+
suspend fun clearTokens()
58
}

data/src/main/java/com/threegap/bitnagil/data/common/SafeApiCall.kt

Lines changed: 47 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,31 +9,60 @@ import java.io.IOException
99

1010
internal suspend inline fun <T> safeApiCall(
1111
crossinline apiCall: suspend () -> BaseResponse<T>,
12-
): Result<T> {
13-
return try {
12+
): Result<T> =
13+
try {
1414
val response = apiCall()
1515
response.data?.let { data ->
1616
Result.success(data)
1717
} ?: Result.failure(
1818
BitnagilError(
19-
code = "EMPTY_DATA",
19+
code = "EMPTY_DATA: ${response.code}",
2020
message = response.message,
2121
),
2222
)
23-
} catch (e: HttpException) {
24-
val errorBody = e.response()?.errorBody()?.string()
25-
val errorResponse = errorBody?.let {
26-
Json.decodeFromString<ErrorResponse>(it)
27-
}
28-
Result.failure(
29-
BitnagilError(
30-
code = errorResponse?.code ?: "HTTP_${e.code()}",
31-
message = errorResponse?.message ?: e.message(),
32-
),
33-
)
34-
} catch (e: IOException) {
35-
Result.failure(Exception(e.message ?: "Network error"))
3623
} catch (e: Exception) {
37-
Result.failure(Exception(e.message ?: "Unknown error"))
24+
handleApiException(e)
25+
}
26+
27+
internal suspend inline fun safeUnitApiCall(
28+
crossinline apiCall: suspend () -> BaseResponse<Unit>,
29+
): Result<Unit> =
30+
try {
31+
apiCall()
32+
Result.success(Unit)
33+
} catch (e: Exception) {
34+
handleApiException(e)
35+
}
36+
37+
private val json = Json { ignoreUnknownKeys = true }
38+
39+
private fun parseErrorResponse(errorBody: String?): ErrorResponse? =
40+
errorBody?.let {
41+
try {
42+
json.decodeFromString<ErrorResponse>(it)
43+
} catch (e: Exception) {
44+
null
45+
}
46+
}
47+
48+
private fun <T> handleApiException(e: Exception): Result<T> =
49+
when (e) {
50+
is HttpException -> {
51+
val errorBody = e.response()?.errorBody()?.string()
52+
val errorResponse = parseErrorResponse(errorBody)
53+
Result.failure(
54+
BitnagilError(
55+
code = errorResponse?.code ?: "HTTP_${e.code()}",
56+
message = errorResponse?.message ?: e.message(),
57+
),
58+
)
59+
}
60+
61+
is IOException -> {
62+
Result.failure(Exception(e.message ?: "Network error"))
63+
}
64+
65+
else -> {
66+
Result.failure(Exception(e.message ?: "Unknown error"))
67+
}
3868
}
39-
}

0 commit comments

Comments
 (0)