diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index 58822303fdb..56107777631 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -458,6 +458,14 @@ "@topicValidationErrorMandatoryButEmpty": { "description": "Topic validation error when topic is required but was empty." }, + "errorContentNotInsertedTitle": "Content not inserted", + "@errorContentNotInsertedTitle": { + "description": "Title for error dialog when an attempt to insert rich content failed." + }, + "errorContentToInsertIsEmpty": "The file to be inserted is empty or cannot be found.", + "@errorContentToInsertIsEmpty": { + "description": "Error message when the rich content to insert is empty or cannot be found." + }, "errorInvalidResponse": "The server sent an invalid response", "@errorInvalidResponse": { "description": "Error message when an API call returned an invalid response." diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index 00d7cfde72b..19732ce7986 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -721,6 +721,18 @@ abstract class ZulipLocalizations { /// **'Topics are required in this organization.'** String get topicValidationErrorMandatoryButEmpty; + /// Title for error dialog when an attempt to insert rich content failed. + /// + /// In en, this message translates to: + /// **'Content not inserted'** + String get errorContentNotInsertedTitle; + + /// Error message when the rich content to insert is empty or cannot be found. + /// + /// In en, this message translates to: + /// **'The file to be inserted is empty or cannot be found.'** + String get errorContentToInsertIsEmpty; + /// Error message when an API call returned an invalid response. /// /// 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 542b85031bc..93da3730fdf 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -357,6 +357,12 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get topicValidationErrorMandatoryButEmpty => 'Topics are required in this organization.'; + @override + String get errorContentNotInsertedTitle => 'Content not inserted'; + + @override + String get errorContentToInsertIsEmpty => 'The file to be inserted is empty or cannot be found.'; + @override String get errorInvalidResponse => 'The server sent an invalid response'; diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index b6bc9f72e7f..2fbc43b7ad9 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -357,6 +357,12 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get topicValidationErrorMandatoryButEmpty => 'Topics are required in this organization.'; + @override + String get errorContentNotInsertedTitle => 'Content not inserted'; + + @override + String get errorContentToInsertIsEmpty => 'The file to be inserted is empty or cannot be found.'; + @override String get errorInvalidResponse => 'The server sent an invalid response'; diff --git a/lib/generated/l10n/zulip_localizations_fr.dart b/lib/generated/l10n/zulip_localizations_fr.dart index c857da2c82f..63c0f11093d 100644 --- a/lib/generated/l10n/zulip_localizations_fr.dart +++ b/lib/generated/l10n/zulip_localizations_fr.dart @@ -357,6 +357,12 @@ class ZulipLocalizationsFr extends ZulipLocalizations { @override String get topicValidationErrorMandatoryButEmpty => 'Topics are required in this organization.'; + @override + String get errorContentNotInsertedTitle => 'Content not inserted'; + + @override + String get errorContentToInsertIsEmpty => 'The file to be inserted is empty or cannot be found.'; + @override String get errorInvalidResponse => 'The server sent an invalid response'; diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart index 7adbc9ae8ad..aa13c0b2181 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -357,6 +357,12 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get topicValidationErrorMandatoryButEmpty => 'Topics are required in this organization.'; + @override + String get errorContentNotInsertedTitle => 'Content not inserted'; + + @override + String get errorContentToInsertIsEmpty => 'The file to be inserted is empty or cannot be found.'; + @override String get errorInvalidResponse => 'The server sent an invalid response'; diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index 07746b3f27c..9279bfabca3 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -357,6 +357,12 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get topicValidationErrorMandatoryButEmpty => 'Wątki są wymagane przez tę organizację.'; + @override + String get errorContentNotInsertedTitle => 'Content not inserted'; + + @override + String get errorContentToInsertIsEmpty => 'The file to be inserted is empty or cannot be found.'; + @override String get errorInvalidResponse => 'Nieprawidłowa odpowiedź serwera'; diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart index 9c2065376bb..7852c49d7a9 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -357,6 +357,12 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get topicValidationErrorMandatoryButEmpty => 'Topics are required in this organization.'; + @override + String get errorContentNotInsertedTitle => 'Content not inserted'; + + @override + String get errorContentToInsertIsEmpty => 'The file to be inserted is empty or cannot be found.'; + @override String get errorInvalidResponse => 'The server sent an invalid response'; diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index 114e392bc7b..d2c9531ac21 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -4,6 +4,7 @@ import 'package:app_settings/app_settings.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:mime/mime.dart'; +import 'package:path/path.dart' as path; import '../api/exception.dart'; import '../api/model/model.dart'; @@ -362,6 +363,32 @@ class _ContentInputState extends State<_ContentInput> with WidgetsBindingObserve } } + void _handleContentInserted(KeyboardInsertedContent content) async { + if (!content.hasData) { + // The data can be empty when the URL is associated with an empty + // resource. See Flutter engine implementation that provides this data: + // https://github.com/flutter/flutter/blob/0ffc4ce00ea7bb912e379adf39354644eab2c17e/engine/src/flutter/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java#L497-L548 + final zulipLocalizations = ZulipLocalizations.of(context); + showErrorDialog( + context: context, + title: zulipLocalizations.errorContentNotInsertedTitle, + message: zulipLocalizations.errorContentToInsertIsEmpty); + return; + } + + final file = _File( + content: Stream.fromIterable([content.data!]), + length: content.data!.length, + filename: path.basename(content.uri), + mimeType: content.mimeType); + + await _uploadFiles( + context: context, + contentController: widget.controller.content, + contentFocusNode: widget.controller.contentFocusNode, + files: [file]); + } + static double maxHeight(BuildContext context) { final clampingTextScaler = MediaQuery.textScalerOf(context) .clamp(maxScaleFactor: 1.5); @@ -405,6 +432,8 @@ class _ContentInputState extends State<_ContentInput> with WidgetsBindingObserve child: TextField( controller: widget.controller.content, focusNode: widget.controller.contentFocusNode, + contentInsertionConfiguration: ContentInsertionConfiguration( + onContentInserted: _handleContentInserted), // Let the content show through the `contentPadding` so that // our [InsetShadowBox] can fade it smoothly there. clipBehavior: Clip.none, diff --git a/test/widgets/compose_box_test.dart b/test/widgets/compose_box_test.dart index 59a3123cb08..664768ab581 100644 --- a/test/widgets/compose_box_test.dart +++ b/test/widgets/compose_box_test.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:checks/checks.dart'; import 'package:file_picker/file_picker.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_checks/flutter_checks.dart'; import 'package:http/http.dart' as http; import 'package:flutter/material.dart'; @@ -575,6 +576,91 @@ void main() { // TODO test what happens when capturing/uploading fails }); + + group('attach from keyboard', () { + // This is adapted from: + // https://github.com/flutter/flutter/blob/0ffc4ce00ea7bb912e379adf39354644eab2c17e/packages/flutter/test/widgets/editable_text_test.dart#L724-L740 + Future insertContentFromKeyboard(WidgetTester tester, { + required List data, + required String attachedFileUrl, + required String mimeType, + }) async { + // This fakes data originally provided by the Flutter engine: + // https://github.com/flutter/flutter/blob/0ffc4ce00ea7bb912e379adf39354644eab2c17e/engine/src/flutter/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java#L497-L548 + final ByteData? messageBytes = const JSONMessageCodec().encodeMessage({ + 'args': [ + -1, + 'TextInputAction.commitContent', + { + "mimeType": mimeType, + "data": data, + "uri": attachedFileUrl, + }, + ], + 'method': 'TextInputClient.performAction', + }); + // This calls [EditableText]'s implementation of + // [TextInputClient.performAction] on the content [TextField], + // which did not expose an API for testing. + await tester.binding.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/textinput', + messageBytes, + (ByteData? _) {}, + ); + } + + testWidgets('success', (tester) async { + const fileContent = [1, 0, 1, 0, 0]; + await prepare(tester); + const uploadUrl = '/user_uploads/1/4e/m2A3MSqFnWRLUf9SaPzQ0Up_/test.gif'; + connection.prepare(json: UploadFileResult(uri: uploadUrl).toJson()); + await insertContentFromKeyboard(tester, + data: fileContent, + attachedFileUrl: + 'content://com.samsung.android.zulipboard.provider' + '/root/com.zulip.android.zulipboard/candidate_temp/test.gif', + mimeType: 'image/gif'); + + await tester.pump(); + check(controller!.content.text) + .equals('see image: [Uploading test.gif…]()\n\n'); + check(connection.lastRequest!).isA() + ..method.equals('POST') + ..files.single.which((it) => it + ..field.equals('file') + ..length.equals(fileContent.length) + ..filename.equals('test.gif') + ..contentType.asString.equals('image/gif') + ..has>>((f) => f.finalize().toBytes(), 'contents') + .completes((it) => it.deepEquals(fileContent)) + ); + checkAppearsLoading(tester, true); + + await tester.pump(Duration.zero); + check(controller!.content.text) + .equals('see image: [test.gif]($uploadUrl)\n\n'); + checkAppearsLoading(tester, false); + }); + + testWidgets('empty file', (tester) async { + await prepare(tester); + await insertContentFromKeyboard(tester, + data: [], + attachedFileUrl: + 'content://com.samsung.android.zulipboard.provider' + '/root/com.zulip.android.zulipboard/candidate_temp/test.gif', + mimeType: 'image/jpeg'); + + await tester.pump(); + check(controller!.content.text).equals('see image: '); + check(connection.takeRequests()).isEmpty(); + checkErrorDialog(tester, + expectedTitle: 'Content not inserted', + expectedMessage: 'The file to be inserted is empty or cannot be found.'); + checkAppearsLoading(tester, false); + }); + + }); }); group('error banner', () {