diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/ActivityUtil.kt b/android/app/src/main/kotlin/com/yubico/authenticator/ActivityUtil.kt new file mode 100644 index 00000000..1f093f34 --- /dev/null +++ b/android/app/src/main/kotlin/com/yubico/authenticator/ActivityUtil.kt @@ -0,0 +1,109 @@ +/* + * 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 + +import android.app.Activity +import android.content.ComponentName +import android.content.pm.PackageManager +import org.slf4j.LoggerFactory + +class ActivityUtil(private val activity: Activity) { + + private val logger = LoggerFactory.getLogger(ActivityUtil::class.java) + + /** + * The app will be started when a complaint USB device is attached. + * + * Calling this method will enable AliasMainActivity alias which contains + * intent-filter for android.hardware.usb.action.USB_DEVICE_ATTACHED. This alias is disabled by + * default in the AndroidManifest.xml. + * + * Devices which will activate the intent filter are defined in `res/xml/device_filter.xml`. + * @see Activity Alias in Android SDK documentation + */ + fun enableSystemUsbDiscovery() { + setState( + MAIN_ACTIVITY_ALIAS, + PackageManager.COMPONENT_ENABLED_STATE_ENABLED + ) + } + + /** + * The system will not start this app when a complaint USB device is attached. + * + * Calling this method will disable AliasMainActivity alias and the intent-filter + * for android.hardware.usb.action.USB_DEVICE_ATTACHED will not be active. + * + * @see Activity Alias in Android SDK documentation + */ + fun disableSystemUsbDiscovery() { + setState( + MAIN_ACTIVITY_ALIAS, + PackageManager.COMPONENT_ENABLED_STATE_DEFAULT + ) + } + + /** + * The system will start the app when an NDEF tag with recognized URI is discovered. + * + * Calling this method will enable AliasNdefActivity alias and the intent-filter + * for android.nfc.action.NDEF_DISCOVERED will be active. This is the default behavior as + * defined in the AndroidManifest.xml. + * + * For list of discoverable URIs see the alias definition in AndroidManifest.xml. + * + * @see Activity Alias in Android SDK documentation + */ + fun enableAppNfcDiscovery() { + setState( + NDEF_ACTIVITY_ALIAS, + PackageManager.COMPONENT_ENABLED_STATE_DEFAULT + ) + } + + /** + * The system will ignore the app when NDEF tags are discovered. + * + * Calling this method will disable AliasNdefActivity alias and there will be no + * active intent-filter for android.nfc.action.NDEF_DISCOVERED. + * + * @see Activity Alias in Android SDK documentation + */ + fun disableAppNfcDiscovery() { + setState( + NDEF_ACTIVITY_ALIAS, + PackageManager.COMPONENT_ENABLED_STATE_DISABLED + ) + } + + private fun setState(aliasName: String, enabledState: Int) { + val componentName = + ComponentName(activity.packageName, "com.yubico.authenticator.$aliasName") + activity.applicationContext.packageManager.setComponentEnabledSetting( + componentName, + enabledState, + PackageManager.DONT_KILL_APP + ) + logger.trace("Activity alias '$aliasName' is enabled: $enabledState") + } + + companion object { + const val NDEF_ACTIVITY_ALIAS = "AliasNdefActivity" + const val MAIN_ACTIVITY_ALIAS = "AliasMainActivity" + } + +} \ 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 8e034773..48f282e8 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/MainActivity.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/MainActivity.kt @@ -71,6 +71,7 @@ class MainActivity : FlutterFragmentActivity() { // receives broadcasts when QR Scanner camera is closed private val qrScannerCameraClosedBR = QRScannerCameraClosedBR() private val nfcAdapterStateChangeBR = NfcAdapterStateChangedBR() + private val activityUtil = ActivityUtil(this) private val logger = LoggerFactory.getLogger(MainActivity::class.java) @@ -88,70 +89,6 @@ class MainActivity : FlutterFragmentActivity() { yubikit = YubiKitManager(this) } - /** - * Enables or disables .AliasMainActivity component. This activity alias adds intent-filter - * for android.hardware.usb.action.USB_DEVICE_ATTACHED. When enabled, the app will be opened - * when a compliant USB device (defined in `res/xml/device_filter.xml`) is attached. - * - * By default the activity alias is disabled through AndroidManifest.xml. - * - * @param enable if true, alias activity will be enabled - */ - private fun enableActivityAlias(aliasName: String, enabledState: Int) { - val componentName = ComponentName(packageName, "com.yubico.authenticator.$aliasName") - applicationContext.packageManager.setComponentEnabledSetting( - componentName, - enabledState, - PackageManager.DONT_KILL_APP - ) - logger.trace("Activity alias '$aliasName' is enabled: $enabledState") - } - - /** - * Sets state of AliasMainActivity to enabled. This will enable USB discovery. - */ - private fun enableAppUsbDiscovery() { - enableActivityAlias( - "AliasMainActivity", - PackageManager.COMPONENT_ENABLED_STATE_ENABLED - ) - } - - /** - * Sets state of AliasMainActivity to default. This will deactivate the USB discovery. - * - * The default for AliasMainActivity is disabled. - */ - private fun disableAppUsbDiscovery() { - enableActivityAlias( - "AliasMainActivity", - PackageManager.COMPONENT_ENABLED_STATE_DEFAULT - ) - } - - /** - * Sets state of AliasNdefActivity to defaut. This will activate NFC intent filters. - * - * The default for AliasNdefActivity is enabled. - */ - private fun enableAppNfcDiscovery() { - enableActivityAlias( - "AliasNdefActivity", - PackageManager.COMPONENT_ENABLED_STATE_DEFAULT - ) - } - - /** - * Sets state of AliasNdefActivity to disabled. This will deactivate NFC intent filters. - */ - private fun disableAppNfcDiscovery() { - // enable NFC discovery based on user preferences - enableActivityAlias( - "AliasNdefActivity", - PackageManager.COMPONENT_ENABLED_STATE_DISABLED - ) - } - override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) setIntent(intent) @@ -225,13 +162,13 @@ class MainActivity : FlutterFragmentActivity() { stopNfcDiscovery() if (!appPreferences.openAppOnUsb) { - disableAppUsbDiscovery() + activityUtil.disableSystemUsbDiscovery() } if (appPreferences.openAppOnNfcTap || appPreferences.copyOtpOnNfcTap) { - enableAppNfcDiscovery() + activityUtil.enableAppNfcDiscovery() } else { - disableAppNfcDiscovery() + activityUtil.disableAppNfcDiscovery() } super.onPause() @@ -240,7 +177,7 @@ class MainActivity : FlutterFragmentActivity() { override fun onResume() { super.onResume() - enableAppUsbDiscovery() + activityUtil.enableSystemUsbDiscovery() // Handle opening through otpauth:// link val intentData = intent.data diff --git a/lib/android/views/settings_views.dart b/lib/android/views/settings_views.dart index 2a379b3d..6fc37e75 100755 --- a/lib/android/views/settings_views.dart +++ b/lib/android/views/settings_views.dart @@ -105,7 +105,8 @@ class NfcKbdLayoutView extends ConsumerWidget { title: Text(l10n.l_kbd_layout_for_static), subtitle: Text(clipKbdLayout), key: keys.nfcKeyboardLayoutSetting, - enabled: tapAction != NfcTapAction.launch, + enabled: tapAction == NfcTapAction.copy || + tapAction == NfcTapAction.launchAndCopy, onTap: () async { final newValue = await _selectKbdLayout( context, diff --git a/lib/app/views/settings_page.dart b/lib/app/views/settings_page.dart index c1a1302d..50a3f759 100755 --- a/lib/app/views/settings_page.dart +++ b/lib/app/views/settings_page.dart @@ -19,6 +19,7 @@ import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:yubico_authenticator/android/state.dart'; import '../../android/views/settings_views.dart'; import '../../core/state.dart'; @@ -122,12 +123,15 @@ class SettingsPage extends ConsumerWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (isAndroid) ...[ + // add nfc options only on devices with NFC capability + if (isAndroid && ref.watch(androidNfcSupportProvider)) ...[ ListTitle(l10n.s_nfc_options), const NfcTapActionView(), const NfcKbdLayoutView(), const NfcBypassTouchView(), const NfcSilenceSoundsView(), + ], + if (isAndroid) ...[ ListTitle(l10n.s_usb_options), const UsbOpenAppView(), ], diff --git a/test/android_settings_page_test.dart b/test/android_settings_page_test.dart index 574b94a3..e07f6d90 100644 --- a/test/android_settings_page_test.dart +++ b/test/android_settings_page_test.dart @@ -14,18 +14,18 @@ * limitations under the License. */ -import 'package:flutter/material.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'package:yubico_authenticator/android/models.dart'; -import 'package:yubico_authenticator/app/views/keys.dart' as app_keys; import 'package:yubico_authenticator/android/keys.dart' as android_keys; +import 'package:yubico_authenticator/android/models.dart'; import 'package:yubico_authenticator/android/state.dart'; import 'package:yubico_authenticator/app/state.dart'; +import 'package:yubico_authenticator/app/views/keys.dart' as app_keys; import 'package:yubico_authenticator/app/views/settings_page.dart'; import 'package:yubico_authenticator/core/state.dart'; @@ -52,6 +52,12 @@ extension _WidgetTesterHelper on WidgetTester { await pumpAndSettle(); } + Future selectDoNothingOption() async { + await openNfcTapOptionSelection(); + await tap(find.byKey(android_keys.nfcTapOption(NfcTapAction.noAction))); + await pumpAndSettle(); + } + Future selectLaunchOption() async { await openNfcTapOptionSelection(); await tap(find.byKey(android_keys.nfcTapOption(NfcTapAction.launch))); @@ -66,7 +72,8 @@ extension _WidgetTesterHelper on WidgetTester { Future selectBothOption() async { await openNfcTapOptionSelection(); - await tap(find.byKey(android_keys.nfcTapOption(NfcTapAction.launchAndCopy))); + await tap( + find.byKey(android_keys.nfcTapOption(NfcTapAction.launchAndCopy))); await pumpAndSettle(); } @@ -149,19 +156,20 @@ extension _WidgetTesterHelper on WidgetTester { Widget androidWidget({ required SharedPreferences sharedPrefs, - required Widget child, int sdkVersion = 33, + bool hasNfcSupport = true, + Widget? child, }) => ProviderScope(overrides: [ prefProvider.overrideWithValue(sharedPrefs), androidSdkVersionProvider.overrideWithValue(sdkVersion), supportedThemesProvider - .overrideWith((ref) => ref.watch(androidSupportedThemesProvider)) - ], child: child); + .overrideWith((ref) => ref.watch(androidSupportedThemesProvider)), + androidNfcSupportProvider.overrideWithValue(hasNfcSupport) + ], child: child ?? createMaterialApp(child: const SettingsPage())); void main() { debugDefaultTargetPlatformOverride = TargetPlatform.android; - var widget = createMaterialApp(child: const SettingsPage()); testWidgets('NFC Tap options', (WidgetTester tester) async { const prefNfcOpenApp = 'prefNfcOpenApp'; @@ -171,10 +179,7 @@ void main() { SharedPreferences sharedPrefs = await SharedPreferences.getInstance(); - await tester.pumpWidget(androidWidget( - sharedPrefs: sharedPrefs, - child: widget, - )); + await tester.pumpWidget(androidWidget(sharedPrefs: sharedPrefs)); // launch - preserves original value await tester.selectLaunchOption(); @@ -191,6 +196,11 @@ void main() { expect(sharedPrefs.getBool(prefNfcOpenApp), equals(true)); expect(sharedPrefs.getBool(prefNfcCopyOtp), equals(true)); + // do nothing + await tester.selectDoNothingOption(); + expect(sharedPrefs.getBool(prefNfcOpenApp), equals(false)); + expect(sharedPrefs.getBool(prefNfcCopyOtp), equals(false)); + // launch - changes to value await tester.selectLaunchOption(); expect(sharedPrefs.getBool(prefNfcOpenApp), equals(true)); @@ -206,10 +216,11 @@ void main() { SharedPreferences sharedPrefs = await SharedPreferences.getInstance(); - await tester.pumpWidget(androidWidget( - sharedPrefs: sharedPrefs, - child: widget, - )); + await tester.pumpWidget(androidWidget(sharedPrefs: sharedPrefs)); + + // option is disabled for "do nothing" + await tester.selectDoNothingOption(); + expect(tester.keyboardLayoutListTile().enabled, equals(false)); // option is disabled for "open" expect(tester.keyboardLayoutListTile().enabled, equals(false)); @@ -243,10 +254,7 @@ void main() { SharedPreferences.setMockInitialValues({prefNfcBypassTouch: false}); SharedPreferences sharedPrefs = await SharedPreferences.getInstance(); - await tester.pumpWidget(androidWidget( - sharedPrefs: sharedPrefs, - child: widget, - )); + await tester.pumpWidget(androidWidget(sharedPrefs: sharedPrefs)); // change to true await tester.tapBypassTouch(); @@ -265,7 +273,6 @@ void main() { await tester.pumpWidget(androidWidget( sharedPrefs: sharedPrefs, - child: widget, // Android 10 (API Level 29) sdkVersion: 29, )); @@ -282,7 +289,6 @@ void main() { await tester.pumpWidget(androidWidget( sharedPrefs: sharedPrefs, - child: widget, // Android 9 (API Level 28) sdkVersion: 28, )); @@ -298,10 +304,7 @@ void main() { SharedPreferences sharedPrefs = await SharedPreferences.getInstance(); const prefTheme = 'APP_STATE_THEME'; - await tester.pumpWidget(androidWidget( - sharedPrefs: sharedPrefs, - child: widget, - )); + await tester.pumpWidget(androidWidget(sharedPrefs: sharedPrefs)); await tester.selectSystemTheme(); expect(sharedPrefs.getString(prefTheme), equals('system')); @@ -319,10 +322,7 @@ void main() { SharedPreferences.setMockInitialValues({prefUsbOpenApp: false}); SharedPreferences sharedPrefs = await SharedPreferences.getInstance(); - await tester.pumpWidget(androidWidget( - sharedPrefs: sharedPrefs, - child: widget, - )); + await tester.pumpWidget(androidWidget(sharedPrefs: sharedPrefs)); // change to true await tester.tapOpenAppOnUsb(); @@ -338,10 +338,7 @@ void main() { SharedPreferences.setMockInitialValues({prefNfcSilenceSounds: false}); SharedPreferences sharedPrefs = await SharedPreferences.getInstance(); - await tester.pumpWidget(androidWidget( - sharedPrefs: sharedPrefs, - child: widget, - )); + await tester.pumpWidget(androidWidget(sharedPrefs: sharedPrefs)); // change to true await tester.tapSilenceNfcSounds(); @@ -352,5 +349,59 @@ void main() { expect(sharedPrefs.getBool(prefNfcSilenceSounds), equals(false)); }); + testWidgets('NFC options visible on device with NFC support', + (WidgetTester tester) async { + SharedPreferences sharedPrefs = await SharedPreferences.getInstance(); + + await tester.pumpWidget(androidWidget( + sharedPrefs: sharedPrefs, + hasNfcSupport: true, + )); + + expect(find.byKey(android_keys.nfcTapSetting), findsOneWidget); + expect(find.byKey(android_keys.nfcKeyboardLayoutSetting), findsOneWidget); + expect(find.byKey(android_keys.nfcSilenceSoundsSettings), findsOneWidget); + expect(find.byKey(android_keys.nfcBypassTouchSetting), findsOneWidget); + }); + + testWidgets('NFC options hidden on device without NFC support', + (WidgetTester tester) async { + SharedPreferences sharedPrefs = await SharedPreferences.getInstance(); + + await tester.pumpWidget(androidWidget( + sharedPrefs: sharedPrefs, + hasNfcSupport: false, + )); + + expect(find.byKey(android_keys.nfcTapSetting), findsNothing); + expect(find.byKey(android_keys.nfcKeyboardLayoutSetting), findsNothing); + expect(find.byKey(android_keys.nfcSilenceSoundsSettings), findsNothing); + expect(find.byKey(android_keys.nfcBypassTouchSetting), findsNothing); + }); + + testWidgets('USB options visible on device with NFC support', + (WidgetTester tester) async { + SharedPreferences sharedPrefs = await SharedPreferences.getInstance(); + + await tester.pumpWidget(androidWidget( + sharedPrefs: sharedPrefs, + hasNfcSupport: true, + )); + + expect(find.byKey(android_keys.usbOpenApp), findsOneWidget); + }); + + testWidgets('USB options visible on device without NFC support', + (WidgetTester tester) async { + SharedPreferences sharedPrefs = await SharedPreferences.getInstance(); + + await tester.pumpWidget(androidWidget( + sharedPrefs: sharedPrefs, + hasNfcSupport: false, + )); + + expect(find.byKey(android_keys.usbOpenApp), findsOneWidget); + }); + debugDefaultTargetPlatformOverride = null; }