From 3b766fb28f2f0bd7f873d4173168f86218c2e499 Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Tue, 10 Oct 2023 13:49:01 +0200 Subject: [PATCH 01/15] support scanning from image data --- .../qrscanner_zxing/QRScannerView.kt | 15 ++-- .../qrscanner_zxing/QRScannerZxingPlugin.kt | 57 ++++++++++++- .../qrscanner_zxing/QrScanner.kt | 18 +++++ .../example/android/app/build.gradle | 10 ++- .../example/android/build.gradle | 6 +- .../gradle/wrapper/gradle-wrapper.properties | 2 +- .../qrscanner_zxing/example/lib/main.dart | 28 +++++++ .../qrscanner_zxing/example/pubspec.lock | 79 ++++++++++++++----- .../qrscanner_zxing/example/pubspec.yaml | 3 +- .../qrscanner_zxing/lib/qrscanner_zxing.dart | 6 ++ .../lib/qrscanner_zxing_method_channel.dart | 7 ++ .../qrscanner_zxing_platform_interface.dart | 6 ++ .../test/qrscanner_zxing_test.dart | 14 ++++ lib/android/keys.dart | 1 + .../qr_scanner/qr_scanner_provider.dart | 45 +++++++---- .../qr_scanner/qr_scanner_ui_view.dart | 63 ++++++++++++--- lib/l10n/app_en.arb | 1 + 17 files changed, 292 insertions(+), 69 deletions(-) create mode 100644 android/flutter_plugins/qrscanner_zxing/android/src/main/kotlin/com/yubico/authenticator/flutter_plugins/qrscanner_zxing/QrScanner.kt 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 184a6b5a..92cf928b 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 @@ -36,7 +36,9 @@ import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat.startActivity import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.Observer -import com.google.zxing.* +import com.google.zxing.BinaryBitmap +import com.google.zxing.NotFoundException +import com.google.zxing.RGBLuminanceSource import com.google.zxing.common.HybridBinarizer import io.flutter.plugin.common.BinaryMessenger import io.flutter.plugin.common.MethodChannel @@ -290,11 +292,6 @@ internal class QRScannerView( ) : ImageAnalysis.Analyzer { var analysisPaused = false - - val multiFormatReader = MultiFormatReader().also { - it.setHints(mapOf(DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE))) - } - var analyzedImagesCount = 0 private fun ByteBuffer.toByteArray(lastRowPadding: Int): ByteArray { @@ -392,14 +389,14 @@ internal class QRScannerView( fullSize } - val result: com.google.zxing.Result = multiFormatReader.decode(bitmapToProcess) + val result = QrCodeScanner.scan(bitmapToProcess) if (analysisPaused) { return } analysisPaused = true // pause - Log.v(TAG, "Analysis result: ${result.text}") - listener.invoke(Result.success(result.text)) + Log.v(TAG, "Analysis result: $result") + listener.invoke(Result.success(result)) } catch (_: NotFoundException) { if (analyzedImagesCount == 0) { Log.v(TAG, " No QR code found (NotFoundException)") diff --git a/android/flutter_plugins/qrscanner_zxing/android/src/main/kotlin/com/yubico/authenticator/flutter_plugins/qrscanner_zxing/QRScannerZxingPlugin.kt b/android/flutter_plugins/qrscanner_zxing/android/src/main/kotlin/com/yubico/authenticator/flutter_plugins/qrscanner_zxing/QRScannerZxingPlugin.kt index 64707a74..803402b8 100644 --- a/android/flutter_plugins/qrscanner_zxing/android/src/main/kotlin/com/yubico/authenticator/flutter_plugins/qrscanner_zxing/QRScannerZxingPlugin.kt +++ b/android/flutter_plugins/qrscanner_zxing/android/src/main/kotlin/com/yubico/authenticator/flutter_plugins/qrscanner_zxing/QRScannerZxingPlugin.kt @@ -16,6 +16,12 @@ package com.yubico.authenticator.flutter_plugins.qrscanner_zxing +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.util.Log +import com.google.zxing.BinaryBitmap +import com.google.zxing.RGBLuminanceSource +import com.google.zxing.common.HybridBinarizer import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.embedding.engine.plugins.activity.ActivityAware import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding @@ -24,6 +30,7 @@ import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel.MethodCallHandler import io.flutter.plugin.common.MethodChannel.Result import io.flutter.plugin.common.PluginRegistry +import java.lang.Exception class PermissionsResultRegistrar { @@ -65,10 +72,48 @@ class QRScannerZxingPlugin : FlutterPlugin, MethodCallHandler, ActivityAware, } override fun onMethodCall(call: MethodCall, result: Result) { - if (call.method == "getPlatformVersion") { - result.success("Android ${android.os.Build.VERSION.RELEASE}") - } else { - result.notImplemented() + when (call.method) { + "getPlatformVersion" -> { + result.success("Android ${android.os.Build.VERSION.RELEASE}") + } + "scanBitmap" -> { + val imageFileData = call.argument("bitmap") + var bitmap: Bitmap? = null + try { + imageFileData?.let { byteArray -> + Log.i(TAG, "Received ${byteArray.size} bytes") + val options = BitmapFactory.Options() + options.inSampleSize = 4 + bitmap = BitmapFactory.decodeByteArray(imageFileData, 0, byteArray.size, options) + bitmap?.let { + val intArray = IntArray(it.allocationByteCount) + it.getPixels(intArray, 0, it.width, 0, 0, it.width, it.height) + val luminanceSource = + RGBLuminanceSource(it.rowBytes, it.height, intArray) + val binaryBitmap = BinaryBitmap(HybridBinarizer(luminanceSource)) + val scanResult = QrCodeScanner.scan(binaryBitmap) + Log.i(TAG, "Scan result: $scanResult") + result.success(scanResult) + return + } + } + } catch (e: Exception) { + Log.e(TAG, "Failure decoding data: $e") + result.error("Failed to decode", e.message, e) + return + } finally { + bitmap?.let { + it.recycle() + bitmap = null + } + } + + Log.e(TAG, "Failure decoding data: Invalid image format ") + result.error("Failed to decode", "Invalid image format", null) + } + else -> { + result.notImplemented() + } } } @@ -97,4 +142,8 @@ class QRScannerZxingPlugin : FlutterPlugin, MethodCallHandler, ActivityAware, ): Boolean { return registrar.onResult(requestCode, permissions, grantResults) } + + companion object { + const val TAG = "QRScannerZxPlugin" + } } diff --git a/android/flutter_plugins/qrscanner_zxing/android/src/main/kotlin/com/yubico/authenticator/flutter_plugins/qrscanner_zxing/QrScanner.kt b/android/flutter_plugins/qrscanner_zxing/android/src/main/kotlin/com/yubico/authenticator/flutter_plugins/qrscanner_zxing/QrScanner.kt new file mode 100644 index 00000000..c34ad3ad --- /dev/null +++ b/android/flutter_plugins/qrscanner_zxing/android/src/main/kotlin/com/yubico/authenticator/flutter_plugins/qrscanner_zxing/QrScanner.kt @@ -0,0 +1,18 @@ +package com.yubico.authenticator.flutter_plugins.qrscanner_zxing + +import com.google.zxing.BarcodeFormat +import com.google.zxing.BinaryBitmap +import com.google.zxing.DecodeHintType +import com.google.zxing.MultiFormatReader + +object QrCodeScanner { + + private val qrCodeScanner = MultiFormatReader().also { + it.setHints(mapOf(DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE))) + } + + fun scan(binaryBitmap: BinaryBitmap) : String { + val result: com.google.zxing.Result = qrCodeScanner.decode(binaryBitmap) + return result.text + } +} \ No newline at end of file diff --git a/android/flutter_plugins/qrscanner_zxing/example/android/app/build.gradle b/android/flutter_plugins/qrscanner_zxing/example/android/app/build.gradle index 69894779..ba756aea 100644 --- a/android/flutter_plugins/qrscanner_zxing/example/android/app/build.gradle +++ b/android/flutter_plugins/qrscanner_zxing/example/android/app/build.gradle @@ -26,9 +26,11 @@ apply plugin: 'kotlin-android' apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" android { - compileSdkVersion 33 + compileSdk 34 ndkVersion flutter.ndkVersion + namespace 'com.yubico.authenticator.flutter_plugins.qrscanner_zxing_example' + compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 @@ -48,7 +50,7 @@ android { // You can update the following values to match your application needs. // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-build-configuration. minSdkVersion 21 - targetSdkVersion 33 + targetSdkVersion 34 versionCode flutterVersionCode.toInteger() versionName flutterVersionName } @@ -60,6 +62,10 @@ android { signingConfig signingConfigs.debug } } + + buildFeatures { + buildConfig true + } } flutter { diff --git a/android/flutter_plugins/qrscanner_zxing/example/android/build.gradle b/android/flutter_plugins/qrscanner_zxing/example/android/build.gradle index 717cd8ce..ad5d690e 100644 --- a/android/flutter_plugins/qrscanner_zxing/example/android/build.gradle +++ b/android/flutter_plugins/qrscanner_zxing/example/android/build.gradle @@ -1,12 +1,12 @@ buildscript { - ext.kotlin_version = '1.7.21' + ext.kotlin_version = '1.9.10' repositories { google() mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:7.1.3' + classpath 'com.android.tools.build:gradle:8.1.2' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } } @@ -26,6 +26,6 @@ subprojects { project.evaluationDependsOn(':app') } -task clean(type: Delete) { +tasks.register('clean', Delete) { delete rootProject.buildDir } diff --git a/android/flutter_plugins/qrscanner_zxing/example/android/gradle/wrapper/gradle-wrapper.properties b/android/flutter_plugins/qrscanner_zxing/example/android/gradle/wrapper/gradle-wrapper.properties index cc5527d7..d07c7fcf 100644 --- a/android/flutter_plugins/qrscanner_zxing/example/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/flutter_plugins/qrscanner_zxing/example/android/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-all.zip diff --git a/android/flutter_plugins/qrscanner_zxing/example/lib/main.dart b/android/flutter_plugins/qrscanner_zxing/example/lib/main.dart index ba12d6e0..12c2c55c 100644 --- a/android/flutter_plugins/qrscanner_zxing/example/lib/main.dart +++ b/android/flutter_plugins/qrscanner_zxing/example/lib/main.dart @@ -14,7 +14,9 @@ * limitations under the License. */ +import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; +import 'package:qrscanner_zxing/qrscanner_zxing_method_channel.dart'; import 'package:qrscanner_zxing/qrscanner_zxing_view.dart'; import 'cutout_overlay.dart'; @@ -64,6 +66,32 @@ class AppHomePage extends StatelessWidget { )); }, child: const Text("Open QR Scanner")), + ElevatedButton( + onPressed: () async { + final result = await FilePicker.platform.pickFiles( + allowedExtensions: ['png', 'jpg'], + type: FileType.custom, + allowMultiple: false, + lockParentWindow: true, + withData: true, + dialogTitle: 'Select file with QR code'); + + if (result == null || !result.isSinglePick) { + // no result + return; + } + + final bytes = result.files.first.bytes; + + if (bytes != null) { + var channel = MethodChannelQRScannerZxing(); + var value = await channel.scanBitmap(bytes); + debugPrint(value); + } else { + // no files selected + } + }, + child: const Text("Scan from file")), ], ), ), diff --git a/android/flutter_plugins/qrscanner_zxing/example/pubspec.lock b/android/flutter_plugins/qrscanner_zxing/example/pubspec.lock index 9c76c768..ffd0042d 100644 --- a/android/flutter_plugins/qrscanner_zxing/example/pubspec.lock +++ b/android/flutter_plugins/qrscanner_zxing/example/pubspec.lock @@ -37,10 +37,10 @@ packages: dependency: transitive description: name: collection - sha256: "4a07be6cb69c84d677a6c3096fcf960cc3285a8330b4603e0d463d15d9bd934c" + sha256: f092b211a4319e98e5ff58223576de6c2803db36221657b46c82574721240687 url: "https://pub.dev" source: hosted - version: "1.17.1" + version: "1.17.2" cupertino_icons: dependency: "direct main" description: @@ -57,6 +57,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.1" + ffi: + dependency: transitive + description: + name: ffi + sha256: "7bf0adc28a23d395f19f3f1eb21dd7cfd1dd9f8e1c50051c069122e6853bc878" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: be325344c1f3070354a1d84a231a1ba75ea85d413774ec4bdf444c023342e030 + url: "https://pub.dev" + source: hosted + version: "5.5.0" flutter: dependency: "direct main" description: flutter @@ -70,19 +86,24 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.1" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: f185ac890306b5779ecbd611f52502d8d4d63d27703ef73161ca0407e815f02c + url: "https://pub.dev" + source: hosted + version: "2.0.16" flutter_test: dependency: "direct dev" description: flutter source: sdk version: "0.0.0" - js: + flutter_web_plugins: dependency: transitive - description: - name: js - sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 - url: "https://pub.dev" - source: hosted - version: "0.6.7" + description: flutter + source: sdk + version: "0.0.0" lints: dependency: transitive description: @@ -95,18 +116,18 @@ packages: dependency: transitive description: name: matcher - sha256: "6501fbd55da300384b768785b83e5ce66991266cec21af89ab9ae7f5ce1c4cbb" + sha256: "1803e76e6653768d64ed8ff2e1e67bea3ad4b923eb5c56a295c3e634bad5960e" url: "https://pub.dev" source: hosted - version: "0.12.15" + version: "0.12.16" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: d92141dc6fe1dad30722f9aa826c7fbc896d021d792f80678280601aff8cf724 + sha256: "9528f2f296073ff54cb9fee677df673ace1218163c3bc7628093e7eed5203d41" url: "https://pub.dev" source: hosted - version: "0.2.0" + version: "0.5.0" meta: dependency: transitive description: @@ -127,10 +148,10 @@ packages: dependency: transitive description: name: plugin_platform_interface - sha256: dbf0f707c78beedc9200146ad3cb0ab4d5da13c246336987be6940f026500d3a + sha256: da3fdfeccc4d4ff2da8f8c556704c08f912542c5fb3cf2233ed75372384a034d url: "https://pub.dev" source: hosted - version: "2.1.3" + version: "2.1.6" qrscanner_zxing: dependency: "direct main" description: @@ -147,10 +168,10 @@ packages: dependency: transitive description: name: source_span - sha256: dd904f795d4b4f3b870833847c461801f6750a9fa8e61ea5ac53f9422b31f250 + sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" url: "https://pub.dev" source: hosted - version: "1.9.1" + version: "1.10.0" stack_trace: dependency: transitive description: @@ -187,10 +208,10 @@ packages: dependency: transitive description: name: test_api - sha256: eb6ac1540b26de412b3403a163d919ba86f6a973fe6cc50ae3541b80092fdcfb + sha256: "75760ffd7786fffdfb9597c35c5b27eaeec82be8edfb6d71d32651128ed7aab8" url: "https://pub.dev" source: hosted - version: "0.5.1" + version: "0.6.0" vector_math: dependency: transitive description: @@ -199,6 +220,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" + web: + dependency: transitive + description: + name: web + sha256: dc8ccd225a2005c1be616fe02951e2e342092edf968cf0844220383757ef8f10 + url: "https://pub.dev" + source: hosted + version: "0.1.4-beta" + win32: + dependency: transitive + description: + name: win32 + sha256: "350a11abd2d1d97e0cc7a28a81b781c08002aa2864d9e3f192ca0ffa18b06ed3" + url: "https://pub.dev" + source: hosted + version: "5.0.9" sdks: - dart: ">=3.0.0-0 <4.0.0" - flutter: ">=2.5.0" + dart: ">=3.1.0-185.0.dev <4.0.0" + flutter: ">=3.7.0" diff --git a/android/flutter_plugins/qrscanner_zxing/example/pubspec.yaml b/android/flutter_plugins/qrscanner_zxing/example/pubspec.yaml index ab2e89d5..067b0b84 100644 --- a/android/flutter_plugins/qrscanner_zxing/example/pubspec.yaml +++ b/android/flutter_plugins/qrscanner_zxing/example/pubspec.yaml @@ -4,7 +4,7 @@ description: Demonstrates how to use the qrscanner_zxing plugin. publish_to: 'none' # Remove this line if you wish to publish to pub.dev environment: - sdk: ">=2.17.0-266.1.beta <3.0.0" + sdk: '>=3.0.0 <4.0.0' dependencies: flutter: @@ -14,6 +14,7 @@ dependencies: path: ../ cupertino_icons: ^1.0.2 + file_picker: ^5.3.2 dev_dependencies: flutter_test: diff --git a/android/flutter_plugins/qrscanner_zxing/lib/qrscanner_zxing.dart b/android/flutter_plugins/qrscanner_zxing/lib/qrscanner_zxing.dart index 568bc096..2a339999 100644 --- a/android/flutter_plugins/qrscanner_zxing/lib/qrscanner_zxing.dart +++ b/android/flutter_plugins/qrscanner_zxing/lib/qrscanner_zxing.dart @@ -14,10 +14,16 @@ * limitations under the License. */ +import 'dart:typed_data'; + import 'qrscanner_zxing_platform_interface.dart'; class QRScannerZxing { Future getPlatformVersion() { return QRScannerZxingPlatform.instance.getPlatformVersion(); } + + Future scanBitmap(Uint8List bitmap) { + return QRScannerZxingPlatform.instance.scanBitmap(bitmap); + } } diff --git a/android/flutter_plugins/qrscanner_zxing/lib/qrscanner_zxing_method_channel.dart b/android/flutter_plugins/qrscanner_zxing/lib/qrscanner_zxing_method_channel.dart index 1e46314c..8642fbbb 100644 --- a/android/flutter_plugins/qrscanner_zxing/lib/qrscanner_zxing_method_channel.dart +++ b/android/flutter_plugins/qrscanner_zxing/lib/qrscanner_zxing_method_channel.dart @@ -31,4 +31,11 @@ class MethodChannelQRScannerZxing extends QRScannerZxingPlatform { await methodChannel.invokeMethod('getPlatformVersion'); return version; } + + @override + Future scanBitmap(Uint8List bitmap) async { + final version = await methodChannel + .invokeMethod('scanBitmap', {'bitmap': bitmap}); + return version; + } } diff --git a/android/flutter_plugins/qrscanner_zxing/lib/qrscanner_zxing_platform_interface.dart b/android/flutter_plugins/qrscanner_zxing/lib/qrscanner_zxing_platform_interface.dart index 54aa6cc0..54740987 100644 --- a/android/flutter_plugins/qrscanner_zxing/lib/qrscanner_zxing_platform_interface.dart +++ b/android/flutter_plugins/qrscanner_zxing/lib/qrscanner_zxing_platform_interface.dart @@ -14,6 +14,8 @@ * limitations under the License. */ +import 'dart:typed_data'; + import 'package:plugin_platform_interface/plugin_platform_interface.dart'; import 'qrscanner_zxing_method_channel.dart'; @@ -42,4 +44,8 @@ abstract class QRScannerZxingPlatform extends PlatformInterface { Future getPlatformVersion() { throw UnimplementedError('platformVersion() has not been implemented.'); } + + Future scanBitmap(Uint8List bitmap) { + throw UnimplementedError('platformVersion() has not been implemented.'); + } } diff --git a/android/flutter_plugins/qrscanner_zxing/test/qrscanner_zxing_test.dart b/android/flutter_plugins/qrscanner_zxing/test/qrscanner_zxing_test.dart index 7020c926..85ccd7d1 100644 --- a/android/flutter_plugins/qrscanner_zxing/test/qrscanner_zxing_test.dart +++ b/android/flutter_plugins/qrscanner_zxing/test/qrscanner_zxing_test.dart @@ -14,6 +14,8 @@ * limitations under the License. */ +import 'dart:typed_data'; + import 'package:flutter_test/flutter_test.dart'; import 'package:plugin_platform_interface/plugin_platform_interface.dart'; import 'package:qrscanner_zxing/qrscanner_zxing.dart'; @@ -25,6 +27,10 @@ class MockQRScannerZxingPlatform implements QRScannerZxingPlatform { @override Future getPlatformVersion() => Future.value('42'); + + @override + Future scanBitmap(Uint8List bitmap) => + Future.value(bitmap.length.toString()); } void main() { @@ -42,4 +48,12 @@ void main() { expect(await qrscannerZxingPlugin.getPlatformVersion(), '42'); }); + + test('scanBitmap', () async { + QRScannerZxing qrscannerZxingPlugin = QRScannerZxing(); + MockQRScannerZxingPlatform fakePlatform = MockQRScannerZxingPlatform(); + QRScannerZxingPlatform.instance = fakePlatform; + + expect(await qrscannerZxingPlugin.scanBitmap(Uint8List(10)), '10'); + }); } diff --git a/lib/android/keys.dart b/lib/android/keys.dart index 75167483..2dcee72a 100755 --- a/lib/android/keys.dart +++ b/lib/android/keys.dart @@ -22,6 +22,7 @@ const _prefix = 'android.keys'; const okButton = Key('$_prefix.ok'); const manualEntryButton = Key('$_prefix.manual_entry'); +const readFromImage = Key('$_prefix.read_image_file'); const nfcBypassTouchSetting = Key('$_prefix.nfc_bypass_touch'); const nfcSilenceSoundsSettings = Key('$_prefix.nfc_silence_sounds'); diff --git a/lib/android/qr_scanner/qr_scanner_provider.dart b/lib/android/qr_scanner/qr_scanner_provider.dart index 764099bd..d5865665 100644 --- a/lib/android/qr_scanner/qr_scanner_provider.dart +++ b/lib/android/qr_scanner/qr_scanner_provider.dart @@ -14,39 +14,50 @@ * limitations under the License. */ +import 'dart:convert'; + import 'package:flutter/material.dart'; import 'package:yubico_authenticator/app/state.dart'; import 'package:yubico_authenticator/exception/cancellation_exception.dart'; import 'package:yubico_authenticator/theme.dart'; +import 'package:qrscanner_zxing/qrscanner_zxing_method_channel.dart'; + import 'qr_scanner_view.dart'; class AndroidQrScanner implements QrScanner { final WithContext _withContext; + AndroidQrScanner(this._withContext); @override - Future scanQr([String? _]) async { - var scannedCode = await _withContext( - (context) async => await Navigator.of(context).push(PageRouteBuilder( - pageBuilder: (_, __, ___) => - Theme(data: AppTheme.darkTheme, child: const QrScannerView()), - settings: const RouteSettings(name: 'android_qr_scanner_view'), - transitionDuration: const Duration(seconds: 0), - reverseTransitionDuration: const Duration(seconds: 0), - ))); - if (scannedCode == null) { - // user has cancelled the scan - throw CancellationException(); + Future scanQr([String? imageData]) async { + if (imageData == null) { + var scannedCode = await _withContext( + (context) async => + await Navigator.of(context).push(PageRouteBuilder( + pageBuilder: (_, __, ___) => + Theme(data: AppTheme.darkTheme, child: const QrScannerView()), + settings: const RouteSettings(name: 'android_qr_scanner_view'), + transitionDuration: const Duration(seconds: 0), + reverseTransitionDuration: const Duration(seconds: 0), + ))); + if (scannedCode == null) { + // user has cancelled the scan + throw CancellationException(); + } + if (scannedCode == '') { + return null; + } + return scannedCode; + } else { + var zxingChannel = MethodChannelQRScannerZxing(); + return await zxingChannel.scanBitmap(base64Decode(imageData)); } - if (scannedCode == '') { - return null; - } - return scannedCode; } } QrScanner? Function(dynamic) androidQrScannerProvider(hasCamera) { return (ref) => - hasCamera ? AndroidQrScanner(ref.watch(withContextProvider)) : null; + hasCamera ? AndroidQrScanner(ref.watch(withContextProvider)) : null; } diff --git a/lib/android/qr_scanner/qr_scanner_ui_view.dart b/lib/android/qr_scanner/qr_scanner_ui_view.dart index 45c396e8..421b113d 100644 --- a/lib/android/qr_scanner/qr_scanner_ui_view.dart +++ b/lib/android/qr_scanner/qr_scanner_ui_view.dart @@ -14,13 +14,18 @@ * limitations under the License. */ +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:yubico_authenticator/app/state.dart'; import '../keys.dart' as keys; import 'qr_scanner_scan_status.dart'; -class QRScannerUI extends StatelessWidget { +class QRScannerUI extends ConsumerWidget { final ScanStatus status; final Size screenSize; final GlobalKey overlayWidgetKey; @@ -32,7 +37,7 @@ class QRScannerUI extends StatelessWidget { required this.overlayWidgetKey}); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final l10n = AppLocalizations.of(context)!; return Stack( @@ -73,15 +78,51 @@ class QRScannerUI extends StatelessWidget { textScaleFactor: 0.7, style: const TextStyle(color: Colors.white), ), - OutlinedButton( - onPressed: () { - Navigator.of(context).pop(''); - }, - key: keys.manualEntryButton, - child: Text( - l10n.s_enter_manually, - style: const TextStyle(color: Colors.white), - )), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + OutlinedButton( + onPressed: () { + Navigator.of(context).pop(''); + }, + key: keys.manualEntryButton, + child: Text( + l10n.s_enter_manually, + style: const TextStyle(color: Colors.white), + )), + OutlinedButton( + onPressed: () async { + Navigator.of(context).pop(''); + final result = await FilePicker.platform.pickFiles( + allowedExtensions: ['png', 'jpg'], + type: FileType.custom, + allowMultiple: false, + lockParentWindow: true, + dialogTitle: 'Select file with QR code'); + if (result != null && result.files.isNotEmpty) { + final fileWithCode = result.files.first; + final bytes = fileWithCode.bytes; + if (bytes == null || bytes.isEmpty) { + //err return + return; + } + if (bytes.length > 3 * 1024 * 1024) { + // too big file + return; + } + final scanner = ref.read(qrScannerProvider); + if (scanner != null) { + await scanner.scanQr(base64UrlEncode(bytes)); + } + } + }, + key: keys.readFromImage, + child: Text( + l10n.s_read_from_image, + style: const TextStyle(color: Colors.white), + )), + ], + ), ], ), const SizedBox(height: 8) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index d041dcff..0d78d8ae 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -516,6 +516,7 @@ "q_want_to_scan": "Would like to scan?", "q_no_qr": "No QR code?", "s_enter_manually": "Enter manually", + "s_read_from_image": "Provide image file", "@_factory_reset": {}, "s_reset": "Reset", From 8a9d465bb122937e1a1d4a9fdc8fef93a1445454 Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Mon, 16 Oct 2023 17:17:01 +0200 Subject: [PATCH 02/15] decodeFromBytes --- .../gradle/wrapper/gradle-wrapper.properties | 4 +- .../qrscanner_zxing/QRScannerView.kt | 2 +- .../qrscanner_zxing/QRScannerZxingPlugin.kt | 53 ++++--------------- .../qrscanner_zxing/QrScanner.kt | 44 ++++++++++++++- .../qrscanner_zxing/example/lib/main.dart | 12 +++-- .../lib/qrscanner_zxing_method_channel.dart | 6 +-- .../qrscanner_zxing_platform_interface.dart | 4 +- .../test/qrscanner_zxing_test.dart | 4 +- 8 files changed, 69 insertions(+), 60 deletions(-) diff --git a/android/flutter_plugins/qrscanner_zxing/android/gradle/wrapper/gradle-wrapper.properties b/android/flutter_plugins/qrscanner_zxing/android/gradle/wrapper/gradle-wrapper.properties index 6b665338..2e0997a2 100644 --- a/android/flutter_plugins/qrscanner_zxing/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/flutter_plugins/qrscanner_zxing/android/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ -#Fri Jun 23 08:50:38 CEST 2017 +#Mon Oct 16 08:48:17 CEST 2023 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-all.zip 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 92cf928b..fb7cbfed 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 @@ -389,7 +389,7 @@ internal class QRScannerView( fullSize } - val result = QrCodeScanner.scan(bitmapToProcess) + val result = QrCodeScanner.decodeFromBinaryBitmap(bitmapToProcess) if (analysisPaused) { return } diff --git a/android/flutter_plugins/qrscanner_zxing/android/src/main/kotlin/com/yubico/authenticator/flutter_plugins/qrscanner_zxing/QRScannerZxingPlugin.kt b/android/flutter_plugins/qrscanner_zxing/android/src/main/kotlin/com/yubico/authenticator/flutter_plugins/qrscanner_zxing/QRScannerZxingPlugin.kt index 803402b8..2ad04d49 100644 --- a/android/flutter_plugins/qrscanner_zxing/android/src/main/kotlin/com/yubico/authenticator/flutter_plugins/qrscanner_zxing/QRScannerZxingPlugin.kt +++ b/android/flutter_plugins/qrscanner_zxing/android/src/main/kotlin/com/yubico/authenticator/flutter_plugins/qrscanner_zxing/QRScannerZxingPlugin.kt @@ -16,12 +16,6 @@ package com.yubico.authenticator.flutter_plugins.qrscanner_zxing -import android.graphics.Bitmap -import android.graphics.BitmapFactory -import android.util.Log -import com.google.zxing.BinaryBitmap -import com.google.zxing.RGBLuminanceSource -import com.google.zxing.common.HybridBinarizer import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.embedding.engine.plugins.activity.ActivityAware import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding @@ -30,7 +24,6 @@ import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel.MethodCallHandler import io.flutter.plugin.common.MethodChannel.Result import io.flutter.plugin.common.PluginRegistry -import java.lang.Exception class PermissionsResultRegistrar { @@ -76,41 +69,17 @@ class QRScannerZxingPlugin : FlutterPlugin, MethodCallHandler, ActivityAware, "getPlatformVersion" -> { result.success("Android ${android.os.Build.VERSION.RELEASE}") } - "scanBitmap" -> { - val imageFileData = call.argument("bitmap") - var bitmap: Bitmap? = null - try { - imageFileData?.let { byteArray -> - Log.i(TAG, "Received ${byteArray.size} bytes") - val options = BitmapFactory.Options() - options.inSampleSize = 4 - bitmap = BitmapFactory.decodeByteArray(imageFileData, 0, byteArray.size, options) - bitmap?.let { - val intArray = IntArray(it.allocationByteCount) - it.getPixels(intArray, 0, it.width, 0, 0, it.width, it.height) - val luminanceSource = - RGBLuminanceSource(it.rowBytes, it.height, intArray) - val binaryBitmap = BinaryBitmap(HybridBinarizer(luminanceSource)) - val scanResult = QrCodeScanner.scan(binaryBitmap) - Log.i(TAG, "Scan result: $scanResult") - result.success(scanResult) - return - } - } - } catch (e: Exception) { - Log.e(TAG, "Failure decoding data: $e") - result.error("Failed to decode", e.message, e) - return - } finally { - bitmap?.let { - it.recycle() - bitmap = null - } - } - Log.e(TAG, "Failure decoding data: Invalid image format ") - result.error("Failed to decode", "Invalid image format", null) + "scanBitmap" -> { + val bytes = call.argument("bytes") + if (bytes != null) { + val scanResult = QrCodeScanner.decodeFromBytes(bytes) + result.success(scanResult) + } else { + result.error("Failure", "Invalid image", null) + } } + else -> { result.notImplemented() } @@ -142,8 +111,4 @@ class QRScannerZxingPlugin : FlutterPlugin, MethodCallHandler, ActivityAware, ): Boolean { return registrar.onResult(requestCode, permissions, grantResults) } - - companion object { - const val TAG = "QRScannerZxPlugin" - } } diff --git a/android/flutter_plugins/qrscanner_zxing/android/src/main/kotlin/com/yubico/authenticator/flutter_plugins/qrscanner_zxing/QrScanner.kt b/android/flutter_plugins/qrscanner_zxing/android/src/main/kotlin/com/yubico/authenticator/flutter_plugins/qrscanner_zxing/QrScanner.kt index c34ad3ad..7ea6902d 100644 --- a/android/flutter_plugins/qrscanner_zxing/android/src/main/kotlin/com/yubico/authenticator/flutter_plugins/qrscanner_zxing/QrScanner.kt +++ b/android/flutter_plugins/qrscanner_zxing/android/src/main/kotlin/com/yubico/authenticator/flutter_plugins/qrscanner_zxing/QrScanner.kt @@ -1,9 +1,15 @@ package com.yubico.authenticator.flutter_plugins.qrscanner_zxing +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.util.Log import com.google.zxing.BarcodeFormat import com.google.zxing.BinaryBitmap import com.google.zxing.DecodeHintType import com.google.zxing.MultiFormatReader +import com.google.zxing.NotFoundException +import com.google.zxing.RGBLuminanceSource +import com.google.zxing.common.HybridBinarizer object QrCodeScanner { @@ -11,8 +17,42 @@ object QrCodeScanner { it.setHints(mapOf(DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE))) } - fun scan(binaryBitmap: BinaryBitmap) : String { - val result: com.google.zxing.Result = qrCodeScanner.decode(binaryBitmap) + fun decodeFromBinaryBitmap(binaryBitmap: BinaryBitmap): String { + val result = qrCodeScanner.decode(binaryBitmap) return result.text } + + fun decodeFromBytes(byteArray: ByteArray): String? { + var bitmap: Bitmap? = null + try { + Log.v(TAG, "Received ${byteArray.size} bytes") + val options = BitmapFactory.Options() + bitmap = BitmapFactory.decodeByteArray(byteArray, 0, byteArray.size, options) + bitmap?.let { + val pixels = IntArray(it.allocationByteCount) + it.getPixels(pixels, 0, it.width, 0, 0, it.width, it.height) + val luminanceSource = + RGBLuminanceSource(it.width, it.height, pixels) + val binaryBitmap = BinaryBitmap(HybridBinarizer(luminanceSource)) + val scanResult = decodeFromBinaryBitmap(binaryBitmap) + Log.v(TAG, "Scan result: $scanResult") + return scanResult + } + Log.e(TAG, "Could not decode image data.") + return null + } catch (_: NotFoundException) { + Log.e(TAG, "No QR code found/decoded.") + return null + } catch (e: Throwable) { + Log.e(TAG, "Exception while decoding data: ", e) + return null + } finally { + bitmap?.let { + it.recycle() + bitmap = null + } + } + } + + private const val TAG = "QRScanner" } \ No newline at end of file diff --git a/android/flutter_plugins/qrscanner_zxing/example/lib/main.dart b/android/flutter_plugins/qrscanner_zxing/example/lib/main.dart index 12c2c55c..097404b0 100644 --- a/android/flutter_plugins/qrscanner_zxing/example/lib/main.dart +++ b/android/flutter_plugins/qrscanner_zxing/example/lib/main.dart @@ -68,8 +68,10 @@ class AppHomePage extends StatelessWidget { child: const Text("Open QR Scanner")), ElevatedButton( onPressed: () async { + var channel = MethodChannelQRScannerZxing(); + final scaffoldMessenger = ScaffoldMessenger.of(context); final result = await FilePicker.platform.pickFiles( - allowedExtensions: ['png', 'jpg'], + allowedExtensions: ['png', 'jpg', 'gif', 'webp'], type: FileType.custom, allowMultiple: false, lockParentWindow: true, @@ -82,11 +84,13 @@ class AppHomePage extends StatelessWidget { } final bytes = result.files.first.bytes; - if (bytes != null) { - var channel = MethodChannelQRScannerZxing(); var value = await channel.scanBitmap(bytes); - debugPrint(value); + final snackBar = SnackBar( + content: Text(value == null + ? 'No QR code detected' + : 'QR: $value')); + scaffoldMessenger.showSnackBar(snackBar); } else { // no files selected } diff --git a/android/flutter_plugins/qrscanner_zxing/lib/qrscanner_zxing_method_channel.dart b/android/flutter_plugins/qrscanner_zxing/lib/qrscanner_zxing_method_channel.dart index 8642fbbb..225d4db9 100644 --- a/android/flutter_plugins/qrscanner_zxing/lib/qrscanner_zxing_method_channel.dart +++ b/android/flutter_plugins/qrscanner_zxing/lib/qrscanner_zxing_method_channel.dart @@ -28,14 +28,14 @@ class MethodChannelQRScannerZxing extends QRScannerZxingPlatform { @override Future getPlatformVersion() async { final version = - await methodChannel.invokeMethod('getPlatformVersion'); + await methodChannel.invokeMethod('getPlatformVersion'); return version; } @override - Future scanBitmap(Uint8List bitmap) async { + Future scanBitmap(Uint8List bytes) async { final version = await methodChannel - .invokeMethod('scanBitmap', {'bitmap': bitmap}); + .invokeMethod('scanBitmap', {'bytes': bytes}); return version; } } diff --git a/android/flutter_plugins/qrscanner_zxing/lib/qrscanner_zxing_platform_interface.dart b/android/flutter_plugins/qrscanner_zxing/lib/qrscanner_zxing_platform_interface.dart index 54740987..a86861b8 100644 --- a/android/flutter_plugins/qrscanner_zxing/lib/qrscanner_zxing_platform_interface.dart +++ b/android/flutter_plugins/qrscanner_zxing/lib/qrscanner_zxing_platform_interface.dart @@ -45,7 +45,7 @@ abstract class QRScannerZxingPlatform extends PlatformInterface { throw UnimplementedError('platformVersion() has not been implemented.'); } - Future scanBitmap(Uint8List bitmap) { - throw UnimplementedError('platformVersion() has not been implemented.'); + Future scanBitmap(Uint8List bytes) { + throw UnimplementedError('scanBitmap(Uint8List) has not been implemented.'); } } diff --git a/android/flutter_plugins/qrscanner_zxing/test/qrscanner_zxing_test.dart b/android/flutter_plugins/qrscanner_zxing/test/qrscanner_zxing_test.dart index 85ccd7d1..562ae25d 100644 --- a/android/flutter_plugins/qrscanner_zxing/test/qrscanner_zxing_test.dart +++ b/android/flutter_plugins/qrscanner_zxing/test/qrscanner_zxing_test.dart @@ -29,8 +29,8 @@ class MockQRScannerZxingPlatform Future getPlatformVersion() => Future.value('42'); @override - Future scanBitmap(Uint8List bitmap) => - Future.value(bitmap.length.toString()); + Future scanBitmap(Uint8List bytes) => + Future.value(bytes.length.toString()); } void main() { From 184e7a7f2c7759e93fd35392cd3e9d82b1257532 Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Wed, 18 Oct 2023 15:34:31 +0200 Subject: [PATCH 03/15] Read QR code from file on Android --- .../lib/qrscanner_zxing_method_channel.dart | 8 +- .../qr_scanner/qr_scanner_provider.dart | 73 +++++++++++++++++-- .../qr_scanner/qr_scanner_ui_view.dart | 44 +++-------- lib/app/views/main_page.dart | 28 ++----- lib/l10n/app_en.arb | 2 +- lib/oath/views/add_account_page.dart | 5 +- lib/oath/views/key_actions.dart | 32 +------- 7 files changed, 94 insertions(+), 98 deletions(-) diff --git a/android/flutter_plugins/qrscanner_zxing/lib/qrscanner_zxing_method_channel.dart b/android/flutter_plugins/qrscanner_zxing/lib/qrscanner_zxing_method_channel.dart index 225d4db9..33bffc62 100644 --- a/android/flutter_plugins/qrscanner_zxing/lib/qrscanner_zxing_method_channel.dart +++ b/android/flutter_plugins/qrscanner_zxing/lib/qrscanner_zxing_method_channel.dart @@ -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. @@ -28,14 +28,14 @@ class MethodChannelQRScannerZxing extends QRScannerZxingPlatform { @override Future getPlatformVersion() async { final version = - await methodChannel.invokeMethod('getPlatformVersion'); + await methodChannel.invokeMethod('getPlatformVersion'); return version; } @override Future scanBitmap(Uint8List bytes) async { - final version = await methodChannel + final result = await methodChannel .invokeMethod('scanBitmap', {'bytes': bytes}); - return version; + return result; } } diff --git a/lib/android/qr_scanner/qr_scanner_provider.dart b/lib/android/qr_scanner/qr_scanner_provider.dart index d5865665..274ba92a 100644 --- a/lib/android/qr_scanner/qr_scanner_provider.dart +++ b/lib/android/qr_scanner/qr_scanner_provider.dart @@ -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. @@ -16,16 +16,25 @@ 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/app/state.dart'; import 'package:yubico_authenticator/exception/cancellation_exception.dart'; import 'package:yubico_authenticator/theme.dart'; -import 'package:qrscanner_zxing/qrscanner_zxing_method_channel.dart'; - +import '../../app/message.dart'; +import '../../oath/views/add_account_page.dart'; +import '../../oath/views/utils.dart'; import 'qr_scanner_view.dart'; class AndroidQrScanner implements QrScanner { + static const String kQrScannerRequestManualEntry = + '__QR_SCANNER_ENTER_MANUALLY__'; + static const String kQrScannerRequestReadFromFile = + '__QR_SCANNER_SCAN_FROM_FILE__'; final WithContext _withContext; AndroidQrScanner(this._withContext); @@ -33,8 +42,7 @@ class AndroidQrScanner implements QrScanner { @override Future scanQr([String? imageData]) async { if (imageData == null) { - var scannedCode = await _withContext( - (context) async => + var scannedCode = await _withContext((context) async => await Navigator.of(context).push(PageRouteBuilder( pageBuilder: (_, __, ___) => Theme(data: AppTheme.darkTheme, child: const QrScannerView()), @@ -55,9 +63,62 @@ class AndroidQrScanner implements QrScanner { return await zxingChannel.scanBitmap(base64Decode(imageData)); } } + + static Future handleScannedData( + String? qrData, WidgetRef ref, AppLocalizations l10n) async { + final withContext = ref.read(withContextProvider); + switch (qrData) { + case null: + break; + case kQrScannerRequestManualEntry: + await withContext((context) => showBlurDialog( + context: context, + routeSettings: const RouteSettings(name: 'oath_add_account'), + builder: (context) { + return const OathAddAccountPage( + null, + null, + credentials: null, + ); + }, + )); + case kQrScannerRequestReadFromFile: + final result = await FilePicker.platform.pickFiles( + allowedExtensions: ['png', 'jpg', 'gif', 'webp'], + type: FileType.custom, + allowMultiple: false, + lockParentWindow: true, + withData: true, + dialogTitle: 'Select file with QR code'); + + if (result == null || !result.isSinglePick) { + // no result + return; + } + + final bytes = result.files.first.bytes; + final scanner = ref.read(qrScannerProvider); + if (bytes != null && scanner != null) { + final b64bytes = base64Encode(bytes); + final qrData = await scanner.scanQr(b64bytes); + if (qrData != null) { + await withContext((context) => + handleUri(context, null, qrData, null, null, l10n)); + return; + } + } + // no QR code found + await withContext( + (context) async => showMessage(context, l10n.l_qr_not_found)); + + default: + await withContext( + (context) => handleUri(context, null, qrData, null, null, l10n)); + } + } } QrScanner? Function(dynamic) androidQrScannerProvider(hasCamera) { return (ref) => - hasCamera ? AndroidQrScanner(ref.watch(withContextProvider)) : null; + hasCamera ? AndroidQrScanner(ref.watch(withContextProvider)) : null; } diff --git a/lib/android/qr_scanner/qr_scanner_ui_view.dart b/lib/android/qr_scanner/qr_scanner_ui_view.dart index 421b113d..8e74b5b1 100644 --- a/lib/android/qr_scanner/qr_scanner_ui_view.dart +++ b/lib/android/qr_scanner/qr_scanner_ui_view.dart @@ -14,13 +14,10 @@ * limitations under the License. */ -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:yubico_authenticator/app/state.dart'; +import 'package:yubico_authenticator/android/qr_scanner/qr_scanner_provider.dart'; import '../keys.dart' as keys; import 'qr_scanner_scan_status.dart'; @@ -79,53 +76,34 @@ class QRScannerUI extends ConsumerWidget { style: const TextStyle(color: Colors.white), ), Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, + mainAxisAlignment: MainAxisAlignment.center, children: [ OutlinedButton( onPressed: () { - Navigator.of(context).pop(''); + Navigator.of(context).pop( + AndroidQrScanner.kQrScannerRequestManualEntry); }, key: keys.manualEntryButton, child: Text( l10n.s_enter_manually, style: const TextStyle(color: Colors.white), )), + const SizedBox(width: 16), OutlinedButton( - onPressed: () async { - Navigator.of(context).pop(''); - final result = await FilePicker.platform.pickFiles( - allowedExtensions: ['png', 'jpg'], - type: FileType.custom, - allowMultiple: false, - lockParentWindow: true, - dialogTitle: 'Select file with QR code'); - if (result != null && result.files.isNotEmpty) { - final fileWithCode = result.files.first; - final bytes = fileWithCode.bytes; - if (bytes == null || bytes.isEmpty) { - //err return - return; - } - if (bytes.length > 3 * 1024 * 1024) { - // too big file - return; - } - final scanner = ref.read(qrScannerProvider); - if (scanner != null) { - await scanner.scanQr(base64UrlEncode(bytes)); - } - } + onPressed: () { + Navigator.of(context).pop( + AndroidQrScanner.kQrScannerRequestReadFromFile); }, key: keys.readFromImage, child: Text( - l10n.s_read_from_image, + l10n.s_read_from_file, style: const TextStyle(color: Colors.white), - )), + )) ], ), ], ), - const SizedBox(height: 8) + const SizedBox(height: 16) ], ), ) diff --git a/lib/app/views/main_page.dart b/lib/app/views/main_page.dart index 64a3635e..0ce8092a 100755 --- a/lib/app/views/main_page.dart +++ b/lib/app/views/main_page.dart @@ -15,19 +15,18 @@ */ import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + import '../../android/app_methods.dart'; +import '../../android/qr_scanner/qr_scanner_provider.dart'; import '../../android/state.dart'; -import '../../exception/cancellation_exception.dart'; import '../../core/state.dart'; +import '../../exception/cancellation_exception.dart'; import '../../fido/views/fido_screen.dart'; -import '../../oath/views/add_account_page.dart'; import '../../oath/views/oath_screen.dart'; -import '../../oath/views/utils.dart'; import '../../piv/views/piv_screen.dart'; import '../../widgets/custom_icons.dart'; -import '../message.dart'; import '../models.dart'; import '../state.dart'; import 'device_error_screen.dart'; @@ -99,33 +98,16 @@ class MainPage extends ConsumerWidget { icon: const Icon(Icons.person_add_alt_1), tooltip: l10n.s_add_account, onPressed: () async { - final withContext = ref.read(withContextProvider); final scanner = ref.read(qrScannerProvider); if (scanner != null) { try { final qrData = await scanner.scanQr(); - if (qrData != null) { - await withContext((context) => - handleUri(context, null, qrData, null, null, l10n)); - return; - } + await AndroidQrScanner.handleScannedData(qrData, ref, l10n); } on CancellationException catch (_) { // ignored - user cancelled return; } } - await withContext((context) => showBlurDialog( - context: context, - routeSettings: - const RouteSettings(name: 'oath_add_account'), - builder: (context) { - return const OathAddAccountPage( - null, - null, - credentials: null, - ); - }, - )); }, ), ); diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 0d78d8ae..0af6b507 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -516,7 +516,7 @@ "q_want_to_scan": "Would like to scan?", "q_no_qr": "No QR code?", "s_enter_manually": "Enter manually", - "s_read_from_image": "Provide image file", + "s_read_from_file": "Read from file", "@_factory_reset": {}, "s_reset": "Reset", diff --git a/lib/oath/views/add_account_page.dart b/lib/oath/views/add_account_page.dart index 0bbf8385..a6887c09 100755 --- a/lib/oath/views/add_account_page.dart +++ b/lib/oath/views/add_account_page.dart @@ -30,10 +30,10 @@ import '../../app/message.dart'; import '../../app/models.dart'; import '../../app/state.dart'; import '../../app/views/user_interaction.dart'; -import '../../exception/apdu_exception.dart'; -import '../../exception/cancellation_exception.dart'; import '../../core/state.dart'; import '../../desktop/models.dart'; +import '../../exception/apdu_exception.dart'; +import '../../exception/cancellation_exception.dart'; import '../../management/models.dart'; import '../../widgets/choice_filter_chip.dart'; import '../../widgets/file_drop_target.dart'; @@ -56,6 +56,7 @@ class OathAddAccountPage extends ConsumerStatefulWidget { final OathState? state; final List? credentials; final CredentialData? credentialData; + const OathAddAccountPage( this.devicePath, this.state, { diff --git a/lib/oath/views/key_actions.dart b/lib/oath/views/key_actions.dart index 09cf0dac..16c7eccd 100755 --- a/lib/oath/views/key_actions.dart +++ b/lib/oath/views/key_actions.dart @@ -20,6 +20,7 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:yubico_authenticator/oath/icon_provider/icon_pack_dialog.dart'; import 'package:yubico_authenticator/oath/views/add_account_dialog.dart'; +import '../../android/qr_scanner/qr_scanner_provider.dart'; import '../../app/message.dart'; import '../../app/models.dart'; import '../../app/state.dart'; @@ -28,11 +29,8 @@ import '../../app/views/action_list.dart'; import '../../core/state.dart'; import '../models.dart'; import '../keys.dart' as keys; -import '../state.dart'; -import 'add_account_page.dart'; import 'manage_password_dialog.dart'; import 'reset_dialog.dart'; -import 'utils.dart'; Widget oathBuildActions( BuildContext context, @@ -59,38 +57,14 @@ Widget oathBuildActions( icon: const Icon(Icons.person_add_alt_1_outlined), onTap: used != null && (capacity == null || capacity > used) ? (context) async { - final credentials = ref.read(credentialsProvider); - final withContext = ref.read(withContextProvider); Navigator.of(context).pop(); if (isAndroid) { final qrScanner = ref.read(qrScannerProvider); if (qrScanner != null) { final qrData = await qrScanner.scanQr(); - if (qrData != null) { - await withContext((context) => handleUri( - context, - credentials, - qrData, - devicePath, - oathState, - l10n, - )); - return; - } + await AndroidQrScanner.handleScannedData( + qrData, ref, l10n); } - await withContext((context) => showBlurDialog( - context: context, - routeSettings: - const RouteSettings(name: 'oath_add_account'), - builder: (context) { - return OathAddAccountPage( - devicePath, - oathState, - credentials: credentials, - credentialData: null, - ); - }, - )); } else { await showBlurDialog( context: context, From 548288b54e9c555b0a0768a0fcb5d46627aa3e61 Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Thu, 19 Oct 2023 08:20:18 +0200 Subject: [PATCH 04/15] disable file drop on Android --- lib/widgets/file_drop_target.dart | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/widgets/file_drop_target.dart b/lib/widgets/file_drop_target.dart index 8eb73ac8..70fdc5ec 100755 --- a/lib/widgets/file_drop_target.dart +++ b/lib/widgets/file_drop_target.dart @@ -17,6 +17,8 @@ import 'package:desktop_drop/desktop_drop.dart'; import 'package:flutter/material.dart'; +import '../core/state.dart'; + class FileDropTarget extends StatefulWidget { final Widget child; final Function(List filedata) onFileDropped; @@ -64,6 +66,7 @@ class _FileDropTargetState extends State { widget.onFileDropped(await file.readAsBytes()); } }, + enable: !isAndroid, child: Stack( alignment: Alignment.center, children: [ From 75f8f5be351bc1eb7c051ec8ac671a91d2ab1426 Mon Sep 17 00:00:00 2001 From: Dain Nilsson Date: Fri, 29 Sep 2023 14:12:11 +0200 Subject: [PATCH 05/15] Add experimental feature flags for OATH and PIV. --- lib/android/init.dart | 28 +++++---- lib/app/features.dart | 25 ++++++++ lib/app/models.dart | 2 + lib/app/models.freezed.dart | 34 ++++++++--- lib/app/state.dart | 21 ++++++- lib/app/views/action_list.dart | 35 +++++++---- lib/app/views/app_list_item.dart | 13 +++-- lib/core/state.dart | 35 +++++++++++ lib/desktop/init.dart | 4 +- lib/oath/features.dart | 31 ++++++++++ lib/oath/views/account_dialog.dart | 94 ++++++++++++++++-------------- lib/oath/views/account_helper.dart | 5 ++ lib/oath/views/account_view.dart | 55 +++++++++-------- lib/oath/views/actions.dart | 38 +++++++----- lib/oath/views/key_actions.dart | 5 ++ lib/oath/views/oath_screen.dart | 33 +++++++---- lib/piv/features.dart | 31 ++++++++++ lib/piv/views/actions.dart | 26 ++++++--- lib/piv/views/key_actions.dart | 5 ++ lib/piv/views/piv_screen.dart | 19 ++++-- 20 files changed, 390 insertions(+), 149 deletions(-) create mode 100644 lib/app/features.dart create mode 100644 lib/oath/features.dart create mode 100644 lib/piv/features.dart diff --git a/lib/android/init.dart b/lib/android/init.dart index 6d3b28b3..d2a19c49 100644 --- a/lib/android/init.dart +++ b/lib/android/init.dart @@ -53,39 +53,37 @@ Future initialize() async { return ProviderScope( overrides: [ - supportedAppsProvider.overrideWithValue([ + supportedAppsProvider.overrideWith(implementedApps([ Application.oath, - ]), + ])), prefProvider.overrideWithValue(await SharedPreferences.getInstance()), logLevelProvider.overrideWith((ref) => AndroidLogger()), - attachedDevicesProvider - .overrideWith( - () => AndroidAttachedDevicesNotifier(), - ), + attachedDevicesProvider.overrideWith( + () => AndroidAttachedDevicesNotifier(), + ), currentDeviceDataProvider.overrideWith( - (ref) => ref.watch(androidDeviceDataProvider), + (ref) => ref.watch(androidDeviceDataProvider), ), oathStateProvider.overrideWithProvider(androidOathStateProvider), credentialListProvider .overrideWithProvider(androidCredentialListProvider), currentAppProvider.overrideWith( - (ref) => AndroidSubPageNotifier(ref.watch(supportedAppsProvider)) - ), + (ref) => AndroidSubPageNotifier(ref.watch(supportedAppsProvider))), managementStateProvider.overrideWithProvider(androidManagementState), currentDeviceProvider.overrideWith( - () => AndroidCurrentDeviceNotifier(), + () => AndroidCurrentDeviceNotifier(), ), qrScannerProvider .overrideWith(androidQrScannerProvider(await getHasCamera())), - windowStateProvider.overrideWith((ref) => ref.watch(androidWindowStateProvider)), + windowStateProvider + .overrideWith((ref) => ref.watch(androidWindowStateProvider)), clipboardProvider.overrideWith( - (ref) => ref.watch(androidClipboardProvider), + (ref) => ref.watch(androidClipboardProvider), ), androidSdkVersionProvider.overrideWithValue(await getAndroidSdkVersion()), androidNfcSupportProvider.overrideWithValue(await getHasNfc()), - supportedThemesProvider - .overrideWith( - (ref) => ref.watch(androidSupportedThemesProvider), + supportedThemesProvider.overrideWith( + (ref) => ref.watch(androidSupportedThemesProvider), ) ], child: DismissKeyboard( diff --git a/lib/app/features.dart b/lib/app/features.dart new file mode 100644 index 00000000..7cd36e5e --- /dev/null +++ b/lib/app/features.dart @@ -0,0 +1,25 @@ +/* + * Copyright (C) 2023 Yubico. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import '../core/state.dart'; + +final oath = root.feature('oath'); +final fido = root.feature('fido'); +final piv = root.feature('piv'); +final management = root.feature('management'); +final openpgp = root.feature('openpgp'); +final hsmauth = root.feature('hsmauth'); +final otp = root.feature('otp'); diff --git a/lib/app/models.dart b/lib/app/models.dart index 5599848f..f6335381 100755 --- a/lib/app/models.dart +++ b/lib/app/models.dart @@ -21,6 +21,7 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import '../../management/models.dart'; import '../core/models.dart'; +import '../core/state.dart'; part 'models.freezed.dart'; @@ -129,6 +130,7 @@ class ActionItem with _$ActionItem { Intent? intent, ActionStyle? actionStyle, Key? key, + Feature? feature, }) = _ActionItem; } diff --git a/lib/app/models.freezed.dart b/lib/app/models.freezed.dart index d4479d69..68080dc6 100644 --- a/lib/app/models.freezed.dart +++ b/lib/app/models.freezed.dart @@ -633,6 +633,7 @@ mixin _$ActionItem { Intent? get intent => throw _privateConstructorUsedError; ActionStyle? get actionStyle => throw _privateConstructorUsedError; Key? get key => throw _privateConstructorUsedError; + Feature? get feature => throw _privateConstructorUsedError; @JsonKey(ignore: true) $ActionItemCopyWith get copyWith => @@ -653,7 +654,8 @@ abstract class $ActionItemCopyWith<$Res> { Widget? trailing, Intent? intent, ActionStyle? actionStyle, - Key? key}); + Key? key, + Feature? feature}); } /// @nodoc @@ -677,6 +679,7 @@ class _$ActionItemCopyWithImpl<$Res, $Val extends ActionItem> Object? intent = freezed, Object? actionStyle = freezed, Object? key = freezed, + Object? feature = freezed, }) { return _then(_value.copyWith( icon: null == icon @@ -711,6 +714,10 @@ class _$ActionItemCopyWithImpl<$Res, $Val extends ActionItem> ? _value.key : key // ignore: cast_nullable_to_non_nullable as Key?, + feature: freezed == feature + ? _value.feature + : feature // ignore: cast_nullable_to_non_nullable + as Feature?, ) as $Val); } } @@ -731,7 +738,8 @@ abstract class _$$_ActionItemCopyWith<$Res> Widget? trailing, Intent? intent, ActionStyle? actionStyle, - Key? key}); + Key? key, + Feature? feature}); } /// @nodoc @@ -753,6 +761,7 @@ class __$$_ActionItemCopyWithImpl<$Res> Object? intent = freezed, Object? actionStyle = freezed, Object? key = freezed, + Object? feature = freezed, }) { return _then(_$_ActionItem( icon: null == icon @@ -787,6 +796,10 @@ class __$$_ActionItemCopyWithImpl<$Res> ? _value.key : key // ignore: cast_nullable_to_non_nullable as Key?, + feature: freezed == feature + ? _value.feature + : feature // ignore: cast_nullable_to_non_nullable + as Feature?, )); } } @@ -802,7 +815,8 @@ class _$_ActionItem implements _ActionItem { this.trailing, this.intent, this.actionStyle, - this.key}); + this.key, + this.feature}); @override final Widget icon; @@ -820,10 +834,12 @@ class _$_ActionItem implements _ActionItem { final ActionStyle? actionStyle; @override final Key? key; + @override + final Feature? feature; @override String toString() { - return 'ActionItem(icon: $icon, title: $title, subtitle: $subtitle, shortcut: $shortcut, trailing: $trailing, intent: $intent, actionStyle: $actionStyle, key: $key)'; + return 'ActionItem(icon: $icon, title: $title, subtitle: $subtitle, shortcut: $shortcut, trailing: $trailing, intent: $intent, actionStyle: $actionStyle, key: $key, feature: $feature)'; } @override @@ -842,12 +858,13 @@ class _$_ActionItem implements _ActionItem { (identical(other.intent, intent) || other.intent == intent) && (identical(other.actionStyle, actionStyle) || other.actionStyle == actionStyle) && - (identical(other.key, key) || other.key == key)); + (identical(other.key, key) || other.key == key) && + (identical(other.feature, feature) || other.feature == feature)); } @override int get hashCode => Object.hash(runtimeType, icon, title, subtitle, shortcut, - trailing, intent, actionStyle, key); + trailing, intent, actionStyle, key, feature); @JsonKey(ignore: true) @override @@ -865,7 +882,8 @@ abstract class _ActionItem implements ActionItem { final Widget? trailing, final Intent? intent, final ActionStyle? actionStyle, - final Key? key}) = _$_ActionItem; + final Key? key, + final Feature? feature}) = _$_ActionItem; @override Widget get icon; @@ -884,6 +902,8 @@ abstract class _ActionItem implements ActionItem { @override Key? get key; @override + Feature? get feature; + @override @JsonKey(ignore: true) _$$_ActionItemCopyWith<_$_ActionItem> get copyWith => throw _privateConstructorUsedError; diff --git a/lib/app/state.dart b/lib/app/state.dart index 06626078..fe1aad2b 100755 --- a/lib/app/state.dart +++ b/lib/app/state.dart @@ -27,6 +27,7 @@ import 'package:yubico_authenticator/app/logging.dart'; import '../core/state.dart'; import 'models.dart'; +import 'features.dart' as features; final _log = Logger('app.state'); @@ -37,7 +38,25 @@ const officialLocales = [ // Override this to alter the set of supported apps. final supportedAppsProvider = - Provider>((ref) => Application.values); + Provider>(implementedApps(Application.values)); + +extension on Application { + Feature get _feature => switch (this) { + Application.oath => features.oath, + Application.fido => features.fido, + Application.otp => features.otp, + Application.piv => features.piv, + Application.management => features.management, + Application.openpgp => features.openpgp, + Application.hsmauth => features.oath, + }; +} + +List Function(Ref) implementedApps(List apps) => + (ref) { + final hasFeature = ref.watch(featureProvider); + return apps.where((app) => hasFeature(app._feature)).toList(); + }; // Default implementation is always focused, override with platform specific version. final windowStateProvider = Provider( diff --git a/lib/app/views/action_list.dart b/lib/app/views/action_list.dart index be6733df..0bf0bafd 100644 --- a/lib/app/views/action_list.dart +++ b/lib/app/views/action_list.dart @@ -15,7 +15,9 @@ */ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../core/state.dart'; import '../../widgets/list_title.dart'; import '../models.dart'; @@ -26,6 +28,7 @@ class ActionListItem extends StatelessWidget { final Widget? trailing; final void Function(BuildContext context)? onTap; final ActionStyle actionStyle; + final Feature? feature; const ActionListItem({ super.key, @@ -35,6 +38,7 @@ class ActionListItem extends StatelessWidget { this.trailing, this.onTap, this.actionStyle = ActionStyle.normal, + this.feature, }); @override @@ -67,7 +71,7 @@ class ActionListItem extends StatelessWidget { } } -class ActionListSection extends StatelessWidget { +class ActionListSection extends ConsumerWidget { final String title; final List children; @@ -82,6 +86,7 @@ class ActionListSection extends StatelessWidget { final intent = action.intent; return ActionListItem( key: action.key, + feature: action.feature, actionStyle: action.actionStyle ?? ActionStyle.normal, icon: action.icon, title: action.title, @@ -96,14 +101,22 @@ class ActionListSection extends StatelessWidget { } @override - Widget build(BuildContext context) => SizedBox( - width: 360, - child: Column(children: [ - ListTitle( - title, - textStyle: Theme.of(context).textTheme.bodyLarge, - ), - ...children, - ]), - ); + Widget build(BuildContext context, WidgetRef ref) { + final hasFeature = ref.watch(featureProvider); + final enabledChildren = children + .where((item) => item.feature == null || hasFeature(item.feature!)); + if (enabledChildren.isEmpty) { + return const SizedBox(); + } + return SizedBox( + width: 360, + child: Column(children: [ + ListTitle( + title, + textStyle: Theme.of(context).textTheme.bodyLarge, + ), + ...enabledChildren, + ]), + ); + } } diff --git a/lib/app/views/app_list_item.dart b/lib/app/views/app_list_item.dart index fc783d6a..f3f47b90 100644 --- a/lib/app/views/app_list_item.dart +++ b/lib/app/views/app_list_item.dart @@ -16,13 +16,14 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/state.dart'; import '../models.dart'; import '../shortcuts.dart'; import 'action_popup_menu.dart'; -class AppListItem extends StatefulWidget { +class AppListItem extends ConsumerStatefulWidget { final Widget? leading; final String title; final String? subtitle; @@ -41,10 +42,10 @@ class AppListItem extends StatefulWidget { }); @override - State createState() => _AppListItemState(); + ConsumerState createState() => _AppListItemState(); } -class _AppListItemState extends State { +class _AppListItemState extends ConsumerState { final FocusNode _focusNode = FocusNode(); int _lastTap = 0; @@ -60,6 +61,7 @@ class _AppListItemState extends State { final buildPopupActions = widget.buildPopupActions; final activationIntent = widget.activationIntent; final trailing = widget.trailing; + final hasFeature = ref.watch(featureProvider); return Shortcuts( shortcuts: { @@ -75,7 +77,10 @@ class _AppListItemState extends State { showPopupMenu( context, details.globalPosition, - buildPopupActions(context), + buildPopupActions(context) + .where((action) => + action.feature == null || hasFeature(action.feature!)) + .toList(), ); }, onTap: () { diff --git a/lib/core/state.dart b/lib/core/state.dart index 30daaa1e..af488d68 100644 --- a/lib/core/state.dart +++ b/lib/core/state.dart @@ -51,3 +51,38 @@ abstract class ApplicationStateNotifier state = AsyncValue.data(value); } } + +// Feature flags +abstract class BaseFeature { + String get path; + + Feature feature(String key, {bool enabled = true}) => + Feature(this, key, enabled: enabled); +} + +class _RootFeature extends BaseFeature { + _RootFeature._(); + @override + String get path => ''; +} + +class Feature extends BaseFeature { + final BaseFeature parent; + final String key; + final bool _defaultState; + + Feature(this.parent, this.key, {bool enabled = true}) + : _defaultState = enabled; + + @override + String get path => '${parent.path}.$key'; +} + +final BaseFeature root = _RootFeature._(); + +typedef FeatureProvider = bool Function(Feature feature); + +final featureProvider = Provider((ref) { + // TODO: Read file, check parents + return (feature) => feature._defaultState; +}); diff --git a/lib/desktop/init.dart b/lib/desktop/init.dart index 75b50f38..d7672be6 100755 --- a/lib/desktop/init.dart +++ b/lib/desktop/init.dart @@ -167,12 +167,12 @@ Future initialize(List argv) async { return ProviderScope( overrides: [ - supportedAppsProvider.overrideWithValue([ + supportedAppsProvider.overrideWith(implementedApps([ Application.oath, Application.fido, Application.piv, Application.management, - ]), + ])), prefProvider.overrideWithValue(prefs), rpcProvider.overrideWith((_) => rpcFuture), windowStateProvider.overrideWith( diff --git a/lib/oath/features.dart b/lib/oath/features.dart new file mode 100644 index 00000000..db56edee --- /dev/null +++ b/lib/oath/features.dart @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2023 Yubico. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import '../app/features.dart'; + +final actions = oath.feature('actions'); + +final actionsAdd = actions.feature('add'); +final actionsIcons = actions.feature('icons'); +final actionsPassword = actions.feature('password'); +final actionsReset = actions.feature('reset'); + +final accounts = actions.feature('accounts'); + +final accountsClipboard = accounts.feature('clipboard'); +final accountsPin = accounts.feature('pin'); +final accountsRename = accounts.feature('rename'); +final accountsDelete = accounts.feature('delete'); diff --git a/lib/oath/views/account_dialog.dart b/lib/oath/views/account_dialog.dart index 4645fafb..a97c5245 100755 --- a/lib/oath/views/account_dialog.dart +++ b/lib/oath/views/account_dialog.dart @@ -27,6 +27,7 @@ import '../../app/views/fs_dialog.dart'; import '../../app/views/action_list.dart'; import '../../core/models.dart'; import '../../core/state.dart'; +import '../features.dart' as features; import '../models.dart'; import '../state.dart'; import 'account_helper.dart'; @@ -49,6 +50,7 @@ class AccountDialog extends ConsumerWidget { return const SizedBox(); } + final hasFeature = ref.watch(featureProvider); final helper = AccountHelper(context, ref, credential); final subtitle = helper.subtitle; @@ -56,54 +58,58 @@ class AccountDialog extends ConsumerWidget { credential, ref: ref, actions: { - EditIntent: CallbackAction(onInvoke: (_) async { - final credentials = ref.read(credentialsProvider); - final withContext = ref.read(withContextProvider); - final renamed = - await withContext((context) async => await showBlurDialog( + if (hasFeature(features.accountsRename)) + EditIntent: CallbackAction(onInvoke: (_) async { + final credentials = ref.read(credentialsProvider); + final withContext = ref.read(withContextProvider); + final renamed = + await withContext((context) async => await showBlurDialog( + context: context, + builder: (context) => RenameAccountDialog.forOathCredential( + ref, + node, + credential, + credentials + ?.map((e) => (e.issuer, e.name)) + .toList() ?? + [], + ))); + if (renamed != null) { + // Replace the dialog with the renamed credential + await withContext((context) async { + Navigator.of(context).pop(); + await showBlurDialog( context: context, - builder: (context) => RenameAccountDialog.forOathCredential( - ref, + builder: (context) { + return AccountDialog(renamed); + }, + ); + }); + } + return renamed; + }), + if (hasFeature(features.accountsDelete)) + DeleteIntent: CallbackAction(onInvoke: (_) async { + final withContext = ref.read(withContextProvider); + final bool? deleted = + await ref.read(withContextProvider)((context) async => + await showBlurDialog( + context: context, + builder: (context) => DeleteAccountDialog( node, credential, - credentials?.map((e) => (e.issuer, e.name)).toList() ?? - [], - ))); - if (renamed != null) { - // Replace the dialog with the renamed credential - await withContext((context) async { - Navigator.of(context).pop(); - await showBlurDialog( - context: context, - builder: (context) { - return AccountDialog(renamed); - }, - ); - }); - } - return renamed; - }), - DeleteIntent: CallbackAction(onInvoke: (_) async { - final withContext = ref.read(withContextProvider); - final bool? deleted = - await ref.read(withContextProvider)((context) async => - await showBlurDialog( - context: context, - builder: (context) => DeleteAccountDialog( - node, - credential, - ), - ) ?? - false); + ), + ) ?? + false); - // Pop the account dialog if deleted - if (deleted == true) { - await withContext((context) async { - Navigator.of(context).pop(); - }); - } - return deleted; - }), + // Pop the account dialog if deleted + if (deleted == true) { + await withContext((context) async { + Navigator.of(context).pop(); + }); + } + return deleted; + }), }, builder: (context) { if (helper.code == null && diff --git a/lib/oath/views/account_helper.dart b/lib/oath/views/account_helper.dart index e8c262b3..4c62e590 100755 --- a/lib/oath/views/account_helper.dart +++ b/lib/oath/views/account_helper.dart @@ -27,6 +27,7 @@ import '../../app/state.dart'; import '../../core/state.dart'; import '../../widgets/circle_timer.dart'; import '../../widgets/custom_icons.dart'; +import '../features.dart' as features; import '../models.dart'; import '../state.dart'; import '../keys.dart' as keys; @@ -68,6 +69,7 @@ class AccountHelper { return [ ActionItem( key: keys.copyAction, + feature: features.accountsClipboard, icon: const Icon(Icons.copy), title: l10n.l_copy_to_clipboard, subtitle: l10n.l_copy_code_desc, @@ -87,6 +89,7 @@ class AccountHelper { ), ActionItem( key: keys.togglePinAction, + feature: features.accountsPin, icon: pinned ? pushPinStrokeIcon : const Icon(Icons.push_pin_outlined), @@ -97,6 +100,7 @@ class AccountHelper { if (data.info.version.isAtLeast(5, 3)) ActionItem( key: keys.editAction, + feature: features.accountsRename, icon: const Icon(Icons.edit_outlined), title: l10n.s_rename_account, subtitle: l10n.l_rename_account_desc, @@ -104,6 +108,7 @@ class AccountHelper { ), ActionItem( key: keys.deleteAction, + feature: features.accountsDelete, actionStyle: ActionStyle.error, icon: const Icon(Icons.delete_outline), title: l10n.s_delete_account, diff --git a/lib/oath/views/account_view.dart b/lib/oath/views/account_view.dart index 09222c47..bb7aa9c6 100755 --- a/lib/oath/views/account_view.dart +++ b/lib/oath/views/account_view.dart @@ -23,7 +23,9 @@ import '../../app/message.dart'; import '../../app/shortcuts.dart'; import '../../app/state.dart'; import '../../app/views/app_list_item.dart'; +import '../../core/state.dart'; import '../models.dart'; +import '../features.dart' as features; import '../state.dart'; import 'account_dialog.dart'; import 'account_helper.dart'; @@ -81,6 +83,7 @@ class _AccountViewState extends ConsumerState { Widget build(BuildContext context) { final theme = Theme.of(context); final darkMode = theme.brightness == Brightness.dark; + final hasFeature = ref.watch(featureProvider); return registerOathActions( credential, @@ -94,29 +97,31 @@ class _AccountViewState extends ConsumerState { ); return null; }), - EditIntent: CallbackAction(onInvoke: (_) async { - final node = ref.read(currentDeviceProvider)!; - final credentials = ref.read(credentialsProvider); - final withContext = ref.read(withContextProvider); - return await withContext((context) async => await showBlurDialog( - context: context, - builder: (context) => RenameAccountDialog.forOathCredential( - ref, - node, - credential, - credentials?.map((e) => (e.issuer, e.name)).toList() ?? [], - ), - )); - }), - DeleteIntent: CallbackAction(onInvoke: (_) async { - final node = ref.read(currentDeviceProvider)!; - return await ref.read(withContextProvider)((context) async => - await showBlurDialog( - context: context, - builder: (context) => DeleteAccountDialog(node, credential), - ) ?? - false); - }), + if (hasFeature(features.accountsRename)) + EditIntent: CallbackAction(onInvoke: (_) async { + final node = ref.read(currentDeviceProvider)!; + final credentials = ref.read(credentialsProvider); + final withContext = ref.read(withContextProvider); + return await withContext((context) async => await showBlurDialog( + context: context, + builder: (context) => RenameAccountDialog.forOathCredential( + ref, + node, + credential, + credentials?.map((e) => (e.issuer, e.name)).toList() ?? [], + ), + )); + }), + if (hasFeature(features.accountsDelete)) + DeleteIntent: CallbackAction(onInvoke: (_) async { + final node = ref.read(currentDeviceProvider)!; + return await ref.read(withContextProvider)((context) async => + await showBlurDialog( + context: context, + builder: (context) => DeleteAccountDialog(node, credential), + ) ?? + false); + }), }, builder: (context) { final helper = AccountHelper(context, ref, credential); @@ -162,7 +167,9 @@ class _AccountViewState extends ConsumerState { onPressed: Actions.handler(context, const OpenIntent()), child: helper.buildCodeIcon()), - activationIntent: const CopyIntent(), + activationIntent: hasFeature(features.accountsClipboard) + ? const CopyIntent() + : const OpenIntent(), buildPopupActions: (_) => helper.buildActions(), ), )); diff --git a/lib/oath/views/actions.dart b/lib/oath/views/actions.dart index 775143b1..5eded137 100755 --- a/lib/oath/views/actions.dart +++ b/lib/oath/views/actions.dart @@ -21,8 +21,10 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import '../../app/message.dart'; import '../../app/shortcuts.dart'; import '../../app/state.dart'; +import '../../core/state.dart'; import '../../exception/cancellation_exception.dart'; import '../models.dart'; +import '../features.dart' as features; import '../state.dart'; class TogglePinIntent extends Intent { @@ -46,18 +48,20 @@ Widget registerOathActions( required WidgetRef ref, required Widget Function(BuildContext context) builder, Map> actions = const {}, -}) => - Actions( - actions: { - RefreshIntent: CallbackAction(onInvoke: (_) { - final code = ref.read(codeProvider(credential)); - if (!(credential.oathType == OathType.totp && - code != null && - !ref.read(expiredProvider(code.validTo)))) { - return _calculateCode(credential, ref); - } - return code; - }), +}) { + final hasFeature = ref.read(featureProvider); + return Actions( + actions: { + RefreshIntent: CallbackAction(onInvoke: (_) { + final code = ref.read(codeProvider(credential)); + if (!(credential.oathType == OathType.totp && + code != null && + !ref.read(expiredProvider(code.validTo)))) { + return _calculateCode(credential, ref); + } + return code; + }), + if (hasFeature(features.accountsClipboard)) CopyIntent: CallbackAction(onInvoke: (_) async { var code = ref.read(codeProvider(credential)); if (code == null || @@ -77,11 +81,13 @@ Widget registerOathActions( } return code; }), + if (hasFeature(features.accountsPin)) TogglePinIntent: CallbackAction(onInvoke: (_) { ref.read(favoritesProvider.notifier).toggleFavorite(credential.id); return null; }), - ...actions, - }, - child: Builder(builder: builder), - ); + ...actions, + }, + child: Builder(builder: builder), + ); +} diff --git a/lib/oath/views/key_actions.dart b/lib/oath/views/key_actions.dart index 09cf0dac..621060eb 100755 --- a/lib/oath/views/key_actions.dart +++ b/lib/oath/views/key_actions.dart @@ -27,6 +27,7 @@ import '../../app/views/fs_dialog.dart'; import '../../app/views/action_list.dart'; import '../../core/state.dart'; import '../models.dart'; +import '../features.dart' as features; import '../keys.dart' as keys; import '../state.dart'; import 'add_account_page.dart'; @@ -49,6 +50,7 @@ Widget oathBuildActions( children: [ ActionListSection(l10n.s_setup, children: [ ActionListItem( + feature: features.actionsAdd, title: l10n.s_add_account, subtitle: used == null ? l10n.l_unlock_first @@ -104,6 +106,7 @@ Widget oathBuildActions( ActionListSection(l10n.s_manage, children: [ ActionListItem( key: keys.customIconsAction, + feature: features.actionsIcons, title: l10n.s_custom_icons, subtitle: l10n.l_set_icons_for_accounts, icon: const Icon(Icons.image_outlined), @@ -118,6 +121,7 @@ Widget oathBuildActions( }), ActionListItem( key: keys.setOrManagePasswordAction, + feature: features.actionsPassword, title: oathState.hasKey ? l10n.s_manage_password : l10n.s_set_password, @@ -133,6 +137,7 @@ Widget oathBuildActions( }), ActionListItem( key: keys.resetAction, + feature: features.actionsReset, icon: const Icon(Icons.delete_outline), actionStyle: ActionStyle.error, title: l10n.s_reset_oath, diff --git a/lib/oath/views/oath_screen.dart b/lib/oath/views/oath_screen.dart index d5f4270b..529e63df 100755 --- a/lib/oath/views/oath_screen.dart +++ b/lib/oath/views/oath_screen.dart @@ -25,6 +25,8 @@ import '../../app/views/app_failure_page.dart'; import '../../app/views/app_page.dart'; import '../../app/views/graphics.dart'; import '../../app/views/message_page.dart'; +import '../../core/state.dart'; +import '../features.dart' as features; import '../keys.dart' as keys; import '../models.dart'; import '../state.dart'; @@ -65,10 +67,12 @@ class _LockedView extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final hasActions = ref.watch(featureProvider)(features.actions); return AppPage( title: Text(AppLocalizations.of(context)!.s_authenticator), - keyActionsBuilder: (context) => - oathBuildActions(context, devicePath, oathState, ref), + keyActionsBuilder: hasActions + ? (context) => oathBuildActions(context, devicePath, oathState, ref) + : null, child: Padding( padding: const EdgeInsets.symmetric(vertical: 18), child: UnlockForm( @@ -114,15 +118,18 @@ class _UnlockedViewState extends ConsumerState<_UnlockedView> { // ONLY rebuild if the number of credentials changes. final numCreds = ref.watch(credentialListProvider(widget.devicePath) .select((value) => value?.length)); + final hasActions = ref.watch(featureProvider)(features.actions); if (numCreds == 0) { return MessagePage( title: Text(l10n.s_authenticator), key: keys.noAccountsView, graphic: noAccounts, header: l10n.s_no_accounts, - keyActionsBuilder: (context) => oathBuildActions( - context, widget.devicePath, widget.oathState, ref, - used: 0), + keyActionsBuilder: hasActions + ? (context) => oathBuildActions( + context, widget.devicePath, widget.oathState, ref, + used: 0) + : null, ); } return Actions( @@ -184,13 +191,15 @@ class _UnlockedViewState extends ConsumerState<_UnlockedView> { ); }), ), - keyActionsBuilder: (context) => oathBuildActions( - context, - widget.devicePath, - widget.oathState, - ref, - used: numCreds ?? 0, - ), + keyActionsBuilder: hasActions + ? (context) => oathBuildActions( + context, + widget.devicePath, + widget.oathState, + ref, + used: numCreds ?? 0, + ) + : null, centered: numCreds == null, delayedContent: numCreds == null, child: numCreds != null diff --git a/lib/piv/features.dart b/lib/piv/features.dart new file mode 100644 index 00000000..94394e38 --- /dev/null +++ b/lib/piv/features.dart @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2023 Yubico. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import '../app/features.dart'; + +final actions = piv.feature('actions'); + +final actionsPin = actions.feature('pin'); +final actionsPuk = actions.feature('puk'); +final actionsManagementKey = actions.feature('managementKey', enabled: false); +final actionsReset = actions.feature('reset', enabled: false); + +final slots = piv.feature('slots'); + +final slotsGenerate = slots.feature('generate', enabled: false); +final slotsImport = slots.feature('import', enabled: false); +final slotsExport = slots.feature('export'); +final slotsDelete = slots.feature('delete', enabled: false); diff --git a/lib/piv/views/actions.dart b/lib/piv/views/actions.dart index 47ac2a32..02a6e889 100644 --- a/lib/piv/views/actions.dart +++ b/lib/piv/views/actions.dart @@ -25,9 +25,11 @@ import '../../app/message.dart'; import '../../app/shortcuts.dart'; import '../../app/state.dart'; import '../../app/models.dart'; +import '../../core/state.dart'; import '../models.dart'; import '../state.dart'; import '../keys.dart' as keys; +import '../features.dart' as features; import 'authentication_dialog.dart'; import 'delete_certificate_dialog.dart'; import 'generate_key_dialog.dart'; @@ -75,9 +77,11 @@ Widget registerPivActions( required WidgetRef ref, required Widget Function(BuildContext context) builder, Map> actions = const {}, -}) => - Actions( - actions: { +}) { + final hasFeature = ref.watch(featureProvider); + return Actions( + actions: { + if (hasFeature(features.slotsGenerate)) GenerateIntent: CallbackAction(onInvoke: (intent) async { final withContext = ref.read(withContextProvider); @@ -129,6 +133,7 @@ Widget registerPivActions( return result != null; }); }), + if (hasFeature(features.slotsImport)) ImportIntent: CallbackAction(onInvoke: (intent) async { final withContext = ref.read(withContextProvider); @@ -164,6 +169,7 @@ Widget registerPivActions( ) ?? false); }), + if (hasFeature(features.slotsExport)) ExportIntent: CallbackAction(onInvoke: (intent) async { final (_, cert) = await ref .read(pivSlotsProvider(devicePath).notifier) @@ -198,6 +204,7 @@ Widget registerPivActions( }); return true; }), + if (hasFeature(features.slotsDelete)) DeleteIntent: CallbackAction(onInvoke: (_) async { final withContext = ref.read(withContextProvider); if (!await withContext( @@ -216,15 +223,17 @@ Widget registerPivActions( false); return deleted; }), - ...actions, - }, - child: Builder(builder: builder), - ); + ...actions, + }, + child: Builder(builder: builder), + ); +} List buildSlotActions(bool hasCert, AppLocalizations l10n) { return [ ActionItem( key: keys.generateAction, + feature: features.slotsGenerate, icon: const Icon(Icons.add_outlined), actionStyle: ActionStyle.primary, title: l10n.s_generate_key, @@ -233,6 +242,7 @@ List buildSlotActions(bool hasCert, AppLocalizations l10n) { ), ActionItem( key: keys.importAction, + feature: features.slotsImport, icon: const Icon(Icons.file_download_outlined), title: l10n.l_import_file, subtitle: l10n.l_import_desc, @@ -241,6 +251,7 @@ List buildSlotActions(bool hasCert, AppLocalizations l10n) { if (hasCert) ...[ ActionItem( key: keys.exportAction, + feature: features.slotsExport, icon: const Icon(Icons.file_upload_outlined), title: l10n.l_export_certificate, subtitle: l10n.l_export_certificate_desc, @@ -248,6 +259,7 @@ List buildSlotActions(bool hasCert, AppLocalizations l10n) { ), ActionItem( key: keys.deleteAction, + feature: features.slotsDelete, actionStyle: ActionStyle.error, icon: const Icon(Icons.delete_outline), title: l10n.l_delete_certificate, diff --git a/lib/piv/views/key_actions.dart b/lib/piv/views/key_actions.dart index 360586b0..39964242 100644 --- a/lib/piv/views/key_actions.dart +++ b/lib/piv/views/key_actions.dart @@ -24,6 +24,7 @@ import '../../app/views/fs_dialog.dart'; import '../../app/views/action_list.dart'; import '../models.dart'; import '../keys.dart' as keys; +import '../features.dart' as features; import 'manage_key_dialog.dart'; import 'manage_pin_puk_dialog.dart'; import 'reset_dialog.dart'; @@ -49,6 +50,7 @@ Widget pivBuildActions(BuildContext context, DevicePath devicePath, children: [ ActionListItem( key: keys.managePinAction, + feature: features.actionsPin, title: l10n.s_pin, subtitle: pinBlocked ? (pukAttempts != 0 @@ -73,6 +75,7 @@ Widget pivBuildActions(BuildContext context, DevicePath devicePath, : null), ActionListItem( key: keys.managePukAction, + feature: features.actionsPuk, title: l10n.s_puk, subtitle: pukAttempts != null ? (pukAttempts == 0 @@ -93,6 +96,7 @@ Widget pivBuildActions(BuildContext context, DevicePath devicePath, : null), ActionListItem( key: keys.manageManagementKeyAction, + feature: features.actionsManagementKey, title: l10n.s_management_key, subtitle: usingDefaultMgmtKey ? l10n.l_warning_default_key @@ -110,6 +114,7 @@ Widget pivBuildActions(BuildContext context, DevicePath devicePath, }), ActionListItem( key: keys.resetAction, + feature: features.actionsReset, icon: const Icon(Icons.delete_outline), actionStyle: ActionStyle.error, title: l10n.s_reset_piv, diff --git a/lib/piv/views/piv_screen.dart b/lib/piv/views/piv_screen.dart index 1ad9aa0a..6bdc8c64 100644 --- a/lib/piv/views/piv_screen.dart +++ b/lib/piv/views/piv_screen.dart @@ -25,9 +25,11 @@ import '../../app/views/app_failure_page.dart'; import '../../app/views/app_list_item.dart'; import '../../app/views/app_page.dart'; import '../../app/views/message_page.dart'; +import '../../core/state.dart'; import '../../widgets/list_title.dart'; import '../models.dart'; import '../state.dart'; +import '../features.dart' as features; import 'actions.dart'; import 'key_actions.dart'; import 'slot_dialog.dart'; @@ -40,6 +42,7 @@ class PivScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final l10n = AppLocalizations.of(context)!; + final hasFeature = ref.watch(featureProvider); return ref.watch(pivStateProvider(devicePath)).when( loading: () => MessagePage( title: Text(l10n.s_piv), @@ -54,8 +57,10 @@ class PivScreen extends ConsumerWidget { final pivSlots = ref.watch(pivSlotsProvider(devicePath)).asData; return AppPage( title: Text(l10n.s_piv), - keyActionsBuilder: (context) => - pivBuildActions(context, devicePath, pivState, ref), + keyActionsBuilder: hasFeature(features.actions) + ? (context) => + pivBuildActions(context, devicePath, pivState, ref) + : null, child: Column( children: [ ListTitle(l10n.s_certificates), @@ -86,16 +91,17 @@ class PivScreen extends ConsumerWidget { } } -class _CertificateListItem extends StatelessWidget { +class _CertificateListItem extends ConsumerWidget { final PivSlot pivSlot; const _CertificateListItem(this.pivSlot); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final slot = pivSlot.slot; final certInfo = pivSlot.certInfo; final l10n = AppLocalizations.of(context)!; final colorScheme = Theme.of(context).colorScheme; + final hasFeature = ref.watch(featureProvider); return Semantics( label: slot.getDisplayName(l10n), @@ -116,8 +122,9 @@ class _CertificateListItem extends StatelessWidget { onPressed: Actions.handler(context, const OpenIntent()), child: const Icon(Icons.more_horiz), ), - buildPopupActions: (context) => - buildSlotActions(certInfo != null, l10n), + buildPopupActions: hasFeature(features.slots) + ? (context) => buildSlotActions(certInfo != null, l10n) + : null, )); } } From 5d9420f47f16c4f9287f8e2cae7acffc4d03ec7c Mon Sep 17 00:00:00 2001 From: Dain Nilsson Date: Wed, 4 Oct 2023 11:08:02 +0200 Subject: [PATCH 06/15] Refactor feature flag support. --- lib/about_page.dart | 1 + lib/core/state.dart | 39 +++++++++++++++++++++++++++++++++------ lib/desktop/init.dart | 33 ++++++++++++++++++++++++++++----- lib/piv/features.dart | 10 +++++----- 4 files changed, 67 insertions(+), 16 deletions(-) diff --git a/lib/about_page.dart b/lib/about_page.dart index f98ad07d..6709c4dd 100755 --- a/lib/about_page.dart +++ b/lib/about_page.dart @@ -165,6 +165,7 @@ class AboutPage extends ConsumerWidget { 'os': Platform.operatingSystem, 'os_version': Platform.operatingSystemVersion, }); + data.insert(data.length - 1, ref.read(featureFlagProvider)); final text = const JsonEncoder.withIndent(' ').convert(data); await ref.read(clipboardProvider).setText(text); await ref.read(withContextProvider)( diff --git a/lib/core/state.dart b/lib/core/state.dart index af488d68..540e2f01 100644 --- a/lib/core/state.dart +++ b/lib/core/state.dart @@ -53,17 +53,21 @@ abstract class ApplicationStateNotifier } // Feature flags -abstract class BaseFeature { +sealed class BaseFeature { String get path; + String _subpath(String key); Feature feature(String key, {bool enabled = true}) => - Feature(this, key, enabled: enabled); + Feature._(this, key, enabled: enabled); } class _RootFeature extends BaseFeature { _RootFeature._(); @override String get path => ''; + + @override + String _subpath(String key) => key; } class Feature extends BaseFeature { @@ -71,18 +75,41 @@ class Feature extends BaseFeature { final String key; final bool _defaultState; - Feature(this.parent, this.key, {bool enabled = true}) + Feature._(this.parent, this.key, {bool enabled = true}) : _defaultState = enabled; @override - String get path => '${parent.path}.$key'; + String get path => parent._subpath(key); + + @override + String _subpath(String key) => '$path.$key'; } final BaseFeature root = _RootFeature._(); typedef FeatureProvider = bool Function(Feature feature); +final featureFlagProvider = + StateNotifierProvider>( + (_) => FeatureFlagsNotifier()); + +class FeatureFlagsNotifier extends StateNotifier> { + FeatureFlagsNotifier() : super({}); + + void loadConfig(Map config) { + const falsey = [0, false, null]; + state = {for (final k in config.keys) k: !falsey.contains(config[k])}; + } +} + final featureProvider = Provider((ref) { - // TODO: Read file, check parents - return (feature) => feature._defaultState; + final featureMap = ref.watch(featureFlagProvider); + + bool isEnabled(BaseFeature feature) => switch (feature) { + _RootFeature() => true, + Feature() => isEnabled(feature.parent) && + (featureMap[feature.path] ?? feature._defaultState), + }; + + return isEnabled; }); diff --git a/lib/desktop/init.dart b/lib/desktop/init.dart index d7672be6..6cc5b21e 100755 --- a/lib/desktop/init.dart +++ b/lib/desktop/init.dart @@ -157,6 +157,11 @@ Future initialize(List argv) async { .toFilePath(); } + // Locate feature flags file + final featureFile = File(Uri.file(Platform.resolvedExecutable) + .resolve('features.json') + .toFilePath()); + final rpcFuture = _initHelper(exe!); _initLicenses(); @@ -218,15 +223,33 @@ Future initialize(List argv) async { ref.read(rpcProvider).valueOrNull?.setLogLevel(level); }); + // Load feature flags, if they exist + featureFile.exists().then( + (exists) async { + if (exists) { + try { + final featureConfig = + jsonDecode(await featureFile.readAsString()); + ref + .read(featureFlagProvider.notifier) + .loadConfig(featureConfig); + } catch (error) { + _log.error('Failed to parse feature flags', error); + } + } + }, + ); + // Initialize systray ref.watch(systrayProvider); // Show a loading or error page while the Helper isn't ready - return ref.watch(rpcProvider).when( - data: (data) => const MainPage(), - error: (error, stackTrace) => AppFailurePage(cause: error), - loading: () => _HelperWaiter(), - ); + return Consumer( + builder: (context, ref, child) => ref.watch(rpcProvider).when( + data: (data) => const MainPage(), + error: (error, stackTrace) => AppFailurePage(cause: error), + loading: () => _HelperWaiter(), + )); }), ), ), diff --git a/lib/piv/features.dart b/lib/piv/features.dart index 94394e38..a96629e6 100644 --- a/lib/piv/features.dart +++ b/lib/piv/features.dart @@ -20,12 +20,12 @@ final actions = piv.feature('actions'); final actionsPin = actions.feature('pin'); final actionsPuk = actions.feature('puk'); -final actionsManagementKey = actions.feature('managementKey', enabled: false); -final actionsReset = actions.feature('reset', enabled: false); +final actionsManagementKey = actions.feature('managementKey'); +final actionsReset = actions.feature('reset'); final slots = piv.feature('slots'); -final slotsGenerate = slots.feature('generate', enabled: false); -final slotsImport = slots.feature('import', enabled: false); +final slotsGenerate = slots.feature('generate'); +final slotsImport = slots.feature('import'); final slotsExport = slots.feature('export'); -final slotsDelete = slots.feature('delete', enabled: false); +final slotsDelete = slots.feature('delete'); From abbd3ae3b6f7be54e88fbab65a5830d38466686e Mon Sep 17 00:00:00 2001 From: Dain Nilsson Date: Fri, 6 Oct 2023 10:49:01 +0200 Subject: [PATCH 07/15] Add fido flags. --- lib/fido/features.dart | 32 +++++++++ lib/fido/views/actions.dart | 4 ++ lib/fido/views/credential_dialog.dart | 45 +++++++------ lib/fido/views/fingerprint_dialog.dart | 93 ++++++++++++++------------ lib/fido/views/key_actions.dart | 4 ++ lib/fido/views/locked_page.dart | 15 +++-- lib/fido/views/unlocked_page.dart | 69 +++++++++++-------- 7 files changed, 165 insertions(+), 97 deletions(-) create mode 100644 lib/fido/features.dart diff --git a/lib/fido/features.dart b/lib/fido/features.dart new file mode 100644 index 00000000..40742611 --- /dev/null +++ b/lib/fido/features.dart @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2023 Yubico. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import '../app/features.dart'; + +final actions = fido.feature('actions'); + +final actionsPin = actions.feature('pin'); +final actionsAddFingerprint = actions.feature('addFingerprint'); +final actionsReset = actions.feature('reset'); + +final credentials = fido.feature('credentials'); + +final credentialsDelete = credentials.feature('delete'); + +final fingerprints = fido.feature('fingerprints'); + +final fingerprintsEdit = fingerprints.feature('edit'); +final fingerprintsDelete = fingerprints.feature('delete'); diff --git a/lib/fido/views/actions.dart b/lib/fido/views/actions.dart index 6c07c47e..532249b5 100644 --- a/lib/fido/views/actions.dart +++ b/lib/fido/views/actions.dart @@ -20,11 +20,13 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import '../../app/models.dart'; import '../../app/shortcuts.dart'; import '../keys.dart' as keys; +import '../features.dart' as features; List buildFingerprintActions(AppLocalizations l10n) { return [ ActionItem( key: keys.editFingerintAction, + feature: features.fingerprintsEdit, icon: const Icon(Icons.edit), title: l10n.s_rename_fp, subtitle: l10n.l_rename_fp_desc, @@ -32,6 +34,7 @@ List buildFingerprintActions(AppLocalizations l10n) { ), ActionItem( key: keys.deleteFingerprintAction, + feature: features.fingerprintsDelete, actionStyle: ActionStyle.error, icon: const Icon(Icons.delete), title: l10n.s_delete_fingerprint, @@ -45,6 +48,7 @@ List buildCredentialActions(AppLocalizations l10n) { return [ ActionItem( key: keys.deleteCredentialAction, + feature: features.credentialsDelete, actionStyle: ActionStyle.error, icon: const Icon(Icons.delete), title: l10n.s_delete_passkey, diff --git a/lib/fido/views/credential_dialog.dart b/lib/fido/views/credential_dialog.dart index 7b9d1119..73fbbec0 100644 --- a/lib/fido/views/credential_dialog.dart +++ b/lib/fido/views/credential_dialog.dart @@ -7,6 +7,8 @@ import '../../app/shortcuts.dart'; import '../../app/state.dart'; import '../../app/views/fs_dialog.dart'; import '../../app/views/action_list.dart'; +import '../../core/state.dart'; +import '../features.dart' as features; import '../models.dart'; import 'actions.dart'; import 'delete_credential_dialog.dart'; @@ -26,29 +28,32 @@ class CredentialDialog extends ConsumerWidget { } final l10n = AppLocalizations.of(context)!; + final hasFeature = ref.watch(featureProvider); + return Actions( actions: { - DeleteIntent: CallbackAction(onInvoke: (_) async { - final withContext = ref.read(withContextProvider); - final bool? deleted = - await ref.read(withContextProvider)((context) async => - await showBlurDialog( - context: context, - builder: (context) => DeleteCredentialDialog( - node.path, - credential, - ), - ) ?? - false); + if (hasFeature(features.credentialsDelete)) + DeleteIntent: CallbackAction(onInvoke: (_) async { + final withContext = ref.read(withContextProvider); + final bool? deleted = + await ref.read(withContextProvider)((context) async => + await showBlurDialog( + context: context, + builder: (context) => DeleteCredentialDialog( + node.path, + credential, + ), + ) ?? + false); - // Pop the account dialog if deleted - if (deleted == true) { - await withContext((context) async { - Navigator.of(context).pop(); - }); - } - return deleted; - }), + // Pop the account dialog if deleted + if (deleted == true) { + await withContext((context) async { + Navigator.of(context).pop(); + }); + } + return deleted; + }), }, child: FocusScope( autofocus: true, diff --git a/lib/fido/views/fingerprint_dialog.dart b/lib/fido/views/fingerprint_dialog.dart index 398493cb..22e73b19 100644 --- a/lib/fido/views/fingerprint_dialog.dart +++ b/lib/fido/views/fingerprint_dialog.dart @@ -7,7 +7,9 @@ import '../../app/shortcuts.dart'; import '../../app/state.dart'; import '../../app/views/fs_dialog.dart'; import '../../app/views/action_list.dart'; +import '../../core/state.dart'; import '../models.dart'; +import '../features.dart' as features; import 'actions.dart'; import 'delete_fingerprint_dialog.dart'; import 'rename_fingerprint_dialog.dart'; @@ -27,53 +29,56 @@ class FingerprintDialog extends ConsumerWidget { } final l10n = AppLocalizations.of(context)!; + final hasFeature = ref.watch(featureProvider); return Actions( actions: { - EditIntent: CallbackAction(onInvoke: (_) async { - final withContext = ref.read(withContextProvider); - final Fingerprint? renamed = - await withContext((context) async => await showBlurDialog( - context: context, - builder: (context) => RenameFingerprintDialog( - node.path, - fingerprint, - ), - )); - if (renamed != null) { - // Replace the dialog with the renamed credential - await withContext((context) async { - Navigator.of(context).pop(); - await showBlurDialog( - context: context, - builder: (context) { - return FingerprintDialog(renamed); - }, - ); - }); - } - return renamed; - }), - DeleteIntent: CallbackAction(onInvoke: (_) async { - final withContext = ref.read(withContextProvider); - final bool? deleted = - await ref.read(withContextProvider)((context) async => - await showBlurDialog( - context: context, - builder: (context) => DeleteFingerprintDialog( - node.path, - fingerprint, - ), - ) ?? - false); + if (hasFeature(features.fingerprintsEdit)) + EditIntent: CallbackAction(onInvoke: (_) async { + final withContext = ref.read(withContextProvider); + final Fingerprint? renamed = + await withContext((context) async => await showBlurDialog( + context: context, + builder: (context) => RenameFingerprintDialog( + node.path, + fingerprint, + ), + )); + if (renamed != null) { + // Replace the dialog with the renamed credential + await withContext((context) async { + Navigator.of(context).pop(); + await showBlurDialog( + context: context, + builder: (context) { + return FingerprintDialog(renamed); + }, + ); + }); + } + return renamed; + }), + if (hasFeature(features.fingerprintsDelete)) + DeleteIntent: CallbackAction(onInvoke: (_) async { + final withContext = ref.read(withContextProvider); + final bool? deleted = + await ref.read(withContextProvider)((context) async => + await showBlurDialog( + context: context, + builder: (context) => DeleteFingerprintDialog( + node.path, + fingerprint, + ), + ) ?? + false); - // Pop the account dialog if deleted - if (deleted == true) { - await withContext((context) async { - Navigator.of(context).pop(); - }); - } - return deleted; - }), + // Pop the account dialog if deleted + if (deleted == true) { + await withContext((context) async { + Navigator.of(context).pop(); + }); + } + return deleted; + }), }, child: FocusScope( autofocus: true, diff --git a/lib/fido/views/key_actions.dart b/lib/fido/views/key_actions.dart index 381732fe..943ac5bb 100755 --- a/lib/fido/views/key_actions.dart +++ b/lib/fido/views/key_actions.dart @@ -23,6 +23,7 @@ import '../../app/views/fs_dialog.dart'; import '../../app/views/action_list.dart'; import '../models.dart'; import '../keys.dart' as keys; +import '../features.dart' as features; import 'add_fingerprint_dialog.dart'; import 'pin_dialog.dart'; import 'reset_dialog.dart'; @@ -48,6 +49,7 @@ Widget fidoBuildActions( children: [ ActionListItem( key: keys.addFingerprintAction, + feature: features.actionsAddFingerprint, actionStyle: ActionStyle.primary, icon: const Icon(Icons.fingerprint_outlined), title: l10n.s_add_fingerprint, @@ -76,6 +78,7 @@ Widget fidoBuildActions( children: [ ActionListItem( key: keys.managePinAction, + feature: features.actionsPin, icon: const Icon(Icons.pin_outlined), title: state.hasPin ? l10n.s_change_pin : l10n.s_set_pin, subtitle: state.hasPin @@ -96,6 +99,7 @@ Widget fidoBuildActions( }), ActionListItem( key: keys.resetAction, + feature: features.actionsReset, actionStyle: ActionStyle.error, icon: const Icon(Icons.delete_outline), title: l10n.s_reset_fido, diff --git a/lib/fido/views/locked_page.dart b/lib/fido/views/locked_page.dart index e7e0bd55..e57f8fb1 100755 --- a/lib/fido/views/locked_page.dart +++ b/lib/fido/views/locked_page.dart @@ -22,8 +22,10 @@ import '../../app/models.dart'; import '../../app/views/app_page.dart'; import '../../app/views/graphics.dart'; import '../../app/views/message_page.dart'; +import '../../core/state.dart'; import '../models.dart'; import '../state.dart'; +import '../features.dart' as features; import 'key_actions.dart'; class FidoLockedPage extends ConsumerWidget { @@ -35,6 +37,9 @@ class FidoLockedPage extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final l10n = AppLocalizations.of(context)!; + final hasFeature = ref.watch(featureProvider); + final hasActions = hasFeature(features.actions); + if (!state.hasPin) { if (state.bioEnroll != null) { return MessagePage( @@ -42,7 +47,7 @@ class FidoLockedPage extends ConsumerWidget { graphic: noFingerprints, header: l10n.s_no_fingerprints, message: l10n.l_set_pin_fingerprints, - keyActionsBuilder: _buildActions, + keyActionsBuilder: hasActions ? _buildActions : null, keyActionsBadge: fidoShowActionsNotifier(state), ); } else { @@ -53,7 +58,7 @@ class FidoLockedPage extends ConsumerWidget { ? l10n.l_no_discoverable_accounts : l10n.l_ready_to_use, message: l10n.l_optionally_set_a_pin, - keyActionsBuilder: _buildActions, + keyActionsBuilder: hasActions ? _buildActions : null, keyActionsBadge: fidoShowActionsNotifier(state), ); } @@ -65,7 +70,7 @@ class FidoLockedPage extends ConsumerWidget { graphic: manageAccounts, header: l10n.l_ready_to_use, message: l10n.l_register_sk_on_websites, - keyActionsBuilder: _buildActions, + keyActionsBuilder: hasActions ? _buildActions : null, keyActionsBadge: fidoShowActionsNotifier(state), ); } @@ -75,14 +80,14 @@ class FidoLockedPage extends ConsumerWidget { title: Text(l10n.s_webauthn), header: l10n.s_pin_change_required, message: l10n.l_pin_change_required_desc, - keyActionsBuilder: _buildActions, + keyActionsBuilder: hasActions ? _buildActions : null, keyActionsBadge: fidoShowActionsNotifier(state), ); } return AppPage( title: Text(l10n.s_webauthn), - keyActionsBuilder: _buildActions, + keyActionsBuilder: hasActions ? _buildActions : null, child: Column( children: [ _PinEntryForm(state, node), diff --git a/lib/fido/views/unlocked_page.dart b/lib/fido/views/unlocked_page.dart index 05395b53..9382e186 100755 --- a/lib/fido/views/unlocked_page.dart +++ b/lib/fido/views/unlocked_page.dart @@ -25,9 +25,11 @@ import '../../app/views/app_list_item.dart'; import '../../app/views/app_page.dart'; import '../../app/views/graphics.dart'; import '../../app/views/message_page.dart'; +import '../../core/state.dart'; import '../../widgets/list_title.dart'; import '../models.dart'; import '../state.dart'; +import '../features.dart' as features; import 'actions.dart'; import 'credential_dialog.dart'; import 'delete_credential_dialog.dart'; @@ -45,7 +47,9 @@ class FidoUnlockedPage extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final l10n = AppLocalizations.of(context)!; + final hasFeature = ref.watch(featureProvider); List children = []; + if (state.credMgmt) { final data = ref.watch(credentialProvider(node.path)).asData; if (data == null) { @@ -62,15 +66,16 @@ class FidoUnlockedPage extends ConsumerWidget { barrierColor: Colors.transparent, builder: (context) => CredentialDialog(cred), )), - DeleteIntent: CallbackAction( - onInvoke: (_) => showBlurDialog( - context: context, - builder: (context) => DeleteCredentialDialog( - node.path, - cred, + if (hasFeature(features.credentialsDelete)) + DeleteIntent: CallbackAction( + onInvoke: (_) => showBlurDialog( + context: context, + builder: (context) => DeleteCredentialDialog( + node.path, + cred, + ), ), ), - ), }, child: _CredentialListItem(cred), ))); @@ -95,33 +100,38 @@ class FidoUnlockedPage extends ConsumerWidget { barrierColor: Colors.transparent, builder: (context) => FingerprintDialog(fp), )), - EditIntent: CallbackAction( - onInvoke: (_) => showBlurDialog( - context: context, - builder: (context) => RenameFingerprintDialog( - node.path, - fp, - ), - )), - DeleteIntent: CallbackAction( - onInvoke: (_) => showBlurDialog( - context: context, - builder: (context) => DeleteFingerprintDialog( - node.path, - fp, - ), - )), + if (hasFeature(features.fingerprintsEdit)) + EditIntent: CallbackAction( + onInvoke: (_) => showBlurDialog( + context: context, + builder: (context) => RenameFingerprintDialog( + node.path, + fp, + ), + )), + if (hasFeature(features.fingerprintsDelete)) + DeleteIntent: CallbackAction( + onInvoke: (_) => showBlurDialog( + context: context, + builder: (context) => DeleteFingerprintDialog( + node.path, + fp, + ), + )), }, child: _FingerprintListItem(fp), ))); } } + final hasActions = ref.watch(featureProvider)(features.actions); + if (children.isNotEmpty) { return AppPage( title: Text(l10n.s_webauthn), - keyActionsBuilder: (context) => - fidoBuildActions(context, node, state, nFingerprints), + keyActionsBuilder: hasActions + ? (context) => fidoBuildActions(context, node, state, nFingerprints) + : null, keyActionsBadge: fidoShowActionsNotifier(state), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: children), @@ -134,8 +144,9 @@ class FidoUnlockedPage extends ConsumerWidget { graphic: noFingerprints, header: l10n.s_no_fingerprints, message: l10n.l_add_one_or_more_fps, - keyActionsBuilder: (context) => - fidoBuildActions(context, node, state, 0), + keyActionsBuilder: hasActions + ? (context) => fidoBuildActions(context, node, state, 0) + : null, keyActionsBadge: fidoShowActionsNotifier(state), ); } @@ -145,7 +156,9 @@ class FidoUnlockedPage extends ConsumerWidget { graphic: manageAccounts, header: l10n.l_no_discoverable_accounts, message: l10n.l_register_sk_on_websites, - keyActionsBuilder: (context) => fidoBuildActions(context, node, state, 0), + keyActionsBuilder: hasActions + ? (context) => fidoBuildActions(context, node, state, 0) + : null, keyActionsBadge: fidoShowActionsNotifier(state), ); } From b84801c83e8143f43ccd953cb7e9fe7264817185 Mon Sep 17 00:00:00 2001 From: Dain Nilsson Date: Fri, 6 Oct 2023 11:18:30 +0200 Subject: [PATCH 08/15] Don't open popup if empty. --- lib/app/views/app_list_item.dart | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/lib/app/views/app_list_item.dart b/lib/app/views/app_list_item.dart index f3f47b90..93285046 100644 --- a/lib/app/views/app_list_item.dart +++ b/lib/app/views/app_list_item.dart @@ -74,14 +74,17 @@ class _AppListItemState extends ConsumerState { onSecondaryTapDown: buildPopupActions == null ? null : (details) { - showPopupMenu( - context, - details.globalPosition, - buildPopupActions(context) - .where((action) => - action.feature == null || hasFeature(action.feature!)) - .toList(), - ); + final menuItems = buildPopupActions(context) + .where((action) => + action.feature == null || hasFeature(action.feature!)) + .toList(); + if (menuItems.isNotEmpty) { + showPopupMenu( + context, + details.globalPosition, + menuItems, + ); + } }, onTap: () { if (isDesktop) { From 0647b9583f7dc947a977fbf5bb6448ebe7e831b0 Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Fri, 27 Oct 2023 10:10:23 +0200 Subject: [PATCH 09/15] preserve connection when paused --- .../com/yubico/authenticator/ActivityUtil.kt | 4 + .../com/yubico/authenticator/MainActivity.kt | 112 +++++++++++------- .../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, 145 insertions(+), 82 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 1f093f34..a3f7dc55 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 48f282e8..ed28131a 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) - } + 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) + } - // Handle existing tag when launched from NDEF - val tag = intent.parcelableExtra(NfcAdapter.EXTRA_TAG) - if (tag != null) { - intent.removeExtra(NfcAdapter.EXTRA_TAG) + // 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() + 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 - } - } + 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 8612e717..4e2275c6 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 fb7cbfed..d1593249 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 7a536f1e..de7d2bf5 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 9b47c8d9..eb2b39cd 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 274ba92a..e4232097 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 9ca1e0c8..f1a00985 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 0ce8092a..d9c315eb 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 16c7eccd..3b69fb92 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( From 311a184cd57772c9468c54d1a8ec43b6f9937bec Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Fri, 27 Oct 2023 10:27:11 +0200 Subject: [PATCH 10/15] bump dependencies --- .../flutter_plugins/qrscanner_zxing/android/build.gradle | 7 +++---- .../android/gradle/wrapper/gradle-wrapper.properties | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/android/flutter_plugins/qrscanner_zxing/android/build.gradle b/android/flutter_plugins/qrscanner_zxing/android/build.gradle index f7f97f31..0de2e97e 100644 --- a/android/flutter_plugins/qrscanner_zxing/android/build.gradle +++ b/android/flutter_plugins/qrscanner_zxing/android/build.gradle @@ -49,12 +49,11 @@ android { } dependencies { - def camerax_version = "1.2.3" + def camerax_version = "1.3.0" implementation "androidx.camera:camera-lifecycle:${camerax_version}" implementation "androidx.camera:camera-view:${camerax_version}" implementation "androidx.camera:camera-camera2:${camerax_version}" - - //noinspection GradleDependency - implementation "com.google.zxing:core:3.3.3" + + implementation "com.google.zxing:core:3.5.2" implementation "com.google.zxing:android-core:3.3.0" } diff --git a/android/flutter_plugins/qrscanner_zxing/android/gradle/wrapper/gradle-wrapper.properties b/android/flutter_plugins/qrscanner_zxing/android/gradle/wrapper/gradle-wrapper.properties index 2e0997a2..35f231b5 100644 --- a/android/flutter_plugins/qrscanner_zxing/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/flutter_plugins/qrscanner_zxing/android/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Mon Oct 16 08:48:17 CEST 2023 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists From 80a4215706646de2e48c73e532d06fa87114bc02 Mon Sep 17 00:00:00 2001 From: Dennis Fokin Date: Thu, 26 Oct 2023 14:02:48 +0200 Subject: [PATCH 11/15] a11y: Fix tooltip for IconButtons --- lib/fido/views/locked_page.dart | 1 + lib/l10n/app_en.arb | 6 ++++++ lib/oath/views/add_account_page.dart | 3 +++ lib/oath/views/unlock_form.dart | 3 +++ lib/piv/views/pin_dialog.dart | 1 + lib/widgets/responsive_dialog.dart | 1 + 6 files changed, 15 insertions(+) diff --git a/lib/fido/views/locked_page.dart b/lib/fido/views/locked_page.dart index e57f8fb1..c90a151d 100755 --- a/lib/fido/views/locked_page.dart +++ b/lib/fido/views/locked_page.dart @@ -182,6 +182,7 @@ class _PinEntryFormState extends ConsumerState<_PinEntryForm> { _isObscure = !_isObscure; }); }, + tooltip: _isObscure ? l10n.s_show_pin : l10n.s_hide_pin, ), ), onChanged: (value) { diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index a7b893bf..8a528952 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -72,6 +72,8 @@ "s_configure_yk": "Configure YubiKey", "s_please_wait": "Please wait\u2026", "s_secret_key": "Secret key", + "s_show_secret_key": "Show secret key", + "s_hide_secret_key": "Hide secret key", "s_private_key": "Private key", "s_invalid_length": "Invalid length", "s_require_touch": "Require touch", @@ -182,6 +184,8 @@ "s_set_pin": "Set PIN", "s_change_pin": "Change PIN", "s_change_puk": "Change PUK", + "s_show_pin": "Show PIN", + "s_hide_pin": "Hide PIN", "s_current_pin": "Current PIN", "s_current_puk": "Current PUK", "s_new_pin": "New PIN", @@ -256,6 +260,8 @@ "s_manage_password": "Manage password", "s_set_password": "Set password", "s_password_set": "Password set", + "s_show_password": "Show password", + "s_hide_password": "Hide password", "l_optional_password_protection": "Optional password protection", "s_new_password": "New password", "s_current_password": "Current password", diff --git a/lib/oath/views/add_account_page.dart b/lib/oath/views/add_account_page.dart index 0bbf8385..cab5bf39 100755 --- a/lib/oath/views/add_account_page.dart +++ b/lib/oath/views/add_account_page.dart @@ -438,6 +438,9 @@ class _OathAddAccountPageState extends ConsumerState { _isObscure = !_isObscure; }); }, + tooltip: _isObscure + ? l10n.s_show_secret_key + : l10n.s_hide_secret_key, ), border: const OutlineInputBorder(), prefixIcon: const Icon(Icons.key_outlined), diff --git a/lib/oath/views/unlock_form.dart b/lib/oath/views/unlock_form.dart index d4223053..532b2982 100755 --- a/lib/oath/views/unlock_form.dart +++ b/lib/oath/views/unlock_form.dart @@ -94,6 +94,9 @@ class _UnlockFormState extends ConsumerState { _isObscure = !_isObscure; }); }, + tooltip: _isObscure + ? l10n.s_show_password + : l10n.s_hide_password, ), ), onChanged: (_) => setState(() { diff --git a/lib/piv/views/pin_dialog.dart b/lib/piv/views/pin_dialog.dart index 7f849e5f..0199c05b 100644 --- a/lib/piv/views/pin_dialog.dart +++ b/lib/piv/views/pin_dialog.dart @@ -110,6 +110,7 @@ class _PinDialogState extends ConsumerState { _isObscure = !_isObscure; }); }, + tooltip: _isObscure ? l10n.s_show_pin : l10n.s_hide_pin, ), ), textInputAction: TextInputAction.next, diff --git a/lib/widgets/responsive_dialog.dart b/lib/widgets/responsive_dialog.dart index dd500f15..eed921bb 100755 --- a/lib/widgets/responsive_dialog.dart +++ b/lib/widgets/responsive_dialog.dart @@ -54,6 +54,7 @@ class _ResponsiveDialogState extends State { title: widget.title, actions: widget.actions, leading: IconButton( + tooltip: AppLocalizations.of(context)!.s_close, icon: const Icon(Icons.close), onPressed: widget.allowCancel ? () { From 6a32cdc8354a72a499b739d5e18540f899d93112 Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Mon, 30 Oct 2023 15:32:55 +0100 Subject: [PATCH 12/15] Try to find QR code in several versions of input --- .../qrscanner_zxing/android/build.gradle | 1 + .../qrscanner_zxing/QrScanner.kt | 103 ++++++++++++++++-- 2 files changed, 97 insertions(+), 7 deletions(-) diff --git a/android/flutter_plugins/qrscanner_zxing/android/build.gradle b/android/flutter_plugins/qrscanner_zxing/android/build.gradle index 0de2e97e..4e3acaa4 100644 --- a/android/flutter_plugins/qrscanner_zxing/android/build.gradle +++ b/android/flutter_plugins/qrscanner_zxing/android/build.gradle @@ -45,6 +45,7 @@ android { defaultConfig { minSdkVersion 21 + targetSdk 34 } } diff --git a/android/flutter_plugins/qrscanner_zxing/android/src/main/kotlin/com/yubico/authenticator/flutter_plugins/qrscanner_zxing/QrScanner.kt b/android/flutter_plugins/qrscanner_zxing/android/src/main/kotlin/com/yubico/authenticator/flutter_plugins/qrscanner_zxing/QrScanner.kt index 7ea6902d..80eeed56 100644 --- a/android/flutter_plugins/qrscanner_zxing/android/src/main/kotlin/com/yubico/authenticator/flutter_plugins/qrscanner_zxing/QrScanner.kt +++ b/android/flutter_plugins/qrscanner_zxing/android/src/main/kotlin/com/yubico/authenticator/flutter_plugins/qrscanner_zxing/QrScanner.kt @@ -2,6 +2,10 @@ package com.yubico.authenticator.flutter_plugins.qrscanner_zxing import android.graphics.Bitmap import android.graphics.BitmapFactory +import android.graphics.Canvas +import android.graphics.ColorMatrix +import android.graphics.ColorMatrixColorFilter +import android.graphics.Paint import android.util.Log import com.google.zxing.BarcodeFormat import com.google.zxing.BinaryBitmap @@ -14,26 +18,48 @@ import com.google.zxing.common.HybridBinarizer object QrCodeScanner { private val qrCodeScanner = MultiFormatReader().also { - it.setHints(mapOf(DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE))) + it.setHints( + mapOf( + DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE), + DecodeHintType.ALSO_INVERTED to true, + DecodeHintType.TRY_HARDER to true + ) + ) } fun decodeFromBinaryBitmap(binaryBitmap: BinaryBitmap): String { - val result = qrCodeScanner.decode(binaryBitmap) + val result = qrCodeScanner.decodeWithState(binaryBitmap) return result.text } - fun decodeFromBytes(byteArray: ByteArray): String? { + private fun decodeFromBytes( + byteArray: ByteArray, + sampleSize: Int, + rotation: Int, + boostContrast: Boolean + ): String? { var bitmap: Bitmap? = null try { - Log.v(TAG, "Received ${byteArray.size} bytes") - val options = BitmapFactory.Options() - bitmap = BitmapFactory.decodeByteArray(byteArray, 0, byteArray.size, options) + Log.v( + TAG, + "Decoding with sampleSize $sampleSize, rotation $rotation, boostContrast: $boostContrast" + ) + bitmap = getScaledBitmap(byteArray, sampleSize, boostContrast) bitmap?.let { val pixels = IntArray(it.allocationByteCount) it.getPixels(pixels, 0, it.width, 0, 0, it.width, it.height) + val luminanceSource = RGBLuminanceSource(it.width, it.height, pixels) - val binaryBitmap = BinaryBitmap(HybridBinarizer(luminanceSource)) + + var binaryBitmap = BinaryBitmap(HybridBinarizer(luminanceSource)) + + if (rotation in 1..3 && binaryBitmap.isRotateSupported) { + for (r in 1..rotation) { + binaryBitmap = binaryBitmap.rotateCounterClockwise() + } + } + val scanResult = decodeFromBinaryBitmap(binaryBitmap) Log.v(TAG, "Scan result: $scanResult") return scanResult @@ -54,5 +80,68 @@ object QrCodeScanner { } } + fun decodeFromBytes(byteArray: ByteArray): String? { + for (boostContrast in sequenceOf(false, true)) { + for (rotation in 0 until 4) { + for (sampleSize in sequenceOf(1, 4, 8, 12)) { + val code = decodeFromBytes(byteArray, sampleSize, rotation, boostContrast) + if (code != null) { + return code + } + } + } + } + return null + } + + private fun getScaledBitmap( + byteArray: ByteArray, + sampleSize: Int, + boostContrast: Boolean + ): Bitmap? { + var inputBitmap: Bitmap? = null + try { + val options = BitmapFactory.Options() + options.inSampleSize = sampleSize + inputBitmap = BitmapFactory.decodeByteArray(byteArray, 0, byteArray.size, options) + inputBitmap?.let { + val drawingBitmap = + Bitmap.createBitmap(it.width, it.height, Bitmap.Config.ARGB_8888) + + val canvas = Canvas(drawingBitmap) + val cm = ColorMatrix() + if (boostContrast) { + + val contrast = 0f + val scale = contrast + 1f + val translate = (-.5f * scale + .5f) * 255f + + cm.setSaturation(0f) + cm.postConcat( + ColorMatrix( + floatArrayOf( + scale, 0f, 0f, 0f, translate, + 0f, scale, 0f, 0f, translate, + 0f, 0f, scale, 0f, translate, + 0f, 0f, 0f, scale, 0f + ) + ) + ) + } + + val paint = Paint() + paint.setColorFilter(ColorMatrixColorFilter(cm)) + canvas.drawBitmap(it, 0f, 0f, paint) + + return drawingBitmap + } + } finally { + inputBitmap?.recycle() + } + + return null + } + + private const val TAG = "QRScanner" } \ No newline at end of file From 2faf75b40e62e8204e49b469a7f24d1540ccb421 Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Mon, 30 Oct 2023 16:53:17 +0100 Subject: [PATCH 13/15] use only sampleSize parameter --- .../qrscanner_zxing/QrScanner.kt | 87 +++---------------- 1 file changed, 10 insertions(+), 77 deletions(-) diff --git a/android/flutter_plugins/qrscanner_zxing/android/src/main/kotlin/com/yubico/authenticator/flutter_plugins/qrscanner_zxing/QrScanner.kt b/android/flutter_plugins/qrscanner_zxing/android/src/main/kotlin/com/yubico/authenticator/flutter_plugins/qrscanner_zxing/QrScanner.kt index 80eeed56..16718093 100644 --- a/android/flutter_plugins/qrscanner_zxing/android/src/main/kotlin/com/yubico/authenticator/flutter_plugins/qrscanner_zxing/QrScanner.kt +++ b/android/flutter_plugins/qrscanner_zxing/android/src/main/kotlin/com/yubico/authenticator/flutter_plugins/qrscanner_zxing/QrScanner.kt @@ -2,10 +2,6 @@ package com.yubico.authenticator.flutter_plugins.qrscanner_zxing import android.graphics.Bitmap import android.graphics.BitmapFactory -import android.graphics.Canvas -import android.graphics.ColorMatrix -import android.graphics.ColorMatrixColorFilter -import android.graphics.Paint import android.util.Log import com.google.zxing.BarcodeFormat import com.google.zxing.BinaryBitmap @@ -34,17 +30,17 @@ object QrCodeScanner { private fun decodeFromBytes( byteArray: ByteArray, - sampleSize: Int, - rotation: Int, - boostContrast: Boolean + sampleSize: Int ): String? { var bitmap: Bitmap? = null try { Log.v( TAG, - "Decoding with sampleSize $sampleSize, rotation $rotation, boostContrast: $boostContrast" + "Decoding with sampleSize $sampleSize" ) - bitmap = getScaledBitmap(byteArray, sampleSize, boostContrast) + val options = BitmapFactory.Options() + options.inSampleSize = sampleSize + bitmap = BitmapFactory.decodeByteArray(byteArray, 0, byteArray.size, options) bitmap?.let { val pixels = IntArray(it.allocationByteCount) it.getPixels(pixels, 0, it.width, 0, 0, it.width, it.height) @@ -52,14 +48,7 @@ object QrCodeScanner { val luminanceSource = RGBLuminanceSource(it.width, it.height, pixels) - var binaryBitmap = BinaryBitmap(HybridBinarizer(luminanceSource)) - - if (rotation in 1..3 && binaryBitmap.isRotateSupported) { - for (r in 1..rotation) { - binaryBitmap = binaryBitmap.rotateCounterClockwise() - } - } - + val binaryBitmap = BinaryBitmap(HybridBinarizer(luminanceSource)) val scanResult = decodeFromBinaryBitmap(binaryBitmap) Log.v(TAG, "Scan result: $scanResult") return scanResult @@ -81,67 +70,11 @@ object QrCodeScanner { } fun decodeFromBytes(byteArray: ByteArray): String? { - for (boostContrast in sequenceOf(false, true)) { - for (rotation in 0 until 4) { - for (sampleSize in sequenceOf(1, 4, 8, 12)) { - val code = decodeFromBytes(byteArray, sampleSize, rotation, boostContrast) - if (code != null) { - return code - } - } - } - } - return null + return decodeFromBytes(byteArray, 1) + ?: decodeFromBytes(byteArray, 4) + ?: decodeFromBytes(byteArray, 8) + ?: decodeFromBytes(byteArray, 12) } - private fun getScaledBitmap( - byteArray: ByteArray, - sampleSize: Int, - boostContrast: Boolean - ): Bitmap? { - var inputBitmap: Bitmap? = null - try { - val options = BitmapFactory.Options() - options.inSampleSize = sampleSize - inputBitmap = BitmapFactory.decodeByteArray(byteArray, 0, byteArray.size, options) - inputBitmap?.let { - val drawingBitmap = - Bitmap.createBitmap(it.width, it.height, Bitmap.Config.ARGB_8888) - - val canvas = Canvas(drawingBitmap) - val cm = ColorMatrix() - if (boostContrast) { - - val contrast = 0f - val scale = contrast + 1f - val translate = (-.5f * scale + .5f) * 255f - - cm.setSaturation(0f) - cm.postConcat( - ColorMatrix( - floatArrayOf( - scale, 0f, 0f, 0f, translate, - 0f, scale, 0f, 0f, translate, - 0f, 0f, scale, 0f, translate, - 0f, 0f, 0f, scale, 0f - ) - ) - ) - } - - val paint = Paint() - paint.setColorFilter(ColorMatrixColorFilter(cm)) - canvas.drawBitmap(it, 0f, 0f, paint) - - return drawingBitmap - } - } finally { - inputBitmap?.recycle() - } - - return null - } - - private const val TAG = "QRScanner" } \ No newline at end of file From fefee65c8e0204c479fddabe7af52805ccff6008 Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Mon, 30 Oct 2023 17:03:12 +0100 Subject: [PATCH 14/15] add Read from file to no-permissions view --- .../qr_scanner_permissions_view.dart | 77 ++++++++++++------- 1 file changed, 51 insertions(+), 26 deletions(-) diff --git a/lib/android/qr_scanner/qr_scanner_permissions_view.dart b/lib/android/qr_scanner/qr_scanner_permissions_view.dart index 935c0520..2f2d4e85 100644 --- a/lib/android/qr_scanner/qr_scanner_permissions_view.dart +++ b/lib/android/qr_scanner/qr_scanner_permissions_view.dart @@ -16,6 +16,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:yubico_authenticator/android/qr_scanner/qr_scanner_provider.dart'; import 'qr_scanner_scan_status.dart'; @@ -47,45 +48,69 @@ class QRScannerPermissionsUI extends StatelessWidget { style: const TextStyle(color: Colors.white), textAlign: TextAlign.center, ), - Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - Column( + Column( + children: [ + Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Column( + children: [ + Text( + l10n.q_want_to_scan, + textScaleFactor: 0.7, + style: const TextStyle(color: Colors.white), + ), + OutlinedButton( + onPressed: () { + onPermissionRequest(); + }, + child: Text( + l10n.s_review_permissions, + style: const TextStyle(color: Colors.white), + )), + ], + ) + ]), + const SizedBox(height: 16), + Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Column(children: [ + Text( + l10n.q_have_account_info, + textScaleFactor: 0.7, + style: const TextStyle(color: Colors.white), + ), + ]) + ]), + Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ - Text( - l10n.q_have_account_info, - textScaleFactor: 0.7, - style: const TextStyle(color: Colors.white), - ), OutlinedButton( onPressed: () { - Navigator.of(context).pop(''); + Navigator.of(context).pop( + AndroidQrScanner.kQrScannerRequestManualEntry); }, child: Text( l10n.s_enter_manually, style: const TextStyle(color: Colors.white), )), - ], - ), - Column( - children: [ - Text( - l10n.q_want_to_scan, - textScaleFactor: 0.7, - style: const TextStyle(color: Colors.white), - ), + const SizedBox(width: 16), OutlinedButton( onPressed: () { - onPermissionRequest(); + Navigator.of(context).pop( + AndroidQrScanner.kQrScannerRequestReadFromFile); }, child: Text( - l10n.s_review_permissions, + l10n.s_read_from_file, style: const TextStyle(color: Colors.white), - )), - ], - ) - ]) + )) + ]), + ], + ) ], ), ), From af828dde4efcd370b0f511c1e73a8276084ba1b1 Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Tue, 31 Oct 2023 09:03:53 +0100 Subject: [PATCH 15/15] pre-review cleanup --- .../qr_scanner/qr_scanner_provider.dart | 2 +- .../qr_scanner/qr_scanner_ui_view.dart | 54 +++++++++---------- lib/l10n/app_en.arb | 1 + lib/oath/views/add_account_page.dart | 5 +- 4 files changed, 29 insertions(+), 33 deletions(-) diff --git a/lib/android/qr_scanner/qr_scanner_provider.dart b/lib/android/qr_scanner/qr_scanner_provider.dart index e4232097..50e2397e 100644 --- a/lib/android/qr_scanner/qr_scanner_provider.dart +++ b/lib/android/qr_scanner/qr_scanner_provider.dart @@ -93,7 +93,7 @@ class AndroidQrScanner implements QrScanner { allowMultiple: false, lockParentWindow: true, withData: true, - dialogTitle: 'Select file with QR code'); + dialogTitle: l10n.l_qr_select_file); if (result == null || !result.isSinglePick) { // no result diff --git a/lib/android/qr_scanner/qr_scanner_ui_view.dart b/lib/android/qr_scanner/qr_scanner_ui_view.dart index 8e74b5b1..6fc9d948 100644 --- a/lib/android/qr_scanner/qr_scanner_ui_view.dart +++ b/lib/android/qr_scanner/qr_scanner_ui_view.dart @@ -16,13 +16,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:yubico_authenticator/android/qr_scanner/qr_scanner_provider.dart'; import '../keys.dart' as keys; import 'qr_scanner_scan_status.dart'; -class QRScannerUI extends ConsumerWidget { +class QRScannerUI extends StatelessWidget { final ScanStatus status; final Size screenSize; final GlobalKey overlayWidgetKey; @@ -34,7 +33,7 @@ class QRScannerUI extends ConsumerWidget { required this.overlayWidgetKey}); @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; return Stack( @@ -75,32 +74,29 @@ class QRScannerUI extends ConsumerWidget { textScaleFactor: 0.7, style: const TextStyle(color: Colors.white), ), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - OutlinedButton( - onPressed: () { - Navigator.of(context).pop( - AndroidQrScanner.kQrScannerRequestManualEntry); - }, - key: keys.manualEntryButton, - child: Text( - l10n.s_enter_manually, - style: const TextStyle(color: Colors.white), - )), - const SizedBox(width: 16), - OutlinedButton( - onPressed: () { - Navigator.of(context).pop( - AndroidQrScanner.kQrScannerRequestReadFromFile); - }, - key: keys.readFromImage, - child: Text( - l10n.s_read_from_file, - style: const TextStyle(color: Colors.white), - )) - ], - ), + Row(mainAxisAlignment: MainAxisAlignment.center, children: [ + OutlinedButton( + onPressed: () { + Navigator.of(context).pop( + AndroidQrScanner.kQrScannerRequestManualEntry); + }, + key: keys.manualEntryButton, + child: Text( + l10n.s_enter_manually, + style: const TextStyle(color: Colors.white), + )), + const SizedBox(width: 16), + OutlinedButton( + onPressed: () { + Navigator.of(context).pop( + AndroidQrScanner.kQrScannerRequestReadFromFile); + }, + key: keys.readFromImage, + child: Text( + l10n.s_read_from_file, + style: const TextStyle(color: Colors.white), + )) + ]) ], ), const SizedBox(height: 16) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index d5a77092..2e2698a2 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -506,6 +506,7 @@ "l_qr_scanned": "Scanned QR code", "l_invalid_qr": "Invalid QR code", "l_qr_not_found": "No QR code found", + "l_qr_select_file": "Select file with QR code", "l_qr_not_read": "Failed reading QR code: {message}", "@l_qr_not_read" : { "placeholders": { diff --git a/lib/oath/views/add_account_page.dart b/lib/oath/views/add_account_page.dart index a6887c09..0bbf8385 100755 --- a/lib/oath/views/add_account_page.dart +++ b/lib/oath/views/add_account_page.dart @@ -30,10 +30,10 @@ import '../../app/message.dart'; import '../../app/models.dart'; import '../../app/state.dart'; import '../../app/views/user_interaction.dart'; -import '../../core/state.dart'; -import '../../desktop/models.dart'; import '../../exception/apdu_exception.dart'; import '../../exception/cancellation_exception.dart'; +import '../../core/state.dart'; +import '../../desktop/models.dart'; import '../../management/models.dart'; import '../../widgets/choice_filter_chip.dart'; import '../../widgets/file_drop_target.dart'; @@ -56,7 +56,6 @@ class OathAddAccountPage extends ConsumerStatefulWidget { final OathState? state; final List? credentials; final CredentialData? credentialData; - const OathAddAccountPage( this.devicePath, this.state, {