From 0647b9583f7dc947a977fbf5bb6448ebe7e831b0 Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Fri, 27 Oct 2023 10:10:23 +0200 Subject: [PATCH] preserve connection when paused --- .../com/yubico/authenticator/ActivityUtil.kt | 4 + .../com/yubico/authenticator/MainActivity.kt | 114 ++++++++++-------- .../yubico/authenticator/oath/OathManager.kt | 2 +- .../qrscanner_zxing/QRScannerView.kt | 30 +++-- .../lib/qrscanner_zxing_view.dart | 11 ++ lib/android/app_methods.dart | 10 ++ .../qr_scanner/qr_scanner_provider.dart | 19 +-- lib/android/qr_scanner/qr_scanner_view.dart | 25 ++-- lib/app/views/main_page.dart | 10 +- lib/oath/views/key_actions.dart | 4 +- 10 files changed, 146 insertions(+), 83 deletions(-) diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/ActivityUtil.kt b/android/app/src/main/kotlin/com/yubico/authenticator/ActivityUtil.kt index 1f093f34a..a3f7dc55b 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/ActivityUtil.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/ActivityUtil.kt @@ -40,6 +40,7 @@ class ActivityUtil(private val activity: Activity) { MAIN_ACTIVITY_ALIAS, PackageManager.COMPONENT_ENABLED_STATE_ENABLED ) + logger.debug("Enabled USB discovery by setting state of $MAIN_ACTIVITY_ALIAS to ENABLED") } /** @@ -55,6 +56,7 @@ class ActivityUtil(private val activity: Activity) { MAIN_ACTIVITY_ALIAS, PackageManager.COMPONENT_ENABLED_STATE_DEFAULT ) + logger.debug("Disabled USB discovery by setting state of $MAIN_ACTIVITY_ALIAS to DEFAULT") } /** @@ -73,6 +75,7 @@ class ActivityUtil(private val activity: Activity) { NDEF_ACTIVITY_ALIAS, PackageManager.COMPONENT_ENABLED_STATE_DEFAULT ) + logger.debug("Enabled NFC discovery by setting state of $NDEF_ACTIVITY_ALIAS to DEFAULT") } /** @@ -88,6 +91,7 @@ class ActivityUtil(private val activity: Activity) { NDEF_ACTIVITY_ALIAS, PackageManager.COMPONENT_ENABLED_STATE_DISABLED ) + logger.debug("Disabled NFC discovery by setting state of $NDEF_ACTIVITY_ALIAS to DISABLED") } private fun setState(aliasName: String, enabledState: Int) { diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/MainActivity.kt b/android/app/src/main/kotlin/com/yubico/authenticator/MainActivity.kt index 48f282e8e..ed28131a3 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/MainActivity.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/MainActivity.kt @@ -68,6 +68,8 @@ class MainActivity : FlutterFragmentActivity() { private lateinit var yubikit: YubiKitManager + private var preserveConnectionOnPause: Boolean = false + // receives broadcasts when QR Scanner camera is closed private val qrScannerCameraClosedBR = QRScannerCameraClosedBR() private val nfcAdapterStateChangeBR = NfcAdapterStateChangedBR() @@ -158,8 +160,12 @@ class MainActivity : FlutterFragmentActivity() { appPreferences.unregisterListener(sharedPreferencesListener) - stopUsbDiscovery() - stopNfcDiscovery() + if (!preserveConnectionOnPause) { + stopUsbDiscovery() + stopNfcDiscovery() + } else { + logger.debug("Any existing connections are preserved") + } if (!appPreferences.openAppOnUsb) { activityUtil.disableSystemUsbDiscovery() @@ -179,62 +185,68 @@ class MainActivity : FlutterFragmentActivity() { activityUtil.enableSystemUsbDiscovery() - // Handle opening through otpauth:// link - val intentData = intent.data - if (intentData != null && - (intentData.scheme == "otpauth" || - intentData.scheme == "otpauth-migration") - ) { - intent.data = null - appLinkMethodChannel.handleUri(intentData) - } - - // Handle existing tag when launched from NDEF - val tag = intent.parcelableExtra(NfcAdapter.EXTRA_TAG) - if (tag != null) { - intent.removeExtra(NfcAdapter.EXTRA_TAG) + if (!preserveConnectionOnPause) { + // Handle opening through otpauth:// link + val intentData = intent.data + if (intentData != null && + (intentData.scheme == "otpauth" || + intentData.scheme == "otpauth-migration") + ) { + intent.data = null + appLinkMethodChannel.handleUri(intentData) + } - val executor = Executors.newSingleThreadExecutor() - val device = NfcYubiKeyDevice(tag, nfcConfiguration.timeout, executor) - lifecycleScope.launch { - try { - contextManager?.processYubiKey(device) - device.remove { - executor.shutdown() - startNfcDiscovery() + // Handle existing tag when launched from NDEF + val tag = intent.parcelableExtra(NfcAdapter.EXTRA_TAG) + if (tag != null) { + intent.removeExtra(NfcAdapter.EXTRA_TAG) + + val executor = Executors.newSingleThreadExecutor() + val device = NfcYubiKeyDevice(tag, nfcConfiguration.timeout, executor) + lifecycleScope.launch { + try { + contextManager?.processYubiKey(device) + device.remove { + executor.shutdown() + startNfcDiscovery() + } + } catch (e: Throwable) { + logger.error("Error processing YubiKey in AppContextManager", e) } - } catch (e: Throwable) { - logger.error("Error processing YubiKey in AppContextManager", e) } + } else { + startNfcDiscovery() } - } else { - startNfcDiscovery() - } - val usbManager = getSystemService(Context.USB_SERVICE) as UsbManager - if (UsbManager.ACTION_USB_DEVICE_ATTACHED == intent.action) { - val device = intent.parcelableExtra(UsbManager.EXTRA_DEVICE) - if (device != null) { - // start the USB discover only if the user approved the app to use the device - if (usbManager.hasPermission(device)) { - startUsbDiscovery() + val usbManager = getSystemService(Context.USB_SERVICE) as UsbManager + if (UsbManager.ACTION_USB_DEVICE_ATTACHED == intent.action) { + val device = intent.parcelableExtra(UsbManager.EXTRA_DEVICE) + if (device != null) { + // start the USB discover only if the user approved the app to use the device + if (usbManager.hasPermission(device)) { + startUsbDiscovery() + } } - } - } else { - // if any YubiKeys are connected, use them directly - val deviceIterator = usbManager.deviceList.values.iterator() - while (deviceIterator.hasNext()) { - val device = deviceIterator.next() - if (device.vendorId == YUBICO_VENDOR_ID) { - // the device might not have a USB permission - // it will be requested during during the UsbDiscovery - startUsbDiscovery() - break + } else { + // if any YubiKeys are connected, use them directly + val deviceIterator = usbManager.deviceList.values.iterator() + while (deviceIterator.hasNext()) { + val device = deviceIterator.next() + if (device.vendorId == YUBICO_VENDOR_ID) { + // the device might not have a USB permission + // it will be requested during during the UsbDiscovery + startUsbDiscovery() + break + } } } + } else { + logger.debug("Resume with preserved connection") } appPreferences.registerListener(sharedPreferencesListener) + + preserveConnectionOnPause = false } override fun onMultiWindowModeChanged(isInMultiWindowMode: Boolean, newConfig: Configuration) { @@ -374,6 +386,14 @@ class MainActivity : FlutterFragmentActivity() { "getAndroidSdkVersion" -> result.success( Build.VERSION.SDK_INT ) + + "preserveConnectionOnPause" -> { + preserveConnectionOnPause = true + result.success( + true + ) + } + "setPrimaryClip" -> { val toClipboard = methodCall.argument("toClipboard") val isSensitive = methodCall.argument("isSensitive") diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/oath/OathManager.kt b/android/app/src/main/kotlin/com/yubico/authenticator/oath/OathManager.kt index 8612e717f..4e2275c69 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/oath/OathManager.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/oath/OathManager.kt @@ -176,7 +176,7 @@ class OathManager( delay(delayMs) } val currentState = lifecycleOwner.lifecycle.currentState - if (currentState.isAtLeast(Lifecycle.State.RESUMED)) { + if (currentState.isAtLeast(Lifecycle.State.STARTED)) { requestRefresh() } else { logger.debug( diff --git a/android/flutter_plugins/qrscanner_zxing/android/src/main/kotlin/com/yubico/authenticator/flutter_plugins/qrscanner_zxing/QRScannerView.kt b/android/flutter_plugins/qrscanner_zxing/android/src/main/kotlin/com/yubico/authenticator/flutter_plugins/qrscanner_zxing/QRScannerView.kt index fb7cbfed1..d15932494 100644 --- a/android/flutter_plugins/qrscanner_zxing/android/src/main/kotlin/com/yubico/authenticator/flutter_plugins/qrscanner_zxing/QRScannerView.kt +++ b/android/flutter_plugins/qrscanner_zxing/android/src/main/kotlin/com/yubico/authenticator/flutter_plugins/qrscanner_zxing/QRScannerView.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2022 Yubico. + * Copyright (C) 2022-2023 Yubico. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -22,8 +22,6 @@ import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.net.Uri -import android.os.Handler -import android.os.Looper import android.provider.Settings import android.util.Log import android.util.Size @@ -46,6 +44,9 @@ import io.flutter.plugin.common.PluginRegistry import io.flutter.plugin.common.StandardMessageCodec import io.flutter.plugin.platform.PlatformView import io.flutter.plugin.platform.PlatformViewFactory +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import org.json.JSONObject import java.nio.ByteBuffer import java.util.concurrent.ExecutorService @@ -82,7 +83,7 @@ internal class QRScannerView( ) : PlatformView { private val stateChangeObserver = StateChangeObserver(context) - private val uiThreadHandler = Handler(Looper.getMainLooper()) + private val coroutineScope = CoroutineScope(Dispatchers.Main) companion object { const val TAG = "QRScannerView" @@ -106,11 +107,17 @@ internal class QRScannerView( } private fun requestPermissions(activity: Activity) { - ActivityCompat.requestPermissions( - activity, - PERMISSIONS_TO_REQUEST, - PERMISSION_REQUEST_CODE - ) + coroutineScope.launch { + methodChannel.invokeMethod( + "beforePermissionsRequest", null + ) + + ActivityCompat.requestPermissions( + activity, + PERMISSIONS_TO_REQUEST, + PERMISSION_REQUEST_CODE + ) + } } private val qrScannerView = View.inflate(context, R.layout.qr_scanner_view, null) @@ -149,7 +156,6 @@ internal class QRScannerView( return qrScannerView } - override fun dispose() { cameraProvider?.unbindAll() preview = null @@ -231,7 +237,7 @@ internal class QRScannerView( } private fun reportViewInitialized(permissionsGranted: Boolean) { - uiThreadHandler.post { + coroutineScope.launch { methodChannel.invokeMethod( "viewInitialized", JSONObject(mapOf("permissionsGranted" to permissionsGranted)).toString() @@ -240,7 +246,7 @@ internal class QRScannerView( } private fun reportCodeFound(code: String) { - uiThreadHandler.post { + coroutineScope.launch { methodChannel.invokeMethod( "codeFound", JSONObject( mapOf("value" to code) diff --git a/android/flutter_plugins/qrscanner_zxing/lib/qrscanner_zxing_view.dart b/android/flutter_plugins/qrscanner_zxing/lib/qrscanner_zxing_view.dart index 7a536f1e5..de7d2bf50 100644 --- a/android/flutter_plugins/qrscanner_zxing/lib/qrscanner_zxing_view.dart +++ b/android/flutter_plugins/qrscanner_zxing/lib/qrscanner_zxing_view.dart @@ -24,13 +24,21 @@ import 'package:flutter/services.dart'; class QRScannerZxingView extends StatefulWidget { final int marginPct; + /// Called when a code has been detected. final Function(String rawData) onDetect; + /// Called before the system UI with runtime permissions request is + /// displayed. + final Function()? beforePermissionsRequest; + /// Called after the view is completely initialized. + /// + /// permissionsGranted is true if the user granted camera permissions. final Function(bool permissionsGranted) onViewInitialized; const QRScannerZxingView( {Key? key, required this.marginPct, required this.onDetect, + this.beforePermissionsRequest, required this.onViewInitialized}) : super(key: key); @@ -51,6 +59,9 @@ class QRScannerZxingViewState extends State { var rawValue = arguments["value"]; widget.onDetect(rawValue); return; + case "beforePermissionsRequest": + widget.beforePermissionsRequest?.call(); + return; case "viewInitialized": var arguments = jsonDecode(call.arguments); var permissionsGranted = arguments["permissionsGranted"]; diff --git a/lib/android/app_methods.dart b/lib/android/app_methods.dart index 9b47c8d94..eb2b39cde 100644 --- a/lib/android/app_methods.dart +++ b/lib/android/app_methods.dart @@ -34,6 +34,16 @@ Future isNfcEnabled() async { return await appMethodsChannel.invokeMethod('isNfcEnabled'); } +/// The next onPause/onResume lifecycle event will not stop and start +/// USB/NFC discovery which will preserve the current YubiKey connection. +/// +/// This function should be called before showing system dialogs, such as +/// native file picker or permission request dialogs. +/// The state automatically resets during onResume call. +Future preserveConnectedDeviceWhenPaused() async { + await appMethodsChannel.invokeMethod('preserveConnectionOnPause'); +} + Future openNfcSettings() async { await appMethodsChannel.invokeMethod('openNfcSettings'); } diff --git a/lib/android/qr_scanner/qr_scanner_provider.dart b/lib/android/qr_scanner/qr_scanner_provider.dart index 274ba92ac..e4232097b 100644 --- a/lib/android/qr_scanner/qr_scanner_provider.dart +++ b/lib/android/qr_scanner/qr_scanner_provider.dart @@ -19,8 +19,8 @@ import 'dart:convert'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:qrscanner_zxing/qrscanner_zxing_method_channel.dart'; +import 'package:yubico_authenticator/android/app_methods.dart'; import 'package:yubico_authenticator/app/state.dart'; import 'package:yubico_authenticator/exception/cancellation_exception.dart'; import 'package:yubico_authenticator/theme.dart'; @@ -65,8 +65,11 @@ class AndroidQrScanner implements QrScanner { } static Future handleScannedData( - String? qrData, WidgetRef ref, AppLocalizations l10n) async { - final withContext = ref.read(withContextProvider); + String? qrData, + WithContext withContext, + QrScanner qrScanner, + AppLocalizations l10n, + ) async { switch (qrData) { case null: break; @@ -83,6 +86,7 @@ class AndroidQrScanner implements QrScanner { }, )); case kQrScannerRequestReadFromFile: + await preserveConnectedDeviceWhenPaused(); final result = await FilePicker.platform.pickFiles( allowedExtensions: ['png', 'jpg', 'gif', 'webp'], type: FileType.custom, @@ -97,13 +101,12 @@ class AndroidQrScanner implements QrScanner { } final bytes = result.files.first.bytes; - final scanner = ref.read(qrScannerProvider); - if (bytes != null && scanner != null) { + if (bytes != null) { final b64bytes = base64Encode(bytes); - final qrData = await scanner.scanQr(b64bytes); - if (qrData != null) { + final imageQrData = await qrScanner.scanQr(b64bytes); + if (imageQrData != null) { await withContext((context) => - handleUri(context, null, qrData, null, null, l10n)); + handleUri(context, null, imageQrData, null, null, l10n)); return; } } diff --git a/lib/android/qr_scanner/qr_scanner_view.dart b/lib/android/qr_scanner/qr_scanner_view.dart index 9ca1e0c89..f1a00985c 100755 --- a/lib/android/qr_scanner/qr_scanner_view.dart +++ b/lib/android/qr_scanner/qr_scanner_view.dart @@ -17,6 +17,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:qrscanner_zxing/qrscanner_zxing_view.dart'; +import 'package:yubico_authenticator/android/app_methods.dart'; import '../../oath/models.dart'; import 'qr_scanner_overlay_view.dart'; @@ -138,17 +139,21 @@ class _QrScannerViewState extends State { maintainSize: true, visible: _permissionsGranted, child: QRScannerZxingView( - key: _zxingViewKey, - marginPct: 10, - onDetect: (scannedData) => handleResult(scannedData), - onViewInitialized: (bool permissionsGranted) { - Future.delayed(const Duration(milliseconds: 50), () { - setState(() { - _previewInitialized = true; - _permissionsGranted = permissionsGranted; - }); + key: _zxingViewKey, + marginPct: 10, + onDetect: (scannedData) => handleResult(scannedData), + onViewInitialized: (bool permissionsGranted) { + Future.delayed(const Duration(milliseconds: 50), () { + setState(() { + _previewInitialized = true; + _permissionsGranted = permissionsGranted; }); - })), + }); + }, + beforePermissionsRequest: () async { + await preserveConnectedDeviceWhenPaused(); + }, + )), Visibility( visible: _permissionsGranted, child: QRScannerOverlay( diff --git a/lib/app/views/main_page.dart b/lib/app/views/main_page.dart index 0ce8092aa..d9c315ebd 100755 --- a/lib/app/views/main_page.dart +++ b/lib/app/views/main_page.dart @@ -98,11 +98,13 @@ class MainPage extends ConsumerWidget { icon: const Icon(Icons.person_add_alt_1), tooltip: l10n.s_add_account, onPressed: () async { - final scanner = ref.read(qrScannerProvider); - if (scanner != null) { + final withContext = ref.read(withContextProvider); + final qrScanner = ref.read(qrScannerProvider); + if (qrScanner != null) { try { - final qrData = await scanner.scanQr(); - await AndroidQrScanner.handleScannedData(qrData, ref, l10n); + final qrData = await qrScanner.scanQr(); + await AndroidQrScanner.handleScannedData( + qrData, withContext, qrScanner, l10n); } on CancellationException catch (_) { // ignored - user cancelled return; diff --git a/lib/oath/views/key_actions.dart b/lib/oath/views/key_actions.dart index 16c7eccd7..3b69fb92a 100755 --- a/lib/oath/views/key_actions.dart +++ b/lib/oath/views/key_actions.dart @@ -57,13 +57,15 @@ Widget oathBuildActions( icon: const Icon(Icons.person_add_alt_1_outlined), onTap: used != null && (capacity == null || capacity > used) ? (context) async { + Navigator.of(context).pop(); if (isAndroid) { + final withContext = ref.read(withContextProvider); final qrScanner = ref.read(qrScannerProvider); if (qrScanner != null) { final qrData = await qrScanner.scanQr(); await AndroidQrScanner.handleScannedData( - qrData, ref, l10n); + qrData, withContext, qrScanner, l10n); } } else { await showBlurDialog(