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
5 changes: 5 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ android {
execution = "ANDROIDX_TEST_ORCHESTRATOR"
// Robolectric 이 병합된 매니페스트·리소스를 읽어야 NavHost 를 실제 컴포지션으로 띄울 수 있다 (#1601).
unitTests.isIncludeAndroidResources = true
unitTests.all {
// 여러 SDK의 앱 리소스를 읽은 뒤에도 후속 테스트의 샌드박스를 생성할 수 있어야 한다.
// 기본 512 MB에서는 API 33 알림 권한 테스트의 리소스 로딩이 OOM으로 실패한다 (#2200).
it.maxHeapSize = "1g"
}
managedDevices {
localDevices {
create("pixel2Api26") {
Expand Down
5 changes: 5 additions & 0 deletions app/src/main/res/values-v29/themes.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- API 29 이상은 시스템 생체 인증창을 사용하므로 기존 Activity 테마를 유지한다. -->
<style name="Theme.Afternotefe.Main" parent="android:Theme.Material.Light.NoActionBar" />
</resources>
6 changes: 3 additions & 3 deletions app/src/main/res/values/themes.xml
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>

<!-- Compose/일반 Activity용 (스플래시 이후 postSplashScreenTheme) -->
<style name="Theme.Afternotefe.Main" parent="android:Theme.Material.Light.NoActionBar" />
<!-- API 26·27 및 일부 API 28 기기의 AndroidX 지문 대화상자는 AppCompat 테마 속성이 필요하다. -->
<style name="Theme.Afternotefe.Main" parent="Theme.AppCompat.Light.NoActionBar" />

<!-- 런처(MainActivity) 전용: Theme.SplashScreen + installSplashScreen() -->
<style name="Theme.Afternotefe.Starting" parent="Theme.SplashScreen">
Expand All @@ -19,4 +19,4 @@
<!-- windowLightNavigationBar 는 API 27+ 라 values-v27/ 에 둔다 (minSdk 26). -->
<item name="android:windowLightStatusBar">true</item>
</style>
</resources>
</resources>
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package com.afternote.afternote_fe

import android.app.Activity
import android.app.Application
import android.app.Dialog
import android.content.Context
import android.content.DialogInterface
import android.os.Looper
import android.util.TypedValue
import android.widget.Button
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config

/** 실제 앱 테마로 AndroidX 지문 대화상자와 같은 AppCompat dialog를 생성한다. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [26, 27, 28], application = Application::class)
class BiometricDialogThemeTest {
@Test
fun `API26부터 28 앱 테마는 AppCompat 인증 대화상자를 띄우고 닫을 수 있다`() {
val controller = Robolectric.buildActivity(Activity::class.java)
controller.get().setTheme(R.style.Theme_Afternotefe_Main)
controller.setup()
val activity = controller.get()
var canceled = false
val dialog = createAppCompatDialog(activity) { canceled = true }

try {
// 플랫폼 Material 테마만 있으면 show()에서 AppCompat 테마 요구 예외가 발생한다.
dialog.show()
assertTrue(dialog.isShowing)
dialog.findViewById<Button>(android.R.id.button2).performClick()
shadowOf(Looper.getMainLooper()).idle()
assertTrue(canceled)
assertFalse(dialog.isShowing)
} finally {
dialog.dismiss()
controller.pause().stop().destroy()
}
}

@Test
@Config(sdk = [29, 30, 35])
fun `API29 이상은 플랫폼 라이트 테마와 액션바 없는 동작을 유지한다`() {
val controller = Robolectric.buildActivity(Activity::class.java)
controller.get().setTheme(R.style.Theme_Afternotefe_Main)
controller.setup()
val activity = controller.get()
val dialog =
android.app.AlertDialog
.Builder(activity)
.setTitle("본인 인증")
.setNegativeButton(android.R.string.cancel, null)
.create()

try {
val attributes =
activity.obtainStyledAttributes(
intArrayOf(android.R.attr.isLightTheme, android.R.attr.windowActionBar, android.R.attr.windowNoTitle),
)
try {
assertTrue(attributes.getBoolean(0, false))
assertFalse(attributes.getBoolean(1, true))
assertTrue(attributes.getBoolean(2, false))
} finally {
attributes.recycle()
}
assertFalse(activity.theme.resolveAttribute(androidx.appcompat.R.attr.windowActionBar, TypedValue(), true))
dialog.show()
assertTrue(dialog.isShowing)
dialog.getButton(android.app.AlertDialog.BUTTON_NEGATIVE).performClick()
shadowOf(Looper.getMainLooper()).idle()
assertFalse(dialog.isShowing)
} finally {
dialog.dismiss()
controller.pause().stop().destroy()
}
}

private fun createAppCompatDialog(
context: Context,
onCancel: () -> Unit,
): Dialog {
// AppCompat는 biometric의 runtime 의존이다. 테스트용 compile 의존을 늘리지 않고
// 실제 라이브러리의 공개 Builder API를 호출한다.
val builderClass = Class.forName("androidx.appcompat.app.AlertDialog\$Builder")
val builder = builderClass.getConstructor(Context::class.java).newInstance(context)
builderClass.getMethod("setTitle", CharSequence::class.java).invoke(builder, "지문 인증")
builderClass.getMethod("setMessage", CharSequence::class.java).invoke(builder, "지문 센서를 터치하세요")
builderClass
.getMethod("setNegativeButton", Int::class.javaPrimitiveType, DialogInterface.OnClickListener::class.java)
.invoke(builder, android.R.string.cancel, DialogInterface.OnClickListener { _, _ -> onCancel() })
return builderClass.getMethod("create").invoke(builder) as Dialog
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.afternote.core.common.biometric

import android.os.Build
import android.util.Log
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG
Expand Down Expand Up @@ -55,8 +56,10 @@ public sealed interface BiometricAuthResult {
* `MainActivity`는 반드시 `FragmentActivity`(또는 `AppCompatActivity`)를 상속해야 한다.
* - [BiometricPrompt]는 UI 컴포넌트이므로 본문을 [Dispatchers.Main.immediate]에서 실행해,
* 호출 코루틴이 백그라운드 디스패처에 있어도 초기화·실행이 메인 스레드로 수렴하도록 한다.
* - 성공은 콜백의 boolean 이 아니라 [confirmWithCryptoOperation] 의 암호 연산 성사로 확정한다.
* 프롬프트를 거치지 않고 성공 콜백만 가로챈 경우 사용자 인증에 묶인 키를 쓸 수 없어 연산이 실패한다.
* - 성공 콜백의 cipher는 [confirmWithCryptoOperation]으로 연산을 검증한다.
* API 30 이상에서 앱 소유 CryptoObject를 준비한 경우에는 사용자 인증에 묶인 키이므로
* 인증 없이 성공 콜백만 가로채 호출하면 연산이 실패한다. AndroidX의 내부 cipher에는
* 이 인증 결합을 가정하지 않으며, 플랫폼 프롬프트의 강한 생체 인증 결과를 따른다.
*/
public suspend fun FragmentActivity.authenticateBiometric(
title: String,
Expand All @@ -66,12 +69,15 @@ public suspend fun FragmentActivity.authenticateBiometric(
withContext(Dispatchers.Main.immediate) {
suspendCancellableCoroutine { continuation ->
val biometricManager = BiometricManager.from(this@authenticateBiometric)
val authenticators = BIOMETRIC_STRONG or DEVICE_CREDENTIAL
// Android 9·10은 STRONG과 기기 자격의 조합을 지원하지 않는다.
// 강도를 낮추지 않고 이 두 버전에서만 생체 인증을 단독 사용한다.
val biometricOnly = Build.VERSION.SDK_INT in Build.VERSION_CODES.P..Build.VERSION_CODES.Q
val authenticators = if (biometricOnly) BIOMETRIC_STRONG else BIOMETRIC_STRONG or DEVICE_CREDENTIAL

when (biometricManager.canAuthenticate(authenticators)) {
BiometricManager.BIOMETRIC_SUCCESS -> {
// API 26~29 에서는 DEVICE_CREDENTIAL 허용자와 CryptoObject 를 함께 쓸 수 없다.
// 이때는 관문 없이 인증하고, 성공 확정은 cipher 부재로 통과시킨다.
// 앱 소유 CryptoObject는 기존 API 30 이상 정책을 유지한다.
// API 28~29의 STRONG 강제는 AndroidX 프롬프트가 담당한다.
val cryptoObject = if (isBiometricCryptoSupported) createBiometricCryptoObject() else null

val promptInfo =
Expand All @@ -80,7 +86,11 @@ public suspend fun FragmentActivity.authenticateBiometric(
.setTitle(title)
.setSubtitle(subtitle)
.setAllowedAuthenticators(authenticators)
.build()
.apply {
if (biometricOnly) {
setNegativeButtonText(getString(android.R.string.cancel))
}
}.build()

val biometricPrompt =
BiometricPrompt(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,16 @@ private val TRANSFORMATION =
private val CRYPTO_CHALLENGE = "afternote-biometric-gate".toByteArray()

/**
* `CryptoObject` 를 동반한 인증이 가능한 API 레벨인지.
* 앱 소유 `CryptoObject` 를 동반하는 인증 경로인지.
*
* `androidx.biometric` 1.1.0 의 `authenticate(promptInfo, cryptoObject)` 는 허용자에
* `androidx.biometric` 의 `authenticate(promptInfo, cryptoObject)` 는 허용자에
* `DEVICE_CREDENTIAL` 이 포함된 채 API 30 미만이면 다음 메시지로 `IllegalArgumentException` 을 던진다.
*
* > Crypto-based authentication is not supported for device credential prior to API 30.
*
* minSdk 는 26 이고 기기 잠금(PIN·패턴)만 등록한 사용자를 막지 않는 것이 현 정책이므로,
* API 26~29 에서는 허용자 조합을 유지한 채 `CryptoObject` 없이 인증한다.
* API 26~27은 기존 기기 자격 폴백을 유지한다. API 28~29는 강한 생체 인증만 허용하며
* 강도 제한에 필요한 내부 CryptoObject는 AndroidX가 준비한다.
* 앱 소유 CryptoObject의 사용 범위는 기존과 같이 API 30 이상으로 유지한다.
*/
val isBiometricCryptoSupported: Boolean
get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R
Expand All @@ -51,13 +52,15 @@ fun createBiometricCryptoObject(): BiometricPrompt.CryptoObject? =
.getOrNull()

/**
* 인증 성공 콜백이 받은 [cipher] 로 실제 암호 연산을 수행해 인증을 확정한다.
* 인증 성공 콜백이 받은 [cipher] 로 실제 암호 연산이 가능한지 검증한다.
*
* 키가 `setUserAuthenticationRequired(true)` 로 만들어져 있어, 프롬프트를 거치지 않은 채
* 성공 콜백만 가로채 호출한 경우에는 이 연산이 `UserNotAuthenticatedException` 으로 실패한다.
* 즉 성패 boolean 이 아니라 암호 연산의 성사 여부가 인증의 근거가 된다.
* [createBiometricCryptoObject]의 앱 소유 키는 `setUserAuthenticationRequired(true)`로
* 만들어져 있어, 프롬프트를 거치지 않고 성공 콜백만 가로채 호출하면 이 연산이 실패한다.
* AndroidX가 강도 제한을 위해 준비하는 내부 cipher는 사용자 인증에 묶인 키가 아니므로
* 같은 연산을 수행하더라도 그 연산 자체를 사용자 인증의 추가 증거로 간주하지 않는다.
*
* [cipher] 가 null 인 경로(API 26~29 폴백)는 검증할 대상이 없으므로 통과시킨다.
* [cipher] 가 null 인 폴백은 검증할 대상이 없으므로 통과시킨다.
* API 30 미만에도 AndroidX가 내부 cipher를 반환하면 동일한 암호 연산으로 검증한다.
* 실패 원인을 호출 측이 기록할 수 있도록 예외를 삼키지 않고 [Result] 로 실어 보낸다.
*
* **거부는 `UserNotAuthenticatedException` 으로 오지 않는다.** API 35 실측(2026-08-26)에서
Expand Down
Loading
Loading