Skip to content

Commit

Permalink
Implement NFC data retrieval for new multiplatform stack.
Browse files Browse the repository at this point in the history
On Android, this works on both the mdoc and mdoc reader side. On iOS
this only works on the mdoc reader side since HCE isn't generally
available.

In addition to NfcTransportMdoc and NfcTransportMdocReader, also add
unit tests for the happy path and cases where the tag reader has
limits on APDU. Also slightly improve tests for
MdocNfcEngagementHelper.

Change scanNfcMdocReader() so it's possible to run the code handling
the resulting MdocTransport in the coroutine for handling the
NfcIsoTag. This is needed on the reader side when doing NFC data
retrieval.

Also add support for the somewhat dubious configuration of QR
engagement and NFC data retrieval. This includes prompting the user to
move the mdoc into the NFC field of the mdoc reader.

Enable edge-to-edge and consume window insets for a more immersive
experience, especially for Credman or NFC engagement presentments (no
title bar, etc). Add a TODO about needing to examine display cutouts
so the close button isn't overlapping with the cutout.

Also introduce PresentmentTimeout so the UI layer can show appropriate
message. Update NFC engagement to emit this and Presentable composable
to react on it.

Test: Manually tested on both Android and iOS.
Test: New unit tests and all unit tests pass.
Signed-off-by: David Zeuthen <[email protected]>
  • Loading branch information
davidz25 committed Jan 30, 2025
1 parent 436f374 commit 6f80e4f
Show file tree
Hide file tree
Showing 32 changed files with 1,582 additions and 172 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
<string name="presentment_success">The information was shared</string>
<string name="presentment_error">Something went wrong</string>
<string name="presentment_canceled">Presentment was canceled</string>
<string name="presentment_timeout">Timed out waiting for reader</string>
<string name="presentment_waiting_for_request">Waiting for request</string>
<string name="presentment_document_picker_title">Select Document</string>
<string name="presentment_document_picker_text">Multiple documents can fulfill the request. Please select one</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import identitycredential.identity_appsupport.generated.resources.presentment_do
import identitycredential.identity_appsupport.generated.resources.presentment_document_picker_title
import identitycredential.identity_appsupport.generated.resources.presentment_error
import identitycredential.identity_appsupport.generated.resources.presentment_success
import identitycredential.identity_appsupport.generated.resources.presentment_timeout
import identitycredential.identity_appsupport.generated.resources.presentment_waiting_for_request
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
Expand All @@ -80,6 +81,7 @@ private const val TAG = "Presentment"
* @param onPresentmentComplete called when the presentment is complete.
* @param appName the name of the application.
* @param appIconPainter the icon for the application.
* @param modifier a [Modifier].
*/
@OptIn(ExperimentalMaterial3Api::class, ExperimentalCoroutinesApi::class, ExperimentalFoundationApi::class)
@Composable
Expand All @@ -90,6 +92,7 @@ fun Presentment(
onPresentmentComplete: () -> Unit,
appName: String,
appIconPainter: Painter,
modifier: Modifier = Modifier
) {
val coroutineScope = rememberCoroutineScope()

Expand Down Expand Up @@ -127,7 +130,7 @@ fun Presentment(
}

Column(
modifier = Modifier.fillMaxSize(),
modifier = modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center
) {
Spacer(modifier = Modifier.weight(0.15f))
Expand Down Expand Up @@ -171,6 +174,11 @@ fun Presentment(
appName, appIconPainter,
stringResource(Res.string.presentment_canceled)
)
} else if (presentmentModel.error is PresentmentTimeout) {
Triple(
"", painterResource(Res.drawable.presentment_icon_error),
stringResource(Res.string.presentment_timeout)
)
} else {
Triple(
"", painterResource(Res.drawable.presentment_icon_error),
Expand Down Expand Up @@ -214,14 +222,17 @@ fun Presentment(
// and since it's hidden it doesn't materially affect a production app.
//
if (presentmentModel.dismissable.collectAsState().value && state != PresentmentModel.State.COMPLETED) {
// TODO: for phones with display cutouts in the top-right (for example Pixel 9 Pro Fold when unfolded)
// the Close icon may be obscured. Examine the displayCutouts path and move the icon so it doesn't
// overlap.
Box(
modifier = Modifier.fillMaxSize()
) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = null,
modifier = Modifier
.align(Alignment.TopEnd).padding(10.dp)
.align(Alignment.TopEnd).padding(20.dp)
.combinedClickable(
onClick = { presentmentModel.dismiss(PresentmentModel.DismissType.CLICK) },
onLongClick = { presentmentModel.dismiss(PresentmentModel.DismissType.LONG_CLICK) },
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.android.identity.appsupport.ui.presentment

/**
* Thrown when timing out waiting for the reader to connect.
*
* @property message message to display.
*/
class PresentmentTimeout(message: String): Exception(message)
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.android.identity.mdoc.transport

import com.android.identity.mdoc.connectionmethod.ConnectionMethod
import com.android.identity.mdoc.connectionmethod.ConnectionMethodBle
import com.android.identity.mdoc.connectionmethod.ConnectionMethodNfc

internal actual fun defaultMdocTransportFactoryCreateTransport(
connectionMethod: ConnectionMethod,
Expand Down Expand Up @@ -57,6 +58,24 @@ internal actual fun defaultMdocTransportFactoryCreateTransport(
}
}
}
is ConnectionMethodNfc -> {
return when (role) {
MdocTransport.Role.MDOC -> {
NfcTransportMdoc(
role,
options,
connectionMethod
)
}
MdocTransport.Role.MDOC_READER -> {
NfcTransportMdocReader(
role,
options,
connectionMethod
)
}
}
}
else -> {
throw IllegalArgumentException("$connectionMethod is not supported")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,12 @@ abstract class ConnectionMethod {
record.type.decodeToString() == Nfc.MIME_TYPE_CONNECTION_HANDOVER_BLE &&
record.id.decodeToString() == "0") {
return ConnectionMethodBle.fromNdefRecord(record, role, uuid)
} else if (record.tnf == NdefRecord.Tnf.EXTERNAL_TYPE &&
record.type.decodeToString() == Nfc.EXTERNAL_TYPE_ISO_18013_5_NFC &&
record.id.decodeToString() == "nfc") {
return ConnectionMethodNfc.fromNdefRecord(record, role)
}
// TODO: add support for Wifi Aware, NFC, and others.
// TODO: add support for Wifi Aware and others.
Logger.w(TAG, "No support for NDEF record $record")
return null
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,14 @@ import com.android.identity.cbor.CborArray
import com.android.identity.cbor.CborMap
import com.android.identity.mdoc.transport.MdocTransport
import com.android.identity.nfc.NdefRecord
import com.android.identity.nfc.Nfc
import com.android.identity.util.Logger
import kotlinx.io.Buffer
import kotlinx.io.bytestring.ByteString
import kotlinx.io.bytestring.encodeToByteString
import kotlinx.io.readByteArray
import kotlinx.io.readByteString
import kotlinx.io.write

/**
* Connection method for NFC.
Expand Down Expand Up @@ -40,13 +47,68 @@ class ConnectionMethodNfc(
)
}


private fun encodeInt(dataType: Int, value: Int, buf: Buffer) {
if (value < 0x100) {
buf.writeByte(0x02.toByte()) // Length
buf.writeByte(dataType.toByte())
buf.writeByte(value.and(0xff).toByte())
} else if (value < 0x10000) {
buf.writeByte(0x03.toByte()) // Length
buf.writeByte(dataType.toByte())
buf.writeByte((value / 0x100).toByte())
buf.writeByte((value.and(0xff)).toByte())
} else {
buf.writeByte(0x04.toByte()) // Length
buf.writeByte(dataType.toByte())
buf.writeByte((value / 0x10000).toByte())
buf.writeByte((value / 0x100).and(0xff).toByte())
buf.writeByte((value.and(0xff)).toByte())
}
}

override fun toNdefRecord(
auxiliaryReferences: List<String>,
role: MdocTransport.Role,
skipUuids: Boolean
): Pair<NdefRecord, NdefRecord>? {
Logger.w(TAG, "toNdefRecord() not yet implemented")
return null
val carrierDataReference = "nfc".encodeToByteString()

// This is defined by ISO 18013-5 8.2.2.2 Alternative Carrier Record for device
// retrieval using NFC.
//
val buf = Buffer()
buf.writeByte(0x01.toByte()) // Version
encodeInt(0x01, commandDataFieldMaxLength.toInt(), buf)
encodeInt(0x02, responseDataFieldMaxLength.toInt(), buf)
val record = NdefRecord(
NdefRecord.Tnf.EXTERNAL_TYPE,
Nfc.EXTERNAL_TYPE_ISO_18013_5_NFC.encodeToByteString(),
carrierDataReference,
buf.readByteString()
)

// From NFC Forum Connection Handover v1.5 section 7.1 Alternative Carrier Record
//
val acrBuf = Buffer()
acrBuf.writeByte(0x01) // CPS: active
acrBuf.writeByte(carrierDataReference.size.toByte()) // Length of carrier data reference
acrBuf.write(carrierDataReference)
acrBuf.writeByte(auxiliaryReferences.size.toByte()) // Number of auxiliary references
for (auxRef in auxiliaryReferences) {
// Each auxiliary reference consists of a single byte for the length and then as
// many bytes for the reference itself.
val auxRefUtf8 = auxRef.encodeToByteString()
acrBuf.writeByte(auxRefUtf8.size.toByte())
acrBuf.write(auxRefUtf8)
}
val acRecordPayload = acrBuf.readByteArray()
val acRecord = NdefRecord(
tnf = NdefRecord.Tnf.WELL_KNOWN,
type = Nfc.RTD_ALTERNATIVE_CARRIER,
payload = ByteString(acRecordPayload)
)
return Pair(record, acRecord)
}

companion object {
Expand All @@ -70,5 +132,52 @@ class ConnectionMethodNfc(
map[OPTION_KEY_RESPONSE_DATA_FIELD_MAX_LENGTH].asNumber
)
}

internal fun fromNdefRecord(
record: NdefRecord,
role: MdocTransport.Role,
): ConnectionMethodNfc? {
val payload = Buffer()
payload.write(record.payload)
val version = payload.readByte().toInt()
if (version != 0x01) {
Logger.w(TAG, "Expected version 0x01, found $version")
return null
}
val cmdLen = payload.readByte().toInt().and(0xff)
val cmdType = payload.readByte().toInt().and(0xff)
if (cmdType != 0x01) {
Logger.w(TAG, "expected type 0x01, found $cmdType")
return null
}
if (cmdLen < 2 || cmdLen > 3) {
Logger.w(TAG, "expected cmdLen in range 2-3, got $cmdLen")
return null
}
var commandDataFieldMaxLength = 0
for (n in 0 until cmdLen - 1) {
commandDataFieldMaxLength *= 256
commandDataFieldMaxLength += payload.readByte().toInt().and(0xff)
}
val rspLen = payload.readByte().toInt().and(0xff)
val rspType = payload.readByte().toInt().and(0xff)
if (rspType != 0x02) {
Logger.w(TAG, "expected type 0x02, found $rspType")
return null
}
if (rspLen < 2 || rspLen > 4) {
Logger.w(TAG, "expected rspLen in range 2-4, got $rspLen")
return null
}
var responseDataFieldMaxLength = 0
for (n in 0 until rspLen - 1) {
responseDataFieldMaxLength *= 256
responseDataFieldMaxLength += payload.readByte().toInt().and(0xff)
}
return ConnectionMethodNfc(
commandDataFieldMaxLength.toLong(),
responseDataFieldMaxLength.toLong()
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ class MdocNfcEngagementHelper(
val requestedApplicationId = command.payload
if (requestedApplicationId != Nfc.NDEF_APPLICATION_ID) {
raiseError("SelectApplication: Expected NDEF AID but got ${requestedApplicationId.toByteArray().toHex()}")
return ResponseApdu(Nfc.RESPONSE_ERROR_FILE_OR_APPLICATION_NOT_FOUND)
return ResponseApdu(Nfc.RESPONSE_STATUS_ERROR_FILE_OR_APPLICATION_NOT_FOUND)
}
ndefApplicationSelected = true
return ResponseApdu(Nfc.RESPONSE_STATUS_SUCCESS)
Expand Down Expand Up @@ -181,7 +181,7 @@ class MdocNfcEngagementHelper(
}
else -> {
raiseError("SelectFile: Unexpected File ID $selectedFileId")
return ResponseApdu(Nfc.RESPONSE_ERROR_FILE_OR_APPLICATION_NOT_FOUND)
return ResponseApdu(Nfc.RESPONSE_STATUS_ERROR_FILE_OR_APPLICATION_NOT_FOUND)
}
}
return ResponseApdu(Nfc.RESPONSE_STATUS_SUCCESS)
Expand Down Expand Up @@ -335,7 +335,7 @@ class MdocNfcEngagementHelper(
selectedFilePayload = bsb.toByteString()
return ResponseApdu(Nfc.RESPONSE_STATUS_SUCCESS)
} catch (error: Throwable) {
raiseError(error.message!!)
raiseError(error.message!!, error)
return ResponseApdu(Nfc.RESPONSE_STATUS_ERROR_NO_PRECISE_DIAGNOSIS)
}
}
Expand All @@ -353,17 +353,17 @@ class MdocNfcEngagementHelper(
if (lenInData == 0) {
if (updateBinaryData != null) {
raiseError("Got reset but is already active")
return ResponseApdu(Nfc.RESPONSE_ERROR_FILE_OR_APPLICATION_NOT_FOUND)
return ResponseApdu(Nfc.RESPONSE_STATUS_ERROR_FILE_OR_APPLICATION_NOT_FOUND)
}
updateBinaryData = ByteStringBuilder()
} else {
if (updateBinaryData == null) {
raiseError("Got length but we are not active")
return ResponseApdu(Nfc.RESPONSE_ERROR_FILE_OR_APPLICATION_NOT_FOUND)
return ResponseApdu(Nfc.RESPONSE_STATUS_ERROR_FILE_OR_APPLICATION_NOT_FOUND)
}
if (lenInData != updateBinaryData!!.size) {
raiseError("Length $lenInData doesn't match received data of ${updateBinaryData!!.size} bytes")
return ResponseApdu(Nfc.RESPONSE_ERROR_FILE_OR_APPLICATION_NOT_FOUND)
return ResponseApdu(Nfc.RESPONSE_STATUS_ERROR_FILE_OR_APPLICATION_NOT_FOUND)
}

// At this point we got the whole NDEF message that the reader wanted to send.
Expand All @@ -374,24 +374,24 @@ class MdocNfcEngagementHelper(
} else {
if (updateBinaryData != null) {
raiseError("Got data in single UPDATE_BINARY but we are already active")
return ResponseApdu(Nfc.RESPONSE_ERROR_FILE_OR_APPLICATION_NOT_FOUND)
return ResponseApdu(Nfc.RESPONSE_STATUS_ERROR_FILE_OR_APPLICATION_NOT_FOUND)
}
val ndefMessage = NdefMessage.fromEncoded(data.toByteArray(2))
return processUpdateBinaryNdefMessage(ndefMessage)
}
} else if (offset == 1) {
raiseError("Unexpected offset $offset")
return ResponseApdu(Nfc.RESPONSE_ERROR_FILE_OR_APPLICATION_NOT_FOUND)
return ResponseApdu(Nfc.RESPONSE_STATUS_ERROR_FILE_OR_APPLICATION_NOT_FOUND)
} else {
// offset >= 2
if (updateBinaryData == null) {
raiseError("Got data but we are not active")
return ResponseApdu(Nfc.RESPONSE_ERROR_FILE_OR_APPLICATION_NOT_FOUND)
return ResponseApdu(Nfc.RESPONSE_STATUS_ERROR_FILE_OR_APPLICATION_NOT_FOUND)
}
// Writes must be sequential
if (offset - 2 != updateBinaryData!!.size) {
raiseError("Got data to write at offset $offset but we currently have ${updateBinaryData!!.size}")
return ResponseApdu(Nfc.RESPONSE_ERROR_FILE_OR_APPLICATION_NOT_FOUND)
return ResponseApdu(Nfc.RESPONSE_STATUS_ERROR_FILE_OR_APPLICATION_NOT_FOUND)
}
updateBinaryData!!.append(data)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import com.android.identity.nfc.ServiceSelectRecord
import com.android.identity.nfc.TnepStatusRecord
import com.android.identity.util.Logger
import com.android.identity.util.UUID
import kotlinx.datetime.Clock
import kotlinx.io.bytestring.ByteString
import kotlinx.io.bytestring.decodeToString
import kotlinx.io.bytestring.encodeToByteString
Expand Down Expand Up @@ -60,7 +59,7 @@ suspend fun mdocReaderNfcHandover(
// user will be shown UI to convey another tap should happen. So since we're the mdoc reader, we
// want to keep scanning...
//
if (e.status == Nfc.RESPONSE_ERROR_FILE_OR_APPLICATION_NOT_FOUND) {
if (e.status == Nfc.RESPONSE_STATUS_ERROR_FILE_OR_APPLICATION_NOT_FOUND) {
Logger.i(TAG, "NDEF application not found, continuing scanning")
return null
}
Expand Down
Loading

0 comments on commit 6f80e4f

Please sign in to comment.