diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/AppAndReceiverCompletionAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/AppAndReceiverCompletionAndroidTest.kt index 3435509b55..50b24c9689 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/AppAndReceiverCompletionAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/AppAndReceiverCompletionAndroidTest.kt @@ -37,7 +37,7 @@ import com.afternote.afternote_fe.test.FailureArtifactRule import com.afternote.afternote_fe.test.FakeAuthRepository import com.afternote.afternote_fe.test.FakeErrorReporter import com.afternote.core.common.reporting.ErrorReporter -import com.afternote.core.domain.error.InvalidLoginCredentialsException +import com.afternote.core.domain.error.CoreAuthFailure import com.afternote.core.domain.repository.UserProfileRepository import com.afternote.core.domain.repository.auth.AuthRepository import com.afternote.core.model.MindRecordCategory @@ -142,7 +142,7 @@ class AppAndReceiverCompletionAndroidTest { @Test fun invalidCredentials_correctedPasswordThenRetry_entersHomeWithoutReportingUserError() { fakeAuth.emailLoginResults.addLast( - Result.failure(InvalidLoginCredentialsException(IllegalStateException("rejected"))), + Result.failure(CoreAuthFailure.InvalidLoginCredentials(IllegalStateException("rejected"))), ) fakeAuth.emailLoginResults.addLast( Result.success(Session.DefaultSession("access", "refresh")), diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/AppOnboardingCanaryTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/AppOnboardingCanaryTest.kt index fe413cb52e..09e40a94c9 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/AppOnboardingCanaryTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/AppOnboardingCanaryTest.kt @@ -17,7 +17,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import com.afternote.afternote_fe.test.FailureArtifactRule import com.afternote.afternote_fe.test.FakeAuthRepository import com.afternote.afternote_fe.test.FakeUserRepository -import com.afternote.core.domain.error.NetworkUnavailableException +import com.afternote.core.domain.error.CoreAuthFailure import com.afternote.core.domain.repository.UserRepository import com.afternote.core.domain.repository.auth.AuthRepository import com.afternote.core.model.Session @@ -83,7 +83,7 @@ class AppOnboardingCanaryTest { @Test fun emailLogin_networkFailureThenRetry_entersHomeOnce() { fakeAuth.emailLoginResults.addLast( - Result.failure(NetworkUnavailableException(IOException("offline"))), + Result.failure(CoreAuthFailure.NetworkUnavailable(IOException("offline"))), ) fakeAuth.emailLoginResults.addLast( Result.success(Session.DefaultSession("access", "refresh")), diff --git a/core/data/src/main/java/com/afternote/core/data/repoimpl/account/AccountFailureMapper.kt b/core/data/src/main/java/com/afternote/core/data/repoimpl/account/AccountFailureMapper.kt index 4f106f2cfb..352699fcea 100644 --- a/core/data/src/main/java/com/afternote/core/data/repoimpl/account/AccountFailureMapper.kt +++ b/core/data/src/main/java/com/afternote/core/data/repoimpl/account/AccountFailureMapper.kt @@ -1,24 +1,19 @@ package com.afternote.core.data.repoimpl.account -import com.afternote.core.domain.error.EmailAlreadyRegisteredException -import com.afternote.core.domain.error.EmailVerificationException -import com.afternote.core.domain.error.NetworkUnavailableException +import com.afternote.core.domain.error.CoreAuthFailure import com.afternote.core.network.model.ApiException import java.io.IOException -/** 인증번호 무효(불일치/만료/미존재 통합) — 아이디 찾기도 회원가입 인증과 같은 값을 쓴다. */ private const val CODE_INVALID_VERIFICATION = 1207 - -/** 이미 가입된 이메일 (`POST auth/email/send` 409, 2026-07-30 실기기 logcat). */ private const val CODE_EMAIL_ALREADY_REGISTERED = 1200 /** * 계정 API 실패를 도메인 예외로 옮긴다 — presentation 이 `core:network` 를 모른 채 타입만으로 * 분기하게 하는 것이 목적이다(#646). * - * 가르는 신호는 서버 봉투의 `code` 뿐이고 `message` 는 옮기지 않는다(BE#92 — 계약이 아니다). - * 표시 문구는 각 화면이 자기 리소스로 갖는다. 취소는 다시 보지 않는다 — 호출부가 전부 - * `runCatchingCancellable`(#661) 이라 `CancellationException` 이 [Result] 로 도달하지 않는다. + * 가르는 신호는 서버 봉투의 `code` 뿐이고 `message` 는 옮기지 않는다(BE#92). 표시 문구는 각 화면이 + * 자기 리소스로 갖는다. 취소는 다시 보지 않는다 — 호출부가 전부 `runCatchingCancellable`(#661) + * 이라 `CancellationException` 이 [Result] 로 도달하지 않는다. */ internal fun Result.mapAccountFailure(): Result = when (val exception = exceptionOrNull()) { @@ -27,11 +22,11 @@ internal fun Result.mapAccountFailure(): Result = is ApiException -> { when (exception.code) { CODE_INVALID_VERIFICATION -> { - Result.failure(EmailVerificationException(exception)) + Result.failure(CoreAuthFailure.EmailVerification(exception)) } CODE_EMAIL_ALREADY_REGISTERED -> { - Result.failure(EmailAlreadyRegisteredException(exception)) + Result.failure(CoreAuthFailure.EmailAlreadyRegistered(exception)) } else -> { @@ -41,7 +36,7 @@ internal fun Result.mapAccountFailure(): Result = } is IOException -> { - Result.failure(NetworkUnavailableException(exception)) + Result.failure(CoreAuthFailure.NetworkUnavailable(exception)) } else -> { diff --git a/core/data/src/main/java/com/afternote/core/data/repoimpl/account/AccountRepositoryImpl.kt b/core/data/src/main/java/com/afternote/core/data/repoimpl/account/AccountRepositoryImpl.kt index c7a4fe4c27..713577c533 100644 --- a/core/data/src/main/java/com/afternote/core/data/repoimpl/account/AccountRepositoryImpl.kt +++ b/core/data/src/main/java/com/afternote/core/data/repoimpl/account/AccountRepositoryImpl.kt @@ -21,8 +21,6 @@ internal class AccountRepositoryImpl constructor( private val accountApiService: AccountApiService, ) : AccountRepository { - // requireStatus() 는 형제 메서드와 맞춘 것이다 — 이것만 빠져 있어 HTTP 200 봉투 안의 - // 실패 status 가 성공으로 통과했고, 그러면 실패를 옮길 매퍼가 볼 실패 자체가 생기지 않는다. override suspend fun sendEmailCode(email: String): Result = runCatchingCancellable { accountApiService @@ -30,15 +28,6 @@ internal class AccountRepositoryImpl .requireStatus() }.mapAccountFailure() - /** - * 인증번호 무효(서버 code 1207)를 `EmailVerificationException` 으로 갈라내는 일은 - * `mapAccountFailure` 가 형제 메서드와 똑같이 처리한다 — 여기서 따로 catch 하던 것을 걷었다. - * 같은 판정을 두 곳에 두면 갈라지기 때문이다(실제로 blank 처리가 서로 어긋나 있었다). - * - * 1207 을 구분하는 이유: 인증번호 무효는 사용자가 재입력으로 고칠 수 있는 실패라 호출처가 - * 인라인 에러로 보여주고, 그 외(네트워크·서버 장애)는 입력과 무관해 스낵바로 보낸다. - * 둘 다 같은 HTTP 400 이라 상태코드로는 안 갈리므로 서버 봉투의 `code` 가 유일한 판별 신호다. - */ override suspend fun verifyEmail( email: String, certificateCode: String, diff --git a/core/data/src/main/java/com/afternote/core/data/repoimpl/auth/AuthRepositoryImpl.kt b/core/data/src/main/java/com/afternote/core/data/repoimpl/auth/AuthRepositoryImpl.kt index c083076f02..7b08bc044c 100644 --- a/core/data/src/main/java/com/afternote/core/data/repoimpl/auth/AuthRepositoryImpl.kt +++ b/core/data/src/main/java/com/afternote/core/data/repoimpl/auth/AuthRepositoryImpl.kt @@ -5,9 +5,7 @@ import com.afternote.core.data.mapper.auth.AuthMapper import com.afternote.core.datastore.LocalStoreRegistry import com.afternote.core.datastore.StoreScope import com.afternote.core.datastore.TokenDataSource -import com.afternote.core.domain.error.InvalidLoginCredentialsException -import com.afternote.core.domain.error.NetworkUnavailableException -import com.afternote.core.domain.error.SocialLoginRejectedException +import com.afternote.core.domain.error.CoreAuthFailure import com.afternote.core.domain.repository.auth.AuthRepository import com.afternote.core.model.Session import com.afternote.core.model.TokenBundle @@ -70,8 +68,6 @@ internal class AuthRepositoryImpl ) } - // TODO:레거시 레포에 있던 authApiService 관련이고 리팩토링해야 하는지 검사 필요 - override suspend fun defaultLogin( email: String, password: String, @@ -155,9 +151,8 @@ internal class AuthRepositoryImpl } } -// BE `ErrorCode.java` 대조 — `AuthService.login()`·`socialLogin()` 이 실제로 던지는 거절만 담았다. -// 대역 판정(4xx 통과)으로 바꾸지 말 것: [ApiException.code] 는 HTTP 상태가 아니라 본문의 -// 비즈니스 코드고, BE 에는 5xx 에 붙는 코드도 있다(1904). +// [ApiException.code] 는 HTTP 상태가 아니라 응답 본문의 비즈니스 코드다 — 대역 판정(4xx 통과)으로 +// 바꾸지 말 것. 5xx 에 붙는 코드도 있어 대역으로는 사용자 거절과 장애가 갈리지 않는다. private const val CODE_USER_NOT_FOUND = 1201 private const val CODE_PASSWORD_MISMATCH = 1202 private const val CODE_SOCIAL_LOGIN_FAILED = 1208 @@ -177,11 +172,11 @@ private fun Result.mapLoginFailure(): Result = is ApiException -> { when (exception.code) { CODE_USER_NOT_FOUND, CODE_PASSWORD_MISMATCH -> { - Result.failure(InvalidLoginCredentialsException(exception)) + Result.failure(CoreAuthFailure.InvalidLoginCredentials(exception)) } CODE_SOCIAL_LOGIN_FAILED, CODE_UNSUPPORTED_SOCIAL_LOGIN -> { - Result.failure(SocialLoginRejectedException(exception)) + Result.failure(CoreAuthFailure.SocialLoginRejected(exception)) } else -> { @@ -191,10 +186,10 @@ private fun Result.mapLoginFailure(): Result = } is IOException -> { - Result.failure(NetworkUnavailableException(exception)) + Result.failure(CoreAuthFailure.NetworkUnavailable(exception)) } else -> { - this // 성공·그 외 예외 모두 통과 + this } } diff --git a/core/data/src/test/java/com/afternote/core/data/repoimpl/account/AccountRepositoryImplTest.kt b/core/data/src/test/java/com/afternote/core/data/repoimpl/account/AccountRepositoryImplTest.kt index 96707bee59..7f5daca85e 100644 --- a/core/data/src/test/java/com/afternote/core/data/repoimpl/account/AccountRepositoryImplTest.kt +++ b/core/data/src/test/java/com/afternote/core/data/repoimpl/account/AccountRepositoryImplTest.kt @@ -1,6 +1,6 @@ package com.afternote.core.data.repoimpl.account -import com.afternote.core.domain.error.EmailVerificationException +import com.afternote.core.domain.error.CoreAuthFailure import com.afternote.core.network.dto.EmailFindDto import com.afternote.core.network.dto.EmailFindRequestDto import com.afternote.core.network.dto.FindSendCodeRequestDto @@ -20,7 +20,7 @@ import org.junit.Test /** * [AccountRepositoryImpl.verifyEmail] 의 예외 번역 계약 회귀 가드 (#472). * - * 계약 — 서버가 인증번호 무효(code 1207)로 거절하면 [EmailVerificationException] 으로 + * 계약 — 서버가 인증번호 무효(code 1207)로 거절하면 [CoreAuthFailure.EmailVerification] 으로 * 번역해 Presentation 이 타입으로 분기(인라인 표시)할 수 있게 하고, 그 외 실패 * (네트워크·서버 오류 등)는 원본 예외를 유지해 기존 스낵바 경로로 흐르게 한다. */ @@ -28,7 +28,7 @@ class AccountRepositoryImplTest { private fun repository(accountApiService: AccountApiService) = AccountRepositoryImpl(accountApiService) @Test - fun `verifyEmail - 인증번호 무효(1207)는 EmailVerificationException 으로 번역`() { + fun `verifyEmail - 인증번호 무효(1207)는 EmailVerification 으로 번역`() { val repository = repository( FakeAccountApiService( @@ -46,7 +46,7 @@ class AccountRepositoryImplTest { val result = runBlocking { repository.verifyEmail("user@example.com", "000000") } val error = result.exceptionOrNull() - assertTrue(error is EmailVerificationException) + assertTrue(error is CoreAuthFailure.EmailVerification) } @Test diff --git a/core/data/src/test/java/com/afternote/core/data/repoimpl/auth/AuthRepositoryImplTest.kt b/core/data/src/test/java/com/afternote/core/data/repoimpl/auth/AuthRepositoryImplTest.kt index 3c4536013b..a91440fc94 100644 --- a/core/data/src/test/java/com/afternote/core/data/repoimpl/auth/AuthRepositoryImplTest.kt +++ b/core/data/src/test/java/com/afternote/core/data/repoimpl/auth/AuthRepositoryImplTest.kt @@ -6,9 +6,7 @@ import androidx.datastore.preferences.core.emptyPreferences import com.afternote.core.datastore.LocalStoreRegistry import com.afternote.core.datastore.StoreScope import com.afternote.core.datastore.TokenDataSource -import com.afternote.core.domain.error.InvalidLoginCredentialsException -import com.afternote.core.domain.error.NetworkUnavailableException -import com.afternote.core.domain.error.SocialLoginRejectedException +import com.afternote.core.domain.error.CoreAuthFailure import com.afternote.core.network.dto.LoginDto import com.afternote.core.network.dto.LoginRequestDto import com.afternote.core.network.dto.LogoutRequestDto @@ -34,8 +32,8 @@ import kotlin.coroutines.cancellation.CancellationException /** * [AuthRepositoryImpl] 의 선제 reissue deadline 관리 계약 회귀 가드 (#408, PR #411 리뷰 반영). * 로그인 경로의 실패 매핑 계약(#628)도 함께 가드한다 — 전송 계층 IO 실패 → - * [NetworkUnavailableException], 자격 거절(1201·1202) → [InvalidLoginCredentialsException], - * 소셜 거절(1208·1209) → [SocialLoginRejectedException], 그 밖의 서버 실패는 치환하지 않아 + * [CoreAuthFailure.NetworkUnavailable], 자격 거절(1201·1202) → [CoreAuthFailure.InvalidLoginCredentials], + * 소셜 거절(1208·1209) → [CoreAuthFailure.SocialLoginRejected], 그 밖의 서버 실패는 치환하지 않아 * 소비처에서 일반 문구로 내려앉는다. 서버 `message` 는 판정에 쓰지 않는다(BE#92). * * 계약 — 발급(로그인) 응답의 `expiresIn` 은 기록하고, 생략(null)이면 이전 토큰 기준 stale @@ -183,7 +181,7 @@ class AuthRepositoryImplTest { } @Test - fun `defaultLogin - 전송 계층 IO 실패는 NetworkUnavailableException 으로 치환 (원인 보존)`() { + fun `defaultLogin - 전송 계층 IO 실패는 NetworkUnavailable 로 치환 (원인 보존)`() { val repository = repository( FakeAuthApiService( @@ -194,12 +192,12 @@ class AuthRepositoryImplTest { val result = runBlocking { repository.defaultLogin("user@example.com", "pw") } val exception = result.exceptionOrNull() - assertTrue(exception is NetworkUnavailableException) + assertTrue(exception is CoreAuthFailure.NetworkUnavailable) assertTrue(exception?.cause is UnknownHostException) } @Test - fun `defaultLogin - 자격 거절(1202)은 InvalidLoginCredentialsException 으로 치환 (원인 보존)`() { + fun `defaultLogin - 자격 거절(1202)은 InvalidLoginCredentials 로 치환 (원인 보존)`() { val repository = repository( FakeAuthApiService( @@ -210,7 +208,7 @@ class AuthRepositoryImplTest { val result = runBlocking { repository.defaultLogin("user@example.com", "pw") } val exception = result.exceptionOrNull() - assertTrue(exception is InvalidLoginCredentialsException) + assertTrue(exception is CoreAuthFailure.InvalidLoginCredentials) assertTrue(exception?.cause is ApiException) } @@ -241,11 +239,11 @@ class AuthRepositoryImplTest { val result = runBlocking { repository.defaultLogin("user@example.com", "pw") } - assertTrue(result.exceptionOrNull() is InvalidLoginCredentialsException) + assertTrue(result.exceptionOrNull() is CoreAuthFailure.InvalidLoginCredentials) } @Test - fun `socialLogin - 소셜 거절(1208)은 SocialLoginRejectedException 으로 치환`() { + fun `socialLogin - 소셜 거절(1208)은 SocialLoginRejected 로 치환`() { val repository = repository( FakeAuthApiService( @@ -262,7 +260,7 @@ class AuthRepositoryImplTest { val result = runBlocking { repository.kakaoLogin("oauth-token") } - assertTrue(result.exceptionOrNull() is SocialLoginRejectedException) + assertTrue(result.exceptionOrNull() is CoreAuthFailure.SocialLoginRejected) } @Test @@ -276,7 +274,7 @@ class AuthRepositoryImplTest { val result = runBlocking { repository.kakaoLogin("oauth-token") } - assertTrue(result.exceptionOrNull() is NetworkUnavailableException) + assertTrue(result.exceptionOrNull() is CoreAuthFailure.NetworkUnavailable) } @Test @@ -310,7 +308,7 @@ class AuthRepositoryImplTest { val result = runBlocking { repository.googleLogin("id-token") } - assertTrue(result.exceptionOrNull() is NetworkUnavailableException) + assertTrue(result.exceptionOrNull() is CoreAuthFailure.NetworkUnavailable) } } diff --git a/core/domain/src/main/java/com/afternote/core/domain/error/CoreAuthFailure.kt b/core/domain/src/main/java/com/afternote/core/domain/error/CoreAuthFailure.kt new file mode 100644 index 0000000000..6cce539bd1 --- /dev/null +++ b/core/domain/src/main/java/com/afternote/core/domain/error/CoreAuthFailure.kt @@ -0,0 +1,68 @@ +package com.afternote.core.domain.error + +/** + * 인증·계정 흐름(로그인·회원가입·이메일 인증·소셜 연동)의 실패 중 **사유가 확인된 것**의 공통 루트. + * + * data 계층이 `ApiException` 을 이 계열로 번역하고, presentation 은 이 루트로 좁힌 뒤 `when` 으로 + * 가른다 — 하위 타입이 늘면 컴파일러가 소비처를 잡아준다(`sealed`). 사유를 확인하지 못한 실패는 + * 번역하지 않고 원본 그대로 흘려보내므로, 소비처의 `else`(루트가 아닌 Throwable) 분기는 계속 필요하다. + * + * 표시 문구는 호출처 리소스가 갖는다(BE#92 — 서버 `message` 는 사용자 노출용이라는 규정이 없다). + * 서버 코드값은 cause 인 `ApiException` 이 갖는다. `message` 는 그 둘 어느 쪽도 아닌 **리포팅 콘솔용 + * 정적 진단 문구**이며, 파라미터를 non-null 로 좁혀 하위 타입이 빠뜨릴 수 없게 했다 — 기본 생성에 맡기면 + * `cause.toString()`(`java.net.UnknownHostException: Unable to resolve host ...` 같은 영문 기술 원문)이 + * message 가 된다. 이 값은 화면에 싣지 않는다(`e.message ?: 폴백` 금지) — 표시 문구는 사유별 매핑이 + * 갖고, 사유를 확인하지 못한 실패는 호출처가 넘긴 작업별 폴백 리소스로 내려앉힌다. + */ +sealed class CoreAuthFailure( + message: String, + cause: Throwable?, +) : Exception(message, cause) { + /** + * 이메일 로그인 자격 거절(서버 code 1201·1202). 계정 미존재와 비밀번호 불일치를 가르지 않는다 — + * 서버 문구도 시안(`3628:23437`)도 어느 쪽이 틀렸는지 노출하지 않는 단일 문구다. + */ + class InvalidLoginCredentials( + cause: Throwable, + ) : CoreAuthFailure("login credentials rejected", cause) + + /** + * 소셜 로그인 거절(서버 code 1208·1209). [InvalidLoginCredentials] 와 갈라 두는 이유 — + * 입력 필드와 무관한 실패라 화면이 필드 인라인이 아닌 별도 안내로 표시해야 한다. + */ + class SocialLoginRejected( + cause: Throwable, + ) : CoreAuthFailure("social login rejected", cause) + + /** 이미 가입된 이메일로 인증코드 발송을 요청했다는 사실(서버 code 1200). */ + class EmailAlreadyRegistered( + cause: Throwable, + ) : CoreAuthFailure("email already registered", cause) + + /** 이메일 인증번호가 무효(불일치·만료·미존재 — 서버는 code 1207 하나로 내려준다)라는 사실. */ + class EmailVerification( + cause: Throwable, + ) : CoreAuthFailure("email verification code invalid", cause) + + /** + * 서버 응답 없이 전송 계층에서 끝난 실패(DNS 해석 불가·타임아웃·연결 거부 등)의 도메인 표현. + * + * data 계층이 인증·계정 API 호출의 IO 예외를 이 타입으로 치환한다 — presentation 은 + * core:network 에 의존하지 않으므로, 이 타입 하나로 "네트워크 연결 안내" 분기를 할 수 있다. + * 원인 예외는 [cause] 로 보존한다(로그 진단용). + */ + class NetworkUnavailable( + cause: Throwable, + ) : CoreAuthFailure("network unavailable", cause) + + /** + * 사용자가 소셜 로그인·계정 연동 인증을 직접 취소했다는 사실. + * + * 코루틴의 [kotlinx.coroutines.CancellationException] 과 구분해 구조화된 동시성(Structured + * Concurrency)이 깨지지 않도록 별도 타입으로 둔다 — 취소를 그 타입으로 나르면 상위 스코프까지 + * 취소로 전파된다. 소비처는 이 타입을 리포팅·에러 표시에서 제외하고 조용히 흘려보낸다. + * + * 서버가 관여하지 않는 단말 측 이탈이라 [cause] 가 없다. + */ + class UserCancelledAuth : CoreAuthFailure("user cancelled social auth", null) +} diff --git a/core/domain/src/main/java/com/afternote/core/domain/error/EmailAlreadyRegisteredException.kt b/core/domain/src/main/java/com/afternote/core/domain/error/EmailAlreadyRegisteredException.kt deleted file mode 100644 index 5ab3fd6328..0000000000 --- a/core/domain/src/main/java/com/afternote/core/domain/error/EmailAlreadyRegisteredException.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.afternote.core.domain.error - -/** - * 이미 가입된 이메일로 인증코드 발송을 요청했다는 사실(서버 code 1200). - * - * 서버 `message` 를 담지 않는다(BE#92 — 계약이 아니다). 표시 문구는 호출처 리소스가 갖고, - * `message` 는 리포팅 콘솔용 정적 진단 문자열이며, 코드값은 cause 인 `ApiException` 이 갖는다. - */ -class EmailAlreadyRegisteredException( - cause: Throwable, -) : Exception("email already registered", cause) diff --git a/core/domain/src/main/java/com/afternote/core/domain/error/EmailVerificationException.kt b/core/domain/src/main/java/com/afternote/core/domain/error/EmailVerificationException.kt deleted file mode 100644 index df8341cbcb..0000000000 --- a/core/domain/src/main/java/com/afternote/core/domain/error/EmailVerificationException.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.afternote.core.domain.error - -/** - * 이메일 인증번호가 무효(불일치·만료·미존재 — 서버는 code 1207 하나로 내려준다)라는 사실. - * - * 서버 `message` 를 담지 않는다(BE#92 — 계약이 아니다). 표시 문구는 호출처 리소스가 갖고, - * `message` 는 리포팅 콘솔용 정적 진단 문자열이며, 코드값은 cause 인 `ApiException` 이 갖는다. - */ -class EmailVerificationException( - cause: Throwable, -) : Exception("email verification code invalid", cause) diff --git a/core/domain/src/main/java/com/afternote/core/domain/error/InvalidLoginCredentialsException.kt b/core/domain/src/main/java/com/afternote/core/domain/error/InvalidLoginCredentialsException.kt deleted file mode 100644 index 94dedcfaac..0000000000 --- a/core/domain/src/main/java/com/afternote/core/domain/error/InvalidLoginCredentialsException.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.afternote.core.domain.error - -/** - * 이메일 로그인 자격 거절(서버 code 1201·1202). 계정 미존재와 비밀번호 불일치를 가르지 않는다 — - * 서버 문구도 시안(`3628:23437`)도 어느 쪽이 틀렸는지 노출하지 않는 단일 문구다. - * `message` 정책은 [EmailVerificationException] 과 동일(표시 문구는 화면 리소스, BE#92). - */ -class InvalidLoginCredentialsException( - cause: Throwable, -) : Exception("login credentials rejected", cause) diff --git a/core/domain/src/main/java/com/afternote/core/domain/error/NetworkUnavailableException.kt b/core/domain/src/main/java/com/afternote/core/domain/error/NetworkUnavailableException.kt deleted file mode 100644 index 8c3c7d3483..0000000000 --- a/core/domain/src/main/java/com/afternote/core/domain/error/NetworkUnavailableException.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.afternote.core.domain.error - -/** - * 서버 응답 없이 전송 계층에서 끝난 실패(DNS 해석 불가·타임아웃·연결 거부 등)의 도메인 표현. - * - * data 계층이 로그인 등 API 호출의 IO 예외를 이 타입으로 치환한다 — presentation 은 - * core:network 에 의존하지 않으므로, 이 타입 하나로 "네트워크 연결 안내" 분기를 할 수 있다. - * 원인 예외는 [cause] 로 보존한다(로그 진단용). message 는 의도적으로 null — 기본 생성이면 - * `cause.toString()`(영문 기술 원문)이 message 가 되어, `e.message ?: 폴백` 패턴의 소비처로 - * 원문이 다시 샌다. null 이면 그런 소비처가 각자의 폴백 문구로 안전하게 내려앉는다. - */ -class NetworkUnavailableException( - cause: Throwable, -) : Exception(null, cause) diff --git a/core/domain/src/main/java/com/afternote/core/domain/error/SocialLoginRejectedException.kt b/core/domain/src/main/java/com/afternote/core/domain/error/SocialLoginRejectedException.kt deleted file mode 100644 index 60dfcfd827..0000000000 --- a/core/domain/src/main/java/com/afternote/core/domain/error/SocialLoginRejectedException.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.afternote.core.domain.error - -/** - * 소셜 로그인 거절(서버 code 1208·1209). [InvalidLoginCredentialsException] 과 갈라 두는 이유 — - * 입력 필드와 무관한 실패라 화면이 필드 인라인이 아닌 별도 안내로 표시해야 한다. - * `message` 정책은 [EmailVerificationException] 과 동일(표시 문구는 화면 리소스, BE#92). - */ -class SocialLoginRejectedException( - cause: Throwable, -) : Exception("social login rejected", cause) diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/reporting/AfternoteFailureReporting.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/reporting/AfternoteFailureReporting.kt index 30074dca2f..2fe449f416 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/reporting/AfternoteFailureReporting.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/reporting/AfternoteFailureReporting.kt @@ -130,7 +130,7 @@ fun ErrorReporter.recordAfternoteFailure( * 사유 code 로 좁히지 않은 이유: 서버의 code 체계가 사용자 오류와 장애를 아직 분리하지 않는다. * * 다른 흐름에는 쓰지 않는다. 회원가입 이메일 인증은 사용자 오류가 code 1207 하나로만 와서 호출부가 - * 타입(`EmailVerificationException`)만 보고 거른다 — 문구 유무를 따질 필요가 없다. + * 타입(`CoreAuthFailure.EmailVerification`)만 보고 거른다 — 문구 유무를 따질 필요가 없다. * * 화면 노출 게이트(`toErrorPayload`, DocumentUploadUiState.kt)는 이 술어를 쓰지 않는다 — 노출은 * 사유 code allowlist 로 더 좁게 가른다. 그쪽을 넓히더라도 이 판정을 따라 넓히지 말 것. diff --git a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/OnboardingFailureMessage.kt b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/OnboardingFailureMessage.kt index 1288fb36ec..0ce1229c18 100644 --- a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/OnboardingFailureMessage.kt +++ b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/OnboardingFailureMessage.kt @@ -1,9 +1,7 @@ package com.afternote.feature.onboarding.presentation import androidx.annotation.StringRes -import com.afternote.core.domain.error.EmailAlreadyRegisteredException -import com.afternote.core.domain.error.NetworkUnavailableException -import com.afternote.core.domain.error.SocialLoginRejectedException +import com.afternote.core.domain.error.CoreAuthFailure import com.afternote.core.ui.UiText /** @@ -15,21 +13,40 @@ import com.afternote.core.ui.UiText */ internal fun Throwable.toDisplayMessage( @StringRes fallbackResId: Int, -): UiText = +): UiText = UiText.Resource(displayMessageResOrFallback(fallbackResId)) + +/** + * [toDisplayMessage] 와 같은 매핑을 리소스 ID 로 준다. + * + * `UiText.asString()` 은 `@Composable` 이라 코루틴 콜백 안에서는 부를 수 없다. 그런 자리(소셜 로그인 + * 결과 처리 등)는 이걸로 ID 를 받아 `LocalResources.current` 의 `getString` 으로 푼다 — 매핑을 복제하지 + * 않기 위한 것이다. `LocalContext.current` 로 조회하지 않는다(lint LocalContextGetResourceValueCall). + */ +@StringRes +internal fun Throwable.displayMessageResOrFallback( + @StringRes fallbackResId: Int, +): Int = (this as? CoreAuthFailure)?.displayMessageResOrNull() ?: fallbackResId + +/** + * 사유에 대응하는 전용 문구. 루트로 좁혀 `when` 을 exhaustive 하게 만든다 — 사유가 늘면 여기가 + * 컴파일 에러로 잡힌다. `else` 로 뭉개 두면 새 사유가 조용히 폴백 문구로 흘러 화면이 틀린 안내를 낸다. + * + * null 은 "전용 문구 없음" 이고, 폴백은 호출처가 [toDisplayMessage] 에 넘긴 작업별 리소스가 맡는다. + */ +@StringRes +private fun CoreAuthFailure.displayMessageResOrNull(): Int? = when (this) { - is NetworkUnavailableException -> { - UiText.Resource(R.string.onboarding_network_error) - } + is CoreAuthFailure.NetworkUnavailable -> R.string.onboarding_network_error - is EmailAlreadyRegisteredException -> { - UiText.Resource(R.string.signup_email_already_registered) - } + is CoreAuthFailure.EmailAlreadyRegistered -> R.string.signup_email_already_registered - is SocialLoginRejectedException -> { - UiText.Resource(R.string.login_social_rejected) - } + is CoreAuthFailure.SocialLoginRejected -> R.string.login_social_rejected - else -> { - UiText.Resource(fallbackResId) - } + // 셋 다 이 함수 밖에서 갈리므로 전용 문구를 갖지 않는다 — 자격 거절·인증번호 무효는 화면이 + // 입력 필드 인라인으로, 인증 취소는 소비처가 무시한다. `else` 가 아니라 열거해 두는 건 + // 사유가 새로 늘 때 컴파일러가 여기를 잡게 하려는 것이다. + is CoreAuthFailure.InvalidLoginCredentials, + is CoreAuthFailure.EmailVerification, + is CoreAuthFailure.UserCancelledAuth, + -> null } diff --git a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/findaccount/FindIdViewModel.kt b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/findaccount/FindIdViewModel.kt index b6887cae4d..cf977c2a66 100644 --- a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/findaccount/FindIdViewModel.kt +++ b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/findaccount/FindIdViewModel.kt @@ -3,7 +3,7 @@ package com.afternote.feature.onboarding.presentation.findaccount import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.afternote.core.common.reporting.ErrorReporter -import com.afternote.core.domain.error.EmailVerificationException +import com.afternote.core.domain.error.CoreAuthFailure import com.afternote.core.domain.repository.account.AccountRepository import com.afternote.feature.onboarding.presentation.R import com.afternote.feature.onboarding.presentation.reporting.AuthFailureStage @@ -73,7 +73,7 @@ class FindIdViewModel * 인증번호까지 함께 받아 계정을 돌려주므로, 확인 = 검증 + 조회를 한 번에 한다. * 결과는 [FindIdUiState.foundAccount] 에 담아두고 "다음" 에서 결과 화면이 소비한다. * - * 실패는 두 갈래로 나뉜다 — 인증번호 무효([EmailVerificationException], 서버 code 1207)만 + * 실패는 두 갈래로 나뉜다 — 인증번호 무효([CoreAuthFailure.EmailVerification], 서버 code 1207)만 * 필드 아래 인라인 문구이고, 그 밖의 실패는 사용자가 입력으로 고칠 수 없어 스낵바로 보낸다. * 갈래를 나누지 않으면 네트워크 실패에도 "인증번호가 일치하지 않습니다" 가 뜬다. */ @@ -93,7 +93,7 @@ class FindIdViewModel // 계측하지 않는다 — 인증번호 오타는 사용자의 정상적인 입력 실수다. // 자세한 사유는 AuthFailureStage.FIND_ACCOUNT_CODE_SEND KDoc. _uiState.update { - if (error is EmailVerificationException) { + if (error is CoreAuthFailure.EmailVerification) { // 스낵바 신호를 함께 내린다 — 이번 실패는 인라인으로 알리므로, // 아직 소비되지 않은 이전 실패 문구가 인라인과 겹쳐 뜨지 않게 한다. it.copy(hasVerificationError = true, errorMessage = null) diff --git a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/login/LoginEntry.kt b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/login/LoginEntry.kt index 6eacf28d18..5bb8b9b072 100644 --- a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/login/LoginEntry.kt +++ b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/login/LoginEntry.kt @@ -11,16 +11,18 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource import androidx.credentials.CredentialManager import androidx.credentials.exceptions.NoCredentialException import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.afternote.core.domain.error.CoreAuthFailure import com.afternote.core.ui.asString import com.afternote.core.ui.findActivity import com.afternote.feature.onboarding.presentation.BuildConfig import com.afternote.feature.onboarding.presentation.R -import com.afternote.feature.onboarding.presentation.login.social.UserCancelledAuthException +import com.afternote.feature.onboarding.presentation.displayMessageResOrFallback import com.afternote.feature.onboarding.presentation.login.social.requestGoogleIdToken import com.afternote.feature.onboarding.presentation.login.social.requestKakaoAccessToken import com.afternote.feature.onboarding.presentation.reporting.AuthProvider @@ -51,8 +53,11 @@ fun LoginEntry( val context = LocalContext.current val credentialManager = remember(context) { CredentialManager.create(context) } - val kakaoFailedMessage = stringResource(R.string.login_kakao_failed) - val googleFailedMessage = stringResource(R.string.login_google_failed) + // 코루틴 콜백 안에서 여는 문구는 `LocalResources` 로 조회한다 — `LocalContext.current` 경유 + // `getString` 은 로케일·구성이 바뀌어도 옛 문자열을 물고, lint 가 막는다 + // (LocalContextGetResourceValueCall). 여기 `context` 는 Activity·Credential Manager 용이다. + val resources = LocalResources.current + val googleNoCredentialsMessage = stringResource(R.string.login_google_no_credentials) val screenUnavailableMessage = stringResource(R.string.login_screen_unavailable) @@ -109,9 +114,13 @@ fun LoginEntry( }.onFailure { exception -> // 카카오 동의 화면·계정 로그인 창을 사용자가 닫은 경우(ClientErrorCause.Cancelled). // 장애가 아니라 정상적인 이탈이므로 리포팅하지 않는다. - if (exception is UserCancelledAuthException) return@onFailure + if (exception is CoreAuthFailure.UserCancelledAuth) return@onFailure viewModel.onSocialTokenRequestFailed(AuthProvider.KAKAO, exception) - showErrorSnackbar(exception.message ?: kakaoFailedMessage) + showErrorSnackbar( + resources.getString( + exception.displayMessageResOrFallback(R.string.login_kakao_failed), + ), + ) } } } else { @@ -133,13 +142,21 @@ fun LoginEntry( when (exception) { // 계정 선택 시트를 사용자가 닫은 경우(GetCredentialCancellationException, // 내부 타입 TYPE_USER_CANCELED). 정상적인 이탈이라 리포팅하지 않는다. - is UserCancelledAuthException -> return@onFailure + is CoreAuthFailure.UserCancelledAuth -> { + return@onFailure + } // 반면 이건 취소가 아니라 "쓸 계정이 없어 로그인 불가"라 리포팅 대상이다 — // 배포본 소셜 로그인 불능을 잡아내려면 이쪽이 신호여야 한다. - is NoCredentialException -> googleNoCredentialsMessage + is NoCredentialException -> { + googleNoCredentialsMessage + } - else -> exception.message ?: googleFailedMessage + else -> { + resources.getString( + exception.displayMessageResOrFallback(R.string.login_google_failed), + ) + } } viewModel.onSocialTokenRequestFailed(AuthProvider.GOOGLE, exception) showErrorSnackbar(message) diff --git a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/login/LoginViewModel.kt b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/login/LoginViewModel.kt index 7515c4066d..e918a57d2c 100644 --- a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/login/LoginViewModel.kt +++ b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/login/LoginViewModel.kt @@ -3,8 +3,7 @@ package com.afternote.feature.onboarding.presentation.login import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.afternote.core.common.reporting.ErrorReporter -import com.afternote.core.domain.error.InvalidLoginCredentialsException -import com.afternote.core.domain.error.NetworkUnavailableException +import com.afternote.core.domain.error.CoreAuthFailure import com.afternote.core.domain.usecase.auth.LoginType import com.afternote.core.domain.usecase.auth.LoginUseCase import com.afternote.feature.onboarding.presentation.R @@ -124,7 +123,7 @@ class LoginViewModel if (exception is CancellationException) throw exception // 자격 거절은 계측하지 않는다 — 비밀번호 오타는 사용자의 정상적인 입력 실수다 // (아이디 찾기 인증번호 오타와 같은 판단, FindIdViewModel.verifyCode). - if (exception !is InvalidLoginCredentialsException) { + if (exception !is CoreAuthFailure.InvalidLoginCredentials) { errorReporter.recordAuthFailure( stage = AuthFailureStage.LOGIN, throwable = exception, @@ -132,16 +131,24 @@ class LoginViewModel ) } _uiState.update { - when (exception) { - is InvalidLoginCredentialsException -> { + // 루트로 좁혀 `when` 을 exhaustive 하게 만든다 — 사유가 늘면 여기가 컴파일 에러로 + // 잡힌다. `else` 로 뭉개 두면 인라인·팝업으로 갈라야 할 새 사유가 조용히 문구 하나로 + // 흘러간다. 사유를 확인하지 못한 실패(null)는 계속 문구 매핑에 맡긴다. + when (exception as? CoreAuthFailure) { + is CoreAuthFailure.InvalidLoginCredentials -> { it.copy(isLoading = false, hasCredentialError = true) } - is NetworkUnavailableException -> { + is CoreAuthFailure.NetworkUnavailable -> { it.copy(isLoading = false, showNetworkErrorPopup = true) } - else -> { + is CoreAuthFailure.EmailAlreadyRegistered, + is CoreAuthFailure.EmailVerification, + is CoreAuthFailure.SocialLoginRejected, + is CoreAuthFailure.UserCancelledAuth, + null, + -> { it.copy(isLoading = false, errorMessage = exception.toDisplayMessage(R.string.login_failed)) } } diff --git a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/login/social/GoogleLoginHelper.kt b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/login/social/GoogleLoginHelper.kt index 9255c04a5c..cb9c37fded 100644 --- a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/login/social/GoogleLoginHelper.kt +++ b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/login/social/GoogleLoginHelper.kt @@ -7,6 +7,7 @@ import androidx.credentials.GetCredentialRequest import androidx.credentials.exceptions.GetCredentialCancellationException import androidx.credentials.exceptions.GetCredentialException import androidx.credentials.exceptions.NoCredentialException +import com.afternote.core.domain.error.CoreAuthFailure import com.google.android.libraries.identity.googleid.GetGoogleIdOption import com.google.android.libraries.identity.googleid.GetSignInWithGoogleOption import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential @@ -19,7 +20,7 @@ import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential * * @param serverClientId Google Cloud Console에서 발급받은 Web Client ID. * 노출 방지를 위해 로컬 설정(`local.properties`)이나 DI로 주입하는 것을 권장한다. - * @return 성공 시 Google ID Token, 사용자가 취소한 경우 [UserCancelledAuthException] 실패 Result. + * @return 성공 시 Google ID Token, 사용자가 취소한 경우 [CoreAuthFailure.UserCancelledAuth] 실패 Result. */ suspend fun requestGoogleIdToken( context: Context, @@ -81,7 +82,7 @@ private suspend fun requestWithOption( Result.failure(IllegalStateException("지원되지 않는 인증 형식입니다.")) } } catch (e: GetCredentialCancellationException) { - Result.failure(UserCancelledAuthException()) + Result.failure(CoreAuthFailure.UserCancelledAuth()) } catch (e: GetCredentialException) { Result.failure(e) } diff --git a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/login/social/KakaoLoginHelper.kt b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/login/social/KakaoLoginHelper.kt index ec86a9d204..dfa17c685a 100644 --- a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/login/social/KakaoLoginHelper.kt +++ b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/login/social/KakaoLoginHelper.kt @@ -1,6 +1,7 @@ package com.afternote.feature.onboarding.presentation.login.social import android.app.Activity +import com.afternote.core.domain.error.CoreAuthFailure import com.kakao.sdk.auth.model.OAuthToken import com.kakao.sdk.common.model.ClientError import com.kakao.sdk.common.model.ClientErrorCause @@ -29,7 +30,7 @@ suspend fun requestKakaoAccessToken(activity: Activity): Result = error != null -> { val failure = if (error is ClientError && error.reason == ClientErrorCause.Cancelled) { - UserCancelledAuthException() + CoreAuthFailure.UserCancelledAuth() } else { error } @@ -54,7 +55,7 @@ suspend fun requestKakaoAccessToken(activity: Activity): Result = // 사용자가 의도적으로 취소한 경우에는 폴백 없이 즉시 실패 처리 if (error is ClientError && error.reason == ClientErrorCause.Cancelled) { if (continuation.isActive) { - continuation.resume(Result.failure(UserCancelledAuthException())) + continuation.resume(Result.failure(CoreAuthFailure.UserCancelledAuth())) } return@loginWithKakaoTalk } diff --git a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/login/social/UserCancelledAuthException.kt b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/login/social/UserCancelledAuthException.kt deleted file mode 100644 index a8b0a4115e..0000000000 --- a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/login/social/UserCancelledAuthException.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.afternote.feature.onboarding.presentation.login.social - -/** - * 사용자가 소셜 로그인(카카오 등)을 직접 취소했을 때 발생하는 예외. - * - * 코루틴의 [java.util.concurrent.CancellationException]과 구분하여 - * 구조화된 동시성(Structured Concurrency)이 깨지지 않도록 별도 타입으로 정의한다. - */ -class UserCancelledAuthException( - message: String = "사용자가 로그인을 취소했습니다.", -) : Exception(message) diff --git a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/signup/SignUpViewModel.kt b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/signup/SignUpViewModel.kt index 8ab69238f3..8032974c4a 100644 --- a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/signup/SignUpViewModel.kt +++ b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/signup/SignUpViewModel.kt @@ -3,7 +3,7 @@ package com.afternote.feature.onboarding.presentation.signup import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.afternote.core.common.reporting.ErrorReporter -import com.afternote.core.domain.error.EmailVerificationException +import com.afternote.core.domain.error.CoreAuthFailure import com.afternote.core.domain.repository.account.AccountRepository import com.afternote.core.domain.usecase.auth.LoginType import com.afternote.core.domain.usecase.auth.LoginUseCase @@ -154,7 +154,7 @@ class SignUpViewModel /** * Step 1 "다음" 클릭 시점에 호출. * 이메일/인증번호를 서버에 검증해 성공 시 [SignUpUiState.shouldNavigateToResidentNumber] = true, - * 인증번호 무효([EmailVerificationException] — 불일치/만료/미존재)는 + * 인증번호 무효([CoreAuthFailure.EmailVerification] — 불일치/만료/미존재)는 * [SignUpUiState.hasVerificationError] (인라인 문구), 그 외 실패는 * [SignUpUiState.errorMessage] (스낵바) 로 set. 만료 판정은 서버가 한다. */ @@ -172,7 +172,7 @@ class SignUpViewModel }.onFailure { error -> // 취소는 장애가 아니다 — 기록·UI 소비 전에 되던져 전파를 보존한다(전수 정정은 #661). if (error is CancellationException) throw error - if (error is EmailVerificationException) { + if (error is CoreAuthFailure.EmailVerification) { // 표시 문구는 화면의 고정 리소스 — 이 값은 인라인 표시 트리거 + 디버깅용 원문. // 인증번호 불일치·만료는 정상적인 사용자 입력 오류라 리포팅하지 않는다. // 스낵바 신호를 함께 내린다 — 이번 실패는 인라인으로 알리므로, diff --git a/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/login/LoginViewModelReportingTest.kt b/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/login/LoginViewModelReportingTest.kt index 72c764a442..daacb31fc1 100644 --- a/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/login/LoginViewModelReportingTest.kt +++ b/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/login/LoginViewModelReportingTest.kt @@ -1,7 +1,7 @@ package com.afternote.feature.onboarding.presentation.login import com.afternote.core.common.reporting.ErrorReporter -import com.afternote.core.domain.error.InvalidLoginCredentialsException +import com.afternote.core.domain.error.CoreAuthFailure import com.afternote.core.domain.repository.auth.AuthRepository import com.afternote.core.domain.usecase.auth.LoginUseCase import com.afternote.core.model.Session @@ -153,7 +153,7 @@ class LoginViewModelReportingTest { fun `자격 거절은 기록하지 않는다 - 비밀번호 오타는 장애가 아니다`() = runTest(dispatcher) { val reporter = FakeErrorReporter() - val viewModel = viewModelWith(InvalidLoginCredentialsException(RuntimeException("401")), reporter) + val viewModel = viewModelWith(CoreAuthFailure.InvalidLoginCredentials(RuntimeException("401")), reporter) viewModel.loginWithEmail() advanceUntilIdle() diff --git a/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/login/LoginViewModelTest.kt b/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/login/LoginViewModelTest.kt index ae85f3e4eb..dd139d18c7 100644 --- a/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/login/LoginViewModelTest.kt +++ b/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/login/LoginViewModelTest.kt @@ -1,9 +1,7 @@ package com.afternote.feature.onboarding.presentation.login import com.afternote.core.common.reporting.ErrorReporter -import com.afternote.core.domain.error.InvalidLoginCredentialsException -import com.afternote.core.domain.error.NetworkUnavailableException -import com.afternote.core.domain.error.SocialLoginRejectedException +import com.afternote.core.domain.error.CoreAuthFailure import com.afternote.core.domain.repository.auth.AuthRepository import com.afternote.core.domain.usecase.auth.LoginUseCase import com.afternote.core.model.Session @@ -29,10 +27,10 @@ import java.net.UnknownHostException /** * [LoginViewModel] 실패 안내 계약 회귀 가드 (#628). * - * 계약 — 자격 거절([InvalidLoginCredentialsException])은 인라인 상태 + * 계약 — 자격 거절([CoreAuthFailure.InvalidLoginCredentials])은 인라인 상태 * ([LoginUiState.hasCredentialError], 입력 변경으로 해제), 전송 계층 실패 - * ([NetworkUnavailableException])는 재시도 팝업([LoginUiState.showNetworkErrorPopup]), - * 소셜 거절([SocialLoginRejectedException])과 그 밖의 예외는 **원문을 쓰지 않고** + * ([CoreAuthFailure.NetworkUnavailable])는 재시도 팝업([LoginUiState.showNetworkErrorPopup]), + * 소셜 거절([CoreAuthFailure.SocialLoginRejected])과 그 밖의 예외는 **원문을 쓰지 않고** * 리소스 문구 스낵바로 고정한다. 실패 시 [LoginUiState.isLoading] 을 해제한다. * * [LoginUseCase] 는 실물 사용 — Repository Result 가 VM 상태로 번역되는 경로 전체를 가드한다. @@ -73,7 +71,7 @@ class LoginViewModelTest { fun `전송 계층 실패 - 재시도 팝업 상태로 표시하고 isLoading 해제`() { val viewModel = viewModel(onDefaultLogin = { - Result.failure(NetworkUnavailableException(UnknownHostException("Unable to resolve host"))) + Result.failure(CoreAuthFailure.NetworkUnavailable(UnknownHostException("Unable to resolve host"))) }) viewModel.attemptEmailLogin() @@ -90,7 +88,7 @@ class LoginViewModelTest { val viewModel = viewModel(onDefaultLogin = { attempts++ - Result.failure(NetworkUnavailableException(UnknownHostException("Unable to resolve host"))) + Result.failure(CoreAuthFailure.NetworkUnavailable(UnknownHostException("Unable to resolve host"))) }) viewModel.attemptEmailLogin() @@ -105,7 +103,7 @@ class LoginViewModelTest { val viewModel = viewModel(onDefaultLogin = { attempts++ - Result.failure(NetworkUnavailableException(UnknownHostException("Unable to resolve host"))) + Result.failure(CoreAuthFailure.NetworkUnavailable(UnknownHostException("Unable to resolve host"))) }) viewModel.attemptEmailLogin() @@ -119,7 +117,7 @@ class LoginViewModelTest { fun `자격 거절 - 인라인 상태로 표시하고 스낵바는 비움`() { val viewModel = viewModel(onDefaultLogin = { - Result.failure(InvalidLoginCredentialsException(Exception("origin"))) + Result.failure(CoreAuthFailure.InvalidLoginCredentials(Exception("origin"))) }) viewModel.attemptEmailLogin() @@ -134,7 +132,7 @@ class LoginViewModelTest { fun `자격 거절 - 입력이 바뀌면 인라인 표시 해제`() { val viewModel = viewModel(onDefaultLogin = { - Result.failure(InvalidLoginCredentialsException(Exception("origin"))) + Result.failure(CoreAuthFailure.InvalidLoginCredentials(Exception("origin"))) }) viewModel.attemptEmailLogin() @@ -147,7 +145,7 @@ class LoginViewModelTest { fun `소셜 거절 - 서버 문구 대신 자체 리소스 문구로 표시`() { val viewModel = viewModel(onDefaultLogin = { - Result.failure(SocialLoginRejectedException(Exception("origin"))) + Result.failure(CoreAuthFailure.SocialLoginRejected(Exception("origin"))) }) viewModel.attemptEmailLogin() diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/ConnectedAccountsScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/ConnectedAccountsScreen.kt index 4dae18037f..2be78a8fd5 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/ConnectedAccountsScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/ConnectedAccountsScreen.kt @@ -23,6 +23,7 @@ import androidx.compose.ui.unit.dp import androidx.credentials.CredentialManager import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.afternote.core.domain.error.CoreAuthFailure import com.afternote.core.ui.findActivity import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.core.ui.topbar.DetailTopBar @@ -30,7 +31,6 @@ import com.afternote.feature.setting.presentation.BuildConfig import com.afternote.feature.setting.presentation.R import com.afternote.feature.setting.presentation.component.SocialAccountRow import com.afternote.feature.setting.presentation.social.KakaoAuthResult -import com.afternote.feature.setting.presentation.social.UserCancelledAuthException import com.afternote.feature.setting.presentation.social.requestGoogleIdToken import com.afternote.feature.setting.presentation.social.requestKakaoAccessToken import com.afternote.feature.setting.presentation.social.toKakaoAuthResult @@ -87,7 +87,7 @@ fun ConnectedAccountsScreen( serverClientId = BuildConfig.GOOGLE_WEB_CLIENT_ID, ).onSuccess { token -> viewModel.link("google", token) } .onFailure { e -> - if (e !is UserCancelledAuthException) { + if (e !is CoreAuthFailure.UserCancelledAuth) { viewModel.notifyLinkError( googleAccountLinkFailedMessage, ) diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/social/GoogleLinkHelper.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/social/GoogleLinkHelper.kt index 3e8d7e7046..915a42c411 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/social/GoogleLinkHelper.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/social/GoogleLinkHelper.kt @@ -7,6 +7,7 @@ import androidx.credentials.GetCredentialRequest import androidx.credentials.exceptions.GetCredentialCancellationException import androidx.credentials.exceptions.GetCredentialException import androidx.credentials.exceptions.NoCredentialException +import com.afternote.core.domain.error.CoreAuthFailure import com.google.android.libraries.identity.googleid.GetGoogleIdOption import com.google.android.libraries.identity.googleid.GetSignInWithGoogleOption import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential @@ -71,7 +72,7 @@ private suspend fun requestWithOption( Result.failure(IllegalStateException("지원되지 않는 인증 형식입니다.")) } } catch (e: GetCredentialCancellationException) { - Result.failure(UserCancelledAuthException()) + Result.failure(CoreAuthFailure.UserCancelledAuth()) } catch (e: GetCredentialException) { Result.failure(e) } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/social/KakaoAuthResult.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/social/KakaoAuthResult.kt index 2b25658ea9..0300e3ae5a 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/social/KakaoAuthResult.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/social/KakaoAuthResult.kt @@ -1,5 +1,7 @@ package com.afternote.feature.setting.presentation.social +import com.afternote.core.domain.error.CoreAuthFailure + internal sealed interface KakaoAuthResult { data class Success( val accessToken: String, @@ -14,7 +16,7 @@ internal fun Result.toKakaoAuthResult(): KakaoAuthResult { val accessToken = getOrNull() return when { !accessToken.isNullOrBlank() -> KakaoAuthResult.Success(accessToken) - exceptionOrNull() is UserCancelledAuthException -> KakaoAuthResult.Cancelled + exceptionOrNull() is CoreAuthFailure.UserCancelledAuth -> KakaoAuthResult.Cancelled else -> KakaoAuthResult.Failure } } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/social/KakaoLinkHelper.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/social/KakaoLinkHelper.kt index 8e32be2aed..b348f527f6 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/social/KakaoLinkHelper.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/social/KakaoLinkHelper.kt @@ -1,6 +1,7 @@ package com.afternote.feature.setting.presentation.social import android.app.Activity +import com.afternote.core.domain.error.CoreAuthFailure import com.kakao.sdk.auth.model.OAuthToken import com.kakao.sdk.common.model.ClientError import com.kakao.sdk.common.model.ClientErrorCause @@ -16,7 +17,7 @@ suspend fun requestKakaoAccessToken(activity: Activity): Result = error != null -> { val failure = if (error is ClientError && error.reason == ClientErrorCause.Cancelled) { - UserCancelledAuthException() + CoreAuthFailure.UserCancelledAuth() } else { error } @@ -39,7 +40,7 @@ suspend fun requestKakaoAccessToken(activity: Activity): Result = if (error != null) { if (error is ClientError && error.reason == ClientErrorCause.Cancelled) { if (continuation.isActive) { - continuation.resume(Result.failure(UserCancelledAuthException())) + continuation.resume(Result.failure(CoreAuthFailure.UserCancelledAuth())) } return@loginWithKakaoTalk } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/social/UserCancelledAuthException.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/social/UserCancelledAuthException.kt deleted file mode 100644 index 651a463394..0000000000 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/social/UserCancelledAuthException.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.afternote.feature.setting.presentation.social - -class UserCancelledAuthException( - message: String = "사용자가 인증을 취소했습니다.", -) : Exception(message) diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/social/KakaoAuthResultTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/social/KakaoAuthResultTest.kt index d3c6e8cada..0fb804e309 100644 --- a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/social/KakaoAuthResultTest.kt +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/social/KakaoAuthResultTest.kt @@ -1,5 +1,6 @@ package com.afternote.feature.setting.presentation.social +import com.afternote.core.domain.error.CoreAuthFailure import org.junit.Assert.assertEquals import org.junit.Test @@ -13,7 +14,7 @@ class KakaoAuthResultTest { @Test fun `user cancellation is cancelled`() { - val result = Result.failure(UserCancelledAuthException()).toKakaoAuthResult() + val result = Result.failure(CoreAuthFailure.UserCancelledAuth()).toKakaoAuthResult() assertEquals(KakaoAuthResult.Cancelled, result) }