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}