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