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;
}