mirror of
https://github.com/Yubico/yubioath-flutter.git
synced 2024-11-22 00:12:09 +03:00
Merge PR #1686
This commit is contained in:
commit
7fa2786ccd
@ -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()
|
@ -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
|
||||
}
|
||||
}
|
@ -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 {
|
||||
|
@ -0,0 +1,63 @@
|
||||
/*
|
||||
* Copyright (C) 2022-2024 Yubico.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.yubico.authenticator
|
||||
|
||||
import io.flutter.plugin.common.BinaryMessenger
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
typealias OnCancelled = suspend () -> Unit
|
||||
|
||||
class NfcOverlayManager(messenger: BinaryMessenger, private val coroutineScope: CoroutineScope) {
|
||||
private val channel =
|
||||
MethodChannel(messenger, "com.yubico.authenticator.channel.nfc_overlay")
|
||||
|
||||
private var onCancelled: OnCancelled? = null
|
||||
|
||||
init {
|
||||
channel.setHandler(coroutineScope) { method, _ ->
|
||||
when (method) {
|
||||
"cancel" -> onClosed()
|
||||
else -> throw NotImplementedError()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun show(cancelled: OnCancelled?) {
|
||||
onCancelled = cancelled
|
||||
coroutineScope.launch {
|
||||
channel.invoke("show", null)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun close() {
|
||||
channel.invoke("close", NULL)
|
||||
}
|
||||
|
||||
private suspend fun onClosed(): String {
|
||||
onCancelled?.let {
|
||||
onCancelled = null
|
||||
withContext(Dispatchers.Main) {
|
||||
it.invoke()
|
||||
}
|
||||
}
|
||||
return NULL
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
|
@ -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()
|
||||
}
|
||||
|
@ -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 =
|
||||
|
@ -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)
|
||||
}
|
@ -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")
|
||||
}
|
||||
}
|
@ -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:
|
||||
|
@ -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'));
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -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'));
|
||||
}
|
||||
|
36
lib/android/overlay/nfc/method_channel_notifier.dart
Normal file
36
lib/android/overlay/nfc/method_channel_notifier.dart
Normal 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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
189
lib/android/overlay/nfc/models.freezed.dart
Normal file
189
lib/android/overlay/nfc/models.freezed.dart
Normal 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;
|
||||
}
|
123
lib/android/overlay/nfc/nfc_event_notifier.dart
Normal file
123
lib/android/overlay/nfc/nfc_event_notifier.dart
Normal 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);
|
||||
}
|
164
lib/android/overlay/nfc/nfc_overlay.dart
Executable file
164
lib/android/overlay/nfc/nfc_overlay.dart
Executable 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;
|
||||
}
|
||||
}
|
55
lib/android/overlay/nfc/views/nfc_content_widget.dart
Normal file
55
lib/android/overlay/nfc/views/nfc_content_widget.dart
Normal 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)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
79
lib/android/overlay/nfc/views/nfc_overlay_icons.dart
Normal file
79
lib/android/overlay/nfc/views/nfc_overlay_icons.dart
Normal 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,
|
||||
);
|
||||
}
|
76
lib/android/overlay/nfc/views/nfc_overlay_widget.dart
Normal file
76
lib/android/overlay/nfc/views/nfc_overlay_widget.dart
Normal 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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
@ -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);
|
||||
|
||||
|
@ -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');
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
@ -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.
|
||||
@ -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');
|
||||
|
@ -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;
|
||||
|
@ -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);
|
||||
})
|
||||
],
|
||||
|
@ -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;
|
||||
|
@ -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
|
||||
|
@ -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;
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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.
|
||||
@ -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;
|
||||
|
@ -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(
|
||||
|
@ -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.
|
||||
@ -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();
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user