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