Merge branch 'main' into translate

This commit is contained in:
Daviteusz 2023-11-01 22:44:24 +01:00
commit 65e95dbd7a
No known key found for this signature in database
GPG Key ID: 8B3614C4A2E3EB66
60 changed files with 1193 additions and 478 deletions

View File

@ -40,6 +40,7 @@ class ActivityUtil(private val activity: Activity) {
MAIN_ACTIVITY_ALIAS, MAIN_ACTIVITY_ALIAS,
PackageManager.COMPONENT_ENABLED_STATE_ENABLED 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, MAIN_ACTIVITY_ALIAS,
PackageManager.COMPONENT_ENABLED_STATE_DEFAULT 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, NDEF_ACTIVITY_ALIAS,
PackageManager.COMPONENT_ENABLED_STATE_DEFAULT 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, NDEF_ACTIVITY_ALIAS,
PackageManager.COMPONENT_ENABLED_STATE_DISABLED 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) { private fun setState(aliasName: String, enabledState: Int) {

View File

@ -68,6 +68,8 @@ class MainActivity : FlutterFragmentActivity() {
private lateinit var yubikit: YubiKitManager private lateinit var yubikit: YubiKitManager
private var preserveConnectionOnPause: Boolean = false
// receives broadcasts when QR Scanner camera is closed // receives broadcasts when QR Scanner camera is closed
private val qrScannerCameraClosedBR = QRScannerCameraClosedBR() private val qrScannerCameraClosedBR = QRScannerCameraClosedBR()
private val nfcAdapterStateChangeBR = NfcAdapterStateChangedBR() private val nfcAdapterStateChangeBR = NfcAdapterStateChangedBR()
@ -158,8 +160,12 @@ class MainActivity : FlutterFragmentActivity() {
appPreferences.unregisterListener(sharedPreferencesListener) appPreferences.unregisterListener(sharedPreferencesListener)
stopUsbDiscovery() if (!preserveConnectionOnPause) {
stopNfcDiscovery() stopUsbDiscovery()
stopNfcDiscovery()
} else {
logger.debug("Any existing connections are preserved")
}
if (!appPreferences.openAppOnUsb) { if (!appPreferences.openAppOnUsb) {
activityUtil.disableSystemUsbDiscovery() activityUtil.disableSystemUsbDiscovery()
@ -179,62 +185,68 @@ class MainActivity : FlutterFragmentActivity() {
activityUtil.enableSystemUsbDiscovery() activityUtil.enableSystemUsbDiscovery()
// Handle opening through otpauth:// link if (!preserveConnectionOnPause) {
val intentData = intent.data // Handle opening through otpauth:// link
if (intentData != null && val intentData = intent.data
(intentData.scheme == "otpauth" || if (intentData != null &&
intentData.scheme == "otpauth-migration") (intentData.scheme == "otpauth" ||
) { intentData.scheme == "otpauth-migration")
intent.data = null ) {
appLinkMethodChannel.handleUri(intentData) intent.data = null
} appLinkMethodChannel.handleUri(intentData)
}
// Handle existing tag when launched from NDEF // Handle existing tag when launched from NDEF
val tag = intent.parcelableExtra<Tag>(NfcAdapter.EXTRA_TAG) val tag = intent.parcelableExtra<Tag>(NfcAdapter.EXTRA_TAG)
if (tag != null) { if (tag != null) {
intent.removeExtra(NfcAdapter.EXTRA_TAG) intent.removeExtra(NfcAdapter.EXTRA_TAG)
val executor = Executors.newSingleThreadExecutor() val executor = Executors.newSingleThreadExecutor()
val device = NfcYubiKeyDevice(tag, nfcConfiguration.timeout, executor) val device = NfcYubiKeyDevice(tag, nfcConfiguration.timeout, executor)
lifecycleScope.launch { lifecycleScope.launch {
try { try {
contextManager?.processYubiKey(device) contextManager?.processYubiKey(device)
device.remove { device.remove {
executor.shutdown() executor.shutdown()
startNfcDiscovery() 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 val usbManager = getSystemService(Context.USB_SERVICE) as UsbManager
if (UsbManager.ACTION_USB_DEVICE_ATTACHED == intent.action) { if (UsbManager.ACTION_USB_DEVICE_ATTACHED == intent.action) {
val device = intent.parcelableExtra<UsbDevice>(UsbManager.EXTRA_DEVICE) val device = intent.parcelableExtra<UsbDevice>(UsbManager.EXTRA_DEVICE)
if (device != null) { if (device != null) {
// start the USB discover only if the user approved the app to use the device // start the USB discover only if the user approved the app to use the device
if (usbManager.hasPermission(device)) { if (usbManager.hasPermission(device)) {
startUsbDiscovery() 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 { } else {
// if any YubiKeys are connected, use them directly logger.debug("Resume with preserved connection")
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
}
}
} }
appPreferences.registerListener(sharedPreferencesListener) appPreferences.registerListener(sharedPreferencesListener)
preserveConnectionOnPause = false
} }
override fun onMultiWindowModeChanged(isInMultiWindowMode: Boolean, newConfig: Configuration) { override fun onMultiWindowModeChanged(isInMultiWindowMode: Boolean, newConfig: Configuration) {
@ -374,6 +386,14 @@ class MainActivity : FlutterFragmentActivity() {
"getAndroidSdkVersion" -> result.success( "getAndroidSdkVersion" -> result.success(
Build.VERSION.SDK_INT Build.VERSION.SDK_INT
) )
"preserveConnectionOnPause" -> {
preserveConnectionOnPause = true
result.success(
true
)
}
"setPrimaryClip" -> { "setPrimaryClip" -> {
val toClipboard = methodCall.argument<String>("toClipboard") val toClipboard = methodCall.argument<String>("toClipboard")
val isSensitive = methodCall.argument<Boolean>("isSensitive") val isSensitive = methodCall.argument<Boolean>("isSensitive")

View File

@ -176,7 +176,7 @@ class OathManager(
delay(delayMs) delay(delayMs)
} }
val currentState = lifecycleOwner.lifecycle.currentState val currentState = lifecycleOwner.lifecycle.currentState
if (currentState.isAtLeast(Lifecycle.State.RESUMED)) { if (currentState.isAtLeast(Lifecycle.State.STARTED)) {
requestRefresh() requestRefresh()
} else { } else {
logger.debug( logger.debug(

View File

@ -45,16 +45,16 @@ android {
defaultConfig { defaultConfig {
minSdkVersion 21 minSdkVersion 21
targetSdk 34
} }
} }
dependencies { dependencies {
def camerax_version = "1.2.3" def camerax_version = "1.3.0"
implementation "androidx.camera:camera-lifecycle:${camerax_version}" implementation "androidx.camera:camera-lifecycle:${camerax_version}"
implementation "androidx.camera:camera-view:${camerax_version}" implementation "androidx.camera:camera-view:${camerax_version}"
implementation "androidx.camera:camera-camera2:${camerax_version}" implementation "androidx.camera:camera-camera2:${camerax_version}"
//noinspection GradleDependency implementation "com.google.zxing:core:3.5.2"
implementation "com.google.zxing:core:3.3.3"
implementation "com.google.zxing:android-core:3.3.0" implementation "com.google.zxing:android-core:3.3.0"
} }

View File

@ -1,6 +1,6 @@
#Fri Jun 23 08:50:38 CEST 2017 #Mon Oct 16 08:48:17 CEST 2023
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-bin.zip
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-all.zip

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2022 Yubico. * Copyright (C) 2022-2023 Yubico.
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with 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.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.net.Uri import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.provider.Settings import android.provider.Settings
import android.util.Log import android.util.Log
import android.util.Size import android.util.Size
@ -36,7 +34,9 @@ import androidx.core.content.ContextCompat
import androidx.core.content.ContextCompat.startActivity import androidx.core.content.ContextCompat.startActivity
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.Observer 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 com.google.zxing.common.HybridBinarizer
import io.flutter.plugin.common.BinaryMessenger import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
@ -44,6 +44,9 @@ import io.flutter.plugin.common.PluginRegistry
import io.flutter.plugin.common.StandardMessageCodec import io.flutter.plugin.common.StandardMessageCodec
import io.flutter.plugin.platform.PlatformView import io.flutter.plugin.platform.PlatformView
import io.flutter.plugin.platform.PlatformViewFactory import io.flutter.plugin.platform.PlatformViewFactory
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.json.JSONObject import org.json.JSONObject
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.util.concurrent.ExecutorService import java.util.concurrent.ExecutorService
@ -80,7 +83,7 @@ internal class QRScannerView(
) : PlatformView { ) : PlatformView {
private val stateChangeObserver = StateChangeObserver(context) private val stateChangeObserver = StateChangeObserver(context)
private val uiThreadHandler = Handler(Looper.getMainLooper()) private val coroutineScope = CoroutineScope(Dispatchers.Main)
companion object { companion object {
const val TAG = "QRScannerView" const val TAG = "QRScannerView"
@ -104,11 +107,17 @@ internal class QRScannerView(
} }
private fun requestPermissions(activity: Activity) { private fun requestPermissions(activity: Activity) {
ActivityCompat.requestPermissions( coroutineScope.launch {
activity, methodChannel.invokeMethod(
PERMISSIONS_TO_REQUEST, "beforePermissionsRequest", null
PERMISSION_REQUEST_CODE )
)
ActivityCompat.requestPermissions(
activity,
PERMISSIONS_TO_REQUEST,
PERMISSION_REQUEST_CODE
)
}
} }
private val qrScannerView = View.inflate(context, R.layout.qr_scanner_view, null) private val qrScannerView = View.inflate(context, R.layout.qr_scanner_view, null)
@ -147,7 +156,6 @@ internal class QRScannerView(
return qrScannerView return qrScannerView
} }
override fun dispose() { override fun dispose() {
cameraProvider?.unbindAll() cameraProvider?.unbindAll()
preview = null preview = null
@ -229,7 +237,7 @@ internal class QRScannerView(
} }
private fun reportViewInitialized(permissionsGranted: Boolean) { private fun reportViewInitialized(permissionsGranted: Boolean) {
uiThreadHandler.post { coroutineScope.launch {
methodChannel.invokeMethod( methodChannel.invokeMethod(
"viewInitialized", "viewInitialized",
JSONObject(mapOf("permissionsGranted" to permissionsGranted)).toString() JSONObject(mapOf("permissionsGranted" to permissionsGranted)).toString()
@ -238,7 +246,7 @@ internal class QRScannerView(
} }
private fun reportCodeFound(code: String) { private fun reportCodeFound(code: String) {
uiThreadHandler.post { coroutineScope.launch {
methodChannel.invokeMethod( methodChannel.invokeMethod(
"codeFound", JSONObject( "codeFound", JSONObject(
mapOf("value" to code) mapOf("value" to code)
@ -290,11 +298,6 @@ internal class QRScannerView(
) : ImageAnalysis.Analyzer { ) : ImageAnalysis.Analyzer {
var analysisPaused = false var analysisPaused = false
val multiFormatReader = MultiFormatReader().also {
it.setHints(mapOf(DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE)))
}
var analyzedImagesCount = 0 var analyzedImagesCount = 0
private fun ByteBuffer.toByteArray(lastRowPadding: Int): ByteArray { private fun ByteBuffer.toByteArray(lastRowPadding: Int): ByteArray {
@ -392,14 +395,14 @@ internal class QRScannerView(
fullSize fullSize
} }
val result: com.google.zxing.Result = multiFormatReader.decode(bitmapToProcess) val result = QrCodeScanner.decodeFromBinaryBitmap(bitmapToProcess)
if (analysisPaused) { if (analysisPaused) {
return return
} }
analysisPaused = true // pause analysisPaused = true // pause
Log.v(TAG, "Analysis result: ${result.text}") Log.v(TAG, "Analysis result: $result")
listener.invoke(Result.success(result.text)) listener.invoke(Result.success(result))
} catch (_: NotFoundException) { } catch (_: NotFoundException) {
if (analyzedImagesCount == 0) { if (analyzedImagesCount == 0) {
Log.v(TAG, " No QR code found (NotFoundException)") Log.v(TAG, " No QR code found (NotFoundException)")

View File

@ -65,10 +65,24 @@ class QRScannerZxingPlugin : FlutterPlugin, MethodCallHandler, ActivityAware,
} }
override fun onMethodCall(call: MethodCall, result: Result) { override fun onMethodCall(call: MethodCall, result: Result) {
if (call.method == "getPlatformVersion") { when (call.method) {
result.success("Android ${android.os.Build.VERSION.RELEASE}") "getPlatformVersion" -> {
} else { result.success("Android ${android.os.Build.VERSION.RELEASE}")
result.notImplemented() }
"scanBitmap" -> {
val bytes = call.argument<ByteArray>("bytes")
if (bytes != null) {
val scanResult = QrCodeScanner.decodeFromBytes(bytes)
result.success(scanResult)
} else {
result.error("Failure", "Invalid image", null)
}
}
else -> {
result.notImplemented()
}
} }
} }

View File

@ -0,0 +1,80 @@
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 {
private val qrCodeScanner = MultiFormatReader().also {
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.decodeWithState(binaryBitmap)
return result.text
}
private fun decodeFromBytes(
byteArray: ByteArray,
sampleSize: Int
): String? {
var bitmap: Bitmap? = null
try {
Log.v(
TAG,
"Decoding with sampleSize $sampleSize"
)
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)
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
}
}
}
fun decodeFromBytes(byteArray: ByteArray): String? {
return decodeFromBytes(byteArray, 1)
?: decodeFromBytes(byteArray, 4)
?: decodeFromBytes(byteArray, 8)
?: decodeFromBytes(byteArray, 12)
}
private const val TAG = "QRScanner"
}

View File

@ -26,9 +26,11 @@ apply plugin: 'kotlin-android'
apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"
android { android {
compileSdkVersion 33 compileSdk 34
ndkVersion flutter.ndkVersion ndkVersion flutter.ndkVersion
namespace 'com.yubico.authenticator.flutter_plugins.qrscanner_zxing_example'
compileOptions { compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8 sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility 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. // 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. // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-build-configuration.
minSdkVersion 21 minSdkVersion 21
targetSdkVersion 33 targetSdkVersion 34
versionCode flutterVersionCode.toInteger() versionCode flutterVersionCode.toInteger()
versionName flutterVersionName versionName flutterVersionName
} }
@ -60,6 +62,10 @@ android {
signingConfig signingConfigs.debug signingConfig signingConfigs.debug
} }
} }
buildFeatures {
buildConfig true
}
} }
flutter { flutter {

View File

@ -1,12 +1,12 @@
buildscript { buildscript {
ext.kotlin_version = '1.7.21' ext.kotlin_version = '1.9.10'
repositories { repositories {
google() google()
mavenCentral() mavenCentral()
} }
dependencies { 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" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
} }
} }
@ -26,6 +26,6 @@ subprojects {
project.evaluationDependsOn(':app') project.evaluationDependsOn(':app')
} }
task clean(type: Delete) { tasks.register('clean', Delete) {
delete rootProject.buildDir delete rootProject.buildDir
} }

View File

@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists 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

View File

@ -14,7 +14,9 @@
* limitations under the License. * limitations under the License.
*/ */
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:qrscanner_zxing/qrscanner_zxing_method_channel.dart';
import 'package:qrscanner_zxing/qrscanner_zxing_view.dart'; import 'package:qrscanner_zxing/qrscanner_zxing_view.dart';
import 'cutout_overlay.dart'; import 'cutout_overlay.dart';
@ -64,6 +66,36 @@ class AppHomePage extends StatelessWidget {
)); ));
}, },
child: const Text("Open QR Scanner")), 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', '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;
if (bytes != null) {
var value = await channel.scanBitmap(bytes);
final snackBar = SnackBar(
content: Text(value == null
? 'No QR code detected'
: 'QR: $value'));
scaffoldMessenger.showSnackBar(snackBar);
} else {
// no files selected
}
},
child: const Text("Scan from file")),
], ],
), ),
), ),

View File

@ -37,10 +37,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: collection name: collection
sha256: "4a07be6cb69c84d677a6c3096fcf960cc3285a8330b4603e0d463d15d9bd934c" sha256: f092b211a4319e98e5ff58223576de6c2803db36221657b46c82574721240687
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.17.1" version: "1.17.2"
cupertino_icons: cupertino_icons:
dependency: "direct main" dependency: "direct main"
description: description:
@ -57,6 +57,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.1" 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: flutter:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
@ -70,19 +86,24 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.1" 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: flutter_test:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
js: flutter_web_plugins:
dependency: transitive dependency: transitive
description: description: flutter
name: js source: sdk
sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 version: "0.0.0"
url: "https://pub.dev"
source: hosted
version: "0.6.7"
lints: lints:
dependency: transitive dependency: transitive
description: description:
@ -95,18 +116,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: matcher name: matcher
sha256: "6501fbd55da300384b768785b83e5ce66991266cec21af89ab9ae7f5ce1c4cbb" sha256: "1803e76e6653768d64ed8ff2e1e67bea3ad4b923eb5c56a295c3e634bad5960e"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.12.15" version: "0.12.16"
material_color_utilities: material_color_utilities:
dependency: transitive dependency: transitive
description: description:
name: material_color_utilities name: material_color_utilities
sha256: d92141dc6fe1dad30722f9aa826c7fbc896d021d792f80678280601aff8cf724 sha256: "9528f2f296073ff54cb9fee677df673ace1218163c3bc7628093e7eed5203d41"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.2.0" version: "0.5.0"
meta: meta:
dependency: transitive dependency: transitive
description: description:
@ -127,10 +148,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: plugin_platform_interface name: plugin_platform_interface
sha256: dbf0f707c78beedc9200146ad3cb0ab4d5da13c246336987be6940f026500d3a sha256: da3fdfeccc4d4ff2da8f8c556704c08f912542c5fb3cf2233ed75372384a034d
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.3" version: "2.1.6"
qrscanner_zxing: qrscanner_zxing:
dependency: "direct main" dependency: "direct main"
description: description:
@ -147,10 +168,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: source_span name: source_span
sha256: dd904f795d4b4f3b870833847c461801f6750a9fa8e61ea5ac53f9422b31f250 sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.9.1" version: "1.10.0"
stack_trace: stack_trace:
dependency: transitive dependency: transitive
description: description:
@ -187,10 +208,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: eb6ac1540b26de412b3403a163d919ba86f6a973fe6cc50ae3541b80092fdcfb sha256: "75760ffd7786fffdfb9597c35c5b27eaeec82be8edfb6d71d32651128ed7aab8"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.5.1" version: "0.6.0"
vector_math: vector_math:
dependency: transitive dependency: transitive
description: description:
@ -199,6 +220,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.4" 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: sdks:
dart: ">=3.0.0-0 <4.0.0" dart: ">=3.1.0-185.0.dev <4.0.0"
flutter: ">=2.5.0" flutter: ">=3.7.0"

View File

@ -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 publish_to: 'none' # Remove this line if you wish to publish to pub.dev
environment: environment:
sdk: ">=2.17.0-266.1.beta <3.0.0" sdk: '>=3.0.0 <4.0.0'
dependencies: dependencies:
flutter: flutter:
@ -14,6 +14,7 @@ dependencies:
path: ../ path: ../
cupertino_icons: ^1.0.2 cupertino_icons: ^1.0.2
file_picker: ^5.3.2
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

View File

@ -14,10 +14,16 @@
* limitations under the License. * limitations under the License.
*/ */
import 'dart:typed_data';
import 'qrscanner_zxing_platform_interface.dart'; import 'qrscanner_zxing_platform_interface.dart';
class QRScannerZxing { class QRScannerZxing {
Future<String?> getPlatformVersion() { Future<String?> getPlatformVersion() {
return QRScannerZxingPlatform.instance.getPlatformVersion(); return QRScannerZxingPlatform.instance.getPlatformVersion();
} }
Future<String?> scanBitmap(Uint8List bitmap) {
return QRScannerZxingPlatform.instance.scanBitmap(bitmap);
}
} }

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2022 Yubico. * Copyright (C) 2022-2023 Yubico.
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -31,4 +31,11 @@ class MethodChannelQRScannerZxing extends QRScannerZxingPlatform {
await methodChannel.invokeMethod<String>('getPlatformVersion'); await methodChannel.invokeMethod<String>('getPlatformVersion');
return version; return version;
} }
@override
Future<String?> scanBitmap(Uint8List bytes) async {
final result = await methodChannel
.invokeMethod<String>('scanBitmap', {'bytes': bytes});
return result;
}
} }

View File

@ -14,6 +14,8 @@
* limitations under the License. * limitations under the License.
*/ */
import 'dart:typed_data';
import 'package:plugin_platform_interface/plugin_platform_interface.dart'; import 'package:plugin_platform_interface/plugin_platform_interface.dart';
import 'qrscanner_zxing_method_channel.dart'; import 'qrscanner_zxing_method_channel.dart';
@ -42,4 +44,8 @@ abstract class QRScannerZxingPlatform extends PlatformInterface {
Future<String?> getPlatformVersion() { Future<String?> getPlatformVersion() {
throw UnimplementedError('platformVersion() has not been implemented.'); throw UnimplementedError('platformVersion() has not been implemented.');
} }
Future<String?> scanBitmap(Uint8List bytes) {
throw UnimplementedError('scanBitmap(Uint8List) has not been implemented.');
}
} }

View File

@ -24,13 +24,21 @@ import 'package:flutter/services.dart';
class QRScannerZxingView extends StatefulWidget { class QRScannerZxingView extends StatefulWidget {
final int marginPct; final int marginPct;
/// Called when a code has been detected.
final Function(String rawData) onDetect; 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; final Function(bool permissionsGranted) onViewInitialized;
const QRScannerZxingView( const QRScannerZxingView(
{Key? key, {Key? key,
required this.marginPct, required this.marginPct,
required this.onDetect, required this.onDetect,
this.beforePermissionsRequest,
required this.onViewInitialized}) required this.onViewInitialized})
: super(key: key); : super(key: key);
@ -51,6 +59,9 @@ class QRScannerZxingViewState extends State<QRScannerZxingView> {
var rawValue = arguments["value"]; var rawValue = arguments["value"];
widget.onDetect(rawValue); widget.onDetect(rawValue);
return; return;
case "beforePermissionsRequest":
widget.beforePermissionsRequest?.call();
return;
case "viewInitialized": case "viewInitialized":
var arguments = jsonDecode(call.arguments); var arguments = jsonDecode(call.arguments);
var permissionsGranted = arguments["permissionsGranted"]; var permissionsGranted = arguments["permissionsGranted"];

View File

@ -14,6 +14,8 @@
* limitations under the License. * limitations under the License.
*/ */
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:plugin_platform_interface/plugin_platform_interface.dart'; import 'package:plugin_platform_interface/plugin_platform_interface.dart';
import 'package:qrscanner_zxing/qrscanner_zxing.dart'; import 'package:qrscanner_zxing/qrscanner_zxing.dart';
@ -25,6 +27,10 @@ class MockQRScannerZxingPlatform
implements QRScannerZxingPlatform { implements QRScannerZxingPlatform {
@override @override
Future<String?> getPlatformVersion() => Future.value('42'); Future<String?> getPlatformVersion() => Future.value('42');
@override
Future<String?> scanBitmap(Uint8List bytes) =>
Future.value(bytes.length.toString());
} }
void main() { void main() {
@ -42,4 +48,12 @@ void main() {
expect(await qrscannerZxingPlugin.getPlatformVersion(), '42'); expect(await qrscannerZxingPlugin.getPlatformVersion(), '42');
}); });
test('scanBitmap', () async {
QRScannerZxing qrscannerZxingPlugin = QRScannerZxing();
MockQRScannerZxingPlatform fakePlatform = MockQRScannerZxingPlatform();
QRScannerZxingPlatform.instance = fakePlatform;
expect(await qrscannerZxingPlugin.scanBitmap(Uint8List(10)), '10');
});
} }

View File

@ -165,6 +165,7 @@ class AboutPage extends ConsumerWidget {
'os': Platform.operatingSystem, 'os': Platform.operatingSystem,
'os_version': Platform.operatingSystemVersion, 'os_version': Platform.operatingSystemVersion,
}); });
data.insert(data.length - 1, ref.read(featureFlagProvider));
final text = const JsonEncoder.withIndent(' ').convert(data); final text = const JsonEncoder.withIndent(' ').convert(data);
await ref.read(clipboardProvider).setText(text); await ref.read(clipboardProvider).setText(text);
await ref.read(withContextProvider)( await ref.read(withContextProvider)(

View File

@ -34,6 +34,16 @@ Future<bool> isNfcEnabled() async {
return await appMethodsChannel.invokeMethod('isNfcEnabled'); 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<void> preserveConnectedDeviceWhenPaused() async {
await appMethodsChannel.invokeMethod('preserveConnectionOnPause');
}
Future<void> openNfcSettings() async { Future<void> openNfcSettings() async {
await appMethodsChannel.invokeMethod('openNfcSettings'); await appMethodsChannel.invokeMethod('openNfcSettings');
} }

View File

@ -53,39 +53,37 @@ Future<Widget> initialize() async {
return ProviderScope( return ProviderScope(
overrides: [ overrides: [
supportedAppsProvider.overrideWithValue([ supportedAppsProvider.overrideWith(implementedApps([
Application.oath, Application.oath,
]), ])),
prefProvider.overrideWithValue(await SharedPreferences.getInstance()), prefProvider.overrideWithValue(await SharedPreferences.getInstance()),
logLevelProvider.overrideWith((ref) => AndroidLogger()), logLevelProvider.overrideWith((ref) => AndroidLogger()),
attachedDevicesProvider attachedDevicesProvider.overrideWith(
.overrideWith( () => AndroidAttachedDevicesNotifier(),
() => AndroidAttachedDevicesNotifier(), ),
),
currentDeviceDataProvider.overrideWith( currentDeviceDataProvider.overrideWith(
(ref) => ref.watch(androidDeviceDataProvider), (ref) => ref.watch(androidDeviceDataProvider),
), ),
oathStateProvider.overrideWithProvider(androidOathStateProvider), oathStateProvider.overrideWithProvider(androidOathStateProvider),
credentialListProvider credentialListProvider
.overrideWithProvider(androidCredentialListProvider), .overrideWithProvider(androidCredentialListProvider),
currentAppProvider.overrideWith( currentAppProvider.overrideWith(
(ref) => AndroidSubPageNotifier(ref.watch(supportedAppsProvider)) (ref) => AndroidSubPageNotifier(ref.watch(supportedAppsProvider))),
),
managementStateProvider.overrideWithProvider(androidManagementState), managementStateProvider.overrideWithProvider(androidManagementState),
currentDeviceProvider.overrideWith( currentDeviceProvider.overrideWith(
() => AndroidCurrentDeviceNotifier(), () => AndroidCurrentDeviceNotifier(),
), ),
qrScannerProvider qrScannerProvider
.overrideWith(androidQrScannerProvider(await getHasCamera())), .overrideWith(androidQrScannerProvider(await getHasCamera())),
windowStateProvider.overrideWith((ref) => ref.watch(androidWindowStateProvider)), windowStateProvider
.overrideWith((ref) => ref.watch(androidWindowStateProvider)),
clipboardProvider.overrideWith( clipboardProvider.overrideWith(
(ref) => ref.watch(androidClipboardProvider), (ref) => ref.watch(androidClipboardProvider),
), ),
androidSdkVersionProvider.overrideWithValue(await getAndroidSdkVersion()), androidSdkVersionProvider.overrideWithValue(await getAndroidSdkVersion()),
androidNfcSupportProvider.overrideWithValue(await getHasNfc()), androidNfcSupportProvider.overrideWithValue(await getHasNfc()),
supportedThemesProvider supportedThemesProvider.overrideWith(
.overrideWith( (ref) => ref.watch(androidSupportedThemesProvider),
(ref) => ref.watch(androidSupportedThemesProvider),
) )
], ],
child: DismissKeyboard( child: DismissKeyboard(

View File

@ -22,6 +22,7 @@ const _prefix = 'android.keys';
const okButton = Key('$_prefix.ok'); const okButton = Key('$_prefix.ok');
const manualEntryButton = Key('$_prefix.manual_entry'); const manualEntryButton = Key('$_prefix.manual_entry');
const readFromImage = Key('$_prefix.read_image_file');
const nfcBypassTouchSetting = Key('$_prefix.nfc_bypass_touch'); const nfcBypassTouchSetting = Key('$_prefix.nfc_bypass_touch');
const nfcSilenceSoundsSettings = Key('$_prefix.nfc_silence_sounds'); const nfcSilenceSoundsSettings = Key('$_prefix.nfc_silence_sounds');

View File

@ -16,6 +16,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.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'; import 'qr_scanner_scan_status.dart';
@ -47,45 +48,69 @@ class QRScannerPermissionsUI extends StatelessWidget {
style: const TextStyle(color: Colors.white), style: const TextStyle(color: Colors.white),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
Row( Column(
mainAxisSize: MainAxisSize.min, children: [
mainAxisAlignment: MainAxisAlignment.spaceEvenly, Row(
children: [ mainAxisSize: MainAxisSize.min,
Column( 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: [ children: [
Text(
l10n.q_have_account_info,
textScaleFactor: 0.7,
style: const TextStyle(color: Colors.white),
),
OutlinedButton( OutlinedButton(
onPressed: () { onPressed: () {
Navigator.of(context).pop(''); Navigator.of(context).pop(
AndroidQrScanner.kQrScannerRequestManualEntry);
}, },
child: Text( child: Text(
l10n.s_enter_manually, l10n.s_enter_manually,
style: const TextStyle(color: Colors.white), style: const TextStyle(color: Colors.white),
)), )),
], const SizedBox(width: 16),
),
Column(
children: [
Text(
l10n.q_want_to_scan,
textScaleFactor: 0.7,
style: const TextStyle(color: Colors.white),
),
OutlinedButton( OutlinedButton(
onPressed: () { onPressed: () {
onPermissionRequest(); Navigator.of(context).pop(
AndroidQrScanner.kQrScannerRequestReadFromFile);
}, },
child: Text( child: Text(
l10n.s_review_permissions, l10n.s_read_from_file,
style: const TextStyle(color: Colors.white), style: const TextStyle(color: Colors.white),
)), ))
], ]),
) ],
]) )
], ],
), ),
), ),

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2022 Yubico. * Copyright (C) 2022-2023 Yubico.
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -14,35 +14,110 @@
* limitations under the License. * limitations under the License.
*/ */
import 'dart:convert';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.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/app/state.dart';
import 'package:yubico_authenticator/exception/cancellation_exception.dart'; import 'package:yubico_authenticator/exception/cancellation_exception.dart';
import 'package:yubico_authenticator/theme.dart'; import 'package:yubico_authenticator/theme.dart';
import '../../app/message.dart';
import '../../oath/views/add_account_page.dart';
import '../../oath/views/utils.dart';
import 'qr_scanner_view.dart'; import 'qr_scanner_view.dart';
class AndroidQrScanner implements QrScanner { class AndroidQrScanner implements QrScanner {
static const String kQrScannerRequestManualEntry =
'__QR_SCANNER_ENTER_MANUALLY__';
static const String kQrScannerRequestReadFromFile =
'__QR_SCANNER_SCAN_FROM_FILE__';
final WithContext _withContext; final WithContext _withContext;
AndroidQrScanner(this._withContext); AndroidQrScanner(this._withContext);
@override @override
Future<String?> scanQr([String? _]) async { Future<String?> scanQr([String? imageData]) async {
var scannedCode = await _withContext( if (imageData == null) {
(context) async => await Navigator.of(context).push(PageRouteBuilder( var scannedCode = await _withContext((context) async =>
pageBuilder: (_, __, ___) => await Navigator.of(context).push(PageRouteBuilder(
Theme(data: AppTheme.darkTheme, child: const QrScannerView()), pageBuilder: (_, __, ___) =>
settings: const RouteSettings(name: 'android_qr_scanner_view'), Theme(data: AppTheme.darkTheme, child: const QrScannerView()),
transitionDuration: const Duration(seconds: 0), settings: const RouteSettings(name: 'android_qr_scanner_view'),
reverseTransitionDuration: const Duration(seconds: 0), transitionDuration: const Duration(seconds: 0),
))); reverseTransitionDuration: const Duration(seconds: 0),
if (scannedCode == null) { )));
// user has cancelled the scan if (scannedCode == null) {
throw CancellationException(); // 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;
static Future<void> handleScannedData(
String? qrData,
WithContext withContext,
QrScanner qrScanner,
AppLocalizations l10n,
) async {
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:
await preserveConnectedDeviceWhenPaused();
final result = await FilePicker.platform.pickFiles(
allowedExtensions: ['png', 'jpg', 'gif', 'webp'],
type: FileType.custom,
allowMultiple: false,
lockParentWindow: true,
withData: true,
dialogTitle: l10n.l_qr_select_file);
if (result == null || !result.isSinglePick) {
// no result
return;
}
final bytes = result.files.first.bytes;
if (bytes != null) {
final b64bytes = base64Encode(bytes);
final imageQrData = await qrScanner.scanQr(b64bytes);
if (imageQrData != null) {
await withContext((context) =>
handleUri(context, null, imageQrData, 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));
} }
return scannedCode;
} }
} }

View File

@ -16,6 +16,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:yubico_authenticator/android/qr_scanner/qr_scanner_provider.dart';
import '../keys.dart' as keys; import '../keys.dart' as keys;
import 'qr_scanner_scan_status.dart'; import 'qr_scanner_scan_status.dart';
@ -73,18 +74,32 @@ class QRScannerUI extends StatelessWidget {
textScaleFactor: 0.7, textScaleFactor: 0.7,
style: const TextStyle(color: Colors.white), style: const TextStyle(color: Colors.white),
), ),
OutlinedButton( Row(mainAxisAlignment: MainAxisAlignment.center, children: [
onPressed: () { OutlinedButton(
Navigator.of(context).pop(''); onPressed: () {
}, Navigator.of(context).pop(
key: keys.manualEntryButton, AndroidQrScanner.kQrScannerRequestManualEntry);
child: Text( },
l10n.s_enter_manually, key: keys.manualEntryButton,
style: const TextStyle(color: Colors.white), 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: 8) const SizedBox(height: 16)
], ],
), ),
) )

View File

@ -17,6 +17,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:qrscanner_zxing/qrscanner_zxing_view.dart'; import 'package:qrscanner_zxing/qrscanner_zxing_view.dart';
import 'package:yubico_authenticator/android/app_methods.dart';
import '../../oath/models.dart'; import '../../oath/models.dart';
import 'qr_scanner_overlay_view.dart'; import 'qr_scanner_overlay_view.dart';
@ -138,17 +139,21 @@ class _QrScannerViewState extends State<QrScannerView> {
maintainSize: true, maintainSize: true,
visible: _permissionsGranted, visible: _permissionsGranted,
child: QRScannerZxingView( child: QRScannerZxingView(
key: _zxingViewKey, key: _zxingViewKey,
marginPct: 10, marginPct: 10,
onDetect: (scannedData) => handleResult(scannedData), onDetect: (scannedData) => handleResult(scannedData),
onViewInitialized: (bool permissionsGranted) { onViewInitialized: (bool permissionsGranted) {
Future.delayed(const Duration(milliseconds: 50), () { Future.delayed(const Duration(milliseconds: 50), () {
setState(() { setState(() {
_previewInitialized = true; _previewInitialized = true;
_permissionsGranted = permissionsGranted; _permissionsGranted = permissionsGranted;
});
}); });
})), });
},
beforePermissionsRequest: () async {
await preserveConnectedDeviceWhenPaused();
},
)),
Visibility( Visibility(
visible: _permissionsGranted, visible: _permissionsGranted,
child: QRScannerOverlay( child: QRScannerOverlay(

25
lib/app/features.dart Normal file
View File

@ -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');

View File

@ -21,6 +21,7 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import '../../management/models.dart'; import '../../management/models.dart';
import '../core/models.dart'; import '../core/models.dart';
import '../core/state.dart';
part 'models.freezed.dart'; part 'models.freezed.dart';
@ -129,6 +130,7 @@ class ActionItem with _$ActionItem {
Intent? intent, Intent? intent,
ActionStyle? actionStyle, ActionStyle? actionStyle,
Key? key, Key? key,
Feature? feature,
}) = _ActionItem; }) = _ActionItem;
} }

View File

@ -633,6 +633,7 @@ mixin _$ActionItem {
Intent? get intent => throw _privateConstructorUsedError; Intent? get intent => throw _privateConstructorUsedError;
ActionStyle? get actionStyle => throw _privateConstructorUsedError; ActionStyle? get actionStyle => throw _privateConstructorUsedError;
Key? get key => throw _privateConstructorUsedError; Key? get key => throw _privateConstructorUsedError;
Feature? get feature => throw _privateConstructorUsedError;
@JsonKey(ignore: true) @JsonKey(ignore: true)
$ActionItemCopyWith<ActionItem> get copyWith => $ActionItemCopyWith<ActionItem> get copyWith =>
@ -653,7 +654,8 @@ abstract class $ActionItemCopyWith<$Res> {
Widget? trailing, Widget? trailing,
Intent? intent, Intent? intent,
ActionStyle? actionStyle, ActionStyle? actionStyle,
Key? key}); Key? key,
Feature? feature});
} }
/// @nodoc /// @nodoc
@ -677,6 +679,7 @@ class _$ActionItemCopyWithImpl<$Res, $Val extends ActionItem>
Object? intent = freezed, Object? intent = freezed,
Object? actionStyle = freezed, Object? actionStyle = freezed,
Object? key = freezed, Object? key = freezed,
Object? feature = freezed,
}) { }) {
return _then(_value.copyWith( return _then(_value.copyWith(
icon: null == icon icon: null == icon
@ -711,6 +714,10 @@ class _$ActionItemCopyWithImpl<$Res, $Val extends ActionItem>
? _value.key ? _value.key
: key // ignore: cast_nullable_to_non_nullable : key // ignore: cast_nullable_to_non_nullable
as Key?, as Key?,
feature: freezed == feature
? _value.feature
: feature // ignore: cast_nullable_to_non_nullable
as Feature?,
) as $Val); ) as $Val);
} }
} }
@ -731,7 +738,8 @@ abstract class _$$_ActionItemCopyWith<$Res>
Widget? trailing, Widget? trailing,
Intent? intent, Intent? intent,
ActionStyle? actionStyle, ActionStyle? actionStyle,
Key? key}); Key? key,
Feature? feature});
} }
/// @nodoc /// @nodoc
@ -753,6 +761,7 @@ class __$$_ActionItemCopyWithImpl<$Res>
Object? intent = freezed, Object? intent = freezed,
Object? actionStyle = freezed, Object? actionStyle = freezed,
Object? key = freezed, Object? key = freezed,
Object? feature = freezed,
}) { }) {
return _then(_$_ActionItem( return _then(_$_ActionItem(
icon: null == icon icon: null == icon
@ -787,6 +796,10 @@ class __$$_ActionItemCopyWithImpl<$Res>
? _value.key ? _value.key
: key // ignore: cast_nullable_to_non_nullable : key // ignore: cast_nullable_to_non_nullable
as Key?, 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.trailing,
this.intent, this.intent,
this.actionStyle, this.actionStyle,
this.key}); this.key,
this.feature});
@override @override
final Widget icon; final Widget icon;
@ -820,10 +834,12 @@ class _$_ActionItem implements _ActionItem {
final ActionStyle? actionStyle; final ActionStyle? actionStyle;
@override @override
final Key? key; final Key? key;
@override
final Feature? feature;
@override @override
String toString() { 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 @override
@ -842,12 +858,13 @@ class _$_ActionItem implements _ActionItem {
(identical(other.intent, intent) || other.intent == intent) && (identical(other.intent, intent) || other.intent == intent) &&
(identical(other.actionStyle, actionStyle) || (identical(other.actionStyle, actionStyle) ||
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 @override
int get hashCode => Object.hash(runtimeType, icon, title, subtitle, shortcut, int get hashCode => Object.hash(runtimeType, icon, title, subtitle, shortcut,
trailing, intent, actionStyle, key); trailing, intent, actionStyle, key, feature);
@JsonKey(ignore: true) @JsonKey(ignore: true)
@override @override
@ -865,7 +882,8 @@ abstract class _ActionItem implements ActionItem {
final Widget? trailing, final Widget? trailing,
final Intent? intent, final Intent? intent,
final ActionStyle? actionStyle, final ActionStyle? actionStyle,
final Key? key}) = _$_ActionItem; final Key? key,
final Feature? feature}) = _$_ActionItem;
@override @override
Widget get icon; Widget get icon;
@ -884,6 +902,8 @@ abstract class _ActionItem implements ActionItem {
@override @override
Key? get key; Key? get key;
@override @override
Feature? get feature;
@override
@JsonKey(ignore: true) @JsonKey(ignore: true)
_$$_ActionItemCopyWith<_$_ActionItem> get copyWith => _$$_ActionItemCopyWith<_$_ActionItem> get copyWith =>
throw _privateConstructorUsedError; throw _privateConstructorUsedError;

View File

@ -27,6 +27,7 @@ import 'package:yubico_authenticator/app/logging.dart';
import '../core/state.dart'; import '../core/state.dart';
import 'models.dart'; import 'models.dart';
import 'features.dart' as features;
final _log = Logger('app.state'); final _log = Logger('app.state');
@ -37,7 +38,25 @@ const officialLocales = [
// Override this to alter the set of supported apps. // Override this to alter the set of supported apps.
final supportedAppsProvider = final supportedAppsProvider =
Provider<List<Application>>((ref) => Application.values); Provider<List<Application>>(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<Application> Function(Ref) implementedApps(List<Application> 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. // Default implementation is always focused, override with platform specific version.
final windowStateProvider = Provider<WindowState>( final windowStateProvider = Provider<WindowState>(

View File

@ -15,7 +15,9 @@
*/ */
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/state.dart';
import '../../widgets/list_title.dart'; import '../../widgets/list_title.dart';
import '../models.dart'; import '../models.dart';
@ -26,6 +28,7 @@ class ActionListItem extends StatelessWidget {
final Widget? trailing; final Widget? trailing;
final void Function(BuildContext context)? onTap; final void Function(BuildContext context)? onTap;
final ActionStyle actionStyle; final ActionStyle actionStyle;
final Feature? feature;
const ActionListItem({ const ActionListItem({
super.key, super.key,
@ -35,6 +38,7 @@ class ActionListItem extends StatelessWidget {
this.trailing, this.trailing,
this.onTap, this.onTap,
this.actionStyle = ActionStyle.normal, this.actionStyle = ActionStyle.normal,
this.feature,
}); });
@override @override
@ -67,7 +71,7 @@ class ActionListItem extends StatelessWidget {
} }
} }
class ActionListSection extends StatelessWidget { class ActionListSection extends ConsumerWidget {
final String title; final String title;
final List<ActionListItem> children; final List<ActionListItem> children;
@ -82,6 +86,7 @@ class ActionListSection extends StatelessWidget {
final intent = action.intent; final intent = action.intent;
return ActionListItem( return ActionListItem(
key: action.key, key: action.key,
feature: action.feature,
actionStyle: action.actionStyle ?? ActionStyle.normal, actionStyle: action.actionStyle ?? ActionStyle.normal,
icon: action.icon, icon: action.icon,
title: action.title, title: action.title,
@ -96,14 +101,22 @@ class ActionListSection extends StatelessWidget {
} }
@override @override
Widget build(BuildContext context) => SizedBox( Widget build(BuildContext context, WidgetRef ref) {
width: 360, final hasFeature = ref.watch(featureProvider);
child: Column(children: [ final enabledChildren = children
ListTitle( .where((item) => item.feature == null || hasFeature(item.feature!));
title, if (enabledChildren.isEmpty) {
textStyle: Theme.of(context).textTheme.bodyLarge, return const SizedBox();
), }
...children, return SizedBox(
]), width: 360,
); child: Column(children: [
ListTitle(
title,
textStyle: Theme.of(context).textTheme.bodyLarge,
),
...enabledChildren,
]),
);
}
} }

View File

@ -16,13 +16,14 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/state.dart'; import '../../core/state.dart';
import '../models.dart'; import '../models.dart';
import '../shortcuts.dart'; import '../shortcuts.dart';
import 'action_popup_menu.dart'; import 'action_popup_menu.dart';
class AppListItem extends StatefulWidget { class AppListItem extends ConsumerStatefulWidget {
final Widget? leading; final Widget? leading;
final String title; final String title;
final String? subtitle; final String? subtitle;
@ -41,10 +42,10 @@ class AppListItem extends StatefulWidget {
}); });
@override @override
State<StatefulWidget> createState() => _AppListItemState(); ConsumerState<ConsumerStatefulWidget> createState() => _AppListItemState();
} }
class _AppListItemState extends State<AppListItem> { class _AppListItemState extends ConsumerState<AppListItem> {
final FocusNode _focusNode = FocusNode(); final FocusNode _focusNode = FocusNode();
int _lastTap = 0; int _lastTap = 0;
@ -60,6 +61,7 @@ class _AppListItemState extends State<AppListItem> {
final buildPopupActions = widget.buildPopupActions; final buildPopupActions = widget.buildPopupActions;
final activationIntent = widget.activationIntent; final activationIntent = widget.activationIntent;
final trailing = widget.trailing; final trailing = widget.trailing;
final hasFeature = ref.watch(featureProvider);
return Shortcuts( return Shortcuts(
shortcuts: { shortcuts: {
@ -72,11 +74,17 @@ class _AppListItemState extends State<AppListItem> {
onSecondaryTapDown: buildPopupActions == null onSecondaryTapDown: buildPopupActions == null
? null ? null
: (details) { : (details) {
showPopupMenu( final menuItems = buildPopupActions(context)
context, .where((action) =>
details.globalPosition, action.feature == null || hasFeature(action.feature!))
buildPopupActions(context), .toList();
); if (menuItems.isNotEmpty) {
showPopupMenu(
context,
details.globalPosition,
menuItems,
);
}
}, },
onTap: () { onTap: () {
if (isDesktop) { if (isDesktop) {

View File

@ -15,19 +15,18 @@
*/ */
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../android/app_methods.dart'; import '../../android/app_methods.dart';
import '../../android/qr_scanner/qr_scanner_provider.dart';
import '../../android/state.dart'; import '../../android/state.dart';
import '../../exception/cancellation_exception.dart';
import '../../core/state.dart'; import '../../core/state.dart';
import '../../exception/cancellation_exception.dart';
import '../../fido/views/fido_screen.dart'; import '../../fido/views/fido_screen.dart';
import '../../oath/views/add_account_page.dart';
import '../../oath/views/oath_screen.dart'; import '../../oath/views/oath_screen.dart';
import '../../oath/views/utils.dart';
import '../../piv/views/piv_screen.dart'; import '../../piv/views/piv_screen.dart';
import '../../widgets/custom_icons.dart'; import '../../widgets/custom_icons.dart';
import '../message.dart';
import '../models.dart'; import '../models.dart';
import '../state.dart'; import '../state.dart';
import 'device_error_screen.dart'; import 'device_error_screen.dart';
@ -100,32 +99,17 @@ class MainPage extends ConsumerWidget {
tooltip: l10n.s_add_account, tooltip: l10n.s_add_account,
onPressed: () async { onPressed: () async {
final withContext = ref.read(withContextProvider); final withContext = ref.read(withContextProvider);
final scanner = ref.read(qrScannerProvider); final qrScanner = ref.read(qrScannerProvider);
if (scanner != null) { if (qrScanner != null) {
try { try {
final qrData = await scanner.scanQr(); final qrData = await qrScanner.scanQr();
if (qrData != null) { await AndroidQrScanner.handleScannedData(
await withContext((context) => qrData, withContext, qrScanner, l10n);
handleUri(context, null, qrData, null, null, l10n));
return;
}
} on CancellationException catch (_) { } on CancellationException catch (_) {
// ignored - user cancelled // ignored - user cancelled
return; return;
} }
} }
await withContext((context) => showBlurDialog(
context: context,
routeSettings:
const RouteSettings(name: 'oath_add_account'),
builder: (context) {
return const OathAddAccountPage(
null,
null,
credentials: null,
);
},
));
}, },
), ),
); );

View File

@ -51,3 +51,65 @@ abstract class ApplicationStateNotifier<T>
state = AsyncValue.data(value); state = AsyncValue.data(value);
} }
} }
// Feature flags
sealed class BaseFeature {
String get path;
String _subpath(String key);
Feature feature(String key, {bool enabled = true}) =>
Feature._(this, key, enabled: enabled);
}
class _RootFeature extends BaseFeature {
_RootFeature._();
@override
String get path => '';
@override
String _subpath(String key) => key;
}
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._subpath(key);
@override
String _subpath(String key) => '$path.$key';
}
final BaseFeature root = _RootFeature._();
typedef FeatureProvider = bool Function(Feature feature);
final featureFlagProvider =
StateNotifierProvider<FeatureFlagsNotifier, Map<String, bool>>(
(_) => FeatureFlagsNotifier());
class FeatureFlagsNotifier extends StateNotifier<Map<String, bool>> {
FeatureFlagsNotifier() : super({});
void loadConfig(Map<String, dynamic> config) {
const falsey = [0, false, null];
state = {for (final k in config.keys) k: !falsey.contains(config[k])};
}
}
final featureProvider = Provider<FeatureProvider>((ref) {
final featureMap = ref.watch(featureFlagProvider);
bool isEnabled(BaseFeature feature) => switch (feature) {
_RootFeature() => true,
Feature() => isEnabled(feature.parent) &&
(featureMap[feature.path] ?? feature._defaultState),
};
return isEnabled;
});

View File

@ -157,6 +157,11 @@ Future<Widget> initialize(List<String> argv) async {
.toFilePath(); .toFilePath();
} }
// Locate feature flags file
final featureFile = File(Uri.file(Platform.resolvedExecutable)
.resolve('features.json')
.toFilePath());
final rpcFuture = _initHelper(exe!); final rpcFuture = _initHelper(exe!);
_initLicenses(); _initLicenses();
@ -167,12 +172,12 @@ Future<Widget> initialize(List<String> argv) async {
return ProviderScope( return ProviderScope(
overrides: [ overrides: [
supportedAppsProvider.overrideWithValue([ supportedAppsProvider.overrideWith(implementedApps([
Application.oath, Application.oath,
Application.fido, Application.fido,
Application.piv, Application.piv,
Application.management, Application.management,
]), ])),
prefProvider.overrideWithValue(prefs), prefProvider.overrideWithValue(prefs),
rpcProvider.overrideWith((_) => rpcFuture), rpcProvider.overrideWith((_) => rpcFuture),
windowStateProvider.overrideWith( windowStateProvider.overrideWith(
@ -218,15 +223,33 @@ Future<Widget> initialize(List<String> argv) async {
ref.read(rpcProvider).valueOrNull?.setLogLevel(level); 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 // Initialize systray
ref.watch(systrayProvider); ref.watch(systrayProvider);
// Show a loading or error page while the Helper isn't ready // Show a loading or error page while the Helper isn't ready
return ref.watch(rpcProvider).when( return Consumer(
data: (data) => const MainPage(), builder: (context, ref, child) => ref.watch(rpcProvider).when(
error: (error, stackTrace) => AppFailurePage(cause: error), data: (data) => const MainPage(),
loading: () => _HelperWaiter(), error: (error, stackTrace) => AppFailurePage(cause: error),
); loading: () => _HelperWaiter(),
));
}), }),
), ),
), ),

32
lib/fido/features.dart Normal file
View File

@ -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');

View File

@ -20,11 +20,13 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import '../../app/models.dart'; import '../../app/models.dart';
import '../../app/shortcuts.dart'; import '../../app/shortcuts.dart';
import '../keys.dart' as keys; import '../keys.dart' as keys;
import '../features.dart' as features;
List<ActionItem> buildFingerprintActions(AppLocalizations l10n) { List<ActionItem> buildFingerprintActions(AppLocalizations l10n) {
return [ return [
ActionItem( ActionItem(
key: keys.editFingerintAction, key: keys.editFingerintAction,
feature: features.fingerprintsEdit,
icon: const Icon(Icons.edit), icon: const Icon(Icons.edit),
title: l10n.s_rename_fp, title: l10n.s_rename_fp,
subtitle: l10n.l_rename_fp_desc, subtitle: l10n.l_rename_fp_desc,
@ -32,6 +34,7 @@ List<ActionItem> buildFingerprintActions(AppLocalizations l10n) {
), ),
ActionItem( ActionItem(
key: keys.deleteFingerprintAction, key: keys.deleteFingerprintAction,
feature: features.fingerprintsDelete,
actionStyle: ActionStyle.error, actionStyle: ActionStyle.error,
icon: const Icon(Icons.delete), icon: const Icon(Icons.delete),
title: l10n.s_delete_fingerprint, title: l10n.s_delete_fingerprint,
@ -45,6 +48,7 @@ List<ActionItem> buildCredentialActions(AppLocalizations l10n) {
return [ return [
ActionItem( ActionItem(
key: keys.deleteCredentialAction, key: keys.deleteCredentialAction,
feature: features.credentialsDelete,
actionStyle: ActionStyle.error, actionStyle: ActionStyle.error,
icon: const Icon(Icons.delete), icon: const Icon(Icons.delete),
title: l10n.s_delete_passkey, title: l10n.s_delete_passkey,

View File

@ -7,6 +7,8 @@ import '../../app/shortcuts.dart';
import '../../app/state.dart'; import '../../app/state.dart';
import '../../app/views/fs_dialog.dart'; import '../../app/views/fs_dialog.dart';
import '../../app/views/action_list.dart'; import '../../app/views/action_list.dart';
import '../../core/state.dart';
import '../features.dart' as features;
import '../models.dart'; import '../models.dart';
import 'actions.dart'; import 'actions.dart';
import 'delete_credential_dialog.dart'; import 'delete_credential_dialog.dart';
@ -26,29 +28,32 @@ class CredentialDialog extends ConsumerWidget {
} }
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
final hasFeature = ref.watch(featureProvider);
return Actions( return Actions(
actions: { actions: {
DeleteIntent: CallbackAction<DeleteIntent>(onInvoke: (_) async { if (hasFeature(features.credentialsDelete))
final withContext = ref.read(withContextProvider); DeleteIntent: CallbackAction<DeleteIntent>(onInvoke: (_) async {
final bool? deleted = final withContext = ref.read(withContextProvider);
await ref.read(withContextProvider)((context) async => final bool? deleted =
await showBlurDialog( await ref.read(withContextProvider)((context) async =>
context: context, await showBlurDialog(
builder: (context) => DeleteCredentialDialog( context: context,
node.path, builder: (context) => DeleteCredentialDialog(
credential, node.path,
), credential,
) ?? ),
false); ) ??
false);
// Pop the account dialog if deleted // Pop the account dialog if deleted
if (deleted == true) { if (deleted == true) {
await withContext((context) async { await withContext((context) async {
Navigator.of(context).pop(); Navigator.of(context).pop();
}); });
} }
return deleted; return deleted;
}), }),
}, },
child: FocusScope( child: FocusScope(
autofocus: true, autofocus: true,

View File

@ -7,7 +7,9 @@ import '../../app/shortcuts.dart';
import '../../app/state.dart'; import '../../app/state.dart';
import '../../app/views/fs_dialog.dart'; import '../../app/views/fs_dialog.dart';
import '../../app/views/action_list.dart'; import '../../app/views/action_list.dart';
import '../../core/state.dart';
import '../models.dart'; import '../models.dart';
import '../features.dart' as features;
import 'actions.dart'; import 'actions.dart';
import 'delete_fingerprint_dialog.dart'; import 'delete_fingerprint_dialog.dart';
import 'rename_fingerprint_dialog.dart'; import 'rename_fingerprint_dialog.dart';
@ -27,53 +29,56 @@ class FingerprintDialog extends ConsumerWidget {
} }
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
final hasFeature = ref.watch(featureProvider);
return Actions( return Actions(
actions: { actions: {
EditIntent: CallbackAction<EditIntent>(onInvoke: (_) async { if (hasFeature(features.fingerprintsEdit))
final withContext = ref.read(withContextProvider); EditIntent: CallbackAction<EditIntent>(onInvoke: (_) async {
final Fingerprint? renamed = final withContext = ref.read(withContextProvider);
await withContext((context) async => await showBlurDialog( final Fingerprint? renamed =
context: context, await withContext((context) async => await showBlurDialog(
builder: (context) => RenameFingerprintDialog( context: context,
node.path, builder: (context) => RenameFingerprintDialog(
fingerprint, node.path,
), fingerprint,
)); ),
if (renamed != null) { ));
// Replace the dialog with the renamed credential if (renamed != null) {
await withContext((context) async { // Replace the dialog with the renamed credential
Navigator.of(context).pop(); await withContext((context) async {
await showBlurDialog( Navigator.of(context).pop();
context: context, await showBlurDialog(
builder: (context) { context: context,
return FingerprintDialog(renamed); builder: (context) {
}, return FingerprintDialog(renamed);
); },
}); );
} });
return renamed; }
}), return renamed;
DeleteIntent: CallbackAction<DeleteIntent>(onInvoke: (_) async { }),
final withContext = ref.read(withContextProvider); if (hasFeature(features.fingerprintsDelete))
final bool? deleted = DeleteIntent: CallbackAction<DeleteIntent>(onInvoke: (_) async {
await ref.read(withContextProvider)((context) async => final withContext = ref.read(withContextProvider);
await showBlurDialog( final bool? deleted =
context: context, await ref.read(withContextProvider)((context) async =>
builder: (context) => DeleteFingerprintDialog( await showBlurDialog(
node.path, context: context,
fingerprint, builder: (context) => DeleteFingerprintDialog(
), node.path,
) ?? fingerprint,
false); ),
) ??
false);
// Pop the account dialog if deleted // Pop the account dialog if deleted
if (deleted == true) { if (deleted == true) {
await withContext((context) async { await withContext((context) async {
Navigator.of(context).pop(); Navigator.of(context).pop();
}); });
} }
return deleted; return deleted;
}), }),
}, },
child: FocusScope( child: FocusScope(
autofocus: true, autofocus: true,

View File

@ -23,6 +23,7 @@ import '../../app/views/fs_dialog.dart';
import '../../app/views/action_list.dart'; import '../../app/views/action_list.dart';
import '../models.dart'; import '../models.dart';
import '../keys.dart' as keys; import '../keys.dart' as keys;
import '../features.dart' as features;
import 'add_fingerprint_dialog.dart'; import 'add_fingerprint_dialog.dart';
import 'pin_dialog.dart'; import 'pin_dialog.dart';
import 'reset_dialog.dart'; import 'reset_dialog.dart';
@ -48,6 +49,7 @@ Widget fidoBuildActions(
children: [ children: [
ActionListItem( ActionListItem(
key: keys.addFingerprintAction, key: keys.addFingerprintAction,
feature: features.actionsAddFingerprint,
actionStyle: ActionStyle.primary, actionStyle: ActionStyle.primary,
icon: const Icon(Icons.fingerprint_outlined), icon: const Icon(Icons.fingerprint_outlined),
title: l10n.s_add_fingerprint, title: l10n.s_add_fingerprint,
@ -76,6 +78,7 @@ Widget fidoBuildActions(
children: [ children: [
ActionListItem( ActionListItem(
key: keys.managePinAction, key: keys.managePinAction,
feature: features.actionsPin,
icon: const Icon(Icons.pin_outlined), icon: const Icon(Icons.pin_outlined),
title: state.hasPin ? l10n.s_change_pin : l10n.s_set_pin, title: state.hasPin ? l10n.s_change_pin : l10n.s_set_pin,
subtitle: state.hasPin subtitle: state.hasPin
@ -96,6 +99,7 @@ Widget fidoBuildActions(
}), }),
ActionListItem( ActionListItem(
key: keys.resetAction, key: keys.resetAction,
feature: features.actionsReset,
actionStyle: ActionStyle.error, actionStyle: ActionStyle.error,
icon: const Icon(Icons.delete_outline), icon: const Icon(Icons.delete_outline),
title: l10n.s_reset_fido, title: l10n.s_reset_fido,

View File

@ -22,8 +22,10 @@ import '../../app/models.dart';
import '../../app/views/app_page.dart'; import '../../app/views/app_page.dart';
import '../../app/views/graphics.dart'; import '../../app/views/graphics.dart';
import '../../app/views/message_page.dart'; import '../../app/views/message_page.dart';
import '../../core/state.dart';
import '../models.dart'; import '../models.dart';
import '../state.dart'; import '../state.dart';
import '../features.dart' as features;
import 'key_actions.dart'; import 'key_actions.dart';
class FidoLockedPage extends ConsumerWidget { class FidoLockedPage extends ConsumerWidget {
@ -35,6 +37,9 @@ class FidoLockedPage extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
final hasFeature = ref.watch(featureProvider);
final hasActions = hasFeature(features.actions);
if (!state.hasPin) { if (!state.hasPin) {
if (state.bioEnroll != null) { if (state.bioEnroll != null) {
return MessagePage( return MessagePage(
@ -42,7 +47,7 @@ class FidoLockedPage extends ConsumerWidget {
graphic: noFingerprints, graphic: noFingerprints,
header: l10n.s_no_fingerprints, header: l10n.s_no_fingerprints,
message: l10n.l_set_pin_fingerprints, message: l10n.l_set_pin_fingerprints,
keyActionsBuilder: _buildActions, keyActionsBuilder: hasActions ? _buildActions : null,
keyActionsBadge: fidoShowActionsNotifier(state), keyActionsBadge: fidoShowActionsNotifier(state),
); );
} else { } else {
@ -53,7 +58,7 @@ class FidoLockedPage extends ConsumerWidget {
? l10n.l_no_discoverable_accounts ? l10n.l_no_discoverable_accounts
: l10n.l_ready_to_use, : l10n.l_ready_to_use,
message: l10n.l_optionally_set_a_pin, message: l10n.l_optionally_set_a_pin,
keyActionsBuilder: _buildActions, keyActionsBuilder: hasActions ? _buildActions : null,
keyActionsBadge: fidoShowActionsNotifier(state), keyActionsBadge: fidoShowActionsNotifier(state),
); );
} }
@ -65,7 +70,7 @@ class FidoLockedPage extends ConsumerWidget {
graphic: manageAccounts, graphic: manageAccounts,
header: l10n.l_ready_to_use, header: l10n.l_ready_to_use,
message: l10n.l_register_sk_on_websites, message: l10n.l_register_sk_on_websites,
keyActionsBuilder: _buildActions, keyActionsBuilder: hasActions ? _buildActions : null,
keyActionsBadge: fidoShowActionsNotifier(state), keyActionsBadge: fidoShowActionsNotifier(state),
); );
} }
@ -75,14 +80,14 @@ class FidoLockedPage extends ConsumerWidget {
title: Text(l10n.s_webauthn), title: Text(l10n.s_webauthn),
header: l10n.s_pin_change_required, header: l10n.s_pin_change_required,
message: l10n.l_pin_change_required_desc, message: l10n.l_pin_change_required_desc,
keyActionsBuilder: _buildActions, keyActionsBuilder: hasActions ? _buildActions : null,
keyActionsBadge: fidoShowActionsNotifier(state), keyActionsBadge: fidoShowActionsNotifier(state),
); );
} }
return AppPage( return AppPage(
title: Text(l10n.s_webauthn), title: Text(l10n.s_webauthn),
keyActionsBuilder: _buildActions, keyActionsBuilder: hasActions ? _buildActions : null,
child: Column( child: Column(
children: [ children: [
_PinEntryForm(state, node), _PinEntryForm(state, node),
@ -177,6 +182,7 @@ class _PinEntryFormState extends ConsumerState<_PinEntryForm> {
_isObscure = !_isObscure; _isObscure = !_isObscure;
}); });
}, },
tooltip: _isObscure ? l10n.s_show_pin : l10n.s_hide_pin,
), ),
), ),
onChanged: (value) { onChanged: (value) {

View File

@ -25,9 +25,11 @@ import '../../app/views/app_list_item.dart';
import '../../app/views/app_page.dart'; import '../../app/views/app_page.dart';
import '../../app/views/graphics.dart'; import '../../app/views/graphics.dart';
import '../../app/views/message_page.dart'; import '../../app/views/message_page.dart';
import '../../core/state.dart';
import '../../widgets/list_title.dart'; import '../../widgets/list_title.dart';
import '../models.dart'; import '../models.dart';
import '../state.dart'; import '../state.dart';
import '../features.dart' as features;
import 'actions.dart'; import 'actions.dart';
import 'credential_dialog.dart'; import 'credential_dialog.dart';
import 'delete_credential_dialog.dart'; import 'delete_credential_dialog.dart';
@ -45,7 +47,9 @@ class FidoUnlockedPage extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
final hasFeature = ref.watch(featureProvider);
List<Widget> children = []; List<Widget> children = [];
if (state.credMgmt) { if (state.credMgmt) {
final data = ref.watch(credentialProvider(node.path)).asData; final data = ref.watch(credentialProvider(node.path)).asData;
if (data == null) { if (data == null) {
@ -62,15 +66,16 @@ class FidoUnlockedPage extends ConsumerWidget {
barrierColor: Colors.transparent, barrierColor: Colors.transparent,
builder: (context) => CredentialDialog(cred), builder: (context) => CredentialDialog(cred),
)), )),
DeleteIntent: CallbackAction<DeleteIntent>( if (hasFeature(features.credentialsDelete))
onInvoke: (_) => showBlurDialog( DeleteIntent: CallbackAction<DeleteIntent>(
context: context, onInvoke: (_) => showBlurDialog(
builder: (context) => DeleteCredentialDialog( context: context,
node.path, builder: (context) => DeleteCredentialDialog(
cred, node.path,
cred,
),
), ),
), ),
),
}, },
child: _CredentialListItem(cred), child: _CredentialListItem(cred),
))); )));
@ -95,33 +100,38 @@ class FidoUnlockedPage extends ConsumerWidget {
barrierColor: Colors.transparent, barrierColor: Colors.transparent,
builder: (context) => FingerprintDialog(fp), builder: (context) => FingerprintDialog(fp),
)), )),
EditIntent: CallbackAction<EditIntent>( if (hasFeature(features.fingerprintsEdit))
onInvoke: (_) => showBlurDialog( EditIntent: CallbackAction<EditIntent>(
context: context, onInvoke: (_) => showBlurDialog(
builder: (context) => RenameFingerprintDialog( context: context,
node.path, builder: (context) => RenameFingerprintDialog(
fp, node.path,
), fp,
)), ),
DeleteIntent: CallbackAction<DeleteIntent>( )),
onInvoke: (_) => showBlurDialog( if (hasFeature(features.fingerprintsDelete))
context: context, DeleteIntent: CallbackAction<DeleteIntent>(
builder: (context) => DeleteFingerprintDialog( onInvoke: (_) => showBlurDialog(
node.path, context: context,
fp, builder: (context) => DeleteFingerprintDialog(
), node.path,
)), fp,
),
)),
}, },
child: _FingerprintListItem(fp), child: _FingerprintListItem(fp),
))); )));
} }
} }
final hasActions = ref.watch(featureProvider)(features.actions);
if (children.isNotEmpty) { if (children.isNotEmpty) {
return AppPage( return AppPage(
title: Text(l10n.s_webauthn), title: Text(l10n.s_webauthn),
keyActionsBuilder: (context) => keyActionsBuilder: hasActions
fidoBuildActions(context, node, state, nFingerprints), ? (context) => fidoBuildActions(context, node, state, nFingerprints)
: null,
keyActionsBadge: fidoShowActionsNotifier(state), keyActionsBadge: fidoShowActionsNotifier(state),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, children: children), crossAxisAlignment: CrossAxisAlignment.start, children: children),
@ -134,8 +144,9 @@ class FidoUnlockedPage extends ConsumerWidget {
graphic: noFingerprints, graphic: noFingerprints,
header: l10n.s_no_fingerprints, header: l10n.s_no_fingerprints,
message: l10n.l_add_one_or_more_fps, message: l10n.l_add_one_or_more_fps,
keyActionsBuilder: (context) => keyActionsBuilder: hasActions
fidoBuildActions(context, node, state, 0), ? (context) => fidoBuildActions(context, node, state, 0)
: null,
keyActionsBadge: fidoShowActionsNotifier(state), keyActionsBadge: fidoShowActionsNotifier(state),
); );
} }
@ -145,7 +156,9 @@ class FidoUnlockedPage extends ConsumerWidget {
graphic: manageAccounts, graphic: manageAccounts,
header: l10n.l_no_discoverable_accounts, header: l10n.l_no_discoverable_accounts,
message: l10n.l_register_sk_on_websites, 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), keyActionsBadge: fidoShowActionsNotifier(state),
); );
} }

View File

@ -72,6 +72,8 @@
"s_configure_yk": "Configure YubiKey", "s_configure_yk": "Configure YubiKey",
"s_please_wait": "Please wait\u2026", "s_please_wait": "Please wait\u2026",
"s_secret_key": "Secret key", "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_private_key": "Private key",
"s_invalid_length": "Invalid length", "s_invalid_length": "Invalid length",
"s_require_touch": "Require touch", "s_require_touch": "Require touch",
@ -182,6 +184,8 @@
"s_set_pin": "Set PIN", "s_set_pin": "Set PIN",
"s_change_pin": "Change PIN", "s_change_pin": "Change PIN",
"s_change_puk": "Change PUK", "s_change_puk": "Change PUK",
"s_show_pin": "Show PIN",
"s_hide_pin": "Hide PIN",
"s_current_pin": "Current PIN", "s_current_pin": "Current PIN",
"s_current_puk": "Current PUK", "s_current_puk": "Current PUK",
"s_new_pin": "New PIN", "s_new_pin": "New PIN",
@ -256,6 +260,8 @@
"s_manage_password": "Manage password", "s_manage_password": "Manage password",
"s_set_password": "Set password", "s_set_password": "Set password",
"s_password_set": "Password set", "s_password_set": "Password set",
"s_show_password": "Show password",
"s_hide_password": "Hide password",
"l_optional_password_protection": "Optional password protection", "l_optional_password_protection": "Optional password protection",
"s_new_password": "New password", "s_new_password": "New password",
"s_current_password": "Current password", "s_current_password": "Current password",
@ -509,6 +515,7 @@
"l_qr_scanned": "Scanned QR code", "l_qr_scanned": "Scanned QR code",
"l_invalid_qr": "Invalid QR code", "l_invalid_qr": "Invalid QR code",
"l_qr_not_found": "No QR code found", "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": "Failed reading QR code: {message}",
"@l_qr_not_read" : { "@l_qr_not_read" : {
"placeholders": { "placeholders": {
@ -519,6 +526,7 @@
"q_want_to_scan": "Would like to scan?", "q_want_to_scan": "Would like to scan?",
"q_no_qr": "No QR code?", "q_no_qr": "No QR code?",
"s_enter_manually": "Enter manually", "s_enter_manually": "Enter manually",
"s_read_from_file": "Read from file",
"@_factory_reset": {}, "@_factory_reset": {},
"s_reset": "Reset", "s_reset": "Reset",

31
lib/oath/features.dart Normal file
View File

@ -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');

View File

@ -27,6 +27,7 @@ import '../../app/views/fs_dialog.dart';
import '../../app/views/action_list.dart'; import '../../app/views/action_list.dart';
import '../../core/models.dart'; import '../../core/models.dart';
import '../../core/state.dart'; import '../../core/state.dart';
import '../features.dart' as features;
import '../models.dart'; import '../models.dart';
import '../state.dart'; import '../state.dart';
import 'account_helper.dart'; import 'account_helper.dart';
@ -49,6 +50,7 @@ class AccountDialog extends ConsumerWidget {
return const SizedBox(); return const SizedBox();
} }
final hasFeature = ref.watch(featureProvider);
final helper = AccountHelper(context, ref, credential); final helper = AccountHelper(context, ref, credential);
final subtitle = helper.subtitle; final subtitle = helper.subtitle;
@ -56,54 +58,58 @@ class AccountDialog extends ConsumerWidget {
credential, credential,
ref: ref, ref: ref,
actions: { actions: {
EditIntent: CallbackAction<EditIntent>(onInvoke: (_) async { if (hasFeature(features.accountsRename))
final credentials = ref.read(credentialsProvider); EditIntent: CallbackAction<EditIntent>(onInvoke: (_) async {
final withContext = ref.read(withContextProvider); final credentials = ref.read(credentialsProvider);
final renamed = final withContext = ref.read(withContextProvider);
await withContext((context) async => await showBlurDialog( 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, context: context,
builder: (context) => RenameAccountDialog.forOathCredential( builder: (context) {
ref, return AccountDialog(renamed);
},
);
});
}
return renamed;
}),
if (hasFeature(features.accountsDelete))
DeleteIntent: CallbackAction<DeleteIntent>(onInvoke: (_) async {
final withContext = ref.read(withContextProvider);
final bool? deleted =
await ref.read(withContextProvider)((context) async =>
await showBlurDialog(
context: context,
builder: (context) => DeleteAccountDialog(
node, node,
credential, credential,
credentials?.map((e) => (e.issuer, e.name)).toList() ?? ),
[], ) ??
))); false);
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<DeleteIntent>(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);
// Pop the account dialog if deleted // Pop the account dialog if deleted
if (deleted == true) { if (deleted == true) {
await withContext((context) async { await withContext((context) async {
Navigator.of(context).pop(); Navigator.of(context).pop();
}); });
} }
return deleted; return deleted;
}), }),
}, },
builder: (context) { builder: (context) {
if (helper.code == null && if (helper.code == null &&

View File

@ -27,6 +27,7 @@ import '../../app/state.dart';
import '../../core/state.dart'; import '../../core/state.dart';
import '../../widgets/circle_timer.dart'; import '../../widgets/circle_timer.dart';
import '../../widgets/custom_icons.dart'; import '../../widgets/custom_icons.dart';
import '../features.dart' as features;
import '../models.dart'; import '../models.dart';
import '../state.dart'; import '../state.dart';
import '../keys.dart' as keys; import '../keys.dart' as keys;
@ -68,6 +69,7 @@ class AccountHelper {
return [ return [
ActionItem( ActionItem(
key: keys.copyAction, key: keys.copyAction,
feature: features.accountsClipboard,
icon: const Icon(Icons.copy), icon: const Icon(Icons.copy),
title: l10n.l_copy_to_clipboard, title: l10n.l_copy_to_clipboard,
subtitle: l10n.l_copy_code_desc, subtitle: l10n.l_copy_code_desc,
@ -87,6 +89,7 @@ class AccountHelper {
), ),
ActionItem( ActionItem(
key: keys.togglePinAction, key: keys.togglePinAction,
feature: features.accountsPin,
icon: pinned icon: pinned
? pushPinStrokeIcon ? pushPinStrokeIcon
: const Icon(Icons.push_pin_outlined), : const Icon(Icons.push_pin_outlined),
@ -97,6 +100,7 @@ class AccountHelper {
if (data.info.version.isAtLeast(5, 3)) if (data.info.version.isAtLeast(5, 3))
ActionItem( ActionItem(
key: keys.editAction, key: keys.editAction,
feature: features.accountsRename,
icon: const Icon(Icons.edit_outlined), icon: const Icon(Icons.edit_outlined),
title: l10n.s_rename_account, title: l10n.s_rename_account,
subtitle: l10n.l_rename_account_desc, subtitle: l10n.l_rename_account_desc,
@ -104,6 +108,7 @@ class AccountHelper {
), ),
ActionItem( ActionItem(
key: keys.deleteAction, key: keys.deleteAction,
feature: features.accountsDelete,
actionStyle: ActionStyle.error, actionStyle: ActionStyle.error,
icon: const Icon(Icons.delete_outline), icon: const Icon(Icons.delete_outline),
title: l10n.s_delete_account, title: l10n.s_delete_account,

View File

@ -23,7 +23,9 @@ import '../../app/message.dart';
import '../../app/shortcuts.dart'; import '../../app/shortcuts.dart';
import '../../app/state.dart'; import '../../app/state.dart';
import '../../app/views/app_list_item.dart'; import '../../app/views/app_list_item.dart';
import '../../core/state.dart';
import '../models.dart'; import '../models.dart';
import '../features.dart' as features;
import '../state.dart'; import '../state.dart';
import 'account_dialog.dart'; import 'account_dialog.dart';
import 'account_helper.dart'; import 'account_helper.dart';
@ -81,6 +83,7 @@ class _AccountViewState extends ConsumerState<AccountView> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final darkMode = theme.brightness == Brightness.dark; final darkMode = theme.brightness == Brightness.dark;
final hasFeature = ref.watch(featureProvider);
return registerOathActions( return registerOathActions(
credential, credential,
@ -94,29 +97,31 @@ class _AccountViewState extends ConsumerState<AccountView> {
); );
return null; return null;
}), }),
EditIntent: CallbackAction<EditIntent>(onInvoke: (_) async { if (hasFeature(features.accountsRename))
final node = ref.read(currentDeviceProvider)!; EditIntent: CallbackAction<EditIntent>(onInvoke: (_) async {
final credentials = ref.read(credentialsProvider); final node = ref.read(currentDeviceProvider)!;
final withContext = ref.read(withContextProvider); final credentials = ref.read(credentialsProvider);
return await withContext((context) async => await showBlurDialog( final withContext = ref.read(withContextProvider);
context: context, return await withContext((context) async => await showBlurDialog(
builder: (context) => RenameAccountDialog.forOathCredential( context: context,
ref, builder: (context) => RenameAccountDialog.forOathCredential(
node, ref,
credential, node,
credentials?.map((e) => (e.issuer, e.name)).toList() ?? [], credential,
), credentials?.map((e) => (e.issuer, e.name)).toList() ?? [],
)); ),
}), ));
DeleteIntent: CallbackAction<DeleteIntent>(onInvoke: (_) async { }),
final node = ref.read(currentDeviceProvider)!; if (hasFeature(features.accountsDelete))
return await ref.read(withContextProvider)((context) async => DeleteIntent: CallbackAction<DeleteIntent>(onInvoke: (_) async {
await showBlurDialog( final node = ref.read(currentDeviceProvider)!;
context: context, return await ref.read(withContextProvider)((context) async =>
builder: (context) => DeleteAccountDialog(node, credential), await showBlurDialog(
) ?? context: context,
false); builder: (context) => DeleteAccountDialog(node, credential),
}), ) ??
false);
}),
}, },
builder: (context) { builder: (context) {
final helper = AccountHelper(context, ref, credential); final helper = AccountHelper(context, ref, credential);
@ -162,7 +167,9 @@ class _AccountViewState extends ConsumerState<AccountView> {
onPressed: onPressed:
Actions.handler(context, const OpenIntent()), Actions.handler(context, const OpenIntent()),
child: helper.buildCodeIcon()), child: helper.buildCodeIcon()),
activationIntent: const CopyIntent(), activationIntent: hasFeature(features.accountsClipboard)
? const CopyIntent()
: const OpenIntent(),
buildPopupActions: (_) => helper.buildActions(), buildPopupActions: (_) => helper.buildActions(),
), ),
)); ));

View File

@ -21,8 +21,10 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import '../../app/message.dart'; import '../../app/message.dart';
import '../../app/shortcuts.dart'; import '../../app/shortcuts.dart';
import '../../app/state.dart'; import '../../app/state.dart';
import '../../core/state.dart';
import '../../exception/cancellation_exception.dart'; import '../../exception/cancellation_exception.dart';
import '../models.dart'; import '../models.dart';
import '../features.dart' as features;
import '../state.dart'; import '../state.dart';
class TogglePinIntent extends Intent { class TogglePinIntent extends Intent {
@ -46,18 +48,20 @@ Widget registerOathActions(
required WidgetRef ref, required WidgetRef ref,
required Widget Function(BuildContext context) builder, required Widget Function(BuildContext context) builder,
Map<Type, Action<Intent>> actions = const {}, Map<Type, Action<Intent>> actions = const {},
}) => }) {
Actions( final hasFeature = ref.read(featureProvider);
actions: { return Actions(
RefreshIntent: CallbackAction<RefreshIntent>(onInvoke: (_) { actions: {
final code = ref.read(codeProvider(credential)); RefreshIntent: CallbackAction<RefreshIntent>(onInvoke: (_) {
if (!(credential.oathType == OathType.totp && final code = ref.read(codeProvider(credential));
code != null && if (!(credential.oathType == OathType.totp &&
!ref.read(expiredProvider(code.validTo)))) { code != null &&
return _calculateCode(credential, ref); !ref.read(expiredProvider(code.validTo)))) {
} return _calculateCode(credential, ref);
return code; }
}), return code;
}),
if (hasFeature(features.accountsClipboard))
CopyIntent: CallbackAction<CopyIntent>(onInvoke: (_) async { CopyIntent: CallbackAction<CopyIntent>(onInvoke: (_) async {
var code = ref.read(codeProvider(credential)); var code = ref.read(codeProvider(credential));
if (code == null || if (code == null ||
@ -77,11 +81,13 @@ Widget registerOathActions(
} }
return code; return code;
}), }),
if (hasFeature(features.accountsPin))
TogglePinIntent: CallbackAction<TogglePinIntent>(onInvoke: (_) { TogglePinIntent: CallbackAction<TogglePinIntent>(onInvoke: (_) {
ref.read(favoritesProvider.notifier).toggleFavorite(credential.id); ref.read(favoritesProvider.notifier).toggleFavorite(credential.id);
return null; return null;
}), }),
...actions, ...actions,
}, },
child: Builder(builder: builder), child: Builder(builder: builder),
); );
}

View File

@ -438,6 +438,9 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
_isObscure = !_isObscure; _isObscure = !_isObscure;
}); });
}, },
tooltip: _isObscure
? l10n.s_show_secret_key
: l10n.s_hide_secret_key,
), ),
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.key_outlined), prefixIcon: const Icon(Icons.key_outlined),

View File

@ -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/icon_provider/icon_pack_dialog.dart';
import 'package:yubico_authenticator/oath/views/add_account_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/message.dart';
import '../../app/models.dart'; import '../../app/models.dart';
import '../../app/state.dart'; import '../../app/state.dart';
@ -27,12 +28,10 @@ import '../../app/views/fs_dialog.dart';
import '../../app/views/action_list.dart'; import '../../app/views/action_list.dart';
import '../../core/state.dart'; import '../../core/state.dart';
import '../models.dart'; import '../models.dart';
import '../features.dart' as features;
import '../keys.dart' as keys; import '../keys.dart' as keys;
import '../state.dart';
import 'add_account_page.dart';
import 'manage_password_dialog.dart'; import 'manage_password_dialog.dart';
import 'reset_dialog.dart'; import 'reset_dialog.dart';
import 'utils.dart';
Widget oathBuildActions( Widget oathBuildActions(
BuildContext context, BuildContext context,
@ -49,6 +48,7 @@ Widget oathBuildActions(
children: [ children: [
ActionListSection(l10n.s_setup, children: [ ActionListSection(l10n.s_setup, children: [
ActionListItem( ActionListItem(
feature: features.actionsAdd,
title: l10n.s_add_account, title: l10n.s_add_account,
subtitle: used == null subtitle: used == null
? l10n.l_unlock_first ? l10n.l_unlock_first
@ -59,38 +59,16 @@ Widget oathBuildActions(
icon: const Icon(Icons.person_add_alt_1_outlined), icon: const Icon(Icons.person_add_alt_1_outlined),
onTap: used != null && (capacity == null || capacity > used) onTap: used != null && (capacity == null || capacity > used)
? (context) async { ? (context) async {
final credentials = ref.read(credentialsProvider);
final withContext = ref.read(withContextProvider);
Navigator.of(context).pop(); Navigator.of(context).pop();
if (isAndroid) { if (isAndroid) {
final withContext = ref.read(withContextProvider);
final qrScanner = ref.read(qrScannerProvider); final qrScanner = ref.read(qrScannerProvider);
if (qrScanner != null) { if (qrScanner != null) {
final qrData = await qrScanner.scanQr(); final qrData = await qrScanner.scanQr();
if (qrData != null) { await AndroidQrScanner.handleScannedData(
await withContext((context) => handleUri( qrData, withContext, qrScanner, l10n);
context,
credentials,
qrData,
devicePath,
oathState,
l10n,
));
return;
}
} }
await withContext((context) => showBlurDialog(
context: context,
routeSettings:
const RouteSettings(name: 'oath_add_account'),
builder: (context) {
return OathAddAccountPage(
devicePath,
oathState,
credentials: credentials,
credentialData: null,
);
},
));
} else { } else {
await showBlurDialog( await showBlurDialog(
context: context, context: context,
@ -104,6 +82,7 @@ Widget oathBuildActions(
ActionListSection(l10n.s_manage, children: [ ActionListSection(l10n.s_manage, children: [
ActionListItem( ActionListItem(
key: keys.customIconsAction, key: keys.customIconsAction,
feature: features.actionsIcons,
title: l10n.s_custom_icons, title: l10n.s_custom_icons,
subtitle: l10n.l_set_icons_for_accounts, subtitle: l10n.l_set_icons_for_accounts,
icon: const Icon(Icons.image_outlined), icon: const Icon(Icons.image_outlined),
@ -118,6 +97,7 @@ Widget oathBuildActions(
}), }),
ActionListItem( ActionListItem(
key: keys.setOrManagePasswordAction, key: keys.setOrManagePasswordAction,
feature: features.actionsPassword,
title: oathState.hasKey title: oathState.hasKey
? l10n.s_manage_password ? l10n.s_manage_password
: l10n.s_set_password, : l10n.s_set_password,
@ -133,6 +113,7 @@ Widget oathBuildActions(
}), }),
ActionListItem( ActionListItem(
key: keys.resetAction, key: keys.resetAction,
feature: features.actionsReset,
icon: const Icon(Icons.delete_outline), icon: const Icon(Icons.delete_outline),
actionStyle: ActionStyle.error, actionStyle: ActionStyle.error,
title: l10n.s_reset_oath, title: l10n.s_reset_oath,

View File

@ -25,6 +25,8 @@ import '../../app/views/app_failure_page.dart';
import '../../app/views/app_page.dart'; import '../../app/views/app_page.dart';
import '../../app/views/graphics.dart'; import '../../app/views/graphics.dart';
import '../../app/views/message_page.dart'; import '../../app/views/message_page.dart';
import '../../core/state.dart';
import '../features.dart' as features;
import '../keys.dart' as keys; import '../keys.dart' as keys;
import '../models.dart'; import '../models.dart';
import '../state.dart'; import '../state.dart';
@ -65,10 +67,12 @@ class _LockedView extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final hasActions = ref.watch(featureProvider)(features.actions);
return AppPage( return AppPage(
title: Text(AppLocalizations.of(context)!.s_authenticator), title: Text(AppLocalizations.of(context)!.s_authenticator),
keyActionsBuilder: (context) => keyActionsBuilder: hasActions
oathBuildActions(context, devicePath, oathState, ref), ? (context) => oathBuildActions(context, devicePath, oathState, ref)
: null,
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(vertical: 18), padding: const EdgeInsets.symmetric(vertical: 18),
child: UnlockForm( child: UnlockForm(
@ -114,15 +118,18 @@ class _UnlockedViewState extends ConsumerState<_UnlockedView> {
// ONLY rebuild if the number of credentials changes. // ONLY rebuild if the number of credentials changes.
final numCreds = ref.watch(credentialListProvider(widget.devicePath) final numCreds = ref.watch(credentialListProvider(widget.devicePath)
.select((value) => value?.length)); .select((value) => value?.length));
final hasActions = ref.watch(featureProvider)(features.actions);
if (numCreds == 0) { if (numCreds == 0) {
return MessagePage( return MessagePage(
title: Text(l10n.s_authenticator), title: Text(l10n.s_authenticator),
key: keys.noAccountsView, key: keys.noAccountsView,
graphic: noAccounts, graphic: noAccounts,
header: l10n.s_no_accounts, header: l10n.s_no_accounts,
keyActionsBuilder: (context) => oathBuildActions( keyActionsBuilder: hasActions
context, widget.devicePath, widget.oathState, ref, ? (context) => oathBuildActions(
used: 0), context, widget.devicePath, widget.oathState, ref,
used: 0)
: null,
); );
} }
return Actions( return Actions(
@ -184,13 +191,15 @@ class _UnlockedViewState extends ConsumerState<_UnlockedView> {
); );
}), }),
), ),
keyActionsBuilder: (context) => oathBuildActions( keyActionsBuilder: hasActions
context, ? (context) => oathBuildActions(
widget.devicePath, context,
widget.oathState, widget.devicePath,
ref, widget.oathState,
used: numCreds ?? 0, ref,
), used: numCreds ?? 0,
)
: null,
centered: numCreds == null, centered: numCreds == null,
delayedContent: numCreds == null, delayedContent: numCreds == null,
child: numCreds != null child: numCreds != null

View File

@ -94,6 +94,9 @@ class _UnlockFormState extends ConsumerState<UnlockForm> {
_isObscure = !_isObscure; _isObscure = !_isObscure;
}); });
}, },
tooltip: _isObscure
? l10n.s_show_password
: l10n.s_hide_password,
), ),
), ),
onChanged: (_) => setState(() { onChanged: (_) => setState(() {

31
lib/piv/features.dart Normal file
View File

@ -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');
final actionsReset = actions.feature('reset');
final slots = piv.feature('slots');
final slotsGenerate = slots.feature('generate');
final slotsImport = slots.feature('import');
final slotsExport = slots.feature('export');
final slotsDelete = slots.feature('delete');

View File

@ -25,9 +25,11 @@ import '../../app/message.dart';
import '../../app/shortcuts.dart'; import '../../app/shortcuts.dart';
import '../../app/state.dart'; import '../../app/state.dart';
import '../../app/models.dart'; import '../../app/models.dart';
import '../../core/state.dart';
import '../models.dart'; import '../models.dart';
import '../state.dart'; import '../state.dart';
import '../keys.dart' as keys; import '../keys.dart' as keys;
import '../features.dart' as features;
import 'authentication_dialog.dart'; import 'authentication_dialog.dart';
import 'delete_certificate_dialog.dart'; import 'delete_certificate_dialog.dart';
import 'generate_key_dialog.dart'; import 'generate_key_dialog.dart';
@ -75,9 +77,11 @@ Widget registerPivActions(
required WidgetRef ref, required WidgetRef ref,
required Widget Function(BuildContext context) builder, required Widget Function(BuildContext context) builder,
Map<Type, Action<Intent>> actions = const {}, Map<Type, Action<Intent>> actions = const {},
}) => }) {
Actions( final hasFeature = ref.watch(featureProvider);
actions: { return Actions(
actions: {
if (hasFeature(features.slotsGenerate))
GenerateIntent: GenerateIntent:
CallbackAction<GenerateIntent>(onInvoke: (intent) async { CallbackAction<GenerateIntent>(onInvoke: (intent) async {
final withContext = ref.read(withContextProvider); final withContext = ref.read(withContextProvider);
@ -129,6 +133,7 @@ Widget registerPivActions(
return result != null; return result != null;
}); });
}), }),
if (hasFeature(features.slotsImport))
ImportIntent: CallbackAction<ImportIntent>(onInvoke: (intent) async { ImportIntent: CallbackAction<ImportIntent>(onInvoke: (intent) async {
final withContext = ref.read(withContextProvider); final withContext = ref.read(withContextProvider);
@ -164,6 +169,7 @@ Widget registerPivActions(
) ?? ) ??
false); false);
}), }),
if (hasFeature(features.slotsExport))
ExportIntent: CallbackAction<ExportIntent>(onInvoke: (intent) async { ExportIntent: CallbackAction<ExportIntent>(onInvoke: (intent) async {
final (_, cert) = await ref final (_, cert) = await ref
.read(pivSlotsProvider(devicePath).notifier) .read(pivSlotsProvider(devicePath).notifier)
@ -198,6 +204,7 @@ Widget registerPivActions(
}); });
return true; return true;
}), }),
if (hasFeature(features.slotsDelete))
DeleteIntent: CallbackAction<DeleteIntent>(onInvoke: (_) async { DeleteIntent: CallbackAction<DeleteIntent>(onInvoke: (_) async {
final withContext = ref.read(withContextProvider); final withContext = ref.read(withContextProvider);
if (!await withContext( if (!await withContext(
@ -216,15 +223,17 @@ Widget registerPivActions(
false); false);
return deleted; return deleted;
}), }),
...actions, ...actions,
}, },
child: Builder(builder: builder), child: Builder(builder: builder),
); );
}
List<ActionItem> buildSlotActions(bool hasCert, AppLocalizations l10n) { List<ActionItem> buildSlotActions(bool hasCert, AppLocalizations l10n) {
return [ return [
ActionItem( ActionItem(
key: keys.generateAction, key: keys.generateAction,
feature: features.slotsGenerate,
icon: const Icon(Icons.add_outlined), icon: const Icon(Icons.add_outlined),
actionStyle: ActionStyle.primary, actionStyle: ActionStyle.primary,
title: l10n.s_generate_key, title: l10n.s_generate_key,
@ -233,6 +242,7 @@ List<ActionItem> buildSlotActions(bool hasCert, AppLocalizations l10n) {
), ),
ActionItem( ActionItem(
key: keys.importAction, key: keys.importAction,
feature: features.slotsImport,
icon: const Icon(Icons.file_download_outlined), icon: const Icon(Icons.file_download_outlined),
title: l10n.l_import_file, title: l10n.l_import_file,
subtitle: l10n.l_import_desc, subtitle: l10n.l_import_desc,
@ -241,6 +251,7 @@ List<ActionItem> buildSlotActions(bool hasCert, AppLocalizations l10n) {
if (hasCert) ...[ if (hasCert) ...[
ActionItem( ActionItem(
key: keys.exportAction, key: keys.exportAction,
feature: features.slotsExport,
icon: const Icon(Icons.file_upload_outlined), icon: const Icon(Icons.file_upload_outlined),
title: l10n.l_export_certificate, title: l10n.l_export_certificate,
subtitle: l10n.l_export_certificate_desc, subtitle: l10n.l_export_certificate_desc,
@ -248,6 +259,7 @@ List<ActionItem> buildSlotActions(bool hasCert, AppLocalizations l10n) {
), ),
ActionItem( ActionItem(
key: keys.deleteAction, key: keys.deleteAction,
feature: features.slotsDelete,
actionStyle: ActionStyle.error, actionStyle: ActionStyle.error,
icon: const Icon(Icons.delete_outline), icon: const Icon(Icons.delete_outline),
title: l10n.l_delete_certificate, title: l10n.l_delete_certificate,

View File

@ -24,6 +24,7 @@ import '../../app/views/fs_dialog.dart';
import '../../app/views/action_list.dart'; import '../../app/views/action_list.dart';
import '../models.dart'; import '../models.dart';
import '../keys.dart' as keys; import '../keys.dart' as keys;
import '../features.dart' as features;
import 'manage_key_dialog.dart'; import 'manage_key_dialog.dart';
import 'manage_pin_puk_dialog.dart'; import 'manage_pin_puk_dialog.dart';
import 'reset_dialog.dart'; import 'reset_dialog.dart';
@ -49,6 +50,7 @@ Widget pivBuildActions(BuildContext context, DevicePath devicePath,
children: [ children: [
ActionListItem( ActionListItem(
key: keys.managePinAction, key: keys.managePinAction,
feature: features.actionsPin,
title: l10n.s_pin, title: l10n.s_pin,
subtitle: pinBlocked subtitle: pinBlocked
? (pukAttempts != 0 ? (pukAttempts != 0
@ -73,6 +75,7 @@ Widget pivBuildActions(BuildContext context, DevicePath devicePath,
: null), : null),
ActionListItem( ActionListItem(
key: keys.managePukAction, key: keys.managePukAction,
feature: features.actionsPuk,
title: l10n.s_puk, title: l10n.s_puk,
subtitle: pukAttempts != null subtitle: pukAttempts != null
? (pukAttempts == 0 ? (pukAttempts == 0
@ -93,6 +96,7 @@ Widget pivBuildActions(BuildContext context, DevicePath devicePath,
: null), : null),
ActionListItem( ActionListItem(
key: keys.manageManagementKeyAction, key: keys.manageManagementKeyAction,
feature: features.actionsManagementKey,
title: l10n.s_management_key, title: l10n.s_management_key,
subtitle: usingDefaultMgmtKey subtitle: usingDefaultMgmtKey
? l10n.l_warning_default_key ? l10n.l_warning_default_key
@ -110,6 +114,7 @@ Widget pivBuildActions(BuildContext context, DevicePath devicePath,
}), }),
ActionListItem( ActionListItem(
key: keys.resetAction, key: keys.resetAction,
feature: features.actionsReset,
icon: const Icon(Icons.delete_outline), icon: const Icon(Icons.delete_outline),
actionStyle: ActionStyle.error, actionStyle: ActionStyle.error,
title: l10n.s_reset_piv, title: l10n.s_reset_piv,

View File

@ -110,6 +110,7 @@ class _PinDialogState extends ConsumerState<PinDialog> {
_isObscure = !_isObscure; _isObscure = !_isObscure;
}); });
}, },
tooltip: _isObscure ? l10n.s_show_pin : l10n.s_hide_pin,
), ),
), ),
textInputAction: TextInputAction.next, textInputAction: TextInputAction.next,

View File

@ -25,9 +25,11 @@ import '../../app/views/app_failure_page.dart';
import '../../app/views/app_list_item.dart'; import '../../app/views/app_list_item.dart';
import '../../app/views/app_page.dart'; import '../../app/views/app_page.dart';
import '../../app/views/message_page.dart'; import '../../app/views/message_page.dart';
import '../../core/state.dart';
import '../../widgets/list_title.dart'; import '../../widgets/list_title.dart';
import '../models.dart'; import '../models.dart';
import '../state.dart'; import '../state.dart';
import '../features.dart' as features;
import 'actions.dart'; import 'actions.dart';
import 'key_actions.dart'; import 'key_actions.dart';
import 'slot_dialog.dart'; import 'slot_dialog.dart';
@ -40,6 +42,7 @@ class PivScreen extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
final hasFeature = ref.watch(featureProvider);
return ref.watch(pivStateProvider(devicePath)).when( return ref.watch(pivStateProvider(devicePath)).when(
loading: () => MessagePage( loading: () => MessagePage(
title: Text(l10n.s_piv), title: Text(l10n.s_piv),
@ -54,8 +57,10 @@ class PivScreen extends ConsumerWidget {
final pivSlots = ref.watch(pivSlotsProvider(devicePath)).asData; final pivSlots = ref.watch(pivSlotsProvider(devicePath)).asData;
return AppPage( return AppPage(
title: Text(l10n.s_piv), title: Text(l10n.s_piv),
keyActionsBuilder: (context) => keyActionsBuilder: hasFeature(features.actions)
pivBuildActions(context, devicePath, pivState, ref), ? (context) =>
pivBuildActions(context, devicePath, pivState, ref)
: null,
child: Column( child: Column(
children: [ children: [
ListTitle(l10n.s_certificates), ListTitle(l10n.s_certificates),
@ -86,16 +91,17 @@ class PivScreen extends ConsumerWidget {
} }
} }
class _CertificateListItem extends StatelessWidget { class _CertificateListItem extends ConsumerWidget {
final PivSlot pivSlot; final PivSlot pivSlot;
const _CertificateListItem(this.pivSlot); const _CertificateListItem(this.pivSlot);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context, WidgetRef ref) {
final slot = pivSlot.slot; final slot = pivSlot.slot;
final certInfo = pivSlot.certInfo; final certInfo = pivSlot.certInfo;
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final hasFeature = ref.watch(featureProvider);
return Semantics( return Semantics(
label: slot.getDisplayName(l10n), label: slot.getDisplayName(l10n),
@ -116,8 +122,9 @@ class _CertificateListItem extends StatelessWidget {
onPressed: Actions.handler(context, const OpenIntent()), onPressed: Actions.handler(context, const OpenIntent()),
child: const Icon(Icons.more_horiz), child: const Icon(Icons.more_horiz),
), ),
buildPopupActions: (context) => buildPopupActions: hasFeature(features.slots)
buildSlotActions(certInfo != null, l10n), ? (context) => buildSlotActions(certInfo != null, l10n)
: null,
)); ));
} }
} }

View File

@ -17,6 +17,8 @@
import 'package:desktop_drop/desktop_drop.dart'; import 'package:desktop_drop/desktop_drop.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../core/state.dart';
class FileDropTarget extends StatefulWidget { class FileDropTarget extends StatefulWidget {
final Widget child; final Widget child;
final Function(List<int> filedata) onFileDropped; final Function(List<int> filedata) onFileDropped;
@ -64,6 +66,7 @@ class _FileDropTargetState extends State<FileDropTarget> {
widget.onFileDropped(await file.readAsBytes()); widget.onFileDropped(await file.readAsBytes());
} }
}, },
enable: !isAndroid,
child: Stack( child: Stack(
alignment: Alignment.center, alignment: Alignment.center,
children: [ children: [

View File

@ -54,6 +54,7 @@ class _ResponsiveDialogState extends State<ResponsiveDialog> {
title: widget.title, title: widget.title,
actions: widget.actions, actions: widget.actions,
leading: IconButton( leading: IconButton(
tooltip: AppLocalizations.of(context)!.s_close,
icon: const Icon(Icons.close), icon: const Icon(Icons.close),
onPressed: widget.allowCancel onPressed: widget.allowCancel
? () { ? () {