diff --git a/lib/model/binding.dart b/lib/model/binding.dart index 7ec9a6a41a..186f174a1a 100644 --- a/lib/model/binding.dart +++ b/lib/model/binding.dart @@ -1,3 +1,4 @@ +import 'package:device_info_plus/device_info_plus.dart' as device_info_plus; import 'package:flutter/foundation.dart'; import 'package:url_launcher/url_launcher.dart' as url_launcher; export 'package:url_launcher/url_launcher.dart' show LaunchMode; @@ -72,6 +73,38 @@ abstract class ZulipBinding { Uri url, { url_launcher.LaunchMode mode = url_launcher.LaunchMode.platformDefault, }); + + /// Provides device and operating system information, + /// via package:device_info_plus. + /// + /// This wraps [device_info_plus.DeviceInfoPlugin.deviceInfo]. + Future deviceInfo(); +} + +/// Like [device_info_plus.BaseDeviceInfo], but without things we don't use. +abstract class BaseDeviceInfo { + BaseDeviceInfo(); +} + +/// Like [device_info_plus.AndroidDeviceInfo], but without things we don't use. +class AndroidDeviceInfo extends BaseDeviceInfo { + /// The Android SDK version. + /// + /// Possible values are defined in: + /// https://developer.android.com/reference/android/os/Build.VERSION_CODES.html + final int sdkInt; + + AndroidDeviceInfo({required this.sdkInt}); +} + +/// Like [device_info_plus.IosDeviceInfo], but without things we don't use. +class IosDeviceInfo extends BaseDeviceInfo { + /// The current operating system version. + /// + /// See: https://developer.apple.com/documentation/uikit/uidevice/1620043-systemversion + final String systemVersion; + + IosDeviceInfo({required this.systemVersion}); } /// A concrete binding for use in the live application. @@ -103,4 +136,14 @@ class LiveZulipBinding extends ZulipBinding { }) { return url_launcher.launchUrl(url, mode: mode); } + + @override + Future deviceInfo() async { + final deviceInfo = await device_info_plus.DeviceInfoPlugin().deviceInfo; + return switch (deviceInfo) { + device_info_plus.AndroidDeviceInfo(:var version) => AndroidDeviceInfo(sdkInt: version.sdkInt), + device_info_plus.IosDeviceInfo(:var systemVersion) => IosDeviceInfo(systemVersion: systemVersion), + _ => throw UnimplementedError(), + }; + } } diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart index a40ed4995a..f7bee7b7f2 100644 --- a/lib/widgets/action_sheet.dart +++ b/lib/widgets/action_sheet.dart @@ -1,9 +1,11 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:share_plus/share_plus.dart'; import '../api/exception.dart'; import '../api/model/model.dart'; import '../api/route/messages.dart'; +import 'clipboard.dart'; import 'compose_box.dart'; import 'dialog.dart'; import 'draggable_scrollable_modal_bottom_sheet.dart'; @@ -28,6 +30,7 @@ void showMessageActionSheet({required BuildContext context, required Message mes message: message, messageListContext: context, ), + CopyButton(message: message, messageListContext: context), ]); }); } @@ -87,6 +90,53 @@ class ShareButton extends MessageActionSheetMenuItemButton { }; } +/// Fetch and return the raw Markdown content for [messageId], +/// showing an error dialog on failure. +Future fetchRawContentWithFeedback({ + required BuildContext context, + required int messageId, + required String errorDialogTitle, +}) async { + Message? fetchedMessage; + String? errorMessage; + // TODO, supported by reusable code: + // - (?) Retry with backoff on plausibly transient errors. + // - If request(s) take(s) a long time, show snackbar with cancel + // button, like "Still working on quote-and-reply…". + // On final failure or success, auto-dismiss the snackbar. + try { + fetchedMessage = await getMessageCompat(PerAccountStoreWidget.of(context).connection, + messageId: messageId, + applyMarkdown: false, + ); + if (fetchedMessage == null) { + errorMessage = 'That message does not seem to exist.'; + } + } catch (e) { + switch (e) { + case ZulipApiException(): + errorMessage = e.message; + // TODO specific messages for common errors, like network errors + // (support with reusable code) + default: + errorMessage = 'Could not fetch message source.'; + } + } + + if (!context.mounted) return null; + + if (fetchedMessage == null) { + assert(errorMessage != null); + // TODO(?) give no feedback on error conditions we expect to + // flag centrally in event polling, like invalid auth, + // user/realm deactivated. (Support with reusable code.) + await showErrorDialog(context: context, + title: errorDialogTitle, message: errorMessage); + } + + return fetchedMessage?.content; +} + class QuoteAndReplyButton extends MessageActionSheetMenuItemButton { QuoteAndReplyButton({ super.key, @@ -121,42 +171,11 @@ class QuoteAndReplyButton extends MessageActionSheetMenuItemButton { message: message, ); - Message? fetchedMessage; - String? errorMessage; - // TODO, supported by reusable code: - // - (?) Retry with backoff on plausibly transient errors. - // - If request(s) take(s) a long time, show snackbar with cancel - // button, like "Still working on quote-and-reply…". - // On final failure or success, auto-dismiss the snackbar. - try { - fetchedMessage = await getMessageCompat(PerAccountStoreWidget.of(messageListContext).connection, - messageId: message.id, - applyMarkdown: false, - ); - if (fetchedMessage == null) { - errorMessage = 'That message does not seem to exist.'; - } - } catch (e) { - switch (e) { - case ZulipApiException(): - errorMessage = e.message; - // TODO specific messages for common errors, like network errors - // (support with reusable code) - default: - errorMessage = 'Could not fetch message source.'; - } - } - - if (!messageListContext.mounted) return; - - if (fetchedMessage == null) { - assert(errorMessage != null); - // TODO(?) give no feedback on error conditions we expect to - // flag centrally in event polling, like invalid auth, - // user/realm deactivated. (Support with reusable code.) - await showErrorDialog(context: messageListContext, - title: 'Quotation failed', message: errorMessage); - } + final rawContent = await fetchRawContentWithFeedback( + context: messageListContext, + messageId: message.id, + errorDialogTitle: 'Quotation failed', + ); if (!messageListContext.mounted) return; @@ -167,10 +186,43 @@ class QuoteAndReplyButton extends MessageActionSheetMenuItemButton { composeBoxController.contentController .registerQuoteAndReplyEnd(PerAccountStoreWidget.of(messageListContext), tag, message: message, - rawContent: fetchedMessage?.content, + rawContent: rawContent, ); if (!composeBoxController.contentFocusNode.hasFocus) { composeBoxController.contentFocusNode.requestFocus(); } }; } + +class CopyButton extends MessageActionSheetMenuItemButton { + CopyButton({ + super.key, + required super.message, + required super.messageListContext, + }); + + @override get icon => Icons.copy; + + @override get label => 'Copy message text'; + + @override get onPressed => (BuildContext context) async { + // Close the message action sheet. We won't be showing request progress, + // but hopefully it won't take long at all, and + // fetchRawContentWithFeedback has a TODO for giving feedback if it does. + Navigator.of(context).pop(); + + final rawContent = await fetchRawContentWithFeedback( + context: messageListContext, + messageId: message.id, + errorDialogTitle: 'Copying failed', + ); + + if (rawContent == null) return; + + if (!messageListContext.mounted) return; + + // TODO(i18n) + copyWithPopup(context: context, successContent: const Text('Message copied'), + data: ClipboardData(text: rawContent)); + }; +} diff --git a/lib/widgets/clipboard.dart b/lib/widgets/clipboard.dart index 3e06c83760..27497c3713 100644 --- a/lib/widgets/clipboard.dart +++ b/lib/widgets/clipboard.dart @@ -1,7 +1,8 @@ -import 'package:device_info_plus/device_info_plus.dart'; 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. @@ -17,26 +18,18 @@ void copyWithPopup({ required Widget successContent, }) async { await Clipboard.setData(data); - final deviceInfo = await DeviceInfoPlugin().deviceInfo; - - // https://github.com/dart-lang/linter/issues/4007 - // ignore: use_build_context_synchronously - if (!context.mounted) { - return; - } + final deviceInfo = await ZulipBinding.instance.deviceInfo(); - final bool shouldShowSnackbar; - switch (deviceInfo) { - case AndroidDeviceInfo(:var version): - // 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 - shouldShowSnackbar = version.sdkInt <= 32; - default: - shouldShowSnackbar = true; - } + 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/test/flutter_checks.dart b/test/flutter_checks.dart index 998281e508..fbafce6d3a 100644 --- a/test/flutter_checks.dart +++ b/test/flutter_checks.dart @@ -4,7 +4,11 @@ import 'dart:ui'; import 'package:checks/checks.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/painting.dart'; +import 'package:flutter/services.dart'; +extension ClipboardDataChecks on Subject { + Subject get text => has((d) => d.text, 'text'); +} extension ValueNotifierChecks on Subject> { Subject get value => has((c) => c.value, 'value'); diff --git a/test/model/binding.dart b/test/model/binding.dart index 9bed223638..2fa197b302 100644 --- a/test/model/binding.dart +++ b/test/model/binding.dart @@ -59,6 +59,7 @@ class TestZulipBinding extends ZulipBinding { launchUrlResult = true; _launchUrlCalls = null; + deviceInfoResult = _defaultDeviceInfoResult; } /// The current global store offered to a [GlobalStoreWidget]. @@ -126,4 +127,15 @@ class TestZulipBinding extends ZulipBinding { (_launchUrlCalls ??= []).add((url: url, mode: mode)); return launchUrlResult; } + + /// The value that `ZulipBinding.instance.deviceInfo()` should return. + /// + /// See also [takeDeviceInfoCalls]. + BaseDeviceInfo deviceInfoResult = _defaultDeviceInfoResult; + static final _defaultDeviceInfoResult = AndroidDeviceInfo(sdkInt: 33); + + @override + Future deviceInfo() { + return Future(() => deviceInfoResult); + } } diff --git a/test/test_clipboard.dart b/test/test_clipboard.dart new file mode 100644 index 0000000000..e03aa4127b --- /dev/null +++ b/test/test_clipboard.dart @@ -0,0 +1,27 @@ +import 'package:flutter/services.dart'; + +// Inspired by MockClipboard in test code in the Flutter tree: +// https://github.com/flutter/flutter/blob/de26ad0a8/packages/flutter/test/widgets/clipboard_utils.dart +class MockClipboard { + MockClipboard(); + + dynamic clipboardData; + + Future handleMethodCall(MethodCall methodCall) async { + switch (methodCall.method) { + case 'Clipboard.getData': + return clipboardData; + case 'Clipboard.hasStrings': + final clipboardDataMap = clipboardData as Map?; + final text = clipboardDataMap?['text'] as String?; + return {'value': text != null && text.isNotEmpty}; + case 'Clipboard.setData': + clipboardData = methodCall.arguments; + default: + if (methodCall.method.startsWith('Clipboard.')) { + throw UnimplementedError(); + } + } + return null; + } +} diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index f4dd6087b4..1299ef9f7d 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -1,5 +1,6 @@ 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/api/model/model.dart'; import 'package:zulip/api/route/messages.dart'; @@ -16,6 +17,7 @@ import '../example_data.dart' as eg; import '../flutter_checks.dart'; import '../model/binding.dart'; import '../model/test_store.dart'; +import '../test_clipboard.dart'; import 'compose_box_checks.dart'; import 'dialog_checks.dart'; @@ -63,6 +65,26 @@ Future setupToMessageActionSheet(WidgetTester tester, { void main() { TestZulipBinding.ensureInitialized(); + void prepareRawContentResponseSuccess(PerAccountStore store, { + required Message message, + required String rawContent, + }) { + // Prepare fetch-raw-Markdown response + // TODO: Message should really only differ from `message` + // in its content / content_type, not in `id` or anything else. + (store.connection as FakeApiConnection).prepare(json: + GetMessageResult(message: eg.streamMessage(contentMarkdown: rawContent)).toJson()); + } + + void prepareRawContentResponseError(PerAccountStore store) { + final fakeResponseJson = { + 'code': 'BAD_REQUEST', + 'msg': 'Invalid message(s)', + 'result': 'error', + }; + (store.connection as FakeApiConnection).prepare(httpStatus: 400, json: fakeResponseJson); + } + group('QuoteAndReplyButton', () { ComposeBoxController? findComposeBoxController(WidgetTester tester) { return tester.widget(find.byType(ComposeBox)) @@ -73,26 +95,6 @@ void main() { return tester.widgetList(find.byIcon(Icons.format_quote_outlined)).singleOrNull; } - void prepareRawContentResponseSuccess(PerAccountStore store, { - required Message message, - required String rawContent, - }) { - // Prepare fetch-raw-Markdown response - // TODO: Message should really only differ from `message` - // in its content / content_type, not in `id` or anything else. - (store.connection as FakeApiConnection).prepare(json: - GetMessageResult(message: eg.streamMessage(contentMarkdown: rawContent)).toJson()); - } - - void prepareRawContentResponseError(PerAccountStore store) { - final fakeResponseJson = { - 'code': 'BAD_REQUEST', - 'msg': 'Invalid message(s)', - 'result': 'error', - }; - (store.connection as FakeApiConnection).prepare(httpStatus: 400, json: fakeResponseJson); - } - /// Simulates tapping the quote-and-reply button in the message action sheet. /// /// Checks that there is a quote-and-reply button. @@ -221,4 +223,48 @@ void main() { check(findQuoteAndReplyButton(tester)).isNull(); }); }); + + group('CopyButton', () { + setUp(() async { + TestZulipBinding.ensureInitialized(); + TestWidgetsFlutterBinding.ensureInitialized(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + MockClipboard().handleMethodCall, + ); + }); + + tearDown(() async { + TestZulipBinding.instance.reset(); + }); + + testWidgets('success', (WidgetTester tester) async { + final message = eg.streamMessage(); + await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); + final store = await TestZulipBinding.instance.globalStore.perAccount(eg.selfAccount.id); + + await tester.ensureVisible(find.byIcon(Icons.copy, skipOffstage: false)); + prepareRawContentResponseSuccess(store, message: message, rawContent: 'Hello world'); + await tester.tap(find.byIcon(Icons.copy)); + await tester.pump(Duration.zero); + check(await Clipboard.getData('text/plain')).isNotNull().text.equals('Hello world'); + }); + + testWidgets('request has an error', (WidgetTester tester) async { + final message = eg.streamMessage(); + await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); + final store = await TestZulipBinding.instance.globalStore.perAccount(eg.selfAccount.id); + + await tester.ensureVisible(find.byIcon(Icons.copy, skipOffstage: false)); + prepareRawContentResponseError(store); + await tester.tap(find.byIcon(Icons.copy)); + await tester.pump(Duration.zero); // error arrives; error dialog shows + + await tester.tap(find.byWidget(checkErrorDialog(tester, + expectedTitle: 'Copying failed', + expectedMessage: 'That message does not seem to exist.', + ))); + check(await Clipboard.getData('text/plain')).isNull(); + }); + }); } diff --git a/test/widgets/clipboard_test.dart b/test/widgets/clipboard_test.dart new file mode 100644 index 0000000000..a847f08aee --- /dev/null +++ b/test/widgets/clipboard_test.dart @@ -0,0 +1,82 @@ +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'; + +void main() { + TestZulipBinding.ensureInitialized(); + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + MockClipboard().handleMethodCall, + ); + }); + + tearDown(() async { + TestZulipBinding.instance.reset(); + }); + + group('copyWithPopup', () { + Future call(WidgetTester tester, {required String text}) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder(builder: (context) => Center( + child: ElevatedButton( + onPressed: () async { + // TODO(i18n) + copyWithPopup(context: context, successContent: const Text('Text copied'), + data: ClipboardData(text: text)); + }, + child: const Text('Copy'))))), + )); + 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', (WidgetTester tester) async { + TestZulipBinding.instance.deviceInfoResult = IosDeviceInfo(systemVersion: '16.0'); + await call(tester, text: 'asdf'); + await checkClipboardText('asdf'); + await checkSnackBar(tester, expected: true); + }); + + testWidgets('Android', (WidgetTester tester) async { + TestZulipBinding.instance.deviceInfoResult = AndroidDeviceInfo(sdkInt: 33); + await call(tester, text: 'asdf'); + await checkClipboardText('asdf'); + await checkSnackBar(tester, expected: false); + }); + + testWidgets('Android <13', (WidgetTester tester) async { + TestZulipBinding.instance.deviceInfoResult = AndroidDeviceInfo(sdkInt: 32); + await call(tester, text: 'asdf'); + await checkClipboardText('asdf'); + await checkSnackBar(tester, expected: true); + }); + }); +}