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