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