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