diff --git a/Near/app/src/main/java/com/alarmy/near/data/datasource/KakaoDataSource.kt b/Near/app/src/main/java/com/alarmy/near/data/datasource/KakaoDataSource.kt deleted file mode 100644 index 17005d8c..00000000 --- a/Near/app/src/main/java/com/alarmy/near/data/datasource/KakaoDataSource.kt +++ /dev/null @@ -1,89 +0,0 @@ -package com.alarmy.near.data.datasource - -import android.content.Context -import com.alarmy.near.model.ProviderType -import com.kakao.sdk.auth.model.OAuthToken -import com.kakao.sdk.common.model.ClientError -import com.kakao.sdk.common.model.ClientErrorCause -import com.kakao.sdk.user.UserApiClient -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.CancellableContinuation -import kotlinx.coroutines.suspendCancellableCoroutine -import javax.inject.Inject -import javax.inject.Singleton -import kotlin.coroutines.resume - -/** - * 카카오 로그인 데이터 소스 - * 단일 책임: 카카오 SDK만 처리 - */ -@Singleton -class KakaoDataSource - @Inject - constructor( - @ApplicationContext private val context: Context, - ) : SocialLoginDataSource { - override val supportedType: ProviderType = ProviderType.KAKAO - - override suspend fun login(): Result = - try { - val token = - if (UserApiClient.instance.isKakaoTalkLoginAvailable(context)) { - loginWithKakaoTalk() - } else { - loginWithKakaoAccount() - } - - if (token.isNotEmpty()) { - Result.success(token) - } else { - Result.failure(Exception("사용자가 로그인을 취소했습니다")) - } - } catch (exception: Exception) { - Result.failure(exception) - } - - private suspend fun loginWithKakaoTalk(): String = - suspendCancellableCoroutine { continuation -> - UserApiClient.instance.loginWithKakaoTalk(context) { token, error -> - when { - error != null -> { - if (error is ClientError && error.reason == ClientErrorCause.Cancelled) { - continuation.resume("") - } else { - UserApiClient.instance.loginWithKakaoAccount(context) { retryToken, retryError -> - handleLoginResult(retryToken, retryError, continuation) - } - } - } - token != null -> continuation.resume(token.accessToken) - else -> continuation.resume("") - } - } - } - - private suspend fun loginWithKakaoAccount(): String = - suspendCancellableCoroutine { continuation -> - UserApiClient.instance.loginWithKakaoAccount(context) { token, error -> - handleLoginResult(token, error, continuation) - } - } - - private fun handleLoginResult( - token: OAuthToken?, - error: Throwable?, - continuation: CancellableContinuation, - ) { - when { - error != null -> { - if (error is ClientError && error.reason == ClientErrorCause.Cancelled) { - continuation.resume("") - } else { - continuation.resumeWith(Result.failure(error)) - } - } - token != null -> continuation.resume(token.accessToken) - else -> continuation.resume("") - } - } - } diff --git a/Near/app/src/main/java/com/alarmy/near/data/datasource/SocialLoginDataSource.kt b/Near/app/src/main/java/com/alarmy/near/data/datasource/SocialLoginDataSource.kt deleted file mode 100644 index 9d2bb534..00000000 --- a/Near/app/src/main/java/com/alarmy/near/data/datasource/SocialLoginDataSource.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.alarmy.near.data.datasource - -import com.alarmy.near.model.ProviderType - -/** - * 소셜 로그인 데이터 소스 인터페이스 - * Strategy 패턴으로 각 소셜 플랫폼별로 구현 - */ -interface SocialLoginDataSource { - /** - * 지원하는 소셜 로그인 타입 - */ - val supportedType: ProviderType - - /** - * 소셜 로그인 수행 - * Context는 생성자에서 주입받아 사용 - */ - suspend fun login(): Result -} diff --git a/Near/app/src/main/java/com/alarmy/near/data/datasource/SocialLoginProcessor.kt b/Near/app/src/main/java/com/alarmy/near/data/datasource/SocialLoginProcessor.kt deleted file mode 100644 index 1944d12a..00000000 --- a/Near/app/src/main/java/com/alarmy/near/data/datasource/SocialLoginProcessor.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.alarmy.near.data.datasource - - -import com.alarmy.near.model.ProviderType -import javax.inject.Inject -import javax.inject.Singleton - -/** - * 소셜 로그인 프로세서 - * Strategy 패턴으로 동적으로 로그인 방식 선택 - */ -@Singleton -class SocialLoginProcessor - @Inject - constructor( - private val socialLoginDataSources: Set<@JvmSuppressWildcards SocialLoginDataSource>, - ) { - /** - * 소셜 로그인 처리 - * @param providerType 로그인 제공자 타입 - * @return 로그인 결과 - */ - suspend fun processLogin( - providerType: ProviderType, - ): Result { - val dataSource = - socialLoginDataSources.find { it.supportedType == providerType } - ?: return Result.failure(Exception("지원하지 않는 로그인 타입입니다: ${providerType.name}")) - - return dataSource.login() - } - } diff --git a/Near/app/src/main/java/com/alarmy/near/data/di/DataSourceModule.kt b/Near/app/src/main/java/com/alarmy/near/data/di/DataSourceModule.kt deleted file mode 100644 index 4a6fb2b8..00000000 --- a/Near/app/src/main/java/com/alarmy/near/data/di/DataSourceModule.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.alarmy.near.data.di - -import com.alarmy.near.data.datasource.KakaoDataSource -import com.alarmy.near.data.datasource.SocialLoginDataSource -import dagger.Binds -import dagger.Module -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import dagger.multibindings.IntoSet - -@Module -@InstallIn(SingletonComponent::class) -interface DataSourceModule { - @Binds - @IntoSet - abstract fun bindKakaoDataSource(kakaoDataSource: KakaoDataSource): SocialLoginDataSource -} diff --git a/Near/app/src/main/java/com/alarmy/near/data/repository/AuthRepository.kt b/Near/app/src/main/java/com/alarmy/near/data/repository/AuthRepository.kt index ded14dc6..0822809a 100644 --- a/Near/app/src/main/java/com/alarmy/near/data/repository/AuthRepository.kt +++ b/Near/app/src/main/java/com/alarmy/near/data/repository/AuthRepository.kt @@ -5,12 +5,7 @@ import kotlinx.coroutines.flow.Flow interface AuthRepository { // 소셜 로그인 수행 - suspend fun performSocialLogin(providerType: ProviderType): Result - - /** - * 소셜 로그인 수행 (토큰 직접 전달) - */ - suspend fun socialLogin( + suspend fun performSocialLogin( accessToken: String, providerType: ProviderType, ): Result diff --git a/Near/app/src/main/java/com/alarmy/near/data/repository/AuthRepositoryImpl.kt b/Near/app/src/main/java/com/alarmy/near/data/repository/AuthRepositoryImpl.kt index 43f51f80..6b8b360b 100644 --- a/Near/app/src/main/java/com/alarmy/near/data/repository/AuthRepositoryImpl.kt +++ b/Near/app/src/main/java/com/alarmy/near/data/repository/AuthRepositoryImpl.kt @@ -1,6 +1,5 @@ package com.alarmy.near.data.repository -import com.alarmy.near.data.datasource.SocialLoginProcessor import com.alarmy.near.model.ProviderType import com.alarmy.near.network.auth.TokenManager import com.alarmy.near.network.request.SocialLoginRequest @@ -13,40 +12,15 @@ class AuthRepositoryImpl @Inject constructor( private val authService: AuthService, - private val socialLoginProcessor: SocialLoginProcessor, private val tokenManager: TokenManager, ) : AuthRepository { - override suspend fun performSocialLogin(providerType: ProviderType): Result = - try { - val result = socialLoginProcessor.processLogin(providerType) - - if (result.isSuccess) { - val accessToken = result.getOrThrow() - socialLogin(accessToken, providerType) - } else { - Result.failure( - createLoginException( - providerType = providerType, - errorMessage = result.exceptionOrNull()?.message, - defaultMessage = "로그인에 실패했습니다", - ), - ) - } - } catch (exception: Exception) { - Result.failure( - createLoginException( - providerType = providerType, - errorMessage = exception.message, - defaultMessage = "로그인 중 오류가 발생했습니다", - ), - ) - } - - override suspend fun socialLogin( + override suspend fun performSocialLogin( accessToken: String, providerType: ProviderType, ): Result = try { + validateAccessToken(accessToken) + val request = SocialLoginRequest( accessToken = accessToken, @@ -72,6 +46,15 @@ class AuthRepositoryImpl Result.failure(Exception(errorMessage)) } + private fun validateAccessToken(accessToken: String) { + require(accessToken.isNotBlank()) { + "액세스 토큰이 비어있습니다" + } + require(accessToken.length >= MIN_TOKEN_LENGTH) { + "액세스 토큰 형식이 올바르지 않습니다" + } + } + override suspend fun logout() { tokenManager.clearAllTokens() } @@ -94,15 +77,6 @@ class AuthRepositoryImpl override suspend fun refreshToken(): Boolean = tokenManager.refreshToken() - private fun createLoginException( - providerType: ProviderType, - errorMessage: String?, - defaultMessage: String, - ): Exception { - val finalMessage = errorMessage ?: "$providerType $defaultMessage" - return Exception(finalMessage) - } - // TODO 추후 에러 메시지 변경 private fun getHttpErrorMessage(httpCode: Int): String = when (httpCode) { @@ -112,4 +86,8 @@ class AuthRepositoryImpl 500 -> "서버에 문제가 발생했습니다" else -> "로그인 중 오류가 발생했습니다" } + + companion object { + private const val MIN_TOKEN_LENGTH = 10 + } } diff --git a/Near/app/src/main/java/com/alarmy/near/model/ProviderType.kt b/Near/app/src/main/java/com/alarmy/near/model/ProviderType.kt index 9d4f0fbe..0e7edca3 100644 --- a/Near/app/src/main/java/com/alarmy/near/model/ProviderType.kt +++ b/Near/app/src/main/java/com/alarmy/near/model/ProviderType.kt @@ -2,5 +2,4 @@ package com.alarmy.near.model enum class ProviderType { KAKAO, - ETC, } diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/LoginScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/LoginScreen.kt index 62601f5d..cb786685 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/LoginScreen.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/LoginScreen.kt @@ -18,11 +18,12 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.Lifecycle @@ -31,8 +32,10 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.repeatOnLifecycle import com.alarmy.near.R import com.alarmy.near.model.ProviderType +import com.alarmy.near.presentation.feature.login.auth.SocialLoginHandler import com.alarmy.near.presentation.feature.login.model.TermType import com.alarmy.near.presentation.ui.theme.NearTheme +import kotlinx.coroutines.launch @Composable internal fun LoginRoute( @@ -42,9 +45,13 @@ internal fun LoginRoute( viewModel: LoginViewModel = hiltViewModel(), ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() - val showPrivacyBottomSheet by viewModel.showPrivacyBottomSheet.collectAsStateWithLifecycle() + val requiresPrivacyConsent by viewModel.requiresPrivacyConsent.collectAsStateWithLifecycle() val termsAgreementState by viewModel.termsAgreementState.collectAsStateWithLifecycle() val lifecycleOwner = LocalLifecycleOwner.current + val context = LocalContext.current + val scope = rememberCoroutineScope() + + val socialLoginHandler = remember { SocialLoginHandler(context) } // 약관 제목을 미리 가져옴 val termsTitles = @@ -83,13 +90,23 @@ internal fun LoginRoute( LoginScreen( uiState = uiState, onLoginClick = { providerType -> - viewModel.performLogin(providerType) + scope.launch { + socialLoginHandler.performSocialLogin( + providerType = providerType, + onSuccess = { accessToken -> + viewModel.onSocialLoginSuccess(accessToken, providerType) + }, + onFailure = { exception -> + viewModel.onSocialLoginFailure(exception) + }, + ) + } }, ) // 개인정보 동의 바텀시트 PrivacyConsentBottomSheet( - isVisible = showPrivacyBottomSheet, + isVisible = requiresPrivacyConsent, termsAgreementState = termsAgreementState, onDismiss = { viewModel.dismissPrivacyBottomSheet() @@ -211,14 +228,3 @@ private object LoginScreenConstants { const val DESCRIPTION_SPACING = 12 const val BOTTOM_SPACING = 96 } - -@Preview(showBackground = true) -@Composable -fun LoginScreenPreview() { - NearTheme { - LoginScreen( - uiState = LoginUiState(), - onLoginClick = { }, - ) - } -} diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/LoginViewModel.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/LoginViewModel.kt index 6df56eca..00e6bb43 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/LoginViewModel.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/LoginViewModel.kt @@ -44,9 +44,9 @@ class LoginViewModel SharingStarted.WhileSubscribed(), TermsAgreementState(), ) - val showPrivacyBottomSheet: StateFlow = + val requiresPrivacyConsent: StateFlow = _loginState - .map { it.showPrivacyBottomSheet } + .map { it.requiresPrivacyConsent } .stateIn( viewModelScope, SharingStarted.WhileSubscribed(), @@ -56,18 +56,26 @@ class LoginViewModel /** * 소셜 로그인 수행 */ - fun performLogin(providerType: ProviderType) { + fun onSocialLoginSuccess( + accessToken: String, + providerType: ProviderType, + ) { + updateLoadingState(isLoading = false) + _loginState.value = + _loginState.value.copy( + socialLoginToken = accessToken, + providerType = providerType, + requiresPrivacyConsent = true, + ) + } + + /** + * 소셜 로그인 실패 시 에러 처리 + */ + fun onSocialLoginFailure(exception: Throwable) { viewModelScope.launch { - updateLoadingState(isLoading = true) - authRepository - .performSocialLogin(providerType) - .onSuccess { - updateLoadingState(isLoading = false) - _loginState.value = _loginState.value.copy(showPrivacyBottomSheet = true) - }.onFailure { exception -> - updateLoadingState(isLoading = false) - _event.send(LoginEvent.ShowError(exception)) - } + updateLoadingState(isLoading = false) + _event.send(LoginEvent.ShowError(exception)) } } @@ -76,8 +84,31 @@ class LoginViewModel */ fun onPrivacyConsentComplete() { viewModelScope.launch { - _loginState.value = _loginState.value.copy(showPrivacyBottomSheet = false) - _event.send(LoginEvent.NavigateToHome) + val currentState = _loginState.value + val token = currentState.socialLoginToken + val providerType = currentState.providerType + + if (token == null || providerType == null) { + _event.send(LoginEvent.ShowError(Exception("로그인 정보가 없습니다"))) + return@launch + } + + updateLoadingState(isLoading = true) + authRepository + .performSocialLogin(token, providerType) + .onSuccess { + updateLoadingState(isLoading = false) + _loginState.value = + _loginState.value.copy( + requiresPrivacyConsent = false, + socialLoginToken = null, + providerType = null, + ) + _event.send(LoginEvent.NavigateToHome) + }.onFailure { exception -> + updateLoadingState(isLoading = false) + _event.send(LoginEvent.ShowError(exception)) + } } } @@ -85,7 +116,12 @@ class LoginViewModel * 프라이버시 바텀시트 닫기 */ fun dismissPrivacyBottomSheet() { - _loginState.value = _loginState.value.copy(showPrivacyBottomSheet = false) + _loginState.value = + _loginState.value.copy( + requiresPrivacyConsent = false, + socialLoginToken = null, + providerType = null, + ) } /** @@ -151,10 +187,10 @@ class LoginViewModel */ fun restoreBottomSheetIfNeeded() { val currentState = _loginState.value - if (currentState.hasNavigatedToWebView && !currentState.showPrivacyBottomSheet) { + if (currentState.hasNavigatedToWebView && !currentState.requiresPrivacyConsent) { _loginState.value = currentState.copy( - showPrivacyBottomSheet = true, + requiresPrivacyConsent = true, hasNavigatedToWebView = false, ) } @@ -167,7 +203,9 @@ class LoginViewModel data class LoginState( val termsAgreementState: TermsAgreementState = TermsAgreementState(), val hasNavigatedToWebView: Boolean = false, - val showPrivacyBottomSheet: Boolean = false, + val requiresPrivacyConsent: Boolean = false, + val socialLoginToken: String? = null, + val providerType: ProviderType? = null, ) /** diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/auth/KakaoLoginManager.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/auth/KakaoLoginManager.kt new file mode 100644 index 00000000..e3042274 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/auth/KakaoLoginManager.kt @@ -0,0 +1,111 @@ +package com.alarmy.near.presentation.feature.login.auth + +import android.content.Context +import com.alarmy.near.R +import com.kakao.sdk.auth.model.OAuthToken +import com.kakao.sdk.common.model.ApiError +import com.kakao.sdk.common.model.AuthError +import com.kakao.sdk.common.model.ClientError +import com.kakao.sdk.common.model.KakaoSdkError +import com.kakao.sdk.user.UserApiClient +import kotlinx.coroutines.CancellableContinuation +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume + +/** + * 카카오 로그인을 담당하는 매니저 클래스 + */ +class KakaoLoginManager( + private val context: Context, +) : SocialLoginProvider { + /** + * 카카오 로그인 수행 + * KakaoTalk이 설치되어 있으면 KakaoTalk으로, 아니면 웹 계정으로 로그인합니다. + */ + override suspend fun performLogin( + onSuccess: (String) -> Unit, + onFailure: (Throwable) -> Unit, + ) { + unlinkKakao() + try { + val accessToken = + if (UserApiClient.instance.isKakaoTalkLoginAvailable(context)) { + loginWithKakaoTalk() + } else { + loginWithKakaoAccount() + } + onSuccess(accessToken) + } catch (error: AuthError) { + // OAuth 인증 과정 에러 + onFailure(Exception(context.getString(R.string.login_auth_error))) + } catch (error: ApiError) { + // API 호출 에러 + onFailure(Exception(context.getString(R.string.login_api_error))) + } catch (error: ClientError) { + // SDK 내부 에러 + onFailure(Exception(context.getString(R.string.login_client_error))) + } catch (error: KakaoSdkError) { + // 카카오 SDK 에러 + onFailure(Exception(context.getString(R.string.login_sdk_error))) + } catch (exception: Exception) { + onFailure(exception) + } + } + + // 카카오 로그아웃 (언링크) + private suspend fun unlinkKakao(): Unit = + suspendCancellableCoroutine { continuation -> + UserApiClient.instance.unlink { error -> + continuation.resume(Unit) + } + } + + // 카카오톡을 통한 로그인 + private suspend fun loginWithKakaoTalk(): String = + suspendCancellableCoroutine { continuation -> + UserApiClient.instance.loginWithKakaoTalk(context) { token, error -> + handleLoginResult( + token = token, + error = error, + continuation = continuation, + ) + } + } + + // 카카오 계정을 통한 로그인 + private suspend fun loginWithKakaoAccount(): String = + suspendCancellableCoroutine { continuation -> + UserApiClient.instance.loginWithKakaoAccount(context) { token, error -> + handleLoginResult( + token = token, + error = error, + continuation = continuation, + ) + } + } + + // 카카오 로그인 결과 처리 + private fun handleLoginResult( + token: OAuthToken?, + error: Throwable?, + continuation: CancellableContinuation, + ) { + when { + error != null -> { + continuation.resumeWith( + Result.failure( + Exception(context.getString(R.string.login_user_cancelled)), + ), + ) + } + token != null -> continuation.resume(token.accessToken) + else -> { + continuation.resumeWith( + Result.failure( + Exception(context.getString(R.string.login_user_cancelled)), + ), + ) + } + } + } +} diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/auth/SocialLoginHandler.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/auth/SocialLoginHandler.kt new file mode 100644 index 00000000..8827eaaf --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/auth/SocialLoginHandler.kt @@ -0,0 +1,34 @@ +package com.alarmy.near.presentation.feature.login.auth + +import android.content.Context +import com.alarmy.near.model.ProviderType + +/** + * 소셜 로그인을 통합 관리하는 핸들러 + */ +class SocialLoginHandler( + private val context: Context, +) { + // 소셜 로그인 제공자들을 Map으로 관리 + // 새로운 로그인 타입 추가 시 이 부분 수정 + private val loginProviders: Map by lazy { + mapOf( + ProviderType.KAKAO to KakaoLoginManager(context), + // TODO: ProviderType.APPLE to AppleLoginManager(context), + // TODO: ProviderType.GOOGLE to GoogleLoginManager(context), + ) + } + + // 소셜 로그인 수행 + suspend fun performSocialLogin( + providerType: ProviderType, + onSuccess: (String) -> Unit, + onFailure: (Throwable) -> Unit, + ) { + val provider = + loginProviders[providerType] + ?: throw IllegalArgumentException("지원하지 않는 로그인 제공자: $providerType") + + provider.performLogin(onSuccess, onFailure) + } +} diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/auth/SocialLoginProvider.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/auth/SocialLoginProvider.kt new file mode 100644 index 00000000..40e869e4 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/auth/SocialLoginProvider.kt @@ -0,0 +1,9 @@ +package com.alarmy.near.presentation.feature.login.auth + +// 소셜 로그인 제공자가 구현해야 하는 인터페이스 +interface SocialLoginProvider { + suspend fun performLogin( + onSuccess: (String) -> Unit, + onFailure: (Throwable) -> Unit, + ) +} diff --git a/Near/app/src/main/res/values/strings.xml b/Near/app/src/main/res/values/strings.xml index 6d2a2354..b926446c 100644 --- a/Near/app/src/main/res/values/strings.xml +++ b/Near/app/src/main/res/values/strings.xml @@ -25,6 +25,11 @@ Near 타이틀 소중한 사람들과 더 가까워지는 시간 카카오 로그인 버튼 + 사용자가 로그인을 취소했습니다 + 인증에 실패했습니다. 다시 시도해주세요. + 카카오 서버와의 통신에 실패했습니다. + 로그인 처리 중 문제가 발생했습니다. + 카카오 로그인에 실패했습니다. MY