kopia lustrzana https://github.com/ryukoposting/Signal-Android
512 wiersze
17 KiB
Java
512 wiersze
17 KiB
Java
package org.thoughtcrime.securesms.components;
|
|
|
|
import android.content.Context;
|
|
import android.content.res.Configuration;
|
|
import android.graphics.Canvas;
|
|
import android.os.Build;
|
|
import android.os.Bundle;
|
|
import android.text.Annotation;
|
|
import android.text.Editable;
|
|
import android.text.InputType;
|
|
import android.text.Spannable;
|
|
import android.text.SpannableString;
|
|
import android.text.SpannableStringBuilder;
|
|
import android.text.Spanned;
|
|
import android.text.TextUtils;
|
|
import android.text.TextUtils.TruncateAt;
|
|
import android.text.style.RelativeSizeSpan;
|
|
import android.util.AttributeSet;
|
|
import android.view.inputmethod.EditorInfo;
|
|
import android.view.inputmethod.InputConnection;
|
|
|
|
import androidx.annotation.NonNull;
|
|
import androidx.annotation.Nullable;
|
|
import androidx.core.content.ContextCompat;
|
|
import androidx.core.view.inputmethod.EditorInfoCompat;
|
|
import androidx.core.view.inputmethod.InputConnectionCompat;
|
|
import androidx.core.view.inputmethod.InputContentInfoCompat;
|
|
|
|
import org.signal.core.util.StringUtil;
|
|
import org.signal.core.util.logging.Log;
|
|
import org.thoughtcrime.securesms.R;
|
|
import org.thoughtcrime.securesms.components.emoji.EmojiEditText;
|
|
import org.thoughtcrime.securesms.components.mention.MentionAnnotation;
|
|
import org.thoughtcrime.securesms.components.mention.MentionDeleter;
|
|
import org.thoughtcrime.securesms.components.mention.MentionRendererDelegate;
|
|
import org.thoughtcrime.securesms.components.mention.MentionValidatorWatcher;
|
|
import org.thoughtcrime.securesms.conversation.MessageSendType;
|
|
import org.thoughtcrime.securesms.conversation.ui.inlinequery.InlineQuery;
|
|
import org.thoughtcrime.securesms.conversation.ui.inlinequery.InlineQueryChangedListener;
|
|
import org.thoughtcrime.securesms.conversation.ui.inlinequery.InlineQueryReplacement;
|
|
import org.thoughtcrime.securesms.database.model.Mention;
|
|
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
|
import org.thoughtcrime.securesms.recipients.RecipientId;
|
|
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
|
|
|
import java.util.List;
|
|
import java.util.Objects;
|
|
import java.util.concurrent.TimeUnit;
|
|
import java.util.regex.Matcher;
|
|
import java.util.regex.Pattern;
|
|
|
|
import static org.thoughtcrime.securesms.database.MentionUtil.MENTION_STARTER;
|
|
|
|
public class ComposeText extends EmojiEditText {
|
|
|
|
private static final char EMOJI_STARTER = ':';
|
|
private static final long EMOJI_KEYWORD_DELAY = 1500;
|
|
|
|
private static final Pattern TIME_PATTERN = Pattern.compile("^[0-9]{1,2}:[0-9]{1,2}$");
|
|
|
|
private CharSequence hint;
|
|
private SpannableString subHint;
|
|
private MentionRendererDelegate mentionRendererDelegate;
|
|
private MentionValidatorWatcher mentionValidatorWatcher;
|
|
|
|
@Nullable private InputPanel.MediaListener mediaListener;
|
|
@Nullable private CursorPositionChangedListener cursorPositionChangedListener;
|
|
@Nullable private InlineQueryChangedListener inlineQueryChangedListener;
|
|
|
|
private final Runnable keywordSearchRunnable = () -> {
|
|
Editable text = getText();
|
|
if (text != null && enoughToFilter(text, true)) {
|
|
performFiltering(text, true);
|
|
}
|
|
};
|
|
|
|
public ComposeText(Context context) {
|
|
super(context);
|
|
initialize();
|
|
}
|
|
|
|
public ComposeText(Context context, AttributeSet attrs) {
|
|
super(context, attrs);
|
|
initialize();
|
|
}
|
|
|
|
public ComposeText(Context context, AttributeSet attrs, int defStyleAttr) {
|
|
super(context, attrs, defStyleAttr);
|
|
initialize();
|
|
}
|
|
|
|
/**
|
|
* Trims and returns text while preserving potential spans like {@link MentionAnnotation}.
|
|
*/
|
|
public @NonNull CharSequence getTextTrimmed() {
|
|
Editable text = getText();
|
|
if (text == null) {
|
|
return "";
|
|
}
|
|
return StringUtil.trimSequence(text);
|
|
}
|
|
|
|
@Override
|
|
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
|
|
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
|
|
|
|
if (getLayout() != null && !TextUtils.isEmpty(hint)) {
|
|
if (!TextUtils.isEmpty(subHint)) {
|
|
setHintWithChecks(new SpannableStringBuilder().append(ellipsizeToWidth(hint))
|
|
.append("\n")
|
|
.append(ellipsizeToWidth(subHint)));
|
|
} else {
|
|
setHintWithChecks(ellipsizeToWidth(hint));
|
|
}
|
|
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
|
|
}
|
|
}
|
|
|
|
@Override
|
|
protected void onSelectionChanged(int selectionStart, int selectionEnd) {
|
|
super.onSelectionChanged(selectionStart, selectionEnd);
|
|
|
|
if (getText() != null) {
|
|
boolean selectionChanged = changeSelectionForPartialMentions(getText(), selectionStart, selectionEnd);
|
|
if (selectionChanged) {
|
|
return;
|
|
}
|
|
|
|
if (selectionStart == selectionEnd) {
|
|
doAfterCursorChange(getText());
|
|
} else {
|
|
clearInlineQuery();
|
|
}
|
|
}
|
|
|
|
if (cursorPositionChangedListener != null) {
|
|
cursorPositionChangedListener.onCursorPositionChanged(selectionStart, selectionEnd);
|
|
}
|
|
}
|
|
|
|
@Override
|
|
protected void onDraw(Canvas canvas) {
|
|
if (getText() != null && getLayout() != null) {
|
|
int checkpoint = canvas.save();
|
|
|
|
// Clip using same logic as TextView drawing
|
|
int maxScrollY = getLayout().getHeight() - getBottom() - getTop() - getCompoundPaddingBottom() - getCompoundPaddingTop();
|
|
float clipLeft = getCompoundPaddingLeft() + getScrollX();
|
|
float clipTop = (getScrollY() == 0) ? 0 : getExtendedPaddingTop() + getScrollY();
|
|
float clipRight = getRight() - getLeft() - getCompoundPaddingRight() + getScrollX();
|
|
float clipBottom = getBottom() - getTop() + getScrollY() - ((getScrollY() == maxScrollY) ? 0 : getExtendedPaddingBottom());
|
|
|
|
canvas.clipRect(clipLeft - 10, clipTop, clipRight + 10, clipBottom);
|
|
canvas.translate(getTotalPaddingLeft(), getTotalPaddingTop());
|
|
|
|
try {
|
|
mentionRendererDelegate.draw(canvas, getText(), getLayout());
|
|
} finally {
|
|
canvas.restoreToCount(checkpoint);
|
|
}
|
|
}
|
|
super.onDraw(canvas);
|
|
}
|
|
|
|
private CharSequence ellipsizeToWidth(CharSequence text) {
|
|
return TextUtils.ellipsize(text,
|
|
getPaint(),
|
|
getWidth() - getPaddingLeft() - getPaddingRight(),
|
|
TruncateAt.END);
|
|
}
|
|
|
|
public void setHint(@NonNull String hint, @Nullable CharSequence subHint) {
|
|
this.hint = hint;
|
|
|
|
if (subHint != null) {
|
|
this.subHint = new SpannableString(subHint);
|
|
this.subHint.setSpan(new RelativeSizeSpan(0.5f), 0, subHint.length(), Spannable.SPAN_INCLUSIVE_INCLUSIVE);
|
|
} else {
|
|
this.subHint = null;
|
|
}
|
|
|
|
if (this.subHint != null) {
|
|
setHintWithChecks(new SpannableStringBuilder().append(ellipsizeToWidth(this.hint))
|
|
.append("\n")
|
|
.append(ellipsizeToWidth(this.subHint)));
|
|
} else {
|
|
setHintWithChecks(ellipsizeToWidth(this.hint));
|
|
}
|
|
|
|
setHintWithChecks(hint);
|
|
}
|
|
|
|
public void appendInvite(String invite) {
|
|
if (getText() == null) {
|
|
return;
|
|
}
|
|
|
|
if (!TextUtils.isEmpty(getText()) && !getText().toString().equals(" ")) {
|
|
append(" ");
|
|
}
|
|
|
|
append(invite);
|
|
setSelection(getText().length());
|
|
}
|
|
|
|
public void setCursorPositionChangedListener(@Nullable CursorPositionChangedListener listener) {
|
|
this.cursorPositionChangedListener = listener;
|
|
}
|
|
|
|
public void setInlineQueryChangedListener(@Nullable InlineQueryChangedListener listener) {
|
|
this.inlineQueryChangedListener = listener;
|
|
}
|
|
|
|
public void setMentionValidator(@Nullable MentionValidatorWatcher.MentionValidator mentionValidator) {
|
|
mentionValidatorWatcher.setMentionValidator(mentionValidator);
|
|
}
|
|
|
|
private boolean isLandscape() {
|
|
return getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE;
|
|
}
|
|
|
|
public void setMessageSendType(MessageSendType messageSendType) {
|
|
final boolean useSystemEmoji = SignalStore.settings().isPreferSystemEmoji();
|
|
|
|
int imeOptions = (getImeOptions() & ~EditorInfo.IME_MASK_ACTION) | EditorInfo.IME_ACTION_SEND;
|
|
int inputType = getInputType();
|
|
|
|
if (isLandscape()) setImeActionLabel(getContext().getString(messageSendType.getComposeHintRes()), EditorInfo.IME_ACTION_SEND);
|
|
else setImeActionLabel(null, 0);
|
|
|
|
if (useSystemEmoji) {
|
|
inputType = (inputType & ~InputType.TYPE_MASK_VARIATION) | InputType.TYPE_TEXT_VARIATION_SHORT_MESSAGE;
|
|
}
|
|
|
|
setImeOptions(imeOptions);
|
|
setHint(getContext().getString(messageSendType.getComposeHintRes()),
|
|
messageSendType.getSimName() != null
|
|
? getContext().getString(R.string.conversation_activity__from_sim_name, messageSendType.getSimName())
|
|
: null);
|
|
setInputType(inputType);
|
|
}
|
|
|
|
@Override
|
|
public InputConnection onCreateInputConnection(EditorInfo editorInfo) {
|
|
InputConnection inputConnection = super.onCreateInputConnection(editorInfo);
|
|
|
|
if (SignalStore.settings().isEnterKeySends()) {
|
|
editorInfo.imeOptions &= ~EditorInfo.IME_FLAG_NO_ENTER_ACTION;
|
|
}
|
|
|
|
if (Build.VERSION.SDK_INT < 21) {
|
|
return inputConnection;
|
|
}
|
|
|
|
if (mediaListener == null) {
|
|
return inputConnection;
|
|
}
|
|
|
|
if (inputConnection == null) {
|
|
return null;
|
|
}
|
|
|
|
EditorInfoCompat.setContentMimeTypes(editorInfo, new String[] { "image/jpeg", "image/png", "image/gif" });
|
|
return InputConnectionCompat.createWrapper(inputConnection, editorInfo, new CommitContentListener(mediaListener));
|
|
}
|
|
|
|
public void setMediaListener(@Nullable InputPanel.MediaListener mediaListener) {
|
|
this.mediaListener = mediaListener;
|
|
}
|
|
|
|
public boolean hasMentions() {
|
|
Editable text = getText();
|
|
if (text != null) {
|
|
return !MentionAnnotation.getMentionAnnotations(text).isEmpty();
|
|
}
|
|
return false;
|
|
}
|
|
|
|
public @NonNull List<Mention> getMentions() {
|
|
return MentionAnnotation.getMentionsFromAnnotations(getText());
|
|
}
|
|
|
|
private void initialize() {
|
|
if (TextSecurePreferences.isIncognitoKeyboardEnabled(getContext())) {
|
|
setImeOptions(getImeOptions() | 16777216);
|
|
}
|
|
|
|
mentionRendererDelegate = new MentionRendererDelegate(getContext(), ContextCompat.getColor(getContext(), R.color.conversation_mention_background_color));
|
|
|
|
addTextChangedListener(new MentionDeleter());
|
|
mentionValidatorWatcher = new MentionValidatorWatcher();
|
|
addTextChangedListener(mentionValidatorWatcher);
|
|
}
|
|
|
|
private void setHintWithChecks(@Nullable CharSequence newHint) {
|
|
if (getLayout() == null || Objects.equals(getHint(), newHint)) {
|
|
return;
|
|
}
|
|
|
|
setHint(newHint);
|
|
}
|
|
|
|
private boolean changeSelectionForPartialMentions(@NonNull Spanned spanned, int selectionStart, int selectionEnd) {
|
|
Annotation[] annotations = spanned.getSpans(0, spanned.length(), Annotation.class);
|
|
for (Annotation annotation : annotations) {
|
|
if (MentionAnnotation.isMentionAnnotation(annotation)) {
|
|
int spanStart = spanned.getSpanStart(annotation);
|
|
int spanEnd = spanned.getSpanEnd(annotation);
|
|
|
|
boolean startInMention = selectionStart > spanStart && selectionStart < spanEnd;
|
|
boolean endInMention = selectionEnd > spanStart && selectionEnd < spanEnd;
|
|
|
|
if (startInMention || endInMention) {
|
|
if (selectionStart == selectionEnd) {
|
|
setSelection(spanEnd, spanEnd);
|
|
} else {
|
|
int newStart = startInMention ? spanStart : selectionStart;
|
|
int newEnd = endInMention ? spanEnd : selectionEnd;
|
|
setSelection(newStart, newEnd);
|
|
}
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private void doAfterCursorChange(@NonNull Editable text) {
|
|
if (enoughToFilter(text, false)) {
|
|
performFiltering(text, false);
|
|
} else {
|
|
clearInlineQuery();
|
|
}
|
|
}
|
|
|
|
private void performFiltering(@NonNull Editable text, boolean keywordEmojiSearch) {
|
|
int end = getSelectionEnd();
|
|
QueryStart queryStart = findQueryStart(text, end, keywordEmojiSearch);
|
|
int start = queryStart.index;
|
|
String query = text.subSequence(start, end).toString();
|
|
|
|
if (inlineQueryChangedListener != null) {
|
|
if (queryStart.isMentionQuery) {
|
|
inlineQueryChangedListener.onQueryChanged(new InlineQuery.Mention(query));
|
|
} else {
|
|
inlineQueryChangedListener.onQueryChanged(new InlineQuery.Emoji(query, keywordEmojiSearch));
|
|
}
|
|
}
|
|
}
|
|
|
|
private void clearInlineQuery() {
|
|
if (inlineQueryChangedListener != null) {
|
|
inlineQueryChangedListener.clearQuery();
|
|
}
|
|
}
|
|
|
|
private boolean enoughToFilter(@NonNull Editable text, boolean keywordEmojiSearch) {
|
|
int end = getSelectionEnd();
|
|
if (end < 0) {
|
|
return false;
|
|
}
|
|
return findQueryStart(text, end, keywordEmojiSearch).index != -1;
|
|
}
|
|
|
|
public void replaceTextWithMention(@NonNull String displayName, @NonNull RecipientId recipientId) {
|
|
replaceText(createReplacementToken(displayName, recipientId), false);
|
|
}
|
|
|
|
public void replaceText(@NonNull InlineQueryReplacement replacement) {
|
|
replaceText(replacement.toCharSequence(getContext()), replacement.isKeywordSearch());
|
|
}
|
|
|
|
private void replaceText(@NonNull CharSequence replacement, boolean keywordReplacement) {
|
|
Editable text = getText();
|
|
if (text == null) {
|
|
return;
|
|
}
|
|
|
|
clearComposingText();
|
|
|
|
int end = getSelectionEnd();
|
|
int start = findQueryStart(text, end, keywordReplacement).index - (keywordReplacement ? 0 : 1);
|
|
|
|
text.replace(start, end, "");
|
|
text.insert(start, replacement);
|
|
}
|
|
|
|
private @NonNull CharSequence createReplacementToken(@NonNull CharSequence text, @NonNull RecipientId recipientId) {
|
|
SpannableStringBuilder builder = new SpannableStringBuilder().append(MENTION_STARTER);
|
|
if (text instanceof Spanned) {
|
|
SpannableString spannableString = new SpannableString(text + " ");
|
|
TextUtils.copySpansFrom((Spanned) text, 0, text.length(), Object.class, spannableString, 0);
|
|
builder.append(spannableString);
|
|
} else {
|
|
builder.append(text).append(" ");
|
|
}
|
|
|
|
builder.setSpan(MentionAnnotation.mentionAnnotationForRecipientId(recipientId), 0, builder.length() - 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
|
|
|
return builder;
|
|
}
|
|
|
|
private QueryStart findQueryStart(@NonNull CharSequence text, int inputCursorPosition, boolean keywordEmojiSearch) {
|
|
if (keywordEmojiSearch) {
|
|
int start = findQueryStart(text, inputCursorPosition, ' ');
|
|
if (start == -1 && inputCursorPosition != 0) {
|
|
start = 0;
|
|
} else if (start == inputCursorPosition) {
|
|
start = -1;
|
|
}
|
|
return new QueryStart(start, false);
|
|
}
|
|
|
|
QueryStart queryStart = new QueryStart(findQueryStart(text, inputCursorPosition, MENTION_STARTER), true);
|
|
|
|
if (queryStart.index < 0) {
|
|
queryStart = new QueryStart(findQueryStart(text, inputCursorPosition, EMOJI_STARTER), false);
|
|
}
|
|
|
|
return queryStart;
|
|
}
|
|
|
|
private int findQueryStart(@NonNull CharSequence text, int inputCursorPosition, char starter) {
|
|
if (inputCursorPosition == 0) {
|
|
return -1;
|
|
}
|
|
|
|
int delimiterSearchIndex = inputCursorPosition - 1;
|
|
while (delimiterSearchIndex >= 0 && (text.charAt(delimiterSearchIndex) != starter && !Character.isWhitespace(text.charAt(delimiterSearchIndex)))) {
|
|
delimiterSearchIndex--;
|
|
}
|
|
|
|
if (delimiterSearchIndex >= 0 && text.charAt(delimiterSearchIndex) == starter) {
|
|
if (couldBeTimeEntry(text, delimiterSearchIndex)) {
|
|
return -1;
|
|
} else {
|
|
return delimiterSearchIndex + 1;
|
|
}
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
/**
|
|
* Return true if we think the user may be inputting a time.
|
|
*/
|
|
private static boolean couldBeTimeEntry(@NonNull CharSequence text, int startIndex) {
|
|
if (startIndex <= 0 || startIndex + 1 >= text.length()) {
|
|
return false;
|
|
}
|
|
|
|
int startOfToken = startIndex;
|
|
while (startOfToken > 0 && !Character.isWhitespace(text.charAt(startOfToken))) {
|
|
startOfToken--;
|
|
}
|
|
startOfToken++;
|
|
|
|
int endOfToken = startIndex;
|
|
while (endOfToken < text.length() && !Character.isWhitespace(text.charAt(endOfToken))) {
|
|
endOfToken++;
|
|
}
|
|
|
|
return TIME_PATTERN.matcher(text.subSequence(startOfToken, endOfToken)).find();
|
|
}
|
|
|
|
private static class CommitContentListener implements InputConnectionCompat.OnCommitContentListener {
|
|
|
|
private static final String TAG = Log.tag(CommitContentListener.class);
|
|
|
|
private final InputPanel.MediaListener mediaListener;
|
|
|
|
private CommitContentListener(@NonNull InputPanel.MediaListener mediaListener) {
|
|
this.mediaListener = mediaListener;
|
|
}
|
|
|
|
@Override
|
|
public boolean onCommitContent(InputContentInfoCompat inputContentInfo, int flags, Bundle opts) {
|
|
if (Build.VERSION.SDK_INT >= 25 && (flags & InputConnectionCompat.INPUT_CONTENT_GRANT_READ_URI_PERMISSION) != 0) {
|
|
try {
|
|
inputContentInfo.requestPermission();
|
|
} catch (Exception e) {
|
|
Log.w(TAG, e);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (inputContentInfo.getDescription().getMimeTypeCount() > 0) {
|
|
mediaListener.onMediaSelected(inputContentInfo.getContentUri(),
|
|
inputContentInfo.getDescription().getMimeType(0));
|
|
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private static class QueryStart {
|
|
public int index;
|
|
public boolean isMentionQuery;
|
|
|
|
public QueryStart(int index, boolean isMentionQuery) {
|
|
this.index = index;
|
|
this.isMentionQuery = isMentionQuery;
|
|
}
|
|
}
|
|
|
|
public interface CursorPositionChangedListener {
|
|
void onCursorPositionChanged(int start, int end);
|
|
}
|
|
|
|
}
|