diff --git a/app-common/src/main/java/im/angry/openeuicc/core/DefaultEuiccChannelManager.kt b/app-common/src/main/java/im/angry/openeuicc/core/DefaultEuiccChannelManager.kt index 81d86ee..0955959 100644 --- a/app-common/src/main/java/im/angry/openeuicc/core/DefaultEuiccChannelManager.kt +++ b/app-common/src/main/java/im/angry/openeuicc/core/DefaultEuiccChannelManager.kt @@ -91,6 +91,10 @@ open class DefaultEuiccChannelManager( override fun findEuiccChannelBySlotBlocking(logicalSlotId: Int): EuiccChannel? = runBlocking { withContext(Dispatchers.IO) { + if (logicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) { + return@withContext usbChannel + } + for (card in uiccCards) { for (port in card.ports) { if (port.logicalSlotIndex == logicalSlotId) { @@ -106,6 +110,10 @@ open class DefaultEuiccChannelManager( override fun findEuiccChannelByPhysicalSlotBlocking(physicalSlotId: Int): EuiccChannel? = runBlocking { withContext(Dispatchers.IO) { + if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) { + return@withContext usbChannel + } + for (card in uiccCards) { if (card.physicalSlotIndex != physicalSlotId) continue for (port in card.ports) { @@ -118,6 +126,10 @@ open class DefaultEuiccChannelManager( } override suspend fun findAllEuiccChannelsByPhysicalSlot(physicalSlotId: Int): List? { + if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) { + return usbChannel?.let { listOf(it) } + } + for (card in uiccCards) { if (card.physicalSlotIndex != physicalSlotId) continue return card.ports.mapNotNull { tryOpenEuiccChannel(it) } @@ -133,6 +145,10 @@ open class DefaultEuiccChannelManager( override suspend fun findEuiccChannelByPort(physicalSlotId: Int, portId: Int): EuiccChannel? = withContext(Dispatchers.IO) { + if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) { + return@withContext usbChannel + } + uiccCards.find { it.physicalSlotIndex == physicalSlotId }?.let { card -> card.ports.find { it.portIndex == portId }?.let { tryOpenEuiccChannel(it) } } diff --git a/app-common/src/main/java/im/angry/openeuicc/core/usb/UsbApduInterface.kt b/app-common/src/main/java/im/angry/openeuicc/core/usb/UsbApduInterface.kt index 86efc24..d181e53 100644 --- a/app-common/src/main/java/im/angry/openeuicc/core/usb/UsbApduInterface.kt +++ b/app-common/src/main/java/im/angry/openeuicc/core/usb/UsbApduInterface.kt @@ -2,6 +2,8 @@ package im.angry.openeuicc.core.usb import android.hardware.usb.UsbDeviceConnection import android.hardware.usb.UsbEndpoint +import android.util.Log +import im.angry.openeuicc.util.* import net.typeblog.lpac_jni.ApduInterface class UsbApduInterface( @@ -9,11 +11,30 @@ class UsbApduInterface( private val bulkIn: UsbEndpoint, private val bulkOut: UsbEndpoint ): ApduInterface { + companion object { + private const val TAG = "UsbApduInterface" + } + private lateinit var ccidDescription: UsbCcidDescription + private lateinit var transceiver: UsbCcidTransceiver + + private var channelId = -1 override fun connect() { ccidDescription = UsbCcidDescription.fromRawDescriptors(conn.rawDescriptors)!! - ccidDescription.checkTransportProtocol() + + if (!ccidDescription.hasT0Protocol) { + throw IllegalArgumentException("Unsupported card reader; T=0 support is required") + } + + transceiver = UsbCcidTransceiver(conn, bulkIn, bulkOut, ccidDescription) + + try { + transceiver.iccPowerOn() + } catch (e: Exception) { + e.printStackTrace() + throw e + } } override fun disconnect() { @@ -21,17 +42,118 @@ class UsbApduInterface( } override fun logicalChannelOpen(aid: ByteArray): Int { - return 0 + check(channelId == -1) { "Logical channel already opened" } + + // OPEN LOGICAL CHANNEL + val req = manageChannelCmd(true, 0) + Log.d(TAG, "OPEN LOGICAL CHANNEL: ${req.encodeHex()}") + + val resp = try { + transmitApduByChannel(req, 0) + } catch (e: Exception) { + e.printStackTrace() + return -1 + } + Log.d(TAG, "OPEN LOGICAL CHANNEL response: ${resp.encodeHex()}") + + return if (resp.size >= 2 && resp.sliceArray((resp.size - 2) until resp.size).contentEquals( + byteArrayOf(0x90.toByte(), 0x00) + ) + ) { + channelId = resp[0].toInt() + Log.d(TAG, "channelId = $channelId") + + // Then, select AID + val selectAid = selectByDfCmd(aid, channelId.toByte()) + Log.d(TAG, "Select DF command: ${selectAid.encodeHex()}") + val selectAidResp = transmitApduByChannel(selectAid, channelId.toByte()) + Log.d(TAG, "Select DF resp: ${selectAidResp.encodeHex()}") + channelId + } else { + -1 + } } override fun logicalChannelClose(handle: Int) { + check(handle == channelId) { "Logical channel ID mismatch" } + check(channelId != -1) { "Logical channel is not opened" } + // CLOSE LOGICAL CHANNEL + val req = manageChannelCmd(false, channelId.toByte()) + Log.d(TAG, "CLOSE LOGICAL CHANNEL: ${req.encodeHex()}") + + val resp = transmitApduByChannel(req, channelId.toByte()) + Log.d(TAG, "CLOSE LOGICAL CHANNEL response: ${resp.encodeHex()}") + + channelId = -1 } override fun transmit(tx: ByteArray): ByteArray { - return byteArrayOf() + check(channelId != -1) { "Logical channel is not opened" } + Log.d(TAG, "USB APDU command: ${tx.encodeHex()}") + val resp = transmitApduByChannel(tx, channelId.toByte()) + Log.d(TAG, "USB APDU response: ${resp.encodeHex()}") + return resp } override val valid: Boolean get() = true + + private fun buildCmd(cla: Byte, ins: Byte, p1: Byte, p2: Byte, data: ByteArray?, le: Byte?) = + byteArrayOf(cla, ins, p1, p2).let { + if (data != null) { + it + data.size.toByte() + data + } else { + it + } + }.let { + if (le != null) { + it + byteArrayOf(le) + } else { + it + } + } + + private fun manageChannelCmd(open: Boolean, channel: Byte) = + if (open) { + buildCmd(0x00, 0x70, 0x00, 0x00, null, 0x01) + } else { + buildCmd(channel, 0x70, 0x80.toByte(), channel, null, null) + } + + private fun selectByDfCmd(aid: ByteArray, channel: Byte) = + buildCmd(channel, 0xA4.toByte(), 0x04, 0x00, aid, null) + + private fun transmitApduByChannel(tx: ByteArray, channel: Byte): ByteArray { + val realTx = tx.copyOf() + // OR the channel mask into the CLA byte + realTx[0] = ((realTx[0].toInt() and 0xFC) or channel.toInt()).toByte() + + var resp = transceiver.sendXfrBlock(realTx)!!.data!! + + if (resp.size >= 2) { + var sw1 = resp[resp.size - 2].toInt() and 0xFF + var sw2 = resp[resp.size - 1].toInt() and 0xFF + + if (sw1 == 0x6C) { + realTx[realTx.size - 1] = resp[resp.size - 1] + resp = transceiver.sendXfrBlock(realTx)!!.data!! + } else if (sw1 == 0x61) { + do { + val getResponseCmd = byteArrayOf( + realTx[0], 0xC0.toByte(), 0x00, 0x00, sw2.toByte() + ) + + val tmp = transceiver.sendXfrBlock(getResponseCmd)!!.data!! + + resp = resp.sliceArray(0 until (resp.size - 2)) + tmp + + sw1 = resp[resp.size - 2].toInt() and 0xFF + sw2 = resp[resp.size - 1].toInt() and 0xFF + } while (sw1 == 0x61) + } + } + + return resp + } } \ No newline at end of file diff --git a/app-common/src/main/java/im/angry/openeuicc/core/usb/UsbCcidDescription.kt b/app-common/src/main/java/im/angry/openeuicc/core/usb/UsbCcidDescription.kt index a9fa394..8a2ed39 100644 --- a/app-common/src/main/java/im/angry/openeuicc/core/usb/UsbCcidDescription.kt +++ b/app-common/src/main/java/im/angry/openeuicc/core/usb/UsbCcidDescription.kt @@ -96,11 +96,9 @@ data class UsbCcidDescription( }.toTypedArray() } - val hasAutomaticPps: Boolean = hasFeature(FEATURE_AUTOMATIC_PPS) + val hasAutomaticPps: Boolean + get() = hasFeature(FEATURE_AUTOMATIC_PPS) - fun checkTransportProtocol() { - val hasT1Protocol = dwProtocols and MASK_T1_PROTO != 0 - val hasT0Protocol = dwProtocols and MASK_T0_PROTO != 0 - android.util.Log.d("CcidDescription", "hasT1Protocol = $hasT1Protocol, hasT0Protocol = $hasT0Protocol") - } + val hasT0Protocol: Boolean + get() = (dwProtocols and MASK_T0_PROTO) != 0 } \ No newline at end of file diff --git a/app-common/src/main/java/im/angry/openeuicc/core/usb/UsbCcidTransceiver.kt b/app-common/src/main/java/im/angry/openeuicc/core/usb/UsbCcidTransceiver.kt new file mode 100644 index 0000000..ac2e1dc --- /dev/null +++ b/app-common/src/main/java/im/angry/openeuicc/core/usb/UsbCcidTransceiver.kt @@ -0,0 +1,348 @@ +package im.angry.openeuicc.core.usb + +import android.hardware.usb.UsbDeviceConnection +import android.hardware.usb.UsbEndpoint +import android.os.SystemClock +import android.util.Log +import im.angry.openeuicc.util.* +import java.nio.ByteBuffer +import java.nio.ByteOrder + + +/** + * Provides raw, APDU-agnostic transmission to the CCID reader + * Adapted from + */ +class UsbCcidTransceiver( + private val usbConnection: UsbDeviceConnection, + private val usbBulkIn: UsbEndpoint, + private val usbBulkOut: UsbEndpoint, + private val usbCcidDescription: UsbCcidDescription +) { + companion object { + private const val TAG = "UsbCcidTransceiver" + + private const val CCID_HEADER_LENGTH = 10 + + private const val MESSAGE_TYPE_RDR_TO_PC_DATA_BLOCK = 0x80 + private const val MESSAGE_TYPE_PC_TO_RDR_ICC_POWER_ON = 0x62 + private const val MESSAGE_TYPE_PC_TO_RDR_ICC_POWER_OFF = 0x63 + private const val MESSAGE_TYPE_PC_TO_RDR_XFR_BLOCK = 0x6f + + private const val COMMAND_STATUS_SUCCESS: Byte = 0 + private const val COMMAND_STATUS_TIME_EXTENSION_RQUESTED: Byte = 2 + + /** + * Level Parameter: APDU is a single command. + * + * "the command APDU begins and ends with this command" + * -- DWG Smart-Card USB Integrated Circuit(s) Card Devices rev 1.0 + * § 6.1.1.3 + */ + const val LEVEL_PARAM_START_SINGLE_CMD_APDU: Short = 0x0000 + + /** + * Level Parameter: First APDU in a multi-command APDU. + * + * "the command APDU begins with this command, and continue in the + * next PC_to_RDR_XfrBlock" + * -- DWG Smart-Card USB Integrated Circuit(s) Card Devices rev 1.0 + * § 6.1.1.3 + */ + const val LEVEL_PARAM_START_MULTI_CMD_APDU: Short = 0x0001 + + /** + * Level Parameter: Final APDU in a multi-command APDU. + * + * "this abData field continues a command APDU and ends the command APDU" + * -- DWG Smart-Card USB Integrated Circuit(s) Card Devices rev 1.0 + * § 6.1.1.3 + */ + const val LEVEL_PARAM_END_MULTI_CMD_APDU: Short = 0x0002 + + /** + * Level Parameter: Next command in a multi-command APDU. + * + * "the abData field continues a command APDU and another block is to follow" + * -- DWG Smart-Card USB Integrated Circuit(s) Card Devices rev 1.0 + * § 6.1.1.3 + */ + const val LEVEL_PARAM_CONTINUE_MULTI_CMD_APDU: Short = 0x0003 + + /** + * Level Parameter: Request the device continue sending APDU. + * + * "empty abData field, continuation of response APDU is expected in the next + * RDR_to_PC_DataBlock" + * -- DWG Smart-Card USB Integrated Circuit(s) Card Devices rev 1.0 + * § 6.1.1.3 + */ + const val LEVEL_PARAM_CONTINUE_RESPONSE: Short = 0x0010 + + private const val SLOT_NUMBER = 0x00 + + private const val ICC_STATUS_SUCCESS: Byte = 0 + + private const val DEVICE_COMMUNICATE_TIMEOUT_MILLIS = 5000 + private const val DEVICE_SKIP_TIMEOUT_MILLIS = 100 + } + + data class UsbCcidErrorException(val msg: String, val errorResponse: CcidDataBlock) : + Exception(msg) + + data class CcidDataBlock( + val dwLength: Int, + val bSlot: Byte, + val bSeq: Byte, + val bStatus: Byte, + val bError: Byte, + val bChainParameter: Byte, + val data: ByteArray? + ) { + companion object { + fun parseHeaderFromBytes(headerBytes: ByteArray): CcidDataBlock { + val buf = ByteBuffer.wrap(headerBytes) + buf.order(ByteOrder.LITTLE_ENDIAN) + + val type = buf.get() + require(type == MESSAGE_TYPE_RDR_TO_PC_DATA_BLOCK.toByte()) { "Header has incorrect type value!" } + val dwLength = buf.int + val bSlot = buf.get() + val bSeq = buf.get() + val bStatus = buf.get() + val bError = buf.get() + val bChainParameter = buf.get() + + return CcidDataBlock(dwLength, bSlot, bSeq, bStatus, bError, bChainParameter, null) + } + } + + fun withData(d: ByteArray): CcidDataBlock { + require(data == null) { "Cannot add data twice" } + return CcidDataBlock(dwLength, bSlot, bSeq, bStatus, bError, bChainParameter, d) + } + + val iccStatus: Byte + get() = (bStatus.toInt() and 0x03).toByte() + + val commandStatus: Byte + get() = ((bStatus.toInt() shr 6) and 0x03).toByte() + + val isStatusTimeoutExtensionRequest: Boolean + get() = commandStatus == COMMAND_STATUS_TIME_EXTENSION_RQUESTED + + val isStatusSuccess: Boolean + get() = iccStatus == ICC_STATUS_SUCCESS && commandStatus == COMMAND_STATUS_SUCCESS + } + + val hasAutomaticPps = usbCcidDescription.hasAutomaticPps + + private val inputBuffer = ByteArray(usbBulkIn.maxPacketSize) + + private var currentSequenceNumber: Byte = 0 + + private fun sendRaw(data: ByteArray, offset: Int, length: Int) { + val tr1 = usbConnection.bulkTransfer( + usbBulkOut, data, offset, length, DEVICE_COMMUNICATE_TIMEOUT_MILLIS + ) + if (tr1 != length) { + throw UsbTransportException( + "USB error - failed to transmit data ($tr1/$length)" + ) + } + } + + private fun receiveDataBlock(expectedSequenceNumber: Byte): CcidDataBlock? { + var response: CcidDataBlock? + do { + response = receiveDataBlockImmediate(expectedSequenceNumber) + } while (response!!.isStatusTimeoutExtensionRequest) + if (!response.isStatusSuccess) { + throw UsbCcidErrorException("USB-CCID error!", response) + } + return response + } + + private fun receiveDataBlockImmediate(expectedSequenceNumber: Byte): CcidDataBlock? { + /* + * Some USB CCID devices (notably NitroKey 3) may time-out and need a subsequent poke to + * carry on communications. No particular reason why the number 3 was chosen. If we get a + * zero-sized reply (or a time-out), we try again. Clamped retries prevent an infinite loop + * if things really turn sour. + */ + var attempts = 3 + Log.d(TAG, "Receive data block immediate seq=$expectedSequenceNumber") + var readBytes: Int + do { + readBytes = usbConnection.bulkTransfer( + usbBulkIn, inputBuffer, inputBuffer.size, DEVICE_COMMUNICATE_TIMEOUT_MILLIS + ) + Log.d(TAG, "Received " + readBytes + " bytes: " + inputBuffer.encodeHex()) + } while (readBytes <= 0 && attempts-- > 0) + if (readBytes < CCID_HEADER_LENGTH) { + throw UsbTransportException("USB-CCID error - failed to receive CCID header") + } + if (inputBuffer[0] != MESSAGE_TYPE_RDR_TO_PC_DATA_BLOCK.toByte()) { + if (expectedSequenceNumber != inputBuffer[6]) { + throw UsbTransportException( + ((("USB-CCID error - bad CCID header, type " + inputBuffer[0]) + " (expected " + + MESSAGE_TYPE_RDR_TO_PC_DATA_BLOCK) + "), sequence number " + inputBuffer[6] + ) + " (expected " + + expectedSequenceNumber + ")" + ) + } + throw UsbTransportException( + "USB-CCID error - bad CCID header type " + inputBuffer[0] + ) + } + var result = CcidDataBlock.parseHeaderFromBytes(inputBuffer) + if (expectedSequenceNumber != result.bSeq) { + throw UsbTransportException( + ("USB-CCID error - expected sequence number " + + expectedSequenceNumber + ", got " + result) + ) + } + + val dataBuffer = ByteArray(result.dwLength) + var bufferedBytes = readBytes - CCID_HEADER_LENGTH + System.arraycopy(inputBuffer, CCID_HEADER_LENGTH, dataBuffer, 0, bufferedBytes) + while (bufferedBytes < dataBuffer.size) { + readBytes = usbConnection.bulkTransfer( + usbBulkIn, inputBuffer, inputBuffer.size, DEVICE_COMMUNICATE_TIMEOUT_MILLIS + ) + if (readBytes < 0) { + throw UsbTransportException( + "USB error - failed reading response data! Header: $result" + ) + } + System.arraycopy(inputBuffer, 0, dataBuffer, bufferedBytes, readBytes) + bufferedBytes += readBytes + } + result = result.withData(dataBuffer) + return result + } + + + private fun skipAvailableInput() { + var ignoredBytes: Int + do { + ignoredBytes = usbConnection.bulkTransfer( + usbBulkIn, inputBuffer, inputBuffer.size, DEVICE_SKIP_TIMEOUT_MILLIS + ) + if (ignoredBytes > 0) { + Log.e(TAG, "Skipped $ignoredBytes bytes") + } + } while (ignoredBytes > 0) + } + + /** + * Transmits XfrBlock + * 6.1.4 PC_to_RDR_XfrBlock + * + * @param payload payload to transmit + */ + fun sendXfrBlock(payload: ByteArray): CcidDataBlock? { + return sendXfrBlock(payload, LEVEL_PARAM_START_SINGLE_CMD_APDU) + } + + /** + * Receives a continued XfrBlock. Should be called when a multiblock response is indicated + * 6.1.4 PC_to_RDR_XfrBlock + */ + fun receiveContinuedResponse(): CcidDataBlock? { + return sendXfrBlock(ByteArray(0), LEVEL_PARAM_CONTINUE_RESPONSE) + } + + /** + * Transmits XfrBlock + * 6.1.4 PC_to_RDR_XfrBlock + * + * @param payload payload to transmit + * @param levelParam Level parameter + */ + private fun sendXfrBlock(payload: ByteArray, levelParam: Short): CcidDataBlock? { + val startTime = SystemClock.elapsedRealtime() + val l = payload.size + val sequenceNumber: Byte = currentSequenceNumber++ + val headerData = byteArrayOf( + MESSAGE_TYPE_PC_TO_RDR_XFR_BLOCK.toByte(), + l.toByte(), + (l shr 8).toByte(), + (l shr 16).toByte(), + (l shr 24).toByte(), + SLOT_NUMBER.toByte(), + sequenceNumber, + 0x00.toByte(), + (levelParam.toInt() and 0x00ff).toByte(), + (levelParam.toInt() shr 8).toByte() + ) + val data: ByteArray = headerData + payload + var sentBytes = 0 + while (sentBytes < data.size) { + val bytesToSend = Math.min(usbBulkOut.maxPacketSize, data.size - sentBytes) + sendRaw(data, sentBytes, bytesToSend) + sentBytes += bytesToSend + } + val ccidDataBlock = receiveDataBlock(sequenceNumber) + val elapsedTime = SystemClock.elapsedRealtime() - startTime + Log.d(TAG, "USB XferBlock call took " + elapsedTime + "ms") + return ccidDataBlock + } + + fun iccPowerOn(): CcidDataBlock { + val startTime = SystemClock.elapsedRealtime() + skipAvailableInput() + var response: CcidDataBlock? = null + for (v in usbCcidDescription.voltages) { + Log.v(TAG, "CCID: attempting to power on with voltage $v") + response = try { + iccPowerOnVoltage(v.powerOnValue) + } catch (e: UsbCcidErrorException) { + if (e.errorResponse.bError.toInt() == 7) { // Power select error + Log.v(TAG, "CCID: failed to power on with voltage $v") + iccPowerOff() + Log.v(TAG, "CCID: powered off") + continue + } + throw e + } + break + } + if (response == null) { + throw UsbTransportException("Couldn't power up ICC2") + } + val elapsedTime = SystemClock.elapsedRealtime() - startTime + Log.d( + TAG, + "Usb transport connected, took " + elapsedTime + "ms, ATR=" + + response.data?.encodeHex() + ) + return response + } + + private fun iccPowerOnVoltage(voltage: Byte): CcidDataBlock? { + val sequenceNumber = currentSequenceNumber++ + val iccPowerCommand = byteArrayOf( + MESSAGE_TYPE_PC_TO_RDR_ICC_POWER_ON.toByte(), + 0x00, 0x00, 0x00, 0x00, + SLOT_NUMBER.toByte(), + sequenceNumber, + voltage, + 0x00, 0x00 // reserved for future use + ) + sendRaw(iccPowerCommand, 0, iccPowerCommand.size) + return receiveDataBlock(sequenceNumber) + } + + private fun iccPowerOff() { + val sequenceNumber = currentSequenceNumber++ + val iccPowerCommand = byteArrayOf( + MESSAGE_TYPE_PC_TO_RDR_ICC_POWER_OFF.toByte(), + 0x00, 0x00, 0x00, 0x00, + 0x00, + sequenceNumber, + 0x00 + ) + sendRaw(iccPowerCommand, 0, iccPowerCommand.size) + } +} \ No newline at end of file diff --git a/app-common/src/main/java/im/angry/openeuicc/core/usb/UsbCcidUtils.kt b/app-common/src/main/java/im/angry/openeuicc/core/usb/UsbCcidUtils.kt index f4e9a87..edca7a0 100644 --- a/app-common/src/main/java/im/angry/openeuicc/core/usb/UsbCcidUtils.kt +++ b/app-common/src/main/java/im/angry/openeuicc/core/usb/UsbCcidUtils.kt @@ -6,6 +6,8 @@ import android.hardware.usb.UsbDevice import android.hardware.usb.UsbEndpoint import android.hardware.usb.UsbInterface +class UsbTransportException(msg: String) : Exception(msg) + fun UsbInterface.getIoEndpoints(): Pair { var bulkIn: UsbEndpoint? = null var bulkOut: UsbEndpoint? = null diff --git a/app/src/main/java/im/angry/openeuicc/util/PrivilegedTelephonyUtils.kt b/app/src/main/java/im/angry/openeuicc/util/PrivilegedTelephonyUtils.kt index 3c30bce..ae7294a 100644 --- a/app/src/main/java/im/angry/openeuicc/util/PrivilegedTelephonyUtils.kt +++ b/app/src/main/java/im/angry/openeuicc/util/PrivilegedTelephonyUtils.kt @@ -74,11 +74,12 @@ fun SubscriptionManager.tryRefreshCachedEuiccInfo(cardId: Int) { } // Every EuiccChannel we use here should be backed by a RealUiccPortInfoCompat +// except when it is from a USB card reader val EuiccChannel.removable - get() = (port as RealUiccPortInfoCompat).card.isRemovable + get() = (port as? RealUiccPortInfoCompat)?.card?.isRemovable ?: true val EuiccChannel.cardId - get() = (port as RealUiccPortInfoCompat).card.cardId + get() = (port as? RealUiccPortInfoCompat)?.card?.cardId ?: -1 val EuiccChannel.isMEP - get() = (port as RealUiccPortInfoCompat).card.isMultipleEnabledProfilesSupported \ No newline at end of file + get() = (port as? RealUiccPortInfoCompat)?.card?.isMultipleEnabledProfilesSupported ?: false \ No newline at end of file