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