From ad9198d430431230c2d88ca7b467b75e7db86c81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Sun, 27 Sep 2026 23:08:48 +0900 Subject: [PATCH 1/3] =?UTF-8?q?refactor(core):=20=EC=83=9D=EC=B2=B4=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20=ED=94=84=EB=A1=AC=ED=94=84=ED=8A=B8=20?= =?UTF-8?q?=EB=9E=98=ED=8D=BC=EB=A5=BC=20core:common=20=EC=9C=BC=EB=A1=9C?= =?UTF-8?q?=20=EC=98=AC=EB=A6=B0=EB=8B=A4=20(#2195)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit afternote 의 authenticateBiometric·BiometricAuthResult·BiometricMessages 를 동작 그대로 core:common biometric 패키지로 옮기고, 설정 패스키의 private authenticate 사본을 지워 core 래퍼를 부르게 했다. 설정 쪽 하드코딩 문구는 값 그대로 setting_biometric_* 리소스로 옮기고, 나머지 문구는 afternote 와 같은 문장으로 두었다. 설정 패스키는 이로써 canAuthenticate 사전 분기와 초기화 예외 처리를 얻어, 지원하지 않는 조합에서 크래시 대신 오류로 끝난다. --- .../core/common/biometric}/BiometricAuth.kt | 20 ++-- .../afternote/presentation/build.gradle.kts | 1 - .../fingerprint/FingerprintLoginRoute.kt | 3 + .../passkey/PassKeyMakingScreen.kt | 108 ++++-------------- .../src/main/res/values/strings.xml | 10 ++ 5 files changed, 43 insertions(+), 99 deletions(-) rename {feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/shared/fingerprint => core/common/src/main/kotlin/com/afternote/core/common/biometric}/BiometricAuth.kt (93%) diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/shared/fingerprint/BiometricAuth.kt b/core/common/src/main/kotlin/com/afternote/core/common/biometric/BiometricAuth.kt similarity index 93% rename from feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/shared/fingerprint/BiometricAuth.kt rename to core/common/src/main/kotlin/com/afternote/core/common/biometric/BiometricAuth.kt index 61d89e62eb..535c0cfe86 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/shared/fingerprint/BiometricAuth.kt +++ b/core/common/src/main/kotlin/com/afternote/core/common/biometric/BiometricAuth.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.afternote.presentation.shared.fingerprint +package com.afternote.core.common.biometric import android.util.Log import androidx.biometric.BiometricManager @@ -7,21 +7,19 @@ import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL import androidx.biometric.BiometricPrompt import androidx.core.content.ContextCompat import androidx.fragment.app.FragmentActivity -import com.afternote.core.common.biometric.confirmWithCryptoOperation -import com.afternote.core.common.biometric.createBiometricCryptoObject -import com.afternote.core.common.biometric.isBiometricCryptoSupported import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext import kotlin.coroutines.resume -internal const val LOG_TAG = "FingerprintLogin" +private const val LOG_TAG = "BiometricAuth" /** * 생체 인식 프롬프트에 노출할 상황별 문구. * `stringResource`로 해석된 값이 필요하므로 Composable에서 빌드해 주입한다. + * 문구는 소비 feature 마다 다르므로 core 는 값을 정하지 않고 호출 측이 넘긴다. */ -data class BiometricMessages( +public data class BiometricMessages( val initFailed: String, val noHardware: String, val noneEnrolled: String, @@ -36,12 +34,12 @@ data class BiometricMessages( * UI 레이어에서 단방향으로 흘려 성공·취소·오류를 명시적으로 분기한다. * 취소는 오류로 노출하지 않도록 별도 타입으로 정의한다. */ -sealed interface BiometricAuthResult { - data object Success : BiometricAuthResult +public sealed interface BiometricAuthResult { + public data object Success : BiometricAuthResult - data object Canceled : BiometricAuthResult + public data object Canceled : BiometricAuthResult - data class Error( + public data class Error( val message: String, ) : BiometricAuthResult } @@ -60,7 +58,7 @@ sealed interface BiometricAuthResult { * - 성공은 콜백의 boolean 이 아니라 [confirmWithCryptoOperation] 의 암호 연산 성사로 확정한다. * 프롬프트를 거치지 않고 성공 콜백만 가로챈 경우 사용자 인증에 묶인 키를 쓸 수 없어 연산이 실패한다. */ -suspend fun FragmentActivity.authenticateBiometric( +public suspend fun FragmentActivity.authenticateBiometric( title: String, subtitle: String, messages: BiometricMessages, diff --git a/feature/afternote/presentation/build.gradle.kts b/feature/afternote/presentation/build.gradle.kts index 51fc3ec066..81f105058e 100644 --- a/feature/afternote/presentation/build.gradle.kts +++ b/feature/afternote/presentation/build.gradle.kts @@ -36,7 +36,6 @@ dependencies { implementation(libs.hilt.navigation) implementation(libs.androidx.lifecycle.viewmodel.ktx) implementation(libs.androidx.lifecycle.runtime.compose) - implementation(libs.androidx.biometric) implementation(libs.androidx.fragment.ktx) implementation(libs.androidx.paging.runtime) implementation(libs.androidx.paging.compose) diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/shared/fingerprint/FingerprintLoginRoute.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/shared/fingerprint/FingerprintLoginRoute.kt index 5cc3dcfcf2..e617f5b2cd 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/shared/fingerprint/FingerprintLoginRoute.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/shared/fingerprint/FingerprintLoginRoute.kt @@ -10,6 +10,9 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.fragment.app.FragmentActivity +import com.afternote.core.common.biometric.BiometricAuthResult +import com.afternote.core.common.biometric.BiometricMessages +import com.afternote.core.common.biometric.authenticateBiometric import com.afternote.core.ui.findActivity import com.afternote.feature.afternote.presentation.R import kotlinx.coroutines.launch diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/passkey/PassKeyMakingScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/passkey/PassKeyMakingScreen.kt index 89aea0918a..21235df1f8 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/passkey/PassKeyMakingScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/passkey/PassKeyMakingScreen.kt @@ -1,8 +1,6 @@ package com.afternote.feature.setting.presentation.passkey -import android.util.Log import androidx.biometric.BiometricManager -import androidx.biometric.BiometricPrompt import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -11,30 +9,17 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.core.content.ContextCompat +import androidx.compose.ui.res.stringResource import androidx.fragment.app.FragmentActivity import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import com.afternote.core.common.biometric.confirmWithCryptoOperation -import com.afternote.core.common.biometric.createBiometricCryptoObject -import com.afternote.core.common.biometric.isBiometricCryptoSupported +import com.afternote.core.common.biometric.BiometricAuthResult +import com.afternote.core.common.biometric.BiometricMessages +import com.afternote.core.common.biometric.authenticateBiometric import com.afternote.core.ui.findActivity import com.afternote.core.ui.popup.Popup import com.afternote.core.ui.popup.PopupType +import com.afternote.feature.setting.presentation.R import kotlinx.coroutines.launch -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlin.coroutines.resume - -private const val LOG_TAG = "PassKeyMaking" - -private sealed interface BiometricResult { - data object Success : BiometricResult - - data object Canceled : BiometricResult - - data class Error( - val message: String, - ) : BiometricResult -} @Composable fun PassKeyMakingScreen( @@ -56,6 +41,18 @@ fun PassKeyMakingScreen( var isAuthenticating by remember { mutableStateOf(false) } var errorMessage by remember { mutableStateOf(null) } + val messages = + BiometricMessages( + initFailed = stringResource(R.string.setting_biometric_init_failed), + noHardware = stringResource(R.string.setting_biometric_no_hardware), + noneEnrolled = stringResource(R.string.setting_biometric_none_enrolled), + hwUnavailable = stringResource(R.string.setting_biometric_hw_unavailable), + notAvailable = stringResource(R.string.setting_biometric_not_available), + verificationFailed = stringResource(R.string.setting_biometric_verification_failed), + ) + val promptTitle = stringResource(R.string.setting_biometric_prompt_title) + val promptSubtitle = stringResource(R.string.setting_biometric_prompt_subtitle) + if (showCompletionDialog) { Popup( type = PopupType.Default, @@ -84,17 +81,17 @@ fun PassKeyMakingScreen( isAuthenticating = true coroutineScope.launch { try { - when (val result = authenticate(activity)) { - BiometricResult.Success -> { + when (val result = activity.authenticateBiometric(promptTitle, promptSubtitle, messages)) { + BiometricAuthResult.Success -> { viewModel.savePasskeyRegistered() showCompletionDialog = true } - BiometricResult.Canceled -> { + BiometricAuthResult.Canceled -> { Unit } - is BiometricResult.Error -> { + is BiometricAuthResult.Error -> { errorMessage = result.message } } @@ -109,66 +106,3 @@ fun PassKeyMakingScreen( modifier = modifier, ) } - -private suspend fun authenticate(activity: FragmentActivity): BiometricResult = - suspendCancellableCoroutine { continuation -> - val authenticators = - BiometricManager.Authenticators.BIOMETRIC_STRONG or - BiometricManager.Authenticators.DEVICE_CREDENTIAL - - val promptInfo = - BiometricPrompt.PromptInfo - .Builder() - .setTitle("패스키 등록") - .setSubtitle("패스키로 사용할 지문을 인식해 주세요.") - .setAllowedAuthenticators(authenticators) - .build() - - // API 26~29 에서는 DEVICE_CREDENTIAL 허용자와 CryptoObject 를 함께 쓸 수 없다. - // 이때는 관문 없이 인증하고, 성공 확정은 cipher 부재로 통과시킨다 (#1166). - val cryptoObject = if (isBiometricCryptoSupported) createBiometricCryptoObject() else null - - val biometricPrompt = - BiometricPrompt( - activity, - ContextCompat.getMainExecutor(activity), - object : BiometricPrompt.AuthenticationCallback() { - override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { - if (!continuation.isActive) return - // 성공은 콜백의 도달이 아니라 사용자 인증에 묶인 키로 암호 연산이 - // 성사되는가로 확정한다 — 콜백만 가로챈 경우 연산이 실패한다 (#1166). - val confirmation = confirmWithCryptoOperation(result.cryptoObject?.cipher) - if (confirmation.isSuccess) { - continuation.resume(BiometricResult.Success) - } else { - Log.w(LOG_TAG, "인증 후 암호 연산 실패", confirmation.exceptionOrNull()) - continuation.resume(BiometricResult.Error("지문 확인에 실패했어요. 다시 시도해 주세요.")) - } - } - - override fun onAuthenticationError( - errorCode: Int, - errString: CharSequence, - ) { - if (!continuation.isActive) return - if (errorCode == BiometricPrompt.ERROR_USER_CANCELED || - errorCode == BiometricPrompt.ERROR_NEGATIVE_BUTTON || - errorCode == BiometricPrompt.ERROR_CANCELED - ) { - continuation.resume(BiometricResult.Canceled) - } else { - continuation.resume(BiometricResult.Error(errString.toString())) - } - } - - override fun onAuthenticationFailed() {} - }, - ) - - continuation.invokeOnCancellation { biometricPrompt.cancelAuthentication() } - if (cryptoObject != null) { - biometricPrompt.authenticate(promptInfo, cryptoObject) - } else { - biometricPrompt.authenticate(promptInfo) - } - } diff --git a/feature/setting/presentation/src/main/res/values/strings.xml b/feature/setting/presentation/src/main/res/values/strings.xml index 1bcff3c581..f2d21309b5 100644 --- a/feature/setting/presentation/src/main/res/values/strings.xml +++ b/feature/setting/presentation/src/main/res/values/strings.xml @@ -130,6 +130,16 @@ 비밀번호로 인증하기 비밀번호를 입력해주세요 + + 패스키 등록 + 패스키로 사용할 지문을 인식해 주세요. + 지문 확인에 실패했어요. 다시 시도해 주세요. + 지문 인증을 사용할 수 없습니다. + 인증 화면을 불러오지 못했습니다. 앱을 다시 시작해 주세요. + 이 기기는 생체 인식을 지원하지 않습니다. + 등록된 지문이나 화면 잠금이 없습니다. 설정에서 등록해 주세요. + 생체 인식을 일시적으로 사용할 수 없습니다. 잠시 후 다시 시도해 주세요. + 패스키 관리 패스키를 만들어 비밀번호 대신 지문 및 얼굴 인식으로\n쉽고 안전하게 로그인할 수 있습니다.\n패스키는 본인 소유의 기기에서만 생성해주세요. From fce9828b41354dfa27095a7bbbf3b3c77b28010e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Thu, 1 Oct 2026 17:11:49 +0900 Subject: [PATCH 2/3] test(setting): cover API 29 biometric failure handling (#2195) --- feature/setting/presentation/build.gradle.kts | 1 + .../passkey/PassKeyMakingApi29Test.kt | 171 ++++++++++++++++++ 2 files changed, 172 insertions(+) create mode 100644 feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/passkey/PassKeyMakingApi29Test.kt diff --git a/feature/setting/presentation/build.gradle.kts b/feature/setting/presentation/build.gradle.kts index 4eb2c8004c..43ea249131 100644 --- a/feature/setting/presentation/build.gradle.kts +++ b/feature/setting/presentation/build.gradle.kts @@ -48,6 +48,7 @@ dependencies { testImplementation(libs.androidx.compose.ui.test.junit4) testImplementation(libs.androidx.test.core.ktx) testImplementation(libs.robolectric) + testImplementation(libs.androidx.datastore.preferences) debugImplementation(libs.androidx.compose.ui.test.manifest) screenshotTestImplementation(libs.screenshot.validation.api) diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/passkey/PassKeyMakingApi29Test.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/passkey/PassKeyMakingApi29Test.kt new file mode 100644 index 0000000000..fc34129104 --- /dev/null +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/passkey/PassKeyMakingApi29Test.kt @@ -0,0 +1,171 @@ +package com.afternote.feature.setting.presentation.passkey + +import android.app.KeyguardManager +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG +import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL +import androidx.biometric.BiometricPrompt +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.emptyPreferences +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.ViewModelStore +import com.afternote.core.common.biometric.BiometricAuthResult +import com.afternote.core.common.biometric.BiometricMessages +import com.afternote.core.common.biometric.authenticateBiometric +import com.afternote.core.datastore.UserProfileDataSource +import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.setting.presentation.R +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.android.controller.ActivityController +import org.robolectric.annotation.Config +import org.robolectric.shadow.api.Shadow +import org.robolectric.shadows.ShadowBiometricManager +import org.robolectric.shadows.ShadowBuild + +/** Android 10에서 #2202가 바꾼 크래시와 오류 팝업 경로를 실제 AndroidX 구현으로 확인한다. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [29]) +class PassKeyMakingApi29Test { + @get:Rule + val composeRule = createComposeRule() + + private val viewModelStore = ViewModelStore() + private var activityController: ActivityController? = null + + @After + fun tearDown() { + viewModelStore.clear() + activityController?.pause()?.stop()?.destroy() + } + + @Test + fun `기존 패스키 프롬프트 허용자 조합은 API29에서 예외를 던진다`() { + val failure = + assertThrows(IllegalArgumentException::class.java) { + BiometricPrompt.PromptInfo + .Builder() + .setTitle("패스키 인증") + .setSubtitle("본인 인증") + .setAllowedAuthenticators(BIOMETRIC_STRONG or DEVICE_CREDENTIAL) + .build() + } + + assertTrue(failure.message.orEmpty().contains("unsupported on API 29")) + } + + @Test + fun `공용 래퍼는 API29의 미지원 조합을 크래시 대신 오류로 반환한다`() { + val activity = secureBiometricActivity() + val messages = + BiometricMessages( + initFailed = "init failed", + noHardware = "no hardware", + noneEnrolled = "none enrolled", + hwUnavailable = "hardware unavailable", + notAvailable = "unsupported authenticators", + verificationFailed = "verification failed", + ) + + composeRule.runOnIdle { + val result = runBlocking { activity.authenticateBiometric("패스키 인증", "본인 인증", messages) } + + assertEquals(BiometricAuthResult.Error(messages.notAvailable), result) + } + } + + @Test + fun `활성화된 지문 인증 버튼은 미지원 오류를 표시하고 패스키를 등록하지 않는다`() { + val activity = secureBiometricActivity() + val preferences = RecordingPreferences() + val viewModel = PassKeyViewModel(UserProfileDataSource(preferences)) + viewModelStore.put("passkey", viewModel) + var backCalls = 0 + var passwordCalls = 0 + val unavailableMessage = activity.getString(R.string.setting_biometric_not_available) + + composeRule.setContent { + CompositionLocalProvider(LocalContext provides activity) { + AfternoteTheme { + PassKeyMakingScreen( + onBackClick = { backCalls++ }, + onPasswordAuthClick = { passwordCalls++ }, + viewModel = viewModel, + ) + } + } + } + + composeRule.onNodeWithText("지문 인증하기").assertIsEnabled().performClick() + + composeRule.onNodeWithText(unavailableMessage).assertIsDisplayed() + composeRule.onNodeWithText("패스키 생성이 완료되었습니다").assertDoesNotExist() + composeRule.runOnIdle { + assertEquals(0, preferences.updateCalls) + assertEquals(false, viewModel.isPasskeyRegistered.value) + assertEquals(0, backCalls) + assertEquals(0, passwordCalls) + } + composeRule.onNodeWithText("확인").performClick() + composeRule.onNodeWithText(unavailableMessage).assertDoesNotExist() + composeRule.onNodeWithText("지문 인증하기").assertIsEnabled().performClick() + composeRule.onNodeWithText(unavailableMessage).assertIsDisplayed() + composeRule.runOnIdle { assertEquals(0, preferences.updateCalls) } + } + + private fun secureBiometricActivity(): FragmentActivity { + val activity = + Robolectric + .buildActivity(FragmentActivity::class.java) + .setup() + .also { activityController = it } + .get() + shadowOf(activity.getSystemService(KeyguardManager::class.java)).apply { + setIsKeyguardSecure(true) + setIsDeviceSecure(true) + } + // AndroidX Biometric 1.1.0의 강한 생체 인증 보장 기기 목록에 있는 모델을 사용한다. + ShadowBuild.setModel("Pixel 4") + Shadow + .extract( + activity.getSystemService(android.hardware.biometrics.BiometricManager::class.java), + ).setCanAuthenticate(true) + + // 실제 AVD에서 버튼이 비활성이라 미도달했던 경로를 열린 상태로 검증한다. + assertEquals(BiometricManager.BIOMETRIC_SUCCESS, BiometricManager.from(activity).canAuthenticate(BIOMETRIC_STRONG)) + assertEquals( + BiometricManager.BIOMETRIC_ERROR_UNSUPPORTED, + BiometricManager.from(activity).canAuthenticate(BIOMETRIC_STRONG or DEVICE_CREDENTIAL), + ) + return activity + } + + private class RecordingPreferences : DataStore { + override val data = MutableStateFlow(emptyPreferences()) + var updateCalls = 0 + private set + + override suspend fun updateData(transform: suspend (t: Preferences) -> Preferences): Preferences { + updateCalls++ + return transform(data.value).also { data.value = it } + } + } +} From a50d04d4fdcd25ea6c069f143ddf76b8c0c9cffc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Thu, 1 Oct 2026 17:42:07 +0900 Subject: [PATCH 3/3] test(setting): construct API 29 regression through Hilt (#2195) --- feature/setting/presentation/build.gradle.kts | 3 +- .../passkey/PassKeyMakingApi29Test.kt | 94 +++++++++++++------ 2 files changed, 68 insertions(+), 29 deletions(-) diff --git a/feature/setting/presentation/build.gradle.kts b/feature/setting/presentation/build.gradle.kts index 43ea249131..e9e8411d0a 100644 --- a/feature/setting/presentation/build.gradle.kts +++ b/feature/setting/presentation/build.gradle.kts @@ -48,7 +48,8 @@ dependencies { testImplementation(libs.androidx.compose.ui.test.junit4) testImplementation(libs.androidx.test.core.ktx) testImplementation(libs.robolectric) - testImplementation(libs.androidx.datastore.preferences) + testImplementation(libs.hilt.android.testing) + kspTest(libs.hilt.compiler) debugImplementation(libs.androidx.compose.ui.test.manifest) screenshotTestImplementation(libs.screenshot.validation.api) diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/passkey/PassKeyMakingApi29Test.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/passkey/PassKeyMakingApi29Test.kt index fc34129104..f61c37fc07 100644 --- a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/passkey/PassKeyMakingApi29Test.kt +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/passkey/PassKeyMakingApi29Test.kt @@ -12,23 +12,32 @@ import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick -import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.core.emptyPreferences import androidx.fragment.app.FragmentActivity -import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.ViewModelProvider import com.afternote.core.common.biometric.BiometricAuthResult import com.afternote.core.common.biometric.BiometricMessages import com.afternote.core.common.biometric.authenticateBiometric -import com.afternote.core.datastore.UserProfileDataSource +import com.afternote.core.common.reporting.ErrorReporter +import com.afternote.core.domain.push.DevicePushTargetProvider +import com.afternote.core.domain.repository.UserProfileCacheRepository import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.setting.domain.SettingAccountRepository +import com.afternote.feature.setting.domain.SettingNotificationRepository +import com.afternote.feature.setting.domain.testing.FakeSettingAccountRepository +import com.afternote.feature.setting.domain.testing.FakeSettingNotificationRepository import com.afternote.feature.setting.presentation.R -import kotlinx.coroutines.flow.MutableStateFlow +import dagger.hilt.android.AndroidEntryPoint +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.HiltTestApplication +import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertThrows import org.junit.Assert.assertTrue +import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -40,20 +49,57 @@ import org.robolectric.annotation.Config import org.robolectric.shadow.api.Shadow import org.robolectric.shadows.ShadowBiometricManager import org.robolectric.shadows.ShadowBuild +import javax.inject.Inject /** Android 10에서 #2202가 바꾼 크래시와 오류 팝업 경로를 실제 AndroidX 구현으로 확인한다. */ @RunWith(RobolectricTestRunner::class) -@Config(sdk = [29]) +@HiltAndroidTest +@Config(sdk = [29], application = HiltTestApplication::class) class PassKeyMakingApi29Test { - @get:Rule + @get:Rule(order = 0) + val hiltRule = HiltAndroidRule(this) + + @get:Rule(order = 1) val composeRule = createComposeRule() - private val viewModelStore = ViewModelStore() - private var activityController: ActivityController? = null + @BindValue + val accountRepository: SettingAccountRepository = FakeSettingAccountRepository.strict() + + @BindValue + val notificationRepository: SettingNotificationRepository = FakeSettingNotificationRepository.strict() + + @BindValue + val pushTargetProvider: DevicePushTargetProvider = + object : DevicePushTargetProvider { + override suspend fun currentTargetId(): String? = error("생체 인증 오류 경로는 푸시 대상을 조회하지 않는다") + + override suspend fun existingTargetId(): String? = error("생체 인증 오류 경로는 푸시 대상을 조회하지 않는다") + } + + @BindValue + val errorReporter: ErrorReporter = + object : ErrorReporter { + override fun writeFailure( + throwable: Throwable, + attributes: Map, + ) { + error("생체 인증 오류 경로에서 예상하지 않은 오류 보고") + } + } + + @Inject + lateinit var profileCache: UserProfileCacheRepository + + private var activityController: ActivityController? = null + + @Before + fun setUp() { + hiltRule.inject() + runBlocking { profileCache.savePasskeyRegistered(false) } + } @After fun tearDown() { - viewModelStore.clear() activityController?.pause()?.stop()?.destroy() } @@ -95,9 +141,8 @@ class PassKeyMakingApi29Test { @Test fun `활성화된 지문 인증 버튼은 미지원 오류를 표시하고 패스키를 등록하지 않는다`() { val activity = secureBiometricActivity() - val preferences = RecordingPreferences() - val viewModel = PassKeyViewModel(UserProfileDataSource(preferences)) - viewModelStore.put("passkey", viewModel) + val viewModel = ViewModelProvider(activity)[PassKeyViewModel::class.java] + composeRule.waitUntil { viewModel.isPasskeyRegistered.value == false } var backCalls = 0 var passwordCalls = 0 val unavailableMessage = activity.getString(R.string.setting_biometric_not_available) @@ -119,22 +164,23 @@ class PassKeyMakingApi29Test { composeRule.onNodeWithText(unavailableMessage).assertIsDisplayed() composeRule.onNodeWithText("패스키 생성이 완료되었습니다").assertDoesNotExist() composeRule.runOnIdle { - assertEquals(0, preferences.updateCalls) assertEquals(false, viewModel.isPasskeyRegistered.value) assertEquals(0, backCalls) assertEquals(0, passwordCalls) } + assertEquals(false, runBlocking { profileCache.isPasskeyRegisteredFlow().first() }) composeRule.onNodeWithText("확인").performClick() composeRule.onNodeWithText(unavailableMessage).assertDoesNotExist() composeRule.onNodeWithText("지문 인증하기").assertIsEnabled().performClick() composeRule.onNodeWithText(unavailableMessage).assertIsDisplayed() - composeRule.runOnIdle { assertEquals(0, preferences.updateCalls) } + composeRule.runOnIdle { assertEquals(false, viewModel.isPasskeyRegistered.value) } + assertEquals(false, runBlocking { profileCache.isPasskeyRegisteredFlow().first() }) } private fun secureBiometricActivity(): FragmentActivity { val activity = Robolectric - .buildActivity(FragmentActivity::class.java) + .buildActivity(BiometricTestActivity::class.java) .setup() .also { activityController = it } .get() @@ -157,15 +203,7 @@ class PassKeyMakingApi29Test { ) return activity } - - private class RecordingPreferences : DataStore { - override val data = MutableStateFlow(emptyPreferences()) - var updateCalls = 0 - private set - - override suspend fun updateData(transform: suspend (t: Preferences) -> Preferences): Preferences { - updateCalls++ - return transform(data.value).also { data.value = it } - } - } } + +@AndroidEntryPoint +class BiometricTestActivity : FragmentActivity()