From ec87865643b460b98ed8ec45dc2b79b245295576 Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Thu, 8 Jun 2023 17:04:17 +0200 Subject: [PATCH] add widget --- .../com/yubico/authenticator/MainActivity.kt | 50 ++++++++++- .../yubico/authenticator/oath/OathManager.kt | 9 +- .../yubikit/NfcActivityDispatcher.kt | 77 ++++++++++++++++ .../authenticator/yubikit/NfcActivityState.kt | 25 ++++++ lib/android/app_methods.dart | 6 ++ lib/android/state.dart | 30 +++++++ lib/android/tap_request_dialog.dart | 47 ++++------ .../nfc/main_page_nfc_activity_widget.dart | 41 +++++++++ lib/android/views/nfc/nfc_activity_icon.dart | 90 +++++++++++++++++++ .../views/nfc/nfc_activity_widget.dart | 49 ++++++++++ lib/app/views/main_page.dart | 3 +- 11 files changed, 392 insertions(+), 35 deletions(-) create mode 100644 android/app/src/main/kotlin/com/yubico/authenticator/yubikit/NfcActivityDispatcher.kt create mode 100644 android/app/src/main/kotlin/com/yubico/authenticator/yubikit/NfcActivityState.kt create mode 100644 lib/android/views/nfc/main_page_nfc_activity_widget.dart create mode 100644 lib/android/views/nfc/nfc_activity_icon.dart create mode 100644 lib/android/views/nfc/nfc_activity_widget.dart 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 173eb214..36e97a58 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/MainActivity.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/MainActivity.kt @@ -16,6 +16,11 @@ package com.yubico.authenticator +import android.content.BroadcastReceiver +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.IntentFilter import android.annotation.SuppressLint import android.content.* import android.content.SharedPreferences.OnSharedPreferenceChangeListener @@ -42,16 +47,22 @@ import com.yubico.authenticator.logging.FlutterLog import com.yubico.authenticator.oath.AppLinkMethodChannel import com.yubico.authenticator.oath.OathManager import com.yubico.authenticator.oath.OathViewModel +import com.yubico.authenticator.yubikit.NfcActivityDispatcher +import com.yubico.authenticator.yubikit.NfcActivityListener +import com.yubico.authenticator.yubikit.NfcActivityState 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.YubiKeyDevice 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 @@ -74,6 +85,20 @@ class MainActivity : FlutterFragmentActivity() { private val logger = LoggerFactory.getLogger(MainActivity::class.java) + private val nfcActivityListener = object : NfcActivityListener { + + var appMethodChannel : AppMethodChannel? = null + + override fun onChange(newState: NfcActivityState) { + appMethodChannel?.let { + logger.debug("setting nfc activity state to ${newState.name}") + it.nfcActivityStateChanged(newState) + } ?: { + logger.warn("cannot set nfc activity state to ${newState.name} - no method channel") + } + } + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -85,7 +110,10 @@ class MainActivity : FlutterFragmentActivity() { allowScreenshots(false) - yubikit = YubiKitManager(this) + yubikit = YubiKitManager( + UsbYubiKeyManager(this), + NfcYubiKeyManager(this, NfcActivityDispatcher(nfcActivityListener)) + ) } /** @@ -263,6 +291,11 @@ class MainActivity : FlutterFragmentActivity() { lifecycleScope.launch { try { it.processYubiKey(device) + if (device is NfcYubiKeyDevice) { + device.remove { + appMethodChannel.nfcActivityStateChanged(NfcActivityState.READY) + } + } } catch (e: Throwable) { logger.error("Error processing YubiKey in AppContextManager", e) } @@ -291,6 +324,8 @@ class MainActivity : FlutterFragmentActivity() { appMethodChannel = AppMethodChannel(messenger) appLinkMethodChannel = AppLinkMethodChannel(messenger) + nfcActivityListener.appMethodChannel = appMethodChannel + flutterStreams = listOf( viewModel.deviceInfo.streamTo(this, messenger, "android.devices.deviceInfo"), oathViewModel.sessionState.streamTo(this, messenger, "android.oath.sessionState"), @@ -306,7 +341,8 @@ class MainActivity : FlutterFragmentActivity() { viewModel, oathViewModel, dialogManager, - appPreferences + appPreferences, + nfcActivityListener ) else -> null } @@ -315,6 +351,7 @@ class MainActivity : FlutterFragmentActivity() { } override fun cleanUpFlutterEngine(flutterEngine: FlutterEngine) { + nfcActivityListener.appMethodChannel = null flutterStreams.forEach { it.close() } super.cleanUpFlutterEngine(flutterEngine) } @@ -427,6 +464,15 @@ class MainActivity : FlutterFragmentActivity() { JSONObject(mapOf("nfcEnabled" to value)).toString() ) } + + fun nfcActivityStateChanged(activityState: NfcActivityState) { + lifecycleScope.launch(Dispatchers.Main) { + methodChannel.invokeMethod( + "nfcActivityChanged", + JSONObject(mapOf("state" to activityState.value)).toString() + ) + } + } } private fun allowScreenshots(value: Boolean): Boolean { 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 ab88b531..e8b9d37e 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 @@ -27,7 +27,6 @@ import com.yubico.authenticator.* import com.yubico.authenticator.device.Capabilities import com.yubico.authenticator.device.Info import com.yubico.authenticator.device.UnknownDevice -import com.yubico.authenticator.logging.Log import com.yubico.authenticator.oath.data.Code import com.yubico.authenticator.oath.data.CodeType import com.yubico.authenticator.oath.data.Credential @@ -43,6 +42,8 @@ import com.yubico.authenticator.oath.keystore.ClearingMemProvider import com.yubico.authenticator.oath.keystore.KeyProvider import com.yubico.authenticator.oath.keystore.KeyStoreProvider import com.yubico.authenticator.oath.keystore.SharedPrefProvider +import com.yubico.authenticator.yubikit.NfcActivityListener +import com.yubico.authenticator.yubikit.NfcActivityState import com.yubico.authenticator.yubikit.getDeviceInfo import com.yubico.authenticator.yubikit.withConnection import com.yubico.yubikit.android.transport.nfc.NfcYubiKeyDevice @@ -76,6 +77,7 @@ class OathManager( private val oathViewModel: OathViewModel, private val dialogManager: DialogManager, private val appPreferences: AppPreferences, + private val nfcActivityListener: NfcActivityListener ) : AppContextManager { companion object { const val NFC_DATA_CLEANUP_DELAY = 30L * 1000 // 30s @@ -330,9 +332,14 @@ class OathManager( logger.debug( "Successfully read Oath session info (and credentials if unlocked) from connected key" ) + + nfcActivityListener.onChange(NfcActivityState.PROCESSING_FINISHED) } catch (e: Exception) { // OATH not enabled/supported, try to get DeviceInfo over other USB interfaces logger.error("Failed to connect to CCID", e) + + nfcActivityListener.onChange(NfcActivityState.PROCESSING_INTERRUPTED) + if (device.transport == Transport.USB || e is ApplicationNotAvailableException) { val deviceInfo = try { getDeviceInfo(device) diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/yubikit/NfcActivityDispatcher.kt b/android/app/src/main/kotlin/com/yubico/authenticator/yubikit/NfcActivityDispatcher.kt new file mode 100644 index 00000000..0bb14899 --- /dev/null +++ b/android/app/src/main/kotlin/com/yubico/authenticator/yubikit/NfcActivityDispatcher.kt @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2023 Yubico. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.yubico.authenticator.yubikit + +import android.app.Activity +import android.nfc.NfcAdapter +import android.nfc.Tag + +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 NfcActivityListener { + fun onChange(newState: NfcActivityState) +} + +class NfcActivityDispatcher(private val listener: NfcActivityListener) : NfcDispatcher { + + private lateinit var adapter: NfcAdapter + private lateinit var yubikitNfcDispatcher: NfcReaderDispatcher + + private val logger = LoggerFactory.getLogger(NfcActivityDispatcher::class.java) + + override fun enable( + activity: Activity, + nfcConfiguration: NfcConfiguration, + handler: NfcDispatcher.OnTagHandler + ) { + adapter = NfcAdapter.getDefaultAdapter(activity) + yubikitNfcDispatcher = NfcReaderDispatcher(adapter) + + logger.debug("enabling yubikit NFC activity dispatcher") + yubikitNfcDispatcher.enable( + activity, + nfcConfiguration, + TagInterceptor(listener, handler) + ) + listener.onChange(NfcActivityState.READY) + } + + override fun disable(activity: Activity) { + listener.onChange(NfcActivityState.NOT_ACTIVE) + yubikitNfcDispatcher.disable(activity) + logger.debug("disabling yubikit NFC activity dispatcher") + } + + class TagInterceptor( + private val listener: NfcActivityListener, + private val tagHandler: NfcDispatcher.OnTagHandler + ) : NfcDispatcher.OnTagHandler { + + private val logger = LoggerFactory.getLogger(TagInterceptor::class.java) + + override fun onTag(tag: Tag) { + listener.onChange(NfcActivityState.PROCESSING_STARTED) + logger.debug("forwarding tag") + tagHandler.onTag(tag) + } + + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/yubikit/NfcActivityState.kt b/android/app/src/main/kotlin/com/yubico/authenticator/yubikit/NfcActivityState.kt new file mode 100644 index 00000000..3d6af5fa --- /dev/null +++ b/android/app/src/main/kotlin/com/yubico/authenticator/yubikit/NfcActivityState.kt @@ -0,0 +1,25 @@ +/* + * Copyright (C) 2023 Yubico. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.yubico.authenticator.yubikit + +enum class NfcActivityState(val value: Int) { + NOT_ACTIVE(0), + READY(1), + PROCESSING_STARTED(2), + PROCESSING_FINISHED(3), + PROCESSING_INTERRUPTED(4) +} \ No newline at end of file diff --git a/lib/android/app_methods.dart b/lib/android/app_methods.dart index 9b47c8d9..75df6b6f 100644 --- a/lib/android/app_methods.dart +++ b/lib/android/app_methods.dart @@ -57,6 +57,12 @@ void setupAppMethodsChannel(WidgetRef ref) { ref.read(androidNfcStateProvider.notifier).setNfcEnabled(nfcEnabled); break; } + case 'nfcActivityChanged': + { + var nfcActivityState = args['state']; + ref.read(androidNfcActivityProvider.notifier).setActivityState(nfcActivityState); + break; + } default: throw PlatformException( code: 'NotImplemented', diff --git a/lib/android/state.dart b/lib/android/state.dart index ea1b46b8..0b3c20cc 100644 --- a/lib/android/state.dart +++ b/lib/android/state.dart @@ -73,6 +73,32 @@ class NfcStateNotifier extends StateNotifier { } } +enum NfcActivity { + notActive, + ready, + processingStarted, + processingFinished, + processingInterrupted, +} + +class NfcActivityNotifier extends StateNotifier { + NfcActivityNotifier() : super(NfcActivity.notActive); + + void setActivityState(int stateValue) { + + var newState = switch (stateValue) { + 0 => NfcActivity.notActive, + 1 => NfcActivity.ready, + 2 => NfcActivity.processingStarted, + 3 => NfcActivity.processingFinished, + 4 => NfcActivity.processingInterrupted, + _ => NfcActivity.notActive + }; + + state = newState; + } +} + final androidSdkVersionProvider = Provider((ref) => -1); final androidNfcSupportProvider = Provider((ref) => false); @@ -80,6 +106,10 @@ final androidNfcSupportProvider = Provider((ref) => false); final androidNfcStateProvider = StateNotifierProvider((ref) => NfcStateNotifier()); +final androidNfcActivityProvider = StateNotifierProvider((ref) => + NfcActivityNotifier() +); + final androidSupportedThemesProvider = StateProvider>((ref) { if (ref.read(androidSdkVersionProvider) < 29) { // the user can select from light or dark theme of the app diff --git a/lib/android/tap_request_dialog.dart b/lib/android/tap_request_dialog.dart index 1a6c1be1..34ae7a91 100755 --- a/lib/android/tap_request_dialog.dart +++ b/lib/android/tap_request_dialog.dart @@ -1,5 +1,5 @@ /* - * Copyright (C) 2022 Yubico. + * 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. @@ -21,9 +21,10 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'views/nfc/nfc_activity_widget.dart'; + import '../app/state.dart'; import '../app/views/user_interaction.dart'; -import '../widgets/custom_icons.dart'; const _channel = MethodChannel('com.yubico.authenticator.channel.dialog'); @@ -35,6 +36,7 @@ final androidDialogProvider = Provider<_DialogProvider>( class _DialogProvider { final WithContext _withContext; + final Widget _icon = const NfcActivityWidget(width: 64, height: 64); UserInteractionController? _controller; _DialogProvider(this._withContext) { @@ -65,46 +67,29 @@ class _DialogProvider { _controller = null; } - Widget? _getIcon(String? icon) => switch (icon) { - 'nfc' => nfcIcon, - 'success' => const Icon(Icons.check_circle), - 'error' => const Icon(Icons.error), - _ => null, - }; - Future _updateDialogState( String? title, String? description, String? iconName) async { - final icon = _getIcon(iconName); await _withContext((context) async { _controller?.updateContent( title: title, description: description, - icon: icon != null - ? IconTheme( - data: IconTheme.of(context).copyWith(size: 64), - child: icon, - ) - : null, + icon: _icon, ); }); } Future _showDialog( String title, String description, String? iconName) async { - final icon = _getIcon(iconName); - _controller = await _withContext((context) async => promptUserInteraction( - context, - title: title, - description: description, - icon: icon != null - ? IconTheme( - data: IconTheme.of(context).copyWith(size: 64), - child: icon, - ) - : null, - onCancel: () { - _channel.invokeMethod('cancel'); - }, - )); + _controller = await _withContext((context) async { + return promptUserInteraction( + context, + title: title, + description: description, + icon: _icon, + onCancel: () { + _channel.invokeMethod('cancel'); + }, + ); + }); } } diff --git a/lib/android/views/nfc/main_page_nfc_activity_widget.dart b/lib/android/views/nfc/main_page_nfc_activity_widget.dart new file mode 100644 index 00000000..b0526cf9 --- /dev/null +++ b/lib/android/views/nfc/main_page_nfc_activity_widget.dart @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2023 Yubico. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'package:flutter/material.dart'; +import 'package:yubico_authenticator/android/state.dart'; +import 'package:yubico_authenticator/android/views/nfc/nfc_activity_widget.dart'; + +class MainPageNfcActivityWidget extends StatelessWidget { + final Widget widget; + const MainPageNfcActivityWidget(this.widget, {super.key}); + + @override + Widget build(BuildContext context) { + return NfcActivityWidget( + width: 128.0, + height: 128.0, + iconView: (nfcActivityState) { + return Opacity( + opacity: switch (nfcActivityState) { + NfcActivity.processingStarted => 1.0, + _ => 0.8 + }, + child: widget, + ); + }, + ); + } +} diff --git a/lib/android/views/nfc/nfc_activity_icon.dart b/lib/android/views/nfc/nfc_activity_icon.dart new file mode 100644 index 00000000..9b160bb4 --- /dev/null +++ b/lib/android/views/nfc/nfc_activity_icon.dart @@ -0,0 +1,90 @@ +/* + * Copyright (C) 2023 Yubico. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:yubico_authenticator/android/state.dart'; + +/// Default icon for [NfcActivityWidget] +class NfcActivityIcon extends StatelessWidget { + final NfcActivity nfcActivity; + + const NfcActivityIcon(this.nfcActivity, {super.key}); + + @override + Widget build(BuildContext context) => switch (nfcActivity) { + NfcActivity.processingStarted => const _NfcIconWithOpacity(1.0), + _ => const _NfcIconWithOpacity(0.8) + }; +} + +class _NfcIconWithOpacity extends StatelessWidget { + final double opacity; + + const _NfcIconWithOpacity(this.opacity); + + @override + Widget build(BuildContext context) => Opacity( + opacity: opacity, + child: const _NfcIcon(), + ); +} + +class _NfcIcon extends StatelessWidget { + const _NfcIcon(); + + @override + Widget build(BuildContext context) { + final theme = IconTheme.of(context); + return LayoutBuilder( + builder: (BuildContext buildContext, BoxConstraints constraints) => + CustomPaint( + size: Size.copy(constraints.biggest), + painter: _NfcIconPainter(theme.color ?? Colors.black), + ), + ); + } +} + +class _NfcIconPainter extends CustomPainter { + final Color color; + + _NfcIconPainter(this.color); + + @override + void paint(Canvas canvas, Size size) { + final step = size.width / 4; + const sweep = pi / 4; + + final paint = Paint() + ..color = color + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round + ..strokeWidth = step / 2; + + final rect = + Offset(size.width * -1.7, 0) & Size(size.width * 2, size.height); + for (var i = 0; i < 3; i++) { + canvas.drawArc(rect.inflate(i * step), -sweep / 2, sweep, false, paint); + } + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) { + return false; + } +} diff --git a/lib/android/views/nfc/nfc_activity_widget.dart b/lib/android/views/nfc/nfc_activity_widget.dart new file mode 100644 index 00000000..703ffa08 --- /dev/null +++ b/lib/android/views/nfc/nfc_activity_widget.dart @@ -0,0 +1,49 @@ +/* + * Copyright (C) 2023 Yubico. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:logging/logging.dart'; + +import '../../../app/logging.dart'; +import '../../state.dart'; +import 'nfc_activity_icon.dart'; + +final _logger = Logger('NfcActivityWidget'); + +class NfcActivityWidget extends ConsumerWidget { + final double width; + final double height; + final Widget Function(NfcActivity)? iconView; + + const NfcActivityWidget( + {super.key, this.width = 32.0, this.height = 32.0, this.iconView}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final NfcActivity nfcActivityState = ref.watch(androidNfcActivityProvider); + + _logger.debug('State for NfcActivityWidget changed to $nfcActivityState'); + + return IgnorePointer( + child: SizedBox( + width: width, + height: height, + child: iconView?.call(nfcActivityState) ?? + NfcActivityIcon(nfcActivityState)), + ); + } +} diff --git a/lib/app/views/main_page.dart b/lib/app/views/main_page.dart index 229272c3..5091dcf6 100755 --- a/lib/app/views/main_page.dart +++ b/lib/app/views/main_page.dart @@ -20,6 +20,7 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import '../../android/app_methods.dart'; import '../../android/state.dart'; +import '../../android/views/nfc/main_page_nfc_activity_widget.dart'; import '../../exception/cancellation_exception.dart'; import '../../core/state.dart'; import '../../fido/views/fido_screen.dart'; @@ -82,7 +83,7 @@ class MainPage extends ConsumerWidget { var hasNfcSupport = ref.watch(androidNfcSupportProvider); var isNfcEnabled = ref.watch(androidNfcStateProvider); return MessagePage( - graphic: noKeyImage, + graphic: MainPageNfcActivityWidget(noKeyImage), message: hasNfcSupport && isNfcEnabled ? l10n.l_insert_or_tap_yk : l10n.l_insert_yk,