Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion analysis_options.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ analyzer:
exclude:
- lib/generated_plugin_registrant.dart
- lib/l10n/*.dart
- '**.g.dart'
- "**.g.dart"
- integration_test/test_bundle.dart

dart_code_metrics:
metrics:
Expand Down
3 changes: 3 additions & 0 deletions assets/images/ic_recovery_key.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions assets/l10n/intl_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -1788,6 +1788,10 @@
},
"recoveryKey": "Recovery key",
"@recoveryKey": {},
"recoveryKeyWarningMessage": "This secret grants access to all your encrypted messages to whomever uses it. Keep it safe and do not share it.",
"@recoveryKeyWarningMessage": {},
"recoveryKeyCopiedToClipboard": "Recovery key copied to clipboard",
"@recoveryKeyCopiedToClipboard": {},
"recoveryKeyLost": "Recovery key lost?",
"@recoveryKeyLost": {},
"seenByUser": "Seen by {username}",
Expand Down
27 changes: 22 additions & 5 deletions integration_test/robots/login_robot.dart
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import 'dart:io';
import 'package:fluffychat/generated/l10n/app_localizations.dart';
import 'package:fluffychat/pages/chat_list/chat_list.dart';
import 'package:fluffychat/pages/homeserver_picker/homeserver_picker_view.dart';
import 'package:fluffychat/pages/twake_welcome/twake_welcome.dart';
import 'package:fluffychat/utils/platform_infos.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:patrol/patrol.dart';
import '../base/core_robot.dart';

Expand Down Expand Up @@ -53,15 +56,18 @@ class LoginRobot extends CoreRobot {
}

Future<void> tapOnUseYourCompanyServer() async {
await $('Use your company server').tap();
final context = $.tester.element(find.byType(Scaffold).first);
final l10n = L10n.of(context)!;
await $(l10n.useYourCompanyServer).tap();
}

Future<void> enterServerUrl(String serverUrl) async {
await $.enterText($(HomeserverTextField), serverUrl);
}

Future<void> clickOnContinueBtn() async {
const label = 'Continue';
final context = $.tester.element(find.byType(Scaffold).first);
final label = L10n.of(context)!.continueProcess;
await $.waitUntilVisible($(label));
await $.tap($(label));
await waitUntilAbsent(
Expand Down Expand Up @@ -182,8 +188,15 @@ class LoginRobot extends CoreRobot {
// set a delay for verifying Captcha
await Future.delayed(const Duration(seconds: 2));

// tap on Sign in
await $.native.tap(getSignInBtn(), appId: getBrowserAppId());
// tap on Sign in – the browser modal may close immediately after a
// successful login, causing Patrol to report an error even though the
// tap succeeded. We catch that error and let the flow continue.
try {
await $.native.tap(getSignInBtn(), appId: getBrowserAppId());
} catch (_) {
// Browser closed after successful SSO login – expected.
return;
}

// if "verify ...please wait for Captcha" dialog is shown, click OK to continue waiting
// and click Sign in again
Expand All @@ -197,7 +210,11 @@ class LoginRobot extends CoreRobot {
getOKBtnInVerifyCaptchaDialog(),
appId: getBrowserAppId(),
);
await $.native.tap(getSignInBtn(), appId: getBrowserAppId());
try {
await $.native.tap(getSignInBtn(), appId: getBrowserAppId());
} catch (_) {
// Browser closed after successful SSO login – expected.
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import 'dart:io';

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:patrol/patrol.dart';

import 'package:fluffychat/generated/l10n/app_localizations.dart';

import '../home_robot.dart';

class SettingsRecoveryKeyRobot extends HomeRobot {
SettingsRecoveryKeyRobot(super.$);

PatrolFinder recoveryKeyItem() {
return $(const Key('recovery_key_settings_item'));
}

PatrolFinder recoveryKeyCopyButton() {
return $(const Key('recovery_key_copy_button'));
}

Future<void> waitForRecoveryKeyVisible() async {
await $.waitUntilVisible(recoveryKeyItem());
}

/// Taps the copy button, then confirms the warning dialog by tapping "Copy".
Future<void> tapCopyAndConfirm() async {
await recoveryKeyCopyButton().tap();
await _tapConfirmCopyInDialog();
}

/// Taps the recovery key row, then confirms the warning dialog.
Future<void> tapRowAndConfirm() async {
await recoveryKeyItem().tap();
await _tapConfirmCopyInDialog();
}

Future<void> _tapConfirmCopyInDialog() async {
final context = $.tester.element(find.byType(Scaffold).first);
final l10n = L10n.of(context)!;

if (Platform.isAndroid) {
final copyButton = $(
AlertDialog,
).$(TextButton).containing(find.text(l10n.copy.toUpperCase()));
await $.waitUntilVisible(copyButton);
await copyButton.tap();
} else {
final copyButton = $(
CupertinoAlertDialog,
).$(CupertinoDialogAction).containing(find.text(l10n.copy));
await $.waitUntilVisible(copyButton);
await copyButton.tap();
}
}

/// Reads the current text content from the system clipboard.
Future<String?> getClipboardText() async {
final data = await Clipboard.getData(Clipboard.kTextPlain);
return data?.text;
}

/// Verifies the snackbar "Recovery key copied to clipboard" is shown.
Future<void> verifySnackBarIsShown() async {
final context = $.tester.element(find.byType(Scaffold).first);
final l10n = L10n.of(context)!;
await $.waitUntilVisible($(l10n.recoveryKeyCopiedToClipboard));
}
}
54 changes: 54 additions & 0 deletions integration_test/tests/setting/settings_recovery_key_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';

import '../../base/test_base.dart';
import '../../robots/home_robot.dart';
import '../../robots/setting/setting_robot.dart';
import '../../robots/setting/settings_recovery_key_robot.dart';

void main() {
TestBase().twakePatrolTest(
description:
'Copy recovery key and verify clipboard contains the actual key',
test: ($) async {
// Clear clipboard before test
await Clipboard.setData(const ClipboardData(text: ''));

// Navigate to Settings > Privacy and Security
await HomeRobot($).gotoSettingScreen();
await SettingRobot($).openPrivacyAndSecuritySetting();

final recoveryKeyRobot = SettingsRecoveryKeyRobot($);

// Verify recovery key item is visible
await recoveryKeyRobot.waitForRecoveryKeyVisible();

// Tap the copy button and confirm the warning dialog
await recoveryKeyRobot.tapCopyAndConfirm();

// Verify the snackbar confirmation is shown
await recoveryKeyRobot.verifySnackBarIsShown();

// Read clipboard content and verify it contains the actual key
final clipboardText = await recoveryKeyRobot.getClipboardText();

expect(
clipboardText,
isNotNull,
reason: 'Clipboard should contain the recovery key after copy',
);
expect(
clipboardText,
isNotEmpty,
reason: 'Recovery key in clipboard should not be empty',
);
// Ensure the clipboard contains the real key, not the masked bullets
expect(
clipboardText,
isNot(equals('\u2022' * 32)),
reason:
'Clipboard should contain the actual recovery key, not the masked value',
);
},
);
}
53 changes: 52 additions & 1 deletion lib/config/go_routes/app_route_paths.dart
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thank you for that, please separate the refactoring for route path in other PR, then the PR for recovery. It is better to review and make it merge

Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,60 @@
/// This file contains all route paths used in go_router configuration
/// and navigation calls to ensure consistency and prevent typos.
abstract class AppRoutePaths {
// Settings prefix check
static const String roomsSettings = '/rooms/settings';

// Profile routes
static const String profileSegment = 'profile';
static const String profileFull = '/rooms/$profileSegment';
static const String profileQrSegment = 'qr';
static const String profileQrFull = '$profileFull/$profileQrSegment';

// Chat settings routes
static const String chatSegment = 'chat';
static const String chatFull = '/rooms/$chatSegment';
static const String emotesSegment = 'emotes';
static const String chatEmotesFull = '$chatFull/$emotesSegment';

// Security routes
static const String roomsSecurityFull = '/rooms/security';
static const String securitySegment = 'security';
static const String roomsSecurityFull = '/rooms/$securitySegment';
static const String contactsVisibilitySegment = 'contactsVisibility';
static const String contactsVisibilityFull =
'$roomsSecurityFull/$contactsVisibilitySegment';
static const String storiesSegment = 'stories';
static const String securityStoriesFull =
'$roomsSecurityFull/$storiesSegment';
static const String blockedUsersSegment = 'blockedUsers';
static const String securityBlockedUsersFull =
'$roomsSecurityFull/$blockedUsersSegment';
static const String threePidSegment = '3pid';
static const String securityThreePidFull =
'$roomsSecurityFull/$threePidSegment';

// Notifications routes
static const String notificationsSegment = 'notifications';
static const String notificationsFull = '/rooms/$notificationsSegment';

// Style routes
static const String styleSegment = 'style';
static const String styleFull = '/rooms/$styleSegment';

// App language routes
static const String appLanguageSegment = 'appLanguage';
static const String appLanguageFull = '/rooms/$appLanguageSegment';

// Devices routes
static const String devicesSegment = 'devices';
static const String devicesFull = '/rooms/$devicesSegment';

// Add account routes
static const String addAccountSegment = 'addaccount';
static const String addAccountFull = '/rooms/$addAccountSegment';
static const String addAccountLoginSegment = 'login';
static const String addAccountLoginFull =
'$addAccountFull/$addAccountLoginSegment';
static const String addAccountHomeserverPickerSegment = 'homeserverpicker';
static const String addAccountHomeserverPickerFull =
'$addAccountFull/$addAccountHomeserverPickerSegment';
}
32 changes: 16 additions & 16 deletions lib/config/go_routes/go_router.dart
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ abstract class AppRoutes {
pageBuilder: (context, state, child) => defaultPageBuilder(
context,
!_responsive.isMobile(context) &&
state.fullPath?.startsWith('/rooms/settings') == false
state.fullPath?.startsWith(AppRoutePaths.roomsSettings) == false
? AppAdaptiveScaffold(
body: AppAdaptiveScaffoldBody(
activeRoomId: state.pathParameters['roomid'],
Expand Down Expand Up @@ -296,58 +296,58 @@ abstract class AppRoutes {
},
),
GoRoute(
path: 'profile',
path: AppRoutePaths.profileSegment,
pageBuilder: (context, state) =>
defaultPageBuilder(context, const SettingsProfile()),
routes: [
if (PlatformInfos.isMobile)
GoRoute(
path: 'qr',
path: AppRoutePaths.profileQrSegment,
pageBuilder: (context, state) =>
defaultPageBuilder(context, const PersonalQr()),
redirect: loggedOutRedirect,
),
],
),
GoRoute(
path: 'notifications',
path: AppRoutePaths.notificationsSegment,
pageBuilder: (context, state) =>
defaultPageBuilder(context, const SettingsNotifications()),
redirect: loggedOutRedirect,
),
GoRoute(
path: 'style',
path: AppRoutePaths.styleSegment,
pageBuilder: (context, state) =>
defaultPageBuilder(context, const SettingsStyle()),
redirect: loggedOutRedirect,
),
GoRoute(
path: 'devices',
path: AppRoutePaths.devicesSegment,
pageBuilder: (context, state) =>
defaultPageBuilder(context, const DevicesSettings()),
redirect: loggedOutRedirect,
),
GoRoute(
path: 'appLanguage',
path: AppRoutePaths.appLanguageSegment,
pageBuilder: (context, state) =>
defaultPageBuilder(context, const SettingsAppLanguage()),
redirect: loggedOutRedirect,
),
GoRoute(
path: 'chat',
path: AppRoutePaths.chatSegment,
pageBuilder: (context, state) =>
defaultPageBuilder(context, const SettingsChat()),
routes: [
GoRoute(
path: 'emotes',
path: AppRoutePaths.emotesSegment,
pageBuilder: (context, state) =>
defaultPageBuilder(context, const EmotesSettings()),
),
],
redirect: loggedOutRedirect,
),
GoRoute(
path: 'addaccount',
path: AppRoutePaths.addAccountSegment,
redirect: loggedOutRedirect,
pageBuilder: (context, state) => defaultPageBuilder(
context,
Expand All @@ -359,38 +359,38 @@ abstract class AppRoutes {
),
routes: [
GoRoute(
path: 'login',
path: AppRoutePaths.addAccountLoginSegment,
pageBuilder: (context, state) =>
defaultPageBuilder(context, const Login()),
redirect: loggedOutRedirect,
),
GoRoute(
path: 'homeserverpicker',
path: AppRoutePaths.addAccountHomeserverPickerSegment,
pageBuilder: (context, state) =>
defaultPageBuilder(context, const HomeserverPicker()),
),
],
),
GoRoute(
path: 'security',
path: AppRoutePaths.securitySegment,
redirect: loggedOutRedirect,
pageBuilder: (context, state) =>
defaultPageBuilder(context, const SettingsSecurity()),
routes: [
GoRoute(
path: 'stories',
path: AppRoutePaths.storiesSegment,
pageBuilder: (context, state) =>
defaultPageBuilder(context, const SettingsStories()),
redirect: loggedOutRedirect,
),
GoRoute(
path: 'blockedUsers',
path: AppRoutePaths.blockedUsersSegment,
pageBuilder: (context, state) =>
defaultPageBuilder(context, const BlockedUsers()),
redirect: loggedOutRedirect,
),
GoRoute(
path: '3pid',
path: AppRoutePaths.threePidSegment,
pageBuilder: (context, state) =>
defaultPageBuilder(context, const Settings3Pid()),
redirect: loggedOutRedirect,
Expand Down
Loading
Loading