diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index 70417e356e..ea2e10cff3 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -447,6 +447,10 @@ "@dialogClose": { "description": "Button label in dialogs to close." }, + "errorDialogLearnMore": "Learn more", + "@errorDialogLearnMore": { + "description": "Button label in error dialogs to open a web page with more information." + }, "errorDialogContinue": "OK", "@errorDialogContinue": { "description": "Button label in error dialogs to acknowledge the error and close the dialog." @@ -534,6 +538,15 @@ "@topicValidationErrorMandatoryButEmpty": { "description": "Topic validation error when topic is required but was empty." }, + "errorServerVersionUnsupportedMessage": "{url} is running Zulip Server {zulipVersion}, which is unsupported. The minimum supported version is Zulip Server {minSupportedZulipVersion}.", + "@errorServerVersionUnsupportedMessage": { + "description": "Error message in the dialog for when the Zulip Server version is unsupported.", + "placeholders": { + "url": {"type": "String", "example": "http://chat.example.com/"}, + "zulipVersion": {"type": "String", "example": "3.2"}, + "minSupportedZulipVersion": {"type": "String", "example": "4.0"} + } + }, "errorInvalidApiKeyMessage": "Your account at {url} could not be authenticated. Please try logging in again or use another account.", "@errorInvalidApiKeyMessage": { "description": "Error message in the dialog for invalid API key.", diff --git a/lib/api/core.dart b/lib/api/core.dart index 96f9e2db11..fb8d564ae6 100644 --- a/lib/api/core.dart +++ b/lib/api/core.dart @@ -10,6 +10,24 @@ import '../model/binding.dart'; import '../model/localizations.dart'; import 'exception.dart'; +/// The Zulip Server version below which we should refuse to connect. +/// +/// When updating this, also update [kMinSupportedZulipFeatureLevel] +/// and the README. +const kMinSupportedZulipVersion = '4.0'; + +/// The Zulip feature level reserved for the [kMinSupportedZulipVersion] release. +/// +/// For this value, see the API changelog: +/// https://zulip.com/api/changelog +const kMinSupportedZulipFeatureLevel = 65; + +/// The doc stating our oldest supported server version. +// TODO: Instead, link to new Help Center doc once we have it: +// https://github.com/zulip/zulip/issues/23842 +final kServerSupportDocUrl = Uri.parse( + 'https://zulip.readthedocs.io/en/latest/overview/release-lifecycle.html#client-apps'); + /// A fused JSON + UTF-8 decoder. /// /// This object is an instance of [`_JsonUtf8Decoder`][1] which is diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index 3203569966..d09393e774 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -693,6 +693,12 @@ abstract class ZulipLocalizations { /// **'Close'** String get dialogClose; + /// Button label in error dialogs to open a web page with more information. + /// + /// In en, this message translates to: + /// **'Learn more'** + String get errorDialogLearnMore; + /// Button label in error dialogs to acknowledge the error and close the dialog. /// /// In en, this message translates to: @@ -819,6 +825,12 @@ abstract class ZulipLocalizations { /// **'Topics are required in this organization.'** String get topicValidationErrorMandatoryButEmpty; + /// Error message in the dialog for when the Zulip Server version is unsupported. + /// + /// In en, this message translates to: + /// **'{url} is running Zulip Server {zulipVersion}, which is unsupported. The minimum supported version is Zulip Server {minSupportedZulipVersion}.'** + String errorServerVersionUnsupportedMessage(String url, String zulipVersion, String minSupportedZulipVersion); + /// Error message in the dialog for invalid API key. /// /// In en, this message translates to: diff --git a/lib/generated/l10n/zulip_localizations_ar.dart b/lib/generated/l10n/zulip_localizations_ar.dart index 20ad3cbe24..c2478f4613 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -348,6 +348,9 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get dialogClose => 'Close'; + @override + String get errorDialogLearnMore => 'Learn more'; + @override String get errorDialogContinue => 'OK'; @@ -413,6 +416,11 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get topicValidationErrorMandatoryButEmpty => 'Topics are required in this organization.'; + @override + String errorServerVersionUnsupportedMessage(String url, String zulipVersion, String minSupportedZulipVersion) { + return '$url is running Zulip Server $zulipVersion, which is unsupported. The minimum supported version is Zulip Server $minSupportedZulipVersion.'; + } + @override String errorInvalidApiKeyMessage(String url) { return 'Your account at $url could not be authenticated. Please try logging in again or use another account.'; diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index a88981fc26..289ba33af2 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -348,6 +348,9 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get dialogClose => 'Close'; + @override + String get errorDialogLearnMore => 'Learn more'; + @override String get errorDialogContinue => 'OK'; @@ -413,6 +416,11 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get topicValidationErrorMandatoryButEmpty => 'Topics are required in this organization.'; + @override + String errorServerVersionUnsupportedMessage(String url, String zulipVersion, String minSupportedZulipVersion) { + return '$url is running Zulip Server $zulipVersion, which is unsupported. The minimum supported version is Zulip Server $minSupportedZulipVersion.'; + } + @override String errorInvalidApiKeyMessage(String url) { return 'Your account at $url could not be authenticated. Please try logging in again or use another account.'; diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart index be6eee8870..00537f73a2 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -348,6 +348,9 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get dialogClose => 'Close'; + @override + String get errorDialogLearnMore => 'Learn more'; + @override String get errorDialogContinue => 'OK'; @@ -413,6 +416,11 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get topicValidationErrorMandatoryButEmpty => 'Topics are required in this organization.'; + @override + String errorServerVersionUnsupportedMessage(String url, String zulipVersion, String minSupportedZulipVersion) { + return '$url is running Zulip Server $zulipVersion, which is unsupported. The minimum supported version is Zulip Server $minSupportedZulipVersion.'; + } + @override String errorInvalidApiKeyMessage(String url) { return 'Your account at $url could not be authenticated. Please try logging in again or use another account.'; diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart index 8e51c7a19b..3c063e91da 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -348,6 +348,9 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get dialogClose => 'Close'; + @override + String get errorDialogLearnMore => 'Learn more'; + @override String get errorDialogContinue => 'OK'; @@ -413,6 +416,11 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get topicValidationErrorMandatoryButEmpty => 'Topics are required in this organization.'; + @override + String errorServerVersionUnsupportedMessage(String url, String zulipVersion, String minSupportedZulipVersion) { + return '$url is running Zulip Server $zulipVersion, which is unsupported. The minimum supported version is Zulip Server $minSupportedZulipVersion.'; + } + @override String errorInvalidApiKeyMessage(String url) { return 'Your account at $url could not be authenticated. Please try logging in again or use another account.'; diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index 0e4009a462..64705fbe02 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -348,6 +348,9 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get dialogClose => 'Zamknij'; + @override + String get errorDialogLearnMore => 'Learn more'; + @override String get errorDialogContinue => 'OK'; @@ -413,6 +416,11 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get topicValidationErrorMandatoryButEmpty => 'Wątki są wymagane przez tę organizację.'; + @override + String errorServerVersionUnsupportedMessage(String url, String zulipVersion, String minSupportedZulipVersion) { + return '$url is running Zulip Server $zulipVersion, which is unsupported. The minimum supported version is Zulip Server $minSupportedZulipVersion.'; + } + @override String errorInvalidApiKeyMessage(String url) { return 'Konto w ramach $url nie zostało przyjęte. Spróbuj ponownie lub skorzystaj z innego konta.'; diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart index f3ec9d623c..911fc281b2 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -348,6 +348,9 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get dialogClose => 'Закрыть'; + @override + String get errorDialogLearnMore => 'Learn more'; + @override String get errorDialogContinue => 'OK'; @@ -413,6 +416,11 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get topicValidationErrorMandatoryButEmpty => 'Темы обязательны в этой организации.'; + @override + String errorServerVersionUnsupportedMessage(String url, String zulipVersion, String minSupportedZulipVersion) { + return '$url is running Zulip Server $zulipVersion, which is unsupported. The minimum supported version is Zulip Server $minSupportedZulipVersion.'; + } + @override String errorInvalidApiKeyMessage(String url) { return 'Your account at $url could not be authenticated. Please try logging in again or use another account.'; diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart index 6d22409eb5..0cb42c3a37 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -348,6 +348,9 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get dialogClose => 'Zavrieť'; + @override + String get errorDialogLearnMore => 'Learn more'; + @override String get errorDialogContinue => 'OK'; @@ -413,6 +416,11 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get topicValidationErrorMandatoryButEmpty => 'Topics are required in this organization.'; + @override + String errorServerVersionUnsupportedMessage(String url, String zulipVersion, String minSupportedZulipVersion) { + return '$url is running Zulip Server $zulipVersion, which is unsupported. The minimum supported version is Zulip Server $minSupportedZulipVersion.'; + } + @override String errorInvalidApiKeyMessage(String url) { return 'Your account at $url could not be authenticated. Please try logging in again or use another account.'; diff --git a/lib/log.dart b/lib/log.dart index 5aa5348931..c85d228263 100644 --- a/lib/log.dart +++ b/lib/log.dart @@ -62,7 +62,11 @@ void profilePrint(String message) { // `null` for the `message` parameter and promptly dismiss the reported errors. typedef ReportErrorCancellablyCallback = void Function(String? message, {String? details}); -typedef ReportErrorCallback = void Function(String title, {String? message}); +typedef ReportErrorCallback = void Function( + String title, { + String? message, + Uri? learnMoreButtonUrl, +}); /// Show the user an error message, without requiring them to interact with it. /// @@ -96,7 +100,11 @@ void defaultReportErrorToUserBriefly(String? message, {String? details}) { _reportErrorToConsole(message, details); } -void defaultReportErrorToUserModally(String title, {String? message}) { +void defaultReportErrorToUserModally( + String title, { + String? message, + Uri? learnMoreButtonUrl, +}) { _reportErrorToConsole(title, message); } diff --git a/lib/model/binding.dart b/lib/model/binding.dart index d8e860dca0..9c66346bec 100644 --- a/lib/model/binding.dart +++ b/lib/model/binding.dart @@ -32,7 +32,7 @@ typedef FirebaseRemoteMessage = firebase_messaging.RemoteMessage; /// /// Most code should not interact with the bindings directly. /// Instead, use the corresponding higher-level APIs that expose the bindings' -/// functionality in a widget-oriented way. +/// functionality in a widget-oriented way; see [PlatformActions] for some. /// /// This piece of architecture is modelled on the "binding" classes in Flutter /// itself. For discussion, see [BindingBase], [WidgetsFlutterBinding], and diff --git a/lib/model/store.dart b/lib/model/store.dart index c3aaa501db..8c5d5bc44a 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -199,14 +199,30 @@ abstract class GlobalStore extends ChangeNotifier { final PerAccountStore store; try { store = await doLoadPerAccount(accountId); + } on AccountNotFoundException { + rethrow; } catch (e) { + final account = getAccount(accountId); + assert(account != null); // doLoadPerAccount would have thrown AccountNotFoundException + final zulipLocalizations = GlobalLocalizations.zulipLocalizations; switch (e) { + case _ServerVersionUnsupportedException(): + reportErrorToUserModally( + zulipLocalizations.errorCouldNotConnectTitle, + message: zulipLocalizations.errorServerVersionUnsupportedMessage( + account!.realmUrl.toString(), + e.data.zulipVersion, + kMinSupportedZulipVersion), + learnMoreButtonUrl: kServerSupportDocUrl); + // The important thing is to tear down per-account UI, + // and logOutAccount conveniently handles that already. + // It's not ideal to force the user to reauthenticate when they retry, + // and we can revisit that later if needed. + await logOutAccount(this, accountId); + throw AccountNotFoundException(); case HttpException(httpStatus: 401): // The API key is invalid and the store can never be loaded // unless the user retries manually. - final account = getAccount(accountId); - assert(account != null); // doLoadPerAccount would have thrown AccountNotFoundException - final zulipLocalizations = GlobalLocalizations.zulipLocalizations; reportErrorToUserModally( zulipLocalizations.errorCouldNotConnectTitle, message: zulipLocalizations.errorInvalidApiKeyMessage( @@ -275,6 +291,17 @@ abstract class GlobalStore extends ChangeNotifier { return result; } + /// Update an account with [ZulipVersionData], returning the new version. + /// + /// The account must already exist in the store. + Future updateZulipVersionData(int accountId, ZulipVersionData data) async { + assert(_accounts.containsKey(accountId)); + return updateAccount(accountId, AccountsCompanion( + zulipVersion: Value(data.zulipVersion), + zulipMergeBase: Value(data.zulipMergeBase), + zulipFeatureLevel: Value(data.zulipFeatureLevel))); + } + /// Update an account in the underlying data store. Future doUpdateAccount(int accountId, AccountsCompanion data); @@ -1023,8 +1050,8 @@ class UpdateMachine { /// /// In the future this might load an old snapshot from local storage first. static Future load(GlobalStore globalStore, int accountId) async { - Account account = globalStore.getAccount(accountId)!; - final connection = globalStore.apiConnectionFromAccount(account); + final connection = globalStore.apiConnectionFromAccount( + globalStore.getAccount(accountId)!); void stopAndThrowIfNoAccount() { final account = globalStore.getAccount(accountId); @@ -1036,21 +1063,29 @@ class UpdateMachine { } final stopwatch = Stopwatch()..start(); - final initialSnapshot = await _registerQueueWithRetry(connection, - stopAndThrowIfNoAccount: stopAndThrowIfNoAccount); + InitialSnapshot? initialSnapshot; + try { + initialSnapshot = await _registerQueueWithRetry(connection, + stopAndThrowIfNoAccount: stopAndThrowIfNoAccount); + } on _ServerVersionUnsupportedException catch (e) { + // `!` is OK because _registerQueueWithRetry would have thrown a + // not-_ServerVersionUnsupportedException if no account + final account = globalStore.getAccount(accountId)!; + if (!e.data.matchesAccount(account)) { + await globalStore.updateZulipVersionData(accountId, e.data); + } + connection.close(); + rethrow; + } if (kProfileMode) { profilePrint("initial fetch time: ${stopwatch.elapsed.inMilliseconds}ms"); } - if (initialSnapshot.zulipVersion != account.zulipVersion - || initialSnapshot.zulipMergeBase != account.zulipMergeBase - || initialSnapshot.zulipFeatureLevel != account.zulipFeatureLevel) { - account = await globalStore.updateAccount(accountId, AccountsCompanion( - zulipVersion: Value(initialSnapshot.zulipVersion), - zulipMergeBase: Value(initialSnapshot.zulipMergeBase), - zulipFeatureLevel: Value(initialSnapshot.zulipFeatureLevel), - )); - connection.zulipFeatureLevel = initialSnapshot.zulipFeatureLevel; + final zulipVersionData = ZulipVersionData.fromInitialSnapshot(initialSnapshot); + // `!` is OK because _registerQueueWithRetry would have thrown if no account + if (!zulipVersionData.matchesAccount(globalStore.getAccount(accountId)!)) { + await globalStore.updateZulipVersionData(accountId, zulipVersionData); + connection.zulipFeatureLevel = zulipVersionData.zulipFeatureLevel; } final store = PerAccountStore.fromInitialSnapshot( @@ -1095,7 +1130,12 @@ class UpdateMachine { } catch (e, s) { stopAndThrowIfNoAccount(); // TODO(#890): tell user if initial-fetch errors persist, or look non-transient + final ZulipVersionData? zulipVersionData; switch (e) { + case MalformedServerResponseException() + when (zulipVersionData = ZulipVersionData.fromMalformedServerResponseException(e)) + ?.isUnsupported == true: + throw _ServerVersionUnsupportedException(zulipVersionData!); case HttpException(httpStatus: 401): // We cannot recover from this error through retrying. // Leave it to [GlobalStore.loadPerAccount]. @@ -1113,6 +1153,10 @@ class UpdateMachine { } if (result != null) { stopAndThrowIfNoAccount(); + final zulipVersionData = ZulipVersionData.fromInitialSnapshot(result); + if (zulipVersionData.isUnsupported) { + throw _ServerVersionUnsupportedException(zulipVersionData); + } return result; } } @@ -1520,6 +1564,58 @@ class UpdateMachine { String toString() => '${objectRuntimeType(this, 'UpdateMachine')}#${shortHash(this)}'; } +/// The fields 'zulip_version', 'zulip_merge_base', and 'zulip_feature_level' +/// from a /register response. +class ZulipVersionData { + const ZulipVersionData({ + required this.zulipVersion, + required this.zulipMergeBase, + required this.zulipFeatureLevel, + }); + + factory ZulipVersionData.fromInitialSnapshot(InitialSnapshot initialSnapshot) => + ZulipVersionData( + zulipVersion: initialSnapshot.zulipVersion, + zulipMergeBase: initialSnapshot.zulipMergeBase, + zulipFeatureLevel: initialSnapshot.zulipFeatureLevel); + + /// Make a [ZulipVersionData] from a [MalformedServerResponseException], + /// if the body was readable/valid JSON and contained the data, else null. + /// + /// If there's a zulip_version but no zulip_feature_level, + /// we infer it's indeed a Zulip server, + /// just an ancient one before feature levels were introduced in Zulip 3.0, + /// and we set 0 for zulipFeatureLevel. + static ZulipVersionData? fromMalformedServerResponseException(MalformedServerResponseException e) { + try { + final data = e.data!; + return ZulipVersionData( + zulipVersion: data['zulip_version'] as String, + zulipMergeBase: data['zulip_merge_base'] as String?, + zulipFeatureLevel: data['zulip_feature_level'] as int? ?? 0); + } catch (inner) { + return null; + } + } + + final String zulipVersion; + final String? zulipMergeBase; + final int zulipFeatureLevel; + + bool matchesAccount(Account account) => + zulipVersion == account.zulipVersion + && zulipMergeBase == account.zulipMergeBase + && zulipFeatureLevel == account.zulipFeatureLevel; + + bool get isUnsupported => zulipFeatureLevel < kMinSupportedZulipFeatureLevel; +} + +class _ServerVersionUnsupportedException implements Exception { + final ZulipVersionData data; + + _ServerVersionUnsupportedException(this.data); +} + class _EventHandlingException implements Exception { final Object cause; final Event event; diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart index c0c590fc7c..0594bd27d2 100644 --- a/lib/widgets/action_sheet.dart +++ b/lib/widgets/action_sheet.dart @@ -16,7 +16,6 @@ import '../model/emoji.dart'; import '../model/internal_link.dart'; import '../model/narrow.dart'; import 'actions.dart'; -import 'clipboard.dart'; import 'color.dart'; import 'compose_box.dart'; import 'dialog.dart'; @@ -917,7 +916,7 @@ class CopyMessageTextButton extends MessageActionSheetMenuItemButton { if (!pageContext.mounted) return; - copyWithPopup(context: pageContext, + PlatformActions.copyWithPopup(context: pageContext, successContent: Text(zulipLocalizations.successMessageTextCopied), data: ClipboardData(text: rawContent)); } @@ -943,7 +942,7 @@ class CopyMessageLinkButton extends MessageActionSheetMenuItemButton { nearMessageId: message.id, ); - copyWithPopup(context: pageContext, + PlatformActions.copyWithPopup(context: pageContext, successContent: Text(zulipLocalizations.successMessageLinkCopied), data: ClipboardData(text: messageLink.toString())); } diff --git a/lib/widgets/actions.dart b/lib/widgets/actions.dart index e39617ac33..96fb5e293a 100644 --- a/lib/widgets/actions.dart +++ b/lib/widgets/actions.dart @@ -1,12 +1,14 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import '../api/exception.dart'; import '../api/model/model.dart'; import '../api/model/narrow.dart'; import '../api/route/messages.dart'; import '../generated/l10n/zulip_localizations.dart'; +import '../model/binding.dart'; import '../model/narrow.dart'; import 'dialog.dart'; import 'store.dart'; @@ -239,3 +241,69 @@ abstract final class ZulipAction { } } } + +/// Methods that act through platform APIs and show feedback in the UI. +/// +/// The static methods on this class can be thought of as higher-level wrappers +/// for some of the platform binding methods in [ZulipBinding]. +/// But they don't belong there, because they also interact with widgets +/// in order to present success or error feedback to the user through the UI. +abstract final class PlatformActions { + /// Copies [data] to the clipboard and shows a popup on success. + /// + /// Must have a [Scaffold] ancestor. + /// + /// On newer Android the popup is defined and shown by the platform. On older + /// Android and on iOS, shows a [Snackbar] with [successContent]. + /// + /// In English, the text in [successContent] should be short, should start with + /// a capital letter, and should have no ending punctuation: "{noun} copied". + static void copyWithPopup({ + required BuildContext context, + required ClipboardData data, + required Widget successContent, + }) async { + await Clipboard.setData(data); + final deviceInfo = await ZulipBinding.instance.deviceInfo; + + if (!context.mounted) return; + + final shouldShowSnackbar = switch (deviceInfo) { + // Android 13+ shows its own popup on copying to the clipboard, + // so we suppress ours, following the advice at: + // https://developer.android.com/develop/ui/views/touch-and-input/copy-paste#duplicate-notifications + // TODO(android-sdk-33): Simplify this and dartdoc + AndroidDeviceInfo(:var sdkInt) => sdkInt <= 32, + _ => true, + }; + if (shouldShowSnackbar) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(behavior: SnackBarBehavior.floating, content: successContent)); + } + } + + /// Opens a URL with [ZulipBinding.launchUrl], with an error dialog on failure. + static Future launchUrl(BuildContext context, Uri url) async { + final globalSettings = GlobalStoreWidget.settingsOf(context); + + bool launched = false; + String? errorMessage; + try { + launched = await ZulipBinding.instance.launchUrl(url, + mode: globalSettings.getUrlLaunchMode(url)); + } on PlatformException catch (e) { + errorMessage = e.message; + } + if (!launched) { // TODO(log) + if (!context.mounted) return; + + final zulipLocalizations = ZulipLocalizations.of(context); + showErrorDialog(context: context, + title: zulipLocalizations.errorCouldNotOpenLinkTitle, + message: [ + zulipLocalizations.errorCouldNotOpenLink(url.toString()), + if (errorMessage != null) errorMessage, + ].join("\n\n")); + } + } +} diff --git a/lib/widgets/app.dart b/lib/widgets/app.dart index d251b88b58..d2f5b36e1d 100644 --- a/lib/widgets/app.dart +++ b/lib/widgets/app.dart @@ -130,13 +130,18 @@ class ZulipApp extends StatefulWidget { } /// The callback we normally use as [reportErrorToUserModally]. - static void _reportErrorToUserModally(String title, {String? message}) { + static void _reportErrorToUserModally( + String title, { + String? message, + Uri? learnMoreButtonUrl, + }) { assert(_ready.value); showErrorDialog( context: navigatorKey.currentContext!, title: title, - message: message); + message: message, + learnMoreButtonUrl: learnMoreButtonUrl); } void _declareReady() { diff --git a/lib/widgets/clipboard.dart b/lib/widgets/clipboard.dart deleted file mode 100644 index 9977af3f0c..0000000000 --- a/lib/widgets/clipboard.dart +++ /dev/null @@ -1,37 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; - -import '../model/binding.dart'; - -/// Copies [data] to the clipboard and shows a popup on success. -/// -/// Must have a [Scaffold] ancestor. -/// -/// On newer Android the popup is defined and shown by the platform. On older -/// Android and on iOS, shows a [Snackbar] with [successContent]. -/// -/// In English, the text in [successContent] should be short, should start with -/// a capital letter, and should have no ending punctuation: "{noun} copied". -void copyWithPopup({ - required BuildContext context, - required ClipboardData data, - required Widget successContent, -}) async { - await Clipboard.setData(data); - final deviceInfo = await ZulipBinding.instance.deviceInfo; - - if (!context.mounted) return; - - final shouldShowSnackbar = switch (deviceInfo) { - // Android 13+ shows its own popup on copying to the clipboard, - // so we suppress ours, following the advice at: - // https://developer.android.com/develop/ui/views/touch-and-input/copy-paste#duplicate-notifications - // TODO(android-sdk-33): Simplify this and dartdoc - AndroidDeviceInfo(:var sdkInt) => sdkInt <= 32, - _ => true, - }; - if (shouldShowSnackbar) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(behavior: SnackBarBehavior.floating, content: successContent)); - } -} diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index 5f2a059a69..f89872dd1d 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -4,7 +4,6 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; -import 'package:flutter/services.dart'; import 'package:html/dom.dart' as dom; import 'package:intl/intl.dart'; @@ -12,9 +11,9 @@ import '../api/core.dart'; import '../api/model/model.dart'; import '../generated/l10n/zulip_localizations.dart'; import '../model/avatar_url.dart'; -import '../model/binding.dart'; import '../model/content.dart'; import '../model/internal_link.dart'; +import 'actions.dart'; import 'code_block.dart'; import 'dialog.dart'; import 'icons.dart'; @@ -1388,20 +1387,13 @@ class MessageTableCell extends StatelessWidget { } void _launchUrl(BuildContext context, String urlString) async { - DialogStatus showError(BuildContext context, String? message) { - final zulipLocalizations = ZulipLocalizations.of(context); - return showErrorDialog(context: context, - title: zulipLocalizations.errorCouldNotOpenLinkTitle, - message: [ - zulipLocalizations.errorCouldNotOpenLink(urlString), - if (message != null) message, - ].join("\n\n")); - } - final store = PerAccountStoreWidget.of(context); final url = store.tryResolveUrl(urlString); if (url == null) { // TODO(log) - showError(context, null); + final zulipLocalizations = ZulipLocalizations.of(context); + showErrorDialog(context: context, + title: zulipLocalizations.errorCouldNotOpenLinkTitle, + message: zulipLocalizations.errorCouldNotOpenLink(urlString)); return; } @@ -1413,19 +1405,7 @@ void _launchUrl(BuildContext context, String urlString) async { return; } - final globalSettings = GlobalStoreWidget.settingsOf(context); - bool launched = false; - String? errorMessage; - try { - launched = await ZulipBinding.instance.launchUrl(url, - mode: globalSettings.getUrlLaunchMode(url)); - } on PlatformException catch (e) { - errorMessage = e.message; - } - if (!launched) { // TODO(log) - if (!context.mounted) return; - showError(context, errorMessage); - } + await PlatformActions.launchUrl(context, url); } /// Like [Image.network], but includes [authHeader] if [src] is on-realm. diff --git a/lib/widgets/dialog.dart b/lib/widgets/dialog.dart index 1b1c1d4713..7ff373db6d 100644 --- a/lib/widgets/dialog.dart +++ b/lib/widgets/dialog.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../generated/l10n/zulip_localizations.dart'; +import 'actions.dart'; Widget _dialogActionText(String text) { return Text( @@ -27,7 +28,8 @@ class DialogStatus { final Future closed; } -/// Displays an [AlertDialog] with a dismiss button. +/// Displays an [AlertDialog] with a dismiss button +/// and optional "Learn more" button. /// /// The [DialogStatus.closed] field of the return value can be used /// for waiting for the dialog to be closed. @@ -39,6 +41,7 @@ DialogStatus showErrorDialog({ required BuildContext context, required String title, String? message, + Uri? learnMoreButtonUrl, }) { final zulipLocalizations = ZulipLocalizations.of(context); final future = showDialog( @@ -47,6 +50,10 @@ DialogStatus showErrorDialog({ title: Text(title), content: message != null ? SingleChildScrollView(child: Text(message)) : null, actions: [ + if (learnMoreButtonUrl != null) + TextButton( + onPressed: () => PlatformActions.launchUrl(context, learnMoreButtonUrl), + child: _dialogActionText(zulipLocalizations.errorDialogLearnMore)), TextButton( onPressed: () => Navigator.pop(context), child: _dialogActionText(zulipLocalizations.errorDialogContinue)), diff --git a/lib/widgets/lightbox.dart b/lib/widgets/lightbox.dart index dd2aca5b82..0ddad1ef38 100644 --- a/lib/widgets/lightbox.dart +++ b/lib/widgets/lightbox.dart @@ -9,10 +9,10 @@ import '../api/model/model.dart'; import '../generated/l10n/zulip_localizations.dart'; import '../log.dart'; import '../model/binding.dart'; +import 'actions.dart'; import 'content.dart'; import 'dialog.dart'; import 'page.dart'; -import 'clipboard.dart'; import 'store.dart'; // TODO(#44): Add index of the image preview in the message, to not break if @@ -82,7 +82,7 @@ class _CopyLinkButton extends StatelessWidget { tooltip: zulipLocalizations.lightboxCopyLinkTooltip, icon: const Icon(Icons.copy), onPressed: () async { - copyWithPopup(context: context, + PlatformActions.copyWithPopup(context: context, successContent: Text(zulipLocalizations.successLinkCopied), data: ClipboardData(text: url.toString())); }); diff --git a/lib/widgets/login.dart b/lib/widgets/login.dart index 504289adc1..bdba3eac0d 100644 --- a/lib/widgets/login.dart +++ b/lib/widgets/login.dart @@ -343,6 +343,9 @@ class _LoginPageState extends State { // Could set [_inProgress]… but we'd need to unset it if the web-auth // attempt is aborted (by the user closing the browser, for example), // and I don't think we can reliably know when that happens. + + // Not using [PlatformActions.launchUrl] because web auth needs special + // error handling. await ZulipBinding.instance.launchUrl(url, mode: LaunchMode.inAppBrowserView); } catch (e) { assert(debugLog(e.toString())); diff --git a/test/example_data.dart b/test/example_data.dart index d44849bc6a..af1be189d4 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'dart:math'; +import 'package:zulip/api/core.dart'; import 'package:zulip/api/exception.dart'; import 'package:zulip/api/model/events.dart'; import 'package:zulip/api/model/initial_snapshot.dart'; @@ -77,6 +78,7 @@ Uri get _realmUrl => realmUrl; const String recentZulipVersion = '9.0'; const int recentZulipFeatureLevel = 278; const int futureZulipFeatureLevel = 9999; +const int ancientZulipFeatureLevel = kMinSupportedZulipFeatureLevel - 1; GetServerSettingsResult serverSettings({ Map? authenticationMethods, diff --git a/test/model/binding.dart b/test/model/binding.dart index 17c9565770..73b4ed5513 100644 --- a/test/model/binding.dart +++ b/test/model/binding.dart @@ -161,11 +161,21 @@ class TestZulipBinding extends ZulipBinding { /// The value that `ZulipBinding.instance.launchUrl()` should return. /// - /// See also [takeLaunchUrlCalls]. + /// See also: + /// * [launchUrlException] + /// * [takeLaunchUrlCalls] bool launchUrlResult = true; + /// The [PlatformException] that `ZulipBinding.instance.launchUrl()` should throw. + /// + /// See also: + /// * [launchUrlResult] + /// * [takeLaunchUrlCalls] + PlatformException? launchUrlException; + void _resetLaunchUrl() { launchUrlResult = true; + launchUrlException = null; _launchUrlCalls = null; } @@ -189,6 +199,22 @@ class TestZulipBinding extends ZulipBinding { url_launcher.LaunchMode mode = url_launcher.LaunchMode.platformDefault, }) async { (_launchUrlCalls ??= []).add((url: url, mode: mode)); + + if (!launchUrlResult && launchUrlException != null) { + throw FlutterError.fromParts([ + ErrorSummary( + 'TestZulipBinding.launchUrl called ' + 'with launchUrlResult: false and non-null launchUrlException'), + ErrorHint( + 'Tests should either set launchUrlResult or launchUrlException, ' + 'but not both.'), + ]); + } + + if (launchUrlException != null) { + throw launchUrlException!; + } + return launchUrlResult; } diff --git a/test/model/settings_test.dart b/test/model/settings_test.dart index 6e728c5237..ad739f5d4b 100644 --- a/test/model/settings_test.dart +++ b/test/model/settings_test.dart @@ -14,6 +14,9 @@ void main() { final nonHttpLink = Uri.parse('mailto:chat@zulip.org'); group('getUrlLaunchMode', () { + // See also test/widgets/actions_test.dart, where we test that the setting + // is actually used when we open links, with PlatformActions.launchUrl. + testAndroidIos('globalSettings.browserPreference is null; use our per-platform defaults for HTTP links', () { final globalSettings = eg.globalStore(globalSettings: GlobalSettingsData( browserPreference: null)).settings; diff --git a/test/model/store_checks.dart b/test/model/store_checks.dart index 5e7f5fbbfe..32379a6f06 100644 --- a/test/model/store_checks.dart +++ b/test/model/store_checks.dart @@ -64,3 +64,9 @@ extension PerAccountStoreChecks on Subject { Subject get recentDmConversationsView => has((x) => x.recentDmConversationsView, 'recentDmConversationsView'); Subject get autocompleteViewManager => has((x) => x.autocompleteViewManager, 'autocompleteViewManager'); } + +extension ZulipVersionDataChecks on Subject { + Subject get zulipVersion => has((x) => x.zulipVersion, 'zulipVersion'); + Subject get zulipMergeBase => has((x) => x.zulipMergeBase, 'zulipMergeBase'); + Subject get zulipFeatureLevel => has((x) => x.zulipFeatureLevel, 'zulipFeatureLevel'); +} diff --git a/test/model/store_test.dart b/test/model/store_test.dart index 4c83cd5da0..6eba7b1299 100644 --- a/test/model/store_test.dart +++ b/test/model/store_test.dart @@ -8,7 +8,9 @@ import 'package:http/http.dart' as http; import 'package:test/scaffolding.dart'; import 'package:zulip/api/backoff.dart'; import 'package:zulip/api/core.dart'; +import 'package:zulip/api/exception.dart'; import 'package:zulip/api/model/events.dart'; +import 'package:zulip/api/model/initial_snapshot.dart'; import 'package:zulip/api/model/model.dart'; import 'package:zulip/api/route/events.dart'; import 'package:zulip/api/route/messages.dart'; @@ -146,6 +148,42 @@ void main() { check(connection).isOpen.isTrue(); })); + test('GlobalStore.perAccount loading succeeds; InitialSnapshot has ancient server version', () => awaitFakeAsync((async) async { + final globalStore = UpdateMachineTestGlobalStore(accounts: [eg.selfAccount]); + final json = eg.initialSnapshot(zulipFeatureLevel: eg.ancientZulipFeatureLevel).toJson(); + globalStore.prepareRegisterQueueResponse = (connection) { + connection.prepare(json: json); + }; + final connection = globalStore.apiConnectionFromAccount(eg.selfAccount) as FakeApiConnection; + final future = globalStore.perAccount(eg.selfAccount.id); + check(connection.takeRequests()).length.equals(1); // register request + + await check(future).throws(); + check(globalStore.takeDoRemoveAccountCalls()).single.equals(eg.selfAccount.id); + // no poll, server-emoji-data, or register-token requests + check(connection.takeRequests()).isEmpty(); + check(connection).isOpen.isFalse(); + })); + + test('GlobalStore.perAccount loading fails; malformed response with ancient server version', () => awaitFakeAsync((async) async { + final globalStore = UpdateMachineTestGlobalStore(accounts: [eg.selfAccount]); + final json = eg.initialSnapshot(zulipFeatureLevel: eg.ancientZulipFeatureLevel).toJson(); + json['realm_emoji'] = 123; + check(() => InitialSnapshot.fromJson(json)).throws(); + globalStore.prepareRegisterQueueResponse = (connection) { + connection.prepare(json: json); + }; + final connection = globalStore.apiConnectionFromAccount(eg.selfAccount) as FakeApiConnection; + final future = globalStore.perAccount(eg.selfAccount.id); + check(connection.takeRequests()).length.equals(1); // register request + + await check(future).throws(); + check(globalStore.takeDoRemoveAccountCalls()).single.equals(eg.selfAccount.id); + // no poll, server-emoji-data, or register-token requests + check(connection.takeRequests()).isEmpty(); + check(connection).isOpen.isFalse(); + })); + test('GlobalStore.perAccount account is logged out while loading; then succeeds', () => awaitFakeAsync((async) async { final globalStore = UpdateMachineTestGlobalStore(accounts: [eg.selfAccount]); globalStore.prepareRegisterQueueResponse = (connection) => @@ -188,6 +226,52 @@ void main() { check(connection).isOpen.isFalse(); })); + test('GlobalStore.perAccount account is logged out while loading; then succeeds; InitialSnapshot has ancient server version', () => awaitFakeAsync((async) async { + final globalStore = UpdateMachineTestGlobalStore(accounts: [eg.selfAccount]); + final json = eg.initialSnapshot(zulipFeatureLevel: eg.ancientZulipFeatureLevel).toJson(); + globalStore.prepareRegisterQueueResponse = (connection) { + connection.prepare( + delay: TestGlobalStore.removeAccountDuration + Duration(seconds: 1), + json: json); + }; + final connection = globalStore.apiConnectionFromAccount(eg.selfAccount) as FakeApiConnection; + final future = globalStore.perAccount(eg.selfAccount.id); + check(connection.takeRequests()).length.equals(1); // register request + + await logOutAccount(globalStore, eg.selfAccount.id); + check(globalStore.takeDoRemoveAccountCalls()).single.equals(eg.selfAccount.id); + + await check(future).throws(); + check(globalStore.takeDoRemoveAccountCalls()).isEmpty(); + // no poll, server-emoji-data, or register-token requests + check(connection.takeRequests()).isEmpty(); + check(connection).isOpen.isFalse(); + })); + + test('GlobalStore.perAccount account is logged out while loading; then fails; malformed response with ancient server version', () => awaitFakeAsync((async) async { + final globalStore = UpdateMachineTestGlobalStore(accounts: [eg.selfAccount]); + final json = eg.initialSnapshot(zulipFeatureLevel: eg.ancientZulipFeatureLevel).toJson(); + json['realm_emoji'] = 123; + check(() => InitialSnapshot.fromJson(json)).throws(); + globalStore.prepareRegisterQueueResponse = (connection) { + connection.prepare( + delay: TestGlobalStore.removeAccountDuration + Duration(seconds: 1), + json: json); + }; + final connection = globalStore.apiConnectionFromAccount(eg.selfAccount) as FakeApiConnection; + final future = globalStore.perAccount(eg.selfAccount.id); + check(connection.takeRequests()).length.equals(1); // register request + + await logOutAccount(globalStore, eg.selfAccount.id); + check(globalStore.takeDoRemoveAccountCalls()).single.equals(eg.selfAccount.id); + + await check(future).throws(); + check(globalStore.takeDoRemoveAccountCalls()).isEmpty(); + // no poll, server-emoji-data, or register-token requests + check(connection.takeRequests()).isEmpty(); + check(connection).isOpen.isFalse(); + })); + test('GlobalStore.perAccount account is logged out during transient-error backoff', () => awaitFakeAsync((async) async { final globalStore = UpdateMachineTestGlobalStore(accounts: [eg.selfAccount]); globalStore.prepareRegisterQueueResponse = (connection) => @@ -258,6 +342,31 @@ void main() { // TODO test database gets updated correctly (an integration test with sqlite?) }); + + test('GlobalStore.updateZulipVersionData', () async { + final [currentZulipVersion, newZulipVersion ] + = ['10.0-beta2-302-gf5b08b11f4', '10.0-beta2-351-g75ac8fe961']; + final [currentZulipMergeBase, newZulipMergeBase ] + = ['10.0-beta2-291-g33ffd8c040', '10.0-beta2-349-g463dc632b3']; + final [currentZulipFeatureLevel, newZulipFeatureLevel ] + = [368, 370 ]; + + final selfAccount = eg.selfAccount.copyWith( + zulipVersion: currentZulipVersion, + zulipMergeBase: Value(currentZulipMergeBase), + zulipFeatureLevel: currentZulipFeatureLevel); + final globalStore = eg.globalStore(accounts: [selfAccount]); + final updated = await globalStore.updateZulipVersionData(selfAccount.id, + ZulipVersionData( + zulipVersion: newZulipVersion, + zulipMergeBase: newZulipMergeBase, + zulipFeatureLevel: newZulipFeatureLevel)); + check(globalStore.getAccount(selfAccount.id)).identicalTo(updated); + check(updated).equals(selfAccount.copyWith( + zulipVersion: newZulipVersion, + zulipMergeBase: Value(newZulipMergeBase), + zulipFeatureLevel: newZulipFeatureLevel)); + }); group('GlobalStore.removeAccount', () { void checkGlobalStore(GlobalStore store, int accountId, { @@ -1100,6 +1209,46 @@ void main() { // Reload never succeeds and there are no unhandled errors. check(globalStore.perAccountSync(eg.selfAccount.id)).isNull(); })); + + test('new store is not loaded, gets InitialSnapshot with ancient server version', () => awaitFakeAsync((async) async { + final json = eg.initialSnapshot(zulipFeatureLevel: eg.ancientZulipFeatureLevel).toJson(); + await prepareReload(async, prepareRegisterQueueResponse: (connection) { + connection.prepare( + delay: Duration(seconds: 1), + json: json); + }); + + async.elapse(const Duration(seconds: 1)); + check(globalStore.takeDoRemoveAccountCalls()).single.equals(eg.selfAccount.id); + + async.elapse(TestGlobalStore.removeAccountDuration); + check(globalStore.perAccountSync(eg.selfAccount.id)).isNull(); + + async.flushTimers(); + // Reload never succeeds and there are no unhandled errors. + check(globalStore.perAccountSync(eg.selfAccount.id)).isNull(); + })); + + test('new store is not loaded, gets malformed response with ancient server version', () => awaitFakeAsync((async) async { + final json = eg.initialSnapshot(zulipFeatureLevel: eg.ancientZulipFeatureLevel).toJson(); + json['realm_emoji'] = 123; + check(() => InitialSnapshot.fromJson(json)).throws(); + await prepareReload(async, prepareRegisterQueueResponse: (connection) { + connection.prepare( + delay: Duration(seconds: 1), + json: json); + }); + + async.elapse(const Duration(seconds: 1)); + check(globalStore.takeDoRemoveAccountCalls()).single.equals(eg.selfAccount.id); + + async.elapse(TestGlobalStore.removeAccountDuration); + check(globalStore.perAccountSync(eg.selfAccount.id)).isNull(); + + async.flushTimers(); + // Reload never succeeds and there are no unhandled errors. + check(globalStore.perAccountSync(eg.selfAccount.id)).isNull(); + })); }); group('UpdateMachine.registerNotificationToken', () { @@ -1192,6 +1341,35 @@ void main() { } })); }); + + group('ZulipVersionData', () { + group('fromMalformedServerResponseException', () { + test('replace missing feature level with 0', () async { + final connection = testBinding.globalStore.apiConnectionFromAccount(eg.selfAccount) as FakeApiConnection; + + final json = eg.initialSnapshot().toJson() + ..['zulip_version'] = '2.0.0' + ..remove('zulip_feature_level') // malformed in current schema + ..remove('zulip_merge_base'); + + Object? error; + connection.prepare(json: json); + try { + await registerQueue(connection); + } catch (e) { + error = e; + } + + check(error).isNotNull().isA(); + final zulipVersionData = ZulipVersionData.fromMalformedServerResponseException( + error as MalformedServerResponseException); + check(zulipVersionData).isNotNull() + ..zulipVersion.equals('2.0.0') + ..zulipMergeBase.isNull() + ..zulipFeatureLevel.equals(0); + }); + }); + }); } class LoadingTestGlobalStore extends TestGlobalStore { diff --git a/test/widgets/actions_test.dart b/test/widgets/actions_test.dart index 4f65515043..95e79d9441 100644 --- a/test/widgets/actions_test.dart +++ b/test/widgets/actions_test.dart @@ -1,341 +1,510 @@ import 'dart:convert'; import 'package:checks/checks.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:zulip/api/model/initial_snapshot.dart'; import 'package:zulip/api/model/model.dart'; import 'package:zulip/api/model/narrow.dart'; import 'package:zulip/api/route/messages.dart'; +import 'package:zulip/model/binding.dart'; import 'package:zulip/model/localizations.dart'; import 'package:zulip/model/narrow.dart'; +import 'package:zulip/model/settings.dart'; import 'package:zulip/model/store.dart'; import 'package:zulip/widgets/actions.dart'; import '../api/fake_api.dart'; import '../example_data.dart' as eg; +import '../flutter_checks.dart'; import '../model/binding.dart'; import '../model/unreads_checks.dart'; import '../stdlib_checks.dart'; +import '../test_clipboard.dart'; import 'dialog_checks.dart'; import 'test_app.dart'; void main() { - TestZulipBinding.ensureInitialized(); - - late PerAccountStore store; - late FakeApiConnection connection; - late BuildContext context; - - Future prepare(WidgetTester tester, { - UnreadMessagesSnapshot? unreadMsgs, - String? ackedPushToken = '123', - bool skipAssertAccountExists = false, - }) async { - addTearDown(testBinding.reset); - final selfAccount = eg.selfAccount.copyWith(ackedPushToken: Value(ackedPushToken)); - await testBinding.globalStore.add(selfAccount, eg.initialSnapshot( - unreadMsgs: unreadMsgs)); - store = await testBinding.globalStore.perAccount(selfAccount.id); - connection = store.connection as FakeApiConnection; - - await tester.pumpWidget(TestZulipApp( - accountId: selfAccount.id, - skipAssertAccountExists: skipAssertAccountExists, - child: const Scaffold(body: Placeholder()))); - await tester.pump(); - context = tester.element(find.byType(Placeholder)); - } - - group('markNarrowAsRead', () { - testWidgets('smoke test on modern server', (tester) async { - final narrow = TopicNarrow.ofMessage(eg.streamMessage()); - await prepare(tester); - connection.prepare(json: UpdateMessageFlagsForNarrowResult( - processedCount: 11, updatedCount: 3, - firstProcessedId: null, lastProcessedId: null, - foundOldest: true, foundNewest: true).toJson()); - final future = ZulipAction.markNarrowAsRead(context, narrow); - await tester.pump(Duration.zero); - await future; - final apiNarrow = narrow.apiEncode()..add(ApiNarrowIs(IsOperand.unread)); - check(connection.lastRequest).isA() - ..method.equals('POST') - ..url.path.equals('/api/v1/messages/flags/narrow') - ..bodyFields.deepEquals({ - 'anchor': 'oldest', - 'include_anchor': 'false', - 'num_before': '0', - 'num_after': '1000', - 'narrow': jsonEncode(apiNarrow), - 'op': 'add', - 'flag': 'read', - }); - }); + group('ZulipActions', () { + TestZulipBinding.ensureInitialized(); - testWidgets('use is:unread optimization', (tester) async { - const narrow = CombinedFeedNarrow(); - await prepare(tester); - connection.prepare(json: UpdateMessageFlagsForNarrowResult( - processedCount: 11, updatedCount: 3, - firstProcessedId: null, lastProcessedId: null, - foundOldest: true, foundNewest: true).toJson()); - final future = ZulipAction.markNarrowAsRead(context, narrow); - await tester.pump(Duration.zero); - await future; - check(connection.lastRequest).isA() - ..method.equals('POST') - ..url.path.equals('/api/v1/messages/flags/narrow') - ..bodyFields.deepEquals({ - 'anchor': 'oldest', - 'include_anchor': 'false', - 'num_before': '0', - 'num_after': '1000', - 'narrow': json.encode([{'operator': 'is', 'operand': 'unread'}]), - 'op': 'add', - 'flag': 'read', - }); - }); + late PerAccountStore store; + late FakeApiConnection connection; + late BuildContext context; - testWidgets('on mark-all-as-read when Unreads.oldUnreadsMissing: true', (tester) async { - const narrow = CombinedFeedNarrow(); - await prepare(tester); - store.unreads.oldUnreadsMissing = true; - - connection.prepare(json: UpdateMessageFlagsForNarrowResult( - processedCount: 11, updatedCount: 3, - firstProcessedId: null, lastProcessedId: null, - foundOldest: true, foundNewest: true).toJson()); - final future = ZulipAction.markNarrowAsRead(context, narrow); - await tester.pump(Duration.zero); - await future; - check(store.unreads.oldUnreadsMissing).isFalse(); - }); + Future prepare(WidgetTester tester, { + UnreadMessagesSnapshot? unreadMsgs, + String? ackedPushToken = '123', + bool skipAssertAccountExists = false, + }) async { + addTearDown(testBinding.reset); + final selfAccount = eg.selfAccount.copyWith(ackedPushToken: Value(ackedPushToken)); + await testBinding.globalStore.add(selfAccount, eg.initialSnapshot( + unreadMsgs: unreadMsgs)); + store = await testBinding.globalStore.perAccount(selfAccount.id); + connection = store.connection as FakeApiConnection; - testWidgets('CombinedFeedNarrow on legacy server', (tester) async { - const narrow = CombinedFeedNarrow(); - await prepare(tester); - // Might as well test with oldUnreadsMissing: true. - store.unreads.oldUnreadsMissing = true; - - connection.zulipFeatureLevel = 154; - connection.prepare(json: {}); - final future = ZulipAction.markNarrowAsRead(context, narrow); - await tester.pump(Duration.zero); - await future; - check(connection.lastRequest).isA() - ..method.equals('POST') - ..url.path.equals('/api/v1/mark_all_as_read') - ..bodyFields.deepEquals({}); - - // Check that [Unreads.handleAllMessagesReadSuccess] wasn't called; - // in the legacy protocol, that'd be redundant with the mark-read event. - check(store.unreads).oldUnreadsMissing.isTrue(); - }); + await tester.pumpWidget(TestZulipApp( + accountId: selfAccount.id, + skipAssertAccountExists: skipAssertAccountExists, + child: const Scaffold(body: Placeholder()))); + await tester.pump(); + context = tester.element(find.byType(Placeholder)); + } + + group('markNarrowAsRead', () { + testWidgets('smoke test on modern server', (tester) async { + final narrow = TopicNarrow.ofMessage(eg.streamMessage()); + await prepare(tester); + connection.prepare(json: UpdateMessageFlagsForNarrowResult( + processedCount: 11, updatedCount: 3, + firstProcessedId: null, lastProcessedId: null, + foundOldest: true, foundNewest: true).toJson()); + final future = ZulipAction.markNarrowAsRead(context, narrow); + await tester.pump(Duration.zero); + await future; + final apiNarrow = narrow.apiEncode()..add(ApiNarrowIs(IsOperand.unread)); + check(connection.lastRequest).isA() + ..method.equals('POST') + ..url.path.equals('/api/v1/messages/flags/narrow') + ..bodyFields.deepEquals({ + 'anchor': 'oldest', + 'include_anchor': 'false', + 'num_before': '0', + 'num_after': '1000', + 'narrow': jsonEncode(apiNarrow), + 'op': 'add', + 'flag': 'read', + }); + }); + + testWidgets('use is:unread optimization', (tester) async { + const narrow = CombinedFeedNarrow(); + await prepare(tester); + connection.prepare(json: UpdateMessageFlagsForNarrowResult( + processedCount: 11, updatedCount: 3, + firstProcessedId: null, lastProcessedId: null, + foundOldest: true, foundNewest: true).toJson()); + final future = ZulipAction.markNarrowAsRead(context, narrow); + await tester.pump(Duration.zero); + await future; + check(connection.lastRequest).isA() + ..method.equals('POST') + ..url.path.equals('/api/v1/messages/flags/narrow') + ..bodyFields.deepEquals({ + 'anchor': 'oldest', + 'include_anchor': 'false', + 'num_before': '0', + 'num_after': '1000', + 'narrow': json.encode([{'operator': 'is', 'operand': 'unread'}]), + 'op': 'add', + 'flag': 'read', + }); + }); + + testWidgets('on mark-all-as-read when Unreads.oldUnreadsMissing: true', (tester) async { + const narrow = CombinedFeedNarrow(); + await prepare(tester); + store.unreads.oldUnreadsMissing = true; + + connection.prepare(json: UpdateMessageFlagsForNarrowResult( + processedCount: 11, updatedCount: 3, + firstProcessedId: null, lastProcessedId: null, + foundOldest: true, foundNewest: true).toJson()); + final future = ZulipAction.markNarrowAsRead(context, narrow); + await tester.pump(Duration.zero); + await future; + check(store.unreads.oldUnreadsMissing).isFalse(); + }); + + testWidgets('CombinedFeedNarrow on legacy server', (tester) async { + const narrow = CombinedFeedNarrow(); + await prepare(tester); + // Might as well test with oldUnreadsMissing: true. + store.unreads.oldUnreadsMissing = true; - testWidgets('ChannelNarrow on legacy server', (tester) async { - final stream = eg.stream(); - final narrow = ChannelNarrow(stream.streamId); - await prepare(tester); - connection.zulipFeatureLevel = 154; - connection.prepare(json: {}); - final future = ZulipAction.markNarrowAsRead(context, narrow); - await tester.pump(Duration.zero); - await future; - check(connection.lastRequest).isA() - ..method.equals('POST') - ..url.path.equals('/api/v1/mark_stream_as_read') - ..bodyFields.deepEquals({ - 'stream_id': stream.streamId.toString(), - }); + connection.zulipFeatureLevel = 154; + connection.prepare(json: {}); + final future = ZulipAction.markNarrowAsRead(context, narrow); + await tester.pump(Duration.zero); + await future; + check(connection.lastRequest).isA() + ..method.equals('POST') + ..url.path.equals('/api/v1/mark_all_as_read') + ..bodyFields.deepEquals({}); + + // Check that [Unreads.handleAllMessagesReadSuccess] wasn't called; + // in the legacy protocol, that'd be redundant with the mark-read event. + check(store.unreads).oldUnreadsMissing.isTrue(); + }); + + testWidgets('ChannelNarrow on legacy server', (tester) async { + final stream = eg.stream(); + final narrow = ChannelNarrow(stream.streamId); + await prepare(tester); + connection.zulipFeatureLevel = 154; + connection.prepare(json: {}); + final future = ZulipAction.markNarrowAsRead(context, narrow); + await tester.pump(Duration.zero); + await future; + check(connection.lastRequest).isA() + ..method.equals('POST') + ..url.path.equals('/api/v1/mark_stream_as_read') + ..bodyFields.deepEquals({ + 'stream_id': stream.streamId.toString(), + }); + }); + + testWidgets('TopicNarrow on legacy server', (tester) async { + final narrow = TopicNarrow.ofMessage(eg.streamMessage()); + await prepare(tester); + connection.zulipFeatureLevel = 154; + connection.prepare(json: {}); + final future = ZulipAction.markNarrowAsRead(context, narrow); + await tester.pump(Duration.zero); + await future; + check(connection.lastRequest).isA() + ..method.equals('POST') + ..url.path.equals('/api/v1/mark_topic_as_read') + ..bodyFields.deepEquals({ + 'stream_id': narrow.streamId.toString(), + 'topic_name': narrow.topic, + }); + }); + + testWidgets('DmNarrow on legacy server', (tester) async { + final message = eg.dmMessage(from: eg.otherUser, to: [eg.selfUser]); + final narrow = DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId); + final unreadMsgs = eg.unreadMsgs(dms: [ + UnreadDmSnapshot(otherUserId: eg.otherUser.userId, + unreadMessageIds: [message.id]), + ]); + await prepare(tester, unreadMsgs: unreadMsgs); + connection.zulipFeatureLevel = 154; + connection.prepare(json: + UpdateMessageFlagsResult(messages: [message.id]).toJson()); + final future = ZulipAction.markNarrowAsRead(context, narrow); + await tester.pump(Duration.zero); + await future; + check(connection.lastRequest).isA() + ..method.equals('POST') + ..url.path.equals('/api/v1/messages/flags') + ..bodyFields.deepEquals({ + 'messages': jsonEncode([message.id]), + 'op': 'add', + 'flag': 'read', + }); + }); + + testWidgets('MentionsNarrow on legacy server', (tester) async { + const narrow = MentionsNarrow(); + final message = eg.streamMessage(flags: [MessageFlag.mentioned]); + final unreadMsgs = eg.unreadMsgs(mentions: [message.id]); + await prepare(tester, unreadMsgs: unreadMsgs); + connection.zulipFeatureLevel = 154; + connection.prepare(json: + UpdateMessageFlagsResult(messages: [message.id]).toJson()); + final future = ZulipAction.markNarrowAsRead(context, narrow); + await tester.pump(Duration.zero); + await future; + check(connection.lastRequest).isA() + ..method.equals('POST') + ..url.path.equals('/api/v1/messages/flags') + ..bodyFields.deepEquals({ + 'messages': jsonEncode([message.id]), + 'op': 'add', + 'flag': 'read', + }); + }); }); - testWidgets('TopicNarrow on legacy server', (tester) async { + group('updateMessageFlagsStartingFromAnchor', () { + String onCompletedMessage(int count) => 'onCompletedMessage($count)'; + const progressMessage = 'progressMessage'; + const onFailedTitle = 'onFailedTitle'; final narrow = TopicNarrow.ofMessage(eg.streamMessage()); - await prepare(tester); - connection.zulipFeatureLevel = 154; - connection.prepare(json: {}); - final future = ZulipAction.markNarrowAsRead(context, narrow); - await tester.pump(Duration.zero); - await future; - check(connection.lastRequest).isA() - ..method.equals('POST') - ..url.path.equals('/api/v1/mark_topic_as_read') - ..bodyFields.deepEquals({ - 'stream_id': narrow.streamId.toString(), - 'topic_name': narrow.topic, - }); - }); + final apiNarrow = narrow.apiEncode()..add(ApiNarrowIs(IsOperand.unread)); - testWidgets('DmNarrow on legacy server', (tester) async { - final message = eg.dmMessage(from: eg.otherUser, to: [eg.selfUser]); - final narrow = DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId); - final unreadMsgs = eg.unreadMsgs(dms: [ - UnreadDmSnapshot(otherUserId: eg.otherUser.userId, - unreadMessageIds: [message.id]), - ]); - await prepare(tester, unreadMsgs: unreadMsgs); - connection.zulipFeatureLevel = 154; - connection.prepare(json: - UpdateMessageFlagsResult(messages: [message.id]).toJson()); - final future = ZulipAction.markNarrowAsRead(context, narrow); - await tester.pump(Duration.zero); - await future; - check(connection.lastRequest).isA() - ..method.equals('POST') - ..url.path.equals('/api/v1/messages/flags') - ..bodyFields.deepEquals({ - 'messages': jsonEncode([message.id]), - 'op': 'add', - 'flag': 'read', - }); - }); + Future invokeUpdateMessageFlagsStartingFromAnchor() => + ZulipAction.updateMessageFlagsStartingFromAnchor( + context: context, + apiNarrow: apiNarrow, + op: UpdateMessageFlagsOp.add, + flag: MessageFlag.read, + includeAnchor: false, + anchor: AnchorCode.oldest, + onCompletedMessage: onCompletedMessage, + onFailedTitle: onFailedTitle, + progressMessage: progressMessage); + + testWidgets('smoke test', (tester) async { + await prepare(tester); + connection.prepare(json: UpdateMessageFlagsForNarrowResult( + processedCount: 11, updatedCount: 3, + firstProcessedId: 1, lastProcessedId: 1980, + foundOldest: true, foundNewest: true).toJson()); + final didPass = invokeUpdateMessageFlagsStartingFromAnchor(); + await tester.pump(Duration.zero); + check(connection.lastRequest).isA() + ..method.equals('POST') + ..url.path.equals('/api/v1/messages/flags/narrow') + ..bodyFields.deepEquals({ + 'anchor': 'oldest', + 'include_anchor': 'false', + 'num_before': '0', + 'num_after': '1000', + 'narrow': jsonEncode(apiNarrow), + 'op': 'add', + 'flag': 'read', + }); + check(await didPass).isTrue(); + }); + + testWidgets('pagination', (tester) async { + // Check that `lastProcessedId` returned from an initial + // response is used as `anchorId` for the subsequent request. + await prepare(tester); - testWidgets('MentionsNarrow on legacy server', (tester) async { - const narrow = MentionsNarrow(); - final message = eg.streamMessage(flags: [MessageFlag.mentioned]); - final unreadMsgs = eg.unreadMsgs(mentions: [message.id]); - await prepare(tester, unreadMsgs: unreadMsgs); - connection.zulipFeatureLevel = 154; - connection.prepare(json: - UpdateMessageFlagsResult(messages: [message.id]).toJson()); - final future = ZulipAction.markNarrowAsRead(context, narrow); - await tester.pump(Duration.zero); - await future; - check(connection.lastRequest).isA() - ..method.equals('POST') - ..url.path.equals('/api/v1/messages/flags') - ..bodyFields.deepEquals({ - 'messages': jsonEncode([message.id]), - 'op': 'add', - 'flag': 'read', - }); + connection.prepare(json: UpdateMessageFlagsForNarrowResult( + processedCount: 1000, updatedCount: 890, + firstProcessedId: 1, lastProcessedId: 1989, + foundOldest: true, foundNewest: false).toJson()); + final didPass = invokeUpdateMessageFlagsStartingFromAnchor(); + check(connection.lastRequest).isA() + ..method.equals('POST') + ..url.path.equals('/api/v1/messages/flags/narrow') + ..bodyFields.deepEquals({ + 'anchor': 'oldest', + 'include_anchor': 'false', + 'num_before': '0', + 'num_after': '1000', + 'narrow': jsonEncode(apiNarrow), + 'op': 'add', + 'flag': 'read', + }); + + connection.prepare(json: UpdateMessageFlagsForNarrowResult( + processedCount: 20, updatedCount: 10, + firstProcessedId: 2000, lastProcessedId: 2023, + foundOldest: false, foundNewest: true).toJson()); + await tester.pump(Duration.zero); + check(find.bySubtype().evaluate()).length.equals(1); + check(connection.lastRequest).isA() + ..method.equals('POST') + ..url.path.equals('/api/v1/messages/flags/narrow') + ..bodyFields.deepEquals({ + 'anchor': '1989', + 'include_anchor': 'false', + 'num_before': '0', + 'num_after': '1000', + 'narrow': jsonEncode(apiNarrow), + 'op': 'add', + 'flag': 'read', + }); + check(await didPass).isTrue(); + }); + + testWidgets('on invalid response', (tester) async { + final zulipLocalizations = GlobalLocalizations.zulipLocalizations; + await prepare(tester); + connection.prepare(json: UpdateMessageFlagsForNarrowResult( + processedCount: 1000, updatedCount: 0, + firstProcessedId: null, lastProcessedId: null, + foundOldest: true, foundNewest: false).toJson()); + final didPass = invokeUpdateMessageFlagsStartingFromAnchor(); + await tester.pump(Duration.zero); + check(connection.lastRequest).isA() + ..method.equals('POST') + ..url.path.equals('/api/v1/messages/flags/narrow') + ..bodyFields.deepEquals({ + 'anchor': 'oldest', + 'include_anchor': 'false', + 'num_before': '0', + 'num_after': '1000', + 'narrow': jsonEncode(apiNarrow), + 'op': 'add', + 'flag': 'read', + }); + checkErrorDialog(tester, + expectedTitle: onFailedTitle, + expectedMessage: zulipLocalizations.errorInvalidResponse); + check(await didPass).isFalse(); + }); + + testWidgets('catch-all api errors', (tester) async { + await prepare(tester); + connection.prepare(httpException: http.ClientException('Oops')); + final didPass = invokeUpdateMessageFlagsStartingFromAnchor(); + await tester.pump(Duration.zero); + checkErrorDialog(tester, + expectedTitle: onFailedTitle, + expectedMessage: 'NetworkException: Oops (ClientException: Oops)'); + check(await didPass).isFalse(); + }); }); }); - group('updateMessageFlagsStartingFromAnchor', () { - String onCompletedMessage(int count) => 'onCompletedMessage($count)'; - const progressMessage = 'progressMessage'; - const onFailedTitle = 'onFailedTitle'; - final narrow = TopicNarrow.ofMessage(eg.streamMessage()); - final apiNarrow = narrow.apiEncode()..add(ApiNarrowIs(IsOperand.unread)); - - Future invokeUpdateMessageFlagsStartingFromAnchor() => - ZulipAction.updateMessageFlagsStartingFromAnchor( - context: context, - apiNarrow: apiNarrow, - op: UpdateMessageFlagsOp.add, - flag: MessageFlag.read, - includeAnchor: false, - anchor: AnchorCode.oldest, - onCompletedMessage: onCompletedMessage, - onFailedTitle: onFailedTitle, - progressMessage: progressMessage); - - testWidgets('smoke test', (tester) async { - await prepare(tester); - connection.prepare(json: UpdateMessageFlagsForNarrowResult( - processedCount: 11, updatedCount: 3, - firstProcessedId: 1, lastProcessedId: 1980, - foundOldest: true, foundNewest: true).toJson()); - final didPass = invokeUpdateMessageFlagsStartingFromAnchor(); - await tester.pump(Duration.zero); - check(connection.lastRequest).isA() - ..method.equals('POST') - ..url.path.equals('/api/v1/messages/flags/narrow') - ..bodyFields.deepEquals({ - 'anchor': 'oldest', - 'include_anchor': 'false', - 'num_before': '0', - 'num_after': '1000', - 'narrow': jsonEncode(apiNarrow), - 'op': 'add', - 'flag': 'read', - }); - check(await didPass).isTrue(); - }); + group('PlatformActions', () { + TestZulipBinding.ensureInitialized(); + TestWidgetsFlutterBinding.ensureInitialized(); - testWidgets('pagination', (tester) async { - // Check that `lastProcessedId` returned from an initial - // response is used as `anchorId` for the subsequent request. - await prepare(tester); - - connection.prepare(json: UpdateMessageFlagsForNarrowResult( - processedCount: 1000, updatedCount: 890, - firstProcessedId: 1, lastProcessedId: 1989, - foundOldest: true, foundNewest: false).toJson()); - final didPass = invokeUpdateMessageFlagsStartingFromAnchor(); - check(connection.lastRequest).isA() - ..method.equals('POST') - ..url.path.equals('/api/v1/messages/flags/narrow') - ..bodyFields.deepEquals({ - 'anchor': 'oldest', - 'include_anchor': 'false', - 'num_before': '0', - 'num_after': '1000', - 'narrow': jsonEncode(apiNarrow), - 'op': 'add', - 'flag': 'read', - }); - - connection.prepare(json: UpdateMessageFlagsForNarrowResult( - processedCount: 20, updatedCount: 10, - firstProcessedId: 2000, lastProcessedId: 2023, - foundOldest: false, foundNewest: true).toJson()); - await tester.pump(Duration.zero); - check(find.bySubtype().evaluate()).length.equals(1); - check(connection.lastRequest).isA() - ..method.equals('POST') - ..url.path.equals('/api/v1/messages/flags/narrow') - ..bodyFields.deepEquals({ - 'anchor': '1989', - 'include_anchor': 'false', - 'num_before': '0', - 'num_after': '1000', - 'narrow': jsonEncode(apiNarrow), - 'op': 'add', - 'flag': 'read', - }); - check(await didPass).isTrue(); + tearDown(() async { + testBinding.reset(); }); - testWidgets('on invalid response', (tester) async { - final zulipLocalizations = GlobalLocalizations.zulipLocalizations; - await prepare(tester); - connection.prepare(json: UpdateMessageFlagsForNarrowResult( - processedCount: 1000, updatedCount: 0, - firstProcessedId: null, lastProcessedId: null, - foundOldest: true, foundNewest: false).toJson()); - final didPass = invokeUpdateMessageFlagsStartingFromAnchor(); - await tester.pump(Duration.zero); - check(connection.lastRequest).isA() - ..method.equals('POST') - ..url.path.equals('/api/v1/messages/flags/narrow') - ..bodyFields.deepEquals({ - 'anchor': 'oldest', - 'include_anchor': 'false', - 'num_before': '0', - 'num_after': '1000', - 'narrow': jsonEncode(apiNarrow), - 'op': 'add', - 'flag': 'read', - }); - checkErrorDialog(tester, - expectedTitle: onFailedTitle, - expectedMessage: zulipLocalizations.errorInvalidResponse); - check(await didPass).isFalse(); + group('copyWithPopup', () { + setUp(() async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + MockClipboard().handleMethodCall, + ); + }); + + Future call(WidgetTester tester, {required String text}) async { + await tester.pumpWidget(TestZulipApp( + child: Scaffold( + body: Builder(builder: (context) => Center( + child: ElevatedButton( + onPressed: () async { + PlatformActions.copyWithPopup(context: context, + successContent: const Text('Text copied'), + data: ClipboardData(text: text)); + }, + child: const Text('Copy'))))))); + await tester.pump(); + await tester.tap(find.text('Copy')); + await tester.pump(); // copy + await tester.pump(Duration.zero); // await platform info (awkwardly async) + } + + Future checkSnackBar(WidgetTester tester, {required bool expected}) async { + if (!expected) { + check(tester.widgetList(find.byType(SnackBar))).isEmpty(); + return; + } + final snackBar = tester.widget(find.byType(SnackBar)); + check(snackBar.behavior).equals(SnackBarBehavior.floating); + tester.widget(find.descendant(matchRoot: true, + of: find.byWidget(snackBar.content), matching: find.text('Text copied'))); + } + + Future checkClipboardText(String expected) async { + check(await Clipboard.getData('text/plain')).isNotNull().text.equals(expected); + } + + testWidgets('iOS', (tester) async { + testBinding.deviceInfoResult = const IosDeviceInfo(systemVersion: '16.0'); + await call(tester, text: 'asdf'); + await checkClipboardText('asdf'); + await checkSnackBar(tester, expected: true); + }); + + testWidgets('Android', (tester) async { + testBinding.deviceInfoResult = const AndroidDeviceInfo(sdkInt: 33, release: '13'); + await call(tester, text: 'asdf'); + await checkClipboardText('asdf'); + await checkSnackBar(tester, expected: false); + }); + + testWidgets('Android <13', (tester) async { + testBinding.deviceInfoResult = const AndroidDeviceInfo(sdkInt: 32, release: '12'); + await call(tester, text: 'asdf'); + await checkClipboardText('asdf'); + await checkSnackBar(tester, expected: true); + }); }); - testWidgets('catch-all api errors', (tester) async { - await prepare(tester); - connection.prepare(httpException: http.ClientException('Oops')); - final didPass = invokeUpdateMessageFlagsStartingFromAnchor(); - await tester.pump(Duration.zero); - checkErrorDialog(tester, - expectedTitle: onFailedTitle, - expectedMessage: 'NetworkException: Oops (ClientException: Oops)'); - check(await didPass).isFalse(); + group('launchUrl', () { + Future call(WidgetTester tester, {required Uri url}) async { + await tester.pumpWidget(TestZulipApp( + child: Builder(builder: (context) => Center( + child: ElevatedButton( + onPressed: () async { + await PlatformActions.launchUrl(context, url); + }, + child: const Text('link')))))); + await tester.pump(); + await tester.tap(find.text('link')); + await tester.pump(Duration.zero); + } + + final httpUrl = Uri.parse('https://chat.example'); + final nonHttpUrl = Uri.parse('mailto:chat@example'); + + Future runAndCheckSuccess(WidgetTester tester, { + required Uri url, + required UrlLaunchMode expectedModeAndroid, + required UrlLaunchMode expectedModeIos, + }) async { + await call(tester, url: url); + + final expectedMode = switch (defaultTargetPlatform) { + TargetPlatform.android => expectedModeAndroid, + TargetPlatform.iOS => expectedModeIos, + _ => throw StateError('attempted to test with $defaultTargetPlatform'), + }; + check(testBinding.takeLaunchUrlCalls()).single + .equals((url: url, mode: expectedMode)); + } + + final androidIosVariant = TargetPlatformVariant({TargetPlatform.iOS, TargetPlatform.android}); + + testWidgets('globalSettings.browserPreference is null; use our per-platform defaults for HTTP links', (tester) async { + await testBinding.globalStore.settings.setBrowserPreference(null); + await runAndCheckSuccess(tester, + url: httpUrl, + expectedModeAndroid: UrlLaunchMode.inAppBrowserView, + expectedModeIos: UrlLaunchMode.externalApplication); + }, variant: androidIosVariant); + + testWidgets('globalSettings.browserPreference is null; use our per-platform defaults for non-HTTP links', (tester) async { + await testBinding.globalStore.settings.setBrowserPreference(null); + await runAndCheckSuccess(tester, + url: nonHttpUrl, + expectedModeAndroid: UrlLaunchMode.platformDefault, + expectedModeIos: UrlLaunchMode.externalApplication); + }, variant: androidIosVariant); + + testWidgets('globalSettings.browserPreference is inApp; follow the user preference for http links', (tester) async { + await testBinding.globalStore.settings.setBrowserPreference(BrowserPreference.inApp); + await runAndCheckSuccess(tester, + url: httpUrl, + expectedModeAndroid: UrlLaunchMode.inAppBrowserView, + expectedModeIos: UrlLaunchMode.inAppBrowserView); + }, variant: androidIosVariant); + + testWidgets('globalSettings.browserPreference is inApp; use platform default for non-http links', (tester) async { + await testBinding.globalStore.settings.setBrowserPreference(BrowserPreference.inApp); + await runAndCheckSuccess(tester, + url: nonHttpUrl, + expectedModeAndroid: UrlLaunchMode.platformDefault, + expectedModeIos: UrlLaunchMode.platformDefault); + }, variant: androidIosVariant); + + testWidgets('globalSettings.browserPreference is external; follow the user preference', (tester) async { + await testBinding.globalStore.settings.setBrowserPreference(BrowserPreference.external); + await runAndCheckSuccess(tester, + url: httpUrl, + expectedModeAndroid: UrlLaunchMode.externalApplication, + expectedModeIos: UrlLaunchMode.externalApplication); + }, variant: androidIosVariant); + + testWidgets('ZulipBinding.launchUrl returns false', (tester) async { + testBinding.launchUrlResult = false; + await call(tester, url: httpUrl); + checkErrorDialog(tester, expectedTitle: 'Unable to open link'); + }, variant: androidIosVariant); + + testWidgets('ZulipBinding.launchUrl throws PlatformException', (tester) async { + testBinding.launchUrlException = PlatformException(code: 'code', message: 'error message'); + await call(tester, url: httpUrl); + checkErrorDialog(tester, + expectedTitle: 'Unable to open link', + expectedMessage: 'Link could not be opened: ${httpUrl.toString()}\n\nerror message'); + }, variant: androidIosVariant); }); }); } diff --git a/test/widgets/clipboard_test.dart b/test/widgets/clipboard_test.dart deleted file mode 100644 index 1c0da04284..0000000000 --- a/test/widgets/clipboard_test.dart +++ /dev/null @@ -1,81 +0,0 @@ -import 'package:checks/checks.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:zulip/model/binding.dart'; -import 'package:zulip/widgets/clipboard.dart'; - -import '../flutter_checks.dart'; -import '../model/binding.dart'; -import '../test_clipboard.dart'; -import 'test_app.dart'; - -void main() { - TestZulipBinding.ensureInitialized(); - TestWidgetsFlutterBinding.ensureInitialized(); - - setUp(() async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( - SystemChannels.platform, - MockClipboard().handleMethodCall, - ); - }); - - tearDown(() async { - testBinding.reset(); - }); - - group('copyWithPopup', () { - Future call(WidgetTester tester, {required String text}) async { - await tester.pumpWidget(TestZulipApp( - child: Scaffold( - body: Builder(builder: (context) => Center( - child: ElevatedButton( - onPressed: () async { - copyWithPopup(context: context, successContent: const Text('Text copied'), - data: ClipboardData(text: text)); - }, - child: const Text('Copy'))))))); - await tester.pump(); - await tester.tap(find.text('Copy')); - await tester.pump(); // copy - await tester.pump(Duration.zero); // await platform info (awkwardly async) - } - - Future checkSnackBar(WidgetTester tester, {required bool expected}) async { - if (!expected) { - check(tester.widgetList(find.byType(SnackBar))).isEmpty(); - return; - } - final snackBar = tester.widget(find.byType(SnackBar)); - check(snackBar.behavior).equals(SnackBarBehavior.floating); - tester.widget(find.descendant(matchRoot: true, - of: find.byWidget(snackBar.content), matching: find.text('Text copied'))); - } - - Future checkClipboardText(String expected) async { - check(await Clipboard.getData('text/plain')).isNotNull().text.equals(expected); - } - - testWidgets('iOS', (tester) async { - testBinding.deviceInfoResult = const IosDeviceInfo(systemVersion: '16.0'); - await call(tester, text: 'asdf'); - await checkClipboardText('asdf'); - await checkSnackBar(tester, expected: true); - }); - - testWidgets('Android', (tester) async { - testBinding.deviceInfoResult = const AndroidDeviceInfo(sdkInt: 33, release: '13'); - await call(tester, text: 'asdf'); - await checkClipboardText('asdf'); - await checkSnackBar(tester, expected: false); - }); - - testWidgets('Android <13', (tester) async { - testBinding.deviceInfoResult = const AndroidDeviceInfo(sdkInt: 32, release: '12'); - await call(tester, text: 'asdf'); - await checkClipboardText('asdf'); - await checkSnackBar(tester, expected: true); - }); - }); -} diff --git a/test/widgets/dialog_checks.dart b/test/widgets/dialog_checks.dart index af0c6e2963..86acec96ce 100644 --- a/test/widgets/dialog_checks.dart +++ b/test/widgets/dialog_checks.dart @@ -23,6 +23,8 @@ Widget checkErrorDialog(WidgetTester tester, { of: find.byWidget(dialog.content!), matching: find.text(expectedMessage))); } + // TODO check "Learn more" button? + return tester.widget( find.descendant(of: find.byWidget(dialog), matching: find.widgetWithText(TextButton, 'OK'))); diff --git a/test/widgets/dialog_test.dart b/test/widgets/dialog_test.dart new file mode 100644 index 0000000000..4082e45424 --- /dev/null +++ b/test/widgets/dialog_test.dart @@ -0,0 +1,29 @@ +import 'package:checks/checks.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:zulip/widgets/dialog.dart'; + +import '../model/binding.dart'; +import 'test_app.dart'; + +void main() { + TestZulipBinding.ensureInitialized(); + + group('showErrorDialog', () { + testWidgets('tap "Learn more" button', (tester) async { + addTearDown(testBinding.reset); + await tester.pumpWidget(TestZulipApp()); + await tester.pump(); + final element = tester.element(find.byType(Placeholder)); + + showErrorDialog(context: element, title: 'hello', + learnMoreButtonUrl: Uri.parse('https://foo.example')); + await tester.pump(); + await tester.tap(find.text('Learn more')); + check(testBinding.takeLaunchUrlCalls()).single.equals(( + url: Uri.parse('https://foo.example'), + mode: LaunchMode.inAppBrowserView)); + }); + }); +} diff --git a/test/widgets/home_test.dart b/test/widgets/home_test.dart index 1525355766..e940d2ce77 100644 --- a/test/widgets/home_test.dart +++ b/test/widgets/home_test.dart @@ -477,4 +477,8 @@ void main () { // access to the account being logged out. check(testBinding.globalStore).accountIds.isEmpty(); }); + + // TODO end-to-end widget test that checks the error dialog when connecting + // to an ancient server: + // https://github.com/zulip/zulip-flutter/pull/1410#discussion_r1999991512 }