From a9f1d83b6601df3128359b463bd873d20cbc001e Mon Sep 17 00:00:00 2001 From: Moonsu Kang Date: Wed, 11 Sep 2024 18:38:09 +0900 Subject: [PATCH 1/3] =?UTF-8?q?[REFACTOR/#213]=20RefreshToken=20=EB=A7=8C?= =?UTF-8?q?=EB=A3=8C=20=EC=8B=9C=20=EB=A1=9C=EC=A7=81=20=EB=B3=80=EA=B2=BD?= =?UTF-8?q?=20=EB=B0=8F=20=EC=83=81=EC=88=98=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data/remote/interceptor/AuthInterceptor.kt | 14 +++++++++++--- .../clody/presentation/ui/main/MainActivity.kt | 9 ++++++--- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/sopt/clody/data/remote/interceptor/AuthInterceptor.kt b/app/src/main/java/com/sopt/clody/data/remote/interceptor/AuthInterceptor.kt index 5bc3515c..84360f76 100644 --- a/app/src/main/java/com/sopt/clody/data/remote/interceptor/AuthInterceptor.kt +++ b/app/src/main/java/com/sopt/clody/data/remote/interceptor/AuthInterceptor.kt @@ -47,9 +47,9 @@ class AuthInterceptor @Inject constructor( } private fun shouldAddAuthorization(url: String): Boolean { - return !url.contains("api/v1/auth/signin") && - !url.contains("api/v1/auth/signup") && - !url.contains("api/v1/auth/reissue") + return !url.contains(AUTH_SIGNIN_URL) && + !url.contains(AUTH_SIGNUP_URL) && + !url.contains(AUTH_REISSUE_URL) } private fun addAuthorizationHeader(request: Request): Request { @@ -95,6 +95,10 @@ class AuthInterceptor @Inject constructor( runBlocking { reissueTokenRepository.getReissueToken(tokenDataStore.refreshToken).onSuccess { data -> updateTokens(data.accessToken, data.refreshToken) + }.onFailure { error -> + if (error.message?.contains(REFRESH_TOKEN_EXPIRED.toString()) == true) { + clearUserInfoAndNavigateToLogin() + } } } true @@ -126,7 +130,11 @@ class AuthInterceptor @Inject constructor( companion object { private const val TOKEN_EXPIRED = 401 + private const val REFRESH_TOKEN_EXPIRED = 500 private const val BEARER = "Bearer" private const val AUTHORIZATION = "Authorization" + private const val AUTH_SIGNIN_URL = "api/v1/auth/signin" + private const val AUTH_SIGNUP_URL = "api/v1/auth/signup" + private const val AUTH_REISSUE_URL = "api/v1/auth/reissue" } } diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/main/MainActivity.kt b/app/src/main/java/com/sopt/clody/presentation/ui/main/MainActivity.kt index e3fd6b00..a92329cb 100644 --- a/app/src/main/java/com/sopt/clody/presentation/ui/main/MainActivity.kt +++ b/app/src/main/java/com/sopt/clody/presentation/ui/main/MainActivity.kt @@ -6,6 +6,7 @@ import androidx.activity.compose.setContent import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen @@ -30,9 +31,11 @@ class MainActivity : ComponentActivity() { CLODYTheme { val navController = rememberNavController() - if (intent.getBooleanExtra("NAVIGATE_TO_LOGIN", false)) { - navController.navigate("register_graph") { - popUpTo(0) { inclusive = true } + LaunchedEffect(key1 = intent.getBooleanExtra("NAVIGATE_TO_LOGIN", false)) { + if (intent.getBooleanExtra("NAVIGATE_TO_LOGIN", false)) { + navController.navigate("register_graph") { + popUpTo(0) { inclusive = true } + } } } From 9139f5e611f6bc9e23686d8d9550cd533926c41e Mon Sep 17 00:00:00 2001 From: Moonsu Kang Date: Wed, 11 Sep 2024 18:38:34 +0900 Subject: [PATCH 2/3] =?UTF-8?q?[FEAT/#213]=20throttle=20=ED=99=95=EC=9E=A5?= =?UTF-8?q?=ED=95=A8=EC=88=98=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../utils/extension/ThrottleExtensions.kt | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 app/src/main/java/com/sopt/clody/presentation/utils/extension/ThrottleExtensions.kt diff --git a/app/src/main/java/com/sopt/clody/presentation/utils/extension/ThrottleExtensions.kt b/app/src/main/java/com/sopt/clody/presentation/utils/extension/ThrottleExtensions.kt new file mode 100644 index 00000000..2761ca86 --- /dev/null +++ b/app/src/main/java/com/sopt/clody/presentation/utils/extension/ThrottleExtensions.kt @@ -0,0 +1,17 @@ +package com.sopt.clody.presentation.utils.extension + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filter + +fun Flow.throttleFirst(throttleTimeMillis: Long): Flow { + var lastEmissionTime = 0L + return this.filter { // .filter을 통해 조건에 맞는 이벤트만 통과시킨다. + val currentTime = System.currentTimeMillis() + if (currentTime - lastEmissionTime >= throttleTimeMillis) { + lastEmissionTime = currentTime + true + } else { + false + } + } +} From 9b6d8510389bca122317d2ef9eb048b2918f69a1 Mon Sep 17 00:00:00 2001 From: Moonsu Kang Date: Wed, 11 Sep 2024 18:39:26 +0900 Subject: [PATCH 3/3] =?UTF-8?q?[FEAT/#213]=20throttle=20=EC=A0=81=EC=9A=A9?= =?UTF-8?q?=20=EB=B0=8F=20=ED=95=A8=EC=88=98=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ui/replydiary/ReplyDiaryViewModel.kt | 78 +++++++++++------ .../screen/ReplyLoadingViewModel.kt | 84 +++++++++++-------- 2 files changed, 101 insertions(+), 61 deletions(-) diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/replydiary/ReplyDiaryViewModel.kt b/app/src/main/java/com/sopt/clody/presentation/ui/replydiary/ReplyDiaryViewModel.kt index 9ab19551..52afa0b5 100644 --- a/app/src/main/java/com/sopt/clody/presentation/ui/replydiary/ReplyDiaryViewModel.kt +++ b/app/src/main/java/com/sopt/clody/presentation/ui/replydiary/ReplyDiaryViewModel.kt @@ -2,15 +2,21 @@ package com.sopt.clody.presentation.ui.replydiary import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.sopt.clody.data.remote.dto.ResponseReplyDiaryDto import com.sopt.clody.data.repository.ReplyDiaryRepository +import com.sopt.clody.presentation.utils.extension.throttleFirst +import com.sopt.clody.presentation.utils.network.ErrorMessages import com.sopt.clody.presentation.utils.network.ErrorMessages.FAILURE_NETWORK_MESSAGE -import com.sopt.clody.presentation.utils.network.ErrorMessages.FAILURE_TEMPORARY_MESSAGE import com.sopt.clody.presentation.utils.network.ErrorMessages.UNKNOWN_ERROR import com.sopt.clody.presentation.utils.network.NetworkUtil import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch +import timber.log.Timber import javax.inject.Inject @HiltViewModel @@ -26,52 +32,70 @@ class ReplyDiaryViewModel @Inject constructor( private var lastMonth: Int = 0 private var lastDate: Int = 0 - private var retryCount = 0 - private val maxRetryCount = 3 + private val _retryFlow = MutableSharedFlow() //연속 클릭을 제어하기 위해 선언. - fun getReplyDiary(year: Int, month: Int, date: Int) { - if (retryCount >= maxRetryCount) { - return - } + init { + setupRetryFlow() + } + private fun setupRetryFlow() { + _retryFlow + .throttleFirst(2000L) // 2초 동안 첫 번째 이벤트만 발행. + .onEach { // Flow에서 발생한 이벤트를 받아서 getReplyDiaryInternal 호출. + getReplyDiaryInternal(lastYear, lastMonth, lastDate) + } + .launchIn(viewModelScope) //Flow를 viewModelScope에서 실행하고 구독을 유지, 즉 viewmodel이 살아있는 동안 flow가 실행됨 + } + + + fun getReplyDiary(year: Int, month: Int, date: Int) { lastYear = year lastMonth = month lastDate = date + getReplyDiaryInternal(year, month, date) + } + private fun getReplyDiaryInternal(year: Int, month: Int, date: Int) { viewModelScope.launch { if (!networkUtil.isNetworkAvailable()) { - _replyDiaryState.value = ReplyDiaryState.Failure(FAILURE_NETWORK_MESSAGE) + updateState(ReplyDiaryState.Failure(FAILURE_NETWORK_MESSAGE)) return@launch } - _replyDiaryState.value = ReplyDiaryState.Loading + updateState(ReplyDiaryState.Loading) + val result = replyDiaryRepository.getReplyDiary(year, month, date) - _replyDiaryState.value = result.fold( - onSuccess = { data -> - retryCount = 0 + handleResult(result) + } + } + + private fun handleResult(result: Result) { + result.fold( + onSuccess = { data -> + updateState( ReplyDiaryState.Success( - content = data.content ?: "", //content가 널이 아님을 보장함, 널이면 onFailure로 감 + content = data.content ?: "", nickname = data.nickname, month = data.month, date = data.date ) - }, - onFailure = { - retryCount++ - if (retryCount >= maxRetryCount) { - ReplyDiaryState.Failure(FAILURE_TEMPORARY_MESSAGE) - } else { - val message = it.localizedMessage ?: UNKNOWN_ERROR - ReplyDiaryState.Failure(message) - } - } - ) - } + ) + }, + onFailure = { throwable -> + updateState(ReplyDiaryState.Failure(ErrorMessages.FAILURE_TEMPORARY_MESSAGE)) + val errorMessage = throwable.localizedMessage ?: UNKNOWN_ERROR + Timber.tag("ReplyDiaryViewModel").e("API 요청 실패: %s", errorMessage) + } + ) + } + + private fun updateState(newState: ReplyDiaryState) { + _replyDiaryState.value = newState } fun retryLastRequest() { - if (retryCount < maxRetryCount) { - getReplyDiary(lastYear, lastMonth, lastDate) + viewModelScope.launch { + _retryFlow.emit(Unit) } } } diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/replyloading/screen/ReplyLoadingViewModel.kt b/app/src/main/java/com/sopt/clody/presentation/ui/replyloading/screen/ReplyLoadingViewModel.kt index 18de600a..1a7b2885 100644 --- a/app/src/main/java/com/sopt/clody/presentation/ui/replyloading/screen/ReplyLoadingViewModel.kt +++ b/app/src/main/java/com/sopt/clody/presentation/ui/replyloading/screen/ReplyLoadingViewModel.kt @@ -2,15 +2,21 @@ package com.sopt.clody.presentation.ui.replyloading.screen import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.sopt.clody.data.remote.dto.response.ResponseDiaryTimeDto import com.sopt.clody.data.repository.DiaryTimeRepository +import com.sopt.clody.presentation.utils.extension.throttleFirst import com.sopt.clody.presentation.utils.network.ErrorMessages.FAILURE_NETWORK_MESSAGE import com.sopt.clody.presentation.utils.network.ErrorMessages.FAILURE_TEMPORARY_MESSAGE import com.sopt.clody.presentation.utils.network.ErrorMessages.UNKNOWN_ERROR import com.sopt.clody.presentation.utils.network.NetworkUtil import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch +import timber.log.Timber import java.time.LocalDateTime import javax.inject.Inject @@ -23,22 +29,33 @@ class ReplyLoadingViewModel @Inject constructor( private val _replyLoadingState = MutableStateFlow(ReplyLoadingState.Idle) val replyLoadingState: StateFlow = _replyLoadingState - private var retryCount = 0 - private val maxRetryCount = 3 - private var lastYear: Int = 0 private var lastMonth: Int = 0 private var lastDate: Int = 0 - fun getDiaryTime(year: Int, month: Int, date: Int) { - if (retryCount >= maxRetryCount) { - return - } + private val _retryFlow = MutableSharedFlow() + + init { + setupRetryFlow() + } + private fun setupRetryFlow() { + _retryFlow + .throttleFirst(2000L) + .onEach { + getDiaryTimeInternal(lastYear, lastMonth, lastDate) + } + .launchIn(viewModelScope) + } + + fun getDiaryTime(year: Int, month: Int, date: Int) { lastYear = year lastMonth = month lastDate = date + getDiaryTimeInternal(year, month, date) + } + private fun getDiaryTimeInternal(year: Int, month: Int, date: Int) { _replyLoadingState.value = ReplyLoadingState.Loading viewModelScope.launch { @@ -48,37 +65,36 @@ class ReplyLoadingViewModel @Inject constructor( } val result = diaryTimeRepository.getDiaryTime(year, month, date) - _replyLoadingState.value = result.fold( - onSuccess = { data -> - retryCount = 0 - val targetDateTime = LocalDateTime.of( - year, month, date, - data.HH, data.mm, data.ss - ).plusMinutes(if (data.isFirst) 1 else 12 * 60) - - ReplyLoadingState.Success(targetDateTime) - }, - onFailure = { - retryCount++ - if (retryCount >= maxRetryCount) { - ReplyLoadingState.Failure(FAILURE_TEMPORARY_MESSAGE) - } else { - val message = if (it.message?.contains("200") == false) { - FAILURE_TEMPORARY_MESSAGE - } else { - it.localizedMessage ?: UNKNOWN_ERROR - } - ReplyLoadingState.Failure(message) - } - } - ) + handleResult(result) } } + private fun handleResult(result: Result) { + result.fold( + onSuccess = { data -> + val targetDateTime = LocalDateTime.of( + lastYear, lastMonth, lastDate, + data.HH, data.mm, data.ss + ).plusMinutes(if (data.isFirst) INITIAL_REMINDER_MINUTES else REGULAR_REMINDER_HOURS * 60) + + _replyLoadingState.value = ReplyLoadingState.Success(targetDateTime) + }, + onFailure = { throwable -> + _replyLoadingState.value = ReplyLoadingState.Failure(FAILURE_TEMPORARY_MESSAGE) + val errorMessage = throwable.localizedMessage ?: UNKNOWN_ERROR + Timber.tag("ReplyLoadingViewModel").e("API 요청 실패: %s", errorMessage) + } + ) + } + fun retryLastRequest() { - if (retryCount < maxRetryCount) { - _replyLoadingState.value = ReplyLoadingState.Loading - getDiaryTime(lastYear, lastMonth, lastDate) + viewModelScope.launch { + _retryFlow.emit(Unit) } } + + companion object { + private const val INITIAL_REMINDER_MINUTES = 1L + private const val REGULAR_REMINDER_HOURS = 12L + } }