ConversationFragment.java

   1package eu.siacs.conversations.ui;
   2
   3import static eu.siacs.conversations.ui.XmppActivity.EXTRA_ACCOUNT;
   4import static eu.siacs.conversations.ui.XmppActivity.REQUEST_INVITE_TO_CONVERSATION;
   5import static eu.siacs.conversations.ui.util.SoftKeyboardUtils.hideSoftKeyboard;
   6import static eu.siacs.conversations.utils.PermissionUtils.allGranted;
   7import static eu.siacs.conversations.utils.PermissionUtils.audioGranted;
   8import static eu.siacs.conversations.utils.PermissionUtils.cameraGranted;
   9import static eu.siacs.conversations.utils.PermissionUtils.getFirstDenied;
  10import static eu.siacs.conversations.utils.PermissionUtils.writeGranted;
  11
  12import android.Manifest;
  13import android.annotation.SuppressLint;
  14import android.app.Activity;
  15import android.app.DatePickerDialog;
  16import android.app.Fragment;
  17import android.app.FragmentManager;
  18import android.app.PendingIntent;
  19import android.app.ProgressDialog;
  20import android.app.TimePickerDialog;
  21import android.content.ActivityNotFoundException;
  22import android.content.Context;
  23import android.content.DialogInterface;
  24import android.content.Intent;
  25import android.content.IntentSender.SendIntentException;
  26import android.content.SharedPreferences;
  27import android.content.pm.PackageManager;
  28import android.content.res.ColorStateList;
  29import android.graphics.Color;
  30import android.icu.util.Calendar;
  31import android.icu.util.TimeZone;
  32import android.net.Uri;
  33import android.os.Build;
  34import android.os.Bundle;
  35import android.os.Environment;
  36import android.os.Handler;
  37import android.os.Looper;
  38import android.os.storage.StorageManager;
  39import android.os.SystemClock;
  40import android.preference.PreferenceManager;
  41import android.provider.MediaStore;
  42import android.text.Editable;
  43import android.text.TextUtils;
  44import android.text.TextWatcher;
  45import android.text.SpannableStringBuilder;
  46import android.text.style.ImageSpan;
  47import android.util.DisplayMetrics;
  48import android.util.Log;
  49import android.view.ContextMenu;
  50import android.view.ContextMenu.ContextMenuInfo;
  51import android.view.Gravity;
  52import android.view.LayoutInflater;
  53import android.view.Menu;
  54import android.view.MenuInflater;
  55import android.view.MenuItem;
  56import android.view.MotionEvent;
  57import android.view.View;
  58import android.view.View.OnClickListener;
  59import android.view.ViewGroup;
  60import android.view.animation.AlphaAnimation;
  61import android.view.animation.Animation;
  62import android.view.animation.CycleInterpolator;
  63import android.view.inputmethod.EditorInfo;
  64import android.view.inputmethod.InputMethodManager;
  65import android.view.WindowManager;
  66import android.widget.AbsListView;
  67import android.widget.AbsListView.OnScrollListener;
  68import android.widget.AdapterView;
  69import android.widget.AdapterView.AdapterContextMenuInfo;
  70import android.widget.CheckBox;
  71import android.widget.ListView;
  72import android.widget.PopupMenu;
  73import android.widget.PopupWindow;
  74import android.widget.TextView.OnEditorActionListener;
  75import android.widget.Toast;
  76
  77import androidx.activity.OnBackPressedCallback;
  78import androidx.annotation.IdRes;
  79import androidx.annotation.NonNull;
  80import androidx.annotation.Nullable;
  81import androidx.annotation.StringRes;
  82import androidx.appcompat.app.AlertDialog;
  83import androidx.core.content.pm.ShortcutInfoCompat;
  84import androidx.core.content.pm.ShortcutManagerCompat;
  85import androidx.core.graphics.ColorUtils;
  86import androidx.core.view.inputmethod.InputConnectionCompat;
  87import androidx.core.view.inputmethod.InputContentInfoCompat;
  88import androidx.databinding.DataBindingUtil;
  89import androidx.documentfile.provider.DocumentFile;
  90import androidx.recyclerview.widget.RecyclerView.Adapter;
  91import androidx.viewpager.widget.PagerAdapter;
  92import androidx.viewpager.widget.ViewPager;
  93
  94import com.cheogram.android.BobTransfer;
  95import com.cheogram.android.EmojiSearch;
  96import com.cheogram.android.EditMessageSelectionActionModeCallback;
  97import com.cheogram.android.WebxdcPage;
  98import com.cheogram.android.WebxdcStore;
  99
 100import com.google.android.material.color.MaterialColors;
 101import com.google.android.material.dialog.MaterialAlertDialogBuilder;
 102import com.google.common.base.Optional;
 103import com.google.common.collect.Collections2;
 104import com.google.common.collect.ImmutableList;
 105import com.google.common.collect.ImmutableSet;
 106import com.google.common.collect.Lists;
 107import com.google.common.collect.Ordering;
 108import com.google.common.io.Files;
 109import com.google.common.util.concurrent.Futures;
 110import com.google.common.util.concurrent.FutureCallback;
 111import com.google.common.util.concurrent.MoreExecutors;
 112
 113import com.otaliastudios.autocomplete.Autocomplete;
 114import com.otaliastudios.autocomplete.AutocompleteCallback;
 115import com.otaliastudios.autocomplete.AutocompletePresenter;
 116import com.otaliastudios.autocomplete.CharPolicy;
 117import com.otaliastudios.autocomplete.RecyclerViewPresenter;
 118
 119import org.jetbrains.annotations.NotNull;
 120
 121import io.ipfs.cid.Cid;
 122
 123import java.io.File;
 124import java.net.URISyntaxException;
 125import java.util.AbstractMap;
 126import java.util.ArrayList;
 127import java.util.Arrays;
 128import java.util.Collection;
 129import java.util.Collections;
 130import java.util.HashSet;
 131import java.util.Iterator;
 132import java.util.LinkedList;
 133import java.util.List;
 134import java.util.Locale;
 135import java.util.Map;
 136import java.util.Set;
 137import java.util.UUID;
 138import java.util.concurrent.atomic.AtomicBoolean;
 139import java.util.regex.Matcher;
 140import java.util.regex.Pattern;
 141import java.util.stream.Collectors;
 142
 143import com.google.common.collect.Iterables;
 144import de.gultsch.common.Linkify;
 145import de.gultsch.common.Patterns;
 146import eu.siacs.conversations.Config;
 147import eu.siacs.conversations.R;
 148import eu.siacs.conversations.crypto.axolotl.AxolotlService;
 149import eu.siacs.conversations.crypto.axolotl.FingerprintStatus;
 150import eu.siacs.conversations.databinding.FragmentConversationBinding;
 151import eu.siacs.conversations.entities.Account;
 152import eu.siacs.conversations.entities.Blockable;
 153import eu.siacs.conversations.entities.Contact;
 154import eu.siacs.conversations.entities.Conversation;
 155import eu.siacs.conversations.entities.Conversational;
 156import eu.siacs.conversations.entities.DownloadableFile;
 157import eu.siacs.conversations.entities.Message;
 158import eu.siacs.conversations.entities.MucOptions;
 159import eu.siacs.conversations.entities.MucOptions.User;
 160import eu.siacs.conversations.entities.Presences;
 161import eu.siacs.conversations.entities.ReadByMarker;
 162import eu.siacs.conversations.entities.Transferable;
 163import eu.siacs.conversations.entities.TransferablePlaceholder;
 164import eu.siacs.conversations.http.HttpDownloadConnection;
 165import eu.siacs.conversations.persistance.FileBackend;
 166import eu.siacs.conversations.services.CallIntegrationConnectionService;
 167import eu.siacs.conversations.services.MessageArchiveService;
 168import eu.siacs.conversations.services.QuickConversationsService;
 169import eu.siacs.conversations.services.XmppConnectionService;
 170import eu.siacs.conversations.ui.adapter.CommandAdapter;
 171import eu.siacs.conversations.ui.adapter.MediaPreviewAdapter;
 172import eu.siacs.conversations.ui.adapter.MessageAdapter;
 173import eu.siacs.conversations.ui.adapter.UserAdapter;
 174import eu.siacs.conversations.ui.util.ActivityResult;
 175import eu.siacs.conversations.ui.util.Attachment;
 176import eu.siacs.conversations.ui.util.ConversationMenuConfigurator;
 177import eu.siacs.conversations.ui.util.DateSeparator;
 178import eu.siacs.conversations.ui.util.EditMessageActionModeCallback;
 179import eu.siacs.conversations.ui.util.ListViewUtils;
 180import eu.siacs.conversations.ui.util.MenuDoubleTabUtil;
 181import eu.siacs.conversations.ui.util.MucDetailsContextMenuHelper;
 182import eu.siacs.conversations.ui.util.PendingItem;
 183import eu.siacs.conversations.ui.util.PresenceSelector;
 184import eu.siacs.conversations.ui.util.ScrollState;
 185import eu.siacs.conversations.ui.util.SendButtonAction;
 186import eu.siacs.conversations.ui.util.SendButtonTool;
 187import eu.siacs.conversations.ui.util.ShareUtil;
 188import eu.siacs.conversations.ui.util.ViewUtil;
 189import eu.siacs.conversations.ui.widget.EditMessage;
 190import eu.siacs.conversations.utils.AccountUtils;
 191import eu.siacs.conversations.utils.Compatibility;
 192import eu.siacs.conversations.utils.Emoticons;
 193import eu.siacs.conversations.utils.GeoHelper;
 194import eu.siacs.conversations.utils.MessageUtils;
 195import eu.siacs.conversations.utils.MimeUtils;
 196import eu.siacs.conversations.utils.NickValidityChecker;
 197import eu.siacs.conversations.utils.PermissionUtils;
 198import eu.siacs.conversations.utils.QuickLoader;
 199import eu.siacs.conversations.utils.StylingHelper;
 200import eu.siacs.conversations.utils.TimeFrameUtils;
 201import eu.siacs.conversations.utils.UIHelper;
 202import eu.siacs.conversations.xml.Element;
 203import eu.siacs.conversations.xml.Namespace;
 204import eu.siacs.conversations.xmpp.Jid;
 205import eu.siacs.conversations.xmpp.XmppConnection;
 206import eu.siacs.conversations.xmpp.chatstate.ChatState;
 207import eu.siacs.conversations.xmpp.jingle.AbstractJingleConnection;
 208import eu.siacs.conversations.xmpp.jingle.JingleConnectionManager;
 209import eu.siacs.conversations.xmpp.jingle.JingleFileTransferConnection;
 210import eu.siacs.conversations.xmpp.jingle.Media;
 211import eu.siacs.conversations.xmpp.jingle.OngoingRtpSession;
 212import eu.siacs.conversations.xmpp.jingle.RtpCapability;
 213import eu.siacs.conversations.xmpp.jingle.RtpEndUserState;
 214import eu.siacs.conversations.xmpp.manager.BookmarkManager;
 215import eu.siacs.conversations.xmpp.manager.DiscoManager;
 216
 217import im.conversations.android.xmpp.Entity;
 218import im.conversations.android.xmpp.model.disco.info.InfoQuery;
 219import im.conversations.android.xmpp.model.disco.items.Item;
 220import im.conversations.android.xmpp.model.muc.Affiliation;
 221import im.conversations.android.xmpp.model.muc.Role;
 222import im.conversations.android.xmpp.model.stanza.Iq;
 223
 224import org.jetbrains.annotations.NotNull;
 225import eu.siacs.conversations.xmpp.manager.HttpUploadManager;
 226import eu.siacs.conversations.xmpp.manager.MultiUserChatManager;
 227import eu.siacs.conversations.xmpp.manager.PresenceManager;
 228import im.conversations.android.xmpp.model.stanza.Presence;
 229import java.util.ArrayList;
 230import java.util.Arrays;
 231import java.util.Collection;
 232import java.util.Collections;
 233import java.util.HashSet;
 234import java.util.Iterator;
 235import java.util.List;
 236import java.util.Objects;
 237import java.util.Set;
 238import java.util.UUID;
 239import java.util.concurrent.atomic.AtomicBoolean;
 240
 241public class ConversationFragment extends XmppFragment
 242        implements EditMessage.KeyboardListener,
 243                MessageAdapter.OnContactPictureLongClicked,
 244                MessageAdapter.OnContactPictureClicked,
 245                MessageAdapter.OnInlineImageLongClicked {
 246
 247    public static final int REQUEST_SEND_MESSAGE = 0x0201;
 248    public static final int REQUEST_DECRYPT_PGP = 0x0202;
 249    public static final int REQUEST_ENCRYPT_MESSAGE = 0x0207;
 250    public static final int REQUEST_TRUST_KEYS_TEXT = 0x0208;
 251    public static final int REQUEST_TRUST_KEYS_ATTACHMENTS = 0x0209;
 252    public static final int REQUEST_START_DOWNLOAD = 0x0210;
 253    public static final int REQUEST_ADD_EDITOR_CONTENT = 0x0211;
 254    public static final int REQUEST_COMMIT_ATTACHMENTS = 0x0212;
 255    public static final int REQUEST_START_AUDIO_CALL = 0x213;
 256    public static final int REQUEST_START_VIDEO_CALL = 0x214;
 257    public static final int REQUEST_SAVE_STICKER = 0x215;
 258    public static final int REQUEST_WEBXDC_STORE = 0x216;
 259    public static final int ATTACHMENT_CHOICE_CHOOSE_IMAGE = 0x0301;
 260    public static final int ATTACHMENT_CHOICE_TAKE_PHOTO = 0x0302;
 261    public static final int ATTACHMENT_CHOICE_CHOOSE_FILE = 0x0303;
 262    public static final int ATTACHMENT_CHOICE_RECORD_VOICE = 0x0304;
 263    public static final int ATTACHMENT_CHOICE_LOCATION = 0x0305;
 264    public static final int ATTACHMENT_CHOICE_INVALID = 0x0306;
 265    public static final int ATTACHMENT_CHOICE_RECORD_VIDEO = 0x0307;
 266
 267    public static final String RECENTLY_USED_QUICK_ACTION = "recently_used_quick_action";
 268    public static final String STATE_CONVERSATION_UUID =
 269            ConversationFragment.class.getName() + ".uuid";
 270    public static final String STATE_SCROLL_POSITION =
 271            ConversationFragment.class.getName() + ".scroll_position";
 272    public static final String STATE_PHOTO_URI =
 273            ConversationFragment.class.getName() + ".media_previews";
 274    public static final String STATE_MEDIA_PREVIEWS =
 275            ConversationFragment.class.getName() + ".take_photo_uri";
 276    private static final String STATE_LAST_MESSAGE_UUID = "state_last_message_uuid";
 277
 278    private final List<Message> messageList = new ArrayList<>();
 279    private final PendingItem<ActivityResult> postponedActivityResult = new PendingItem<>();
 280    private final PendingItem<String> pendingConversationsUuid = new PendingItem<>();
 281    private final PendingItem<ArrayList<Attachment>> pendingMediaPreviews = new PendingItem<>();
 282    private final PendingItem<Bundle> pendingExtras = new PendingItem<>();
 283    private final PendingItem<Uri> pendingTakePhotoUri = new PendingItem<>();
 284    private final PendingItem<ScrollState> pendingScrollState = new PendingItem<>();
 285    private final PendingItem<String> pendingLastMessageUuid = new PendingItem<>();
 286    private final PendingItem<Message> pendingMessage = new PendingItem<>();
 287    public Uri mPendingEditorContent = null;
 288    protected ArrayList<WebxdcPage> extensions = new ArrayList<>();
 289    protected MessageAdapter messageListAdapter;
 290    protected CommandAdapter commandAdapter;
 291    private MediaPreviewAdapter mediaPreviewAdapter;
 292    private String lastMessageUuid = null;
 293    private Conversation conversation;
 294    private FragmentConversationBinding binding;
 295    private Toast messageLoaderToast;
 296    private ConversationsActivity activity;
 297    private boolean reInitRequiredOnStart = true;
 298    private int identiconWidth = -1;
 299    private File savingAsSticker = null;
 300    private EmojiSearch emojiSearch = null;
 301    File dirStickers = new File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS) + "/monocles chat" + File.separator + "Stickers");
 302    private String[] StickerfilesPaths;
 303    private String[] StickerfilesNames;
 304    private String[] GifsfilesPaths;
 305    private String[] GifsfilesNames;
 306
 307    private LinkedList<Message> replyJumps = new LinkedList<>();
 308
 309    private PinnedMessageRepository pinnedMessageRepository;
 310    private String currentDisplayedPinnedMessageUuid = null;
 311
 312    private final ExecutorService backgroundExecutor = Executors.newSingleThreadExecutor();
 313
 314    private KeyboardHeightProvider.KeyboardHeightListener keyboardHeightListener = null;
 315    private KeyboardHeightProvider keyboardHeightProvider = null;
 316    private static final String PINNED_MESSAGE_KEY_PREFIX = "pinned_message_";
 317
 318    protected OnClickListener clickToVerify = new OnClickListener() {
 319        @Override
 320        public void onClick(View v) {
 321            activity.verifyOtrSessionDialog(conversation, v);
 322        }
 323    };
 324
 325    private final OnClickListener clickToMuc =
 326            new OnClickListener() {
 327
 328                @Override
 329                public void onClick(View v) {
 330                    ConferenceDetailsActivity.open(getActivity(), conversation);
 331                }
 332            };
 333    private final OnClickListener leaveMuc =
 334            new OnClickListener() {
 335
 336                @Override
 337                public void onClick(View v) {
 338                    activity.xmppConnectionService.archiveConversation(conversation);
 339                }
 340            };
 341    private final OnClickListener joinMuc =
 342            new OnClickListener() {
 343
 344                @Override
 345                public void onClick(View v) {
 346                    activity.xmppConnectionService.joinMuc(conversation);
 347                }
 348            };
 349
 350    private final OnClickListener acceptJoin =
 351            new OnClickListener() {
 352                @Override
 353                public void onClick(View v) {
 354                    conversation.setAttribute("accept_non_anonymous", true);
 355                    activity.xmppConnectionService.updateConversation(conversation);
 356                    activity.xmppConnectionService.joinMuc(conversation);
 357                }
 358            };
 359
 360    private final OnClickListener enterPassword =
 361            new OnClickListener() {
 362
 363                @Override
 364                public void onClick(View v) {
 365                    MucOptions muc = conversation.getMucOptions();
 366                    String password = muc.getPassword();
 367                    if (password == null) {
 368                        password = "";
 369                    }
 370                    activity.quickPasswordEdit(
 371                            password,
 372                            value -> {
 373                                activity.xmppConnectionService.providePasswordForMuc(
 374                                        conversation, value);
 375                                return null;
 376                            });
 377                }
 378            };
 379    private final OnScrollListener mOnScrollListener =
 380            new OnScrollListener() {
 381
 382                @Override
 383                public void onScrollStateChanged(AbsListView view, int scrollState) {
 384                    if (AbsListView.OnScrollListener.SCROLL_STATE_IDLE == scrollState) {
 385                        fireReadEvent();
 386                    }
 387                }
 388
 389                @Override
 390                public void onScroll(
 391                        final AbsListView view,
 392                        int firstVisibleItem,
 393                        int visibleItemCount,
 394                        int totalItemCount) {
 395                    toggleScrollDownButton(view);
 396                    synchronized (ConversationFragment.this.messageList) {
 397                        boolean paginateBackward = firstVisibleItem < 5;
 398                        boolean paginationForward = conversation.isInHistoryPart() && firstVisibleItem + visibleItemCount + 5 > totalItemCount;
 399                        loadMoreMessages(paginateBackward, paginationForward, view);
 400                    }
 401                }
 402            };
 403
 404    private void loadMoreMessages(boolean paginateBackward, boolean paginationForward, AbsListView view) {
 405        if (paginateBackward && !conversation.messagesLoaded.get()) {
 406            paginateBackward = false;
 407        }
 408
 409        if (
 410                conversation != null &&
 411                        messageList.size() > 0 &&
 412                        ((paginateBackward && conversation.messagesLoaded.compareAndSet(true, false)) ||
 413                                (paginationForward && conversation.historyPartLoadedForward.compareAndSet(true, false)))
 414        ) {
 415            long timestamp;
 416
 417            if (paginateBackward) {
 418                if (messageList.get(0).getType() == Message.TYPE_STATUS
 419                        && messageList.size() >= 2) {
 420                    timestamp = messageList.get(1).getTimeSent();
 421                } else {
 422                    timestamp = messageList.get(0).getTimeSent();
 423                }
 424            } else {
 425                if (messageList.get(messageList.size() - 1).getType() == Message.TYPE_STATUS
 426                        && messageList.size() >= 2) {
 427                    timestamp = messageList.get(messageList.size() - 2).getTimeSent();
 428                } else {
 429                    timestamp = messageList.get(messageList.size() - 1).getTimeSent();
 430                }
 431            }
 432
 433            boolean finalPaginateBackward = paginateBackward;
 434            activity.xmppConnectionService.loadMoreMessages(
 435                    conversation,
 436                    timestamp,
 437                    !paginateBackward,
 438                    new XmppConnectionService.OnMoreMessagesLoaded() {
 439                        @Override
 440                        public void onMoreMessagesLoaded(
 441                                final int c, final Conversation conversation) {
 442                            if (ConversationFragment.this.conversation
 443                                    != conversation) {
 444                                conversation.messagesLoaded.set(true);
 445                                return;
 446                            }
 447                            runOnUiThread(
 448                                    () -> {
 449                                        synchronized (messageList) {
 450                                            final int oldPosition =
 451                                                    binding.messagesView
 452                                                            .getFirstVisiblePosition();
 453                                            Message message = null;
 454                                            int childPos;
 455                                            for (childPos = 0;
 456                                                 childPos + oldPosition
 457                                                         < messageList.size();
 458                                                 ++childPos) {
 459                                                message =
 460                                                        messageList.get(
 461                                                                oldPosition
 462                                                                        + childPos);
 463                                                if (message.getType()
 464                                                        != Message.TYPE_STATUS) {
 465                                                    break;
 466                                                }
 467                                            }
 468                                            final String uuid =
 469                                                    message != null
 470                                                            ? message.getUuid()
 471                                                            : null;
 472                                            View v =
 473                                                    binding.messagesView.getChildAt(
 474                                                            childPos);
 475                                            final int pxOffset =
 476                                                    (v == null) ? 0 : v.getTop();
 477                                            ConversationFragment.this.conversation
 478                                                    .populateWithMessages(
 479                                                            ConversationFragment
 480                                                                    .this
 481                                                                    .messageList,
 482                                                            activity == null ? null : activity.xmppConnectionService);
 483                                            try {
 484                                                updateStatusMessages();
 485                                            } catch (IllegalStateException e) {
 486                                                Log.d(
 487                                                        Config.LOGTAG,
 488                                                        "caught illegal state exception while updating status messages");
 489                                            }
 490                                            messageListAdapter
 491                                                    .notifyDataSetChanged();
 492                                            int pos =
 493                                                    Math.max(
 494                                                            getIndexOf(
 495                                                                    uuid,
 496                                                                    messageList),
 497                                                            0);
 498                                            binding.messagesView
 499                                                    .setSelectionFromTop(
 500                                                            pos, pxOffset);
 501                                            if (messageLoaderToast != null) {
 502                                                messageLoaderToast.cancel();
 503                                            }
 504
 505                                            if (!finalPaginateBackward) {
 506                                                conversation.historyPartLoadedForward.set(true);
 507                                            } else {
 508                                                conversation.messagesLoaded.set(true);
 509                                            }
 510                                        }
 511                                    });
 512                        }
 513
 514                        @Override
 515                        public void informUser(final int resId) {
 516
 517                            runOnUiThread(
 518                                    () -> {
 519                                        if (messageLoaderToast != null) {
 520                                            messageLoaderToast.cancel();
 521                                        }
 522                                        if (ConversationFragment.this.conversation
 523                                                != conversation) {
 524                                            return;
 525                                        }
 526                                        messageLoaderToast =
 527                                                Toast.makeText(
 528                                                        view.getContext(),
 529                                                        resId,
 530                                                        Toast.LENGTH_LONG);
 531                                        messageLoaderToast.show();
 532                                    });
 533                        }
 534                    });
 535        }
 536    }
 537    private final EditMessage.OnCommitContentListener mEditorContentListener =
 538            new EditMessage.OnCommitContentListener() {
 539                @Override
 540                public boolean onCommitContent(
 541                        InputContentInfoCompat inputContentInfo,
 542                        int flags,
 543                        Bundle opts,
 544                        String[] contentMimeTypes) {
 545                    // try to get permission to read the image, if applicable
 546                    if ((flags & InputConnectionCompat.INPUT_CONTENT_GRANT_READ_URI_PERMISSION)
 547                            != 0) {
 548                        try {
 549                            inputContentInfo.requestPermission();
 550                        } catch (Exception e) {
 551                            Log.e(
 552                                    Config.LOGTAG,
 553                                    "InputContentInfoCompat#requestPermission() failed.",
 554                                    e);
 555                            Toast.makeText(
 556                                            getActivity(),
 557                                            activity.getString(
 558                                                    R.string.no_permission_to_access_x,
 559                                                    inputContentInfo.getDescription()),
 560                                            Toast.LENGTH_LONG)
 561                                    .show();
 562                            return false;
 563                        }
 564                    }
 565                    if (hasPermissions(
 566                            REQUEST_ADD_EDITOR_CONTENT,
 567                            Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
 568                        attachEditorContentToConversation(inputContentInfo.getContentUri());
 569                    } else {
 570                        mPendingEditorContent = inputContentInfo.getContentUri();
 571                    }
 572                    return true;
 573                }
 574            };
 575    private Message selectedMessage;
 576    private final OnClickListener mEnableAccountListener =
 577            new OnClickListener() {
 578                @Override
 579                public void onClick(View v) {
 580                    final Account account = conversation == null ? null : conversation.getAccount();
 581                    if (account != null) {
 582                        account.setOption(Account.OPTION_SOFT_DISABLED, false);
 583                        account.setOption(Account.OPTION_DISABLED, false);
 584                        activity.xmppConnectionService.updateAccount(account);
 585                    }
 586                }
 587            };
 588    private final OnClickListener mUnblockClickListener =
 589            new OnClickListener() {
 590                @Override
 591                public void onClick(final View v) {
 592                    v.post(() -> v.setVisibility(View.INVISIBLE));
 593                    if (conversation.isDomainBlocked()) {
 594                        BlockContactDialog.show(activity, conversation);
 595                    } else {
 596                        unblockConversation(conversation);
 597                    }
 598                }
 599            };
 600    private final OnClickListener mBlockClickListener = this::showBlockSubmenu;
 601    private final OnClickListener mAddBackClickListener =
 602            new OnClickListener() {
 603
 604                @Override
 605                public void onClick(View v) {
 606                    final Contact contact = conversation == null ? null : conversation.getContact();
 607                    if (contact != null) {
 608                        activity.xmppConnectionService.createContact(contact);
 609                        activity.switchToContactDetails(contact);
 610                    }
 611                }
 612            };
 613    private final View.OnLongClickListener mLongPressBlockListener = this::showBlockSubmenu;
 614    private final OnClickListener mAllowPresenceSubscription =
 615            new OnClickListener() {
 616                @Override
 617                public void onClick(View v) {
 618                    final Contact contact = conversation == null ? null : conversation.getContact();
 619                    if (contact != null) {
 620                        final var connection = contact.getAccount().getXmppConnection();
 621                        connection
 622                                .getManager(PresenceManager.class)
 623                                .subscribed(contact.getJid().asBareJid());
 624                        hideSnackbar();
 625                    }
 626                }
 627            };
 628    protected OnClickListener clickToDecryptListener =
 629            new OnClickListener() {
 630
 631                @Override
 632                public void onClick(View v) {
 633                    PendingIntent pendingIntent =
 634                            conversation.getAccount().getPgpDecryptionService().getPendingIntent();
 635                    if (pendingIntent != null) {
 636                        try {
 637                            getActivity()
 638                                    .startIntentSenderForResult(
 639                                            pendingIntent.getIntentSender(),
 640                                            REQUEST_DECRYPT_PGP,
 641                                            null,
 642                                            0,
 643                                            0,
 644                                            0,
 645                                            Compatibility.pgpStartIntentSenderOptions());
 646                        } catch (SendIntentException e) {
 647                            Toast.makeText(
 648                                            getActivity(),
 649                                            R.string.unable_to_connect_to_keychain,
 650                                            Toast.LENGTH_SHORT)
 651                                    .show();
 652                            conversation
 653                                    .getAccount()
 654                                    .getPgpDecryptionService()
 655                                    .continueDecryption(true);
 656                        }
 657                    }
 658                    updateSnackBar(conversation);
 659                }
 660            };
 661    private final AtomicBoolean mSendingPgpMessage = new AtomicBoolean(false);
 662    private final OnEditorActionListener mEditorActionListener =
 663            (v, actionId, event) -> {
 664                if (actionId == EditorInfo.IME_ACTION_SEND) {
 665                    InputMethodManager imm =
 666                            (InputMethodManager)
 667                                    activity.getSystemService(Context.INPUT_METHOD_SERVICE);
 668                    if (imm != null && imm.isFullscreenMode()) {
 669                        imm.hideSoftInputFromWindow(v.getWindowToken(), 0);
 670                    }
 671                    sendMessage();
 672                    return true;
 673                } else {
 674                    return false;
 675                }
 676            };
 677    private final OnClickListener mScrollButtonListener =
 678            new OnClickListener() {
 679
 680                @Override
 681                public void onClick(View v) {
 682                    stopScrolling();
 683
 684                    if (!replyJumps.isEmpty()) {
 685                        int lastVisiblePosition = binding.messagesView.getLastVisiblePosition();
 686                        Message lastVisibleMessage = messageListAdapter.getItem(lastVisiblePosition);
 687                        if (lastVisibleMessage == null) {
 688                            replyJumps.clear();
 689                        } else {
 690                            while (!replyJumps.isEmpty()) {
 691                                Message jump = replyJumps.pop();
 692                                if (jump.getTimeSent() > lastVisibleMessage.getTimeSent()) {
 693                                    Runnable postSelectionRunnable = () -> highlightMessage(jump.getUuid());
 694                                    updateSelection(jump.getUuid(), binding.messagesView.getHeight() / 2, postSelectionRunnable, false, false);
 695                                    return;
 696                                }
 697                            }
 698                        }
 699                    }
 700
 701                    if (conversation.isInHistoryPart()) {
 702                        conversation.jumpToLatest();
 703                        refresh(false);
 704                    }
 705                    setSelection(binding.messagesView.getCount() - 1, true);
 706                }
 707            };
 708    private final OnClickListener mSendButtonListener =
 709            new OnClickListener() {
 710
 711                @Override
 712                public void onClick(View v) {
 713                    Object tag = v.getTag();
 714                    if (tag instanceof SendButtonAction action) {
 715                        switch (action) {
 716                            case TAKE_PHOTO:
 717                            case RECORD_VIDEO:
 718                            case SEND_LOCATION:
 719                            case RECORD_VOICE:
 720                            case CHOOSE_PICTURE:
 721                                attachFile(action.toChoice());
 722                                break;
 723                            case CANCEL:
 724                                if (conversation != null) {
 725                                    conversation.setUserSelectedThread(false);
 726                                    if (conversation.setCorrectingMessage(null)) {
 727                                        binding.textinput.setText("");
 728                                        binding.textinput.append(conversation.getDraftMessage());
 729                                        conversation.setDraftMessage(null);
 730                                    } else if (conversation.getMode() == Conversation.MODE_MULTI) {
 731                                        conversation.setNextCounterpart(null);
 732                                        binding.textinput.setText("");
 733                                    } else {
 734                                        binding.textinput.setText("");
 735                                    }
 736                                    binding.textinputSubject.setText("");
 737                                    binding.textinputSubject.setVisibility(View.GONE);
 738                                    updateChatMsgHint();
 739                                    updateSendButton();
 740                                    updateEditablity();
 741                                }
 742                                break;
 743                            default:
 744                                sendMessage();
 745                        }
 746                    } else {
 747                        sendMessage();
 748                    }
 749                }
 750            };
 751    private OnBackPressedCallback backPressedLeaveSingleThread = new OnBackPressedCallback(false) {
 752        @Override
 753        public void handleOnBackPressed() {
 754            conversation.setLockThread(false);
 755            this.setEnabled(false);
 756            conversation.setUserSelectedThread(false);
 757            setThread(null);
 758            refresh();
 759            updateThreadFromLastMessage();
 760        }
 761    };
 762    private int completionIndex = 0;
 763    private int lastCompletionLength = 0;
 764    private String incomplete;
 765    private int lastCompletionCursor;
 766    private boolean firstWord = false;
 767    private Message mPendingDownloadableMessage;
 768    private ProgressDialog fetchHistoryDialog;
 769
 770    private static ConversationFragment findConversationFragment(Activity activity) {
 771        Fragment fragment = activity.getFragmentManager().findFragmentById(R.id.main_fragment);
 772        if (fragment instanceof ConversationFragment) {
 773            return (ConversationFragment) fragment;
 774        }
 775        fragment = activity.getFragmentManager().findFragmentById(R.id.secondary_fragment);
 776        if (fragment instanceof ConversationFragment) {
 777            return (ConversationFragment) fragment;
 778        }
 779        return null;
 780    }
 781
 782    public static void startStopPending(Activity activity) {
 783        ConversationFragment fragment = findConversationFragment(activity);
 784        if (fragment != null) {
 785            fragment.messageListAdapter.startStopPending();
 786        }
 787    }
 788
 789    public static void downloadFile(Activity activity, Message message) {
 790        ConversationFragment fragment = findConversationFragment(activity);
 791        if (fragment != null) {
 792            fragment.startDownloadable(message);
 793        }
 794    }
 795
 796    public static void registerPendingMessage(Activity activity, Message message) {
 797        ConversationFragment fragment = findConversationFragment(activity);
 798        if (fragment != null) {
 799            fragment.pendingMessage.push(message);
 800        }
 801    }
 802
 803    public static void openPendingMessage(Activity activity) {
 804        ConversationFragment fragment = findConversationFragment(activity);
 805        if (fragment != null) {
 806            Message message = fragment.pendingMessage.pop();
 807            if (message != null) {
 808                fragment.messageListAdapter.openDownloadable(message);
 809            }
 810        }
 811    }
 812
 813    public static Conversation getConversation(Activity activity) {
 814        return getConversation(activity, R.id.secondary_fragment);
 815    }
 816
 817    private static Conversation getConversation(Activity activity, @IdRes int res) {
 818        final Fragment fragment = activity.getFragmentManager().findFragmentById(res);
 819        if (fragment instanceof ConversationFragment) {
 820            return ((ConversationFragment) fragment).getConversation();
 821        } else {
 822            return null;
 823        }
 824    }
 825
 826    public static ConversationFragment get(Activity activity) {
 827        FragmentManager fragmentManager = activity.getFragmentManager();
 828        Fragment fragment = fragmentManager.findFragmentById(R.id.main_fragment);
 829        if (fragment instanceof ConversationFragment) {
 830            return (ConversationFragment) fragment;
 831        } else {
 832            fragment = fragmentManager.findFragmentById(R.id.secondary_fragment);
 833            return fragment instanceof ConversationFragment
 834                    ? (ConversationFragment) fragment
 835                    : null;
 836        }
 837    }
 838
 839    public static Conversation getConversationReliable(Activity activity) {
 840        final Conversation conversation = getConversation(activity, R.id.secondary_fragment);
 841        if (conversation != null) {
 842            return conversation;
 843        }
 844        return getConversation(activity, R.id.main_fragment);
 845    }
 846
 847    private static boolean scrolledToBottom(AbsListView listView) {
 848        final int count = listView.getCount();
 849        if (count == 0) {
 850            return true;
 851        } else if (listView.getLastVisiblePosition() == count - 1) {
 852            final View lastChild = listView.getChildAt(listView.getChildCount() - 1);
 853            return lastChild != null && lastChild.getBottom() <= listView.getHeight();
 854        } else {
 855            return false;
 856        }
 857    }
 858
 859    private void toggleScrollDownButton() {
 860        toggleScrollDownButton(binding.messagesView);
 861    }
 862
 863    private void toggleScrollDownButton(AbsListView listView) {
 864        if (conversation == null) {
 865            return;
 866        }
 867        if (scrolledToBottom(listView) && !conversation.isInHistoryPart()) {
 868            lastMessageUuid = null;
 869            hideUnreadMessagesCount();
 870        } else {
 871            binding.scrollToBottomButton.setEnabled(true);
 872            binding.scrollToBottomButton.show();
 873            if (lastMessageUuid == null) {
 874                lastMessageUuid = conversation.getLatestMessage().getUuid();
 875            }
 876            if (conversation.getReceivedMessagesCountSinceUuid(lastMessageUuid) > 0) {
 877                binding.unreadCountCustomView.setVisibility(View.VISIBLE);
 878            }
 879        }
 880    }
 881
 882    private int getIndexOf(String uuid, List<Message> messages) {
 883        if (uuid == null) {
 884            return messages.size() - 1;
 885        }
 886        for (int i = 0; i < messages.size(); ++i) {
 887            if (uuid.equals(messages.get(i).getUuid())) {
 888                return i;
 889            }
 890        }
 891        return -1;
 892    }
 893
 894    private int getIndexOfExtended(String uuid, List<Message> messages) {
 895        if (uuid == null) {
 896            return messages.size() - 1;
 897        }
 898        for (int i = 0; i < messages.size(); ++i) {
 899            if (uuid.equals(messages.get(i).getServerMsgId())) {
 900                return i;
 901            }
 902
 903            if (uuid.equals(messages.get(i).getRemoteMsgId())) {
 904                return i;
 905            }
 906
 907            if (uuid.equals(messages.get(i).getUuid())) {
 908                return i;
 909            }
 910        }
 911        return -1;
 912    }
 913
 914    private ScrollState getScrollPosition() {
 915        final ListView listView = this.binding == null ? null : this.binding.messagesView;
 916        if (listView == null
 917                || listView.getCount() == 0
 918                || listView.getLastVisiblePosition() == listView.getCount() - 1) {
 919            return null;
 920        } else {
 921            final int pos = listView.getFirstVisiblePosition();
 922            final View view = listView.getChildAt(0);
 923            if (view == null) {
 924                return null;
 925            } else {
 926                return new ScrollState(pos, view.getTop());
 927            }
 928        }
 929    }
 930
 931    private void setScrollPosition(ScrollState scrollPosition, String lastMessageUuid) {
 932        if (scrollPosition != null) {
 933
 934            this.lastMessageUuid = lastMessageUuid;
 935            if (lastMessageUuid != null) {
 936                binding.unreadCountCustomView.setUnreadCount(
 937                        conversation.getReceivedMessagesCountSinceUuid(lastMessageUuid));
 938            }
 939            // TODO maybe this needs a 'post'
 940            this.binding.messagesView.setSelectionFromTop(
 941                    scrollPosition.position, scrollPosition.offset);
 942            toggleScrollDownButton();
 943        }
 944    }
 945
 946    private void attachLocationToConversation(Conversation conversation, Uri uri) {
 947        if (conversation == null) {
 948            return;
 949        }
 950        final String subject = binding.textinputSubject.getText().toString();
 951        activity.xmppConnectionService.attachLocationToConversation(
 952                conversation,
 953                uri,
 954                subject,
 955                new UiCallback<Message>() {
 956
 957                    @Override
 958                    public void success(Message message) {
 959                        messageSent();
 960                    }
 961
 962                    @Override
 963                    public void error(int errorCode, Message object) {
 964                        // TODO show possible pgp error
 965                    }
 966
 967                    @Override
 968                    public void userInputRequired(PendingIntent pi, Message object) {}
 969                });
 970    }
 971
 972    private void attachFileToConversation(Conversation conversation, Uri uri, String type, Runnable next) {
 973        if (conversation == null) {
 974            return;
 975        }
 976        final String subject = binding.textinputSubject.getText().toString();
 977        if ("application/webxdc+zip".equals(type)) newSubThread();
 978        final Toast prepareFileToast =
 979                Toast.makeText(getActivity(), getText(R.string.preparing_file), Toast.LENGTH_LONG);
 980        prepareFileToast.show();
 981        activity.delegateUriPermissionsToService(uri);
 982        activity.xmppConnectionService.attachFileToConversation(
 983                conversation,
 984                uri,
 985                type,
 986                subject,
 987                new UiInformableCallback<Message>() {
 988                    @Override
 989                    public void inform(final String text) {
 990                        hidePrepareFileToast(prepareFileToast);
 991                        runOnUiThread(() -> activity.replaceToast(text));
 992                    }
 993
 994                    @Override
 995                    public void success(Message message) {
 996                        if (next == null) {
 997                            runOnUiThread(() -> {
 998                                activity.hideToast();
 999                                messageSent();
1000                            });
1001                        } else {
1002                            runOnUiThread(next);
1003                        }
1004                        hidePrepareFileToast(prepareFileToast);
1005                    }
1006
1007                    @Override
1008                    public void error(final int errorCode, Message message) {
1009                        hidePrepareFileToast(prepareFileToast);
1010                        runOnUiThread(() -> activity.replaceToast(getString(errorCode)));
1011                    }
1012
1013                    @Override
1014                    public void userInputRequired(PendingIntent pi, Message message) {
1015                        hidePrepareFileToast(prepareFileToast);
1016                    }
1017                });
1018    }
1019
1020    public void attachEditorContentToConversation(Uri uri) {
1021        mediaPreviewAdapter.addMediaPreviews(
1022                Attachment.of(getActivity(), uri, Attachment.Type.FILE));
1023        toggleInputMethod();
1024    }
1025
1026    private void attachImageToConversation(Conversation conversation, Uri uri, String type, Runnable next) {
1027        if (conversation == null) {
1028            return;
1029        }
1030        final String subject = binding.textinputSubject.getText().toString();
1031        final Toast prepareFileToast =
1032                Toast.makeText(getActivity(), getText(R.string.preparing_image), Toast.LENGTH_LONG);
1033        prepareFileToast.show();
1034        activity.delegateUriPermissionsToService(uri);
1035        activity.xmppConnectionService.attachImageToConversation(
1036                conversation,
1037                uri,
1038                type,
1039                subject,
1040                new UiCallback<Message>() {
1041
1042                    @Override
1043                    public void userInputRequired(PendingIntent pi, Message object) {
1044                        hidePrepareFileToast(prepareFileToast);
1045                    }
1046
1047                    @Override
1048                    public void success(Message message) {
1049                        hidePrepareFileToast(prepareFileToast);
1050                        if (next == null) {
1051                            runOnUiThread(() -> messageSent());
1052                        } else {
1053                            runOnUiThread(next);
1054                        }
1055                    }
1056
1057                    @Override
1058                    public void error(final int error, final Message message) {
1059                        hidePrepareFileToast(prepareFileToast);
1060                        final ConversationsActivity activity = ConversationFragment.this.activity;
1061                        if (activity == null) {
1062                            return;
1063                        }
1064                        activity.runOnUiThread(() -> activity.replaceToast(getString(error)));
1065                    }
1066                });
1067    }
1068
1069    private void hidePrepareFileToast(final Toast prepareFileToast) {
1070        if (prepareFileToast != null && activity != null) {
1071            activity.runOnUiThread(prepareFileToast::cancel);
1072        }
1073    }
1074
1075    private void sendMessage() {
1076        sendMessage((Long) null);
1077    }
1078
1079    private void sendMessage(Long sendAt) {
1080        if (sendAt != null && sendAt < System.currentTimeMillis()) sendAt = null; // No sending in past plz
1081        if (mediaPreviewAdapter.hasAttachments()) {
1082            commitAttachments();
1083            return;
1084        }
1085        Editable body = this.binding.textinput.getText();
1086        if (body == null) body = new SpannableStringBuilder("");
1087        if (body.length() > Config.MAX_DISPLAY_MESSAGE_CHARS) {
1088            Toast.makeText(activity, "Message is too long", Toast.LENGTH_SHORT).show();
1089            return;
1090        }
1091        final Conversation conversation = this.conversation;
1092        final boolean hasSubject = binding.textinputSubject.getText().length() > 0;
1093        if (conversation == null || (body.length() == 0 && (conversation.getThread() == null || !hasSubject))) {
1094            if (Build.VERSION.SDK_INT >= 24) {
1095                binding.textSendButton.showContextMenu(0, 0);
1096            } else {
1097                binding.textSendButton.showContextMenu();
1098            }
1099            return;
1100        }
1101        if (trustKeysIfNeeded(conversation, REQUEST_TRUST_KEYS_TEXT)) {
1102            return;
1103        }
1104        final Message message;
1105        if (conversation.getCorrectingMessage() == null) {
1106            boolean attention = false;
1107            if (Pattern.compile("\\A@here\\s.*").matcher(body).find()) {
1108                attention = true;
1109                body.delete(0, 6);
1110                while (body.length() > 0 && Character.isWhitespace(body.charAt(0))) body.delete(0, 1);
1111            }
1112            if (conversation.getReplyTo() != null) {
1113                if (Emoticons.isEmoji(body.toString().replaceAll("\\s", "")) && conversation.getNextCounterpart() == null && !conversation.getReplyTo().isPrivateMessage()) {
1114                    final var aggregated = conversation.getReplyTo().getAggregatedReactions();
1115                    final ImmutableSet.Builder<String> reactionBuilder = new ImmutableSet.Builder<>();
1116                    reactionBuilder.addAll(aggregated.ourReactions);
1117                    reactionBuilder.add(body.toString().replaceAll("\\s", ""));
1118                    activity.xmppConnectionService.sendReactions(conversation.getReplyTo(), reactionBuilder.build());
1119                    messageSent();
1120                    return;
1121                } else {
1122                    message = conversation.getReplyTo().reply();
1123                    message.appendBody(body);
1124                }
1125                message.setEncryption(conversation.getNextEncryption());
1126            } else {
1127                message = new Message(conversation, body.toString(), conversation.getNextEncryption());
1128                message.setBody(hasSubject && body.length() == 0 ? null : body);
1129                if (message.bodyIsOnlyEmojis()) {
1130                    var spannable = message.getSpannableBody(null, null);
1131                    ImageSpan[] imageSpans = spannable.getSpans(0, spannable.length(), ImageSpan.class);
1132                    for (ImageSpan span : imageSpans) {
1133                        final int start = spannable.getSpanStart(span);
1134                        final int end = spannable.getSpanEnd(span);
1135                        spannable.delete(start, end);
1136                    }
1137                    if (imageSpans.length == 1 && spannable.toString().replaceAll("\\s", "").length() < 1) {
1138                        // Only one inline image, so it's a sticker
1139                        String source = imageSpans[0].getSource();
1140                        if (source != null && source.length() > 0 && source.substring(0, 4).equals("cid:")) {
1141                            try {
1142                                final Cid cid = BobTransfer.cid(Uri.parse(source));
1143                                final String url = activity.xmppConnectionService.getUrlForCid(cid);
1144                                final File f = activity.xmppConnectionService.getFileForCid(cid);
1145                                if (url != null) {
1146                                    message.setBody("");
1147                                    message.setRelativeFilePath(f.getAbsolutePath());
1148                                    activity.xmppConnectionService.getFileBackend().updateFileParams(message);
1149                                }
1150                            } catch (final Exception e) { }
1151                        }
1152                    }
1153                }
1154            }
1155            if (hasSubject) message.setSubject(binding.textinputSubject.getText().toString());
1156            message.setThread(conversation.getThread());
1157            if (attention) {
1158                message.addPayload(new Element("attention", "urn:xmpp:attention:0"));
1159            }
1160            Message.configurePrivateMessage(message);
1161        } else {
1162            message = conversation.getCorrectingMessage();
1163            if (hasSubject) message.setSubject(binding.textinputSubject.getText().toString());
1164            message.setThread(conversation.getThread());
1165            if (conversation.getReplyTo() != null) {
1166                if (Emoticons.isEmoji(body.toString().replaceAll("\\s", ""))) {
1167                    message.updateReaction(conversation.getReplyTo(), body.toString().replaceAll("\\s", ""));
1168                } else {
1169                    message.updateReplyTo(conversation.getReplyTo(), body);
1170                }
1171            } else {
1172                message.clearReplyReact();
1173                message.setBody(hasSubject && body.length() == 0 ? null : body);
1174            }
1175            if (message.getStatus() == Message.STATUS_WAITING) {
1176                if (sendAt != null) message.setTime(sendAt);
1177                activity.xmppConnectionService.updateMessage(message);
1178                messageSent();
1179                return;
1180            } else {
1181                message.putEdited(message.getUuid(), message.getServerMsgId());
1182                message.setServerMsgId(null);
1183                message.setUuid(UUID.randomUUID().toString());
1184            }
1185        }
1186        if (sendAt != null) message.setTime(sendAt);
1187        switch (conversation.getNextEncryption()) {
1188            case Message.ENCRYPTION_PGP:
1189                sendPgpMessage(message);
1190                break;
1191            default:
1192                sendMessage(message);
1193        }
1194        setupReply(null);
1195    }
1196
1197    private boolean trustKeysIfNeeded(final Conversation conversation, final int requestCode) {
1198        return conversation.getNextEncryption() == Message.ENCRYPTION_AXOLOTL
1199                && trustKeysIfNeeded(requestCode);
1200    }
1201
1202    protected boolean trustKeysIfNeeded(int requestCode) {
1203        AxolotlService axolotlService = conversation.getAccount().getAxolotlService();
1204        if (axolotlService == null) return false;
1205        final List<Jid> targets = axolotlService.getCryptoTargets(conversation);
1206        boolean hasUnaccepted = !conversation.getAcceptedCryptoTargets().containsAll(targets);
1207        boolean hasUndecidedOwn =
1208                !axolotlService
1209                        .getKeysWithTrust(FingerprintStatus.createActiveUndecided())
1210                        .isEmpty();
1211        boolean hasUndecidedContacts =
1212                !axolotlService
1213                        .getKeysWithTrust(FingerprintStatus.createActiveUndecided(), targets)
1214                        .isEmpty();
1215        boolean hasPendingKeys = !axolotlService.findDevicesWithoutSession(conversation).isEmpty();
1216        boolean hasNoTrustedKeys = axolotlService.anyTargetHasNoTrustedKeys(targets);
1217        boolean downloadInProgress = axolotlService.hasPendingKeyFetches(targets);
1218        if (hasUndecidedOwn
1219                || hasUndecidedContacts
1220                || hasPendingKeys
1221                || hasNoTrustedKeys
1222                || hasUnaccepted
1223                || downloadInProgress) {
1224            axolotlService.createSessionsIfNeeded(conversation);
1225            Intent intent = new Intent(getActivity(), TrustKeysActivity.class);
1226            String[] contacts = new String[targets.size()];
1227            for (int i = 0; i < contacts.length; ++i) {
1228                contacts[i] = targets.get(i).toString();
1229            }
1230            intent.putExtra("contacts", contacts);
1231            intent.putExtra(
1232                    EXTRA_ACCOUNT, conversation.getAccount().getJid().asBareJid().toString());
1233            intent.putExtra("conversation", conversation.getUuid());
1234            startActivityForResult(intent, requestCode);
1235            return true;
1236        } else {
1237            return false;
1238        }
1239    }
1240
1241    public void updateChatMsgHint() {
1242        final boolean multi = conversation.getMode() == Conversation.MODE_MULTI;
1243        if (conversation.getCorrectingMessage() != null) {
1244            this.binding.textInputHint.setVisibility(View.GONE);
1245            this.binding.textinput.setHint(R.string.send_corrected_message);
1246            binding.conversationViewPager.setCurrentItem(0);
1247        } else if (multi && conversation.getNextCounterpart() != null) {
1248            this.binding.textinput.setHint(R.string.send_message);
1249            this.binding.textInputHint.setVisibility(View.VISIBLE);
1250            final MucOptions.User user = conversation.getMucOptions().findUserByName(conversation.getNextCounterpart().getResource());
1251            String nick = user == null ? null : user.getNick();
1252            if (nick == null) nick = conversation.getNextCounterpart().getResource();
1253            this.binding.textInputHint.setText(
1254                    getString(
1255                            R.string.send_private_message_to,
1256                            nick));
1257            binding.conversationViewPager.setCurrentItem(0);
1258        } else if (multi && !conversation.getMucOptions().participating()) {
1259            this.binding.textInputHint.setVisibility(View.GONE);
1260            this.binding.textinput.setHint(R.string.you_are_not_participating);
1261            this.binding.inputLayout.setBackgroundColor(android.R.color.transparent);
1262        } else {
1263            this.binding.textInputHint.setVisibility(View.GONE);
1264            if (activity == null) return;
1265            this.binding.textinput.setHint(UIHelper.getMessageHint(activity, conversation));
1266            this.binding.inputLayout.setBackground(activity.getDrawable(R.drawable.background_message_bubble));
1267            activity.invalidateOptionsMenu();
1268        }
1269
1270        binding.messagesView.post(this::updateThreadFromLastMessage);
1271    }
1272
1273    public void setupIme() {
1274        this.binding.textinput.refreshIme();
1275    }
1276
1277    private void handleActivityResult(ActivityResult activityResult) {
1278        if (activityResult.resultCode == Activity.RESULT_OK) {
1279            handlePositiveActivityResult(activityResult.requestCode, activityResult.data);
1280        } else {
1281            handleNegativeActivityResult(activityResult.requestCode);
1282        }
1283    }
1284
1285    private void handlePositiveActivityResult(int requestCode, final Intent data) {
1286        switch (requestCode) {
1287            case REQUEST_WEBXDC_STORE:
1288                mediaPreviewAdapter.addMediaPreviews(Attachment.of(activity, data.getData(), Attachment.Type.FILE));
1289                toggleInputMethod();
1290                break;
1291            case REQUEST_SAVE_STICKER:
1292                final DocumentFile df = DocumentFile.fromSingleUri(activity, data.getData());
1293                final File f = savingAsSticker;
1294                savingAsSticker = null;
1295                try {
1296                    activity.xmppConnectionService.getFileBackend().copyFileToDocumentFile(activity, f, df);
1297                    Toast.makeText(activity, "Sticker saved", Toast.LENGTH_SHORT).show();
1298                } catch (final FileBackend.FileCopyException e) {
1299                    Toast.makeText(activity, e.getResId(), Toast.LENGTH_SHORT).show();
1300                }
1301                break;
1302            case REQUEST_TRUST_KEYS_TEXT:
1303                sendMessage();
1304                break;
1305            case REQUEST_TRUST_KEYS_ATTACHMENTS:
1306                commitAttachments();
1307                break;
1308            case REQUEST_START_AUDIO_CALL:
1309                triggerRtpSession(RtpSessionActivity.ACTION_MAKE_VOICE_CALL);
1310                break;
1311            case REQUEST_START_VIDEO_CALL:
1312                triggerRtpSession(RtpSessionActivity.ACTION_MAKE_VIDEO_CALL);
1313                break;
1314            case ATTACHMENT_CHOICE_CHOOSE_IMAGE: {
1315                final Uri takePhotoUri = pendingTakePhotoUri.pop();
1316                if (takePhotoUri != null && (data == null || (data.getData() == null && data.getClipData() == null))) {
1317                    mediaPreviewAdapter.addMediaPreviews(
1318                            Attachment.of(getActivity(), takePhotoUri, Attachment.Type.IMAGE));
1319                }
1320                final List<Attachment> imageUris =
1321                        Attachment.extractAttachments(getActivity(), data, Attachment.Type.IMAGE);
1322                mediaPreviewAdapter.addMediaPreviews(imageUris);
1323                toggleInputMethod();
1324                break;
1325            }
1326            case ATTACHMENT_CHOICE_TAKE_PHOTO: {
1327                final Uri takePhotoUri = pendingTakePhotoUri.pop();
1328                if (takePhotoUri != null) {
1329                    mediaPreviewAdapter.addMediaPreviews(
1330                            Attachment.of(getActivity(), takePhotoUri, Attachment.Type.IMAGE));
1331                    toggleInputMethod();
1332                } else {
1333                    Log.d(Config.LOGTAG, "lost take photo uri. unable to to attach");
1334                }
1335                break;
1336            }
1337            case ATTACHMENT_CHOICE_CHOOSE_FILE:
1338            case ATTACHMENT_CHOICE_RECORD_VIDEO:
1339            case ATTACHMENT_CHOICE_RECORD_VOICE:
1340                final Attachment.Type type =
1341                        requestCode == ATTACHMENT_CHOICE_RECORD_VOICE
1342                                ? Attachment.Type.RECORDING
1343                                : Attachment.Type.FILE;
1344                final List<Attachment> fileUris =
1345                        Attachment.extractAttachments(getActivity(), data, type);
1346                mediaPreviewAdapter.addMediaPreviews(fileUris);
1347                toggleInputMethod();
1348                break;
1349            case ATTACHMENT_CHOICE_LOCATION:
1350                final double latitude = data.getDoubleExtra("latitude", 0);
1351                final double longitude = data.getDoubleExtra("longitude", 0);
1352                final int accuracy = data.getIntExtra("accuracy", 0);
1353                final Uri geo;
1354                if (accuracy > 0) {
1355                    geo = Uri.parse(String.format("geo:%s,%s;u=%s", latitude, longitude, accuracy));
1356                } else {
1357                    geo = Uri.parse(String.format("geo:%s,%s", latitude, longitude));
1358                }
1359                mediaPreviewAdapter.addMediaPreviews(
1360                        Attachment.of(getActivity(), geo, Attachment.Type.LOCATION));
1361                toggleInputMethod();
1362                break;
1363            case REQUEST_INVITE_TO_CONVERSATION:
1364                XmppActivity.ConferenceInvite invite = XmppActivity.ConferenceInvite.parse(data);
1365                if (invite != null) {
1366                    if (invite.execute(activity)) {
1367                        activity.mToast =
1368                                Toast.makeText(
1369                                        activity, R.string.creating_conference, Toast.LENGTH_LONG);
1370                        activity.mToast.show();
1371                    }
1372                }
1373                break;
1374        }
1375    }
1376
1377    private void commitAttachments() {
1378        final List<Attachment> attachments = mediaPreviewAdapter.getAttachments();
1379        if (anyNeedsExternalStoragePermission(attachments)
1380                && !hasPermissions(
1381                        REQUEST_COMMIT_ATTACHMENTS, Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
1382            return;
1383        }
1384        if (trustKeysIfNeeded(conversation, REQUEST_TRUST_KEYS_ATTACHMENTS)) {
1385            return;
1386        }
1387        final PresenceSelector.OnPresenceSelected callback =
1388                () -> {
1389                    final Iterator<Attachment> i = attachments.iterator();
1390                    final Runnable next = new Runnable() {
1391                        @Override
1392                        public void run() {
1393                            try {
1394                                if (!i.hasNext()) return;
1395                                final Attachment attachment = i.next();
1396                                if (attachment.getType() == Attachment.Type.LOCATION) {
1397                                        attachLocationToConversation(conversation, attachment.getUri());
1398                                    if (i.hasNext()) runOnUiThread(this);
1399                                } else if (attachment.getType() == Attachment.Type.IMAGE) {
1400                                    Log.d(
1401                                          Config.LOGTAG,
1402                                          "ConversationsActivity.commitAttachments() - attaching image to conversations. CHOOSE_IMAGE");
1403                                    attachImageToConversation(conversation, attachment.getUri(), attachment.getMime(), this);
1404                                } else {
1405                                    Log.d(
1406                                          Config.LOGTAG,
1407                                          "ConversationsActivity.commitAttachments() - attaching file to conversations. CHOOSE_FILE/RECORD_VOICE/RECORD_VIDEO");
1408                                    attachFileToConversation(conversation, attachment.getUri(), attachment.getMime(), this);
1409                                }
1410                                i.remove();
1411                                if (!i.hasNext()) messageSent();
1412                            } catch (final java.util.ConcurrentModificationException e) {
1413                                // Abort, leave any unsent attachments alone for the user to try again
1414                                Toast.makeText(activity, "Sometimes went wrong with some attachments. Try again?", Toast.LENGTH_SHORT).show();
1415                            }
1416                            mediaPreviewAdapter.notifyDataSetChanged();
1417                            toggleInputMethod();
1418                        }
1419                    };
1420                    next.run();
1421                };
1422        if (conversation == null
1423                || conversation.getMode() == Conversation.MODE_MULTI
1424                || Attachment.canBeSendInBand(attachments)
1425                || (conversation.getAccount().httpUploadAvailable()
1426                        && FileBackend.allFilesUnderSize(
1427                                getActivity(), attachments, getMaxHttpUploadSize(conversation)))) {
1428            callback.onPresenceSelected();
1429        } else {
1430            activity.selectPresence(conversation, callback);
1431        }
1432    }
1433
1434    private static boolean anyNeedsExternalStoragePermission(
1435            final Collection<Attachment> attachments) {
1436        for (final Attachment attachment : attachments) {
1437            if (attachment.getType() != Attachment.Type.LOCATION) {
1438                return true;
1439            }
1440        }
1441        return false;
1442    }
1443
1444    public void toggleInputMethod() {
1445        boolean hasAttachments = mediaPreviewAdapter.hasAttachments();
1446        binding.textinput.setVisibility(hasAttachments ? View.GONE : View.VISIBLE);
1447        binding.mediaPreview.setVisibility(hasAttachments ? View.VISIBLE : View.GONE);
1448        updateSendButton();
1449    }
1450
1451    private void handleNegativeActivityResult(int requestCode) {
1452        switch (requestCode) {
1453            case ATTACHMENT_CHOICE_TAKE_PHOTO:
1454                if (pendingTakePhotoUri.clear()) {
1455                    Log.d(
1456                            Config.LOGTAG,
1457                            "cleared pending photo uri after negative activity result");
1458                }
1459                break;
1460        }
1461    }
1462
1463    @Override
1464    public void onActivityResult(int requestCode, int resultCode, final Intent data) {
1465        super.onActivityResult(requestCode, resultCode, data);
1466        ActivityResult activityResult = ActivityResult.of(requestCode, resultCode, data);
1467        if (activity != null && activity.xmppConnectionService != null) {
1468            handleActivityResult(activityResult);
1469        } else {
1470            this.postponedActivityResult.push(activityResult);
1471        }
1472    }
1473
1474    public void unblockConversation(final Blockable conversation) {
1475        activity.xmppConnectionService.sendUnblockRequest(conversation);
1476    }
1477
1478    @Override
1479    public void onAttach(Activity activity) {
1480        super.onAttach(activity);
1481        Log.d(Config.LOGTAG, "ConversationFragment.onAttach()");
1482        if (activity instanceof ConversationsActivity) {
1483            this.activity = (ConversationsActivity) activity;
1484        } else {
1485            throw new IllegalStateException(
1486                    "Trying to attach fragment to activity that is not the ConversationsActivity");
1487        }
1488    }
1489
1490    @Override
1491    public void onDetach() {
1492        super.onDetach();
1493        this.activity = null; // TODO maybe not a good idea since some callbacks really need it
1494    }
1495
1496    @Override
1497    public void onCreate(Bundle savedInstanceState) {
1498        super.onCreate(savedInstanceState);
1499        setHasOptionsMenu(true);
1500        activity.getOnBackPressedDispatcher().addCallback(this, backPressedLeaveSingleThread);
1501        if (savedInstanceState == null) {
1502            conversation.jumpToLatest();
1503        }
1504    }
1505
1506    @Override
1507    public void onCreateOptionsMenu(Menu menu, MenuInflater menuInflater) {
1508        if (activity != null && activity.xmppConnectionService != null && activity.xmppConnectionService.isOnboarding()) return;
1509
1510        menuInflater.inflate(R.menu.fragment_conversation, menu);
1511        final MenuItem menuMucDetails = menu.findItem(R.id.action_muc_details);
1512        final MenuItem menuContactDetails = menu.findItem(R.id.action_contact_details);
1513        final MenuItem menuInviteContact = menu.findItem(R.id.action_invite);
1514        final MenuItem menuMute = menu.findItem(R.id.action_mute);
1515        final MenuItem menuUnmute = menu.findItem(R.id.action_unmute);
1516        final MenuItem menuCall = menu.findItem(R.id.action_call);
1517        final MenuItem menuOngoingCall = menu.findItem(R.id.action_ongoing_call);
1518        final MenuItem menuVideoCall = menu.findItem(R.id.action_video_call);
1519        final MenuItem menuTogglePinned = menu.findItem(R.id.action_toggle_pinned);
1520        final MenuItem menuArchiveChat = menu.findItem(R.id.action_archive);
1521
1522        if (conversation != null) {
1523            if (conversation.getMode() == Conversation.MODE_MULTI) {
1524                menuContactDetails.setVisible(false);
1525                menuInviteContact.setVisible(conversation.getMucOptions().canInvite());
1526                menuMucDetails.setTitle(
1527                        conversation.getMucOptions().isPrivateAndNonAnonymous()
1528                                ? R.string.action_muc_details
1529                                : R.string.channel_details);
1530                menuCall.setVisible(false);
1531                menuOngoingCall.setVisible(false);
1532                menuArchiveChat.setTitle("Leave " + (conversation.getMucOptions().isPrivateAndNonAnonymous() ? "group chat" : "Channel"));
1533            } else {
1534                final XmppConnectionService service =
1535                        activity == null ? null : activity.xmppConnectionService;
1536                final Optional<OngoingRtpSession> ongoingRtpSession =
1537                        service == null
1538                                ? Optional.absent()
1539                                : service.getJingleConnectionManager()
1540                                        .getOngoingRtpConnection(conversation.getContact());
1541                if (ongoingRtpSession.isPresent()) {
1542                    menuOngoingCall.setVisible(true);
1543                    menuCall.setVisible(false);
1544                } else {
1545                    menuOngoingCall.setVisible(false);
1546                    final RtpCapability.Capability rtpCapability =
1547                            RtpCapability.check(conversation.getContact());
1548                    final boolean cameraAvailable =
1549                            activity != null && activity.isCameraFeatureAvailable();
1550                    menuCall.setVisible(true);
1551                    menuVideoCall.setVisible(rtpCapability != RtpCapability.Capability.AUDIO && cameraAvailable);
1552                }
1553                menuContactDetails.setVisible(!this.conversation.withSelf());
1554                menuMucDetails.setVisible(false);
1555                final var connection = this.conversation.getAccount().getXmppConnection();
1556                menuInviteContact.setVisible(
1557                        !connection.getManager(MultiUserChatManager.class).getServices().isEmpty());
1558            }
1559            if (conversation.isMuted()) {
1560                menuMute.setVisible(false);
1561            } else {
1562                menuUnmute.setVisible(false);
1563            }
1564            ConversationMenuConfigurator.configureAttachmentMenu(conversation, menu, TextUtils.isEmpty(binding.textinput.getText()));
1565            ConversationMenuConfigurator.configureEncryptionMenu(conversation, menu);
1566            if (conversation.getBooleanAttribute(Conversation.ATTRIBUTE_PINNED_ON_TOP, false)) {
1567                menuTogglePinned.setTitle(R.string.remove_from_favorites);
1568            } else {
1569                menuTogglePinned.setTitle(R.string.add_to_favorites);
1570            }
1571        }
1572        super.onCreateOptionsMenu(menu, menuInflater);
1573    }
1574
1575    @Override
1576    public View onCreateView(
1577            final LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
1578        this.binding =
1579                DataBindingUtil.inflate(inflater, R.layout.fragment_conversation, container, false);
1580        binding.getRoot().setOnClickListener(null); // TODO why the fuck did we do this?
1581
1582        binding.textinput.setOnEditorActionListener(mEditorActionListener);
1583        binding.textinput.setRichContentListener(new String[] {"image/*"}, mEditorContentListener);
1584        DisplayMetrics displayMetrics = new DisplayMetrics();
1585        activity.getWindowManager().getDefaultDisplay().getMetrics(displayMetrics);
1586        if (displayMetrics.heightPixels > 0) binding.textinput.setMaxHeight(displayMetrics.heightPixels / 4);
1587
1588        binding.textSendButton.setOnClickListener(this.mSendButtonListener);
1589        binding.contextPreviewCancel.setOnClickListener((v) -> {
1590            setThread(null);
1591            conversation.setUserSelectedThread(false);
1592            setupReply(null);
1593        });
1594        binding.requestVoice.setOnClickListener((v) -> {
1595            activity.xmppConnectionService.requestVoice(conversation.getAccount(), conversation.getJid());
1596            binding.requestVoice.setVisibility(View.GONE);
1597            Toast.makeText(activity, "Your request has been sent to the moderators", Toast.LENGTH_SHORT).show();
1598        });
1599
1600        binding.scrollToBottomButton.setOnClickListener(this.mScrollButtonListener);
1601        binding.messagesView.setOnScrollListener(mOnScrollListener);
1602        binding.messagesView.setTranscriptMode(ListView.TRANSCRIPT_MODE_NORMAL);
1603        mediaPreviewAdapter = new MediaPreviewAdapter(this);
1604        binding.mediaPreview.setAdapter(mediaPreviewAdapter);
1605        messageListAdapter = new MessageAdapter((XmppActivity) getActivity(), this.messageList);
1606        messageListAdapter.setOnContactPictureClicked(this);
1607        messageListAdapter.setOnContactPictureLongClicked(this);
1608        messageListAdapter.setOnInlineImageLongClicked(this);
1609        messageListAdapter.setConversationFragment(this);
1610        // messageListAdapter.setReplyClickListener(this::scrollToReply);       //TODO add a better scrol to reply later
1611        binding.messagesView.setAdapter(messageListAdapter);
1612
1613        binding.textinput.addTextChangedListener(
1614                new StylingHelper.MessageEditorStyler(binding.textinput, messageListAdapter));
1615
1616        registerForContextMenu(binding.messagesView);
1617        registerForContextMenu(binding.textSendButton);
1618
1619        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
1620            this.binding.textinput.setCustomInsertionActionModeCallback(
1621                    new EditMessageActionModeCallback(this.binding.textinput));
1622            this.binding.textinput.setCustomSelectionActionModeCallback(
1623                    new EditMessageSelectionActionModeCallback(this.binding.textinput));
1624        }
1625
1626        messageListAdapter.setOnMessageBoxClicked(message -> {
1627            if (message.isPrivateMessage()) privateMessageWith(message.getCounterpart());
1628            setThread(message.getThread());
1629            conversation.setUserSelectedThread(true);
1630        });
1631
1632        messageListAdapter.setOnMessageBoxSwiped(message -> {
1633            quoteMessage(message);
1634        });
1635
1636        binding.threadIdenticonLayout.setOnClickListener(v -> {
1637            boolean wasLocked = conversation.getLockThread();
1638            conversation.setLockThread(false);
1639            backPressedLeaveSingleThread.setEnabled(false);
1640            if (wasLocked) {
1641                setThread(null);
1642                conversation.setUserSelectedThread(false);
1643                refresh();
1644                updateThreadFromLastMessage();
1645            } else {
1646                newThread();
1647                conversation.setUserSelectedThread(true);
1648                newThreadTutorialToast("Switched to new thread");
1649            }
1650        });
1651
1652        binding.threadIdenticonLayout.setOnLongClickListener(v -> {
1653            boolean wasLocked = conversation.getLockThread();
1654            conversation.setLockThread(false);
1655            backPressedLeaveSingleThread.setEnabled(false);
1656            setThread(null);
1657            conversation.setUserSelectedThread(true);
1658            if (wasLocked) refresh();
1659            newThreadTutorialToast("Cleared thread");
1660            return true;
1661        });
1662
1663        Autocomplete.<MucOptions.User>on(binding.textinput)
1664            .with(activity.getDrawable(R.drawable.background_message_bubble))
1665            .with(new CharPolicy('@'))
1666            .with(new RecyclerViewPresenter<MucOptions.User>(activity) {
1667                protected UserAdapter adapter;
1668
1669                @Override
1670                protected Adapter instantiateAdapter() {
1671                    adapter = new UserAdapter(false) {
1672                        @Override
1673                        public void onBindViewHolder(UserAdapter.ViewHolder viewHolder, int position) {
1674                            super.onBindViewHolder(viewHolder, position);
1675                            final var item = getItem(position);
1676                            viewHolder.binding.getRoot().setOnClickListener(v -> {
1677                                dispatchClick(item);
1678                            });
1679                        }
1680                    };
1681                    return adapter;
1682                }
1683
1684                @Override
1685                protected void onQuery(@Nullable CharSequence query) {
1686                    if (!activity.xmppConnectionService.getBooleanPreference("message_autocomplete", R.bool.message_autocomplete)) return;
1687
1688                    final var allUsers = conversation.getMucOptions().getUsers();
1689                    if (!conversation.getMucOptions().getUsersByRole(Role.MODERATOR).isEmpty()) {
1690                        final var u = new MucOptions.User(conversation.getMucOptions(), null, "\0role:moderator", "Notify active moderators", new HashSet<>());
1691                        u.setRole(Role.PARTICIPANT);
1692                        allUsers.add(u);
1693                    }
1694                    if (!allUsers.isEmpty() && conversation.getMucOptions().getSelf() != null && conversation.getMucOptions().getSelf().ranks(Affiliation.MEMBER)) {
1695                        final var u = new MucOptions.User(conversation.getMucOptions(), null, "\0attention", "Notify active participants", new HashSet<>());
1696                        u.setRole(Role.PARTICIPANT);
1697                        allUsers.add(u);
1698                    }
1699                    final String needle = query.toString().toLowerCase(Locale.getDefault());
1700                    if (getRecyclerView() != null) getRecyclerView().setItemAnimator(null);
1701                    adapter.submitList(
1702                        Ordering.natural().immutableSortedCopy(Collections2.filter(
1703                            allUsers,
1704                            user -> {
1705                                if ("mods".contains(needle) && "\0role:moderator".equals(user.getOccupantId())) return true;
1706                                if ("here".contains(needle) && "\0attention".equals(user.getOccupantId())) return true;
1707                                final String name = user.getNick();
1708                                if (name == null) return false;
1709                                for (final var hat : user.getHats()) {
1710                                    if (hat.toString().toLowerCase(Locale.getDefault()).contains(needle)) return true;
1711                                }
1712                                for (final var hat : user.getPseudoHats(activity)) {
1713                                    if (hat.toString().toLowerCase(Locale.getDefault()).contains(needle)) return true;
1714                                }
1715                                final Contact contact = user.getContact();
1716                                return name.toLowerCase(Locale.getDefault()).contains(needle)
1717                                    || contact != null
1718                                    && contact.getDisplayName().toLowerCase(Locale.getDefault()).contains(needle);
1719                            })));
1720                }
1721
1722                @Override
1723                protected AutocompletePresenter.PopupDimensions getPopupDimensions() {
1724                    final var dim = new AutocompletePresenter.PopupDimensions();
1725                    dim.width = displayMetrics.widthPixels * 4/5;
1726                    return dim;
1727                }
1728            })
1729            .with(new AutocompleteCallback<MucOptions.User>() {
1730                @Override
1731                public boolean onPopupItemClicked(Editable editable, MucOptions.User user) {
1732                    int[] range = com.otaliastudios.autocomplete.CharPolicy.getQueryRange(editable);
1733                    if (range == null) return false;
1734                    range[0] -= 1;
1735                    if ("\0attention".equals(user.getOccupantId())) {
1736                        editable.delete(Math.max(0, range[0]), Math.min(editable.length(), range[1]));
1737                        editable.insert(0, "@here ");
1738                        return true;
1739                    }
1740                    int colon = editable.toString().indexOf(':');
1741                    final var beforeColon = range[0] < colon;
1742                    String prefix = "";
1743                    String suffix = " ";
1744                    if (beforeColon) suffix = ", ";
1745                    if (colon < 0 && range[0] == 0) suffix = ": ";
1746                    if (colon > 0 && colon == range[0] - 2) {
1747                        prefix = ", ";
1748                        suffix = ": ";
1749                        range[0] -= 2;
1750                    }
1751                    var insert = user.getNick();
1752                    if ("\0role:moderator".equals(user.getOccupantId())) {
1753                        insert = conversation.getMucOptions().getUsersByRole(Role.MODERATOR).stream().map(MucOptions.User::getNick).collect(Collectors.joining(", "));
1754                    }
1755                    editable.replace(Math.max(0, range[0]), Math.min(editable.length(), range[1]), prefix + insert + suffix);
1756                    return true;
1757                }
1758
1759                @Override
1760                public void onPopupVisibilityChanged(boolean shown) {}
1761            }).build();
1762
1763        Handler emojiDebounce = new Handler(Looper.getMainLooper());
1764        setupEmojiSearch();
1765        Autocomplete.<EmojiSearch.Emoji>on(binding.textinput)
1766            .with(activity.getDrawable(R.drawable.background_message_bubble))
1767            .with(new CharPolicy(':'))
1768            .with(new RecyclerViewPresenter<EmojiSearch.Emoji>(activity) {
1769                protected EmojiSearch.EmojiSearchAdapter adapter;
1770
1771                @Override
1772                protected Adapter instantiateAdapter() {
1773                    setupEmojiSearch();
1774                    adapter = emojiSearch.makeAdapter(item -> dispatchClick(item));
1775                    return adapter;
1776                }
1777
1778                @Override
1779                protected void onViewHidden() {
1780                    if (getRecyclerView() == null) return;
1781                    super.onViewHidden();
1782                }
1783
1784                @Override
1785                protected void onQuery(@Nullable CharSequence query) {
1786                    if (!activity.xmppConnectionService.getBooleanPreference("message_autocomplete", R.bool.message_autocomplete)) return;
1787
1788                    emojiDebounce.removeCallbacksAndMessages(null);
1789                    emojiDebounce.postDelayed(() -> {
1790                        if (getRecyclerView() == null) return;
1791                        getRecyclerView().setItemAnimator(null);
1792                        adapter.search(activity, getRecyclerView(), query.toString());
1793                    }, 100L);
1794                }
1795            })
1796            .with(new AutocompleteCallback<EmojiSearch.Emoji>() {
1797                @Override
1798                public boolean onPopupItemClicked(Editable editable, EmojiSearch.Emoji emoji) {
1799                    int[] range = com.otaliastudios.autocomplete.CharPolicy.getQueryRange(editable);
1800                    if (range == null) return false;
1801                    range[0] -= 1;
1802                    final var toInsert = emoji.toInsert();
1803                    toInsert.append(" ");
1804                    editable.replace(Math.max(0, range[0]), Math.min(editable.length(), range[1]), toInsert);
1805                    return true;
1806                }
1807
1808                @Override
1809                public void onPopupVisibilityChanged(boolean shown) {}
1810            }).build();
1811
1812        return binding.getRoot();
1813    }
1814
1815    protected void setupEmojiSearch() {
1816        if (activity != null && activity.xmppConnectionService != null) {
1817            if (emojiSearch == null) {
1818                emojiSearch = activity.xmppConnectionService.emojiSearch();
1819            }
1820        }
1821    }
1822
1823    protected void newThreadTutorialToast(String s) {
1824        if (activity == null) return;
1825        final SharedPreferences p = PreferenceManager.getDefaultSharedPreferences(activity);
1826        final int tutorialCount = p.getInt("thread_tutorial", 0);
1827        if (tutorialCount < 5) {
1828            Toast.makeText(activity, s, Toast.LENGTH_SHORT).show();
1829            p.edit().putInt("thread_tutorial", tutorialCount + 1).apply();
1830        }
1831    }
1832
1833    @Override
1834    public void onDestroyView() {
1835        super.onDestroyView();
1836        Log.d(Config.LOGTAG, "ConversationFragment.onDestroyView()");
1837        messageListAdapter.setOnContactPictureClicked(null);
1838        messageListAdapter.setOnContactPictureLongClicked(null);
1839        messageListAdapter.setOnInlineImageLongClicked(null);
1840        messageListAdapter.setConversationFragment(null);
1841        messageListAdapter.setOnMessageBoxClicked(null);
1842        messageListAdapter.setOnMessageBoxSwiped(null);
1843        binding.conversationViewPager.setAdapter(null);
1844        if (conversation != null) conversation.setupViewPager(null, null, false, null);
1845    }
1846
1847    public void quoteText(String text) {
1848        if (binding.textinput.isEnabled()) {
1849            binding.textinput.insertAsQuote(text);
1850            binding.textinput.requestFocus();
1851            InputMethodManager inputMethodManager =
1852                    (InputMethodManager)
1853                            getActivity().getSystemService(Context.INPUT_METHOD_SERVICE);
1854            if (inputMethodManager != null) {
1855                inputMethodManager.showSoftInput(
1856                        binding.textinput, InputMethodManager.SHOW_IMPLICIT);
1857            }
1858        }
1859    }
1860
1861    private void quoteMessage(Message message) {
1862        if (message.isPrivateMessage()) privateMessageWith(message.getCounterpart());
1863        setThread(message.getThread());
1864        conversation.setUserSelectedThread(true);
1865        if (!forkNullThread(message)) newThread();
1866        setupReply(message);
1867    }
1868
1869    private boolean forkNullThread(Message message) {
1870        if (message.getThread() != null || conversation.getMode() != Conversation.MODE_MULTI) return true;
1871        for (final Message m : conversation.findReplies(message.getServerMsgId())) {
1872            final Element thread = m.getThread();
1873            if (thread != null) {
1874                setThread(thread);
1875                return true;
1876            }
1877        }
1878
1879        return false;
1880    }
1881
1882    private void setupReply(Message message) {
1883        if (message != null) {
1884            final var correcting = conversation.getCorrectingMessage();
1885            if (correcting != null && correcting.getUuid().equals(message.getUuid())) return;
1886        }
1887        conversation.setReplyTo(message);
1888        if (message == null) {
1889            binding.contextPreview.setVisibility(View.GONE);
1890            binding.textsend.setBackgroundResource(R.drawable.textsend);
1891            return;
1892        }
1893
1894        var body = message.getSpannableBody(null, null);
1895        if (message.isFileOrImage() || message.isOOb()) body.append(" 🖼️");
1896        messageListAdapter.handleTextQuotes(binding.contextPreviewText, body);
1897        binding.contextPreviewText.setText(body);
1898        binding.contextPreview.setVisibility(View.VISIBLE);
1899    }
1900
1901    private void setThread(Element thread) {
1902        this.conversation.setThread(thread);
1903        binding.threadIdenticon.setAlpha(0f);
1904        binding.threadIdenticonLock.setVisibility(this.conversation.getLockThread() ? View.VISIBLE : View.GONE);
1905        if (thread != null) {
1906            final String threadId = thread.getContent();
1907            if (threadId != null) {
1908                binding.threadIdenticon.setAlpha(1f);
1909                binding.threadIdenticon.setColor(UIHelper.getColorForName(threadId));
1910                binding.threadIdenticon.setHash(UIHelper.identiconHash(threadId));
1911            }
1912        }
1913        updateSendButton();
1914    }
1915
1916
1917    private void scrollToReply(Message message) {
1918        Element reply = message.getReply();
1919        if (reply == null) return;
1920
1921        String replyId = reply.getAttribute("id");
1922
1923        if (replyId != null) {
1924            Runnable postSelectionRunnable = () -> highlightMessage(replyId);
1925            replyJumps.push(message);
1926            updateSelection(replyId, binding.messagesView.getHeight() / 2, postSelectionRunnable, true, false);
1927        }
1928    }
1929
1930    private void highlightMessage(String uuid) {
1931        binding.messagesView.postDelayed(() -> {
1932            int actualIndex = getIndexOfExtended(uuid, messageList);
1933
1934            if (actualIndex == -1) {
1935                return;
1936            }
1937
1938            View view = ListViewUtils.getViewByPosition(actualIndex, binding.messagesView);
1939            View messageBox = view.findViewById(R.id.message_box);
1940            if (messageBox != null) {
1941                messageBox.animate()
1942                        .scaleX(1.14f)
1943                        .scaleY(1.14f)
1944                        .setInterpolator(new CycleInterpolator(0.5f))
1945                        .setDuration(400L)
1946                        .start();
1947            }
1948        }, 300L);
1949    }
1950
1951    private void updateSelection(String uuid, Integer offsetFormTop, Runnable selectionUpdatedRunnable, boolean populateFromMam, boolean recursiveFetch) {
1952        if (recursiveFetch && (fetchHistoryDialog == null || !fetchHistoryDialog.isShowing())) return;
1953
1954        int pos = getIndexOfExtended(uuid, messageList);
1955
1956        Runnable updateSelectionRunnable = () -> {
1957            FragmentConversationBinding binding = ConversationFragment.this.binding;
1958
1959            Runnable performRunnable = () -> {
1960                if (offsetFormTop != null) {
1961                    binding.messagesView.setSelectionFromTop(pos, offsetFormTop);
1962                    return;
1963                }
1964
1965                binding.messagesView.setSelection(pos);
1966            };
1967
1968            performRunnable.run();
1969            binding.messagesView.post(performRunnable);
1970
1971            if (selectionUpdatedRunnable != null) {
1972                selectionUpdatedRunnable.run();
1973            }
1974        };
1975
1976        if (pos != -1) {
1977            hideFetchHistoryDialog();
1978            updateSelectionRunnable.run();
1979        } else {
1980            activity.xmppConnectionService.jumpToMessage(conversation, uuid, new XmppConnectionService.JumpToMessageListener() {
1981                @Override
1982                public void onSuccess() {
1983                    activity.runOnUiThread(() -> {
1984                        refresh(false);
1985                        conversation.messagesLoaded.set(true);
1986                        conversation.historyPartLoadedForward.set(true);
1987                        toggleScrollDownButton();
1988                        updateSelection(uuid, binding.messagesView.getHeight() / 2, selectionUpdatedRunnable, populateFromMam, false);
1989                    });
1990                }
1991
1992                @Override
1993                public void onNotFound() {
1994                    activity.runOnUiThread(() -> {
1995                        if (populateFromMam && conversation.hasMessagesLeftOnServer()) {
1996                            showFetchHistoryDialog();
1997                            loadMoreMessages(true, false, binding.messagesView);
1998                            binding.messagesView.postDelayed(() -> updateSelection(uuid, binding.messagesView.getHeight() / 2, selectionUpdatedRunnable, populateFromMam, true), 500L);
1999                        } else {
2000                            hideFetchHistoryDialog();
2001                        }
2002                    });
2003                }
2004            });
2005        }
2006    }
2007
2008    private void showFetchHistoryDialog() {
2009        if (fetchHistoryDialog != null && fetchHistoryDialog.isShowing()) return;
2010
2011        fetchHistoryDialog = new ProgressDialog(getActivity());
2012        fetchHistoryDialog.setIndeterminate(true);
2013        fetchHistoryDialog.setMessage(getString(R.string.please_wait));
2014        fetchHistoryDialog.setCancelable(true);
2015        fetchHistoryDialog.show();
2016    }
2017
2018    private void hideFetchHistoryDialog() {
2019        if (fetchHistoryDialog != null && fetchHistoryDialog.isShowing()) {
2020            fetchHistoryDialog.hide();
2021        }
2022    }
2023
2024    @Override
2025    public void onCreateContextMenu(ContextMenu menu, View v, ContextMenuInfo menuInfo) {
2026        // This should cancel any remaining click events that would otherwise trigger links
2027        v.dispatchTouchEvent(MotionEvent.obtain(0, 0, MotionEvent.ACTION_CANCEL, 0f, 0f, 0));
2028
2029        if (v == binding.textSendButton) {
2030            super.onCreateContextMenu(menu, v, menuInfo);
2031            try {
2032                java.lang.reflect.Method m = menu.getClass().getSuperclass().getDeclaredMethod("setOptionalIconsVisible", Boolean.TYPE);
2033                m.setAccessible(true);
2034                m.invoke(menu, true);
2035            } catch (Exception e) {
2036                e.printStackTrace();
2037            }
2038            Menu tmpMenu = new PopupMenu(activity, null).getMenu();
2039            activity.getMenuInflater().inflate(R.menu.fragment_conversation, tmpMenu);
2040            MenuItem attachMenu = tmpMenu.findItem(R.id.action_attach_file);
2041            for (int i = 0; i < attachMenu.getSubMenu().size(); i++) {
2042                MenuItem item = attachMenu.getSubMenu().getItem(i);
2043                MenuItem newItem = menu.add(item.getGroupId(), item.getItemId(), item.getOrder(), item.getTitle());
2044                newItem.setIcon(item.getIcon());
2045            }
2046
2047            extensions.clear();
2048            final var xmppConnectionService = activity.xmppConnectionService;
2049            final var dir = new File(xmppConnectionService.getExternalFilesDir(null), "extensions");
2050            for (File file : Files.fileTraverser().breadthFirst(dir)) {
2051                if (file.isFile() && file.canRead()) {
2052                    final var dummy = new Message(conversation, null, conversation.getNextEncryption());
2053                    dummy.setStatus(Message.STATUS_DUMMY);
2054                    dummy.setThread(conversation.getThread());
2055                    dummy.setUuid(file.getName());
2056                    final var xdc = new WebxdcPage(activity, file, dummy);
2057                    extensions.add(xdc);
2058                    final var item = menu.add(0x1, extensions.size() - 1, 0, xdc.getName());
2059                    item.setIcon(xdc.getIcon(24));
2060                }
2061            }
2062            ConversationMenuConfigurator.configureAttachmentMenu(conversation, menu, TextUtils.isEmpty(binding.textinput.getText()));
2063            return;
2064        }
2065
2066        synchronized (this.messageList) {
2067            super.onCreateContextMenu(menu, v, menuInfo);
2068            AdapterView.AdapterContextMenuInfo acmi = (AdapterContextMenuInfo) menuInfo;
2069            this.selectedMessage = this.messageList.get(acmi.position);
2070            populateContextMenu(menu);
2071        }
2072    }
2073
2074    private void populateContextMenu(final ContextMenu menu) {
2075        final Message m = this.selectedMessage;
2076        final Transferable t = m.getTransferable();
2077        if (m.getType() != Message.TYPE_STATUS && m.getType() != Message.TYPE_RTP_SESSION) {
2078
2079            if (m.getEncryption() == Message.ENCRYPTION_AXOLOTL_NOT_FOR_THIS_DEVICE
2080                    || m.getEncryption() == Message.ENCRYPTION_AXOLOTL_FAILED) {
2081                return;
2082            }
2083
2084            if (m.getStatus() == Message.STATUS_RECEIVED
2085                    && t != null
2086                    && (t.getStatus() == Transferable.STATUS_CANCELLED
2087                            || t.getStatus() == Transferable.STATUS_FAILED)) {
2088                return;
2089            }
2090
2091            final boolean deleted = m.isDeleted();
2092            final boolean encrypted =
2093                    m.getEncryption() == Message.ENCRYPTION_DECRYPTION_FAILED
2094                            || m.getEncryption() == Message.ENCRYPTION_PGP;
2095            final boolean receiving =
2096                    m.getStatus() == Message.STATUS_RECEIVED
2097                            && (t instanceof JingleFileTransferConnection
2098                                    || t instanceof HttpDownloadConnection);
2099            activity.getMenuInflater().inflate(R.menu.message_context, menu);
2100            final MenuItem reportAndBlock = menu.findItem(R.id.action_report_and_block);
2101            final MenuItem addReaction = menu.findItem(R.id.action_add_reaction);
2102            final MenuItem openWith = menu.findItem(R.id.open_with);
2103            final MenuItem copyMessage = menu.findItem(R.id.copy_message);
2104            final MenuItem quoteMessage = menu.findItem(R.id.quote_message);
2105            final MenuItem retryDecryption = menu.findItem(R.id.retry_decryption);
2106            final MenuItem correctMessage = menu.findItem(R.id.correct_message);
2107            final MenuItem retractMessage = menu.findItem(R.id.retract_message);
2108            final MenuItem moderateMessage = menu.findItem(R.id.moderate_message);
2109            final MenuItem onlyThisThread = menu.findItem(R.id.only_this_thread);
2110            final MenuItem shareWith = menu.findItem(R.id.share_with);
2111            final MenuItem sendAgain = menu.findItem(R.id.send_again);
2112            final MenuItem retryAsP2P = menu.findItem(R.id.send_again_as_p2p);
2113            final MenuItem copyUrl = menu.findItem(R.id.copy_url);
2114            final MenuItem copyLink = menu.findItem(R.id.copy_link);
2115            final MenuItem saveAsSticker = menu.findItem(R.id.save_as_sticker);
2116            final MenuItem downloadFile = menu.findItem(R.id.download_file);
2117            final MenuItem cancelTransmission = menu.findItem(R.id.cancel_transmission);
2118            final MenuItem blockMedia = menu.findItem(R.id.block_media);
2119            final MenuItem deleteFile = menu.findItem(R.id.delete_file);
2120            final MenuItem showErrorMessage = menu.findItem(R.id.show_error_message);
2121            onlyThisThread.setVisible(!conversation.getLockThread() && m.getThread() != null);
2122            final boolean unInitiatedButKnownSize = MessageUtils.unInitiatedButKnownSize(m);
2123            final boolean showError =
2124                    m.getStatus() == Message.STATUS_SEND_FAILED
2125                            && m.getErrorMessage() != null
2126                            && !Message.ERROR_MESSAGE_CANCELLED.equals(m.getErrorMessage());
2127            final Conversational conversational = m.getConversation();
2128            final var connection = conversational.getAccount().getXmppConnection();
2129            if (m.getStatus() == Message.STATUS_RECEIVED
2130                    && conversational instanceof Conversation c) {
2131                if (c.isWithStranger()
2132                        && m.getServerMsgId() != null
2133                        && !c.isBlocked()
2134                        && connection != null
2135                        && connection.getFeatures().spamReporting()) {
2136                    reportAndBlock.setVisible(true);
2137                }
2138            }
2139            if (conversational instanceof Conversation c) {
2140                addReaction.setVisible(
2141                        m.getStatus() != Message.STATUS_SEND_FAILED
2142                                && !m.isDeleted()
2143                                && !m.isPrivateMessage()
2144                                && (c.getMode() == Conversational.MODE_SINGLE
2145                                        || (c.getMucOptions().occupantId()
2146                                                && c.getMucOptions().participating())));
2147            } else {
2148                addReaction.setVisible(false);
2149            }
2150            if (!m.isFileOrImage()
2151                    && !encrypted
2152                    && !m.isGeoUri()
2153                    && !m.treatAsDownloadable()
2154                    && !unInitiatedButKnownSize
2155                    && t == null) {
2156                copyMessage.setVisible(true);
2157                quoteMessage.setVisible(!showError && !MessageUtils.prepareQuote(m).isEmpty());
2158                final var firstUri = Iterables.getFirst(Linkify.getLinks(m.getBody()), null);
2159                if (firstUri != null) {
2160                    final var scheme = firstUri.getScheme();
2161                    final @StringRes int resForScheme =
2162                            switch (scheme) {
2163                                case "xmpp" -> R.string.copy_jabber_id;
2164                                case "http", "https", "gemini" -> R.string.copy_link;
2165                                case "geo" -> R.string.copy_geo_uri;
2166                                case "tel" -> R.string.copy_telephone_number;
2167                                case "mailto" -> R.string.copy_email_address;
2168                                default -> R.string.copy_URI;
2169                            };
2170                    copyLink.setTitle(resForScheme);
2171                    copyLink.setVisible(true);
2172                } else {
2173                    copyLink.setVisible(false);
2174                }
2175            }
2176            quoteMessage.setVisible(!encrypted && !showError);
2177            if (m.getEncryption() == Message.ENCRYPTION_DECRYPTION_FAILED && !deleted) {
2178                retryDecryption.setVisible(true);
2179            }
2180            if (!showError
2181                    && m.getType() == Message.TYPE_TEXT
2182                    && m.isEditable()
2183                    && !m.isGeoUri()
2184                    && m.getConversation() instanceof Conversation) {
2185                correctMessage.setVisible(true);
2186                if (!m.getBody().equals("") && !m.getBody().equals(" ")) retractMessage.setVisible(true);
2187            }
2188            if (m.getStatus() == Message.STATUS_WAITING) {
2189                correctMessage.setVisible(true);
2190                retractMessage.setVisible(true);
2191            }
2192            if (conversation.getMode() == Conversation.MODE_MULTI && m.getServerMsgId() != null && m.getModerated() == null && conversation.getMucOptions().getSelf().ranks(Role.MODERATOR) && conversation.getMucOptions().hasFeature("urn:xmpp:message-moderate:0")) {
2193                moderateMessage.setVisible(true);
2194            }
2195            if ((m.isFileOrImage() && !deleted && !receiving)
2196                    || (m.getType() == Message.TYPE_TEXT && !m.treatAsDownloadable())
2197                            && !unInitiatedButKnownSize
2198                            && t == null) {
2199                shareWith.setVisible(true);
2200            }
2201            if (m.getStatus() == Message.STATUS_SEND_FAILED) {
2202                sendAgain.setVisible(true);
2203                final var httpUploadAvailable =
2204                        connection != null
2205                                && Objects.nonNull(
2206                                        connection
2207                                                .getManager(HttpUploadManager.class)
2208                                                .getService());
2209                final var fileNotUploaded = m.isFileOrImage() && !m.hasFileOnRemoteHost();
2210                final var isPeerOnline =
2211                        conversational.getMode() == Conversation.MODE_SINGLE
2212                                && (conversational instanceof Conversation c)
2213                                && !c.getContact().getPresences().isEmpty();
2214                retryAsP2P.setVisible(fileNotUploaded && isPeerOnline && httpUploadAvailable);
2215            }
2216            if (m.hasFileOnRemoteHost()
2217                    || m.isGeoUri()
2218                    || m.treatAsDownloadable()
2219                    || unInitiatedButKnownSize
2220                    || t instanceof HttpDownloadConnection) {
2221                copyUrl.setVisible(true);
2222            }
2223            if (m.isFileOrImage() && deleted && m.hasFileOnRemoteHost()) {
2224                downloadFile.setVisible(true);
2225                downloadFile.setTitle(
2226                        activity.getString(
2227                                R.string.download_x_file,
2228                                UIHelper.getFileDescriptionString(activity, m)));
2229            }
2230            final boolean waitingOfferedSending =
2231                    m.getStatus() == Message.STATUS_WAITING
2232                            || m.getStatus() == Message.STATUS_UNSEND
2233                            || m.getStatus() == Message.STATUS_OFFERED;
2234            final boolean cancelable =
2235                    (t != null && !deleted) || waitingOfferedSending && m.needsUploading();
2236            if (cancelable) {
2237                cancelTransmission.setVisible(true);
2238            }
2239            if (m.isFileOrImage() && !deleted && !cancelable) {
2240                final String path = m.getRelativeFilePath();
2241                if (path != null) {
2242                    final var file = new File(path);
2243                    if (file.canRead()) saveAsSticker.setVisible(true);
2244                    blockMedia.setVisible(true);
2245                    if (file.canWrite()) deleteFile.setVisible(true);
2246                    deleteFile.setTitle(
2247                            activity.getString(
2248                                    R.string.delete_x_file,
2249                                    UIHelper.getFileDescriptionString(activity, m)));
2250                }
2251            }
2252
2253            if (m.getFileParams() != null && !m.getFileParams().getThumbnails().isEmpty()) {
2254                // We might be showing a thumbnail worth blocking
2255                blockMedia.setVisible(true);
2256            }
2257            if (showError) {
2258                showErrorMessage.setVisible(true);
2259            }
2260            final String mime = m.isFileOrImage() ? m.getMimeType() : null;
2261            if ((m.isGeoUri() && GeoHelper.openInOsmAnd(getActivity(), m))
2262                    || (mime != null && mime.startsWith("audio/"))) {
2263                openWith.setVisible(true);
2264            }
2265        }
2266    }
2267
2268    @Override
2269    public boolean onContextItemSelected(MenuItem item) {
2270        switch (item.getItemId()) {
2271            case R.id.share_with:
2272                ShareUtil.share(activity, selectedMessage);
2273                return true;
2274            case R.id.correct_message:
2275                correctMessage(selectedMessage);
2276                return true;
2277            case R.id.retract_message:
2278                new AlertDialog.Builder(activity)
2279                    .setTitle(R.string.retract_message)
2280                    .setMessage("Do you really want to retract this message?")
2281                    .setPositiveButton(R.string.yes, (dialog, whichButton) -> {
2282                        final var message = selectedMessage;
2283                        if (message.getStatus() == Message.STATUS_WAITING || message.getStatus() == Message.STATUS_OFFERED) {
2284                            activity.xmppConnectionService.deleteMessage(message);
2285                            return;
2286                        }
2287                        Element reactions = message.getReactionsEl();
2288                        if (reactions != null) {
2289                            final Message previousReaction = conversation.findMessageReactingTo(reactions.getAttribute("id"), null);
2290                            if (previousReaction != null) reactions = previousReaction.getReactionsEl();
2291                            for (Element el : reactions.getChildren()) {
2292                                if (message.getRawBody().endsWith(el.getContent())) {
2293                                    reactions.removeChild(el);
2294                                }
2295                            }
2296                            message.setReactions(reactions);
2297                            if (previousReaction != null) {
2298                                previousReaction.setReactions(reactions);
2299                                activity.xmppConnectionService.updateMessage(previousReaction);
2300                            }
2301                        } else {
2302                            message.setInReplyTo(null);
2303                            message.clearPayloads();
2304                        }
2305                        message.setBody(" ");
2306                        message.setSubject(null);
2307                        message.putEdited(message.getUuid(), message.getServerMsgId());
2308                        message.setServerMsgId(null);
2309                        message.setUuid(UUID.randomUUID().toString());
2310                        sendMessage(message);
2311                    })
2312                    .setNegativeButton(R.string.no, null).show();
2313                return true;
2314            case R.id.moderate_message:
2315                activity.quickEdit("Spam", (reason) -> {
2316                    activity.xmppConnectionService.moderateMessage(conversation.getAccount(), selectedMessage, reason);
2317                    return null;
2318                }, R.string.moderate_reason, false, false, true, true);
2319                return true;
2320            case R.id.copy_message:
2321                ShareUtil.copyToClipboard(activity, selectedMessage);
2322                return true;
2323            case R.id.quote_message:
2324                quoteMessage(selectedMessage);
2325                return true;
2326            case R.id.send_again:
2327                resendMessage(selectedMessage, false);
2328                return true;
2329            case R.id.send_again_as_p2p:
2330                resendMessage(selectedMessage, true);
2331                return true;
2332            case R.id.copy_url:
2333                ShareUtil.copyUrlToClipboard(activity, selectedMessage);
2334                return true;
2335            case R.id.save_as_sticker:
2336                saveAsSticker(selectedMessage);
2337                return true;
2338            case R.id.download_file:
2339                startDownloadable(selectedMessage);
2340                return true;
2341            case R.id.cancel_transmission:
2342                cancelTransmission(selectedMessage);
2343                return true;
2344            case R.id.retry_decryption:
2345                retryDecryption(selectedMessage);
2346                return true;
2347            case R.id.block_media:
2348                new AlertDialog.Builder(activity)
2349                    .setTitle(R.string.block_media)
2350                    .setMessage("Do you really want to block this media in all messages?")
2351                    .setPositiveButton(R.string.yes, (dialog, whichButton) -> {
2352                        List<Element> thumbs = selectedMessage.getFileParams() != null ? selectedMessage.getFileParams().getThumbnails() : null;
2353                        if (thumbs != null && !thumbs.isEmpty()) {
2354                            for (Element thumb : thumbs) {
2355                                Uri uri = Uri.parse(thumb.getAttribute("uri"));
2356                                if (uri.getScheme().equals("cid")) {
2357                                    Cid cid = BobTransfer.cid(uri);
2358                                    if (cid == null) continue;
2359                                    DownloadableFile f = activity.xmppConnectionService.getFileForCid(cid);
2360                                    activity.xmppConnectionService.blockMedia(f);
2361                                    activity.xmppConnectionService.evictPreview(f);
2362                                    f.delete();
2363                                }
2364                            }
2365                        }
2366                        File f = activity.xmppConnectionService.getFileBackend().getFile(selectedMessage);
2367                        activity.xmppConnectionService.blockMedia(f);
2368                        activity.xmppConnectionService.getFileBackend().deleteFile(selectedMessage);
2369                        activity.xmppConnectionService.evictPreview(f);
2370                        activity.xmppConnectionService.updateMessage(selectedMessage, false);
2371                        activity.onConversationsListItemUpdated();
2372                        refresh();
2373                    })
2374                    .setNegativeButton(R.string.no, null).show();
2375                return true;
2376            case R.id.delete_file:
2377                deleteFile(selectedMessage);
2378                return true;
2379            case R.id.show_error_message:
2380                showErrorMessage(selectedMessage);
2381                return true;
2382            case R.id.open_with:
2383                openWith(selectedMessage);
2384                return true;
2385            case R.id.only_this_thread:
2386                conversation.setLockThread(true);
2387                backPressedLeaveSingleThread.setEnabled(true);
2388                setThread(selectedMessage.getThread());
2389                refresh();
2390                return true;
2391            case R.id.action_report_and_block:
2392                reportMessage(selectedMessage);
2393                return true;
2394            case R.id.action_add_reaction:
2395                addReaction(selectedMessage);
2396                return true;
2397            default:
2398                return onOptionsItemSelected(item);
2399        }
2400    }
2401
2402    @Override
2403    public boolean onOptionsItemSelected(final MenuItem item) {
2404        if (MenuDoubleTabUtil.shouldIgnoreTap()) {
2405            return false;
2406        } else if (conversation == null) {
2407            return super.onOptionsItemSelected(item);
2408        }
2409        if (item.getGroupId() == 0x1) {
2410            conversation.startWebxdc(extensions.get(item.getItemId()));
2411            return true;
2412        }
2413        switch (item.getItemId()) {
2414            case R.id.encryption_choice_axolotl:
2415            case R.id.encryption_choice_pgp:
2416            case R.id.encryption_choice_none:
2417                handleEncryptionSelection(item);
2418                return true;
2419            case R.id.attach_choose_picture:
2420            //case R.id.attach_take_picture:
2421            //case R.id.attach_record_video:
2422            case R.id.attach_choose_file:
2423            case R.id.attach_record_voice:
2424            case R.id.attach_location:
2425                handleAttachmentSelection(item);
2426                return true;
2427            case R.id.attach_webxdc:
2428                final Intent intent = new Intent(getActivity(), WebxdcStore.class);
2429                startActivityForResult(intent, REQUEST_WEBXDC_STORE);
2430                return true;
2431            case R.id.attach_subject:
2432                binding.textinputSubject.setVisibility(binding.textinputSubject.getVisibility() == View.GONE ? View.VISIBLE : View.GONE);
2433                return true;
2434            case R.id.attach_schedule:
2435                scheduleMessage();
2436                return true;
2437            case R.id.action_search:
2438                startSearch();
2439                return true;
2440            case R.id.action_archive:
2441                activity.xmppConnectionService.archiveConversation(conversation);
2442                return true;
2443            case R.id.action_contact_details:
2444                activity.switchToContactDetails(conversation.getContact());
2445                return true;
2446            case R.id.action_muc_details:
2447                ConferenceDetailsActivity.open(activity, conversation);
2448                return true;
2449            case R.id.action_invite:
2450                startActivityForResult(
2451                        ChooseContactActivity.create(activity, conversation),
2452                        REQUEST_INVITE_TO_CONVERSATION);
2453                return true;
2454            case R.id.action_clear_history:
2455                clearHistoryDialog(conversation);
2456                return true;
2457            case R.id.action_mute:
2458                muteConversationDialog(conversation);
2459                return true;
2460            case R.id.action_unmute:
2461                unMuteConversation(conversation);
2462                return true;
2463            case R.id.action_block:
2464            case R.id.action_unblock:
2465                BlockContactDialog.show(activity, conversation);
2466                return true;
2467            case R.id.action_audio_call:
2468                checkPermissionAndTriggerAudioCall();
2469                return true;
2470            case R.id.action_video_call:
2471                checkPermissionAndTriggerVideoCall();
2472                return true;
2473            case R.id.action_ongoing_call:
2474                returnToOngoingCall();
2475                return true;
2476            case R.id.action_toggle_pinned:
2477                togglePinned();
2478                return true;
2479            case R.id.action_add_shortcut:
2480                addShortcut();
2481                return true;
2482            case R.id.action_block_avatar:
2483                new AlertDialog.Builder(activity)
2484                    .setTitle(R.string.block_media)
2485                    .setMessage("Do you really want to block this avatar?")
2486                    .setPositiveButton(R.string.yes, (dialog, whichButton) -> {
2487                            activity.xmppConnectionService.blockMedia(activity.xmppConnectionService.getFileBackend().getAvatarFile(conversation.getContact().getAvatar()));
2488                            activity.xmppConnectionService.getFileBackend().getAvatarFile(conversation.getContact().getAvatar()).delete();
2489                            activity.avatarService().clear(conversation);
2490                            conversation.getContact().setAvatar(null);
2491                            activity.xmppConnectionService.updateConversationUi();
2492                    })
2493                    .setNegativeButton(R.string.no, null).show();
2494                return true;
2495            case R.id.action_refresh_feature_discovery:
2496                refreshFeatureDiscovery();
2497                return true;
2498            default:
2499                break;
2500        }
2501        return super.onOptionsItemSelected(item);
2502    }
2503
2504    public boolean onBackPressed() {
2505        boolean wasLocked = conversation.getLockThread();
2506        conversation.setLockThread(false);
2507        backPressedLeaveSingleThread.setEnabled(false);
2508        if (wasLocked) {
2509            setThread(null);
2510            conversation.setUserSelectedThread(false);
2511            refresh();
2512            updateThreadFromLastMessage();
2513            return true;
2514        }
2515        return false;
2516    }
2517
2518    private void startSearch() {
2519        final Intent intent = new Intent(getActivity(), SearchActivity.class);
2520        intent.putExtra(SearchActivity.EXTRA_CONVERSATION_UUID, conversation.getUuid());
2521        startActivity(intent);
2522    }
2523
2524    private void scheduleMessage() {
2525        if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) {
2526            final var datePicker = com.google.android.material.datepicker.MaterialDatePicker.Builder.datePicker()
2527                .setTitleText("Schedule Message")
2528                .setSelection(com.google.android.material.datepicker.MaterialDatePicker.todayInUtcMilliseconds())
2529                .setCalendarConstraints(
2530                    new com.google.android.material.datepicker.CalendarConstraints.Builder()
2531                        .setStart(com.google.android.material.datepicker.MaterialDatePicker.todayInUtcMilliseconds())
2532                        .build()
2533                 )
2534                .build();
2535            datePicker.addOnPositiveButtonClickListener((date) -> {
2536                final Calendar now = Calendar.getInstance();
2537                final var timePicker = new com.google.android.material.timepicker.MaterialTimePicker.Builder()
2538                    .setTitleText("Schedule Message")
2539                    .setHour(now.get(Calendar.HOUR_OF_DAY))
2540                    .setMinute(now.get(Calendar.MINUTE))
2541                    .setTimeFormat(android.text.format.DateFormat.is24HourFormat(activity) ? com.google.android.material.timepicker.TimeFormat.CLOCK_24H : com.google.android.material.timepicker.TimeFormat.CLOCK_12H)
2542                    .build();
2543                timePicker.addOnPositiveButtonClickListener((v2) -> {
2544                        final var dateCal = Calendar.getInstance(TimeZone.getTimeZone("UTC"));
2545                        dateCal.setTimeInMillis(date);
2546                        final var time = Calendar.getInstance();
2547                        time.set(dateCal.get(Calendar.YEAR), dateCal.get(Calendar.MONTH), dateCal.get(Calendar.DAY_OF_MONTH), timePicker.getHour(), timePicker.getMinute(), 0);
2548                        final long timestamp = time.getTimeInMillis();
2549                        sendMessage(timestamp);
2550                        Log.d(Config.LOGTAG, conversation.getAccount().getJid().asBareJid() + ": scheduled message for " + timestamp);
2551                    });
2552                timePicker.show(activity.getSupportFragmentManager(), "schedulMessageTime");
2553            });
2554            datePicker.show(activity.getSupportFragmentManager(), "schedulMessageDate");
2555        }
2556    }
2557
2558    private void returnToOngoingCall() {
2559        final Optional<OngoingRtpSession> ongoingRtpSession =
2560                activity.xmppConnectionService
2561                        .getJingleConnectionManager()
2562                        .getOngoingRtpConnection(conversation.getContact());
2563        if (ongoingRtpSession.isPresent()) {
2564            final OngoingRtpSession id = ongoingRtpSession.get();
2565            final Intent intent = new Intent(activity, RtpSessionActivity.class);
2566            intent.setAction(Intent.ACTION_VIEW);
2567            intent.putExtra(
2568                    RtpSessionActivity.EXTRA_ACCOUNT,
2569                    id.getAccount().getJid().asBareJid().toString());
2570            intent.putExtra(RtpSessionActivity.EXTRA_WITH, id.getWith().toString());
2571            if (id instanceof AbstractJingleConnection) {
2572                intent.putExtra(RtpSessionActivity.EXTRA_SESSION_ID, id.getSessionId());
2573                activity.startActivity(intent);
2574            } else if (id instanceof JingleConnectionManager.RtpSessionProposal proposal) {
2575                if (Media.audioOnly(proposal.media)) {
2576                    intent.putExtra(
2577                            RtpSessionActivity.EXTRA_LAST_ACTION,
2578                            RtpSessionActivity.ACTION_MAKE_VOICE_CALL);
2579                } else {
2580                    intent.putExtra(
2581                            RtpSessionActivity.EXTRA_LAST_ACTION,
2582                            RtpSessionActivity.ACTION_MAKE_VIDEO_CALL);
2583                }
2584                intent.putExtra(RtpSessionActivity.EXTRA_PROPOSED_SESSION_ID, proposal.sessionId);
2585                activity.startActivity(intent);
2586            }
2587        }
2588    }
2589
2590    private void refreshFeatureDiscovery() {
2591        final var connection = conversation.getContact().getAccount().getXmppConnection();
2592        if (connection == null) return;
2593
2594        var jids = conversation.getContact().getPresences().getFullJids();
2595        if (jids.isEmpty()) {
2596            jids = new HashSet<>();
2597            jids.add(conversation.getContact().getJid());
2598        }
2599        for (final var jid : jids) {
2600            Futures.addCallback(
2601                connection.getManager(DiscoManager.class).info(Entity.presence(jid), null, null),
2602                new FutureCallback<>() {
2603                    @Override
2604                    public void onSuccess(InfoQuery disco) {
2605                        if (activity == null) return;
2606                        activity.runOnUiThread(() -> {
2607                            refresh();
2608                            refreshCommands(true);
2609                        });
2610                    }
2611
2612                    @Override
2613                    public void onFailure(@NonNull Throwable throwable) {}
2614                },
2615                MoreExecutors.directExecutor()
2616            );
2617        }
2618    }
2619
2620    private void addShortcut() {
2621        ShortcutInfoCompat info;
2622        if (conversation.getMode() == Conversation.MODE_MULTI) {
2623            info = activity.xmppConnectionService.getShortcutService().getShortcutInfo(conversation.getMucOptions());
2624        } else {
2625            info = activity.xmppConnectionService.getShortcutService().getShortcutInfo(conversation.getContact());
2626        }
2627        ShortcutManagerCompat.requestPinShortcut(activity, info, null);
2628    }
2629
2630    private void togglePinned() {
2631        final boolean pinned =
2632                conversation.getBooleanAttribute(Conversation.ATTRIBUTE_PINNED_ON_TOP, false);
2633        conversation.setAttribute(Conversation.ATTRIBUTE_PINNED_ON_TOP, !pinned);
2634        activity.xmppConnectionService.updateConversation(conversation);
2635        activity.invalidateOptionsMenu();
2636    }
2637
2638    private void checkPermissionAndTriggerAudioCall() {
2639        if (activity.mUseTor || conversation.getAccount().isOnion()) {
2640            Toast.makeText(activity, R.string.disable_tor_to_make_call, Toast.LENGTH_SHORT).show();
2641            return;
2642        }
2643        final List<String> permissions;
2644        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
2645            permissions =
2646                    Arrays.asList(
2647                            Manifest.permission.RECORD_AUDIO,
2648                            Manifest.permission.BLUETOOTH_CONNECT);
2649        } else {
2650            permissions = Collections.singletonList(Manifest.permission.RECORD_AUDIO);
2651        }
2652        if (hasPermissions(REQUEST_START_AUDIO_CALL, permissions)) {
2653            triggerRtpSession(RtpSessionActivity.ACTION_MAKE_VOICE_CALL);
2654        }
2655    }
2656
2657    private void checkPermissionAndTriggerVideoCall() {
2658        if (activity.mUseTor || conversation.getAccount().isOnion()) {
2659            Toast.makeText(activity, R.string.disable_tor_to_make_call, Toast.LENGTH_SHORT).show();
2660            return;
2661        }
2662        final List<String> permissions;
2663        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
2664            permissions =
2665                    Arrays.asList(
2666                            Manifest.permission.RECORD_AUDIO,
2667                            Manifest.permission.CAMERA,
2668                            Manifest.permission.BLUETOOTH_CONNECT);
2669        } else {
2670            permissions =
2671                    Arrays.asList(Manifest.permission.RECORD_AUDIO, Manifest.permission.CAMERA);
2672        }
2673        if (hasPermissions(REQUEST_START_VIDEO_CALL, permissions)) {
2674            triggerRtpSession(RtpSessionActivity.ACTION_MAKE_VIDEO_CALL);
2675        }
2676    }
2677
2678    private void triggerRtpSession(final String action) {
2679        if (activity.xmppConnectionService.getJingleConnectionManager().isBusy()) {
2680            Toast.makeText(getActivity(), R.string.only_one_call_at_a_time, Toast.LENGTH_LONG)
2681                    .show();
2682            return;
2683        }
2684        final Account account = conversation.getAccount();
2685        if (account.setOption(Account.OPTION_SOFT_DISABLED, false)) {
2686            activity.xmppConnectionService.updateAccount(account);
2687        }
2688        final Contact contact = conversation.getContact();
2689        if (Config.USE_JINGLE_MESSAGE_INIT && RtpCapability.jmiSupport(contact)) {
2690            triggerRtpSession(contact.getAccount(), contact.getJid().asBareJid(), action);
2691        } else {
2692            final RtpCapability.Capability capability;
2693            if (action.equals(RtpSessionActivity.ACTION_MAKE_VIDEO_CALL)) {
2694                capability = RtpCapability.Capability.VIDEO;
2695            } else {
2696                capability = RtpCapability.Capability.AUDIO;
2697            }
2698            PresenceSelector.selectFullJidForDirectRtpConnection(
2699                    activity,
2700                    contact,
2701                    capability,
2702                    fullJid -> {
2703                        triggerRtpSession(contact.getAccount(), fullJid, action);
2704                    });
2705        }
2706    }
2707
2708    private void triggerRtpSession(final Account account, final Jid with, final String action) {
2709        CallIntegrationConnectionService.placeCall(
2710                activity.xmppConnectionService,
2711                account,
2712                with,
2713                RtpSessionActivity.actionToMedia(action));
2714    }
2715
2716    private void handleAttachmentSelection(MenuItem item) {
2717        switch (item.getItemId()) {
2718            case R.id.attach_choose_picture:
2719                attachFile(ATTACHMENT_CHOICE_CHOOSE_IMAGE);
2720                break;
2721            /*case R.id.attach_take_picture:
2722                attachFile(ATTACHMENT_CHOICE_TAKE_PHOTO);
2723                break;
2724            case R.id.attach_record_video:
2725                attachFile(ATTACHMENT_CHOICE_RECORD_VIDEO);
2726                break;*/
2727            case R.id.attach_choose_file:
2728                attachFile(ATTACHMENT_CHOICE_CHOOSE_FILE);
2729                break;
2730            case R.id.attach_record_voice:
2731                attachFile(ATTACHMENT_CHOICE_RECORD_VOICE);
2732                break;
2733            case R.id.attach_location:
2734                attachFile(ATTACHMENT_CHOICE_LOCATION);
2735                break;
2736        }
2737    }
2738
2739    private void handleEncryptionSelection(MenuItem item) {
2740        if (conversation == null) {
2741            return;
2742        }
2743        final boolean updated;
2744        switch (item.getItemId()) {
2745            case R.id.encryption_choice_none:
2746                updated = conversation.setNextEncryption(Message.ENCRYPTION_NONE);
2747                item.setChecked(true);
2748                break;
2749            case R.id.encryption_choice_pgp:
2750                if (activity.hasPgp()) {
2751                    if (conversation.getAccount().getPgpSignature() != null) {
2752                        updated = conversation.setNextEncryption(Message.ENCRYPTION_PGP);
2753                        item.setChecked(true);
2754                    } else {
2755                        updated = false;
2756                        activity.announcePgp(
2757                                conversation.getAccount(),
2758                                conversation,
2759                                null,
2760                                activity.onOpenPGPKeyPublished);
2761                    }
2762                } else {
2763                    activity.showInstallPgpDialog();
2764                    updated = false;
2765                }
2766                break;
2767            case R.id.encryption_choice_axolotl:
2768                Log.d(
2769                        Config.LOGTAG,
2770                        AxolotlService.getLogprefix(conversation.getAccount())
2771                                + "Enabled axolotl for Contact "
2772                                + conversation.getContact().getJid());
2773                updated = conversation.setNextEncryption(Message.ENCRYPTION_AXOLOTL);
2774                item.setChecked(true);
2775                break;
2776            default:
2777                updated = conversation.setNextEncryption(Message.ENCRYPTION_NONE);
2778                break;
2779        }
2780        if (updated) {
2781            activity.xmppConnectionService.updateConversation(conversation);
2782        }
2783        updateChatMsgHint();
2784        getActivity().invalidateOptionsMenu();
2785        activity.refreshUi();
2786    }
2787
2788    public void attachFile(final int attachmentChoice) {
2789        attachFile(attachmentChoice, true, false);
2790    }
2791
2792    public void attachFile(final int attachmentChoice, final boolean updateRecentlyUsed) {
2793        attachFile(attachmentChoice, updateRecentlyUsed, false);
2794    }
2795
2796    public void attachFile(final int attachmentChoice, final boolean updateRecentlyUsed, final boolean fromPermissions) {
2797        if (attachmentChoice == ATTACHMENT_CHOICE_RECORD_VOICE) {
2798            if (!hasPermissions(
2799                    attachmentChoice,
2800                    Manifest.permission.WRITE_EXTERNAL_STORAGE,
2801                    Manifest.permission.RECORD_AUDIO)) {
2802                return;
2803            }
2804        } else if (attachmentChoice == ATTACHMENT_CHOICE_TAKE_PHOTO
2805                || attachmentChoice == ATTACHMENT_CHOICE_RECORD_VIDEO
2806                || (attachmentChoice == ATTACHMENT_CHOICE_CHOOSE_IMAGE && !fromPermissions)) {
2807            if (!hasPermissions(
2808                    attachmentChoice,
2809                    Manifest.permission.WRITE_EXTERNAL_STORAGE,
2810                    Manifest.permission.CAMERA)) {
2811                return;
2812            }
2813        } else if (attachmentChoice != ATTACHMENT_CHOICE_LOCATION) {
2814            if (!hasPermissions(attachmentChoice, Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
2815                return;
2816            }
2817        }
2818        if (updateRecentlyUsed) {
2819            storeRecentlyUsedQuickAction(attachmentChoice);
2820        }
2821        final int encryption = conversation.getNextEncryption();
2822        final int mode = conversation.getMode();
2823        if (encryption == Message.ENCRYPTION_PGP) {
2824            if (activity.hasPgp()) {
2825                if (mode == Conversation.MODE_SINGLE
2826                        && conversation.getContact().getPgpKeyId() != 0) {
2827                    activity.xmppConnectionService
2828                            .getPgpEngine()
2829                            .hasKey(
2830                                    conversation.getContact(),
2831                                    new UiCallback<Contact>() {
2832
2833                                        @Override
2834                                        public void userInputRequired(
2835                                                PendingIntent pi, Contact contact) {
2836                                            startPendingIntent(pi, attachmentChoice);
2837                                        }
2838
2839                                        @Override
2840                                        public void success(Contact contact) {
2841                                            invokeAttachFileIntent(attachmentChoice);
2842                                        }
2843
2844                                        @Override
2845                                        public void error(int error, Contact contact) {
2846                                            activity.replaceToast(getString(error));
2847                                        }
2848                                    });
2849                } else if (mode == Conversation.MODE_MULTI
2850                        && conversation.getMucOptions().pgpKeysInUse()) {
2851                    if (!conversation.getMucOptions().everybodyHasKeys()) {
2852                        Toast warning =
2853                                Toast.makeText(
2854                                        getActivity(),
2855                                        R.string.missing_public_keys,
2856                                        Toast.LENGTH_LONG);
2857                        warning.setGravity(Gravity.CENTER_VERTICAL, 0, 0);
2858                        warning.show();
2859                    }
2860                    invokeAttachFileIntent(attachmentChoice);
2861                } else {
2862                    showNoPGPKeyDialog(
2863                            false,
2864                            (dialog, which) -> {
2865                                conversation.setNextEncryption(Message.ENCRYPTION_NONE);
2866                                activity.xmppConnectionService.updateConversation(conversation);
2867                                invokeAttachFileIntent(attachmentChoice);
2868                            });
2869                }
2870            } else {
2871                activity.showInstallPgpDialog();
2872            }
2873        } else {
2874            invokeAttachFileIntent(attachmentChoice);
2875        }
2876    }
2877
2878    private void storeRecentlyUsedQuickAction(final int attachmentChoice) {
2879        try {
2880            activity.getPreferences()
2881                    .edit()
2882                    .putString(
2883                            RECENTLY_USED_QUICK_ACTION,
2884                            SendButtonAction.of(attachmentChoice).toString())
2885                    .apply();
2886        } catch (IllegalArgumentException e) {
2887            // just do not save
2888        }
2889    }
2890
2891    @Override
2892    public void onRequestPermissionsResult(
2893            int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
2894        final PermissionUtils.PermissionResult permissionResult =
2895                PermissionUtils.removeBluetoothConnect(permissions, grantResults);
2896        if (grantResults.length > 0) {
2897            if (allGranted(permissionResult.grantResults) || requestCode == ATTACHMENT_CHOICE_CHOOSE_IMAGE) {
2898                switch (requestCode) {
2899                    case REQUEST_START_DOWNLOAD:
2900                        if (this.mPendingDownloadableMessage != null) {
2901                            startDownloadable(this.mPendingDownloadableMessage);
2902                        }
2903                        break;
2904                    case REQUEST_ADD_EDITOR_CONTENT:
2905                        if (this.mPendingEditorContent != null) {
2906                            attachEditorContentToConversation(this.mPendingEditorContent);
2907                        }
2908                        break;
2909                    case REQUEST_COMMIT_ATTACHMENTS:
2910                        commitAttachments();
2911                        break;
2912                    case REQUEST_START_AUDIO_CALL:
2913                        triggerRtpSession(RtpSessionActivity.ACTION_MAKE_VOICE_CALL);
2914                        break;
2915                    case REQUEST_START_VIDEO_CALL:
2916                        triggerRtpSession(RtpSessionActivity.ACTION_MAKE_VIDEO_CALL);
2917                        break;
2918                    default:
2919                        attachFile(requestCode, true, true);
2920                        break;
2921                }
2922            } else {
2923                @StringRes int res;
2924                String firstDenied =
2925                        getFirstDenied(permissionResult.grantResults, permissionResult.permissions);
2926                if (Manifest.permission.RECORD_AUDIO.equals(firstDenied)) {
2927                    res = R.string.no_microphone_permission;
2928                } else if (Manifest.permission.CAMERA.equals(firstDenied)) {
2929                    res = R.string.no_camera_permission;
2930                } else {
2931                    res = R.string.no_storage_permission;
2932                }
2933                Toast.makeText(
2934                                getActivity(),
2935                                getString(res, getString(R.string.app_name)),
2936                                Toast.LENGTH_SHORT)
2937                        .show();
2938            }
2939        }
2940        if (writeGranted(grantResults, permissions)) {
2941            if (activity != null && activity.xmppConnectionService != null) {
2942                activity.xmppConnectionService.getDrawableCache().evictAll();
2943                activity.xmppConnectionService.restartFileObserver();
2944            }
2945            refresh();
2946        }
2947        if (cameraGranted(grantResults, permissions) || audioGranted(grantResults, permissions)) {
2948            XmppConnectionService.toggleForegroundService(activity);
2949        }
2950    }
2951
2952    public void startDownloadable(Message message) {
2953        if (!hasPermissions(REQUEST_START_DOWNLOAD, Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
2954            this.mPendingDownloadableMessage = message;
2955            return;
2956        }
2957        Transferable transferable = message.getTransferable();
2958        if (transferable != null) {
2959            if (transferable instanceof TransferablePlaceholder && message.hasFileOnRemoteHost()) {
2960                createNewConnection(message);
2961                return;
2962            }
2963            if (!transferable.start()) {
2964                Log.d(Config.LOGTAG, "type: " + transferable.getClass().getName());
2965                Toast.makeText(getActivity(), R.string.not_connected_try_again, Toast.LENGTH_SHORT)
2966                        .show();
2967            }
2968        } else if (message.treatAsDownloadable()
2969                || message.hasFileOnRemoteHost()
2970                || MessageUtils.unInitiatedButKnownSize(message)) {
2971            createNewConnection(message);
2972        } else {
2973            Log.d(
2974                    Config.LOGTAG,
2975                    message.getConversation().getAccount() + ": unable to start downloadable");
2976        }
2977    }
2978
2979    private void createNewConnection(final Message message) {
2980        if (!activity.xmppConnectionService.hasInternetConnection()) {
2981            Toast.makeText(getActivity(), R.string.not_connected_try_again, Toast.LENGTH_SHORT)
2982                    .show();
2983            return;
2984        }
2985        if (message.getOob() != null && "cid".equalsIgnoreCase(message.getOob().getScheme())) {
2986            try {
2987                BobTransfer transfer = new BobTransfer.ForMessage(message, activity.xmppConnectionService);
2988                message.setTransferable(transfer);
2989                transfer.start();
2990            } catch (URISyntaxException e) {
2991                Log.d(Config.LOGTAG, "BobTransfer failed to parse URI");
2992            }
2993        } else {
2994            activity.xmppConnectionService
2995                    .getHttpConnectionManager()
2996                    .createNewDownloadConnection(message, true);
2997        }
2998    }
2999
3000    @SuppressLint("InflateParams")
3001    protected void clearHistoryDialog(final Conversation conversation) {
3002        final MaterialAlertDialogBuilder builder =
3003                new MaterialAlertDialogBuilder(requireActivity());
3004        builder.setTitle(R.string.clear_conversation_history);
3005        final View dialogView =
3006                requireActivity().getLayoutInflater().inflate(R.layout.dialog_clear_history, null);
3007        final CheckBox endConversationCheckBox =
3008                dialogView.findViewById(R.id.end_conversation_checkbox);
3009        builder.setView(dialogView);
3010        builder.setNegativeButton(getString(R.string.cancel), null);
3011        builder.setPositiveButton(
3012                getString(R.string.confirm),
3013                (dialog, which) -> {
3014                    this.activity.xmppConnectionService.clearConversationHistory(conversation);
3015                    if (endConversationCheckBox.isChecked()) {
3016                        this.activity.xmppConnectionService.archiveConversation(conversation);
3017                        this.activity.onConversationArchived(conversation);
3018                    } else {
3019                        activity.onConversationsListItemUpdated();
3020                        refresh();
3021                    }
3022                });
3023        builder.create().show();
3024    }
3025
3026    protected void muteConversationDialog(final Conversation conversation) {
3027        final MaterialAlertDialogBuilder builder =
3028                new MaterialAlertDialogBuilder(requireActivity());
3029        builder.setTitle(R.string.disable_notifications);
3030        final int[] durations = activity.getResources().getIntArray(R.array.mute_options_durations);
3031        final CharSequence[] labels = new CharSequence[durations.length];
3032        for (int i = 0; i < durations.length; ++i) {
3033            if (durations[i] == -1) {
3034                labels[i] = activity.getString(R.string.until_further_notice);
3035            } else {
3036                labels[i] = TimeFrameUtils.resolve(activity, 1000L * durations[i]);
3037            }
3038        }
3039        builder.setItems(
3040                labels,
3041                (dialog, which) -> {
3042                    final long till;
3043                    if (durations[which] == -1) {
3044                        till = Long.MAX_VALUE;
3045                    } else {
3046                        till = System.currentTimeMillis() + (durations[which] * 1000L);
3047                    }
3048                    conversation.setMutedTill(till);
3049                    activity.xmppConnectionService.updateConversation(conversation);
3050                    activity.onConversationsListItemUpdated();
3051                    refresh();
3052                    activity.invalidateOptionsMenu();
3053                });
3054        builder.create().show();
3055    }
3056
3057    private boolean hasPermissions(int requestCode, List<String> permissions) {
3058        final List<String> missingPermissions = new ArrayList<>();
3059        for (String permission : permissions) {
3060            if ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
3061                            || Config.ONLY_INTERNAL_STORAGE)
3062                    && permission.equals(Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
3063                continue;
3064            }
3065            if (activity.checkSelfPermission(permission) != PackageManager.PERMISSION_GRANTED) {
3066                missingPermissions.add(permission);
3067            }
3068        }
3069        if (missingPermissions.size() == 0) {
3070            return true;
3071        } else {
3072            requestPermissions(missingPermissions.toArray(new String[0]), requestCode);
3073            return false;
3074        }
3075    }
3076
3077    private boolean hasPermissions(int requestCode, String... permissions) {
3078        return hasPermissions(requestCode, ImmutableList.copyOf(permissions));
3079    }
3080
3081    public void unMuteConversation(final Conversation conversation) {
3082        conversation.setMutedTill(0);
3083        this.activity.xmppConnectionService.updateConversation(conversation);
3084        this.activity.onConversationsListItemUpdated();
3085        refresh();
3086        this.activity.invalidateOptionsMenu();
3087    }
3088
3089    protected void invokeAttachFileIntent(final int attachmentChoice) {
3090        Intent intent = new Intent();
3091
3092        final var takePhotoIntent = new Intent();
3093        final Uri takePhotoUri = activity.xmppConnectionService.getFileBackend().getTakePhotoUri();
3094        pendingTakePhotoUri.push(takePhotoUri);
3095        takePhotoIntent.putExtra(MediaStore.EXTRA_OUTPUT, takePhotoUri);
3096        takePhotoIntent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
3097        takePhotoIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
3098        takePhotoIntent.setAction(MediaStore.ACTION_IMAGE_CAPTURE);
3099
3100        final var takeVideoIntent = new Intent();
3101        takeVideoIntent.setAction(MediaStore.ACTION_VIDEO_CAPTURE);
3102
3103        switch (attachmentChoice) {
3104            case ATTACHMENT_CHOICE_CHOOSE_IMAGE:
3105                intent.setAction(Intent.ACTION_GET_CONTENT);
3106                intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true);
3107                intent.setType("*/*");
3108                intent.putExtra(Intent.EXTRA_MIME_TYPES, new String[] {"image/*", "video/*"});
3109                intent = Intent.createChooser(intent, getString(R.string.perform_action_with));
3110                if (activity.checkSelfPermission(Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
3111                    intent.putExtra(Intent.EXTRA_INITIAL_INTENTS, new Intent[] { takePhotoIntent, takeVideoIntent });
3112                }
3113                break;
3114            case ATTACHMENT_CHOICE_RECORD_VIDEO:
3115                intent = takeVideoIntent;
3116                break;
3117            case ATTACHMENT_CHOICE_TAKE_PHOTO:
3118                intent = takePhotoIntent;
3119                break;
3120            case ATTACHMENT_CHOICE_CHOOSE_FILE:
3121                intent.setAction(Intent.ACTION_GET_CONTENT);
3122                intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true);
3123                intent.setType("*/*");
3124                intent.addCategory(Intent.CATEGORY_OPENABLE);
3125                intent = Intent.createChooser(intent, getString(R.string.perform_action_with));
3126                break;
3127            case ATTACHMENT_CHOICE_RECORD_VOICE:
3128                intent = new Intent(getActivity(), RecordingActivity.class);
3129                break;
3130            case ATTACHMENT_CHOICE_LOCATION:
3131                intent = GeoHelper.getFetchIntent(activity);
3132                break;
3133        }
3134        final Context context = getActivity();
3135        if (context == null) {
3136            return;
3137        }
3138        try {
3139            startActivityForResult(intent, attachmentChoice);
3140        } catch (final ActivityNotFoundException e) {
3141            Toast.makeText(context, R.string.no_application_found, Toast.LENGTH_LONG).show();
3142        }
3143    }
3144
3145    @Override
3146    public void onResume() {
3147        super.onResume();
3148        binding.messagesView.post(this::fireReadEvent);
3149    }
3150
3151    private void fireReadEvent() {
3152        if (activity != null && this.conversation != null) {
3153            String uuid = getLastVisibleMessageUuid();
3154            if (uuid != null) {
3155                activity.onConversationRead(this.conversation, uuid);
3156            }
3157        }
3158    }
3159
3160    private void newSubThread() {
3161        Element oldThread = conversation.getThread();
3162        Element thread = new Element("thread", "jabber:client");
3163        thread.setContent(UUID.randomUUID().toString());
3164        if (oldThread != null) thread.setAttribute("parent", oldThread.getContent());
3165        setThread(thread);
3166    }
3167
3168    private void newThread() {
3169        Element thread = new Element("thread", "jabber:client");
3170        thread.setContent(UUID.randomUUID().toString());
3171        setThread(thread);
3172    }
3173
3174    private void updateThreadFromLastMessage() {
3175        if (this.conversation != null && !this.conversation.getUserSelectedThread() && TextUtils.isEmpty(binding.textinput.getText())) {
3176            Message message = getLastVisibleMessage();
3177            if (message == null) {
3178                newThread();
3179            } else {
3180                if (conversation.getMode() == Conversation.MODE_MULTI) {
3181                    if (activity == null || activity.xmppConnectionService == null) return;
3182                    if (message.getStatus() < Message.STATUS_SEND) {
3183                        if (!activity.xmppConnectionService.getBooleanPreference("follow_thread_in_channel", R.bool.follow_thread_in_channel)) return;
3184                    }
3185                }
3186
3187                setThread(message.getThread());
3188            }
3189        }
3190    }
3191
3192    private String getLastVisibleMessageUuid() {
3193        Message message =  getLastVisibleMessage();
3194        return message == null ? null : message.getUuid();
3195    }
3196
3197    private Message getLastVisibleMessage() {
3198        if (binding == null) {
3199            return null;
3200        }
3201        synchronized (this.messageList) {
3202            int pos = binding.messagesView.getLastVisiblePosition();
3203            if (pos >= 0) {
3204                Message message = null;
3205                for (int i = pos; i >= 0; --i) {
3206                    try {
3207                        message = (Message) binding.messagesView.getItemAtPosition(i);
3208                    } catch (IndexOutOfBoundsException e) {
3209                        // should not happen if we synchronize properly. however if that fails we
3210                        // just gonna try item -1
3211                        continue;
3212                    }
3213                    if (message.getType() != Message.TYPE_STATUS) {
3214                        break;
3215                    }
3216                }
3217                if (message != null) {
3218                    return message;
3219                }
3220            }
3221        }
3222        return null;
3223    }
3224
3225    public void jumpTo(final Message message) {
3226        if (message.getUuid() == null) return;
3227        for (int i = 0; i < messageList.size(); i++) {
3228            final var m = messageList.get(i);
3229            if (m == null) continue;
3230            if (message.getUuid().equals(m.getUuid())) {
3231                binding.messagesView.setSelection(i);
3232                return;
3233            }
3234        }
3235    }
3236
3237    private void openWith(final Message message) {
3238        if (message.isGeoUri()) {
3239            GeoHelper.view(getActivity(), message);
3240        } else {
3241            final DownloadableFile file =
3242                    activity.xmppConnectionService.getFileBackend().getFile(message);
3243            final var fp = message.getFileParams();
3244            final var name = fp == null ? null : fp.getName();
3245            final var displayName = name == null ? file.getName() : name;
3246            ViewUtil.view(activity, file, displayName);
3247        }
3248    }
3249
3250    public void addReaction(final Message message) {
3251        activity.addReaction(emoji -> {
3252            setupReply(message);
3253            binding.textinput.setText(emoji.toInsert());
3254            sendMessage();
3255        });
3256    }
3257
3258    private void reportMessage(final Message message) {
3259        BlockContactDialog.show(activity, conversation.getContact(), message.getServerMsgId());
3260    }
3261
3262    private void showErrorMessage(final Message message) {
3263        final MaterialAlertDialogBuilder builder =
3264                new MaterialAlertDialogBuilder(requireActivity());
3265        builder.setTitle(R.string.error_message);
3266        final String errorMessage = message.getErrorMessage();
3267        final String[] errorMessageParts =
3268                errorMessage == null ? new String[0] : errorMessage.split("\\u001f");
3269        final String displayError;
3270        if (errorMessageParts.length == 2) {
3271            displayError = errorMessageParts[1];
3272        } else {
3273            displayError = errorMessage;
3274        }
3275        builder.setMessage(displayError);
3276        builder.setNegativeButton(
3277                R.string.copy_to_clipboard,
3278                (dialog, which) -> {
3279                    if (activity.copyTextToClipboard(displayError, R.string.error_message)
3280                            && Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
3281                        Toast.makeText(
3282                                        activity,
3283                                        R.string.error_message_copied_to_clipboard,
3284                                        Toast.LENGTH_SHORT)
3285                                .show();
3286                    }
3287                });
3288        builder.setPositiveButton(R.string.confirm, null);
3289        builder.create().show();
3290    }
3291
3292    public boolean onInlineImageLongClicked(Cid cid) {
3293        DownloadableFile f = activity.xmppConnectionService.getFileForCid(cid);
3294        if (f == null) return false;
3295
3296        saveAsSticker(f, null);
3297        return true;
3298    }
3299
3300    private void saveAsSticker(final Message m) {
3301        String existingName = m.getFileParams() != null && m.getFileParams().getName() != null ? m.getFileParams().getName() : "";
3302        existingName = existingName.lastIndexOf(".") == -1 ? existingName : existingName.substring(0, existingName.lastIndexOf("."));
3303        saveAsSticker(activity.xmppConnectionService.getFileBackend().getFile(m), existingName);
3304    }
3305
3306    private void saveAsSticker(final File file, final String name) {
3307        savingAsSticker = file;
3308
3309        Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT);
3310        intent.addCategory(Intent.CATEGORY_OPENABLE);
3311        intent.setType(MimeUtils.guessMimeTypeFromUri(activity, activity.xmppConnectionService.getFileBackend().getUriForFile(activity, file, file.getName())));
3312        intent.putExtra(Intent.EXTRA_TITLE, name);
3313
3314        SharedPreferences p = PreferenceManager.getDefaultSharedPreferences(activity);
3315        final String dir = p.getString("sticker_directory", "Stickers");
3316        if (dir.startsWith("content://")) {
3317            intent.putExtra("android.provider.extra.INITIAL_URI", Uri.parse(dir));
3318        } else {
3319            new File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES) + "/" + dir + "/User Pack").mkdirs();
3320            Uri uri;
3321            if (Build.VERSION.SDK_INT >= 29) {
3322                Intent tmp = ((StorageManager) activity.getSystemService(Context.STORAGE_SERVICE)).getPrimaryStorageVolume().createOpenDocumentTreeIntent();
3323                uri = tmp.getParcelableExtra("android.provider.extra.INITIAL_URI");
3324                uri = Uri.parse(uri.toString().replace("/root/", "/document/") + "%3APictures%2F" + dir);
3325            } else {
3326                uri = Uri.parse("content://com.android.externalstorage.documents/document/primary%3APictures%2F" + dir);
3327            }
3328            intent.putExtra("android.provider.extra.INITIAL_URI", uri);
3329            intent.putExtra("android.content.extra.SHOW_ADVANCED", true);
3330        }
3331
3332        Toast.makeText(activity, "Choose a sticker pack to add this sticker to", Toast.LENGTH_SHORT).show();
3333        startActivityForResult(Intent.createChooser(intent, "Choose sticker pack"), REQUEST_SAVE_STICKER);
3334    }
3335
3336    private void deleteFile(final Message message) {
3337        final MaterialAlertDialogBuilder builder =
3338                new MaterialAlertDialogBuilder(requireActivity());
3339        builder.setNegativeButton(R.string.cancel, null);
3340        builder.setTitle(R.string.delete_file_dialog);
3341        builder.setMessage(R.string.delete_file_dialog_msg);
3342        builder.setPositiveButton(
3343                R.string.confirm,
3344                (dialog, which) -> {
3345                    List<Element> thumbs = selectedMessage.getFileParams() != null ? selectedMessage.getFileParams().getThumbnails() : null;
3346                    if (thumbs != null && !thumbs.isEmpty()) {
3347                        for (Element thumb : thumbs) {
3348                            Uri uri = Uri.parse(thumb.getAttribute("uri"));
3349                            if (uri.getScheme().equals("cid")) {
3350                                Cid cid = BobTransfer.cid(uri);
3351                                if (cid == null) continue;
3352                                DownloadableFile f = activity.xmppConnectionService.getFileForCid(cid);
3353                                activity.xmppConnectionService.evictPreview(f);
3354                                f.delete();
3355                            }
3356                        }
3357                    }
3358                    if (activity.xmppConnectionService.getFileBackend().deleteFile(message)) {
3359                        activity.xmppConnectionService.evictPreview(activity.xmppConnectionService.getFileBackend().getFile(message));
3360                        activity.xmppConnectionService.updateMessage(message, false);
3361                        activity.onConversationsListItemUpdated();
3362                        refresh();
3363                    }
3364                });
3365        builder.create().show();
3366    }
3367
3368    private void resendMessage(final Message message, final boolean forceP2P) {
3369        if (message.isFileOrImage()) {
3370            if (!(message.getConversation() instanceof Conversation conversation)) {
3371                return;
3372            }
3373            final DownloadableFile file =
3374                    activity.xmppConnectionService.getFileBackend().getFile(message);
3375            if ((file.exists() && file.canRead()) || message.hasFileOnRemoteHost()) {
3376                final XmppConnection xmppConnection = conversation.getAccount().getXmppConnection();
3377                if (!message.hasFileOnRemoteHost()
3378                        && xmppConnection != null
3379                        && conversation.getMode() == Conversational.MODE_SINGLE
3380                        && (!xmppConnection
3381                                        .getManager(HttpUploadManager.class)
3382                                        .isAvailableForSize(message.getFileParams().getSize())
3383                                || forceP2P)) {
3384                    activity.selectPresence(
3385                            conversation,
3386                            () -> {
3387                                message.setCounterpart(conversation.getNextCounterpart());
3388                                activity.xmppConnectionService.resendFailedMessages(
3389                                        message, forceP2P);
3390                                new Handler()
3391                                        .post(
3392                                                () -> {
3393                                                    int size = messageList.size();
3394                                                    this.binding.messagesView.setSelection(
3395                                                            size - 1);
3396                                                });
3397                            });
3398                    return;
3399                }
3400            } else if (!Compatibility.hasStoragePermission(getActivity())) {
3401                Toast.makeText(activity, R.string.no_storage_permission, Toast.LENGTH_SHORT).show();
3402                return;
3403            } else {
3404                Toast.makeText(activity, R.string.file_deleted, Toast.LENGTH_SHORT).show();
3405                message.setDeleted(true);
3406                activity.xmppConnectionService.updateMessage(message, false);
3407                activity.onConversationsListItemUpdated();
3408                refresh();
3409                return;
3410            }
3411        }
3412        activity.xmppConnectionService.resendFailedMessages(message, false);
3413        new Handler()
3414                .post(
3415                        () -> {
3416                            int size = messageList.size();
3417                            this.binding.messagesView.setSelection(size - 1);
3418                        });
3419    }
3420
3421    private void cancelTransmission(Message message) {
3422        Transferable transferable = message.getTransferable();
3423        if (transferable != null) {
3424            transferable.cancel();
3425        } else if (message.getStatus() != Message.STATUS_RECEIVED) {
3426            activity.xmppConnectionService.markMessage(
3427                    message, Message.STATUS_SEND_FAILED, Message.ERROR_MESSAGE_CANCELLED);
3428        }
3429    }
3430
3431    private void retryDecryption(Message message) {
3432        message.setEncryption(Message.ENCRYPTION_PGP);
3433        activity.onConversationsListItemUpdated();
3434        refresh();
3435        conversation.getAccount().getPgpDecryptionService().decrypt(message, false);
3436    }
3437
3438    public void privateMessageWith(final Jid counterpart) {
3439        if (conversation.setOutgoingChatState(Config.DEFAULT_CHAT_STATE)) {
3440            activity.xmppConnectionService.sendChatState(conversation);
3441        }
3442        this.binding.textinput.setText("");
3443        this.conversation.setNextCounterpart(counterpart);
3444        updateChatMsgHint();
3445        updateSendButton();
3446        updateEditablity();
3447    }
3448
3449    private void correctMessage(final Message message) {
3450        setThread(message.getThread());
3451        conversation.setUserSelectedThread(true);
3452        this.conversation.setCorrectingMessage(message);
3453        final Editable editable = binding.textinput.getText();
3454        this.conversation.setDraftMessage(editable.toString());
3455        this.binding.textinput.setText("");
3456        this.binding.textinput.append(message.getBody(true));
3457        if (message.getSubject() != null && message.getSubject().length() > 0) {
3458            this.binding.textinputSubject.setText(message.getSubject());
3459            this.binding.textinputSubject.setVisibility(View.VISIBLE);
3460        }
3461        setupReply(message.getInReplyTo());
3462    }
3463
3464    private void highlightInConference(String nick) {
3465        final Editable editable = this.binding.textinput.getText();
3466        String oldString = editable.toString().trim();
3467        final int pos = this.binding.textinput.getSelectionStart();
3468        if (oldString.isEmpty() || pos == 0) {
3469            editable.insert(0, nick + ": ");
3470        } else {
3471            final char before = editable.charAt(pos - 1);
3472            final char after = editable.length() > pos ? editable.charAt(pos) : '\0';
3473            if (before == '\n') {
3474                editable.insert(pos, nick + ": ");
3475            } else {
3476                if (pos > 2 && editable.subSequence(pos - 2, pos).toString().equals(": ")) {
3477                    if (NickValidityChecker.check(
3478                            conversation,
3479                            Arrays.asList(
3480                                    editable.subSequence(0, pos - 2).toString().split(", ")))) {
3481                        editable.insert(pos - 2, ", " + nick);
3482                        return;
3483                    }
3484                }
3485                editable.insert(
3486                        pos,
3487                        (Character.isWhitespace(before) ? "" : " ")
3488                                + nick
3489                                + (Character.isWhitespace(after) ? "" : " "));
3490                if (Character.isWhitespace(after)) {
3491                    this.binding.textinput.setSelection(
3492                            this.binding.textinput.getSelectionStart() + 1);
3493                }
3494            }
3495        }
3496    }
3497
3498    @Override
3499    public void startActivityForResult(Intent intent, int requestCode) {
3500        final Activity activity = getActivity();
3501        if (activity instanceof ConversationsActivity) {
3502            ((ConversationsActivity) activity).clearPendingViewIntent();
3503        }
3504        super.startActivityForResult(intent, requestCode);
3505    }
3506
3507    @Override
3508    public void onSaveInstanceState(@NonNull Bundle outState) {
3509        super.onSaveInstanceState(outState);
3510        if (conversation != null) {
3511            outState.putString(STATE_CONVERSATION_UUID, conversation.getUuid());
3512            outState.putString(STATE_LAST_MESSAGE_UUID, lastMessageUuid);
3513            final Uri uri = pendingTakePhotoUri.peek();
3514            if (uri != null) {
3515                outState.putString(STATE_PHOTO_URI, uri.toString());
3516            }
3517            final ScrollState scrollState = getScrollPosition();
3518            if (scrollState != null) {
3519                outState.putParcelable(STATE_SCROLL_POSITION, scrollState);
3520            }
3521            final ArrayList<Attachment> attachments =
3522                    mediaPreviewAdapter == null
3523                            ? new ArrayList<>()
3524                            : mediaPreviewAdapter.getAttachments();
3525            if (attachments.size() > 0) {
3526                outState.putParcelableArrayList(STATE_MEDIA_PREVIEWS, attachments);
3527            }
3528        }
3529    }
3530
3531    @Override
3532    public void onActivityCreated(Bundle savedInstanceState) {
3533        super.onActivityCreated(savedInstanceState);
3534        if (savedInstanceState == null) {
3535            return;
3536        }
3537        String uuid = savedInstanceState.getString(STATE_CONVERSATION_UUID);
3538        ArrayList<Attachment> attachments =
3539                savedInstanceState.getParcelableArrayList(STATE_MEDIA_PREVIEWS);
3540        pendingLastMessageUuid.push(savedInstanceState.getString(STATE_LAST_MESSAGE_UUID, null));
3541        if (uuid != null) {
3542            QuickLoader.set(uuid);
3543            this.pendingConversationsUuid.push(uuid);
3544            if (attachments != null && attachments.size() > 0) {
3545                this.pendingMediaPreviews.push(attachments);
3546            }
3547            String takePhotoUri = savedInstanceState.getString(STATE_PHOTO_URI);
3548            if (takePhotoUri != null) {
3549                pendingTakePhotoUri.push(Uri.parse(takePhotoUri));
3550            }
3551            pendingScrollState.push(savedInstanceState.getParcelable(STATE_SCROLL_POSITION));
3552        }
3553    }
3554
3555    @Override
3556    public void onStart() {
3557        super.onStart();
3558        if (this.reInitRequiredOnStart && this.conversation != null) {
3559            final Bundle extras = pendingExtras.pop();
3560            reInit(this.conversation, extras != null, extras != null && extras.getString(ConversationsActivity.EXTRA_MESSAGE_UUID) != null);
3561            if (extras != null) {
3562                processExtras(extras);
3563            }
3564        } else if (conversation == null
3565                && activity != null
3566                && activity.xmppConnectionService != null) {
3567            final String uuid = pendingConversationsUuid.pop();
3568            Log.d(
3569                    Config.LOGTAG,
3570                    "ConversationFragment.onStart() - activity was bound but no conversation"
3571                            + " loaded. uuid="
3572                            + uuid);
3573            if (uuid != null) {
3574                findAndReInitByUuidOrArchive(uuid);
3575            }
3576        }
3577    }
3578
3579    @Override
3580    public void onStop() {
3581        super.onStop();
3582        final Activity activity = getActivity();
3583        messageListAdapter.unregisterListenerInAudioPlayer();
3584        if (activity == null || !activity.isChangingConfigurations()) {
3585            hideSoftKeyboard(activity);
3586            messageListAdapter.stopAudioPlayer();
3587        }
3588        if (this.conversation != null) {
3589            final String msg = this.binding.textinput.getText().toString();
3590            storeNextMessage(msg);
3591            updateChatState(this.conversation, msg);
3592            this.activity.xmppConnectionService.getNotificationService().setOpenConversation(null);
3593        }
3594        this.reInitRequiredOnStart = true;
3595    }
3596
3597    private void updateChatState(final Conversation conversation, final String msg) {
3598        ChatState state = msg.length() == 0 ? Config.DEFAULT_CHAT_STATE : ChatState.PAUSED;
3599        Account.State status = conversation.getAccount().getStatus();
3600        if (status == Account.State.ONLINE && conversation.setOutgoingChatState(state)) {
3601            activity.xmppConnectionService.sendChatState(conversation);
3602        }
3603    }
3604
3605    private void saveMessageDraftStopAudioPlayer() {
3606        final Conversation previousConversation = this.conversation;
3607        if (this.activity == null || this.binding == null || previousConversation == null) {
3608            return;
3609        }
3610        Log.d(Config.LOGTAG, "ConversationFragment.saveMessageDraftStopAudioPlayer()");
3611        final String msg = this.binding.textinput.getText().toString();
3612        storeNextMessage(msg);
3613        updateChatState(this.conversation, msg);
3614        messageListAdapter.stopAudioPlayer();
3615        mediaPreviewAdapter.clearPreviews();
3616        toggleInputMethod();
3617    }
3618
3619    public void reInit(final Conversation conversation, final Bundle extras) {
3620        QuickLoader.set(conversation.getUuid());
3621        final boolean changedConversation = this.conversation != conversation;
3622        if (changedConversation) {
3623            this.saveMessageDraftStopAudioPlayer();
3624        }
3625        this.clearPending();
3626        if (this.reInit(conversation, extras != null, extras != null && extras.getString(ConversationsActivity.EXTRA_MESSAGE_UUID) != null)) {
3627            if (extras != null) {
3628                processExtras(extras);
3629            }
3630            this.reInitRequiredOnStart = false;
3631        } else {
3632            this.reInitRequiredOnStart = true;
3633            pendingExtras.push(extras);
3634        }
3635        resetUnreadMessagesCount();
3636    }
3637
3638    private void reInit(Conversation conversation) {
3639        reInit(conversation, false, false);
3640        if (activity != null) {
3641            activity.getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_PAN);
3642        }
3643    }
3644
3645    private boolean reInit(final Conversation conversation, final boolean hasExtras, final boolean hasMessageUUID) {
3646        if (conversation == null) {
3647            return false;
3648        }
3649        final Conversation originalConversation = this.conversation;
3650        this.conversation = conversation;
3651        // once we set the conversation all is good and it will automatically do the right thing in
3652        // onStart()
3653        if (this.activity == null || this.binding == null) {
3654            return false;
3655        }
3656
3657        if (!activity.xmppConnectionService.isConversationStillOpen(this.conversation)) {
3658            activity.onConversationArchived(this.conversation);
3659            return false;
3660        }
3661
3662        final var cursord = activity.getDrawable(R.drawable.cursor_on_tertiary_container);
3663        if (activity.xmppConnectionService != null && activity.xmppConnectionService.getAccounts().size() > 1) {
3664            final var bg = MaterialColors.getColor(binding.textinput, com.google.android.material.R.attr.colorSurface);
3665            final var accountColor = conversation.getAccount().getColor(activity.isDark());
3666            final var colors = MaterialColors.getColorRoles(activity, accountColor);
3667            final var accent = activity.isDark() ? ColorUtils.blendARGB(colors.getAccentContainer(), bg, 1.0f - Math.max(0.25f, Color.alpha(accountColor) / 255.0f)) : colors.getAccentContainer();
3668            cursord.setTintList(ColorStateList.valueOf(colors.getOnAccentContainer()));
3669            binding.inputLayout.setBackgroundTintList(ColorStateList.valueOf(accent));
3670            binding.textinputSubject.setTextColor(colors.getOnAccentContainer());
3671            binding.textinput.setTextColor(colors.getOnAccentContainer());
3672            binding.textinputSubject.setHintTextColor(ColorStateList.valueOf(colors.getOnAccentContainer()).withAlpha(115));
3673            binding.textinput.setHintTextColor(ColorStateList.valueOf(colors.getOnAccentContainer()).withAlpha(115));
3674            binding.textInputHint.setTextColor(colors.getOnAccentContainer());
3675        } else {
3676            cursord.setTintList(ColorStateList.valueOf(MaterialColors.getColor(binding.textinput, com.google.android.material.R.attr.colorOnTertiaryContainer)));
3677            binding.inputLayout.setBackgroundTintList(ColorStateList.valueOf(MaterialColors.getColor(binding.inputLayout, com.google.android.material.R.attr.colorTertiaryContainer)));
3678            binding.textinputSubject.setTextColor(MaterialColors.getColor(binding.textinputSubject, com.google.android.material.R.attr.colorOnTertiaryContainer));
3679            binding.textinput.setTextColor(MaterialColors.getColor(binding.textinput, com.google.android.material.R.attr.colorOnTertiaryContainer));
3680            binding.textinputSubject.setHintTextColor(R.color.hint_on_tertiary_container);
3681            binding.textinput.setHintTextColor(R.color.hint_on_tertiary_container);
3682            binding.textInputHint.setTextColor(MaterialColors.getColor(binding.textInputHint, com.google.android.material.R.attr.colorOnTertiaryContainer));
3683        }
3684        if (Build.VERSION.SDK_INT >= 29) {
3685            binding.textinputSubject.setTextCursorDrawable(cursord);
3686            binding.textinput.setTextCursorDrawable(cursord);
3687        }
3688
3689        setThread(conversation.getThread());
3690        setupReply(conversation.getReplyTo());
3691
3692        stopScrolling();
3693        Log.d(Config.LOGTAG, "reInit(hasExtras=" + hasExtras + ")");
3694
3695        if (this.conversation.isRead(activity == null ? null : activity.xmppConnectionService) && hasExtras) {
3696            Log.d(Config.LOGTAG, "trimming conversation");
3697            this.conversation.trim();
3698        }
3699
3700        setupIme();
3701
3702        final boolean scrolledToBottomAndNoPending =
3703                this.scrolledToBottom() && pendingScrollState.peek() == null;
3704
3705        this.binding.textSendButton.setContentDescription(
3706                activity.getString(R.string.send_message_to_x, conversation.getName()));
3707        this.binding.textinput.setKeyboardListener(null);
3708        this.binding.textinputSubject.setKeyboardListener(null);
3709        final boolean participating =
3710                conversation.getMode() == Conversational.MODE_SINGLE
3711                        || conversation.getMucOptions().participating();
3712        if (participating) {
3713            this.binding.textinput.setText(this.conversation.getNextMessage());
3714            this.binding.textinput.setSelection(this.binding.textinput.length());
3715        } else {
3716            this.binding.textinput.setText(MessageUtils.EMPTY_STRING);
3717        }
3718        this.binding.textinput.setKeyboardListener(this);
3719        this.binding.textinputSubject.setKeyboardListener(this);
3720        messageListAdapter.updatePreferences();
3721        refresh(false);
3722        activity.invalidateOptionsMenu();
3723        this.conversation.messagesLoaded.set(true);
3724        Log.d(Config.LOGTAG, "scrolledToBottomAndNoPending=" + scrolledToBottomAndNoPending);
3725
3726        if (!hasMessageUUID && (hasExtras || scrolledToBottomAndNoPending)) {
3727            resetUnreadMessagesCount();
3728            synchronized (this.messageList) {
3729                Log.d(Config.LOGTAG, "jump to first unread message");
3730                final Message first = conversation.getFirstUnreadMessage();
3731                final int bottom = Math.max(0, this.messageList.size() - 1);
3732                final int pos;
3733                final boolean jumpToBottom;
3734                if (first == null) {
3735                    pos = bottom;
3736                    jumpToBottom = true;
3737                } else {
3738                    int i = getIndexOf(first.getUuid(), this.messageList);
3739                    pos = i < 0 ? bottom : i;
3740                    jumpToBottom = false;
3741                }
3742                setSelection(pos, jumpToBottom);
3743            }
3744        }
3745
3746        this.binding.messagesView.post(this::fireReadEvent);
3747        // TODO if we only do this when this fragment is running on main it won't *bing* in tablet
3748        // layout which might be unnecessary since we can *see* it
3749        activity.xmppConnectionService
3750                .getNotificationService()
3751                .setOpenConversation(this.conversation);
3752
3753        if (commandAdapter != null && conversation != originalConversation) {
3754            commandAdapter.clear();
3755            conversation.setupViewPager(binding.conversationViewPager, binding.tabLayout, activity.xmppConnectionService.isOnboarding(), originalConversation);
3756            refreshCommands(false);
3757        }
3758        if (commandAdapter == null && conversation != null) {
3759            conversation.setupViewPager(binding.conversationViewPager, binding.tabLayout, activity.xmppConnectionService.isOnboarding(), null);
3760            commandAdapter = new CommandAdapter((XmppActivity) getActivity());
3761            binding.commandsView.setAdapter(commandAdapter);
3762            binding.commandsView.setOnItemClickListener((parent, view, position, id) -> {
3763                if (activity == null) return;
3764
3765                commandAdapter.getItem(position).start(activity, ConversationFragment.this.conversation);
3766            });
3767            refreshCommands(false);
3768        }
3769        binding.commandsNote.setVisibility(activity.xmppConnectionService.isOnboarding() ? View.VISIBLE : View.GONE);
3770        replyJumps.clear();
3771        return true;
3772    }
3773
3774    @Override
3775    public void refreshForNewCaps(final Set<Jid> newCapsJids) {
3776        if (newCapsJids.isEmpty() || (conversation != null && newCapsJids.contains(conversation.getJid().asBareJid()))) {
3777            refreshCommands(true);
3778        }
3779    }
3780
3781    protected void refreshCommands(boolean delayShow) {
3782        if (commandAdapter == null) return;
3783
3784        final CommandAdapter.MucConfig mucConfig =
3785            conversation.getMucOptions().getSelf().ranks(Affiliation.OWNER) ?
3786            new CommandAdapter.MucConfig() :
3787            null;
3788
3789        Jid commandJid = conversation.getContact().resourceWhichSupport(Namespace.COMMANDS);
3790        if (commandJid == null && conversation.getMode() == Conversation.MODE_MULTI && conversation.getMucOptions().hasFeature(Namespace.COMMANDS)) {
3791            commandJid = conversation.getJid().asBareJid();
3792        }
3793        if (commandJid == null && conversation.getJid().isDomainJid()) {
3794            commandJid = conversation.getJid();
3795        }
3796        if (commandJid == null) {
3797            binding.commandsViewProgressbar.setVisibility(View.GONE);
3798            if (mucConfig == null) {
3799                conversation.hideViewPager();
3800            } else {
3801                commandAdapter.clear();
3802                commandAdapter.add(mucConfig);
3803                conversation.showViewPager();
3804            }
3805        } else {
3806            if (!delayShow) conversation.showViewPager();
3807            binding.commandsViewProgressbar.setVisibility(View.VISIBLE);
3808            final var discoManager = conversation.getAccount().getXmppConnection().getManager(DiscoManager.class);
3809            final var future = discoManager.items(Entity.discoItem(commandJid), Namespace.COMMANDS);
3810            Futures.addCallback(
3811                    future,
3812                    new FutureCallback<>() {
3813                        @Override
3814                        public void onSuccess(Collection<Item> result) {
3815                            if (activity == null) return;
3816
3817                            activity.runOnUiThread(() -> {
3818                                binding.commandsViewProgressbar.setVisibility(View.GONE);
3819                                commandAdapter.clear();
3820                                for (final var command : result) {
3821                                    commandAdapter.add(new CommandAdapter.Command0050(command));
3822                                }
3823
3824                                if (mucConfig != null) commandAdapter.add(mucConfig);
3825
3826                                if (commandAdapter.getCount() < 1) {
3827                                    conversation.hideViewPager();
3828                                } else if (delayShow) {
3829                                    conversation.showViewPager();
3830                                }
3831                            });
3832
3833                        }
3834
3835                        @Override
3836                        public void onFailure(@NonNull Throwable throwable) {
3837                            Log.d(Config.LOGTAG, "Failed to get commands: " + throwable);
3838
3839                            if (activity == null) return;
3840
3841                            activity.runOnUiThread(() -> {
3842                                binding.commandsViewProgressbar.setVisibility(View.GONE);
3843                                commandAdapter.clear();
3844
3845                                if (mucConfig != null) commandAdapter.add(mucConfig);
3846
3847                                if (commandAdapter.getCount() < 1) {
3848                                    conversation.hideViewPager();
3849                                } else if (delayShow) {
3850                                    conversation.showViewPager();
3851                                }
3852                            });
3853                        }
3854                    },
3855                    MoreExecutors.directExecutor()
3856            );
3857        }
3858    }
3859
3860    private void resetUnreadMessagesCount() {
3861        lastMessageUuid = null;
3862        hideUnreadMessagesCount();
3863    }
3864
3865    private void hideUnreadMessagesCount() {
3866        if (this.binding == null) {
3867            return;
3868        }
3869        this.binding.scrollToBottomButton.setEnabled(false);
3870        this.binding.scrollToBottomButton.hide();
3871        replyJumps.clear();
3872        this.binding.unreadCountCustomView.setVisibility(View.GONE);
3873    }
3874
3875    private void setSelection(int pos, boolean jumpToBottom) {
3876        ListViewUtils.setSelection(this.binding.messagesView, pos, jumpToBottom);
3877        this.binding.messagesView.post(
3878                () -> ListViewUtils.setSelection(this.binding.messagesView, pos, jumpToBottom));
3879        this.binding.messagesView.post(this::fireReadEvent);
3880    }
3881
3882    private boolean scrolledToBottom() {
3883        return !conversation.isInHistoryPart() && this.binding != null && scrolledToBottom(this.binding.messagesView);
3884    }
3885
3886    private void processExtras(final Bundle extras) {
3887        final String downloadUuid = extras.getString(ConversationsActivity.EXTRA_DOWNLOAD_UUID);
3888        final String text = extras.getString(Intent.EXTRA_TEXT);
3889        final String nick = extras.getString(ConversationsActivity.EXTRA_NICK);
3890        final String node = extras.getString(ConversationsActivity.EXTRA_NODE);
3891        final String postInitAction =
3892                extras.getString(ConversationsActivity.EXTRA_POST_INIT_ACTION);
3893        final boolean asQuote = extras.getBoolean(ConversationsActivity.EXTRA_AS_QUOTE);
3894        final boolean pm = extras.getBoolean(ConversationsActivity.EXTRA_IS_PRIVATE_MESSAGE, false);
3895        final boolean doNotAppend =
3896                extras.getBoolean(ConversationsActivity.EXTRA_DO_NOT_APPEND, false);
3897        final String type = extras.getString(ConversationsActivity.EXTRA_TYPE);
3898
3899        final String thread = extras.getString(ConversationsActivity.EXTRA_THREAD);
3900        if (thread != null) {
3901            conversation.setLockThread(true);
3902            backPressedLeaveSingleThread.setEnabled(true);
3903            setThread(new Element("thread").setContent(thread));
3904            refresh();
3905        }
3906
3907        final List<Uri> uris = extractUris(extras);
3908        if (uris != null && uris.size() > 0) {
3909            if (uris.size() == 1 && "geo".equals(uris.get(0).getScheme())) {
3910                mediaPreviewAdapter.addMediaPreviews(
3911                        Attachment.of(getActivity(), uris.get(0), Attachment.Type.LOCATION));
3912            } else {
3913                final List<Uri> cleanedUris = cleanUris(new ArrayList<>(uris));
3914                mediaPreviewAdapter.addMediaPreviews(
3915                        Attachment.of(getActivity(), cleanedUris, type));
3916            }
3917            toggleInputMethod();
3918            return;
3919        }
3920        if (nick != null) {
3921            if (pm) {
3922                Jid jid = conversation.getJid();
3923                try {
3924                    Jid next = Jid.of(jid.getLocal(), jid.getDomain(), nick);
3925                    privateMessageWith(next);
3926                } catch (final IllegalArgumentException ignored) {
3927                    // do nothing
3928                }
3929            } else {
3930                final MucOptions mucOptions = conversation.getMucOptions();
3931                if (mucOptions.participating() || conversation.getNextCounterpart() != null) {
3932                    highlightInConference(nick);
3933                }
3934            }
3935        } else {
3936            if (text != null && Patterns.URI_GEO.matcher(text).matches()) {
3937                mediaPreviewAdapter.addMediaPreviews(
3938                        Attachment.of(getActivity(), Uri.parse(text), Attachment.Type.LOCATION));
3939                toggleInputMethod();
3940                return;
3941            } else if (text != null && asQuote) {
3942                quoteText(text);
3943            } else {
3944                appendText(text, doNotAppend);
3945            }
3946        }
3947        if (ConversationsActivity.POST_ACTION_RECORD_VOICE.equals(postInitAction)) {
3948            attachFile(ATTACHMENT_CHOICE_RECORD_VOICE, false);
3949            return;
3950        }
3951        if ("call".equals(postInitAction)) {
3952            checkPermissionAndTriggerAudioCall();
3953        }
3954        if ("message".equals(postInitAction)) {
3955            binding.conversationViewPager.post(() -> {
3956                binding.conversationViewPager.setCurrentItem(0);
3957            });
3958        }
3959        if ("command".equals(postInitAction)) {
3960            binding.conversationViewPager.post(() -> {
3961                PagerAdapter adapter = binding.conversationViewPager.getAdapter();
3962                if (adapter != null && adapter.getCount() > 1) {
3963                    binding.conversationViewPager.setCurrentItem(1);
3964                }
3965                final String jid = extras.getString(ConversationsActivity.EXTRA_JID);
3966                Jid commandJid = null;
3967                if (jid != null) {
3968                    try {
3969                        commandJid = Jid.of(jid);
3970                    } catch (final IllegalArgumentException e) { }
3971                }
3972                if (commandJid == null || !commandJid.isFullJid()) {
3973                    final Jid discoJid = conversation.getContact().resourceWhichSupport(Namespace.COMMANDS);
3974                    if (discoJid != null) commandJid = discoJid;
3975                }
3976                if (node != null && commandJid != null && activity != null) {
3977                    conversation.startCommand(commandFor(commandJid, node), activity.xmppConnectionService);
3978                }
3979            });
3980            return;
3981        }
3982        Message message =
3983                downloadUuid == null ? null : conversation.findMessageWithFileAndUuid(downloadUuid);
3984        if ("webxdc".equals(postInitAction)) {
3985            if (message == null) {
3986                message = activity.xmppConnectionService.getMessage(conversation, downloadUuid);
3987            }
3988            if (message == null) return;
3989
3990            Cid webxdcCid = message.getFileParams().getCids().get(0);
3991            WebxdcPage webxdc = new WebxdcPage(activity, webxdcCid, message);
3992            Conversation conversation = (Conversation) message.getConversation();
3993            if (!conversation.switchToSession("webxdc\0" + message.getUuid())) {
3994                conversation.startWebxdc(webxdc);
3995            }
3996        }
3997        if (message != null) {
3998            startDownloadable(message);
3999        }
4000        if (activity.xmppConnectionService.isOnboarding() && conversation.getJid().equals(Jid.of("cheogram.com"))) {
4001            if (!conversation.switchToSession("jabber:iq:register")) {
4002                conversation.startCommand(commandFor(Jid.of("cheogram.com/CHEOGRAM%jabber:iq:register"), "jabber:iq:register"), activity.xmppConnectionService);
4003            }
4004        }
4005        String messageUuid = extras.getString(ConversationsActivity.EXTRA_MESSAGE_UUID);
4006        if (messageUuid != null) {
4007            Runnable postSelectionRunnable = () -> highlightMessage(messageUuid);
4008            updateSelection(messageUuid, binding.messagesView.getHeight() / 2, postSelectionRunnable, false, false);
4009        }
4010    }
4011
4012    private Element commandFor(final Jid jid, final String node) {
4013        if (commandAdapter != null) {
4014            for (int i = 0; i < commandAdapter.getCount(); i++) {
4015                final CommandAdapter.Command c = commandAdapter.getItem(i);
4016                if (!(c instanceof CommandAdapter.Command0050)) continue;
4017
4018                final Element command = ((CommandAdapter.Command0050) c).el;
4019                final String commandNode = command.getAttribute("node");
4020                if (commandNode == null || !commandNode.equals(node)) continue;
4021
4022                final Jid commandJid = command.getAttributeAsJid("jid");
4023                if (commandJid != null && !commandJid.asBareJid().equals(jid.asBareJid())) continue;
4024
4025                return command;
4026            }
4027        }
4028
4029        return new Element("command", Namespace.COMMANDS).setAttribute("name", node).setAttribute("node", node).setAttribute("jid", jid);
4030    }
4031
4032    private List<Uri> extractUris(final Bundle extras) {
4033        final List<Uri> uris = extras.getParcelableArrayList(Intent.EXTRA_STREAM);
4034        if (uris != null) {
4035            return uris;
4036        }
4037        final Uri uri = extras.getParcelable(Intent.EXTRA_STREAM);
4038        if (uri != null) {
4039            return Collections.singletonList(uri);
4040        } else {
4041            return null;
4042        }
4043    }
4044
4045    private List<Uri> cleanUris(final List<Uri> uris) {
4046        final Iterator<Uri> iterator = uris.iterator();
4047        while (iterator.hasNext()) {
4048            final Uri uri = iterator.next();
4049            if (FileBackend.dangerousFile(uri)) {
4050                iterator.remove();
4051                Toast.makeText(
4052                                requireActivity(),
4053                                R.string.security_violation_not_attaching_file,
4054                                Toast.LENGTH_SHORT)
4055                        .show();
4056            }
4057        }
4058        return uris;
4059    }
4060
4061    private boolean showBlockSubmenu(View view) {
4062        final Jid jid = conversation.getJid();
4063        final int mode = conversation.getMode();
4064        final var contact = mode == Conversation.MODE_SINGLE ? conversation.getContact() : null;
4065        final boolean showReject = contact.getOption(Contact.Options.PENDING_SUBSCRIPTION_REQUEST);
4066        PopupMenu popupMenu = new PopupMenu(getActivity(), view);
4067        popupMenu.inflate(R.menu.block);
4068        popupMenu.getMenu().findItem(R.id.block_contact).setVisible(jid.getLocal() != null);
4069        popupMenu.getMenu().findItem(R.id.reject).setVisible(showReject);
4070        popupMenu.getMenu().findItem(R.id.add_contact).setVisible(!contact.showInRoster());
4071        popupMenu.setOnMenuItemClickListener(
4072                menuItem -> {
4073                    Blockable blockable;
4074                    switch (menuItem.getItemId()) {
4075                        case R.id.reject:
4076                            activity.xmppConnectionService.stopPresenceUpdatesTo(
4077                                    conversation.getContact());
4078                            updateSnackBar(conversation);
4079                            return true;
4080                        case R.id.add_contact:
4081                            mAddBackClickListener.onClick(view);
4082                            return true;
4083                        // case R.id.block_domain:
4084                        //     blockable =
4085                        //             conversation
4086                        //                     .getAccount()
4087                        //                     .getRoster()
4088                        //                     .getContact(jid.getDomain());
4089                        //     break;
4090                        default:
4091                            blockable = conversation;
4092                    }
4093                    BlockContactDialog.show(activity, blockable);
4094                    return true;
4095                });
4096        popupMenu.show();
4097        return true;
4098    }
4099
4100    private boolean showBlockMucSubmenu(View view) {
4101        final var jid = conversation.getJid();
4102        final var popupMenu = new PopupMenu(getActivity(), view);
4103        popupMenu.inflate(R.menu.block_muc);
4104        popupMenu.getMenu().findItem(R.id.block_contact).setVisible(jid.getLocal() != null);
4105        popupMenu.setOnMenuItemClickListener(
4106                menuItem -> {
4107                    Blockable blockable;
4108                    switch (menuItem.getItemId()) {
4109                        case R.id.reject:
4110                            activity.xmppConnectionService.clearConversationHistory(conversation);
4111                            activity.xmppConnectionService.archiveConversation(conversation);
4112                            return true;
4113                        case R.id.add_bookmark:
4114                            conversation.getAccount().getXmppConnection().getManager(BookmarkManager.class).save(conversation, "");
4115                            updateSnackBar(conversation);
4116                            return true;
4117                        case R.id.block_contact:
4118                            blockable =
4119                                    conversation
4120                                            .getAccount()
4121                                            .getRoster()
4122                                            .getContact(Jid.of(conversation.getAttribute("inviter")));
4123                            break;
4124                        default:
4125                            blockable = conversation;
4126                    }
4127                    BlockContactDialog.show(activity, blockable);
4128                    activity.xmppConnectionService.archiveConversation(conversation);
4129                    return true;
4130                });
4131        popupMenu.show();
4132        return true;
4133    }
4134
4135    private void updateSnackBar(final Conversation conversation) {
4136        final Account account = conversation.getAccount();
4137        final XmppConnection connection = account.getXmppConnection();
4138        final int mode = conversation.getMode();
4139        final Contact contact = mode == Conversation.MODE_SINGLE ? conversation.getContact() : null;
4140        if (conversation.getStatus() == Conversation.STATUS_ARCHIVED) {
4141            return;
4142        }
4143        if (account.getStatus() == Account.State.DISABLED) {
4144            showSnackbar(
4145                    R.string.this_account_is_disabled,
4146                    R.string.enable,
4147                    this.mEnableAccountListener);
4148        } else if (account.getStatus() == Account.State.LOGGED_OUT) {
4149            showSnackbar(
4150                    R.string.this_account_is_logged_out,
4151                    R.string.log_in,
4152                    this.mEnableAccountListener);
4153        } else if (conversation.isBlocked()) {
4154            showSnackbar(R.string.contact_blocked, R.string.unblock, this.mUnblockClickListener);
4155        } else if (account.getStatus() == Account.State.CONNECTING) {
4156            showSnackbar(R.string.this_account_is_connecting, 0, null);
4157        } else if (account.getStatus() != Account.State.ONLINE) {
4158            showSnackbar(R.string.this_account_is_offline, 0, null);
4159        } else if (contact != null
4160                && !contact.showInRoster()
4161                && contact.getOption(Contact.Options.PENDING_SUBSCRIPTION_REQUEST)) {
4162            showSnackbar(
4163                    R.string.contact_added_you,
4164                    R.string.options,
4165                    this.mBlockClickListener,
4166                    this.mLongPressBlockListener);
4167        } else if (contact != null
4168                && contact.getOption(Contact.Options.PENDING_SUBSCRIPTION_REQUEST)) {
4169            showSnackbar(
4170                    R.string.contact_asks_for_presence_subscription,
4171                    R.string.allow,
4172                    this.mAllowPresenceSubscription,
4173                    this.mLongPressBlockListener);
4174        } else if (mode == Conversation.MODE_MULTI
4175                && !conversation.getMucOptions().online()
4176                && account.getStatus() == Account.State.ONLINE) {
4177            switch (conversation.getMucOptions().getError()) {
4178                case NICK_IN_USE:
4179                    showSnackbar(R.string.nick_in_use, R.string.edit, clickToMuc);
4180                    break;
4181                case NO_RESPONSE:
4182                    showSnackbar(R.string.joining_conference, 0, null);
4183                    break;
4184                case SERVER_NOT_FOUND:
4185                    if (conversation.receivedMessagesCount() > 0) {
4186                        showSnackbar(R.string.remote_server_not_found, R.string.try_again, joinMuc);
4187                    } else {
4188                        showSnackbar(R.string.remote_server_not_found, R.string.leave, leaveMuc);
4189                    }
4190                    break;
4191                case REMOTE_SERVER_TIMEOUT:
4192                    if (conversation.receivedMessagesCount() > 0) {
4193                        showSnackbar(R.string.remote_server_timeout, R.string.try_again, joinMuc);
4194                    } else {
4195                        showSnackbar(R.string.remote_server_timeout, R.string.leave, leaveMuc);
4196                    }
4197                    break;
4198                case PASSWORD_REQUIRED:
4199                    showSnackbar(
4200                            R.string.conference_requires_password,
4201                            R.string.enter_password,
4202                            enterPassword);
4203                    break;
4204                case BANNED:
4205                    showSnackbar(R.string.conference_banned, R.string.leave, leaveMuc);
4206                    break;
4207                case MEMBERS_ONLY:
4208                    showSnackbar(R.string.conference_members_only, R.string.leave, leaveMuc);
4209                    break;
4210                case RESOURCE_CONSTRAINT:
4211                    showSnackbar(
4212                            R.string.conference_resource_constraint, R.string.try_again, joinMuc);
4213                    break;
4214                case KICKED:
4215                    showSnackbar(R.string.conference_kicked, R.string.join, joinMuc);
4216                    break;
4217                case TECHNICAL_PROBLEMS:
4218                    showSnackbar(
4219                            R.string.conference_technical_problems, R.string.try_again, joinMuc);
4220                    break;
4221                case UNKNOWN:
4222                    showSnackbar(R.string.conference_unknown_error, R.string.try_again, joinMuc);
4223                    break;
4224                case INVALID_NICK:
4225                    showSnackbar(R.string.invalid_muc_nick, R.string.edit, clickToMuc);
4226                case SHUTDOWN:
4227                    showSnackbar(R.string.conference_shutdown, R.string.try_again, joinMuc);
4228                    break;
4229                case DESTROYED:
4230                    showSnackbar(R.string.conference_destroyed, R.string.leave, leaveMuc);
4231                    break;
4232                case NON_ANONYMOUS:
4233                    showSnackbar(
4234                            R.string.group_chat_will_make_your_jabber_id_public,
4235                            R.string.join,
4236                            acceptJoin);
4237                    break;
4238                default:
4239                    hideSnackbar();
4240                    break;
4241            }
4242        } else if (account.hasPendingPgpIntent(conversation)) {
4243            showSnackbar(R.string.openpgp_messages_found, R.string.decrypt, clickToDecryptListener);
4244        } else if (connection != null
4245                && connection.getFeatures().blocking()
4246                && conversation.strangerInvited()) {
4247            showSnackbar(
4248                    R.string.received_invite_from_stranger,
4249                    R.string.options,
4250                    (v) -> showBlockMucSubmenu(v),
4251                    (v) -> showBlockMucSubmenu(v));
4252        } else if (connection != null
4253                && connection.getFeatures().blocking()
4254                && conversation.countMessages() != 0
4255                && !conversation.isBlocked()
4256                && conversation.isWithStranger()) {
4257            showSnackbar(
4258                    R.string.received_message_from_stranger,
4259                    R.string.options,
4260                    this.mBlockClickListener,
4261                    this.mLongPressBlockListener);
4262        } else {
4263            hideSnackbar();
4264        }
4265    }
4266
4267    @Override
4268    public void refresh() {
4269        if (this.binding == null) {
4270            Log.d(
4271                    Config.LOGTAG,
4272                    "ConversationFragment.refresh() skipped updated because view binding was null");
4273            return;
4274        }
4275        if (this.conversation != null
4276                && this.activity != null
4277                && this.activity.xmppConnectionService != null) {
4278            if (!activity.xmppConnectionService.isConversationStillOpen(this.conversation)) {
4279                activity.onConversationArchived(this.conversation);
4280                return;
4281            }
4282        }
4283        this.refresh(true);
4284    }
4285
4286    private void refresh(boolean notifyConversationRead) {
4287        synchronized (this.messageList) {
4288            if (this.conversation != null) {
4289                if (messageListAdapter.hasSelection()) {
4290                    if (notifyConversationRead) binding.messagesView.postDelayed(this::refresh, 1000L);
4291                } else {
4292                    conversation.populateWithMessages(this.messageList, activity == null ? null : activity.xmppConnectionService);
4293                    try {
4294                        updateStatusMessages();
4295                    } catch (IllegalStateException e) {
4296                        Log.e(Config.LOGTAG, "Problem updating status messages on refresh: " + e);
4297                    }
4298                    this.messageListAdapter.notifyDataSetChanged();
4299                }
4300                if (conversation.getReceivedMessagesCountSinceUuid(lastMessageUuid) != 0) {
4301                    binding.unreadCountCustomView.setVisibility(View.VISIBLE);
4302                    binding.unreadCountCustomView.setUnreadCount(
4303                            conversation.getReceivedMessagesCountSinceUuid(lastMessageUuid));
4304                }
4305                updateSnackBar(conversation);
4306                if (activity != null) updateChatMsgHint();
4307                if (notifyConversationRead && activity != null) {
4308                    binding.messagesView.post(this::fireReadEvent);
4309                }
4310                updateSendButton();
4311                updateEditablity();
4312                conversation.refreshSessions();
4313            }
4314        }
4315    }
4316
4317    protected void messageSent() {
4318        binding.textinputSubject.setText("");
4319        binding.textinputSubject.setVisibility(View.GONE);
4320        setThread(null);
4321        setupReply(null);
4322        conversation.setUserSelectedThread(false);
4323        mSendingPgpMessage.set(false);
4324        this.binding.textinput.setText("");
4325        if (conversation.setCorrectingMessage(null)) {
4326            this.binding.textinput.append(conversation.getDraftMessage());
4327            conversation.setDraftMessage(null);
4328        }
4329        storeNextMessage();
4330        updateChatMsgHint();
4331        if (activity == null) return;
4332        SharedPreferences p = PreferenceManager.getDefaultSharedPreferences(activity);
4333        final boolean prefScrollToBottom =
4334                p.getBoolean(
4335                        "scroll_to_bottom",
4336                        activity.getResources().getBoolean(R.bool.scroll_to_bottom));
4337        if (prefScrollToBottom || scrolledToBottom()) {
4338            new Handler()
4339                    .post(
4340                            () -> {
4341                                int size = messageList.size();
4342                                this.binding.messagesView.setSelection(size - 1);
4343                            });
4344        }
4345    }
4346
4347    private boolean storeNextMessage() {
4348        return storeNextMessage(this.binding.textinput.getText().toString());
4349    }
4350
4351    private boolean storeNextMessage(String msg) {
4352        final boolean participating =
4353                conversation.getMode() == Conversational.MODE_SINGLE
4354                        || conversation.getMucOptions().participating();
4355        if (this.conversation.getStatus() != Conversation.STATUS_ARCHIVED
4356                && participating
4357                && this.conversation.setNextMessage(msg) && activity != null) {
4358            activity.xmppConnectionService.updateConversation(this.conversation);
4359            return true;
4360        }
4361        return false;
4362    }
4363
4364    public void doneSendingPgpMessage() {
4365        mSendingPgpMessage.set(false);
4366    }
4367
4368    public Long getMaxHttpUploadSize(final Conversation conversation) {
4369
4370        final var connection = conversation.getAccount().getXmppConnection();
4371        final var httpUploadService = connection.getManager(HttpUploadManager.class).getService();
4372        if (httpUploadService == null) {
4373            return -1L;
4374        }
4375        return httpUploadService.getMaxFileSize();
4376    }
4377
4378    private boolean canWrite() {
4379        return
4380                this.conversation.getMode() == Conversation.MODE_SINGLE
4381                        || this.conversation.getMucOptions().participating()
4382                        || this.conversation.getNextCounterpart() != null;
4383    }
4384
4385    private void updateEditablity() {
4386        boolean canWrite = canWrite();
4387        this.binding.textinput.setFocusable(canWrite);
4388        this.binding.textinput.setFocusableInTouchMode(canWrite);
4389        this.binding.textSendButton.setEnabled(canWrite);
4390        this.binding.textSendButton.setVisibility(canWrite ? View.VISIBLE : View.GONE);
4391        this.binding.requestVoice.setVisibility(canWrite ? View.GONE : View.VISIBLE);
4392        this.binding.textinput.setCursorVisible(canWrite);
4393        this.binding.textinput.setEnabled(canWrite);
4394    }
4395
4396    public void updateSendButton() {
4397        boolean hasAttachments =
4398                mediaPreviewAdapter != null && mediaPreviewAdapter.hasAttachments();
4399        final Conversation c = this.conversation;
4400        final Presence.Availability status;
4401        final String text =
4402                this.binding.textinput == null ? "" : this.binding.textinput.getText().toString();
4403        final SendButtonAction action;
4404        if (hasAttachments) {
4405            action = SendButtonAction.TEXT;
4406        } else {
4407            action = SendButtonTool.getAction(getActivity(), c, text, binding.textinputSubject.getText().toString());
4408        }
4409        if (c.getAccount().getStatus() == Account.State.ONLINE) {
4410            if (activity != null
4411                    && activity.xmppConnectionService != null
4412                    && activity.xmppConnectionService.getMessageArchiveService().isCatchingUp(c)) {
4413                status = Presence.Availability.OFFLINE;
4414            } else if (c.getMode() == Conversation.MODE_SINGLE) {
4415                status = c.getContact().getShownStatus();
4416            } else {
4417                status =
4418                        c.getMucOptions().online()
4419                                ? Presence.Availability.ONLINE
4420                                : Presence.Availability.OFFLINE;
4421            }
4422        } else {
4423            status = Presence.Availability.OFFLINE;
4424        }
4425        this.binding.textSendButton.setTag(action);
4426        this.binding.textSendButton.setIconTint(ColorStateList.valueOf(SendButtonTool.getSendButtonColor(this.binding.textSendButton, status)));
4427        // TODO send button color
4428        final Activity activity = getActivity();
4429        if (activity != null) {
4430            this.binding.textSendButton.setIconResource(
4431                    SendButtonTool.getSendButtonImageResource(action, text.length() > 0 || hasAttachments || (c.getThread() != null && binding.textinputSubject.getText().length() > 0)));
4432        }
4433
4434        ViewGroup.LayoutParams params = binding.threadIdenticonLayout.getLayoutParams();
4435        if (identiconWidth < 0) identiconWidth = params.width;
4436        if (hasAttachments || binding.textinput.getText().toString().replaceFirst("^(\\w|[, ])+:\\s*", "").length() > 0) {
4437            binding.conversationViewPager.setCurrentItem(0);
4438            params.width = conversation.getThread() == null ? 0 : identiconWidth;
4439        } else {
4440            params.width = identiconWidth;
4441        }
4442        if (!canWrite()) params.width = 0;
4443        binding.threadIdenticonLayout.setLayoutParams(params);
4444    }
4445
4446    protected void updateStatusMessages() {
4447        DateSeparator.addAll(this.messageList);
4448        if (showLoadMoreMessages(conversation)) {
4449            this.messageList.add(0, Message.createLoadMoreMessage(conversation));
4450        }
4451        if (conversation.getMode() == Conversation.MODE_SINGLE) {
4452            ChatState state = conversation.getIncomingChatState();
4453            if (state == ChatState.COMPOSING) {
4454                this.messageList.add(
4455                        Message.createStatusMessage(
4456                                conversation,
4457                                getString(R.string.contact_is_typing, conversation.getName())));
4458            } else if (state == ChatState.PAUSED) {
4459                this.messageList.add(
4460                        Message.createStatusMessage(
4461                                conversation,
4462                                getString(
4463                                        R.string.contact_has_stopped_typing,
4464                                        conversation.getName())));
4465            } else {
4466                for (int i = this.messageList.size() - 1; i >= 0; --i) {
4467                    final Message message = this.messageList.get(i);
4468                    if (message.getType() != Message.TYPE_STATUS) {
4469                        if (message.getStatus() == Message.STATUS_RECEIVED) {
4470                            return;
4471                        } else {
4472                            if (message.getStatus() == Message.STATUS_SEND_DISPLAYED) {
4473                                this.messageList.add(
4474                                        i + 1,
4475                                        Message.createStatusMessage(
4476                                                conversation,
4477                                                getString(
4478                                                        R.string.contact_has_read_up_to_this_point,
4479                                                        conversation.getName())));
4480                                return;
4481                            }
4482                        }
4483                    }
4484                }
4485            }
4486        } else {
4487            final MucOptions mucOptions = conversation.getMucOptions();
4488            final List<MucOptions.User> allUsers = mucOptions.getUsers();
4489            final Set<ReadByMarker> addedMarkers = new HashSet<>();
4490            ChatState state = ChatState.COMPOSING;
4491            List<MucOptions.User> users =
4492                    conversation.getMucOptions().getUsersWithChatState(state, 5);
4493            if (users.size() == 0) {
4494                state = ChatState.PAUSED;
4495                users = conversation.getMucOptions().getUsersWithChatState(state, 5);
4496            }
4497            if (mucOptions.isPrivateAndNonAnonymous()) {
4498                for (int i = this.messageList.size() - 1; i >= 0; --i) {
4499                    final Set<ReadByMarker> markersForMessage =
4500                            messageList.get(i).getReadByMarkers();
4501                    final List<MucOptions.User> shownMarkers = new ArrayList<>();
4502                    for (ReadByMarker marker : markersForMessage) {
4503                        if (!ReadByMarker.contains(marker, addedMarkers)) {
4504                            addedMarkers.add(
4505                                    marker); // may be put outside this condition. set should do
4506                            // dedup anyway
4507                            MucOptions.User user = mucOptions.findUser(marker);
4508                            if (user != null && !users.contains(user)) {
4509                                shownMarkers.add(user);
4510                            }
4511                        }
4512                    }
4513                    final ReadByMarker markerForSender = ReadByMarker.from(messageList.get(i));
4514                    final Message statusMessage;
4515                    final int size = shownMarkers.size();
4516                    if (size > 1) {
4517                        final String body;
4518                        if (size <= 4) {
4519                            body =
4520                                    getString(
4521                                            R.string.contacts_have_read_up_to_this_point,
4522                                            UIHelper.concatNames(shownMarkers));
4523                        } else if (ReadByMarker.allUsersRepresented(
4524                                allUsers, markersForMessage, markerForSender)) {
4525                            body = getString(R.string.everyone_has_read_up_to_this_point);
4526                        } else {
4527                            body =
4528                                    getString(
4529                                            R.string.contacts_and_n_more_have_read_up_to_this_point,
4530                                            UIHelper.concatNames(shownMarkers, 3),
4531                                            size - 3);
4532                        }
4533                        statusMessage = Message.createStatusMessage(conversation, body);
4534                        statusMessage.setCounterparts(shownMarkers);
4535                    } else if (size == 1) {
4536                        statusMessage =
4537                                Message.createStatusMessage(
4538                                        conversation,
4539                                        getString(
4540                                                R.string.contact_has_read_up_to_this_point,
4541                                                UIHelper.getDisplayName(shownMarkers.get(0))));
4542                        statusMessage.setCounterpart(shownMarkers.get(0).getFullJid());
4543                        statusMessage.setTrueCounterpart(shownMarkers.get(0).getRealJid());
4544                    } else {
4545                        statusMessage = null;
4546                    }
4547                    if (statusMessage != null) {
4548                        this.messageList.add(i + 1, statusMessage);
4549                    }
4550                    addedMarkers.add(markerForSender);
4551                    if (ReadByMarker.allUsersRepresented(allUsers, addedMarkers)) {
4552                        break;
4553                    }
4554                }
4555            }
4556            if (users.size() > 0) {
4557                Message statusMessage;
4558                if (users.size() == 1) {
4559                    MucOptions.User user = users.get(0);
4560                    int id =
4561                            state == ChatState.COMPOSING
4562                                    ? R.string.contact_is_typing
4563                                    : R.string.contact_has_stopped_typing;
4564                    statusMessage =
4565                            Message.createStatusMessage(
4566                                    conversation, getString(id, UIHelper.getDisplayName(user)));
4567                    statusMessage.setTrueCounterpart(user.getRealJid());
4568                    statusMessage.setCounterpart(user.getFullJid());
4569                } else {
4570                    int id =
4571                            state == ChatState.COMPOSING
4572                                    ? R.string.contacts_are_typing
4573                                    : R.string.contacts_have_stopped_typing;
4574                    statusMessage =
4575                            Message.createStatusMessage(
4576                                    conversation, getString(id, UIHelper.concatNames(users)));
4577                    statusMessage.setCounterparts(users);
4578                }
4579                this.messageList.add(statusMessage);
4580            }
4581        }
4582    }
4583
4584    private void stopScrolling() {
4585        long now = SystemClock.uptimeMillis();
4586        MotionEvent cancel = MotionEvent.obtain(now, now, MotionEvent.ACTION_CANCEL, 0, 0, 0);
4587        binding.messagesView.dispatchTouchEvent(cancel);
4588    }
4589
4590    private boolean showLoadMoreMessages(final Conversation c) {
4591        if (activity == null || activity.xmppConnectionService == null) {
4592            return false;
4593        }
4594        final boolean mam = hasMamSupport(c) && !c.getContact().isBlocked();
4595        final MessageArchiveService service =
4596                activity.xmppConnectionService.getMessageArchiveService();
4597        return mam
4598                && (c.getLastClearHistory().getTimestamp() != 0
4599                        || (c.countMessages() == 0
4600                                && c.messagesLoaded.get()
4601                                && c.hasMessagesLeftOnServer()
4602                                && !service.queryInProgress(c)));
4603    }
4604
4605    private boolean hasMamSupport(final Conversation c) {
4606        if (c.getMode() == Conversation.MODE_SINGLE) {
4607            final XmppConnection connection = c.getAccount().getXmppConnection();
4608            return connection != null && connection.getFeatures().mam();
4609        } else {
4610            return c.getMucOptions().mamSupport();
4611        }
4612    }
4613
4614    protected void showSnackbar(
4615            final int message, final int action, final OnClickListener clickListener) {
4616        showSnackbar(message, action, clickListener, null);
4617    }
4618
4619    protected void showSnackbar(
4620            final int message,
4621            final int action,
4622            final OnClickListener clickListener,
4623            final View.OnLongClickListener longClickListener) {
4624        this.binding.snackbar.setVisibility(View.VISIBLE);
4625        this.binding.snackbar.setOnClickListener(null);
4626        this.binding.snackbarMessage.setText(message);
4627        this.binding.snackbarMessage.setOnClickListener(null);
4628        this.binding.snackbarAction.setVisibility(clickListener == null ? View.GONE : View.VISIBLE);
4629        if (action != 0) {
4630            this.binding.snackbarAction.setText(action);
4631        }
4632        this.binding.snackbarAction.setOnClickListener(clickListener);
4633        this.binding.snackbarAction.setOnLongClickListener(longClickListener);
4634    }
4635
4636    protected void hideSnackbar() {
4637        this.binding.snackbar.setVisibility(View.GONE);
4638    }
4639
4640    protected void sendMessage(Message message) {
4641        new Thread(() -> activity.xmppConnectionService.sendMessage(message)).start();
4642        messageSent();
4643    }
4644
4645    protected void sendPgpMessage(final Message message) {
4646        final XmppConnectionService xmppService = activity.xmppConnectionService;
4647        final Contact contact = message.getConversation().getContact();
4648        if (!activity.hasPgp()) {
4649            activity.showInstallPgpDialog();
4650            return;
4651        }
4652        if (conversation.getAccount().getPgpSignature() == null) {
4653            activity.announcePgp(
4654                    conversation.getAccount(), conversation, null, activity.onOpenPGPKeyPublished);
4655            return;
4656        }
4657        if (!mSendingPgpMessage.compareAndSet(false, true)) {
4658            Log.d(Config.LOGTAG, "sending pgp message already in progress");
4659        }
4660        if (conversation.getMode() == Conversation.MODE_SINGLE) {
4661            if (contact.getPgpKeyId() != 0) {
4662                xmppService
4663                        .getPgpEngine()
4664                        .hasKey(
4665                                contact,
4666                                new UiCallback<Contact>() {
4667
4668                                    @Override
4669                                    public void userInputRequired(
4670                                            PendingIntent pi, Contact contact) {
4671                                        startPendingIntent(pi, REQUEST_ENCRYPT_MESSAGE);
4672                                    }
4673
4674                                    @Override
4675                                    public void success(Contact contact) {
4676                                        encryptTextMessage(message);
4677                                    }
4678
4679                                    @Override
4680                                    public void error(int error, Contact contact) {
4681                                        activity.runOnUiThread(
4682                                                () ->
4683                                                        Toast.makeText(
4684                                                                        activity,
4685                                                                        R.string
4686                                                                                .unable_to_connect_to_keychain,
4687                                                                        Toast.LENGTH_SHORT)
4688                                                                .show());
4689                                        mSendingPgpMessage.set(false);
4690                                    }
4691                                });
4692
4693            } else {
4694                showNoPGPKeyDialog(
4695                        false,
4696                        (dialog, which) -> {
4697                            conversation.setNextEncryption(Message.ENCRYPTION_NONE);
4698                            xmppService.updateConversation(conversation);
4699                            message.setEncryption(Message.ENCRYPTION_NONE);
4700                            xmppService.sendMessage(message);
4701                            messageSent();
4702                        });
4703            }
4704        } else {
4705            if (conversation.getMucOptions().pgpKeysInUse()) {
4706                if (!conversation.getMucOptions().everybodyHasKeys()) {
4707                    Toast warning =
4708                            Toast.makeText(
4709                                    getActivity(), R.string.missing_public_keys, Toast.LENGTH_LONG);
4710                    warning.setGravity(Gravity.CENTER_VERTICAL, 0, 0);
4711                    warning.show();
4712                }
4713                encryptTextMessage(message);
4714            } else {
4715                showNoPGPKeyDialog(
4716                        true,
4717                        (dialog, which) -> {
4718                            conversation.setNextEncryption(Message.ENCRYPTION_NONE);
4719                            message.setEncryption(Message.ENCRYPTION_NONE);
4720                            xmppService.updateConversation(conversation);
4721                            xmppService.sendMessage(message);
4722                            messageSent();
4723                        });
4724            }
4725        }
4726    }
4727
4728    public void encryptTextMessage(Message message) {
4729        activity.xmppConnectionService
4730                .getPgpEngine()
4731                .encrypt(
4732                        message,
4733                        new UiCallback<Message>() {
4734
4735                            @Override
4736                            public void userInputRequired(PendingIntent pi, Message message) {
4737                                startPendingIntent(pi, REQUEST_SEND_MESSAGE);
4738                            }
4739
4740                            @Override
4741                            public void success(Message message) {
4742                                // TODO the following two call can be made before the callback
4743                                getActivity().runOnUiThread(() -> messageSent());
4744                            }
4745
4746                            @Override
4747                            public void error(final int error, Message message) {
4748                                getActivity()
4749                                        .runOnUiThread(
4750                                                () -> {
4751                                                    doneSendingPgpMessage();
4752                                                    Toast.makeText(
4753                                                                    getActivity(),
4754                                                                    error == 0
4755                                                                            ? R.string
4756                                                                                    .unable_to_connect_to_keychain
4757                                                                            : error,
4758                                                                    Toast.LENGTH_SHORT)
4759                                                            .show();
4760                                                });
4761                            }
4762                        });
4763    }
4764
4765    public void showNoPGPKeyDialog(
4766            final boolean plural, final DialogInterface.OnClickListener listener) {
4767        final MaterialAlertDialogBuilder builder =
4768                new MaterialAlertDialogBuilder(requireActivity());
4769        if (plural) {
4770            builder.setTitle(getString(R.string.no_pgp_keys));
4771            builder.setMessage(getText(R.string.contacts_have_no_pgp_keys));
4772        } else {
4773            builder.setTitle(getString(R.string.no_pgp_key));
4774            builder.setMessage(getText(R.string.contact_has_no_pgp_key));
4775        }
4776        builder.setNegativeButton(getString(R.string.cancel), null);
4777        builder.setPositiveButton(getString(R.string.send_unencrypted), listener);
4778        builder.create().show();
4779    }
4780
4781    public void appendText(String text, final boolean doNotAppend) {
4782        if (text == null) {
4783            return;
4784        }
4785        final Editable editable = this.binding.textinput.getText();
4786        String previous = editable == null ? "" : editable.toString();
4787        if (doNotAppend && !TextUtils.isEmpty(previous)) {
4788            Toast.makeText(getActivity(), R.string.already_drafting_message, Toast.LENGTH_LONG)
4789                    .show();
4790            return;
4791        }
4792        if (UIHelper.isLastLineQuote(previous)) {
4793            text = '\n' + text;
4794        } else if (previous.length() != 0
4795                && !Character.isWhitespace(previous.charAt(previous.length() - 1))) {
4796            text = " " + text;
4797        }
4798        this.binding.textinput.append(text);
4799    }
4800
4801    @Override
4802    public boolean onEnterPressed(final boolean isCtrlPressed) {
4803        if (isCtrlPressed || enterIsSend()) {
4804            sendMessage();
4805            return true;
4806        }
4807        return false;
4808    }
4809
4810    private boolean enterIsSend() {
4811        final SharedPreferences p = PreferenceManager.getDefaultSharedPreferences(getActivity());
4812        return p.getBoolean("enter_is_send", getResources().getBoolean(R.bool.enter_is_send));
4813    }
4814
4815    public boolean onArrowUpCtrlPressed() {
4816        final Message lastEditableMessage =
4817                conversation == null ? null : conversation.getLastEditableMessage();
4818        if (lastEditableMessage != null) {
4819            correctMessage(lastEditableMessage);
4820            return true;
4821        } else {
4822            Toast.makeText(getActivity(), R.string.could_not_correct_message, Toast.LENGTH_LONG)
4823                    .show();
4824            return false;
4825        }
4826    }
4827
4828    @Override
4829    public void onTypingStarted() {
4830        final XmppConnectionService service =
4831                activity == null ? null : activity.xmppConnectionService;
4832        if (service == null) {
4833            return;
4834        }
4835        final Account.State status = conversation.getAccount().getStatus();
4836        if (status == Account.State.ONLINE
4837                && conversation.setOutgoingChatState(ChatState.COMPOSING)) {
4838            service.sendChatState(conversation);
4839        }
4840        runOnUiThread(this::updateSendButton);
4841    }
4842
4843    @Override
4844    public void onTypingStopped() {
4845        final XmppConnectionService service =
4846                activity == null ? null : activity.xmppConnectionService;
4847        if (service == null) {
4848            return;
4849        }
4850        final Account.State status = conversation.getAccount().getStatus();
4851        if (status == Account.State.ONLINE && conversation.setOutgoingChatState(ChatState.PAUSED)) {
4852            service.sendChatState(conversation);
4853        }
4854    }
4855
4856    @Override
4857    public void onTextDeleted() {
4858        final XmppConnectionService service =
4859                activity == null ? null : activity.xmppConnectionService;
4860        if (service == null) {
4861            return;
4862        }
4863        final Account.State status = conversation.getAccount().getStatus();
4864        if (status == Account.State.ONLINE
4865                && conversation.setOutgoingChatState(Config.DEFAULT_CHAT_STATE)) {
4866            service.sendChatState(conversation);
4867        }
4868        final boolean stored = storeNextMessage(null);
4869        runOnUiThread(
4870                () -> {
4871                    if (stored && activity != null) {
4872                        activity.onConversationsListItemUpdated();
4873                    }
4874                    updateSendButton();
4875                });
4876    }
4877
4878    @Override
4879    public void onTextChanged() {
4880        if (conversation != null && conversation.getCorrectingMessage() != null) {
4881            runOnUiThread(this::updateSendButton);
4882        }
4883    }
4884
4885    @Override
4886    public boolean onTabPressed(boolean repeated) {
4887        if (conversation == null || conversation.getMode() == Conversation.MODE_SINGLE) {
4888            return false;
4889        }
4890        if (repeated) {
4891            completionIndex++;
4892        } else {
4893            lastCompletionLength = 0;
4894            completionIndex = 0;
4895            final String content = this.binding.textinput.getText().toString();
4896            lastCompletionCursor = this.binding.textinput.getSelectionEnd();
4897            int start =
4898                    lastCompletionCursor > 0
4899                            ? content.lastIndexOf(" ", lastCompletionCursor - 1) + 1
4900                            : 0;
4901            firstWord = start == 0;
4902            incomplete = content.substring(start, lastCompletionCursor);
4903        }
4904        List<String> completions = new ArrayList<>();
4905        for (MucOptions.User user : conversation.getMucOptions().getUsers()) {
4906            String name = user.getNick();
4907            if (name != null && name.startsWith(incomplete)) {
4908                completions.add(name + (firstWord ? ": " : " "));
4909            }
4910        }
4911        Collections.sort(completions);
4912        if (completions.size() > completionIndex) {
4913            String completion = completions.get(completionIndex).substring(incomplete.length());
4914            this.binding
4915                    .textinput
4916                    .getEditableText()
4917                    .delete(lastCompletionCursor, lastCompletionCursor + lastCompletionLength);
4918            this.binding.textinput.getEditableText().insert(lastCompletionCursor, completion);
4919            lastCompletionLength = completion.length();
4920        } else {
4921            completionIndex = -1;
4922            this.binding
4923                    .textinput
4924                    .getEditableText()
4925                    .delete(lastCompletionCursor, lastCompletionCursor + lastCompletionLength);
4926            lastCompletionLength = 0;
4927        }
4928        return true;
4929    }
4930
4931    private void startPendingIntent(PendingIntent pendingIntent, int requestCode) {
4932        try {
4933            getActivity()
4934                    .startIntentSenderForResult(
4935                            pendingIntent.getIntentSender(),
4936                            requestCode,
4937                            null,
4938                            0,
4939                            0,
4940                            0,
4941                            Compatibility.pgpStartIntentSenderOptions());
4942        } catch (final SendIntentException ignored) {
4943        }
4944    }
4945
4946    @Override
4947    public void onBackendConnected() {
4948        Log.d(Config.LOGTAG, "ConversationFragment.onBackendConnected()");
4949        setupEmojiSearch();
4950        String uuid = pendingConversationsUuid.pop();
4951        if (uuid != null) {
4952            if (!findAndReInitByUuidOrArchive(uuid)) {
4953                return;
4954            }
4955        } else {
4956            if (!activity.xmppConnectionService.isConversationStillOpen(conversation)) {
4957                clearPending();
4958                activity.onConversationArchived(conversation);
4959                return;
4960            }
4961        }
4962        ActivityResult activityResult = postponedActivityResult.pop();
4963        if (activityResult != null) {
4964            handleActivityResult(activityResult);
4965        }
4966        clearPending();
4967    }
4968
4969    private boolean findAndReInitByUuidOrArchive(@NonNull final String uuid) {
4970        Conversation conversation = activity.xmppConnectionService.findConversationByUuid(uuid);
4971        if (conversation == null) {
4972            clearPending();
4973            activity.onConversationArchived(null);
4974            return false;
4975        }
4976        reInit(conversation);
4977        ScrollState scrollState = pendingScrollState.pop();
4978        String lastMessageUuid = pendingLastMessageUuid.pop();
4979        List<Attachment> attachments = pendingMediaPreviews.pop();
4980        if (scrollState != null) {
4981            setScrollPosition(scrollState, lastMessageUuid);
4982        }
4983        if (attachments != null && attachments.size() > 0) {
4984            Log.d(Config.LOGTAG, "had attachments on restore");
4985            mediaPreviewAdapter.addMediaPreviews(attachments);
4986            toggleInputMethod();
4987        }
4988        return true;
4989    }
4990
4991    private void clearPending() {
4992        if (postponedActivityResult.clear()) {
4993            Log.e(Config.LOGTAG, "cleared pending intent with unhandled result left");
4994            if (pendingTakePhotoUri.clear()) {
4995                Log.e(Config.LOGTAG, "cleared pending photo uri");
4996            }
4997        }
4998        if (pendingScrollState.clear()) {
4999            Log.e(Config.LOGTAG, "cleared scroll state");
5000        }
5001        if (pendingConversationsUuid.clear()) {
5002            Log.e(Config.LOGTAG, "cleared pending conversations uuid");
5003        }
5004        if (pendingMediaPreviews.clear()) {
5005            Log.e(Config.LOGTAG, "cleared pending media previews");
5006        }
5007    }
5008
5009    public Conversation getConversation() {
5010        return conversation;
5011    }
5012
5013    @Override
5014    public void onContactPictureLongClicked(View v, final Message message) {
5015        final String fingerprint;
5016        if (message.getEncryption() == Message.ENCRYPTION_PGP
5017                || message.getEncryption() == Message.ENCRYPTION_DECRYPTED) {
5018            fingerprint = "pgp";
5019        } else {
5020            fingerprint = message.getFingerprint();
5021        }
5022        final PopupMenu popupMenu = new PopupMenu(getActivity(), v);
5023        final Contact contact = message.getContact();
5024        if (message.getStatus() <= Message.STATUS_RECEIVED
5025                && (contact == null || !contact.isSelf())) {
5026            if (message.getConversation().getMode() == Conversation.MODE_MULTI) {
5027                final Jid cp = message.getCounterpart();
5028                if (cp == null || cp.isBareJid()) {
5029                    return;
5030                }
5031                final Jid tcp = message.getTrueCounterpart();
5032                final String occupantId = message.getOccupantId();
5033                final User userByRealJid =
5034                        tcp != null
5035                                ? conversation.getMucOptions().findOrCreateUserByRealJid(tcp, cp, occupantId)
5036                                : null;
5037                final User userByOccupantId =
5038                        occupantId != null
5039                                ? conversation.getMucOptions().findUserByOccupantId(occupantId, cp)
5040                                : null;
5041                final User user =
5042                        userByRealJid != null
5043                                ? userByRealJid
5044                                : (userByOccupantId != null ? userByOccupantId : conversation.getMucOptions().findUserByFullJid(cp));
5045                if (user == null) return;
5046                popupMenu.inflate(R.menu.muc_details_context);
5047                final Menu menu = popupMenu.getMenu();
5048                MucDetailsContextMenuHelper.configureMucDetailsContextMenu(
5049                        activity, menu, conversation, user);
5050                popupMenu.setOnMenuItemClickListener(
5051                        menuItem ->
5052                                MucDetailsContextMenuHelper.onContextItemSelected(
5053                                        menuItem, user, activity, fingerprint));
5054            } else {
5055                popupMenu.inflate(R.menu.one_on_one_context);
5056                popupMenu.setOnMenuItemClickListener(
5057                        item -> {
5058                            switch (item.getItemId()) {
5059                                case R.id.action_contact_details:
5060                                    activity.switchToContactDetails(
5061                                            message.getContact(), fingerprint);
5062                                    break;
5063                                case R.id.action_show_qr_code:
5064                                    activity.showQrCode(
5065                                            "xmpp:"
5066                                                    + message.getContact()
5067                                                            .getJid()
5068                                                            .asBareJid()
5069                                                            .toString());
5070                                    break;
5071                            }
5072                            return true;
5073                        });
5074            }
5075        } else {
5076            popupMenu.inflate(R.menu.account_context);
5077            final Menu menu = popupMenu.getMenu();
5078            menu.findItem(R.id.action_manage_accounts)
5079                    .setVisible(QuickConversationsService.isConversations());
5080            popupMenu.setOnMenuItemClickListener(
5081                    item -> {
5082                        final XmppActivity activity = this.activity;
5083                        if (activity == null) {
5084                            Log.e(Config.LOGTAG, "Unable to perform action. no context provided");
5085                            return true;
5086                        }
5087                        switch (item.getItemId()) {
5088                            case R.id.action_show_qr_code:
5089                                activity.showQrCode(conversation.getAccount().getShareableUri());
5090                                break;
5091                            case R.id.action_account_details:
5092                                activity.switchToAccount(
5093                                        message.getConversation().getAccount(), fingerprint);
5094                                break;
5095                            case R.id.action_manage_accounts:
5096                                AccountUtils.launchManageAccounts(activity);
5097                                break;
5098                        }
5099                        return true;
5100                    });
5101        }
5102        popupMenu.show();
5103    }
5104
5105    @Override
5106    public void onContactPictureClicked(Message message) {
5107        setThread(message.getThread());
5108        if (message.isPrivateMessage()) {
5109            privateMessageWith(message.getCounterpart());
5110            return;
5111        }
5112        forkNullThread(message);
5113        conversation.setUserSelectedThread(true);
5114
5115        final boolean received = message.getStatus() <= Message.STATUS_RECEIVED;
5116        if (received) {
5117            if (message.getConversation() instanceof Conversation
5118                    && message.getConversation().getMode() == Conversation.MODE_MULTI) {
5119                Jid tcp = message.getTrueCounterpart();
5120                Jid user = message.getCounterpart();
5121                if (user != null && !user.isBareJid()) {
5122                    final MucOptions mucOptions =
5123                            ((Conversation) message.getConversation()).getMucOptions();
5124                    if (mucOptions.participating()
5125                            || ((Conversation) message.getConversation()).getNextCounterpart()
5126                                    != null) {
5127                        MucOptions.User mucUser = mucOptions.findUserByFullJid(user);
5128                        MucOptions.User tcpMucUser = mucOptions.findUserByRealJid(tcp == null ? null : tcp.asBareJid());
5129                        if (mucUser == null && tcpMucUser == null) {
5130                            Toast.makeText(
5131                                            getActivity(),
5132                                            activity.getString(
5133                                                    R.string.user_has_left_conference,
5134                                                    user.getResource()),
5135                                            Toast.LENGTH_SHORT)
5136                                    .show();
5137                        }
5138                        highlightInConference(mucUser == null || mucUser.getNick() == null ? (tcpMucUser == null || tcpMucUser.getNick() == null ? user.getResource() : tcpMucUser.getNick()) : mucUser.getNick());
5139                    } else {
5140                        Toast.makeText(
5141                                        getActivity(),
5142                                        R.string.you_are_not_participating,
5143                                        Toast.LENGTH_SHORT)
5144                                .show();
5145                    }
5146                }
5147            }
5148        }
5149    }
5150
5151    private Activity requireActivity() {
5152        Activity activity = getActivity();
5153        if (activity == null) activity = this.activity;
5154        if (activity == null) {
5155            throw new IllegalStateException("Activity not attached");
5156        }
5157        return activity;
5158    }
5159}