1package eu.siacs.conversations.ui.widget;
  2
  3import android.content.Context;
  4import android.content.SharedPreferences;
  5import android.os.Build;
  6import android.os.Bundle;
  7import android.os.Handler;
  8import android.preference.PreferenceManager;
  9import android.text.Editable;
 10import android.text.InputFilter;
 11import android.text.InputType;
 12import android.text.Spannable;
 13import android.text.Spanned;
 14import android.text.SpannableStringBuilder;
 15import android.text.TextWatcher;
 16import android.text.style.ImageSpan;
 17import android.util.AttributeSet;
 18import android.view.KeyEvent;
 19import android.view.inputmethod.EditorInfo;
 20import android.view.inputmethod.InputConnection;
 21
 22import androidx.appcompat.widget.AppCompatEditText;
 23import androidx.core.view.inputmethod.EditorInfoCompat;
 24import androidx.core.view.inputmethod.InputConnectionCompat;
 25import androidx.core.view.inputmethod.InputContentInfoCompat;
 26
 27import java.util.ArrayList;
 28import java.util.List;
 29import java.util.concurrent.ExecutorService;
 30import java.util.concurrent.Executors;
 31
 32import eu.siacs.conversations.Config;
 33import eu.siacs.conversations.R;
 34import eu.siacs.conversations.ui.util.QuoteHelper;
 35
 36public class EditMessage extends AppCompatEditText {
 37
 38    private static final InputFilter SPAN_FILTER = (source, start, end, dest, dstart, dend) -> source instanceof Spanned ? source.toString() : source;
 39    private final ExecutorService executor = Executors.newSingleThreadExecutor();
 40    protected Handler mTypingHandler = new Handler();
 41    protected KeyboardListener keyboardListener;
 42    private OnCommitContentListener mCommitContentListener = null;
 43    private String[] mimeTypes = null;
 44    private boolean isUserTyping = false;
 45    private final Runnable mTypingTimeout = new Runnable() {
 46        @Override
 47        public void run() {
 48            if (isUserTyping && keyboardListener != null) {
 49                keyboardListener.onTypingStopped();
 50                isUserTyping = false;
 51            }
 52        }
 53    };
 54    private boolean lastInputWasTab = false;
 55
 56    public EditMessage(Context context, AttributeSet attrs) {
 57        super(context, attrs);
 58        addTextChangedListener(new Watcher());
 59    }
 60
 61    public EditMessage(Context context) {
 62        super(context);
 63        addTextChangedListener(new Watcher());
 64    }
 65
 66    @Override
 67    public boolean onKeyDown(final int keyCode, final KeyEvent e) {
 68        final boolean isCtrlPressed = e.isCtrlPressed();
 69        if (keyCode == KeyEvent.KEYCODE_ENTER && !e.isShiftPressed()) {
 70            lastInputWasTab = false;
 71            if (keyboardListener != null && keyboardListener.onEnterPressed(isCtrlPressed)) {
 72                return true;
 73            }
 74        } else if (keyCode == KeyEvent.KEYCODE_TAB && !e.isAltPressed() && !isCtrlPressed) {
 75            if (keyboardListener != null && keyboardListener.onTabPressed(this.lastInputWasTab)) {
 76                lastInputWasTab = true;
 77                return true;
 78            }
 79        } else {
 80            lastInputWasTab = false;
 81        }
 82        return super.onKeyDown(keyCode, e);
 83    }
 84
 85    @Override
 86    public int getAutofillType() {
 87        return AUTOFILL_TYPE_NONE;
 88    }
 89
 90    @Override
 91    public void onTextChanged(CharSequence text, int start, int lengthBefore, int lengthAfter) {
 92        super.onTextChanged(text, start, lengthBefore, lengthAfter);
 93        lastInputWasTab = false;
 94        if (this.mTypingHandler != null && this.keyboardListener != null) {
 95            executor.execute(() -> triggerKeyboardEvents(text.length()));
 96        }
 97    }
 98
 99    private void triggerKeyboardEvents(final int length) {
100        final KeyboardListener listener = this.keyboardListener;
101        if (listener == null) {
102            return;
103        }
104        this.mTypingHandler.removeCallbacks(mTypingTimeout);
105        this.mTypingHandler.postDelayed(mTypingTimeout, Config.TYPING_TIMEOUT * 1000);
106        if (!isUserTyping && length > 0) {
107            this.isUserTyping = true;
108            listener.onTypingStarted();
109        } else if (length == 0) {
110            this.isUserTyping = false;
111            listener.onTextDeleted();
112        }
113        listener.onTextChanged();
114    }
115
116    public void setKeyboardListener(KeyboardListener listener) {
117        this.keyboardListener = listener;
118        if (listener != null) {
119            this.isUserTyping = false;
120        }
121    }
122
123    @Override
124    public boolean onTextContextMenuItem(int id) {
125        if (id == android.R.id.paste) {
126            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
127                return super.onTextContextMenuItem(android.R.id.pasteAsPlainText);
128            } else {
129                Editable editable = getEditableText();
130                InputFilter[] filters = editable.getFilters();
131                InputFilter[] tempFilters = new InputFilter[filters != null ? filters.length + 1 : 1];
132                if (filters != null) {
133                    System.arraycopy(filters, 0, tempFilters, 1, filters.length);
134                }
135                tempFilters[0] = SPAN_FILTER;
136                editable.setFilters(tempFilters);
137                try {
138                    return super.onTextContextMenuItem(id);
139                } finally {
140                    editable.setFilters(filters);
141                }
142            }
143        } else {
144            return super.onTextContextMenuItem(id);
145        }
146    }
147
148    public void setRichContentListener(String[] mimeTypes, OnCommitContentListener listener) {
149        this.mimeTypes = mimeTypes;
150        this.mCommitContentListener = listener;
151    }
152
153    public void insertAsQuote(String text) {
154        text = QuoteHelper.quote(text);
155        Editable editable = getEditableText();
156        int position = getSelectionEnd();
157        if (position == -1) position = editable.length();
158        if (position > 0 && editable.charAt(position - 1) != '\n') {
159            editable.insert(position++, "\n");
160        }
161        editable.insert(position, text);
162        position += text.length();
163        editable.insert(position++, "\n");
164        if (position < editable.length() && editable.charAt(position) != '\n') {
165            editable.insert(position, "\n");
166        }
167        setSelection(position);
168    }
169
170    @Override
171    public InputConnection onCreateInputConnection(EditorInfo editorInfo) {
172        final InputConnection ic = super.onCreateInputConnection(editorInfo);
173
174        if (mimeTypes != null && mCommitContentListener != null && ic != null) {
175            EditorInfoCompat.setContentMimeTypes(editorInfo, mimeTypes);
176            return InputConnectionCompat.createWrapper(ic, editorInfo, (inputContentInfo, flags, opts) -> EditMessage.this.mCommitContentListener.onCommitContent(inputContentInfo, flags, opts, mimeTypes));
177        } else {
178            return ic;
179        }
180    }
181
182    public void refreshIme() {
183        SharedPreferences p = PreferenceManager.getDefaultSharedPreferences(getContext());
184        final boolean usingEnterKey = p.getBoolean("display_enter_key", getResources().getBoolean(R.bool.display_enter_key));
185        final boolean enterIsSend = p.getBoolean("enter_is_send", getResources().getBoolean(R.bool.enter_is_send));
186
187        if (usingEnterKey && enterIsSend) {
188            setInputType(getInputType() & (~InputType.TYPE_TEXT_FLAG_MULTI_LINE));
189            setInputType(getInputType() & (~InputType.TYPE_TEXT_VARIATION_SHORT_MESSAGE));
190        } else if (usingEnterKey) {
191            setInputType(getInputType() | InputType.TYPE_TEXT_FLAG_MULTI_LINE);
192            setInputType(getInputType() & (~InputType.TYPE_TEXT_VARIATION_SHORT_MESSAGE));
193        } else {
194            setInputType(getInputType() | InputType.TYPE_TEXT_FLAG_MULTI_LINE);
195            setInputType(getInputType() | InputType.TYPE_TEXT_VARIATION_SHORT_MESSAGE);
196        }
197    }
198
199    public interface OnCommitContentListener {
200        boolean onCommitContent(InputContentInfoCompat inputContentInfo, int flags, Bundle opts, String[] mimeTypes);
201    }
202
203    public interface KeyboardListener {
204        boolean onEnterPressed(boolean isCtrlPressed);
205
206        void onTypingStarted();
207
208        void onTypingStopped();
209
210        void onTextDeleted();
211
212        void onTextChanged();
213
214        boolean onTabPressed(boolean repeated);
215    }
216
217    public static class Watcher implements TextWatcher {
218        protected List<ImageSpan> spansToRemove = new ArrayList<>();
219
220        @Override
221        public void beforeTextChanged(CharSequence s, int start, int count, int after) {
222            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
223                if (s instanceof SpannableStringBuilder && ((SpannableStringBuilder) s).getTextWatcherDepth() > 1) return;
224            }
225
226            if (!(s instanceof Spannable)) return;
227            Spannable text = (Spannable) s;
228
229            if (count > 0 && text != null) { // something deleted
230                int end = start + count;
231                ImageSpan[] spans = text.getSpans(start, end, ImageSpan.class);
232                synchronized(spansToRemove) {
233                    for (ImageSpan span : spans) {
234                        if (text.getSpanStart(span) < end && start < text.getSpanEnd(span)) {
235                            spansToRemove.add(span);
236                        }
237                    }
238                }
239            }
240        }
241
242        @Override
243        public void afterTextChanged(Editable s) {
244            List<ImageSpan> toRemove;
245            synchronized(spansToRemove) {
246                toRemove = new ArrayList<>(spansToRemove);
247                spansToRemove.clear();
248            }
249            for (ImageSpan span : toRemove) {
250                if (s.getSpanStart(span) > -1 && s.getSpanEnd(span) > -1) {
251                    s.removeSpan(span);
252                }
253            }
254        }
255
256        @Override
257        public void onTextChanged(CharSequence s, int start, int count, int after) { }
258    }
259}