diff --git a/shell/platform/android/io/flutter/embedding/engine/systemchannels/TextInputChannel.java b/shell/platform/android/io/flutter/embedding/engine/systemchannels/TextInputChannel.java index ecdf86c71820e..88f4d63409f58 100644 --- a/shell/platform/android/io/flutter/embedding/engine/systemchannels/TextInputChannel.java +++ b/shell/platform/android/io/flutter/embedding/engine/systemchannels/TextInputChannel.java @@ -16,6 +16,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Set; import org.json.JSONArray; @@ -317,6 +318,14 @@ public void previous(int inputClientId) { "TextInputClient.performAction", Arrays.asList(inputClientId, "TextInputAction.previous")); } + /** Instructs flutter to commit content back to the text channel. */ + public void commitContent(int inputClientId, Map content) { + Log.v(TAG, "Sending 'commitContent' message."); + channel.invokeMethod( + "TextInputClient.performAction", + Arrays.asList(inputClientId, "TextInputAction.commitContent", content)); + } + /** Instructs Flutter to execute an "unspecified" action. */ public void unspecifiedAction(int inputClientId) { Log.v(TAG, "Sending 'unspecified' message."); @@ -452,6 +461,19 @@ public static Configuration fromJson(@NonNull JSONObject json) } } final Integer inputAction = inputActionFromTextInputAction(inputActionName); + + // Build list of content commit mime types from the passed in JSON list + List contentList = new ArrayList(); + JSONArray contentCommitMimeTypes = + json.isNull("contentCommitMimeTypes") + ? null + : json.getJSONArray("contentCommitMimeTypes"); + if (contentCommitMimeTypes != null) { + for (int i = 0; i < contentCommitMimeTypes.length(); i++) { + contentList.add(contentCommitMimeTypes.optString(i)); + } + } + return new Configuration( json.optBoolean("obscureText"), json.optBoolean("autocorrect", true), @@ -463,6 +485,7 @@ public static Configuration fromJson(@NonNull JSONObject json) inputAction, json.isNull("actionLabel") ? null : json.getString("actionLabel"), json.isNull("autofill") ? null : Autofill.fromJson(json.getJSONObject("autofill")), + contentList.toArray(new String[contentList.size()]), fields); } @@ -619,6 +642,7 @@ public Autofill( @Nullable public final Integer inputAction; @Nullable public final String actionLabel; @Nullable public final Autofill autofill; + @Nullable public final String[] contentCommitMimeTypes; @Nullable public final Configuration[] fields; public Configuration( @@ -632,6 +656,7 @@ public Configuration( @Nullable Integer inputAction, @Nullable String actionLabel, @Nullable Autofill autofill, + @Nullable String[] contentCommitMimeTypes, @Nullable Configuration[] fields) { this.obscureText = obscureText; this.autocorrect = autocorrect; @@ -643,6 +668,7 @@ public Configuration( this.inputAction = inputAction; this.actionLabel = actionLabel; this.autofill = autofill; + this.contentCommitMimeTypes = contentCommitMimeTypes; this.fields = fields; } } diff --git a/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java b/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java index d3528a9ccc79d..56812274b1a78 100644 --- a/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java +++ b/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java @@ -4,9 +4,11 @@ package io.flutter.plugin.editing; +import android.annotation.TargetApi; import android.content.ClipData; import android.content.ClipboardManager; import android.content.Context; +import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.text.DynamicLayout; @@ -22,11 +24,20 @@ import android.view.inputmethod.EditorInfo; import android.view.inputmethod.ExtractedText; import android.view.inputmethod.ExtractedTextRequest; +import android.view.inputmethod.InputContentInfo; import android.view.inputmethod.InputMethodManager; +import androidx.annotation.RequiresApi; +import androidx.core.os.BuildCompat; +import androidx.core.view.inputmethod.InputConnectionCompat; import io.flutter.Log; import io.flutter.embedding.android.KeyboardManager; import io.flutter.embedding.engine.FlutterJNI; import io.flutter.embedding.engine.systemchannels.TextInputChannel; +import java.io.ByteArrayOutputStream; +import java.io.FileNotFoundException; +import java.io.InputStream; +import java.util.HashMap; +import java.util.Map; class InputConnectionAdaptor extends BaseInputConnection implements ListenableEditingState.EditingStateWatcher { @@ -473,6 +484,69 @@ public boolean performEditorAction(int actionCode) { return true; } + @Override + @TargetApi(25) + @RequiresApi(25) + public boolean commitContent(InputContentInfo inputContentInfo, int flags, Bundle opts) { + // Ensure permission is granted. + if (Build.VERSION.SDK_INT >= 25 + && (flags & InputConnectionCompat.INPUT_CONTENT_GRANT_READ_URI_PERMISSION) != 0) { + try { + inputContentInfo.requestPermission(); + } catch (Exception e) { + return false; + } + } else { + return false; + } + + if (inputContentInfo.getDescription().getMimeTypeCount() > 0) { + inputContentInfo.requestPermission(); + + final Uri uri = inputContentInfo.getContentUri(); + final String mimeType = inputContentInfo.getDescription().getMimeType(0); + Context context = mFlutterView.getContext(); + Boolean retval = false; + + try { + final InputStream is = context.getContentResolver().openInputStream(uri); + final byte[] data = this.readStreamFully(is, 64 * 1024); + + final Map obj = new HashMap<>(); + obj.put("mimeType", mimeType); + obj.put("data", data); + obj.put("uri", uri != null ? uri.toString() : null); + + // Commit the content to the text input channel + textInputChannel.commitContent(mClient, obj); + retval = true; + } catch (FileNotFoundException ex) { + } catch (Exception ex) { + } finally { + inputContentInfo.releasePermission(); + } + return retval; + } + return false; + } + + public byte[] readStreamFully(InputStream is, int blocksize) { + try { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + + byte[] buffer = new byte[blocksize]; + while (true) { + int len = is.read(buffer); + if (len == -1) break; + baos.write(buffer, 0, len); + } + return baos.toByteArray(); + } catch (Exception e) { + } + + return new byte[0]; + } + // -------- Start: ListenableEditingState watcher implementation ------- @Override public void didChangeEditingState( diff --git a/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java b/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java index 7ca0febb19c39..c23cd740a8c5d 100644 --- a/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java +++ b/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java @@ -25,6 +25,7 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; +import androidx.core.view.inputmethod.EditorInfoCompat; import io.flutter.Log; import io.flutter.embedding.android.KeyboardManager; import io.flutter.embedding.engine.systemchannels.TextInputChannel; @@ -339,6 +340,11 @@ public InputConnection createInputConnection( } outAttrs.imeOptions |= enterAction; + if (configuration.contentCommitMimeTypes != null) { + String[] imgTypeString = configuration.contentCommitMimeTypes; + EditorInfoCompat.setContentMimeTypes(outAttrs, imgTypeString); + } + InputConnectionAdaptor connection = new InputConnectionAdaptor( view, inputTarget.id, textInputChannel, keyboardManager, mEditable, outAttrs); diff --git a/shell/platform/android/test/io/flutter/plugin/editing/InputConnectionAdaptorTest.java b/shell/platform/android/test/io/flutter/plugin/editing/InputConnectionAdaptorTest.java index 37a73cbf65978..e4c71e1b87796 100644 --- a/shell/platform/android/test/io/flutter/plugin/editing/InputConnectionAdaptorTest.java +++ b/shell/platform/android/test/io/flutter/plugin/editing/InputConnectionAdaptorTest.java @@ -16,9 +16,11 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import android.content.ClipDescription; import android.content.ClipboardManager; import android.content.Context; import android.content.res.AssetManager; +import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.text.InputType; @@ -31,6 +33,7 @@ import android.view.inputmethod.ExtractedText; import android.view.inputmethod.ExtractedTextRequest; import android.view.inputmethod.InputConnection; +import android.view.inputmethod.InputContentInfo; import android.view.inputmethod.InputMethodManager; import com.ibm.icu.lang.UCharacter; import com.ibm.icu.lang.UProperty; @@ -173,6 +176,44 @@ public void testPerformContextMenuAction_paste() { assertTrue(editable.toString().startsWith(textToBePasted)); } + @Test + public void testCommitContent() throws JSONException { + View testView = new View(RuntimeEnvironment.application); + int client = 0; + FlutterJNI mockFlutterJNI = mock(FlutterJNI.class); + DartExecutor dartExecutor = spy(new DartExecutor(mockFlutterJNI, mock(AssetManager.class))); + TextInputChannel textInputChannel = new TextInputChannel(dartExecutor); + ListenableEditingState editable = sampleEditable(0, 0); + InputConnectionAdaptor adaptor = + new InputConnectionAdaptor( + testView, + client, + textInputChannel, + mockKeyboardManager, + editable, + null, + mockFlutterJNI); + adaptor.commitContent( + new InputContentInfo( + Uri.parse("content://mock/uri/test/commitContent"), + new ClipDescription("commitContent test", new String[] {"image/png"})), + 0, + null); + + ArgumentCaptor channelCaptor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor bufferCaptor = ArgumentCaptor.forClass(ByteBuffer.class); + verify(dartExecutor, times(1)).send(channelCaptor.capture(), bufferCaptor.capture(), isNull()); + assertEquals("flutter/textinput", channelCaptor.getValue()); + verifyMethodCall( + bufferCaptor.getValue(), + "TextInputClient.performAction", + new String[] { + "0", + "TextInputAction.commitContent", + "{\"data\":[],\"mimeType\":\"image\\/png\",\"uri\":\"content:\\/\\/mock\\/uri\\/test\\/commitContent\"}" + }); + } + @Test public void testPerformPrivateCommand_dataIsNull() throws JSONException { View testView = new View(RuntimeEnvironment.application); diff --git a/shell/platform/android/test/io/flutter/plugin/editing/TextInputPluginTest.java b/shell/platform/android/test/io/flutter/plugin/editing/TextInputPluginTest.java index dfb80619a345b..bc87711f541b2 100644 --- a/shell/platform/android/test/io/flutter/plugin/editing/TextInputPluginTest.java +++ b/shell/platform/android/test/io/flutter/plugin/editing/TextInputPluginTest.java @@ -165,6 +165,7 @@ public void setTextInputEditingState_doesNotInvokeUpdateEditingState() { null, null, null, + null, null)); textInputPlugin.setTextInputEditingState( @@ -227,6 +228,7 @@ public void setTextInputEditingState_doesNotInvokeUpdateEditingStateWithDeltas() null, null, null, + null, null)); textInputPlugin.setTextInputEditingState( @@ -281,6 +283,7 @@ public void textEditingDelta_TestUpdateEditingValueWithDeltasIsNotInvokedWhenDel null, null, null, + null, null)); // There's a pending restart since we initialized the text input client. Flush that now. @@ -397,6 +400,7 @@ public void textEditingDelta_TestUpdateEditingValueIsNotInvokedWhenDeltaModelEna null, null, null, + null, null)); // There's a pending restart since we initialized the text input client. Flush that now. @@ -527,6 +531,7 @@ public void textEditingDelta_TestDeltaIsCreatedWhenComposingTextSetIsInserting() null, null, null, + null, null)); // There's a pending restart since we initialized the text input client. Flush that now. @@ -638,6 +643,7 @@ public void textEditingDelta_TestDeltaIsCreatedWhenComposingTextSetIsDeleting() null, null, null, + null, null)); // There's a pending restart since we initialized the text input client. Flush that now. @@ -747,6 +753,7 @@ public void textEditingDelta_TestDeltaIsCreatedWhenComposingTextSetIsReplacing() null, null, null, + null, null)); // There's a pending restart since we initialized the text input client. Flush that now. @@ -853,6 +860,7 @@ public void inputConnectionAdaptor_RepeatFilter() throws NullPointerException { null, null, null, + null, null)); // There's a pending restart since we initialized the text input client. Flush that now. @@ -943,6 +951,7 @@ public void setTextInputEditingState_doesNotRestartWhenTextIsIdentical() { null, null, null, + null, null)); // There's a pending restart since we initialized the text input client. Flush that now. textInputPlugin.setTextInputEditingState( @@ -982,6 +991,7 @@ public void setTextInputEditingState_alwaysSetEditableWhenDifferent() { null, null, null, + null, null)); // There's a pending restart since we initialized the text input client. Flush that now. With // changed text, we should @@ -1030,6 +1040,7 @@ public void setTextInputEditingState_restartsIMEOnlyWhenFrameworkChangesComposin null, null, null, + null, null)); // There's a pending restart since we initialized the text input client. Flush that now. textInputPlugin.setTextInputEditingState( @@ -1132,6 +1143,7 @@ public void setTextInputEditingState_nullInputMethodSubtype() { null, null, null, + null, null)); // There's a pending restart since we initialized the text input client. Flush that now. textInputPlugin.setTextInputEditingState( @@ -1176,6 +1188,7 @@ public void inputConnection_createsActionFromEnter() throws JSONException { null, null, null, + null, null)); // There's a pending restart since we initialized the text input client. Flush that now. textInputPlugin.setTextInputEditingState( @@ -1244,6 +1257,7 @@ public void inputConnection_finishComposingTextUpdatesIMM() throws JSONException null, null, null, + null, null)); // There's a pending restart since we initialized the text input client. Flush that now. textInputPlugin.setTextInputEditingState( @@ -1281,6 +1295,7 @@ public void inputConnection_textInputTypeNone() { null, null, null, + null, null)); InputConnection connection = @@ -1312,6 +1327,7 @@ public void showTextInput_textInputTypeNone() { null, null, null, + null, null)); textInputPlugin.showTextInput(testView); @@ -1344,6 +1360,7 @@ public void autofill_enabledByDefault() { null, null, autofill, + null, null); textInputPlugin.setTextInputClient( @@ -1359,6 +1376,7 @@ public void autofill_enabledByDefault() { null, null, autofill, + null, new TextInputChannel.Configuration[] {config})); final ViewStructure viewStructure = mock(ViewStructure.class); @@ -1401,6 +1419,7 @@ public void autofill_canBeDisabled() { null, null, null, + null, null); textInputPlugin.setTextInputClient(0, config); @@ -1440,6 +1459,7 @@ public void autofill_hintText() { null, null, autofill, + null, null); textInputPlugin.setTextInputClient(0, config); @@ -1489,6 +1509,7 @@ public void autofill_onProvideVirtualViewStructure() { null, null, autofill1, + null, null); final TextInputChannel.Configuration config2 = new TextInputChannel.Configuration( @@ -1502,6 +1523,7 @@ public void autofill_onProvideVirtualViewStructure() { null, null, autofill2, + null, null); textInputPlugin.setTextInputClient( @@ -1517,6 +1539,7 @@ public void autofill_onProvideVirtualViewStructure() { null, null, autofill1, + null, new TextInputChannel.Configuration[] {config1, config2})); final ViewStructure viewStructure = mock(ViewStructure.class); @@ -1572,6 +1595,7 @@ public void autofill_onProvideVirtualViewStructure_single() { null, null, autofill, + null, null)); final ViewStructure viewStructure = mock(ViewStructure.class); @@ -1630,6 +1654,7 @@ public void autofill_testLifeCycle() { null, null, autofill1, + null, null); final TextInputChannel.Configuration config2 = new TextInputChannel.Configuration( @@ -1643,6 +1668,7 @@ public void autofill_testLifeCycle() { null, null, autofill2, + null, null); // Set client. This should call notifyViewExited on the FlutterView if the previous client is @@ -1659,6 +1685,7 @@ public void autofill_testLifeCycle() { null, null, autofill1, + null, new TextInputChannel.Configuration[] {config1, config2}); textInputPlugin.setTextInputClient(0, autofillConfiguration); @@ -1703,6 +1730,7 @@ public void autofill_testLifeCycle() { null, null, null, + null, null)); assertEquals("1".hashCode(), testAfm.exitId); @@ -1761,6 +1789,7 @@ public void autofill_testAutofillUpdatesTheFramework() { null, null, autofill1, + null, null); final TextInputChannel.Configuration config2 = new TextInputChannel.Configuration( @@ -1774,6 +1803,7 @@ public void autofill_testAutofillUpdatesTheFramework() { null, null, autofill2, + null, null); final TextInputChannel.Configuration autofillConfiguration = @@ -1788,6 +1818,7 @@ public void autofill_testAutofillUpdatesTheFramework() { null, null, autofill1, + null, new TextInputChannel.Configuration[] {config1, config2}); textInputPlugin.setTextInputClient(0, autofillConfiguration); @@ -1856,6 +1887,7 @@ public void autofill_testSetTextIpnutClientUpdatesSideFields() { null, null, autofill1, + null, null); final TextInputChannel.Configuration config2 = new TextInputChannel.Configuration( @@ -1869,6 +1901,7 @@ public void autofill_testSetTextIpnutClientUpdatesSideFields() { null, null, autofill2, + null, null); final TextInputChannel.Configuration autofillConfiguration = @@ -1883,6 +1916,7 @@ public void autofill_testSetTextIpnutClientUpdatesSideFields() { null, null, autofill1, + null, new TextInputChannel.Configuration[] {config1, config2}); textInputPlugin.setTextInputClient(0, autofillConfiguration);