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.util.Log;
34import android.view.View;
35import android.view.ViewGroup;
36import android.view.WindowManager;
37import android.widget.ArrayAdapter;
38import android.widget.ImageView;
39import android.widget.LinearLayout;
40import android.widget.ListAdapter;
41import android.widget.ListView;
42import android.widget.RelativeLayout;
43import android.widget.TextView;
44import android.widget.Toast;
45import androidx.annotation.AttrRes;
46import androidx.annotation.ColorInt;
47import androidx.annotation.DrawableRes;
48import androidx.annotation.NonNull;
49import androidx.annotation.Nullable;
50import androidx.constraintlayout.widget.ConstraintLayout;
51import androidx.core.app.ActivityCompat;
52import androidx.core.content.ContextCompat;
53import androidx.core.content.res.ResourcesCompat;
54import androidx.core.widget.ImageViewCompat;
55import androidx.databinding.DataBindingUtil;
56
57import com.google.android.material.imageview.ShapeableImageView;
58import com.google.android.material.shape.CornerFamily;
59import com.google.android.material.shape.ShapeAppearanceModel;
60
61import com.cheogram.android.BobTransfer;
62import com.cheogram.android.EmojiSearch;
63import com.cheogram.android.GetThumbnailForCid;
64import com.cheogram.android.MessageTextActionModeCallback;
65import com.cheogram.android.SwipeDetector;
66import com.cheogram.android.Util;
67import com.cheogram.android.WebxdcPage;
68import com.cheogram.android.WebxdcUpdate;
69
70import com.google.android.material.button.MaterialButton;
71import com.google.android.material.chip.ChipGroup;
72import com.google.android.material.color.MaterialColors;
73import com.google.android.material.dialog.MaterialAlertDialogBuilder;
74import com.google.common.base.Joiner;
75import com.google.common.base.Strings;
76import com.google.common.collect.Collections2;
77import com.google.common.collect.ImmutableList;
78import com.google.common.collect.ImmutableSet;
79
80import com.lelloman.identicon.view.GithubIdenticonView;
81
82import io.ipfs.cid.Cid;
83
84import java.io.IOException;
85import java.net.URI;
86import java.net.URISyntaxException;
87import java.security.NoSuchAlgorithmException;
88import java.util.HashMap;
89import java.util.List;
90import java.util.Map;
91import java.util.Locale;
92import java.util.function.Function;
93import java.util.regex.Matcher;
94import java.util.regex.Pattern;
95
96import me.saket.bettermovementmethod.BetterLinkMovementMethod;
97
98import net.fellbaum.jemoji.EmojiManager;
99
100import de.gultsch.common.Linkify;
101import eu.siacs.conversations.AppSettings;
102import eu.siacs.conversations.Config;
103import eu.siacs.conversations.R;
104import eu.siacs.conversations.crypto.axolotl.FingerprintStatus;
105import eu.siacs.conversations.databinding.LinkDescriptionBinding;
106import eu.siacs.conversations.databinding.DialogAddReactionBinding;
107import eu.siacs.conversations.databinding.ItemMessageDateBubbleBinding;
108import eu.siacs.conversations.databinding.ItemMessageEndBinding;
109import eu.siacs.conversations.databinding.ItemMessageRtpSessionBinding;
110import eu.siacs.conversations.databinding.ItemMessageStartBinding;
111import eu.siacs.conversations.databinding.ItemMessageStatusBinding;
112import eu.siacs.conversations.entities.Account;
113import eu.siacs.conversations.entities.Contact;
114import eu.siacs.conversations.entities.Conversation;
115import eu.siacs.conversations.entities.Conversational;
116import eu.siacs.conversations.entities.DownloadableFile;
117import eu.siacs.conversations.entities.Message.FileParams;
118import eu.siacs.conversations.entities.Message;
119import eu.siacs.conversations.entities.MucOptions;
120import eu.siacs.conversations.entities.Reaction;
121import eu.siacs.conversations.entities.Roster;
122import eu.siacs.conversations.entities.RtpSessionStatus;
123import eu.siacs.conversations.entities.Transferable;
124import eu.siacs.conversations.persistance.FileBackend;
125import eu.siacs.conversations.services.MessageArchiveService;
126import eu.siacs.conversations.services.NotificationService;
127import eu.siacs.conversations.ui.Activities;
128import eu.siacs.conversations.ui.BindingAdapters;
129import eu.siacs.conversations.ui.ConversationFragment;
130import eu.siacs.conversations.ui.ConversationsActivity;
131import eu.siacs.conversations.ui.XmppActivity;
132import eu.siacs.conversations.ui.service.AudioPlayer;
133import eu.siacs.conversations.ui.text.DividerSpan;
134import eu.siacs.conversations.ui.text.FixedURLSpan;
135import eu.siacs.conversations.ui.text.QuoteSpan;
136import eu.siacs.conversations.ui.util.Attachment;
137import eu.siacs.conversations.ui.util.AvatarWorkerTask;
138import eu.siacs.conversations.ui.util.QuoteHelper;
139import eu.siacs.conversations.ui.util.ShareUtil;
140import eu.siacs.conversations.ui.util.ViewUtil;
141import eu.siacs.conversations.utils.CryptoHelper;
142import eu.siacs.conversations.utils.Emoticons;
143import eu.siacs.conversations.utils.GeoHelper;
144import eu.siacs.conversations.utils.MessageUtils;
145import eu.siacs.conversations.utils.StylingHelper;
146import eu.siacs.conversations.utils.TimeFrameUtils;
147import eu.siacs.conversations.utils.UIHelper;
148import eu.siacs.conversations.xmpp.Jid;
149import eu.siacs.conversations.xmpp.mam.MamReference;
150import eu.siacs.conversations.xml.Element;
151import kotlin.coroutines.Continuation;
152
153import java.net.URI;
154import java.util.Arrays;
155import java.util.Collection;
156import java.util.List;
157import java.util.Locale;
158import java.util.regex.Matcher;
159import java.util.regex.Pattern;
160
161public class MessageAdapter extends ArrayAdapter<Message> {
162
163 public static final String DATE_SEPARATOR_BODY = "DATE_SEPARATOR";
164 private static final int END = 0;
165 private static final int START = 1;
166 private static final int STATUS = 2;
167 private static final int DATE_SEPARATOR = 3;
168 private static final int RTP_SESSION = 4;
169 private final XmppActivity activity;
170 private final AudioPlayer audioPlayer;
171 private List<String> highlightedTerm = null;
172 private final DisplayMetrics metrics;
173 private ConversationFragment mConversationFragment = null;
174 private OnContactPictureClicked mOnContactPictureClickedListener;
175 private OnContactPictureClicked mOnMessageBoxClickedListener;
176 private OnContactPictureClicked mOnMessageBoxSwipedListener;
177 private OnContactPictureLongClicked mOnContactPictureLongClickedListener;
178 private OnInlineImageLongClicked mOnInlineImageLongClickedListener;
179 private BubbleDesign bubbleDesign = new BubbleDesign(false, false, false, true);
180 private final boolean mForceNames;
181 private final Map<String, WebxdcUpdate> lastWebxdcUpdate = new HashMap<>();
182 private String selectionUuid = null;
183 private final AppSettings appSettings;
184
185 public MessageAdapter(
186 final XmppActivity activity, final List<Message> messages, final boolean forceNames) {
187 super(activity, 0, messages);
188 this.audioPlayer = new AudioPlayer(this);
189 this.activity = activity;
190 metrics = getContext().getResources().getDisplayMetrics();
191 appSettings = new AppSettings(activity);
192 updatePreferences();
193 this.mForceNames = forceNames;
194 }
195
196 public MessageAdapter(final XmppActivity activity, final List<Message> messages) {
197 this(activity, messages, false);
198 }
199
200 private static void resetClickListener(View... views) {
201 for (View view : views) {
202 if (view != null) view.setOnClickListener(null);
203 }
204 }
205
206 public void flagScreenOn() {
207 activity.getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
208 }
209
210 public void flagScreenOff() {
211 activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
212 }
213
214 public void setVolumeControl(final int stream) {
215 activity.setVolumeControlStream(stream);
216 }
217
218 public void setOnContactPictureClicked(OnContactPictureClicked listener) {
219 this.mOnContactPictureClickedListener = listener;
220 }
221
222 public void setOnMessageBoxClicked(OnContactPictureClicked listener) {
223 this.mOnMessageBoxClickedListener = listener;
224 }
225
226 public void setOnMessageBoxSwiped(OnContactPictureClicked listener) {
227 this.mOnMessageBoxSwipedListener = listener;
228 }
229
230 public void setConversationFragment(ConversationFragment frag) {
231 mConversationFragment = frag;
232 }
233
234 public void quoteText(String text) {
235 if (mConversationFragment != null) mConversationFragment.quoteText(text);
236 }
237
238 public boolean hasSelection() {
239 return selectionUuid != null;
240 }
241
242 public Activity getActivity() {
243 return activity;
244 }
245
246 public void setOnContactPictureLongClicked(OnContactPictureLongClicked listener) {
247 this.mOnContactPictureLongClickedListener = listener;
248 }
249
250 public void setOnInlineImageLongClicked(OnInlineImageLongClicked listener) {
251 this.mOnInlineImageLongClickedListener = listener;
252 }
253
254 @Override
255 public int getViewTypeCount() {
256 return 5;
257 }
258
259 private static int getItemViewType(final Message message, final boolean alignStart) {
260 if (message.getType() == Message.TYPE_STATUS) {
261 if (DATE_SEPARATOR_BODY.equals(message.getBody())) {
262 return DATE_SEPARATOR;
263 } else {
264 return STATUS;
265 }
266 } else if (message.getType() == Message.TYPE_RTP_SESSION) {
267 return RTP_SESSION;
268 } else if (message.getStatus() <= Message.STATUS_RECEIVED || alignStart) {
269 return START;
270 } else {
271 return END;
272 }
273 }
274
275 @Override
276 public int getItemViewType(final int position) {
277 return getItemViewType(getItem(position), bubbleDesign.alignStart);
278 }
279
280 private void displayStatus(
281 final BubbleMessageItemViewHolder viewHolder,
282 final Message message,
283 final BubbleColor bubbleColor) {
284 final int status = message.getStatus();
285 final boolean error;
286 final Transferable transferable = message.getTransferable();
287 final boolean multiReceived =
288 message.getConversation().getMode() == Conversation.MODE_MULTI
289 && message.getStatus() <= Message.STATUS_RECEIVED;
290 final boolean sent = status != Message.STATUS_RECEIVED;
291 final boolean showUserNickname =
292 message.getConversation().getMode() == Conversation.MODE_MULTI
293 && viewHolder instanceof StartBubbleMessageItemViewHolder;
294 final String fileSize;
295 if (message.isFileOrImage()
296 || transferable != null
297 || MessageUtils.unInitiatedButKnownSize(message)) {
298 final FileParams params = message.getFileParams();
299 fileSize = params.size != null ? UIHelper.filesizeToString(params.size) : null;
300 if (message.getStatus() == Message.STATUS_SEND_FAILED
301 || (transferable != null
302 && (transferable.getStatus() == Transferable.STATUS_FAILED
303 || transferable.getStatus()
304 == Transferable.STATUS_CANCELLED))) {
305 error = true;
306 } else {
307 error = message.getStatus() == Message.STATUS_SEND_FAILED;
308 }
309 } else {
310 fileSize = null;
311 error = message.getStatus() == Message.STATUS_SEND_FAILED;
312 }
313
314 if (sent) {
315 final @DrawableRes Integer receivedIndicator =
316 getMessageStatusAsDrawable(message, status);
317 if (receivedIndicator == null) {
318 viewHolder.indicatorReceived().setVisibility(View.INVISIBLE);
319 } else {
320 viewHolder.indicatorReceived().setImageResource(receivedIndicator);
321 if (status == Message.STATUS_SEND_FAILED) {
322 setImageTintError(viewHolder.indicatorReceived());
323 } else {
324 setImageTint(viewHolder.indicatorReceived(), bubbleColor);
325 }
326 viewHolder.indicatorReceived().setVisibility(View.VISIBLE);
327 }
328 } else {
329 viewHolder.indicatorReceived().setVisibility(View.GONE);
330 }
331 final var additionalStatusInfo = getAdditionalStatusInfo(message, status);
332
333 if (error && sent) {
334 viewHolder
335 .time()
336 .setTextColor(
337 MaterialColors.getColor(
338 viewHolder.time(), androidx.appcompat.R.attr.colorError));
339 } else {
340 setTextColor(viewHolder.time(), bubbleColor);
341 }
342 setTextColor(viewHolder.subject(), bubbleColor);
343 if (message.getEncryption() == Message.ENCRYPTION_NONE) {
344 viewHolder.indicatorSecurity().setVisibility(View.GONE);
345 } else {
346 boolean verified = false;
347 if (message.getEncryption() == Message.ENCRYPTION_AXOLOTL) {
348 final FingerprintStatus fingerprintStatus =
349 message.getConversation()
350 .getAccount()
351 .getAxolotlService()
352 .getFingerprintTrust(message.getFingerprint());
353 if (fingerprintStatus != null && fingerprintStatus.isVerified()) {
354 verified = true;
355 }
356 }
357 if (verified) {
358 viewHolder.indicatorSecurity().setImageResource(R.drawable.ic_verified_user_24dp);
359 } else {
360 viewHolder.indicatorSecurity().setImageResource(R.drawable.ic_lock_24dp);
361 }
362 if (error && sent) {
363 setImageTintError(viewHolder.indicatorSecurity());
364 } else {
365 setImageTint(viewHolder.indicatorSecurity(), bubbleColor);
366 }
367 viewHolder.indicatorSecurity().setVisibility(View.VISIBLE);
368 }
369
370 if (message.edited()) {
371 viewHolder.indicatorEdit().setVisibility(View.VISIBLE);
372 if (error && sent) {
373 setImageTintError(viewHolder.indicatorEdit());
374 } else {
375 setImageTint(viewHolder.indicatorEdit(), bubbleColor);
376 }
377 } else {
378 viewHolder.indicatorEdit().setVisibility(View.GONE);
379 }
380
381 final String formattedTime =
382 UIHelper.readableTimeDifferenceFull(getContext(), message.getTimeSent());
383 final String bodyLanguage = message.getBodyLanguage();
384 final ImmutableList.Builder<String> timeInfoBuilder = new ImmutableList.Builder<>();
385
386 if (mForceNames || multiReceived || showUserNickname || (message.getTrueCounterpart() != null && message.getContact() != null)) {
387 final String displayName = UIHelper.getMessageDisplayName(message);
388 if (displayName != null) {
389 timeInfoBuilder.add(displayName);
390 }
391 }
392 if (fileSize != null) {
393 timeInfoBuilder.add(fileSize);
394 }
395 if (bodyLanguage != null) {
396 timeInfoBuilder.add(bodyLanguage.toUpperCase(Locale.US));
397 }
398 // for space reasons we display only 'additional status info' (send progress or concrete
399 // failure reason) or the time
400 if (additionalStatusInfo != null) {
401 timeInfoBuilder.add(additionalStatusInfo);
402 } else {
403 timeInfoBuilder.add(formattedTime);
404 }
405 final var timeInfo = timeInfoBuilder.build();
406 viewHolder.time().setText(Joiner.on(" · ").join(timeInfo));
407 }
408
409 public static @DrawableRes Integer getMessageStatusAsDrawable(
410 final Message message, final int status) {
411 final var transferable = message.getTransferable();
412 return switch (status) {
413 case Message.STATUS_WAITING -> R.drawable.ic_more_horiz_24dp;
414 case Message.STATUS_UNSEND -> transferable == null ? null : R.drawable.ic_upload_24dp;
415 case Message.STATUS_SEND -> R.drawable.ic_done_24dp;
416 case Message.STATUS_SEND_RECEIVED, Message.STATUS_SEND_DISPLAYED ->
417 R.drawable.ic_done_all_24dp;
418 case Message.STATUS_SEND_FAILED -> {
419 final String errorMessage = message.getErrorMessage();
420 if (Message.ERROR_MESSAGE_CANCELLED.equals(errorMessage)) {
421 yield R.drawable.ic_cancel_24dp;
422 } else {
423 yield R.drawable.ic_error_24dp;
424 }
425 }
426 case Message.STATUS_OFFERED -> R.drawable.ic_p2p_24dp;
427 default -> null;
428 };
429 }
430
431 @Nullable
432 private String getAdditionalStatusInfo(final Message message, final int mergedStatus) {
433 final String additionalStatusInfo;
434 if (mergedStatus == Message.STATUS_SEND_FAILED) {
435 final String errorMessage = Strings.nullToEmpty(message.getErrorMessage());
436 final String[] errorParts = errorMessage.split("\\u001f", 2);
437 if (errorParts.length == 2 && errorParts[0].equals("file-too-large")) {
438 additionalStatusInfo = getContext().getString(R.string.file_too_large);
439 } else {
440 additionalStatusInfo = null;
441 }
442 } else if (mergedStatus == Message.STATUS_UNSEND) {
443 final var transferable = message.getTransferable();
444 if (transferable == null) {
445 return null;
446 }
447 return getContext().getString(R.string.sending_file, transferable.getProgress());
448 } else {
449 additionalStatusInfo = null;
450 }
451 return additionalStatusInfo;
452 }
453
454 private void displayInfoMessage(
455 BubbleMessageItemViewHolder viewHolder,
456 CharSequence text,
457 final BubbleColor bubbleColor) {
458 viewHolder.downloadButton().setVisibility(View.GONE);
459 viewHolder.audioPlayer().setVisibility(View.GONE);
460 viewHolder.image().setVisibility(View.GONE);
461 viewHolder.messageBody().setTypeface(null, Typeface.ITALIC);
462 viewHolder.messageBody().setVisibility(View.VISIBLE);
463 viewHolder.messageBody().setText(text);
464 viewHolder
465 .messageBody()
466 .setTextColor(bubbleToOnSurfaceVariant(viewHolder.messageBody(), bubbleColor));
467 viewHolder.messageBody().setTextIsSelectable(false);
468 }
469
470 private void displayEmojiMessage(
471 final BubbleMessageItemViewHolder viewHolder,
472 final Message message,
473 final BubbleColor bubbleColor) {
474 displayTextMessage(viewHolder, message, bubbleColor);
475 viewHolder.downloadButton().setVisibility(View.GONE);
476 viewHolder.audioPlayer().setVisibility(View.GONE);
477 viewHolder.image().setVisibility(View.GONE);
478 viewHolder.messageBody().setTypeface(null, Typeface.NORMAL);
479 viewHolder.messageBody().setVisibility(View.VISIBLE);
480 setTextColor(viewHolder.messageBody(), bubbleColor);
481 final var body = getSpannableBody(message);
482 ImageSpan[] imageSpans = body.getSpans(0, body.length(), ImageSpan.class);
483 float size = imageSpans.length == 1 || Emoticons.isEmoji(body.toString()) ? 5.0f : 2.0f;
484 body.setSpan(
485 new RelativeSizeSpan(size), 0, body.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
486 viewHolder.messageBody().setText(body);
487 }
488
489 private void applyQuoteSpan(
490 final TextView textView,
491 Editable body,
492 int start,
493 int end,
494 final BubbleColor bubbleColor,
495 final boolean makeEdits) {
496 if (makeEdits && start > 1 && !"\n\n".equals(body.subSequence(start - 2, start).toString())) {
497 body.insert(start++, "\n");
498 body.setSpan(
499 new DividerSpan(false), start - 2, start, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
500 end++;
501 }
502 if (makeEdits && end < body.length() - 1 && !"\n\n".equals(body.subSequence(end, end + 2).toString())) {
503 body.insert(end, "\n");
504 body.setSpan(
505 new DividerSpan(false),
506 end,
507 end + ("\n".equals(body.subSequence(end + 1, end + 2).toString()) ? 2 : 1),
508 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
509 );
510 }
511 final DisplayMetrics metrics = getContext().getResources().getDisplayMetrics();
512 body.setSpan(
513 new QuoteSpan(bubbleToOnSurfaceVariant(textView, bubbleColor), metrics),
514 start,
515 end,
516 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
517 }
518
519 public boolean handleTextQuotes(final TextView textView, final Editable body) {
520 return handleTextQuotes(textView, body, true);
521 }
522
523 public boolean handleTextQuotes(final TextView textView, final Editable body, final boolean deleteMarkers) {
524 final boolean colorfulBackground = this.bubbleDesign.colorfulChatBubbles;
525 final BubbleColor bubbleColor = colorfulBackground ? (deleteMarkers ? BubbleColor.SECONDARY : BubbleColor.TERTIARY) : BubbleColor.SURFACE;
526 return handleTextQuotes(textView, body, bubbleColor, deleteMarkers);
527 }
528
529 /**
530 * Applies QuoteSpan to group of lines which starts with > or » characters. Appends likebreaks
531 * and applies DividerSpan to them to show a padding between quote and text.
532 */
533 public boolean handleTextQuotes(
534 final TextView textView,
535 final Editable body,
536 final BubbleColor bubbleColor,
537 final boolean deleteMarkers) {
538 boolean startsWithQuote = false;
539 int quoteDepth = 1;
540 while (QuoteHelper.bodyContainsQuoteStart(body) && quoteDepth <= Config.QUOTE_MAX_DEPTH) {
541 char previous = '\n';
542 int lineStart = -1;
543 int lineTextStart = -1;
544 int quoteStart = -1;
545 int skipped = 0;
546 for (int i = 0; i <= body.length(); i++) {
547 if (!deleteMarkers && QuoteHelper.isRelativeSizeSpanned(body, i)) {
548 skipped++;
549 continue;
550 }
551 char current = body.length() > i ? body.charAt(i) : '\n';
552 if (lineStart == -1) {
553 if (previous == '\n') {
554 if (i < body.length() && QuoteHelper.isPositionQuoteStart(body, i)) {
555 // Line start with quote
556 lineStart = i;
557 if (quoteStart == -1) quoteStart = i - skipped;
558 if (i == 0) startsWithQuote = true;
559 } else if (quoteStart >= 0) {
560 // Line start without quote, apply spans there
561 applyQuoteSpan(textView, body, quoteStart, i - 1, bubbleColor, deleteMarkers);
562 quoteStart = -1;
563 }
564 }
565 } else {
566 // Remove extra spaces between > and first character in the line
567 // > character will be removed too
568 if (current != ' ' && lineTextStart == -1) {
569 lineTextStart = i;
570 }
571 if (current == '\n') {
572 if (deleteMarkers) {
573 i -= lineTextStart - lineStart;
574 body.delete(lineStart, lineTextStart);
575 if (i == lineStart) {
576 // Avoid empty lines because span over empty line can be hidden
577 body.insert(i++, " ");
578 }
579 } else {
580 body.setSpan(new RelativeSizeSpan(i - (lineTextStart - lineStart) == lineStart ? 1 : 0), lineStart, lineTextStart, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE | StylingHelper.XHTML_REMOVE << Spanned.SPAN_USER_SHIFT);
581 }
582 lineStart = -1;
583 lineTextStart = -1;
584 }
585 }
586 previous = current;
587 skipped = 0;
588 }
589 if (quoteStart >= 0) {
590 // Apply spans to finishing open quote
591 applyQuoteSpan(textView, body, quoteStart, body.length(), bubbleColor, deleteMarkers);
592 }
593 quoteDepth++;
594 }
595 return startsWithQuote;
596 }
597
598 private SpannableStringBuilder getSpannableBody(final Message message) {
599 Drawable fallbackImg = ResourcesCompat.getDrawable(activity.getResources(), R.drawable.ic_photo_24dp, null);
600 return message.getSpannableBody(new Thumbnailer(message), fallbackImg);
601 }
602
603 private void displayTextMessage(
604 final BubbleMessageItemViewHolder viewHolder,
605 final Message message,
606 final BubbleColor bubbleColor) {
607 viewHolder.inReplyToQuote().setVisibility(View.GONE);
608 viewHolder.downloadButton().setVisibility(View.GONE);
609 viewHolder.image().setVisibility(View.GONE);
610 viewHolder.audioPlayer().setVisibility(View.GONE);
611 viewHolder.messageBody().setVisibility(View.VISIBLE);
612 setTextColor(viewHolder.messageBody(), bubbleColor);
613 setTextSize(viewHolder.messageBody(), this.bubbleDesign.largeFont);
614 viewHolder.messageBody().setTypeface(null, Typeface.NORMAL);
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.inReplyToQuote().setLayoutParams(qlayoutParams);
623
624 final var rawBody = message.getBody();
625 if (Strings.isNullOrEmpty(rawBody)) {
626 viewHolder.messageBody().setText("");
627 viewHolder.messageBody().setTextIsSelectable(false);
628 toggleWhisperInfo(viewHolder, message, bubbleColor);
629 return;
630 }
631 viewHolder.messageBody().setTextIsSelectable(true);
632 final String nick = UIHelper.getMessageDisplayName(message);
633 SpannableStringBuilder body = getSpannableBody(message);
634 final var processMarkup = body.getSpans(0, body.length(), Message.PlainTextSpan.class).length > 0;
635 if (body.length() > Config.MAX_DISPLAY_MESSAGE_CHARS) {
636 body = new SpannableStringBuilder(body, 0, Config.MAX_DISPLAY_MESSAGE_CHARS);
637 body.append("…");
638 }
639 if (processMarkup) StylingHelper.format(body, viewHolder.messageBody().getCurrentTextColor());
640 Linkify.addLinks(body, message.getConversation().getAccount(), message.getConversation().getJid());
641 FixedURLSpan.fix(body);
642 boolean startsWithQuote = processMarkup ? handleTextQuotes(viewHolder.messageBody(), body, bubbleColor, true) : false;
643 for (final android.text.style.QuoteSpan quote : body.getSpans(0, body.length(), android.text.style.QuoteSpan.class)) {
644 int start = body.getSpanStart(quote);
645 int end = body.getSpanEnd(quote);
646 if (start < 0 || end < 0) continue;
647
648 body.removeSpan(quote);
649 applyQuoteSpan(viewHolder.messageBody(), body, start, end, bubbleColor, true);
650 if (start == 0) {
651 if (message.getInReplyTo() == null) {
652 startsWithQuote = true;
653 } else {
654 viewHolder.inReplyToQuote().setText(body.subSequence(start, end));
655 viewHolder.inReplyToQuote().setVisibility(View.VISIBLE);
656 body.delete(start, end);
657 while (body.length() > start && body.charAt(start) == '\n') body.delete(start, 1); // Newlines after quote
658 continue;
659 }
660 }
661 }
662 boolean hasMeCommand = body.toString().startsWith(Message.ME_COMMAND);
663 if (hasMeCommand) {
664 body.replace(0, Message.ME_COMMAND.length(), String.format("%s ", nick));
665 }
666 if (!message.isPrivateMessage()) {
667 if (hasMeCommand && body.length() > nick.length()) {
668 body.setSpan(
669 new StyleSpan(Typeface.BOLD_ITALIC),
670 0,
671 nick.length(),
672 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
673 }
674 } else {
675 String privateMarker;
676 if (message.getStatus() <= Message.STATUS_RECEIVED) {
677 privateMarker = activity.getString(R.string.private_message);
678 } else {
679 Jid cp = message.getCounterpart();
680 privateMarker =
681 activity.getString(
682 R.string.private_message_to,
683 Strings.nullToEmpty(cp == null ? null : cp.getResource()));
684 }
685 body.insert(0, privateMarker);
686 int privateMarkerIndex = privateMarker.length();
687 if (startsWithQuote) {
688 body.insert(privateMarkerIndex, "\n\n");
689 body.setSpan(
690 new DividerSpan(false),
691 privateMarkerIndex,
692 privateMarkerIndex + 2,
693 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
694 } else {
695 body.insert(privateMarkerIndex, " ");
696 }
697 body.setSpan(
698 new ForegroundColorSpan(
699 bubbleToOnSurfaceVariant(viewHolder.messageBody(), bubbleColor)),
700 0,
701 privateMarkerIndex,
702 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
703 body.setSpan(
704 new StyleSpan(Typeface.BOLD),
705 0,
706 privateMarkerIndex,
707 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
708 if (hasMeCommand) {
709 body.setSpan(
710 new StyleSpan(Typeface.BOLD_ITALIC),
711 privateMarkerIndex + 1,
712 privateMarkerIndex + 1 + nick.length(),
713 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
714 }
715 }
716 if (message.getConversation().getMode() == Conversation.MODE_MULTI
717 && message.getStatus() == Message.STATUS_RECEIVED) {
718 if (message.getConversation() instanceof Conversation conversation) {
719 Pattern pattern =
720 NotificationService.generateNickHighlightPattern(
721 conversation.getMucOptions().getActualNick());
722 Matcher matcher = pattern.matcher(body);
723 while (matcher.find()) {
724 body.setSpan(
725 new StyleSpan(Typeface.BOLD),
726 matcher.start(),
727 matcher.end(),
728 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
729 }
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 (highlightedTerm != null) {
752 StylingHelper.highlight(viewHolder.messageBody(), body, highlightedTerm);
753 }
754
755 viewHolder.messageBody().setAutoLinkMask(0);
756 viewHolder.messageBody().setText(body);
757 if (body.length() <= 0) viewHolder.messageBody().setVisibility(View.GONE);
758 BetterLinkMovementMethod method = new BetterLinkMovementMethod() {
759 @Override
760 protected void dispatchUrlLongClick(TextView tv, ClickableSpan span) {
761 if (span instanceof URLSpan || mOnInlineImageLongClickedListener == null) {
762 tv.dispatchTouchEvent(MotionEvent.obtain(0, 0, MotionEvent.ACTION_CANCEL, 0f, 0f, 0));
763 super.dispatchUrlLongClick(tv, span);
764 return;
765 }
766
767 Spannable body = (Spannable) tv.getText();
768 ImageSpan[] imageSpans = body.getSpans(body.getSpanStart(span), body.getSpanEnd(span), ImageSpan.class);
769 if (imageSpans.length > 0) {
770 Uri uri = Uri.parse(imageSpans[0].getSource());
771 Cid cid = BobTransfer.cid(uri);
772 if (cid == null) return;
773 if (mOnInlineImageLongClickedListener.onInlineImageLongClicked(cid)) {
774 tv.dispatchTouchEvent(MotionEvent.obtain(0, 0, MotionEvent.ACTION_CANCEL, 0f, 0f, 0));
775 }
776 }
777 }
778 };
779 method.setOnLinkLongClickListener((tv, url) -> {
780 tv.dispatchTouchEvent(MotionEvent.obtain(0, 0, MotionEvent.ACTION_CANCEL, 0f, 0f, 0));
781 ShareUtil.copyLinkToClipboard(activity, url);
782 return true;
783 });
784 viewHolder.messageBody().setMovementMethod(method);
785 }
786
787 private void displayDownloadableMessage(
788 final BubbleMessageItemViewHolder viewHolder,
789 final Message message,
790 String text,
791 final BubbleColor bubbleColor) {
792 displayTextMessage(viewHolder, message, bubbleColor);
793 viewHolder.image().setVisibility(View.GONE);
794 List<Element> thumbs = message.getFileParams() != null ? message.getFileParams().getThumbnails() : null;
795 if (thumbs != null && !thumbs.isEmpty()) {
796 for (Element thumb : thumbs) {
797 Uri uri = Uri.parse(thumb.getAttribute("uri"));
798 if (uri.getScheme().equals("data")) {
799 String[] parts = uri.getSchemeSpecificPart().split(",", 2);
800 parts = parts[0].split(";");
801 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;
802 } else if (uri.getScheme().equals("cid")) {
803 Cid cid = BobTransfer.cid(uri);
804 if (cid == null) continue;
805 DownloadableFile f = activity.xmppConnectionService.getFileForCid(cid);
806 if (f == null || !f.canRead()) {
807 if (!message.trusted() && !message.getConversation().canInferPresence()) continue;
808
809 try {
810 new BobTransfer(BobTransfer.uri(cid), message.getConversation().getAccount(), message.getCounterpart(), activity.xmppConnectionService).start();
811 } catch (final NoSuchAlgorithmException | URISyntaxException e) { }
812 continue;
813 }
814 } else {
815 continue;
816 }
817
818 int width = message.getFileParams().width;
819 if (width < 1 && thumb.getAttribute("width") != null) width = Integer.parseInt(thumb.getAttribute("width"));
820 if (width < 1) width = 1920;
821
822 int height = message.getFileParams().height;
823 if (height < 1 && thumb.getAttribute("height") != null) height = Integer.parseInt(thumb.getAttribute("height"));
824 if (height < 1) height = 1080;
825
826 viewHolder.image().setVisibility(View.VISIBLE);
827 imagePreviewLayout(width, height, viewHolder.image(), message.getInReplyTo() != null, true, viewHolder);
828 activity.loadBitmap(message, viewHolder.image());
829 viewHolder.image().setOnClickListener(v -> ConversationFragment.downloadFile(activity, message));
830
831 break;
832 }
833 }
834 viewHolder.audioPlayer().setVisibility(View.GONE);
835 viewHolder.downloadButton().setVisibility(View.VISIBLE);
836 viewHolder.downloadButton().setText(text);
837 final var attachment = Attachment.of(message);
838 final @DrawableRes int imageResource = MediaAdapter.getImageDrawable(attachment);
839 viewHolder.downloadButton().setIconResource(imageResource);
840 viewHolder
841 .downloadButton()
842 .setOnClickListener(v -> ConversationFragment.downloadFile(activity, message));
843 }
844
845 private void displayWebxdcMessage(BubbleMessageItemViewHolder viewHolder, final Message message, final BubbleColor bubbleColor) {
846 Cid webxdcCid = message.getFileParams().getCids().get(0);
847 WebxdcPage webxdc = new WebxdcPage(activity, webxdcCid, message);
848 displayTextMessage(viewHolder, message, bubbleColor);
849 viewHolder.image().setVisibility(View.GONE);
850 viewHolder.audioPlayer().setVisibility(View.GONE);
851 viewHolder.downloadButton().setVisibility(View.VISIBLE);
852 viewHolder.downloadButton().setIconResource(0);
853 viewHolder.downloadButton().setText("Open " + webxdc.getName());
854 viewHolder.downloadButton().setOnClickListener(v -> {
855 Conversation conversation = (Conversation) message.getConversation();
856 if (!conversation.switchToSession("webxdc\0" + message.getUuid())) {
857 conversation.startWebxdc(webxdc);
858 }
859 });
860 viewHolder.image().setOnClickListener(v -> {
861 Conversation conversation = (Conversation) message.getConversation();
862 if (!conversation.switchToSession("webxdc\0" + message.getUuid())) {
863 conversation.startWebxdc(webxdc);
864 }
865 });
866
867 final WebxdcUpdate lastUpdate;
868 synchronized(lastWebxdcUpdate) { lastUpdate = lastWebxdcUpdate.get(message.getUuid()); }
869 if (lastUpdate == null) {
870 new Thread(() -> {
871 final WebxdcUpdate update = activity.xmppConnectionService.findLastWebxdcUpdate(message);
872 if (update != null) {
873 synchronized(lastWebxdcUpdate) { lastWebxdcUpdate.put(message.getUuid(), update); }
874 activity.xmppConnectionService.updateConversationUi();
875 }
876 }).start();
877 } else {
878 if (lastUpdate != null && (lastUpdate.getSummary() != null || lastUpdate.getDocument() != null)) {
879 viewHolder.messageBody().setVisibility(View.VISIBLE);
880 viewHolder.messageBody().setText(
881 (lastUpdate.getDocument() == null ? "" : lastUpdate.getDocument() + "\n") +
882 (lastUpdate.getSummary() == null ? "" : lastUpdate.getSummary())
883 );
884 }
885 }
886
887 final LruCache<String, Drawable> cache = activity.xmppConnectionService.getDrawableCache();
888 final Drawable d = cache.get("webxdc:icon:" + webxdcCid);
889 if (d == null) {
890 new Thread(() -> {
891 Drawable icon = webxdc.getIcon();
892 if (icon != null) {
893 cache.put("webxdc:icon:" + webxdcCid, icon);
894 activity.xmppConnectionService.updateConversationUi();
895 }
896 }).start();
897 } else {
898 viewHolder.image().setVisibility(View.VISIBLE);
899 viewHolder.image().setImageDrawable(d);
900 imagePreviewLayout(d.getIntrinsicWidth(), d.getIntrinsicHeight(), viewHolder.image(), message.getInReplyTo() != null, true, viewHolder);
901 }
902 }
903
904 private void displayOpenableMessage(
905 final BubbleMessageItemViewHolder viewHolder,
906 final Message message,
907 final BubbleColor bubbleColor) {
908 displayTextMessage(viewHolder, message, bubbleColor);
909 viewHolder.image().setVisibility(View.GONE);
910 viewHolder.audioPlayer().setVisibility(View.GONE);
911 viewHolder.downloadButton().setVisibility(View.VISIBLE);
912 viewHolder
913 .downloadButton()
914 .setText(
915 activity.getString(
916 R.string.open_x_file,
917 UIHelper.getFileDescriptionString(activity, message)));
918 final var attachment = Attachment.of(message);
919 final @DrawableRes int imageResource = MediaAdapter.getImageDrawable(attachment);
920 viewHolder.downloadButton().setIconResource(imageResource);
921 viewHolder.downloadButton().setOnClickListener(v -> openDownloadable(message));
922 }
923
924 private void displayURIMessage(
925 BubbleMessageItemViewHolder viewHolder, final Message message, final BubbleColor bubbleColor) {
926 displayTextMessage(viewHolder, message, bubbleColor);
927 viewHolder.messageBody().setVisibility(View.GONE);
928 viewHolder.image().setVisibility(View.GONE);
929 viewHolder.audioPlayer().setVisibility(View.GONE);
930 viewHolder.downloadButton().setVisibility(View.VISIBLE);
931 final var uri = message.wholeIsKnownURI();
932 if ("bitcoin".equals(uri.getScheme())) {
933 final var amount = uri.getQueryParameter("amount");
934 final var formattedAmount = amount == null || amount.equals("") ? "" : amount + " ";
935 viewHolder.downloadButton().setIconResource(R.drawable.bitcoin_24dp);
936 viewHolder.downloadButton().setText("Send " + formattedAmount + "Bitcoin");
937 } else if ("bitcoincash".equals(uri.getScheme())) {
938 final var amount = uri.getQueryParameter("amount");
939 final var formattedAmount = amount == null || amount.equals("") ? "" : amount + " ";
940 viewHolder.downloadButton().setIconResource(R.drawable.bitcoin_cash_24dp);
941 viewHolder.downloadButton().setText("Send " + formattedAmount + "Bitcoin Cash");
942 } else if ("ethereum".equals(uri.getScheme())) {
943 final var amount = uri.getQueryParameter("value");
944 final var formattedAmount = amount == null || amount.equals("") ? "" : amount + " ";
945 viewHolder.downloadButton().setIconResource(R.drawable.eth_24dp);
946 viewHolder.downloadButton().setText("Send " + formattedAmount + "via Ethereum");
947 } else if ("monero".equals(uri.getScheme())) {
948 final var amount = uri.getQueryParameter("tx_amount");
949 final var formattedAmount = amount == null || amount.equals("") ? "" : amount + " ";
950 viewHolder.downloadButton().setIconResource(R.drawable.monero_24dp);
951 viewHolder.downloadButton().setText("Send " + formattedAmount + "Monero");
952 } else if ("wownero".equals(uri.getScheme())) {
953 final var amount = uri.getQueryParameter("tx_amount");
954 final var formattedAmount = amount == null || amount.equals("") ? "" : amount + " ";
955 viewHolder.downloadButton().setIconResource(R.drawable.wownero_24dp);
956 viewHolder.downloadButton().setText("Send " + formattedAmount + "Wownero");
957 }
958 viewHolder.downloadButton().setOnClickListener(v -> new FixedURLSpan(message.getRawBody()).onClick(v));
959 }
960
961 private void displayLocationMessage(
962 final BubbleMessageItemViewHolder viewHolder,
963 final Message message,
964 final BubbleColor bubbleColor) {
965 displayTextMessage(viewHolder, message, bubbleColor);
966 viewHolder.image().setVisibility(View.GONE);
967 viewHolder.audioPlayer().setVisibility(View.GONE);
968 viewHolder.downloadButton().setVisibility(View.VISIBLE);
969 viewHolder.downloadButton().setText(R.string.show_location);
970 final var attachment = Attachment.of(message);
971 final @DrawableRes int imageResource = MediaAdapter.getImageDrawable(attachment);
972 viewHolder.downloadButton().setIconResource(imageResource);
973 viewHolder.downloadButton().setOnClickListener(v -> showLocation(message));
974 }
975
976 private void displayAudioMessage(
977 final BubbleMessageItemViewHolder viewHolder,
978 Message message,
979 final BubbleColor bubbleColor) {
980 displayTextMessage(viewHolder, message, bubbleColor);
981 viewHolder.image().setVisibility(View.GONE);
982 viewHolder.downloadButton().setVisibility(View.GONE);
983 final RelativeLayout audioPlayer = viewHolder.audioPlayer();
984 audioPlayer.setVisibility(View.VISIBLE);
985 AudioPlayer.ViewHolder.get(audioPlayer).setBubbleColor(bubbleColor);
986 this.audioPlayer.init(audioPlayer, message);
987 }
988
989 private void displayMediaPreviewMessage(
990 final BubbleMessageItemViewHolder viewHolder,
991 final Message message,
992 final BubbleColor bubbleColor) {
993 displayTextMessage(viewHolder, message, bubbleColor);
994 viewHolder.downloadButton().setVisibility(View.GONE);
995 viewHolder.audioPlayer().setVisibility(View.GONE);
996 viewHolder.image().setVisibility(View.VISIBLE);
997 final FileParams params = message.getFileParams();
998 imagePreviewLayout(params.width, params.height, viewHolder.image(), message.getInReplyTo() != null, viewHolder.messageBody().getVisibility() != View.GONE, viewHolder);
999 activity.loadBitmap(message, viewHolder.image());
1000 viewHolder.image().setOnClickListener(v -> openDownloadable(message));
1001 }
1002
1003 private void imagePreviewLayout(int w, int h, ShapeableImageView image, boolean otherAbove, boolean otherBelow, BubbleMessageItemViewHolder viewHolder) {
1004 final float target = activity.getResources().getDimension(R.dimen.image_preview_width);
1005 final int scaledW;
1006 final int scaledH;
1007 if (Math.max(h, w) * metrics.density <= target) {
1008 scaledW = (int) (w * metrics.density);
1009 scaledH = (int) (h * metrics.density);
1010 } else if (Math.max(h, w) <= target) {
1011 scaledW = w;
1012 scaledH = h;
1013 } else if (w <= h) {
1014 scaledW = (int) (w / ((double) h / target));
1015 scaledH = (int) target;
1016 } else {
1017 scaledW = (int) target;
1018 scaledH = (int) (h / ((double) w / target));
1019 }
1020 final var bodyWidth = Math.max(viewHolder.messageBody().getWidth(), viewHolder.downloadButton().getWidth() + (20 * metrics.density));
1021 var targetImageWidth = 200 * metrics.density;
1022 if (!otherBelow) targetImageWidth = 110 * metrics.density;
1023 if (bodyWidth > 0 && bodyWidth < targetImageWidth) targetImageWidth = bodyWidth;
1024 final var small = scaledW < targetImageWidth;
1025 final LinearLayout.LayoutParams layoutParams =
1026 new LinearLayout.LayoutParams(scaledW, scaledH);
1027 image.setLayoutParams(layoutParams);
1028
1029 final var bubbleRadius = activity.getResources().getDimension(R.dimen.bubble_radius);
1030 var shape = new ShapeAppearanceModel.Builder();
1031 if (!otherAbove) {
1032 shape = shape.setTopRightCorner(CornerFamily.ROUNDED, bubbleRadius);
1033 if (viewHolder instanceof EndBubbleMessageItemViewHolder) {
1034 shape = shape.setTopLeftCorner(CornerFamily.ROUNDED, bubbleRadius);
1035 }
1036 }
1037 if (small) {
1038 final var imageRadius = activity.getResources().getDimension(R.dimen.image_radius);
1039 shape = shape.setAllCorners(CornerFamily.ROUNDED, imageRadius);
1040 image.setPadding(0, (int)(8 * metrics.density), 0, 0);
1041 } else {
1042 image.setPadding(0, 0, 0, 0);
1043 }
1044 image.setShapeAppearanceModel(shape.build());
1045
1046 if (!small) {
1047 final ViewGroup.LayoutParams blayoutParams = viewHolder.messageBody().getLayoutParams();
1048 blayoutParams.width = (int) (scaledW - (22 * metrics.density));
1049 viewHolder.messageBody().setLayoutParams(blayoutParams);
1050
1051 final ViewGroup.LayoutParams qlayoutParams = viewHolder.inReplyToQuote().getLayoutParams();
1052 qlayoutParams.width = (int) (scaledW - (22 * metrics.density));
1053 viewHolder.messageBody().setLayoutParams(qlayoutParams);
1054 }
1055 }
1056
1057 private void toggleWhisperInfo(
1058 final BubbleMessageItemViewHolder viewHolder,
1059 final Message message,
1060 final BubbleColor bubbleColor) {
1061 if (message.isPrivateMessage()) {
1062 final String privateMarker;
1063 if (message.getStatus() <= Message.STATUS_RECEIVED) {
1064 privateMarker = activity.getString(R.string.private_message);
1065 } else {
1066 Jid cp = message.getCounterpart();
1067 privateMarker =
1068 activity.getString(
1069 R.string.private_message_to,
1070 Strings.nullToEmpty(cp == null ? null : cp.getResource()));
1071 }
1072 final SpannableString body = new SpannableString(privateMarker);
1073 body.setSpan(
1074 new ForegroundColorSpan(
1075 bubbleToOnSurfaceVariant(viewHolder.messageBody(), bubbleColor)),
1076 0,
1077 privateMarker.length(),
1078 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
1079 body.setSpan(
1080 new StyleSpan(Typeface.BOLD),
1081 0,
1082 privateMarker.length(),
1083 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
1084 viewHolder.messageBody().setText(body);
1085 viewHolder.messageBody().setTypeface(null, Typeface.NORMAL);
1086 viewHolder.messageBody().setVisibility(View.VISIBLE);
1087 } else {
1088 viewHolder.messageBody().setVisibility(View.GONE);
1089 }
1090 }
1091
1092 private void loadMoreMessages(final Conversation conversation) {
1093 conversation.setLastClearHistory(0, null);
1094 activity.xmppConnectionService.updateConversation(conversation);
1095 conversation.setHasMessagesLeftOnServer(true);
1096 conversation.setFirstMamReference(null);
1097 long timestamp = conversation.getLastMessageTransmitted().getTimestamp();
1098 if (timestamp == 0) {
1099 timestamp = System.currentTimeMillis();
1100 }
1101 conversation.messagesLoaded.set(true);
1102 MessageArchiveService.Query query =
1103 activity.xmppConnectionService
1104 .getMessageArchiveService()
1105 .query(conversation, new MamReference(0), timestamp, false);
1106 if (query != null) {
1107 Toast.makeText(activity, R.string.fetching_history_from_server, Toast.LENGTH_LONG)
1108 .show();
1109 } else {
1110 Toast.makeText(
1111 activity,
1112 R.string.not_fetching_history_retention_period,
1113 Toast.LENGTH_SHORT)
1114 .show();
1115 }
1116 }
1117
1118 private MessageItemViewHolder getViewHolder(
1119 final View view, final @NonNull ViewGroup parent, final int type) {
1120 if (view != null && view.getTag() instanceof MessageItemViewHolder messageItemViewHolder) {
1121 return messageItemViewHolder;
1122 } else {
1123 final MessageItemViewHolder viewHolder =
1124 switch (type) {
1125 case RTP_SESSION ->
1126 new RtpSessionMessageItemViewHolder(
1127 DataBindingUtil.inflate(
1128 LayoutInflater.from(parent.getContext()),
1129 R.layout.item_message_rtp_session,
1130 parent,
1131 false));
1132 case DATE_SEPARATOR ->
1133 new DateSeperatorMessageItemViewHolder(
1134 DataBindingUtil.inflate(
1135 LayoutInflater.from(parent.getContext()),
1136 R.layout.item_message_date_bubble,
1137 parent,
1138 false));
1139 case STATUS ->
1140 new StatusMessageItemViewHolder(
1141 DataBindingUtil.inflate(
1142 LayoutInflater.from(parent.getContext()),
1143 R.layout.item_message_status,
1144 parent,
1145 false));
1146 case END ->
1147 new EndBubbleMessageItemViewHolder(
1148 DataBindingUtil.inflate(
1149 LayoutInflater.from(parent.getContext()),
1150 R.layout.item_message_end,
1151 parent,
1152 false));
1153 case START ->
1154 new StartBubbleMessageItemViewHolder(
1155 DataBindingUtil.inflate(
1156 LayoutInflater.from(parent.getContext()),
1157 R.layout.item_message_start,
1158 parent,
1159 false));
1160 default -> throw new AssertionError("Unable to create ViewHolder for type");
1161 };
1162 viewHolder.itemView.setTag(viewHolder);
1163 return viewHolder;
1164 }
1165 }
1166
1167 @NonNull
1168 @Override
1169 public View getView(final int position, final View view, final @NonNull ViewGroup parent) {
1170 final Message message = getItem(position);
1171 final int type = getItemViewType(message, bubbleDesign.alignStart);
1172 final MessageItemViewHolder viewHolder = getViewHolder(view, parent, type);
1173
1174 if (type == DATE_SEPARATOR
1175 && viewHolder instanceof DateSeperatorMessageItemViewHolder messageItemViewHolder) {
1176 return render(message, messageItemViewHolder);
1177 }
1178
1179 if (type == RTP_SESSION
1180 && viewHolder instanceof RtpSessionMessageItemViewHolder messageItemViewHolder) {
1181 return render(message, messageItemViewHolder);
1182 }
1183
1184 if (type == STATUS
1185 && viewHolder instanceof StatusMessageItemViewHolder messageItemViewHolder) {
1186 return render(message, messageItemViewHolder);
1187 }
1188
1189 if ((type == END || type == START)
1190 && viewHolder instanceof BubbleMessageItemViewHolder messageItemViewHolder) {
1191 return render(position, message, messageItemViewHolder);
1192 }
1193
1194 throw new AssertionError();
1195 }
1196
1197 private View render(
1198 final int position,
1199 final Message message,
1200 final BubbleMessageItemViewHolder viewHolder) {
1201 final boolean omemoEncryption = message.getEncryption() == Message.ENCRYPTION_AXOLOTL;
1202 final boolean isInValidSession =
1203 message.isValidInSession() && (!omemoEncryption || message.isTrusted());
1204 final Conversational conversation = message.getConversation();
1205 final Account account = conversation.getAccount();
1206 final List<Element> commands = message.getCommands();
1207
1208 viewHolder.linkDescriptions().setOnItemClickListener((adapter, v, pos, id) -> {
1209 final var desc = (Element) adapter.getItemAtPosition(pos);
1210 var url = desc.findChildContent("url", "https://ogp.me/ns#");
1211 // should we prefer about? Maybe, it's the real original link, but it's not what we show the user
1212 if (url == null || url.length() < 1) url = desc.getAttribute("{http://www.w3.org/1999/02/22-rdf-syntax-ns#}about");
1213 if (url == null || url.length() < 1) return;
1214 new FixedURLSpan(url).onClick(v);
1215 });
1216
1217 if (viewHolder.messageBody() != null) {
1218 viewHolder.messageBody().setCustomSelectionActionModeCallback(new MessageTextActionModeCallback(this, viewHolder.messageBody()));
1219 }
1220
1221 if (viewHolder.time() != null) {
1222 if (message.isAttention()) {
1223 viewHolder.time().setTypeface(null, Typeface.BOLD);
1224 } else {
1225 viewHolder.time().setTypeface(null, Typeface.NORMAL);
1226 }
1227 }
1228
1229 final var black = MaterialColors.getColor(viewHolder.root(), com.google.android.material.R.attr.colorSecondaryContainer) == viewHolder.root().getContext().getColor(android.R.color.black);
1230 final boolean colorfulBackground = this.bubbleDesign.colorfulChatBubbles;
1231 final boolean received = message.getStatus() == Message.STATUS_RECEIVED;
1232 final BubbleColor bubbleColor;
1233 if (received) {
1234 if (isInValidSession) {
1235 bubbleColor = colorfulBackground || black ? BubbleColor.SECONDARY : BubbleColor.SURFACE;
1236 } else {
1237 bubbleColor = BubbleColor.WARNING;
1238 }
1239 } else {
1240 if (!colorfulBackground && black) {
1241 bubbleColor = BubbleColor.SECONDARY;
1242 } else {
1243 bubbleColor = colorfulBackground ? BubbleColor.TERTIARY : BubbleColor.SURFACE_HIGH;
1244 }
1245 }
1246
1247 if (viewHolder.threadIdenticon() != null) {
1248 viewHolder.threadIdenticon().setVisibility(View.GONE);
1249 final Element thread = message.getThread();
1250 if (thread != null) {
1251 final String threadId = thread.getContent();
1252 if (threadId != null) {
1253 final var roles = MaterialColors.getColorRoles(activity, UIHelper.getColorForName(threadId));
1254 viewHolder.threadIdenticon().setVisibility(View.VISIBLE);
1255 viewHolder.threadIdenticon().setColor(roles.getAccent());
1256 viewHolder.threadIdenticon().setHash(UIHelper.identiconHash(threadId));
1257 }
1258 }
1259 }
1260
1261 final var mergeIntoTop = mergeIntoTop(position, message);
1262 final var mergeIntoBottom = mergeIntoBottom(position, message);
1263 final var showAvatar =
1264 bubbleDesign.showAvatars
1265 || (viewHolder instanceof StartBubbleMessageItemViewHolder
1266 && message.getConversation().getMode() == Conversation.MODE_MULTI);
1267 setBubblePadding(viewHolder.root(), mergeIntoTop, mergeIntoBottom);
1268 if (showAvatar) {
1269 final var requiresAvatar =
1270 viewHolder instanceof StartBubbleMessageItemViewHolder
1271 ? !mergeIntoTop
1272 : !mergeIntoBottom;
1273 setRequiresAvatar(viewHolder, requiresAvatar);
1274 AvatarWorkerTask.loadAvatar(message, viewHolder.contactPicture(), R.dimen.avatar);
1275 } else {
1276 viewHolder.contactPicture().setVisibility(View.GONE);
1277 }
1278 setAvatarDistance(viewHolder.messageBox(), viewHolder.getClass(), showAvatar);
1279 //viewHolder.messageBox().setClipToOutline(true); remove to show tails
1280
1281 resetClickListener(viewHolder.messageBox(), viewHolder.messageBody());
1282
1283 viewHolder.messageBox().setOnClickListener(v -> {
1284 if (MessageAdapter.this.mOnMessageBoxClickedListener != null) {
1285 MessageAdapter.this.mOnMessageBoxClickedListener
1286 .onContactPictureClicked(message);
1287 }
1288 });
1289 SwipeDetector swipeDetector = new SwipeDetector((action) -> {
1290 if (action == SwipeDetector.Action.LR && MessageAdapter.this.mOnMessageBoxSwipedListener != null) {
1291 MessageAdapter.this.mOnMessageBoxSwipedListener.onContactPictureClicked(message);
1292 }
1293 });
1294 viewHolder.messageBox().setOnTouchListener(swipeDetector);
1295 viewHolder.image().setOnTouchListener(swipeDetector);
1296 viewHolder.time().setOnTouchListener(swipeDetector);
1297
1298 // Treat touch-up as click so we don't have to touch twice
1299 // (touch twice is because it's waiting to see if you double-touch for text selection)
1300 viewHolder.messageBody().setOnTouchListener((v, event) -> {
1301 if (event.getAction() == MotionEvent.ACTION_UP) {
1302 if (MessageAdapter.this.mOnMessageBoxClickedListener != null) {
1303 MessageAdapter.this.mOnMessageBoxClickedListener
1304 .onContactPictureClicked(message);
1305 }
1306 }
1307
1308 swipeDetector.onTouch(v, event);
1309
1310 return false;
1311 });
1312 viewHolder.messageBody().setOnClickListener(v -> {
1313 if (MessageAdapter.this.mOnMessageBoxClickedListener != null) {
1314 MessageAdapter.this.mOnMessageBoxClickedListener
1315 .onContactPictureClicked(message);
1316 }
1317 });
1318 viewHolder.messageBody().setAccessibilityDelegate(null);
1319
1320 viewHolder
1321 .contactPicture()
1322 .setOnClickListener(
1323 v -> {
1324 if (MessageAdapter.this.mOnContactPictureClickedListener != null) {
1325 MessageAdapter.this.mOnContactPictureClickedListener
1326 .onContactPictureClicked(message);
1327 }
1328 });
1329 viewHolder
1330 .contactPicture()
1331 .setOnLongClickListener(
1332 v -> {
1333 if (MessageAdapter.this.mOnContactPictureLongClickedListener != null) {
1334 MessageAdapter.this.mOnContactPictureLongClickedListener
1335 .onContactPictureLongClicked(v, message);
1336 return true;
1337 } else {
1338 return false;
1339 }
1340 });
1341
1342 boolean footerWrap = false;
1343 final Transferable transferable = message.getTransferable();
1344 final boolean unInitiatedButKnownSize = MessageUtils.unInitiatedButKnownSize(message);
1345
1346 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));
1347 if (muted) {
1348 // Muted MUC participant
1349 displayInfoMessage(viewHolder, "Muted", bubbleColor);
1350 } else if (unInitiatedButKnownSize || message.isDeleted() || (transferable != null && transferable.getStatus() != Transferable.STATUS_UPLOADING)) {
1351 if (unInitiatedButKnownSize || (message.isDeleted() && message.getModerated() == null) || transferable != null && transferable.getStatus() == Transferable.STATUS_OFFER) {
1352 displayDownloadableMessage(viewHolder, message, activity.getString(R.string.download_x_file, UIHelper.getFileDescriptionString(activity, message)), bubbleColor);
1353 } else if (transferable != null && transferable.getStatus() == Transferable.STATUS_OFFER_CHECK_FILESIZE) {
1354 displayDownloadableMessage(viewHolder, message, activity.getString(R.string.check_x_filesize, UIHelper.getFileDescriptionString(activity, message)), bubbleColor);
1355 } else {
1356 displayInfoMessage(viewHolder, UIHelper.getMessagePreview(activity.xmppConnectionService, message).first, bubbleColor);
1357 }
1358 } else if (message.isFileOrImage()
1359 && message.getEncryption() != Message.ENCRYPTION_PGP
1360 && message.getEncryption() != Message.ENCRYPTION_DECRYPTION_FAILED) {
1361 if (message.getFileParams().width > 0 && message.getFileParams().height > 0) {
1362 displayMediaPreviewMessage(viewHolder, message, bubbleColor);
1363 } else if (message.getFileParams().runtime > 0) {
1364 displayAudioMessage(viewHolder, message, bubbleColor);
1365 } else if ("application/webxdc+zip".equals(message.getFileParams().getMediaType()) && message.getConversation() instanceof Conversation && message.getThread() != null && !message.getFileParams().getCids().isEmpty()) {
1366 displayWebxdcMessage(viewHolder, message, bubbleColor);
1367 } else {
1368 displayOpenableMessage(viewHolder, message, bubbleColor);
1369 }
1370 } else if (message.getEncryption() == Message.ENCRYPTION_PGP) {
1371 if (account.isPgpDecryptionServiceConnected()) {
1372 if (conversation instanceof Conversation
1373 && !account.hasPendingPgpIntent((Conversation) conversation)) {
1374 displayInfoMessage(
1375 viewHolder,
1376 activity.getString(R.string.message_decrypting),
1377 bubbleColor);
1378 } else {
1379 displayInfoMessage(
1380 viewHolder, activity.getString(R.string.pgp_message), bubbleColor);
1381 }
1382 } else {
1383 displayInfoMessage(
1384 viewHolder, activity.getString(R.string.install_openkeychain), bubbleColor);
1385 viewHolder.messageBox().setOnClickListener(this::promptOpenKeychainInstall);
1386 viewHolder.messageBody().setOnClickListener(this::promptOpenKeychainInstall);
1387 }
1388 } else if (message.getEncryption() == Message.ENCRYPTION_DECRYPTION_FAILED) {
1389 displayInfoMessage(
1390 viewHolder, activity.getString(R.string.decryption_failed), bubbleColor);
1391 } else if (message.getEncryption() == Message.ENCRYPTION_AXOLOTL_NOT_FOR_THIS_DEVICE) {
1392 displayInfoMessage(
1393 viewHolder,
1394 activity.getString(R.string.not_encrypted_for_this_device),
1395 bubbleColor);
1396 } else if (message.getEncryption() == Message.ENCRYPTION_AXOLOTL_FAILED) {
1397 displayInfoMessage(
1398 viewHolder, activity.getString(R.string.omemo_decryption_failed), bubbleColor);
1399 } else {
1400 if (message.wholeIsKnownURI() != null) {
1401 displayURIMessage(viewHolder, message, bubbleColor);
1402 } else if (message.isGeoUri()) {
1403 displayLocationMessage(viewHolder, message, bubbleColor);
1404 } else if (message.treatAsDownloadable()) {
1405 try {
1406 final URI uri = message.getOob();
1407 displayDownloadableMessage(viewHolder,
1408 message,
1409 activity.getString(
1410 R.string.check_x_filesize_on_host,
1411 UIHelper.getFileDescriptionString(activity, message),
1412 uri.getHost()),
1413 bubbleColor);
1414 } catch (Exception e) {
1415 displayDownloadableMessage(
1416 viewHolder,
1417 message,
1418 activity.getString(
1419 R.string.check_x_filesize,
1420 UIHelper.getFileDescriptionString(activity, message)),
1421 bubbleColor);
1422 }
1423 } else if (message.bodyIsOnlyEmojis() && message.getType() != Message.TYPE_PRIVATE) {
1424 displayEmojiMessage(viewHolder, message, bubbleColor);
1425 } else {
1426 displayTextMessage(viewHolder, message, bubbleColor);
1427 }
1428 }
1429
1430 if (!black && viewHolder.image().getLayoutParams().width > metrics.density * 110) {
1431 footerWrap = true;
1432 }
1433
1434 viewHolder.messageBoxInner().setMinimumWidth(footerWrap ? (int) (110 * metrics.density) : 0);
1435 LinearLayout.LayoutParams statusParams = (LinearLayout.LayoutParams) viewHolder.statusLine().getLayoutParams();
1436 statusParams.width = footerWrap ? ViewGroup.LayoutParams.MATCH_PARENT : ViewGroup.LayoutParams.WRAP_CONTENT;
1437 viewHolder.statusLine().setLayoutParams(statusParams);
1438
1439 final Function<Reaction, GetThumbnailForCid> reactionThumbnailer = (r) -> new Thumbnailer(conversation.getAccount(), r, conversation.canInferPresence());
1440 if (received) {
1441 if (!muted && commands != null && conversation instanceof Conversation) {
1442 CommandButtonAdapter adapter = new CommandButtonAdapter(activity);
1443 adapter.addAll(commands);
1444 viewHolder.commandsList().setAdapter(adapter);
1445 viewHolder.commandsList().setVisibility(View.VISIBLE);
1446 viewHolder.commandsList().setOnItemClickListener((p, v, pos, id) -> {
1447 final Element command = adapter.getItem(pos);
1448 activity.startCommand(conversation.getAccount(), command.getAttributeAsJid("jid"), command.getAttribute("node"));
1449 });
1450 } else {
1451 // It's unclear if we can set this to null...
1452 ListAdapter adapter = viewHolder.commandsList().getAdapter();
1453 if (adapter instanceof ArrayAdapter) {
1454 ((ArrayAdapter<?>) adapter).clear();
1455 }
1456 viewHolder.commandsList().setVisibility(View.GONE);
1457 viewHolder.commandsList().setOnItemClickListener(null);
1458 }
1459 }
1460
1461 setBackgroundTint(viewHolder.messageBox(), bubbleColor);
1462 setTextColor(viewHolder.messageBody(), bubbleColor);
1463 viewHolder.messageBody().setLinkTextColor(bubbleToOnSurfaceColor(viewHolder.messageBody(), bubbleColor));
1464
1465 if (received && viewHolder instanceof StartBubbleMessageItemViewHolder startViewHolder) {
1466 setTextColor(startViewHolder.encryption(), bubbleColor);
1467 if (isInValidSession) {
1468 startViewHolder.encryption().setVisibility(View.GONE);
1469 } else {
1470 startViewHolder.encryption().setVisibility(View.VISIBLE);
1471 if (omemoEncryption && !message.isTrusted()) {
1472 startViewHolder.encryption().setText(R.string.not_trusted);
1473 } else {
1474 startViewHolder
1475 .encryption()
1476 .setText(CryptoHelper.encryptionTypeToText(message.getEncryption()));
1477 }
1478 }
1479 final var aggregatedReactions = conversation instanceof Conversation ? ((Conversation) conversation).aggregatedReactionsFor(message, reactionThumbnailer) : message.getAggregatedReactions();
1480 BindingAdapters.setReactionsOnReceived(
1481 viewHolder.reactions(),
1482 aggregatedReactions,
1483 reactions -> sendReactions(message, reactions),
1484 emoji -> showDetailedReaction(message, emoji),
1485 emoji -> sendCustomReaction(message, emoji),
1486 reaction -> removeCustomReaction(conversation, reaction),
1487 () -> addReaction(message));
1488 } else {
1489 if (viewHolder instanceof StartBubbleMessageItemViewHolder startViewHolder) {
1490 startViewHolder.encryption().setVisibility(View.GONE);
1491 }
1492 BindingAdapters.setReactionsOnSent(
1493 viewHolder.reactions(),
1494 message.getAggregatedReactions(),
1495 reactions -> sendReactions(message, reactions),
1496 emoji -> showDetailedReaction(message, emoji));
1497 }
1498
1499 var subject = message.getSubject();
1500 if (subject == null && message.getThread() != null) {
1501 final var thread = ((Conversation) message.getConversation()).getThread(message.getThread().getContent());
1502 if (thread != null) subject = thread.getSubject();
1503 }
1504 if (muted || subject == null) {
1505 viewHolder.subject().setVisibility(View.GONE);
1506 } else {
1507 viewHolder.subject().setVisibility(View.VISIBLE);
1508 viewHolder.subject().setText(subject);
1509 }
1510
1511 if (message.getInReplyTo() == null) {
1512 viewHolder.inReplyToBox().setVisibility(View.GONE);
1513 } else {
1514 viewHolder.inReplyToBox().setVisibility(View.VISIBLE);
1515 viewHolder.inReplyTo().setText(UIHelper.getMessageDisplayName(message.getInReplyTo()));
1516 viewHolder.inReplyTo().setOnClickListener((v) -> mConversationFragment.jumpTo(message.getInReplyTo()));
1517 viewHolder.inReplyToQuote().setOnClickListener((v) -> mConversationFragment.jumpTo(message.getInReplyTo()));
1518 setTextColor(viewHolder.inReplyTo(), bubbleColor);
1519 }
1520
1521 if (appSettings.showLinkPreviews()) {
1522 final var descriptions = message.getLinkDescriptions();
1523 viewHolder.linkDescriptions().setAdapter(new ArrayAdapter<>(activity, 0, descriptions) {
1524 @Override
1525 public View getView(int position, View view, @NonNull ViewGroup parent) {
1526 final LinkDescriptionBinding binding = DataBindingUtil.inflate(LayoutInflater.from(parent.getContext()), R.layout.link_description, parent, false);
1527 binding.title.setText(getItem(position).findChildContent("title", "https://ogp.me/ns#"));
1528 binding.description.setText(getItem(position).findChildContent("description", "https://ogp.me/ns#"));
1529 binding.url.setText(getItem(position).findChildContent("url", "https://ogp.me/ns#"));
1530 final var video = getItem(position).findChildContent("video", "https://ogp.me/ns#");
1531 if (video != null && video.length() > 0) {
1532 binding.playButton.setVisibility(View.VISIBLE);
1533 binding.playButton.setOnClickListener((v) -> {
1534 new FixedURLSpan(video).onClick(v);
1535 });
1536 }
1537 return binding.getRoot();
1538 }
1539 });
1540 Util.justifyListViewHeightBasedOnChildren(viewHolder.linkDescriptions(), (int)(metrics.density * 100), true);
1541 }
1542
1543 displayStatus(viewHolder, message, bubbleColor);
1544
1545 viewHolder.messageBody().setAccessibilityDelegate(new View.AccessibilityDelegate() {
1546 @Override
1547 public void sendAccessibilityEvent(View host, int eventType) {
1548 super.sendAccessibilityEvent(host, eventType);
1549 if (eventType == AccessibilityEvent.TYPE_VIEW_TEXT_SELECTION_CHANGED) {
1550 if (viewHolder.messageBody().hasSelection()) {
1551 selectionUuid = message.getUuid();
1552 } else if (message.getUuid() != null && message.getUuid().equals(selectionUuid)) {
1553 selectionUuid = null;
1554 }
1555 }
1556 }
1557 });
1558
1559 return viewHolder.root();
1560 }
1561
1562 private View render(
1563 final Message message, final DateSeperatorMessageItemViewHolder viewHolder) {
1564 final boolean colorfulBackground = this.bubbleDesign.colorfulChatBubbles;
1565 if (UIHelper.today(message.getTimeSent())) {
1566 viewHolder.binding.messageBody.setText(R.string.today);
1567 } else if (UIHelper.yesterday(message.getTimeSent())) {
1568 viewHolder.binding.messageBody.setText(R.string.yesterday);
1569 } else {
1570 viewHolder.binding.messageBody.setText(
1571 DateUtils.formatDateTime(
1572 activity,
1573 message.getTimeSent(),
1574 DateUtils.FORMAT_SHOW_DATE | DateUtils.FORMAT_SHOW_YEAR));
1575 }
1576 if (colorfulBackground) {
1577 setBackgroundTint(viewHolder.binding.messageBox, BubbleColor.PRIMARY);
1578 setTextColor(viewHolder.binding.messageBody, BubbleColor.PRIMARY);
1579 } else {
1580 setBackgroundTint(viewHolder.binding.messageBox, BubbleColor.SURFACE_HIGH);
1581 setTextColor(viewHolder.binding.messageBody, BubbleColor.SURFACE_HIGH);
1582 }
1583 return viewHolder.binding.getRoot();
1584 }
1585
1586 private View render(final Message message, final RtpSessionMessageItemViewHolder viewHolder) {
1587 final boolean colorfulBackground = this.bubbleDesign.colorfulChatBubbles;
1588 final boolean received = message.getStatus() <= Message.STATUS_RECEIVED;
1589 final RtpSessionStatus rtpSessionStatus = RtpSessionStatus.of(message.getBody());
1590 final long duration = rtpSessionStatus.duration;
1591 if (received) {
1592 if (duration > 0) {
1593 viewHolder.binding.messageBody.setText(
1594 activity.getString(
1595 R.string.incoming_call_duration_timestamp,
1596 TimeFrameUtils.resolve(activity, duration),
1597 UIHelper.readableTimeDifferenceFull(
1598 activity, message.getTimeSent())));
1599 } else if (rtpSessionStatus.successful) {
1600 viewHolder.binding.messageBody.setText(R.string.incoming_call);
1601 } else {
1602 viewHolder.binding.messageBody.setText(
1603 activity.getString(
1604 R.string.missed_call_timestamp,
1605 UIHelper.readableTimeDifferenceFull(
1606 activity, message.getTimeSent())));
1607 }
1608 } else {
1609 if (duration > 0) {
1610 viewHolder.binding.messageBody.setText(
1611 activity.getString(
1612 R.string.outgoing_call_duration_timestamp,
1613 TimeFrameUtils.resolve(activity, duration),
1614 UIHelper.readableTimeDifferenceFull(
1615 activity, message.getTimeSent())));
1616 } else {
1617 viewHolder.binding.messageBody.setText(
1618 activity.getString(
1619 R.string.outgoing_call_timestamp,
1620 UIHelper.readableTimeDifferenceFull(
1621 activity, message.getTimeSent())));
1622 }
1623 }
1624 if (colorfulBackground) {
1625 setBackgroundTint(viewHolder.binding.messageBox, BubbleColor.SECONDARY);
1626 setTextColor(viewHolder.binding.messageBody, BubbleColor.SECONDARY);
1627 setImageTint(viewHolder.binding.indicatorReceived, BubbleColor.SECONDARY);
1628 } else {
1629 setBackgroundTint(viewHolder.binding.messageBox, BubbleColor.SURFACE_HIGH);
1630 setTextColor(viewHolder.binding.messageBody, BubbleColor.SURFACE_HIGH);
1631 setImageTint(viewHolder.binding.indicatorReceived, BubbleColor.SURFACE_HIGH);
1632 }
1633 viewHolder.binding.indicatorReceived.setImageResource(
1634 RtpSessionStatus.getDrawable(received, rtpSessionStatus.successful));
1635 return viewHolder.binding.getRoot();
1636 }
1637
1638 private View render(final Message message, final StatusMessageItemViewHolder viewHolder) {
1639 final var conversation = message.getConversation();
1640 if ("LOAD_MORE".equals(message.getBody())) {
1641 viewHolder.binding.statusMessage.setVisibility(View.GONE);
1642 viewHolder.binding.messagePhoto.setVisibility(View.GONE);
1643 viewHolder.binding.loadMoreMessages.setVisibility(View.VISIBLE);
1644 viewHolder.binding.loadMoreMessages.setOnClickListener(
1645 v -> loadMoreMessages((Conversation) message.getConversation()));
1646 } else {
1647 viewHolder.binding.statusMessage.setVisibility(View.VISIBLE);
1648 viewHolder.binding.loadMoreMessages.setVisibility(View.GONE);
1649 viewHolder.binding.statusMessage.setText(message.getBody());
1650 boolean showAvatar;
1651 if (conversation.getMode() == Conversation.MODE_SINGLE) {
1652 showAvatar = true;
1653 AvatarWorkerTask.loadAvatar(
1654 message, viewHolder.binding.messagePhoto, R.dimen.avatar_on_status_message);
1655 } else if (message.getCounterpart() != null
1656 || message.getTrueCounterpart() != null
1657 || (message.getCounterparts() != null
1658 && !message.getCounterparts().isEmpty())) {
1659 showAvatar = true;
1660 AvatarWorkerTask.loadAvatar(
1661 message, viewHolder.binding.messagePhoto, R.dimen.avatar_on_status_message);
1662 } else {
1663 showAvatar = false;
1664 }
1665 if (showAvatar) {
1666 viewHolder.binding.messagePhoto.setAlpha(0.5f);
1667 viewHolder.binding.messagePhoto.setVisibility(View.VISIBLE);
1668 } else {
1669 viewHolder.binding.messagePhoto.setVisibility(View.GONE);
1670 }
1671 }
1672 return viewHolder.binding.getRoot();
1673 }
1674
1675 private void setAvatarDistance(
1676 final LinearLayout messageBox,
1677 final Class<? extends BubbleMessageItemViewHolder> clazz,
1678 final boolean showAvatar) {
1679 final ViewGroup.MarginLayoutParams layoutParams =
1680 (ViewGroup.MarginLayoutParams) messageBox.getLayoutParams();
1681 if (false) { // no need for space since the shape has space inside it for tails
1682 final var resources = messageBox.getResources();
1683 if (clazz == StartBubbleMessageItemViewHolder.class) {
1684 layoutParams.setMarginStart(
1685 resources.getDimensionPixelSize(R.dimen.bubble_avatar_distance));
1686 layoutParams.setMarginEnd(0);
1687 } else if (clazz == EndBubbleMessageItemViewHolder.class) {
1688 layoutParams.setMarginStart(0);
1689 layoutParams.setMarginEnd(
1690 resources.getDimensionPixelSize(R.dimen.bubble_avatar_distance));
1691 } else {
1692 throw new AssertionError("Avatar distances are not available on this view type");
1693 }
1694 } else {
1695 layoutParams.setMarginStart(0);
1696 layoutParams.setMarginEnd(0);
1697 }
1698 messageBox.setLayoutParams(layoutParams);
1699 }
1700
1701 private void setBubblePadding(
1702 final ConstraintLayout root,
1703 final boolean mergeIntoTop,
1704 final boolean mergeIntoBottom) {
1705 final var resources = root.getResources();
1706 final var horizontal = resources.getDimensionPixelSize(R.dimen.bubble_horizontal_padding);
1707 final int top =
1708 resources.getDimensionPixelSize(
1709 mergeIntoTop
1710 ? R.dimen.bubble_vertical_padding_minimum
1711 : R.dimen.bubble_vertical_padding);
1712 final int bottom =
1713 resources.getDimensionPixelSize(
1714 mergeIntoBottom
1715 ? R.dimen.bubble_vertical_padding_minimum
1716 : R.dimen.bubble_vertical_padding);
1717 root.setPadding(horizontal, top, horizontal, bottom);
1718 }
1719
1720 private void setRequiresAvatar(
1721 final BubbleMessageItemViewHolder viewHolder, final boolean requiresAvatar) {
1722 final var layoutParams = viewHolder.contactPicture().getLayoutParams();
1723 if (requiresAvatar) {
1724 final var resources = viewHolder.contactPicture().getResources();
1725 final var avatarSize = resources.getDimensionPixelSize(R.dimen.bubble_avatar_size);
1726 layoutParams.height = avatarSize;
1727 viewHolder.contactPicture().setVisibility(View.VISIBLE);
1728 viewHolder.messageBox().setMinimumHeight(avatarSize);
1729 } else {
1730 layoutParams.height = 0;
1731 viewHolder.contactPicture().setVisibility(View.INVISIBLE);
1732 viewHolder.messageBox().setMinimumHeight(0);
1733 }
1734 viewHolder.contactPicture().setLayoutParams(layoutParams);
1735 }
1736
1737 private boolean mergeIntoTop(final int position, final Message message) {
1738 if (position < 0) {
1739 return false;
1740 }
1741 final var top = getItem(position - 1);
1742 return merge(top, message);
1743 }
1744
1745 private boolean mergeIntoBottom(final int position, final Message message) {
1746 final Message bottom;
1747 try {
1748 bottom = getItem(position + 1);
1749 } catch (final IndexOutOfBoundsException e) {
1750 return false;
1751 }
1752 return merge(message, bottom);
1753 }
1754
1755 private static boolean merge(final Message a, final Message b) {
1756 if (getItemViewType(a, false) != getItemViewType(b, false)) {
1757 return false;
1758 }
1759 final var receivedA = a.getStatus() == Message.STATUS_RECEIVED;
1760 final var receivedB = b.getStatus() == Message.STATUS_RECEIVED;
1761 if (receivedA != receivedB) {
1762 return false;
1763 }
1764 if (a.getConversation().getMode() == Conversation.MODE_MULTI
1765 && a.getStatus() == Message.STATUS_RECEIVED) {
1766 final var occupantIdA = a.getOccupantId();
1767 final var occupantIdB = b.getOccupantId();
1768 if (occupantIdA != null && occupantIdB != null) {
1769 if (!occupantIdA.equals(occupantIdB)) {
1770 return false;
1771 }
1772 }
1773 final var counterPartA = a.getCounterpart();
1774 final var counterPartB = b.getCounterpart();
1775 if (counterPartA == null || !counterPartA.equals(counterPartB)) {
1776 return false;
1777 }
1778 }
1779 return b.getTimeSent() - a.getTimeSent() <= Config.MESSAGE_MERGE_WINDOW;
1780 }
1781
1782 private boolean showDetailedReaction(final Message message, Map.Entry<EmojiSearch.Emoji, Collection<Reaction>> reaction) {
1783 final var c = message.getConversation();
1784 if (c instanceof Conversation conversation && c.getMode() == Conversational.MODE_MULTI) {
1785 final var reactions = reaction.getValue();
1786 final var mucOptions = conversation.getMucOptions();
1787 final var users = mucOptions.findUsers(reactions);
1788 if (users.isEmpty()) {
1789 return true;
1790 }
1791 final MaterialAlertDialogBuilder dialogBuilder =
1792 new MaterialAlertDialogBuilder(activity);
1793 dialogBuilder.setTitle(reaction.getKey().toString());
1794 dialogBuilder.setMessage(UIHelper.concatNames(users));
1795 dialogBuilder.create().show();
1796 return true;
1797 } else {
1798 return false;
1799 }
1800 }
1801
1802 private void sendReactions(final Message message, final Collection<String> reactions) {
1803 if (!message.isPrivateMessage() && activity.xmppConnectionService.sendReactions(message, reactions)) {
1804 return;
1805 }
1806 Toast.makeText(activity, R.string.could_not_add_reaction, Toast.LENGTH_LONG).show();
1807 }
1808
1809 private void sendCustomReaction(final Message inReplyTo, final EmojiSearch.CustomEmoji emoji) {
1810 final var message = inReplyTo.reply();
1811 message.appendBody(emoji.toInsert());
1812 Message.configurePrivateMessage(message);
1813 new Thread(() -> activity.xmppConnectionService.sendMessage(message)).start();
1814 }
1815
1816 private void removeCustomReaction(final Conversational conversation, final Reaction reaction) {
1817 if (!(conversation instanceof Conversation)) {
1818 Toast.makeText(activity, R.string.could_not_add_reaction, Toast.LENGTH_LONG).show();
1819 return;
1820 }
1821
1822 final var message = new Message(conversation, " ", ((Conversation) conversation).getNextEncryption());
1823 final var envelope = ((Conversation) conversation).findMessageWithUuidOrRemoteId(reaction.envelopeId);
1824 if (envelope != null) {
1825 ((Conversation) conversation).remove(envelope);
1826 message.addPayload(envelope.getReply());
1827 message.getOrMakeHtml();
1828 message.putEdited(reaction.envelopeId, envelope.getServerMsgId());
1829 } else {
1830 message.putEdited(reaction.envelopeId, null);
1831 }
1832
1833 new Thread(() -> activity.xmppConnectionService.sendMessage(message)).start();
1834 }
1835
1836 private void addReaction(final Message message) {
1837 if (mConversationFragment == null) return;
1838 mConversationFragment.addReaction(message);
1839 }
1840
1841 private void promptOpenKeychainInstall(View view) {
1842 activity.showInstallPgpDialog();
1843 }
1844
1845 public FileBackend getFileBackend() {
1846 return activity.xmppConnectionService.getFileBackend();
1847 }
1848
1849 public void stopAudioPlayer() {
1850 audioPlayer.stop();
1851 }
1852
1853 public void unregisterListenerInAudioPlayer() {
1854 audioPlayer.unregisterListener();
1855 }
1856
1857 public void startStopPending() {
1858 audioPlayer.startStopPending();
1859 }
1860
1861 public void openDownloadable(Message message) {
1862 if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU
1863 && ContextCompat.checkSelfPermission(
1864 activity, Manifest.permission.WRITE_EXTERNAL_STORAGE)
1865 != PackageManager.PERMISSION_GRANTED) {
1866 ConversationFragment.registerPendingMessage(activity, message);
1867 ActivityCompat.requestPermissions(
1868 activity,
1869 new String[] {Manifest.permission.WRITE_EXTERNAL_STORAGE},
1870 ConversationsActivity.REQUEST_OPEN_MESSAGE);
1871 return;
1872 }
1873 final DownloadableFile file =
1874 activity.xmppConnectionService.getFileBackend().getFile(message);
1875 final var fp = message.getFileParams();
1876 final var name = fp == null ? null : fp.getName();
1877 final var displayName = name == null ? file.getName() : name;
1878 ViewUtil.view(activity, file, displayName);
1879 }
1880
1881 private void showLocation(Message message) {
1882 for (Intent intent : GeoHelper.createGeoIntentsFromMessage(activity, message)) {
1883 if (intent.resolveActivity(getContext().getPackageManager()) != null) {
1884 getContext().startActivity(intent);
1885 return;
1886 }
1887 }
1888 Toast.makeText(
1889 activity,
1890 R.string.no_application_found_to_display_location,
1891 Toast.LENGTH_SHORT)
1892 .show();
1893 }
1894
1895 public void updatePreferences() {
1896 this.bubbleDesign =
1897 new BubbleDesign(
1898 appSettings.isColorfulChatBubbles(),
1899 appSettings.isAlignStart(),
1900 appSettings.isLargeFont(),
1901 appSettings.isShowAvatars());
1902 }
1903
1904 public void setHighlightedTerm(List<String> terms) {
1905 this.highlightedTerm = terms == null ? null : StylingHelper.filterHighlightedWords(terms);
1906 }
1907
1908 public interface OnContactPictureClicked {
1909 void onContactPictureClicked(Message message);
1910 }
1911
1912 public interface OnContactPictureLongClicked {
1913 void onContactPictureLongClicked(View v, Message message);
1914 }
1915
1916 public interface OnInlineImageLongClicked {
1917 boolean onInlineImageLongClicked(Cid cid);
1918 }
1919
1920 private static void setBackgroundTint(final LinearLayout view, final BubbleColor bubbleColor) {
1921 view.setBackgroundTintList(bubbleToColorStateList(view, bubbleColor));
1922 }
1923
1924 private static ColorStateList bubbleToColorStateList(
1925 final View view, final BubbleColor bubbleColor) {
1926 final @AttrRes int colorAttributeResId =
1927 switch (bubbleColor) {
1928 case SURFACE ->
1929 Activities.isNightMode(view.getContext())
1930 ? com.google.android.material.R.attr.colorSurfaceContainerHigh
1931 : com.google.android.material.R.attr.colorSurfaceContainerLow;
1932 case SURFACE_HIGH ->
1933 Activities.isNightMode(view.getContext())
1934 ? com.google.android.material.R.attr
1935 .colorSurfaceContainerHighest
1936 : com.google.android.material.R.attr.colorSurfaceContainerHigh;
1937 case PRIMARY -> com.google.android.material.R.attr.colorPrimaryContainer;
1938 case SECONDARY -> com.google.android.material.R.attr.colorSecondaryContainer;
1939 case TERTIARY -> com.google.android.material.R.attr.colorTertiaryContainer;
1940 case WARNING -> com.google.android.material.R.attr.colorErrorContainer;
1941 };
1942 return ColorStateList.valueOf(MaterialColors.getColor(view, colorAttributeResId));
1943 }
1944
1945 public static void setImageTint(final ImageView imageView, final BubbleColor bubbleColor) {
1946 ImageViewCompat.setImageTintList(
1947 imageView, bubbleToOnSurfaceColorStateList(imageView, bubbleColor));
1948 }
1949
1950 public static void setImageTintError(final ImageView imageView) {
1951 ImageViewCompat.setImageTintList(
1952 imageView,
1953 ColorStateList.valueOf(
1954 MaterialColors.getColor(imageView, androidx.appcompat.R.attr.colorError)));
1955 }
1956
1957 public static void setTextColor(final TextView textView, final BubbleColor bubbleColor) {
1958 final var color = bubbleToOnSurfaceColor(textView, bubbleColor);
1959 textView.setTextColor(color);
1960 if (BubbleColor.SURFACES.contains(bubbleColor)) {
1961 textView.setLinkTextColor(
1962 MaterialColors.getColor(textView, androidx.appcompat.R.attr.colorPrimary));
1963 } else {
1964 textView.setLinkTextColor(color);
1965 }
1966 }
1967
1968 private static void setTextSize(final TextView textView, final boolean largeFont) {
1969 if (largeFont) {
1970 textView.setTextAppearance(
1971 com.google.android.material.R.style.TextAppearance_Material3_BodyLarge);
1972 textView.setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, 18);
1973 } else {
1974 textView.setTextAppearance(
1975 com.google.android.material.R.style.TextAppearance_Material3_BodyMedium);
1976 }
1977 }
1978
1979 private static @ColorInt int bubbleToOnSurfaceVariant(
1980 final View view, final BubbleColor bubbleColor) {
1981 final @AttrRes int colorAttributeResId;
1982 if (BubbleColor.SURFACES.contains(bubbleColor)) {
1983 colorAttributeResId = com.google.android.material.R.attr.colorOnSurfaceVariant;
1984 } else {
1985 colorAttributeResId = bubbleToOnSurface(bubbleColor);
1986 }
1987 return MaterialColors.getColor(view, colorAttributeResId);
1988 }
1989
1990 private static @ColorInt int bubbleToOnSurfaceColor(
1991 final View view, final BubbleColor bubbleColor) {
1992 return MaterialColors.getColor(view, bubbleToOnSurface(bubbleColor));
1993 }
1994
1995 public static ColorStateList bubbleToOnSurfaceColorStateList(
1996 final View view, final BubbleColor bubbleColor) {
1997 return ColorStateList.valueOf(bubbleToOnSurfaceColor(view, bubbleColor));
1998 }
1999
2000 private static @AttrRes int bubbleToOnSurface(final BubbleColor bubbleColor) {
2001 return switch (bubbleColor) {
2002 case SURFACE, SURFACE_HIGH -> com.google.android.material.R.attr.colorOnSurface;
2003 case PRIMARY -> com.google.android.material.R.attr.colorOnPrimaryContainer;
2004 case SECONDARY -> com.google.android.material.R.attr.colorOnSecondaryContainer;
2005 case TERTIARY -> com.google.android.material.R.attr.colorOnTertiaryContainer;
2006 case WARNING -> com.google.android.material.R.attr.colorOnErrorContainer;
2007 };
2008 }
2009
2010 public enum BubbleColor {
2011 SURFACE,
2012 SURFACE_HIGH,
2013 PRIMARY,
2014 SECONDARY,
2015 TERTIARY,
2016 WARNING;
2017
2018 private static final Collection<BubbleColor> SURFACES =
2019 Arrays.asList(BubbleColor.SURFACE, BubbleColor.SURFACE_HIGH);
2020 }
2021
2022 private static class BubbleDesign {
2023 public final boolean colorfulChatBubbles;
2024 public final boolean alignStart;
2025 public final boolean largeFont;
2026 public final boolean showAvatars;
2027
2028 private BubbleDesign(
2029 final boolean colorfulChatBubbles,
2030 final boolean alignStart,
2031 final boolean largeFont,
2032 final boolean showAvatars) {
2033 this.colorfulChatBubbles = colorfulChatBubbles;
2034 this.alignStart = alignStart;
2035 this.largeFont = largeFont;
2036 this.showAvatars = showAvatars;
2037 }
2038 }
2039
2040 private abstract static class MessageItemViewHolder /*extends RecyclerView.ViewHolder*/ {
2041
2042 private View itemView;
2043
2044 private MessageItemViewHolder(@NonNull View itemView) {
2045 this.itemView = itemView;
2046 }
2047 }
2048
2049 private abstract static class BubbleMessageItemViewHolder extends MessageItemViewHolder {
2050
2051 private BubbleMessageItemViewHolder(@NonNull View itemView) {
2052 super(itemView);
2053 }
2054
2055 public abstract ConstraintLayout root();
2056
2057 protected abstract ImageView indicatorEdit();
2058
2059 protected abstract RelativeLayout audioPlayer();
2060
2061 protected abstract LinearLayout messageBox();
2062
2063 protected abstract MaterialButton downloadButton();
2064
2065 protected abstract ShapeableImageView image();
2066
2067 protected abstract ImageView indicatorSecurity();
2068
2069 protected abstract ImageView indicatorReceived();
2070
2071 protected abstract TextView time();
2072
2073 protected abstract TextView messageBody();
2074
2075 protected abstract ImageView contactPicture();
2076
2077 protected abstract ChipGroup reactions();
2078
2079 protected abstract ListView commandsList();
2080
2081 protected abstract View messageBoxInner();
2082
2083 protected abstract View statusLine();
2084
2085 protected abstract GithubIdenticonView threadIdenticon();
2086
2087 protected abstract ListView linkDescriptions();
2088
2089 protected abstract LinearLayout inReplyToBox();
2090
2091 protected abstract TextView inReplyTo();
2092
2093 protected abstract TextView inReplyToQuote();
2094
2095 protected abstract TextView subject();
2096 }
2097
2098 private static class StartBubbleMessageItemViewHolder extends BubbleMessageItemViewHolder {
2099
2100 private final ItemMessageStartBinding binding;
2101
2102 public StartBubbleMessageItemViewHolder(@NonNull ItemMessageStartBinding binding) {
2103 super(binding.getRoot());
2104 this.binding = binding;
2105 }
2106
2107 @Override
2108 public ConstraintLayout root() {
2109 return (ConstraintLayout) this.binding.getRoot();
2110 }
2111
2112 @Override
2113 protected ImageView indicatorEdit() {
2114 return this.binding.editIndicator;
2115 }
2116
2117 @Override
2118 protected RelativeLayout audioPlayer() {
2119 return this.binding.messageContent.audioPlayer;
2120 }
2121
2122 @Override
2123 protected LinearLayout messageBox() {
2124 return this.binding.messageBox;
2125 }
2126
2127 @Override
2128 protected MaterialButton downloadButton() {
2129 return this.binding.messageContent.downloadButton;
2130 }
2131
2132 @Override
2133 protected ShapeableImageView image() {
2134 return this.binding.messageContent.messageImage;
2135 }
2136
2137 protected ImageView indicatorSecurity() {
2138 return this.binding.securityIndicator;
2139 }
2140
2141 @Override
2142 protected ImageView indicatorReceived() {
2143 return this.binding.indicatorReceived;
2144 }
2145
2146 @Override
2147 protected TextView time() {
2148 return this.binding.messageTime;
2149 }
2150
2151 @Override
2152 protected TextView messageBody() {
2153 return this.binding.messageContent.messageBody;
2154 }
2155
2156 protected TextView encryption() {
2157 return this.binding.messageEncryption;
2158 }
2159
2160 @Override
2161 protected ImageView contactPicture() {
2162 return this.binding.messagePhoto;
2163 }
2164
2165 @Override
2166 protected ChipGroup reactions() {
2167 return this.binding.reactions;
2168 }
2169
2170 @Override
2171 protected ListView commandsList() {
2172 return this.binding.messageContent.commandsList;
2173 }
2174
2175 @Override
2176 protected View messageBoxInner() {
2177 return this.binding.messageBoxInner;
2178 }
2179
2180 @Override
2181 protected View statusLine() {
2182 return this.binding.statusLine;
2183 }
2184
2185 @Override
2186 protected GithubIdenticonView threadIdenticon() {
2187 return this.binding.threadIdenticon;
2188 }
2189
2190 @Override
2191 protected ListView linkDescriptions() {
2192 return this.binding.messageContent.linkDescriptions;
2193 }
2194
2195 @Override
2196 protected LinearLayout inReplyToBox() {
2197 return this.binding.messageContent.inReplyToBox;
2198 }
2199
2200 @Override
2201 protected TextView inReplyTo() {
2202 return this.binding.messageContent.inReplyTo;
2203 }
2204
2205 @Override
2206 protected TextView inReplyToQuote() {
2207 return this.binding.messageContent.inReplyToQuote;
2208 }
2209
2210 @Override
2211 protected TextView subject() {
2212 return this.binding.messageSubject;
2213 }
2214 }
2215
2216 private static class EndBubbleMessageItemViewHolder extends BubbleMessageItemViewHolder {
2217
2218 private final ItemMessageEndBinding binding;
2219
2220 private EndBubbleMessageItemViewHolder(@NonNull ItemMessageEndBinding binding) {
2221 super(binding.getRoot());
2222 this.binding = binding;
2223 }
2224
2225 @Override
2226 public ConstraintLayout root() {
2227 return (ConstraintLayout) this.binding.getRoot();
2228 }
2229
2230 @Override
2231 protected ImageView indicatorEdit() {
2232 return this.binding.editIndicator;
2233 }
2234
2235 @Override
2236 protected RelativeLayout audioPlayer() {
2237 return this.binding.messageContent.audioPlayer;
2238 }
2239
2240 @Override
2241 protected LinearLayout messageBox() {
2242 return this.binding.messageBox;
2243 }
2244
2245 @Override
2246 protected MaterialButton downloadButton() {
2247 return this.binding.messageContent.downloadButton;
2248 }
2249
2250 @Override
2251 protected ShapeableImageView image() {
2252 return this.binding.messageContent.messageImage;
2253 }
2254
2255 @Override
2256 protected ImageView indicatorSecurity() {
2257 return this.binding.securityIndicator;
2258 }
2259
2260 @Override
2261 protected ImageView indicatorReceived() {
2262 return this.binding.indicatorReceived;
2263 }
2264
2265 @Override
2266 protected TextView time() {
2267 return this.binding.messageTime;
2268 }
2269
2270 @Override
2271 protected TextView messageBody() {
2272 return this.binding.messageContent.messageBody;
2273 }
2274
2275 @Override
2276 protected ImageView contactPicture() {
2277 return this.binding.messagePhoto;
2278 }
2279
2280 @Override
2281 protected ChipGroup reactions() {
2282 return this.binding.reactions;
2283 }
2284
2285 @Override
2286 protected ListView commandsList() {
2287 return this.binding.messageContent.commandsList;
2288 }
2289
2290 @Override
2291 protected View messageBoxInner() {
2292 return this.binding.messageBoxInner;
2293 }
2294
2295 @Override
2296 protected View statusLine() {
2297 return this.binding.statusLine;
2298 }
2299
2300 @Override
2301 protected GithubIdenticonView threadIdenticon() {
2302 return this.binding.threadIdenticon;
2303 }
2304
2305 @Override
2306 protected ListView linkDescriptions() {
2307 return this.binding.messageContent.linkDescriptions;
2308 }
2309
2310 @Override
2311 protected LinearLayout inReplyToBox() {
2312 return this.binding.messageContent.inReplyToBox;
2313 }
2314
2315 @Override
2316 protected TextView inReplyTo() {
2317 return this.binding.messageContent.inReplyTo;
2318 }
2319
2320 @Override
2321 protected TextView inReplyToQuote() {
2322 return this.binding.messageContent.inReplyToQuote;
2323 }
2324
2325 @Override
2326 protected TextView subject() {
2327 return this.binding.messageSubject;
2328 }
2329 }
2330
2331 private static class DateSeperatorMessageItemViewHolder extends MessageItemViewHolder {
2332
2333 private final ItemMessageDateBubbleBinding binding;
2334
2335 private DateSeperatorMessageItemViewHolder(@NonNull ItemMessageDateBubbleBinding binding) {
2336 super(binding.getRoot());
2337 this.binding = binding;
2338 }
2339 }
2340
2341 private static class RtpSessionMessageItemViewHolder extends MessageItemViewHolder {
2342
2343 private final ItemMessageRtpSessionBinding binding;
2344
2345 private RtpSessionMessageItemViewHolder(@NonNull ItemMessageRtpSessionBinding binding) {
2346 super(binding.getRoot());
2347 this.binding = binding;
2348 }
2349 }
2350
2351 private static class StatusMessageItemViewHolder extends MessageItemViewHolder {
2352
2353 private final ItemMessageStatusBinding binding;
2354
2355 private StatusMessageItemViewHolder(@NonNull ItemMessageStatusBinding binding) {
2356 super(binding.getRoot());
2357 this.binding = binding;
2358 }
2359 }
2360
2361 class Thumbnailer implements GetThumbnailForCid {
2362 final Account account;
2363 final boolean canFetch;
2364 final Jid counterpart;
2365
2366 public Thumbnailer(final Message message) {
2367 account = message.getConversation().getAccount();
2368 canFetch = message.trusted() || message.getConversation().canInferPresence();
2369 counterpart = message.getCounterpart();
2370 }
2371
2372 public Thumbnailer(final Account account, final Reaction reaction, final boolean allowFetch) {
2373 canFetch = allowFetch;
2374 counterpart = reaction.from;
2375 this.account = account;
2376 }
2377
2378 @Override
2379 public Drawable getThumbnail(Cid cid) {
2380 try {
2381 DownloadableFile f = activity.xmppConnectionService.getFileForCid(cid);
2382 if (f == null || !f.canRead()) {
2383 if (!canFetch) return null;
2384
2385 try {
2386 new BobTransfer(BobTransfer.uri(cid), account, counterpart, activity.xmppConnectionService).start();
2387 } catch (final NoSuchAlgorithmException | URISyntaxException e) { }
2388 return null;
2389 }
2390
2391 Drawable d = activity.xmppConnectionService.getFileBackend().getThumbnail(f, activity.getResources(), (int) (metrics.density * 288), true);
2392 if (d == null) {
2393 new ThumbnailTask().execute(f);
2394 }
2395 return d;
2396 } catch (final IOException e) {
2397 return null;
2398 }
2399 }
2400 }
2401
2402 class ThumbnailTask extends AsyncTask<DownloadableFile, Void, Drawable[]> {
2403 @Override
2404 protected Drawable[] doInBackground(DownloadableFile... params) {
2405 if (isCancelled()) return null;
2406
2407 Drawable[] d = new Drawable[params.length];
2408 for (int i = 0; i < params.length; i++) {
2409 try {
2410 d[i] = activity.xmppConnectionService.getFileBackend().getThumbnail(params[i], activity.getResources(), (int) (metrics.density * 288), false);
2411 } catch (final IOException e) {
2412 d[i] = null;
2413 }
2414 }
2415
2416 return d;
2417 }
2418
2419 @Override
2420 protected void onPostExecute(final Drawable[] d) {
2421 if (isCancelled()) return;
2422 activity.xmppConnectionService.updateConversationUi();
2423 }
2424 }
2425}