From 6ad102e62008eafde3a4b213223028e27e06b7a2 Mon Sep 17 00:00:00 2001 From: dgddgd314 Date: Sun, 12 Jul 2026 01:49:50 +0900 Subject: [PATCH 1/2] feat: accessToken & refreshToken impl --- .../data/local/AuthTokenStorage.kt | 25 ++++- .../data/network/AuthTokenAuthenticator.kt | 99 +++++++++++++++++++ .../data/network/api/AuthApi.kt | 15 ++- .../data/network/model/LoginResponse.kt | 3 +- .../data/network/model/RefreshTokenRequest.kt | 5 + .../data/repository/AuthRepository.kt | 7 ++ .../hangsha_android/di/NetworkModule.kt | 75 ++++++++++---- .../ui/view/login/LoginViewModel.kt | 66 +++++++------ .../ui/view/mypage/MyPageViewModel.kt | 97 ++++++++++-------- .../ui/view/signup/SignUpViewModel.kt | 19 ++-- 10 files changed, 308 insertions(+), 103 deletions(-) create mode 100644 app/src/main/java/com/example/hangsha_android/data/network/AuthTokenAuthenticator.kt create mode 100644 app/src/main/java/com/example/hangsha_android/data/network/model/RefreshTokenRequest.kt diff --git a/app/src/main/java/com/example/hangsha_android/data/local/AuthTokenStorage.kt b/app/src/main/java/com/example/hangsha_android/data/local/AuthTokenStorage.kt index 284a15f..4159a48 100644 --- a/app/src/main/java/com/example/hangsha_android/data/local/AuthTokenStorage.kt +++ b/app/src/main/java/com/example/hangsha_android/data/local/AuthTokenStorage.kt @@ -21,24 +21,47 @@ class AuthTokenStorage @Inject constructor( EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM ) + fun saveTokens(accessToken: String, refreshToken: String) { + sharedPreferences.edit() + .putString(KEY_ACCESS_TOKEN, accessToken) + .putString(KEY_REFRESH_TOKEN, refreshToken) + .apply() + } + fun saveAccessToken(accessToken: String) { sharedPreferences.edit() .putString(KEY_ACCESS_TOKEN, accessToken) .apply() } + fun saveRefreshToken(refreshToken: String) { + sharedPreferences.edit() + .putString(KEY_REFRESH_TOKEN, refreshToken) + .apply() + } + fun getAccessToken(): String? { return sharedPreferences.getString(KEY_ACCESS_TOKEN, null) } - fun clearAccessToken() { + fun getRefreshToken(): String? { + return sharedPreferences.getString(KEY_REFRESH_TOKEN, null) + } + + fun clearTokens() { sharedPreferences.edit() .remove(KEY_ACCESS_TOKEN) + .remove(KEY_REFRESH_TOKEN) .apply() } + fun clearAccessToken() { + clearTokens() + } + companion object { private const val FILE_NAME = "auth_secure_prefs" private const val KEY_ACCESS_TOKEN = "access_token" + private const val KEY_REFRESH_TOKEN = "refresh_token" } } diff --git a/app/src/main/java/com/example/hangsha_android/data/network/AuthTokenAuthenticator.kt b/app/src/main/java/com/example/hangsha_android/data/network/AuthTokenAuthenticator.kt new file mode 100644 index 0000000..51e404e --- /dev/null +++ b/app/src/main/java/com/example/hangsha_android/data/network/AuthTokenAuthenticator.kt @@ -0,0 +1,99 @@ +package com.example.hangsha_android.data.network + +import com.example.hangsha_android.data.local.AuthTokenStorage +import com.example.hangsha_android.data.network.api.AuthApi +import com.example.hangsha_android.data.network.model.RefreshTokenRequest +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.runBlocking +import okhttp3.Authenticator +import okhttp3.Request +import okhttp3.Response +import okhttp3.Route + +@Singleton +class AuthTokenAuthenticator @Inject constructor( + private val authTokenStorage: AuthTokenStorage, + private val authApi: AuthApi +) : Authenticator { + + override fun authenticate(route: Route?, response: Response): Request? { + if (isAuthRequest(response.request) || responseCount(response) >= MAX_AUTH_RETRY_COUNT) { + return null + } + + val requestAccessToken = response.request.header(AUTHORIZATION_HEADER) + ?.removePrefix(BEARER_PREFIX) + ?.trim() + .takeUnless { it.isNullOrBlank() } + ?: return null + + synchronized(this) { + val latestAccessToken = authTokenStorage.getAccessToken() + if (!latestAccessToken.isNullOrBlank() && latestAccessToken != requestAccessToken) { + return response.request.newBuilder() + .header(AUTHORIZATION_HEADER, "$BEARER_PREFIX$latestAccessToken") + .build() + } + + val refreshToken = authTokenStorage.getRefreshToken() + ?.takeUnless { it.isBlank() } + ?: run { + authTokenStorage.clearTokens() + return null + } + + val refreshResponse = runBlocking { + authApi.refresh( + RefreshTokenRequest(refreshToken = refreshToken) + ) + } + if (!refreshResponse.isSuccessful) { + authTokenStorage.clearTokens() + return null + } + + val refreshedTokens = refreshResponse.body() + val newAccessToken = refreshedTokens?.accessToken + val newRefreshToken = refreshedTokens?.refreshToken + if (newAccessToken.isNullOrBlank() || newRefreshToken.isNullOrBlank()) { + authTokenStorage.clearTokens() + return null + } + + authTokenStorage.saveTokens( + accessToken = newAccessToken, + refreshToken = newRefreshToken + ) + + return response.request.newBuilder() + .header(AUTHORIZATION_HEADER, "$BEARER_PREFIX$newAccessToken") + .build() + } + } + + private fun isAuthRequest(request: Request): Boolean { + val path = request.url.encodedPath + return AUTH_PATH_PREFIXES.any(path::startsWith) + } + + private fun responseCount(response: Response): Int { + var currentResponse: Response? = response + var count = 1 + while (currentResponse?.priorResponse != null) { + count += 1 + currentResponse = currentResponse.priorResponse + } + return count + } + + private companion object { + private const val AUTHORIZATION_HEADER = "Authorization" + private const val BEARER_PREFIX = "Bearer " + private const val MAX_AUTH_RETRY_COUNT = 2 + private val AUTH_PATH_PREFIXES = listOf( + "/api/v1/mobile/auth/", + "/api/v1/auth/" + ) + } +} diff --git a/app/src/main/java/com/example/hangsha_android/data/network/api/AuthApi.kt b/app/src/main/java/com/example/hangsha_android/data/network/api/AuthApi.kt index 0608983..b9a3c65 100644 --- a/app/src/main/java/com/example/hangsha_android/data/network/api/AuthApi.kt +++ b/app/src/main/java/com/example/hangsha_android/data/network/api/AuthApi.kt @@ -2,6 +2,7 @@ package com.example.hangsha_android.data.network.api import com.example.hangsha_android.data.network.model.LoginRequest import com.example.hangsha_android.data.network.model.LoginResponse +import com.example.hangsha_android.data.network.model.RefreshTokenRequest import com.example.hangsha_android.data.network.model.RegisterRequest import com.example.hangsha_android.data.network.model.SocialLoginRequest import retrofit2.Response @@ -9,7 +10,7 @@ import retrofit2.http.Body import retrofit2.http.POST interface AuthApi { - @POST("api/v1/auth/login") + @POST("api/v1/mobile/auth/login") suspend fun login( @Body request: LoginRequest ): Response @@ -19,8 +20,18 @@ interface AuthApi { @Body request: SocialLoginRequest ): Response - @POST("api/v1/auth/register") + @POST("api/v1/mobile/auth/register") suspend fun register( @Body request: RegisterRequest ): Response + + @POST("api/v1/mobile/auth/refresh") + suspend fun refresh( + @Body request: RefreshTokenRequest + ): Response + + @POST("api/v1/mobile/auth/logout") + suspend fun logout( + @Body request: RefreshTokenRequest + ): Response } diff --git a/app/src/main/java/com/example/hangsha_android/data/network/model/LoginResponse.kt b/app/src/main/java/com/example/hangsha_android/data/network/model/LoginResponse.kt index 70abe5c..dd1b08f 100644 --- a/app/src/main/java/com/example/hangsha_android/data/network/model/LoginResponse.kt +++ b/app/src/main/java/com/example/hangsha_android/data/network/model/LoginResponse.kt @@ -1,5 +1,6 @@ package com.example.hangsha_android.data.network.model data class LoginResponse( - val accessToken: String + val accessToken: String, + val refreshToken: String ) diff --git a/app/src/main/java/com/example/hangsha_android/data/network/model/RefreshTokenRequest.kt b/app/src/main/java/com/example/hangsha_android/data/network/model/RefreshTokenRequest.kt new file mode 100644 index 0000000..c4be11e --- /dev/null +++ b/app/src/main/java/com/example/hangsha_android/data/network/model/RefreshTokenRequest.kt @@ -0,0 +1,5 @@ +package com.example.hangsha_android.data.network.model + +data class RefreshTokenRequest( + val refreshToken: String +) diff --git a/app/src/main/java/com/example/hangsha_android/data/repository/AuthRepository.kt b/app/src/main/java/com/example/hangsha_android/data/repository/AuthRepository.kt index 2994ea2..2fb315b 100644 --- a/app/src/main/java/com/example/hangsha_android/data/repository/AuthRepository.kt +++ b/app/src/main/java/com/example/hangsha_android/data/repository/AuthRepository.kt @@ -3,6 +3,7 @@ package com.example.hangsha_android.data.repository import com.example.hangsha_android.data.network.api.AuthApi import com.example.hangsha_android.data.network.model.LoginRequest import com.example.hangsha_android.data.network.model.LoginResponse +import com.example.hangsha_android.data.network.model.RefreshTokenRequest import com.example.hangsha_android.data.network.model.RegisterRequest import com.example.hangsha_android.data.network.model.SocialLoginRequest import javax.inject.Inject @@ -46,6 +47,12 @@ class AuthRepository @Inject constructor( ) } + suspend fun logout(refreshToken: String): Response { + return authApi.logout( + RefreshTokenRequest(refreshToken = refreshToken) + ) + } + companion object { private const val GOOGLE_PROVIDER = "GOOGLE" } diff --git a/app/src/main/java/com/example/hangsha_android/di/NetworkModule.kt b/app/src/main/java/com/example/hangsha_android/di/NetworkModule.kt index c1af947..c0be38b 100644 --- a/app/src/main/java/com/example/hangsha_android/di/NetworkModule.kt +++ b/app/src/main/java/com/example/hangsha_android/di/NetworkModule.kt @@ -2,6 +2,7 @@ package com.example.hangsha_android.di import com.example.hangsha_android.BuildConfig import com.example.hangsha_android.data.local.AuthTokenStorage +import com.example.hangsha_android.data.network.AuthTokenAuthenticator import com.example.hangsha_android.data.network.api.AuthApi import com.example.hangsha_android.data.network.api.BookmarkApi import com.example.hangsha_android.data.network.api.BugReportApi @@ -15,12 +16,12 @@ import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import javax.inject.Named +import javax.inject.Singleton import okhttp3.OkHttpClient -import okhttp3.Request import okhttp3.logging.HttpLoggingInterceptor import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory -import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) @@ -36,32 +37,65 @@ object NetworkModule { @Provides @Singleton - fun provideOkHttpClient( + @Named("auth") + fun provideAuthOkHttpClient( + loggingInterceptor: HttpLoggingInterceptor + ): OkHttpClient { + return OkHttpClient.Builder() + .addInterceptor(loggingInterceptor) + .build() + } + + @Provides + @Singleton + @Named("app") + fun provideAppOkHttpClient( loggingInterceptor: HttpLoggingInterceptor, - authTokenStorage: AuthTokenStorage + authTokenStorage: AuthTokenStorage, + authTokenAuthenticator: AuthTokenAuthenticator ): OkHttpClient { return OkHttpClient.Builder() .addInterceptor { chain -> + val originalRequest = chain.request() + val path = originalRequest.url.encodedPath + if (AUTH_PATH_PREFIXES.any(path::startsWith)) { + return@addInterceptor chain.proceed(originalRequest) + } + val accessToken = authTokenStorage.getAccessToken() val request = if (accessToken.isNullOrBlank()) { - chain.request() + originalRequest } else { - chain.request() - .newBuilder() + originalRequest.newBuilder() .header("Authorization", "Bearer $accessToken") .build() } chain.proceed(request) } + .authenticator(authTokenAuthenticator) .addInterceptor(loggingInterceptor) .build() } @Provides @Singleton - fun provideRetrofit( - okHttpClient: OkHttpClient + @Named("auth") + fun provideAuthRetrofit( + @Named("auth") okHttpClient: OkHttpClient + ): Retrofit { + return Retrofit.Builder() + .baseUrl(BuildConfig.SERVER_BASE_URL) + .client(okHttpClient) + .addConverterFactory(GsonConverterFactory.create()) + .build() + } + + @Provides + @Singleton + @Named("app") + fun provideAppRetrofit( + @Named("app") okHttpClient: OkHttpClient ): Retrofit { return Retrofit.Builder() .baseUrl(BuildConfig.SERVER_BASE_URL) @@ -73,7 +107,7 @@ object NetworkModule { @Provides @Singleton fun provideServerHealthApi( - retrofit: Retrofit + @Named("app") retrofit: Retrofit ): ServerHealthApi { return retrofit.create(ServerHealthApi::class.java) } @@ -81,7 +115,7 @@ object NetworkModule { @Provides @Singleton fun provideAuthApi( - retrofit: Retrofit + @Named("auth") retrofit: Retrofit ): AuthApi { return retrofit.create(AuthApi::class.java) } @@ -89,7 +123,7 @@ object NetworkModule { @Provides @Singleton fun provideUserApi( - retrofit: Retrofit + @Named("app") retrofit: Retrofit ): UserApi { return retrofit.create(UserApi::class.java) } @@ -97,7 +131,7 @@ object NetworkModule { @Provides @Singleton fun provideEventApi( - retrofit: Retrofit + @Named("app") retrofit: Retrofit ): EventApi { return retrofit.create(EventApi::class.java) } @@ -105,7 +139,7 @@ object NetworkModule { @Provides @Singleton fun provideBookmarkApi( - retrofit: Retrofit + @Named("app") retrofit: Retrofit ): BookmarkApi { return retrofit.create(BookmarkApi::class.java) } @@ -113,7 +147,7 @@ object NetworkModule { @Provides @Singleton fun provideExcludedKeywordsApi( - retrofit: Retrofit + @Named("app") retrofit: Retrofit ): ExcludedKeywordsApi { return retrofit.create(ExcludedKeywordsApi::class.java) } @@ -121,7 +155,7 @@ object NetworkModule { @Provides @Singleton fun provideBugReportApi( - retrofit: Retrofit + @Named("app") retrofit: Retrofit ): BugReportApi { return retrofit.create(BugReportApi::class.java) } @@ -129,7 +163,7 @@ object NetworkModule { @Provides @Singleton fun provideMemoApi( - retrofit: Retrofit + @Named("app") retrofit: Retrofit ): MemoApi { return retrofit.create(MemoApi::class.java) } @@ -137,8 +171,13 @@ object NetworkModule { @Provides @Singleton fun provideCategoryApi( - retrofit: Retrofit + @Named("app") retrofit: Retrofit ): CategoryApi { return retrofit.create(CategoryApi::class.java) } + + private val AUTH_PATH_PREFIXES = listOf( + "/api/v1/mobile/auth/", + "/api/v1/auth/" + ) } diff --git a/app/src/main/java/com/example/hangsha_android/ui/view/login/LoginViewModel.kt b/app/src/main/java/com/example/hangsha_android/ui/view/login/LoginViewModel.kt index 12ba034..9541a76 100644 --- a/app/src/main/java/com/example/hangsha_android/ui/view/login/LoginViewModel.kt +++ b/app/src/main/java/com/example/hangsha_android/ui/view/login/LoginViewModel.kt @@ -4,13 +4,14 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.example.hangsha_android.BuildConfig import com.example.hangsha_android.data.local.AuthTokenStorage +import com.example.hangsha_android.data.network.model.LoginResponse import com.example.hangsha_android.data.repository.AuthRepository import com.example.hangsha_android.data.repository.ExcludedKeywordsRepository import com.example.hangsha_android.data.repository.UserRepository +import dagger.hilt.android.lifecycle.HiltViewModel import java.io.IOException import java.net.SocketTimeoutException import java.net.UnknownHostException -import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -18,6 +19,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import retrofit2.HttpException +import retrofit2.Response @HiltViewModel class LoginViewModel @Inject constructor( @@ -54,21 +56,21 @@ class LoginViewModel @Inject constructor( val password = currentState.password when { - email.isBlank() -> onAuthFailure("이메일을 입력해주세요.") - password.isBlank() -> onAuthFailure("비밀번호를 입력해주세요.") + email.isBlank() -> onAuthFailure("Please enter your email.") + password.isBlank() -> onAuthFailure("Please enter your password.") else -> viewModelScope.launch { onCredentialLoginStarted() val result = runCatching { val response = authRepository.login(email = email, password = password) - saveAccessTokenFromResponse(response) + saveTokensFromResponse(response) loadOrganizationNames() loadExcludedKeywords() } result.fold( onSuccess = { - onAuthSuccess("로그인되었습니다.") + onAuthSuccess("Logged in successfully.") }, onFailure = { error -> onAuthFailure(error, "login") @@ -79,11 +81,11 @@ class LoginViewModel @Inject constructor( } fun onGoogleLoginConfigMissing() { - onAuthFailure("Google 로그인이 아직 설정되지 않았습니다.") + onAuthFailure("Google login is not configured yet.") } fun onGoogleLoginCancelled() { - onAuthFailure("Google 로그인이 취소되었습니다.") + onAuthFailure("Google login was cancelled.") } fun onGoogleLoginError(message: String) { @@ -103,7 +105,7 @@ class LoginViewModel @Inject constructor( _uiState.update { it.copy( isGoogleHistoryClearing = false, - loginMessage = "Google 로그인 기록을 지웠습니다." + loginMessage = "Google sign-in history was cleared." ) } } @@ -115,7 +117,7 @@ class LoginViewModel @Inject constructor( } if (serverAuthCode.isNullOrBlank()) { - onGoogleLoginError("Google 로그인 정보를 가져오지 못했습니다.") + onGoogleLoginError("Could not retrieve Google login information.") return } @@ -124,14 +126,14 @@ class LoginViewModel @Inject constructor( val result = runCatching { val response = authRepository.loginWithGoogle(serverAuthCode) - saveAccessTokenFromResponse(response) + saveTokensFromResponse(response) loadOrganizationNames() loadExcludedKeywords() } result.fold( onSuccess = { - onAuthSuccess("Google 로그인이 완료되었습니다.") + onAuthSuccess("Google login completed successfully.") }, onFailure = { error -> onAuthFailure(error, "Google login") @@ -168,19 +170,22 @@ class LoginViewModel @Inject constructor( } } - private fun saveAccessTokenFromResponse( - response: retrofit2.Response - ) { + private fun saveTokensFromResponse(response: Response) { if (!response.isSuccessful) { throw HttpException(response) } - val accessToken = response.body()?.accessToken - if (accessToken.isNullOrBlank()) { - throw IllegalStateException("로그인 응답에 필요한 토큰이 없습니다.") + val authTokens = response.body() + val accessToken = authTokens?.accessToken + val refreshToken = authTokens?.refreshToken + if (accessToken.isNullOrBlank() || refreshToken.isNullOrBlank()) { + throw IllegalStateException("Authentication response did not include auth tokens.") } - authTokenStorage.saveAccessToken(accessToken) + authTokenStorage.saveTokens( + accessToken = accessToken, + refreshToken = refreshToken + ) } private suspend fun loadOrganizationNames() { @@ -220,24 +225,23 @@ class LoginViewModel @Inject constructor( private fun onAuthFailure(error: Throwable, actionLabel: String) { val message = when (error) { - is UnknownHostException -> "인터넷 연결을 확인해주세요." - is SocketTimeoutException -> "요청 시간이 초과되었습니다. 다시 시도해주세요." + is UnknownHostException -> "Please check your internet connection." + is SocketTimeoutException -> "The request timed out. Please try again." is HttpException -> when (error.code()) { - 400 -> "입력한 정보를 확인해주세요." + 400 -> "Please check the information you entered." 401 -> if (actionLabel == "login") { - "이메일 또는 비밀번호가 일치하지 않습니다." + "Your email or password is incorrect." } else { - "Google 로그인에 실패했습니다." + "Google login failed." } - 403 -> "계속 진행할 권한이 없습니다." - 404 -> "계정 정보를 찾을 수 없습니다." - in 500..599 -> "서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요." - else -> "로그인에 실패했습니다. (${error.code()})" + 403 -> "You do not have permission to continue." + 404 -> "Account information could not be found." + in 500..599 -> "A server error occurred. Please try again later." + else -> "Login failed. (${error.code()})" } - is IOException -> "네트워크 오류가 발생했습니다. 다시 시도해주세요." - is IllegalStateException -> error.message - ?: "로그인에 실패했습니다." - else -> error.message ?: "로그인에 실패했습니다." + is IOException -> "A network error occurred. Please try again." + is IllegalStateException -> error.message ?: "Login failed." + else -> error.message ?: "Login failed." } onAuthFailure(message) diff --git a/app/src/main/java/com/example/hangsha_android/ui/view/mypage/MyPageViewModel.kt b/app/src/main/java/com/example/hangsha_android/ui/view/mypage/MyPageViewModel.kt index 41b21da..f296455 100644 --- a/app/src/main/java/com/example/hangsha_android/ui/view/mypage/MyPageViewModel.kt +++ b/app/src/main/java/com/example/hangsha_android/ui/view/mypage/MyPageViewModel.kt @@ -7,6 +7,7 @@ import androidx.lifecycle.viewModelScope import com.example.hangsha_android.data.local.AuthTokenStorage import com.example.hangsha_android.data.network.model.EventSummaryResponse import com.example.hangsha_android.data.network.model.MemoResponse +import com.example.hangsha_android.data.repository.AuthRepository import com.example.hangsha_android.data.repository.BookmarkRepository import com.example.hangsha_android.data.repository.BugReportRepository import com.example.hangsha_android.data.repository.MemoRepository @@ -33,6 +34,7 @@ import retrofit2.HttpException @HiltViewModel class MyPageViewModel @Inject constructor( + private val authRepository: AuthRepository, private val userRepository: UserRepository, private val bookmarkRepository: BookmarkRepository, private val bugReportRepository: BugReportRepository, @@ -370,7 +372,7 @@ class MyPageViewModel @Inject constructor( } if (title.isBlank() || content.isBlank()) { _uiState.update { - it.copy(bugReportToastMessage = "제목과 내용을 모두 입력해 주세요.") + it.copy(bugReportToastMessage = "Please enter both a title and description.") } return } @@ -398,7 +400,7 @@ class MyPageViewModel @Inject constructor( bugReportTitle = "", bugReportContent = "", isSubmittingBugReport = false, - bugReportToastMessage = "버그 신고가 접수되었습니다." + bugReportToastMessage = "Your bug report was submitted." ) } }, @@ -421,9 +423,18 @@ class MyPageViewModel @Inject constructor( } fun logout() { - authTokenStorage.clearAccessToken() - _uiState.update { - it.copy(isLoggedOut = true) + viewModelScope.launch { + val refreshToken = authTokenStorage.getRefreshToken() + if (!refreshToken.isNullOrBlank()) { + runCatching { + authRepository.logout(refreshToken) + } + } + + authTokenStorage.clearTokens() + _uiState.update { + it.copy(isLoggedOut = true) + } } } @@ -447,7 +458,7 @@ class MyPageViewModel @Inject constructor( } }.fold( onSuccess = { - authTokenStorage.clearAccessToken() + authTokenStorage.clearTokens() _uiState.update { it.copy( isDeletingAccount = false, @@ -513,7 +524,7 @@ class MyPageViewModel @Inject constructor( private fun validateDraftUsername(username: String): String? { val trimmedUsername = username.trim() if (trimmedUsername.isBlank()) { - return "사용자 이름을 입력해 주세요." + return "Please enter a username." } val maxLength = if (trimmedUsername.any { char -> char.isKorean() }) { @@ -522,7 +533,7 @@ class MyPageViewModel @Inject constructor( ENGLISH_USERNAME_MAX_LENGTH } return if (trimmedUsername.length > maxLength) { - "사용자 이름은 ${maxLength}자 이하여야 합니다." + "Username must be $maxLength characters or less." } else { null } @@ -530,7 +541,7 @@ class MyPageViewModel @Inject constructor( private fun mapProfileSaveErrorMessage(error: Throwable): String { return when (error) { - is IllegalArgumentException -> error.message ?: "프로필 입력값을 확인해 주세요." + is IllegalArgumentException -> error.message ?: "Please check your profile input." else -> mapErrorMessage(error) } } @@ -538,58 +549,58 @@ class MyPageViewModel @Inject constructor( private fun mapAccountDeletionErrorMessage(error: Throwable): String { return when (error) { is HttpException -> when (error.code()) { - 401 -> "로그인이 필요합니다." - 403 -> "회원 탈퇴 권한이 없습니다." - in 500..599 -> "서버 오류가 발생했습니다. 잠시 후 다시 시도해 주세요." - else -> "회원 탈퇴에 실패했습니다. (${error.code()})" + 401 -> "Login is required." + 403 -> "You do not have permission to delete this account." + in 500..599 -> "A server error occurred. Please try again later." + else -> "Failed to delete the account. (${error.code()})" } - is UnknownHostException -> "인터넷 연결을 확인해 주세요." - is SocketTimeoutException -> "요청 시간이 초과되었습니다. 다시 시도해 주세요." - is IOException -> "네트워크 오류가 발생했습니다. 다시 시도해 주세요." - else -> error.message ?: "회원 탈퇴에 실패했습니다." + is UnknownHostException -> "Please check your internet connection." + is SocketTimeoutException -> "The request timed out. Please try again." + is IOException -> "A network error occurred. Please try again." + else -> error.message ?: "Failed to delete the account." } } private fun mapBugReportErrorMessage(error: Throwable): String { return when (error) { is HttpException -> when (error.code()) { - 400 -> "버그 신고 내용을 확인해 주세요." - 401 -> "로그인이 필요합니다." - in 500..599 -> "서버 오류가 발생했습니다. 잠시 후 다시 시도해 주세요." - else -> "버그 신고에 실패했습니다. (${error.code()})" + 400 -> "Please check your bug report content." + 401 -> "Login is required." + in 500..599 -> "A server error occurred. Please try again later." + else -> "Failed to submit the bug report. (${error.code()})" } - is UnknownHostException -> "인터넷 연결을 확인해 주세요." - is SocketTimeoutException -> "요청 시간이 초과되었습니다. 다시 시도해 주세요." - is IOException -> "네트워크 오류가 발생했습니다. 다시 시도해 주세요." - else -> error.message ?: "버그 신고에 실패했습니다." + is UnknownHostException -> "Please check your internet connection." + is SocketTimeoutException -> "The request timed out. Please try again." + is IOException -> "A network error occurred. Please try again." + else -> error.message ?: "Failed to submit the bug report." } } private fun mapBookmarksPreviewErrorMessage(error: Throwable): String { return when (error) { is HttpException -> when (error.code()) { - 401 -> "로그인이 필요합니다." - in 500..599 -> "서버 오류가 발생했습니다. 잠시 후 다시 시도해 주세요." - else -> "찜 목록을 불러오지 못했습니다. (${error.code()})" + 401 -> "Login is required." + in 500..599 -> "A server error occurred. Please try again later." + else -> "Failed to load bookmarks. (${error.code()})" } - is UnknownHostException -> "인터넷 연결을 확인해 주세요." - is SocketTimeoutException -> "요청 시간이 초과되었습니다. 다시 시도해 주세요." - is IOException -> "네트워크 오류가 발생했습니다. 다시 시도해 주세요." - else -> error.message ?: "찜 목록을 불러오지 못했습니다." + is UnknownHostException -> "Please check your internet connection." + is SocketTimeoutException -> "The request timed out. Please try again." + is IOException -> "A network error occurred. Please try again." + else -> error.message ?: "Failed to load bookmarks." } } private fun mapMemosPreviewErrorMessage(error: Throwable): String { return when (error) { is HttpException -> when (error.code()) { - 401 -> "로그인이 필요합니다." - in 500..599 -> "서버 오류가 발생했습니다. 잠시 후 다시 시도해 주세요." - else -> "메모 목록을 불러오지 못했습니다. (${error.code()})" + 401 -> "Login is required." + in 500..599 -> "A server error occurred. Please try again later." + else -> "Failed to load memos. (${error.code()})" } - is UnknownHostException -> "인터넷 연결을 확인해 주세요." - is SocketTimeoutException -> "요청 시간이 초과되었습니다. 다시 시도해 주세요." - is IOException -> "네트워크 오류가 발생했습니다. 다시 시도해 주세요." - else -> error.message ?: "메모 목록을 불러오지 못했습니다." + is UnknownHostException -> "Please check your internet connection." + is SocketTimeoutException -> "The request timed out. Please try again." + is IOException -> "A network error occurred. Please try again." + else -> error.message ?: "Failed to load memos." } } @@ -621,11 +632,11 @@ private fun EventSummaryResponse.toBookmarkedEventItem(): BookmarkedEventItem { val dDayLabel = applyEndDate?.let { targetDate -> val diff = targetDate.toEpochDay() - LocalDate.now().toEpochDay() when { - diff == 0L -> "지원 D-day" - diff > 0L -> "지원 D-$diff" - else -> "지원 D$diff" + diff == 0L -> "Apply D-day" + diff > 0L -> "Apply D-$diff" + else -> "Apply D$diff" } - } ?: "지원 -" + } ?: "Apply -" return BookmarkedEventItem( id = id, diff --git a/app/src/main/java/com/example/hangsha_android/ui/view/signup/SignUpViewModel.kt b/app/src/main/java/com/example/hangsha_android/ui/view/signup/SignUpViewModel.kt index 8a4aaba..e11006f 100644 --- a/app/src/main/java/com/example/hangsha_android/ui/view/signup/SignUpViewModel.kt +++ b/app/src/main/java/com/example/hangsha_android/ui/view/signup/SignUpViewModel.kt @@ -7,10 +7,10 @@ import com.example.hangsha_android.data.network.model.LoginResponse import com.example.hangsha_android.data.repository.AuthRepository import com.example.hangsha_android.data.repository.ExcludedKeywordsRepository import com.example.hangsha_android.data.repository.UserRepository +import dagger.hilt.android.lifecycle.HiltViewModel import java.io.IOException import java.net.SocketTimeoutException import java.net.UnknownHostException -import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -85,7 +85,7 @@ class SignUpViewModel @Inject constructor( username = "" ) - saveAccessTokenFromResponse(response) + saveTokensFromResponse(response) loadOrganizationNames() loadExcludedKeywords() }.fold( @@ -138,17 +138,22 @@ class SignUpViewModel @Inject constructor( onSignUpFailure(message) } - private fun saveAccessTokenFromResponse(response: Response) { + private fun saveTokensFromResponse(response: Response) { if (!response.isSuccessful) { throw HttpException(response) } - val accessToken = response.body()?.accessToken - if (accessToken.isNullOrBlank()) { - throw IllegalStateException("Sign-up response did not include an access token.") + val authTokens = response.body() + val accessToken = authTokens?.accessToken + val refreshToken = authTokens?.refreshToken + if (accessToken.isNullOrBlank() || refreshToken.isNullOrBlank()) { + throw IllegalStateException("Sign-up response did not include auth tokens.") } - authTokenStorage.saveAccessToken(accessToken) + authTokenStorage.saveTokens( + accessToken = accessToken, + refreshToken = refreshToken + ) } private suspend fun loadOrganizationNames() { From a1ad641aa5c3e3ccfa1bfd8f6140022702e7f067 Mon Sep 17 00:00:00 2001 From: dgddgd314 Date: Sun, 12 Jul 2026 01:58:12 +0900 Subject: [PATCH 2/2] feat: autoLogin Impl --- .../data/repository/AuthRepository.kt | 6 ++ .../ui/navigation/HangshaNavHost.kt | 4 + .../ui/view/login/LoginUiState.kt | 3 +- .../ui/view/login/LoginViewModel.kt | 75 +++++++++++++++++++ 4 files changed, 87 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/example/hangsha_android/data/repository/AuthRepository.kt b/app/src/main/java/com/example/hangsha_android/data/repository/AuthRepository.kt index 2fb315b..038348f 100644 --- a/app/src/main/java/com/example/hangsha_android/data/repository/AuthRepository.kt +++ b/app/src/main/java/com/example/hangsha_android/data/repository/AuthRepository.kt @@ -53,6 +53,12 @@ class AuthRepository @Inject constructor( ) } + suspend fun refresh(refreshToken: String): Response { + return authApi.refresh( + RefreshTokenRequest(refreshToken = refreshToken) + ) + } + companion object { private const val GOOGLE_PROVIDER = "GOOGLE" } diff --git a/app/src/main/java/com/example/hangsha_android/ui/navigation/HangshaNavHost.kt b/app/src/main/java/com/example/hangsha_android/ui/navigation/HangshaNavHost.kt index 5842b1d..457ecec 100644 --- a/app/src/main/java/com/example/hangsha_android/ui/navigation/HangshaNavHost.kt +++ b/app/src/main/java/com/example/hangsha_android/ui/navigation/HangshaNavHost.kt @@ -156,6 +156,10 @@ fun NavGraphBuilder.loginGraph(navController: NavHostController) { loginViewModel.loginWithGoogle(serverAuthCode) } + LaunchedEffect(Unit) { + loginViewModel.tryAutoLogin() + } + LaunchedEffect(loginUiState.isLoginSuccessful) { if (!loginUiState.isLoginSuccessful) { return@LaunchedEffect diff --git a/app/src/main/java/com/example/hangsha_android/ui/view/login/LoginUiState.kt b/app/src/main/java/com/example/hangsha_android/ui/view/login/LoginUiState.kt index 3d3af77..6a7e565 100644 --- a/app/src/main/java/com/example/hangsha_android/ui/view/login/LoginUiState.kt +++ b/app/src/main/java/com/example/hangsha_android/ui/view/login/LoginUiState.kt @@ -3,6 +3,7 @@ package com.example.hangsha_android.ui.view.login data class LoginUiState( val username: String = "", val password: String = "", + val isAutoLoginLoading: Boolean = false, val isCredentialLoginLoading: Boolean = false, val isGoogleLoginLoading: Boolean = false, val isGoogleHistoryClearing: Boolean = false, @@ -10,5 +11,5 @@ data class LoginUiState( val loginMessage: String? = null ) { val isAnyLoginLoading: Boolean - get() = isCredentialLoginLoading || isGoogleLoginLoading + get() = isAutoLoginLoading || isCredentialLoginLoading || isGoogleLoginLoading } diff --git a/app/src/main/java/com/example/hangsha_android/ui/view/login/LoginViewModel.kt b/app/src/main/java/com/example/hangsha_android/ui/view/login/LoginViewModel.kt index 9541a76..d3d9d70 100644 --- a/app/src/main/java/com/example/hangsha_android/ui/view/login/LoginViewModel.kt +++ b/app/src/main/java/com/example/hangsha_android/ui/view/login/LoginViewModel.kt @@ -31,6 +31,7 @@ class LoginViewModel @Inject constructor( private val _uiState = MutableStateFlow(LoginUiState()) val uiState: StateFlow = _uiState.asStateFlow() + private var hasAttemptedAutoLogin = false fun onUsernameChanged(username: String) { _uiState.update { @@ -50,6 +51,46 @@ class LoginViewModel @Inject constructor( } } + fun tryAutoLogin() { + if (hasAttemptedAutoLogin) { + return + } + hasAttemptedAutoLogin = true + + val refreshToken = authTokenStorage.getRefreshToken() + if (refreshToken.isNullOrBlank()) { + return + } + + viewModelScope.launch { + _uiState.update { + it.copy( + isAutoLoginLoading = true, + loginMessage = null + ) + } + + val result = runCatching { + val response = authRepository.refresh(refreshToken) + if (!response.isSuccessful) { + throw HttpException(response) + } + saveTokensFromResponse(response) + loadOrganizationNames() + loadExcludedKeywords() + } + + result.fold( + onSuccess = { + onAuthSuccess("Logged in automatically.") + }, + onFailure = { error -> + onAutoLoginFailure(error) + } + ) + } + } + fun loginWithCredentials() { val currentState = _uiState.value val email = currentState.username.trim() @@ -203,6 +244,7 @@ class LoginViewModel @Inject constructor( private fun onAuthSuccess(message: String) { _uiState.update { it.copy( + isAutoLoginLoading = false, isCredentialLoginLoading = false, isGoogleLoginLoading = false, isLoginSuccessful = true, @@ -214,6 +256,7 @@ class LoginViewModel @Inject constructor( private fun onAuthFailure(message: String) { _uiState.update { it.copy( + isAutoLoginLoading = false, isCredentialLoginLoading = false, isGoogleLoginLoading = false, isGoogleHistoryClearing = false, @@ -246,4 +289,36 @@ class LoginViewModel @Inject constructor( onAuthFailure(message) } + + private fun onAutoLoginFailure(error: Throwable) { + when (error) { + is HttpException -> { + if (error.code() == 404) { + authTokenStorage.clearTokens() + _uiState.update { + it.copy( + isAutoLoginLoading = false, + isLoginSuccessful = false, + loginMessage = null + ) + } + return + } + + if (error.code() == 401) { + authTokenStorage.clearTokens() + _uiState.update { + it.copy( + isAutoLoginLoading = false, + isLoginSuccessful = false, + loginMessage = "Your session has expired. Please log in again." + ) + } + return + } + } + } + + onAuthFailure(error, "auto login") + } }