diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/AppContextManager.kt b/android/app/src/main/kotlin/com/yubico/authenticator/AppContextManager.kt index de1a30ae..4467b6ad 100755 --- a/android/app/src/main/kotlin/com/yubico/authenticator/AppContextManager.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/AppContextManager.kt @@ -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() {} -} \ No newline at end of file + + open fun onError() {} +} + +class ContextDisposedException : Exception() \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/DialogManager.kt b/android/app/src/main/kotlin/com/yubico/authenticator/DialogManager.kt deleted file mode 100644 index c3df2e04..00000000 --- a/android/app/src/main/kotlin/com/yubico/authenticator/DialogManager.kt +++ /dev/null @@ -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 - } -} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/MainActivity.kt b/android/app/src/main/kotlin/com/yubico/authenticator/MainActivity.kt index 00abfc22..57301c5f 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/MainActivity.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/MainActivity.kt @@ -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 { 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 @@ -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 { diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/NfcOverlayManager.kt b/android/app/src/main/kotlin/com/yubico/authenticator/NfcOverlayManager.kt new file mode 100644 index 00000000..50660bd6 --- /dev/null +++ b/android/app/src/main/kotlin/com/yubico/authenticator/NfcOverlayManager.kt @@ -0,0 +1,63 @@ +/* + * Copyright (C) 2022-2023 Yubico. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.yubico.authenticator + +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.MethodChannel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +typealias OnCancelled = suspend () -> Unit + +class NfcOverlayManager(messenger: BinaryMessenger, private val coroutineScope: CoroutineScope) { + private val channel = + MethodChannel(messenger, "com.yubico.authenticator.channel.nfc_overlay") + + private var onCancelled: OnCancelled? = null + + init { + channel.setHandler(coroutineScope) { method, _ -> + when (method) { + "cancel" -> onClosed() + else -> throw NotImplementedError() + } + } + } + + fun show(cancelled: OnCancelled?) { + onCancelled = cancelled + coroutineScope.launch { + channel.invoke("show", null) + } + } + + suspend fun close() { + channel.invoke("close", NULL) + } + + private suspend fun onClosed(): String { + onCancelled?.let { + onCancelled = null + withContext(Dispatchers.Main) { + it.invoke() + } + } + return NULL + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/device/DeviceManager.kt b/android/app/src/main/kotlin/com/yubico/authenticator/device/DeviceManager.kt index 01e7f04f..8968fd79 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/device/DeviceManager.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/device/DeviceManager.kt @@ -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 withKey(onNfc: suspend () -> T, onUsb: suspend (UsbYubiKeyDevice) -> T) = + suspend fun withKey( + onUsb: suspend (UsbYubiKeyDevice) -> T, + onNfc: suspend () -> com.yubico.yubikit.core.util.Result, + onCancelled: () -> Unit + ): T = appViewModel.connectedYubiKey.value?.let { onUsb(it) - } ?: onNfc() + } ?: onNfc(onNfc, onCancelled) + + + private suspend fun onNfc( + onNfc: suspend () -> com.yubico.yubikit.core.util.Result, + 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 + } + } } \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/fido/FidoConnectionHelper.kt b/android/app/src/main/kotlin/com/yubico/authenticator/fido/FidoConnectionHelper.kt index 88ab6853..bc81e92e 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/fido/FidoConnectionHelper.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/fido/FidoConnectionHelper.kt @@ -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 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 useSessionUsb( @@ -74,9 +85,8 @@ class FidoConnectionHelper( } suspend fun useSessionNfc( - actionDescription: FidoActionDescription, block: (YubiKitFidoSession) -> T - ): T { + ): Result { 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) } } diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/fido/FidoManager.kt b/android/app/src/main/kotlin/com/yubico/authenticator/fido/FidoManager.kt index 138f0a38..8f6908ae 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/fido/FidoManager.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/fido/FidoManager.kt @@ -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 { connection -> - processYubiKey(connection, device) + requestHandled = processYubiKey(connection, device) } } else { device.withConnection { 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) diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/fido/FidoResetHelper.kt b/android/app/src/main/kotlin/com/yubico/authenticator/fido/FidoResetHelper.kt index 33d54f92..00c2c4a2 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/fido/FidoResetHelper.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/fido/FidoResetHelper.kt @@ -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) } } diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/management/ManagementConnectionHelper.kt b/android/app/src/main/kotlin/com/yubico/authenticator/management/ManagementConnectionHelper.kt index 891043b8..4bacb524 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/management/ManagementConnectionHelper.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/management/ManagementConnectionHelper.kt @@ -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) -> Unit class ManagementConnectionHelper( - private val deviceManager: DeviceManager, - private val dialogManager: DialogManager + private val deviceManager: DeviceManager ) { private var action: ManagementAction? = null - suspend fun useSession( - actionDescription: ManagementActionDescription, - action: (YubiKitManagementSession) -> T - ): T { - return deviceManager.withKey( - onNfc = { useSessionNfc(actionDescription, action) }, - onUsb = { useSessionUsb(it, action) }) - } + suspend fun 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 useSessionUsb( device: UsbYubiKeyDevice, @@ -54,37 +50,20 @@ class ManagementConnectionHelper( } private suspend fun useSessionNfc( - actionDescription: ManagementActionDescription, - block: (YubiKitManagementSession) -> T - ): T { + block: (YubiKitManagementSession) -> T): Result { try { - val result = suspendCoroutine { outer -> + val result = suspendCoroutine { 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) - } } \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/management/ManagementHandler.kt b/android/app/src/main/kotlin/com/yubico/authenticator/management/ManagementHandler.kt index 4c5096d1..abf5542b 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/management/ManagementHandler.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/management/ManagementHandler.kt @@ -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 } diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/oath/OathManager.kt b/android/app/src/main/kotlin/com/yubico/authenticator/oath/OathManager.kt index 03c9dd7a..c8073b38 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/oath/OathManager.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/oath/OathManager.kt @@ -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 { 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 { + return session.credentials.map { credential -> + Pair( + Credential(credential, session.deviceId), + null + ) + }.toMap() + } private fun calculateOathCodes(session: YubiKitOathSession): Map { 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 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 useOathSessionUsb( + private suspend fun useSessionUsb( device: UsbYubiKeyDevice, updateDeviceInfo: Boolean = false, block: (YubiKitOathSession) -> T @@ -685,10 +786,9 @@ class OathManager( } } - private suspend fun useOathSessionNfc( - oathActionDescription: OathActionDescription, - block: (YubiKitOathSession) -> T - ): T { + private suspend fun useSessionNfc( + block: (YubiKitOathSession) -> T, + ): Result { 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() } diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/oath/data/Credential.kt b/android/app/src/main/kotlin/com/yubico/authenticator/oath/data/Credential.kt index 60c45ab9..b827605f 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/oath/data/Credential.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/oath/data/Credential.kt @@ -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 = diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/oath/OathActionDescription.kt b/android/app/src/main/kotlin/com/yubico/authenticator/yubikit/NfcState.kt similarity index 56% rename from android/app/src/main/kotlin/com/yubico/authenticator/oath/OathActionDescription.kt rename to android/app/src/main/kotlin/com/yubico/authenticator/yubikit/NfcState.kt index ac78d2c5..670acf66 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/oath/OathActionDescription.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/yubikit/NfcState.kt @@ -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) } \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/yubikit/NfcStateDispatcher.kt b/android/app/src/main/kotlin/com/yubico/authenticator/yubikit/NfcStateDispatcher.kt new file mode 100644 index 00000000..d825bb0e --- /dev/null +++ b/android/app/src/main/kotlin/com/yubico/authenticator/yubikit/NfcStateDispatcher.kt @@ -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") + } +} \ No newline at end of file diff --git a/lib/android/app_methods.dart b/lib/android/app_methods.dart index f383cfb0..d152d82a 100644 --- a/lib/android/app_methods.dart +++ b/lib/android/app_methods.dart @@ -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: diff --git a/lib/android/fido/state.dart b/lib/android/fido/state.dart index 956c58b7..3afcfc5f 100644 --- a/lib/android/fido/state.dart +++ b/lib/android/fido/state.dart @@ -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.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 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 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 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 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> 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 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 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> build(DevicePath devicePath) async { @@ -371,13 +359,8 @@ class _FidoCredentialsNotifier extends FidoCredentialsNotifier { @override Future 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')); +} diff --git a/lib/android/init.dart b/lib/android/init.dart index 6322acd0..b638df95 100644 --- a/lib/android/init.dart +++ b/lib/android/init.dart @@ -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 initialize() async { @@ -106,6 +107,8 @@ Future 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 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); diff --git a/lib/android/oath/state.dart b/lib/android/oath/state.dart index 887d4ff9..2b47e892 100755 --- a/lib/android/oath/state.dart +++ b/lib/android/oath/state.dart @@ -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( _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 build(DevicePath arg) { @@ -74,10 +76,7 @@ class _AndroidOathStateNotifier extends OathStateNotifier { @override Future 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 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 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 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 credentialUris, List 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 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 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 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')); +} diff --git a/lib/android/overlay/nfc/method_channel_notifier.dart b/lib/android/overlay/nfc/method_channel_notifier.dart new file mode 100644 index 00000000..d5bfa5b8 --- /dev/null +++ b/lib/android/overlay/nfc/method_channel_notifier.dart @@ -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 { + final MethodChannel _channel; + + MethodChannelNotifier(this._channel); + + @override + void build() {} + + Future invoke(String name, + [Map args = const {}]) async { + final result = await _channel.invokeMethod(name, args); + await ref.read(nfcOverlay.notifier).waitForHide(); + return result; + } +} diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/fido/FidoActionDescription.kt b/lib/android/overlay/nfc/models.dart similarity index 59% rename from android/app/src/main/kotlin/com/yubico/authenticator/fido/FidoActionDescription.kt rename to lib/android/overlay/nfc/models.dart index ae0d8945..fe24afa8 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/fido/FidoActionDescription.kt +++ b/lib/android/overlay/nfc/models.dart @@ -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 -} \ No newline at end of file +@freezed +class NfcOverlayWidgetProperties with _$NfcOverlayWidgetProperties { + factory NfcOverlayWidgetProperties({ + required Widget child, + @Default(false) bool visible, + @Default(false) bool hasCloseButton, + }) = _NfcOverlayWidgetProperties; +} diff --git a/lib/android/overlay/nfc/models.freezed.dart b/lib/android/overlay/nfc/models.freezed.dart new file mode 100644 index 00000000..3216a0e4 --- /dev/null +++ b/lib/android/overlay/nfc/models.freezed.dart @@ -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 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 + 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; +} diff --git a/lib/android/overlay/nfc/nfc_event_notifier.dart b/lib/android/overlay/nfc/nfc_event_notifier.dart new file mode 100644 index 00000000..ec240170 --- /dev/null +++ b/lib/android/overlay/nfc/nfc_event_notifier.dart @@ -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 { + @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? 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); +} diff --git a/lib/android/overlay/nfc/nfc_overlay.dart b/lib/android/overlay/nfc/nfc_overlay.dart new file mode 100755 index 00000000..028a4d7b --- /dev/null +++ b/lib/android/overlay/nfc/nfc_overlay.dart @@ -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 { + 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 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; + } +} diff --git a/lib/android/overlay/nfc/views/nfc_content_widget.dart b/lib/android/overlay/nfc/views/nfc_content_widget.dart new file mode 100644 index 00000000..fa784005 --- /dev/null +++ b/lib/android/overlay/nfc/views/nfc_content_widget.dart @@ -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) + ], + ), + ); + } +} diff --git a/lib/android/overlay/nfc/views/nfc_overlay_icons.dart b/lib/android/overlay/nfc/views/nfc_overlay_icons.dart new file mode 100644 index 00000000..92ed4a76 --- /dev/null +++ b/lib/android/overlay/nfc/views/nfc_overlay_icons.dart @@ -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, + ); +} diff --git a/lib/android/overlay/nfc/views/nfc_overlay_widget.dart b/lib/android/overlay/nfc/views/nfc_overlay_widget.dart new file mode 100644 index 00000000..db608ca0 --- /dev/null +++ b/lib/android/overlay/nfc/views/nfc_overlay_widget.dart @@ -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 { + @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), + ], + ); + } +} diff --git a/lib/android/state.dart b/lib/android/state.dart index 551cd3ad..4ccc15bd 100644 --- a/lib/android/state.dart +++ b/lib/android/state.dart @@ -69,22 +69,50 @@ class _AndroidClipboard extends AppClipboard { } } -class NfcStateNotifier extends StateNotifier { - NfcStateNotifier() : super(false); +class NfcAdapterState extends StateNotifier { + NfcAdapterState() : super(false); - void setNfcEnabled(bool value) { + void enable(bool value) { state = value; } } +enum NfcState { + disabled, + idle, + ongoing, + success, + failure, +} + +class NfcStateNotifier extends StateNotifier { + 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>((ref) => []); final androidSdkVersionProvider = Provider((ref) => -1); final androidNfcSupportProvider = Provider((ref) => false); -final androidNfcStateProvider = - StateNotifierProvider((ref) => NfcStateNotifier()); +final androidNfcAdapterState = + StateNotifierProvider((ref) => NfcAdapterState()); + +final androidNfcState = StateNotifierProvider( + (ref) => NfcStateNotifier()); final androidSupportedThemesProvider = StateProvider>((ref) { if (ref.read(androidSdkVersionProvider) < 29) { @@ -191,6 +219,7 @@ class NfcTapActionNotifier extends StateNotifier { 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 { 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 { 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 { 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 { static const _prefUsbOpenApp = 'prefUsbOpenApp'; final SharedPreferences _prefs; + UsbLaunchAppNotifier(this._prefs) : super(_prefs.getBool(_prefUsbOpenApp) ?? false); diff --git a/lib/android/tap_request_dialog.dart b/lib/android/tap_request_dialog.dart deleted file mode 100755 index 8073525c..00000000 --- a/lib/android/tap_request_dialog.dart +++ /dev/null @@ -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 _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 _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'); - }, - )); - } -} diff --git a/lib/android/window_state_provider.dart b/lib/android/window_state_provider.dart index 62788028..162cd713 100644 --- a/lib/android/window_state_provider.dart +++ b/lib/android/window_state_provider.dart @@ -58,7 +58,7 @@ class _WindowStateNotifier extends StateNotifier 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'); diff --git a/lib/app/views/device_picker.dart b/lib/app/views/device_picker.dart index 126c90da..a1046f6a 100644 --- a/lib/app/views/device_picker.dart +++ b/lib/app/views/device_picker.dart @@ -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; diff --git a/lib/app/views/main_page.dart b/lib/app/views/main_page.dart index 63afad57..c94e435a 100755 --- a/lib/app/views/main_page.dart +++ b/lib/app/views/main_page.dart @@ -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>(currentDeviceDataProvider, (_, __) { + ref.listen>(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); }) ], diff --git a/lib/app/views/message_page_not_initialized.dart b/lib/app/views/message_page_not_initialized.dart index 229ed436..df72e53a 100644 --- a/lib/app/views/message_page_not_initialized.dart +++ b/lib/app/views/message_page_not_initialized.dart @@ -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; diff --git a/lib/fido/views/pin_dialog.dart b/lib/fido/views/pin_dialog.dart index bb1db268..fdda7bde 100755 --- a/lib/fido/views/pin_dialog.dart +++ b/lib/fido/views/pin_dialog.dart @@ -280,6 +280,10 @@ class _FidoPinDialogState extends ConsumerState { } void _submit() async { + _currentPinFocus.unfocus(); + _newPinFocus.unfocus(); + _confirmPinFocus.unfocus(); + final l10n = AppLocalizations.of(context)!; final oldPin = _currentPinController.text.isNotEmpty ? _currentPinController.text diff --git a/lib/fido/views/pin_entry_form.dart b/lib/fido/views/pin_entry_form.dart index 371e077c..2e7acdea 100644 --- a/lib/fido/views/pin_entry_form.dart +++ b/lib/fido/views/pin_entry_form.dart @@ -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 { } void _submit() async { + _pinFocus.unfocus(); + setState(() { _pinIsWrong = false; _isObscure = true; diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index d58eb479..58eef40b 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -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", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index c3d4b7e2..2f4efa75 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -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", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index a30e0a98..b4cf1b09 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -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", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 5ef3ebce..2eb46bac 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -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", diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 3a037117..b829bdae 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -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", diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 4697ab51..3c16a010 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -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", diff --git a/lib/oath/views/add_account_page.dart b/lib/oath/views/add_account_page.dart index 4a88bcfb..5d8d39c2 100755 --- a/lib/oath/views/add_account_page.dart +++ b/lib/oath/views/add_account_page.dart @@ -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 { 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 { List _periodValues = [20, 30, 45, 60]; List _digitsValues = [6, 8]; List? _credentials; + bool _submitting = false; @override void dispose() { @@ -95,6 +98,9 @@ class _OathAddAccountPageState extends ConsumerState { _accountController.dispose(); _secretController.dispose(); _periodController.dispose(); + _issuerFocus.dispose(); + _accountFocus.dispose(); + _secretFocus.dispose(); super.dispose(); } @@ -121,6 +127,7 @@ class _OathAddAccountPageState extends ConsumerState { _counter = data.counter; _isObscure = true; _dataLoaded = true; + _submitting = false; }); } @@ -128,8 +135,6 @@ class _OathAddAccountPageState extends ConsumerState { {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 { 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 { }, ); } + + setState(() { + _submitting = false; + }); } else { setState(() { _validateSecret = true; @@ -372,8 +389,7 @@ class _OathAddAccountPageState extends ConsumerState { 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 { prefixIcon: const Icon(Symbols.business), ), textInputAction: TextInputAction.next, + focusNode: _issuerFocus, onChanged: (value) { setState(() { // Update maxlengths @@ -400,19 +417,22 @@ class _OathAddAccountPageState extends ConsumerState { 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 { )), readOnly: _dataLoaded, textInputAction: TextInputAction.done, + focusNode: _secretFocus, onChanged: (value) { setState(() { _validateSecret = false; diff --git a/lib/oath/views/manage_password_dialog.dart b/lib/oath/views/manage_password_dialog.dart index cc77e308..14c34773 100755 --- a/lib/oath/views/manage_password_dialog.dart +++ b/lib/oath/views/manage_password_dialog.dart @@ -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 { 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 { onPressed: _currentPasswordController.text.isNotEmpty && !_currentIsWrong ? () async { + _removeFocus(); + final result = await ref .read(oathStateProvider(widget.path).notifier) .unsetPassword( diff --git a/lib/oath/views/rename_account_dialog.dart b/lib/oath/views/rename_account_dialog.dart index c3eee142..f5d5d4e1 100755 --- a/lib/oath/views/rename_account_dialog.dart +++ b/lib/oath/views/rename_account_dialog.dart @@ -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 { 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 { _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 { 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 { prefixIcon: const Icon(Symbols.people_alt), ), textInputAction: TextInputAction.done, + focusNode: _nameFocus, onChanged: (value) { setState(() { _name = value.trim(); diff --git a/lib/theme.dart b/lib/theme.dart index d929fa31..b2d82e13 100755 --- a/lib/theme.dart +++ b/lib/theme.dart @@ -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