diff --git a/app/src/main/java/com/eatssu/android/App.kt b/app/src/main/java/com/eatssu/android/App.kt index dba3085c4..3c540372d 100644 --- a/app/src/main/java/com/eatssu/android/App.kt +++ b/app/src/main/java/com/eatssu/android/App.kt @@ -2,17 +2,30 @@ package com.eatssu.android import android.app.Application import android.content.Context +import com.eatssu.android.domain.model.TokenState +import com.eatssu.android.domain.model.TokenStateManager +import com.eatssu.android.presentation.base.TokenEventBus import com.google.firebase.FirebaseApp import com.kakao.sdk.common.KakaoSdk import dagger.hilt.android.HiltAndroidApp +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch import timber.log.Timber +/** App: 앱이 살아있는 동안 공통 리소스 관리를 위한 클래스 */ @HiltAndroidApp class App: Application() { companion object{ lateinit var appContext: Context //todo 이거 빼기 } + /** 앱 전체에서 사용할 수 있는 CoroutineScope(독립적인 공간을 만들어 안정성 높임) + * 자식 CoroutineScope가 취소되더라도 부모 CoroutineScope는 취소되지 않음 + * */ + private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + override fun onCreate() { super.onCreate() FirebaseApp.initializeApp(this) @@ -23,5 +36,20 @@ class App: Application() { if (BuildConfig.DEBUG) { Timber.plant(Timber.DebugTree()) } + + collectTokenState() + } + + /** 토큰 상태를 application에서 감지하여 TokenEventBus에 전달 */ + private fun collectTokenState(){ + appScope.launch { + TokenStateManager.state.collect { state -> + if (state == TokenState.EXPIRED) { + TokenEventBus.notifyTokenExpired() + } else if(state == TokenState.ERROR) { + TokenEventBus.notifyServerError() + } + } + } } } \ No newline at end of file diff --git a/app/src/main/java/com/eatssu/android/data/RetrofitImpl.kt b/app/src/main/java/com/eatssu/android/data/RetrofitImpl.kt deleted file mode 100644 index 1e8c398c8..000000000 --- a/app/src/main/java/com/eatssu/android/data/RetrofitImpl.kt +++ /dev/null @@ -1,211 +0,0 @@ -package com.eatssu.android.data - -import android.content.Context -import android.content.Intent -import android.net.ConnectivityManager -import android.os.Handler -import android.os.Looper -import android.util.Log -import android.widget.Toast -import com.eatssu.android.App -import com.eatssu.android.BuildConfig -import com.eatssu.android.BuildConfig.BASE_URL -import com.eatssu.android.data.dto.response.BaseResponse -import com.eatssu.android.data.dto.response.TokenResponse -import com.eatssu.android.di.network.TokenInterceptor -import com.eatssu.android.presentation.login.LoginActivity -import com.google.gson.Gson -import com.google.gson.reflect.TypeToken -import kotlinx.coroutines.runBlocking -import okhttp3.* -import okhttp3.RequestBody.Companion.toRequestBody -import okhttp3.logging.HttpLoggingInterceptor -import retrofit2.Retrofit -import retrofit2.converter.gson.GsonConverterFactory -import java.io.IOException -import java.lang.reflect.Type - -object RetrofitImpl { - - val size = 10 * 1024 * 1024 // 10MB Cache size - - val mCache = Cache(App.appContext.cacheDir, size.toLong()) - - var newAccessToken: String = "" - - val cacheInterceptor = Interceptor { chain -> - var request = chain.request() - request = if (hasNetwork(App.appContext)) - request.newBuilder().header("Cache-Control", "public, max-age=" + 5).build() - else - request.newBuilder() - .header("Cache-Control", "public, only-if-cached, max-stale=" + 60 * 60 * 24 * 7) - .build() - chain.proceed(request) - } - - // Check if network is available - fun hasNetwork(context: Context): Boolean { - val connectivityManager = - context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - val activeNetwork = connectivityManager.activeNetwork - val networkCapabilities = connectivityManager.getNetworkCapabilities(activeNetwork) - return networkCapabilities != null - } - - - // 공통으로 사용하는 OkHttpClient 생성 - private val commonOkHttpClient: OkHttpClient by lazy { - val httpLoggingInterceptor = HttpLoggingInterceptor() - .setLevel(HttpLoggingInterceptor.Level.BODY) - OkHttpClient.Builder() - .addInterceptor(httpLoggingInterceptor) - .addInterceptor(cacheInterceptor) - .cache(mCache) - .build() - } - - // 토큰 없는 Retrofit - val nonRetrofit: Retrofit by lazy { - createRetrofit(commonOkHttpClient) - } - - // 토큰이 있는 Retrofit - val retrofit: Retrofit by lazy { - createRetrofit(createTokenOkHttpClient()) - } - - // 멀티파트 레트로핏 - val mRetrofit: Retrofit by lazy { - createRetrofit(createMultiPartOkHttpClient()) - } - - private fun createRetrofit(client: OkHttpClient): Retrofit { - return Retrofit.Builder() - .addConverterFactory(GsonConverterFactory.create()) - .client(client) - .baseUrl(BASE_URL) - .build() - } - - private fun createTokenOkHttpClient(): OkHttpClient { - return commonOkHttpClient.newBuilder() - .addInterceptor(AppInterceptor(App.appContext)) - .build() - } - - private fun createMultiPartOkHttpClient(): OkHttpClient { - return commonOkHttpClient.newBuilder() - .addInterceptor(mAppInterceptor()) - .build() - } - - private class AppInterceptor(val context: Context) : Interceptor { - @Throws(IOException::class) - override fun intercept(chain: Interceptor.Chain): Response = with(chain) { - var response: Response - val originalRequest = request() - val requestBuilder = originalRequest.newBuilder() - .addHeader("accept", "application/hal+json") - .addHeader("Content-Type", "application/json") - .addHeader( - "Authorization", - "Bearer ${MySharedPreferences.getAccessToken(App.appContext)}" - ) - - val request = requestBuilder.build() - response = proceed(request) - - // Unauthorized (401) 상태 코드를 받았을 경우 토큰 재발급 시도 - if (response.code == 401) { - response.close() - - Log.d("AppInterceptor", "토큰 재발급 시도") - - try { - val refreshTokenRequest = originalRequest.newBuilder() - .post("".toRequestBody()) - .url("${BuildConfig.BASE_URL}/oauths/reissue/token") - .addHeader( - "Authorization", - "Bearer ${MySharedPreferences.getRefreshToken(App.appContext)}" - ) - .build() - - Log.d(TokenInterceptor.TAG, "재발급 중") - - val refreshTokenResponse = chain.proceed(refreshTokenRequest) - Log.d(TokenInterceptor.TAG, " : $refreshTokenResponse") - - if (refreshTokenResponse.isSuccessful) { - Log.d(TokenInterceptor.TAG, "재발급 성공") - - val responseToken = parseRefreshTokenResponse(refreshTokenResponse) - - responseToken?.result?.let { - runBlocking { - MySharedPreferences.setAccessToken( - App.appContext, - it.accessToken - ) - MySharedPreferences.setRefreshToken( - App.appContext, - it.refreshToken - ) - - newAccessToken = it.accessToken - } - } - - refreshTokenResponse.close() - val newRequest = originalRequest.newAuthBuilder().build() - return chain.proceed(newRequest) - } - - } catch (e: Exception) { - runBlocking { MySharedPreferences.clearUser(App.appContext) } - Log.d(TokenInterceptor.TAG, "재발급 실패 $e") - - Handler(Looper.getMainLooper()).post { - val context = App.appContext - Toast.makeText(context, "토큰이 만료되어 로그아웃 됩니다.", Toast.LENGTH_SHORT).show() - val intent = Intent(context, LoginActivity::class.java) // 로그인 화면으로 이동 - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) - context.startActivity(intent) - } - } - } - response - } - } - - private fun Request.newAuthBuilder() = - this.newBuilder().addHeader("Authorization", "Bearer $newAccessToken") - - - private class mAppInterceptor : Interceptor { - @Throws(IOException::class) - override fun intercept(chain: Interceptor.Chain): Response = with(chain) { - val requestBuilder = request().newBuilder() - .addHeader("Content-Type", "multipart/form-data") - .addHeader( - "Authorization", - "Bearer ${MySharedPreferences.getAccessToken(App.appContext)}" - ) - - val newRequest = requestBuilder.build() - proceed(newRequest) - } - } - - - private fun parseRefreshTokenResponse(response: Response): BaseResponse? { - return try { - val gson = Gson() - val responseType: Type = object : TypeToken>() {}.type - gson.fromJson(response.body?.string(), responseType) - } catch (e: Exception) { - null - } - } -} diff --git a/app/src/main/java/com/eatssu/android/di/NetworkModule.kt b/app/src/main/java/com/eatssu/android/di/NetworkModule.kt index 69789b518..2c81ff3d4 100644 --- a/app/src/main/java/com/eatssu/android/di/NetworkModule.kt +++ b/app/src/main/java/com/eatssu/android/di/NetworkModule.kt @@ -4,11 +4,16 @@ package com.eatssu.android.di import com.eatssu.android.BuildConfig import com.eatssu.android.BuildConfig.BASE_URL import com.eatssu.android.data.service.MealService +import com.eatssu.android.data.service.MenuService import com.eatssu.android.data.service.OauthService import com.eatssu.android.data.service.ReportService import com.eatssu.android.data.service.ReviewService import com.eatssu.android.data.service.UserService +import com.eatssu.android.di.network.TokenAuthenticator import com.eatssu.android.di.network.TokenInterceptor +import com.eatssu.android.domain.usecase.auth.LogoutUseCase +import com.eatssu.android.domain.usecase.auth.SetAccessTokenUseCase +import com.eatssu.android.domain.usecase.auth.SetRefreshTokenUseCase import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -21,6 +26,9 @@ import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory import java.lang.reflect.Type import javax.inject.Singleton +import com.eatssu.android.domain.usecase.auth.GetRefreshTokenUseCase +import com.eatssu.android.domain.usecase.auth.ReissueTokenUseCase +import javax.inject.Qualifier class NullOnEmptyConverterFactory : Converter.Factory() { override fun responseBodyConverter( @@ -34,14 +42,26 @@ class NullOnEmptyConverterFactory : Converter.Factory() { } } +/** retrofit, okhttpClient에 토큰이 필요하지 않음을 명시하기 위한 Qualifier */ +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class NoToken + +/** + * NetworkModule : Retrofit과 OkHttpClient를 제공하는 모듈 + * - OkHttpClient : API 요청 시 AccessToken을 헤더에 추가하는 인터셉터와 로깅 인터셉터를 사용 + * - Retrofit : OkHttpClient를 사용하여 API 요청을 처리 + * */ @Module @InstallIn(SingletonComponent::class) object NetworkModule { + // 토큰이 필요한 okhttpClient @Singleton @Provides - fun provideOkHttpClient( + fun provideAuthOkHttpClient( tokenInterceptor: TokenInterceptor, + tokenAuthenticator: TokenAuthenticator ) = if (BuildConfig.DEBUG) { val loggingInterceptor = HttpLoggingInterceptor() loggingInterceptor.setLevel(HttpLoggingInterceptor.Level.BODY) @@ -49,28 +69,74 @@ object NetworkModule { OkHttpClient.Builder() .addInterceptor(loggingInterceptor) .addInterceptor(tokenInterceptor) + .authenticator(tokenAuthenticator) .build() } else { // 프로덕션 환경에서는 로깅 인터셉터를 추가하지 않음 OkHttpClient.Builder() .addInterceptor(tokenInterceptor) + .authenticator(tokenAuthenticator) .build() } + // 토큰 없는 OkHttpClient (로그인/회원가입/토큰 재발급용) + @Singleton + @Provides + @NoToken + fun provideNoAuthOkHttpClient(): OkHttpClient { + val builder = OkHttpClient.Builder() + if (BuildConfig.DEBUG) { + builder.addInterceptor(HttpLoggingInterceptor().apply { + level = HttpLoggingInterceptor.Level.BODY + }) + } + return builder.build() + } + + // 토큰이 필요한 retrofit @Singleton @Provides - fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit { + fun provideAuthRetrofit(okHttpClient: OkHttpClient): Retrofit { return Retrofit.Builder().client(okHttpClient).baseUrl(BASE_URL) .addConverterFactory(GsonConverterFactory.create()) .addConverterFactory(NullOnEmptyConverterFactory()) .build() } + // 토큰 없는 retrofit + @Singleton + @Provides + @NoToken + fun provideNoAuthRetrofit(@NoToken okHttpClient: OkHttpClient): Retrofit { + return Retrofit.Builder().client(okHttpClient).baseUrl(BASE_URL) + .addConverterFactory(GsonConverterFactory.create()) + .addConverterFactory(NullOnEmptyConverterFactory()) + .build() + } @Provides @Singleton - fun provideOauthService(retrofit: Retrofit): OauthService { - return retrofit.create(OauthService::class.java) + fun provideTokenAuthenticator( + getRefreshTokenUseCase: GetRefreshTokenUseCase, + setAccessTokenUseCase: SetAccessTokenUseCase, + setRefreshTokenUseCase: SetRefreshTokenUseCase, + reissueTokenUseCase: ReissueTokenUseCase, + logoutUseCase: LogoutUseCase, + ): TokenAuthenticator { + return TokenAuthenticator( + getRefreshTokenUseCase, + setAccessTokenUseCase, + setRefreshTokenUseCase, + reissueTokenUseCase, + logoutUseCase, + ) + } + + // provide service + @Provides + @Singleton + fun provideOauthService(@NoToken noTokenRetrofit: Retrofit): OauthService { + return noTokenRetrofit.create(OauthService::class.java) } @Provides @@ -97,4 +163,9 @@ object NetworkModule { return retrofit.create(MealService::class.java) } + @Provides + @Singleton + fun provideMenuService(retrofit: Retrofit): MenuService { + return retrofit.create(MenuService::class.java) + } } \ No newline at end of file diff --git a/app/src/main/java/com/eatssu/android/di/network/TokenAuthenticator.kt b/app/src/main/java/com/eatssu/android/di/network/TokenAuthenticator.kt new file mode 100644 index 000000000..6a9b23625 --- /dev/null +++ b/app/src/main/java/com/eatssu/android/di/network/TokenAuthenticator.kt @@ -0,0 +1,92 @@ +package com.eatssu.android.di.network + +import com.eatssu.android.data.dto.response.BaseResponse +import com.eatssu.android.data.dto.response.TokenResponse +import com.eatssu.android.domain.usecase.auth.* +import com.eatssu.android.domain.model.TokenStateManager +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.runBlocking +import okhttp3.Authenticator +import okhttp3.Request +import okhttp3.Response +import okhttp3.Route +import timber.log.Timber +import javax.inject.Inject + +/** + * AccessToken이 만료되어 서버가 401 응답을 줄 때 + * '자동'으로 RefreshToken을 사용해 새 AccessToken을 발급받고, + * 원래 요청을 새 토큰으로 다시 보내주는 클래스 - 백그라운드 스레드에서 실행 + * */ +class TokenAuthenticator @Inject constructor( + private val getRefreshTokenUseCase: GetRefreshTokenUseCase, + private val setAccessTokenUseCase: SetAccessTokenUseCase, + private val setRefreshTokenUseCase: SetRefreshTokenUseCase, + private val reissueTokenUseCase: ReissueTokenUseCase, + private val logoutUseCase: LogoutUseCase, +) : Authenticator { + + /** + * 401 Unauthorized 응답을h 받았을 때 호출되는 메서드 + * @param route : 요청한 경로 + * @param response : 응답 객체 + * @return : 새로운 요청 객체 + */ + + override fun authenticate(route: Route?, response: Response): Request? { + if (responseCount(response) >= 2) { + Timber.w("401 응답 재시도 2회 초과 → 요청 중단") + return null + } + + val expiredRefreshToken = runBlocking { getRefreshTokenUseCase() } + + return runBlocking { + try { + Timber.d("TokenAuthenticator → refreshToken으로 재발급 시도") + + val newTokenResponse: BaseResponse? = reissueTokenUseCase(expiredRefreshToken).firstOrNull() + val newAccessToken = newTokenResponse?.result?.accessToken + val newRefreshToken = newTokenResponse?.result?.refreshToken + + if (newAccessToken != null && newRefreshToken != null) { + Timber.d("TokenAuthenticator → 새 토큰 발급 성공") + setAccessTokenUseCase(newAccessToken) + setRefreshTokenUseCase(newRefreshToken) + } else { + // 잘못된 토큰을 받은 경우 + Timber.e("TokenAuthenticator → 새 토큰 발급 실패") + logoutUseCase() // 로그아웃 처리 + TokenStateManager.setTokenError() + } + + Timber.d("TokenAuthenticator → 새 토큰 저장 및 기존 API 재요청") + + response.request.newBuilder() + .header("Authorization", "Bearer ${newAccessToken}") + .build() + + } catch (e: Exception) { + // refreshToken이 만료된 경우 + Timber.e(e, "refreshToken이 만료") + logoutUseCase() + TokenStateManager.setTokenExpired() + + null + } + } + } + + // 무한 루프 방지를 위한 재귀 호출 횟수 체크 + private fun responseCount(response: Response): Int { + var count = 1 + var prior = response.priorResponse // 이전 응답 + while (prior != null) { + count++ + prior = prior.priorResponse + } + + Timber.d("TokenAuthenticator → responseCount: $count") + return count + } +} diff --git a/app/src/main/java/com/eatssu/android/di/network/TokenInterceptor.kt b/app/src/main/java/com/eatssu/android/di/network/TokenInterceptor.kt index 189071bce..0328fe041 100644 --- a/app/src/main/java/com/eatssu/android/di/network/TokenInterceptor.kt +++ b/app/src/main/java/com/eatssu/android/di/network/TokenInterceptor.kt @@ -27,156 +27,32 @@ import timber.log.Timber import java.lang.reflect.Type import javax.inject.Inject +/** + * TokenInterceptor : API 요청 시 AccessToken을 헤더에 추가하는 인터셉터 + * */ class TokenInterceptor @Inject constructor( private val getAccessTokenUseCase: GetAccessTokenUseCase, - private val getRefreshTokenUseCase: GetRefreshTokenUseCase, - private val setAccessTokenUseCase: SetAccessTokenUseCase, - private val setRefreshTokenUseCase: SetRefreshTokenUseCase, - private val logoutUseCase: LogoutUseCase, - @ApplicationContext private val context: Context ) : Interceptor { companion object { - const val TAG = "TokenInterceptor" - - val EXCEPT_LIST = listOf( - "/oauths/reissue/token", - "/oauths/kakao", - ) -// val MULTI_PART = "/reviews/upload/image" - - private const val CODE_TOKEN_EXPIRED = 401 private const val HEADER_AUTHORIZATION = "Authorization" private const val HEADER_CONTENT_TYPE = "Content-Type" private const val HEADER_ACCEPT = "accept" - private const val HEADER_ACCESS_TOKEN = "X-ACCESS-AUTH" - private const val HEADER_REFRESH_TOKEN = "X-REFRESH-AUTH" } - private lateinit var newAccessToken: String - private lateinit var newRefreshToken: String - override fun intercept(chain: Interceptor.Chain): Response { val accessToken = runBlocking { getAccessTokenUseCase() } - val refreshToken = runBlocking { getRefreshTokenUseCase() } - val originalRequest = chain.request() - val request = chain.request().newBuilder().apply { - if (EXCEPT_LIST.none { originalRequest.url.encodedPath.endsWith(it) }) { - addHeader(HEADER_ACCEPT, "application/hal+json") - addHeader(HEADER_CONTENT_TYPE, "application/json") - addHeader(HEADER_AUTHORIZATION, "Bearer $accessToken") - } -// else if (MULTI_PART.none { originalRequest.url.encodedPath.endsWith(it) }) { -// Timber.d("멀티파트 시작!") -// addHeader(HEADER_ACCEPT, "application/hal+json") -// removeHeader("Content-Type") -// addHeader("Content-Type", "multipart/form-data") -// addHeader(HEADER_AUTHORIZATION, "Bearer $accessToken") -// } - }.build() - - val response = chain.proceed(request) - - if (response.code == 401) { - Timber.d("토큰 401") - response.close() - - try { - val refreshTokenRequest = originalRequest.newBuilder() - .post("".toRequestBody()) - .url("$BASE_URL/oauths/reissue/token") - .addHeader(HEADER_AUTHORIZATION, "Bearer $refreshToken") - .build() - - Timber.d("토큰 재발급 중") - - val refreshTokenResponse = chain.proceed(refreshTokenRequest) - Timber.d("refreshTokenResponse : $refreshTokenResponse") - - if (refreshTokenResponse.isSuccessful) { - Timber.d("재발급 성공") - - val responseToken = parseRefreshTokenResponse(refreshTokenResponse) - responseToken?.result?.let { - runBlocking { - setAccessTokenUseCase(it.accessToken) - setRefreshTokenUseCase(it.refreshToken) - - newAccessToken = it.accessToken - } - } - - refreshTokenResponse.close() - val newRequest = originalRequest.newAuthBuilder().build() - return chain.proceed(newRequest) - } else { - /** - * 리프레쉬도 상한 상태 - */ - runBlocking { logoutUseCase() } - Timber.e("재발급에서의 401") - - Handler(Looper.getMainLooper()).post { - Toast.makeText(context, "토큰이 만료되어 로그아웃 됩니다.", Toast.LENGTH_SHORT).show() - val intent = Intent(context, LoginActivity::class.java) // 로그인 화면으로 이동 - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) - context.startActivity(intent) - } - } - - } catch (e: Exception) { - runBlocking { logoutUseCase() } - Timber.e("재발급 실패 $e") - - Handler(Looper.getMainLooper()).post { - Toast.makeText(context, "토큰이 만료되어 로그아웃 됩니다.", Toast.LENGTH_SHORT).show() - val intent = Intent(context, LoginActivity::class.java) // 로그인 화면으로 이동 - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) - context.startActivity(intent) - } - } - } - - if (response.code == 404) { -// runBlocking { logoutUseCase() } - Timber.e("404 + 다른 유저!") - -// Handler(Looper.getMainLooper()).post { -// Toast.makeText(context, "토큰이 만료되어 로그아웃 됩니다.", Toast.LENGTH_SHORT).show() -// val intent = Intent(context, LoginActivity::class.java) // 로그인 화면으로 이동 -// intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) -// context.startActivity(intent) -// } - } - - if (response.code == 500) { -// runBlocking { logoutUseCase() } - Timber.e("500 + 다른 유저") - -// Handler(Looper.getMainLooper()).post { -// Toast.makeText(context, "토큰이 만료되어 로그아웃 됩니다.", Toast.LENGTH_SHORT).show() -// val intent = Intent(context, LoginActivity::class.java) // 로그인 화면으로 이동 -// intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) -// context.startActivity(intent) -// } - } - - return response - } + val request = originalRequest.newBuilder() + .addHeader(HEADER_ACCEPT, "application/hal+json") + .addHeader(HEADER_CONTENT_TYPE, "application/json") + .addHeader(HEADER_AUTHORIZATION, "Bearer $accessToken") + .build() - private fun Request.newAuthBuilder() = - this.newBuilder().addHeader(HEADER_AUTHORIZATION, "Bearer $newAccessToken") + Timber.d("AccessToken 헤더 추가됨: $accessToken") - private fun parseRefreshTokenResponse(response: Response): BaseResponse? { - return try { - val gson = Gson() - val responseType: Type = object : TypeToken>() {}.type - gson.fromJson(response.body?.string(), responseType) - } catch (e: Exception) { - null - } + return chain.proceed(request) } } \ No newline at end of file diff --git a/app/src/main/java/com/eatssu/android/domain/model/TokenState.kt b/app/src/main/java/com/eatssu/android/domain/model/TokenState.kt new file mode 100644 index 000000000..4e4026af2 --- /dev/null +++ b/app/src/main/java/com/eatssu/android/domain/model/TokenState.kt @@ -0,0 +1,31 @@ +package com.eatssu.android.domain.model + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import timber.log.Timber + +enum class TokenState { + INITIAL, VALID, EXPIRED, ERROR +} + +/** 현재 토큰 상태를 관리하는 객체 */ +object TokenStateManager { + private val _state = MutableStateFlow(TokenState.INITIAL) + val state: StateFlow = _state + + fun setTokenExpired() { + _state.value = TokenState.EXPIRED + Timber.e("TokenStateManager → Token expired") + } + + fun setTokenValid() { + _state.value = TokenState.VALID + Timber.d("TokenStateManager → Token valid") + } + + fun setTokenError() { + _state.value = TokenState.ERROR + Timber.e("TokenStateManager → Token error") + } +} + diff --git a/app/src/main/java/com/eatssu/android/domain/usecase/auth/LogoutUseCase.kt b/app/src/main/java/com/eatssu/android/domain/usecase/auth/LogoutUseCase.kt index ab3f1db70..8d07fc65b 100644 --- a/app/src/main/java/com/eatssu/android/domain/usecase/auth/LogoutUseCase.kt +++ b/app/src/main/java/com/eatssu/android/domain/usecase/auth/LogoutUseCase.kt @@ -9,8 +9,6 @@ class LogoutUseCase @Inject constructor( @ApplicationContext private val context: Context ) { suspend operator fun invoke() { - MySharedPreferences.clearUser(context) - } } \ No newline at end of file diff --git a/app/src/main/java/com/eatssu/android/presentation/base/BaseActivity.kt b/app/src/main/java/com/eatssu/android/presentation/base/BaseActivity.kt index 466b39ca4..753248068 100644 --- a/app/src/main/java/com/eatssu/android/presentation/base/BaseActivity.kt +++ b/app/src/main/java/com/eatssu/android/presentation/base/BaseActivity.kt @@ -10,9 +10,14 @@ import android.view.View import android.view.ViewGroup import android.view.inputmethod.InputMethodManager import android.widget.TextView +import android.widget.Toast +import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.widget.Toolbar +import androidx.lifecycle.Lifecycle import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import androidx.viewbinding.ViewBinding import com.eatssu.android.R import com.eatssu.android.data.repository.FirebaseRemoteConfigRepository @@ -21,7 +26,9 @@ import com.eatssu.android.presentation.common.ForceUpdateDialogActivity import com.eatssu.android.presentation.common.NetworkConnection import com.eatssu.android.presentation.common.VersionViewModel import com.eatssu.android.presentation.common.VersionViewModelFactory +import com.eatssu.android.presentation.login.LoginActivity import com.google.android.material.card.MaterialCardView +import kotlinx.coroutines.launch abstract class BaseActivity( @@ -74,6 +81,30 @@ abstract class BaseActivity( // } _binding = bindingFactory(layoutInflater, findViewById(R.id.fl_content), true) + + // refreshtoken 관리 + observeTokenExpiration() + } + + private fun observeTokenExpiration() { + lifecycleScope.launch { + TokenEventBus.tokenExpired.collect { + Toast.makeText(this@BaseActivity, "세션이 만료되었습니다. 다시 로그인 해주세요.", Toast.LENGTH_SHORT).show() + navigateToLogin() + } + + TokenEventBus.tokenServerError.collect { + Toast.makeText(this@BaseActivity, "시스템 오류로 다시 로그인해주세요.", Toast.LENGTH_SHORT).show() + navigateToLogin() + } + } + } + + private fun navigateToLogin() { + startActivity(Intent(this, LoginActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + }) + finishAffinity() } override fun onDestroy() { diff --git a/app/src/main/java/com/eatssu/android/presentation/base/TokenEventBus.kt b/app/src/main/java/com/eatssu/android/presentation/base/TokenEventBus.kt new file mode 100644 index 000000000..a2d182420 --- /dev/null +++ b/app/src/main/java/com/eatssu/android/presentation/base/TokenEventBus.kt @@ -0,0 +1,22 @@ +package com.eatssu.android.presentation.base + +import kotlinx.coroutines.flow.MutableSharedFlow +import timber.log.Timber + +/** application에서 발생하는 토큰 만료 이벤트를 전달하기 위한 Bus */ +object TokenEventBus { + private val _tokenExpired = MutableSharedFlow(replay = 0) + val tokenExpired = _tokenExpired + + private val _tokenServerError = MutableSharedFlow(replay = 0) + val tokenServerError = _tokenServerError + + suspend fun notifyTokenExpired() { + _tokenExpired.emit(Unit) + } + + suspend fun notifyServerError() { + Timber.d("TokenEventBus → Suver error") + _tokenServerError.emit(Unit) + } +} diff --git a/app/src/main/java/com/eatssu/android/presentation/login/LoginViewModel.kt b/app/src/main/java/com/eatssu/android/presentation/login/LoginViewModel.kt index c2937aad0..a82bd53a2 100644 --- a/app/src/main/java/com/eatssu/android/presentation/login/LoginViewModel.kt +++ b/app/src/main/java/com/eatssu/android/presentation/login/LoginViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.eatssu.android.R import com.eatssu.android.data.dto.request.LoginWithKakaoRequest +import com.eatssu.android.domain.model.TokenStateManager import com.eatssu.android.domain.usecase.auth.LoginUseCase import com.eatssu.android.domain.usecase.auth.SetAccessTokenUseCase import com.eatssu.android.domain.usecase.auth.SetRefreshTokenUseCase @@ -57,6 +58,8 @@ class LoginViewModel @Inject constructor( _uiState.value = UiState.Success(LoginState.LoginSuccess) _uiEvent.emit(UiEvent.ShowToast(context.getString(R.string.login_done))) + + TokenStateManager.setTokenValid() } } } diff --git a/app/src/main/java/com/eatssu/android/presentation/main/menu/MenuFragment.kt b/app/src/main/java/com/eatssu/android/presentation/main/menu/MenuFragment.kt index a970a6acd..b23a64666 100644 --- a/app/src/main/java/com/eatssu/android/presentation/main/menu/MenuFragment.kt +++ b/app/src/main/java/com/eatssu/android/presentation/main/menu/MenuFragment.kt @@ -9,10 +9,10 @@ import android.view.ViewGroup import androidx.annotation.RequiresApi import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels +import androidx.fragment.app.viewModels import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModelProvider import androidx.recyclerview.widget.LinearLayoutManager -import com.eatssu.android.data.RetrofitImpl import com.eatssu.android.data.dto.response.mapFixedMenuResponseToMenu import com.eatssu.android.data.dto.response.mapTodayMenuResponseToMenu import com.eatssu.android.data.enums.MenuType @@ -24,20 +24,19 @@ import com.eatssu.android.databinding.FragmentMenuBinding import com.eatssu.android.domain.model.Section import com.eatssu.android.presentation.info.InfoViewModel import com.eatssu.android.presentation.main.calendar.CalendarViewModel +import dagger.hilt.android.AndroidEntryPoint import java.time.DayOfWeek import java.time.LocalDate import java.time.LocalDateTime import java.time.format.DateTimeFormatter +@AndroidEntryPoint class MenuFragment : Fragment() { private var _binding: FragmentMenuBinding? = null private val binding get() = _binding!! private lateinit var calendarViewModel: CalendarViewModel - private lateinit var menuViewModel: MenuViewModel - - private lateinit var menuService: MenuService - private lateinit var mealService: MealService + private val menuViewModel: MenuViewModel by viewModels() private lateinit var menuDate: String private lateinit var cafeteriaLocation: String @@ -86,20 +85,10 @@ class MenuFragment : Fragment() { @RequiresApi(Build.VERSION_CODES.O) fun observeViewModel() { - menuService = RetrofitImpl.retrofit.create(MenuService::class.java) - mealService = RetrofitImpl.retrofit.create(MealService::class.java) -// Log.d("MenuFragment", App.token_prefs.accessToken + "여기부터" + App.token_prefs.refreshToken) val calendardate = this.arguments?.getString("calendardata") Log.d("lunchdate", "$calendardate") -// menuRepository = MenuRepository(menuService) - menuViewModel = - ViewModelProvider( - this, - MenuViewModelFactory(menuService, mealService) - )[MenuViewModel::class.java] - // ViewModel에서 데이터 가져오기 calendarViewModel = ViewModelProvider(requireActivity())[CalendarViewModel::class.java] retainInstance = true diff --git a/app/src/main/java/com/eatssu/android/presentation/main/menu/MenuViewModel.kt b/app/src/main/java/com/eatssu/android/presentation/main/menu/MenuViewModel.kt index 4117396fa..c5c253fe4 100644 --- a/app/src/main/java/com/eatssu/android/presentation/main/menu/MenuViewModel.kt +++ b/app/src/main/java/com/eatssu/android/presentation/main/menu/MenuViewModel.kt @@ -13,6 +13,7 @@ import com.eatssu.android.data.enums.Time import com.eatssu.android.data.service.MealService import com.eatssu.android.data.service.MenuService import com.eatssu.android.domain.model.MenuMini +import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -20,14 +21,13 @@ import kotlinx.coroutines.launch import retrofit2.Call import retrofit2.Callback import retrofit2.Response +import javax.inject.Inject - -class MenuViewModel( +@HiltViewModel +class MenuViewModel @Inject constructor( private val menuService: MenuService, private val mealService: MealService, -) : - ViewModel() { - +) :ViewModel() { private val _todayMealDataDodam = MutableLiveData>() val todayMealDataDodam: LiveData> = _todayMealDataDodam diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 947e5f0ab..88f23dc44 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -169,5 +169,5 @@ http://pf.kakao.com/_ZlVAn 오픈소스 라이브러리 - 로그인이 실패했습니다.\n + 로그인이 실패했습니다. \ No newline at end of file