Skip to content

Commit

Permalink
compose: Support images from keyboard for Android
Browse files Browse the repository at this point in the history
Fixes: #419
Fixes: #1173

Signed-off-by: Zixuan James Li <[email protected]>
  • Loading branch information
PIG208 committed Dec 24, 2024
1 parent df9a1de commit af8c5b3
Show file tree
Hide file tree
Showing 10 changed files with 171 additions and 0 deletions.
8 changes: 8 additions & 0 deletions assets/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
12 changes: 12 additions & 0 deletions lib/generated/l10n/zulip_localizations.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 6 additions & 0 deletions lib/generated/l10n/zulip_localizations_ar.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
6 changes: 6 additions & 0 deletions lib/generated/l10n/zulip_localizations_en.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
6 changes: 6 additions & 0 deletions lib/generated/l10n/zulip_localizations_fr.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
6 changes: 6 additions & 0 deletions lib/generated/l10n/zulip_localizations_ja.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
6 changes: 6 additions & 0 deletions lib/generated/l10n/zulip_localizations_pl.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
6 changes: 6 additions & 0 deletions lib/generated/l10n/zulip_localizations_ru.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
29 changes: 29 additions & 0 deletions lib/widgets/compose_box.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand Down
86 changes: 86 additions & 0 deletions test/widgets/compose_box_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<void> insertContentFromKeyboard(WidgetTester tester, {
required List<int> 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<http.MultipartRequest>()
..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<Future<List<int>>>((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', () {
Expand Down

0 comments on commit af8c5b3

Please sign in to comment.