mirror of
https://github.com/Yubico/yubioath-flutter.git
synced 2024-11-22 08:22:16 +03:00
oath account, andr. settings, andr. beta dlg tests
This commit is contained in:
parent
9e567b348c
commit
69228ae482
39
integration_test/android/beta_welcome_dialog_test.dart
Normal file
39
integration_test/android/beta_welcome_dialog_test.dart
Normal file
@ -0,0 +1,39 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../test_util.dart';
|
||||
import 'constants.dart';
|
||||
|
||||
void main() {
|
||||
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
binding.framePolicy = LiveTestWidgetsFlutterBindingFramePolicy.fullyLive;
|
||||
|
||||
group('Beta welcome dialog', () {
|
||||
testWidgets('startup', (WidgetTester tester) async {
|
||||
await tester.startUp({
|
||||
'dlg.beta.enabled': false,
|
||||
'delay.startup': 5,
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('shows welcome screen', (WidgetTester tester) async {
|
||||
await tester.startUp({
|
||||
'dlg.beta.enabled': true,
|
||||
});
|
||||
expect(find.byKey(betaDialogKey), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('does not show welcome dialog', (WidgetTester tester) async {
|
||||
await tester.startUp();
|
||||
expect(find.byKey(betaDialogKey), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('updates preferences', (WidgetTester tester) async {
|
||||
await tester.startUp({'dlg.beta.enabled': true});
|
||||
var prefs = await SharedPreferences.getInstance();
|
||||
await tester.tap(find.byKey(gotItBtn));
|
||||
await expectLater(prefs.getBool(betaDialogPrefName), equals(false));
|
||||
});
|
||||
});
|
||||
}
|
22
integration_test/android/constants.dart
Normal file
22
integration_test/android/constants.dart
Normal file
@ -0,0 +1,22 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
// widget key names
|
||||
const betaDialogKey = Key('android.beta.dialog');
|
||||
const gotItBtn = Key('android.beta.dialog.btn.got_it');
|
||||
|
||||
const settingsOnNfcTapOptionKey = Key('android.settings.option.on_nfc_tap');
|
||||
const settingsOnNfcTapLaunch = Key('android.settings.on_nfc_tap.launch');
|
||||
const settingsOnNfcTapCopy = Key('android.settings.on_nfc_tap.copy');
|
||||
const settingsOnNfcTapBoth = Key('android.settings.on_nfc_tap.both');
|
||||
const settingsKeyboardLayoutOptionKey = Key('android.settings.option.keyboard_layout');
|
||||
const settingsKeyboardLayoutUS = Key('android.settings.keyboard_layout.US');
|
||||
const settingsKeyboardLayoutDE = Key('android.settings.keyboard_layout.DE');
|
||||
const settingsKeyboardLayoutDECH = Key('android.settings.keyboard_layout.DE-CH');
|
||||
const settingsBypassTouchKey = Key('android.settings.bypass_touch');
|
||||
|
||||
// shared preferences keys
|
||||
const betaDialogPrefName = 'prefBetaDialogShouldBeShown';
|
||||
const prefNfcOpenApp = 'prefNfcOpenApp';
|
||||
const prefNfcBypassTouch = 'prefNfcBypassTouch';
|
||||
const prefNfcCopyOtp = 'prefNfcCopyOtp';
|
||||
const prefClipKbdLayout = 'prefClipKbdLayout';
|
11
integration_test/android/test_driver.dart
Normal file
11
integration_test/android/test_driver.dart
Normal file
@ -0,0 +1,11 @@
|
||||
import 'dart:io';
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:integration_test/integration_test_driver_extended.dart';
|
||||
|
||||
Future<void> main() async {
|
||||
await Process.run('adb' , ['shell' ,'pm', 'grant', 'com.yubico.yubioath', 'android.permission.CAMERA']);
|
||||
await Process.run('adb' , ['shell' ,'pm', 'grant', 'com.yubico.yubioath', 'android.permission.WRITE_EXTERNAL_STORAGE']);
|
||||
|
||||
await integrationDriver();
|
||||
}
|
29
integration_test/android/util.dart
Normal file
29
integration_test/android/util.dart
Normal file
@ -0,0 +1,29 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../test_util.dart';
|
||||
import 'constants.dart';
|
||||
|
||||
class AndroidTestUtils {
|
||||
static void setShowBetaDialogPref(bool value) async {
|
||||
SharedPreferences.setMockInitialValues({betaDialogPrefName: value});
|
||||
}
|
||||
|
||||
static Future<void> startUp(WidgetTester tester,
|
||||
[Map<dynamic, dynamic>? startUpParams]) async {
|
||||
// on Android disable Beta welcome dialog
|
||||
// we need to do it before we pump the app
|
||||
var betaDlgEnabled = startUpParams?['dlg.beta.enabled'] ?? false;
|
||||
setShowBetaDialogPref(betaDlgEnabled);
|
||||
|
||||
await tester.pumpWidget(
|
||||
await getAuthenticatorApp(), const Duration(milliseconds: 500));
|
||||
|
||||
var startupDelay = startUpParams?['delay.startup'] ?? 0;
|
||||
if (startupDelay != 0) {
|
||||
tester.printToConsole('Connect YubiKey and approve USB Connection');
|
||||
await tester.pump(Duration(seconds: startupDelay));
|
||||
tester.printToConsole('Assuming YubiKey connected');
|
||||
}
|
||||
}
|
||||
}
|
@ -1,7 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:yubico_authenticator/core/state.dart';
|
||||
import 'package:yubico_authenticator/oath/views/account_list.dart';
|
||||
import 'package:yubico_authenticator/oath/views/account_view.dart';
|
||||
import 'package:yubico_authenticator/oath/views/oath_screen.dart';
|
||||
|
||||
import 'test_util.dart';
|
||||
@ -28,20 +30,255 @@ String generateSecret(int index) {
|
||||
return List.generate(16, (_) => base32(index)).toString();
|
||||
}
|
||||
|
||||
class OathDeviceMenu {
|
||||
static const addAccountKey = Key('add oath account');
|
||||
static const setManagePasswordKey = Key('set or manage oath password');
|
||||
static const resetKey = Key('reset oath app');
|
||||
}
|
||||
|
||||
const qrScannerEnterManuallyKey = Key('android.qr_scanner.btn.enter_manually');
|
||||
const deleteAccountBtnKey = Key('oath.dlg.delete_account.btn.delete');
|
||||
const renameAccountBtnSaveKey = Key('oath.dlg.rename_account.btn.save');
|
||||
const renameAccountEditIssuerKey = Key('oath.dlg.rename_account.edit.issuer');
|
||||
const renameAccountEditNameKey = Key('oath.dlg.rename_account.edit.name');
|
||||
|
||||
const shortestWaitMs = 10;
|
||||
const shortWaitMs = 50;
|
||||
const longWaitMs = 200;
|
||||
const veryLongWaitS = 10; // seconds
|
||||
|
||||
class Account {
|
||||
final String? issuer;
|
||||
final String name;
|
||||
final String secret;
|
||||
|
||||
const Account({
|
||||
this.issuer,
|
||||
this.name = '',
|
||||
this.secret = 'abcdefghabcdefgh',
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() => '$issuer/$name';
|
||||
}
|
||||
|
||||
extension OathHelper on WidgetTester {
|
||||
/// Opens the device menu and taps the "Add account" menu item
|
||||
|
||||
Future<void> shortestWait() async {
|
||||
testLog(false, 'shortestWait ${shortestWaitMs}ms');
|
||||
await pump(const Duration(milliseconds: shortestWaitMs));
|
||||
}
|
||||
|
||||
Future<void> shortWait() async {
|
||||
testLog(false, 'shortWait ${shortWaitMs}ms');
|
||||
await pump(const Duration(milliseconds: shortWaitMs));
|
||||
}
|
||||
|
||||
Future<void> longWait() async {
|
||||
testLog(false, 'longWait ${longWaitMs}ms');
|
||||
await pump(const Duration(milliseconds: longWaitMs));
|
||||
}
|
||||
|
||||
Future<void> veryLongWait() async {
|
||||
testLog(false, 'veryLongWait ${veryLongWaitS}s');
|
||||
await pump(const Duration(seconds: veryLongWaitS));
|
||||
}
|
||||
|
||||
Future<void> tapAddAccount() async {
|
||||
await tapDeviceButton();
|
||||
await tap(find.byKey(const Key('add oath account')));
|
||||
await pump(const Duration(milliseconds: 500));
|
||||
await tap(find.byKey(OathDeviceMenu.addAccountKey).hitTestable());
|
||||
await longWait();
|
||||
}
|
||||
|
||||
/// Opens the device menu and taps the "Set/Manage password" menu item
|
||||
Future<void> tapSetOrManagePassword() async {
|
||||
await pump(const Duration(milliseconds: 300));
|
||||
await shortWait();
|
||||
await tapDeviceButton();
|
||||
await tap(find.byKey(const Key('set or manage oath password')));
|
||||
await pump(const Duration(milliseconds: 500));
|
||||
await tap(find.byKey(OathDeviceMenu.setManagePasswordKey));
|
||||
await longWait();
|
||||
}
|
||||
|
||||
Future<void> addAccount(Account a, {bool quiet = true}) async {
|
||||
var accountView = await findAccount(a);
|
||||
if (accountView != null) {
|
||||
testLog(quiet, 'Account already exists: $a');
|
||||
return;
|
||||
}
|
||||
|
||||
await tapAddAccount();
|
||||
|
||||
if (isAndroid) {
|
||||
// on android a QR Scanner starts
|
||||
// we want to do a manual addition
|
||||
var manualEntryBtn = find.byKey(qrScannerEnterManuallyKey).hitTestable();
|
||||
if (manualEntryBtn.evaluate().isEmpty) {
|
||||
printToConsole('Allow camera permission');
|
||||
await pump(const Duration(seconds: 2));
|
||||
manualEntryBtn = find.byKey(qrScannerEnterManuallyKey).hitTestable();
|
||||
}
|
||||
|
||||
await tap(manualEntryBtn);
|
||||
await longWait();
|
||||
}
|
||||
|
||||
var issuerText = find.byKey(const Key('issuer')).hitTestable();
|
||||
await tap(issuerText);
|
||||
await enterText(issuerText, a.issuer ?? '');
|
||||
await shortWait();
|
||||
var nameText = find.byKey(const Key('name')).hitTestable();
|
||||
await tap(nameText);
|
||||
await enterText(nameText, a.name);
|
||||
await shortWait();
|
||||
var secretText = find.byKey(const Key('secret')).hitTestable();
|
||||
await tap(secretText);
|
||||
await enterText(secretText, a.secret);
|
||||
await shortWait();
|
||||
|
||||
await tap(find.byKey(const Key('save_btn')));
|
||||
|
||||
await longWait();
|
||||
|
||||
accountView = await findAccount(a);
|
||||
expect(accountView, isNotNull);
|
||||
if (accountView != null) {
|
||||
testLog(quiet, 'Added account $a');
|
||||
}
|
||||
}
|
||||
|
||||
Future<AccountList?> getAccountList() async {
|
||||
var accountList = find.byType(AccountList).hitTestable();
|
||||
expect(accountList, findsOneWidget);
|
||||
return accountList.evaluate().single.widget as AccountList;
|
||||
}
|
||||
|
||||
Future<AccountView?> findAccount(Account a, {bool quiet = true}) async {
|
||||
var accountList = find.byType(AccountList);
|
||||
expect(accountList, findsOneWidget);
|
||||
|
||||
// find an AccountView with issuer/name in the account list
|
||||
var matchingAccounts = find.descendant(
|
||||
of: accountList,
|
||||
matching: find.byWidgetPredicate(
|
||||
(widget) =>
|
||||
widget is AccountView &&
|
||||
widget.credential.name == a.name &&
|
||||
widget.credential.issuer == a.issuer,
|
||||
skipOffstage: false));
|
||||
|
||||
matchingAccounts.evaluate().forEach((element) {
|
||||
var widget = element.widget;
|
||||
if (widget is AccountView) {
|
||||
testLog(quiet,
|
||||
'Found ${widget.credential.issuer}/${widget.credential.name} matching account');
|
||||
} else {
|
||||
printToConsole('Unexpected widget type found: $widget');
|
||||
}
|
||||
});
|
||||
|
||||
// return the AccountView if there is only one found
|
||||
var evaluated = matchingAccounts.evaluate();
|
||||
return evaluated.isEmpty
|
||||
? null
|
||||
: evaluated.length != 1
|
||||
? null
|
||||
: evaluated.single.widget as AccountView;
|
||||
}
|
||||
|
||||
Future<void> openAccountDialog(Account a) async {
|
||||
var accountView = await findAccount(a);
|
||||
expect(accountView, isNotNull);
|
||||
|
||||
if (accountView != null) {
|
||||
await ensureVisible(find.byWidget(accountView));
|
||||
await tap(find.byWidget(accountView));
|
||||
await shortWait();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteAccount(Account a, {bool quiet = true}) async {
|
||||
// only delete account if it exists
|
||||
var accountView = await findAccount(a);
|
||||
if (accountView == null) {
|
||||
testLog(quiet, 'Account to delete does not exist: $a');
|
||||
return;
|
||||
}
|
||||
|
||||
await openAccountDialog(a);
|
||||
var deleteIconButton = find.byIcon(Icons.delete_outline).hitTestable();
|
||||
expect(deleteIconButton, findsOneWidget);
|
||||
await tap(deleteIconButton);
|
||||
await longWait();
|
||||
|
||||
// TODO check dialog shows correct information about account
|
||||
var deleteButton = find.byKey(deleteAccountBtnKey).hitTestable();
|
||||
expect(deleteButton, findsOneWidget);
|
||||
await tap(deleteButton);
|
||||
await longWait();
|
||||
|
||||
// try to find account
|
||||
var deletedAccountView = await findAccount(a);
|
||||
expect(deletedAccountView, isNull);
|
||||
if (deletedAccountView == null) {
|
||||
testLog(quiet, 'Deleted account $a');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> renameAccount(
|
||||
Account a,
|
||||
String? newIssuer,
|
||||
String newName, {
|
||||
bool quiet = true,
|
||||
}) async {
|
||||
var accountView = await findAccount(a);
|
||||
if (accountView == null) {
|
||||
testLog(quiet, 'Account to rename does not exist: $a');
|
||||
return;
|
||||
}
|
||||
|
||||
await openAccountDialog(a);
|
||||
var renameIconButton = find.byIcon(Icons.edit_outlined).hitTestable();
|
||||
expect(renameIconButton, findsOneWidget);
|
||||
await tap(renameIconButton);
|
||||
await longWait();
|
||||
|
||||
// fill new info
|
||||
var issuerTextField = find.byKey(renameAccountEditIssuerKey).hitTestable();
|
||||
await tap(issuerTextField);
|
||||
await enterText(issuerTextField, newIssuer ?? '');
|
||||
var nameTextField = find.byKey(renameAccountEditNameKey).hitTestable();
|
||||
await tap(nameTextField);
|
||||
await enterText(nameTextField, newName);
|
||||
await shortestWait();
|
||||
|
||||
var saveButton = find.byKey(renameAccountBtnSaveKey).hitTestable();
|
||||
expect(saveButton, findsOneWidget);
|
||||
await tap(saveButton);
|
||||
await longWait();
|
||||
|
||||
// now the account dialog is shown
|
||||
// TODO verify it shows correct issuer and name
|
||||
|
||||
// close the account dialog by tapping out of it
|
||||
await tapAt(const Offset(10, 10));
|
||||
await longWait();
|
||||
|
||||
// verify accounts in the list
|
||||
var renamedAccount = Account(issuer: newIssuer, name: newName);
|
||||
var renamedAccountView = await findAccount(renamedAccount);
|
||||
await shortWait();
|
||||
var originalAccountView = await findAccount(a);
|
||||
expect(renamedAccountView, isNotNull);
|
||||
expect(originalAccountView, isNull);
|
||||
if (renamedAccountView != null && originalAccountView == null) {
|
||||
testLog(quiet, 'Renamed account from $a to $renamedAccount');
|
||||
}
|
||||
}
|
||||
|
||||
void testLog(bool quiet, String message) {
|
||||
if (!quiet) {
|
||||
printToConsole(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -49,32 +286,87 @@ void main() {
|
||||
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
binding.framePolicy = LiveTestWidgetsFlutterBindingFramePolicy.fullyLive;
|
||||
|
||||
var startupParams = {};
|
||||
|
||||
if (isAndroid) {
|
||||
// default android parameters
|
||||
startupParams = {'dlg.beta.enabled': false, 'delay.startup': 5};
|
||||
testWidgets('', (WidgetTester tester) async {
|
||||
// delay first start
|
||||
await tester.startUp(startupParams);
|
||||
// remove delay.startup
|
||||
startupParams = {'dlg.beta.enabled': false};
|
||||
});
|
||||
}
|
||||
|
||||
group('OATH UI tests', () {
|
||||
// Validates that expected UI is present
|
||||
testWidgets('OATH UI: "Add account" menu item exists',
|
||||
(WidgetTester tester) async {
|
||||
await tester.startUp();
|
||||
testWidgets('Menu items exist', (WidgetTester tester) async {
|
||||
await tester.startUp(startupParams);
|
||||
await tester.tapDeviceButton();
|
||||
expect(find.byKey(const Key('add oath account')), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('OATH-UI: "Set or manage oath password" menu item exists',
|
||||
(WidgetTester tester) async {
|
||||
await tester.startUp();
|
||||
await tester.tapDeviceButton();
|
||||
expect(
|
||||
find.byKey(const Key('set or manage oath password')), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('OATH-UI: "Reset OATH" menu item exists',
|
||||
(WidgetTester tester) async {
|
||||
await tester.startUp();
|
||||
await tester.tapDeviceButton();
|
||||
expect(find.byKey(const Key('reset oath app')), findsOneWidget);
|
||||
expect(find.byKey(OathDeviceMenu.addAccountKey), findsOneWidget);
|
||||
expect(find.byKey(OathDeviceMenu.setManagePasswordKey), findsOneWidget);
|
||||
expect(find.byKey(OathDeviceMenu.resetKey), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
group('OATH Password tests', () {
|
||||
group('OATH Account tests', () {
|
||||
testWidgets('Create account', (WidgetTester tester) async {
|
||||
await tester.startUp(startupParams);
|
||||
|
||||
// account with issuer
|
||||
var testAccount = const Account(
|
||||
issuer: 'IssuerForTests',
|
||||
name: 'NameForTests',
|
||||
secret: 'aaaaaaaaaaaaaaaa',
|
||||
);
|
||||
|
||||
await tester.deleteAccount(testAccount);
|
||||
await tester.addAccount(testAccount, quiet: false);
|
||||
|
||||
// account without issuer
|
||||
testAccount = const Account(
|
||||
name: 'NoIssuerName',
|
||||
secret: 'bbbbbbbbbbbbbbbb',
|
||||
);
|
||||
|
||||
await tester.deleteAccount(testAccount);
|
||||
await tester.addAccount(testAccount, quiet: false);
|
||||
});
|
||||
|
||||
// deletes accounts created in previous test
|
||||
testWidgets('Delete account', (WidgetTester tester) async {
|
||||
await tester.startUp(startupParams);
|
||||
|
||||
var testAccount =
|
||||
const Account(issuer: 'IssuerForTests', name: 'NameForTests');
|
||||
|
||||
await tester.deleteAccount(testAccount, quiet: false);
|
||||
expect(await tester.findAccount(testAccount), isNull);
|
||||
|
||||
testAccount = const Account(issuer: null, name: 'NoIssuerName');
|
||||
await tester.deleteAccount(testAccount, quiet: false);
|
||||
expect(await tester.findAccount(testAccount), isNull);
|
||||
});
|
||||
|
||||
// adds an account, renames, verifies
|
||||
testWidgets('Rename account', (WidgetTester tester) async {
|
||||
await tester.startUp(startupParams);
|
||||
|
||||
var testAccount =
|
||||
const Account(issuer: 'IssuerToRename', name: 'NameToRename');
|
||||
|
||||
// delete account if it exists
|
||||
await tester.deleteAccount(testAccount);
|
||||
await tester.deleteAccount(
|
||||
const Account(issuer: 'RenamedIssuer', name: 'RenamedName'));
|
||||
|
||||
await tester.addAccount(testAccount);
|
||||
await tester.renameAccount(testAccount, 'RenamedIssuer', 'RenamedName');
|
||||
});
|
||||
});
|
||||
|
||||
group('OATH Password tests', skip: true, () {
|
||||
/*
|
||||
These tests verify that all oath options are verified to function correctly by:
|
||||
1. setting firsPassword and verifying it
|
||||
@ -200,7 +492,8 @@ void main() {
|
||||
Tests will verify all TOTP functionality, not yet though:
|
||||
1. Add 32 TOTP accounts
|
||||
*/
|
||||
testWidgets('TOTP: Add 32 accounts', (WidgetTester tester) async {
|
||||
testWidgets('TOTP: Add 32 accounts', skip: true,
|
||||
(WidgetTester tester) async {
|
||||
await tester.startUp();
|
||||
|
||||
for (var i = 0; i < 32; i++) {
|
||||
|
@ -5,6 +5,8 @@ import 'package:yubico_authenticator/app/views/device_button.dart';
|
||||
import 'package:yubico_authenticator/core/state.dart';
|
||||
import 'package:yubico_authenticator/desktop/init.dart' as desktop;
|
||||
|
||||
import 'android/util.dart';
|
||||
|
||||
Future<Widget> getAuthenticatorApp() async => isDesktop
|
||||
? await desktop.initialize([])
|
||||
: isAndroid
|
||||
@ -14,12 +16,18 @@ Future<Widget> getAuthenticatorApp() async => isDesktop
|
||||
extension TestHelper on WidgetTester {
|
||||
/// Taps the device button
|
||||
Future<void> tapDeviceButton() async {
|
||||
await tap(find.byType(DeviceButton));
|
||||
await tap(find.byType(DeviceButton).hitTestable());
|
||||
await pump(const Duration(milliseconds: 500));
|
||||
}
|
||||
|
||||
Future<void> startUp() async {
|
||||
await pumpWidget(
|
||||
|
||||
Future<void> startUp([Map<dynamic, dynamic>? startUpParams]) async {
|
||||
if (isAndroid) {
|
||||
return AndroidTestUtils.startUp(this, startUpParams);
|
||||
} else {
|
||||
// desktop
|
||||
return await pumpWidget(
|
||||
await getAuthenticatorApp(), const Duration(milliseconds: 2000));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -52,6 +52,7 @@ class QRScannerUI extends StatelessWidget {
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
key: const Key('android.qr_scanner.btn.enter_manually'),
|
||||
child: const Text('Enter manually',
|
||||
style: TextStyle(color: Colors.white))),
|
||||
],
|
||||
|
@ -2,8 +2,8 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../../core/state.dart';
|
||||
import '../../app/state.dart';
|
||||
import '../../core/state.dart';
|
||||
import '../../widgets/list_title.dart';
|
||||
import '../../widgets/responsive_dialog.dart';
|
||||
|
||||
@ -32,6 +32,17 @@ enum _TapAction {
|
||||
}
|
||||
}
|
||||
|
||||
Key get key {
|
||||
switch (this) {
|
||||
case _TapAction.launch:
|
||||
return const Key('android.settings.on_nfc_tap.launch');
|
||||
case _TapAction.copy:
|
||||
return const Key('android.settings.on_nfc_tap.copy');
|
||||
case _TapAction.both:
|
||||
return const Key('android.settings.on_nfc_tap.both');
|
||||
}
|
||||
}
|
||||
|
||||
static _TapAction load(SharedPreferences prefs) {
|
||||
final launchApp = prefs.getBool(_prefNfcOpenApp) ?? true;
|
||||
final copyOtp = prefs.getBool(_prefNfcCopyOtp) ?? false;
|
||||
@ -103,6 +114,7 @@ class _AndroidSettingsPageState extends ConsumerState<AndroidSettingsPage> {
|
||||
ListTile(
|
||||
title: const Text('On YubiKey NFC tap'),
|
||||
subtitle: Text(tapAction.description),
|
||||
key: const Key('android.settings.option.on_nfc_tap'),
|
||||
onTap: () async {
|
||||
final newTapAction = await _selectTapAction(context, tapAction);
|
||||
newTapAction.save(prefs);
|
||||
@ -112,6 +124,7 @@ class _AndroidSettingsPageState extends ConsumerState<AndroidSettingsPage> {
|
||||
ListTile(
|
||||
title: const Text('Keyboard Layout (for static password)'),
|
||||
subtitle: Text(clipKbdLayout),
|
||||
key: const Key('android.settings.option.keyboard_layout'),
|
||||
enabled: tapAction != _TapAction.launch,
|
||||
onTap: () async {
|
||||
var newValue = await _selectKbdLayout(context, clipKbdLayout);
|
||||
@ -129,6 +142,7 @@ class _AndroidSettingsPageState extends ConsumerState<AndroidSettingsPage> {
|
||||
: const Text(
|
||||
'Accounts that require touch need an additional tap over NFC.'),
|
||||
value: nfcBypassTouch,
|
||||
key: const Key('android.settings.bypass_touch'),
|
||||
onChanged: (value) {
|
||||
prefs.setBool(_prefNfcBypassTouch, value);
|
||||
setState(() {});
|
||||
@ -159,8 +173,10 @@ class _AndroidSettingsPageState extends ConsumerState<AndroidSettingsPage> {
|
||||
.map(
|
||||
(e) => RadioListTile<_TapAction>(
|
||||
title: Text(e.description),
|
||||
key: e.key,
|
||||
value: e,
|
||||
groupValue: tapAction,
|
||||
toggleable: true,
|
||||
onChanged: (mode) {
|
||||
Navigator.pop(context, e);
|
||||
}),
|
||||
@ -182,6 +198,8 @@ class _AndroidSettingsPageState extends ConsumerState<AndroidSettingsPage> {
|
||||
(e) => RadioListTile<String>(
|
||||
title: Text(e),
|
||||
value: e,
|
||||
key: Key('android.settings.keyboard_layout.$e'),
|
||||
toggleable: true,
|
||||
groupValue: currentKbdLayout,
|
||||
onChanged: (mode) {
|
||||
Navigator.pop(context, e);
|
||||
@ -204,6 +222,7 @@ class _AndroidSettingsPageState extends ConsumerState<AndroidSettingsPage> {
|
||||
title: Text(e.displayName),
|
||||
value: e,
|
||||
groupValue: themeMode,
|
||||
toggleable: true,
|
||||
onChanged: (mode) {
|
||||
Navigator.pop(context, e);
|
||||
},
|
||||
|
@ -12,11 +12,12 @@ class BetaDialog {
|
||||
|
||||
void request() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
await ref.read(prefProvider).reload();
|
||||
var sharedPrefs = ref.read(prefProvider);
|
||||
await sharedPrefs.reload();
|
||||
var dialogShouldBeShown =
|
||||
ref.read(prefProvider).getBool(prefBetaDialogShouldBeShown) ?? true;
|
||||
sharedPrefs.getBool(prefBetaDialogShouldBeShown) ?? true;
|
||||
if (dialogShouldBeShown) {
|
||||
Future.delayed(Duration.zero, () async {
|
||||
Future.delayed(const Duration(milliseconds: 100), () async {
|
||||
await showBetaDialog();
|
||||
});
|
||||
}
|
||||
@ -31,6 +32,7 @@ class BetaDialog {
|
||||
return WillPopScope(
|
||||
onWillPop: () async => false,
|
||||
child: AlertDialog(
|
||||
key: const Key('android.beta.dialog'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
@ -75,6 +77,7 @@ class BetaDialog {
|
||||
// },
|
||||
// ),
|
||||
TextButton(
|
||||
key: const Key('android.beta.dialog.btn.got_it'),
|
||||
style: TextButton.styleFrom(
|
||||
textStyle: Theme.of(context)
|
||||
.textTheme
|
||||
|
@ -25,6 +25,7 @@ class DeleteAccountDialog extends ConsumerWidget {
|
||||
title: Text(AppLocalizations.of(context)!.oath_delete_account),
|
||||
actions: [
|
||||
TextButton(
|
||||
key: const Key('oath.dlg.delete_account.btn.delete'),
|
||||
onPressed: () async {
|
||||
try {
|
||||
await ref
|
||||
|
@ -116,6 +116,7 @@ class _RenameAccountDialogState extends ConsumerState<RenameAccountDialog> {
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: didChange && isValid ? _submit : null,
|
||||
key: const Key('oath.dlg.rename_account.btn.save'),
|
||||
child: Text(AppLocalizations.of(context)!.oath_save),
|
||||
),
|
||||
],
|
||||
@ -133,6 +134,7 @@ class _RenameAccountDialogState extends ConsumerState<RenameAccountDialog> {
|
||||
maxLength: issuerRemaining > 0 ? issuerRemaining : null,
|
||||
buildCounter: buildByteCounterFor(_issuer),
|
||||
inputFormatters: [limitBytesLength(issuerRemaining)],
|
||||
key: const Key('oath.dlg.rename_account.edit.issuer'),
|
||||
decoration: InputDecoration(
|
||||
border: const OutlineInputBorder(),
|
||||
labelText: AppLocalizations.of(context)!.oath_issuer_optional,
|
||||
@ -151,6 +153,7 @@ class _RenameAccountDialogState extends ConsumerState<RenameAccountDialog> {
|
||||
maxLength: nameRemaining,
|
||||
inputFormatters: [limitBytesLength(nameRemaining)],
|
||||
buildCounter: buildByteCounterFor(_account),
|
||||
key: const Key('oath.dlg.rename_account.edit.name'),
|
||||
decoration: InputDecoration(
|
||||
border: const OutlineInputBorder(),
|
||||
labelText: AppLocalizations.of(context)!.oath_account_name,
|
||||
@ -162,6 +165,17 @@ class _RenameAccountDialogState extends ConsumerState<RenameAccountDialog> {
|
||||
: null,
|
||||
prefixIcon: const Icon(Icons.people_alt_outlined),
|
||||
),
|
||||
textInputAction: TextInputAction.done,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_account = value.trim();
|
||||
});
|
||||
},
|
||||
onFieldSubmitted: (_) {
|
||||
if (didChange && isValid) {
|
||||
_submit();
|
||||
}
|
||||
},
|
||||
),
|
||||
]
|
||||
.map((e) => Padding(
|
||||
|
177
test/android_settings_page_test.dart
Normal file
177
test/android_settings_page_test.dart
Normal file
@ -0,0 +1,177 @@
|
||||
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/views/android_settings_page.dart';
|
||||
import 'package:yubico_authenticator/core/state.dart';
|
||||
|
||||
import '../integration_test/android/constants.dart';
|
||||
|
||||
Widget createMaterialApp({required Widget child}) {
|
||||
return MaterialApp(
|
||||
localizationsDelegates: const [
|
||||
AppLocalizations.delegate,
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
],
|
||||
supportedLocales: const [
|
||||
Locale('en', ''),
|
||||
],
|
||||
home: child,
|
||||
);
|
||||
}
|
||||
|
||||
extension _WidgetTesterHelper on WidgetTester {
|
||||
Future<void> openNfcTapOptionSelection() async {
|
||||
var widget = find.byKey(settingsOnNfcTapOptionKey).hitTestable();
|
||||
expect(widget, findsOneWidget);
|
||||
await tap(widget);
|
||||
await pumpAndSettle();
|
||||
}
|
||||
|
||||
Future<void> selectLaunchOption() async {
|
||||
await openNfcTapOptionSelection();
|
||||
await tap(find.byKey(settingsOnNfcTapLaunch));
|
||||
await pumpAndSettle();
|
||||
}
|
||||
|
||||
Future<void> selectCopyOption() async {
|
||||
await openNfcTapOptionSelection();
|
||||
await tap(find.byKey(settingsOnNfcTapCopy));
|
||||
await pumpAndSettle();
|
||||
}
|
||||
|
||||
Future<void> selectBothOption() async {
|
||||
await openNfcTapOptionSelection();
|
||||
await tap(find.byKey(settingsOnNfcTapBoth));
|
||||
await pumpAndSettle();
|
||||
}
|
||||
|
||||
ListTile keyboardLayoutListTile() =>
|
||||
find.byKey(settingsKeyboardLayoutOptionKey).evaluate().single.widget
|
||||
as ListTile;
|
||||
|
||||
Future<void> openKeyboardLayoutOptionSelection() async {
|
||||
var widget = find.byKey(settingsKeyboardLayoutOptionKey).hitTestable();
|
||||
expect(widget, findsOneWidget);
|
||||
await tap(widget);
|
||||
await pumpAndSettle();
|
||||
}
|
||||
|
||||
Future<void> selectKeyboardLayoutUSOption() async {
|
||||
await openKeyboardLayoutOptionSelection();
|
||||
await tap(find.byKey(settingsKeyboardLayoutUS));
|
||||
await pumpAndSettle();
|
||||
}
|
||||
|
||||
Future<void> selectKeyboardLayoutDEOption() async {
|
||||
await openKeyboardLayoutOptionSelection();
|
||||
await tap(find.byKey(settingsKeyboardLayoutDE));
|
||||
await pumpAndSettle();
|
||||
}
|
||||
|
||||
Future<void> selectKeyboardLayoutDECHOption() async {
|
||||
await openKeyboardLayoutOptionSelection();
|
||||
await tap(find.byKey(settingsKeyboardLayoutDECH));
|
||||
await pumpAndSettle();
|
||||
}
|
||||
|
||||
Future<void> tapBypassTouch() async {
|
||||
await tap(find.byKey(settingsBypassTouchKey));
|
||||
await pumpAndSettle();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
void main() {
|
||||
|
||||
var widget = createMaterialApp(child: const AndroidSettingsPage());
|
||||
|
||||
testWidgets('NFC Tap options', (WidgetTester tester) async {
|
||||
SharedPreferences.setMockInitialValues(
|
||||
{prefNfcOpenApp: true, prefNfcCopyOtp: false});
|
||||
|
||||
SharedPreferences sharedPrefs = await SharedPreferences.getInstance();
|
||||
|
||||
await tester.pumpWidget(ProviderScope(
|
||||
overrides: [prefProvider.overrideWithValue(sharedPrefs)],
|
||||
child: widget));
|
||||
|
||||
// launch - preserves original value
|
||||
await tester.selectLaunchOption();
|
||||
expect(sharedPrefs.getBool(prefNfcOpenApp), equals(true));
|
||||
expect(sharedPrefs.getBool(prefNfcCopyOtp), equals(false));
|
||||
|
||||
// copy
|
||||
await tester.selectCopyOption();
|
||||
expect(sharedPrefs.getBool(prefNfcOpenApp), equals(false));
|
||||
expect(sharedPrefs.getBool(prefNfcCopyOtp), equals(true));
|
||||
|
||||
// both
|
||||
await tester.selectBothOption();
|
||||
expect(sharedPrefs.getBool(prefNfcOpenApp), equals(true));
|
||||
expect(sharedPrefs.getBool(prefNfcCopyOtp), equals(true));
|
||||
|
||||
// launch - changes to value
|
||||
await tester.selectLaunchOption();
|
||||
expect(sharedPrefs.getBool(prefNfcOpenApp), equals(true));
|
||||
expect(sharedPrefs.getBool(prefNfcCopyOtp), equals(false));
|
||||
});
|
||||
|
||||
testWidgets('Static password keyboard layout', (WidgetTester tester) async {
|
||||
SharedPreferences.setMockInitialValues(
|
||||
{prefNfcOpenApp: true, prefNfcCopyOtp: false, prefClipKbdLayout: 'US'});
|
||||
|
||||
SharedPreferences sharedPrefs = await SharedPreferences.getInstance();
|
||||
|
||||
await tester.pumpWidget(ProviderScope(
|
||||
overrides: [prefProvider.overrideWithValue(sharedPrefs)],
|
||||
child: widget));
|
||||
|
||||
// option is disabled for "open"
|
||||
expect(tester.keyboardLayoutListTile().enabled, equals(false));
|
||||
|
||||
// option is enabled for "copy" and "launch"
|
||||
await tester.selectCopyOption();
|
||||
expect(tester.keyboardLayoutListTile().enabled, equals(true));
|
||||
|
||||
await tester.selectBothOption();
|
||||
expect(tester.keyboardLayoutListTile().enabled, equals(true));
|
||||
|
||||
// US - preserves the original value value
|
||||
await tester.selectKeyboardLayoutUSOption();
|
||||
expect(sharedPrefs.getString(prefClipKbdLayout), equals('US'));
|
||||
|
||||
// DE
|
||||
await tester.selectKeyboardLayoutDEOption();
|
||||
expect(sharedPrefs.getString(prefClipKbdLayout), equals('DE'));
|
||||
|
||||
// DE-CH
|
||||
await tester.selectKeyboardLayoutDECHOption();
|
||||
expect(sharedPrefs.getString(prefClipKbdLayout), equals('DE-CH'));
|
||||
|
||||
// US
|
||||
await tester.selectKeyboardLayoutUSOption();
|
||||
expect(sharedPrefs.getString(prefClipKbdLayout), equals('US'));
|
||||
});
|
||||
|
||||
testWidgets('Bypass touch req', (WidgetTester tester) async {
|
||||
SharedPreferences.setMockInitialValues({prefNfcBypassTouch: false});
|
||||
SharedPreferences sharedPrefs = await SharedPreferences.getInstance();
|
||||
|
||||
await tester.pumpWidget(ProviderScope(
|
||||
overrides: [prefProvider.overrideWithValue(sharedPrefs)],
|
||||
child: widget));
|
||||
|
||||
// change to true
|
||||
await tester.tapBypassTouch();
|
||||
expect(sharedPrefs.getBool(prefNfcBypassTouch), equals(true));
|
||||
|
||||
// change to false
|
||||
await tester.tapBypassTouch();
|
||||
expect(sharedPrefs.getBool(prefNfcBypassTouch), equals(false));
|
||||
});
|
||||
}
|
Loading…
Reference in New Issue
Block a user