diff --git a/app/build.gradle b/app/build.gradle index 0592a0b..e23c4db 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -105,10 +105,15 @@ dependencies { implementation 'com.github.d-max:spots-dialog:0.7@aar' // Coppernic - implementation "fr.coppernic.sdk.core:CpcCore:3.8.0" + implementation("fr.coppernic.sdk.core:CpcCore:3.8.0") { + exclude group: "fr.coppernic.external", module: "D2xx" + } + implementation 'fr.coppernic.lib.splash:splash:0.4.1' - pcscv1Implementation 'fr.coppernic.sdk.pcsc:CpcPcsc:1.6.0' - pcscv2Implementation "fr.coppernic.sdk.pcsc2:CpcCore:3.7.0" + pcscv1Implementation 'fr.coppernic.sdk.pcsc:CpcPcsc:1.7.0' + pcscv2Implementation("fr.coppernic.sdk.pcsc2:CpcCore:3.7.0") { + exclude group: "fr.coppernic.external", module: "D2xx" + } // RX-java implementation 'io.reactivex.rxjava2:rxjava:2.2.10' @@ -117,4 +122,9 @@ dependencies { // Coroutine implementation "androidx.lifecycle:lifecycle-common-java8:2.5.1" implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.5.1" + + + // FTDI for Fusion support + pcscv1Implementation(files("libs/d2xx.jar")) + } diff --git a/app/libs/d2xx.jar b/app/libs/d2xx.jar new file mode 100644 index 0000000..8673f2f Binary files /dev/null and b/app/libs/d2xx.jar differ diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index edc0088..9bb7baf 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -37,6 +37,26 @@ android:name="activity" android:value="fr.coppernic.samples.pcsc.ui.MainActivity"/> + + + + + + + + + + + + - \ No newline at end of file + diff --git a/app/src/main/res/xml/device_filter.xml b/app/src/main/res/xml/device_filter.xml new file mode 100644 index 0000000..129e140 --- /dev/null +++ b/app/src/main/res/xml/device_filter.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/app/src/pcscv1/java/fr/coppernic/samples/pcsc/ui/MainActivity.java b/app/src/pcscv1/java/fr/coppernic/samples/pcsc/ui/MainActivity.java index 5fd8d09..075d5ae 100644 --- a/app/src/pcscv1/java/fr/coppernic/samples/pcsc/ui/MainActivity.java +++ b/app/src/pcscv1/java/fr/coppernic/samples/pcsc/ui/MainActivity.java @@ -1,11 +1,7 @@ package fr.coppernic.samples.pcsc.ui; -import static fr.coppernic.sdk.power.OutletPowerManager.ACTION_USB_PERMISSION; - -import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; -import android.content.IntentFilter; import android.os.Bundle; import android.text.Editable; import android.text.TextWatcher; @@ -17,7 +13,10 @@ import android.widget.ArrayAdapter; import android.widget.EditText; import android.widget.TextView; +import android.widget.Toast; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.widget.SwitchCompat; import androidx.appcompat.widget.Toolbar; @@ -33,6 +32,7 @@ import fr.coppernic.samples.pcsc.reader.PcscReader; import fr.coppernic.sdk.pcsc.ApduResponse; import fr.coppernic.sdk.power.OutletPowerManager; +import fr.coppernic.sdk.usboutlet.UsbConfigurationManager; import fr.coppernic.sdk.utils.core.CpcBytes; import fr.coppernic.sdk.utils.core.CpcResult; import fr.coppernic.sdk.utils.core.CpcResult.RESULT; @@ -41,6 +41,21 @@ public class MainActivity extends AppCompatActivity { + private UsbConfigurationManager usbConfigurationManager; + + private final ActivityResultLauncher usbConfigurationLauncher = + registerForActivityResult( + new ActivityResultContracts.StartActivityForResult(), + result -> { + if (result.getResultCode() == RESULT_OK) { + startPcscReader(); + } else { + handleUsbConfigurationFailure(); + } + } + ); + + FloatingActionButton fab; Toolbar toolbar; MaterialBetterSpinner spReader; @@ -69,6 +84,25 @@ public boolean onEditorAction(TextView textView, int i, KeyEvent keyEvent) { protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); + + initUi(); + + usbConfigurationManager = new UsbConfigurationManager(this); + } + + private void initializeUsb() { + // If SmartCard reader is powered and permission granted, no need to launch configuration activity + if (usbConfigurationManager.isSmartCardReaderReady()) { + startPcscReader(); + } else { + usbConfigurationLauncher.launch( + UsbConfigurationManager.Companion.createConfigurationIntent(this) + ); + } + } + + private void initUi() { + initTitle(); fab = findViewById(R.id.fab); @@ -113,37 +147,33 @@ public void afterTextChanged(Editable s) { //Init empty spinner ArrayAdapter arrayAdapter = new ArrayAdapter<>(this, - android.R.layout.simple_dropdown_item_1line, - new ArrayList()); + android.R.layout.simple_dropdown_item_1line, + new ArrayList()); spReader.setAdapter(arrayAdapter); etResult.clearFocus(); etApdu.setOnEditorActionListener(editorActionListener); + + showFAB(false); + + swConnect.setEnabled(false); + } + + private void handleUsbConfigurationFailure() { + Toast.makeText(this, "USB configuration failed", Toast.LENGTH_SHORT).show(); + finish(); } @Override protected void onStart() { Timber.d("onStart"); super.onStart(); - showFAB(false); - - // Register USB receiver - IntentFilter filter = new IntentFilter(); - filter.addAction(ACTION_USB_PERMISSION); - registerReceiver(usbReceiver, filter, RECEIVER_EXPORTED); - - powerOn(); + initializeUsb(); } @Override protected void onStop() { Timber.d("onStop"); - // Unregister USB receiver - try { - unregisterReceiver(usbReceiver); - } catch (Exception e) { - e.printStackTrace(); - } stopPcscReader(); super.onStop(); } @@ -199,38 +229,6 @@ private void stopPcscReader() { } //endregion - - //region USB - private BroadcastReceiver usbReceiver = new BroadcastReceiver() { - @Override - public void onReceive(Context context, Intent intent) { - String action = intent.getAction(); - if (ACTION_USB_PERMISSION.equalsIgnoreCase(action)) { - swConnect.setEnabled(true); - startPcscReader(); - } - } - }; - //endregion - - - // region Methods - void powerOn() { - if (!manager.isPowerOn(getApplicationContext())) { - Coroutines coroutines = new Coroutines(); - manager.powerOn(MainActivity.this, 10000L, coroutines.getContinuation( - (res, err) -> { - try { - Thread.sleep(500L); - } catch (InterruptedException e) { - throw new RuntimeException(e); - } - manager.getUsbPermissions(MainActivity.this); - } - )); - } - } - void connectCard() { if (reader != null) { if (!reader.isConnected()) {//Connect reader @@ -325,6 +323,8 @@ private void updateSpinner() { deviceList); spReader.setAdapter(arrayAdapter); } + + swConnect.setEnabled(deviceList != null); } //endregion } diff --git a/app/src/pcscv1/java/fr/coppernic/sdk/usboutlet/FTdiManager.kt b/app/src/pcscv1/java/fr/coppernic/sdk/usboutlet/FTdiManager.kt new file mode 100644 index 0000000..bd6885d --- /dev/null +++ b/app/src/pcscv1/java/fr/coppernic/sdk/usboutlet/FTdiManager.kt @@ -0,0 +1,83 @@ +package fr.coppernic.sdk.usboutlet + +import android.content.Context +import android.content.Context.USB_SERVICE +import android.hardware.usb.UsbManager +import android.util.Log +import com.ftdi.j2xx.D2xxManager +import com.ftdi.j2xx.FT_Device + +const val SET_GPIO_OUTPUT_ALL_HIGH = 0xF0 + +const val SET_GPIO_OUTPUT_ALL_LOG = 0xFF + +const val SET_BIT_MODE = 0x20 +const val FTDI_CONFIGURATION_DELAY = 2000L + + +object FtdiManager { + + private var ftDevice: FT_Device? = null + private const val TAG = "FtdiManager" + + fun powerSmartCardReader(context: Context): Boolean { + val usbManager = context.applicationContext.getSystemService(USB_SERVICE) as UsbManager + + val ftdiUsbDevice = + findUsbDevice(usbManager, FTDI_VID, listOf(FTDI_PID_1, FTDI_PID_2)) ?: return false + + + try { + val d2xxManager = D2xxManager.getInstance(context) + val count = d2xxManager.createDeviceInfoList(context) + + if (count == 0) { + Log.e(TAG, "D2xx createDeviceInfoList() count == 0") + return false + } + + ftDevice = d2xxManager.openByUsbDevice( + context, ftdiUsbDevice + ) + + if (ftDevice == null) { + Log.e(TAG, "D2xx openByUsbDevice() failed") + return false + } + + ftDevice?.apply { + resetDevice() + setBaudRate(9600) + setDataCharacteristics( + D2xxManager.FT_DATA_BITS_8, + D2xxManager.FT_STOP_BITS_1, + D2xxManager.FT_PARITY_NONE + ) + } + + val mask = computeFtdiGpioOutputMask(false, false, false, true) + ftDevice?.setBitMode(mask, SET_BIT_MODE.toByte()) + + return true + } catch (e: Exception) { + Log.e(TAG, "FTDI communication failed", e) + return false + } + } + + + private fun computeFtdiGpioOutputMask( + gpio0: Boolean, gpio1: Boolean, gpio2: Boolean, gpio3: Boolean + ): Byte { + + val direction = 0xF shl 4 + var values = 0 + if (!gpio0) values = values or (1 shl 0) + if (!gpio1) values = values or (1 shl 1) + if (!gpio2) values = values or (1 shl 2) + if (!gpio3) values = values or (1 shl 3) + + return (direction or values).toByte() + } + +} diff --git a/app/src/pcscv1/java/fr/coppernic/sdk/usboutlet/UsbAttachActivity.kt b/app/src/pcscv1/java/fr/coppernic/sdk/usboutlet/UsbAttachActivity.kt new file mode 100644 index 0000000..1509db5 --- /dev/null +++ b/app/src/pcscv1/java/fr/coppernic/sdk/usboutlet/UsbAttachActivity.kt @@ -0,0 +1,31 @@ +package fr.coppernic.sdk.usboutlet + +import android.app.Activity +import android.hardware.usb.UsbDevice +import android.hardware.usb.UsbManager +import android.os.Bundle +import android.util.Log + +class UsbAttachActivity : Activity() { + private val TAG = "UsbAttachActivity" + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val device = + intent.getParcelableExtra(UsbManager.EXTRA_DEVICE) + + if (device != null) { + val manager = + getSystemService(USB_SERVICE) as UsbManager + + Log.d( + TAG, + "USB attached: " + device.deviceName + + ", permission=" + manager.hasPermission(device) + ) + } + + finish() + } +} diff --git a/app/src/pcscv1/java/fr/coppernic/sdk/usboutlet/UsbConfigurationActivity.kt b/app/src/pcscv1/java/fr/coppernic/sdk/usboutlet/UsbConfigurationActivity.kt new file mode 100644 index 0000000..3e3c6c4 --- /dev/null +++ b/app/src/pcscv1/java/fr/coppernic/sdk/usboutlet/UsbConfigurationActivity.kt @@ -0,0 +1,355 @@ +package fr.coppernic.sdk.usboutlet + +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.util.Log +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat +import androidx.lifecycle.lifecycleScope +import com.ftdi.j2xx.FT_Device +import fr.coppernic.sdk.power.OutletPowerManager +import kotlin.coroutines.cancellation.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +class UsbConfigurationActivity : AppCompatActivity() { + + companion object { + private const val TAG = "UsbConfiguration" + + // Adapt these + private const val OUTLET_POWER_TIMEOUT_MS = 5_000L + private const val DEVICE_ENUM_TIMEOUT_MS = 5_000L + + // Time given to the user to answer each permission dialog + private const val PERMISSION_TIMEOUT_MS = 20_000L + + private const val POLL_DELAY_MS = 200L + } + + private lateinit var usbManager: UsbManager + + private val usbPermissionAction by lazy { + "$packageName.USB_PERMISSION" + } + + private data class PendingPermission( + val deviceId: Int, val result: CompletableDeferred + ) + + private var pendingPermission: PendingPermission? = null + + private val usbPermissionReceiver = object : BroadcastReceiver() { + + override fun onReceive(context: Context, intent: Intent) { + Log.d(TAG, "USB permission received" + intent.action) + + if (intent.action != usbPermissionAction) { + return + } + + val device = getUsbDevice(intent) ?: return + + val pending = pendingPermission ?: return + + // Ignore an old/unrelated permission answer + if (device.deviceId != pending.deviceId) { + return + } + + val granted = intent.getBooleanExtra( + UsbManager.EXTRA_PERMISSION_GRANTED, false + ) + + Log.i( + TAG, "Permission result for " + "%04X:%04X = $granted".format( + device.vendorId, device.productId + ) + ) + + pending.result.complete(granted) + } + + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + Log.i(TAG, "Starting USB configuration") + + usbManager = getSystemService(USB_SERVICE) as UsbManager + + registerPermissionReceiver() + + lifecycleScope.launch { + val success = configureUsb() + + setResult( + if (success) RESULT_OK + else RESULT_CANCELED + ) + + finish() + } + } + + override fun onDestroy() { + pendingPermission?.result?.cancel() + pendingPermission = null + + unregisterReceiver(usbPermissionReceiver) + + super.onDestroy() + } + + private suspend fun configureUsb(): Boolean { + try { + + if (!setupFtdi()) { + Log.e(TAG, "Unable to setup FTDI") + return false + } + + if (!setupSmartCardReader()) { + Log.e(TAG, "Unable to setup SmartCard reader") + return false + } + + /* + * Everything is ready. + */ + Log.i(TAG, "USB configuration successful") + return true + + } catch (e: CancellationException) { + throw e + + } catch (e: Exception) { + Log.e(TAG, "USB configuration failed", e) + return false + } finally { + } + } + + private suspend fun powerOutlet(): Boolean { + if (findUsbDevice(usbManager, FTDI_VID, listOf(FTDI_PID_1, FTDI_PID_2)) != null) { + Log.d(TAG, "FTDI already powered") + return true + }/* + * 1. Power the outlet. + * + * this call blocks until the outlet is powered + * or until its own timeout occurs. + */ + Log.i(TAG, "Powering FTDI outlet") + + withContext(Dispatchers.IO) { + OutletPowerManager().powerOn( + applicationContext, OUTLET_POWER_TIMEOUT_MS + ) + } + + /* + * 2. Wait for Android USB host to enumerate FTDI. + */ + Log.i(TAG, "Waiting for FTDI") + + val ftdiDevice = waitForUsbDevice( + FTDI_VID, listOf(FTDI_PID_1, FTDI_PID_2), DEVICE_ENUM_TIMEOUT_MS + ) + + if (ftdiDevice == null) { + Log.e(TAG, "FTDI did not appear") + return false + } + + Log.i(TAG, "device detected: ${ftdiDevice.deviceName}") + Log.i(TAG, "device is Ftdi : ${ftdiDevice.isFtdi()}") + + return true + } + + private suspend fun setupFtdi(): Boolean { + if (!powerOutlet()) { + return false + } + + val ftdiDevice = findUsbDevice( + usbManager, FTDI_VID, listOf(FTDI_PID_1, FTDI_PID_2) + ) ?: return false + + /* + * 3. Get FTDI permission, only when necessary. + */ + if (!ensureUsbPermission(ftdiDevice)) { + Log.e(TAG, "FTDI permission not granted") + return false + } + return true + } + + private suspend fun setupSmartCardReader(): Boolean { + if (!powerSmartCardReader()) { + Log.e(TAG, "Unable to power smart card reader") + return false + } + + val sec1210USbDevice = + findUsbDevice(usbManager, SEC1210_VID, listOf(SEC1210_PID)) ?: return false + + if (!ensureUsbPermission(sec1210USbDevice)) { + Log.e(TAG, "SEC1210 permission not granted") + return false + } + + return true + } + + /** + * Waits for Android to enumerate a specific USB device. + * + * Polling here is intentional: + * this Activity only lives for a few seconds and it makes + * enumeration after power-on very simple. + */ + private suspend fun waitForUsbDevice( + vendorId: Int, productIdList: List, timeoutMs: Long + ): UsbDevice? = withTimeoutOrNull(timeoutMs) { + var device: UsbDevice? = null + + while (device == null) { + device = findUsbDevice(usbManager, vendorId, productIdList) + + if (device == null) { + delay(POLL_DELAY_MS) + } + } + device + } + + /** + * Returns true if permission exists or if the user grants it. + * + * If the user ignores the permission dialog for too long, + * this returns false and the Activity terminates. + */ + private suspend fun ensureUsbPermission( + device: UsbDevice + ): Boolean { + + if (usbManager.hasPermission(device)) { + Log.i( + TAG, "Permission already granted for ${device.deviceName}" + ) + return true + } + + Log.i( + TAG, "Requesting permission for ${device.deviceName}" + ) + + val deferred = CompletableDeferred() + + pendingPermission = PendingPermission( + deviceId = device.deviceId, result = deferred + ) + + val permissionIntent = Intent(usbPermissionAction).setPackage(packageName) + + val pendingIntent = PendingIntent.getBroadcast( + this, 0, permissionIntent, PendingIntent.FLAG_MUTABLE + ) + + usbManager.requestPermission( + device, pendingIntent + ) + + val granted = try { + withTimeoutOrNull(PERMISSION_TIMEOUT_MS) { + deferred.await() + } ?: false + + } finally { + if (pendingPermission?.deviceId == device.deviceId) { + pendingPermission = null + } + } + + if (!granted) { + Log.w( + TAG, "Permission denied or timed out for ${device.deviceName}" + ) + } + + return granted + } + + var ftDevice: FT_Device? = null + + /** + * FTDI CBUS command that powers the smart card reader. + */ + private suspend fun powerSmartCardReader(): Boolean = withContext(Dispatchers.IO) { + if (findUsbDevice(usbManager, SEC1210_VID, listOf(SEC1210_PID)) != null) { + Log.d(TAG, "Smart Card reader already powered") + return@withContext true + } + + if (!FtdiManager.powerSmartCardReader(context = this@UsbConfigurationActivity)) { + Log.e(TAG, "Error while powering Smartcard reader with FTDI") + return@withContext false + } + + Log.i(TAG, "Waiting for SEC1210") + + val sec1210 = waitForUsbDevice( + SEC1210_VID, listOf(SEC1210_PID), DEVICE_ENUM_TIMEOUT_MS + ) + + if (sec1210 == null) { + Log.e(TAG, "SEC1210 did not appear") + return@withContext false + } + + Log.i(TAG, "SEC1210 detected: ${sec1210.deviceName}") + + return@withContext true + + } + + private fun registerPermissionReceiver() { + ContextCompat.registerReceiver( + this, + usbPermissionReceiver, + IntentFilter(usbPermissionAction), + ContextCompat.RECEIVER_NOT_EXPORTED + ) + } + + private fun getUsbDevice( + intent: Intent + ): UsbDevice? { + + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra( + UsbManager.EXTRA_DEVICE, UsbDevice::class.java + ) + } else { + @Suppress("DEPRECATION") intent.getParcelableExtra( + UsbManager.EXTRA_DEVICE + ) + } + } + +} + diff --git a/app/src/pcscv1/java/fr/coppernic/sdk/usboutlet/UsbConfigurationManager.kt b/app/src/pcscv1/java/fr/coppernic/sdk/usboutlet/UsbConfigurationManager.kt new file mode 100644 index 0000000..e498948 --- /dev/null +++ b/app/src/pcscv1/java/fr/coppernic/sdk/usboutlet/UsbConfigurationManager.kt @@ -0,0 +1,32 @@ +package fr.coppernic.sdk.usboutlet + +import android.content.Context +import android.content.Intent +import android.hardware.usb.UsbManager + +class UsbConfigurationManager( + context: Context +) { + + companion object { + + fun createConfigurationIntent(context: Context): Intent { + return Intent( + context, + UsbConfigurationActivity::class.java + ) + } + } + + private val usbManager = + context.applicationContext + .getSystemService(Context.USB_SERVICE) as UsbManager + + fun isSmartCardReaderReady(): Boolean { + val reader = getSmartCardReader(usbManager) + ?: return false + + return usbManager.hasPermission(reader) + } + + } diff --git a/app/src/pcscv1/java/fr/coppernic/sdk/usboutlet/UsbExtension.kt b/app/src/pcscv1/java/fr/coppernic/sdk/usboutlet/UsbExtension.kt new file mode 100644 index 0000000..6960053 --- /dev/null +++ b/app/src/pcscv1/java/fr/coppernic/sdk/usboutlet/UsbExtension.kt @@ -0,0 +1,46 @@ +package fr.coppernic.sdk.usboutlet + +import android.hardware.usb.UsbDevice +import android.hardware.usb.UsbManager + +const val FTDI_VID = 0x0403 +const val FTDI_PID_1 = 0x6010 +const val FTDI_PID_2 = 0x6015 + +const val SEC1210_VID = 0x0424 +const val SEC1210_PID = 0x1202 + +fun isUsbDeviceReady(usbManager: UsbManager, vendorId: Int, productIdList: List): Boolean { + val device = findUsbDevice(usbManager, vendorId, productIdList) ?: return false + return usbManager.hasPermission(device) +} + + +fun findUsbDevice(usbManager: UsbManager, + vendorId: Int, productIdList: List +): UsbDevice? { + return usbManager.deviceList.values.firstOrNull { device -> + device.vendorId == vendorId && productIdList.contains(device.productId) + } +} + +fun getFtdiDevice(usbManager: UsbManager): UsbDevice? { + return findUsbDevice( + usbManager, + FTDI_VID, + listOf(FTDI_PID_1, FTDI_PID_2) + ) +} + +fun getSmartCardReader(usbManager: UsbManager): UsbDevice? { + return findUsbDevice( + usbManager, + SEC1210_VID, + listOf(SEC1210_PID) + ) +} + +fun UsbDevice.isFtdi(): Boolean { + return (this.productId == FTDI_PID_1 || this.productId == FTDI_PID_2) && this.vendorId == FTDI_VID +} +