diff --git a/identity-issuance/src/main/java/com/android/identity/issuance/evidence/EvidenceRequestMessage.kt b/identity-issuance/src/main/java/com/android/identity/issuance/evidence/EvidenceRequestMessage.kt index 91e580f27..e02c917a0 100644 --- a/identity-issuance/src/main/java/com/android/identity/issuance/evidence/EvidenceRequestMessage.kt +++ b/identity-issuance/src/main/java/com/android/identity/issuance/evidence/EvidenceRequestMessage.kt @@ -9,10 +9,16 @@ import kotlinx.io.bytestring.ByteString * [assets] images that can be referenced in markdown, type (PNG, JPEG, or SVG) determined by the extension * [acceptButtonText] button label to continue to the next screen * [rejectButtonText] optional button label to continue without accepting + * [messageType] optional identifier used for classifying message types (success, error, info, ..) + * for showing a more appropriate UI than a standard basic message + * [messageTitle] optional title to show above the message for a non-standard or non-basic message + * that is filtered/classified via the [messageType] */ data class EvidenceRequestMessage( val message: String, val assets: Map, val acceptButtonText: String, val rejectButtonText: String?, + val messageType: String? = null, + val messageTitle: String? = null ) : EvidenceRequest() \ No newline at end of file diff --git a/identity-issuance/src/main/java/com/android/identity/issuance/proofing/ProofingGraphBuilder.kt b/identity-issuance/src/main/java/com/android/identity/issuance/proofing/ProofingGraphBuilder.kt index 6b0a26c13..90ea878c2 100644 --- a/identity-issuance/src/main/java/com/android/identity/issuance/proofing/ProofingGraphBuilder.kt +++ b/identity-issuance/src/main/java/com/android/identity/issuance/proofing/ProofingGraphBuilder.kt @@ -10,8 +10,8 @@ import com.android.identity.issuance.evidence.EvidenceRequestQuestionString import com.android.identity.issuance.evidence.EvidenceRequestSelfieVideo import com.android.identity.issuance.evidence.EvidenceRequestSetupCloudSecureArea import com.android.identity.issuance.evidence.EvidenceResponse -import com.android.identity.securearea.PassphraseConstraints import com.android.identity.issuance.proofing.ProofingGraph.Node +import com.android.identity.securearea.PassphraseConstraints import kotlinx.io.bytestring.ByteString /** @@ -27,9 +27,24 @@ class ProofingGraphBuilder { private val chain = mutableListOf<(Node?) -> Node>() /** Sends [EvidenceRequestMessage]. */ - fun message(id: String, message: String, assets: Map, - acceptButtonText: String, rejectButtonText: String?) { - val evidenceRequest = EvidenceRequestMessage(message, assets, acceptButtonText, rejectButtonText) + fun message( + id: String, + message: String, + assets: Map, + acceptButtonText: String, + rejectButtonText: String?, + messageType: String? = null, + messageTitle: String? = null + ) { + val evidenceRequest = + EvidenceRequestMessage( + message, + assets, + acceptButtonText, + rejectButtonText, + messageType, + messageTitle, + ) chain.add { followUp -> ProofingGraph.SimpleNode(id, followUp, evidenceRequest) } } diff --git a/identity-issuance/src/main/java/com/android/identity/issuance/proofing/defaultGraph.kt b/identity-issuance/src/main/java/com/android/identity/issuance/proofing/defaultGraph.kt index 01960fdaa..8293898cc 100644 --- a/identity-issuance/src/main/java/com/android/identity/issuance/proofing/defaultGraph.kt +++ b/identity-issuance/src/main/java/com/android/identity/issuance/proofing/defaultGraph.kt @@ -200,14 +200,18 @@ fun defaultGraph( } } message( - "message", - message = """ + id = "message", + message = resources.getStringResource("R.string.evidence_request_complete_info") ?: """ Your application is about to be sent the ID issuer for verification. You will get notified when the application is approved. """.trimIndent(), + messageType = "evidence_request_complete", + messageTitle = resources.getStringResource("R.string.evidence_request_complete_title") + ?: "Submitted for Verification", assets = mapOf(), - acceptButtonText = "Continue", - null + acceptButtonText = resources.getStringResource("R.string.evidence_request_complete_done_button") + ?: "Continue", + rejectButtonText = null ) requestNotificationPermission( "notificationPermission", diff --git a/mrtd-reader-android/src/main/java/com/android/identity/mrtd/MrtdMrzScanner.kt b/mrtd-reader-android/src/main/java/com/android/identity/mrtd/MrtdMrzScanner.kt index dfed56d51..b15e1b100 100644 --- a/mrtd-reader-android/src/main/java/com/android/identity/mrtd/MrtdMrzScanner.kt +++ b/mrtd-reader-android/src/main/java/com/android/identity/mrtd/MrtdMrzScanner.kt @@ -6,8 +6,11 @@ import androidx.activity.ComponentActivity import androidx.camera.core.CameraSelector import androidx.camera.core.ExperimentalGetImage import androidx.camera.core.ImageAnalysis +import androidx.camera.core.ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST import androidx.camera.core.ImageProxy import androidx.camera.core.Preview +import androidx.camera.core.resolutionselector.ResolutionSelector +import androidx.camera.core.resolutionselector.ResolutionStrategy import androidx.camera.lifecycle.ProcessCameraProvider import com.google.mlkit.vision.common.InputImage import com.google.mlkit.vision.text.TextRecognition @@ -46,10 +49,20 @@ class MrtdMrzScanner(private val mActivity: ComponentActivity) { val previewUseCase = Preview.Builder().build() previewUseCase.setSurfaceProvider(surfaceProvider) - val analysisBuilder = ImageAnalysis.Builder() // we take the portrait format of the Image. - analysisBuilder.setTargetResolution(Size(4 * 480, 4 * 640)) - val analysisUseCase = analysisBuilder.build() + val analysisUseCase = ImageAnalysis.Builder() + .setResolutionSelector( + ResolutionSelector.Builder() + .setResolutionStrategy( + ResolutionStrategy( + Size(4 * 480, 4 * 640), + ResolutionStrategy.FALLBACK_RULE_CLOSEST_HIGHER_THEN_LOWER + ) + ) + .build() + ) + .setBackpressureStrategy(STRATEGY_KEEP_ONLY_LATEST) + .build() val executor = Executors.newFixedThreadPool(1)!! return suspendCoroutine { continuation -> analysisUseCase.setAnalyzer( diff --git a/wallet/src/main/java/com/android/identity/issuance/remote/LocalDevelopmentEnvironment.kt b/wallet/src/main/java/com/android/identity/issuance/remote/LocalDevelopmentEnvironment.kt index 94366ebf1..6721270ce 100644 --- a/wallet/src/main/java/com/android/identity/issuance/remote/LocalDevelopmentEnvironment.kt +++ b/wallet/src/main/java/com/android/identity/issuance/remote/LocalDevelopmentEnvironment.kt @@ -5,11 +5,11 @@ import android.graphics.Bitmap import android.graphics.BitmapFactory import android.os.Handler import androidx.annotation.RawRes +import com.android.identity.flow.handler.FlowNotifications import com.android.identity.flow.server.Configuration +import com.android.identity.flow.server.FlowEnvironment import com.android.identity.flow.server.Resources import com.android.identity.flow.server.Storage -import com.android.identity.flow.server.FlowEnvironment -import com.android.identity.flow.handler.FlowNotifications import com.android.identity.issuance.ApplicationSupport import com.android.identity.securearea.SecureArea import com.android.identity_credential.wallet.R @@ -199,7 +199,28 @@ internal class LocalDevelopmentEnvironment( context.resources.getString(R.string.utopia_local_issuing_authority_photoid_tos) "funke/tos.html" -> context.resources.getString(R.string.funke_issuing_authority_tos) - else -> null + else -> { + // String passthrough allowing upstream code to reference downstream Strings + // there's no need to hard-code/define every possible String in here + if (name.startsWith("R.string.")){ + val stringName = name.split("R.string.")[1] + /** + * Localized function resolving a String resource ID from a String's name. + * Note: This approach uses reflection under the hood and is less efficient + * than retrieving a String directly by its identifier. If this ends + * up being a bottleneck we can change the approach of how downstream + * Strings/resources are shared upstream. + */ + fun getStringResId(stringName: String): Int { + return context.resources.getIdentifier(stringName, "string", context.packageName) + } + // return the requested String's value + context.resources.getString(getStringResId(stringName)) + } else { + // unknown raw file name or String resource, return null + null + } + } } } diff --git a/wallet/src/main/java/com/android/identity_credential/wallet/ui/destination/provisioncredential/EvidenceRequest.kt b/wallet/src/main/java/com/android/identity_credential/wallet/ui/destination/provisioncredential/EvidenceRequest.kt index 90fd51d39..fd21b5c73 100644 --- a/wallet/src/main/java/com/android/identity_credential/wallet/ui/destination/provisioncredential/EvidenceRequest.kt +++ b/wallet/src/main/java/com/android/identity_credential/wallet/ui/destination/provisioncredential/EvidenceRequest.kt @@ -9,17 +9,30 @@ import android.view.ViewGroup import android.widget.LinearLayout import androidx.camera.view.PreviewView import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.LinearOutSlowInEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight +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.layout.size import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.RadioButton import androidx.compose.material3.Text @@ -35,11 +48,18 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView @@ -91,7 +111,6 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import kotlin.time.Duration.Companion.seconds - private const val TAG = "EvidenceRequest" @Composable @@ -143,6 +162,89 @@ fun EvidenceRequestMessageView( } } +/** + * Show the "Document Scanning Complete" screen at the end of the Evidence-Request gathering steps + * for Provisioning a Personal ID. + */ +@Composable +fun EvidenceRequestCompleteScreen( + evidenceRequest: EvidenceRequestMessage, + provisioningViewModel: ProvisioningViewModel, + walletServerProvider: WalletServerProvider, + documentStore: DocumentStore +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 10.dp) + ) { + Image( + painter = painterResource(id = R.drawable.id_card), + contentDescription = stringResource( + R.string.accessibility_artwork_for, + evidenceRequest.messageTitle!! + ), + modifier = Modifier + .size(48.dp) + .padding(start = 16.dp) + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + Text( + text = evidenceRequest.messageTitle!!, + modifier = Modifier.padding(16.dp), + textAlign = TextAlign.Left, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.headlineLarge + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Absolute.Left + ) { + Text( + modifier = Modifier.padding(start = 16.dp, end = 16.dp), + text = evidenceRequest.message, + style = MaterialTheme.typography.bodyLarge + ) + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 200.dp, bottom = 150.dp), + horizontalArrangement = Arrangement.Center + ) { + Icon( + painter = painterResource(id = R.drawable.check_circled), + tint = colorResource(id = R.color.success_green), + modifier = Modifier.size(100.dp), + contentDescription = stringResource( + R.string.accessibility_artwork_for, + evidenceRequest.messageTitle!! + ) + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + Button( + modifier = Modifier.padding(8.dp), + onClick = { + provisioningViewModel.provideEvidence( + evidence = EvidenceResponseMessage(true), + walletServerProvider = walletServerProvider, + documentStore = documentStore + ) + }) { + Text(evidenceRequest.acceptButtonText) + } + } + } +} + @OptIn(ExperimentalPermissionsApi::class) @Composable fun EvidenceRequestNotificationPermissionView( @@ -649,6 +751,7 @@ fun EvidenceRequestIcaoNfcTunnelView( provisioningViewModel: ProvisioningViewModel, permissionTracker: PermissionTracker ) { + var showSuccessfulScanningScreen by remember { mutableStateOf(false) } val tunnelScanner = NfcTunnelScanner(provisioningViewModel) // Start with the camera scan only if it was requested val initialRoute = @@ -660,8 +763,79 @@ fun EvidenceRequestIcaoNfcTunnelView( // data scanned from passport MRZ strip. IcaoMrtdCommunicationModel.Route.CAMERA_SCAN } - EvidenceRequestIcaoView(tunnelScanner, permissionTracker, initialRoute) { - provisioningViewModel.finishTunnel() + + // show the Evidence Request Camera/NFC Screen + if (!showSuccessfulScanningScreen) { + EvidenceRequestIcaoView(tunnelScanner, permissionTracker, initialRoute) { + // upon completion of nfc scanning, show a transitional "Success" screen + showSuccessfulScanningScreen = true + } + } else { + // briefly show a transitional success screen then finish the tunneling process + DocumentSuccessfullyScannedScreen(pauseDurationMs = 1000L) { + provisioningViewModel.finishTunnel() + } + } +} + +/** + * Briefly show the "Document Scanning Successful" screen before moving to the next screen/step + * of evidence request (automatically after waiting for [pauseDurationMs]). + */ +@Composable +fun DocumentSuccessfullyScannedScreen(pauseDurationMs: Long, onFinishedShowingScreen: () -> Unit) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 10.dp) + ) { + Image( + painter = painterResource(id = R.drawable.id_card), + contentDescription = stringResource( + R.string.accessibility_artwork_for, + stringResource(id = R.string.evidence_nfc_scan_successful_title) + ), + modifier = Modifier + .size(48.dp) + .padding(start = 16.dp) + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + Text( + text = stringResource(R.string.evidence_nfc_scan_successful_title), + modifier = Modifier.padding(16.dp), + textAlign = TextAlign.Left, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.headlineLarge + ) + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 100.dp, bottom = 200.dp), + horizontalArrangement = Arrangement.Center + ) { + Column( + modifier = Modifier.padding(top = 80.dp, bottom = 50.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + painter = painterResource(id = R.drawable.check_circled), + tint = colorResource(id = R.color.success_green), + modifier = Modifier.size(100.dp), + contentDescription = stringResource( + R.string.accessibility_artwork_for, + stringResource(id = R.string.evidence_nfc_scan_successful_title) + ) + ) + } + } + } + LaunchedEffect(key1 = true) { + delay(pauseDurationMs) + onFinishedShowingScreen.invoke() } } @@ -680,9 +854,23 @@ fun EvidenceRequestIcaoView( permissionTracker.PermissionCheck(requiredPermissions) { val navController = rememberNavController() val icaoCommunication = rememberIcaoMrtdCommunicationModel(reader, navController, onResult) - NavHost(navController = navController, startDestination = initialRoute.route ) { + NavHost(navController = navController, startDestination = initialRoute.route) { composable(IcaoMrtdCommunicationModel.Route.CAMERA_SCAN.route) { - Column { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 10.dp) + ) { + Image( + painter = painterResource(id = R.drawable.id_card), + contentDescription = stringResource( + R.string.accessibility_artwork_for, + stringResource(id = R.string.evidence_camera_scan_mrz_title) + ), + modifier = Modifier + .size(48.dp) + .padding(start = 16.dp) + ) Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center @@ -690,13 +878,15 @@ fun EvidenceRequestIcaoView( Text( text = stringResource(R.string.evidence_camera_scan_mrz_title), modifier = Modifier.padding(16.dp), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.titleLarge + textAlign = TextAlign.Left, + style = MaterialTheme.typography.headlineLarge ) } Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 5.dp), + horizontalArrangement = Arrangement.Absolute.Left ) { Text( modifier = Modifier.padding(start = 16.dp, end = 16.dp), @@ -706,85 +896,146 @@ fun EvidenceRequestIcaoView( } Row( modifier = Modifier - .fillMaxWidth() - .fillMaxHeight(), + .fillMaxSize() + .padding(vertical = 10.dp), horizontalArrangement = Arrangement.Center ) { - AndroidView( - modifier = Modifier.padding(16.dp), - factory = { context -> - PreviewView(context).apply { - layoutParams = LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT - ) - scaleType = PreviewView.ScaleType.FILL_START - implementationMode = PreviewView.ImplementationMode.COMPATIBLE - post { - icaoCommunication.launchCameraScan(surfaceProvider) + Column { + AndroidView( + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + .clip(RoundedCornerShape(32.dp)) + .background( + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.6f) + ), + factory = { context -> + PreviewView(context).apply { + layoutParams = LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT - 100 + ) + scaleType = PreviewView.ScaleType.FILL_CENTER + implementationMode = + PreviewView.ImplementationMode.COMPATIBLE + post { + icaoCommunication.launchCameraScan(surfaceProvider) + } } } - } - ) + ) + } } } } composable(IcaoMrtdCommunicationModel.Route.NFC_SCAN.route) { - Column { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 10.dp) + ) { + Image( + painter = painterResource(id = R.drawable.id_card), + contentDescription = stringResource( + R.string.accessibility_artwork_for, + stringResource(id = R.string.evidence_camera_scan_mrz_title) + ), + modifier = Modifier + .size(48.dp) + .padding(start = 16.dp) + ) Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center ) { Text( text = stringResource(R.string.evidence_nfc_scan_title), - textAlign = TextAlign.Center, - modifier = Modifier.padding(8.dp), - style = MaterialTheme.typography.titleLarge + modifier = Modifier.padding(16.dp), + textAlign = TextAlign.Left, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.headlineLarge ) } Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center + horizontalArrangement = Arrangement.Absolute.Left ) { Text( - style = MaterialTheme.typography.bodyLarge, - text = when (icaoCommunication.status.value) { - is MrtdNfc.Initial -> stringResource(R.string.nfc_status_initial) - is MrtdNfc.Connected -> stringResource(R.string.nfc_status_connected) - is MrtdNfc.AttemptingPACE -> stringResource(R.string.nfc_status_attempting_pace) - is MrtdNfc.PACESucceeded -> stringResource(R.string.nfc_status_pace_succeeded) - is MrtdNfc.PACENotSupported -> stringResource(R.string.nfc_status_pace_not_supported) - is MrtdNfc.PACEFailed -> stringResource(R.string.nfc_status_pace_failed) - is MrtdNfc.AttemptingBAC -> stringResource(R.string.nfc_status_attempting_bac) - is MrtdNfc.BACSucceeded -> stringResource(R.string.nfc_status_bac_succeeded) - is MrtdNfc.ReadingData -> { - val s = icaoCommunication.status.value as MrtdNfc.ReadingData - stringResource( - R.string.nfc_status_reading_data, - s.progressPercent - ) - } - - is MrtdNfc.TunnelAuthenticating -> { - val s = - icaoCommunication.status.value as MrtdNfc.TunnelAuthenticating - stringResource( - R.string.nfc_status_tunnel_authenticating, - s.progressPercent - ) - } - - is MrtdNfc.TunnelReading -> { - val s = icaoCommunication.status.value as MrtdNfc.TunnelReading - stringResource( - R.string.nfc_status_tunnel_reading_data, - s.progressPercent - ) + modifier = Modifier.padding(start = 16.dp, end = 16.dp), + text = stringResource(R.string.evidence_nfc_scan_info), + style = MaterialTheme.typography.bodyLarge + ) + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 30.dp), + horizontalArrangement = Arrangement.Center + ) { + Column( + modifier = Modifier.padding(top = 80.dp, bottom = 50.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + val icaoStatusValue = icaoCommunication.status.value + NfcHeartbeatAnimation( + NfcAnimationStatus.getAnimationStatus(icaoStatusValue) + ) + Text( + modifier = Modifier.padding(top = 45.dp), + style = MaterialTheme.typography.bodyLarge, + fontFamily = FontFamily.SansSerif, + text = when (icaoStatusValue) { + is MrtdNfc.Connected -> stringResource(R.string.nfc_status_connected) + is MrtdNfc.AttemptingPACE -> stringResource(R.string.nfc_status_attempting_pace) + is MrtdNfc.PACESucceeded -> stringResource(R.string.nfc_status_pace_succeeded) + is MrtdNfc.PACENotSupported -> stringResource(R.string.nfc_status_pace_not_supported) + is MrtdNfc.PACEFailed -> stringResource(R.string.nfc_status_pace_failed) + is MrtdNfc.AttemptingBAC -> stringResource(R.string.nfc_status_attempting_bac) + is MrtdNfc.BACSucceeded -> stringResource(R.string.nfc_status_bac_succeeded) + is MrtdNfc.ReadingData -> + stringResource( + R.string.nfc_status_reading_data, + icaoStatusValue.progressPercent + ) + is MrtdNfc.TunnelAuthenticating -> + stringResource( + R.string.nfc_status_tunnel_authenticating, + icaoStatusValue.progressPercent + ) + is MrtdNfc.TunnelReading -> + stringResource( + R.string.nfc_status_tunnel_reading_data, + icaoStatusValue.progressPercent + ) + is MrtdNfc.Finished -> stringResource(R.string.nfc_status_finished) + else -> "" } - - is MrtdNfc.Finished -> stringResource(R.string.nfc_status_finished) - } + ) + } + } + Column( + modifier = Modifier + .padding(top = 60.dp) + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + painter = painterResource(id = R.drawable.passport_biometric_icon), + tint = Color.LightGray, + contentDescription = stringResource( + R.string.accessibility_artwork_for, + stringResource(id = R.string.evidence_nfc_scan_passport_symbol_accesibility_description) + ), + modifier = Modifier + .size(48.dp) + .padding(start = 16.dp) + .clip(RoundedCornerShape(32.dp)) + ) + Text( + modifier = Modifier.padding(start = 16.dp, end = 16.dp), + text = stringResource(R.string.evidence_nfc_scan_passport_symbol_check), + style = MaterialTheme.typography.labelMedium ) } } @@ -793,6 +1044,124 @@ fun EvidenceRequestIcaoView( } } +/** + * Nfc Animation Status - used to update the NFC "contactless" icon colors and animation according + * to the value of [MrtdNfc.Status] + */ +enum class NfcAnimationStatus { + // Waiting to detect an NFC card + Initial, + // Any state that's not Initial, Error, or Finished + Connected, + // Some error occurred + Error, + // Finished scanning NFC document + Finished + ; + companion object { + /** + * Return an [NfcAnimationStatus] from an MrtdNfc.Status + */ + fun getAnimationStatus(nfcStatus: MrtdNfc.Status): NfcAnimationStatus = when (nfcStatus) { + MrtdNfc.Initial -> Initial + MrtdNfc.PACENotSupported, MrtdNfc.PACEFailed -> Error + MrtdNfc.Finished -> Finished + else -> Connected + } + } +} + +/** + * Compose the NFC "Heartbeat" animation. + */ +@Composable +fun NfcHeartbeatAnimation(nfcAnimationStatus: NfcAnimationStatus) { + val infiniteTransition = rememberInfiniteTransition(label = "repeating transition") + // duration of all animation transitions + val transitionDurationMs = 1500 + // adjust size of contactless icon here + val iconSize = 80.dp + // define the starting alpha of background color of radius animation + val animationBgAlpha = 0.1F + // transition animation that grows the radius around the contactless icon + val scale by infiniteTransition.animateFloat( + initialValue = 1f, + targetValue = 2f, + animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = transitionDurationMs, + easing = LinearOutSlowInEasing + ), + repeatMode = RepeatMode.Restart + ), label = "scaling animation" + ) + // transition animation for changing alpha of background color of radius animation + val alpha by infiniteTransition.animateFloat( + initialValue = animationBgAlpha, + targetValue = 0f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = transitionDurationMs, easing = LinearEasing), + repeatMode = RepeatMode.Restart + ), label = "alpha animation" + ) + // start composing the "nfc heartbeat" animation + Box( + contentAlignment = Alignment.Center, + ) { + // don't show the radius animation when encountering an error + if (nfcAnimationStatus != NfcAnimationStatus.Error) { + Box( + modifier = Modifier + .size(iconSize) // heartbeat size + .graphicsLayer(scaleX = scale, scaleY = scale) + .alpha(alpha) + .background(colorResource(id = R.color.contactless_blue), CircleShape) + ) + } + // Clip the icon to be circular + Box( + modifier = Modifier.clip(CircleShape) + ) { + // get the main and background colors of the "contactless" icon + val (tintColor, bgColor) = when (nfcAnimationStatus) { + // waiting to begin reading an NFC document + // contactless blue color icon with the same color for background having a low alpha + NfcAnimationStatus.Initial -> Pair( + colorResource(R.color.contactless_blue), + colorResource(R.color.contactless_blue).copy(alpha = animationBgAlpha) + ) + // once an NFC document is detected, inverse colors of "Initial" status + NfcAnimationStatus.Connected -> Pair( + MaterialTheme.colorScheme.background, + colorResource(R.color.contactless_blue) + ) + // Error while initializing/reading NFC card + // white icon with light red background with a lower alpha + NfcAnimationStatus.Error -> Pair( + MaterialTheme.colorScheme.background, + Color.Red.copy(alpha = 0.3F) + ) + // NFC scan complete -- colors are not used b/c icon is changed entirely + else -> Pair(Color.Transparent, Color.Transparent) + } + // compose the "contactless" icon + Icon( + painter = painterResource(id = R.drawable.contactless), + contentDescription = stringResource( + R.string.accessibility_artwork_for, + stringResource(id = R.string.evidence_nfc_scan_title) + ), + // icon color + tint = tintColor, + modifier = Modifier + .size(iconSize) + // background color + .background(bgColor) + ) + } + } +} + @Composable fun EvidenceRequestSelfieVideoView( evidenceRequest: EvidenceRequestSelfieVideo, diff --git a/wallet/src/main/java/com/android/identity_credential/wallet/ui/destination/provisioncredential/ProvisionCredentialScreen.kt b/wallet/src/main/java/com/android/identity_credential/wallet/ui/destination/provisioncredential/ProvisionCredentialScreen.kt index 6f93e8d97..f585e3f3e 100644 --- a/wallet/src/main/java/com/android/identity_credential/wallet/ui/destination/provisioncredential/ProvisionCredentialScreen.kt +++ b/wallet/src/main/java/com/android/identity_credential/wallet/ui/destination/provisioncredential/ProvisionCredentialScreen.kt @@ -1,7 +1,6 @@ package com.android.identity_credential.wallet.ui.destination.provisioncredential import android.content.Context -import android.widget.Toast import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth @@ -149,12 +148,26 @@ fun ProvisionDocumentScreen( } is EvidenceRequestMessage -> { - EvidenceRequestMessageView( - evidenceRequest, - provisioningViewModel = provisioningViewModel, - walletServerProvider = walletServerProvider, - documentStore = documentStore - ) + // show a standard message that does not have a [messageType] defined + if (evidenceRequest.messageType==null) { + EvidenceRequestMessageView( + evidenceRequest = evidenceRequest, + provisioningViewModel = provisioningViewModel, + walletServerProvider = walletServerProvider, + documentStore = documentStore + ) + } else { + // handle showing a non-standard message according to the classification + // of the defined [messageType] + if (evidenceRequest.messageType=="evidence_request_complete"){ + EvidenceRequestCompleteScreen( + evidenceRequest = evidenceRequest, + provisioningViewModel = provisioningViewModel, + walletServerProvider = walletServerProvider, + documentStore = documentStore + ) + } + } } is EvidenceRequestNotificationPermission -> { diff --git a/wallet/src/main/res/drawable/check_circled.xml b/wallet/src/main/res/drawable/check_circled.xml new file mode 100644 index 000000000..0442cc8e5 --- /dev/null +++ b/wallet/src/main/res/drawable/check_circled.xml @@ -0,0 +1,20 @@ + + + + diff --git a/wallet/src/main/res/drawable/contactless.xml b/wallet/src/main/res/drawable/contactless.xml new file mode 100644 index 000000000..19f9a231a --- /dev/null +++ b/wallet/src/main/res/drawable/contactless.xml @@ -0,0 +1,9 @@ + + + diff --git a/wallet/src/main/res/drawable/id_card.xml b/wallet/src/main/res/drawable/id_card.xml new file mode 100644 index 000000000..7907359d5 --- /dev/null +++ b/wallet/src/main/res/drawable/id_card.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/wallet/src/main/res/drawable/passport_biometric_icon.xml b/wallet/src/main/res/drawable/passport_biometric_icon.xml new file mode 100644 index 000000000..5c638aab5 --- /dev/null +++ b/wallet/src/main/res/drawable/passport_biometric_icon.xml @@ -0,0 +1,9 @@ + + + diff --git a/wallet/src/main/res/values/colors.xml b/wallet/src/main/res/values/colors.xml index f8c6127d3..e267864ac 100644 --- a/wallet/src/main/res/values/colors.xml +++ b/wallet/src/main/res/values/colors.xml @@ -7,4 +7,6 @@ #FF018786 #FF000000 #FFFFFFFF + #FF0E4DC4 + #FF64CF85 \ No newline at end of file diff --git a/wallet/src/main/res/values/strings.xml b/wallet/src/main/res/values/strings.xml index 8ba28e0b9..109f06aee 100644 --- a/wallet/src/main/res/values/strings.xml +++ b/wallet/src/main/res/values/strings.xml @@ -307,18 +307,29 @@ Answer - Scan your document using camera + Take a photo of your document - Scan the main page of your passport or machine-readable area of your card. When scanning - succeeds, the next screen will appear automatically. + Center the main page of your Passport or Machine Readable Zone (MRZ) of your Id card. Upon successfully + recognizing the document Id, you will progress to the next screen. - Scan your document using NFC + + Scan the NFC chip on your document + Move your phone over the document to start scanning. + Confirmation will appear when successful. + Check your document for any chip or NFC-\n + related symbols to ensure it is scannable + Biometric Passport Symbol Passphrase PIN Next Confirm The passphrase didn\'t match, please try again The PIN didn\'t match, please try again + Document Scanning Successful + + Document Scanning Complete + Your application is now under ID issuer verification. This process might take a few hours. + Done Next @@ -336,16 +347,16 @@ Establishing NFC connection - NFC connected - Attempting PACE authorization - Authorized through PACE - PACE authorization is not supported - PACE authorization failed - Attempting BAC authorization - Authorized through BAC + Scanning... + Initializing... + Success! + Unable to initialize + Failed to initialize + Initializing... + Success! Reading data: %1$d%% - Establishing secure tunnel: %1$d%% - Reading data through tunnel: %1$d%% + Preparing to read... %1$d%% + Reading... %1$d%% Finished