This commit is contained in:
Adam Velebil 2024-09-11 12:46:32 +02:00
commit 7fa2786ccd
No known key found for this signature in database
GPG Key ID: C9B1E4A3CBBD2E10
44 changed files with 1509 additions and 864 deletions

View File

@ -22,9 +22,13 @@ import com.yubico.yubikit.core.YubiKeyDevice
* Provides behavior to run when a YubiKey is inserted/tapped for a specific view of the app. * Provides behavior to run when a YubiKey is inserted/tapped for a specific view of the app.
*/ */
abstract class AppContextManager { abstract class AppContextManager {
abstract suspend fun processYubiKey(device: YubiKeyDevice) abstract suspend fun processYubiKey(device: YubiKeyDevice): Boolean
open fun dispose() {} open fun dispose() {}
open fun onPause() {} open fun onPause() {}
open fun onError() {}
} }
class ContextDisposedException : Exception()

View File

@ -1,105 +0,0 @@
/*
* Copyright (C) 2022-2023 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* 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.
*/
package com.yubico.authenticator
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodChannel
import kotlinx.coroutines.*
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
typealias OnDialogCancelled = suspend () -> Unit
enum class DialogIcon(val value: Int) {
Nfc(0),
Success(1),
Failure(2);
}
enum class DialogTitle(val value: Int) {
TapKey(0),
OperationSuccessful(1),
OperationFailed(2)
}
class DialogManager(messenger: BinaryMessenger, private val coroutineScope: CoroutineScope) {
private val channel =
MethodChannel(messenger, "com.yubico.authenticator.channel.dialog")
private var onCancelled: OnDialogCancelled? = null
init {
channel.setHandler(coroutineScope) { method, _ ->
when (method) {
"cancel" -> dialogClosed()
else -> throw NotImplementedError()
}
}
}
fun showDialog(
dialogIcon: DialogIcon,
dialogTitle: DialogTitle,
dialogDescriptionId: Int,
cancelled: OnDialogCancelled?
) {
onCancelled = cancelled
coroutineScope.launch {
channel.invoke(
"show",
Json.encodeToString(
mapOf(
"title" to dialogTitle.value,
"description" to dialogDescriptionId,
"icon" to dialogIcon.value
)
)
)
}
}
suspend fun updateDialogState(
dialogIcon: DialogIcon? = null,
dialogTitle: DialogTitle,
dialogDescriptionId: Int? = null,
) {
channel.invoke(
"state",
Json.encodeToString(
mapOf(
"title" to dialogTitle.value,
"description" to dialogDescriptionId,
"icon" to dialogIcon?.value
)
)
)
}
suspend fun closeDialog() {
channel.invoke("close", NULL)
}
private suspend fun dialogClosed(): String {
onCancelled?.let {
onCancelled = null
withContext(Dispatchers.Main) {
it.invoke()
}
}
return NULL
}
}

View File

@ -16,8 +16,11 @@
package com.yubico.authenticator package com.yubico.authenticator
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.*
import android.content.SharedPreferences.OnSharedPreferenceChangeListener import android.content.SharedPreferences.OnSharedPreferenceChangeListener
import android.content.pm.ActivityInfo import android.content.pm.ActivityInfo
import android.content.pm.PackageManager import android.content.pm.PackageManager
@ -48,13 +51,18 @@ import com.yubico.authenticator.management.ManagementHandler
import com.yubico.authenticator.oath.AppLinkMethodChannel import com.yubico.authenticator.oath.AppLinkMethodChannel
import com.yubico.authenticator.oath.OathManager import com.yubico.authenticator.oath.OathManager
import com.yubico.authenticator.oath.OathViewModel import com.yubico.authenticator.oath.OathViewModel
import com.yubico.authenticator.yubikit.NfcStateDispatcher
import com.yubico.authenticator.yubikit.NfcStateListener
import com.yubico.authenticator.yubikit.NfcState
import com.yubico.authenticator.yubikit.DeviceInfoHelper.Companion.getDeviceInfo import com.yubico.authenticator.yubikit.DeviceInfoHelper.Companion.getDeviceInfo
import com.yubico.authenticator.yubikit.withConnection import com.yubico.authenticator.yubikit.withConnection
import com.yubico.yubikit.android.YubiKitManager import com.yubico.yubikit.android.YubiKitManager
import com.yubico.yubikit.android.transport.nfc.NfcConfiguration import com.yubico.yubikit.android.transport.nfc.NfcConfiguration
import com.yubico.yubikit.android.transport.nfc.NfcNotAvailable import com.yubico.yubikit.android.transport.nfc.NfcNotAvailable
import com.yubico.yubikit.android.transport.nfc.NfcYubiKeyDevice import com.yubico.yubikit.android.transport.nfc.NfcYubiKeyDevice
import com.yubico.yubikit.android.transport.nfc.NfcYubiKeyManager
import com.yubico.yubikit.android.transport.usb.UsbConfiguration import com.yubico.yubikit.android.transport.usb.UsbConfiguration
import com.yubico.yubikit.android.transport.usb.UsbYubiKeyManager
import com.yubico.yubikit.core.Transport import com.yubico.yubikit.core.Transport
import com.yubico.yubikit.core.YubiKeyDevice import com.yubico.yubikit.core.YubiKeyDevice
import com.yubico.yubikit.core.smartcard.SmartCardConnection import com.yubico.yubikit.core.smartcard.SmartCardConnection
@ -66,6 +74,7 @@ import io.flutter.embedding.android.FlutterFragmentActivity
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.BinaryMessenger import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.json.JSONObject import org.json.JSONObject
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
@ -94,6 +103,20 @@ class MainActivity : FlutterFragmentActivity() {
private val logger = LoggerFactory.getLogger(MainActivity::class.java) private val logger = LoggerFactory.getLogger(MainActivity::class.java)
private val nfcStateListener = object : NfcStateListener {
var appMethodChannel : AppMethodChannel? = null
override fun onChange(newState: NfcState) {
appMethodChannel?.let {
logger.debug("set nfc state to ${newState.name}")
it.nfcStateChanged(newState)
} ?: {
logger.warn("failed set nfc state to ${newState.name} - no method channel")
}
}
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -105,7 +128,10 @@ class MainActivity : FlutterFragmentActivity() {
allowScreenshots(false) allowScreenshots(false)
yubikit = YubiKitManager(this) yubikit = YubiKitManager(
UsbYubiKeyManager(this),
NfcYubiKeyManager(this, NfcStateDispatcher(nfcStateListener))
)
} }
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {
@ -291,10 +317,15 @@ class MainActivity : FlutterFragmentActivity() {
return return
} }
if (device is NfcYubiKeyDevice) {
appMethodChannel.nfcStateChanged(NfcState.ONGOING)
}
deviceManager.scpKeyParams = null
// If NFC and FIPS check for SCP11b key // If NFC and FIPS check for SCP11b key
if (device.transport == Transport.NFC && deviceInfo.fipsCapable != 0) { if (device.transport == Transport.NFC && deviceInfo.fipsCapable != 0) {
logger.debug("Checking for usable SCP11b key...") logger.debug("Checking for usable SCP11b key...")
deviceManager.scpKeyParams = deviceManager.scpKeyParams = try {
device.withConnection<SmartCardConnection, ScpKeyParams?> { connection -> device.withConnection<SmartCardConnection, ScpKeyParams?> { connection ->
val scp = SecurityDomainSession(connection) val scp = SecurityDomainSession(connection)
val keyRef = scp.keyInformation.keys.firstOrNull { it.kid == ScpKid.SCP11b } val keyRef = scp.keyInformation.keys.firstOrNull { it.kid == ScpKid.SCP11b }
@ -308,6 +339,14 @@ class MainActivity : FlutterFragmentActivity() {
logger.debug("Found SCP11b key: {}", keyRef) logger.debug("Found SCP11b key: {}", keyRef)
} }
} }
} catch (e: Exception) {
logger.debug("Exception while getting scp keys: ", e)
contextManager?.onError()
if (device is NfcYubiKeyDevice) {
appMethodChannel.nfcStateChanged(NfcState.FAILURE)
}
null
}
} }
// this YubiKey provides SCP11b key but the phone cannot perform AESCMAC // this YubiKey provides SCP11b key but the phone cannot perform AESCMAC
@ -319,6 +358,7 @@ class MainActivity : FlutterFragmentActivity() {
deviceManager.setDeviceInfo(deviceInfo) deviceManager.setDeviceInfo(deviceInfo)
val supportedContexts = DeviceManager.getSupportedContexts(deviceInfo) val supportedContexts = DeviceManager.getSupportedContexts(deviceInfo)
logger.debug("Connected key supports: {}", supportedContexts) logger.debug("Connected key supports: {}", supportedContexts)
var switchedContext: Boolean = false
if (!supportedContexts.contains(viewModel.appContext.value)) { if (!supportedContexts.contains(viewModel.appContext.value)) {
val preferredContext = DeviceManager.getPreferredContext(supportedContexts) val preferredContext = DeviceManager.getPreferredContext(supportedContexts)
logger.debug( logger.debug(
@ -326,18 +366,28 @@ class MainActivity : FlutterFragmentActivity() {
viewModel.appContext.value, viewModel.appContext.value,
preferredContext preferredContext
) )
switchContext(preferredContext) switchedContext = switchContext(preferredContext)
} }
if (contextManager == null && supportedContexts.isNotEmpty()) { if (contextManager == null && supportedContexts.isNotEmpty()) {
switchContext(DeviceManager.getPreferredContext(supportedContexts)) switchedContext = switchContext(DeviceManager.getPreferredContext(supportedContexts))
} }
contextManager?.let { contextManager?.let {
try { try {
it.processYubiKey(device) val requestHandled = it.processYubiKey(device)
} catch (e: Throwable) { if (requestHandled) {
logger.error("Error processing YubiKey in AppContextManager", e) appMethodChannel.nfcStateChanged(NfcState.SUCCESS)
}
if (!switchedContext && device is NfcYubiKeyDevice) {
device.remove {
appMethodChannel.nfcStateChanged(NfcState.IDLE)
}
}
} catch (e: Exception) {
logger.debug("Caught Exception during YubiKey processing: ", e)
appMethodChannel.nfcStateChanged(NfcState.FAILURE)
} }
} }
} }
@ -351,7 +401,7 @@ class MainActivity : FlutterFragmentActivity() {
private var contextManager: AppContextManager? = null private var contextManager: AppContextManager? = null
private lateinit var deviceManager: DeviceManager private lateinit var deviceManager: DeviceManager
private lateinit var appContext: AppContext private lateinit var appContext: AppContext
private lateinit var dialogManager: DialogManager private lateinit var nfcOverlayManager: NfcOverlayManager
private lateinit var appPreferences: AppPreferences private lateinit var appPreferences: AppPreferences
private lateinit var flutterLog: FlutterLog private lateinit var flutterLog: FlutterLog
private lateinit var flutterStreams: List<Closeable> private lateinit var flutterStreams: List<Closeable>
@ -365,13 +415,16 @@ class MainActivity : FlutterFragmentActivity() {
messenger = flutterEngine.dartExecutor.binaryMessenger messenger = flutterEngine.dartExecutor.binaryMessenger
flutterLog = FlutterLog(messenger) flutterLog = FlutterLog(messenger)
deviceManager = DeviceManager(this, viewModel)
appContext = AppContext(messenger, this.lifecycleScope, viewModel)
dialogManager = DialogManager(messenger, this.lifecycleScope)
appPreferences = AppPreferences(this)
appMethodChannel = AppMethodChannel(messenger) appMethodChannel = AppMethodChannel(messenger)
nfcOverlayManager = NfcOverlayManager(messenger, this.lifecycleScope)
deviceManager = DeviceManager(this, viewModel,appMethodChannel, nfcOverlayManager)
appContext = AppContext(messenger, this.lifecycleScope, viewModel)
appPreferences = AppPreferences(this)
appLinkMethodChannel = AppLinkMethodChannel(messenger) appLinkMethodChannel = AppLinkMethodChannel(messenger)
managementHandler = ManagementHandler(messenger, deviceManager, dialogManager) managementHandler = ManagementHandler(messenger, deviceManager)
nfcStateListener.appMethodChannel = appMethodChannel
flutterStreams = listOf( flutterStreams = listOf(
viewModel.deviceInfo.streamTo(this, messenger, "android.devices.deviceInfo"), viewModel.deviceInfo.streamTo(this, messenger, "android.devices.deviceInfo"),
@ -390,7 +443,8 @@ class MainActivity : FlutterFragmentActivity() {
} }
} }
private fun switchContext(appContext: OperationContext) { private fun switchContext(appContext: OperationContext) : Boolean {
var switchHappened = false
// TODO: refactor this when more OperationContext are handled // TODO: refactor this when more OperationContext are handled
// only recreate the contextManager object if it cannot be reused // only recreate the contextManager object if it cannot be reused
if (appContext == OperationContext.Home || if (appContext == OperationContext.Home ||
@ -404,6 +458,7 @@ class MainActivity : FlutterFragmentActivity() {
} else { } else {
contextManager?.dispose() contextManager?.dispose()
contextManager = null contextManager = null
switchHappened = true
} }
if (contextManager == null) { if (contextManager == null) {
@ -413,7 +468,7 @@ class MainActivity : FlutterFragmentActivity() {
messenger, messenger,
deviceManager, deviceManager,
oathViewModel, oathViewModel,
dialogManager, nfcOverlayManager,
appPreferences appPreferences
) )
@ -422,17 +477,20 @@ class MainActivity : FlutterFragmentActivity() {
messenger, messenger,
this, this,
deviceManager, deviceManager,
appMethodChannel,
nfcOverlayManager,
fidoViewModel, fidoViewModel,
viewModel, viewModel
dialogManager
) )
else -> null else -> null
} }
} }
return switchHappened
} }
override fun cleanUpFlutterEngine(flutterEngine: FlutterEngine) { override fun cleanUpFlutterEngine(flutterEngine: FlutterEngine) {
nfcStateListener.appMethodChannel = null
flutterStreams.forEach { it.close() } flutterStreams.forEach { it.close() }
contextManager?.dispose() contextManager?.dispose()
deviceManager.dispose() deviceManager.dispose()
@ -572,9 +630,18 @@ class MainActivity : FlutterFragmentActivity() {
fun nfcAdapterStateChanged(value: Boolean) { fun nfcAdapterStateChanged(value: Boolean) {
methodChannel.invokeMethod( methodChannel.invokeMethod(
"nfcAdapterStateChanged", "nfcAdapterStateChanged",
JSONObject(mapOf("nfcEnabled" to value)).toString() JSONObject(mapOf("enabled" to value)).toString()
) )
} }
fun nfcStateChanged(activityState: NfcState) {
lifecycleScope.launch(Dispatchers.Main) {
methodChannel.invokeMethod(
"nfcStateChanged",
JSONObject(mapOf("state" to activityState.value)).toString()
)
}
}
} }
private fun allowScreenshots(value: Boolean): Boolean { private fun allowScreenshots(value: Boolean): Boolean {

View File

@ -0,0 +1,63 @@
/*
* Copyright (C) 2022-2024 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.
*/
package com.yubico.authenticator
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodChannel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
typealias OnCancelled = suspend () -> Unit
class NfcOverlayManager(messenger: BinaryMessenger, private val coroutineScope: CoroutineScope) {
private val channel =
MethodChannel(messenger, "com.yubico.authenticator.channel.nfc_overlay")
private var onCancelled: OnCancelled? = null
init {
channel.setHandler(coroutineScope) { method, _ ->
when (method) {
"cancel" -> onClosed()
else -> throw NotImplementedError()
}
}
}
fun show(cancelled: OnCancelled?) {
onCancelled = cancelled
coroutineScope.launch {
channel.invoke("show", null)
}
}
suspend fun close() {
channel.invoke("close", NULL)
}
private suspend fun onClosed(): String {
onCancelled?.let {
onCancelled = null
withContext(Dispatchers.Main) {
it.invoke()
}
}
return NULL
}
}

View File

@ -20,13 +20,19 @@ import androidx.collection.ArraySet
import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.Observer import androidx.lifecycle.Observer
import com.yubico.authenticator.ContextDisposedException
import com.yubico.authenticator.MainActivity
import com.yubico.authenticator.MainViewModel import com.yubico.authenticator.MainViewModel
import com.yubico.authenticator.NfcOverlayManager
import com.yubico.authenticator.OperationContext import com.yubico.authenticator.OperationContext
import com.yubico.authenticator.yubikit.NfcState
import com.yubico.yubikit.android.transport.usb.UsbYubiKeyDevice import com.yubico.yubikit.android.transport.usb.UsbYubiKeyDevice
import com.yubico.yubikit.core.YubiKeyDevice import com.yubico.yubikit.core.YubiKeyDevice
import com.yubico.yubikit.core.smartcard.scp.ScpKeyParams import com.yubico.yubikit.core.smartcard.scp.ScpKeyParams
import com.yubico.yubikit.management.Capability import com.yubico.yubikit.management.Capability
import kotlinx.coroutines.CancellationException
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import java.io.IOException
interface DeviceListener { interface DeviceListener {
// a USB device is connected // a USB device is connected
@ -41,7 +47,9 @@ interface DeviceListener {
class DeviceManager( class DeviceManager(
private val lifecycleOwner: LifecycleOwner, private val lifecycleOwner: LifecycleOwner,
private val appViewModel: MainViewModel private val appViewModel: MainViewModel,
private val appMethodChannel: MainActivity.AppMethodChannel,
private val nfcOverlayManager: NfcOverlayManager
) { ) {
var clearDeviceInfoOnDisconnect: Boolean = true var clearDeviceInfoOnDisconnect: Boolean = true
@ -167,7 +175,6 @@ class DeviceManager(
fun setDeviceInfo(deviceInfo: Info?) { fun setDeviceInfo(deviceInfo: Info?) {
appViewModel.setDeviceInfo(deviceInfo) appViewModel.setDeviceInfo(deviceInfo)
scpKeyParams = null
} }
fun isUsbKeyConnected(): Boolean { fun isUsbKeyConnected(): Boolean {
@ -179,8 +186,32 @@ class DeviceManager(
onUsb(it) onUsb(it)
} }
suspend fun <T> withKey(onNfc: suspend () -> T, onUsb: suspend (UsbYubiKeyDevice) -> T) = suspend fun <T> withKey(
onUsb: suspend (UsbYubiKeyDevice) -> T,
onNfc: suspend () -> com.yubico.yubikit.core.util.Result<T, Throwable>,
onCancelled: () -> Unit
): T =
appViewModel.connectedYubiKey.value?.let { appViewModel.connectedYubiKey.value?.let {
onUsb(it) onUsb(it)
} ?: onNfc() } ?: onNfc(onNfc, onCancelled)
private suspend fun <T> onNfc(
onNfc: suspend () -> com.yubico.yubikit.core.util.Result<T, Throwable>,
onCancelled: () -> Unit
): T {
nfcOverlayManager.show {
logger.debug("NFC action was cancelled")
onCancelled.invoke()
}
try {
return onNfc.invoke().value.also {
appMethodChannel.nfcStateChanged(NfcState.SUCCESS)
}
} catch (e: Exception) {
appMethodChannel.nfcStateChanged(NfcState.FAILURE)
throw e
}
}
} }

View File

@ -16,9 +16,6 @@
package com.yubico.authenticator.fido package com.yubico.authenticator.fido
import com.yubico.authenticator.DialogIcon
import com.yubico.authenticator.DialogManager
import com.yubico.authenticator.DialogTitle
import com.yubico.authenticator.device.DeviceManager import com.yubico.authenticator.device.DeviceManager
import com.yubico.authenticator.fido.data.YubiKitFidoSession import com.yubico.authenticator.fido.data.YubiKitFidoSession
import com.yubico.authenticator.yubikit.DeviceInfoHelper.Companion.getDeviceInfo import com.yubico.authenticator.yubikit.DeviceInfoHelper.Companion.getDeviceInfo
@ -27,18 +24,28 @@ import com.yubico.yubikit.android.transport.usb.UsbYubiKeyDevice
import com.yubico.yubikit.core.fido.FidoConnection import com.yubico.yubikit.core.fido.FidoConnection
import com.yubico.yubikit.core.util.Result import com.yubico.yubikit.core.util.Result
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import java.util.TimerTask
import kotlin.coroutines.cancellation.CancellationException import kotlin.coroutines.cancellation.CancellationException
import kotlin.coroutines.suspendCoroutine import kotlin.coroutines.suspendCoroutine
class FidoConnectionHelper( class FidoConnectionHelper(private val deviceManager: DeviceManager) {
private val deviceManager: DeviceManager,
private val dialogManager: DialogManager
) {
private var pendingAction: FidoAction? = null private var pendingAction: FidoAction? = null
fun invokePending(fidoSession: YubiKitFidoSession) { fun invokePending(fidoSession: YubiKitFidoSession): Boolean {
var requestHandled = true
pendingAction?.let { action -> pendingAction?.let { action ->
pendingAction = null
// it is the pending action who handles this request
requestHandled = false
action.invoke(Result.success(fidoSession)) action.invoke(Result.success(fidoSession))
}
return requestHandled
}
fun failPending(e: Exception) {
pendingAction?.let { action ->
logger.error("Failing pending action with {}", e.message)
action.invoke(Result.failure(e))
pendingAction = null pendingAction = null
} }
} }
@ -51,14 +58,18 @@ class FidoConnectionHelper(
} }
suspend fun <T> useSession( suspend fun <T> useSession(
actionDescription: FidoActionDescription,
updateDeviceInfo: Boolean = false, updateDeviceInfo: Boolean = false,
action: (YubiKitFidoSession) -> T block: (YubiKitFidoSession) -> T
): T { ): T {
FidoManager.updateDeviceInfo.set(updateDeviceInfo) FidoManager.updateDeviceInfo.set(updateDeviceInfo)
return deviceManager.withKey( return deviceManager.withKey(
onNfc = { useSessionNfc(actionDescription,action) }, onUsb = { useSessionUsb(it, updateDeviceInfo, block) },
onUsb = { useSessionUsb(it, updateDeviceInfo, action) }) onNfc = { useSessionNfc(block) },
onCancelled = {
pendingAction?.invoke(Result.failure(CancellationException()))
pendingAction = null
}
)
} }
suspend fun <T> useSessionUsb( suspend fun <T> useSessionUsb(
@ -74,9 +85,8 @@ class FidoConnectionHelper(
} }
suspend fun <T> useSessionNfc( suspend fun <T> useSessionNfc(
actionDescription: FidoActionDescription,
block: (YubiKitFidoSession) -> T block: (YubiKitFidoSession) -> T
): T { ): Result<T, Throwable> {
try { try {
val result = suspendCoroutine { outer -> val result = suspendCoroutine { outer ->
pendingAction = { pendingAction = {
@ -84,23 +94,13 @@ class FidoConnectionHelper(
block.invoke(it.value) block.invoke(it.value)
}) })
} }
dialogManager.showDialog(
DialogIcon.Nfc,
DialogTitle.TapKey,
actionDescription.id
) {
logger.debug("Cancelled Dialog {}", actionDescription.name)
pendingAction?.invoke(Result.failure(CancellationException()))
pendingAction = null
}
} }
return result return Result.success(result!!)
} catch (cancelled: CancellationException) { } catch (cancelled: CancellationException) {
throw cancelled return Result.failure(cancelled)
} catch (error: Throwable) { } catch (error: Throwable) {
throw error logger.error("Exception during action: ", error)
} finally { return Result.failure(error)
dialogManager.closeDialog()
} }
} }

View File

@ -18,7 +18,8 @@ package com.yubico.authenticator.fido
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import com.yubico.authenticator.AppContextManager import com.yubico.authenticator.AppContextManager
import com.yubico.authenticator.DialogManager import com.yubico.authenticator.NfcOverlayManager
import com.yubico.authenticator.MainActivity
import com.yubico.authenticator.MainViewModel import com.yubico.authenticator.MainViewModel
import com.yubico.authenticator.NULL import com.yubico.authenticator.NULL
import com.yubico.authenticator.asString import com.yubico.authenticator.asString
@ -70,9 +71,10 @@ class FidoManager(
messenger: BinaryMessenger, messenger: BinaryMessenger,
lifecycleOwner: LifecycleOwner, lifecycleOwner: LifecycleOwner,
private val deviceManager: DeviceManager, private val deviceManager: DeviceManager,
private val appMethodChannel: MainActivity.AppMethodChannel,
private val nfcOverlayManager: NfcOverlayManager,
private val fidoViewModel: FidoViewModel, private val fidoViewModel: FidoViewModel,
mainViewModel: MainViewModel, mainViewModel: MainViewModel
dialogManager: DialogManager,
) : AppContextManager(), DeviceListener { ) : AppContextManager(), DeviceListener {
@OptIn(ExperimentalStdlibApi::class) @OptIn(ExperimentalStdlibApi::class)
@ -100,7 +102,7 @@ class FidoManager(
} }
} }
private val connectionHelper = FidoConnectionHelper(deviceManager, dialogManager) private val connectionHelper = FidoConnectionHelper(deviceManager)
private val dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher() private val dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
private val coroutineScope = CoroutineScope(SupervisorJob() + dispatcher) private val coroutineScope = CoroutineScope(SupervisorJob() + dispatcher)
@ -117,14 +119,14 @@ class FidoManager(
FidoResetHelper( FidoResetHelper(
lifecycleOwner, lifecycleOwner,
deviceManager, deviceManager,
appMethodChannel,
nfcOverlayManager,
fidoViewModel, fidoViewModel,
mainViewModel, mainViewModel,
connectionHelper, connectionHelper,
pinStore pinStore
) )
init { init {
pinRetries = null pinRetries = null
@ -172,6 +174,12 @@ class FidoManager(
} }
} }
override fun onError() {
super.onError()
logger.debug("Cancel any pending action because of upstream error")
connectionHelper.cancelPending()
}
override fun dispose() { override fun dispose() {
super.dispose() super.dispose()
deviceManager.removeDeviceListener(this) deviceManager.removeDeviceListener(this)
@ -182,15 +190,16 @@ class FidoManager(
coroutineScope.cancel() coroutineScope.cancel()
} }
override suspend fun processYubiKey(device: YubiKeyDevice) { override suspend fun processYubiKey(device: YubiKeyDevice): Boolean {
var requestHandled = true
try { try {
if (device.supportsConnection(FidoConnection::class.java)) { if (device.supportsConnection(FidoConnection::class.java)) {
device.withConnection<FidoConnection, Unit> { connection -> device.withConnection<FidoConnection, Unit> { connection ->
processYubiKey(connection, device) requestHandled = processYubiKey(connection, device)
} }
} else { } else {
device.withConnection<SmartCardConnection, Unit> { connection -> device.withConnection<SmartCardConnection, Unit> { connection ->
processYubiKey(connection, device) requestHandled = processYubiKey(connection, device)
} }
} }
@ -201,13 +210,21 @@ class FidoManager(
// something went wrong, try to get DeviceInfo from any available connection type // something went wrong, try to get DeviceInfo from any available connection type
logger.error("Failure when processing YubiKey: ", e) logger.error("Failure when processing YubiKey: ", e)
// Clear any cached FIDO state connectionHelper.failPending(e)
fidoViewModel.clearSessionState()
if (e !is IOException) {
// we don't clear the session on IOExceptions so that the session is ready for
// a possible re-run of a failed action.
fidoViewModel.clearSessionState()
}
throw e
} }
return requestHandled
} }
private fun processYubiKey(connection: YubiKeyConnection, device: YubiKeyDevice) { private fun processYubiKey(connection: YubiKeyConnection, device: YubiKeyDevice): Boolean {
var requestHandled = true
val fidoSession = val fidoSession =
if (connection is FidoConnection) { if (connection is FidoConnection) {
YubiKitFidoSession(connection) YubiKitFidoSession(connection)
@ -226,7 +243,7 @@ class FidoManager(
val sameDevice = currentSession == previousSession val sameDevice = currentSession == previousSession
if (device is NfcYubiKeyDevice && (sameDevice || resetHelper.inProgress)) { if (device is NfcYubiKeyDevice && (sameDevice || resetHelper.inProgress)) {
connectionHelper.invokePending(fidoSession) requestHandled = connectionHelper.invokePending(fidoSession)
} else { } else {
if (!sameDevice) { if (!sameDevice) {
@ -250,6 +267,8 @@ class FidoManager(
Session(infoData, pinStore.hasPin(), pinRetries) Session(infoData, pinStore.hasPin(), pinRetries)
) )
} }
return requestHandled
} }
private fun getPinPermissionsCM(fidoSession: YubiKitFidoSession): Int { private fun getPinPermissionsCM(fidoSession: YubiKitFidoSession): Int {
@ -353,7 +372,7 @@ class FidoManager(
} }
private suspend fun unlock(pin: CharArray): String = private suspend fun unlock(pin: CharArray): String =
connectionHelper.useSession(FidoActionDescription.Unlock) { fidoSession -> connectionHelper.useSession { fidoSession ->
try { try {
val clientPin = val clientPin =
@ -390,7 +409,7 @@ class FidoManager(
} }
private suspend fun setPin(pin: CharArray?, newPin: CharArray): String = private suspend fun setPin(pin: CharArray?, newPin: CharArray): String =
connectionHelper.useSession(FidoActionDescription.SetPin, updateDeviceInfo = true) { fidoSession -> connectionHelper.useSession(updateDeviceInfo = true) { fidoSession ->
try { try {
val clientPin = val clientPin =
ClientPin(fidoSession, getPreferredPinUvAuthProtocol(fidoSession.cachedInfo)) ClientPin(fidoSession, getPreferredPinUvAuthProtocol(fidoSession.cachedInfo))
@ -438,7 +457,7 @@ class FidoManager(
} }
private suspend fun deleteCredential(rpId: String, credentialId: String): String = private suspend fun deleteCredential(rpId: String, credentialId: String): String =
connectionHelper.useSession(FidoActionDescription.DeleteCredential) { fidoSession -> connectionHelper.useSession { fidoSession ->
val clientPin = val clientPin =
ClientPin(fidoSession, getPreferredPinUvAuthProtocol(fidoSession.cachedInfo)) ClientPin(fidoSession, getPreferredPinUvAuthProtocol(fidoSession.cachedInfo))
@ -486,7 +505,7 @@ class FidoManager(
} }
private suspend fun deleteFingerprint(templateId: String): String = private suspend fun deleteFingerprint(templateId: String): String =
connectionHelper.useSession(FidoActionDescription.DeleteFingerprint) { fidoSession -> connectionHelper.useSession { fidoSession ->
val clientPin = val clientPin =
ClientPin(fidoSession, getPreferredPinUvAuthProtocol(fidoSession.cachedInfo)) ClientPin(fidoSession, getPreferredPinUvAuthProtocol(fidoSession.cachedInfo))
@ -511,7 +530,7 @@ class FidoManager(
} }
private suspend fun renameFingerprint(templateId: String, name: String): String = private suspend fun renameFingerprint(templateId: String, name: String): String =
connectionHelper.useSession(FidoActionDescription.RenameFingerprint) { fidoSession -> connectionHelper.useSession { fidoSession ->
val clientPin = val clientPin =
ClientPin(fidoSession, getPreferredPinUvAuthProtocol(fidoSession.cachedInfo)) ClientPin(fidoSession, getPreferredPinUvAuthProtocol(fidoSession.cachedInfo))
@ -541,7 +560,7 @@ class FidoManager(
} }
private suspend fun registerFingerprint(name: String?): String = private suspend fun registerFingerprint(name: String?): String =
connectionHelper.useSession(FidoActionDescription.RegisterFingerprint) { fidoSession -> connectionHelper.useSession { fidoSession ->
state?.cancel() state?.cancel()
state = CommandState() state = CommandState()
val clientPin = val clientPin =
@ -588,7 +607,7 @@ class FidoManager(
} }
else -> throw ctapException else -> throw ctapException
} }
} catch (io: IOException) { } catch (_: IOException) {
return@useSession JSONObject( return@useSession JSONObject(
mapOf( mapOf(
"success" to false, "success" to false,
@ -617,7 +636,7 @@ class FidoManager(
} }
private suspend fun enableEnterpriseAttestation(): String = private suspend fun enableEnterpriseAttestation(): String =
connectionHelper.useSession(FidoActionDescription.EnableEnterpriseAttestation) { fidoSession -> connectionHelper.useSession { fidoSession ->
try { try {
val uvAuthProtocol = getPreferredPinUvAuthProtocol(fidoSession.cachedInfo) val uvAuthProtocol = getPreferredPinUvAuthProtocol(fidoSession.cachedInfo)
val clientPin = ClientPin(fidoSession, uvAuthProtocol) val clientPin = ClientPin(fidoSession, uvAuthProtocol)

View File

@ -18,11 +18,14 @@ package com.yubico.authenticator.fido
import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import com.yubico.authenticator.NfcOverlayManager
import com.yubico.authenticator.MainActivity
import com.yubico.authenticator.MainViewModel import com.yubico.authenticator.MainViewModel
import com.yubico.authenticator.NULL import com.yubico.authenticator.NULL
import com.yubico.authenticator.device.DeviceManager import com.yubico.authenticator.device.DeviceManager
import com.yubico.authenticator.fido.data.Session import com.yubico.authenticator.fido.data.Session
import com.yubico.authenticator.fido.data.YubiKitFidoSession import com.yubico.authenticator.fido.data.YubiKitFidoSession
import com.yubico.authenticator.yubikit.NfcState
import com.yubico.yubikit.core.application.CommandState import com.yubico.yubikit.core.application.CommandState
import com.yubico.yubikit.core.fido.CtapException import com.yubico.yubikit.core.fido.CtapException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -68,6 +71,8 @@ fun createCaptureErrorEvent(code: Int) : FidoRegisterFpCaptureErrorEvent {
class FidoResetHelper( class FidoResetHelper(
private val lifecycleOwner: LifecycleOwner, private val lifecycleOwner: LifecycleOwner,
private val deviceManager: DeviceManager, private val deviceManager: DeviceManager,
private val appMethodChannel: MainActivity.AppMethodChannel,
private val nfcOverlayManager: NfcOverlayManager,
private val fidoViewModel: FidoViewModel, private val fidoViewModel: FidoViewModel,
private val mainViewModel: MainViewModel, private val mainViewModel: MainViewModel,
private val connectionHelper: FidoConnectionHelper, private val connectionHelper: FidoConnectionHelper,
@ -106,7 +111,7 @@ class FidoResetHelper(
resetOverNfc() resetOverNfc()
} }
logger.info("FIDO reset complete") logger.info("FIDO reset complete")
} catch (e: CancellationException) { } catch (_: CancellationException) {
logger.debug("FIDO reset cancelled") logger.debug("FIDO reset cancelled")
} finally { } finally {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
@ -210,16 +215,22 @@ class FidoResetHelper(
private suspend fun resetOverNfc() = suspendCoroutine { continuation -> private suspend fun resetOverNfc() = suspendCoroutine { continuation ->
coroutineScope.launch { coroutineScope.launch {
nfcOverlayManager.show {
}
fidoViewModel.updateResetState(FidoResetState.Touch) fidoViewModel.updateResetState(FidoResetState.Touch)
try { try {
FidoManager.updateDeviceInfo.set(true) FidoManager.updateDeviceInfo.set(true)
connectionHelper.useSessionNfc(FidoActionDescription.Reset) { fidoSession -> connectionHelper.useSessionNfc { fidoSession ->
doReset(fidoSession) doReset(fidoSession)
appMethodChannel.nfcStateChanged(NfcState.SUCCESS)
continuation.resume(Unit) continuation.resume(Unit)
} }.value
} catch (e: Throwable) { } catch (e: Throwable) {
// on NFC, clean device info in this situation // on NFC, clean device info in this situation
mainViewModel.setDeviceInfo(null) mainViewModel.setDeviceInfo(null)
appMethodChannel.nfcStateChanged(NfcState.FAILURE)
logger.error("Failure during FIDO reset:", e)
continuation.resumeWithException(e) continuation.resumeWithException(e)
} }
} }

View File

@ -16,15 +16,11 @@
package com.yubico.authenticator.management package com.yubico.authenticator.management
import com.yubico.authenticator.DialogIcon
import com.yubico.authenticator.DialogManager
import com.yubico.authenticator.DialogTitle
import com.yubico.authenticator.device.DeviceManager import com.yubico.authenticator.device.DeviceManager
import com.yubico.authenticator.yubikit.withConnection import com.yubico.authenticator.yubikit.withConnection
import com.yubico.yubikit.android.transport.usb.UsbYubiKeyDevice import com.yubico.yubikit.android.transport.usb.UsbYubiKeyDevice
import com.yubico.yubikit.core.smartcard.SmartCardConnection import com.yubico.yubikit.core.smartcard.SmartCardConnection
import com.yubico.yubikit.core.util.Result import com.yubico.yubikit.core.util.Result
import org.slf4j.LoggerFactory
import kotlin.coroutines.cancellation.CancellationException import kotlin.coroutines.cancellation.CancellationException
import kotlin.coroutines.suspendCoroutine import kotlin.coroutines.suspendCoroutine
@ -32,19 +28,19 @@ typealias YubiKitManagementSession = com.yubico.yubikit.management.ManagementSes
typealias ManagementAction = (Result<YubiKitManagementSession, Exception>) -> Unit typealias ManagementAction = (Result<YubiKitManagementSession, Exception>) -> Unit
class ManagementConnectionHelper( class ManagementConnectionHelper(
private val deviceManager: DeviceManager, private val deviceManager: DeviceManager
private val dialogManager: DialogManager
) { ) {
private var action: ManagementAction? = null private var action: ManagementAction? = null
suspend fun <T> useSession( suspend fun <T> useSession(block: (YubiKitManagementSession) -> T): T =
actionDescription: ManagementActionDescription, deviceManager.withKey(
action: (YubiKitManagementSession) -> T onUsb = { useSessionUsb(it, block) },
): T { onNfc = { useSessionNfc(block) },
return deviceManager.withKey( onCancelled = {
onNfc = { useSessionNfc(actionDescription, action) }, action?.invoke(Result.failure(CancellationException()))
onUsb = { useSessionUsb(it, action) }) action = null
} }
)
private suspend fun <T> useSessionUsb( private suspend fun <T> useSessionUsb(
device: UsbYubiKeyDevice, device: UsbYubiKeyDevice,
@ -54,37 +50,20 @@ class ManagementConnectionHelper(
} }
private suspend fun <T> useSessionNfc( private suspend fun <T> useSessionNfc(
actionDescription: ManagementActionDescription, block: (YubiKitManagementSession) -> T): Result<T, Throwable> {
block: (YubiKitManagementSession) -> T
): T {
try { try {
val result = suspendCoroutine { outer -> val result = suspendCoroutine<T> { outer ->
action = { action = {
outer.resumeWith(runCatching { outer.resumeWith(runCatching {
block.invoke(it.value) block.invoke(it.value)
}) })
} }
dialogManager.showDialog(
DialogIcon.Nfc,
DialogTitle.TapKey,
actionDescription.id
) {
logger.debug("Cancelled Dialog {}", actionDescription.name)
action?.invoke(Result.failure(CancellationException()))
action = null
}
} }
return result return Result.success(result!!)
} catch (cancelled: CancellationException) { } catch (cancelled: CancellationException) {
throw cancelled return Result.failure(cancelled)
} catch (error: Throwable) { } catch (error: Throwable) {
throw error return Result.failure(error)
} finally {
dialogManager.closeDialog()
} }
} }
companion object {
private val logger = LoggerFactory.getLogger(ManagementConnectionHelper::class.java)
}
} }

View File

@ -16,7 +16,6 @@
package com.yubico.authenticator.management package com.yubico.authenticator.management
import com.yubico.authenticator.DialogManager
import com.yubico.authenticator.NULL import com.yubico.authenticator.NULL
import com.yubico.authenticator.device.DeviceManager import com.yubico.authenticator.device.DeviceManager
import com.yubico.authenticator.setHandler import com.yubico.authenticator.setHandler
@ -27,25 +26,15 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.asCoroutineDispatcher
import java.util.concurrent.Executors import java.util.concurrent.Executors
const val dialogDescriptionManagementIndex = 300
enum class ManagementActionDescription(private val value: Int) {
DeviceReset(0), ActionFailure(1);
val id: Int
get() = value + dialogDescriptionManagementIndex
}
class ManagementHandler( class ManagementHandler(
messenger: BinaryMessenger, messenger: BinaryMessenger,
deviceManager: DeviceManager, deviceManager: DeviceManager
dialogManager: DialogManager
) { ) {
private val channel = MethodChannel(messenger, "android.management.methods") private val channel = MethodChannel(messenger, "android.management.methods")
private val dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher() private val dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
private val coroutineScope = CoroutineScope(SupervisorJob() + dispatcher) private val coroutineScope = CoroutineScope(SupervisorJob() + dispatcher)
private val connectionHelper = ManagementConnectionHelper(deviceManager, dialogManager) private val connectionHelper = ManagementConnectionHelper(deviceManager)
init { init {
channel.setHandler(coroutineScope) { method, _ -> channel.setHandler(coroutineScope) { method, _ ->
@ -58,7 +47,7 @@ class ManagementHandler(
} }
private suspend fun deviceReset(): String = private suspend fun deviceReset(): String =
connectionHelper.useSession(ManagementActionDescription.DeviceReset) { managementSession -> connectionHelper.useSession { managementSession ->
managementSession.deviceReset() managementSession.deviceReset()
NULL NULL
} }

View File

@ -63,6 +63,7 @@ import kotlinx.serialization.encodeToString
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import java.io.IOException import java.io.IOException
import java.net.URI import java.net.URI
import java.util.TimerTask
import java.util.concurrent.Executors import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import kotlin.coroutines.suspendCoroutine import kotlin.coroutines.suspendCoroutine
@ -74,8 +75,8 @@ class OathManager(
messenger: BinaryMessenger, messenger: BinaryMessenger,
private val deviceManager: DeviceManager, private val deviceManager: DeviceManager,
private val oathViewModel: OathViewModel, private val oathViewModel: OathViewModel,
private val dialogManager: DialogManager, private val nfcOverlayManager: NfcOverlayManager,
private val appPreferences: AppPreferences, private val appPreferences: AppPreferences
) : AppContextManager(), DeviceListener { ) : AppContextManager(), DeviceListener {
companion object { companion object {
@ -107,15 +108,26 @@ class OathManager(
private var refreshJob: Job? = null private var refreshJob: Job? = null
private var addToAny = false private var addToAny = false
private val updateDeviceInfo = AtomicBoolean(false) private val updateDeviceInfo = AtomicBoolean(false)
private var deviceInfoTimer: TimerTask? = null
override fun onError() {
super.onError()
logger.debug("Cancel any pending action because of upstream error")
pendingAction?.let { action ->
action.invoke(Result.failure(CancellationException()))
pendingAction = null
}
}
override fun onPause() { override fun onPause() {
deviceInfoTimer?.cancel()
// cancel any pending actions, except for addToAny // cancel any pending actions, except for addToAny
if (!addToAny) { if (!addToAny) {
pendingAction?.let { pendingAction?.let {
logger.debug("Cancelling pending action/closing nfc dialog.") logger.debug("Cancelling pending action/closing nfc overlay.")
it.invoke(Result.failure(CancellationException())) it.invoke(Result.failure(CancellationException()))
coroutineScope.launch { coroutineScope.launch {
dialogManager.closeDialog() nfcOverlayManager.close()
} }
pendingAction = null pendingAction = null
} }
@ -186,6 +198,7 @@ class OathManager(
) )
"deleteAccount" -> deleteAccount(args["credentialId"] as String) "deleteAccount" -> deleteAccount(args["credentialId"] as String)
"addAccountToAny" -> addAccountToAny( "addAccountToAny" -> addAccountToAny(
args["uri"] as String, args["uri"] as String,
args["requireTouch"] as Boolean args["requireTouch"] as Boolean
@ -208,28 +221,59 @@ class OathManager(
oathChannel.setMethodCallHandler(null) oathChannel.setMethodCallHandler(null)
oathViewModel.clearSession() oathViewModel.clearSession()
oathViewModel.updateCredentials(mapOf()) oathViewModel.updateCredentials(mapOf())
pendingAction?.invoke(Result.failure(Exception())) pendingAction?.invoke(Result.failure(ContextDisposedException()))
pendingAction = null
coroutineScope.cancel() coroutineScope.cancel()
} }
override suspend fun processYubiKey(device: YubiKeyDevice) { override suspend fun processYubiKey(device: YubiKeyDevice): Boolean {
var requestHandled = true
try { try {
device.withConnection<SmartCardConnection, Unit> { connection -> device.withConnection<SmartCardConnection, Unit> { connection ->
val session = getOathSession(connection) val session = getOathSession(connection)
val previousId = oathViewModel.currentSession()?.deviceId val previousId = oathViewModel.currentSession()?.deviceId
if (session.deviceId == previousId && device is NfcYubiKeyDevice) { // only run pending action over NFC
// Run any pending action // when the device is still the same
pendingAction?.let { action -> // or when there is no previous device, but we have a pending action
action.invoke(Result.success(session)) if (device is NfcYubiKeyDevice &&
pendingAction = null ((session.deviceId == previousId) ||
(previousId == null && pendingAction != null))
) {
// update session if it is null
if (previousId == null) {
oathViewModel.setSessionState(
Session(
session,
keyManager.isRemembered(session.deviceId)
)
)
if (!session.isLocked) {
try {
// only load the accounts without calculating the codes
oathViewModel.updateCredentials(getAccounts(session))
} catch (e: IOException) {
oathViewModel.updateCredentials(emptyMap())
} }
} }
// Refresh codes // Either run a pending action, or just refresh codes
if (!session.isLocked) { if (pendingAction != null) {
try { pendingAction?.let { action ->
oathViewModel.updateCredentials(calculateOathCodes(session)) pendingAction = null
} catch (error: Exception) { // it is the pending action who handles this request
logger.error("Failed to refresh codes", error) requestHandled = false
action.invoke(Result.success(session))
}
} else {
// Refresh codes
if (!session.isLocked) {
try {
oathViewModel.updateCredentials(calculateOathCodes(session))
} catch (error: Exception) {
logger.error("Failed to refresh codes: ", error)
throw error
}
} }
} }
} else { } else {
@ -246,7 +290,15 @@ class OathManager(
) )
) )
if (!session.isLocked) { if (!session.isLocked) {
oathViewModel.updateCredentials(calculateOathCodes(session)) try {
oathViewModel.updateCredentials(calculateOathCodes(session))
} catch (e: IOException) {
// in this situation we clear the session because otherwise
// the credential list would be in loading state
// clearing the session will prompt the user to try again
oathViewModel.clearSession()
throw e
}
} }
// Awaiting an action for a different or no device? // Awaiting an action for a different or no device?
@ -255,6 +307,7 @@ class OathManager(
if (addToAny) { if (addToAny) {
// Special "add to any YubiKey" action, process // Special "add to any YubiKey" action, process
addToAny = false addToAny = false
requestHandled = false
action.invoke(Result.success(session)) action.invoke(Result.success(session))
} else { } else {
// Awaiting an action for a different device? Fail it and stop processing. // Awaiting an action for a different device? Fail it and stop processing.
@ -284,6 +337,7 @@ class OathManager(
} }
} }
} }
logger.debug( logger.debug(
"Successfully read Oath session info (and credentials if unlocked) from connected key" "Successfully read Oath session info (and credentials if unlocked) from connected key"
) )
@ -293,11 +347,25 @@ class OathManager(
} }
} catch (e: Exception) { } catch (e: Exception) {
// OATH not enabled/supported, try to get DeviceInfo over other USB interfaces // OATH not enabled/supported, try to get DeviceInfo over other USB interfaces
logger.error("Failed to connect to CCID: ", e) logger.error("Exception during SmartCard connection/OATH session creation: ", e)
// Clear any cached OATH state // Remove any pending action
oathViewModel.clearSession() pendingAction?.let { action ->
logger.error("Failing pending action with {}", e.message)
action.invoke(Result.failure(e))
pendingAction = null
}
if (e !is IOException) {
// we don't clear the session on IOExceptions so that the session is ready for
// a possible re-run of a failed action.
oathViewModel.clearSession()
}
throw e
} }
return requestHandled
} }
private suspend fun addAccountToAny( private suspend fun addAccountToAny(
@ -307,7 +375,7 @@ class OathManager(
val credentialData: CredentialData = val credentialData: CredentialData =
CredentialData.parseUri(URI.create(uri)) CredentialData.parseUri(URI.create(uri))
addToAny = true addToAny = true
return useOathSessionNfc(OathActionDescription.AddAccount) { session -> return useOathSession { session ->
// We need to check for duplicates here since we haven't yet read the credentials // We need to check for duplicates here since we haven't yet read the credentials
if (session.credentials.any { it.id.contentEquals(credentialData.id) }) { if (session.credentials.any { it.id.contentEquals(credentialData.id) }) {
throw IllegalArgumentException() throw IllegalArgumentException()
@ -337,7 +405,7 @@ class OathManager(
logger.trace("Adding following accounts: {}", uris) logger.trace("Adding following accounts: {}", uris)
addToAny = true addToAny = true
return useOathSession(OathActionDescription.AddMultipleAccounts) { session -> return useOathSession { session ->
var successCount = 0 var successCount = 0
for (index in uris.indices) { for (index in uris.indices) {
@ -369,7 +437,7 @@ class OathManager(
} }
private suspend fun reset(): String = private suspend fun reset(): String =
useOathSession(OathActionDescription.Reset, updateDeviceInfo = true) { useOathSession(updateDeviceInfo = true) {
// note, it is ok to reset locked session // note, it is ok to reset locked session
it.reset() it.reset()
keyManager.removeKey(it.deviceId) keyManager.removeKey(it.deviceId)
@ -381,7 +449,7 @@ class OathManager(
} }
private suspend fun unlock(password: String, remember: Boolean): String = private suspend fun unlock(password: String, remember: Boolean): String =
useOathSession(OathActionDescription.Unlock) { useOathSession {
val accessKey = it.deriveAccessKey(password.toCharArray()) val accessKey = it.deriveAccessKey(password.toCharArray())
keyManager.addKey(it.deviceId, accessKey, remember) keyManager.addKey(it.deviceId, accessKey, remember)
@ -390,9 +458,13 @@ class OathManager(
if (unlocked) { if (unlocked) {
oathViewModel.setSessionState(Session(it, remembered)) oathViewModel.setSessionState(Session(it, remembered))
// fetch credentials after unlocking only if the YubiKey is connected over USB try {
if (deviceManager.isUsbKeyConnected()) {
oathViewModel.updateCredentials(calculateOathCodes(it)) oathViewModel.updateCredentials(calculateOathCodes(it))
} catch (e: Exception) {
// after unlocking there was problem getting the codes
// to avoid inconsistent UI, clear the session
oathViewModel.clearSession()
throw e
} }
} }
@ -404,7 +476,6 @@ class OathManager(
newPassword: String, newPassword: String,
): String = ): String =
useOathSession( useOathSession(
OathActionDescription.SetPassword,
unlock = false, unlock = false,
updateDeviceInfo = true updateDeviceInfo = true
) { session -> ) { session ->
@ -426,7 +497,7 @@ class OathManager(
} }
private suspend fun unsetPassword(currentPassword: String): String = private suspend fun unsetPassword(currentPassword: String): String =
useOathSession(OathActionDescription.UnsetPassword, unlock = false) { session -> useOathSession(unlock = false) { session ->
if (session.isAccessKeySet) { if (session.isAccessKeySet) {
// test current password sent by the user // test current password sent by the user
if (session.unlock(currentPassword.toCharArray())) { if (session.unlock(currentPassword.toCharArray())) {
@ -458,7 +529,7 @@ class OathManager(
uri: String, uri: String,
requireTouch: Boolean, requireTouch: Boolean,
): String = ): String =
useOathSession(OathActionDescription.AddAccount) { session -> useOathSession { session ->
val credentialData: CredentialData = val credentialData: CredentialData =
CredentialData.parseUri(URI.create(uri)) CredentialData.parseUri(URI.create(uri))
@ -479,21 +550,24 @@ class OathManager(
} }
private suspend fun renameAccount(uri: String, name: String, issuer: String?): String = private suspend fun renameAccount(uri: String, name: String, issuer: String?): String =
useOathSession(OathActionDescription.RenameAccount) { session -> useOathSession { session ->
val credential = getOathCredential(session, uri) val credential = getCredential(uri)
val renamedCredential = val renamed = Credential(
Credential(session.renameCredential(credential, name, issuer), session.deviceId) session.renameCredential(credential, name, issuer),
oathViewModel.renameCredential( session.deviceId
Credential(credential, session.deviceId),
renamedCredential
) )
jsonSerializer.encodeToString(renamedCredential) oathViewModel.renameCredential(
Credential(credential, session.deviceId),
renamed
)
jsonSerializer.encodeToString(renamed)
} }
private suspend fun deleteAccount(credentialId: String): String = private suspend fun deleteAccount(credentialId: String): String =
useOathSession(OathActionDescription.DeleteAccount) { session -> useOathSession { session ->
val credential = getOathCredential(session, credentialId) val credential = getCredential(credentialId)
session.deleteCredential(credential) session.deleteCredential(credential)
oathViewModel.removeCredential(Credential(credential, session.deviceId)) oathViewModel.removeCredential(Credential(credential, session.deviceId))
NULL NULL
@ -510,7 +584,7 @@ class OathManager(
deviceManager.withKey { usbYubiKeyDevice -> deviceManager.withKey { usbYubiKeyDevice ->
try { try {
useOathSessionUsb(usbYubiKeyDevice) { session -> useSessionUsb(usbYubiKeyDevice) { session ->
try { try {
oathViewModel.updateCredentials(calculateOathCodes(session)) oathViewModel.updateCredentials(calculateOathCodes(session))
} catch (apduException: ApduException) { } catch (apduException: ApduException) {
@ -534,7 +608,10 @@ class OathManager(
logger.error("IOException when accessing USB device: ", ioException) logger.error("IOException when accessing USB device: ", ioException)
clearCodes() clearCodes()
} catch (illegalStateException: IllegalStateException) { } catch (illegalStateException: IllegalStateException) {
logger.error("IllegalStateException when accessing USB device: ", illegalStateException) logger.error(
"IllegalStateException when accessing USB device: ",
illegalStateException
)
clearCodes() clearCodes()
} }
} }
@ -542,8 +619,8 @@ class OathManager(
private suspend fun calculate(credentialId: String): String = private suspend fun calculate(credentialId: String): String =
useOathSession(OathActionDescription.CalculateCode) { session -> useOathSession { session ->
val credential = getOathCredential(session, credentialId) val credential = getCredential(credentialId)
val code = Code.from(calculateCode(session, credential)) val code = Code.from(calculateCode(session, credential))
oathViewModel.updateCode( oathViewModel.updateCode(
@ -633,6 +710,14 @@ class OathManager(
return session return session
} }
private fun getAccounts(session: YubiKitOathSession): Map<Credential, Code?> {
return session.credentials.map { credential ->
Pair(
Credential(credential, session.deviceId),
null
)
}.toMap()
}
private fun calculateOathCodes(session: YubiKitOathSession): Map<Credential, Code?> { private fun calculateOathCodes(session: YubiKitOathSession): Map<Credential, Code?> {
val isUsbKey = deviceManager.isUsbKeyConnected() val isUsbKey = deviceManager.isUsbKeyConnected()
@ -645,35 +730,51 @@ class OathManager(
return session.calculateCodes(timestamp).map { (credential, code) -> return session.calculateCodes(timestamp).map { (credential, code) ->
Pair( Pair(
Credential(credential, session.deviceId), Credential(credential, session.deviceId),
Code.from(if (credential.isSteamCredential() && (!credential.isTouchRequired || bypassTouch)) { Code.from(
session.calculateSteamCode(credential, timestamp) if (credential.isSteamCredential() && (!credential.isTouchRequired || bypassTouch)) {
} else if (credential.isTouchRequired && bypassTouch) { session.calculateSteamCode(credential, timestamp)
session.calculateCode(credential, timestamp) } else if (credential.isTouchRequired && bypassTouch) {
} else { session.calculateCode(credential, timestamp)
code } else {
}) code
}
)
) )
}.toMap() }.toMap()
} }
private fun getCredential(id: String): YubiKitCredential {
val credential =
oathViewModel.credentials.value?.find { it.credential.id == id }?.credential
if (credential == null || credential.data == null) {
logger.debug("Failed to find credential with id: {}", id)
throw Exception("Failed to find account")
}
return credential.data
}
private suspend fun <T> useOathSession( private suspend fun <T> useOathSession(
oathActionDescription: OathActionDescription,
unlock: Boolean = true, unlock: Boolean = true,
updateDeviceInfo: Boolean = false, updateDeviceInfo: Boolean = false,
action: (YubiKitOathSession) -> T block: (YubiKitOathSession) -> T
): T { ): T {
// callers can decide whether the session should be unlocked first // callers can decide whether the session should be unlocked first
unlockOnConnect.set(unlock) unlockOnConnect.set(unlock)
// callers can request whether device info should be updated after session operation // callers can request whether device info should be updated after session operation
this@OathManager.updateDeviceInfo.set(updateDeviceInfo) this@OathManager.updateDeviceInfo.set(updateDeviceInfo)
return deviceManager.withKey( return deviceManager.withKey(
onUsb = { useOathSessionUsb(it, updateDeviceInfo, action) }, onUsb = { useSessionUsb(it, updateDeviceInfo, block) },
onNfc = { useOathSessionNfc(oathActionDescription, action) } onNfc = { useSessionNfc(block) },
onCancelled = {
pendingAction?.invoke(Result.failure(CancellationException()))
pendingAction = null
}
) )
} }
private suspend fun <T> useOathSessionUsb( private suspend fun <T> useSessionUsb(
device: UsbYubiKeyDevice, device: UsbYubiKeyDevice,
updateDeviceInfo: Boolean = false, updateDeviceInfo: Boolean = false,
block: (YubiKitOathSession) -> T block: (YubiKitOathSession) -> T
@ -685,10 +786,9 @@ class OathManager(
} }
} }
private suspend fun <T> useOathSessionNfc( private suspend fun <T> useSessionNfc(
oathActionDescription: OathActionDescription, block: (YubiKitOathSession) -> T,
block: (YubiKitOathSession) -> T ): Result<T, Throwable> {
): T {
try { try {
val result = suspendCoroutine { outer -> val result = suspendCoroutine { outer ->
pendingAction = { pendingAction = {
@ -696,41 +796,18 @@ class OathManager(
block.invoke(it.value) block.invoke(it.value)
}) })
} }
dialogManager.showDialog(DialogIcon.Nfc, DialogTitle.TapKey, oathActionDescription.id) { // here the coroutine is suspended and waits till pendingAction is
logger.debug("Cancelled Dialog {}", oathActionDescription.name) // invoked - the pending action result will resume this coroutine
pendingAction?.invoke(Result.failure(CancellationException()))
pendingAction = null
}
} }
dialogManager.updateDialogState( return Result.success(result!!)
dialogIcon = DialogIcon.Success,
dialogTitle = DialogTitle.OperationSuccessful
)
// TODO: This delays the closing of the dialog, but also the return value
delay(500)
return result
} catch (cancelled: CancellationException) { } catch (cancelled: CancellationException) {
throw cancelled return Result.failure(cancelled)
} catch (error: Throwable) { } catch (e: Exception) {
dialogManager.updateDialogState( logger.error("Exception during action: ", e)
dialogIcon = DialogIcon.Failure, return Result.failure(e)
dialogTitle = DialogTitle.OperationFailed,
dialogDescriptionId = OathActionDescription.ActionFailure.id
)
// TODO: This delays the closing of the dialog, but also the return value
delay(1500)
throw error
} finally {
dialogManager.closeDialog()
} }
} }
private fun getOathCredential(session: YubiKitOathSession, credentialId: String) =
// we need to use oathSession.calculateCodes() to get proper Credential.touchRequired value
session.calculateCodes().map { e -> e.key }.firstOrNull { credential ->
(credential != null) && credential.id.asString() == credentialId
} ?: throw Exception("Failed to find account")
override fun onConnected(device: YubiKeyDevice) { override fun onConnected(device: YubiKeyDevice) {
refreshJob?.cancel() refreshJob?.cancel()
} }

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2023 Yubico. * Copyright (C) 2023-2024 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.
@ -35,9 +35,10 @@ data class Credential(
@SerialName("name") @SerialName("name")
val accountName: String, val accountName: String,
@SerialName("touch_required") @SerialName("touch_required")
val touchRequired: Boolean val touchRequired: Boolean,
@kotlinx.serialization.Transient
val data: YubiKitCredential? = null
) { ) {
constructor(credential: YubiKitCredential, deviceId: String) : this( constructor(credential: YubiKitCredential, deviceId: String) : this(
deviceId = deviceId, deviceId = deviceId,
id = credential.id.asString(), id = credential.id.asString(),
@ -48,7 +49,8 @@ data class Credential(
period = credential.period, period = credential.period,
issuer = credential.issuer, issuer = credential.issuer,
accountName = credential.accountName, accountName = credential.accountName,
touchRequired = credential.isTouchRequired touchRequired = credential.isTouchRequired,
data = credential
) )
override fun equals(other: Any?): Boolean = override fun equals(other: Any?): Boolean =

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2023 Yubico. * Copyright (C) 2023-2024 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,22 +14,12 @@
* limitations under the License. * limitations under the License.
*/ */
package com.yubico.authenticator.oath package com.yubico.authenticator.yubikit
const val dialogDescriptionOathIndex = 100 enum class NfcState(val value: Int) {
DISABLED(0),
enum class OathActionDescription(private val value: Int) { IDLE(1),
Reset(0), ONGOING(2),
Unlock(1), SUCCESS(3),
SetPassword(2), FAILURE(4)
UnsetPassword(3),
AddAccount(4),
RenameAccount(5),
DeleteAccount(6),
CalculateCode(7),
ActionFailure(8),
AddMultipleAccounts(9);
val id: Int
get() = value + dialogDescriptionOathIndex
} }

View File

@ -0,0 +1,60 @@
/*
* Copyright (C) 2023-2024 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.
*/
package com.yubico.authenticator.yubikit
import android.app.Activity
import android.nfc.NfcAdapter
import com.yubico.yubikit.android.transport.nfc.NfcConfiguration
import com.yubico.yubikit.android.transport.nfc.NfcDispatcher
import com.yubico.yubikit.android.transport.nfc.NfcReaderDispatcher
import org.slf4j.LoggerFactory
interface NfcStateListener {
fun onChange(newState: NfcState)
}
class NfcStateDispatcher(private val listener: NfcStateListener) : NfcDispatcher {
private lateinit var adapter: NfcAdapter
private lateinit var yubikitNfcDispatcher: NfcReaderDispatcher
private val logger = LoggerFactory.getLogger(NfcStateDispatcher::class.java)
override fun enable(
activity: Activity,
nfcConfiguration: NfcConfiguration,
handler: NfcDispatcher.OnTagHandler
) {
adapter = NfcAdapter.getDefaultAdapter(activity)
yubikitNfcDispatcher = NfcReaderDispatcher(adapter)
logger.debug("enabling yubikit NFC state dispatcher")
yubikitNfcDispatcher.enable(
activity,
nfcConfiguration,
handler
)
}
override fun disable(activity: Activity) {
listener.onChange(NfcState.DISABLED)
yubikitNfcDispatcher.disable(activity)
logger.debug("disabling yubikit NFC state dispatcher")
}
}

View File

@ -18,6 +18,7 @@ import 'dart:convert';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../theme.dart'; import '../theme.dart';
import 'state.dart'; import 'state.dart';
@ -73,8 +74,14 @@ void setupAppMethodsChannel(WidgetRef ref) {
switch (call.method) { switch (call.method) {
case 'nfcAdapterStateChanged': case 'nfcAdapterStateChanged':
{ {
var nfcEnabled = args['nfcEnabled']; var enabled = args['enabled'];
ref.read(androidNfcStateProvider.notifier).setNfcEnabled(nfcEnabled); ref.read(androidNfcAdapterState.notifier).enable(enabled);
break;
}
case 'nfcStateChanged':
{
var nfcState = args['state'];
ref.read(androidNfcState.notifier).set(nfcState);
break; break;
} }
default: default:

View File

@ -32,17 +32,18 @@ import '../../exception/no_data_exception.dart';
import '../../exception/platform_exception_decoder.dart'; import '../../exception/platform_exception_decoder.dart';
import '../../fido/models.dart'; import '../../fido/models.dart';
import '../../fido/state.dart'; import '../../fido/state.dart';
import '../overlay/nfc/method_channel_notifier.dart';
final _log = Logger('android.fido.state'); final _log = Logger('android.fido.state');
const _methods = MethodChannel('android.fido.methods');
final androidFidoStateProvider = AsyncNotifierProvider.autoDispose final androidFidoStateProvider = AsyncNotifierProvider.autoDispose
.family<FidoStateNotifier, FidoState, DevicePath>(_FidoStateNotifier.new); .family<FidoStateNotifier, FidoState, DevicePath>(_FidoStateNotifier.new);
class _FidoStateNotifier extends FidoStateNotifier { class _FidoStateNotifier extends FidoStateNotifier {
final _events = const EventChannel('android.fido.sessionState'); final _events = const EventChannel('android.fido.sessionState');
late StreamSubscription _sub; late StreamSubscription _sub;
late final _FidoMethodChannelNotifier fido =
ref.read(_fidoMethodsProvider.notifier);
@override @override
FutureOr<FidoState> build(DevicePath devicePath) async { FutureOr<FidoState> build(DevicePath devicePath) async {
@ -79,7 +80,7 @@ class _FidoStateNotifier extends FidoStateNotifier {
}); });
controller.onCancel = () async { controller.onCancel = () async {
await _methods.invokeMethod('cancelReset'); await fido.invoke('cancelReset');
if (!controller.isClosed) { if (!controller.isClosed) {
await subscription.cancel(); await subscription.cancel();
} }
@ -87,7 +88,7 @@ class _FidoStateNotifier extends FidoStateNotifier {
controller.onListen = () async { controller.onListen = () async {
try { try {
await _methods.invokeMethod('reset'); await fido.invoke('reset');
await controller.sink.close(); await controller.sink.close();
ref.invalidateSelf(); ref.invalidateSelf();
} catch (e) { } catch (e) {
@ -102,13 +103,8 @@ class _FidoStateNotifier extends FidoStateNotifier {
@override @override
Future<PinResult> setPin(String newPin, {String? oldPin}) async { Future<PinResult> setPin(String newPin, {String? oldPin}) async {
try { try {
final response = jsonDecode(await _methods.invokeMethod( final response = jsonDecode(
'setPin', await fido.invoke('setPin', {'pin': oldPin, 'newPin': newPin}));
{
'pin': oldPin,
'newPin': newPin,
},
));
if (response['success'] == true) { if (response['success'] == true) {
_log.debug('FIDO PIN set/change successful'); _log.debug('FIDO PIN set/change successful');
return PinResult.success(); return PinResult.success();
@ -134,10 +130,7 @@ class _FidoStateNotifier extends FidoStateNotifier {
@override @override
Future<PinResult> unlock(String pin) async { Future<PinResult> unlock(String pin) async {
try { try {
final response = jsonDecode(await _methods.invokeMethod( final response = jsonDecode(await fido.invoke('unlock', {'pin': pin}));
'unlock',
{'pin': pin},
));
if (response['success'] == true) { if (response['success'] == true) {
_log.debug('FIDO applet unlocked'); _log.debug('FIDO applet unlocked');
@ -165,9 +158,8 @@ class _FidoStateNotifier extends FidoStateNotifier {
@override @override
Future<void> enableEnterpriseAttestation() async { Future<void> enableEnterpriseAttestation() async {
try { try {
final response = jsonDecode(await _methods.invokeMethod( final response =
'enableEnterpriseAttestation', jsonDecode(await fido.invoke('enableEnterpriseAttestation'));
));
if (response['success'] == true) { if (response['success'] == true) {
_log.debug('Enterprise attestation enabled'); _log.debug('Enterprise attestation enabled');
@ -193,6 +185,8 @@ final androidFingerprintProvider = AsyncNotifierProvider.autoDispose
class _FidoFingerprintsNotifier extends FidoFingerprintsNotifier { class _FidoFingerprintsNotifier extends FidoFingerprintsNotifier {
final _events = const EventChannel('android.fido.fingerprints'); final _events = const EventChannel('android.fido.fingerprints');
late StreamSubscription _sub; late StreamSubscription _sub;
late final _FidoMethodChannelNotifier fido =
ref.read(_fidoMethodsProvider.notifier);
@override @override
FutureOr<List<Fingerprint>> build(DevicePath devicePath) async { FutureOr<List<Fingerprint>> build(DevicePath devicePath) async {
@ -243,7 +237,7 @@ class _FidoFingerprintsNotifier extends FidoFingerprintsNotifier {
controller.onCancel = () async { controller.onCancel = () async {
if (!controller.isClosed) { if (!controller.isClosed) {
_log.debug('Cancelling fingerprint registration'); _log.debug('Cancelling fingerprint registration');
await _methods.invokeMethod('cancelRegisterFingerprint'); await fido.invoke('cancelRegisterFingerprint');
await registerFpSub.cancel(); await registerFpSub.cancel();
} }
}; };
@ -251,7 +245,7 @@ class _FidoFingerprintsNotifier extends FidoFingerprintsNotifier {
controller.onListen = () async { controller.onListen = () async {
try { try {
final registerFpResult = final registerFpResult =
await _methods.invokeMethod('registerFingerprint', {'name': name}); await fido.invoke('registerFingerprint', {'name': name});
_log.debug('Finished registerFingerprint with: $registerFpResult'); _log.debug('Finished registerFingerprint with: $registerFpResult');
@ -286,13 +280,9 @@ class _FidoFingerprintsNotifier extends FidoFingerprintsNotifier {
Future<Fingerprint> renameFingerprint( Future<Fingerprint> renameFingerprint(
Fingerprint fingerprint, String name) async { Fingerprint fingerprint, String name) async {
try { try {
final renameFingerprintResponse = jsonDecode(await _methods.invokeMethod( final renameFingerprintResponse = jsonDecode(await fido.invoke(
'renameFingerprint', 'renameFingerprint',
{ {'templateId': fingerprint.templateId, 'name': name}));
'templateId': fingerprint.templateId,
'name': name,
},
));
if (renameFingerprintResponse['success'] == true) { if (renameFingerprintResponse['success'] == true) {
_log.debug('FIDO rename fingerprint succeeded'); _log.debug('FIDO rename fingerprint succeeded');
@ -316,12 +306,8 @@ class _FidoFingerprintsNotifier extends FidoFingerprintsNotifier {
@override @override
Future<void> deleteFingerprint(Fingerprint fingerprint) async { Future<void> deleteFingerprint(Fingerprint fingerprint) async {
try { try {
final deleteFingerprintResponse = jsonDecode(await _methods.invokeMethod( final deleteFingerprintResponse = jsonDecode(await fido
'deleteFingerprint', .invoke('deleteFingerprint', {'templateId': fingerprint.templateId}));
{
'templateId': fingerprint.templateId,
},
));
if (deleteFingerprintResponse['success'] == true) { if (deleteFingerprintResponse['success'] == true) {
_log.debug('FIDO delete fingerprint succeeded'); _log.debug('FIDO delete fingerprint succeeded');
@ -348,6 +334,8 @@ final androidCredentialProvider = AsyncNotifierProvider.autoDispose
class _FidoCredentialsNotifier extends FidoCredentialsNotifier { class _FidoCredentialsNotifier extends FidoCredentialsNotifier {
final _events = const EventChannel('android.fido.credentials'); final _events = const EventChannel('android.fido.credentials');
late StreamSubscription _sub; late StreamSubscription _sub;
late final _FidoMethodChannelNotifier fido =
ref.read(_fidoMethodsProvider.notifier);
@override @override
FutureOr<List<FidoCredential>> build(DevicePath devicePath) async { FutureOr<List<FidoCredential>> build(DevicePath devicePath) async {
@ -371,13 +359,8 @@ class _FidoCredentialsNotifier extends FidoCredentialsNotifier {
@override @override
Future<void> deleteCredential(FidoCredential credential) async { Future<void> deleteCredential(FidoCredential credential) async {
try { try {
await _methods.invokeMethod( await fido.invoke('deleteCredential',
'deleteCredential', {'rpId': credential.rpId, 'credentialId': credential.credentialId});
{
'rpId': credential.rpId,
'credentialId': credential.credentialId,
},
);
} on PlatformException catch (pe) { } on PlatformException catch (pe) {
var decodedException = pe.decode(); var decodedException = pe.decode();
if (decodedException is CancellationException) { if (decodedException is CancellationException) {
@ -388,3 +371,11 @@ class _FidoCredentialsNotifier extends FidoCredentialsNotifier {
} }
} }
} }
final _fidoMethodsProvider = NotifierProvider<_FidoMethodChannelNotifier, void>(
() => _FidoMethodChannelNotifier());
class _FidoMethodChannelNotifier extends MethodChannelNotifier {
_FidoMethodChannelNotifier()
: super(const MethodChannel('android.fido.methods'));
}

View File

@ -40,9 +40,10 @@ import 'logger.dart';
import 'management/state.dart'; import 'management/state.dart';
import 'oath/otp_auth_link_handler.dart'; import 'oath/otp_auth_link_handler.dart';
import 'oath/state.dart'; import 'oath/state.dart';
import 'overlay/nfc/nfc_event_notifier.dart';
import 'overlay/nfc/nfc_overlay.dart';
import 'qr_scanner/qr_scanner_provider.dart'; import 'qr_scanner/qr_scanner_provider.dart';
import 'state.dart'; import 'state.dart';
import 'tap_request_dialog.dart';
import 'window_state_provider.dart'; import 'window_state_provider.dart';
Future<Widget> initialize() async { Future<Widget> initialize() async {
@ -106,6 +107,8 @@ Future<Widget> initialize() async {
child: DismissKeyboard( child: DismissKeyboard(
child: YubicoAuthenticatorApp(page: Consumer( child: YubicoAuthenticatorApp(page: Consumer(
builder: (context, ref, child) { builder: (context, ref, child) {
ref.read(nfcEventNotifierListener).startListener(context);
Timer.run(() { Timer.run(() {
ref.read(featureFlagProvider.notifier) ref.read(featureFlagProvider.notifier)
// TODO: Load feature flags from file/config? // TODO: Load feature flags from file/config?
@ -119,8 +122,8 @@ Future<Widget> initialize() async {
// activates window state provider // activates window state provider
ref.read(androidWindowStateProvider); ref.read(androidWindowStateProvider);
// initializes global handler for dialogs // initializes overlay for nfc events
ref.read(androidDialogProvider); ref.read(nfcOverlay);
// set context which will handle otpauth links // set context which will handle otpauth links
setupOtpAuthLinkHandler(context); setupOtpAuthLinkHandler(context);

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2022-2023 Yubico. * Copyright (C) 2022-2024 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.
@ -36,12 +36,12 @@ import '../../exception/platform_exception_decoder.dart';
import '../../oath/models.dart'; import '../../oath/models.dart';
import '../../oath/state.dart'; import '../../oath/state.dart';
import '../../widgets/toast.dart'; import '../../widgets/toast.dart';
import '../tap_request_dialog.dart'; import '../app_methods.dart';
import '../overlay/nfc/method_channel_notifier.dart';
import '../overlay/nfc/nfc_overlay.dart';
final _log = Logger('android.oath.state'); final _log = Logger('android.oath.state');
const _methods = MethodChannel('android.oath.methods');
final androidOathStateProvider = AsyncNotifierProvider.autoDispose final androidOathStateProvider = AsyncNotifierProvider.autoDispose
.family<OathStateNotifier, OathState, DevicePath>( .family<OathStateNotifier, OathState, DevicePath>(
_AndroidOathStateNotifier.new); _AndroidOathStateNotifier.new);
@ -49,6 +49,8 @@ final androidOathStateProvider = AsyncNotifierProvider.autoDispose
class _AndroidOathStateNotifier extends OathStateNotifier { class _AndroidOathStateNotifier extends OathStateNotifier {
final _events = const EventChannel('android.oath.sessionState'); final _events = const EventChannel('android.oath.sessionState');
late StreamSubscription _sub; late StreamSubscription _sub;
late _OathMethodChannelNotifier oath =
ref.watch(_oathMethodsProvider.notifier);
@override @override
FutureOr<OathState> build(DevicePath arg) { FutureOr<OathState> build(DevicePath arg) {
@ -74,10 +76,7 @@ class _AndroidOathStateNotifier extends OathStateNotifier {
@override @override
Future<void> reset() async { Future<void> reset() async {
try { try {
// await ref await oath.invoke('reset');
// .read(androidAppContextHandler)
// .switchAppContext(Application.accounts);
await _methods.invokeMethod('reset');
} catch (e) { } catch (e) {
_log.debug('Calling reset failed with exception: $e'); _log.debug('Calling reset failed with exception: $e');
} }
@ -86,8 +85,8 @@ class _AndroidOathStateNotifier extends OathStateNotifier {
@override @override
Future<(bool, bool)> unlock(String password, {bool remember = false}) async { Future<(bool, bool)> unlock(String password, {bool remember = false}) async {
try { try {
final unlockResponse = jsonDecode(await _methods.invokeMethod( final unlockResponse = jsonDecode(await oath
'unlock', {'password': password, 'remember': remember})); .invoke('unlock', {'password': password, 'remember': remember}));
_log.debug('applet unlocked'); _log.debug('applet unlocked');
final unlocked = unlockResponse['unlocked'] == true; final unlocked = unlockResponse['unlocked'] == true;
@ -108,11 +107,16 @@ class _AndroidOathStateNotifier extends OathStateNotifier {
@override @override
Future<bool> setPassword(String? current, String password) async { Future<bool> setPassword(String? current, String password) async {
try { try {
await _methods.invokeMethod( await oath
'setPassword', {'current': current, 'password': password}); .invoke('setPassword', {'current': current, 'password': password});
return true; return true;
} on PlatformException catch (e) { } on PlatformException catch (pe) {
_log.debug('Calling set password failed with exception: $e'); final decoded = pe.decode();
if (decoded is CancellationException) {
_log.debug('Set password cancelled');
throw decoded;
}
_log.debug('Calling set password failed with exception: $pe');
return false; return false;
} }
} }
@ -120,10 +124,15 @@ class _AndroidOathStateNotifier extends OathStateNotifier {
@override @override
Future<bool> unsetPassword(String current) async { Future<bool> unsetPassword(String current) async {
try { try {
await _methods.invokeMethod('unsetPassword', {'current': current}); await oath.invoke('unsetPassword', {'current': current});
return true; return true;
} on PlatformException catch (e) { } on PlatformException catch (pe) {
_log.debug('Calling unset password failed with exception: $e'); final decoded = pe.decode();
if (decoded is CancellationException) {
_log.debug('Unset password cancelled');
throw decoded;
}
_log.debug('Calling unset password failed with exception: $pe');
return false; return false;
} }
} }
@ -131,7 +140,7 @@ class _AndroidOathStateNotifier extends OathStateNotifier {
@override @override
Future<void> forgetPassword() async { Future<void> forgetPassword() async {
try { try {
await _methods.invokeMethod('forgetPassword'); await oath.invoke('forgetPassword');
} on PlatformException catch (e) { } on PlatformException catch (e) {
_log.debug('Calling forgetPassword failed with exception: $e'); _log.debug('Calling forgetPassword failed with exception: $e');
} }
@ -146,7 +155,7 @@ Exception handlePlatformException(
toast(String message, {bool popStack = false}) => toast(String message, {bool popStack = false}) =>
withContext((context) async { withContext((context) async {
ref.read(androidDialogProvider).closeDialog(); ref.read(nfcOverlay.notifier).hide();
if (popStack) { if (popStack) {
Navigator.of(context).popUntil((route) { Navigator.of(context).popUntil((route) {
return route.isFirst; return route.isFirst;
@ -167,7 +176,7 @@ Exception handlePlatformException(
return CancellationException(); return CancellationException();
} }
case PlatformException pe: case PlatformException pe:
if (pe.code == 'JobCancellationException') { if (pe.code == 'ContextDisposedException') {
// pop stack to show FIDO view // pop stack to show FIDO view
toast(l10n.l_add_account_func_missing, popStack: true); toast(l10n.l_add_account_func_missing, popStack: true);
return CancellationException(); return CancellationException();
@ -181,46 +190,33 @@ Exception handlePlatformException(
final addCredentialToAnyProvider = final addCredentialToAnyProvider =
Provider((ref) => (Uri credentialUri, {bool requireTouch = false}) async { Provider((ref) => (Uri credentialUri, {bool requireTouch = false}) async {
final oath = ref.watch(_oathMethodsProvider.notifier);
try { try {
String resultString = await _methods.invokeMethod( await preserveConnectedDeviceWhenPaused();
'addAccountToAny', { var result = jsonDecode(await oath.invoke('addAccountToAny', {
'uri': credentialUri.toString(), 'uri': credentialUri.toString(),
'requireTouch': requireTouch 'requireTouch': requireTouch
}); }));
var result = jsonDecode(resultString);
return OathCredential.fromJson(result['credential']); return OathCredential.fromJson(result['credential']);
} on PlatformException catch (pe) { } on PlatformException catch (pe) {
_log.error('Received exception: $pe');
throw handlePlatformException(ref, pe); throw handlePlatformException(ref, pe);
} }
}); });
final addCredentialsToAnyProvider = Provider( final addCredentialsToAnyProvider = Provider(
(ref) => (List<String> credentialUris, List<bool> touchRequired) async { (ref) => (List<String> credentialUris, List<bool> touchRequired) async {
final oath = ref.read(_oathMethodsProvider.notifier);
try { try {
await preserveConnectedDeviceWhenPaused();
_log.debug( _log.debug(
'Calling android with ${credentialUris.length} credentials to be added'); 'Calling android with ${credentialUris.length} credentials to be added');
var result = jsonDecode(await oath.invoke('addAccountsToAny',
String resultString = await _methods.invokeMethod( {'uris': credentialUris, 'requireTouch': touchRequired}));
'addAccountsToAny',
{
'uris': credentialUris,
'requireTouch': touchRequired,
},
);
_log.debug('Call result: $resultString');
var result = jsonDecode(resultString);
return result['succeeded'] == credentialUris.length; return result['succeeded'] == credentialUris.length;
} on PlatformException catch (pe) { } on PlatformException catch (pe) {
var decodedException = pe.decode(); _log.error('Received exception: $pe');
if (decodedException is CancellationException) { throw handlePlatformException(ref, pe);
_log.debug('User cancelled adding multiple accounts');
} else {
_log.error('Failed to add multiple accounts.', pe);
}
throw decodedException;
} }
}); });
@ -238,6 +234,8 @@ class _AndroidCredentialListNotifier extends OathCredentialListNotifier {
final WithContext _withContext; final WithContext _withContext;
final Ref _ref; final Ref _ref;
late StreamSubscription _sub; late StreamSubscription _sub;
late _OathMethodChannelNotifier oath =
_ref.read(_oathMethodsProvider.notifier);
_AndroidCredentialListNotifier(this._withContext, this._ref) : super() { _AndroidCredentialListNotifier(this._withContext, this._ref) : super() {
_sub = _events.receiveBroadcastStream().listen((event) { _sub = _events.receiveBroadcastStream().listen((event) {
@ -284,8 +282,8 @@ class _AndroidCredentialListNotifier extends OathCredentialListNotifier {
} }
try { try {
final resultJson = await _methods final resultJson =
.invokeMethod('calculate', {'credentialId': credential.id}); await oath.invoke('calculate', {'credentialId': credential.id});
_log.debug('Calculate', resultJson); _log.debug('Calculate', resultJson);
return OathCode.fromJson(jsonDecode(resultJson)); return OathCode.fromJson(jsonDecode(resultJson));
} on PlatformException catch (pe) { } on PlatformException catch (pe) {
@ -300,9 +298,8 @@ class _AndroidCredentialListNotifier extends OathCredentialListNotifier {
Future<OathCredential> addAccount(Uri credentialUri, Future<OathCredential> addAccount(Uri credentialUri,
{bool requireTouch = false}) async { {bool requireTouch = false}) async {
try { try {
String resultString = await _methods.invokeMethod('addAccount', String resultString = await oath.invoke('addAccount',
{'uri': credentialUri.toString(), 'requireTouch': requireTouch}); {'uri': credentialUri.toString(), 'requireTouch': requireTouch});
var result = jsonDecode(resultString); var result = jsonDecode(resultString);
return OathCredential.fromJson(result['credential']); return OathCredential.fromJson(result['credential']);
} on PlatformException catch (pe) { } on PlatformException catch (pe) {
@ -314,9 +311,8 @@ class _AndroidCredentialListNotifier extends OathCredentialListNotifier {
Future<OathCredential> renameAccount( Future<OathCredential> renameAccount(
OathCredential credential, String? issuer, String name) async { OathCredential credential, String? issuer, String name) async {
try { try {
final response = await _methods.invokeMethod('renameAccount', final response = await oath.invoke('renameAccount',
{'credentialId': credential.id, 'name': name, 'issuer': issuer}); {'credentialId': credential.id, 'name': name, 'issuer': issuer});
_log.debug('Rename response: $response'); _log.debug('Rename response: $response');
var responseJson = jsonDecode(response); var responseJson = jsonDecode(response);
@ -331,11 +327,24 @@ class _AndroidCredentialListNotifier extends OathCredentialListNotifier {
@override @override
Future<void> deleteAccount(OathCredential credential) async { Future<void> deleteAccount(OathCredential credential) async {
try { try {
await _methods await oath.invoke('deleteAccount', {'credentialId': credential.id});
.invokeMethod('deleteAccount', {'credentialId': credential.id});
} on PlatformException catch (e) { } on PlatformException catch (e) {
_log.debug('Received exception: $e'); var decoded = e.decode();
throw e.decode(); if (decoded is CancellationException) {
_log.debug('Account delete was cancelled.');
} else {
_log.debug('Received exception: $e');
}
throw decoded;
} }
} }
} }
final _oathMethodsProvider = NotifierProvider<_OathMethodChannelNotifier, void>(
() => _OathMethodChannelNotifier());
class _OathMethodChannelNotifier extends MethodChannelNotifier {
_OathMethodChannelNotifier()
: super(const MethodChannel('android.oath.methods'));
}

View File

@ -0,0 +1,36 @@
/*
* Copyright (C) 2024 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 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'nfc_overlay.dart';
class MethodChannelNotifier extends Notifier<void> {
final MethodChannel _channel;
MethodChannelNotifier(this._channel);
@override
void build() {}
Future<dynamic> invoke(String name,
[Map<String, dynamic> args = const {}]) async {
final result = await _channel.invokeMethod(name, args);
await ref.read(nfcOverlay.notifier).waitForHide();
return result;
}
}

View File

@ -14,21 +14,16 @@
* limitations under the License. * limitations under the License.
*/ */
package com.yubico.authenticator.fido import 'package:flutter/material.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
const val dialogDescriptionFidoIndex = 200 part 'models.freezed.dart';
enum class FidoActionDescription(private val value: Int) { @freezed
Reset(0), class NfcOverlayWidgetProperties with _$NfcOverlayWidgetProperties {
Unlock(1), factory NfcOverlayWidgetProperties({
SetPin(2), required Widget child,
DeleteCredential(3), @Default(false) bool visible,
DeleteFingerprint(4), @Default(false) bool hasCloseButton,
RenameFingerprint(5), }) = _NfcOverlayWidgetProperties;
RegisterFingerprint(6),
EnableEnterpriseAttestation(7),
ActionFailure(8);
val id: Int
get() = value + dialogDescriptionFidoIndex
} }

View File

@ -0,0 +1,189 @@
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'models.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
T _$identity<T>(T value) => value;
final _privateConstructorUsedError = UnsupportedError(
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models');
/// @nodoc
mixin _$NfcOverlayWidgetProperties {
Widget get child => throw _privateConstructorUsedError;
bool get visible => throw _privateConstructorUsedError;
bool get hasCloseButton => throw _privateConstructorUsedError;
/// Create a copy of NfcOverlayWidgetProperties
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$NfcOverlayWidgetPropertiesCopyWith<NfcOverlayWidgetProperties>
get copyWith => throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $NfcOverlayWidgetPropertiesCopyWith<$Res> {
factory $NfcOverlayWidgetPropertiesCopyWith(NfcOverlayWidgetProperties value,
$Res Function(NfcOverlayWidgetProperties) then) =
_$NfcOverlayWidgetPropertiesCopyWithImpl<$Res,
NfcOverlayWidgetProperties>;
@useResult
$Res call({Widget child, bool visible, bool hasCloseButton});
}
/// @nodoc
class _$NfcOverlayWidgetPropertiesCopyWithImpl<$Res,
$Val extends NfcOverlayWidgetProperties>
implements $NfcOverlayWidgetPropertiesCopyWith<$Res> {
_$NfcOverlayWidgetPropertiesCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of NfcOverlayWidgetProperties
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? child = null,
Object? visible = null,
Object? hasCloseButton = null,
}) {
return _then(_value.copyWith(
child: null == child
? _value.child
: child // ignore: cast_nullable_to_non_nullable
as Widget,
visible: null == visible
? _value.visible
: visible // ignore: cast_nullable_to_non_nullable
as bool,
hasCloseButton: null == hasCloseButton
? _value.hasCloseButton
: hasCloseButton // ignore: cast_nullable_to_non_nullable
as bool,
) as $Val);
}
}
/// @nodoc
abstract class _$$NfcOverlayWidgetPropertiesImplCopyWith<$Res>
implements $NfcOverlayWidgetPropertiesCopyWith<$Res> {
factory _$$NfcOverlayWidgetPropertiesImplCopyWith(
_$NfcOverlayWidgetPropertiesImpl value,
$Res Function(_$NfcOverlayWidgetPropertiesImpl) then) =
__$$NfcOverlayWidgetPropertiesImplCopyWithImpl<$Res>;
@override
@useResult
$Res call({Widget child, bool visible, bool hasCloseButton});
}
/// @nodoc
class __$$NfcOverlayWidgetPropertiesImplCopyWithImpl<$Res>
extends _$NfcOverlayWidgetPropertiesCopyWithImpl<$Res,
_$NfcOverlayWidgetPropertiesImpl>
implements _$$NfcOverlayWidgetPropertiesImplCopyWith<$Res> {
__$$NfcOverlayWidgetPropertiesImplCopyWithImpl(
_$NfcOverlayWidgetPropertiesImpl _value,
$Res Function(_$NfcOverlayWidgetPropertiesImpl) _then)
: super(_value, _then);
/// Create a copy of NfcOverlayWidgetProperties
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? child = null,
Object? visible = null,
Object? hasCloseButton = null,
}) {
return _then(_$NfcOverlayWidgetPropertiesImpl(
child: null == child
? _value.child
: child // ignore: cast_nullable_to_non_nullable
as Widget,
visible: null == visible
? _value.visible
: visible // ignore: cast_nullable_to_non_nullable
as bool,
hasCloseButton: null == hasCloseButton
? _value.hasCloseButton
: hasCloseButton // ignore: cast_nullable_to_non_nullable
as bool,
));
}
}
/// @nodoc
class _$NfcOverlayWidgetPropertiesImpl implements _NfcOverlayWidgetProperties {
_$NfcOverlayWidgetPropertiesImpl(
{required this.child, this.visible = false, this.hasCloseButton = false});
@override
final Widget child;
@override
@JsonKey()
final bool visible;
@override
@JsonKey()
final bool hasCloseButton;
@override
String toString() {
return 'NfcOverlayWidgetProperties(child: $child, visible: $visible, hasCloseButton: $hasCloseButton)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$NfcOverlayWidgetPropertiesImpl &&
(identical(other.child, child) || other.child == child) &&
(identical(other.visible, visible) || other.visible == visible) &&
(identical(other.hasCloseButton, hasCloseButton) ||
other.hasCloseButton == hasCloseButton));
}
@override
int get hashCode => Object.hash(runtimeType, child, visible, hasCloseButton);
/// Create a copy of NfcOverlayWidgetProperties
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$NfcOverlayWidgetPropertiesImplCopyWith<_$NfcOverlayWidgetPropertiesImpl>
get copyWith => __$$NfcOverlayWidgetPropertiesImplCopyWithImpl<
_$NfcOverlayWidgetPropertiesImpl>(this, _$identity);
}
abstract class _NfcOverlayWidgetProperties
implements NfcOverlayWidgetProperties {
factory _NfcOverlayWidgetProperties(
{required final Widget child,
final bool visible,
final bool hasCloseButton}) = _$NfcOverlayWidgetPropertiesImpl;
@override
Widget get child;
@override
bool get visible;
@override
bool get hasCloseButton;
/// Create a copy of NfcOverlayWidgetProperties
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$NfcOverlayWidgetPropertiesImplCopyWith<_$NfcOverlayWidgetPropertiesImpl>
get copyWith => throw _privateConstructorUsedError;
}

View File

@ -0,0 +1,123 @@
/*
* Copyright (C) 2024 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 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:logging/logging.dart';
import '../../../app/logging.dart';
import '../../../app/state.dart';
import 'nfc_overlay.dart';
import 'views/nfc_overlay_widget.dart';
final _log = Logger('android.nfc_event_notifier');
class NfcEvent {
const NfcEvent();
}
class NfcHideViewEvent extends NfcEvent {
final Duration delay;
const NfcHideViewEvent({this.delay = Duration.zero});
}
class NfcSetViewEvent extends NfcEvent {
final Widget child;
final bool showIfHidden;
const NfcSetViewEvent({required this.child, this.showIfHidden = true});
}
final nfcEventNotifier =
NotifierProvider<_NfcEventNotifier, NfcEvent>(_NfcEventNotifier.new);
class _NfcEventNotifier extends Notifier<NfcEvent> {
@override
NfcEvent build() {
return const NfcEvent();
}
void send(NfcEvent event) {
state = event;
}
}
final nfcEventNotifierListener = Provider<_NfcEventNotifierListener>(
(ref) => _NfcEventNotifierListener(ref));
class _NfcEventNotifierListener {
final ProviderRef _ref;
ProviderSubscription<NfcEvent>? listener;
_NfcEventNotifierListener(this._ref);
void startListener(BuildContext context) {
listener?.close();
listener = _ref.listen(nfcEventNotifier, (previous, action) {
_log.debug('Event change: $previous -> $action');
switch (action) {
case (NfcSetViewEvent a):
if (!visible && a.showIfHidden) {
_show(context, a.child);
} else {
_ref
.read(nfcOverlayWidgetProperties.notifier)
.update(child: a.child);
}
break;
case (NfcHideViewEvent e):
_hide(context, e.delay);
break;
}
});
}
void _show(BuildContext context, Widget child) async {
final notifier = _ref.read(nfcOverlayWidgetProperties.notifier);
notifier.update(child: child);
if (!visible) {
visible = true;
final result = await showModalBottomSheet(
context: context,
builder: (BuildContext context) {
return const NfcOverlayWidget();
});
if (result == null) {
// the modal sheet was cancelled by Back button, close button or dismiss
_ref.read(nfcOverlay.notifier).onCancel();
}
visible = false;
}
}
void _hide(BuildContext context, Duration timeout) {
Future.delayed(timeout, () {
_ref.read(withContextProvider)((context) async {
if (visible) {
Navigator.of(context).pop('HIDDEN');
visible = false;
}
});
});
}
bool get visible =>
_ref.read(nfcOverlayWidgetProperties.select((s) => s.visible));
set visible(bool visible) =>
_ref.read(nfcOverlayWidgetProperties.notifier).update(visible: visible);
}

View File

@ -0,0 +1,164 @@
/*
* Copyright (C) 2022-2024 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 'dart:async';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:logging/logging.dart';
import '../../../app/logging.dart';
import '../../../app/state.dart';
import '../../state.dart';
import 'nfc_event_notifier.dart';
import 'views/nfc_content_widget.dart';
import 'views/nfc_overlay_icons.dart';
import 'views/nfc_overlay_widget.dart';
final _log = Logger('android.nfc_overlay');
const _channel = MethodChannel('com.yubico.authenticator.channel.nfc_overlay');
final nfcOverlay =
NotifierProvider<_NfcOverlayNotifier, int>(_NfcOverlayNotifier.new);
class _NfcOverlayNotifier extends Notifier<int> {
Timer? processingViewTimeout;
late final l10n = ref.read(l10nProvider);
@override
int build() {
ref.listen(androidNfcState, (previous, current) {
_log.debug('Received nfc state: $current');
processingViewTimeout?.cancel();
final notifier = ref.read(nfcEventNotifier.notifier);
switch (current) {
case NfcState.ongoing:
// the "Hold still..." view will be shown after this timeout
// if the action is finished before, the timer might be cancelled
// causing the view not to be visible at all
const timeout = 300;
processingViewTimeout =
Timer(const Duration(milliseconds: timeout), () {
notifier.send(showHoldStill());
});
break;
case NfcState.success:
notifier.send(showDone());
notifier
.send(const NfcHideViewEvent(delay: Duration(milliseconds: 400)));
break;
case NfcState.failure:
notifier.send(showFailed());
notifier
.send(const NfcHideViewEvent(delay: Duration(milliseconds: 800)));
break;
case NfcState.disabled:
_log.debug('Received state: disabled');
break;
case NfcState.idle:
_log.debug('Received state: idle');
break;
}
});
_channel.setMethodCallHandler((call) async {
final notifier = ref.read(nfcEventNotifier.notifier);
switch (call.method) {
case 'show':
notifier.send(showTapYourYubiKey());
break;
case 'close':
hide();
break;
default:
throw PlatformException(
code: 'NotImplemented',
message: 'Method ${call.method} is not implemented',
);
}
});
return 0;
}
NfcEvent showTapYourYubiKey() {
ref.read(nfcOverlayWidgetProperties.notifier).update(hasCloseButton: true);
return NfcSetViewEvent(
child: NfcContentWidget(
title: l10n.s_nfc_ready_to_scan,
subtitle: l10n.s_nfc_tap_your_yubikey,
icon: const NfcIconProgressBar(false),
));
}
NfcEvent showHoldStill() {
ref.read(nfcOverlayWidgetProperties.notifier).update(hasCloseButton: false);
return NfcSetViewEvent(
child: NfcContentWidget(
title: l10n.s_nfc_ready_to_scan,
subtitle: l10n.s_nfc_hold_still,
icon: const NfcIconProgressBar(true),
));
}
NfcEvent showDone() {
ref.read(nfcOverlayWidgetProperties.notifier).update(hasCloseButton: false);
return NfcSetViewEvent(
child: NfcContentWidget(
title: l10n.s_nfc_ready_to_scan,
subtitle: l10n.s_done,
icon: const NfcIconSuccess(),
),
showIfHidden: false);
}
NfcEvent showFailed() {
ref.read(nfcOverlayWidgetProperties.notifier).update(hasCloseButton: false);
return NfcSetViewEvent(
child: NfcContentWidget(
title: l10n.s_nfc_ready_to_scan,
subtitle: l10n.l_nfc_failed_to_scan,
icon: const NfcIconFailure(),
),
showIfHidden: false);
}
void hide() {
ref.read(nfcEventNotifier.notifier).send(const NfcHideViewEvent());
}
void onCancel() async {
await _channel.invokeMethod('cancel');
}
Future<void> waitForHide() async {
final completer = Completer();
Timer.periodic(
const Duration(milliseconds: 200),
(timer) {
if (ref.read(nfcOverlayWidgetProperties.select((s) => !s.visible))) {
timer.cancel();
completer.complete();
}
},
);
await completer.future;
}
}

View File

@ -0,0 +1,55 @@
/*
* Copyright (C) 2024 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 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class NfcContentWidget extends ConsumerWidget {
final String title;
final String subtitle;
final Widget icon;
const NfcContentWidget({
super.key,
required this.title,
required this.subtitle,
required this.icon,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final textTheme = theme.textTheme;
final colorScheme = theme.colorScheme;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: Column(
children: [
Text(title, textAlign: TextAlign.center, style: textTheme.titleLarge),
const SizedBox(height: 8),
Text(subtitle,
textAlign: TextAlign.center,
style: textTheme.titleMedium!.copyWith(
color: colorScheme.onSurfaceVariant,
)),
const SizedBox(height: 32),
icon,
const SizedBox(height: 24)
],
),
);
}
}

View File

@ -0,0 +1,79 @@
/*
* Copyright (C) 2024 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 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
class NfcIconProgressBar extends StatelessWidget {
final bool inProgress;
const NfcIconProgressBar(this.inProgress, {super.key});
@override
Widget build(BuildContext context) => IconTheme(
data: IconThemeData(
size: 64,
color: Theme.of(context).colorScheme.primary,
),
child: Stack(
alignment: AlignmentDirectional.center,
children: [
const Opacity(
opacity: 0.5,
child: Icon(Symbols.contactless),
),
const ClipOval(
child: SizedBox(
width: 42,
height: 42,
child: OverflowBox(
maxWidth: double.infinity,
maxHeight: double.infinity,
child: Icon(Symbols.contactless),
),
),
),
SizedBox(
width: 50,
height: 50,
child: CircularProgressIndicator(value: inProgress ? null : 1.0),
),
],
),
);
}
class NfcIconSuccess extends StatelessWidget {
const NfcIconSuccess({super.key});
@override
Widget build(BuildContext context) => Icon(
Symbols.check,
size: 64,
color: Theme.of(context).colorScheme.primary,
);
}
class NfcIconFailure extends StatelessWidget {
const NfcIconFailure({super.key});
@override
Widget build(BuildContext context) => Icon(
Symbols.close,
size: 64,
color: Theme.of(context).colorScheme.error,
);
}

View File

@ -0,0 +1,76 @@
/*
* Copyright (C) 2024 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 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../models.dart';
final nfcOverlayWidgetProperties =
NotifierProvider<_NfcOverlayWidgetProperties, NfcOverlayWidgetProperties>(
_NfcOverlayWidgetProperties.new);
class _NfcOverlayWidgetProperties extends Notifier<NfcOverlayWidgetProperties> {
@override
NfcOverlayWidgetProperties build() {
return NfcOverlayWidgetProperties(child: const SizedBox());
}
void update({
Widget? child,
bool? visible,
bool? hasCloseButton,
}) {
state = state.copyWith(
child: child ?? state.child,
visible: visible ?? state.visible,
hasCloseButton: hasCloseButton ?? state.hasCloseButton);
}
}
class NfcOverlayWidget extends ConsumerWidget {
const NfcOverlayWidget({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final widget = ref.watch(nfcOverlayWidgetProperties.select((s) => s.child));
final showCloseButton =
ref.watch(nfcOverlayWidgetProperties.select((s) => s.hasCloseButton));
return Column(
mainAxisAlignment: MainAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Stack(fit: StackFit.passthrough, children: [
if (showCloseButton)
Positioned(
top: 10,
right: 10,
child: IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Symbols.close, fill: 1, size: 24)),
),
Padding(
padding: const EdgeInsets.fromLTRB(0, 50, 0, 0),
child: widget,
)
]),
const SizedBox(height: 32),
],
);
}
}

View File

@ -69,22 +69,50 @@ class _AndroidClipboard extends AppClipboard {
} }
} }
class NfcStateNotifier extends StateNotifier<bool> { class NfcAdapterState extends StateNotifier<bool> {
NfcStateNotifier() : super(false); NfcAdapterState() : super(false);
void setNfcEnabled(bool value) { void enable(bool value) {
state = value; state = value;
} }
} }
enum NfcState {
disabled,
idle,
ongoing,
success,
failure,
}
class NfcStateNotifier extends StateNotifier<NfcState> {
NfcStateNotifier() : super(NfcState.disabled);
void set(int stateValue) {
var newState = switch (stateValue) {
0 => NfcState.disabled,
1 => NfcState.idle,
2 => NfcState.ongoing,
3 => NfcState.success,
4 => NfcState.failure,
_ => NfcState.disabled
};
state = newState;
}
}
final androidSectionPriority = Provider<List<Section>>((ref) => []); final androidSectionPriority = Provider<List<Section>>((ref) => []);
final androidSdkVersionProvider = Provider<int>((ref) => -1); final androidSdkVersionProvider = Provider<int>((ref) => -1);
final androidNfcSupportProvider = Provider<bool>((ref) => false); final androidNfcSupportProvider = Provider<bool>((ref) => false);
final androidNfcStateProvider = final androidNfcAdapterState =
StateNotifierProvider<NfcStateNotifier, bool>((ref) => NfcStateNotifier()); StateNotifierProvider<NfcAdapterState, bool>((ref) => NfcAdapterState());
final androidNfcState = StateNotifierProvider<NfcStateNotifier, NfcState>(
(ref) => NfcStateNotifier());
final androidSupportedThemesProvider = StateProvider<List<ThemeMode>>((ref) { final androidSupportedThemesProvider = StateProvider<List<ThemeMode>>((ref) {
if (ref.read(androidSdkVersionProvider) < 29) { if (ref.read(androidSdkVersionProvider) < 29) {
@ -191,6 +219,7 @@ class NfcTapActionNotifier extends StateNotifier<NfcTapAction> {
static const _prefNfcOpenApp = 'prefNfcOpenApp'; static const _prefNfcOpenApp = 'prefNfcOpenApp';
static const _prefNfcCopyOtp = 'prefNfcCopyOtp'; static const _prefNfcCopyOtp = 'prefNfcCopyOtp';
final SharedPreferences _prefs; final SharedPreferences _prefs;
NfcTapActionNotifier._(this._prefs, super._state); NfcTapActionNotifier._(this._prefs, super._state);
factory NfcTapActionNotifier(SharedPreferences prefs) { factory NfcTapActionNotifier(SharedPreferences prefs) {
@ -232,6 +261,7 @@ class NfcKbdLayoutNotifier extends StateNotifier<String> {
static const String _defaultClipKbdLayout = 'US'; static const String _defaultClipKbdLayout = 'US';
static const _prefClipKbdLayout = 'prefClipKbdLayout'; static const _prefClipKbdLayout = 'prefClipKbdLayout';
final SharedPreferences _prefs; final SharedPreferences _prefs;
NfcKbdLayoutNotifier(this._prefs) NfcKbdLayoutNotifier(this._prefs)
: super(_prefs.getString(_prefClipKbdLayout) ?? _defaultClipKbdLayout); : super(_prefs.getString(_prefClipKbdLayout) ?? _defaultClipKbdLayout);
@ -250,6 +280,7 @@ final androidNfcBypassTouchProvider =
class NfcBypassTouchNotifier extends StateNotifier<bool> { class NfcBypassTouchNotifier extends StateNotifier<bool> {
static const _prefNfcBypassTouch = 'prefNfcBypassTouch'; static const _prefNfcBypassTouch = 'prefNfcBypassTouch';
final SharedPreferences _prefs; final SharedPreferences _prefs;
NfcBypassTouchNotifier(this._prefs) NfcBypassTouchNotifier(this._prefs)
: super(_prefs.getBool(_prefNfcBypassTouch) ?? false); : super(_prefs.getBool(_prefNfcBypassTouch) ?? false);
@ -268,6 +299,7 @@ final androidNfcSilenceSoundsProvider =
class NfcSilenceSoundsNotifier extends StateNotifier<bool> { class NfcSilenceSoundsNotifier extends StateNotifier<bool> {
static const _prefNfcSilenceSounds = 'prefNfcSilenceSounds'; static const _prefNfcSilenceSounds = 'prefNfcSilenceSounds';
final SharedPreferences _prefs; final SharedPreferences _prefs;
NfcSilenceSoundsNotifier(this._prefs) NfcSilenceSoundsNotifier(this._prefs)
: super(_prefs.getBool(_prefNfcSilenceSounds) ?? false); : super(_prefs.getBool(_prefNfcSilenceSounds) ?? false);
@ -286,6 +318,7 @@ final androidUsbLaunchAppProvider =
class UsbLaunchAppNotifier extends StateNotifier<bool> { class UsbLaunchAppNotifier extends StateNotifier<bool> {
static const _prefUsbOpenApp = 'prefUsbOpenApp'; static const _prefUsbOpenApp = 'prefUsbOpenApp';
final SharedPreferences _prefs; final SharedPreferences _prefs;
UsbLaunchAppNotifier(this._prefs) UsbLaunchAppNotifier(this._prefs)
: super(_prefs.getBool(_prefUsbOpenApp) ?? false); : super(_prefs.getBool(_prefUsbOpenApp) ?? false);

View File

@ -1,231 +0,0 @@
/*
* Copyright (C) 2022-2024 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 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../app/state.dart';
import '../app/views/user_interaction.dart';
const _channel = MethodChannel('com.yubico.authenticator.channel.dialog');
// _DIcon identifies the icon which should be displayed on the dialog
enum _DIcon {
nfcIcon,
successIcon,
failureIcon,
invalid;
static _DIcon fromId(int? id) =>
const {
0: _DIcon.nfcIcon,
1: _DIcon.successIcon,
2: _DIcon.failureIcon
}[id] ??
_DIcon.invalid;
}
// _DDesc contains id of title resource for the dialog
enum _DTitle {
tapKey,
operationSuccessful,
operationFailed,
invalid;
static _DTitle fromId(int? id) =>
const {
0: _DTitle.tapKey,
1: _DTitle.operationSuccessful,
2: _DTitle.operationFailed
}[id] ??
_DTitle.invalid;
}
// _DDesc contains action description in the dialog
enum _DDesc {
// oath descriptions
oathResetApplet,
oathUnlockSession,
oathSetPassword,
oathUnsetPassword,
oathAddAccount,
oathRenameAccount,
oathDeleteAccount,
oathCalculateCode,
oathActionFailure,
oathAddMultipleAccounts,
// FIDO descriptions
fidoResetApplet,
fidoUnlockSession,
fidoSetPin,
fidoDeleteCredential,
fidoDeleteFingerprint,
fidoRenameFingerprint,
fidoRegisterFingerprint,
fidoEnableEnterpriseAttestation,
fidoActionFailure,
// Others
invalid;
static const int dialogDescriptionOathIndex = 100;
static const int dialogDescriptionFidoIndex = 200;
static _DDesc fromId(int? id) =>
const {
dialogDescriptionOathIndex + 0: oathResetApplet,
dialogDescriptionOathIndex + 1: oathUnlockSession,
dialogDescriptionOathIndex + 2: oathSetPassword,
dialogDescriptionOathIndex + 3: oathUnsetPassword,
dialogDescriptionOathIndex + 4: oathAddAccount,
dialogDescriptionOathIndex + 5: oathRenameAccount,
dialogDescriptionOathIndex + 6: oathDeleteAccount,
dialogDescriptionOathIndex + 7: oathCalculateCode,
dialogDescriptionOathIndex + 8: oathActionFailure,
dialogDescriptionOathIndex + 9: oathAddMultipleAccounts,
dialogDescriptionFidoIndex + 0: fidoResetApplet,
dialogDescriptionFidoIndex + 1: fidoUnlockSession,
dialogDescriptionFidoIndex + 2: fidoSetPin,
dialogDescriptionFidoIndex + 3: fidoDeleteCredential,
dialogDescriptionFidoIndex + 4: fidoDeleteFingerprint,
dialogDescriptionFidoIndex + 5: fidoRenameFingerprint,
dialogDescriptionFidoIndex + 6: fidoRegisterFingerprint,
dialogDescriptionFidoIndex + 7: fidoEnableEnterpriseAttestation,
dialogDescriptionFidoIndex + 8: fidoActionFailure,
}[id] ??
_DDesc.invalid;
}
final androidDialogProvider = Provider<_DialogProvider>(
(ref) {
return _DialogProvider(ref.watch(withContextProvider));
},
);
class _DialogProvider {
final WithContext _withContext;
UserInteractionController? _controller;
_DialogProvider(this._withContext) {
_channel.setMethodCallHandler((call) async {
final args = jsonDecode(call.arguments);
switch (call.method) {
case 'close':
closeDialog();
break;
case 'show':
await _showDialog(args['title'], args['description'], args['icon']);
break;
case 'state':
await _updateDialogState(
args['title'], args['description'], args['icon']);
break;
default:
throw PlatformException(
code: 'NotImplemented',
message: 'Method ${call.method} is not implemented',
);
}
});
}
void closeDialog() {
_controller?.close();
_controller = null;
}
Widget? _getIcon(int? icon) => switch (_DIcon.fromId(icon)) {
_DIcon.nfcIcon => const Icon(Symbols.contactless),
_DIcon.successIcon => const Icon(Symbols.check_circle),
_DIcon.failureIcon => const Icon(Symbols.error),
_ => null,
};
String _getTitle(BuildContext context, int? titleId) {
final l10n = AppLocalizations.of(context)!;
return switch (_DTitle.fromId(titleId)) {
_DTitle.tapKey => l10n.l_nfc_dialog_tap_key,
_DTitle.operationSuccessful => l10n.s_nfc_dialog_operation_success,
_DTitle.operationFailed => l10n.s_nfc_dialog_operation_failed,
_ => ''
};
}
String _getDialogDescription(BuildContext context, int? descriptionId) {
final l10n = AppLocalizations.of(context)!;
return switch (_DDesc.fromId(descriptionId)) {
_DDesc.oathResetApplet => l10n.s_nfc_dialog_oath_reset,
_DDesc.oathUnlockSession => l10n.s_nfc_dialog_oath_unlock,
_DDesc.oathSetPassword => l10n.s_nfc_dialog_oath_set_password,
_DDesc.oathUnsetPassword => l10n.s_nfc_dialog_oath_unset_password,
_DDesc.oathAddAccount => l10n.s_nfc_dialog_oath_add_account,
_DDesc.oathRenameAccount => l10n.s_nfc_dialog_oath_rename_account,
_DDesc.oathDeleteAccount => l10n.s_nfc_dialog_oath_delete_account,
_DDesc.oathCalculateCode => l10n.s_nfc_dialog_oath_calculate_code,
_DDesc.oathActionFailure => l10n.s_nfc_dialog_oath_failure,
_DDesc.oathAddMultipleAccounts =>
l10n.s_nfc_dialog_oath_add_multiple_accounts,
_DDesc.fidoResetApplet => l10n.s_nfc_dialog_fido_reset,
_DDesc.fidoUnlockSession => l10n.s_nfc_dialog_fido_unlock,
_DDesc.fidoSetPin => l10n.l_nfc_dialog_fido_set_pin,
_DDesc.fidoDeleteCredential => l10n.s_nfc_dialog_fido_delete_credential,
_DDesc.fidoDeleteFingerprint => l10n.s_nfc_dialog_fido_delete_fingerprint,
_DDesc.fidoRenameFingerprint => l10n.s_nfc_dialog_fido_rename_fingerprint,
_DDesc.fidoActionFailure => l10n.s_nfc_dialog_fido_failure,
_ => ''
};
}
Future<void> _updateDialogState(
int? title, int? description, int? dialogIcon) async {
final icon = _getIcon(dialogIcon);
await _withContext((context) async {
_controller?.updateContent(
title: _getTitle(context, title),
description: _getDialogDescription(context, description),
icon: icon != null
? IconTheme(
data: IconTheme.of(context).copyWith(size: 64),
child: icon,
)
: null,
);
});
}
Future<void> _showDialog(int title, int description, int? dialogIcon) async {
final icon = _getIcon(dialogIcon);
_controller = await _withContext((context) async => promptUserInteraction(
context,
title: _getTitle(context, title),
description: _getDialogDescription(context, description),
icon: icon != null
? IconTheme(
data: IconTheme.of(context).copyWith(size: 64),
child: icon,
)
: null,
onCancel: () {
_channel.invokeMethod('cancel');
},
));
}
}

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2022-2023 Yubico. * Copyright (C) 2022-2024 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.
@ -58,7 +58,7 @@ class _WindowStateNotifier extends StateNotifier<WindowState>
if (lifeCycleState == AppLifecycleState.resumed) { if (lifeCycleState == AppLifecycleState.resumed) {
_log.debug('Reading nfc enabled value'); _log.debug('Reading nfc enabled value');
isNfcEnabled().then((value) => isNfcEnabled().then((value) =>
_ref.read(androidNfcStateProvider.notifier).setNfcEnabled(value)); _ref.read(androidNfcAdapterState.notifier).enable(value));
} }
} else { } else {
_log.debug('Ignoring appLifecycleStateChange'); _log.debug('Ignoring appLifecycleStateChange');

View File

@ -71,7 +71,7 @@ class DevicePickerContent extends ConsumerWidget {
Widget? androidNoKeyWidget; Widget? androidNoKeyWidget;
if (isAndroid && devices.isEmpty) { if (isAndroid && devices.isEmpty) {
var hasNfcSupport = ref.watch(androidNfcSupportProvider); var hasNfcSupport = ref.watch(androidNfcSupportProvider);
var isNfcEnabled = ref.watch(androidNfcStateProvider); var isNfcEnabled = ref.watch(androidNfcAdapterState);
final subtitle = hasNfcSupport && isNfcEnabled final subtitle = hasNfcSupport && isNfcEnabled
? l10n.l_insert_or_tap_yk ? l10n.l_insert_or_tap_yk
: l10n.l_insert_yk; : l10n.l_insert_yk;

View File

@ -52,12 +52,21 @@ class MainPage extends ConsumerWidget {
); );
if (isAndroid) { if (isAndroid) {
isNfcEnabled().then((value) => isNfcEnabled().then(
ref.read(androidNfcStateProvider.notifier).setNfcEnabled(value)); (value) => ref.read(androidNfcAdapterState.notifier).enable(value));
} }
// If the current device changes, we need to pop any open dialogs. // If the current device changes, we need to pop any open dialogs.
ref.listen<AsyncValue<YubiKeyData>>(currentDeviceDataProvider, (_, __) { ref.listen<AsyncValue<YubiKeyData>>(currentDeviceDataProvider,
(prev, next) {
final serial = next.hasValue == true ? next.value?.info.serial : null;
final prevSerial =
prev?.hasValue == true ? prev?.value?.info.serial : null;
if ((serial != null && serial == prevSerial) ||
(next.hasValue && (prev != null && prev.isLoading))) {
return;
}
Navigator.of(context).popUntil((route) { Navigator.of(context).popUntil((route) {
return route.isFirst || return route.isFirst ||
[ [
@ -69,7 +78,6 @@ class MainPage extends ConsumerWidget {
'oath_add_account', 'oath_add_account',
'oath_icon_pack_dialog', 'oath_icon_pack_dialog',
'android_qr_scanner_view', 'android_qr_scanner_view',
'android_alert_dialog'
].contains(route.settings.name); ].contains(route.settings.name);
}); });
}); });
@ -84,7 +92,7 @@ class MainPage extends ConsumerWidget {
if (deviceNode == null) { if (deviceNode == null) {
if (isAndroid) { if (isAndroid) {
var hasNfcSupport = ref.watch(androidNfcSupportProvider); var hasNfcSupport = ref.watch(androidNfcSupportProvider);
var isNfcEnabled = ref.watch(androidNfcStateProvider); var isNfcEnabled = ref.watch(androidNfcAdapterState);
return HomeMessagePage( return HomeMessagePage(
centered: true, centered: true,
graphic: noKeyImage, graphic: noKeyImage,
@ -103,6 +111,10 @@ class MainPage extends ConsumerWidget {
label: Text(l10n.s_add_account), label: Text(l10n.s_add_account),
icon: const Icon(Symbols.person_add_alt), icon: const Icon(Symbols.person_add_alt),
onPressed: () async { onPressed: () async {
// make sure we execute the "Add account" in OATH section
ref
.read(currentSectionProvider.notifier)
.setCurrentSection(Section.accounts);
await addOathAccount(context, ref); await addOathAccount(context, ref);
}) })
], ],

View File

@ -46,7 +46,7 @@ class MessagePageNotInitialized extends ConsumerWidget {
if (isAndroid) { if (isAndroid) {
var hasNfcSupport = ref.watch(androidNfcSupportProvider); var hasNfcSupport = ref.watch(androidNfcSupportProvider);
var isNfcEnabled = ref.watch(androidNfcStateProvider); var isNfcEnabled = ref.watch(androidNfcAdapterState);
var isUsbYubiKey = var isUsbYubiKey =
ref.watch(attachedDevicesProvider).firstOrNull?.transport == ref.watch(attachedDevicesProvider).firstOrNull?.transport ==
Transport.usb; Transport.usb;

View File

@ -280,6 +280,10 @@ class _FidoPinDialogState extends ConsumerState<FidoPinDialog> {
} }
void _submit() async { void _submit() async {
_currentPinFocus.unfocus();
_newPinFocus.unfocus();
_confirmPinFocus.unfocus();
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
final oldPin = _currentPinController.text.isNotEmpty final oldPin = _currentPinController.text.isNotEmpty
? _currentPinController.text ? _currentPinController.text

View File

@ -30,6 +30,7 @@ import '../state.dart';
class PinEntryForm extends ConsumerStatefulWidget { class PinEntryForm extends ConsumerStatefulWidget {
final FidoState _state; final FidoState _state;
final DeviceNode _deviceNode; final DeviceNode _deviceNode;
const PinEntryForm(this._state, this._deviceNode, {super.key}); const PinEntryForm(this._state, this._deviceNode, {super.key});
@override @override
@ -58,6 +59,8 @@ class _PinEntryFormState extends ConsumerState<PinEntryForm> {
} }
void _submit() async { void _submit() async {
_pinFocus.unfocus();
setState(() { setState(() {
_pinIsWrong = false; _pinIsWrong = false;
_isObscure = true; _isObscure = true;

View File

@ -899,29 +899,6 @@
"l_launch_app_on_usb_off": "Andere Anwendungen können den YubiKey über USB nutzen", "l_launch_app_on_usb_off": "Andere Anwendungen können den YubiKey über USB nutzen",
"s_allow_screenshots": "Bildschirmfotos erlauben", "s_allow_screenshots": "Bildschirmfotos erlauben",
"l_nfc_dialog_tap_key": "Halten Sie Ihren Schlüssel dagegen",
"s_nfc_dialog_operation_success": "Erfolgreich",
"s_nfc_dialog_operation_failed": "Fehlgeschlagen",
"s_nfc_dialog_oath_reset": "Aktion: OATH-Anwendung zurücksetzen",
"s_nfc_dialog_oath_unlock": "Aktion: OATH-Anwendung entsperren",
"s_nfc_dialog_oath_set_password": "Aktion: OATH-Passwort setzen",
"s_nfc_dialog_oath_unset_password": "Aktion: OATH-Passwort entfernen",
"s_nfc_dialog_oath_add_account": "Aktion: neues Konto hinzufügen",
"s_nfc_dialog_oath_rename_account": "Aktion: Konto umbenennen",
"s_nfc_dialog_oath_delete_account": "Aktion: Konto löschen",
"s_nfc_dialog_oath_calculate_code": "Aktion: OATH-Code berechnen",
"s_nfc_dialog_oath_failure": "OATH-Operation fehlgeschlagen",
"s_nfc_dialog_oath_add_multiple_accounts": "Aktion: mehrere Konten hinzufügen",
"s_nfc_dialog_fido_reset": "Aktion: FIDO-Anwendung zurücksetzen",
"s_nfc_dialog_fido_unlock": "Aktion: FIDO-Anwendung entsperren",
"l_nfc_dialog_fido_set_pin": "Aktion: FIDO-PIN setzen oder ändern",
"s_nfc_dialog_fido_delete_credential": "Aktion: Passkey löschen",
"s_nfc_dialog_fido_delete_fingerprint": "Aktion: Fingerabdruck löschen",
"s_nfc_dialog_fido_rename_fingerprint": "Aktion: Fingerabdruck umbenennen",
"s_nfc_dialog_fido_failure": "FIDO-Operation fehlgeschlagen",
"@_nfc": {}, "@_nfc": {},
"s_nfc_ready_to_scan": "Bereit zum Scannen", "s_nfc_ready_to_scan": "Bereit zum Scannen",
"s_nfc_hold_still": "Stillhalten\u2026", "s_nfc_hold_still": "Stillhalten\u2026",

View File

@ -899,29 +899,6 @@
"l_launch_app_on_usb_off": "Other apps can use the YubiKey over USB", "l_launch_app_on_usb_off": "Other apps can use the YubiKey over USB",
"s_allow_screenshots": "Allow screenshots", "s_allow_screenshots": "Allow screenshots",
"l_nfc_dialog_tap_key": "Tap and hold your key",
"s_nfc_dialog_operation_success": "Success",
"s_nfc_dialog_operation_failed": "Failed",
"s_nfc_dialog_oath_reset": "Action: reset OATH application",
"s_nfc_dialog_oath_unlock": "Action: unlock OATH application",
"s_nfc_dialog_oath_set_password": "Action: set OATH password",
"s_nfc_dialog_oath_unset_password": "Action: remove OATH password",
"s_nfc_dialog_oath_add_account": "Action: add new account",
"s_nfc_dialog_oath_rename_account": "Action: rename account",
"s_nfc_dialog_oath_delete_account": "Action: delete account",
"s_nfc_dialog_oath_calculate_code": "Action: calculate OATH code",
"s_nfc_dialog_oath_failure": "OATH operation failed",
"s_nfc_dialog_oath_add_multiple_accounts": "Action: add multiple accounts",
"s_nfc_dialog_fido_reset": "Action: reset FIDO application",
"s_nfc_dialog_fido_unlock": "Action: unlock FIDO application",
"l_nfc_dialog_fido_set_pin": "Action: set or change the FIDO PIN",
"s_nfc_dialog_fido_delete_credential": "Action: delete Passkey",
"s_nfc_dialog_fido_delete_fingerprint": "Action: delete fingerprint",
"s_nfc_dialog_fido_rename_fingerprint": "Action: rename fingerprint",
"s_nfc_dialog_fido_failure": "FIDO operation failed",
"@_nfc": {}, "@_nfc": {},
"s_nfc_ready_to_scan": "Ready to scan", "s_nfc_ready_to_scan": "Ready to scan",
"s_nfc_hold_still": "Hold still\u2026", "s_nfc_hold_still": "Hold still\u2026",

View File

@ -899,29 +899,6 @@
"l_launch_app_on_usb_off": "D'autres applications peuvent utiliser la YubiKey en USB", "l_launch_app_on_usb_off": "D'autres applications peuvent utiliser la YubiKey en USB",
"s_allow_screenshots": "Autoriser captures d'écran", "s_allow_screenshots": "Autoriser captures d'écran",
"l_nfc_dialog_tap_key": "Appuyez et maintenez votre clé",
"s_nfc_dialog_operation_success": "Succès",
"s_nfc_dialog_operation_failed": "Échec",
"s_nfc_dialog_oath_reset": "Action\u00a0: réinitialiser applet OATH",
"s_nfc_dialog_oath_unlock": "Action\u00a0: débloquer applet OATH",
"s_nfc_dialog_oath_set_password": "Action\u00a0: définir mot de passe OATH",
"s_nfc_dialog_oath_unset_password": "Action\u00a0: supprimer mot de passe OATH",
"s_nfc_dialog_oath_add_account": "Action\u00a0: ajouter nouveau compte",
"s_nfc_dialog_oath_rename_account": "Action\u00a0: renommer compte",
"s_nfc_dialog_oath_delete_account": "Action\u00a0: supprimer compte",
"s_nfc_dialog_oath_calculate_code": "Action\u00a0: calculer code OATH",
"s_nfc_dialog_oath_failure": "Opération OATH impossible",
"s_nfc_dialog_oath_add_multiple_accounts": "Action\u00a0: ajouter plusieurs comptes",
"s_nfc_dialog_fido_reset": "Action : réinitialiser l'application FIDO",
"s_nfc_dialog_fido_unlock": "Action : déverrouiller l'application FIDO",
"l_nfc_dialog_fido_set_pin": "Action : définir ou modifier le code PIN FIDO",
"s_nfc_dialog_fido_delete_credential": "Action : supprimer le Passkey",
"s_nfc_dialog_fido_delete_fingerprint": "Action : supprimer l'empreinte digitale",
"s_nfc_dialog_fido_rename_fingerprint": "Action : renommer l'empreinte digitale",
"s_nfc_dialog_fido_failure": "Échec de l'opération FIDO",
"@_nfc": {}, "@_nfc": {},
"s_nfc_ready_to_scan": "Prêt à numériser", "s_nfc_ready_to_scan": "Prêt à numériser",
"s_nfc_hold_still": "Ne bougez pas\u2026", "s_nfc_hold_still": "Ne bougez pas\u2026",

View File

@ -899,29 +899,6 @@
"l_launch_app_on_usb_off": "他のアプリがUSB経由でYubiKeyを使用できます", "l_launch_app_on_usb_off": "他のアプリがUSB経由でYubiKeyを使用できます",
"s_allow_screenshots": "スクリーンショットを許可", "s_allow_screenshots": "スクリーンショットを許可",
"l_nfc_dialog_tap_key": "キーをタップして長押しします",
"s_nfc_dialog_operation_success": "成功",
"s_nfc_dialog_operation_failed": "失敗",
"s_nfc_dialog_oath_reset": "アクションOATHアプレットをリセット",
"s_nfc_dialog_oath_unlock": "アクションOATHアプレットをロック解除",
"s_nfc_dialog_oath_set_password": "アクションOATHパスワードを設定",
"s_nfc_dialog_oath_unset_password": "アクションOATHパスワードを削除",
"s_nfc_dialog_oath_add_account": "アクション:新しいアカウントを追加",
"s_nfc_dialog_oath_rename_account": "アクション:アカウント名を変更",
"s_nfc_dialog_oath_delete_account": "アクション:アカウントを削除",
"s_nfc_dialog_oath_calculate_code": "アクションOATHコードを計算",
"s_nfc_dialog_oath_failure": "OATH操作が失敗しました",
"s_nfc_dialog_oath_add_multiple_accounts": "アクション:複数アカウントを追加",
"s_nfc_dialog_fido_reset": "アクション: FIDOアプリケーションをリセット",
"s_nfc_dialog_fido_unlock": "アクションFIDOアプリケーションのロックを解除する",
"l_nfc_dialog_fido_set_pin": "アクションFIDOのPINの設定または変更",
"s_nfc_dialog_fido_delete_credential": "アクション: パスキーを削除",
"s_nfc_dialog_fido_delete_fingerprint": "アクション: 指紋の削除",
"s_nfc_dialog_fido_rename_fingerprint": "アクション: 指紋の名前を変更する",
"s_nfc_dialog_fido_failure": "FIDO操作に失敗しました",
"@_nfc": {}, "@_nfc": {},
"s_nfc_ready_to_scan": "スキャン準備完了", "s_nfc_ready_to_scan": "スキャン準備完了",
"s_nfc_hold_still": "そのまま\u2026", "s_nfc_hold_still": "そのまま\u2026",

View File

@ -899,29 +899,6 @@
"l_launch_app_on_usb_off": "Inne aplikacje mogą korzystać z klucza YubiKey przez USB", "l_launch_app_on_usb_off": "Inne aplikacje mogą korzystać z klucza YubiKey przez USB",
"s_allow_screenshots": "Zezwalaj na zrzuty ekranu", "s_allow_screenshots": "Zezwalaj na zrzuty ekranu",
"l_nfc_dialog_tap_key": "Zbliż i przytrzymaj klucz",
"s_nfc_dialog_operation_success": "Udało się",
"s_nfc_dialog_operation_failed": "Niepowodzenie",
"s_nfc_dialog_oath_reset": "Działanie: zresetowanie aplikacji OATH",
"s_nfc_dialog_oath_unlock": "Działanie: odblokowanie aplikacji OATH",
"s_nfc_dialog_oath_set_password": "Działanie: ustawienie hasła OATH",
"s_nfc_dialog_oath_unset_password": "Działanie: usunięcie hasła OATH",
"s_nfc_dialog_oath_add_account": "Działanie: dodanie nowego konta",
"s_nfc_dialog_oath_rename_account": "Działanie: zmiana nazwy konta",
"s_nfc_dialog_oath_delete_account": "Działanie: usunięcie konta",
"s_nfc_dialog_oath_calculate_code": "Działanie: obliczenie kodu OATH",
"s_nfc_dialog_oath_failure": "Operacja OATH nie powiodła się",
"s_nfc_dialog_oath_add_multiple_accounts": "Działanie: dodanie wielu kont",
"s_nfc_dialog_fido_reset": "Działanie: zresetowanie aplikacji FIDO",
"s_nfc_dialog_fido_unlock": "Działanie: odblokowanie aplikacji FIDO",
"l_nfc_dialog_fido_set_pin": "Działanie: ustawienie lub zmiana kodu PIN FIDO",
"s_nfc_dialog_fido_delete_credential": "Działanie: usunięcie klucza dostępu",
"s_nfc_dialog_fido_delete_fingerprint": "Działanie: usunięcie odcisku palca",
"s_nfc_dialog_fido_rename_fingerprint": "Działanie: zmiana nazwy odcisku palca",
"s_nfc_dialog_fido_failure": "Operacja FIDO nie powiodła się",
"@_nfc": {}, "@_nfc": {},
"s_nfc_ready_to_scan": "Gotowy do skanowania", "s_nfc_ready_to_scan": "Gotowy do skanowania",
"s_nfc_hold_still": "Nie ruszaj się\u2026", "s_nfc_hold_still": "Nie ruszaj się\u2026",

View File

@ -899,29 +899,6 @@
"l_launch_app_on_usb_off": "Các ứng dụng khác có thể sử dụng YubiKey qua USB", "l_launch_app_on_usb_off": "Các ứng dụng khác có thể sử dụng YubiKey qua USB",
"s_allow_screenshots": "Cho phép chụp ảnh màn hình", "s_allow_screenshots": "Cho phép chụp ảnh màn hình",
"l_nfc_dialog_tap_key": "Chạm và giữ khóa của bạn",
"s_nfc_dialog_operation_success": "Thành công",
"s_nfc_dialog_operation_failed": "Thất bại",
"s_nfc_dialog_oath_reset": "Hành động: đặt lại ứng dụng OATH",
"s_nfc_dialog_oath_unlock": "Hành động: mở khóa ứng dụng OATH",
"s_nfc_dialog_oath_set_password": "Hành động: đặt mật khẩu OATH",
"s_nfc_dialog_oath_unset_password": "Hành động: xóa mật khẩu OATH",
"s_nfc_dialog_oath_add_account": "Hành động: thêm tài khoản mới",
"s_nfc_dialog_oath_rename_account": "Hành động: đổi tên tài khoản",
"s_nfc_dialog_oath_delete_account": "Hành động: xóa tài khoản",
"s_nfc_dialog_oath_calculate_code": "Hành động: tính toán mã OATH",
"s_nfc_dialog_oath_failure": "Hành động OATH thất bại",
"s_nfc_dialog_oath_add_multiple_accounts": "Hành động: thêm nhiều tài khoản",
"s_nfc_dialog_fido_reset": "Hành động: đặt lại ứng dụng FIDO",
"s_nfc_dialog_fido_unlock": "Hành động: mở khóa ứng dụng FIDO",
"l_nfc_dialog_fido_set_pin": "Hành động: đặt hoặc thay đổi PIN FIDO",
"s_nfc_dialog_fido_delete_credential": "Hành động: xóa Passkey",
"s_nfc_dialog_fido_delete_fingerprint": "Hành động: xóa dấu vân tay",
"s_nfc_dialog_fido_rename_fingerprint": "Hành động: đổi tên dấu vân tay",
"s_nfc_dialog_fido_failure": "Hành động FIDO thất bại",
"@_nfc": {}, "@_nfc": {},
"s_nfc_ready_to_scan": "Sẵn sàng để quét", "s_nfc_ready_to_scan": "Sẵn sàng để quét",
"s_nfc_hold_still": "Giữ yên\u2026", "s_nfc_hold_still": "Giữ yên\u2026",

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2022-2023 Yubico. * Copyright (C) 2022-2024 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.
@ -40,7 +40,6 @@ import '../../widgets/app_text_field.dart';
import '../../widgets/choice_filter_chip.dart'; import '../../widgets/choice_filter_chip.dart';
import '../../widgets/file_drop_overlay.dart'; import '../../widgets/file_drop_overlay.dart';
import '../../widgets/file_drop_target.dart'; import '../../widgets/file_drop_target.dart';
import '../../widgets/focus_utils.dart';
import '../../widgets/responsive_dialog.dart'; import '../../widgets/responsive_dialog.dart';
import '../../widgets/utf8_utils.dart'; import '../../widgets/utf8_utils.dart';
import '../keys.dart' as keys; import '../keys.dart' as keys;
@ -74,6 +73,9 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
final _issuerController = TextEditingController(); final _issuerController = TextEditingController();
final _accountController = TextEditingController(); final _accountController = TextEditingController();
final _secretController = TextEditingController(); final _secretController = TextEditingController();
final _issuerFocus = FocusNode();
final _accountFocus = FocusNode();
final _secretFocus = FocusNode();
final _periodController = TextEditingController(text: '$defaultPeriod'); final _periodController = TextEditingController(text: '$defaultPeriod');
UserInteractionController? _promptController; UserInteractionController? _promptController;
Uri? _otpauthUri; Uri? _otpauthUri;
@ -88,6 +90,7 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
List<int> _periodValues = [20, 30, 45, 60]; List<int> _periodValues = [20, 30, 45, 60];
List<int> _digitsValues = [6, 8]; List<int> _digitsValues = [6, 8];
List<OathCredential>? _credentials; List<OathCredential>? _credentials;
bool _submitting = false;
@override @override
void dispose() { void dispose() {
@ -95,6 +98,9 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
_accountController.dispose(); _accountController.dispose();
_secretController.dispose(); _secretController.dispose();
_periodController.dispose(); _periodController.dispose();
_issuerFocus.dispose();
_accountFocus.dispose();
_secretFocus.dispose();
super.dispose(); super.dispose();
} }
@ -121,6 +127,7 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
_counter = data.counter; _counter = data.counter;
_isObscure = true; _isObscure = true;
_dataLoaded = true; _dataLoaded = true;
_submitting = false;
}); });
} }
@ -128,8 +135,6 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
{DevicePath? devicePath, required Uri credUri}) async { {DevicePath? devicePath, required Uri credUri}) async {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
try { try {
FocusUtils.unfocus(context);
if (devicePath == null) { if (devicePath == null) {
assert(isAndroid, 'devicePath is only optional for Android'); assert(isAndroid, 'devicePath is only optional for Android');
await ref await ref
@ -272,6 +277,14 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
void submit() async { void submit() async {
if (secretLengthValid && secretFormatValid) { if (secretLengthValid && secretFormatValid) {
_issuerFocus.unfocus();
_accountFocus.unfocus();
_secretFocus.unfocus();
setState(() {
_submitting = true;
});
final cred = CredentialData( final cred = CredentialData(
issuer: issuerText.isEmpty ? null : issuerText, issuer: issuerText.isEmpty ? null : issuerText,
name: nameText, name: nameText,
@ -302,6 +315,10 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
}, },
); );
} }
setState(() {
_submitting = false;
});
} else { } else {
setState(() { setState(() {
_validateSecret = true; _validateSecret = true;
@ -372,8 +389,7 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
decoration: AppInputDecoration( decoration: AppInputDecoration(
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
labelText: l10n.s_issuer_optional, labelText: l10n.s_issuer_optional,
helperText: helperText: '', // Prevents dialog resizing when
'', // Prevents dialog resizing when disabled
errorText: (byteLength(issuerText) > issuerMaxLength) errorText: (byteLength(issuerText) > issuerMaxLength)
? '' // needs empty string to render as error ? '' // needs empty string to render as error
: issuerNoColon : issuerNoColon
@ -382,6 +398,7 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
prefixIcon: const Icon(Symbols.business), prefixIcon: const Icon(Symbols.business),
), ),
textInputAction: TextInputAction.next, textInputAction: TextInputAction.next,
focusNode: _issuerFocus,
onChanged: (value) { onChanged: (value) {
setState(() { setState(() {
// Update maxlengths // Update maxlengths
@ -400,19 +417,22 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
decoration: AppInputDecoration( decoration: AppInputDecoration(
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
labelText: l10n.s_account_name, labelText: l10n.s_account_name,
helperText: '', helperText:
// Prevents dialog resizing when disabled '', // Prevents dialog resizing when disabled
errorText: (byteLength(nameText) > nameMaxLength) errorText: _submitting
? '' // needs empty string to render as error ? null
: isUnique : (byteLength(nameText) > nameMaxLength)
? null ? '' // needs empty string to render as error
: l10n.l_name_already_exists, : isUnique
? null
: l10n.l_name_already_exists,
prefixIcon: const Icon(Symbols.person), prefixIcon: const Icon(Symbols.person),
), ),
textInputAction: TextInputAction.next, textInputAction: TextInputAction.next,
focusNode: _accountFocus,
onChanged: (value) { onChanged: (value) {
setState(() { setState(() {
// Update maxlengths // Update max lengths
}); });
}, },
onSubmitted: (_) { onSubmitted: (_) {
@ -452,6 +472,7 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
)), )),
readOnly: _dataLoaded, readOnly: _dataLoaded,
textInputAction: TextInputAction.done, textInputAction: TextInputAction.done,
focusNode: _secretFocus,
onChanged: (value) { onChanged: (value) {
setState(() { setState(() {
_validateSecret = false; _validateSecret = false;

View File

@ -25,7 +25,6 @@ import '../../app/state.dart';
import '../../management/models.dart'; import '../../management/models.dart';
import '../../widgets/app_input_decoration.dart'; import '../../widgets/app_input_decoration.dart';
import '../../widgets/app_text_field.dart'; import '../../widgets/app_text_field.dart';
import '../../widgets/focus_utils.dart';
import '../../widgets/responsive_dialog.dart'; import '../../widgets/responsive_dialog.dart';
import '../keys.dart' as keys; import '../keys.dart' as keys;
import '../models.dart'; import '../models.dart';
@ -63,8 +62,14 @@ class _ManagePasswordDialogState extends ConsumerState<ManagePasswordDialog> {
super.dispose(); super.dispose();
} }
void _removeFocus() {
_currentPasswordFocus.unfocus();
_newPasswordFocus.unfocus();
_confirmPasswordFocus.unfocus();
}
_submit() async { _submit() async {
FocusUtils.unfocus(context); _removeFocus();
final result = await ref final result = await ref
.read(oathStateProvider(widget.path).notifier) .read(oathStateProvider(widget.path).notifier)
@ -171,6 +176,8 @@ class _ManagePasswordDialogState extends ConsumerState<ManagePasswordDialog> {
onPressed: _currentPasswordController.text.isNotEmpty && onPressed: _currentPasswordController.text.isNotEmpty &&
!_currentIsWrong !_currentIsWrong
? () async { ? () async {
_removeFocus();
final result = await ref final result = await ref
.read(oathStateProvider(widget.path).notifier) .read(oathStateProvider(widget.path).notifier)
.unsetPassword( .unsetPassword(

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2022-2023 Yubico. * Copyright (C) 2022-2024 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.
@ -28,7 +28,6 @@ import '../../desktop/models.dart';
import '../../exception/cancellation_exception.dart'; import '../../exception/cancellation_exception.dart';
import '../../widgets/app_input_decoration.dart'; import '../../widgets/app_input_decoration.dart';
import '../../widgets/app_text_form_field.dart'; import '../../widgets/app_text_form_field.dart';
import '../../widgets/focus_utils.dart';
import '../../widgets/responsive_dialog.dart'; import '../../widgets/responsive_dialog.dart';
import '../../widgets/utf8_utils.dart'; import '../../widgets/utf8_utils.dart';
import '../keys.dart' as keys; import '../keys.dart' as keys;
@ -93,7 +92,7 @@ class RenameAccountDialog extends ConsumerStatefulWidget {
} on CancellationException catch (_) { } on CancellationException catch (_) {
// ignored // ignored
} catch (e) { } catch (e) {
_log.error('Failed to add account', e); _log.error('Failed to rename account', e);
final String errorMessage; final String errorMessage;
// TODO: Make this cleaner than importing desktop specific RpcError. // TODO: Make this cleaner than importing desktop specific RpcError.
if (e is RpcError) { if (e is RpcError) {
@ -118,6 +117,9 @@ class _RenameAccountDialogState extends ConsumerState<RenameAccountDialog> {
late String _issuer; late String _issuer;
late String _name; late String _name;
final _issuerFocus = FocusNode();
final _nameFocus = FocusNode();
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@ -125,8 +127,16 @@ class _RenameAccountDialogState extends ConsumerState<RenameAccountDialog> {
_name = widget.name.trim(); _name = widget.name.trim();
} }
@override
void dispose() {
_issuerFocus.dispose();
_nameFocus.dispose();
super.dispose();
}
void _submit() async { void _submit() async {
FocusUtils.unfocus(context); _issuerFocus.unfocus();
_nameFocus.unfocus();
final nav = Navigator.of(context); final nav = Navigator.of(context);
final renamed = final renamed =
await widget.rename(_issuer.isNotEmpty ? _issuer : null, _name); await widget.rename(_issuer.isNotEmpty ? _issuer : null, _name);
@ -188,6 +198,8 @@ class _RenameAccountDialogState extends ConsumerState<RenameAccountDialog> {
prefixIcon: const Icon(Symbols.business), prefixIcon: const Icon(Symbols.business),
), ),
textInputAction: TextInputAction.next, textInputAction: TextInputAction.next,
focusNode: _issuerFocus,
autofocus: true,
onChanged: (value) { onChanged: (value) {
setState(() { setState(() {
_issuer = value.trim(); _issuer = value.trim();
@ -212,6 +224,7 @@ class _RenameAccountDialogState extends ConsumerState<RenameAccountDialog> {
prefixIcon: const Icon(Symbols.people_alt), prefixIcon: const Icon(Symbols.people_alt),
), ),
textInputAction: TextInputAction.done, textInputAction: TextInputAction.done,
focusNode: _nameFocus,
onChanged: (value) { onChanged: (value) {
setState(() { setState(() {
_name = value.trim(); _name = value.trim();

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2022 Yubico. * Copyright (C) 2021-2024 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.
@ -15,6 +15,7 @@
*/ */
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
const defaultPrimaryColor = Colors.lightGreen; const defaultPrimaryColor = Colors.lightGreen;
@ -50,6 +51,9 @@ class AppTheme {
fontFamily: 'Roboto', fontFamily: 'Roboto',
appBarTheme: const AppBarTheme( appBarTheme: const AppBarTheme(
color: Colors.transparent, color: Colors.transparent,
systemOverlayStyle: SystemUiOverlayStyle(
statusBarIconBrightness: Brightness.dark,
statusBarColor: Colors.transparent),
), ),
listTileTheme: const ListTileThemeData( listTileTheme: const ListTileThemeData(
// For alignment under menu button // For alignment under menu button
@ -81,6 +85,9 @@ class AppTheme {
scaffoldBackgroundColor: colorScheme.surface, scaffoldBackgroundColor: colorScheme.surface,
appBarTheme: const AppBarTheme( appBarTheme: const AppBarTheme(
color: Colors.transparent, color: Colors.transparent,
systemOverlayStyle: SystemUiOverlayStyle(
statusBarIconBrightness: Brightness.light,
statusBarColor: Colors.transparent),
), ),
listTileTheme: const ListTileThemeData( listTileTheme: const ListTileThemeData(
// For alignment under menu button // For alignment under menu button