Skip to content

Commit af8c5b3

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

10 files changed

+171
-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 found.",
466+
"@errorContentToInsertIsEmpty": {
467+
"description": "Error message when the rich content to insert is empty or cannot be found."
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
@@ -721,6 +721,18 @@ abstract class ZulipLocalizations {
721721
/// **'Topics are required in this organization.'**
722722
String get topicValidationErrorMandatoryButEmpty;
723723

724+
/// Title for error dialog when an attempt to insert rich content failed.
725+
///
726+
/// In en, this message translates to:
727+
/// **'Content not inserted'**
728+
String get errorContentNotInsertedTitle;
729+
730+
/// Error message when the rich content to insert is empty or cannot be found.
731+
///
732+
/// In en, this message translates to:
733+
/// **'The file to be inserted is empty or cannot be found.'**
734+
String get errorContentToInsertIsEmpty;
735+
724736
/// Error message when an API call returned an invalid response.
725737
///
726738
/// 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 found.';
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 found.';
365+
360366
@override
361367
String get errorInvalidResponse => 'The server sent an invalid response';
362368

lib/generated/l10n/zulip_localizations_fr.dart

+6
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,12 @@ class ZulipLocalizationsFr 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 found.';
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 found.';
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 found.';
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 => '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 found.';
365+
360366
@override
361367
String get errorInvalidResponse => 'The server sent an invalid response';
362368

lib/widgets/compose_box.dart

+29
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';
@@ -362,6 +363,32 @@ class _ContentInputState extends State<_ContentInput> with WidgetsBindingObserve
362363
}
363364
}
364365

366+
void _handleContentInserted(KeyboardInsertedContent content) async {
367+
if (!content.hasData) {
368+
// The data can be empty when the URL is associated with an empty
369+
// resource. See Flutter engine implementation that provides this data:
370+
// https://github.com/flutter/flutter/blob/0ffc4ce00ea7bb912e379adf39354644eab2c17e/engine/src/flutter/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java#L497-L548
371+
final zulipLocalizations = ZulipLocalizations.of(context);
372+
showErrorDialog(
373+
context: context,
374+
title: zulipLocalizations.errorContentNotInsertedTitle,
375+
message: zulipLocalizations.errorContentToInsertIsEmpty);
376+
return;
377+
}
378+
379+
final file = _File(
380+
content: Stream.fromIterable([content.data!]),
381+
length: content.data!.length,
382+
filename: path.basename(content.uri),
383+
mimeType: content.mimeType);
384+
385+
await _uploadFiles(
386+
context: context,
387+
contentController: widget.controller.content,
388+
contentFocusNode: widget.controller.contentFocusNode,
389+
files: [file]);
390+
}
391+
365392
static double maxHeight(BuildContext context) {
366393
final clampingTextScaler = MediaQuery.textScalerOf(context)
367394
.clamp(maxScaleFactor: 1.5);
@@ -405,6 +432,8 @@ class _ContentInputState extends State<_ContentInput> with WidgetsBindingObserve
405432
child: TextField(
406433
controller: widget.controller.content,
407434
focusNode: widget.controller.contentFocusNode,
435+
contentInsertionConfiguration: ContentInsertionConfiguration(
436+
onContentInserted: _handleContentInserted),
408437
// Let the content show through the `contentPadding` so that
409438
// our [InsetShadowBox] can fade it smoothly there.
410439
clipBehavior: Clip.none,

test/widgets/compose_box_test.dart

+86
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import 'dart:convert';
33

44
import 'package:checks/checks.dart';
55
import 'package:file_picker/file_picker.dart';
6+
import 'package:flutter/services.dart';
67
import 'package:flutter_checks/flutter_checks.dart';
78
import 'package:http/http.dart' as http;
89
import 'package:flutter/material.dart';
@@ -575,6 +576,91 @@ void main() {
575576

576577
// TODO test what happens when capturing/uploading fails
577578
});
579+
580+
group('attach from keyboard', () {
581+
// This is adapted from:
582+
// https://github.com/flutter/flutter/blob/0ffc4ce00ea7bb912e379adf39354644eab2c17e/packages/flutter/test/widgets/editable_text_test.dart#L724-L740
583+
Future<void> insertContentFromKeyboard(WidgetTester tester, {
584+
required List<int> data,
585+
required String attachedFileUrl,
586+
required String mimeType,
587+
}) async {
588+
// This fakes data originally provided by the Flutter engine:
589+
// https://github.com/flutter/flutter/blob/0ffc4ce00ea7bb912e379adf39354644eab2c17e/engine/src/flutter/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java#L497-L548
590+
final ByteData? messageBytes = const JSONMessageCodec().encodeMessage({
591+
'args': [
592+
-1,
593+
'TextInputAction.commitContent',
594+
{
595+
"mimeType": mimeType,
596+
"data": data,
597+
"uri": attachedFileUrl,
598+
},
599+
],
600+
'method': 'TextInputClient.performAction',
601+
});
602+
// This calls [EditableText]'s implementation of
603+
// [TextInputClient.performAction] on the content [TextField],
604+
// which did not expose an API for testing.
605+
await tester.binding.defaultBinaryMessenger.handlePlatformMessage(
606+
'flutter/textinput',
607+
messageBytes,
608+
(ByteData? _) {},
609+
);
610+
}
611+
612+
testWidgets('success', (tester) async {
613+
const fileContent = [1, 0, 1, 0, 0];
614+
await prepare(tester);
615+
const uploadUrl = '/user_uploads/1/4e/m2A3MSqFnWRLUf9SaPzQ0Up_/test.gif';
616+
connection.prepare(json: UploadFileResult(uri: uploadUrl).toJson());
617+
await insertContentFromKeyboard(tester,
618+
data: fileContent,
619+
attachedFileUrl:
620+
'content://com.samsung.android.zulipboard.provider'
621+
'/root/com.zulip.android.zulipboard/candidate_temp/test.gif',
622+
mimeType: 'image/gif');
623+
624+
await tester.pump();
625+
check(controller!.content.text)
626+
.equals('see image: [Uploading test.gif…]()\n\n');
627+
check(connection.lastRequest!).isA<http.MultipartRequest>()
628+
..method.equals('POST')
629+
..files.single.which((it) => it
630+
..field.equals('file')
631+
..length.equals(fileContent.length)
632+
..filename.equals('test.gif')
633+
..contentType.asString.equals('image/gif')
634+
..has<Future<List<int>>>((f) => f.finalize().toBytes(), 'contents')
635+
.completes((it) => it.deepEquals(fileContent))
636+
);
637+
checkAppearsLoading(tester, true);
638+
639+
await tester.pump(Duration.zero);
640+
check(controller!.content.text)
641+
.equals('see image: [test.gif]($uploadUrl)\n\n');
642+
checkAppearsLoading(tester, false);
643+
});
644+
645+
testWidgets('empty file', (tester) async {
646+
await prepare(tester);
647+
await insertContentFromKeyboard(tester,
648+
data: [],
649+
attachedFileUrl:
650+
'content://com.samsung.android.zulipboard.provider'
651+
'/root/com.zulip.android.zulipboard/candidate_temp/test.gif',
652+
mimeType: 'image/jpeg');
653+
654+
await tester.pump();
655+
check(controller!.content.text).equals('see image: ');
656+
check(connection.takeRequests()).isEmpty();
657+
checkErrorDialog(tester,
658+
expectedTitle: 'Content not inserted',
659+
expectedMessage: 'The file to be inserted is empty or cannot be found.');
660+
checkAppearsLoading(tester, false);
661+
});
662+
663+
});
578664
});
579665

580666
group('error banner', () {

0 commit comments

Comments
 (0)