diff --git a/Libraries/Components/TextInput/TextInput.js b/Libraries/Components/TextInput/TextInput.js index decaeae0057f21..52b4022ddd6148 100644 --- a/Libraries/Components/TextInput/TextInput.js +++ b/Libraries/Components/TextInput/TextInput.js @@ -125,6 +125,15 @@ export type EditingEvent = SyntheticEvent< |}>, >; +export type ImageInputEvent = SyntheticEvent< + $ReadOnly<{| + uri: string, + linkUri: string, + data: string, + mime: string, + |}>, +>; + type DataDetectorTypesType = | 'phoneNumber' | 'link' @@ -600,6 +609,12 @@ export type Props = $ReadOnly<{| */ onScroll?: ?(e: ScrollEvent) => mixed, + /** + * Invoked on image input from IME with `{ nativeEvent: { uri, linkUri, data, mime } }`. + * @platform android + */ + onImageInput?: ?(e: ImageInputEvent) => mixed, + /** * The string that will be rendered before text input has been entered. */ @@ -1048,6 +1063,10 @@ function InternalTextInput(props: Props): React.Node { props.onScroll && props.onScroll(event); }; + const _onImageInput = (event: ImageInputEvent) => { + props.onImageInput && props.onImageInput(event); + }; + let textInput = null; let additionalTouchableProps: {| rejectResponderTermination?: $PropertyType< @@ -1114,10 +1133,12 @@ function InternalTextInput(props: Props): React.Node { onBlur={_onBlur} onChange={_onChange} onFocus={_onFocus} + onImageInput={_onImageInput} /* $FlowFixMe the types for AndroidTextInput don't match up exactly * with the props for TextInput. This will need to get fixed */ onScroll={_onScroll} onSelectionChange={_onSelectionChange} + selection={selection} style={style} text={text} diff --git a/Libraries/DeprecatedPropTypes/DeprecatedTextInputPropTypes.js b/Libraries/DeprecatedPropTypes/DeprecatedTextInputPropTypes.js index 275d607b94821d..e522776880ce47 100644 --- a/Libraries/DeprecatedPropTypes/DeprecatedTextInputPropTypes.js +++ b/Libraries/DeprecatedPropTypes/DeprecatedTextInputPropTypes.js @@ -371,6 +371,11 @@ module.exports = { /** * The string that will be rendered before text input has been entered. */ + /** + * Invoked on image input from IME with `{ nativeEvent: { uri, linkUri, mime } }`. + * @platform android + */ + onImageInput: PropTypes.func, placeholder: PropTypes.string, /** * The text color of the placeholder string. diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ImageInputWatcher.java b/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ImageInputWatcher.java new file mode 100644 index 00000000000000..41b5ef523972e3 --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ImageInputWatcher.java @@ -0,0 +1,12 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.react.views.textinput; + +public interface ImageInputWatcher { + public void onImageInput(String uri, String linkUri, String data, String mime); +} diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.java b/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.java index 86ae295b262c43..891b474facd596 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.java @@ -10,9 +10,11 @@ import static com.facebook.react.uimanager.UIManagerHelper.getReactContext; import android.content.Context; +import android.content.ClipDescription; import android.graphics.Rect; import android.graphics.Typeface; import android.graphics.drawable.Drawable; +import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.text.Editable; @@ -32,10 +34,16 @@ import android.view.inputmethod.EditorInfo; import android.view.inputmethod.InputConnection; import android.view.inputmethod.InputMethodManager; +import android.util.Base64; +import android.util.Base64OutputStream; import androidx.annotation.Nullable; import androidx.appcompat.widget.AppCompatEditText; +import androidx.core.os.BuildCompat; import androidx.core.view.AccessibilityDelegateCompat; import androidx.core.view.ViewCompat; +import androidx.core.view.inputmethod.EditorInfoCompat; +import androidx.core.view.inputmethod.InputContentInfoCompat; +import androidx.core.view.inputmethod.InputConnectionCompat; import com.facebook.infer.annotation.Assertions; import com.facebook.react.bridge.JavaOnlyMap; import com.facebook.react.bridge.ReactContext; @@ -47,6 +55,11 @@ import com.facebook.react.views.text.TextAttributes; import com.facebook.react.views.text.TextInlineImageSpan; import com.facebook.react.views.view.ReactViewBackgroundManager; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.InputStream; +import java.io.IOException; import java.util.ArrayList; /** @@ -86,6 +99,7 @@ public class ReactEditText extends AppCompatEditText { private @Nullable SelectionWatcher mSelectionWatcher; private @Nullable ContentSizeWatcher mContentSizeWatcher; private @Nullable ScrollWatcher mScrollWatcher; + private @Nullable ImageInputWatcher mImageInputWatcher; private final InternalKeyListener mKeyListener; private boolean mDetectScrollMovement = false; private boolean mOnKeyPress = false; @@ -127,6 +141,7 @@ public ReactEditText(Context context) { mStagedInputType = getInputType(); mKeyListener = new InternalKeyListener(); mScrollWatcher = null; + mImageInputWatcher = null; mTextAttributes = new TextAttributes(); applyTextAttributes(); @@ -212,13 +227,83 @@ protected void onScrollChanged(int horiz, int vert, int oldHoriz, int oldVert) { } } - @Override + @Override public InputConnection onCreateInputConnection(EditorInfo outAttrs) { - ReactContext reactContext = getReactContext(this); - InputConnection inputConnection = super.onCreateInputConnection(outAttrs); + + final ReactContext reactContext = getReactContext(this); + InputConnection ic = super.onCreateInputConnection(outAttrs); + + EditorInfoCompat.setContentMimeTypes(outAttrs, new String [] {"image/png", + "image/gif", + "image/jpg", + "image/jpeg", + "image/webp"}); + + final InputConnectionCompat.OnCommitContentListener callback = + new InputConnectionCompat.OnCommitContentListener() { + @Override + public boolean onCommitContent(InputContentInfoCompat inputContentInfo, int flags, Bundle opts) { + // read and display inputContentInfo asynchronously + if (BuildCompat.isAtLeastNMR1() && (flags & + InputConnectionCompat.INPUT_CONTENT_GRANT_READ_URI_PERMISSION) != 0) { + try { + inputContentInfo.requestPermission(); + } + catch (Exception e) { + return false; + } + } + + // Avoid loading the image into memory if its not going to be used + if (mImageInputWatcher == null) { + return false; + } + + String uri = null; + String linkUri = null; + String mime = null; + String data = null; + + Uri contentUri = inputContentInfo.getContentUri(); + if (contentUri != null) { + uri = contentUri.toString(); + + // Load the data, we have to do this now otherwise we cannot release permissions + try { + data = loadFile(reactContext, contentUri); + } + catch(IOException e) { + inputContentInfo.releasePermission(); + return false; + } + } + + // Get the optional uri to web link + Uri link = inputContentInfo.getLinkUri(); + if (link != null) { + linkUri = link.toString(); + } + + ClipDescription description = inputContentInfo.getDescription(); + if (description != null && description.getMimeTypeCount() > 0) { + mime = description.getMimeType(0); + } + + if (mImageInputWatcher != null) { + mImageInputWatcher.onImageInput(uri, linkUri, data, mime); + } + + // Releasing the permission means that the content at the uri is probably no longer accessible + inputContentInfo.releasePermission(); + + return true; + } + }; + + InputConnection inputConnection = InputConnectionCompat.createWrapper(ic, outAttrs, callback); + if (inputConnection != null && mOnKeyPress) { - inputConnection = - new ReactEditTextInputConnectionWrapper(inputConnection, reactContext, this); + inputConnection = new ReactEditTextInputConnectionWrapper(inputConnection, reactContext, this); } if (isMultiline() && getBlurOnSubmit()) { @@ -285,6 +370,10 @@ public void setScrollWatcher(ScrollWatcher scrollWatcher) { mScrollWatcher = scrollWatcher; } + public void setImageInputWatcher(ImageInputWatcher imageInputWatcher) { + mImageInputWatcher = imageInputWatcher; + } + /** * Attempt to set a selection or fail silently. Intentionally meant to handle bad inputs. * EventCounter is the same one used as with text. @@ -818,6 +907,24 @@ protected void applyTextAttributes() { } } + private static String loadFile(Context context, Uri contentUri) throws IOException { + InputStream inputStream = context.getContentResolver().openInputStream(contentUri); + byte[] buffer = new byte[8192]; + int bytesRead; + ByteArrayOutputStream output = new ByteArrayOutputStream(); + Base64OutputStream output64 = new Base64OutputStream(output, Base64.DEFAULT); + try { + while ((bytesRead = inputStream.read(buffer)) != -1) { + output64.write(buffer, 0, bytesRead); + } + } catch (IOException e) { + e.printStackTrace(); + } + output64.close(); + + return output.toString(); + } + /** * This class will redirect *TextChanged calls to the listeners only in the case where the text is * changed by the user, and not explicitly set by JS. diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputImageEvent.java b/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputImageEvent.java new file mode 100644 index 00000000000000..afe2095ca6077d --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputImageEvent.java @@ -0,0 +1,60 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.react.views.textinput; + +import com.facebook.react.bridge.Arguments; +import com.facebook.react.bridge.WritableMap; +import com.facebook.react.uimanager.events.Event; +import com.facebook.react.uimanager.events.RCTEventEmitter; + +/** + * Event emitted by EditText native view when text changes. + * VisibleForTesting from {@link TextInputEventsTestCase}. + */ +public class ReactTextInputImageEvent extends Event { + + public static final String EVENT_NAME = "topImageInput"; + + private String mUri; + private String mLinkUri; + private String mData; + private String mMime; + + public ReactTextInputImageEvent( + int viewId, + String uri, + String linkUri, + String data, + String mime) { + super(viewId); + mUri = uri; + mLinkUri = linkUri; + mData = data; + mMime = mime; + } + + @Override + public String getEventName() { + return EVENT_NAME; + } + + @Override + public void dispatch(RCTEventEmitter rctEventEmitter) { + rctEventEmitter.receiveEvent(getViewTag(), getEventName(), serializeEventData()); + } + + private WritableMap serializeEventData() { + WritableMap eventData = Arguments.createMap(); + eventData.putString("uri", mUri); + eventData.putString("linkUri", mLinkUri); + eventData.putString("data", mData); + eventData.putString("mime", mMime); + eventData.putInt("target", getViewTag()); + return eventData; + } +} diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.java b/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.java index 4bbbca329733de..6a932e8a335432 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.java @@ -191,6 +191,7 @@ public Map getExportedCustomDirectEventTypeConstants() { .put( ScrollEventType.getJSEventName(ScrollEventType.SCROLL), MapBuilder.of("registrationName", "onScroll")) + .put("topImageInput", MapBuilder.of("registrationName", "onImageInput")) .build(); } @@ -391,6 +392,15 @@ public void setOnKeyPress(final ReactEditText view, boolean onKeyPress) { view.setOnKeyPress(onKeyPress); } + @ReactProp(name = "onImageInput", defaultBoolean = false) + public void setOnImageInput(final ReactEditText view, boolean onImageInput) { + if (onImageInput) { + view.setImageInputWatcher(new ReactImageInputWatcher(view)); + } else { + view.setImageInputWatcher(null); + } + } + // Sets the letter spacing as an absolute point size. // This extra handling, on top of what ReactBaseTextShadowNode already does, is required for the // correct display of spacing in placeholder (hint) text. @@ -1202,6 +1212,32 @@ public void onScrollChanged(int horiz, int vert, int oldHoriz, int oldVert) { } } + private class ReactImageInputWatcher implements ImageInputWatcher { + + private ReactEditText mReactEditText; + private EventDispatcher mEventDispatcher; + + public ReactImageInputWatcher(ReactEditText editText) { + mReactEditText = editText; + ReactContext reactContext = (ReactContext) editText.getContext(); + mEventDispatcher = reactContext.getNativeModule(UIManagerModule.class).getEventDispatcher(); + } + + @Override + public void onImageInput(String uri, String linkUri, String data, String mime) { + if (uri != null) { + ReactTextInputImageEvent event = new ReactTextInputImageEvent( + mReactEditText.getId(), + uri, + linkUri, + data, + mime); + + mEventDispatcher.dispatchEvent(event); + } + } + } + @Override public @Nullable Map getExportedViewConstants() { return MapBuilder.of(