Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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,
Expand All @@ -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
}
Expand All @@ -60,7 +58,7 @@ sealed interface BiometricAuthResult {
* - 성공은 콜백의 boolean 이 아니라 [confirmWithCryptoOperation] 의 암호 연산 성사로 확정한다.
* 프롬프트를 거치지 않고 성공 콜백만 가로챈 경우 사용자 인증에 묶인 키를 쓸 수 없어 연산이 실패한다.
*/
suspend fun FragmentActivity.authenticateBiometric(
public suspend fun FragmentActivity.authenticateBiometric(
title: String,
subtitle: String,
messages: BiometricMessages,
Expand Down
1 change: 0 additions & 1 deletion feature/afternote/presentation/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions feature/setting/presentation/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ dependencies {
testImplementation(libs.androidx.compose.ui.test.junit4)
testImplementation(libs.androidx.test.core.ktx)
testImplementation(libs.robolectric)
testImplementation(libs.hilt.android.testing)
kspTest(libs.hilt.compiler)
debugImplementation(libs.androidx.compose.ui.test.manifest)

screenshotTestImplementation(libs.screenshot.validation.api)
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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(
Expand All @@ -56,6 +41,18 @@ fun PassKeyMakingScreen(
var isAuthenticating by remember { mutableStateOf(false) }
var errorMessage by remember { mutableStateOf<String?>(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,
Expand Down Expand Up @@ -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
}
}
Expand All @@ -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)
}
}
10 changes: 10 additions & 0 deletions feature/setting/presentation/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,16 @@
<string name="setting_btn_password_auth">비밀번호로 인증하기</string>
<string name="setting_insert_password">비밀번호를 입력해주세요</string>

<!-- 패스키 등록 생체 인증 프롬프트 -->
<string name="setting_biometric_prompt_title">패스키 등록</string>
<string name="setting_biometric_prompt_subtitle">패스키로 사용할 지문을 인식해 주세요.</string>
<string name="setting_biometric_verification_failed">지문 확인에 실패했어요. 다시 시도해 주세요.</string>
<string name="setting_biometric_not_available">지문 인증을 사용할 수 없습니다.</string>
<string name="setting_biometric_init_failed">인증 화면을 불러오지 못했습니다. 앱을 다시 시작해 주세요.</string>
<string name="setting_biometric_no_hardware">이 기기는 생체 인식을 지원하지 않습니다.</string>
<string name="setting_biometric_none_enrolled">등록된 지문이나 화면 잠금이 없습니다. 설정에서 등록해 주세요.</string>
<string name="setting_biometric_hw_unavailable">생체 인식을 일시적으로 사용할 수 없습니다. 잠시 후 다시 시도해 주세요.</string>

<!-- 안내 텍스트 -->
<string name="setting_passkey_section_title">패스키 관리</string>
<string name="setting_passkey_description">패스키를 만들어 비밀번호 대신 지문 및 얼굴 인식으로\n쉽고 안전하게 로그인할 수 있습니다.\n패스키는 본인 소유의 기기에서만 생성해주세요.</string>
Expand Down
Loading
Loading