diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 55e3b31a7..359276178 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -318,6 +318,7 @@ dependencies { androidTestImplementation(testFixtures(projects.feature.mindrecord.domain)) androidTestImplementation(projects.feature.receiver.domain) androidTestImplementation(testFixtures(projects.feature.receiver.domain)) + androidTestImplementation(testFixtures(projects.feature.setting.data)) androidTestImplementation(projects.feature.setting.data) androidTestImplementation(projects.feature.setting.domain) androidTestImplementation(testFixtures(projects.feature.setting.domain)) diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/PasskeyRegistrationAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/PasskeyRegistrationAndroidTest.kt new file mode 100644 index 000000000..71abe4117 --- /dev/null +++ b/app/src/androidTest/java/com/afternote/afternote_fe/PasskeyRegistrationAndroidTest.kt @@ -0,0 +1,132 @@ +package com.afternote.afternote_fe + +import android.os.Build +import androidx.activity.compose.setContent +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.afternote.afternote_fe.navigation.AppNavigation +import com.afternote.afternote_fe.navigation.rememberAfternoteAppState +import com.afternote.core.ui.Route +import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.setting.data.PasskeyTestScenario +import com.afternote.feature.setting.domain.Passkey +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import jakarta.inject.Inject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeFalse +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@HiltAndroidTest +@RunWith(AndroidJUnit4::class) +class PasskeyRegistrationAndroidTest { + @get:Rule(order = 0) + val hiltRule = HiltAndroidRule(this) + + @get:Rule(order = 1) + val composeRule = createAndroidComposeRule() + + @Inject + lateinit var scenario: PasskeyTestScenario + + @Before + fun setUp() { + hiltRule.inject() + composeRule.activityRule.scenario.onActivity { activity -> + activity.setContent { + AfternoteTheme { + AppNavigation(startDestination = Route.Setting(), appState = rememberAfternoteAppState()) + } + } + } + } + + @Test + fun passkeyList_queriesServerWithoutLocalFlagAndRefreshesOnReturn() { + scenario.passkeys = listOf(Passkey(7L, "Server passkey", "2026-09-06T10:00:00")) + openPasskeys() + waitForText("Server passkey") + composeRule.onNodeWithText("Server passkey").assertIsDisplayed() + + composeRule.runOnIdle { composeRule.activity.onBackPressedDispatcher.onBackPressed() } + scenario.passkeys = listOf(Passkey(8L, "New server passkey", "2026-09-06T11:00:00")) + openPasskeys() + waitForText("New server passkey") + composeRule.onNodeWithText("Server passkey").assertDoesNotExist() + assertTrue(scenario.listCalls.get() >= 2) + } + + @Test + fun passkeyList_failureShowsRetryAndNeverPretendsThereAreNoPasskeys() { + scenario.listFails = true + openPasskeys() + waitForText("패스키 목록을 불러올 수 없습니다.") + composeRule.onNodeWithText("패스키 등록").assertDoesNotExist() + + scenario.listFails = false + composeRule.onNodeWithText("다시 시도").performClick() + waitForText("패스키 등록") + composeRule.onNodeWithText("패스키 등록").assertIsDisplayed() + assertEquals(2, scenario.listCalls.get()) + } + + @Test + fun passkeyRegistration_withoutCredentialProviderShowsFailureAndDoesNotComplete() { + // CI의 AOSP lane에서 실제 Credential Manager 실패를 검증한다. 계정이 있는 Play 기기는 대상이 아니다. + val packageManager = composeRule.activity.packageManager + val hasPlayServices = + runCatching { packageManager.getPackageInfo("com.google.android.gms", 0) }.isSuccess + assumeFalse(hasPlayServices) + assumeFalse(Build.VERSION.SDK_INT < 28) + + openPasskeyPassword() + listOf("1", "2", "3", "4").forEach { composeRule.onNodeWithText(it).performClick() } + waitForText("패스키 등록에 실패했습니다. 다시 시도해 주세요.") + composeRule.onNodeWithText("패스키 등록에 실패했습니다. 다시 시도해 주세요.").assertIsDisplayed() + assertEquals(1, scenario.optionsCalls.get()) + assertTrue(scenario.registeredCredentials.isEmpty()) + composeRule.onNodeWithText("패스키 생성이 완료되었습니다").assertDoesNotExist() + } + + @Test + fun passkeyList_existingEntryRefreshesAfterRegistrationScreenReturns() { + scenario.passkeys = emptyList() + openPasskeys() + waitForText("패스키 등록") + composeRule.onNodeWithText("패스키 등록").performClick() + waitForText("비밀번호로 인증하기") + scenario.passkeys = listOf(Passkey(9L, "Registered on return", "2026-09-08T10:00:00")) + composeRule.runOnIdle { composeRule.activity.onBackPressedDispatcher.onBackPressed() } + waitForText("Registered on return") + assertEquals(2, scenario.listCalls.get()) + } + + private fun openPasskeys() { + waitForText("패스키 관리") + composeRule.onNodeWithText("패스키 관리").performScrollTo().performClick() + } + + private fun openPasskeyPassword() { + openPasskeys() + waitForText("패스키 등록") + composeRule.onNodeWithText("패스키 등록").performClick() + waitForText("비밀번호로 인증하기") + composeRule.onNodeWithText("비밀번호로 인증하기").performClick() + waitForText("비밀번호를 입력해 주세요.") + } + + private fun waitForText(text: String) { + composeRule.waitUntil(timeoutMillis = 15_000) { + composeRule.onAllNodes(hasText(text)).fetchSemanticsNodes().isNotEmpty() + } + } +} diff --git a/feature/setting/data/build.gradle.kts b/feature/setting/data/build.gradle.kts index 584c87c69..60748588e 100644 --- a/feature/setting/data/build.gradle.kts +++ b/feature/setting/data/build.gradle.kts @@ -5,6 +5,9 @@ plugins { android { namespace = "com.afternote.feature.setting.data" + testFixtures { + enable = true + } } dependencies { @@ -12,6 +15,10 @@ dependencies { // 탈퇴 실패 진단(ErrorReporter). core:domain·model·network 는 data 규약이 이미 붙인다. implementation(projects.core.common) + testFixturesImplementation(projects.feature.setting.domain) + testFixturesImplementation(libs.hilt.android.testing) + add("kspTestFixtures", libs.hilt.compiler) + testImplementation(testFixtures(projects.core.domain)) testImplementation(libs.coroutines.test) } diff --git a/feature/setting/data/src/main/kotlin/com/afternote/feature/setting/data/PasskeyApiService.kt b/feature/setting/data/src/main/kotlin/com/afternote/feature/setting/data/PasskeyApiService.kt new file mode 100644 index 000000000..7d7c2dcf0 --- /dev/null +++ b/feature/setting/data/src/main/kotlin/com/afternote/feature/setting/data/PasskeyApiService.kt @@ -0,0 +1,29 @@ +package com.afternote.feature.setting.data + +import com.afternote.core.network.model.BaseResponse +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.POST + +internal interface PasskeyApiService { + @GET("users/passkeys") + suspend fun getPasskeys(): BaseResponse> + + @POST("auth/passkey/register/options") + suspend fun getRegistrationOptions(): BaseResponse + + @POST("auth/passkey/register") + suspend fun registerPasskey( + @Body credential: JsonElement, + ): BaseResponse +} + +@Serializable +internal data class PasskeyDto( + @SerialName("id") val id: Long, + @SerialName("displayName") val displayName: String, + @SerialName("createdAt") val createdAt: String, +) diff --git a/feature/setting/data/src/main/kotlin/com/afternote/feature/setting/data/PasskeyModule.kt b/feature/setting/data/src/main/kotlin/com/afternote/feature/setting/data/PasskeyModule.kt new file mode 100644 index 000000000..423d2b258 --- /dev/null +++ b/feature/setting/data/src/main/kotlin/com/afternote/feature/setting/data/PasskeyModule.kt @@ -0,0 +1,16 @@ +package com.afternote.feature.setting.data + +import com.afternote.feature.setting.domain.PasskeyRepository +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import retrofit2.Retrofit +import retrofit2.create + +@Module +@InstallIn(SingletonComponent::class) +internal object PasskeyModule { + @Provides + fun providePasskeyRepository(retrofit: Retrofit): PasskeyRepository = PasskeyRepositoryImpl(retrofit.create()) +} diff --git a/feature/setting/data/src/main/kotlin/com/afternote/feature/setting/data/PasskeyRepositoryImpl.kt b/feature/setting/data/src/main/kotlin/com/afternote/feature/setting/data/PasskeyRepositoryImpl.kt new file mode 100644 index 000000000..8b571d737 --- /dev/null +++ b/feature/setting/data/src/main/kotlin/com/afternote/feature/setting/data/PasskeyRepositoryImpl.kt @@ -0,0 +1,19 @@ +package com.afternote.feature.setting.data + +import com.afternote.core.network.model.requireData +import com.afternote.feature.setting.domain.Passkey +import com.afternote.feature.setting.domain.PasskeyRepository +import kotlinx.serialization.json.Json + +internal class PasskeyRepositoryImpl( + private val apiService: PasskeyApiService, +) : PasskeyRepository { + override suspend fun getPasskeys(): List = apiService.getPasskeys().requireData().map { it.toDomain() } + + override suspend fun getRegistrationOptions(): String = apiService.getRegistrationOptions().requireData().toString() + + override suspend fun registerPasskey(credentialJson: String): Passkey = + apiService.registerPasskey(Json.parseToJsonElement(credentialJson)).requireData().toDomain() +} + +private fun PasskeyDto.toDomain(): Passkey = Passkey(id = id, displayName = displayName, createdAt = createdAt) diff --git a/feature/setting/data/src/test/kotlin/com/afternote/feature/setting/data/PasskeyRepositoryTest.kt b/feature/setting/data/src/test/kotlin/com/afternote/feature/setting/data/PasskeyRepositoryTest.kt new file mode 100644 index 000000000..4c7cd3442 --- /dev/null +++ b/feature/setting/data/src/test/kotlin/com/afternote/feature/setting/data/PasskeyRepositoryTest.kt @@ -0,0 +1,75 @@ +package com.afternote.feature.setting.data + +import com.afternote.core.network.model.BaseResponse +import com.afternote.feature.setting.domain.Passkey +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class PasskeyRepositoryTest { + @Test + fun registration_roundTripsCompleteServerOptionsAndPlatformCredential() = + runBlocking { + val options = Json.parseToJsonElement("""{"challenge":"challenge","rp":{"id":"example.test"},"extension":{"future":true}}""") + val credential = Json.parseToJsonElement("""{"id":"credential-id","response":{"attestationObject":"attestation"}}""") + var submitted: JsonElement? = null + val service = + object : PasskeyApiService { + override suspend fun getPasskeys(): BaseResponse> = error("Not used") + + override suspend fun getRegistrationOptions(): BaseResponse = + BaseResponse(status = 200, code = 200, data = options) + + override suspend fun registerPasskey(credential: JsonElement): BaseResponse { + submitted = credential + return BaseResponse(status = 200, code = 200, data = DTO) + } + } + val repository = PasskeyRepositoryImpl(service) + assertEquals(options, Json.parseToJsonElement(repository.getRegistrationOptions())) + assertEquals(Passkey(DTO.id, DTO.displayName, DTO.createdAt), repository.registerPasskey(credential.toString())) + assertEquals(credential, submitted) + } + + @Test + fun list_preservesEveryRegisteredPasskeyAndAnActualEmptyList() = + runBlocking { + val service = ListPasskeyService() + val repository = PasskeyRepositoryImpl(service) + assertTrue(repository.getPasskeys().isEmpty()) + service.response = BaseResponse(status = 200, code = 200, data = listOf(DTO, DTO.copy(id = 8L))) + assertEquals(listOf(7L, 8L), repository.getPasskeys().map { it.id }) + } + + @Test + fun list_failedOrMissingResponseDoesNotBecomeAnEmptyList() = + runBlocking { + val service = ListPasskeyService() + val repository = PasskeyRepositoryImpl(service) + service.response = BaseResponse(status = 500, code = 500, data = emptyList()) + assertTrue(runCatching { repository.getPasskeys() }.isFailure) + service.response = BaseResponse(status = 200, code = 200, data = null) + assertTrue(runCatching { repository.getPasskeys() }.isFailure) + } + + @Test(expected = SerializationException::class) + fun list_missingRequiredIdentityIsNotDefaulted() { + Json.decodeFromString("""{"displayName":"Passkey","createdAt":"2026-09-06T10:00:00"}""") + } +} + +private class ListPasskeyService : PasskeyApiService { + var response = BaseResponse(status = 200, code = 200, data = emptyList()) + + override suspend fun getPasskeys(): BaseResponse> = response + + override suspend fun getRegistrationOptions(): BaseResponse = error("Not used") + + override suspend fun registerPasskey(credential: JsonElement): BaseResponse = error("Not used") +} + +private val DTO = PasskeyDto(id = 7L, displayName = "Passkey", createdAt = "2026-09-06T10:00:00") diff --git a/feature/setting/data/src/testFixtures/kotlin/com/afternote/feature/setting/data/PasskeyTestModule.kt b/feature/setting/data/src/testFixtures/kotlin/com/afternote/feature/setting/data/PasskeyTestModule.kt new file mode 100644 index 000000000..bc91723a5 --- /dev/null +++ b/feature/setting/data/src/testFixtures/kotlin/com/afternote/feature/setting/data/PasskeyTestModule.kt @@ -0,0 +1,61 @@ +package com.afternote.feature.setting.data + +import com.afternote.feature.setting.domain.Passkey +import com.afternote.feature.setting.domain.PasskeyRepository +import dagger.Module +import dagger.Provides +import dagger.hilt.components.SingletonComponent +import dagger.hilt.testing.TestInstallIn +import jakarta.inject.Singleton +import java.io.IOException +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.atomic.AtomicInteger + +/** app 계측은 이 fixture를 통해 internal 프로덕션 모듈을 대체한다. */ +@Module +@TestInstallIn(components = [SingletonComponent::class], replaces = [PasskeyModule::class]) +public object PasskeyTestModule { + @Provides + @Singleton + public fun provideScenario(): PasskeyTestScenario = PasskeyTestScenario() + + @Provides + public fun providePasskeyRepository(scenario: PasskeyTestScenario): PasskeyRepository = scenario +} + +public class PasskeyTestScenario : PasskeyRepository { + @Volatile + public var passkeys: List = emptyList() + + @Volatile + public var listFails: Boolean = false + + public val listCalls: AtomicInteger = AtomicInteger() + public val optionsCalls: AtomicInteger = AtomicInteger() + public val registeredCredentials: CopyOnWriteArrayList = CopyOnWriteArrayList() + + override suspend fun getPasskeys(): List { + listCalls.incrementAndGet() + if (listFails) throw IOException("Controlled passkey list failure") + return passkeys + } + + override suspend fun getRegistrationOptions(): String { + optionsCalls.incrementAndGet() + return """ + { + "challenge": "YWZ0ZXJub3RlLXRlc3QtY2hhbGxlbmdl", + "rp": {"name": "Afternote test", "id": "example.test"}, + "user": {"id": "dGVzdA", "name": "test@example.test", "displayName": "Test"}, + "pubKeyCredParams": [{"type": "public-key", "alg": -7}], + "timeout": 1000, + "attestation": "none" + } + """.trimIndent() + } + + override suspend fun registerPasskey(credentialJson: String): Passkey { + registeredCredentials += credentialJson + error("Real credentials must not be registered by this fixture") + } +} diff --git a/feature/setting/domain/src/main/java/com/afternote/feature/setting/domain/PasskeyRepository.kt b/feature/setting/domain/src/main/java/com/afternote/feature/setting/domain/PasskeyRepository.kt new file mode 100644 index 000000000..8e96dd6ca --- /dev/null +++ b/feature/setting/domain/src/main/java/com/afternote/feature/setting/domain/PasskeyRepository.kt @@ -0,0 +1,18 @@ +package com.afternote.feature.setting.domain + +public data class Passkey( + val id: Long, + val displayName: String, + val createdAt: String, +) + +/** 설정의 패스키 목록·등록 계약. 서버가 자격 증명 등록 여부의 정본이다. */ +public interface PasskeyRepository { + public suspend fun getPasskeys(): List + + /** Credential Manager에 전달할 PublicKeyCredentialCreationOptions JSON. */ + public suspend fun getRegistrationOptions(): String + + /** Credential Manager가 생성한 PublicKeyCredential JSON을 서버에서 검증·등록한다. */ + public suspend fun registerPasskey(credentialJson: String): Passkey +} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/applock/AppLockSetupContract.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/applock/AppLockSetupContract.kt new file mode 100644 index 000000000..c5e2e67d6 --- /dev/null +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/applock/AppLockSetupContract.kt @@ -0,0 +1,24 @@ +package com.afternote.feature.setting.presentation.applock + +import com.afternote.core.ui.mvi.MviIntent +import com.afternote.core.ui.mvi.ReducerEvent + +internal sealed interface AppLockSetupIntent : MviIntent { + data class DigitInput( + val digit: String, + ) : AppLockSetupIntent + + data object Delete : AppLockSetupIntent + + data object ResetPin : AppLockSetupIntent +} + +internal sealed interface AppLockSetupReducerEvent : ReducerEvent { + data class DigitEntered( + val digit: String, + ) : AppLockSetupReducerEvent + + data object Deleted : AppLockSetupReducerEvent + + data object PinReset : AppLockSetupReducerEvent +} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/applock/AppLockSetupScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/applock/AppLockSetupScreen.kt index 0dc39dabf..7a704844e 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/applock/AppLockSetupScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/applock/AppLockSetupScreen.kt @@ -9,7 +9,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle @Composable -fun AppLockSetupScreen( +internal fun AppLockSetupScreen( step: PinSetupStep, onPinComplete: (pin: String) -> Unit, onBack: () -> Unit, @@ -22,15 +22,15 @@ fun AppLockSetupScreen( LaunchedEffect(uiState.isComplete) { if (uiState.isComplete) { currentOnPinComplete(uiState.pin) - viewModel.resetPin() + viewModel.onIntent(AppLockSetupIntent.ResetPin) } } AppLockSetupContent( step = step, passwordLength = uiState.pin.length, - onDigitClick = viewModel::onDigitInput, - onDeleteClick = viewModel::onDelete, + onDigitClick = { viewModel.onIntent(AppLockSetupIntent.DigitInput(it)) }, + onDeleteClick = { viewModel.onIntent(AppLockSetupIntent.Delete) }, onConfirmClick = { currentOnPinComplete(uiState.pin) }, onBack = onBack, modifier = modifier, diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/applock/AppLockSetupUiState.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/applock/AppLockSetupUiState.kt index 99c281555..12c99a65c 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/applock/AppLockSetupUiState.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/applock/AppLockSetupUiState.kt @@ -1,6 +1,8 @@ package com.afternote.feature.setting.presentation.applock -data class AppLockSetupUiState( +import com.afternote.core.ui.mvi.UiState + +internal data class AppLockSetupUiState( val pin: String = "", val isComplete: Boolean = false, -) +) : UiState diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/applock/AppLockSetupViewModel.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/applock/AppLockSetupViewModel.kt index c7866b7f1..2a87350a3 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/applock/AppLockSetupViewModel.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/applock/AppLockSetupViewModel.kt @@ -1,38 +1,42 @@ package com.afternote.feature.setting.presentation.applock -import androidx.lifecycle.ViewModel +import com.afternote.core.ui.mvi.MviViewModel import dagger.hilt.android.lifecycle.HiltViewModel import jakarta.inject.Inject -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update @HiltViewModel -class AppLockSetupViewModel +internal class AppLockSetupViewModel @Inject - constructor() : ViewModel() { - private val _uiState = MutableStateFlow(AppLockSetupUiState()) - val uiState: StateFlow = _uiState.asStateFlow() - - fun onDigitInput(digit: String) { - _uiState.update { state -> - if (state.pin.length < 4) { - val newPin = state.pin + digit - state.copy(pin = newPin, isComplete = newPin.length == 4) - } else { - state - } + constructor() : + MviViewModel(AppLockSetupUiState()) { + override fun onIntent(intent: AppLockSetupIntent) { + when (intent) { + is AppLockSetupIntent.DigitInput -> dispatch(AppLockSetupReducerEvent.DigitEntered(intent.digit)) + AppLockSetupIntent.Delete -> dispatch(AppLockSetupReducerEvent.Deleted) + AppLockSetupIntent.ResetPin -> dispatch(AppLockSetupReducerEvent.PinReset) } } - fun onDelete() { - _uiState.update { state -> - state.copy(pin = state.pin.dropLast(1), isComplete = false) - } - } + override fun reduce( + state: AppLockSetupUiState, + event: AppLockSetupReducerEvent, + ): AppLockSetupUiState = + when (event) { + is AppLockSetupReducerEvent.DigitEntered -> { + if (state.pin.length < 4) { + val newPin = state.pin + event.digit + state.copy(pin = newPin, isComplete = newPin.length == 4) + } else { + state + } + } - fun resetPin() { - _uiState.update { AppLockSetupUiState() } - } + AppLockSetupReducerEvent.Deleted -> { + state.copy(pin = state.pin.dropLast(1), isComplete = false) + } + + AppLockSetupReducerEvent.PinReset -> { + AppLockSetupUiState() + } + } } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/navigation/SettingNavHost.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/navigation/SettingNavHost.kt index f30810c26..63f3717ed 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/navigation/SettingNavHost.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/navigation/SettingNavHost.kt @@ -5,11 +5,14 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.LifecycleStartEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.rememberNavBackStack +import com.afternote.core.ui.asString import com.afternote.core.ui.navigation.FeatureNavDisplay import com.afternote.core.ui.navigation.FeatureNavigationCallbacks import com.afternote.feature.setting.presentation.account.ConnectedAccountsScreen @@ -24,11 +27,11 @@ import com.afternote.feature.setting.presentation.home.WithdrawGuideScreen import com.afternote.feature.setting.presentation.notice.NoticeListScreen import com.afternote.feature.setting.presentation.notification.NotificationSettingScreen import com.afternote.feature.setting.presentation.notification.PushNotificationScreen +import com.afternote.feature.setting.presentation.passkey.PassKeyListIntent import com.afternote.feature.setting.presentation.passkey.PassKeyListScreen +import com.afternote.feature.setting.presentation.passkey.PassKeyListViewModel import com.afternote.feature.setting.presentation.passkey.PassKeyMakingScreen import com.afternote.feature.setting.presentation.passkey.PassKeyPasswordScreen -import com.afternote.feature.setting.presentation.passkey.PassKeyScreen -import com.afternote.feature.setting.presentation.passkey.PassKeyViewModel import com.afternote.feature.setting.presentation.password.PasswordChangeScreen import com.afternote.feature.setting.presentation.profile.ProfileEditScreen import com.afternote.feature.setting.presentation.receiver.ReceiverEditScreen @@ -214,16 +217,17 @@ public fun SettingNavHost( } entry { - val viewModel: PassKeyViewModel = hiltViewModel() - val isPasskeyRegistered by viewModel.isPasskeyRegistered.collectAsStateWithLifecycle() - if (isPasskeyRegistered == true) { - PassKeyListScreen(onBackClick = actions::popBack) - } else if (isPasskeyRegistered == false) { - PassKeyScreen( - onBackClick = actions::popBack, - onRegisterClick = actions::onPasskeyRegisterClick, - ) - } + val viewModel: PassKeyListViewModel = hiltViewModel() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { viewModel.onIntent(PassKeyListIntent.Refresh) } + PassKeyListScreen( + passkeys = uiState.passkeys, + isLoading = uiState.isLoading, + errorMessage = uiState.errorMessage?.asString(), + onBackClick = actions::popBack, + onRegisterClick = actions::onPasskeyRegisterClick, + onRetryClick = { viewModel.onIntent(PassKeyListIntent.Refresh) }, + ) } entry { diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/passkey/PassKeyContract.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/passkey/PassKeyContract.kt new file mode 100644 index 000000000..9126c4f25 --- /dev/null +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/passkey/PassKeyContract.kt @@ -0,0 +1,45 @@ +package com.afternote.feature.setting.presentation.passkey + +import com.afternote.core.ui.mvi.MviIntent +import com.afternote.core.ui.mvi.ReducerEvent +import com.afternote.core.ui.mvi.UiState + +internal data class PassKeyUiState( + val isRegistering: Boolean = false, + val registrationId: Long = 0, + val result: PasskeyRegistrationResult? = null, +) : UiState + +internal sealed interface PassKeyIntent : MviIntent { + // Activity 소유 플랫폼 요청은 화면이 제공하고 등록 중에만 보유한다. 화면 폐기 시 CancelRegistration으로 해제한다. + data class Register( + val createCredential: suspend (optionsJson: String) -> String, + ) : PassKeyIntent + + data object CancelRegistration : PassKeyIntent + + data class ConsumeResult( + val registrationId: Long, + val result: PasskeyRegistrationResult, + ) : PassKeyIntent +} + +internal sealed interface PassKeyReducerEvent : ReducerEvent { + data object Started : PassKeyReducerEvent + + data class Finished( + val registrationId: Long, + val result: PasskeyRegistrationResult, + ) : PassKeyReducerEvent + + data class Stopped( + val registrationId: Long, + ) : PassKeyReducerEvent + + data object Canceled : PassKeyReducerEvent + + data class Consumed( + val registrationId: Long, + val result: PasskeyRegistrationResult, + ) : PassKeyReducerEvent +} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/passkey/PassKeyListContract.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/passkey/PassKeyListContract.kt new file mode 100644 index 000000000..31ba50180 --- /dev/null +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/passkey/PassKeyListContract.kt @@ -0,0 +1,19 @@ +package com.afternote.feature.setting.presentation.passkey + +import com.afternote.core.ui.mvi.MviIntent +import com.afternote.core.ui.mvi.ReducerEvent +import com.afternote.feature.setting.domain.Passkey + +internal sealed interface PassKeyListIntent : MviIntent { + data object Refresh : PassKeyListIntent +} + +internal sealed interface PassKeyListReducerEvent : ReducerEvent { + data object Loading : PassKeyListReducerEvent + + data class Loaded( + val passkeys: List, + ) : PassKeyListReducerEvent + + data object Failed : PassKeyListReducerEvent +} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/passkey/PassKeyListScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/passkey/PassKeyListScreen.kt index f7128d889..0247b1e4a 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/passkey/PassKeyListScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/passkey/PassKeyListScreen.kt @@ -1,27 +1,45 @@ package com.afternote.feature.setting.presentation.passkey +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import com.afternote.core.ui.button.AfternoteButton +import com.afternote.core.ui.button.AfternoteButtonType +import com.afternote.core.ui.loading.LoadingBody import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.core.ui.topbar.DetailTopBar +import com.afternote.feature.setting.domain.Passkey import com.afternote.feature.setting.presentation.R import com.afternote.feature.setting.presentation.passkey.component.PasskeyListItem @Composable -fun PassKeyListScreen( +internal fun PassKeyListScreen( + passkeys: List, + isLoading: Boolean, + errorMessage: String?, onBackClick: () -> Unit, + onRegisterClick: () -> Unit, + onRetryClick: () -> Unit, modifier: Modifier = Modifier, ) { + if (!isLoading && errorMessage == null && passkeys.isEmpty()) { + PassKeyScreen(onBackClick = onBackClick, onRegisterClick = onRegisterClick, modifier = modifier) + return + } Scaffold( modifier = modifier, containerColor = Color.Transparent, @@ -32,34 +50,76 @@ fun PassKeyListScreen( ) }, ) { innerPadding -> - LazyColumn( - modifier = - Modifier - .fillMaxWidth() - .padding(innerPadding) - .padding(horizontal = 20.dp), - ) { - item { - Spacer(modifier = Modifier.height(16.dp)) - Text( - text = stringResource(id = R.string.setting_passkey_section_title), - style = AfternoteDesign.typography.bodyLargeB, - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = stringResource(id = R.string.setting_passkey_description), - style = AfternoteDesign.typography.bodySmallR, - ) - Spacer(modifier = Modifier.height(24.dp)) - Text( - text = stringResource(id = R.string.setting_passkey_list_header), - style = AfternoteDesign.typography.bodyLargeB, + when { + isLoading -> { + LoadingBody(modifier = Modifier.padding(innerPadding)) + } + + errorMessage != null -> { + PassKeyListErrorState( + message = errorMessage, + onRetryClick = onRetryClick, + modifier = + Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(horizontal = 20.dp), ) - Spacer(modifier = Modifier.height(12.dp)) } - item { - PasskeyListItem() + + else -> { + LazyColumn( + modifier = + Modifier + .fillMaxWidth() + .padding(innerPadding) + .padding(horizontal = 20.dp), + ) { + item { + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(id = R.string.setting_passkey_section_title), + style = AfternoteDesign.typography.bodyLargeB, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(id = R.string.setting_passkey_description), + style = AfternoteDesign.typography.bodySmallR, + ) + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = stringResource(id = R.string.setting_passkey_list_header), + style = AfternoteDesign.typography.bodyLargeB, + ) + Spacer(modifier = Modifier.height(12.dp)) + } + items(passkeys, key = { it.id }) { passkey -> + PasskeyListItem(passkey = passkey) + } + } } } } } + +@Composable +private fun PassKeyListErrorState( + message: String, + onRetryClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box(modifier = modifier, contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = message, + style = AfternoteDesign.typography.bodySmallR, + ) + Spacer(modifier = Modifier.height(16.dp)) + AfternoteButton( + text = stringResource(id = R.string.setting_passkey_list_retry), + onClick = onRetryClick, + type = AfternoteButtonType.Default, + ) + } + } +} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/passkey/PassKeyListUiState.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/passkey/PassKeyListUiState.kt new file mode 100644 index 000000000..5e44749f0 --- /dev/null +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/passkey/PassKeyListUiState.kt @@ -0,0 +1,11 @@ +package com.afternote.feature.setting.presentation.passkey + +import com.afternote.core.ui.UiText +import com.afternote.core.ui.mvi.UiState +import com.afternote.feature.setting.domain.Passkey + +internal data class PassKeyListUiState( + val isLoading: Boolean = false, + val passkeys: List = emptyList(), + val errorMessage: UiText? = null, +) : UiState diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/passkey/PassKeyListViewModel.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/passkey/PassKeyListViewModel.kt new file mode 100644 index 000000000..b1cc9f42d --- /dev/null +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/passkey/PassKeyListViewModel.kt @@ -0,0 +1,82 @@ +package com.afternote.feature.setting.presentation.passkey + +import androidx.lifecycle.viewModelScope +import com.afternote.core.common.reporting.ErrorReporter +import com.afternote.core.common.result.runCatchingCancellable +import com.afternote.core.domain.repository.UserProfileCacheRepository +import com.afternote.core.ui.UiText +import com.afternote.core.ui.mvi.MviViewModel +import com.afternote.feature.setting.domain.PasskeyRepository +import com.afternote.feature.setting.presentation.R +import dagger.hilt.android.lifecycle.HiltViewModel +import jakarta.inject.Inject +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch + +@HiltViewModel +internal class PassKeyListViewModel + @Inject + constructor( + private val passkeyRepository: PasskeyRepository, + private val userProfileCacheRepository: UserProfileCacheRepository, + private val errorReporter: ErrorReporter, + ) : MviViewModel(PassKeyListUiState(isLoading = true)) { + override fun onIntent(intent: PassKeyListIntent) { + when (intent) { + PassKeyListIntent.Refresh -> refresh() + } + } + + override fun reduce( + state: PassKeyListUiState, + event: PassKeyListReducerEvent, + ): PassKeyListUiState = + when (event) { + PassKeyListReducerEvent.Loading -> { + state.copy(isLoading = true, errorMessage = null) + } + + is PassKeyListReducerEvent.Loaded -> { + state.copy(isLoading = false, passkeys = event.passkeys) + } + + PassKeyListReducerEvent.Failed -> { + state.copy( + isLoading = false, + errorMessage = UiText.Resource(R.string.setting_passkey_list_error), + ) + } + } + + private var loadJob: Job? = null + private var requestId = 0L + + /** 최초 진입·등록 화면에서 복귀·사용자 재시도 모두 서버 목록을 다시 읽는다. */ + private fun refresh() { + val currentRequestId = ++requestId + loadJob?.cancel() + dispatch(PassKeyListReducerEvent.Loading) + loadJob = + viewModelScope.launch { + runCatchingCancellable { passkeyRepository.getPasskeys() } + .onSuccess { passkeys -> + if (requestId == currentRequestId) dispatch(PassKeyListReducerEvent.Loaded(passkeys)) + if (passkeys.isNotEmpty()) syncRegisteredCache() + }.onFailure { failure -> + if (requestId == currentRequestId) { + errorReporter.recordFailure(failure, mapOf("stage" to "passkey_list")) + dispatch(PassKeyListReducerEvent.Failed) + } + } + } + } + + /** + * 등록 직후 캐시 저장이 빠진 기기를 서버 목록으로 되살린다. 지문 관문을 켜는 방향으로만 맞춘다 — + * 빈 목록으로 캐시를 내리면 목록 응답 하나가 관문을 끌 수 있다. + */ + private suspend fun syncRegisteredCache() { + runCatchingCancellable { userProfileCacheRepository.savePasskeyRegistered(true) } + .onFailure { errorReporter.recordFailure(it, mapOf("stage" to "passkey_list_cache")) } + } + } 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 21235df1f..ba24e847d 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 @@ -2,6 +2,7 @@ package com.afternote.feature.setting.presentation.passkey import androidx.biometric.BiometricManager import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -10,19 +11,23 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.credentials.CredentialManager import androidx.fragment.app.FragmentActivity import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle 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.asString import com.afternote.core.ui.findActivity +import com.afternote.core.ui.mvi.ObserveSignal 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 @Composable -fun PassKeyMakingScreen( +internal fun PassKeyMakingScreen( onBackClick: () -> Unit, onPasswordAuthClick: () -> Unit, modifier: Modifier = Modifier, @@ -37,7 +42,21 @@ fun PassKeyMakingScreen( .from(context) .canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) == BiometricManager.BIOMETRIC_SUCCESS } - var showCompletionDialog by remember { mutableStateOf(false) } + val credentialManager = remember(context) { CredentialManager.create(context) } + val registrationState by viewModel.uiState.collectAsStateWithLifecycle() + DisposableEffect(viewModel) { + onDispose { viewModel.onIntent(PassKeyIntent.CancelRegistration) } + } + ObserveSignal( + signal = registrationState.registrationId.takeIf { registrationState.result == PasskeyRegistrationResult.Canceled }, + consumed = PassKeyIntent.ConsumeResult(registrationState.registrationId, PasskeyRegistrationResult.Canceled), + onIntent = viewModel::onIntent, + ) { } + val consumeResult: () -> Unit = { + registrationState.result?.let { + viewModel.onIntent(PassKeyIntent.ConsumeResult(registrationState.registrationId, it)) + } + } var isAuthenticating by remember { mutableStateOf(false) } var errorMessage by remember { mutableStateOf(null) } @@ -53,15 +72,24 @@ fun PassKeyMakingScreen( val promptTitle = stringResource(R.string.setting_biometric_prompt_title) val promptSubtitle = stringResource(R.string.setting_biometric_prompt_subtitle) - if (showCompletionDialog) { + if (registrationState.result == PasskeyRegistrationResult.Success) { Popup( type = PopupType.Default, - message = "패스키 생성이 완료되었습니다", + message = stringResource(R.string.setting_passkey_registration_complete), onConfirm = { - showCompletionDialog = false + consumeResult() onBackClick() }, - onDismiss = { showCompletionDialog = false }, + onDismiss = { consumeResult() }, + ) + } + + (registrationState.result as? PasskeyRegistrationResult.Error)?.let { failure -> + Popup( + type = PopupType.Default, + message = failure.message.asString(), + onConfirm = consumeResult, + onDismiss = consumeResult, ) } @@ -77,14 +105,17 @@ fun PassKeyMakingScreen( PassKeyMakingContent( onBackClick = onBackClick, onBiometricAuthClick = { - if (!isAuthenticating && activity != null) { + if (!isAuthenticating && !registrationState.isRegistering && registrationState.result == null && activity != null) { isAuthenticating = true coroutineScope.launch { try { when (val result = activity.authenticateBiometric(promptTitle, promptSubtitle, messages)) { BiometricAuthResult.Success -> { - viewModel.savePasskeyRegistered() - showCompletionDialog = true + viewModel.onIntent( + PassKeyIntent.Register { options -> + createPasskeyCredential(activity, credentialManager, options) + }, + ) } BiometricAuthResult.Canceled -> { @@ -101,7 +132,7 @@ fun PassKeyMakingScreen( } } }, - onPasswordAuthClick = onPasswordAuthClick, + onPasswordAuthClick = { if (!isAuthenticating && !registrationState.isRegistering) onPasswordAuthClick() }, isBiometricAvailable = isBiometricAvailable, modifier = modifier, ) diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/passkey/PassKeyPasswordScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/passkey/PassKeyPasswordScreen.kt index 5993c0ad2..47f84769d 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/passkey/PassKeyPasswordScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/passkey/PassKeyPasswordScreen.kt @@ -1,16 +1,25 @@ package com.afternote.feature.setting.presentation.passkey import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.credentials.CredentialManager import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.afternote.core.ui.asString +import com.afternote.core.ui.mvi.ObserveSignal +import com.afternote.core.ui.popup.Popup +import com.afternote.core.ui.popup.PopupType +import com.afternote.feature.setting.presentation.applock.AppLockSetupIntent import com.afternote.feature.setting.presentation.applock.AppLockSetupViewModel @Composable -fun PassKeyPasswordScreen( +internal fun PassKeyPasswordScreen( onPinComplete: (pin: String) -> Unit, onBack: () -> Unit, modifier: Modifier = Modifier, @@ -19,20 +28,56 @@ fun PassKeyPasswordScreen( ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val currentOnPinComplete by rememberUpdatedState(onPinComplete) - - LaunchedEffect(uiState.isComplete) { - if (uiState.isComplete) { - passKeyViewModel.savePasskeyRegistered() - currentOnPinComplete(uiState.pin) - viewModel.resetPin() + val context = LocalContext.current + val credentialManager = remember(context) { CredentialManager.create(context) } + val registrationState by passKeyViewModel.uiState.collectAsStateWithLifecycle() + val isRegistering = registrationState.isRegistering + DisposableEffect(passKeyViewModel, viewModel) { + onDispose { + passKeyViewModel.onIntent(PassKeyIntent.CancelRegistration) + viewModel.onIntent(AppLockSetupIntent.ResetPin) + } + } + val result = registrationState.result + ObserveSignal( + signal = result?.takeUnless { it is PasskeyRegistrationResult.Error }?.let { registrationState.registrationId to it }, + consumed = PassKeyIntent.ConsumeResult(registrationState.registrationId, result ?: PasskeyRegistrationResult.Canceled), + onIntent = passKeyViewModel::onIntent, + ) { (_, completed) -> + val pin = uiState.pin + viewModel.onIntent(AppLockSetupIntent.ResetPin) + if (completed == PasskeyRegistrationResult.Success) currentOnPinComplete(pin) + } + LaunchedEffect(result) { + if (result is PasskeyRegistrationResult.Error) viewModel.onIntent(AppLockSetupIntent.ResetPin) + } + val register: () -> Unit = { + if (uiState.isComplete && !isRegistering && result == null) { + passKeyViewModel.onIntent( + PassKeyIntent.Register { options -> + createPasskeyCredential(context, credentialManager, options) + }, + ) } } + LaunchedEffect(uiState.isComplete) { + if (uiState.isComplete) register() + } + + (result as? PasskeyRegistrationResult.Error)?.let { failure -> + Popup( + type = PopupType.Default, + message = failure.message.asString(), + onConfirm = { passKeyViewModel.onIntent(PassKeyIntent.ConsumeResult(registrationState.registrationId, failure)) }, + onDismiss = { passKeyViewModel.onIntent(PassKeyIntent.ConsumeResult(registrationState.registrationId, failure)) }, + ) + } PassKeyPasswordContent( passwordLength = uiState.pin.length, - onDigitClick = viewModel::onDigitInput, - onDeleteClick = viewModel::onDelete, - onConfirmClick = { currentOnPinComplete(uiState.pin) }, + onDigitClick = { if (!isRegistering) viewModel.onIntent(AppLockSetupIntent.DigitInput(it)) }, + onDeleteClick = { if (!isRegistering) viewModel.onIntent(AppLockSetupIntent.Delete) }, + onConfirmClick = register, onBack = onBack, modifier = modifier, ) diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/passkey/PassKeyViewModel.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/passkey/PassKeyViewModel.kt index 345fcb0e5..b9437ab33 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/passkey/PassKeyViewModel.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/passkey/PassKeyViewModel.kt @@ -1,29 +1,161 @@ package com.afternote.feature.setting.presentation.passkey -import androidx.lifecycle.ViewModel +import androidx.credentials.exceptions.CreateCredentialCancellationException import androidx.lifecycle.viewModelScope -import com.afternote.core.datastore.UserProfileDataSource +import com.afternote.core.common.reporting.ErrorReporter +import com.afternote.core.common.result.runCatchingCancellable +import com.afternote.core.domain.repository.UserProfileCacheRepository +import com.afternote.core.ui.UiText +import com.afternote.core.ui.mvi.MviViewModel +import com.afternote.feature.setting.domain.PasskeyRepository +import com.afternote.feature.setting.presentation.R import dagger.hilt.android.lifecycle.HiltViewModel import jakarta.inject.Inject -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlin.coroutines.cancellation.CancellationException + +internal sealed interface PasskeyRegistrationResult { + data object Success : PasskeyRegistrationResult + + data object Canceled : PasskeyRegistrationResult + + data class Error( + val message: UiText, + ) : PasskeyRegistrationResult +} @HiltViewModel -class PassKeyViewModel +internal class PassKeyViewModel @Inject constructor( - private val dataSource: UserProfileDataSource, - ) : ViewModel() { - val isPasskeyRegistered: StateFlow = - dataSource - .isPasskeyRegisteredFlow() - .stateIn(viewModelScope, SharingStarted.Eagerly, null) - - fun savePasskeyRegistered() { - viewModelScope.launch { - dataSource.savePasskeyRegistered(true) + private val userProfileCacheRepository: UserProfileCacheRepository, + private val passkeyRepository: PasskeyRepository, + private val errorReporter: ErrorReporter, + ) : MviViewModel(PassKeyUiState()) { + private var registrationJob: Job? = null + + override fun onIntent(intent: PassKeyIntent) { + when (intent) { + is PassKeyIntent.Register -> { + register(intent.createCredential) + } + + PassKeyIntent.CancelRegistration -> { + val canceledJob = registrationJob + registrationJob = null + dispatch(PassKeyReducerEvent.Canceled) + canceledJob?.cancel() + } + + is PassKeyIntent.ConsumeResult -> { + dispatch(PassKeyReducerEvent.Consumed(intent.registrationId, intent.result)) + } + } + } + + override fun reduce( + state: PassKeyUiState, + event: PassKeyReducerEvent, + ): PassKeyUiState = + when (event) { + PassKeyReducerEvent.Started -> { + state.copy(isRegistering = true, result = null, registrationId = state.registrationId + 1) + } + + is PassKeyReducerEvent.Finished -> { + if (state.registrationId == event.registrationId && state.isRegistering) { + state.copy(isRegistering = false, result = event.result) + } else { + state + } + } + + is PassKeyReducerEvent.Stopped -> { + if (state.registrationId == event.registrationId) state.copy(isRegistering = false) else state + } + + PassKeyReducerEvent.Canceled -> { + state.copy(isRegistering = false, result = null, registrationId = state.registrationId + 1) + } + + is PassKeyReducerEvent.Consumed -> { + if (state.registrationId == event.registrationId && state.result == event.result) state.copy(result = null) else state + } + } + + private fun register(createCredential: suspend (String) -> String) { + if (currentState.isRegistering || currentState.result != null) return + dispatch(PassKeyReducerEvent.Started) + val registrationId = currentState.registrationId + registrationJob = + viewModelScope.launch { + try { + registerPasskey(registrationId, createCredential) + } finally { + dispatch(PassKeyReducerEvent.Stopped(registrationId)) + } + } + } + + /** Activity를 가진 화면이 플랫폼 요청을 실행한다. 서버 등록이 성공한 뒤에만 완료 상태를 확정한다. */ + private suspend fun registerPasskey( + registrationId: Long, + createCredential: suspend (optionsJson: String) -> String, + ) { + var stage = STAGE_OPTIONS + try { + val options = passkeyRepository.getRegistrationOptions() + currentCoroutineContext().ensureActive() + stage = STAGE_CREDENTIAL + val credential = createCredential(options) + currentCoroutineContext().ensureActive() + stage = STAGE_REGISTER + passkeyRepository.registerPasskey(credential) + // 서버 성공을 먼저 확정한다. 캐시의 실패나 취소는 재등록을 유도하지 않는다. + dispatch(PassKeyReducerEvent.Finished(registrationId, PasskeyRegistrationResult.Success)) + // 지문 관문이 이 캐시를 본다. 서버에 등록된 뒤에는 화면 이탈·구성 변경으로 job 이 취소돼도 + // 저장을 끝낸다. + withContext(NonCancellable) { + runCatchingCancellable { userProfileCacheRepository.savePasskeyRegistered(true) } + .onFailure { reportFailure(it, STAGE_LOCAL_CACHE) } + } + } catch (_: CreateCredentialCancellationException) { + dispatch(PassKeyReducerEvent.Finished(registrationId, PasskeyRegistrationResult.Canceled)) + } catch (canceled: CancellationException) { + throw canceled + } catch (failure: Exception) { + reportFailure(failure, stage) + dispatch( + PassKeyReducerEvent.Finished( + registrationId, + PasskeyRegistrationResult.Error( + UiText.Resource( + if (stage == STAGE_OPTIONS) { + R.string.setting_passkey_options_error + } else { + R.string.setting_passkey_registration_error + }, + ), + ), + ), + ) } } + + private fun reportFailure( + failure: Throwable, + stage: String, + ) { + errorReporter.recordFailure(failure, mapOf("stage" to stage)) + } } + +private const val STAGE_OPTIONS = "passkey_registration_options" +private const val STAGE_CREDENTIAL = "passkey_create_credential" +private const val STAGE_REGISTER = "passkey_register" +private const val STAGE_LOCAL_CACHE = "passkey_registration_cache" diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/passkey/PasskeyRegistrationResult.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/passkey/PasskeyRegistrationResult.kt new file mode 100644 index 000000000..833db4183 --- /dev/null +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/passkey/PasskeyRegistrationResult.kt @@ -0,0 +1,20 @@ +package com.afternote.feature.setting.presentation.passkey + +import android.content.Context +import androidx.credentials.CreatePublicKeyCredentialRequest +import androidx.credentials.CreatePublicKeyCredentialResponse +import androidx.credentials.CredentialManager + +/** 모든 실패와 취소는 ViewModel의 등록 경계에서 처리한다. */ +internal suspend fun createPasskeyCredential( + context: Context, + credentialManager: CredentialManager, + optionsJson: String, +): String { + val request = CreatePublicKeyCredentialRequest(requestJson = optionsJson) + val response = credentialManager.createCredential(context, request) + val publicKeyResponse = + response as? CreatePublicKeyCredentialResponse + ?: error("Credential Manager returned an unexpected credential response type") + return publicKeyResponse.registrationResponseJson +} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/passkey/component/PasskeyListItem.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/passkey/component/PasskeyListItem.kt index c8fbde5de..6e2a1120f 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/passkey/component/PasskeyListItem.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/passkey/component/PasskeyListItem.kt @@ -7,18 +7,32 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Icon +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp -import com.afternote.core.ui.theme.AfternoteDesign +import com.afternote.feature.setting.domain.Passkey import com.afternote.feature.setting.presentation.R +import java.time.LocalDateTime +import java.time.OffsetDateTime +import java.time.format.DateTimeFormatter + +private val passkeyCreatedAtFormatter = DateTimeFormatter.ofPattern("yyyy.MM.dd HH:mm") + +private fun formatCreatedAt(raw: String): String = + runCatching { OffsetDateTime.parse(raw).format(passkeyCreatedAtFormatter) } + .recoverCatching { LocalDateTime.parse(raw).format(passkeyCreatedAtFormatter) } + .getOrDefault(raw) @Composable -fun PasskeyListItem(modifier: Modifier = Modifier) { +internal fun PasskeyListItem( + passkey: Passkey, + modifier: Modifier = Modifier, +) { Row( modifier = modifier @@ -26,24 +40,20 @@ fun PasskeyListItem(modifier: Modifier = Modifier) { .padding(horizontal = 20.dp), verticalAlignment = Alignment.CenterVertically, ) { - Image(painterResource(R.drawable.setting_ic_apple_login), contentDescription = "패스키기본") - Spacer(modifier = Modifier.weight(1f)) - Column { - Text("이름") - Text("생성일시") + Image( + painter = painterResource(R.drawable.setting_ic_fingerprint), + contentDescription = null, + modifier = Modifier.size(40.dp), + ) + Spacer(modifier = Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text(passkey.displayName) + Text(formatCreatedAt(passkey.createdAt)) } - Spacer(modifier = Modifier.weight(1f)) + Spacer(modifier = Modifier.width(12.dp)) Box { - Icon( - painter = painterResource(R.drawable.setting_ic_vector1), - contentDescription = "왼쪽 막대기", - tint = AfternoteDesign.colors.gray5, - ) - Icon( - painter = painterResource(R.drawable.setting_ic_vector2), - contentDescription = "오른쪽 막대기", - tint = AfternoteDesign.colors.gray5, - ) + Image(painterResource(R.drawable.setting_ic_vector1), contentDescription = "왼쪽 막대기") + Image(painterResource(R.drawable.setting_ic_vector2), contentDescription = "오른쪽 막대기") } } } diff --git a/feature/setting/presentation/src/main/res/values/strings.xml b/feature/setting/presentation/src/main/res/values/strings.xml index ea2972049..f402406b6 100644 --- a/feature/setting/presentation/src/main/res/values/strings.xml +++ b/feature/setting/presentation/src/main/res/values/strings.xml @@ -194,4 +194,9 @@ 프로필 수정에 실패했습니다. 다시 시도해 주세요. 알림 설정을 불러올 수 없습니다. 다시 시도 + 다시 시도 + 패스키 목록을 불러올 수 없습니다. + 패스키 등록 준비에 실패했습니다. 다시 시도해 주세요. + 패스키 등록에 실패했습니다. 다시 시도해 주세요. + 패스키 생성이 완료되었습니다 diff --git a/feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/passkey/PassKeyListStateScreenshotTest.kt b/feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/passkey/PassKeyListStateScreenshotTest.kt new file mode 100644 index 000000000..555922aa3 --- /dev/null +++ b/feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/passkey/PassKeyListStateScreenshotTest.kt @@ -0,0 +1,70 @@ +package com.afternote.feature.setting.presentation.passkey + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.setting.domain.Passkey +import com.afternote.feature.setting.presentation.COMPACT_DEVICE_SPEC +import com.afternote.feature.setting.presentation.R +import com.android.tools.screenshot.PreviewTest + +@PreviewTest +@Preview(showBackground = true) +@Preview(showBackground = true, device = COMPACT_DEVICE_SPEC) +@Composable +internal fun passkeyListLoadingScreenshot() { + PasskeyListScreenshotContent(isLoading = true) +} + +@PreviewTest +@Preview(showBackground = true) +@Preview(showBackground = true, device = COMPACT_DEVICE_SPEC) +@Composable +internal fun passkeyListErrorScreenshot() { + PasskeyListScreenshotContent(errorMessage = stringResource(R.string.setting_passkey_list_error)) +} + +@PreviewTest +@Preview(showBackground = true) +@Preview(showBackground = true, device = COMPACT_DEVICE_SPEC) +@Composable +internal fun passkeyListEmptyScreenshot() { + PasskeyListScreenshotContent() +} + +@PreviewTest +@Preview(showBackground = true) +@Preview(showBackground = true, device = COMPACT_DEVICE_SPEC) +@Composable +internal fun passkeyListRegisteredScreenshot() { + PasskeyListScreenshotContent( + passkeys = + listOf( + Passkey(7L, "휴대전화 패스키", "2026-09-06T10:00:00"), + Passkey( + 8L, + "회사에서 사용하는 태블릿에 저장한 아주 긴 이름의 패스키", + "2026-09-08T15:30:00.123456789+09:00", + ), + ), + ) +} + +@Composable +private fun PasskeyListScreenshotContent( + passkeys: List = emptyList(), + isLoading: Boolean = false, + errorMessage: String? = null, +) { + AfternoteTheme { + PassKeyListScreen( + passkeys = passkeys, + isLoading = isLoading, + errorMessage = errorMessage, + onBackClick = {}, + onRegisterClick = {}, + onRetryClick = {}, + ) + } +} diff --git a/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/passkey/PassKeyListStateScreenshotTestKt/passkeyListEmptyScreenshot_748aa731_0.png b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/passkey/PassKeyListStateScreenshotTestKt/passkeyListEmptyScreenshot_748aa731_0.png new file mode 100644 index 000000000..bcabda7f1 Binary files /dev/null and b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/passkey/PassKeyListStateScreenshotTestKt/passkeyListEmptyScreenshot_748aa731_0.png differ diff --git a/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/passkey/PassKeyListStateScreenshotTestKt/passkeyListEmptyScreenshot_c2abd7e6_0.png b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/passkey/PassKeyListStateScreenshotTestKt/passkeyListEmptyScreenshot_c2abd7e6_0.png new file mode 100644 index 000000000..baa6cec5f Binary files /dev/null and b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/passkey/PassKeyListStateScreenshotTestKt/passkeyListEmptyScreenshot_c2abd7e6_0.png differ diff --git a/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/passkey/PassKeyListStateScreenshotTestKt/passkeyListErrorScreenshot_748aa731_0.png b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/passkey/PassKeyListStateScreenshotTestKt/passkeyListErrorScreenshot_748aa731_0.png new file mode 100644 index 000000000..dcd6a6c8f Binary files /dev/null and b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/passkey/PassKeyListStateScreenshotTestKt/passkeyListErrorScreenshot_748aa731_0.png differ diff --git a/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/passkey/PassKeyListStateScreenshotTestKt/passkeyListErrorScreenshot_c2abd7e6_0.png b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/passkey/PassKeyListStateScreenshotTestKt/passkeyListErrorScreenshot_c2abd7e6_0.png new file mode 100644 index 000000000..d6d28345f Binary files /dev/null and b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/passkey/PassKeyListStateScreenshotTestKt/passkeyListErrorScreenshot_c2abd7e6_0.png differ diff --git a/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/passkey/PassKeyListStateScreenshotTestKt/passkeyListLoadingScreenshot_748aa731_0.png b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/passkey/PassKeyListStateScreenshotTestKt/passkeyListLoadingScreenshot_748aa731_0.png new file mode 100644 index 000000000..b3601627f Binary files /dev/null and b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/passkey/PassKeyListStateScreenshotTestKt/passkeyListLoadingScreenshot_748aa731_0.png differ diff --git a/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/passkey/PassKeyListStateScreenshotTestKt/passkeyListLoadingScreenshot_c2abd7e6_0.png b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/passkey/PassKeyListStateScreenshotTestKt/passkeyListLoadingScreenshot_c2abd7e6_0.png new file mode 100644 index 000000000..833c5eeea Binary files /dev/null and b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/passkey/PassKeyListStateScreenshotTestKt/passkeyListLoadingScreenshot_c2abd7e6_0.png differ diff --git a/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/passkey/PassKeyListStateScreenshotTestKt/passkeyListRegisteredScreenshot_748aa731_0.png b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/passkey/PassKeyListStateScreenshotTestKt/passkeyListRegisteredScreenshot_748aa731_0.png new file mode 100644 index 000000000..3d3f04f11 Binary files /dev/null and b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/passkey/PassKeyListStateScreenshotTestKt/passkeyListRegisteredScreenshot_748aa731_0.png differ diff --git a/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/passkey/PassKeyListStateScreenshotTestKt/passkeyListRegisteredScreenshot_c2abd7e6_0.png b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/passkey/PassKeyListStateScreenshotTestKt/passkeyListRegisteredScreenshot_c2abd7e6_0.png new file mode 100644 index 000000000..f1c1b03ca Binary files /dev/null and b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/passkey/PassKeyListStateScreenshotTestKt/passkeyListRegisteredScreenshot_c2abd7e6_0.png differ diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingAccountSecurityTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingAccountSecurityTest.kt index 1075ae0e8..138cee7f8 100644 --- a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingAccountSecurityTest.kt +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingAccountSecurityTest.kt @@ -24,6 +24,7 @@ import com.afternote.core.model.user.User import com.afternote.core.model.user.UserConnectedAccount import com.afternote.core.ui.UiText import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.setting.domain.Passkey import com.afternote.feature.setting.domain.UpdateTimeLetterDeliveryConditionUseCase import com.afternote.feature.setting.domain.testing.FakeSettingAccountRepository import com.afternote.feature.setting.domain.testing.FakeSettingAccountRepository.ConnectedAccountLinkCall @@ -273,7 +274,14 @@ class SettingAccountSecurityTest { } SecurityContractScreen.PASSKEY_LIST -> { - PassKeyListScreen(onBackClick = {}) + PassKeyListScreen( + passkeys = listOf(Passkey(7L, "서버 패스키", "2026-09-06T10:00:00")), + isLoading = false, + errorMessage = null, + onBackClick = {}, + onRegisterClick = {}, + onRetryClick = {}, + ) } } } @@ -297,8 +305,8 @@ class SettingAccountSecurityTest { assertEquals(1, registerCalls) composeRule.onNodeWithText("패스키 목록").assertIsDisplayed() - composeRule.onNodeWithText("이름").assertIsDisplayed() - composeRule.onNodeWithText("생성일시").assertIsDisplayed() + composeRule.onNodeWithText("서버 패스키").assertIsDisplayed() + composeRule.onNodeWithText("2026.09.06 10:00").assertIsDisplayed() } @Test 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 697b5fc54..013ea9a8e 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 @@ -27,6 +27,8 @@ 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.Passkey +import com.afternote.feature.setting.domain.PasskeyRepository import com.afternote.feature.setting.domain.SettingAccountRepository import com.afternote.feature.setting.domain.SettingNotificationRepository import com.afternote.feature.setting.domain.testing.FakeSettingAccountRepository @@ -86,6 +88,16 @@ class PassKeyMakingApi29Test { @get:Rule(order = 1) val composeRule = createComposeRule() + @BindValue + val passkeyRepository: PasskeyRepository = + object : PasskeyRepository { + override suspend fun getPasskeys(): List = error("생체 인증 오류 경로는 목록을 조회하지 않는다") + + override suspend fun getRegistrationOptions(): String = error("생체 인증 오류 경로는 등록 옵션을 조회하지 않는다") + + override suspend fun registerPasskey(credentialJson: String): Passkey = error("생체 인증 오류 경로는 등록하지 않는다") + } + @BindValue val accountRepository: SettingAccountRepository = FakeSettingAccountRepository.strict() @@ -278,7 +290,6 @@ class PassKeyMakingApi29Test { private fun showPasskeyScreen(activity: FragmentActivity): PassKeyViewModel { val viewModel = ViewModelProvider(activity)[PassKeyViewModel::class.java] - composeRule.waitUntil { viewModel.isPasskeyRegistered.value == false } composeRule.setContent { CompositionLocalProvider(LocalContext provides activity) { AfternoteTheme { @@ -296,7 +307,8 @@ class PassKeyMakingApi29Test { private fun assertNotRegistered(viewModel: PassKeyViewModel) { composeRule.onNodeWithText("패스키 생성이 완료되었습니다").assertDoesNotExist() composeRule.runOnIdle { - assertEquals(false, viewModel.isPasskeyRegistered.value) + assertEquals(null, viewModel.uiState.value.result) + assertFalse(viewModel.uiState.value.isRegistering) assertEquals(0, backCalls) assertEquals(0, passwordCalls) } diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/passkey/PasskeyViewModelTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/passkey/PasskeyViewModelTest.kt new file mode 100644 index 000000000..f85582cae --- /dev/null +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/passkey/PasskeyViewModelTest.kt @@ -0,0 +1,362 @@ +package com.afternote.feature.setting.presentation.passkey + +import androidx.credentials.exceptions.CreateCredentialCancellationException +import com.afternote.core.common.reporting.ErrorReporter +import com.afternote.core.domain.testing.FakeUserProfileCacheRepository +import com.afternote.core.ui.UiText +import com.afternote.feature.setting.domain.Passkey +import com.afternote.feature.setting.domain.PasskeyRepository +import com.afternote.feature.setting.presentation.R +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withContext +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.io.IOException +import kotlin.coroutines.cancellation.CancellationException + +@OptIn(ExperimentalCoroutinesApi::class) +class PasskeyViewModelTest { + private val dispatcher = StandardTestDispatcher() + private val repository = PasskeyScenario() + private val cache = FakeUserProfileCacheRepository() + private val reporter = PasskeyFailureRecorder() + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun registration_waitsForServerBeforeUpdatingCacheOrReturningSuccess() = + runTest(dispatcher) { + val server = CompletableDeferred() + repository.register = { credential -> + assertEquals("platform-credential", credential) + server.await() + } + val viewModel = PassKeyViewModel(cache, repository, reporter) + viewModel.onIntent( + PassKeyIntent.Register { options -> + assertEquals("server-options", options) + "platform-credential" + }, + ) + runCurrent() + assertTrue(viewModel.uiState.value.isRegistering) + assertEquals(null, viewModel.uiState.value.result) + assertTrue(cache.savedPasskeyValues.isEmpty()) + server.complete(PASSKEY) + runCurrent() + assertSame(PasskeyRegistrationResult.Success, viewModel.uiState.value.result) + assertEquals(listOf(true), cache.savedPasskeyValues) + assertTrue(reporter.stages.isEmpty()) + } + + @Test + fun registration_optionsFailureDoesNotLaunchCredentialManagerOrSaveCache() = + runTest(dispatcher) { + repository.options = { throw IOException("private server response") } + val result = + register(PassKeyViewModel(cache, repository, reporter)) { + error("Credential Manager must not run after options failure") + } + assertEquals( + PasskeyRegistrationResult.Error(UiText.Resource(R.string.setting_passkey_options_error)), + result, + ) + assertEquals(listOf("passkey_registration_options"), reporter.stages) + assertTrue(cache.savedPasskeyValues.isEmpty()) + assertTrue(repository.credentials.isEmpty()) + } + + @Test + fun registration_unexpectedCredentialResponseIsReportedWithoutRegistering() = + runTest(dispatcher) { + val result = + register(PassKeyViewModel(cache, repository, reporter)) { + error("Unexpected credential response") + } + assertEquals( + PasskeyRegistrationResult.Error(UiText.Resource(R.string.setting_passkey_registration_error)), + result, + ) + assertEquals(listOf("passkey_create_credential"), reporter.stages) + assertTrue(repository.credentials.isEmpty()) + assertTrue(cache.savedPasskeyValues.isEmpty()) + } + + @Test + fun registration_nativeCancellationIsQuietAndDoesNotRegister() = + runTest(dispatcher) { + val result = + register(PassKeyViewModel(cache, repository, reporter)) { + throw CreateCredentialCancellationException() + } + assertSame(PasskeyRegistrationResult.Canceled, result) + assertTrue(reporter.stages.isEmpty()) + assertTrue(repository.credentials.isEmpty()) + assertTrue(cache.savedPasskeyValues.isEmpty()) + } + + @Test + fun registration_coroutineCancellationPropagatesWithoutReporting() = + runTest(dispatcher) { + val cancellation = CancellationException("screen left") + repository.options = { throw cancellation } + val viewModel = PassKeyViewModel(cache, repository, reporter) + viewModel.onIntent(PassKeyIntent.Register { "unused" }) + runCurrent() + assertFalse(viewModel.uiState.value.isRegistering) + assertEquals(null, viewModel.uiState.value.result) + assertTrue(reporter.stages.isEmpty()) + assertTrue(cache.savedPasskeyValues.isEmpty()) + } + + @Test + fun registration_serverFailureIsReportedWithoutSavingSuccess() = + runTest(dispatcher) { + repository.register = { throw IOException("server rejected credential") } + val result = register(PassKeyViewModel(cache, repository, reporter)) { "credential" } + assertTrue(result is PasskeyRegistrationResult.Error) + assertEquals(listOf("passkey_register"), reporter.stages) + assertTrue(cache.savedPasskeyValues.isEmpty()) + } + + @Test + fun registration_cacheFailureKeepsServerSuccessAndReportsOnlyCacheStage() = + runTest(dispatcher) { + cache.onSavePasskeyRegistered = { throw IOException("disk") } + val result = register(PassKeyViewModel(cache, repository, reporter)) { "credential" } + assertSame(PasskeyRegistrationResult.Success, result) + assertEquals(listOf("credential"), repository.credentials) + assertEquals(listOf("passkey_registration_cache"), reporter.stages) + } + + @Test + fun list_failureIsDistinctFromEmptyAndRetryCanRecover() = + runTest(dispatcher) { + repository.list = { throw IOException("offline") } + val viewModel = PassKeyListViewModel(repository, cache, reporter) + viewModel.onIntent(PassKeyListIntent.Refresh) + advanceUntilIdle() + assertFalse(viewModel.uiState.value.isLoading) + assertEquals(UiText.Resource(R.string.setting_passkey_list_error), viewModel.uiState.value.errorMessage) + assertEquals(listOf("passkey_list"), reporter.stages) + + repository.list = { emptyList() } + viewModel.onIntent(PassKeyListIntent.Refresh) + advanceUntilIdle() + assertEquals(PassKeyListUiState(), viewModel.uiState.value) + + assertTrue(cache.savedPasskeyValues.isEmpty()) + + repository.list = { listOf(PASSKEY) } + viewModel.onIntent(PassKeyListIntent.Refresh) + advanceUntilIdle() + assertEquals(listOf(PASSKEY), viewModel.uiState.value.passkeys) + assertTrue(cache.passkeyRegisteredState.value) + } + + @Test + fun list_resumeRefreshSupersedesARequestStartedBeforeRegistration() = + runTest(dispatcher) { + val oldList = CompletableDeferred>() + var calls = 0 + repository.list = { + calls++ + if (calls == 1) withContext(NonCancellable) { oldList.await() } else listOf(PASSKEY) + } + val viewModel = PassKeyListViewModel(repository, cache, reporter) + viewModel.onIntent(PassKeyListIntent.Refresh) + runCurrent() + viewModel.onIntent(PassKeyListIntent.Refresh) + runCurrent() + assertEquals(2, calls) + assertEquals(listOf(PASSKEY), viewModel.uiState.value.passkeys) + oldList.complete(emptyList()) + runCurrent() + assertEquals(listOf(PASSKEY), viewModel.uiState.value.passkeys) + assertFalse(viewModel.uiState.value.isLoading) + } + + @Test + fun registration_cancelThenRetryIgnoresPreviousCompletionAndBlocksDuplicateSubmission() = + runTest(dispatcher) { + val first = CompletableDeferred() + val second = CompletableDeferred() + val viewModel = PassKeyViewModel(cache, repository, reporter) + viewModel.onIntent(PassKeyIntent.Register { first.await() }) + runCurrent() + viewModel.onIntent(PassKeyIntent.CancelRegistration) + viewModel.onIntent(PassKeyIntent.Register { second.await() }) + viewModel.onIntent(PassKeyIntent.Register { error("duplicate platform request") }) + runCurrent() + assertTrue(viewModel.uiState.value.isRegistering) + first.complete("stale") + runCurrent() + assertTrue(repository.credentials.isEmpty()) + second.complete("new") + runCurrent() + assertEquals(listOf("new"), repository.credentials) + assertSame(PasskeyRegistrationResult.Success, viewModel.uiState.value.result) + viewModel.onIntent(PassKeyIntent.Register { error("success must be consumed first") }) + runCurrent() + assertEquals(listOf("new"), repository.credentials) + viewModel.onIntent(PassKeyIntent.ConsumeResult(viewModel.uiState.value.registrationId, PasskeyRegistrationResult.Success)) + assertEquals(null, viewModel.uiState.value.result) + viewModel.onIntent(PassKeyIntent.Register { "again" }) + runCurrent() + assertSame(PasskeyRegistrationResult.Success, viewModel.uiState.value.result) + assertEquals(listOf("new", "again"), repository.credentials) + } + + @Test + fun registration_serverSuccessIsVisibleBeforeCacheCompletesAndCacheCancellationKeepsIt() = + runTest(dispatcher) { + val cacheWrite = CompletableDeferred() + cache.onSavePasskeyRegistered = { cacheWrite.await() } + val viewModel = PassKeyViewModel(cache, repository, reporter) + viewModel.onIntent(PassKeyIntent.Register { "credential" }) + runCurrent() + assertSame(PasskeyRegistrationResult.Success, viewModel.uiState.value.result) + assertFalse(viewModel.uiState.value.isRegistering) + cacheWrite.cancel() + runCurrent() + assertSame(PasskeyRegistrationResult.Success, viewModel.uiState.value.result) + assertEquals(listOf("credential"), repository.credentials) + assertTrue(reporter.stages.isEmpty()) + } + + @Test + fun registration_leavingScreenAfterServerSuccessStillSavesCache() = + runTest(dispatcher) { + val cacheWrite = CompletableDeferred() + cache.onSavePasskeyRegistered = { registered -> + cacheWrite.await() + cache.passkeyRegisteredState.value = registered + } + val viewModel = PassKeyViewModel(cache, repository, reporter) + viewModel.onIntent(PassKeyIntent.Register { "credential" }) + runCurrent() + assertSame(PasskeyRegistrationResult.Success, viewModel.uiState.value.result) + viewModel.onIntent(PassKeyIntent.CancelRegistration) + runCurrent() + cacheWrite.complete(Unit) + runCurrent() + assertTrue(cache.passkeyRegisteredState.value) + assertTrue(reporter.stages.isEmpty()) + } + + @Test + fun registration_canceledPlatformReturningLateCannotSubmitCredential() = + runTest(dispatcher) { + val oldCredential = CompletableDeferred() + val newCredential = CompletableDeferred() + val viewModel = PassKeyViewModel(cache, repository, reporter) + viewModel.onIntent(PassKeyIntent.Register { withContext(NonCancellable) { oldCredential.await() } }) + runCurrent() + viewModel.onIntent(PassKeyIntent.CancelRegistration) + viewModel.onIntent(PassKeyIntent.Register { newCredential.await() }) + runCurrent() + oldCredential.complete("stale") + runCurrent() + assertTrue(repository.credentials.isEmpty()) + assertTrue(viewModel.uiState.value.isRegistering) + newCredential.complete("fresh") + runCurrent() + assertEquals(listOf("fresh"), repository.credentials) + } + + @Test + fun registration_staleConsumptionCannotClearNextSuccessfulRegistration() = + runTest(dispatcher) { + val viewModel = PassKeyViewModel(cache, repository, reporter) + register(viewModel) { "first" } + val firstId = viewModel.uiState.value.registrationId + viewModel.onIntent(PassKeyIntent.ConsumeResult(firstId, PasskeyRegistrationResult.Success)) + register(viewModel) { "second" } + viewModel.onIntent(PassKeyIntent.ConsumeResult(firstId, PasskeyRegistrationResult.Success)) + assertSame(PasskeyRegistrationResult.Success, viewModel.uiState.value.result) + assertEquals(listOf("first", "second"), repository.credentials) + } + + @Test + fun registration_canceledServerReturningLateCannotOverwriteNextRequest() = + runTest(dispatcher) { + val server = CompletableDeferred() + val nextCredential = CompletableDeferred() + repository.register = { withContext(NonCancellable) { server.await() } } + val viewModel = PassKeyViewModel(cache, repository, reporter) + viewModel.onIntent(PassKeyIntent.Register { "first" }) + runCurrent() + viewModel.onIntent(PassKeyIntent.CancelRegistration) + viewModel.onIntent(PassKeyIntent.Register { nextCredential.await() }) + runCurrent() + server.complete(PASSKEY) + runCurrent() + assertTrue(viewModel.uiState.value.isRegistering) + assertEquals(null, viewModel.uiState.value.result) + // 화면 상태는 덮어쓰지 않지만, 서버에 패스키가 생겼으므로 지문 관문 캐시는 켠다. + assertEquals(listOf(true), cache.savedPasskeyValues) + viewModel.onIntent(PassKeyIntent.CancelRegistration) + } + + private fun TestScope.register( + viewModel: PassKeyViewModel, + createCredential: suspend (String) -> String, + ): PasskeyRegistrationResult? { + viewModel.onIntent(PassKeyIntent.Register(createCredential)) + runCurrent() + return viewModel.uiState.value.result + } +} + +private class PasskeyScenario : PasskeyRepository { + var list: suspend () -> List = { emptyList() } + var options: suspend () -> String = { "server-options" } + var register: suspend (String) -> Passkey = { PASSKEY } + val credentials = mutableListOf() + + override suspend fun getPasskeys(): List = list() + + override suspend fun getRegistrationOptions(): String = options() + + override suspend fun registerPasskey(credentialJson: String): Passkey { + credentials += credentialJson + return register(credentialJson) + } +} + +private class PasskeyFailureRecorder : ErrorReporter { + val stages = mutableListOf() + + override fun writeFailure( + throwable: Throwable, + attributes: Map, + ) { + stages += requireNotNull(attributes["stage"]) + assertFalse(throwable.message.orEmpty().contains("private server response")) + } +} + +private val PASSKEY = Passkey(id = 7L, displayName = "Test passkey", createdAt = "2026-09-06T10:00:00") diff --git a/konsist/src/test/kotlin/com/afternote/konsist/MviContractKonsistTest.kt b/konsist/src/test/kotlin/com/afternote/konsist/MviContractKonsistTest.kt index 7e4dadd60..f2ec612a5 100644 --- a/konsist/src/test/kotlin/com/afternote/konsist/MviContractKonsistTest.kt +++ b/konsist/src/test/kotlin/com/afternote/konsist/MviContractKonsistTest.kt @@ -540,8 +540,6 @@ class MviContractKonsistTest { */ private val ISSUE_1805_SETTING = setOf( - "com.afternote.feature.setting.presentation.applock.AppLockSetupViewModel", - "com.afternote.feature.setting.presentation.passkey.PassKeyViewModel", "com.afternote.feature.setting.presentation.receiver.ReceiverRegisterViewModel", ) diff --git a/konsist/src/test/kotlin/com/afternote/konsist/PresentationLayerDependencyKonsistTest.kt b/konsist/src/test/kotlin/com/afternote/konsist/PresentationLayerDependencyKonsistTest.kt index 46f6c5b65..f8ce71958 100644 --- a/konsist/src/test/kotlin/com/afternote/konsist/PresentationLayerDependencyKonsistTest.kt +++ b/konsist/src/test/kotlin/com/afternote/konsist/PresentationLayerDependencyKonsistTest.kt @@ -129,13 +129,7 @@ class PresentationLayerDependencyKonsistTest { val FORBIDDEN_DATA_IMPORT = Regex("""^com\.afternote\.core\.datastore\..*$|^com\.afternote\.core\.data\.repoimpl\..*$""") - /** - * #635 (setting 4건 중 3번) 이 수복한다. `PassKeyViewModel` 이 `UserProfileCacheRepository` - * 를 건너뛰고 `UserProfileDataSource` 를 직접 주입받는다. setting 은 다른 담당이라 - * 이 PR 에서 고치지 않는다. - */ - val KNOWN_LAYER_BYPASSES = - setOf("PassKeyViewModel: com.afternote.core.datastore.UserProfileDataSource") + val KNOWN_LAYER_BYPASSES = emptySet() /** 구현이 사는 모듈 — 이 모듈들의 테스트는 자기 구현을 조립하는 것이 맞다. */ val DATA_LAYER_MODULE_PREFIXES = listOf("core/data/", "core/datastore/")