Skip to content

Commit 43f3cfe

Browse files
committed
compose: Support images from keyboard for Android
Fixes: #419 Fixes: #1173 Signed-off-by: Zixuan James Li <[email protected]>
1 parent 0005944 commit 43f3cfe

11 files changed

+199
-0
lines changed

assets/l10n/app_en.arb

+8
Original file line numberDiff line numberDiff line change
@@ -458,6 +458,14 @@
458458
"@topicValidationErrorMandatoryButEmpty": {
459459
"description": "Topic validation error when topic is required but was empty."
460460
},
461+
"errorContentNotInsertedTitle": "Content not inserted",
462+
"@errorContentNotInsertedTitle": {
463+
"description": "Title for error dialog when an attempt to insert rich content failed."
464+
},
465+
"errorContentToInsertIsEmpty": "The file to be inserted is empty or cannot be accessed.",
466+
"@errorContentToInsertIsEmpty": {
467+
"description": "Error message when the rich content to be inserted is empty or cannot be accessed."
468+
},
461469
"errorInvalidResponse": "The server sent an invalid response",
462470
"@errorInvalidResponse": {
463471
"description": "Error message when an API call returned an invalid response."

lib/generated/l10n/zulip_localizations.dart

+12
Original file line numberDiff line numberDiff line change
@@ -723,6 +723,18 @@ abstract class ZulipLocalizations {
723723
/// **'Topics are required in this organization.'**
724724
String get topicValidationErrorMandatoryButEmpty;
725725

726+
/// Title for error dialog when an attempt to insert rich content failed.
727+
///
728+
/// In en, this message translates to:
729+
/// **'Content not inserted'**
730+
String get errorContentNotInsertedTitle;
731+
732+
/// Error message when the rich content to be inserted is empty or cannot be accessed.
733+
///
734+
/// In en, this message translates to:
735+
/// **'The file to be inserted is empty or cannot be accessed.'**
736+
String get errorContentToInsertIsEmpty;
737+
726738
/// Error message when an API call returned an invalid response.
727739
///
728740
/// In en, this message translates to:

lib/generated/l10n/zulip_localizations_ar.dart

+6
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,12 @@ class ZulipLocalizationsAr extends ZulipLocalizations {
357357
@override
358358
String get topicValidationErrorMandatoryButEmpty => 'Topics are required in this organization.';
359359

360+
@override
361+
String get errorContentNotInsertedTitle => 'Content not inserted';
362+
363+
@override
364+
String get errorContentToInsertIsEmpty => 'The file to be inserted is empty or cannot be accessed.';
365+
360366
@override
361367
String get errorInvalidResponse => 'The server sent an invalid response';
362368

lib/generated/l10n/zulip_localizations_en.dart

+6
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,12 @@ class ZulipLocalizationsEn extends ZulipLocalizations {
357357
@override
358358
String get topicValidationErrorMandatoryButEmpty => 'Topics are required in this organization.';
359359

360+
@override
361+
String get errorContentNotInsertedTitle => 'Content not inserted';
362+
363+
@override
364+
String get errorContentToInsertIsEmpty => 'The file to be inserted is empty or cannot be accessed.';
365+
360366
@override
361367
String get errorInvalidResponse => 'The server sent an invalid response';
362368

lib/generated/l10n/zulip_localizations_ja.dart

+6
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,12 @@ class ZulipLocalizationsJa extends ZulipLocalizations {
357357
@override
358358
String get topicValidationErrorMandatoryButEmpty => 'Topics are required in this organization.';
359359

360+
@override
361+
String get errorContentNotInsertedTitle => 'Content not inserted';
362+
363+
@override
364+
String get errorContentToInsertIsEmpty => 'The file to be inserted is empty or cannot be accessed.';
365+
360366
@override
361367
String get errorInvalidResponse => 'The server sent an invalid response';
362368

lib/generated/l10n/zulip_localizations_nb.dart

+6
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,12 @@ class ZulipLocalizationsNb extends ZulipLocalizations {
357357
@override
358358
String get topicValidationErrorMandatoryButEmpty => 'Topics are required in this organization.';
359359

360+
@override
361+
String get errorContentNotInsertedTitle => 'Content not inserted';
362+
363+
@override
364+
String get errorContentToInsertIsEmpty => 'The file to be inserted is empty or cannot be accessed.';
365+
360366
@override
361367
String get errorInvalidResponse => 'The server sent an invalid response';
362368

lib/generated/l10n/zulip_localizations_pl.dart

+6
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,12 @@ class ZulipLocalizationsPl extends ZulipLocalizations {
357357
@override
358358
String get topicValidationErrorMandatoryButEmpty => 'Wątki są wymagane przez tę organizację.';
359359

360+
@override
361+
String get errorContentNotInsertedTitle => 'Content not inserted';
362+
363+
@override
364+
String get errorContentToInsertIsEmpty => 'The file to be inserted is empty or cannot be accessed.';
365+
360366
@override
361367
String get errorInvalidResponse => 'Nieprawidłowa odpowiedź serwera';
362368

lib/generated/l10n/zulip_localizations_ru.dart

+6
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,12 @@ class ZulipLocalizationsRu extends ZulipLocalizations {
357357
@override
358358
String get topicValidationErrorMandatoryButEmpty => 'Темы обязательны в этой организации.';
359359

360+
@override
361+
String get errorContentNotInsertedTitle => 'Content not inserted';
362+
363+
@override
364+
String get errorContentToInsertIsEmpty => 'The file to be inserted is empty or cannot be accessed.';
365+
360366
@override
361367
String get errorInvalidResponse => 'Получен недопустимый ответ сервера';
362368

lib/generated/l10n/zulip_localizations_sk.dart

+6
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,12 @@ class ZulipLocalizationsSk extends ZulipLocalizations {
357357
@override
358358
String get topicValidationErrorMandatoryButEmpty => 'Topics are required in this organization.';
359359

360+
@override
361+
String get errorContentNotInsertedTitle => 'Content not inserted';
362+
363+
@override
364+
String get errorContentToInsertIsEmpty => 'The file to be inserted is empty or cannot be accessed.';
365+
360366
@override
361367
String get errorInvalidResponse => 'Server poslal nesprávnu odpoveď';
362368

lib/widgets/compose_box.dart

+36
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import 'package:app_settings/app_settings.dart';
44
import 'package:flutter/material.dart';
55
import 'package:flutter/services.dart';
66
import 'package:mime/mime.dart';
7+
import 'package:path/path.dart' as path;
78

89
import '../api/exception.dart';
910
import '../api/model/model.dart';
@@ -399,6 +400,39 @@ class _ContentInputState extends State<_ContentInput> with WidgetsBindingObserve
399400
}
400401
}
401402

403+
void _handleContentInserted(KeyboardInsertedContent content) async {
404+
if (content.data == null || content.data!.isEmpty) {
405+
// As of writing, the engine implementation never leaves `content.data` as
406+
// `null`, but ideally it should be when the data cannot be read for
407+
// errors.
408+
//
409+
// When `content.data` is empty, the data is not literally empty — this
410+
// can also happen when the data can't be read from the input stream
411+
// provided by the Android SDK because of an IO exception.
412+
//
413+
// See Flutter engine implementation that prepares this data:
414+
// https://github.com/flutter/flutter/blob/0ffc4ce00/engine/src/flutter/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java#L497-L548
415+
// TODO(upstream): improve the API for this
416+
final zulipLocalizations = ZulipLocalizations.of(context);
417+
showErrorDialog(context: context,
418+
title: zulipLocalizations.errorContentNotInsertedTitle,
419+
message: zulipLocalizations.errorContentToInsertIsEmpty);
420+
return;
421+
}
422+
423+
final file = _File(
424+
content: Stream.fromIterable([content.data!]),
425+
length: content.data!.length,
426+
filename: path.basename(content.uri),
427+
mimeType: content.mimeType);
428+
429+
await _uploadFiles(
430+
context: context,
431+
contentController: widget.controller.content,
432+
contentFocusNode: widget.controller.contentFocusNode,
433+
files: [file]);
434+
}
435+
402436
static double maxHeight(BuildContext context) {
403437
final clampingTextScaler = MediaQuery.textScalerOf(context)
404438
.clamp(maxScaleFactor: 1.5);
@@ -442,6 +476,8 @@ class _ContentInputState extends State<_ContentInput> with WidgetsBindingObserve
442476
child: TextField(
443477
controller: widget.controller.content,
444478
focusNode: widget.controller.contentFocusNode,
479+
contentInsertionConfiguration: ContentInsertionConfiguration(
480+
onContentInserted: _handleContentInserted),
445481
// Let the content show through the `contentPadding` so that
446482
// our [InsetShadowBox] can fade it smoothly there.
447483
clipBehavior: Clip.none,

test/widgets/compose_box_test.dart

+101
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import 'dart:io';
44

55
import 'package:checks/checks.dart';
66
import 'package:file_picker/file_picker.dart';
7+
import 'package:flutter/services.dart';
78
import 'package:flutter_checks/flutter_checks.dart';
89
import 'package:http/http.dart' as http;
910
import 'package:flutter/material.dart';
@@ -690,6 +691,106 @@ void main() {
690691
// target platform the test is simulating.
691692
// TODO(upstream): unskip after fix to https://github.com/flutter/flutter/issues/161073
692693
skip: Platform.isWindows);
694+
695+
group('attach from keyboard', () {
696+
// This is adapted from:
697+
// https://github.com/flutter/flutter/blob/0ffc4ce00/packages/flutter/test/widgets/editable_text_test.dart#L724-L740
698+
Future<void> insertContentFromKeyboard(WidgetTester tester, {
699+
required List<int>? data,
700+
required String attachedFileUrl,
701+
required String mimeType,
702+
}) async {
703+
await tester.showKeyboard(contentInputFinder);
704+
// This invokes [EditableText.performAction] on the content [TextField],
705+
// which did not expose an API for testing.
706+
// TODO(upstream): support a better API for testing this
707+
await tester.binding.defaultBinaryMessenger.handlePlatformMessage(
708+
SystemChannels.textInput.name,
709+
SystemChannels.textInput.codec.encodeMethodCall(
710+
MethodCall('TextInputClient.performAction', <dynamic>[
711+
-1,
712+
'TextInputAction.commitContent',
713+
// This fakes data originally provided by the Flutter engine:
714+
// https://github.com/flutter/flutter/blob/0ffc4ce00/engine/src/flutter/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java#L497-L548
715+
{
716+
"mimeType": mimeType,
717+
"data": data,
718+
"uri": attachedFileUrl,
719+
},
720+
])),
721+
(ByteData? data) {});
722+
}
723+
724+
testWidgets('success', (tester) async {
725+
const fileContent = [1, 0, 1, 0, 0];
726+
await prepare(tester);
727+
const uploadUrl = '/user_uploads/1/4e/m2A3MSqFnWRLUf9SaPzQ0Up_/test.gif';
728+
connection.prepare(json: UploadFileResult(uri: uploadUrl).toJson());
729+
await insertContentFromKeyboard(tester,
730+
data: fileContent,
731+
attachedFileUrl:
732+
'content://com.zulip.android.zulipboard.provider'
733+
'/root/com.zulip.android.zulipboard/candidate_temp/test.gif',
734+
mimeType: 'image/gif');
735+
736+
await tester.pump();
737+
check(controller!.content.text)
738+
.equals('see image: [Uploading test.gif…]()\n\n');
739+
// (the request is checked more thoroughly in API tests)
740+
check(connection.lastRequest!).isA<http.MultipartRequest>()
741+
..method.equals('POST')
742+
..files.single.which((it) => it
743+
..field.equals('file')
744+
..length.equals(fileContent.length)
745+
..filename.equals('test.gif')
746+
..contentType.asString.equals('image/gif')
747+
..has<Future<List<int>>>((f) => f.finalize().toBytes(), 'contents')
748+
.completes((it) => it.deepEquals(fileContent))
749+
);
750+
checkAppearsLoading(tester, true);
751+
752+
await tester.pump(Duration.zero);
753+
check(controller!.content.text)
754+
.equals('see image: [test.gif]($uploadUrl)\n\n');
755+
checkAppearsLoading(tester, false);
756+
});
757+
758+
testWidgets('data is null', (tester) async {
759+
await prepare(tester);
760+
await insertContentFromKeyboard(tester,
761+
data: null,
762+
attachedFileUrl:
763+
'content://com.zulip.android.zulipboard.provider'
764+
'/root/com.zulip.android.zulipboard/candidate_temp/test.gif',
765+
mimeType: 'image/jpeg');
766+
767+
await tester.pump();
768+
check(controller!.content.text).equals('see image: ');
769+
check(connection.takeRequests()).isEmpty();
770+
checkErrorDialog(tester,
771+
expectedTitle: 'Content not inserted',
772+
expectedMessage: 'The file to be inserted is empty or cannot be accessed.');
773+
checkAppearsLoading(tester, false);
774+
});
775+
776+
testWidgets('data is empty', (tester) async {
777+
await prepare(tester);
778+
await insertContentFromKeyboard(tester,
779+
data: [],
780+
attachedFileUrl:
781+
'content://com.zulip.android.zulipboard.provider'
782+
'/root/com.zulip.android.zulipboard/candidate_temp/test.gif',
783+
mimeType: 'image/jpeg');
784+
785+
await tester.pump();
786+
check(controller!.content.text).equals('see image: ');
787+
check(connection.takeRequests()).isEmpty();
788+
checkErrorDialog(tester,
789+
expectedTitle: 'Content not inserted',
790+
expectedMessage: 'The file to be inserted is empty or cannot be accessed.');
791+
checkAppearsLoading(tester, false);
792+
});
793+
});
693794
});
694795

695796
group('error banner', () {

0 commit comments

Comments
 (0)