From 43f3cfe6a56cfb4f97da7e9a09c52e21aa4c0a45 Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Mon, 23 Dec 2024 20:27:54 -0500 Subject: [PATCH] compose: Support images from keyboard for Android Fixes: #419 Fixes: #1173 Signed-off-by: Zixuan James Li --- assets/l10n/app_en.arb | 8 ++ lib/generated/l10n/zulip_localizations.dart | 12 +++ .../l10n/zulip_localizations_ar.dart | 6 ++ .../l10n/zulip_localizations_en.dart | 6 ++ .../l10n/zulip_localizations_ja.dart | 6 ++ .../l10n/zulip_localizations_nb.dart | 6 ++ .../l10n/zulip_localizations_pl.dart | 6 ++ .../l10n/zulip_localizations_ru.dart | 6 ++ .../l10n/zulip_localizations_sk.dart | 6 ++ lib/widgets/compose_box.dart | 36 +++++++ test/widgets/compose_box_test.dart | 101 ++++++++++++++++++ 11 files changed, 199 insertions(+) diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index 58822303fd..56d448c24a 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 accessed.", + "@errorContentToInsertIsEmpty": { + "description": "Error message when the rich content to be inserted is empty or cannot be accessed." + }, "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 6ff41633fd..6bbac8cfbc 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -723,6 +723,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 be inserted is empty or cannot be accessed. + /// + /// In en, this message translates to: + /// **'The file to be inserted is empty or cannot be accessed.'** + 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 542b85031b..4e4214c943 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 accessed.'; + @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 b6bc9f72e7..b96d5af4c6 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 accessed.'; + @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 7adbc9ae8a..10040d095e 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 accessed.'; + @override String get errorInvalidResponse => 'The server sent an invalid response'; diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart index 99c545f98e..6f125da51a 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -357,6 +357,12 @@ class ZulipLocalizationsNb 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 accessed.'; + @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 e7a05a58aa..630e90c9ba 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 accessed.'; + @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 2082984588..7036d0bc49 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 => 'Темы обязательны в этой организации.'; + @override + String get errorContentNotInsertedTitle => 'Content not inserted'; + + @override + String get errorContentToInsertIsEmpty => 'The file to be inserted is empty or cannot be accessed.'; + @override String get errorInvalidResponse => 'Получен недопустимый ответ сервера'; diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart index fabfa06eb4..dcc3d917d1 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -357,6 +357,12 @@ class ZulipLocalizationsSk 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 accessed.'; + @override String get errorInvalidResponse => 'Server poslal nesprávnu odpoveď'; diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index 750c703085..074a0d4a2c 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'; @@ -399,6 +400,39 @@ class _ContentInputState extends State<_ContentInput> with WidgetsBindingObserve } } + void _handleContentInserted(KeyboardInsertedContent content) async { + if (content.data == null || content.data!.isEmpty) { + // As of writing, the engine implementation never leaves `content.data` as + // `null`, but ideally it should be when the data cannot be read for + // errors. + // + // When `content.data` is empty, the data is not literally empty — this + // can also happen when the data can't be read from the input stream + // provided by the Android SDK because of an IO exception. + // + // See Flutter engine implementation that prepares this data: + // https://github.com/flutter/flutter/blob/0ffc4ce00/engine/src/flutter/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java#L497-L548 + // TODO(upstream): improve the API for this + 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); @@ -442,6 +476,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 4cd80e6fe2..380b757647 100644 --- a/test/widgets/compose_box_test.dart +++ b/test/widgets/compose_box_test.dart @@ -4,6 +4,7 @@ import 'dart:io'; 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'; @@ -690,6 +691,106 @@ void main() { // target platform the test is simulating. // TODO(upstream): unskip after fix to https://github.com/flutter/flutter/issues/161073 skip: Platform.isWindows); + + group('attach from keyboard', () { + // This is adapted from: + // https://github.com/flutter/flutter/blob/0ffc4ce00/packages/flutter/test/widgets/editable_text_test.dart#L724-L740 + Future insertContentFromKeyboard(WidgetTester tester, { + required List? data, + required String attachedFileUrl, + required String mimeType, + }) async { + await tester.showKeyboard(contentInputFinder); + // This invokes [EditableText.performAction] on the content [TextField], + // which did not expose an API for testing. + // TODO(upstream): support a better API for testing this + await tester.binding.defaultBinaryMessenger.handlePlatformMessage( + SystemChannels.textInput.name, + SystemChannels.textInput.codec.encodeMethodCall( + MethodCall('TextInputClient.performAction', [ + -1, + 'TextInputAction.commitContent', + // This fakes data originally provided by the Flutter engine: + // https://github.com/flutter/flutter/blob/0ffc4ce00/engine/src/flutter/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java#L497-L548 + { + "mimeType": mimeType, + "data": data, + "uri": attachedFileUrl, + }, + ])), + (ByteData? data) {}); + } + + 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.zulip.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'); + // (the request is checked more thoroughly in API tests) + 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('data is null', (tester) async { + await prepare(tester); + await insertContentFromKeyboard(tester, + data: null, + attachedFileUrl: + 'content://com.zulip.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 accessed.'); + checkAppearsLoading(tester, false); + }); + + testWidgets('data is empty', (tester) async { + await prepare(tester); + await insertContentFromKeyboard(tester, + data: [], + attachedFileUrl: + 'content://com.zulip.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 accessed.'); + checkAppearsLoading(tester, false); + }); + }); }); group('error banner', () {