-
Notifications
You must be signed in to change notification settings - Fork 6k
Add support for image insertion on Android #35619
Changes from all commits
4a68d3d
ebbf8d5
6b3793b
eee16ef
0fa0edd
6313ccb
d6d7031
db0cfee
76e8c2a
97a2d01
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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.NonNull; | ||
import androidx.annotation.RequiresApi; | ||
import androidx.core.view.inputmethod.InputConnectionCompat; | ||
import io.flutter.Log; | ||
import io.flutter.embedding.engine.FlutterJNI; | ||
import io.flutter.embedding.engine.systemchannels.TextInputChannel; | ||
import java.io.ByteArrayOutputStream; | ||
import java.io.FileNotFoundException; | ||
import java.io.IOException; | ||
import java.io.InputStream; | ||
import java.util.HashMap; | ||
import java.util.Map; | ||
|
||
public class InputConnectionAdaptor extends BaseInputConnection | ||
implements ListenableEditingState.EditingStateWatcher { | ||
|
@@ -477,6 +488,75 @@ 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(); | ||
|
||
if (uri != null) { | ||
InputStream is; | ||
try { | ||
// Extract byte data from the given URI. | ||
is = context.getContentResolver().openInputStream(uri); | ||
} catch (FileNotFoundException ex) { | ||
inputContentInfo.releasePermission(); | ||
return false; | ||
} | ||
|
||
if (is != null) { | ||
final byte[] data = this.readStreamFully(is, 64 * 1024); | ||
|
||
final Map<String, Object> obj = new HashMap<>(); | ||
obj.put("mimeType", mimeType); | ||
obj.put("data", data); | ||
obj.put("uri", uri.toString()); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Are all 3 of these parameters guaranteed to be valid at this point, or should there be any checking and error handling here? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not sure to be honest. This is why we made the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. So I guess it will be up to the framework to handle an invalid CommittedContent. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Correct, or the developer themselves. They can choose what they want to do if a parameter they need happens to be null. |
||
|
||
// Commit the content to the text input channel and release the permission. | ||
textInputChannel.commitContent(mClient, obj); | ||
inputContentInfo.releasePermission(); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do you have to release permission when There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed. Good catch :) |
||
return true; | ||
} | ||
} | ||
|
||
inputContentInfo.releasePermission(); | ||
} | ||
return false; | ||
} | ||
|
||
private byte[] readStreamFully(InputStream is, int blocksize) { | ||
ByteArrayOutputStream baos = new ByteArrayOutputStream(); | ||
|
||
byte[] buffer = new byte[blocksize]; | ||
while (true) { | ||
int len = -1; | ||
try { | ||
len = is.read(buffer); | ||
} catch (IOException ex) { | ||
} | ||
if (len == -1) break; | ||
baos.write(buffer, 0, len); | ||
} | ||
return baos.toByteArray(); | ||
} | ||
|
||
// -------- Start: ListenableEditingState watcher implementation ------- | ||
@Override | ||
public void didChangeEditingState( | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -16,9 +16,12 @@ | |
import static org.mockito.Mockito.verify; | ||
import static org.mockito.Mockito.when; | ||
|
||
import android.content.ClipDescription; | ||
import android.content.ClipboardManager; | ||
import android.content.ContentResolver; | ||
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,7 +34,9 @@ | |
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 androidx.core.view.inputmethod.InputConnectionCompat; | ||
import androidx.test.core.app.ApplicationProvider; | ||
import androidx.test.ext.junit.runners.AndroidJUnit4; | ||
import com.ibm.icu.lang.UCharacter; | ||
|
@@ -43,7 +48,9 @@ | |
import io.flutter.plugin.common.JSONMethodCodec; | ||
import io.flutter.plugin.common.MethodCall; | ||
import io.flutter.util.FakeKeyEvent; | ||
import java.io.ByteArrayInputStream; | ||
import java.nio.ByteBuffer; | ||
import java.nio.charset.Charset; | ||
import org.json.JSONArray; | ||
import org.json.JSONException; | ||
import org.junit.Before; | ||
|
@@ -52,10 +59,12 @@ | |
import org.mockito.ArgumentCaptor; | ||
import org.mockito.Mock; | ||
import org.mockito.MockitoAnnotations; | ||
import org.robolectric.Shadows; | ||
import org.robolectric.annotation.Config; | ||
import org.robolectric.annotation.Implementation; | ||
import org.robolectric.annotation.Implements; | ||
import org.robolectric.shadow.api.Shadow; | ||
import org.robolectric.shadows.ShadowContentResolver; | ||
import org.robolectric.shadows.ShadowInputMethodManager; | ||
|
||
@Config( | ||
|
@@ -64,6 +73,9 @@ | |
@RunWith(AndroidJUnit4.class) | ||
public class InputConnectionAdaptorTest { | ||
private final Context ctx = ApplicationProvider.getApplicationContext(); | ||
private ContentResolver contentResolver; | ||
private ShadowContentResolver shadowContentResolver; | ||
|
||
@Mock KeyboardManager mockKeyboardManager; | ||
// Verifies the method and arguments for a captured method call. | ||
private void verifyMethodCall(ByteBuffer buffer, String methodName, String[] expectedArgs) | ||
|
@@ -83,6 +95,8 @@ private void verifyMethodCall(ByteBuffer buffer, String methodName, String[] exp | |
@Before | ||
public void setUp() { | ||
MockitoAnnotations.openMocks(this); | ||
contentResolver = ctx.getContentResolver(); | ||
shadowContentResolver = Shadows.shadowOf(contentResolver); | ||
} | ||
|
||
@Test | ||
|
@@ -171,6 +185,65 @@ public void testPerformContextMenuAction_paste() { | |
assertTrue(editable.toString().startsWith(textToBePasted)); | ||
} | ||
|
||
@Test | ||
public void testCommitContent() throws JSONException { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Heads up that this test appears to be failing
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm aware of this, the error hasn't really helped me to figure out why its failing. No idea what needs to change to get it to work. |
||
View testView = new View(ctx); | ||
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); | ||
|
||
String uri = "content://mock/uri/test/commitContent"; | ||
Charset charset = Charset.forName("UTF-8"); | ||
String fakeImageData = "fake image data"; | ||
byte[] fakeImageDataBytes = fakeImageData.getBytes(charset); | ||
shadowContentResolver.registerInputStream( | ||
Uri.parse(uri), new ByteArrayInputStream(fakeImageDataBytes)); | ||
|
||
boolean commitContentSuccess = | ||
adaptor.commitContent( | ||
new InputContentInfo( | ||
Uri.parse(uri), | ||
new ClipDescription("commitContent test", new String[] {"image/png"})), | ||
InputConnectionCompat.INPUT_CONTENT_GRANT_READ_URI_PERMISSION, | ||
null); | ||
assertTrue(commitContentSuccess); | ||
|
||
ArgumentCaptor<String> channelCaptor = ArgumentCaptor.forClass(String.class); | ||
ArgumentCaptor<ByteBuffer> bufferCaptor = ArgumentCaptor.forClass(ByteBuffer.class); | ||
verify(dartExecutor, times(1)).send(channelCaptor.capture(), bufferCaptor.capture(), isNull()); | ||
assertEquals("flutter/textinput", channelCaptor.getValue()); | ||
|
||
String fakeImageDataIntString = ""; | ||
for (int i = 0; i < fakeImageDataBytes.length; i++) { | ||
int byteAsInt = fakeImageDataBytes[i]; | ||
fakeImageDataIntString += byteAsInt; | ||
if (i < (fakeImageDataBytes.length - 1)) { | ||
fakeImageDataIntString += ","; | ||
} | ||
} | ||
verifyMethodCall( | ||
bufferCaptor.getValue(), | ||
"TextInputClient.performAction", | ||
new String[] { | ||
"0", | ||
"TextInputAction.commitContent", | ||
"{\"data\":[" | ||
+ fakeImageDataIntString | ||
+ "],\"mimeType\":\"image\\/png\",\"uri\":\"content:\\/\\/mock\\/uri\\/test\\/commitContent\"}" | ||
}); | ||
} | ||
|
||
@Test | ||
public void testPerformPrivateCommand_dataIsNull() throws JSONException { | ||
View testView = new View(ctx); | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What will this end up looking like in the framework if this isn't supported by the device for some reason? Ideally an app developer would be able to gracefully handle this situation.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There's a toast message displayed that "
<app>
doesn't support image insertion here". It will display this toast any time this function returns false. To my knowledge, it cannot be customized and is an Android framework thing.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ok sounds like that's the same as native so that should be fine 👍