Merge branch 'adamve/nfc_activity_widget'

This commit is contained in:
Adam Velebil 2024-09-11 07:30:21 +02:00
commit 835749db45
No known key found for this signature in database
GPG Key ID: C9B1E4A3CBBD2E10
44 changed files with 1506 additions and 861 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.
*/
abstract class AppContextManager {
abstract suspend fun processYubiKey(device: YubiKeyDevice)
abstract suspend fun processYubiKey(device: YubiKeyDevice): Boolean
open fun dispose() {}
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
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.annotation.SuppressLint
import android.content.*
import android.content.SharedPreferences.OnSharedPreferenceChangeListener
import android.content.pm.ActivityInfo
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.OathManager
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.withConnection
import com.yubico.yubikit.android.YubiKitManager
import com.yubico.yubikit.android.transport.nfc.NfcConfiguration
import com.yubico.yubikit.android.transport.nfc.NfcNotAvailable
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.UsbYubiKeyManager
import com.yubico.yubikit.core.Transport
import com.yubico.yubikit.core.YubiKeyDevice
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.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodChannel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.json.JSONObject
import org.slf4j.LoggerFactory
@ -94,6 +103,20 @@ class MainActivity : FlutterFragmentActivity() {
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?) {
super.onCreate(savedInstanceState)
@ -105,7 +128,10 @@ class MainActivity : FlutterFragmentActivity() {
allowScreenshots(false)
yubikit = YubiKitManager(this)
yubikit = YubiKitManager(
UsbYubiKeyManager(this),
NfcYubiKeyManager(this, NfcStateDispatcher(nfcStateListener))
)
}
override fun onNewIntent(intent: Intent) {
@ -291,10 +317,15 @@ class MainActivity : FlutterFragmentActivity() {
return
}
if (device is NfcYubiKeyDevice) {
appMethodChannel.nfcStateChanged(NfcState.ONGOING)
}
deviceManager.scpKeyParams = null
// If NFC and FIPS check for SCP11b key
if (device.transport == Transport.NFC && deviceInfo.fipsCapable != 0) {
logger.debug("Checking for usable SCP11b key...")
deviceManager.scpKeyParams =
deviceManager.scpKeyParams = try {
device.withConnection<SmartCardConnection, ScpKeyParams?> { connection ->
val scp = SecurityDomainSession(connection)
val keyRef = scp.keyInformation.keys.firstOrNull { it.kid == ScpKid.SCP11b }
@ -308,6 +339,14 @@ class MainActivity : FlutterFragmentActivity() {
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
@ -319,6 +358,7 @@ class MainActivity : FlutterFragmentActivity() {
deviceManager.setDeviceInfo(deviceInfo)
val supportedContexts = DeviceManager.getSupportedContexts(deviceInfo)
logger.debug("Connected key supports: {}", supportedContexts)
var switchedContext: Boolean = false
if (!supportedContexts.contains(viewModel.appContext.value)) {
val preferredContext = DeviceManager.getPreferredContext(supportedContexts)
logger.debug(
@ -326,18 +366,28 @@ class MainActivity : FlutterFragmentActivity() {
viewModel.appContext.value,
preferredContext
)
switchContext(preferredContext)
switchedContext = switchContext(preferredContext)
}
if (contextManager == null && supportedContexts.isNotEmpty()) {
switchContext(DeviceManager.getPreferredContext(supportedContexts))
switchedContext = switchContext(DeviceManager.getPreferredContext(supportedContexts))
}
contextManager?.let {
try {
it.processYubiKey(device)
} catch (e: Throwable) {
logger.error("Error processing YubiKey in AppContextManager", e)
val requestHandled = it.processYubiKey(device)
if (requestHandled) {
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 lateinit var deviceManager: DeviceManager
private lateinit var appContext: AppContext
private lateinit var dialogManager: DialogManager
private lateinit var nfcOverlayManager: NfcOverlayManager
private lateinit var appPreferences: AppPreferences
private lateinit var flutterLog: FlutterLog
private lateinit var flutterStreams: List<Closeable>
@ -365,13 +415,16 @@ class MainActivity : FlutterFragmentActivity() {
messenger = flutterEngine.dartExecutor.binaryMessenger
flutterLog = FlutterLog(messenger)
deviceManager = DeviceManager(this, viewModel)
appContext = AppContext(messenger, this.lifecycleScope, viewModel)
dialogManager = DialogManager(messenger, this.lifecycleScope)
appPreferences = AppPreferences(this)
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)
managementHandler = ManagementHandler(messenger, deviceManager, dialogManager)
managementHandler = ManagementHandler(messenger, deviceManager)
nfcStateListener.appMethodChannel = appMethodChannel
flutterStreams = listOf(
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
// only recreate the contextManager object if it cannot be reused
if (appContext == OperationContext.Home ||
@ -404,6 +458,7 @@ class MainActivity : FlutterFragmentActivity() {
} else {
contextManager?.dispose()
contextManager = null
switchHappened = true
}
if (contextManager == null) {
@ -413,7 +468,7 @@ class MainActivity : FlutterFragmentActivity() {
messenger,
deviceManager,
oathViewModel,
dialogManager,
nfcOverlayManager,
appPreferences
)
@ -422,17 +477,20 @@ class MainActivity : FlutterFragmentActivity() {
messenger,
this,
deviceManager,
appMethodChannel,
nfcOverlayManager,
fidoViewModel,
viewModel,
dialogManager
viewModel
)
else -> null
}
}
return switchHappened
}
override fun cleanUpFlutterEngine(flutterEngine: FlutterEngine) {
nfcStateListener.appMethodChannel = null
flutterStreams.forEach { it.close() }
contextManager?.dispose()
deviceManager.dispose()
@ -572,9 +630,18 @@ class MainActivity : FlutterFragmentActivity() {
fun nfcAdapterStateChanged(value: Boolean) {
methodChannel.invokeMethod(
"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 {

View File

@ -0,0 +1,63 @@
/*
* 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.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.LifecycleOwner
import androidx.lifecycle.Observer
import com.yubico.authenticator.ContextDisposedException
import com.yubico.authenticator.MainActivity
import com.yubico.authenticator.MainViewModel
import com.yubico.authenticator.NfcOverlayManager
import com.yubico.authenticator.OperationContext
import com.yubico.authenticator.yubikit.NfcState
import com.yubico.yubikit.android.transport.usb.UsbYubiKeyDevice
import com.yubico.yubikit.core.YubiKeyDevice
import com.yubico.yubikit.core.smartcard.scp.ScpKeyParams
import com.yubico.yubikit.management.Capability
import kotlinx.coroutines.CancellationException
import org.slf4j.LoggerFactory
import java.io.IOException
interface DeviceListener {
// a USB device is connected
@ -41,7 +47,9 @@ interface DeviceListener {
class DeviceManager(
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
@ -167,7 +175,6 @@ class DeviceManager(
fun setDeviceInfo(deviceInfo: Info?) {
appViewModel.setDeviceInfo(deviceInfo)
scpKeyParams = null
}
fun isUsbKeyConnected(): Boolean {
@ -179,8 +186,32 @@ class DeviceManager(
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 {
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
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.fido.data.YubiKitFidoSession
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.util.Result
import org.slf4j.LoggerFactory
import java.util.TimerTask
import kotlin.coroutines.cancellation.CancellationException
import kotlin.coroutines.suspendCoroutine
class FidoConnectionHelper(
private val deviceManager: DeviceManager,
private val dialogManager: DialogManager
) {
class FidoConnectionHelper(private val deviceManager: DeviceManager) {
private var pendingAction: FidoAction? = null
fun invokePending(fidoSession: YubiKitFidoSession) {
fun invokePending(fidoSession: YubiKitFidoSession): Boolean {
var requestHandled = true
pendingAction?.let { action ->
pendingAction = null
// it is the pending action who handles this request
requestHandled = false
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
}
}
@ -51,14 +58,18 @@ class FidoConnectionHelper(
}
suspend fun <T> useSession(
actionDescription: FidoActionDescription,
updateDeviceInfo: Boolean = false,
action: (YubiKitFidoSession) -> T
block: (YubiKitFidoSession) -> T
): T {
FidoManager.updateDeviceInfo.set(updateDeviceInfo)
return deviceManager.withKey(
onNfc = { useSessionNfc(actionDescription,action) },
onUsb = { useSessionUsb(it, updateDeviceInfo, action) })
onUsb = { useSessionUsb(it, updateDeviceInfo, block) },
onNfc = { useSessionNfc(block) },
onCancelled = {
pendingAction?.invoke(Result.failure(CancellationException()))
pendingAction = null
}
)
}
suspend fun <T> useSessionUsb(
@ -74,9 +85,8 @@ class FidoConnectionHelper(
}
suspend fun <T> useSessionNfc(
actionDescription: FidoActionDescription,
block: (YubiKitFidoSession) -> T
): T {
): Result<T, Throwable> {
try {
val result = suspendCoroutine { outer ->
pendingAction = {
@ -84,23 +94,13 @@ class FidoConnectionHelper(
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) {
throw cancelled
return Result.failure(cancelled)
} catch (error: Throwable) {
throw error
} finally {
dialogManager.closeDialog()
logger.error("Exception during action: ", error)
return Result.failure(error)
}
}

View File

@ -18,7 +18,8 @@ package com.yubico.authenticator.fido
import androidx.lifecycle.LifecycleOwner
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.NULL
import com.yubico.authenticator.asString
@ -70,9 +71,10 @@ class FidoManager(
messenger: BinaryMessenger,
lifecycleOwner: LifecycleOwner,
private val deviceManager: DeviceManager,
private val appMethodChannel: MainActivity.AppMethodChannel,
private val nfcOverlayManager: NfcOverlayManager,
private val fidoViewModel: FidoViewModel,
mainViewModel: MainViewModel,
dialogManager: DialogManager,
mainViewModel: MainViewModel
) : AppContextManager(), DeviceListener {
@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 coroutineScope = CoroutineScope(SupervisorJob() + dispatcher)
@ -117,14 +119,14 @@ class FidoManager(
FidoResetHelper(
lifecycleOwner,
deviceManager,
appMethodChannel,
nfcOverlayManager,
fidoViewModel,
mainViewModel,
connectionHelper,
pinStore
)
init {
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() {
super.dispose()
deviceManager.removeDeviceListener(this)
@ -182,15 +190,16 @@ class FidoManager(
coroutineScope.cancel()
}
override suspend fun processYubiKey(device: YubiKeyDevice) {
override suspend fun processYubiKey(device: YubiKeyDevice): Boolean {
var requestHandled = true
try {
if (device.supportsConnection(FidoConnection::class.java)) {
device.withConnection<FidoConnection, Unit> { connection ->
processYubiKey(connection, device)
requestHandled = processYubiKey(connection, device)
}
} else {
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
logger.error("Failure when processing YubiKey: ", e)
// Clear any cached FIDO state
fidoViewModel.clearSessionState()
connectionHelper.failPending(e)
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 =
if (connection is FidoConnection) {
YubiKitFidoSession(connection)
@ -226,7 +243,7 @@ class FidoManager(
val sameDevice = currentSession == previousSession
if (device is NfcYubiKeyDevice && (sameDevice || resetHelper.inProgress)) {
connectionHelper.invokePending(fidoSession)
requestHandled = connectionHelper.invokePending(fidoSession)
} else {
if (!sameDevice) {
@ -250,6 +267,8 @@ class FidoManager(
Session(infoData, pinStore.hasPin(), pinRetries)
)
}
return requestHandled
}
private fun getPinPermissionsCM(fidoSession: YubiKitFidoSession): Int {
@ -353,7 +372,7 @@ class FidoManager(
}
private suspend fun unlock(pin: CharArray): String =
connectionHelper.useSession(FidoActionDescription.Unlock) { fidoSession ->
connectionHelper.useSession { fidoSession ->
try {
val clientPin =
@ -390,7 +409,7 @@ class FidoManager(
}
private suspend fun setPin(pin: CharArray?, newPin: CharArray): String =
connectionHelper.useSession(FidoActionDescription.SetPin, updateDeviceInfo = true) { fidoSession ->
connectionHelper.useSession(updateDeviceInfo = true) { fidoSession ->
try {
val clientPin =
ClientPin(fidoSession, getPreferredPinUvAuthProtocol(fidoSession.cachedInfo))
@ -438,7 +457,7 @@ class FidoManager(
}
private suspend fun deleteCredential(rpId: String, credentialId: String): String =
connectionHelper.useSession(FidoActionDescription.DeleteCredential) { fidoSession ->
connectionHelper.useSession { fidoSession ->
val clientPin =
ClientPin(fidoSession, getPreferredPinUvAuthProtocol(fidoSession.cachedInfo))
@ -486,7 +505,7 @@ class FidoManager(
}
private suspend fun deleteFingerprint(templateId: String): String =
connectionHelper.useSession(FidoActionDescription.DeleteFingerprint) { fidoSession ->
connectionHelper.useSession { fidoSession ->
val clientPin =
ClientPin(fidoSession, getPreferredPinUvAuthProtocol(fidoSession.cachedInfo))
@ -511,7 +530,7 @@ class FidoManager(
}
private suspend fun renameFingerprint(templateId: String, name: String): String =
connectionHelper.useSession(FidoActionDescription.RenameFingerprint) { fidoSession ->
connectionHelper.useSession { fidoSession ->
val clientPin =
ClientPin(fidoSession, getPreferredPinUvAuthProtocol(fidoSession.cachedInfo))
@ -541,7 +560,7 @@ class FidoManager(
}
private suspend fun registerFingerprint(name: String?): String =
connectionHelper.useSession(FidoActionDescription.RegisterFingerprint) { fidoSession ->
connectionHelper.useSession { fidoSession ->
state?.cancel()
state = CommandState()
val clientPin =
@ -588,7 +607,7 @@ class FidoManager(
}
else -> throw ctapException
}
} catch (io: IOException) {
} catch (_: IOException) {
return@useSession JSONObject(
mapOf(
"success" to false,
@ -617,7 +636,7 @@ class FidoManager(
}
private suspend fun enableEnterpriseAttestation(): String =
connectionHelper.useSession(FidoActionDescription.EnableEnterpriseAttestation) { fidoSession ->
connectionHelper.useSession { fidoSession ->
try {
val uvAuthProtocol = getPreferredPinUvAuthProtocol(fidoSession.cachedInfo)
val clientPin = ClientPin(fidoSession, uvAuthProtocol)

View File

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

View File

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

View File

@ -16,7 +16,6 @@
package com.yubico.authenticator.management
import com.yubico.authenticator.DialogManager
import com.yubico.authenticator.NULL
import com.yubico.authenticator.device.DeviceManager
import com.yubico.authenticator.setHandler
@ -27,25 +26,15 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.asCoroutineDispatcher
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(
messenger: BinaryMessenger,
deviceManager: DeviceManager,
dialogManager: DialogManager
deviceManager: DeviceManager
) {
private val channel = MethodChannel(messenger, "android.management.methods")
private val dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
private val coroutineScope = CoroutineScope(SupervisorJob() + dispatcher)
private val connectionHelper = ManagementConnectionHelper(deviceManager, dialogManager)
private val connectionHelper = ManagementConnectionHelper(deviceManager)
init {
channel.setHandler(coroutineScope) { method, _ ->
@ -58,7 +47,7 @@ class ManagementHandler(
}
private suspend fun deviceReset(): String =
connectionHelper.useSession(ManagementActionDescription.DeviceReset) { managementSession ->
connectionHelper.useSession { managementSession ->
managementSession.deviceReset()
NULL
}

View File

@ -63,6 +63,7 @@ import kotlinx.serialization.encodeToString
import org.slf4j.LoggerFactory
import java.io.IOException
import java.net.URI
import java.util.TimerTask
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.coroutines.suspendCoroutine
@ -74,8 +75,8 @@ class OathManager(
messenger: BinaryMessenger,
private val deviceManager: DeviceManager,
private val oathViewModel: OathViewModel,
private val dialogManager: DialogManager,
private val appPreferences: AppPreferences,
private val nfcOverlayManager: NfcOverlayManager,
private val appPreferences: AppPreferences
) : AppContextManager(), DeviceListener {
companion object {
@ -107,15 +108,26 @@ class OathManager(
private var refreshJob: Job? = null
private var addToAny = 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() {
deviceInfoTimer?.cancel()
// cancel any pending actions, except for addToAny
if (!addToAny) {
pendingAction?.let {
logger.debug("Cancelling pending action/closing nfc dialog.")
logger.debug("Cancelling pending action/closing nfc overlay.")
it.invoke(Result.failure(CancellationException()))
coroutineScope.launch {
dialogManager.closeDialog()
nfcOverlayManager.close()
}
pendingAction = null
}
@ -186,6 +198,7 @@ class OathManager(
)
"deleteAccount" -> deleteAccount(args["credentialId"] as String)
"addAccountToAny" -> addAccountToAny(
args["uri"] as String,
args["requireTouch"] as Boolean
@ -208,28 +221,59 @@ class OathManager(
oathChannel.setMethodCallHandler(null)
oathViewModel.clearSession()
oathViewModel.updateCredentials(mapOf())
pendingAction?.invoke(Result.failure(Exception()))
pendingAction?.invoke(Result.failure(ContextDisposedException()))
pendingAction = null
coroutineScope.cancel()
}
override suspend fun processYubiKey(device: YubiKeyDevice) {
override suspend fun processYubiKey(device: YubiKeyDevice): Boolean {
var requestHandled = true
try {
device.withConnection<SmartCardConnection, Unit> { connection ->
val session = getOathSession(connection)
val previousId = oathViewModel.currentSession()?.deviceId
if (session.deviceId == previousId && device is NfcYubiKeyDevice) {
// Run any pending action
pendingAction?.let { action ->
action.invoke(Result.success(session))
pendingAction = null
// only run pending action over NFC
// when the device is still the same
// or when there is no previous device, but we have a pending action
if (device is NfcYubiKeyDevice &&
((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
if (!session.isLocked) {
try {
oathViewModel.updateCredentials(calculateOathCodes(session))
} catch (error: Exception) {
logger.error("Failed to refresh codes", error)
// Either run a pending action, or just refresh codes
if (pendingAction != null) {
pendingAction?.let { action ->
pendingAction = null
// it is the pending action who handles this request
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 {
@ -246,7 +290,15 @@ class OathManager(
)
)
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?
@ -255,6 +307,7 @@ class OathManager(
if (addToAny) {
// Special "add to any YubiKey" action, process
addToAny = false
requestHandled = false
action.invoke(Result.success(session))
} else {
// Awaiting an action for a different device? Fail it and stop processing.
@ -284,6 +337,7 @@ class OathManager(
}
}
}
logger.debug(
"Successfully read Oath session info (and credentials if unlocked) from connected key"
)
@ -293,11 +347,25 @@ class OathManager(
}
} catch (e: Exception) {
// 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
oathViewModel.clearSession()
// Remove any pending action
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(
@ -307,7 +375,7 @@ class OathManager(
val credentialData: CredentialData =
CredentialData.parseUri(URI.create(uri))
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
if (session.credentials.any { it.id.contentEquals(credentialData.id) }) {
throw IllegalArgumentException()
@ -337,7 +405,7 @@ class OathManager(
logger.trace("Adding following accounts: {}", uris)
addToAny = true
return useOathSession(OathActionDescription.AddMultipleAccounts) { session ->
return useOathSession { session ->
var successCount = 0
for (index in uris.indices) {
@ -369,7 +437,7 @@ class OathManager(
}
private suspend fun reset(): String =
useOathSession(OathActionDescription.Reset, updateDeviceInfo = true) {
useOathSession(updateDeviceInfo = true) {
// note, it is ok to reset locked session
it.reset()
keyManager.removeKey(it.deviceId)
@ -381,7 +449,7 @@ class OathManager(
}
private suspend fun unlock(password: String, remember: Boolean): String =
useOathSession(OathActionDescription.Unlock) {
useOathSession {
val accessKey = it.deriveAccessKey(password.toCharArray())
keyManager.addKey(it.deviceId, accessKey, remember)
@ -390,9 +458,13 @@ class OathManager(
if (unlocked) {
oathViewModel.setSessionState(Session(it, remembered))
// fetch credentials after unlocking only if the YubiKey is connected over USB
if (deviceManager.isUsbKeyConnected()) {
try {
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,
): String =
useOathSession(
OathActionDescription.SetPassword,
unlock = false,
updateDeviceInfo = true
) { session ->
@ -426,7 +497,7 @@ class OathManager(
}
private suspend fun unsetPassword(currentPassword: String): String =
useOathSession(OathActionDescription.UnsetPassword, unlock = false) { session ->
useOathSession(unlock = false) { session ->
if (session.isAccessKeySet) {
// test current password sent by the user
if (session.unlock(currentPassword.toCharArray())) {
@ -458,7 +529,7 @@ class OathManager(
uri: String,
requireTouch: Boolean,
): String =
useOathSession(OathActionDescription.AddAccount) { session ->
useOathSession { session ->
val credentialData: CredentialData =
CredentialData.parseUri(URI.create(uri))
@ -479,21 +550,24 @@ class OathManager(
}
private suspend fun renameAccount(uri: String, name: String, issuer: String?): String =
useOathSession(OathActionDescription.RenameAccount) { session ->
val credential = getOathCredential(session, uri)
val renamedCredential =
Credential(session.renameCredential(credential, name, issuer), session.deviceId)
oathViewModel.renameCredential(
Credential(credential, session.deviceId),
renamedCredential
useOathSession { session ->
val credential = getCredential(uri)
val renamed = Credential(
session.renameCredential(credential, name, issuer),
session.deviceId
)
jsonSerializer.encodeToString(renamedCredential)
oathViewModel.renameCredential(
Credential(credential, session.deviceId),
renamed
)
jsonSerializer.encodeToString(renamed)
}
private suspend fun deleteAccount(credentialId: String): String =
useOathSession(OathActionDescription.DeleteAccount) { session ->
val credential = getOathCredential(session, credentialId)
useOathSession { session ->
val credential = getCredential(credentialId)
session.deleteCredential(credential)
oathViewModel.removeCredential(Credential(credential, session.deviceId))
NULL
@ -510,7 +584,7 @@ class OathManager(
deviceManager.withKey { usbYubiKeyDevice ->
try {
useOathSessionUsb(usbYubiKeyDevice) { session ->
useSessionUsb(usbYubiKeyDevice) { session ->
try {
oathViewModel.updateCredentials(calculateOathCodes(session))
} catch (apduException: ApduException) {
@ -534,7 +608,10 @@ class OathManager(
logger.error("IOException when accessing USB device: ", ioException)
clearCodes()
} catch (illegalStateException: IllegalStateException) {
logger.error("IllegalStateException when accessing USB device: ", illegalStateException)
logger.error(
"IllegalStateException when accessing USB device: ",
illegalStateException
)
clearCodes()
}
}
@ -542,8 +619,8 @@ class OathManager(
private suspend fun calculate(credentialId: String): String =
useOathSession(OathActionDescription.CalculateCode) { session ->
val credential = getOathCredential(session, credentialId)
useOathSession { session ->
val credential = getCredential(credentialId)
val code = Code.from(calculateCode(session, credential))
oathViewModel.updateCode(
@ -633,6 +710,14 @@ class OathManager(
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?> {
val isUsbKey = deviceManager.isUsbKeyConnected()
@ -645,35 +730,51 @@ class OathManager(
return session.calculateCodes(timestamp).map { (credential, code) ->
Pair(
Credential(credential, session.deviceId),
Code.from(if (credential.isSteamCredential() && (!credential.isTouchRequired || bypassTouch)) {
session.calculateSteamCode(credential, timestamp)
} else if (credential.isTouchRequired && bypassTouch) {
session.calculateCode(credential, timestamp)
} else {
code
})
Code.from(
if (credential.isSteamCredential() && (!credential.isTouchRequired || bypassTouch)) {
session.calculateSteamCode(credential, timestamp)
} else if (credential.isTouchRequired && bypassTouch) {
session.calculateCode(credential, timestamp)
} else {
code
}
)
)
}.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(
oathActionDescription: OathActionDescription,
unlock: Boolean = true,
updateDeviceInfo: Boolean = false,
action: (YubiKitOathSession) -> T
block: (YubiKitOathSession) -> T
): T {
// callers can decide whether the session should be unlocked first
unlockOnConnect.set(unlock)
// callers can request whether device info should be updated after session operation
this@OathManager.updateDeviceInfo.set(updateDeviceInfo)
return deviceManager.withKey(
onUsb = { useOathSessionUsb(it, updateDeviceInfo, action) },
onNfc = { useOathSessionNfc(oathActionDescription, action) }
onUsb = { useSessionUsb(it, updateDeviceInfo, block) },
onNfc = { useSessionNfc(block) },
onCancelled = {
pendingAction?.invoke(Result.failure(CancellationException()))
pendingAction = null
}
)
}
private suspend fun <T> useOathSessionUsb(
private suspend fun <T> useSessionUsb(
device: UsbYubiKeyDevice,
updateDeviceInfo: Boolean = false,
block: (YubiKitOathSession) -> T
@ -685,10 +786,9 @@ class OathManager(
}
}
private suspend fun <T> useOathSessionNfc(
oathActionDescription: OathActionDescription,
block: (YubiKitOathSession) -> T
): T {
private suspend fun <T> useSessionNfc(
block: (YubiKitOathSession) -> T,
): Result<T, Throwable> {
try {
val result = suspendCoroutine { outer ->
pendingAction = {
@ -696,41 +796,18 @@ class OathManager(
block.invoke(it.value)
})
}
dialogManager.showDialog(DialogIcon.Nfc, DialogTitle.TapKey, oathActionDescription.id) {
logger.debug("Cancelled Dialog {}", oathActionDescription.name)
pendingAction?.invoke(Result.failure(CancellationException()))
pendingAction = null
}
// here the coroutine is suspended and waits till pendingAction is
// invoked - the pending action result will resume this coroutine
}
dialogManager.updateDialogState(
dialogIcon = DialogIcon.Success,
dialogTitle = DialogTitle.OperationSuccessful
)
// TODO: This delays the closing of the dialog, but also the return value
delay(500)
return result
return Result.success(result!!)
} catch (cancelled: CancellationException) {
throw cancelled
} catch (error: Throwable) {
dialogManager.updateDialogState(
dialogIcon = DialogIcon.Failure,
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()
return Result.failure(cancelled)
} catch (e: Exception) {
logger.error("Exception during action: ", e)
return Result.failure(e)
}
}
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) {
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");
* you may not use this file except in compliance with the License.
@ -35,9 +35,10 @@ data class Credential(
@SerialName("name")
val accountName: String,
@SerialName("touch_required")
val touchRequired: Boolean
val touchRequired: Boolean,
@kotlinx.serialization.Transient
val data: YubiKitCredential? = null
) {
constructor(credential: YubiKitCredential, deviceId: String) : this(
deviceId = deviceId,
id = credential.id.asString(),
@ -48,7 +49,8 @@ data class Credential(
period = credential.period,
issuer = credential.issuer,
accountName = credential.accountName,
touchRequired = credential.isTouchRequired
touchRequired = credential.isTouchRequired,
data = credential
)
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");
* you may not use this file except in compliance with the License.
@ -14,22 +14,12 @@
* limitations under the License.
*/
package com.yubico.authenticator.oath
package com.yubico.authenticator.yubikit
const val dialogDescriptionOathIndex = 100
enum class OathActionDescription(private val value: Int) {
Reset(0),
Unlock(1),
SetPassword(2),
UnsetPassword(3),
AddAccount(4),
RenameAccount(5),
DeleteAccount(6),
CalculateCode(7),
ActionFailure(8),
AddMultipleAccounts(9);
val id: Int
get() = value + dialogDescriptionOathIndex
enum class NfcState(val value: Int) {
DISABLED(0),
IDLE(1),
ONGOING(2),
SUCCESS(3),
FAILURE(4)
}

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_riverpod/flutter_riverpod.dart';
import '../theme.dart';
import 'state.dart';
@ -73,8 +74,14 @@ void setupAppMethodsChannel(WidgetRef ref) {
switch (call.method) {
case 'nfcAdapterStateChanged':
{
var nfcEnabled = args['nfcEnabled'];
ref.read(androidNfcStateProvider.notifier).setNfcEnabled(nfcEnabled);
var enabled = args['enabled'];
ref.read(androidNfcAdapterState.notifier).enable(enabled);
break;
}
case 'nfcStateChanged':
{
var nfcState = args['state'];
ref.read(androidNfcState.notifier).set(nfcState);
break;
}
default:

View File

@ -32,17 +32,18 @@ import '../../exception/no_data_exception.dart';
import '../../exception/platform_exception_decoder.dart';
import '../../fido/models.dart';
import '../../fido/state.dart';
import '../overlay/nfc/method_channel_notifier.dart';
final _log = Logger('android.fido.state');
const _methods = MethodChannel('android.fido.methods');
final androidFidoStateProvider = AsyncNotifierProvider.autoDispose
.family<FidoStateNotifier, FidoState, DevicePath>(_FidoStateNotifier.new);
class _FidoStateNotifier extends FidoStateNotifier {
final _events = const EventChannel('android.fido.sessionState');
late StreamSubscription _sub;
late final _FidoMethodChannelNotifier fido =
ref.read(_fidoMethodsProvider.notifier);
@override
FutureOr<FidoState> build(DevicePath devicePath) async {
@ -79,7 +80,7 @@ class _FidoStateNotifier extends FidoStateNotifier {
});
controller.onCancel = () async {
await _methods.invokeMethod('cancelReset');
await fido.invoke('cancelReset');
if (!controller.isClosed) {
await subscription.cancel();
}
@ -87,7 +88,7 @@ class _FidoStateNotifier extends FidoStateNotifier {
controller.onListen = () async {
try {
await _methods.invokeMethod('reset');
await fido.invoke('reset');
await controller.sink.close();
ref.invalidateSelf();
} catch (e) {
@ -102,13 +103,8 @@ class _FidoStateNotifier extends FidoStateNotifier {
@override
Future<PinResult> setPin(String newPin, {String? oldPin}) async {
try {
final response = jsonDecode(await _methods.invokeMethod(
'setPin',
{
'pin': oldPin,
'newPin': newPin,
},
));
final response = jsonDecode(
await fido.invoke('setPin', {'pin': oldPin, 'newPin': newPin}));
if (response['success'] == true) {
_log.debug('FIDO PIN set/change successful');
return PinResult.success();
@ -134,10 +130,7 @@ class _FidoStateNotifier extends FidoStateNotifier {
@override
Future<PinResult> unlock(String pin) async {
try {
final response = jsonDecode(await _methods.invokeMethod(
'unlock',
{'pin': pin},
));
final response = jsonDecode(await fido.invoke('unlock', {'pin': pin}));
if (response['success'] == true) {
_log.debug('FIDO applet unlocked');
@ -165,9 +158,8 @@ class _FidoStateNotifier extends FidoStateNotifier {
@override
Future<void> enableEnterpriseAttestation() async {
try {
final response = jsonDecode(await _methods.invokeMethod(
'enableEnterpriseAttestation',
));
final response =
jsonDecode(await fido.invoke('enableEnterpriseAttestation'));
if (response['success'] == true) {
_log.debug('Enterprise attestation enabled');
@ -193,6 +185,8 @@ final androidFingerprintProvider = AsyncNotifierProvider.autoDispose
class _FidoFingerprintsNotifier extends FidoFingerprintsNotifier {
final _events = const EventChannel('android.fido.fingerprints');
late StreamSubscription _sub;
late final _FidoMethodChannelNotifier fido =
ref.read(_fidoMethodsProvider.notifier);
@override
FutureOr<List<Fingerprint>> build(DevicePath devicePath) async {
@ -243,7 +237,7 @@ class _FidoFingerprintsNotifier extends FidoFingerprintsNotifier {
controller.onCancel = () async {
if (!controller.isClosed) {
_log.debug('Cancelling fingerprint registration');
await _methods.invokeMethod('cancelRegisterFingerprint');
await fido.invoke('cancelRegisterFingerprint');
await registerFpSub.cancel();
}
};
@ -251,7 +245,7 @@ class _FidoFingerprintsNotifier extends FidoFingerprintsNotifier {
controller.onListen = () async {
try {
final registerFpResult =
await _methods.invokeMethod('registerFingerprint', {'name': name});
await fido.invoke('registerFingerprint', {'name': name});
_log.debug('Finished registerFingerprint with: $registerFpResult');
@ -286,13 +280,9 @@ class _FidoFingerprintsNotifier extends FidoFingerprintsNotifier {
Future<Fingerprint> renameFingerprint(
Fingerprint fingerprint, String name) async {
try {
final renameFingerprintResponse = jsonDecode(await _methods.invokeMethod(
'renameFingerprint',
{
'templateId': fingerprint.templateId,
'name': name,
},
));
final renameFingerprintResponse = jsonDecode(await fido.invoke(
'renameFingerprint',
{'templateId': fingerprint.templateId, 'name': name}));
if (renameFingerprintResponse['success'] == true) {
_log.debug('FIDO rename fingerprint succeeded');
@ -316,12 +306,8 @@ class _FidoFingerprintsNotifier extends FidoFingerprintsNotifier {
@override
Future<void> deleteFingerprint(Fingerprint fingerprint) async {
try {
final deleteFingerprintResponse = jsonDecode(await _methods.invokeMethod(
'deleteFingerprint',
{
'templateId': fingerprint.templateId,
},
));
final deleteFingerprintResponse = jsonDecode(await fido
.invoke('deleteFingerprint', {'templateId': fingerprint.templateId}));
if (deleteFingerprintResponse['success'] == true) {
_log.debug('FIDO delete fingerprint succeeded');
@ -348,6 +334,8 @@ final androidCredentialProvider = AsyncNotifierProvider.autoDispose
class _FidoCredentialsNotifier extends FidoCredentialsNotifier {
final _events = const EventChannel('android.fido.credentials');
late StreamSubscription _sub;
late final _FidoMethodChannelNotifier fido =
ref.read(_fidoMethodsProvider.notifier);
@override
FutureOr<List<FidoCredential>> build(DevicePath devicePath) async {
@ -371,13 +359,8 @@ class _FidoCredentialsNotifier extends FidoCredentialsNotifier {
@override
Future<void> deleteCredential(FidoCredential credential) async {
try {
await _methods.invokeMethod(
'deleteCredential',
{
'rpId': credential.rpId,
'credentialId': credential.credentialId,
},
);
await fido.invoke('deleteCredential',
{'rpId': credential.rpId, 'credentialId': credential.credentialId});
} on PlatformException catch (pe) {
var decodedException = pe.decode();
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 'oath/otp_auth_link_handler.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 'state.dart';
import 'tap_request_dialog.dart';
import 'window_state_provider.dart';
Future<Widget> initialize() async {
@ -106,6 +107,8 @@ Future<Widget> initialize() async {
child: DismissKeyboard(
child: YubicoAuthenticatorApp(page: Consumer(
builder: (context, ref, child) {
ref.read(nfcEventNotifierListener).startListener(context);
Timer.run(() {
ref.read(featureFlagProvider.notifier)
// TODO: Load feature flags from file/config?
@ -119,8 +122,8 @@ Future<Widget> initialize() async {
// activates window state provider
ref.read(androidWindowStateProvider);
// initializes global handler for dialogs
ref.read(androidDialogProvider);
// initializes overlay for nfc events
ref.read(nfcOverlay);
// set context which will handle otpauth links
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");
* 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/state.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');
const _methods = MethodChannel('android.oath.methods');
final androidOathStateProvider = AsyncNotifierProvider.autoDispose
.family<OathStateNotifier, OathState, DevicePath>(
_AndroidOathStateNotifier.new);
@ -49,6 +49,8 @@ final androidOathStateProvider = AsyncNotifierProvider.autoDispose
class _AndroidOathStateNotifier extends OathStateNotifier {
final _events = const EventChannel('android.oath.sessionState');
late StreamSubscription _sub;
late _OathMethodChannelNotifier oath =
ref.watch(_oathMethodsProvider.notifier);
@override
FutureOr<OathState> build(DevicePath arg) {
@ -74,10 +76,7 @@ class _AndroidOathStateNotifier extends OathStateNotifier {
@override
Future<void> reset() async {
try {
// await ref
// .read(androidAppContextHandler)
// .switchAppContext(Application.accounts);
await _methods.invokeMethod('reset');
await oath.invoke('reset');
} catch (e) {
_log.debug('Calling reset failed with exception: $e');
}
@ -86,8 +85,8 @@ class _AndroidOathStateNotifier extends OathStateNotifier {
@override
Future<(bool, bool)> unlock(String password, {bool remember = false}) async {
try {
final unlockResponse = jsonDecode(await _methods.invokeMethod(
'unlock', {'password': password, 'remember': remember}));
final unlockResponse = jsonDecode(await oath
.invoke('unlock', {'password': password, 'remember': remember}));
_log.debug('applet unlocked');
final unlocked = unlockResponse['unlocked'] == true;
@ -108,11 +107,16 @@ class _AndroidOathStateNotifier extends OathStateNotifier {
@override
Future<bool> setPassword(String? current, String password) async {
try {
await _methods.invokeMethod(
'setPassword', {'current': current, 'password': password});
await oath
.invoke('setPassword', {'current': current, 'password': password});
return true;
} on PlatformException catch (e) {
_log.debug('Calling set password failed with exception: $e');
} on PlatformException catch (pe) {
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;
}
}
@ -120,10 +124,15 @@ class _AndroidOathStateNotifier extends OathStateNotifier {
@override
Future<bool> unsetPassword(String current) async {
try {
await _methods.invokeMethod('unsetPassword', {'current': current});
await oath.invoke('unsetPassword', {'current': current});
return true;
} on PlatformException catch (e) {
_log.debug('Calling unset password failed with exception: $e');
} on PlatformException catch (pe) {
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;
}
}
@ -131,7 +140,7 @@ class _AndroidOathStateNotifier extends OathStateNotifier {
@override
Future<void> forgetPassword() async {
try {
await _methods.invokeMethod('forgetPassword');
await oath.invoke('forgetPassword');
} on PlatformException catch (e) {
_log.debug('Calling forgetPassword failed with exception: $e');
}
@ -146,7 +155,7 @@ Exception handlePlatformException(
toast(String message, {bool popStack = false}) =>
withContext((context) async {
ref.read(androidDialogProvider).closeDialog();
ref.read(nfcOverlay.notifier).hide();
if (popStack) {
Navigator.of(context).popUntil((route) {
return route.isFirst;
@ -167,7 +176,7 @@ Exception handlePlatformException(
return CancellationException();
}
case PlatformException pe:
if (pe.code == 'JobCancellationException') {
if (pe.code == 'ContextDisposedException') {
// pop stack to show FIDO view
toast(l10n.l_add_account_func_missing, popStack: true);
return CancellationException();
@ -181,46 +190,33 @@ Exception handlePlatformException(
final addCredentialToAnyProvider =
Provider((ref) => (Uri credentialUri, {bool requireTouch = false}) async {
final oath = ref.watch(_oathMethodsProvider.notifier);
try {
String resultString = await _methods.invokeMethod(
'addAccountToAny', {
await preserveConnectedDeviceWhenPaused();
var result = jsonDecode(await oath.invoke('addAccountToAny', {
'uri': credentialUri.toString(),
'requireTouch': requireTouch
});
var result = jsonDecode(resultString);
}));
return OathCredential.fromJson(result['credential']);
} on PlatformException catch (pe) {
_log.error('Received exception: $pe');
throw handlePlatformException(ref, pe);
}
});
final addCredentialsToAnyProvider = Provider(
(ref) => (List<String> credentialUris, List<bool> touchRequired) async {
final oath = ref.read(_oathMethodsProvider.notifier);
try {
await preserveConnectedDeviceWhenPaused();
_log.debug(
'Calling android with ${credentialUris.length} credentials to be added');
String resultString = await _methods.invokeMethod(
'addAccountsToAny',
{
'uris': credentialUris,
'requireTouch': touchRequired,
},
);
_log.debug('Call result: $resultString');
var result = jsonDecode(resultString);
var result = jsonDecode(await oath.invoke('addAccountsToAny',
{'uris': credentialUris, 'requireTouch': touchRequired}));
return result['succeeded'] == credentialUris.length;
} on PlatformException catch (pe) {
var decodedException = pe.decode();
if (decodedException is CancellationException) {
_log.debug('User cancelled adding multiple accounts');
} else {
_log.error('Failed to add multiple accounts.', pe);
}
throw decodedException;
_log.error('Received exception: $pe');
throw handlePlatformException(ref, pe);
}
});
@ -238,6 +234,8 @@ class _AndroidCredentialListNotifier extends OathCredentialListNotifier {
final WithContext _withContext;
final Ref _ref;
late StreamSubscription _sub;
late _OathMethodChannelNotifier oath =
_ref.read(_oathMethodsProvider.notifier);
_AndroidCredentialListNotifier(this._withContext, this._ref) : super() {
_sub = _events.receiveBroadcastStream().listen((event) {
@ -284,8 +282,8 @@ class _AndroidCredentialListNotifier extends OathCredentialListNotifier {
}
try {
final resultJson = await _methods
.invokeMethod('calculate', {'credentialId': credential.id});
final resultJson =
await oath.invoke('calculate', {'credentialId': credential.id});
_log.debug('Calculate', resultJson);
return OathCode.fromJson(jsonDecode(resultJson));
} on PlatformException catch (pe) {
@ -300,9 +298,8 @@ class _AndroidCredentialListNotifier extends OathCredentialListNotifier {
Future<OathCredential> addAccount(Uri credentialUri,
{bool requireTouch = false}) async {
try {
String resultString = await _methods.invokeMethod('addAccount',
String resultString = await oath.invoke('addAccount',
{'uri': credentialUri.toString(), 'requireTouch': requireTouch});
var result = jsonDecode(resultString);
return OathCredential.fromJson(result['credential']);
} on PlatformException catch (pe) {
@ -314,9 +311,8 @@ class _AndroidCredentialListNotifier extends OathCredentialListNotifier {
Future<OathCredential> renameAccount(
OathCredential credential, String? issuer, String name) async {
try {
final response = await _methods.invokeMethod('renameAccount',
final response = await oath.invoke('renameAccount',
{'credentialId': credential.id, 'name': name, 'issuer': issuer});
_log.debug('Rename response: $response');
var responseJson = jsonDecode(response);
@ -331,11 +327,24 @@ class _AndroidCredentialListNotifier extends OathCredentialListNotifier {
@override
Future<void> deleteAccount(OathCredential credential) async {
try {
await _methods
.invokeMethod('deleteAccount', {'credentialId': credential.id});
await oath.invoke('deleteAccount', {'credentialId': credential.id});
} on PlatformException catch (e) {
_log.debug('Received exception: $e');
throw e.decode();
var decoded = 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.
*/
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) {
Reset(0),
Unlock(1),
SetPin(2),
DeleteCredential(3),
DeleteFingerprint(4),
RenameFingerprint(5),
RegisterFingerprint(6),
EnableEnterpriseAttestation(7),
ActionFailure(8);
val id: Int
get() = value + dialogDescriptionFidoIndex
}
@freezed
class NfcOverlayWidgetProperties with _$NfcOverlayWidgetProperties {
factory NfcOverlayWidgetProperties({
required Widget child,
@Default(false) bool visible,
@Default(false) bool hasCloseButton,
}) = _NfcOverlayWidgetProperties;
}

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> {
NfcStateNotifier() : super(false);
class NfcAdapterState extends StateNotifier<bool> {
NfcAdapterState() : super(false);
void setNfcEnabled(bool value) {
void enable(bool 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 androidSdkVersionProvider = Provider<int>((ref) => -1);
final androidNfcSupportProvider = Provider<bool>((ref) => false);
final androidNfcStateProvider =
StateNotifierProvider<NfcStateNotifier, bool>((ref) => NfcStateNotifier());
final androidNfcAdapterState =
StateNotifierProvider<NfcAdapterState, bool>((ref) => NfcAdapterState());
final androidNfcState = StateNotifierProvider<NfcStateNotifier, NfcState>(
(ref) => NfcStateNotifier());
final androidSupportedThemesProvider = StateProvider<List<ThemeMode>>((ref) {
if (ref.read(androidSdkVersionProvider) < 29) {
@ -191,6 +219,7 @@ class NfcTapActionNotifier extends StateNotifier<NfcTapAction> {
static const _prefNfcOpenApp = 'prefNfcOpenApp';
static const _prefNfcCopyOtp = 'prefNfcCopyOtp';
final SharedPreferences _prefs;
NfcTapActionNotifier._(this._prefs, super._state);
factory NfcTapActionNotifier(SharedPreferences prefs) {
@ -232,6 +261,7 @@ class NfcKbdLayoutNotifier extends StateNotifier<String> {
static const String _defaultClipKbdLayout = 'US';
static const _prefClipKbdLayout = 'prefClipKbdLayout';
final SharedPreferences _prefs;
NfcKbdLayoutNotifier(this._prefs)
: super(_prefs.getString(_prefClipKbdLayout) ?? _defaultClipKbdLayout);
@ -250,6 +280,7 @@ final androidNfcBypassTouchProvider =
class NfcBypassTouchNotifier extends StateNotifier<bool> {
static const _prefNfcBypassTouch = 'prefNfcBypassTouch';
final SharedPreferences _prefs;
NfcBypassTouchNotifier(this._prefs)
: super(_prefs.getBool(_prefNfcBypassTouch) ?? false);
@ -268,6 +299,7 @@ final androidNfcSilenceSoundsProvider =
class NfcSilenceSoundsNotifier extends StateNotifier<bool> {
static const _prefNfcSilenceSounds = 'prefNfcSilenceSounds';
final SharedPreferences _prefs;
NfcSilenceSoundsNotifier(this._prefs)
: super(_prefs.getBool(_prefNfcSilenceSounds) ?? false);
@ -286,6 +318,7 @@ final androidUsbLaunchAppProvider =
class UsbLaunchAppNotifier extends StateNotifier<bool> {
static const _prefUsbOpenApp = 'prefUsbOpenApp';
final SharedPreferences _prefs;
UsbLaunchAppNotifier(this._prefs)
: 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

@ -58,7 +58,7 @@ class _WindowStateNotifier extends StateNotifier<WindowState>
if (lifeCycleState == AppLifecycleState.resumed) {
_log.debug('Reading nfc enabled value');
isNfcEnabled().then((value) =>
_ref.read(androidNfcStateProvider.notifier).setNfcEnabled(value));
_ref.read(androidNfcAdapterState.notifier).enable(value));
}
} else {
_log.debug('Ignoring appLifecycleStateChange');

View File

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

View File

@ -52,12 +52,21 @@ class MainPage extends ConsumerWidget {
);
if (isAndroid) {
isNfcEnabled().then((value) =>
ref.read(androidNfcStateProvider.notifier).setNfcEnabled(value));
isNfcEnabled().then(
(value) => ref.read(androidNfcAdapterState.notifier).enable(value));
}
// 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) {
return route.isFirst ||
[
@ -69,7 +78,6 @@ class MainPage extends ConsumerWidget {
'oath_add_account',
'oath_icon_pack_dialog',
'android_qr_scanner_view',
'android_alert_dialog'
].contains(route.settings.name);
});
});
@ -84,7 +92,7 @@ class MainPage extends ConsumerWidget {
if (deviceNode == null) {
if (isAndroid) {
var hasNfcSupport = ref.watch(androidNfcSupportProvider);
var isNfcEnabled = ref.watch(androidNfcStateProvider);
var isNfcEnabled = ref.watch(androidNfcAdapterState);
return HomeMessagePage(
centered: true,
graphic: noKeyImage,
@ -103,6 +111,10 @@ class MainPage extends ConsumerWidget {
label: Text(l10n.s_add_account),
icon: const Icon(Symbols.person_add_alt),
onPressed: () async {
// make sure we execute the "Add account" in OATH section
ref
.read(currentSectionProvider.notifier)
.setCurrentSection(Section.accounts);
await addOathAccount(context, ref);
})
],

View File

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

View File

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

View File

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

View File

@ -899,29 +899,6 @@
"l_launch_app_on_usb_off": "Andere Anwendungen können den YubiKey über USB nutzen",
"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": {},
"s_nfc_ready_to_scan": "Bereit zum Scannen",
"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",
"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": {},
"s_nfc_ready_to_scan": "Ready to scan",
"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",
"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": {},
"s_nfc_ready_to_scan": "Prêt à numériser",
"s_nfc_hold_still": "Ne bougez pas\u2026",

View File

@ -899,29 +899,6 @@
"l_launch_app_on_usb_off": "他のアプリがUSB経由でYubiKeyを使用できます",
"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": {},
"s_nfc_ready_to_scan": "スキャン準備完了",
"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",
"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": {},
"s_nfc_ready_to_scan": "Gotowy do skanowania",
"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",
"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": {},
"s_nfc_ready_to_scan": "Sẵn sàng để quét",
"s_nfc_hold_still": "Giữ yên\u2026",

View File

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

View File

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

View File

@ -28,7 +28,6 @@ import '../../desktop/models.dart';
import '../../exception/cancellation_exception.dart';
import '../../widgets/app_input_decoration.dart';
import '../../widgets/app_text_form_field.dart';
import '../../widgets/focus_utils.dart';
import '../../widgets/responsive_dialog.dart';
import '../../widgets/utf8_utils.dart';
import '../keys.dart' as keys;
@ -93,7 +92,7 @@ class RenameAccountDialog extends ConsumerStatefulWidget {
} on CancellationException catch (_) {
// ignored
} catch (e) {
_log.error('Failed to add account', e);
_log.error('Failed to rename account', e);
final String errorMessage;
// TODO: Make this cleaner than importing desktop specific RpcError.
if (e is RpcError) {
@ -118,6 +117,9 @@ class _RenameAccountDialogState extends ConsumerState<RenameAccountDialog> {
late String _issuer;
late String _name;
final _issuerFocus = FocusNode();
final _nameFocus = FocusNode();
@override
void initState() {
super.initState();
@ -125,8 +127,16 @@ class _RenameAccountDialogState extends ConsumerState<RenameAccountDialog> {
_name = widget.name.trim();
}
@override
void dispose() {
_issuerFocus.dispose();
_nameFocus.dispose();
super.dispose();
}
void _submit() async {
FocusUtils.unfocus(context);
_issuerFocus.unfocus();
_nameFocus.unfocus();
final nav = Navigator.of(context);
final renamed =
await widget.rename(_issuer.isNotEmpty ? _issuer : null, _name);
@ -188,6 +198,8 @@ class _RenameAccountDialogState extends ConsumerState<RenameAccountDialog> {
prefixIcon: const Icon(Symbols.business),
),
textInputAction: TextInputAction.next,
focusNode: _issuerFocus,
autofocus: true,
onChanged: (value) {
setState(() {
_issuer = value.trim();
@ -212,6 +224,7 @@ class _RenameAccountDialogState extends ConsumerState<RenameAccountDialog> {
prefixIcon: const Icon(Symbols.people_alt),
),
textInputAction: TextInputAction.done,
focusNode: _nameFocus,
onChanged: (value) {
setState(() {
_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");
* you may not use this file except in compliance with the License.
@ -15,6 +15,7 @@
*/
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
const defaultPrimaryColor = Colors.lightGreen;
@ -50,6 +51,9 @@ class AppTheme {
fontFamily: 'Roboto',
appBarTheme: const AppBarTheme(
color: Colors.transparent,
systemOverlayStyle: SystemUiOverlayStyle(
statusBarIconBrightness: Brightness.dark,
statusBarColor: Colors.transparent),
),
listTileTheme: const ListTileThemeData(
// For alignment under menu button
@ -81,6 +85,9 @@ class AppTheme {
scaffoldBackgroundColor: colorScheme.surface,
appBarTheme: const AppBarTheme(
color: Colors.transparent,
systemOverlayStyle: SystemUiOverlayStyle(
statusBarIconBrightness: Brightness.light,
statusBarColor: Colors.transparent),
),
listTileTheme: const ListTileThemeData(
// For alignment under menu button