Skip to content

Commit 0e22aca

Browse files
authored
Add support for image insertion on Android (#110052)
* Add support for image insertion on Android * Fix checks * Use proper Dart syntax on snippet * Specify type annotation on list * Fix nits, add some asserts, and improve example code * Add missing import * Fix nullsafety error * Fix nullsafety error * Remove reference to contentCommitMimeTypes in docs * Fix nits * Fix warnings and import * Add test for content commit in editable_text_test.dart * Check that URIs are equal in test * Fix nits and rename functions / classes to be more self-explanatory * Fix failing debugFillProperties tests * Add empty implementation to `insertContent` in TextInputClient * Tweak documentation slightly * Improve docs for contentInsertionMimeTypes and fix assert * Rework contentInsertionMimeType asserts * Add test for onContentInserted example * Switch implementation to a configuration class for more granularity in setting mime types * Fix nits * Improve docs and fix doc tests * Fix more nits (LongCatIsLooong) * Fix failing tests * Make parameters (guaranteed by platform to be non-nullable) non-nullable * Fix analysis issues
1 parent 7bf95f4 commit 0e22aca

File tree

12 files changed

+437
-2
lines changed

12 files changed

+437
-2
lines changed
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
// Copyright 2014 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
// Flutter code sample for EditableText.onContentInserted
6+
7+
import 'dart:typed_data';
8+
9+
import 'package:flutter/material.dart';
10+
11+
void main() => runApp(const KeyboardInsertedContentApp());
12+
13+
class KeyboardInsertedContentApp extends StatelessWidget {
14+
const KeyboardInsertedContentApp({super.key});
15+
16+
static const String _title = 'Keyboard Inserted Content Sample';
17+
18+
@override
19+
Widget build(BuildContext context) {
20+
return const MaterialApp(
21+
title: _title,
22+
home: KeyboardInsertedContentDemo(),
23+
);
24+
}
25+
}
26+
27+
class KeyboardInsertedContentDemo extends StatefulWidget {
28+
const KeyboardInsertedContentDemo({super.key});
29+
30+
@override
31+
State<KeyboardInsertedContentDemo> createState() => _KeyboardInsertedContentDemoState();
32+
}
33+
34+
class _KeyboardInsertedContentDemoState extends State<KeyboardInsertedContentDemo> {
35+
final TextEditingController _controller = TextEditingController();
36+
Uint8List? bytes;
37+
38+
@override
39+
void dispose() {
40+
_controller.dispose();
41+
super.dispose();
42+
}
43+
44+
@override
45+
Widget build(BuildContext context) {
46+
return Scaffold(
47+
appBar: AppBar(title: const Text('Keyboard Inserted Content Sample')),
48+
body: Column(
49+
mainAxisAlignment: MainAxisAlignment.center,
50+
children: <Widget>[
51+
const Text("Here's a text field that supports inserting only png or gif content:"),
52+
TextField(
53+
controller: _controller,
54+
contentInsertionConfiguration: ContentInsertionConfiguration(
55+
allowedMimeTypes: const <String>['image/png', 'image/gif'],
56+
onContentInserted: (KeyboardInsertedContent data) async {
57+
if (data.data != null) {
58+
setState(() {
59+
bytes = data.data;
60+
});
61+
}
62+
},
63+
),
64+
),
65+
if (bytes != null)
66+
const Text("Here's the most recently inserted content:"),
67+
if (bytes != null)
68+
Image.memory(bytes!),
69+
],
70+
),
71+
);
72+
}
73+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
// Copyright 2014 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
import 'dart:convert';
6+
7+
import 'package:flutter/material.dart';
8+
import 'package:flutter/services.dart';
9+
import 'package:flutter_api_samples/widgets/editable_text/editable_text.on_content_inserted.0.dart' as example;
10+
import 'package:flutter_test/flutter_test.dart';
11+
12+
void main() {
13+
testWidgets('Image.memory displays inserted content', (WidgetTester tester) async {
14+
await tester.pumpWidget(
15+
const example.KeyboardInsertedContentApp(),
16+
);
17+
18+
expect(find.text('Keyboard Inserted Content Sample'), findsOneWidget);
19+
20+
await tester.tap(find.byType(EditableText));
21+
await tester.enterText(find.byType(EditableText), 'test');
22+
await tester.idle();
23+
24+
const String uri = 'content://com.google.android.inputmethod.latin.fileprovider/test.png';
25+
const List<int> kBlueSquarePng = <int>[
26+
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49,
27+
0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x32, 0x00, 0x00, 0x00, 0x32, 0x08, 0x06,
28+
0x00, 0x00, 0x00, 0x1e, 0x3f, 0x88, 0xb1, 0x00, 0x00, 0x00, 0x48, 0x49, 0x44,
29+
0x41, 0x54, 0x78, 0xda, 0xed, 0xcf, 0x31, 0x0d, 0x00, 0x30, 0x08, 0x00, 0xb0,
30+
0x61, 0x63, 0x2f, 0xfe, 0x2d, 0x61, 0x05, 0x34, 0xf0, 0x92, 0xd6, 0x41, 0x23,
31+
0x7f, 0xf5, 0x3b, 0x20, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44,
32+
0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44,
33+
0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44,
34+
0x44, 0x44, 0x44, 0x36, 0x06, 0x03, 0x6e, 0x69, 0x47, 0x12, 0x8e, 0xea, 0xaa,
35+
0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
36+
];
37+
final ByteData? messageBytes = const JSONMessageCodec().encodeMessage(<String, dynamic>{
38+
'args': <dynamic>[
39+
-1,
40+
'TextInputAction.commitContent',
41+
jsonDecode('{"mimeType": "image/png", "data": $kBlueSquarePng, "uri": "$uri"}'),
42+
],
43+
'method': 'TextInputClient.performAction',
44+
});
45+
46+
try {
47+
await ServicesBinding.instance.defaultBinaryMessenger.handlePlatformMessage(
48+
'flutter/textinput',
49+
messageBytes,
50+
(ByteData? _) {},
51+
);
52+
} catch (_) {}
53+
54+
await tester.pumpAndSettle();
55+
expect(find.byType(Image), findsOneWidget);
56+
});
57+
}

packages/flutter/lib/services.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export 'src/services/deferred_component.dart';
2121
export 'src/services/font_loader.dart';
2222
export 'src/services/haptic_feedback.dart';
2323
export 'src/services/hardware_keyboard.dart';
24+
export 'src/services/keyboard_inserted_content.dart';
2425
export 'src/services/keyboard_key.g.dart';
2526
export 'src/services/keyboard_maps.g.dart';
2627
export 'src/services/message_codec.dart';

packages/flutter/lib/src/cupertino/text_field.dart

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,7 @@ class CupertinoTextField extends StatefulWidget {
274274
this.scrollController,
275275
this.scrollPhysics,
276276
this.autofillHints = const <String>[],
277+
this.contentInsertionConfiguration,
277278
this.clipBehavior = Clip.hardEdge,
278279
this.restorationId,
279280
this.scribbleEnabled = true,
@@ -403,6 +404,7 @@ class CupertinoTextField extends StatefulWidget {
403404
this.scrollController,
404405
this.scrollPhysics,
405406
this.autofillHints = const <String>[],
407+
this.contentInsertionConfiguration,
406408
this.clipBehavior = Clip.hardEdge,
407409
this.restorationId,
408410
this.scribbleEnabled = true,
@@ -723,6 +725,9 @@ class CupertinoTextField extends StatefulWidget {
723725
/// {@macro flutter.services.TextInputConfiguration.enableIMEPersonalizedLearning}
724726
final bool enableIMEPersonalizedLearning;
725727

728+
/// {@macro flutter.widgets.editableText.contentInsertionConfiguration}
729+
final ContentInsertionConfiguration? contentInsertionConfiguration;
730+
726731
/// {@macro flutter.widgets.EditableText.contextMenuBuilder}
727732
///
728733
/// If not provided, will build a default menu based on the platform.
@@ -819,6 +824,7 @@ class CupertinoTextField extends StatefulWidget {
819824
properties.add(DiagnosticsProperty<bool>('scribbleEnabled', scribbleEnabled, defaultValue: true));
820825
properties.add(DiagnosticsProperty<bool>('enableIMEPersonalizedLearning', enableIMEPersonalizedLearning, defaultValue: true));
821826
properties.add(DiagnosticsProperty<SpellCheckConfiguration>('spellCheckConfiguration', spellCheckConfiguration, defaultValue: null));
827+
properties.add(DiagnosticsProperty<List<String>>('contentCommitMimeTypes', contentInsertionConfiguration?.allowedMimeTypes ?? const <String>[], defaultValue: contentInsertionConfiguration == null ? const <String>[] : kDefaultContentInsertionMimeTypes));
822828
}
823829

824830
static final TextMagnifierConfiguration _iosMagnifierConfiguration = TextMagnifierConfiguration(
@@ -1328,6 +1334,7 @@ class _CupertinoTextFieldState extends State<CupertinoTextField> with Restoratio
13281334
restorationId: 'editable',
13291335
scribbleEnabled: widget.scribbleEnabled,
13301336
enableIMEPersonalizedLearning: widget.enableIMEPersonalizedLearning,
1337+
contentInsertionConfiguration: widget.contentInsertionConfiguration,
13311338
contextMenuBuilder: widget.contextMenuBuilder,
13321339
spellCheckConfiguration: spellCheckConfiguration,
13331340
),

packages/flutter/lib/src/material/text_field.dart

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,7 @@ class TextField extends StatefulWidget {
307307
this.scrollController,
308308
this.scrollPhysics,
309309
this.autofillHints = const <String>[],
310+
this.contentInsertionConfiguration,
310311
this.clipBehavior = Clip.hardEdge,
311312
this.restorationId,
312313
this.scribbleEnabled = true,
@@ -754,6 +755,9 @@ class TextField extends StatefulWidget {
754755
/// {@macro flutter.services.TextInputConfiguration.enableIMEPersonalizedLearning}
755756
final bool enableIMEPersonalizedLearning;
756757

758+
/// {@macro flutter.widgets.editableText.contentInsertionConfiguration}
759+
final ContentInsertionConfiguration? contentInsertionConfiguration;
760+
757761
/// {@macro flutter.widgets.EditableText.contextMenuBuilder}
758762
///
759763
/// If not provided, will build a default menu based on the platform.
@@ -865,6 +869,7 @@ class TextField extends StatefulWidget {
865869
properties.add(DiagnosticsProperty<bool>('scribbleEnabled', scribbleEnabled, defaultValue: true));
866870
properties.add(DiagnosticsProperty<bool>('enableIMEPersonalizedLearning', enableIMEPersonalizedLearning, defaultValue: true));
867871
properties.add(DiagnosticsProperty<SpellCheckConfiguration>('spellCheckConfiguration', spellCheckConfiguration, defaultValue: null));
872+
properties.add(DiagnosticsProperty<List<String>>('contentCommitMimeTypes', contentInsertionConfiguration?.allowedMimeTypes ?? const <String>[], defaultValue: contentInsertionConfiguration == null ? const <String>[] : kDefaultContentInsertionMimeTypes));
868873
}
869874
}
870875

@@ -1361,6 +1366,7 @@ class _TextFieldState extends State<TextField> with RestorationMixin implements
13611366
restorationId: 'editable',
13621367
scribbleEnabled: widget.scribbleEnabled,
13631368
enableIMEPersonalizedLearning: widget.enableIMEPersonalizedLearning,
1369+
contentInsertionConfiguration: widget.contentInsertionConfiguration,
13641370
contextMenuBuilder: widget.contextMenuBuilder,
13651371
spellCheckConfiguration: spellCheckConfiguration,
13661372
magnifierConfiguration: widget.magnifierConfiguration ?? TextMagnifier.adaptiveMagnifierConfiguration,
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
// Copyright 2014 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
import 'package:flutter/foundation.dart';
6+
7+
/// A class representing rich content (such as a PNG image) inserted via the
8+
/// system input method.
9+
///
10+
/// The following data is represented in this class:
11+
/// - MIME Type
12+
/// - Bytes
13+
/// - URI
14+
@immutable
15+
class KeyboardInsertedContent {
16+
/// Creates an object to represent content that is inserted from the virtual
17+
/// keyboard.
18+
///
19+
/// The mime type and URI will always be provided, but the bytedata may be null.
20+
const KeyboardInsertedContent({required this.mimeType, required this.uri, this.data});
21+
22+
/// Converts JSON received from the Flutter Engine into the Dart class.
23+
KeyboardInsertedContent.fromJson(Map<String, dynamic> metadata):
24+
mimeType = metadata['mimeType'] as String,
25+
uri = metadata['uri'] as String,
26+
data = metadata['data'] != null
27+
? Uint8List.fromList(List<int>.from(metadata['data'] as Iterable<dynamic>))
28+
: null;
29+
30+
/// The mime type of the inserted content.
31+
final String mimeType;
32+
33+
/// The URI (location) of the inserted content, usually a "content://" URI.
34+
final String uri;
35+
36+
/// The bytedata of the inserted content.
37+
final Uint8List? data;
38+
39+
/// Convenience getter to check if bytedata is available for the inserted content.
40+
bool get hasData => data?.isNotEmpty ?? false;
41+
42+
@override
43+
String toString() => '${objectRuntimeType(this, 'KeyboardInsertedContent')}($mimeType, $uri, $data)';
44+
45+
@override
46+
bool operator ==(Object other) {
47+
if (other.runtimeType != runtimeType) {
48+
return false;
49+
}
50+
return other is KeyboardInsertedContent
51+
&& other.mimeType == mimeType
52+
&& other.uri == uri
53+
&& other.data == data;
54+
}
55+
56+
@override
57+
int get hashCode => Object.hash(mimeType, uri, data);
58+
}

packages/flutter/lib/src/services/text_input.dart

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import 'package:vector_math/vector_math_64.dart' show Matrix4;
1717

1818
import 'autofill.dart';
1919
import 'clipboard.dart' show Clipboard;
20+
import 'keyboard_inserted_content.dart';
2021
import 'message_codec.dart';
2122
import 'platform_channel.dart';
2223
import 'system_channels.dart';
@@ -477,6 +478,7 @@ class TextInputConfiguration {
477478
this.textCapitalization = TextCapitalization.none,
478479
this.autofillConfiguration = AutofillConfiguration.disabled,
479480
this.enableIMEPersonalizedLearning = true,
481+
this.allowedMimeTypes = const <String>[],
480482
this.enableDeltaModel = false,
481483
}) : smartDashesType = smartDashesType ?? (obscureText ? SmartDashesType.disabled : SmartDashesType.enabled),
482484
smartQuotesType = smartQuotesType ?? (obscureText ? SmartQuotesType.disabled : SmartQuotesType.enabled);
@@ -618,6 +620,9 @@ class TextInputConfiguration {
618620
/// {@endtemplate}
619621
final bool enableIMEPersonalizedLearning;
620622

623+
/// {@macro flutter.widgets.contentInsertionConfiguration.allowedMimeTypes}
624+
final List<String> allowedMimeTypes;
625+
621626
/// Creates a copy of this [TextInputConfiguration] with the given fields
622627
/// replaced with new values.
623628
TextInputConfiguration copyWith({
@@ -634,6 +639,7 @@ class TextInputConfiguration {
634639
Brightness? keyboardAppearance,
635640
TextCapitalization? textCapitalization,
636641
bool? enableIMEPersonalizedLearning,
642+
List<String>? allowedMimeTypes,
637643
AutofillConfiguration? autofillConfiguration,
638644
bool? enableDeltaModel,
639645
}) {
@@ -650,6 +656,7 @@ class TextInputConfiguration {
650656
textCapitalization: textCapitalization ?? this.textCapitalization,
651657
keyboardAppearance: keyboardAppearance ?? this.keyboardAppearance,
652658
enableIMEPersonalizedLearning: enableIMEPersonalizedLearning?? this.enableIMEPersonalizedLearning,
659+
allowedMimeTypes: allowedMimeTypes ?? this.allowedMimeTypes,
653660
autofillConfiguration: autofillConfiguration ?? this.autofillConfiguration,
654661
enableDeltaModel: enableDeltaModel ?? this.enableDeltaModel,
655662
);
@@ -697,6 +704,7 @@ class TextInputConfiguration {
697704
'textCapitalization': textCapitalization.toString(),
698705
'keyboardAppearance': keyboardAppearance.toString(),
699706
'enableIMEPersonalizedLearning': enableIMEPersonalizedLearning,
707+
'contentCommitMimeTypes': allowedMimeTypes,
700708
if (autofill != null) 'autofill': autofill,
701709
'enableDeltaModel' : enableDeltaModel,
702710
};
@@ -1105,6 +1113,9 @@ mixin TextInputClient {
11051113
/// Requests that this client perform the given action.
11061114
void performAction(TextInputAction action);
11071115

1116+
/// Notify client about new content insertion from Android keyboard.
1117+
void insertContent(KeyboardInsertedContent content) {}
1118+
11081119
/// Request from the input method that this client perform the given private
11091120
/// command.
11101121
///
@@ -1847,7 +1858,12 @@ class TextInput {
18471858
(_currentConnection!._client as DeltaTextInputClient).updateEditingValueWithDeltas(deltas);
18481859
break;
18491860
case 'TextInputClient.performAction':
1850-
_currentConnection!._client.performAction(_toTextInputAction(args[1] as String));
1861+
if (args[1] as String == 'TextInputAction.commitContent') {
1862+
final KeyboardInsertedContent content = KeyboardInsertedContent.fromJson(args[2] as Map<String, dynamic>);
1863+
_currentConnection!._client.insertContent(content);
1864+
} else {
1865+
_currentConnection!._client.performAction(_toTextInputAction(args[1] as String));
1866+
}
18511867
break;
18521868
case 'TextInputClient.performSelectors':
18531869
final List<String> selectors = (args[1] as List<dynamic>).cast<String>();

0 commit comments

Comments
 (0)