feat: USB CCID reader support [1/n]
This commit is contained in:
parent
6396f17012
commit
803b88f74e
8 changed files with 343 additions and 3 deletions
|
@ -1,14 +1,23 @@
|
|||
package im.angry.openeuicc.core
|
||||
|
||||
import android.content.Context
|
||||
import android.hardware.usb.UsbDevice
|
||||
import android.hardware.usb.UsbInterface
|
||||
import android.hardware.usb.UsbManager
|
||||
import android.se.omapi.SEService
|
||||
import android.util.Log
|
||||
import im.angry.openeuicc.core.usb.UsbApduInterface
|
||||
import im.angry.openeuicc.core.usb.getIoEndpoints
|
||||
import im.angry.openeuicc.util.*
|
||||
import java.lang.IllegalArgumentException
|
||||
|
||||
open class DefaultEuiccChannelFactory(protected val context: Context) : EuiccChannelFactory {
|
||||
private var seService: SEService? = null
|
||||
|
||||
private val usbManager by lazy {
|
||||
context.getSystemService(Context.USB_SERVICE) as UsbManager
|
||||
}
|
||||
|
||||
private suspend fun ensureSEService() {
|
||||
if (seService == null || !seService!!.isConnected) {
|
||||
seService = connectSEService(context)
|
||||
|
@ -36,6 +45,17 @@ open class DefaultEuiccChannelFactory(protected val context: Context) : EuiccCha
|
|||
return null
|
||||
}
|
||||
|
||||
override fun tryOpenUsbEuiccChannel(usbDevice: UsbDevice, usbInterface: UsbInterface): EuiccChannel? {
|
||||
val (bulkIn, bulkOut) = usbInterface.getIoEndpoints()
|
||||
if (bulkIn == null || bulkOut == null) return null
|
||||
val conn = usbManager.openDevice(usbDevice) ?: return null
|
||||
if (!conn.claimInterface(usbInterface, true)) return null
|
||||
return EuiccChannel(
|
||||
FakeUiccPortInfoCompat(FakeUiccCardInfoCompat(EuiccChannelManager.USB_CHANNEL_ID)),
|
||||
UsbApduInterface(conn, bulkIn, bulkOut)
|
||||
)
|
||||
}
|
||||
|
||||
override fun cleanup() {
|
||||
seService?.shutdown()
|
||||
seService = null
|
||||
|
|
|
@ -1,8 +1,11 @@
|
|||
package im.angry.openeuicc.core
|
||||
|
||||
import android.content.Context
|
||||
import android.hardware.usb.UsbDevice
|
||||
import android.hardware.usb.UsbManager
|
||||
import android.telephony.SubscriptionManager
|
||||
import android.util.Log
|
||||
import im.angry.openeuicc.core.usb.getSmartCardInterface
|
||||
import im.angry.openeuicc.di.AppContainer
|
||||
import im.angry.openeuicc.util.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
@ -23,12 +26,18 @@ open class DefaultEuiccChannelManager(
|
|||
|
||||
private val channelCache = mutableListOf<EuiccChannel>()
|
||||
|
||||
private var usbChannel: EuiccChannel? = null
|
||||
|
||||
private val lock = Mutex()
|
||||
|
||||
protected val tm by lazy {
|
||||
appContainer.telephonyManager
|
||||
}
|
||||
|
||||
private val usbManager by lazy {
|
||||
context.getSystemService(Context.USB_SERVICE) as UsbManager
|
||||
}
|
||||
|
||||
private val euiccChannelFactory by lazy {
|
||||
appContainer.euiccChannelFactory
|
||||
}
|
||||
|
@ -38,6 +47,15 @@ open class DefaultEuiccChannelManager(
|
|||
|
||||
private suspend fun tryOpenEuiccChannel(port: UiccPortInfoCompat): EuiccChannel? {
|
||||
lock.withLock {
|
||||
if (port.card.physicalSlotIndex == EuiccChannelManager.USB_CHANNEL_ID) {
|
||||
return if (usbChannel != null && usbChannel!!.valid) {
|
||||
usbChannel
|
||||
} else {
|
||||
usbChannel = null
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
val existing =
|
||||
channelCache.find { it.slotId == port.card.physicalSlotIndex && it.portId == port.portIndex }
|
||||
if (existing != null) {
|
||||
|
@ -162,11 +180,37 @@ open class DefaultEuiccChannelManager(
|
|||
}
|
||||
}
|
||||
|
||||
override suspend fun enumerateUsbEuiccChannel(): Pair<UsbDevice?, EuiccChannel?> =
|
||||
withContext(Dispatchers.IO) {
|
||||
usbManager.deviceList.values.forEach { device ->
|
||||
Log.i(TAG, "Scanning USB device ${device.deviceId}:${device.vendorId}")
|
||||
val iface = device.getSmartCardInterface() ?: return@forEach
|
||||
// If we don't have permission, tell UI code that we found a candidate device, but we
|
||||
// need permission to be able to do anything with it
|
||||
if (!usbManager.hasPermission(device)) return@withContext Pair(device, null)
|
||||
Log.i(TAG, "Found CCID interface on ${device.deviceId}:${device.vendorId}, and has permission; trying to open channel")
|
||||
try {
|
||||
val channel = euiccChannelFactory.tryOpenUsbEuiccChannel(device, iface)
|
||||
if (channel != null && channel.lpa.valid) {
|
||||
usbChannel = channel
|
||||
return@withContext Pair(device, channel)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Ignored -- skip forward
|
||||
e.printStackTrace()
|
||||
}
|
||||
Log.i(TAG, "No valid eUICC channel found on USB device ${device.deviceId}:${device.vendorId}")
|
||||
}
|
||||
return@withContext Pair(null, null)
|
||||
}
|
||||
|
||||
override fun invalidate() {
|
||||
for (channel in channelCache) {
|
||||
channel.close()
|
||||
}
|
||||
|
||||
usbChannel?.close()
|
||||
usbChannel = null
|
||||
channelCache.clear()
|
||||
euiccChannelFactory.cleanup()
|
||||
}
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
package im.angry.openeuicc.core
|
||||
|
||||
import android.hardware.usb.UsbDevice
|
||||
import android.hardware.usb.UsbInterface
|
||||
import im.angry.openeuicc.util.*
|
||||
|
||||
// This class is here instead of inside DI because it contains a bit more logic than just
|
||||
|
@ -7,6 +9,8 @@ import im.angry.openeuicc.util.*
|
|||
interface EuiccChannelFactory {
|
||||
suspend fun tryOpenEuiccChannel(port: UiccPortInfoCompat): EuiccChannel?
|
||||
|
||||
fun tryOpenUsbEuiccChannel(usbDevice: UsbDevice, usbInterface: UsbInterface): EuiccChannel?
|
||||
|
||||
/**
|
||||
* Release all resources used by this EuiccChannelFactory
|
||||
* Note that the same instance may be reused; any resources allocated must be automatically
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
package im.angry.openeuicc.core
|
||||
|
||||
import android.hardware.usb.UsbDevice
|
||||
|
||||
/**
|
||||
* EuiccChannelManager holds references to, and manages the lifecycles of, individual
|
||||
* APDU channels to SIM cards. The find* methods will create channels when needed, and
|
||||
|
@ -11,13 +13,25 @@ package im.angry.openeuicc.core
|
|||
* Holding references independent of EuiccChannelManagerService is unsupported.
|
||||
*/
|
||||
interface EuiccChannelManager {
|
||||
companion object {
|
||||
const val USB_CHANNEL_ID = 99
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan all possible sources for EuiccChannels, return them and have all
|
||||
* Scan all possible _device internal_ sources for EuiccChannels, return them and have all
|
||||
* scanned channels cached; these channels will remain open for the entire lifetime of
|
||||
* this EuiccChannelManager object, unless disconnected externally or invalidate()'d
|
||||
*/
|
||||
suspend fun enumerateEuiccChannels(): List<EuiccChannel>
|
||||
|
||||
/**
|
||||
* Scan all possible USB devices for CCID readers that may contain eUICC cards.
|
||||
* If found, try to open it for access, and add it to the internal EuiccChannel cache
|
||||
* as a "port" with id 99. When user interaction is required to obtain permission
|
||||
* to interact with the device, the second return value (EuiccChannel) will be null.
|
||||
*/
|
||||
suspend fun enumerateUsbEuiccChannel(): Pair<UsbDevice?, EuiccChannel?>
|
||||
|
||||
/**
|
||||
* Wait for a slot + port to reconnect (i.e. become valid again)
|
||||
* If the port is currently valid, this function will return immediately.
|
||||
|
|
|
@ -0,0 +1,37 @@
|
|||
package im.angry.openeuicc.core.usb
|
||||
|
||||
import android.hardware.usb.UsbDeviceConnection
|
||||
import android.hardware.usb.UsbEndpoint
|
||||
import net.typeblog.lpac_jni.ApduInterface
|
||||
|
||||
class UsbApduInterface(
|
||||
private val conn: UsbDeviceConnection,
|
||||
private val bulkIn: UsbEndpoint,
|
||||
private val bulkOut: UsbEndpoint
|
||||
): ApduInterface {
|
||||
private lateinit var ccidDescription: UsbCcidDescription
|
||||
|
||||
override fun connect() {
|
||||
ccidDescription = UsbCcidDescription.fromRawDescriptors(conn.rawDescriptors)!!
|
||||
ccidDescription.checkTransportProtocol()
|
||||
}
|
||||
|
||||
override fun disconnect() {
|
||||
conn.close()
|
||||
}
|
||||
|
||||
override fun logicalChannelOpen(aid: ByteArray): Int {
|
||||
return 0
|
||||
}
|
||||
|
||||
override fun logicalChannelClose(handle: Int) {
|
||||
|
||||
}
|
||||
|
||||
override fun transmit(tx: ByteArray): ByteArray {
|
||||
return byteArrayOf()
|
||||
}
|
||||
|
||||
override val valid: Boolean
|
||||
get() = true
|
||||
}
|
|
@ -0,0 +1,106 @@
|
|||
package im.angry.openeuicc.core.usb
|
||||
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
|
||||
data class UsbCcidDescription(
|
||||
private val bMaxSlotIndex: Byte,
|
||||
private val bVoltageSupport: Byte,
|
||||
private val dwProtocols: Int,
|
||||
private val dwFeatures: Int
|
||||
) {
|
||||
companion object {
|
||||
private const val DESCRIPTOR_LENGTH: Byte = 0x36
|
||||
private const val DESCRIPTOR_TYPE: Byte = 0x21
|
||||
|
||||
// dwFeatures Masks
|
||||
private const val FEATURE_AUTOMATIC_VOLTAGE = 0x00008
|
||||
private const val FEATURE_AUTOMATIC_PPS = 0x00080
|
||||
|
||||
private const val FEATURE_EXCHANGE_LEVEL_TPDU = 0x10000
|
||||
private const val FEATURE_EXCHANGE_LEVEL_SHORT_APDU = 0x20000
|
||||
private const val FEATURE_EXCHAGE_LEVEL_EXTENDED_APDU = 0x40000
|
||||
|
||||
// bVoltageSupport Masks
|
||||
private const val VOLTAGE_5V: Byte = 1
|
||||
private const val VOLTAGE_3V: Byte = 2
|
||||
private const val VOLTAGE_1_8V: Byte = 4
|
||||
|
||||
private const val SLOT_OFFSET = 4
|
||||
private const val FEATURES_OFFSET = 40
|
||||
private const val MASK_T0_PROTO = 1
|
||||
private const val MASK_T1_PROTO = 2
|
||||
|
||||
fun fromRawDescriptors(desc: ByteArray): UsbCcidDescription? {
|
||||
var dwProtocols = 0
|
||||
var dwFeatures = 0
|
||||
var bMaxSlotIndex: Byte = 0
|
||||
var bVoltageSupport: Byte = 0
|
||||
|
||||
var hasCcidDescriptor = false
|
||||
|
||||
val byteBuffer = ByteBuffer.wrap(desc).order(ByteOrder.LITTLE_ENDIAN)
|
||||
|
||||
while (byteBuffer.hasRemaining()) {
|
||||
byteBuffer.mark()
|
||||
val len = byteBuffer.get()
|
||||
val type = byteBuffer.get()
|
||||
if (type == DESCRIPTOR_TYPE && len == DESCRIPTOR_LENGTH) {
|
||||
byteBuffer.reset()
|
||||
byteBuffer.position(byteBuffer.position() + SLOT_OFFSET)
|
||||
bMaxSlotIndex = byteBuffer.get()
|
||||
bVoltageSupport = byteBuffer.get()
|
||||
dwProtocols = byteBuffer.int
|
||||
byteBuffer.reset()
|
||||
byteBuffer.position(byteBuffer.position() + FEATURES_OFFSET)
|
||||
dwFeatures = byteBuffer.int
|
||||
hasCcidDescriptor = true
|
||||
break
|
||||
} else {
|
||||
byteBuffer.position(byteBuffer.position() + len - 2)
|
||||
}
|
||||
}
|
||||
|
||||
return if (hasCcidDescriptor) {
|
||||
UsbCcidDescription(bMaxSlotIndex, bVoltageSupport, dwProtocols, dwFeatures)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class Voltage(powerOnValue: Int, mask: Int) {
|
||||
AUTO(0, 0), _5V(1, VOLTAGE_5V.toInt()), _3V(2, VOLTAGE_3V.toInt()), _1_8V(
|
||||
3,
|
||||
VOLTAGE_1_8V.toInt()
|
||||
);
|
||||
|
||||
val mask = powerOnValue.toByte()
|
||||
val powerOnValue = mask.toByte()
|
||||
}
|
||||
|
||||
private fun hasFeature(feature: Int): Boolean =
|
||||
(dwFeatures and feature) != 0
|
||||
|
||||
val voltages: Array<Voltage>
|
||||
get() =
|
||||
if (hasFeature(FEATURE_AUTOMATIC_VOLTAGE)) {
|
||||
arrayOf(Voltage.AUTO)
|
||||
} else {
|
||||
Voltage.values().mapNotNull {
|
||||
if ((it.mask.toInt() and bVoltageSupport.toInt()) != 0) {
|
||||
it
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}.toTypedArray()
|
||||
}
|
||||
|
||||
val hasAutomaticPps: Boolean = 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")
|
||||
}
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
// Adapted from <https://github.com/open-keychain/open-keychain/blob/master/OpenKeychain/src/main/java/org/sufficientlysecure/keychain/securitytoken/usb>
|
||||
package im.angry.openeuicc.core.usb
|
||||
|
||||
import android.hardware.usb.UsbConstants
|
||||
import android.hardware.usb.UsbDevice
|
||||
import android.hardware.usb.UsbEndpoint
|
||||
import android.hardware.usb.UsbInterface
|
||||
|
||||
fun UsbInterface.getIoEndpoints(): Pair<UsbEndpoint?, UsbEndpoint?> {
|
||||
var bulkIn: UsbEndpoint? = null
|
||||
var bulkOut: UsbEndpoint? = null
|
||||
for (i in 0 until endpointCount) {
|
||||
val endpoint = getEndpoint(i)
|
||||
if (endpoint.type != UsbConstants.USB_ENDPOINT_XFER_BULK) {
|
||||
continue
|
||||
}
|
||||
if (endpoint.direction == UsbConstants.USB_DIR_IN) {
|
||||
bulkIn = endpoint
|
||||
} else if (endpoint.direction == UsbConstants.USB_DIR_OUT) {
|
||||
bulkOut = endpoint
|
||||
}
|
||||
}
|
||||
return Pair(bulkIn, bulkOut)
|
||||
}
|
||||
|
||||
fun UsbDevice.getSmartCardInterface(): UsbInterface? {
|
||||
for (i in 0 until interfaceCount) {
|
||||
val anInterface = getInterface(i)
|
||||
if (anInterface.interfaceClass == UsbConstants.USB_CLASS_CSCID) {
|
||||
return anInterface
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
|
@ -1,6 +1,14 @@
|
|||
package im.angry.openeuicc.ui
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.PendingIntent
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.hardware.usb.UsbDevice
|
||||
import android.hardware.usb.UsbManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.telephony.TelephonyManager
|
||||
import android.util.Log
|
||||
|
@ -12,6 +20,7 @@ import android.widget.ArrayAdapter
|
|||
import android.widget.Spinner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import im.angry.openeuicc.common.R
|
||||
import im.angry.openeuicc.core.EuiccChannel
|
||||
import im.angry.openeuicc.util.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
@ -20,6 +29,7 @@ import kotlinx.coroutines.withContext
|
|||
open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
|
||||
companion object {
|
||||
const val TAG = "MainActivity"
|
||||
const val ACTION_USB_PERMISSION = "im.angry.openeuicc.USB_PERMISSION"
|
||||
}
|
||||
|
||||
private lateinit var spinnerAdapter: ArrayAdapter<String>
|
||||
|
@ -30,6 +40,28 @@ open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
|
|||
|
||||
protected lateinit var tm: TelephonyManager
|
||||
|
||||
private val usbManager: UsbManager by lazy {
|
||||
getSystemService(USB_SERVICE) as UsbManager
|
||||
}
|
||||
|
||||
private var usbDevice: UsbDevice? = null
|
||||
private var usbChannel: EuiccChannel? = null
|
||||
|
||||
private lateinit var usbPendingIntent: PendingIntent
|
||||
|
||||
private val usbPermissionReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
if (intent?.action == ACTION_USB_PERMISSION) {
|
||||
if (intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)) {
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
switchToUsbFragmentIfPossible()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("WrongConstant", "UnspecifiedRegisterReceiverFlag")
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_main)
|
||||
|
@ -43,6 +75,15 @@ open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
|
|||
tm = telephonyManager
|
||||
|
||||
spinnerAdapter = ArrayAdapter<String>(this, R.layout.spinner_item)
|
||||
|
||||
usbPendingIntent = PendingIntent.getBroadcast(this, 0,
|
||||
Intent(ACTION_USB_PERMISSION), PendingIntent.FLAG_IMMUTABLE)
|
||||
val filter = IntentFilter(ACTION_USB_PERMISSION)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
registerReceiver(usbPermissionReceiver, filter, Context.RECEIVER_EXPORTED)
|
||||
} else {
|
||||
registerReceiver(usbPermissionReceiver, filter)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
|
@ -62,8 +103,15 @@ open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
|
|||
position: Int,
|
||||
id: Long
|
||||
) {
|
||||
supportFragmentManager.beginTransaction()
|
||||
.replace(R.id.fragment_root, fragments[position]).commit()
|
||||
if (position < fragments.size) {
|
||||
supportFragmentManager.beginTransaction()
|
||||
.replace(R.id.fragment_root, fragments[position]).commit()
|
||||
} else if (position == fragments.size) {
|
||||
// If we are at the last position, this is the USB device
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
switchToUsbFragmentIfPossible()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNothingSelected(parent: AdapterView<*>?) {
|
||||
|
@ -106,12 +154,22 @@ open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
|
|||
}
|
||||
}
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
val res = euiccChannelManager.enumerateUsbEuiccChannel()
|
||||
usbDevice = res.first
|
||||
usbChannel = res.second
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
knownChannels.sortedBy { it.logicalSlotId }.forEach { channel ->
|
||||
spinnerAdapter.add(getString(R.string.channel_name_format, channel.logicalSlotId))
|
||||
fragments.add(appContainer.uiComponentFactory.createEuiccManagementFragment(channel))
|
||||
}
|
||||
|
||||
// If USB readers exist, add them at the very last
|
||||
// The adapter logic depends on this assumption
|
||||
usbDevice?.let { spinnerAdapter.add(it.productName) }
|
||||
|
||||
if (fragments.isNotEmpty()) {
|
||||
if (this@MainActivity::spinner.isInitialized) {
|
||||
spinnerItem.isVisible = true
|
||||
|
@ -120,4 +178,27 @@ open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun switchToUsbFragmentIfPossible() {
|
||||
if (usbDevice != null && usbChannel == null) {
|
||||
if (!usbManager.hasPermission(usbDevice)) {
|
||||
usbManager.requestPermission(usbDevice, usbPendingIntent)
|
||||
return
|
||||
} else {
|
||||
val (device, channel) = withContext(Dispatchers.IO) {
|
||||
euiccChannelManager.enumerateUsbEuiccChannel()
|
||||
}
|
||||
|
||||
if (device != null && channel != null) {
|
||||
usbDevice = device
|
||||
usbChannel = channel
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (usbChannel != null) {
|
||||
supportFragmentManager.beginTransaction().replace(R.id.fragment_root,
|
||||
appContainer.uiComponentFactory.createEuiccManagementFragment(usbChannel!!)).commit()
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Add table
Reference in a new issue