1package eu.siacs.conversations.ui.adapter;
2
3import android.Manifest;
4import android.app.Activity;
5import android.content.Intent;
6import android.content.pm.PackageManager;
7import android.graphics.PorterDuff;
8import android.graphics.drawable.Drawable;
9import android.content.res.ColorStateList;
10import android.graphics.Typeface;
11import android.net.Uri;
12import android.os.AsyncTask;
13import android.os.Build;
14import android.preference.PreferenceManager;
15import android.text.Editable;
16import android.text.Spanned;
17import android.text.Spannable;
18import android.text.SpannableString;
19import android.text.SpannableStringBuilder;
20import android.text.style.ImageSpan;
21import android.text.style.ClickableSpan;
22import android.text.format.DateUtils;
23import android.text.style.ForegroundColorSpan;
24import android.text.style.RelativeSizeSpan;
25import android.text.style.StyleSpan;
26import android.text.style.URLSpan;
27import android.util.DisplayMetrics;
28import android.util.LruCache;
29import android.view.accessibility.AccessibilityEvent;
30import android.view.Gravity;
31import android.view.LayoutInflater;
32import android.view.MotionEvent;
33import android.view.View;
34import android.view.ViewGroup;
35import android.view.WindowManager;
36import android.widget.ArrayAdapter;
37import android.widget.ImageView;
38import android.widget.LinearLayout;
39import android.widget.ListAdapter;
40import android.widget.ListView;
41import android.widget.RelativeLayout;
42import android.widget.TextView;
43import android.widget.Toast;
44
45import androidx.annotation.AttrRes;
46import androidx.annotation.ColorInt;
47import androidx.annotation.DrawableRes;
48import androidx.annotation.NonNull;
49import androidx.annotation.Nullable;
50import androidx.core.app.ActivityCompat;
51import androidx.core.content.ContextCompat;
52import androidx.core.content.res.ResourcesCompat;
53import androidx.core.widget.ImageViewCompat;
54import androidx.databinding.DataBindingUtil;
55
56import com.google.android.material.imageview.ShapeableImageView;
57import com.google.android.material.shape.CornerFamily;
58import com.google.android.material.shape.ShapeAppearanceModel;
59
60import com.cheogram.android.BobTransfer;
61import com.cheogram.android.MessageTextActionModeCallback;
62import com.cheogram.android.SwipeDetector;
63import com.cheogram.android.Util;
64import com.cheogram.android.WebxdcPage;
65import com.cheogram.android.WebxdcUpdate;
66
67import com.google.android.material.button.MaterialButton;
68import com.google.android.material.color.MaterialColors;
69import com.google.common.base.Joiner;
70import com.google.common.base.Strings;
71import com.google.common.collect.ImmutableList;
72
73import com.lelloman.identicon.view.GithubIdenticonView;
74
75import io.ipfs.cid.Cid;
76
77import java.io.IOException;
78import java.net.URI;
79import java.net.URISyntaxException;
80import java.security.NoSuchAlgorithmException;
81import java.util.HashMap;
82import java.util.List;
83import java.util.Map;
84import java.util.Locale;
85import java.util.regex.Matcher;
86import java.util.regex.Pattern;
87
88import me.saket.bettermovementmethod.BetterLinkMovementMethod;
89
90import net.fellbaum.jemoji.EmojiManager;
91
92import eu.siacs.conversations.AppSettings;
93import eu.siacs.conversations.Config;
94import eu.siacs.conversations.R;
95import eu.siacs.conversations.crypto.axolotl.FingerprintStatus;
96import eu.siacs.conversations.databinding.LinkDescriptionBinding;
97import eu.siacs.conversations.entities.Account;
98import eu.siacs.conversations.entities.Contact;
99import eu.siacs.conversations.entities.Conversation;
100import eu.siacs.conversations.entities.Conversational;
101import eu.siacs.conversations.entities.DownloadableFile;
102import eu.siacs.conversations.entities.Message.FileParams;
103import eu.siacs.conversations.entities.Message;
104import eu.siacs.conversations.entities.MucOptions;
105import eu.siacs.conversations.entities.Roster;
106import eu.siacs.conversations.entities.RtpSessionStatus;
107import eu.siacs.conversations.entities.Transferable;
108import eu.siacs.conversations.persistance.FileBackend;
109import eu.siacs.conversations.services.MessageArchiveService;
110import eu.siacs.conversations.services.NotificationService;
111import eu.siacs.conversations.ui.Activities;
112import eu.siacs.conversations.ui.ConversationFragment;
113import eu.siacs.conversations.ui.ConversationsActivity;
114import eu.siacs.conversations.ui.XmppActivity;
115import eu.siacs.conversations.ui.service.AudioPlayer;
116import eu.siacs.conversations.ui.text.DividerSpan;
117import eu.siacs.conversations.ui.text.FixedURLSpan;
118import eu.siacs.conversations.ui.text.QuoteSpan;
119import eu.siacs.conversations.ui.util.Attachment;
120import eu.siacs.conversations.ui.util.AvatarWorkerTask;
121import eu.siacs.conversations.ui.util.MyLinkify;
122import eu.siacs.conversations.ui.util.QuoteHelper;
123import eu.siacs.conversations.ui.util.ShareUtil;
124import eu.siacs.conversations.ui.util.ViewUtil;
125import eu.siacs.conversations.utils.CryptoHelper;
126import eu.siacs.conversations.utils.Emoticons;
127import eu.siacs.conversations.utils.GeoHelper;
128import eu.siacs.conversations.utils.MessageUtils;
129import eu.siacs.conversations.utils.StylingHelper;
130import eu.siacs.conversations.utils.TimeFrameUtils;
131import eu.siacs.conversations.utils.UIHelper;
132import eu.siacs.conversations.xmpp.Jid;
133import eu.siacs.conversations.xmpp.mam.MamReference;
134import eu.siacs.conversations.xml.Element;
135
136import java.net.URI;
137import java.util.Arrays;
138import java.util.Collection;
139import java.util.List;
140import java.util.Locale;
141import java.util.regex.Matcher;
142import java.util.regex.Pattern;
143
144public class MessageAdapter extends ArrayAdapter<Message> {
145
146 public static final String DATE_SEPARATOR_BODY = "DATE_SEPARATOR";
147 private static final int SENT = 0;
148 private static final int RECEIVED = 1;
149 private static final int STATUS = 2;
150 private static final int DATE_SEPARATOR = 3;
151 private static final int RTP_SESSION = 4;
152 private final XmppActivity activity;
153 private final AudioPlayer audioPlayer;
154 private List<String> highlightedTerm = null;
155 private final DisplayMetrics metrics;
156 private ConversationFragment mConversationFragment = null;
157 private OnContactPictureClicked mOnContactPictureClickedListener;
158 private OnContactPictureClicked mOnMessageBoxClickedListener;
159 private OnContactPictureClicked mOnMessageBoxSwipedListener;
160 private OnContactPictureLongClicked mOnContactPictureLongClickedListener;
161 private OnInlineImageLongClicked mOnInlineImageLongClickedListener;
162 private boolean mUseGreenBackground = false;
163 private BubbleDesign bubbleDesign = new BubbleDesign(false, false);
164 private final boolean mForceNames;
165 private final Map<String, WebxdcUpdate> lastWebxdcUpdate = new HashMap<>();
166 private String selectionUuid = null;
167
168 public MessageAdapter(
169 final XmppActivity activity, final List<Message> messages, final boolean forceNames) {
170 super(activity, 0, messages);
171 this.audioPlayer = new AudioPlayer(this);
172 this.activity = activity;
173 metrics = getContext().getResources().getDisplayMetrics();
174 updatePreferences();
175 this.mForceNames = forceNames;
176 }
177
178 public MessageAdapter(final XmppActivity activity, final List<Message> messages) {
179 this(activity, messages, false);
180 }
181
182 private static void resetClickListener(View... views) {
183 for (View view : views) {
184 if (view != null) view.setOnClickListener(null);
185 }
186 }
187
188 public void flagScreenOn() {
189 activity.getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
190 }
191
192 public void flagScreenOff() {
193 activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
194 }
195
196 public void setVolumeControl(final int stream) {
197 activity.setVolumeControlStream(stream);
198 }
199
200 public void setOnContactPictureClicked(OnContactPictureClicked listener) {
201 this.mOnContactPictureClickedListener = listener;
202 }
203
204 public void setOnMessageBoxClicked(OnContactPictureClicked listener) {
205 this.mOnMessageBoxClickedListener = listener;
206 }
207
208 public void setOnMessageBoxSwiped(OnContactPictureClicked listener) {
209 this.mOnMessageBoxSwipedListener = listener;
210 }
211
212 public void setConversationFragment(ConversationFragment frag) {
213 mConversationFragment = frag;
214 }
215
216 public void quoteText(String text) {
217 if (mConversationFragment != null) mConversationFragment.quoteText(text);
218 }
219
220 public boolean hasSelection() {
221 return selectionUuid != null;
222 }
223
224 public Activity getActivity() {
225 return activity;
226 }
227
228 public void setOnContactPictureLongClicked(OnContactPictureLongClicked listener) {
229 this.mOnContactPictureLongClickedListener = listener;
230 }
231
232 public void setOnInlineImageLongClicked(OnInlineImageLongClicked listener) {
233 this.mOnInlineImageLongClickedListener = listener;
234 }
235
236 @Override
237 public int getViewTypeCount() {
238 return 5;
239 }
240
241 private int getItemViewType(Message message) {
242 if (message.getType() == Message.TYPE_STATUS) {
243 if (DATE_SEPARATOR_BODY.equals(message.getBody())) {
244 return DATE_SEPARATOR;
245 } else {
246 return STATUS;
247 }
248 } else if (message.getType() == Message.TYPE_RTP_SESSION) {
249 return RTP_SESSION;
250 } else if (message.getStatus() <= Message.STATUS_RECEIVED) {
251 return RECEIVED;
252 } else {
253 return SENT;
254 }
255 }
256
257 @Override
258 public int getItemViewType(int position) {
259 return this.getItemViewType(getItem(position));
260 }
261
262 private void displayStatus(
263 final ViewHolder viewHolder,
264 final Message message,
265 final int type,
266 final BubbleColor bubbleColor) {
267 final int mergedStatus = message.getMergedStatus();
268 final boolean error;
269 if (viewHolder.indicatorReceived != null) {
270 viewHolder.indicatorReceived.setVisibility(View.GONE);
271 }
272 final Transferable transferable = message.getTransferable();
273 final boolean multiReceived =
274 message.getConversation().getMode() == Conversation.MODE_MULTI
275 && mergedStatus <= Message.STATUS_RECEIVED;
276 final String fileSize;
277 if (message.isFileOrImage()
278 || transferable != null
279 || MessageUtils.unInitiatedButKnownSize(message)) {
280 final FileParams params = message.getFileParams();
281 fileSize = params.size != null ? UIHelper.filesizeToString(params.size) : null;
282 if (message.getStatus() == Message.STATUS_SEND_FAILED
283 || (transferable != null
284 && (transferable.getStatus() == Transferable.STATUS_FAILED
285 || transferable.getStatus()
286 == Transferable.STATUS_CANCELLED))) {
287 error = true;
288 } else {
289 error = message.getStatus() == Message.STATUS_SEND_FAILED;
290 }
291 } else {
292 fileSize = null;
293 error = message.getStatus() == Message.STATUS_SEND_FAILED;
294 }
295 if (type == SENT) {
296 final @DrawableRes Integer receivedIndicator =
297 getMessageStatusAsDrawable(message, mergedStatus);
298 if (receivedIndicator == null) {
299 viewHolder.indicatorReceived.setVisibility(View.INVISIBLE);
300 } else {
301 viewHolder.indicatorReceived.setImageResource(receivedIndicator);
302 if (mergedStatus == Message.STATUS_SEND_FAILED) {
303 setImageTintError(viewHolder.indicatorReceived);
304 } else {
305 setImageTint(viewHolder.indicatorReceived, bubbleColor);
306 }
307 viewHolder.indicatorReceived.setVisibility(View.VISIBLE);
308 }
309 }
310 final var additionalStatusInfo = getAdditionalStatusInfo(message, mergedStatus);
311
312 if (error && type == SENT) {
313 viewHolder.time.setTextColor(
314 MaterialColors.getColor(
315 viewHolder.time, com.google.android.material.R.attr.colorError));
316 } else {
317 setTextColor(viewHolder.time, bubbleColor);
318 }
319 setTextColor(viewHolder.subject, bubbleColor);
320 if (message.getEncryption() == Message.ENCRYPTION_NONE) {
321 viewHolder.indicator.setVisibility(View.GONE);
322 } else {
323 boolean verified = false;
324 if (message.getEncryption() == Message.ENCRYPTION_AXOLOTL) {
325 final FingerprintStatus status =
326 message.getConversation()
327 .getAccount()
328 .getAxolotlService()
329 .getFingerprintTrust(message.getFingerprint());
330 if (status != null && status.isVerified()) {
331 verified = true;
332 }
333 }
334 if (verified) {
335 viewHolder.indicator.setImageResource(R.drawable.ic_verified_user_24dp);
336 } else {
337 viewHolder.indicator.setImageResource(R.drawable.ic_lock_24dp);
338 }
339 if (error && type == SENT) {
340 setImageTintError(viewHolder.indicator);
341 } else {
342 setImageTint(viewHolder.indicator, bubbleColor);
343 }
344 viewHolder.indicator.setVisibility(View.VISIBLE);
345 }
346
347 if (viewHolder.edit_indicator != null) {
348 if (message.edited()) {
349 viewHolder.edit_indicator.setVisibility(View.VISIBLE);
350 if (error && type == SENT) {
351 setImageTintError(viewHolder.edit_indicator);
352 } else {
353 setImageTint(viewHolder.edit_indicator, bubbleColor);
354 }
355 } else {
356 viewHolder.edit_indicator.setVisibility(View.GONE);
357 }
358 }
359
360 final String formattedTime =
361 UIHelper.readableTimeDifferenceFull(getContext(), message.getMergedTimeSent());
362 final String bodyLanguage = message.getBodyLanguage();
363 final ImmutableList.Builder<String> timeInfoBuilder = new ImmutableList.Builder<>();
364 if (message.getStatus() <= Message.STATUS_RECEIVED) {
365 timeInfoBuilder.add(formattedTime);
366 if (fileSize != null) {
367 timeInfoBuilder.add(fileSize);
368 }
369 if (mForceNames || multiReceived || (message.getTrueCounterpart() != null && message.getContact() != null)) {
370 final String displayName = UIHelper.getMessageDisplayName(message);
371 if (displayName != null) {
372 timeInfoBuilder.add(displayName);
373 }
374 }
375 if (bodyLanguage != null) {
376 timeInfoBuilder.add(bodyLanguage.toUpperCase(Locale.US));
377 }
378 } else {
379 if (bodyLanguage != null) {
380 timeInfoBuilder.add(bodyLanguage.toUpperCase(Locale.US));
381 }
382 if (fileSize != null) {
383 timeInfoBuilder.add(fileSize);
384 }
385 // for space reasons we display only 'additional status info' (send progress or concrete
386 // failure reason) or the time
387 if (additionalStatusInfo != null) {
388 timeInfoBuilder.add(additionalStatusInfo);
389 } else {
390 timeInfoBuilder.add(formattedTime);
391 }
392 }
393 final var timeInfo = timeInfoBuilder.build();
394 viewHolder.time.setText(Joiner.on(" \u00B7 ").join(timeInfo));
395 }
396
397 public static @DrawableRes Integer getMessageStatusAsDrawable(
398 final Message message, final int status) {
399 final var transferable = message.getTransferable();
400 return switch (status) {
401 case Message.STATUS_WAITING -> R.drawable.ic_more_horiz_24dp;
402 case Message.STATUS_UNSEND -> transferable == null ? null : R.drawable.ic_upload_24dp;
403 case Message.STATUS_SEND -> R.drawable.ic_done_24dp;
404 case Message.STATUS_SEND_RECEIVED, Message.STATUS_SEND_DISPLAYED -> R.drawable
405 .ic_done_all_24dp;
406 case Message.STATUS_SEND_FAILED -> {
407 final String errorMessage = message.getErrorMessage();
408 if (Message.ERROR_MESSAGE_CANCELLED.equals(errorMessage)) {
409 yield R.drawable.ic_cancel_24dp;
410 } else {
411 yield R.drawable.ic_error_24dp;
412 }
413 }
414 case Message.STATUS_OFFERED -> R.drawable.ic_p2p_24dp;
415 default -> null;
416 };
417 }
418
419 @Nullable
420 private String getAdditionalStatusInfo(final Message message, final int mergedStatus) {
421 final String additionalStatusInfo;
422 if (mergedStatus == Message.STATUS_SEND_FAILED) {
423 final String errorMessage = Strings.nullToEmpty(message.getErrorMessage());
424 final String[] errorParts = errorMessage.split("\\u001f", 2);
425 if (errorParts.length == 2 && errorParts[0].equals("file-too-large")) {
426 additionalStatusInfo = getContext().getString(R.string.file_too_large);
427 } else {
428 additionalStatusInfo = null;
429 }
430 } else if (mergedStatus == Message.STATUS_UNSEND) {
431 final var transferable = message.getTransferable();
432 if (transferable == null) {
433 return null;
434 }
435 return getContext().getString(R.string.sending_file, transferable.getProgress());
436 } else {
437 additionalStatusInfo = null;
438 }
439 return additionalStatusInfo;
440 }
441
442 private void displayInfoMessage(
443 ViewHolder viewHolder, CharSequence text, final BubbleColor bubbleColor) {
444 viewHolder.download_button.setVisibility(View.GONE);
445 viewHolder.audioPlayer.setVisibility(View.GONE);
446 viewHolder.image.setVisibility(View.GONE);
447 viewHolder.messageBody.setVisibility(View.VISIBLE);
448 viewHolder.messageBody.setText(text);
449 viewHolder.messageBody.setTextColor(
450 bubbleToOnSurfaceVariant(viewHolder.messageBody, bubbleColor));
451 viewHolder.messageBody.setTextIsSelectable(false);
452 }
453
454 private void displayEmojiMessage(
455 final ViewHolder viewHolder, final Message message, final BubbleColor bubbleColor, int type) {
456 displayTextMessage(viewHolder, message, bubbleColor, type);
457 viewHolder.download_button.setVisibility(View.GONE);
458 viewHolder.audioPlayer.setVisibility(View.GONE);
459 viewHolder.image.setVisibility(View.GONE);
460 viewHolder.messageBody.setVisibility(View.VISIBLE);
461 setTextColor(viewHolder.messageBody, bubbleColor);
462 final var body = getSpannableBody(message);
463 ImageSpan[] imageSpans = body.getSpans(0, body.length(), ImageSpan.class);
464 float size = imageSpans.length == 1 || Emoticons.isEmoji(body.toString()) ? 5.0f : 2.0f;
465 body.setSpan(
466 new RelativeSizeSpan(size), 0, body.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
467 viewHolder.messageBody.setText(body);
468 }
469
470 private void applyQuoteSpan(
471 final TextView textView,
472 Editable body,
473 int start,
474 int end,
475 final BubbleColor bubbleColor,
476 final boolean makeEdits) {
477 if (makeEdits && start > 1 && !"\n\n".equals(body.subSequence(start - 2, start).toString())) {
478 body.insert(start++, "\n");
479 body.setSpan(
480 new DividerSpan(false), start - 2, start, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
481 end++;
482 }
483 if (makeEdits && end < body.length() - 1 && !"\n\n".equals(body.subSequence(end, end + 2).toString())) {
484 body.insert(end, "\n");
485 body.setSpan(
486 new DividerSpan(false),
487 end,
488 end + ("\n".equals(body.subSequence(end + 1, end + 2).toString()) ? 2 : 1),
489 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
490 );
491 }
492 final DisplayMetrics metrics = getContext().getResources().getDisplayMetrics();
493 body.setSpan(
494 new QuoteSpan(bubbleToOnSurfaceVariant(textView, bubbleColor), metrics),
495 start,
496 end,
497 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
498 }
499
500 public boolean handleTextQuotes(final TextView textView, final Editable body) {
501 return handleTextQuotes(textView, body, true);
502 }
503
504 public boolean handleTextQuotes(final TextView textView, final Editable body, final boolean deleteMarkers) {
505 final boolean colorfulBackground = this.bubbleDesign.colorfulChatBubbles;
506 final BubbleColor bubbleColor = colorfulBackground ? (deleteMarkers ? BubbleColor.SECONDARY : BubbleColor.TERTIARY) : BubbleColor.SURFACE;
507 return handleTextQuotes(textView, body, bubbleColor, deleteMarkers);
508 }
509
510 /**
511 * Applies QuoteSpan to group of lines which starts with > or » characters. Appends likebreaks
512 * and applies DividerSpan to them to show a padding between quote and text.
513 */
514 public boolean handleTextQuotes(
515 final TextView textView,
516 final Editable body,
517 final BubbleColor bubbleColor,
518 final boolean deleteMarkers) {
519 boolean startsWithQuote = false;
520 int quoteDepth = 1;
521 while (QuoteHelper.bodyContainsQuoteStart(body) && quoteDepth <= Config.QUOTE_MAX_DEPTH) {
522 char previous = '\n';
523 int lineStart = -1;
524 int lineTextStart = -1;
525 int quoteStart = -1;
526 int skipped = 0;
527 for (int i = 0; i <= body.length(); i++) {
528 if (!deleteMarkers && QuoteHelper.isRelativeSizeSpanned(body, i)) {
529 skipped++;
530 continue;
531 }
532 char current = body.length() > i ? body.charAt(i) : '\n';
533 if (lineStart == -1) {
534 if (previous == '\n') {
535 if (i < body.length() && QuoteHelper.isPositionQuoteStart(body, i)) {
536 // Line start with quote
537 lineStart = i;
538 if (quoteStart == -1) quoteStart = i - skipped;
539 if (i == 0) startsWithQuote = true;
540 } else if (quoteStart >= 0) {
541 // Line start without quote, apply spans there
542 applyQuoteSpan(textView, body, quoteStart, i - 1, bubbleColor, deleteMarkers);
543 quoteStart = -1;
544 }
545 }
546 } else {
547 // Remove extra spaces between > and first character in the line
548 // > character will be removed too
549 if (current != ' ' && lineTextStart == -1) {
550 lineTextStart = i;
551 }
552 if (current == '\n') {
553 if (deleteMarkers) {
554 i -= lineTextStart - lineStart;
555 body.delete(lineStart, lineTextStart);
556 if (i == lineStart) {
557 // Avoid empty lines because span over empty line can be hidden
558 body.insert(i++, " ");
559 }
560 } else {
561 body.setSpan(new RelativeSizeSpan(i - (lineTextStart - lineStart) == lineStart ? 1 : 0), lineStart, lineTextStart, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE | StylingHelper.XHTML_REMOVE << Spanned.SPAN_USER_SHIFT);
562 }
563 lineStart = -1;
564 lineTextStart = -1;
565 }
566 }
567 previous = current;
568 skipped = 0;
569 }
570 if (quoteStart >= 0) {
571 // Apply spans to finishing open quote
572 applyQuoteSpan(textView, body, quoteStart, body.length(), bubbleColor, deleteMarkers);
573 }
574 quoteDepth++;
575 }
576 return startsWithQuote;
577 }
578
579 private SpannableStringBuilder getSpannableBody(final Message message) {
580 Drawable fallbackImg = ResourcesCompat.getDrawable(activity.getResources(), R.drawable.ic_photo_24dp, null);
581 return message.getMergedBody((cid) -> {
582 try {
583 DownloadableFile f = activity.xmppConnectionService.getFileForCid(cid);
584 if (f == null || !f.canRead()) {
585 if (!message.trusted() && !message.getConversation().canInferPresence()) return null;
586
587 try {
588 new BobTransfer(BobTransfer.uri(cid), message.getConversation().getAccount(), message.getCounterpart(), activity.xmppConnectionService).start();
589 } catch (final NoSuchAlgorithmException | URISyntaxException e) { }
590 return null;
591 }
592
593 Drawable d = activity.xmppConnectionService.getFileBackend().getThumbnail(f, activity.getResources(), (int) (metrics.density * 288), true);
594 if (d == null) {
595 new ThumbnailTask().execute(f);
596 }
597 return d;
598 } catch (final IOException e) {
599 return null;
600 }
601 }, fallbackImg);
602 }
603
604 private void displayTextMessage(
605 final ViewHolder viewHolder, final Message message, final BubbleColor bubbleColor, final int type) {
606 viewHolder.inReplyToQuote.setVisibility(View.GONE);
607 viewHolder.download_button.setVisibility(View.GONE);
608 viewHolder.image.setVisibility(View.GONE);
609 viewHolder.audioPlayer.setVisibility(View.GONE);
610 viewHolder.messageBody.setVisibility(View.VISIBLE);
611 setTextColor(viewHolder.messageBody, bubbleColor);
612 setTextSize(viewHolder.messageBody, this.bubbleDesign.largeFont);
613
614 final ViewGroup.LayoutParams layoutParams = viewHolder.messageBody.getLayoutParams();
615 layoutParams.width = ViewGroup.LayoutParams.WRAP_CONTENT;
616 viewHolder.messageBody.setLayoutParams(layoutParams);
617
618 final ViewGroup.LayoutParams qlayoutParams = viewHolder.inReplyToQuote.getLayoutParams();
619 qlayoutParams.width = ViewGroup.LayoutParams.WRAP_CONTENT;
620 viewHolder.messageBody.setLayoutParams(qlayoutParams);
621
622 viewHolder.messageBody.setTypeface(null, Typeface.NORMAL);
623
624 if (message.getBody() != null && !message.getBody().equals("")) {
625 viewHolder.messageBody.setTextIsSelectable(true);
626 viewHolder.messageBody.setVisibility(View.VISIBLE);
627 final String nick = UIHelper.getMessageDisplayName(message);
628 SpannableStringBuilder body = getSpannableBody(message);
629 final var processMarkup = body.getSpans(0, body.length(), Message.PlainTextSpan.class).length > 0;
630 boolean hasMeCommand = message.hasMeCommand();
631 if (hasMeCommand) {
632 body = body.replace(0, Message.ME_COMMAND.length(), nick + " ");
633 }
634 if (body.length() > Config.MAX_DISPLAY_MESSAGE_CHARS) {
635 body = new SpannableStringBuilder(body, 0, Config.MAX_DISPLAY_MESSAGE_CHARS);
636 body.append("\u2026");
637 }
638 Message.MergeSeparator[] mergeSeparators =
639 body.getSpans(0, body.length(), Message.MergeSeparator.class);
640 for (Message.MergeSeparator mergeSeparator : mergeSeparators) {
641 int start = body.getSpanStart(mergeSeparator);
642 int end = body.getSpanEnd(mergeSeparator);
643 body.setSpan(new DividerSpan(true), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
644 }
645 if (processMarkup) StylingHelper.format(body, viewHolder.messageBody.getCurrentTextColor());
646 MyLinkify.addLinks(body, message.getConversation().getAccount(), message.getConversation().getJid());
647 boolean startsWithQuote = processMarkup ? handleTextQuotes(viewHolder.messageBody, body, bubbleColor, true) : false;
648 for (final android.text.style.QuoteSpan quote : body.getSpans(0, body.length(), android.text.style.QuoteSpan.class)) {
649 int start = body.getSpanStart(quote);
650 int end = body.getSpanEnd(quote);
651 if (start < 0 || end < 0) continue;
652
653 body.removeSpan(quote);
654 applyQuoteSpan(viewHolder.messageBody, body, start, end, bubbleColor, true);
655 if (start == 0) {
656 if (message.getInReplyTo() == null) {
657 startsWithQuote = true;
658 } else {
659 viewHolder.inReplyToQuote.setText(body.subSequence(start, end));
660 viewHolder.inReplyToQuote.setVisibility(View.VISIBLE);
661 body.delete(start, end);
662 while (body.length() > start && body.charAt(start) == '\n') body.delete(start, 1); // Newlines after quote
663 continue;
664 }
665 }
666 }
667 if (!message.isPrivateMessage()) {
668 if (hasMeCommand) {
669 body.setSpan(
670 new StyleSpan(Typeface.BOLD_ITALIC),
671 0,
672 nick.length(),
673 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
674 }
675 } else {
676 String privateMarker;
677 if (message.getStatus() <= Message.STATUS_RECEIVED) {
678 privateMarker = activity.getString(R.string.private_message);
679 } else {
680 Jid cp = message.getCounterpart();
681 privateMarker =
682 activity.getString(
683 R.string.private_message_to,
684 Strings.nullToEmpty(cp == null ? null : cp.getResource()));
685 }
686 body.insert(0, privateMarker);
687 int privateMarkerIndex = privateMarker.length();
688 if (startsWithQuote) {
689 body.insert(privateMarkerIndex, "\n\n");
690 body.setSpan(
691 new DividerSpan(false),
692 privateMarkerIndex,
693 privateMarkerIndex + 2,
694 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
695 } else {
696 body.insert(privateMarkerIndex, " ");
697 }
698 body.setSpan(
699 new ForegroundColorSpan(
700 bubbleToOnSurfaceVariant(viewHolder.messageBody, bubbleColor)),
701 0,
702 privateMarkerIndex,
703 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
704 body.setSpan(
705 new StyleSpan(Typeface.BOLD),
706 0,
707 privateMarkerIndex,
708 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
709 if (hasMeCommand) {
710 body.setSpan(
711 new StyleSpan(Typeface.BOLD_ITALIC),
712 privateMarkerIndex + 1,
713 privateMarkerIndex + 1 + nick.length(),
714 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
715 }
716 }
717 if (message.getConversation().getMode() == Conversation.MODE_MULTI
718 && message.getStatus() == Message.STATUS_RECEIVED) {
719 if (message.getConversation() instanceof Conversation conversation) {
720 Pattern pattern =
721 NotificationService.generateNickHighlightPattern(
722 conversation.getMucOptions().getActualNick());
723 Matcher matcher = pattern.matcher(body);
724 while (matcher.find()) {
725 body.setSpan(
726 new StyleSpan(Typeface.BOLD),
727 matcher.start(),
728 matcher.end(),
729 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
730 }
731
732 pattern = NotificationService.generateNickHighlightPattern(conversation.getMucOptions().getActualName());
733 matcher = pattern.matcher(body);
734 while (matcher.find()) {
735 body.setSpan(new StyleSpan(Typeface.BOLD), matcher.start(), matcher.end(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
736 }
737 }
738 }
739 for (final var emoji : EmojiManager.extractEmojisInOrderWithIndex(body.toString())) {
740 var end = emoji.getCharIndex() + emoji.getEmoji().getEmoji().length();
741 if (body.length() > end && body.charAt(end) == '\uFE0F') end++;
742 body.setSpan(
743 new RelativeSizeSpan(1.2f),
744 emoji.getCharIndex(),
745 end,
746 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
747 }
748 // Make custom emoji bigger too, to match emoji
749 for (final var span : body.getSpans(0, body.length(), com.cheogram.android.InlineImageSpan.class)) {
750 body.setSpan(
751 new RelativeSizeSpan(1.2f),
752 body.getSpanStart(span),
753 body.getSpanEnd(span),
754 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
755 }
756
757 if (highlightedTerm != null) {
758 StylingHelper.highlight(viewHolder.messageBody, body, highlightedTerm);
759 }
760
761 viewHolder.messageBody.setAutoLinkMask(0);
762 viewHolder.messageBody.setText(body);
763 if (body.length() <= 0) viewHolder.messageBody.setVisibility(View.GONE);
764 BetterLinkMovementMethod method = new BetterLinkMovementMethod() {
765 @Override
766 protected void dispatchUrlLongClick(TextView tv, ClickableSpan span) {
767 if (span instanceof URLSpan || mOnInlineImageLongClickedListener == null) {
768 tv.dispatchTouchEvent(MotionEvent.obtain(0, 0, MotionEvent.ACTION_CANCEL, 0f, 0f, 0));
769 super.dispatchUrlLongClick(tv, span);
770 return;
771 }
772
773 Spannable body = (Spannable) tv.getText();
774 ImageSpan[] imageSpans = body.getSpans(body.getSpanStart(span), body.getSpanEnd(span), ImageSpan.class);
775 if (imageSpans.length > 0) {
776 Uri uri = Uri.parse(imageSpans[0].getSource());
777 Cid cid = BobTransfer.cid(uri);
778 if (cid == null) return;
779 if (mOnInlineImageLongClickedListener.onInlineImageLongClicked(cid)) {
780 tv.dispatchTouchEvent(MotionEvent.obtain(0, 0, MotionEvent.ACTION_CANCEL, 0f, 0f, 0));
781 }
782 }
783 }
784 };
785 method.setOnLinkLongClickListener((tv, url) -> {
786 tv.dispatchTouchEvent(MotionEvent.obtain(0, 0, MotionEvent.ACTION_CANCEL, 0f, 0f, 0));
787 ShareUtil.copyLinkToClipboard(activity, url);
788 return true;
789 });
790 viewHolder.messageBody.setMovementMethod(method);
791 } else {
792 viewHolder.messageBody.setText("");
793 viewHolder.messageBody.setTextIsSelectable(false);
794 toggleWhisperInfo(viewHolder, message, bubbleColor);
795 }
796 }
797
798 private void displayDownloadableMessage(
799 ViewHolder viewHolder,
800 final Message message,
801 String text,
802 final BubbleColor bubbleColor, final int type) {
803 displayTextMessage(viewHolder, message, bubbleColor, type);
804 viewHolder.image.setVisibility(View.GONE);
805 List<Element> thumbs = message.getFileParams() != null ? message.getFileParams().getThumbnails() : null;
806 if (thumbs != null && !thumbs.isEmpty()) {
807 for (Element thumb : thumbs) {
808 Uri uri = Uri.parse(thumb.getAttribute("uri"));
809 if (uri.getScheme().equals("data")) {
810 String[] parts = uri.getSchemeSpecificPart().split(",", 2);
811 parts = parts[0].split(";");
812 if (!parts[0].equals("image/blurhash") && !parts[0].equals("image/thumbhash") && !parts[0].equals("image/jpeg") && !parts[0].equals("image/png") && !parts[0].equals("image/webp") && !parts[0].equals("image/gif")) continue;
813 } else if (uri.getScheme().equals("cid")) {
814 Cid cid = BobTransfer.cid(uri);
815 if (cid == null) continue;
816 DownloadableFile f = activity.xmppConnectionService.getFileForCid(cid);
817 if (f == null || !f.canRead()) {
818 if (!message.trusted() && !message.getConversation().canInferPresence()) continue;
819
820 try {
821 new BobTransfer(BobTransfer.uri(cid), message.getConversation().getAccount(), message.getCounterpart(), activity.xmppConnectionService).start();
822 } catch (final NoSuchAlgorithmException | URISyntaxException e) { }
823 continue;
824 }
825 } else {
826 continue;
827 }
828
829 int width = message.getFileParams().width;
830 if (width < 1 && thumb.getAttribute("width") != null) width = Integer.parseInt(thumb.getAttribute("width"));
831 if (width < 1) width = 1920;
832
833 int height = message.getFileParams().height;
834 if (height < 1 && thumb.getAttribute("height") != null) height = Integer.parseInt(thumb.getAttribute("height"));
835 if (height < 1) height = 1080;
836
837 viewHolder.image.setVisibility(View.VISIBLE);
838 imagePreviewLayout(width, height, viewHolder.image, message.getInReplyTo() != null, true, type, viewHolder);
839 activity.loadBitmap(message, viewHolder.image);
840 viewHolder.image.setOnClickListener(v -> ConversationFragment.downloadFile(activity, message));
841
842 break;
843 }
844 }
845 viewHolder.audioPlayer.setVisibility(View.GONE);
846 viewHolder.download_button.setVisibility(View.VISIBLE);
847 viewHolder.download_button.setText(text);
848 final var attachment = Attachment.of(message);
849 final @DrawableRes int imageResource = MediaAdapter.getImageDrawable(attachment);
850 viewHolder.download_button.setIconResource(imageResource);
851 viewHolder.download_button.setOnClickListener(
852 v -> ConversationFragment.downloadFile(activity, message));
853 }
854
855 private void displayWebxdcMessage(ViewHolder viewHolder, final Message message, final BubbleColor bubbleColor, final int type) {
856 Cid webxdcCid = message.getFileParams().getCids().get(0);
857 WebxdcPage webxdc = new WebxdcPage(activity, webxdcCid, message, activity.xmppConnectionService);
858 displayTextMessage(viewHolder, message, bubbleColor, type);
859 viewHolder.image.setVisibility(View.GONE);
860 viewHolder.audioPlayer.setVisibility(View.GONE);
861 viewHolder.download_button.setVisibility(View.VISIBLE);
862 viewHolder.download_button.setIconResource(0);
863 viewHolder.download_button.setText("Open " + webxdc.getName());
864 viewHolder.download_button.setOnClickListener(v -> {
865 Conversation conversation = (Conversation) message.getConversation();
866 if (!conversation.switchToSession("webxdc\0" + message.getUuid())) {
867 conversation.startWebxdc(webxdc);
868 }
869 });
870 viewHolder.image.setOnClickListener(v -> {
871 Conversation conversation = (Conversation) message.getConversation();
872 if (!conversation.switchToSession("webxdc\0" + message.getUuid())) {
873 conversation.startWebxdc(webxdc);
874 }
875 });
876
877 final WebxdcUpdate lastUpdate;
878 synchronized(lastWebxdcUpdate) { lastUpdate = lastWebxdcUpdate.get(message.getUuid()); }
879 if (lastUpdate == null) {
880 new Thread(() -> {
881 final WebxdcUpdate update = activity.xmppConnectionService.findLastWebxdcUpdate(message);
882 if (update != null) {
883 synchronized(lastWebxdcUpdate) { lastWebxdcUpdate.put(message.getUuid(), update); }
884 activity.xmppConnectionService.updateConversationUi();
885 }
886 }).start();
887 } else {
888 if (lastUpdate != null && (lastUpdate.getSummary() != null || lastUpdate.getDocument() != null)) {
889 viewHolder.messageBody.setVisibility(View.VISIBLE);
890 viewHolder.messageBody.setText(
891 (lastUpdate.getDocument() == null ? "" : lastUpdate.getDocument() + "\n") +
892 (lastUpdate.getSummary() == null ? "" : lastUpdate.getSummary())
893 );
894 }
895 }
896
897 final LruCache<String, Drawable> cache = activity.xmppConnectionService.getDrawableCache();
898 final Drawable d = cache.get("webxdc:icon:" + webxdcCid);
899 if (d == null) {
900 new Thread(() -> {
901 Drawable icon = webxdc.getIcon();
902 if (icon != null) {
903 cache.put("webxdc:icon:" + webxdcCid, icon);
904 activity.xmppConnectionService.updateConversationUi();
905 }
906 }).start();
907 } else {
908 viewHolder.image.setVisibility(View.VISIBLE);
909 viewHolder.image.setImageDrawable(d);
910 imagePreviewLayout(d.getIntrinsicWidth(), d.getIntrinsicHeight(), viewHolder.image, message.getInReplyTo() != null, true, type, viewHolder);
911 }
912 }
913
914 private void displayOpenableMessage(
915 ViewHolder viewHolder, final Message message, final BubbleColor bubbleColor, final int type) {
916 displayTextMessage(viewHolder, message, bubbleColor, type);
917 viewHolder.image.setVisibility(View.GONE);
918 viewHolder.audioPlayer.setVisibility(View.GONE);
919 viewHolder.download_button.setVisibility(View.VISIBLE);
920 viewHolder.download_button.setText(
921 activity.getString(
922 R.string.open_x_file,
923 UIHelper.getFileDescriptionString(activity, message)));
924 final var attachment = Attachment.of(message);
925 final @DrawableRes int imageResource = MediaAdapter.getImageDrawable(attachment);
926 viewHolder.download_button.setIconResource(imageResource);
927 viewHolder.download_button.setOnClickListener(v -> openDownloadable(message));
928 }
929
930 private void displayLocationMessage(
931 ViewHolder viewHolder, final Message message, final BubbleColor bubbleColor, final int type) {
932 displayTextMessage(viewHolder, message, bubbleColor, type);
933 viewHolder.image.setVisibility(View.GONE);
934 viewHolder.audioPlayer.setVisibility(View.GONE);
935 viewHolder.download_button.setVisibility(View.VISIBLE);
936 viewHolder.download_button.setText(R.string.show_location);
937 final var attachment = Attachment.of(message);
938 final @DrawableRes int imageResource = MediaAdapter.getImageDrawable(attachment);
939 viewHolder.download_button.setIconResource(imageResource);
940 viewHolder.download_button.setOnClickListener(v -> showLocation(message));
941 }
942
943 private void displayAudioMessage(
944 ViewHolder viewHolder, Message message, final BubbleColor bubbleColor, final int type) {
945 displayTextMessage(viewHolder, message, bubbleColor, type);
946 viewHolder.image.setVisibility(View.GONE);
947 viewHolder.download_button.setVisibility(View.GONE);
948 final RelativeLayout audioPlayer = viewHolder.audioPlayer;
949 audioPlayer.setVisibility(View.VISIBLE);
950 AudioPlayer.ViewHolder.get(audioPlayer).setBubbleColor(bubbleColor);
951 this.audioPlayer.init(audioPlayer, message);
952 }
953
954 private void displayMediaPreviewMessage(
955 ViewHolder viewHolder, final Message message, final BubbleColor bubbleColor, final int type) {
956 displayTextMessage(viewHolder, message, bubbleColor, type);
957 viewHolder.download_button.setVisibility(View.GONE);
958 viewHolder.audioPlayer.setVisibility(View.GONE);
959 viewHolder.image.setVisibility(View.VISIBLE);
960 final FileParams params = message.getFileParams();
961 imagePreviewLayout(params.width, params.height, viewHolder.image, message.getInReplyTo() != null, viewHolder.messageBody.getVisibility() != View.GONE, type, viewHolder);
962 activity.loadBitmap(message, viewHolder.image);
963 viewHolder.image.setOnClickListener(v -> openDownloadable(message));
964 }
965
966 private void imagePreviewLayout(int w, int h, ShapeableImageView image, boolean otherAbove, boolean otherBelow, int type, ViewHolder viewHolder) {
967 final float target = activity.getResources().getDimension(R.dimen.image_preview_width);
968 final int scaledW;
969 final int scaledH;
970 if (Math.max(h, w) * metrics.density <= target) {
971 scaledW = (int) (w * metrics.density);
972 scaledH = (int) (h * metrics.density);
973 } else if (Math.max(h, w) <= target) {
974 scaledW = w;
975 scaledH = h;
976 } else if (w <= h) {
977 scaledW = (int) (w / ((double) h / target));
978 scaledH = (int) target;
979 } else {
980 scaledW = (int) target;
981 scaledH = (int) (h / ((double) w / target));
982 }
983 final var bodyWidth = Math.max(viewHolder.messageBody.getWidth(), viewHolder.download_button.getWidth() + (20 * metrics.density));
984 var targetImageWidth = 200 * metrics.density;
985 if (!otherBelow) targetImageWidth = 110 * metrics.density;
986 if (bodyWidth > 0 && bodyWidth < targetImageWidth) targetImageWidth = bodyWidth;
987 final var small = scaledW < targetImageWidth;
988 final LinearLayout.LayoutParams layoutParams =
989 new LinearLayout.LayoutParams(scaledW, scaledH);
990 image.setLayoutParams(layoutParams);
991
992 final var bubbleRadius = activity.getResources().getDimension(R.dimen.bubble_radius);
993 var shape = new ShapeAppearanceModel.Builder();
994 if (!otherAbove) {
995 shape = shape.setTopRightCorner(CornerFamily.ROUNDED, bubbleRadius);
996 if (type == SENT) {
997 shape = shape.setTopLeftCorner(CornerFamily.ROUNDED, bubbleRadius);
998 }
999 }
1000 if (small) {
1001 final var imageRadius = activity.getResources().getDimension(R.dimen.image_radius);
1002 shape = shape.setAllCorners(CornerFamily.ROUNDED, imageRadius);
1003 image.setPadding(0, (int)(8 * metrics.density), 0, 0);
1004 } else {
1005 image.setPadding(0, 0, 0, 0);
1006 }
1007 image.setShapeAppearanceModel(shape.build());
1008
1009 if (!small) {
1010 final ViewGroup.LayoutParams blayoutParams = viewHolder.messageBody.getLayoutParams();
1011 blayoutParams.width = (int) (scaledW - (22 * metrics.density));
1012 viewHolder.messageBody.setLayoutParams(blayoutParams);
1013
1014 final ViewGroup.LayoutParams qlayoutParams = viewHolder.inReplyToQuote.getLayoutParams();
1015 qlayoutParams.width = (int) (scaledW - (22 * metrics.density));
1016 viewHolder.messageBody.setLayoutParams(qlayoutParams);
1017 }
1018 }
1019
1020 private void toggleWhisperInfo(
1021 ViewHolder viewHolder, final Message message, final BubbleColor bubbleColor) {
1022 if (message.isPrivateMessage()) {
1023 final String privateMarker;
1024 if (message.getStatus() <= Message.STATUS_RECEIVED) {
1025 privateMarker = activity.getString(R.string.private_message);
1026 } else {
1027 Jid cp = message.getCounterpart();
1028 privateMarker =
1029 activity.getString(
1030 R.string.private_message_to,
1031 Strings.nullToEmpty(cp == null ? null : cp.getResource()));
1032 }
1033 final SpannableString body = new SpannableString(privateMarker);
1034 body.setSpan(
1035 new ForegroundColorSpan(
1036 bubbleToOnSurfaceVariant(viewHolder.messageBody, bubbleColor)),
1037 0,
1038 privateMarker.length(),
1039 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
1040 body.setSpan(
1041 new StyleSpan(Typeface.BOLD),
1042 0,
1043 privateMarker.length(),
1044 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
1045 viewHolder.messageBody.setText(body);
1046 viewHolder.messageBody.setVisibility(View.VISIBLE);
1047 } else {
1048 viewHolder.messageBody.setVisibility(View.GONE);
1049 }
1050 }
1051
1052 private void loadMoreMessages(Conversation conversation) {
1053 conversation.setLastClearHistory(0, null);
1054 activity.xmppConnectionService.updateConversation(conversation);
1055 conversation.setHasMessagesLeftOnServer(true);
1056 conversation.setFirstMamReference(null);
1057 long timestamp = conversation.getLastMessageTransmitted().getTimestamp();
1058 if (timestamp == 0) {
1059 timestamp = System.currentTimeMillis();
1060 }
1061 conversation.messagesLoaded.set(true);
1062 MessageArchiveService.Query query =
1063 activity.xmppConnectionService
1064 .getMessageArchiveService()
1065 .query(conversation, new MamReference(0), timestamp, false);
1066 if (query != null) {
1067 Toast.makeText(activity, R.string.fetching_history_from_server, Toast.LENGTH_LONG)
1068 .show();
1069 } else {
1070 Toast.makeText(
1071 activity,
1072 R.string.not_fetching_history_retention_period,
1073 Toast.LENGTH_SHORT)
1074 .show();
1075 }
1076 }
1077
1078 @Override
1079 public View getView(final int position, View view, final @NonNull ViewGroup parent) {
1080 final Message message = getItem(position);
1081 final boolean omemoEncryption = message.getEncryption() == Message.ENCRYPTION_AXOLOTL;
1082 final boolean isInValidSession =
1083 message.isValidInSession() && (!omemoEncryption || message.isTrusted());
1084 final Conversational conversation = message.getConversation();
1085 final Account account = conversation.getAccount();
1086 final List<Element> commands = message.getCommands();
1087 final int type = getItemViewType(position);
1088 ViewHolder viewHolder;
1089 if (view == null) {
1090 viewHolder = new ViewHolder();
1091 switch (type) {
1092 case DATE_SEPARATOR:
1093 view =
1094 activity.getLayoutInflater()
1095 .inflate(R.layout.item_message_date_bubble, parent, false);
1096 viewHolder.status_message = view.findViewById(R.id.message_body);
1097 viewHolder.message_box = view.findViewById(R.id.message_box);
1098 break;
1099 case RTP_SESSION:
1100 view =
1101 activity.getLayoutInflater()
1102 .inflate(R.layout.item_message_rtp_session, parent, false);
1103 viewHolder.status_message = view.findViewById(R.id.message_body);
1104 viewHolder.message_box = view.findViewById(R.id.message_box);
1105 viewHolder.indicatorReceived = view.findViewById(R.id.indicator_received);
1106 break;
1107 case SENT:
1108 view = activity.getLayoutInflater().inflate(R.layout.item_message_sent, parent, false);
1109 viewHolder.status_line = view.findViewById(R.id.status_line);
1110 viewHolder.message_box_inner = view.findViewById(R.id.message_box_inner);
1111 viewHolder.message_box = view.findViewById(R.id.message_box);
1112 viewHolder.contact_picture = view.findViewById(R.id.message_photo);
1113 viewHolder.download_button = view.findViewById(R.id.download_button);
1114 viewHolder.indicator = view.findViewById(R.id.security_indicator);
1115 viewHolder.edit_indicator = view.findViewById(R.id.edit_indicator);
1116 viewHolder.image = view.findViewById(R.id.message_image);
1117 viewHolder.messageBody = view.findViewById(R.id.message_body);
1118 viewHolder.time = view.findViewById(R.id.message_time);
1119 viewHolder.subject = view.findViewById(R.id.message_subject);
1120 viewHolder.inReplyTo = view.findViewById(R.id.in_reply_to);
1121 viewHolder.inReplyToBox = view.findViewById(R.id.in_reply_to_box);
1122 viewHolder.inReplyToQuote = view.findViewById(R.id.in_reply_to_quote);
1123 viewHolder.indicatorReceived = view.findViewById(R.id.indicator_received);
1124 viewHolder.audioPlayer = view.findViewById(R.id.audio_player);
1125 viewHolder.link_descriptions = view.findViewById(R.id.link_descriptions);
1126 viewHolder.thread_identicon = view.findViewById(R.id.thread_identicon);
1127 break;
1128 case RECEIVED:
1129 view = activity.getLayoutInflater().inflate(R.layout.item_message_received, parent, false);
1130 viewHolder.status_line = view.findViewById(R.id.status_line);
1131 viewHolder.message_box_inner = view.findViewById(R.id.message_box_inner);
1132 viewHolder.message_box = view.findViewById(R.id.message_box);
1133 viewHolder.contact_picture = view.findViewById(R.id.message_photo);
1134 viewHolder.download_button = view.findViewById(R.id.download_button);
1135 viewHolder.indicator = view.findViewById(R.id.security_indicator);
1136 viewHolder.edit_indicator = view.findViewById(R.id.edit_indicator);
1137 viewHolder.image = view.findViewById(R.id.message_image);
1138 viewHolder.messageBody = view.findViewById(R.id.message_body);
1139 viewHolder.time = view.findViewById(R.id.message_time);
1140 viewHolder.subject = view.findViewById(R.id.message_subject);
1141 viewHolder.inReplyTo = view.findViewById(R.id.in_reply_to);
1142 viewHolder.inReplyToQuote = view.findViewById(R.id.in_reply_to_quote);
1143 viewHolder.inReplyToBox = view.findViewById(R.id.in_reply_to_box);
1144 viewHolder.indicatorReceived = view.findViewById(R.id.indicator_received);
1145 viewHolder.encryption = view.findViewById(R.id.message_encryption);
1146 viewHolder.audioPlayer = view.findViewById(R.id.audio_player);
1147 viewHolder.commands_list = view.findViewById(R.id.commands_list);
1148 viewHolder.link_descriptions = view.findViewById(R.id.link_descriptions);
1149 viewHolder.thread_identicon = view.findViewById(R.id.thread_identicon);
1150 break;
1151 case STATUS:
1152 view =
1153 activity.getLayoutInflater()
1154 .inflate(R.layout.item_message_status, parent, false);
1155 viewHolder.contact_picture = view.findViewById(R.id.message_photo);
1156 viewHolder.status_message = view.findViewById(R.id.status_message);
1157 viewHolder.load_more_messages = view.findViewById(R.id.load_more_messages);
1158 break;
1159 default:
1160 throw new AssertionError("Unknown view type");
1161 }
1162 if (viewHolder.link_descriptions != null) {
1163 viewHolder.link_descriptions.setOnItemClickListener((adapter, v, pos, id) -> {
1164 final var desc = (Element) adapter.getItemAtPosition(pos);
1165 var url = desc.findChildContent("url", "https://ogp.me/ns#");
1166 // should we prefer about? Maybe, it's the real original link, but it's not what we show the user
1167 if (url == null || url.length() < 1) url = desc.getAttribute("{http://www.w3.org/1999/02/22-rdf-syntax-ns#}about");
1168 if (url == null || url.length() < 1) return;
1169 new FixedURLSpan(url).onClick(v);
1170 });
1171 }
1172 view.setTag(viewHolder);
1173 } else {
1174 viewHolder = (ViewHolder) view.getTag();
1175 if (viewHolder == null) {
1176 return view;
1177 }
1178 }
1179
1180 if (viewHolder.messageBody != null) {
1181 viewHolder.messageBody.setCustomSelectionActionModeCallback(new MessageTextActionModeCallback(this, viewHolder.messageBody));
1182 }
1183
1184 if (viewHolder.time != null) {
1185 if (message.isAttention()) {
1186 viewHolder.time.setTypeface(null, Typeface.BOLD);
1187 } else {
1188 viewHolder.time.setTypeface(null, Typeface.NORMAL);
1189 }
1190 }
1191
1192 final var black = MaterialColors.getColor(view, com.google.android.material.R.attr.colorSecondaryContainer) == view.getContext().getColor(android.R.color.black);
1193 final boolean colorfulBackground = this.bubbleDesign.colorfulChatBubbles;
1194 final BubbleColor bubbleColor;
1195 if (type == RECEIVED) {
1196 if (isInValidSession) {
1197 bubbleColor = colorfulBackground || black ? BubbleColor.SECONDARY : BubbleColor.SURFACE;
1198 } else {
1199 bubbleColor = BubbleColor.WARNING;
1200 }
1201 } else {
1202 if (!colorfulBackground && black) {
1203 bubbleColor = BubbleColor.SECONDARY;
1204 } else {
1205 bubbleColor = colorfulBackground ? BubbleColor.TERTIARY : BubbleColor.SURFACE_HIGH;
1206 }
1207 }
1208
1209 if (viewHolder.thread_identicon != null) {
1210 viewHolder.thread_identicon.setVisibility(View.GONE);
1211 final Element thread = message.getThread();
1212 if (thread != null) {
1213 final String threadId = thread.getContent();
1214 if (threadId != null) {
1215 final var roles = MaterialColors.getColorRoles(activity, UIHelper.getColorForName(threadId));
1216 viewHolder.thread_identicon.setVisibility(View.VISIBLE);
1217 viewHolder.thread_identicon.setColor(roles.getAccent());
1218 viewHolder.thread_identicon.setHash(UIHelper.identiconHash(threadId));
1219 }
1220 }
1221 }
1222
1223 if (type == DATE_SEPARATOR) {
1224 if (UIHelper.today(message.getTimeSent())) {
1225 viewHolder.status_message.setText(R.string.today);
1226 } else if (UIHelper.yesterday(message.getTimeSent())) {
1227 viewHolder.status_message.setText(R.string.yesterday);
1228 } else {
1229 viewHolder.status_message.setText(
1230 DateUtils.formatDateTime(
1231 activity,
1232 message.getTimeSent(),
1233 DateUtils.FORMAT_SHOW_DATE | DateUtils.FORMAT_SHOW_YEAR));
1234 }
1235 if (colorfulBackground) {
1236 setBackgroundTint(viewHolder.message_box, BubbleColor.PRIMARY);
1237 setTextColor(viewHolder.status_message, BubbleColor.PRIMARY);
1238 } else {
1239 setBackgroundTint(viewHolder.message_box, BubbleColor.SURFACE_HIGH);
1240 setTextColor(viewHolder.status_message, BubbleColor.SURFACE_HIGH);
1241 }
1242 return view;
1243 } else if (type == RTP_SESSION) {
1244 final boolean received = message.getStatus() <= Message.STATUS_RECEIVED;
1245 final RtpSessionStatus rtpSessionStatus = RtpSessionStatus.of(message.getBody());
1246 final long duration = rtpSessionStatus.duration;
1247 final String callTime = UIHelper.readableTimeDifferenceFull(activity, message.getTimeSent());
1248 if (received) {
1249 if (duration > 0) {
1250 viewHolder.status_message.setText(
1251 activity.getString(
1252 R.string.incoming_call_duration_timestamp,
1253 TimeFrameUtils.resolve(activity, duration),
1254 UIHelper.readableTimeDifferenceFull(
1255 activity, message.getTimeSent())));
1256 } else if (rtpSessionStatus.successful) {
1257 viewHolder.status_message.setText(activity.getString(R.string.incoming_call_timestamp, callTime));
1258 } else {
1259 viewHolder.status_message.setText(
1260 activity.getString(
1261 R.string.missed_call_timestamp,
1262 UIHelper.readableTimeDifferenceFull(
1263 activity, message.getTimeSent())));
1264 }
1265 } else {
1266 if (duration > 0) {
1267 viewHolder.status_message.setText(
1268 activity.getString(
1269 R.string.outgoing_call_duration_timestamp,
1270 TimeFrameUtils.resolve(activity, duration),
1271 UIHelper.readableTimeDifferenceFull(
1272 activity, message.getTimeSent())));
1273 } else {
1274 viewHolder.status_message.setText(
1275 activity.getString(
1276 R.string.outgoing_call_timestamp,
1277 UIHelper.readableTimeDifferenceFull(
1278 activity, message.getTimeSent())));
1279 }
1280 }
1281 if (colorfulBackground) {
1282 setBackgroundTint(viewHolder.message_box, BubbleColor.SECONDARY);
1283 setTextColor(viewHolder.status_message, BubbleColor.SECONDARY);
1284 setImageTint(viewHolder.indicatorReceived, BubbleColor.SECONDARY);
1285 } else {
1286 setBackgroundTint(viewHolder.message_box, BubbleColor.SURFACE_HIGH);
1287 setTextColor(viewHolder.status_message, BubbleColor.SURFACE_HIGH);
1288 setImageTint(viewHolder.indicatorReceived, BubbleColor.SURFACE_HIGH);
1289 }
1290 viewHolder.indicatorReceived.setImageResource(
1291 RtpSessionStatus.getDrawable(received, rtpSessionStatus.successful));
1292 return view;
1293 } else if (type == STATUS) {
1294 if ("LOAD_MORE".equals(message.getBody())) {
1295 viewHolder.status_message.setVisibility(View.GONE);
1296 viewHolder.contact_picture.setVisibility(View.GONE);
1297 viewHolder.load_more_messages.setVisibility(View.VISIBLE);
1298 viewHolder.load_more_messages.setOnClickListener(
1299 v -> loadMoreMessages((Conversation) message.getConversation()));
1300 } else {
1301 viewHolder.status_message.setVisibility(View.VISIBLE);
1302 viewHolder.load_more_messages.setVisibility(View.GONE);
1303 viewHolder.status_message.setText(message.getBody());
1304 boolean showAvatar;
1305 if (conversation.getMode() == Conversation.MODE_SINGLE) {
1306 showAvatar = true;
1307 AvatarWorkerTask.loadAvatar(
1308 message, viewHolder.contact_picture, R.dimen.avatar_on_status_message);
1309 } else if (message.getCounterpart() != null
1310 || message.getTrueCounterpart() != null
1311 || (message.getCounterparts() != null
1312 && message.getCounterparts().size() > 0)) {
1313 showAvatar = true;
1314 AvatarWorkerTask.loadAvatar(
1315 message, viewHolder.contact_picture, R.dimen.avatar_on_status_message);
1316 } else {
1317 showAvatar = false;
1318 }
1319 if (showAvatar) {
1320 viewHolder.contact_picture.setAlpha(0.5f);
1321 viewHolder.contact_picture.setVisibility(View.VISIBLE);
1322 } else {
1323 viewHolder.contact_picture.setVisibility(View.GONE);
1324 }
1325 }
1326 return view;
1327 } else {
1328 // viewHolder.message_box.setClipToOutline(true); This eats the bubble tails on A14 for some reason
1329 AvatarWorkerTask.loadAvatar(message, viewHolder.contact_picture, R.dimen.avatar);
1330 }
1331
1332 resetClickListener(viewHolder.message_box, viewHolder.messageBody);
1333
1334 viewHolder.message_box.setOnClickListener(v -> {
1335 if (MessageAdapter.this.mOnMessageBoxClickedListener != null) {
1336 MessageAdapter.this.mOnMessageBoxClickedListener
1337 .onContactPictureClicked(message);
1338 }
1339 });
1340 SwipeDetector swipeDetector = new SwipeDetector((action) -> {
1341 if (action == SwipeDetector.Action.LR && MessageAdapter.this.mOnMessageBoxSwipedListener != null) {
1342 MessageAdapter.this.mOnMessageBoxSwipedListener.onContactPictureClicked(message);
1343 }
1344 });
1345 viewHolder.message_box.setOnTouchListener(swipeDetector);
1346 viewHolder.image.setOnTouchListener(swipeDetector);
1347 viewHolder.time.setOnTouchListener(swipeDetector);
1348
1349 // Treat touch-up as click so we don't have to touch twice
1350 // (touch twice is because it's waiting to see if you double-touch for text selection)
1351 viewHolder.messageBody.setOnTouchListener((v, event) -> {
1352 if (event.getAction() == MotionEvent.ACTION_UP) {
1353 if (MessageAdapter.this.mOnMessageBoxClickedListener != null) {
1354 MessageAdapter.this.mOnMessageBoxClickedListener
1355 .onContactPictureClicked(message);
1356 }
1357 }
1358
1359 swipeDetector.onTouch(v, event);
1360
1361 return false;
1362 });
1363 viewHolder.messageBody.setOnClickListener(v -> {
1364 if (MessageAdapter.this.mOnMessageBoxClickedListener != null) {
1365 MessageAdapter.this.mOnMessageBoxClickedListener
1366 .onContactPictureClicked(message);
1367 }
1368 });
1369 viewHolder.contact_picture.setOnClickListener(v -> {
1370 if (MessageAdapter.this.mOnContactPictureClickedListener != null) {
1371 MessageAdapter.this.mOnContactPictureClickedListener
1372 .onContactPictureClicked(message);
1373 }
1374
1375 });
1376 viewHolder.contact_picture.setOnLongClickListener(v -> {
1377 if (MessageAdapter.this.mOnContactPictureLongClickedListener != null) {
1378 MessageAdapter.this.mOnContactPictureLongClickedListener
1379 .onContactPictureLongClicked(v, message);
1380 return true;
1381 } else {
1382 return false;
1383 }
1384 });
1385 viewHolder.messageBody.setAccessibilityDelegate(null);
1386
1387 boolean footerWrap = false;
1388
1389 final Transferable transferable = message.getTransferable();
1390 final boolean unInitiatedButKnownSize = MessageUtils.unInitiatedButKnownSize(message);
1391
1392 final boolean muted = message.getStatus() == Message.STATUS_RECEIVED && conversation.getMode() == Conversation.MODE_MULTI && activity.xmppConnectionService.isMucUserMuted(new MucOptions.User(null, conversation.getJid(), message.getOccupantId(), null, null));
1393 if (muted) {
1394 // Muted MUC participant
1395 displayInfoMessage(viewHolder, "Muted", bubbleColor);
1396 } else if (unInitiatedButKnownSize || message.isDeleted() || (transferable != null && transferable.getStatus() != Transferable.STATUS_UPLOADING)) {
1397 if (unInitiatedButKnownSize || (message.isDeleted() && message.getModerated() == null) || transferable != null && transferable.getStatus() == Transferable.STATUS_OFFER) {
1398 displayDownloadableMessage(viewHolder, message, activity.getString(R.string.download_x_file, UIHelper.getFileDescriptionString(activity, message)), bubbleColor, type);
1399 } else if (transferable != null && transferable.getStatus() == Transferable.STATUS_OFFER_CHECK_FILESIZE) {
1400 displayDownloadableMessage(viewHolder, message, activity.getString(R.string.check_x_filesize, UIHelper.getFileDescriptionString(activity, message)), bubbleColor, type);
1401 } else {
1402 displayInfoMessage(viewHolder, UIHelper.getMessagePreview(activity.xmppConnectionService, message).first, bubbleColor);
1403 }
1404 } else if (message.isFileOrImage()
1405 && message.getEncryption() != Message.ENCRYPTION_PGP
1406 && message.getEncryption() != Message.ENCRYPTION_DECRYPTION_FAILED) {
1407 if (message.getFileParams().width > 0 && message.getFileParams().height > 0) {
1408 displayMediaPreviewMessage(viewHolder, message, bubbleColor, type);
1409 if (!black && viewHolder.image.getLayoutParams().width > metrics.density * 110) {
1410 footerWrap = true;
1411 }
1412 } else if (message.getFileParams().runtime > 0) {
1413 displayAudioMessage(viewHolder, message, bubbleColor, type);
1414 } else if ("application/xdc+zip".equals(message.getFileParams().getMediaType()) && message.getConversation() instanceof Conversation && message.getThread() != null && !message.getFileParams().getCids().isEmpty()) {
1415 displayWebxdcMessage(viewHolder, message, bubbleColor, type);
1416 } else {
1417 displayOpenableMessage(viewHolder, message, bubbleColor, type);
1418 }
1419 } else if (message.getEncryption() == Message.ENCRYPTION_PGP) {
1420 if (account.isPgpDecryptionServiceConnected()) {
1421 if (conversation instanceof Conversation
1422 && !account.hasPendingPgpIntent((Conversation) conversation)) {
1423 displayInfoMessage(
1424 viewHolder,
1425 activity.getString(R.string.message_decrypting),
1426 bubbleColor);
1427 } else {
1428 displayInfoMessage(
1429 viewHolder, activity.getString(R.string.pgp_message), bubbleColor);
1430 }
1431 } else {
1432 displayInfoMessage(
1433 viewHolder, activity.getString(R.string.install_openkeychain), bubbleColor);
1434 viewHolder.message_box.setOnClickListener(this::promptOpenKeychainInstall);
1435 viewHolder.messageBody.setOnClickListener(this::promptOpenKeychainInstall);
1436 }
1437 } else if (message.getEncryption() == Message.ENCRYPTION_DECRYPTION_FAILED) {
1438 displayInfoMessage(
1439 viewHolder, activity.getString(R.string.decryption_failed), bubbleColor);
1440 } else if (message.getEncryption() == Message.ENCRYPTION_AXOLOTL_NOT_FOR_THIS_DEVICE) {
1441 displayInfoMessage(
1442 viewHolder,
1443 activity.getString(R.string.not_encrypted_for_this_device),
1444 bubbleColor);
1445 } else if (message.getEncryption() == Message.ENCRYPTION_AXOLOTL_FAILED) {
1446 displayInfoMessage(
1447 viewHolder, activity.getString(R.string.omemo_decryption_failed), bubbleColor);
1448 } else {
1449 if (message.isGeoUri()) {
1450 displayLocationMessage(viewHolder, message, bubbleColor, type);
1451 } else if (message.treatAsDownloadable()) {
1452 try {
1453 final URI uri = message.getOob();
1454 displayDownloadableMessage(viewHolder,
1455 message,
1456 activity.getString(
1457 R.string.check_x_filesize_on_host,
1458 UIHelper.getFileDescriptionString(activity, message),
1459 uri.getHost()),
1460 bubbleColor, type);
1461 } catch (Exception e) {
1462 displayDownloadableMessage(
1463 viewHolder,
1464 message,
1465 activity.getString(
1466 R.string.check_x_filesize,
1467 UIHelper.getFileDescriptionString(activity, message)),
1468 bubbleColor, type);
1469 }
1470 } else if (message.bodyIsOnlyEmojis() && message.getType() != Message.TYPE_PRIVATE) {
1471 displayEmojiMessage(viewHolder, message, bubbleColor, type);
1472 } else {
1473 displayTextMessage(viewHolder, message, bubbleColor, message.getType());
1474 }
1475 }
1476
1477 viewHolder.message_box_inner.setMinimumWidth(footerWrap ? (int) (110 * metrics.density) : 0);
1478 LinearLayout.LayoutParams statusParams = (LinearLayout.LayoutParams) viewHolder.status_line.getLayoutParams();
1479 statusParams.width = footerWrap ? ViewGroup.LayoutParams.MATCH_PARENT : ViewGroup.LayoutParams.WRAP_CONTENT;
1480 viewHolder.status_line.setLayoutParams(statusParams);
1481
1482 setBackgroundTint(viewHolder.message_box, bubbleColor);
1483 setTextColor(viewHolder.messageBody, bubbleColor);
1484 viewHolder.messageBody.setLinkTextColor(bubbleToOnSurfaceColor(viewHolder.messageBody, bubbleColor));
1485
1486 if (type == RECEIVED) {
1487 if (!muted && commands != null && conversation instanceof Conversation) {
1488 CommandButtonAdapter adapter = new CommandButtonAdapter(activity);
1489 adapter.addAll(commands);
1490 viewHolder.commands_list.setAdapter(adapter);
1491 viewHolder.commands_list.setVisibility(View.VISIBLE);
1492 viewHolder.commands_list.setOnItemClickListener((p, v, pos, id) -> {
1493 final Element command = adapter.getItem(pos);
1494 activity.startCommand(conversation.getAccount(), command.getAttributeAsJid("jid"), command.getAttribute("node"));
1495 });
1496 } else {
1497 // It's unclear if we can set this to null...
1498 ListAdapter adapter = viewHolder.commands_list.getAdapter();
1499 if (adapter instanceof ArrayAdapter) {
1500 ((ArrayAdapter<?>) adapter).clear();
1501 }
1502 viewHolder.commands_list.setVisibility(View.GONE);
1503 viewHolder.commands_list.setOnItemClickListener(null);
1504 }
1505
1506 setTextColor(viewHolder.encryption, bubbleColor);
1507
1508 if (isInValidSession) {
1509 viewHolder.encryption.setVisibility(View.GONE);
1510 } else {
1511 viewHolder.encryption.setVisibility(View.VISIBLE);
1512 if (omemoEncryption && !message.isTrusted()) {
1513 viewHolder.encryption.setText(R.string.not_trusted);
1514 } else {
1515 viewHolder.encryption.setText(
1516 CryptoHelper.encryptionTypeToText(message.getEncryption()));
1517 }
1518 }
1519 }
1520
1521 if (type == RECEIVED || type == SENT) {
1522 String subject = message.getSubject();
1523 if (subject == null && message.getThread() != null) {
1524 final var thread = ((Conversation) message.getConversation()).getThread(message.getThread().getContent());
1525 if (thread != null) subject = thread.getSubject();
1526 }
1527 if (muted || subject == null) {
1528 viewHolder.subject.setVisibility(View.GONE);
1529 } else {
1530 viewHolder.subject.setVisibility(View.VISIBLE);
1531 viewHolder.subject.setText(subject);
1532 }
1533
1534 if (message.getInReplyTo() == null) {
1535 viewHolder.inReplyToBox.setVisibility(View.GONE);
1536 } else {
1537 viewHolder.inReplyToBox.setVisibility(View.VISIBLE);
1538 viewHolder.inReplyTo.setText(UIHelper.getMessageDisplayName(message.getInReplyTo()));
1539 viewHolder.inReplyTo.setOnClickListener((v) -> mConversationFragment.jumpTo(message.getInReplyTo()));
1540 viewHolder.inReplyToQuote.setOnClickListener((v) -> mConversationFragment.jumpTo(message.getInReplyTo()));
1541 setTextColor(viewHolder.inReplyTo, bubbleColor);
1542 }
1543
1544 final var descriptions = message.getLinkDescriptions();
1545 viewHolder.link_descriptions.setAdapter(new ArrayAdapter<>(activity, 0, descriptions) {
1546 @Override
1547 public View getView(int position, View view, @NonNull ViewGroup parent) {
1548 final LinkDescriptionBinding binding = DataBindingUtil.inflate(LayoutInflater.from(parent.getContext()), R.layout.link_description, parent, false);
1549 binding.title.setText(getItem(position).findChildContent("title", "https://ogp.me/ns#"));
1550 binding.description.setText(getItem(position).findChildContent("description", "https://ogp.me/ns#"));
1551 binding.url.setText(getItem(position).findChildContent("url", "https://ogp.me/ns#"));
1552 return binding.getRoot();
1553 }
1554 });
1555 Util.justifyListViewHeightBasedOnChildren(viewHolder.link_descriptions, (int)(metrics.density * 100), true);
1556 }
1557
1558 displayStatus(viewHolder, message, type, bubbleColor);
1559
1560 viewHolder.messageBody.setAccessibilityDelegate(new View.AccessibilityDelegate() {
1561 @Override
1562 public void sendAccessibilityEvent(View host, int eventType) {
1563 super.sendAccessibilityEvent(host, eventType);
1564 if (eventType == AccessibilityEvent.TYPE_VIEW_TEXT_SELECTION_CHANGED) {
1565 if (viewHolder.messageBody.hasSelection()) {
1566 selectionUuid = message.getUuid();
1567 } else if (message.getUuid() != null && message.getUuid().equals(selectionUuid)) {
1568 selectionUuid = null;
1569 }
1570 }
1571 }
1572 });
1573
1574 return view;
1575 }
1576
1577 private void promptOpenKeychainInstall(View view) {
1578 activity.showInstallPgpDialog();
1579 }
1580
1581 public FileBackend getFileBackend() {
1582 return activity.xmppConnectionService.getFileBackend();
1583 }
1584
1585 public void stopAudioPlayer() {
1586 audioPlayer.stop();
1587 }
1588
1589 public void unregisterListenerInAudioPlayer() {
1590 audioPlayer.unregisterListener();
1591 }
1592
1593 public void startStopPending() {
1594 audioPlayer.startStopPending();
1595 }
1596
1597 public void openDownloadable(Message message) {
1598 if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU
1599 && ContextCompat.checkSelfPermission(
1600 activity, Manifest.permission.WRITE_EXTERNAL_STORAGE)
1601 != PackageManager.PERMISSION_GRANTED) {
1602 ConversationFragment.registerPendingMessage(activity, message);
1603 ActivityCompat.requestPermissions(
1604 activity,
1605 new String[] {Manifest.permission.WRITE_EXTERNAL_STORAGE},
1606 ConversationsActivity.REQUEST_OPEN_MESSAGE);
1607 return;
1608 }
1609 final DownloadableFile file =
1610 activity.xmppConnectionService.getFileBackend().getFile(message);
1611 ViewUtil.view(activity, file);
1612 }
1613
1614 private void showLocation(Message message) {
1615 for (Intent intent : GeoHelper.createGeoIntentsFromMessage(activity, message)) {
1616 if (intent.resolveActivity(getContext().getPackageManager()) != null) {
1617 getContext().startActivity(intent);
1618 return;
1619 }
1620 }
1621 Toast.makeText(
1622 activity,
1623 R.string.no_application_found_to_display_location,
1624 Toast.LENGTH_SHORT)
1625 .show();
1626 }
1627
1628 public void updatePreferences() {
1629 final AppSettings appSettings = new AppSettings(activity);
1630 this.bubbleDesign =
1631 new BubbleDesign(appSettings.isColorfulChatBubbles(), appSettings.isLargeFont());
1632 }
1633
1634 public void setHighlightedTerm(List<String> terms) {
1635 this.highlightedTerm = terms == null ? null : StylingHelper.filterHighlightedWords(terms);
1636 }
1637
1638 public interface OnContactPictureClicked {
1639 void onContactPictureClicked(Message message);
1640 }
1641
1642 public interface OnContactPictureLongClicked {
1643 void onContactPictureLongClicked(View v, Message message);
1644 }
1645
1646 public interface OnInlineImageLongClicked {
1647 boolean onInlineImageLongClicked(Cid cid);
1648 }
1649
1650 private static void setBackgroundTint(final View view, final BubbleColor bubbleColor) {
1651 view.setBackgroundTintList(bubbleToColorStateList(view, bubbleColor));
1652 }
1653
1654 private static ColorStateList bubbleToColorStateList(
1655 final View view, final BubbleColor bubbleColor) {
1656 final @AttrRes int colorAttributeResId =
1657 switch (bubbleColor) {
1658 case SURFACE -> Activities.isNightMode(view.getContext())
1659 ? com.google.android.material.R.attr.colorSurfaceContainerHigh
1660 : com.google.android.material.R.attr.colorSurfaceContainerLow;
1661 case SURFACE_HIGH -> Activities.isNightMode(view.getContext())
1662 ? com.google.android.material.R.attr.colorSurfaceContainerHighest
1663 : com.google.android.material.R.attr.colorSurfaceContainerHigh;
1664 case PRIMARY -> com.google.android.material.R.attr.colorPrimaryContainer;
1665 case SECONDARY -> com.google.android.material.R.attr.colorSecondaryContainer;
1666 case TERTIARY -> com.google.android.material.R.attr.colorTertiaryContainer;
1667 case WARNING -> com.google.android.material.R.attr.colorErrorContainer;
1668 };
1669 return ColorStateList.valueOf(MaterialColors.getColor(view, colorAttributeResId));
1670 }
1671
1672 public static void setImageTint(final ImageView imageView, final BubbleColor bubbleColor) {
1673 ImageViewCompat.setImageTintList(
1674 imageView, bubbleToOnSurfaceColorStateList(imageView, bubbleColor));
1675 }
1676
1677 public static void setImageTintError(final ImageView imageView) {
1678 ImageViewCompat.setImageTintList(
1679 imageView,
1680 ColorStateList.valueOf(
1681 MaterialColors.getColor(
1682 imageView, com.google.android.material.R.attr.colorError)));
1683 }
1684
1685 public static void setTextColor(final TextView textView, final BubbleColor bubbleColor) {
1686 final var color = bubbleToOnSurfaceColor(textView, bubbleColor);
1687 textView.setTextColor(color);
1688 if (BubbleColor.SURFACES.contains(bubbleColor)) {
1689 textView.setLinkTextColor(
1690 MaterialColors.getColor(
1691 textView, com.google.android.material.R.attr.colorPrimary));
1692 } else {
1693 textView.setLinkTextColor(color);
1694 }
1695 }
1696
1697 private static void setTextSize(final TextView textView, final boolean largeFont) {
1698 if (largeFont) {
1699 textView.setTextAppearance(
1700 com.google.android.material.R.style.TextAppearance_Material3_BodyLarge);
1701 textView.setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, 18);
1702 } else {
1703 textView.setTextAppearance(
1704 com.google.android.material.R.style.TextAppearance_Material3_BodyMedium);
1705 }
1706 }
1707
1708 private static @ColorInt int bubbleToOnSurfaceVariant(
1709 final View view, final BubbleColor bubbleColor) {
1710 final @AttrRes int colorAttributeResId;
1711 if (BubbleColor.SURFACES.contains(bubbleColor)) {
1712 colorAttributeResId = com.google.android.material.R.attr.colorOnSurfaceVariant;
1713 } else {
1714 colorAttributeResId = bubbleToOnSurface(bubbleColor);
1715 }
1716 return MaterialColors.getColor(view, colorAttributeResId);
1717 }
1718
1719 private static @ColorInt int bubbleToOnSurfaceColor(
1720 final View view, final BubbleColor bubbleColor) {
1721 return MaterialColors.getColor(view, bubbleToOnSurface(bubbleColor));
1722 }
1723
1724 public static ColorStateList bubbleToOnSurfaceColorStateList(
1725 final View view, final BubbleColor bubbleColor) {
1726 return ColorStateList.valueOf(bubbleToOnSurfaceColor(view, bubbleColor));
1727 }
1728
1729 private static @AttrRes int bubbleToOnSurface(final BubbleColor bubbleColor) {
1730 return switch (bubbleColor) {
1731 case SURFACE, SURFACE_HIGH -> com.google.android.material.R.attr.colorOnSurface;
1732 case PRIMARY -> com.google.android.material.R.attr.colorOnPrimaryContainer;
1733 case SECONDARY -> com.google.android.material.R.attr.colorOnSecondaryContainer;
1734 case TERTIARY -> com.google.android.material.R.attr.colorOnTertiaryContainer;
1735 case WARNING -> com.google.android.material.R.attr.colorOnErrorContainer;
1736 };
1737 }
1738
1739 public enum BubbleColor {
1740 SURFACE,
1741 SURFACE_HIGH,
1742 PRIMARY,
1743 SECONDARY,
1744 TERTIARY,
1745 WARNING;
1746
1747 private static final Collection<BubbleColor> SURFACES =
1748 Arrays.asList(BubbleColor.SURFACE, BubbleColor.SURFACE_HIGH);
1749 }
1750
1751 private static class BubbleDesign {
1752 public final boolean colorfulChatBubbles;
1753 public final boolean largeFont;
1754
1755 private BubbleDesign(final boolean colorfulChatBubbles, final boolean largeFont) {
1756 this.colorfulChatBubbles = colorfulChatBubbles;
1757 this.largeFont = largeFont;
1758 }
1759 }
1760
1761 private static class ViewHolder {
1762
1763 public MaterialButton load_more_messages;
1764 public ImageView edit_indicator;
1765 public RelativeLayout audioPlayer;
1766 protected View status_line;
1767 protected LinearLayout message_box;
1768 protected View message_box_inner;
1769 protected MaterialButton download_button;
1770 protected ShapeableImageView image;
1771 protected ImageView indicator;
1772 protected ImageView indicatorReceived;
1773 protected TextView time;
1774 protected TextView subject;
1775 protected TextView inReplyTo;
1776 protected TextView inReplyToQuote;
1777 protected LinearLayout inReplyToBox;
1778 protected TextView messageBody;
1779 protected ImageView contact_picture;
1780 protected TextView status_message;
1781 protected TextView encryption;
1782 protected ListView commands_list;
1783 protected ListView link_descriptions;
1784 protected GithubIdenticonView thread_identicon;
1785 }
1786
1787 class ThumbnailTask extends AsyncTask<DownloadableFile, Void, Drawable[]> {
1788 @Override
1789 protected Drawable[] doInBackground(DownloadableFile... params) {
1790 if (isCancelled()) return null;
1791
1792 Drawable[] d = new Drawable[params.length];
1793 for (int i = 0; i < params.length; i++) {
1794 try {
1795 d[i] = activity.xmppConnectionService.getFileBackend().getThumbnail(params[i], activity.getResources(), (int) (metrics.density * 288), false);
1796 } catch (final IOException e) {
1797 d[i] = null;
1798 }
1799 }
1800
1801 return d;
1802 }
1803
1804 @Override
1805 protected void onPostExecute(final Drawable[] d) {
1806 if (isCancelled()) return;
1807 activity.xmppConnectionService.updateConversationUi();
1808 }
1809 }
1810}