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.Fragment;
16import android.app.FragmentManager;
17import android.app.PendingIntent;
18import android.content.ActivityNotFoundException;
19import android.content.Context;
20import android.content.DialogInterface;
21import android.content.Intent;
22import android.content.IntentSender.SendIntentException;
23import android.content.SharedPreferences;
24import android.content.pm.PackageManager;
25import android.content.res.ColorStateList;
26import android.net.Uri;
27import android.os.Build;
28import android.os.Bundle;
29import android.os.Handler;
30import android.os.SystemClock;
31import android.preference.PreferenceManager;
32import android.provider.MediaStore;
33import android.text.Editable;
34import android.text.TextUtils;
35import android.util.Log;
36import android.view.ContextMenu;
37import android.view.ContextMenu.ContextMenuInfo;
38import android.view.Gravity;
39import android.view.LayoutInflater;
40import android.view.Menu;
41import android.view.MenuInflater;
42import android.view.MenuItem;
43import android.view.MotionEvent;
44import android.view.View;
45import android.view.View.OnClickListener;
46import android.view.ViewGroup;
47import android.view.inputmethod.EditorInfo;
48import android.view.inputmethod.InputMethodManager;
49import android.widget.AbsListView;
50import android.widget.AbsListView.OnScrollListener;
51import android.widget.AdapterView;
52import android.widget.AdapterView.AdapterContextMenuInfo;
53import android.widget.CheckBox;
54import android.widget.ListView;
55import android.widget.PopupMenu;
56import android.widget.TextView.OnEditorActionListener;
57import android.widget.Toast;
58import androidx.annotation.IdRes;
59import androidx.annotation.NonNull;
60import androidx.annotation.StringRes;
61import androidx.core.view.inputmethod.InputConnectionCompat;
62import androidx.core.view.inputmethod.InputContentInfoCompat;
63import androidx.databinding.DataBindingUtil;
64import com.google.android.material.dialog.MaterialAlertDialogBuilder;
65import com.google.common.base.Optional;
66import com.google.common.collect.ImmutableList;
67import com.google.common.collect.Iterables;
68import de.gultsch.common.Linkify;
69import de.gultsch.common.Patterns;
70import eu.siacs.conversations.Config;
71import eu.siacs.conversations.R;
72import eu.siacs.conversations.crypto.axolotl.AxolotlService;
73import eu.siacs.conversations.crypto.axolotl.FingerprintStatus;
74import eu.siacs.conversations.databinding.FragmentConversationBinding;
75import eu.siacs.conversations.entities.Account;
76import eu.siacs.conversations.entities.Blockable;
77import eu.siacs.conversations.entities.Contact;
78import eu.siacs.conversations.entities.Conversation;
79import eu.siacs.conversations.entities.Conversational;
80import eu.siacs.conversations.entities.DownloadableFile;
81import eu.siacs.conversations.entities.Message;
82import eu.siacs.conversations.entities.MucOptions;
83import eu.siacs.conversations.entities.MucOptions.User;
84import eu.siacs.conversations.entities.ReadByMarker;
85import eu.siacs.conversations.entities.Transferable;
86import eu.siacs.conversations.entities.TransferablePlaceholder;
87import eu.siacs.conversations.http.HttpDownloadConnection;
88import eu.siacs.conversations.persistance.FileBackend;
89import eu.siacs.conversations.services.CallIntegrationConnectionService;
90import eu.siacs.conversations.services.MessageArchiveService;
91import eu.siacs.conversations.services.QuickConversationsService;
92import eu.siacs.conversations.services.XmppConnectionService;
93import eu.siacs.conversations.ui.adapter.MediaPreviewAdapter;
94import eu.siacs.conversations.ui.adapter.MessageAdapter;
95import eu.siacs.conversations.ui.util.ActivityResult;
96import eu.siacs.conversations.ui.util.Attachment;
97import eu.siacs.conversations.ui.util.ConversationMenuConfigurator;
98import eu.siacs.conversations.ui.util.DateSeparator;
99import eu.siacs.conversations.ui.util.EditMessageActionModeCallback;
100import eu.siacs.conversations.ui.util.ListViewUtils;
101import eu.siacs.conversations.ui.util.MenuDoubleTabUtil;
102import eu.siacs.conversations.ui.util.MucDetailsContextMenuHelper;
103import eu.siacs.conversations.ui.util.PendingItem;
104import eu.siacs.conversations.ui.util.PresenceSelector;
105import eu.siacs.conversations.ui.util.ScrollState;
106import eu.siacs.conversations.ui.util.SendButtonAction;
107import eu.siacs.conversations.ui.util.SendButtonTool;
108import eu.siacs.conversations.ui.util.ShareUtil;
109import eu.siacs.conversations.ui.util.ViewUtil;
110import eu.siacs.conversations.ui.widget.EditMessage;
111import eu.siacs.conversations.utils.AccountUtils;
112import eu.siacs.conversations.utils.Compatibility;
113import eu.siacs.conversations.utils.GeoHelper;
114import eu.siacs.conversations.utils.MessageUtils;
115import eu.siacs.conversations.utils.NickValidityChecker;
116import eu.siacs.conversations.utils.PermissionUtils;
117import eu.siacs.conversations.utils.QuickLoader;
118import eu.siacs.conversations.utils.StylingHelper;
119import eu.siacs.conversations.utils.TimeFrameUtils;
120import eu.siacs.conversations.utils.UIHelper;
121import eu.siacs.conversations.xmpp.Jid;
122import eu.siacs.conversations.xmpp.XmppConnection;
123import eu.siacs.conversations.xmpp.chatstate.ChatState;
124import eu.siacs.conversations.xmpp.jingle.AbstractJingleConnection;
125import eu.siacs.conversations.xmpp.jingle.JingleConnectionManager;
126import eu.siacs.conversations.xmpp.jingle.JingleFileTransferConnection;
127import eu.siacs.conversations.xmpp.jingle.Media;
128import eu.siacs.conversations.xmpp.jingle.OngoingRtpSession;
129import eu.siacs.conversations.xmpp.jingle.RtpCapability;
130import eu.siacs.conversations.xmpp.manager.HttpUploadManager;
131import eu.siacs.conversations.xmpp.manager.PresenceManager;
132import im.conversations.android.xmpp.model.stanza.Presence;
133import java.util.ArrayList;
134import java.util.Arrays;
135import java.util.Collection;
136import java.util.Collections;
137import java.util.HashSet;
138import java.util.Iterator;
139import java.util.List;
140import java.util.Objects;
141import java.util.Set;
142import java.util.UUID;
143import java.util.concurrent.atomic.AtomicBoolean;
144
145public class ConversationFragment extends XmppFragment
146 implements EditMessage.KeyboardListener,
147 MessageAdapter.OnContactPictureLongClicked,
148 MessageAdapter.OnContactPictureClicked {
149
150 public static final int REQUEST_SEND_MESSAGE = 0x0201;
151 public static final int REQUEST_DECRYPT_PGP = 0x0202;
152 public static final int REQUEST_ENCRYPT_MESSAGE = 0x0207;
153 public static final int REQUEST_TRUST_KEYS_TEXT = 0x0208;
154 public static final int REQUEST_TRUST_KEYS_ATTACHMENTS = 0x0209;
155 public static final int REQUEST_START_DOWNLOAD = 0x0210;
156 public static final int REQUEST_ADD_EDITOR_CONTENT = 0x0211;
157 public static final int REQUEST_COMMIT_ATTACHMENTS = 0x0212;
158 public static final int REQUEST_START_AUDIO_CALL = 0x213;
159 public static final int REQUEST_START_VIDEO_CALL = 0x214;
160 public static final int ATTACHMENT_CHOICE_CHOOSE_IMAGE = 0x0301;
161 public static final int ATTACHMENT_CHOICE_TAKE_PHOTO = 0x0302;
162 public static final int ATTACHMENT_CHOICE_CHOOSE_FILE = 0x0303;
163 public static final int ATTACHMENT_CHOICE_RECORD_VOICE = 0x0304;
164 public static final int ATTACHMENT_CHOICE_LOCATION = 0x0305;
165 public static final int ATTACHMENT_CHOICE_INVALID = 0x0306;
166 public static final int ATTACHMENT_CHOICE_RECORD_VIDEO = 0x0307;
167
168 public static final String RECENTLY_USED_QUICK_ACTION = "recently_used_quick_action";
169 public static final String STATE_CONVERSATION_UUID =
170 ConversationFragment.class.getName() + ".uuid";
171 public static final String STATE_SCROLL_POSITION =
172 ConversationFragment.class.getName() + ".scroll_position";
173 public static final String STATE_PHOTO_URI =
174 ConversationFragment.class.getName() + ".media_previews";
175 public static final String STATE_MEDIA_PREVIEWS =
176 ConversationFragment.class.getName() + ".take_photo_uri";
177 private static final String STATE_LAST_MESSAGE_UUID = "state_last_message_uuid";
178
179 private final List<Message> messageList = new ArrayList<>();
180 private final PendingItem<ActivityResult> postponedActivityResult = new PendingItem<>();
181 private final PendingItem<String> pendingConversationsUuid = new PendingItem<>();
182 private final PendingItem<ArrayList<Attachment>> pendingMediaPreviews = new PendingItem<>();
183 private final PendingItem<Bundle> pendingExtras = new PendingItem<>();
184 private final PendingItem<Uri> pendingTakePhotoUri = new PendingItem<>();
185 private final PendingItem<ScrollState> pendingScrollState = new PendingItem<>();
186 private final PendingItem<String> pendingLastMessageUuid = new PendingItem<>();
187 private final PendingItem<Message> pendingMessage = new PendingItem<>();
188 public Uri mPendingEditorContent = null;
189 protected MessageAdapter messageListAdapter;
190 private MediaPreviewAdapter mediaPreviewAdapter;
191 private String lastMessageUuid = null;
192 private Conversation conversation;
193 private FragmentConversationBinding binding;
194 private Toast messageLoaderToast;
195 private ConversationsActivity activity;
196 private boolean reInitRequiredOnStart = true;
197 private final OnClickListener clickToMuc =
198 new OnClickListener() {
199
200 @Override
201 public void onClick(View v) {
202 ConferenceDetailsActivity.open(getActivity(), conversation);
203 }
204 };
205 private final OnClickListener leaveMuc =
206 new OnClickListener() {
207
208 @Override
209 public void onClick(View v) {
210 activity.xmppConnectionService.archiveConversation(conversation);
211 }
212 };
213 private final OnClickListener joinMuc =
214 new OnClickListener() {
215
216 @Override
217 public void onClick(View v) {
218 activity.xmppConnectionService.joinMuc(conversation);
219 }
220 };
221
222 private final OnClickListener acceptJoin =
223 new OnClickListener() {
224 @Override
225 public void onClick(View v) {
226 conversation.setAttribute("accept_non_anonymous", true);
227 activity.xmppConnectionService.updateConversation(conversation);
228 activity.xmppConnectionService.joinMuc(conversation);
229 }
230 };
231
232 private final OnClickListener enterPassword =
233 new OnClickListener() {
234
235 @Override
236 public void onClick(View v) {
237 MucOptions muc = conversation.getMucOptions();
238 String password = muc.getPassword();
239 if (password == null) {
240 password = "";
241 }
242 activity.quickPasswordEdit(
243 password,
244 value -> {
245 activity.xmppConnectionService.providePasswordForMuc(
246 conversation, value);
247 return null;
248 });
249 }
250 };
251 private final OnScrollListener mOnScrollListener =
252 new OnScrollListener() {
253
254 @Override
255 public void onScrollStateChanged(AbsListView view, int scrollState) {
256 if (AbsListView.OnScrollListener.SCROLL_STATE_IDLE == scrollState) {
257 fireReadEvent();
258 }
259 }
260
261 @Override
262 public void onScroll(
263 final AbsListView view,
264 int firstVisibleItem,
265 int visibleItemCount,
266 int totalItemCount) {
267 toggleScrollDownButton(view);
268 synchronized (ConversationFragment.this.messageList) {
269 if (firstVisibleItem < 5
270 && conversation != null
271 && conversation.messagesLoaded.compareAndSet(true, false)
272 && messageList.size() > 0) {
273 long timestamp;
274 if (messageList.get(0).getType() == Message.TYPE_STATUS
275 && messageList.size() >= 2) {
276 timestamp = messageList.get(1).getTimeSent();
277 } else {
278 timestamp = messageList.get(0).getTimeSent();
279 }
280 activity.xmppConnectionService.loadMoreMessages(
281 conversation,
282 timestamp,
283 new XmppConnectionService.OnMoreMessagesLoaded() {
284 @Override
285 public void onMoreMessagesLoaded(
286 final int c, final Conversation conversation) {
287 if (ConversationFragment.this.conversation
288 != conversation) {
289 conversation.messagesLoaded.set(true);
290 return;
291 }
292 runOnUiThread(
293 () -> {
294 synchronized (messageList) {
295 final int oldPosition =
296 binding.messagesView
297 .getFirstVisiblePosition();
298 Message message = null;
299 int childPos;
300 for (childPos = 0;
301 childPos + oldPosition
302 < messageList.size();
303 ++childPos) {
304 message =
305 messageList.get(
306 oldPosition
307 + childPos);
308 if (message.getType()
309 != Message.TYPE_STATUS) {
310 break;
311 }
312 }
313 final String uuid =
314 message != null
315 ? message.getUuid()
316 : null;
317 View v =
318 binding.messagesView.getChildAt(
319 childPos);
320 final int pxOffset =
321 (v == null) ? 0 : v.getTop();
322 ConversationFragment.this.conversation
323 .populateWithMessages(
324 ConversationFragment
325 .this
326 .messageList);
327 try {
328 updateStatusMessages();
329 } catch (IllegalStateException e) {
330 Log.d(
331 Config.LOGTAG,
332 "caught illegal state"
333 + " exception while"
334 + " updating status"
335 + " messages");
336 }
337 messageListAdapter
338 .notifyDataSetChanged();
339 int pos =
340 Math.max(
341 getIndexOf(
342 uuid,
343 messageList),
344 0);
345 binding.messagesView
346 .setSelectionFromTop(
347 pos, pxOffset);
348 if (messageLoaderToast != null) {
349 messageLoaderToast.cancel();
350 }
351 conversation.messagesLoaded.set(true);
352 }
353 });
354 }
355
356 @Override
357 public void informUser(final int resId) {
358
359 runOnUiThread(
360 () -> {
361 if (messageLoaderToast != null) {
362 messageLoaderToast.cancel();
363 }
364 if (ConversationFragment.this.conversation
365 != conversation) {
366 return;
367 }
368 messageLoaderToast =
369 Toast.makeText(
370 view.getContext(),
371 resId,
372 Toast.LENGTH_LONG);
373 messageLoaderToast.show();
374 });
375 }
376 });
377 }
378 }
379 }
380 };
381 private final EditMessage.OnCommitContentListener mEditorContentListener =
382 new EditMessage.OnCommitContentListener() {
383 @Override
384 public boolean onCommitContent(
385 InputContentInfoCompat inputContentInfo,
386 int flags,
387 Bundle opts,
388 String[] contentMimeTypes) {
389 // try to get permission to read the image, if applicable
390 if ((flags & InputConnectionCompat.INPUT_CONTENT_GRANT_READ_URI_PERMISSION)
391 != 0) {
392 try {
393 inputContentInfo.requestPermission();
394 } catch (Exception e) {
395 Log.e(
396 Config.LOGTAG,
397 "InputContentInfoCompat#requestPermission() failed.",
398 e);
399 Toast.makeText(
400 getActivity(),
401 activity.getString(
402 R.string.no_permission_to_access_x,
403 inputContentInfo.getDescription()),
404 Toast.LENGTH_LONG)
405 .show();
406 return false;
407 }
408 }
409 if (hasPermissions(
410 REQUEST_ADD_EDITOR_CONTENT,
411 Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
412 attachEditorContentToConversation(inputContentInfo.getContentUri());
413 } else {
414 mPendingEditorContent = inputContentInfo.getContentUri();
415 }
416 return true;
417 }
418 };
419 private Message selectedMessage;
420 private final OnClickListener mEnableAccountListener =
421 new OnClickListener() {
422 @Override
423 public void onClick(View v) {
424 final Account account = conversation == null ? null : conversation.getAccount();
425 if (account != null) {
426 account.setOption(Account.OPTION_SOFT_DISABLED, false);
427 account.setOption(Account.OPTION_DISABLED, false);
428 activity.xmppConnectionService.updateAccount(account);
429 }
430 }
431 };
432 private final OnClickListener mUnblockClickListener =
433 new OnClickListener() {
434 @Override
435 public void onClick(final View v) {
436 v.post(() -> v.setVisibility(View.INVISIBLE));
437 if (conversation.isDomainBlocked()) {
438 BlockContactDialog.show(activity, conversation);
439 } else {
440 unblockConversation(conversation);
441 }
442 }
443 };
444 private final OnClickListener mBlockClickListener = this::showBlockSubmenu;
445 private final OnClickListener mAddBackClickListener =
446 new OnClickListener() {
447
448 @Override
449 public void onClick(View v) {
450 final Contact contact = conversation == null ? null : conversation.getContact();
451 if (contact != null) {
452 activity.xmppConnectionService.createContact(contact);
453 activity.switchToContactDetails(contact);
454 }
455 }
456 };
457 private final View.OnLongClickListener mLongPressBlockListener = this::showBlockSubmenu;
458 private final OnClickListener mAllowPresenceSubscription =
459 new OnClickListener() {
460 @Override
461 public void onClick(View v) {
462 final Contact contact = conversation == null ? null : conversation.getContact();
463 if (contact != null) {
464 final var connection = contact.getAccount().getXmppConnection();
465 connection
466 .getManager(PresenceManager.class)
467 .subscribed(contact.getJid().asBareJid());
468 hideSnackbar();
469 }
470 }
471 };
472 protected OnClickListener clickToDecryptListener =
473 new OnClickListener() {
474
475 @Override
476 public void onClick(View v) {
477 PendingIntent pendingIntent =
478 conversation.getAccount().getPgpDecryptionService().getPendingIntent();
479 if (pendingIntent != null) {
480 try {
481 getActivity()
482 .startIntentSenderForResult(
483 pendingIntent.getIntentSender(),
484 REQUEST_DECRYPT_PGP,
485 null,
486 0,
487 0,
488 0,
489 Compatibility.pgpStartIntentSenderOptions());
490 } catch (SendIntentException e) {
491 Toast.makeText(
492 getActivity(),
493 R.string.unable_to_connect_to_keychain,
494 Toast.LENGTH_SHORT)
495 .show();
496 conversation
497 .getAccount()
498 .getPgpDecryptionService()
499 .continueDecryption(true);
500 }
501 }
502 updateSnackBar(conversation);
503 }
504 };
505 private final AtomicBoolean mSendingPgpMessage = new AtomicBoolean(false);
506 private final OnEditorActionListener mEditorActionListener =
507 (v, actionId, event) -> {
508 if (actionId == EditorInfo.IME_ACTION_SEND) {
509 InputMethodManager imm =
510 (InputMethodManager)
511 activity.getSystemService(Context.INPUT_METHOD_SERVICE);
512 if (imm != null && imm.isFullscreenMode()) {
513 imm.hideSoftInputFromWindow(v.getWindowToken(), 0);
514 }
515 sendMessage();
516 return true;
517 } else {
518 return false;
519 }
520 };
521 private final OnClickListener mScrollButtonListener =
522 new OnClickListener() {
523
524 @Override
525 public void onClick(View v) {
526 stopScrolling();
527 setSelection(binding.messagesView.getCount() - 1, true);
528 }
529 };
530 private final OnClickListener mSendButtonListener =
531 new OnClickListener() {
532
533 @Override
534 public void onClick(View v) {
535 Object tag = v.getTag();
536 if (tag instanceof SendButtonAction action) {
537 switch (action) {
538 case TAKE_PHOTO:
539 case RECORD_VIDEO:
540 case SEND_LOCATION:
541 case RECORD_VOICE:
542 case CHOOSE_PICTURE:
543 attachFile(action.toChoice());
544 break;
545 case CANCEL:
546 if (conversation != null) {
547 if (conversation.setCorrectingMessage(null)) {
548 binding.textinput.setText("");
549 binding.textinput.append(conversation.getDraftMessage());
550 conversation.setDraftMessage(null);
551 } else if (conversation.getMode() == Conversation.MODE_MULTI) {
552 conversation.setNextCounterpart(null);
553 binding.textinput.setText("");
554 } else {
555 binding.textinput.setText("");
556 }
557 updateChatMsgHint();
558 updateSendButton();
559 updateEditablity();
560 }
561 break;
562 default:
563 sendMessage();
564 }
565 } else {
566 sendMessage();
567 }
568 }
569 };
570 private int completionIndex = 0;
571 private int lastCompletionLength = 0;
572 private String incomplete;
573 private int lastCompletionCursor;
574 private boolean firstWord = false;
575 private Message mPendingDownloadableMessage;
576
577 private static ConversationFragment findConversationFragment(Activity activity) {
578 Fragment fragment = activity.getFragmentManager().findFragmentById(R.id.main_fragment);
579 if (fragment instanceof ConversationFragment) {
580 return (ConversationFragment) fragment;
581 }
582 fragment = activity.getFragmentManager().findFragmentById(R.id.secondary_fragment);
583 if (fragment instanceof ConversationFragment) {
584 return (ConversationFragment) fragment;
585 }
586 return null;
587 }
588
589 public static void startStopPending(Activity activity) {
590 ConversationFragment fragment = findConversationFragment(activity);
591 if (fragment != null) {
592 fragment.messageListAdapter.startStopPending();
593 }
594 }
595
596 public static void downloadFile(Activity activity, Message message) {
597 ConversationFragment fragment = findConversationFragment(activity);
598 if (fragment != null) {
599 fragment.startDownloadable(message);
600 }
601 }
602
603 public static void registerPendingMessage(Activity activity, Message message) {
604 ConversationFragment fragment = findConversationFragment(activity);
605 if (fragment != null) {
606 fragment.pendingMessage.push(message);
607 }
608 }
609
610 public static void openPendingMessage(Activity activity) {
611 ConversationFragment fragment = findConversationFragment(activity);
612 if (fragment != null) {
613 Message message = fragment.pendingMessage.pop();
614 if (message != null) {
615 fragment.messageListAdapter.openDownloadable(message);
616 }
617 }
618 }
619
620 public static Conversation getConversation(Activity activity) {
621 return getConversation(activity, R.id.secondary_fragment);
622 }
623
624 private static Conversation getConversation(Activity activity, @IdRes int res) {
625 final Fragment fragment = activity.getFragmentManager().findFragmentById(res);
626 if (fragment instanceof ConversationFragment) {
627 return ((ConversationFragment) fragment).getConversation();
628 } else {
629 return null;
630 }
631 }
632
633 public static ConversationFragment get(Activity activity) {
634 FragmentManager fragmentManager = activity.getFragmentManager();
635 Fragment fragment = fragmentManager.findFragmentById(R.id.main_fragment);
636 if (fragment instanceof ConversationFragment) {
637 return (ConversationFragment) fragment;
638 } else {
639 fragment = fragmentManager.findFragmentById(R.id.secondary_fragment);
640 return fragment instanceof ConversationFragment
641 ? (ConversationFragment) fragment
642 : null;
643 }
644 }
645
646 public static Conversation getConversationReliable(Activity activity) {
647 final Conversation conversation = getConversation(activity, R.id.secondary_fragment);
648 if (conversation != null) {
649 return conversation;
650 }
651 return getConversation(activity, R.id.main_fragment);
652 }
653
654 private static boolean scrolledToBottom(AbsListView listView) {
655 final int count = listView.getCount();
656 if (count == 0) {
657 return true;
658 } else if (listView.getLastVisiblePosition() == count - 1) {
659 final View lastChild = listView.getChildAt(listView.getChildCount() - 1);
660 return lastChild != null && lastChild.getBottom() <= listView.getHeight();
661 } else {
662 return false;
663 }
664 }
665
666 private void toggleScrollDownButton() {
667 toggleScrollDownButton(binding.messagesView);
668 }
669
670 private void toggleScrollDownButton(AbsListView listView) {
671 if (conversation == null) {
672 return;
673 }
674 if (scrolledToBottom(listView)) {
675 lastMessageUuid = null;
676 hideUnreadMessagesCount();
677 } else {
678 binding.scrollToBottomButton.setEnabled(true);
679 binding.scrollToBottomButton.show();
680 if (lastMessageUuid == null) {
681 lastMessageUuid = conversation.getLatestMessage().getUuid();
682 }
683 if (conversation.getReceivedMessagesCountSinceUuid(lastMessageUuid) > 0) {
684 binding.unreadCountCustomView.setVisibility(View.VISIBLE);
685 }
686 }
687 }
688
689 private int getIndexOf(String uuid, List<Message> messages) {
690 if (uuid == null) {
691 return messages.size() - 1;
692 }
693 for (int i = 0; i < messages.size(); ++i) {
694 if (uuid.equals(messages.get(i).getUuid())) {
695 return i;
696 }
697 }
698 return -1;
699 }
700
701 private ScrollState getScrollPosition() {
702 final ListView listView = this.binding == null ? null : this.binding.messagesView;
703 if (listView == null
704 || listView.getCount() == 0
705 || listView.getLastVisiblePosition() == listView.getCount() - 1) {
706 return null;
707 } else {
708 final int pos = listView.getFirstVisiblePosition();
709 final View view = listView.getChildAt(0);
710 if (view == null) {
711 return null;
712 } else {
713 return new ScrollState(pos, view.getTop());
714 }
715 }
716 }
717
718 private void setScrollPosition(ScrollState scrollPosition, String lastMessageUuid) {
719 if (scrollPosition != null) {
720
721 this.lastMessageUuid = lastMessageUuid;
722 if (lastMessageUuid != null) {
723 binding.unreadCountCustomView.setUnreadCount(
724 conversation.getReceivedMessagesCountSinceUuid(lastMessageUuid));
725 }
726 // TODO maybe this needs a 'post'
727 this.binding.messagesView.setSelectionFromTop(
728 scrollPosition.position, scrollPosition.offset);
729 toggleScrollDownButton();
730 }
731 }
732
733 private void attachLocationToConversation(Conversation conversation, Uri uri) {
734 if (conversation == null) {
735 return;
736 }
737 activity.xmppConnectionService.attachLocationToConversation(
738 conversation,
739 uri,
740 new UiCallback<Message>() {
741
742 @Override
743 public void success(Message message) {}
744
745 @Override
746 public void error(int errorCode, Message object) {
747 // TODO show possible pgp error
748 }
749
750 @Override
751 public void userInputRequired(PendingIntent pi, Message object) {}
752 });
753 }
754
755 private void attachFileToConversation(Conversation conversation, Uri uri, String type) {
756 if (conversation == null) {
757 return;
758 }
759 final Toast prepareFileToast =
760 Toast.makeText(getActivity(), getText(R.string.preparing_file), Toast.LENGTH_LONG);
761 prepareFileToast.show();
762 activity.delegateUriPermissionsToService(uri);
763 activity.xmppConnectionService.attachFileToConversation(
764 conversation,
765 uri,
766 type,
767 new UiInformableCallback<Message>() {
768 @Override
769 public void inform(final String text) {
770 hidePrepareFileToast(prepareFileToast);
771 runOnUiThread(() -> activity.replaceToast(text));
772 }
773
774 @Override
775 public void success(Message message) {
776 runOnUiThread(() -> activity.hideToast());
777 hidePrepareFileToast(prepareFileToast);
778 }
779
780 @Override
781 public void error(final int errorCode, Message message) {
782 hidePrepareFileToast(prepareFileToast);
783 runOnUiThread(() -> activity.replaceToast(getString(errorCode)));
784 }
785
786 @Override
787 public void userInputRequired(PendingIntent pi, Message message) {
788 hidePrepareFileToast(prepareFileToast);
789 }
790 });
791 }
792
793 public void attachEditorContentToConversation(Uri uri) {
794 mediaPreviewAdapter.addMediaPreviews(
795 Attachment.of(getActivity(), uri, Attachment.Type.FILE));
796 toggleInputMethod();
797 }
798
799 private void attachImageToConversation(Conversation conversation, Uri uri, String type) {
800 if (conversation == null) {
801 return;
802 }
803 final Toast prepareFileToast =
804 Toast.makeText(getActivity(), getText(R.string.preparing_image), Toast.LENGTH_LONG);
805 prepareFileToast.show();
806 activity.delegateUriPermissionsToService(uri);
807 activity.xmppConnectionService.attachImageToConversation(
808 conversation,
809 uri,
810 type,
811 new UiCallback<Message>() {
812
813 @Override
814 public void userInputRequired(PendingIntent pi, Message object) {
815 hidePrepareFileToast(prepareFileToast);
816 }
817
818 @Override
819 public void success(Message message) {
820 hidePrepareFileToast(prepareFileToast);
821 }
822
823 @Override
824 public void error(final int error, final Message message) {
825 hidePrepareFileToast(prepareFileToast);
826 final ConversationsActivity activity = ConversationFragment.this.activity;
827 if (activity == null) {
828 return;
829 }
830 activity.runOnUiThread(() -> activity.replaceToast(getString(error)));
831 }
832 });
833 }
834
835 private void hidePrepareFileToast(final Toast prepareFileToast) {
836 if (prepareFileToast != null && activity != null) {
837 activity.runOnUiThread(prepareFileToast::cancel);
838 }
839 }
840
841 private void sendMessage() {
842 if (mediaPreviewAdapter.hasAttachments()) {
843 commitAttachments();
844 return;
845 }
846 final Editable text = this.binding.textinput.getText();
847 final String body = text == null ? "" : text.toString();
848 final Conversation conversation = this.conversation;
849 if (body.isEmpty() || conversation == null) {
850 return;
851 }
852 if (trustKeysIfNeeded(conversation, REQUEST_TRUST_KEYS_TEXT)) {
853 return;
854 }
855 final Message message;
856 if (conversation.getCorrectingMessage() == null) {
857 message = new Message(conversation, body, conversation.getNextEncryption());
858 Message.configurePrivateMessage(message);
859 } else {
860 message = conversation.getCorrectingMessage();
861 message.setBody(body);
862 message.putEdited(message.getUuid(), message.getServerMsgId());
863 message.setUuid(UUID.randomUUID().toString());
864 }
865 switch (conversation.getNextEncryption()) {
866 case Message.ENCRYPTION_PGP:
867 sendPgpMessage(message);
868 break;
869 default:
870 sendMessage(message);
871 }
872 }
873
874 private boolean trustKeysIfNeeded(final Conversation conversation, final int requestCode) {
875 return conversation.getNextEncryption() == Message.ENCRYPTION_AXOLOTL
876 && trustKeysIfNeeded(requestCode);
877 }
878
879 protected boolean trustKeysIfNeeded(int requestCode) {
880 AxolotlService axolotlService = conversation.getAccount().getAxolotlService();
881 final List<Jid> targets = axolotlService.getCryptoTargets(conversation);
882 boolean hasUnaccepted = !conversation.getAcceptedCryptoTargets().containsAll(targets);
883 boolean hasUndecidedOwn =
884 !axolotlService
885 .getKeysWithTrust(FingerprintStatus.createActiveUndecided())
886 .isEmpty();
887 boolean hasUndecidedContacts =
888 !axolotlService
889 .getKeysWithTrust(FingerprintStatus.createActiveUndecided(), targets)
890 .isEmpty();
891 boolean hasPendingKeys = !axolotlService.findDevicesWithoutSession(conversation).isEmpty();
892 boolean hasNoTrustedKeys = axolotlService.anyTargetHasNoTrustedKeys(targets);
893 boolean downloadInProgress = axolotlService.hasPendingKeyFetches(targets);
894 if (hasUndecidedOwn
895 || hasUndecidedContacts
896 || hasPendingKeys
897 || hasNoTrustedKeys
898 || hasUnaccepted
899 || downloadInProgress) {
900 axolotlService.createSessionsIfNeeded(conversation);
901 Intent intent = new Intent(getActivity(), TrustKeysActivity.class);
902 String[] contacts = new String[targets.size()];
903 for (int i = 0; i < contacts.length; ++i) {
904 contacts[i] = targets.get(i).toString();
905 }
906 intent.putExtra("contacts", contacts);
907 intent.putExtra(
908 EXTRA_ACCOUNT, conversation.getAccount().getJid().asBareJid().toString());
909 intent.putExtra("conversation", conversation.getUuid());
910 startActivityForResult(intent, requestCode);
911 return true;
912 } else {
913 return false;
914 }
915 }
916
917 public void updateChatMsgHint() {
918 final boolean multi = conversation.getMode() == Conversation.MODE_MULTI;
919 if (conversation.getCorrectingMessage() != null) {
920 this.binding.textInputHint.setVisibility(View.GONE);
921 this.binding.textinput.setHint(R.string.send_corrected_message);
922 } else if (multi && conversation.getNextCounterpart() != null) {
923 this.binding.textinput.setHint(R.string.send_unencrypted_message);
924 this.binding.textInputHint.setVisibility(View.VISIBLE);
925 this.binding.textInputHint.setText(
926 getString(
927 R.string.send_private_message_to,
928 conversation.getNextCounterpart().getResource()));
929 } else if (multi && !conversation.getMucOptions().participating()) {
930 this.binding.textInputHint.setVisibility(View.GONE);
931 this.binding.textinput.setHint(R.string.you_are_not_participating);
932 } else {
933 this.binding.textInputHint.setVisibility(View.GONE);
934 this.binding.textinput.setHint(UIHelper.getMessageHint(getActivity(), conversation));
935 getActivity().invalidateOptionsMenu();
936 }
937 }
938
939 public void setupIme() {
940 this.binding.textinput.refreshIme();
941 }
942
943 private void handleActivityResult(ActivityResult activityResult) {
944 if (activityResult.resultCode == Activity.RESULT_OK) {
945 handlePositiveActivityResult(activityResult.requestCode, activityResult.data);
946 } else {
947 handleNegativeActivityResult(activityResult.requestCode);
948 }
949 }
950
951 private void handlePositiveActivityResult(int requestCode, final Intent data) {
952 switch (requestCode) {
953 case REQUEST_TRUST_KEYS_TEXT:
954 sendMessage();
955 break;
956 case REQUEST_TRUST_KEYS_ATTACHMENTS:
957 commitAttachments();
958 break;
959 case REQUEST_START_AUDIO_CALL:
960 triggerRtpSession(RtpSessionActivity.ACTION_MAKE_VOICE_CALL);
961 break;
962 case REQUEST_START_VIDEO_CALL:
963 triggerRtpSession(RtpSessionActivity.ACTION_MAKE_VIDEO_CALL);
964 break;
965 case ATTACHMENT_CHOICE_CHOOSE_IMAGE:
966 final List<Attachment> imageUris =
967 Attachment.extractAttachments(getActivity(), data, Attachment.Type.IMAGE);
968 mediaPreviewAdapter.addMediaPreviews(imageUris);
969 toggleInputMethod();
970 break;
971 case ATTACHMENT_CHOICE_TAKE_PHOTO:
972 final Uri takePhotoUri = pendingTakePhotoUri.pop();
973 if (takePhotoUri != null) {
974 mediaPreviewAdapter.addMediaPreviews(
975 Attachment.of(getActivity(), takePhotoUri, Attachment.Type.IMAGE));
976 toggleInputMethod();
977 } else {
978 Log.d(Config.LOGTAG, "lost take photo uri. unable to to attach");
979 }
980 break;
981 case ATTACHMENT_CHOICE_CHOOSE_FILE:
982 case ATTACHMENT_CHOICE_RECORD_VIDEO:
983 case ATTACHMENT_CHOICE_RECORD_VOICE:
984 final Attachment.Type type =
985 requestCode == ATTACHMENT_CHOICE_RECORD_VOICE
986 ? Attachment.Type.RECORDING
987 : Attachment.Type.FILE;
988 final List<Attachment> fileUris =
989 Attachment.extractAttachments(getActivity(), data, type);
990 mediaPreviewAdapter.addMediaPreviews(fileUris);
991 toggleInputMethod();
992 break;
993 case ATTACHMENT_CHOICE_LOCATION:
994 final double latitude = data.getDoubleExtra("latitude", 0);
995 final double longitude = data.getDoubleExtra("longitude", 0);
996 final int accuracy = data.getIntExtra("accuracy", 0);
997 final Uri geo;
998 if (accuracy > 0) {
999 geo = Uri.parse(String.format("geo:%s,%s;u=%s", latitude, longitude, accuracy));
1000 } else {
1001 geo = Uri.parse(String.format("geo:%s,%s", latitude, longitude));
1002 }
1003 mediaPreviewAdapter.addMediaPreviews(
1004 Attachment.of(getActivity(), geo, Attachment.Type.LOCATION));
1005 toggleInputMethod();
1006 break;
1007 case REQUEST_INVITE_TO_CONVERSATION:
1008 XmppActivity.ConferenceInvite invite = XmppActivity.ConferenceInvite.parse(data);
1009 if (invite != null) {
1010 if (invite.execute(activity)) {
1011 activity.mToast =
1012 Toast.makeText(
1013 activity, R.string.creating_conference, Toast.LENGTH_LONG);
1014 activity.mToast.show();
1015 }
1016 }
1017 break;
1018 }
1019 }
1020
1021 private void commitAttachments() {
1022 final List<Attachment> attachments = mediaPreviewAdapter.getAttachments();
1023 if (anyNeedsExternalStoragePermission(attachments)
1024 && !hasPermissions(
1025 REQUEST_COMMIT_ATTACHMENTS, Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
1026 return;
1027 }
1028 if (trustKeysIfNeeded(conversation, REQUEST_TRUST_KEYS_ATTACHMENTS)) {
1029 return;
1030 }
1031 final PresenceSelector.OnPresenceSelected callback =
1032 () -> {
1033 for (Iterator<Attachment> i = attachments.iterator(); i.hasNext(); i.remove()) {
1034 final Attachment attachment = i.next();
1035 if (attachment.getType() == Attachment.Type.LOCATION) {
1036 attachLocationToConversation(conversation, attachment.getUri());
1037 } else if (attachment.getType() == Attachment.Type.IMAGE) {
1038 Log.d(
1039 Config.LOGTAG,
1040 "ConversationsActivity.commitAttachments() - attaching image to"
1041 + " conversations. CHOOSE_IMAGE");
1042 attachImageToConversation(
1043 conversation, attachment.getUri(), attachment.getMime());
1044 } else {
1045 Log.d(
1046 Config.LOGTAG,
1047 "ConversationsActivity.commitAttachments() - attaching file to"
1048 + " conversations. CHOOSE_FILE/RECORD_VOICE/RECORD_VIDEO");
1049 attachFileToConversation(
1050 conversation, attachment.getUri(), attachment.getMime());
1051 }
1052 }
1053 mediaPreviewAdapter.notifyDataSetChanged();
1054 toggleInputMethod();
1055 };
1056 if (conversation == null
1057 || conversation.getMode() == Conversation.MODE_MULTI
1058 || Attachment.canBeSendInBand(attachments)
1059 || (conversation.getAccount().httpUploadAvailable()
1060 && FileBackend.allFilesUnderSize(
1061 getActivity(), attachments, getMaxHttpUploadSize(conversation)))) {
1062 callback.onPresenceSelected();
1063 } else {
1064 activity.selectPresence(conversation, callback);
1065 }
1066 }
1067
1068 private static boolean anyNeedsExternalStoragePermission(
1069 final Collection<Attachment> attachments) {
1070 for (final Attachment attachment : attachments) {
1071 if (attachment.getType() != Attachment.Type.LOCATION) {
1072 return true;
1073 }
1074 }
1075 return false;
1076 }
1077
1078 public void toggleInputMethod() {
1079 boolean hasAttachments = mediaPreviewAdapter.hasAttachments();
1080 binding.textinput.setVisibility(hasAttachments ? View.GONE : View.VISIBLE);
1081 binding.mediaPreview.setVisibility(hasAttachments ? View.VISIBLE : View.GONE);
1082 updateSendButton();
1083 }
1084
1085 private void handleNegativeActivityResult(int requestCode) {
1086 switch (requestCode) {
1087 case ATTACHMENT_CHOICE_TAKE_PHOTO:
1088 if (pendingTakePhotoUri.clear()) {
1089 Log.d(
1090 Config.LOGTAG,
1091 "cleared pending photo uri after negative activity result");
1092 }
1093 break;
1094 }
1095 }
1096
1097 @Override
1098 public void onActivityResult(int requestCode, int resultCode, final Intent data) {
1099 super.onActivityResult(requestCode, resultCode, data);
1100 ActivityResult activityResult = ActivityResult.of(requestCode, resultCode, data);
1101 if (activity != null && activity.xmppConnectionService != null) {
1102 handleActivityResult(activityResult);
1103 } else {
1104 this.postponedActivityResult.push(activityResult);
1105 }
1106 }
1107
1108 public void unblockConversation(final Blockable conversation) {
1109 activity.xmppConnectionService.sendUnblockRequest(conversation);
1110 }
1111
1112 @Override
1113 public void onAttach(Activity activity) {
1114 super.onAttach(activity);
1115 Log.d(Config.LOGTAG, "ConversationFragment.onAttach()");
1116 if (activity instanceof ConversationsActivity) {
1117 this.activity = (ConversationsActivity) activity;
1118 } else {
1119 throw new IllegalStateException(
1120 "Trying to attach fragment to activity that is not the ConversationsActivity");
1121 }
1122 }
1123
1124 @Override
1125 public void onDetach() {
1126 super.onDetach();
1127 this.activity = null; // TODO maybe not a good idea since some callbacks really need it
1128 }
1129
1130 @Override
1131 public void onCreate(Bundle savedInstanceState) {
1132 super.onCreate(savedInstanceState);
1133 setHasOptionsMenu(true);
1134 }
1135
1136 @Override
1137 public void onCreateOptionsMenu(Menu menu, MenuInflater menuInflater) {
1138 menuInflater.inflate(R.menu.fragment_conversation, menu);
1139 final MenuItem menuMucDetails = menu.findItem(R.id.action_muc_details);
1140 final MenuItem menuContactDetails = menu.findItem(R.id.action_contact_details);
1141 final MenuItem menuInviteContact = menu.findItem(R.id.action_invite);
1142 final MenuItem menuMute = menu.findItem(R.id.action_mute);
1143 final MenuItem menuUnmute = menu.findItem(R.id.action_unmute);
1144 final MenuItem menuCall = menu.findItem(R.id.action_call);
1145 final MenuItem menuOngoingCall = menu.findItem(R.id.action_ongoing_call);
1146 final MenuItem menuVideoCall = menu.findItem(R.id.action_video_call);
1147 final MenuItem menuTogglePinned = menu.findItem(R.id.action_toggle_pinned);
1148
1149 if (conversation != null) {
1150 if (conversation.getMode() == Conversation.MODE_MULTI) {
1151 menuContactDetails.setVisible(false);
1152 menuInviteContact.setVisible(conversation.getMucOptions().canInvite());
1153 menuMucDetails.setTitle(
1154 conversation.getMucOptions().isPrivateAndNonAnonymous()
1155 ? R.string.action_muc_details
1156 : R.string.channel_details);
1157 menuCall.setVisible(false);
1158 menuOngoingCall.setVisible(false);
1159 } else {
1160 final XmppConnectionService service =
1161 activity == null ? null : activity.xmppConnectionService;
1162 final Optional<OngoingRtpSession> ongoingRtpSession =
1163 service == null
1164 ? Optional.absent()
1165 : service.getJingleConnectionManager()
1166 .getOngoingRtpConnection(conversation.getContact());
1167 if (ongoingRtpSession.isPresent()) {
1168 menuOngoingCall.setVisible(true);
1169 menuCall.setVisible(false);
1170 } else {
1171 menuOngoingCall.setVisible(false);
1172 // use RtpCapability.check(conversation.getContact()); to check if contact
1173 // actually has support
1174 final boolean cameraAvailable =
1175 activity != null && activity.isCameraFeatureAvailable();
1176 menuCall.setVisible(true);
1177 menuVideoCall.setVisible(cameraAvailable);
1178 }
1179 menuContactDetails.setVisible(!this.conversation.withSelf());
1180 menuMucDetails.setVisible(false);
1181 menuInviteContact.setVisible(
1182 service != null
1183 && service.findConferenceServer(conversation.getAccount()) != null);
1184 }
1185 if (conversation.isMuted()) {
1186 menuMute.setVisible(false);
1187 } else {
1188 menuUnmute.setVisible(false);
1189 }
1190 ConversationMenuConfigurator.configureAttachmentMenu(conversation, menu);
1191 ConversationMenuConfigurator.configureEncryptionMenu(conversation, menu);
1192 if (conversation.getBooleanAttribute(Conversation.ATTRIBUTE_PINNED_ON_TOP, false)) {
1193 menuTogglePinned.setTitle(R.string.remove_from_favorites);
1194 } else {
1195 menuTogglePinned.setTitle(R.string.add_to_favorites);
1196 }
1197 }
1198 super.onCreateOptionsMenu(menu, menuInflater);
1199 }
1200
1201 @Override
1202 public View onCreateView(
1203 final LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
1204 this.binding =
1205 DataBindingUtil.inflate(inflater, R.layout.fragment_conversation, container, false);
1206 binding.getRoot().setOnClickListener(null); // TODO why the fuck did we do this?
1207
1208 binding.textinput.addTextChangedListener(
1209 new StylingHelper.MessageEditorStyler(binding.textinput));
1210
1211 binding.textinput.setOnEditorActionListener(mEditorActionListener);
1212 binding.textinput.setRichContentListener(new String[] {"image/*"}, mEditorContentListener);
1213
1214 binding.textSendButton.setOnClickListener(this.mSendButtonListener);
1215
1216 binding.scrollToBottomButton.setOnClickListener(this.mScrollButtonListener);
1217 binding.messagesView.setOnScrollListener(mOnScrollListener);
1218 binding.messagesView.setTranscriptMode(ListView.TRANSCRIPT_MODE_NORMAL);
1219 mediaPreviewAdapter = new MediaPreviewAdapter(this);
1220 binding.mediaPreview.setAdapter(mediaPreviewAdapter);
1221 messageListAdapter = new MessageAdapter((XmppActivity) getActivity(), this.messageList);
1222 messageListAdapter.setOnContactPictureClicked(this);
1223 messageListAdapter.setOnContactPictureLongClicked(this);
1224 binding.messagesView.setAdapter(messageListAdapter);
1225
1226 registerForContextMenu(binding.messagesView);
1227
1228 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
1229 this.binding.textinput.setCustomInsertionActionModeCallback(
1230 new EditMessageActionModeCallback(this.binding.textinput));
1231 }
1232
1233 return binding.getRoot();
1234 }
1235
1236 @Override
1237 public void onDestroyView() {
1238 super.onDestroyView();
1239 Log.d(Config.LOGTAG, "ConversationFragment.onDestroyView()");
1240 messageListAdapter.setOnContactPictureClicked(null);
1241 messageListAdapter.setOnContactPictureLongClicked(null);
1242 }
1243
1244 private void quoteText(String text) {
1245 if (binding.textinput.isEnabled()) {
1246 binding.textinput.insertAsQuote(text);
1247 binding.textinput.requestFocus();
1248 InputMethodManager inputMethodManager =
1249 (InputMethodManager)
1250 getActivity().getSystemService(Context.INPUT_METHOD_SERVICE);
1251 if (inputMethodManager != null) {
1252 inputMethodManager.showSoftInput(
1253 binding.textinput, InputMethodManager.SHOW_IMPLICIT);
1254 }
1255 }
1256 }
1257
1258 private void quoteMessage(Message message) {
1259 quoteText(MessageUtils.prepareQuote(message));
1260 }
1261
1262 @Override
1263 public void onCreateContextMenu(ContextMenu menu, View v, ContextMenuInfo menuInfo) {
1264 // This should cancel any remaining click events that would otherwise trigger links
1265 v.dispatchTouchEvent(MotionEvent.obtain(0, 0, MotionEvent.ACTION_CANCEL, 0f, 0f, 0));
1266 synchronized (this.messageList) {
1267 super.onCreateContextMenu(menu, v, menuInfo);
1268 AdapterView.AdapterContextMenuInfo acmi = (AdapterContextMenuInfo) menuInfo;
1269 this.selectedMessage = this.messageList.get(acmi.position);
1270 populateContextMenu(menu);
1271 }
1272 }
1273
1274 private void populateContextMenu(final ContextMenu menu) {
1275 final Message m = this.selectedMessage;
1276 final Transferable t = m.getTransferable();
1277 if (m.getType() != Message.TYPE_STATUS && m.getType() != Message.TYPE_RTP_SESSION) {
1278
1279 if (m.getEncryption() == Message.ENCRYPTION_AXOLOTL_NOT_FOR_THIS_DEVICE
1280 || m.getEncryption() == Message.ENCRYPTION_AXOLOTL_FAILED) {
1281 return;
1282 }
1283
1284 if (m.getStatus() == Message.STATUS_RECEIVED
1285 && t != null
1286 && (t.getStatus() == Transferable.STATUS_CANCELLED
1287 || t.getStatus() == Transferable.STATUS_FAILED)) {
1288 return;
1289 }
1290
1291 final boolean deleted = m.isDeleted();
1292 final boolean encrypted =
1293 m.getEncryption() == Message.ENCRYPTION_DECRYPTION_FAILED
1294 || m.getEncryption() == Message.ENCRYPTION_PGP;
1295 final boolean receiving =
1296 m.getStatus() == Message.STATUS_RECEIVED
1297 && (t instanceof JingleFileTransferConnection
1298 || t instanceof HttpDownloadConnection);
1299 activity.getMenuInflater().inflate(R.menu.message_context, menu);
1300 menu.setHeaderTitle(R.string.message_options);
1301 final MenuItem addReaction = menu.findItem(R.id.action_add_reaction);
1302 final MenuItem reportAndBlock = menu.findItem(R.id.action_report_and_block);
1303 final MenuItem openWith = menu.findItem(R.id.open_with);
1304 final MenuItem copyMessage = menu.findItem(R.id.copy_message);
1305 final MenuItem copyLink = menu.findItem(R.id.copy_link);
1306 final MenuItem quoteMessage = menu.findItem(R.id.quote_message);
1307 final MenuItem retryDecryption = menu.findItem(R.id.retry_decryption);
1308 final MenuItem correctMessage = menu.findItem(R.id.correct_message);
1309 final MenuItem shareWith = menu.findItem(R.id.share_with);
1310 final MenuItem sendAgain = menu.findItem(R.id.send_again);
1311 final MenuItem retryAsP2P = menu.findItem(R.id.send_again_as_p2p);
1312 final MenuItem copyUrl = menu.findItem(R.id.copy_url);
1313 final MenuItem downloadFile = menu.findItem(R.id.download_file);
1314 final MenuItem cancelTransmission = menu.findItem(R.id.cancel_transmission);
1315 final MenuItem deleteFile = menu.findItem(R.id.delete_file);
1316 final MenuItem showErrorMessage = menu.findItem(R.id.show_error_message);
1317 final boolean unInitiatedButKnownSize = MessageUtils.unInitiatedButKnownSize(m);
1318 final boolean showError =
1319 m.getStatus() == Message.STATUS_SEND_FAILED
1320 && m.getErrorMessage() != null
1321 && !Message.ERROR_MESSAGE_CANCELLED.equals(m.getErrorMessage());
1322 final Conversational conversational = m.getConversation();
1323 final var connection = conversational.getAccount().getXmppConnection();
1324 if (m.getStatus() == Message.STATUS_RECEIVED
1325 && conversational instanceof Conversation c) {
1326 if (c.isWithStranger()
1327 && m.getServerMsgId() != null
1328 && !c.isBlocked()
1329 && connection != null
1330 && connection.getFeatures().spamReporting()) {
1331 reportAndBlock.setVisible(true);
1332 }
1333 }
1334 if (conversational instanceof Conversation c) {
1335 addReaction.setVisible(
1336 m.getStatus() != Message.STATUS_SEND_FAILED
1337 && !m.isDeleted()
1338 && (c.getMode() == Conversational.MODE_SINGLE
1339 || (c.getMucOptions().occupantId()
1340 && c.getMucOptions().participating())));
1341 } else {
1342 addReaction.setVisible(false);
1343 }
1344 if (!m.isFileOrImage()
1345 && !encrypted
1346 && !m.isGeoUri()
1347 && !m.treatAsDownloadable()
1348 && !unInitiatedButKnownSize
1349 && t == null) {
1350 copyMessage.setVisible(true);
1351 quoteMessage.setVisible(!showError && !MessageUtils.prepareQuote(m).isEmpty());
1352 final var firstUri = Iterables.getFirst(Linkify.getLinks(m.getBody()), null);
1353 if (firstUri != null) {
1354 final var scheme = firstUri.getScheme();
1355 final @StringRes int resForScheme =
1356 switch (scheme) {
1357 case "xmpp" -> R.string.copy_jabber_id;
1358 case "http", "https", "gemini" -> R.string.copy_link;
1359 case "geo" -> R.string.copy_geo_uri;
1360 case "tel" -> R.string.copy_telephone_number;
1361 case "mailto" -> R.string.copy_email_address;
1362 default -> R.string.copy_URI;
1363 };
1364 copyLink.setTitle(resForScheme);
1365 copyLink.setVisible(true);
1366 } else {
1367 copyLink.setVisible(false);
1368 }
1369 }
1370 if (m.getEncryption() == Message.ENCRYPTION_DECRYPTION_FAILED && !deleted) {
1371 retryDecryption.setVisible(true);
1372 }
1373 if (!showError
1374 && m.getType() == Message.TYPE_TEXT
1375 && !m.isGeoUri()
1376 && m.isLastCorrectableMessage()
1377 && m.getConversation() instanceof Conversation) {
1378 correctMessage.setVisible(true);
1379 }
1380 if ((m.isFileOrImage() && !deleted && !receiving)
1381 || (m.getType() == Message.TYPE_TEXT && !m.treatAsDownloadable())
1382 && !unInitiatedButKnownSize
1383 && t == null) {
1384 shareWith.setVisible(true);
1385 }
1386 if (m.getStatus() == Message.STATUS_SEND_FAILED) {
1387 sendAgain.setVisible(true);
1388 final var httpUploadAvailable =
1389 connection != null
1390 && Objects.nonNull(
1391 connection
1392 .getManager(HttpUploadManager.class)
1393 .getService());
1394 final var fileNotUploaded = m.isFileOrImage() && !m.hasFileOnRemoteHost();
1395 final var isPeerOnline =
1396 conversational.getMode() == Conversation.MODE_SINGLE
1397 && (conversational instanceof Conversation c)
1398 && !c.getContact().getPresences().isEmpty();
1399 retryAsP2P.setVisible(fileNotUploaded && isPeerOnline && httpUploadAvailable);
1400 }
1401 if (m.hasFileOnRemoteHost()
1402 || m.isGeoUri()
1403 || m.treatAsDownloadable()
1404 || unInitiatedButKnownSize
1405 || t instanceof HttpDownloadConnection) {
1406 copyUrl.setVisible(true);
1407 }
1408 if (m.isFileOrImage() && deleted && m.hasFileOnRemoteHost()) {
1409 downloadFile.setVisible(true);
1410 downloadFile.setTitle(
1411 activity.getString(
1412 R.string.download_x_file,
1413 UIHelper.getFileDescriptionString(activity, m)));
1414 }
1415 final boolean waitingOfferedSending =
1416 m.getStatus() == Message.STATUS_WAITING
1417 || m.getStatus() == Message.STATUS_UNSEND
1418 || m.getStatus() == Message.STATUS_OFFERED;
1419 final boolean cancelable =
1420 (t != null && !deleted) || waitingOfferedSending && m.needsUploading();
1421 if (cancelable) {
1422 cancelTransmission.setVisible(true);
1423 }
1424 if (m.isFileOrImage() && !deleted && !cancelable) {
1425 final String path = m.getRelativeFilePath();
1426 if (path == null
1427 || !path.startsWith("/")
1428 || FileBackend.inConversationsDirectory(requireActivity(), path)) {
1429 deleteFile.setVisible(true);
1430 deleteFile.setTitle(
1431 activity.getString(
1432 R.string.delete_x_file,
1433 UIHelper.getFileDescriptionString(activity, m)));
1434 }
1435 }
1436 if (showError) {
1437 showErrorMessage.setVisible(true);
1438 }
1439 final String mime = m.isFileOrImage() ? m.getMimeType() : null;
1440 if ((m.isGeoUri() && GeoHelper.openInOsmAnd(getActivity(), m))
1441 || (mime != null && mime.startsWith("audio/"))) {
1442 openWith.setVisible(true);
1443 }
1444 }
1445 }
1446
1447 @Override
1448 public boolean onContextItemSelected(MenuItem item) {
1449 switch (item.getItemId()) {
1450 case R.id.share_with:
1451 ShareUtil.share(activity, selectedMessage);
1452 return true;
1453 case R.id.correct_message:
1454 correctMessage(selectedMessage);
1455 return true;
1456 case R.id.copy_message:
1457 ShareUtil.copyToClipboard(activity, selectedMessage);
1458 return true;
1459 case R.id.copy_link:
1460 ShareUtil.copyLinkToClipboard(activity, selectedMessage);
1461 return true;
1462 case R.id.quote_message:
1463 quoteMessage(selectedMessage);
1464 return true;
1465 case R.id.send_again:
1466 resendMessage(selectedMessage, false);
1467 return true;
1468 case R.id.send_again_as_p2p:
1469 resendMessage(selectedMessage, true);
1470 return true;
1471 case R.id.copy_url:
1472 ShareUtil.copyUrlToClipboard(activity, selectedMessage);
1473 return true;
1474 case R.id.download_file:
1475 startDownloadable(selectedMessage);
1476 return true;
1477 case R.id.cancel_transmission:
1478 cancelTransmission(selectedMessage);
1479 return true;
1480 case R.id.retry_decryption:
1481 retryDecryption(selectedMessage);
1482 return true;
1483 case R.id.delete_file:
1484 deleteFile(selectedMessage);
1485 return true;
1486 case R.id.show_error_message:
1487 showErrorMessage(selectedMessage);
1488 return true;
1489 case R.id.open_with:
1490 openWith(selectedMessage);
1491 return true;
1492 case R.id.action_report_and_block:
1493 reportMessage(selectedMessage);
1494 return true;
1495 case R.id.action_add_reaction:
1496 addReaction(selectedMessage);
1497 return true;
1498 default:
1499 return super.onContextItemSelected(item);
1500 }
1501 }
1502
1503 @Override
1504 public boolean onOptionsItemSelected(final MenuItem item) {
1505 if (MenuDoubleTabUtil.shouldIgnoreTap()) {
1506 return false;
1507 } else if (conversation == null) {
1508 return super.onOptionsItemSelected(item);
1509 }
1510 switch (item.getItemId()) {
1511 case R.id.encryption_choice_axolotl:
1512 case R.id.encryption_choice_pgp:
1513 case R.id.encryption_choice_none:
1514 handleEncryptionSelection(item);
1515 break;
1516 case R.id.attach_choose_picture:
1517 case R.id.attach_take_picture:
1518 case R.id.attach_record_video:
1519 case R.id.attach_choose_file:
1520 case R.id.attach_record_voice:
1521 case R.id.attach_location:
1522 handleAttachmentSelection(item);
1523 break;
1524 case R.id.action_search:
1525 startSearch();
1526 break;
1527 case R.id.action_archive:
1528 activity.xmppConnectionService.archiveConversation(conversation);
1529 break;
1530 case R.id.action_contact_details:
1531 activity.switchToContactDetails(conversation.getContact());
1532 break;
1533 case R.id.action_muc_details:
1534 ConferenceDetailsActivity.open(getActivity(), conversation);
1535 break;
1536 case R.id.action_invite:
1537 startActivityForResult(
1538 ChooseContactActivity.create(activity, conversation),
1539 REQUEST_INVITE_TO_CONVERSATION);
1540 break;
1541 case R.id.action_clear_history:
1542 clearHistoryDialog(conversation);
1543 break;
1544 case R.id.action_mute:
1545 muteConversationDialog(conversation);
1546 break;
1547 case R.id.action_unmute:
1548 unMuteConversation(conversation);
1549 break;
1550 case R.id.action_block:
1551 case R.id.action_unblock:
1552 final Activity activity = getActivity();
1553 if (activity instanceof XmppActivity) {
1554 BlockContactDialog.show((XmppActivity) activity, conversation);
1555 }
1556 break;
1557 case R.id.action_audio_call:
1558 checkPermissionAndTriggerAudioCall();
1559 break;
1560 case R.id.action_video_call:
1561 checkPermissionAndTriggerVideoCall();
1562 break;
1563 case R.id.action_ongoing_call:
1564 returnToOngoingCall();
1565 break;
1566 case R.id.action_toggle_pinned:
1567 togglePinned();
1568 break;
1569 default:
1570 break;
1571 }
1572 return super.onOptionsItemSelected(item);
1573 }
1574
1575 private void startSearch() {
1576 final Intent intent = new Intent(getActivity(), SearchActivity.class);
1577 intent.putExtra(SearchActivity.EXTRA_CONVERSATION_UUID, conversation.getUuid());
1578 startActivity(intent);
1579 }
1580
1581 private void returnToOngoingCall() {
1582 final Optional<OngoingRtpSession> ongoingRtpSession =
1583 activity.xmppConnectionService
1584 .getJingleConnectionManager()
1585 .getOngoingRtpConnection(conversation.getContact());
1586 if (ongoingRtpSession.isPresent()) {
1587 final OngoingRtpSession id = ongoingRtpSession.get();
1588 final Intent intent = new Intent(getActivity(), RtpSessionActivity.class);
1589 intent.setAction(Intent.ACTION_VIEW);
1590 intent.putExtra(
1591 RtpSessionActivity.EXTRA_ACCOUNT,
1592 id.getAccount().getJid().asBareJid().toString());
1593 intent.putExtra(RtpSessionActivity.EXTRA_WITH, id.getWith().toString());
1594 if (id instanceof AbstractJingleConnection) {
1595 intent.putExtra(RtpSessionActivity.EXTRA_SESSION_ID, id.getSessionId());
1596 startActivity(intent);
1597 } else if (id instanceof JingleConnectionManager.RtpSessionProposal proposal) {
1598 if (Media.audioOnly(proposal.media)) {
1599 intent.putExtra(
1600 RtpSessionActivity.EXTRA_LAST_ACTION,
1601 RtpSessionActivity.ACTION_MAKE_VOICE_CALL);
1602 } else {
1603 intent.putExtra(
1604 RtpSessionActivity.EXTRA_LAST_ACTION,
1605 RtpSessionActivity.ACTION_MAKE_VIDEO_CALL);
1606 }
1607 intent.putExtra(RtpSessionActivity.EXTRA_PROPOSED_SESSION_ID, proposal.sessionId);
1608 startActivity(intent);
1609 }
1610 }
1611 }
1612
1613 private void togglePinned() {
1614 final boolean pinned =
1615 conversation.getBooleanAttribute(Conversation.ATTRIBUTE_PINNED_ON_TOP, false);
1616 conversation.setAttribute(Conversation.ATTRIBUTE_PINNED_ON_TOP, !pinned);
1617 activity.xmppConnectionService.updateConversation(conversation);
1618 activity.invalidateOptionsMenu();
1619 }
1620
1621 private void checkPermissionAndTriggerAudioCall() {
1622 if (activity.mUseTor || conversation.getAccount().isOnion()) {
1623 Toast.makeText(activity, R.string.disable_tor_to_make_call, Toast.LENGTH_SHORT).show();
1624 return;
1625 }
1626 final List<String> permissions;
1627 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
1628 permissions =
1629 Arrays.asList(
1630 Manifest.permission.RECORD_AUDIO,
1631 Manifest.permission.BLUETOOTH_CONNECT);
1632 } else {
1633 permissions = Collections.singletonList(Manifest.permission.RECORD_AUDIO);
1634 }
1635 if (hasPermissions(REQUEST_START_AUDIO_CALL, permissions)) {
1636 triggerRtpSession(RtpSessionActivity.ACTION_MAKE_VOICE_CALL);
1637 }
1638 }
1639
1640 private void checkPermissionAndTriggerVideoCall() {
1641 if (activity.mUseTor || conversation.getAccount().isOnion()) {
1642 Toast.makeText(activity, R.string.disable_tor_to_make_call, Toast.LENGTH_SHORT).show();
1643 return;
1644 }
1645 final List<String> permissions;
1646 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
1647 permissions =
1648 Arrays.asList(
1649 Manifest.permission.RECORD_AUDIO,
1650 Manifest.permission.CAMERA,
1651 Manifest.permission.BLUETOOTH_CONNECT);
1652 } else {
1653 permissions =
1654 Arrays.asList(Manifest.permission.RECORD_AUDIO, Manifest.permission.CAMERA);
1655 }
1656 if (hasPermissions(REQUEST_START_VIDEO_CALL, permissions)) {
1657 triggerRtpSession(RtpSessionActivity.ACTION_MAKE_VIDEO_CALL);
1658 }
1659 }
1660
1661 private void triggerRtpSession(final String action) {
1662 if (activity.xmppConnectionService.getJingleConnectionManager().isBusy()) {
1663 Toast.makeText(getActivity(), R.string.only_one_call_at_a_time, Toast.LENGTH_LONG)
1664 .show();
1665 return;
1666 }
1667 final Account account = conversation.getAccount();
1668 if (account.setOption(Account.OPTION_SOFT_DISABLED, false)) {
1669 activity.xmppConnectionService.updateAccount(account);
1670 }
1671 final Contact contact = conversation.getContact();
1672 if (Config.USE_JINGLE_MESSAGE_INIT && RtpCapability.jmiSupport(contact)) {
1673 triggerRtpSession(contact.getAccount(), contact.getJid().asBareJid(), action);
1674 } else {
1675 final RtpCapability.Capability capability;
1676 if (action.equals(RtpSessionActivity.ACTION_MAKE_VIDEO_CALL)) {
1677 capability = RtpCapability.Capability.VIDEO;
1678 } else {
1679 capability = RtpCapability.Capability.AUDIO;
1680 }
1681 PresenceSelector.selectFullJidForDirectRtpConnection(
1682 activity,
1683 contact,
1684 capability,
1685 fullJid -> {
1686 triggerRtpSession(contact.getAccount(), fullJid, action);
1687 });
1688 }
1689 }
1690
1691 private void triggerRtpSession(final Account account, final Jid with, final String action) {
1692 CallIntegrationConnectionService.placeCall(
1693 activity.xmppConnectionService,
1694 account,
1695 with,
1696 RtpSessionActivity.actionToMedia(action));
1697 }
1698
1699 private void handleAttachmentSelection(MenuItem item) {
1700 switch (item.getItemId()) {
1701 case R.id.attach_choose_picture:
1702 attachFile(ATTACHMENT_CHOICE_CHOOSE_IMAGE);
1703 break;
1704 case R.id.attach_take_picture:
1705 attachFile(ATTACHMENT_CHOICE_TAKE_PHOTO);
1706 break;
1707 case R.id.attach_record_video:
1708 attachFile(ATTACHMENT_CHOICE_RECORD_VIDEO);
1709 break;
1710 case R.id.attach_choose_file:
1711 attachFile(ATTACHMENT_CHOICE_CHOOSE_FILE);
1712 break;
1713 case R.id.attach_record_voice:
1714 attachFile(ATTACHMENT_CHOICE_RECORD_VOICE);
1715 break;
1716 case R.id.attach_location:
1717 attachFile(ATTACHMENT_CHOICE_LOCATION);
1718 break;
1719 }
1720 }
1721
1722 private void handleEncryptionSelection(MenuItem item) {
1723 if (conversation == null) {
1724 return;
1725 }
1726 final boolean updated;
1727 switch (item.getItemId()) {
1728 case R.id.encryption_choice_none:
1729 updated = conversation.setNextEncryption(Message.ENCRYPTION_NONE);
1730 item.setChecked(true);
1731 break;
1732 case R.id.encryption_choice_pgp:
1733 if (activity.hasPgp()) {
1734 if (conversation.getAccount().getPgpSignature() != null) {
1735 updated = conversation.setNextEncryption(Message.ENCRYPTION_PGP);
1736 item.setChecked(true);
1737 } else {
1738 updated = false;
1739 activity.announcePgp(
1740 conversation.getAccount(),
1741 conversation,
1742 null,
1743 activity.onOpenPGPKeyPublished);
1744 }
1745 } else {
1746 activity.showInstallPgpDialog();
1747 updated = false;
1748 }
1749 break;
1750 case R.id.encryption_choice_axolotl:
1751 Log.d(
1752 Config.LOGTAG,
1753 AxolotlService.getLogprefix(conversation.getAccount())
1754 + "Enabled axolotl for Contact "
1755 + conversation.getContact().getJid());
1756 updated = conversation.setNextEncryption(Message.ENCRYPTION_AXOLOTL);
1757 item.setChecked(true);
1758 break;
1759 default:
1760 updated = conversation.setNextEncryption(Message.ENCRYPTION_NONE);
1761 break;
1762 }
1763 if (updated) {
1764 activity.xmppConnectionService.updateConversation(conversation);
1765 }
1766 updateChatMsgHint();
1767 getActivity().invalidateOptionsMenu();
1768 activity.refreshUi();
1769 }
1770
1771 public void attachFile(final int attachmentChoice) {
1772 attachFile(attachmentChoice, true);
1773 }
1774
1775 public void attachFile(final int attachmentChoice, final boolean updateRecentlyUsed) {
1776 if (attachmentChoice == ATTACHMENT_CHOICE_RECORD_VOICE) {
1777 if (!hasPermissions(
1778 attachmentChoice,
1779 Manifest.permission.WRITE_EXTERNAL_STORAGE,
1780 Manifest.permission.RECORD_AUDIO)) {
1781 return;
1782 }
1783 } else if (attachmentChoice == ATTACHMENT_CHOICE_TAKE_PHOTO
1784 || attachmentChoice == ATTACHMENT_CHOICE_RECORD_VIDEO) {
1785 if (!hasPermissions(
1786 attachmentChoice,
1787 Manifest.permission.WRITE_EXTERNAL_STORAGE,
1788 Manifest.permission.CAMERA)) {
1789 return;
1790 }
1791 } else if (attachmentChoice != ATTACHMENT_CHOICE_LOCATION) {
1792 if (!hasPermissions(attachmentChoice, Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
1793 return;
1794 }
1795 }
1796 if (updateRecentlyUsed) {
1797 storeRecentlyUsedQuickAction(attachmentChoice);
1798 }
1799 final int encryption = conversation.getNextEncryption();
1800 final int mode = conversation.getMode();
1801 if (encryption == Message.ENCRYPTION_PGP) {
1802 if (activity.hasPgp()) {
1803 if (mode == Conversation.MODE_SINGLE
1804 && conversation.getContact().getPgpKeyId() != 0) {
1805 activity.xmppConnectionService
1806 .getPgpEngine()
1807 .hasKey(
1808 conversation.getContact(),
1809 new UiCallback<Contact>() {
1810
1811 @Override
1812 public void userInputRequired(
1813 PendingIntent pi, Contact contact) {
1814 startPendingIntent(pi, attachmentChoice);
1815 }
1816
1817 @Override
1818 public void success(Contact contact) {
1819 invokeAttachFileIntent(attachmentChoice);
1820 }
1821
1822 @Override
1823 public void error(int error, Contact contact) {
1824 activity.replaceToast(getString(error));
1825 }
1826 });
1827 } else if (mode == Conversation.MODE_MULTI
1828 && conversation.getMucOptions().pgpKeysInUse()) {
1829 if (!conversation.getMucOptions().everybodyHasKeys()) {
1830 Toast warning =
1831 Toast.makeText(
1832 getActivity(),
1833 R.string.missing_public_keys,
1834 Toast.LENGTH_LONG);
1835 warning.setGravity(Gravity.CENTER_VERTICAL, 0, 0);
1836 warning.show();
1837 }
1838 invokeAttachFileIntent(attachmentChoice);
1839 } else {
1840 showNoPGPKeyDialog(
1841 false,
1842 (dialog, which) -> {
1843 conversation.setNextEncryption(Message.ENCRYPTION_NONE);
1844 activity.xmppConnectionService.updateConversation(conversation);
1845 invokeAttachFileIntent(attachmentChoice);
1846 });
1847 }
1848 } else {
1849 activity.showInstallPgpDialog();
1850 }
1851 } else {
1852 invokeAttachFileIntent(attachmentChoice);
1853 }
1854 }
1855
1856 private void storeRecentlyUsedQuickAction(final int attachmentChoice) {
1857 try {
1858 activity.getPreferences()
1859 .edit()
1860 .putString(
1861 RECENTLY_USED_QUICK_ACTION,
1862 SendButtonAction.of(attachmentChoice).toString())
1863 .apply();
1864 } catch (IllegalArgumentException e) {
1865 // just do not save
1866 }
1867 }
1868
1869 @Override
1870 public void onRequestPermissionsResult(
1871 int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
1872 final PermissionUtils.PermissionResult permissionResult =
1873 PermissionUtils.removeBluetoothConnect(permissions, grantResults);
1874 if (grantResults.length > 0) {
1875 if (allGranted(permissionResult.grantResults)) {
1876 switch (requestCode) {
1877 case REQUEST_START_DOWNLOAD:
1878 if (this.mPendingDownloadableMessage != null) {
1879 startDownloadable(this.mPendingDownloadableMessage);
1880 }
1881 break;
1882 case REQUEST_ADD_EDITOR_CONTENT:
1883 if (this.mPendingEditorContent != null) {
1884 attachEditorContentToConversation(this.mPendingEditorContent);
1885 }
1886 break;
1887 case REQUEST_COMMIT_ATTACHMENTS:
1888 commitAttachments();
1889 break;
1890 case REQUEST_START_AUDIO_CALL:
1891 triggerRtpSession(RtpSessionActivity.ACTION_MAKE_VOICE_CALL);
1892 break;
1893 case REQUEST_START_VIDEO_CALL:
1894 triggerRtpSession(RtpSessionActivity.ACTION_MAKE_VIDEO_CALL);
1895 break;
1896 default:
1897 attachFile(requestCode);
1898 break;
1899 }
1900 } else {
1901 @StringRes int res;
1902 String firstDenied =
1903 getFirstDenied(permissionResult.grantResults, permissionResult.permissions);
1904 if (Manifest.permission.RECORD_AUDIO.equals(firstDenied)) {
1905 res = R.string.no_microphone_permission;
1906 } else if (Manifest.permission.CAMERA.equals(firstDenied)) {
1907 res = R.string.no_camera_permission;
1908 } else {
1909 res = R.string.no_storage_permission;
1910 }
1911 Toast.makeText(
1912 getActivity(),
1913 getString(res, getString(R.string.app_name)),
1914 Toast.LENGTH_SHORT)
1915 .show();
1916 }
1917 }
1918 if (writeGranted(grantResults, permissions)) {
1919 if (activity != null && activity.xmppConnectionService != null) {
1920 activity.xmppConnectionService.getBitmapCache().evictAll();
1921 activity.xmppConnectionService.restartFileObserver();
1922 }
1923 refresh();
1924 }
1925 if (cameraGranted(grantResults, permissions) || audioGranted(grantResults, permissions)) {
1926 XmppConnectionService.toggleForegroundService(activity);
1927 }
1928 }
1929
1930 public void startDownloadable(Message message) {
1931 if (!hasPermissions(REQUEST_START_DOWNLOAD, Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
1932 this.mPendingDownloadableMessage = message;
1933 return;
1934 }
1935 Transferable transferable = message.getTransferable();
1936 if (transferable != null) {
1937 if (transferable instanceof TransferablePlaceholder && message.hasFileOnRemoteHost()) {
1938 createNewConnection(message);
1939 return;
1940 }
1941 if (!transferable.start()) {
1942 Log.d(Config.LOGTAG, "type: " + transferable.getClass().getName());
1943 Toast.makeText(getActivity(), R.string.not_connected_try_again, Toast.LENGTH_SHORT)
1944 .show();
1945 }
1946 } else if (message.treatAsDownloadable()
1947 || message.hasFileOnRemoteHost()
1948 || MessageUtils.unInitiatedButKnownSize(message)) {
1949 createNewConnection(message);
1950 } else {
1951 Log.d(
1952 Config.LOGTAG,
1953 message.getConversation().getAccount() + ": unable to start downloadable");
1954 }
1955 }
1956
1957 private void createNewConnection(final Message message) {
1958 if (!activity.xmppConnectionService.hasInternetConnection()) {
1959 Toast.makeText(getActivity(), R.string.not_connected_try_again, Toast.LENGTH_SHORT)
1960 .show();
1961 return;
1962 }
1963 activity.xmppConnectionService
1964 .getHttpConnectionManager()
1965 .createNewDownloadConnection(message, true);
1966 }
1967
1968 @SuppressLint("InflateParams")
1969 protected void clearHistoryDialog(final Conversation conversation) {
1970 final MaterialAlertDialogBuilder builder =
1971 new MaterialAlertDialogBuilder(requireActivity());
1972 builder.setTitle(R.string.clear_conversation_history);
1973 final View dialogView =
1974 requireActivity().getLayoutInflater().inflate(R.layout.dialog_clear_history, null);
1975 final CheckBox endConversationCheckBox =
1976 dialogView.findViewById(R.id.end_conversation_checkbox);
1977 builder.setView(dialogView);
1978 builder.setNegativeButton(getString(R.string.cancel), null);
1979 builder.setPositiveButton(
1980 getString(R.string.confirm),
1981 (dialog, which) -> {
1982 this.activity.xmppConnectionService.clearConversationHistory(conversation);
1983 if (endConversationCheckBox.isChecked()) {
1984 this.activity.xmppConnectionService.archiveConversation(conversation);
1985 this.activity.onConversationArchived(conversation);
1986 } else {
1987 activity.onConversationsListItemUpdated();
1988 refresh();
1989 }
1990 });
1991 builder.create().show();
1992 }
1993
1994 protected void muteConversationDialog(final Conversation conversation) {
1995 final MaterialAlertDialogBuilder builder =
1996 new MaterialAlertDialogBuilder(requireActivity());
1997 builder.setTitle(R.string.disable_notifications);
1998 final int[] durations = getResources().getIntArray(R.array.mute_options_durations);
1999 final CharSequence[] labels = new CharSequence[durations.length];
2000 for (int i = 0; i < durations.length; ++i) {
2001 if (durations[i] == -1) {
2002 labels[i] = getString(R.string.until_further_notice);
2003 } else {
2004 labels[i] = TimeFrameUtils.resolve(activity, 1000L * durations[i]);
2005 }
2006 }
2007 builder.setItems(
2008 labels,
2009 (dialog, which) -> {
2010 final long till;
2011 if (durations[which] == -1) {
2012 till = Long.MAX_VALUE;
2013 } else {
2014 till = System.currentTimeMillis() + (durations[which] * 1000L);
2015 }
2016 conversation.setMutedTill(till);
2017 activity.xmppConnectionService.updateConversation(conversation);
2018 activity.onConversationsListItemUpdated();
2019 refresh();
2020 requireActivity().invalidateOptionsMenu();
2021 });
2022 builder.create().show();
2023 }
2024
2025 private boolean hasPermissions(int requestCode, List<String> permissions) {
2026 final List<String> missingPermissions = new ArrayList<>();
2027 for (String permission : permissions) {
2028 if ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
2029 || Config.ONLY_INTERNAL_STORAGE)
2030 && permission.equals(Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
2031 continue;
2032 }
2033 if (activity.checkSelfPermission(permission) != PackageManager.PERMISSION_GRANTED) {
2034 missingPermissions.add(permission);
2035 }
2036 }
2037 if (missingPermissions.size() == 0) {
2038 return true;
2039 } else {
2040 requestPermissions(missingPermissions.toArray(new String[0]), requestCode);
2041 return false;
2042 }
2043 }
2044
2045 private boolean hasPermissions(int requestCode, String... permissions) {
2046 return hasPermissions(requestCode, ImmutableList.copyOf(permissions));
2047 }
2048
2049 public void unMuteConversation(final Conversation conversation) {
2050 conversation.setMutedTill(0);
2051 this.activity.xmppConnectionService.updateConversation(conversation);
2052 this.activity.onConversationsListItemUpdated();
2053 refresh();
2054 requireActivity().invalidateOptionsMenu();
2055 }
2056
2057 protected void invokeAttachFileIntent(final int attachmentChoice) {
2058 Intent intent = new Intent();
2059 boolean chooser = false;
2060 switch (attachmentChoice) {
2061 case ATTACHMENT_CHOICE_CHOOSE_IMAGE:
2062 intent.setAction(Intent.ACTION_GET_CONTENT);
2063 intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true);
2064 intent.setType("image/*");
2065 chooser = true;
2066 break;
2067 case ATTACHMENT_CHOICE_RECORD_VIDEO:
2068 intent.setAction(MediaStore.ACTION_VIDEO_CAPTURE);
2069 break;
2070 case ATTACHMENT_CHOICE_TAKE_PHOTO:
2071 final Uri uri = activity.xmppConnectionService.getFileBackend().getTakePhotoUri();
2072 pendingTakePhotoUri.push(uri);
2073 intent.putExtra(MediaStore.EXTRA_OUTPUT, uri);
2074 intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
2075 intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
2076 intent.setAction(MediaStore.ACTION_IMAGE_CAPTURE);
2077 break;
2078 case ATTACHMENT_CHOICE_CHOOSE_FILE:
2079 chooser = true;
2080 intent.setType("*/*");
2081 intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true);
2082 intent.addCategory(Intent.CATEGORY_OPENABLE);
2083 intent.setAction(Intent.ACTION_GET_CONTENT);
2084 break;
2085 case ATTACHMENT_CHOICE_RECORD_VOICE:
2086 intent = new Intent(getActivity(), RecordingActivity.class);
2087 break;
2088 case ATTACHMENT_CHOICE_LOCATION:
2089 intent = GeoHelper.getFetchIntent(activity);
2090 break;
2091 }
2092 final Context context = getActivity();
2093 if (context == null) {
2094 return;
2095 }
2096 try {
2097 if (chooser) {
2098 startActivityForResult(
2099 Intent.createChooser(intent, getString(R.string.perform_action_with)),
2100 attachmentChoice);
2101 } else {
2102 startActivityForResult(intent, attachmentChoice);
2103 }
2104 } catch (final ActivityNotFoundException e) {
2105 Toast.makeText(context, R.string.no_application_found, Toast.LENGTH_LONG).show();
2106 }
2107 }
2108
2109 @Override
2110 public void onResume() {
2111 super.onResume();
2112 binding.messagesView.post(this::fireReadEvent);
2113 }
2114
2115 private void fireReadEvent() {
2116 if (activity != null && this.conversation != null) {
2117 String uuid = getLastVisibleMessageUuid();
2118 if (uuid != null) {
2119 activity.onConversationRead(this.conversation, uuid);
2120 }
2121 }
2122 }
2123
2124 private String getLastVisibleMessageUuid() {
2125 if (binding == null) {
2126 return null;
2127 }
2128 synchronized (this.messageList) {
2129 int pos = binding.messagesView.getLastVisiblePosition();
2130 if (pos >= 0) {
2131 Message message = null;
2132 for (int i = pos; i >= 0; --i) {
2133 try {
2134 message = (Message) binding.messagesView.getItemAtPosition(i);
2135 } catch (IndexOutOfBoundsException e) {
2136 // should not happen if we synchronize properly. however if that fails we
2137 // just gonna try item -1
2138 continue;
2139 }
2140 if (message.getType() != Message.TYPE_STATUS) {
2141 break;
2142 }
2143 }
2144 if (message != null) {
2145 return message.getUuid();
2146 }
2147 }
2148 }
2149 return null;
2150 }
2151
2152 private void openWith(final Message message) {
2153 if (message.isGeoUri()) {
2154 GeoHelper.view(getActivity(), message);
2155 } else {
2156 final DownloadableFile file =
2157 activity.xmppConnectionService.getFileBackend().getFile(message);
2158 ViewUtil.view(activity, file);
2159 }
2160 }
2161
2162 private void addReaction(final Message message) {
2163 activity.addReaction(
2164 message,
2165 reactions -> {
2166 if (activity.xmppConnectionService.sendReactions(message, reactions)) {
2167 return;
2168 }
2169 Toast.makeText(activity, R.string.could_not_add_reaction, Toast.LENGTH_LONG)
2170 .show();
2171 });
2172 }
2173
2174 private void reportMessage(final Message message) {
2175 BlockContactDialog.show(activity, conversation.getContact(), message.getServerMsgId());
2176 }
2177
2178 private void showErrorMessage(final Message message) {
2179 final MaterialAlertDialogBuilder builder =
2180 new MaterialAlertDialogBuilder(requireActivity());
2181 builder.setTitle(R.string.error_message);
2182 final String errorMessage = message.getErrorMessage();
2183 final String[] errorMessageParts =
2184 errorMessage == null ? new String[0] : errorMessage.split("\\u001f");
2185 final String displayError;
2186 if (errorMessageParts.length == 2) {
2187 displayError = errorMessageParts[1];
2188 } else {
2189 displayError = errorMessage;
2190 }
2191 builder.setMessage(displayError);
2192 builder.setNegativeButton(
2193 R.string.copy_to_clipboard,
2194 (dialog, which) -> {
2195 if (activity.copyTextToClipboard(displayError, R.string.error_message)
2196 && Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
2197 Toast.makeText(
2198 activity,
2199 R.string.error_message_copied_to_clipboard,
2200 Toast.LENGTH_SHORT)
2201 .show();
2202 }
2203 });
2204 builder.setPositiveButton(R.string.confirm, null);
2205 builder.create().show();
2206 }
2207
2208 private void deleteFile(final Message message) {
2209 final MaterialAlertDialogBuilder builder =
2210 new MaterialAlertDialogBuilder(requireActivity());
2211 builder.setNegativeButton(R.string.cancel, null);
2212 builder.setTitle(R.string.delete_file_dialog);
2213 builder.setMessage(R.string.delete_file_dialog_msg);
2214 builder.setPositiveButton(
2215 R.string.confirm,
2216 (dialog, which) -> {
2217 if (activity.xmppConnectionService.getFileBackend().deleteFile(message)) {
2218 message.setDeleted(true);
2219 activity.xmppConnectionService.evictPreview(message.getUuid());
2220 activity.xmppConnectionService.updateMessage(message, false);
2221 activity.onConversationsListItemUpdated();
2222 refresh();
2223 }
2224 });
2225 builder.create().show();
2226 }
2227
2228 private void resendMessage(final Message message, final boolean forceP2P) {
2229 if (message.isFileOrImage()) {
2230 if (!(message.getConversation() instanceof Conversation conversation)) {
2231 return;
2232 }
2233 final DownloadableFile file =
2234 activity.xmppConnectionService.getFileBackend().getFile(message);
2235 if ((file.exists() && file.canRead()) || message.hasFileOnRemoteHost()) {
2236 final XmppConnection xmppConnection = conversation.getAccount().getXmppConnection();
2237 if (!message.hasFileOnRemoteHost()
2238 && xmppConnection != null
2239 && conversation.getMode() == Conversational.MODE_SINGLE
2240 && (!xmppConnection
2241 .getManager(HttpUploadManager.class)
2242 .isAvailableForSize(message.getFileParams().getSize())
2243 || forceP2P)) {
2244 activity.selectPresence(
2245 conversation,
2246 () -> {
2247 message.setCounterpart(conversation.getNextCounterpart());
2248 activity.xmppConnectionService.resendFailedMessages(
2249 message, forceP2P);
2250 new Handler()
2251 .post(
2252 () -> {
2253 int size = messageList.size();
2254 this.binding.messagesView.setSelection(
2255 size - 1);
2256 });
2257 });
2258 return;
2259 }
2260 } else if (!Compatibility.hasStoragePermission(getActivity())) {
2261 Toast.makeText(activity, R.string.no_storage_permission, Toast.LENGTH_SHORT).show();
2262 return;
2263 } else {
2264 Toast.makeText(activity, R.string.file_deleted, Toast.LENGTH_SHORT).show();
2265 message.setDeleted(true);
2266 activity.xmppConnectionService.updateMessage(message, false);
2267 activity.onConversationsListItemUpdated();
2268 refresh();
2269 return;
2270 }
2271 }
2272 activity.xmppConnectionService.resendFailedMessages(message, false);
2273 new Handler()
2274 .post(
2275 () -> {
2276 int size = messageList.size();
2277 this.binding.messagesView.setSelection(size - 1);
2278 });
2279 }
2280
2281 private void cancelTransmission(Message message) {
2282 Transferable transferable = message.getTransferable();
2283 if (transferable != null) {
2284 transferable.cancel();
2285 } else if (message.getStatus() != Message.STATUS_RECEIVED) {
2286 activity.xmppConnectionService.markMessage(
2287 message, Message.STATUS_SEND_FAILED, Message.ERROR_MESSAGE_CANCELLED);
2288 }
2289 }
2290
2291 private void retryDecryption(Message message) {
2292 message.setEncryption(Message.ENCRYPTION_PGP);
2293 activity.onConversationsListItemUpdated();
2294 refresh();
2295 conversation.getAccount().getPgpDecryptionService().decrypt(message, false);
2296 }
2297
2298 public void privateMessageWith(final Jid counterpart) {
2299 if (conversation.setOutgoingChatState(Config.DEFAULT_CHAT_STATE)) {
2300 activity.xmppConnectionService.sendChatState(conversation);
2301 }
2302 this.binding.textinput.setText("");
2303 this.conversation.setNextCounterpart(counterpart);
2304 updateChatMsgHint();
2305 updateSendButton();
2306 updateEditablity();
2307 }
2308
2309 private void correctMessage(final Message message) {
2310 this.conversation.setCorrectingMessage(message);
2311 final Editable editable = binding.textinput.getText();
2312 this.conversation.setDraftMessage(editable.toString());
2313 this.binding.textinput.setText("");
2314 this.binding.textinput.append(message.getBody());
2315 }
2316
2317 private void highlightInConference(String nick) {
2318 final Editable editable = this.binding.textinput.getText();
2319 String oldString = editable.toString().trim();
2320 final int pos = this.binding.textinput.getSelectionStart();
2321 if (oldString.isEmpty() || pos == 0) {
2322 editable.insert(0, nick + ": ");
2323 } else {
2324 final char before = editable.charAt(pos - 1);
2325 final char after = editable.length() > pos ? editable.charAt(pos) : '\0';
2326 if (before == '\n') {
2327 editable.insert(pos, nick + ": ");
2328 } else {
2329 if (pos > 2 && editable.subSequence(pos - 2, pos).toString().equals(": ")) {
2330 if (NickValidityChecker.check(
2331 conversation,
2332 Arrays.asList(
2333 editable.subSequence(0, pos - 2).toString().split(", ")))) {
2334 editable.insert(pos - 2, ", " + nick);
2335 return;
2336 }
2337 }
2338 editable.insert(
2339 pos,
2340 (Character.isWhitespace(before) ? "" : " ")
2341 + nick
2342 + (Character.isWhitespace(after) ? "" : " "));
2343 if (Character.isWhitespace(after)) {
2344 this.binding.textinput.setSelection(
2345 this.binding.textinput.getSelectionStart() + 1);
2346 }
2347 }
2348 }
2349 }
2350
2351 @Override
2352 public void startActivityForResult(Intent intent, int requestCode) {
2353 final Activity activity = getActivity();
2354 if (activity instanceof ConversationsActivity) {
2355 ((ConversationsActivity) activity).clearPendingViewIntent();
2356 }
2357 super.startActivityForResult(intent, requestCode);
2358 }
2359
2360 @Override
2361 public void onSaveInstanceState(@NonNull Bundle outState) {
2362 super.onSaveInstanceState(outState);
2363 if (conversation != null) {
2364 outState.putString(STATE_CONVERSATION_UUID, conversation.getUuid());
2365 outState.putString(STATE_LAST_MESSAGE_UUID, lastMessageUuid);
2366 final Uri uri = pendingTakePhotoUri.peek();
2367 if (uri != null) {
2368 outState.putString(STATE_PHOTO_URI, uri.toString());
2369 }
2370 final ScrollState scrollState = getScrollPosition();
2371 if (scrollState != null) {
2372 outState.putParcelable(STATE_SCROLL_POSITION, scrollState);
2373 }
2374 final ArrayList<Attachment> attachments =
2375 mediaPreviewAdapter == null
2376 ? new ArrayList<>()
2377 : mediaPreviewAdapter.getAttachments();
2378 if (attachments.size() > 0) {
2379 outState.putParcelableArrayList(STATE_MEDIA_PREVIEWS, attachments);
2380 }
2381 }
2382 }
2383
2384 @Override
2385 public void onActivityCreated(Bundle savedInstanceState) {
2386 super.onActivityCreated(savedInstanceState);
2387 if (savedInstanceState == null) {
2388 return;
2389 }
2390 String uuid = savedInstanceState.getString(STATE_CONVERSATION_UUID);
2391 ArrayList<Attachment> attachments =
2392 savedInstanceState.getParcelableArrayList(STATE_MEDIA_PREVIEWS);
2393 pendingLastMessageUuid.push(savedInstanceState.getString(STATE_LAST_MESSAGE_UUID, null));
2394 if (uuid != null) {
2395 QuickLoader.set(uuid);
2396 this.pendingConversationsUuid.push(uuid);
2397 if (attachments != null && attachments.size() > 0) {
2398 this.pendingMediaPreviews.push(attachments);
2399 }
2400 String takePhotoUri = savedInstanceState.getString(STATE_PHOTO_URI);
2401 if (takePhotoUri != null) {
2402 pendingTakePhotoUri.push(Uri.parse(takePhotoUri));
2403 }
2404 pendingScrollState.push(savedInstanceState.getParcelable(STATE_SCROLL_POSITION));
2405 }
2406 }
2407
2408 @Override
2409 public void onStart() {
2410 super.onStart();
2411 if (this.reInitRequiredOnStart && this.conversation != null) {
2412 final Bundle extras = pendingExtras.pop();
2413 reInit(this.conversation, extras != null);
2414 if (extras != null) {
2415 processExtras(extras);
2416 }
2417 } else if (conversation == null
2418 && activity != null
2419 && activity.xmppConnectionService != null) {
2420 final String uuid = pendingConversationsUuid.pop();
2421 Log.d(
2422 Config.LOGTAG,
2423 "ConversationFragment.onStart() - activity was bound but no conversation"
2424 + " loaded. uuid="
2425 + uuid);
2426 if (uuid != null) {
2427 findAndReInitByUuidOrArchive(uuid);
2428 }
2429 }
2430 }
2431
2432 @Override
2433 public void onStop() {
2434 super.onStop();
2435 final Activity activity = getActivity();
2436 messageListAdapter.unregisterListenerInAudioPlayer();
2437 if (activity == null || !activity.isChangingConfigurations()) {
2438 hideSoftKeyboard(activity);
2439 messageListAdapter.stopAudioPlayer();
2440 }
2441 if (this.conversation != null) {
2442 final String msg = this.binding.textinput.getText().toString();
2443 storeNextMessage(msg);
2444 updateChatState(this.conversation, msg);
2445 this.activity.xmppConnectionService.getNotificationService().setOpenConversation(null);
2446 }
2447 this.reInitRequiredOnStart = true;
2448 }
2449
2450 private void updateChatState(final Conversation conversation, final String msg) {
2451 ChatState state = msg.length() == 0 ? Config.DEFAULT_CHAT_STATE : ChatState.PAUSED;
2452 Account.State status = conversation.getAccount().getStatus();
2453 if (status == Account.State.ONLINE && conversation.setOutgoingChatState(state)) {
2454 activity.xmppConnectionService.sendChatState(conversation);
2455 }
2456 }
2457
2458 private void saveMessageDraftStopAudioPlayer() {
2459 final Conversation previousConversation = this.conversation;
2460 if (this.activity == null || this.binding == null || previousConversation == null) {
2461 return;
2462 }
2463 Log.d(Config.LOGTAG, "ConversationFragment.saveMessageDraftStopAudioPlayer()");
2464 final String msg = this.binding.textinput.getText().toString();
2465 storeNextMessage(msg);
2466 updateChatState(this.conversation, msg);
2467 messageListAdapter.stopAudioPlayer();
2468 mediaPreviewAdapter.clearPreviews();
2469 toggleInputMethod();
2470 }
2471
2472 public void reInit(final Conversation conversation, final Bundle extras) {
2473 QuickLoader.set(conversation.getUuid());
2474 final boolean changedConversation = this.conversation != conversation;
2475 if (changedConversation) {
2476 this.saveMessageDraftStopAudioPlayer();
2477 }
2478 this.clearPending();
2479 if (this.reInit(conversation, extras != null)) {
2480 if (extras != null) {
2481 processExtras(extras);
2482 }
2483 this.reInitRequiredOnStart = false;
2484 } else {
2485 this.reInitRequiredOnStart = true;
2486 pendingExtras.push(extras);
2487 }
2488 resetUnreadMessagesCount();
2489 }
2490
2491 private void reInit(Conversation conversation) {
2492 reInit(conversation, false);
2493 }
2494
2495 private boolean reInit(final Conversation conversation, final boolean hasExtras) {
2496 if (conversation == null) {
2497 return false;
2498 }
2499 this.conversation = conversation;
2500 // once we set the conversation all is good and it will automatically do the right thing in
2501 // onStart()
2502 if (this.activity == null || this.binding == null) {
2503 return false;
2504 }
2505
2506 if (!activity.xmppConnectionService.isConversationStillOpen(this.conversation)) {
2507 activity.onConversationArchived(this.conversation);
2508 return false;
2509 }
2510
2511 stopScrolling();
2512 Log.d(Config.LOGTAG, "reInit(hasExtras=" + hasExtras + ")");
2513
2514 if (this.conversation.isRead() && hasExtras) {
2515 Log.d(Config.LOGTAG, "trimming conversation");
2516 this.conversation.trim();
2517 }
2518
2519 setupIme();
2520
2521 final boolean scrolledToBottomAndNoPending =
2522 this.scrolledToBottom() && pendingScrollState.peek() == null;
2523
2524 this.binding.textSendButton.setContentDescription(
2525 activity.getString(R.string.send_message_to_x, conversation.getName()));
2526 this.binding.textinput.setKeyboardListener(null);
2527 final boolean participating =
2528 conversation.getMode() == Conversational.MODE_SINGLE
2529 || conversation.getMucOptions().participating();
2530 if (participating) {
2531 this.binding.textinput.setText(this.conversation.getNextMessage());
2532 this.binding.textinput.setSelection(this.binding.textinput.length());
2533 } else {
2534 this.binding.textinput.setText(MessageUtils.EMPTY_STRING);
2535 }
2536 this.binding.textinput.setKeyboardListener(this);
2537 messageListAdapter.updatePreferences();
2538 refresh(false);
2539 activity.invalidateOptionsMenu();
2540 this.conversation.messagesLoaded.set(true);
2541 Log.d(Config.LOGTAG, "scrolledToBottomAndNoPending=" + scrolledToBottomAndNoPending);
2542
2543 if (hasExtras || scrolledToBottomAndNoPending) {
2544 resetUnreadMessagesCount();
2545 synchronized (this.messageList) {
2546 Log.d(Config.LOGTAG, "jump to first unread message");
2547 final Message first = conversation.getFirstUnreadMessage();
2548 final int bottom = Math.max(0, this.messageList.size() - 1);
2549 final int pos;
2550 final boolean jumpToBottom;
2551 if (first == null) {
2552 pos = bottom;
2553 jumpToBottom = true;
2554 } else {
2555 int i = getIndexOf(first.getUuid(), this.messageList);
2556 pos = i < 0 ? bottom : i;
2557 jumpToBottom = false;
2558 }
2559 setSelection(pos, jumpToBottom);
2560 }
2561 }
2562
2563 this.binding.messagesView.post(this::fireReadEvent);
2564 // TODO if we only do this when this fragment is running on main it won't *bing* in tablet
2565 // layout which might be unnecessary since we can *see* it
2566 activity.xmppConnectionService
2567 .getNotificationService()
2568 .setOpenConversation(this.conversation);
2569 return true;
2570 }
2571
2572 private void resetUnreadMessagesCount() {
2573 lastMessageUuid = null;
2574 hideUnreadMessagesCount();
2575 }
2576
2577 private void hideUnreadMessagesCount() {
2578 if (this.binding == null) {
2579 return;
2580 }
2581 this.binding.scrollToBottomButton.setEnabled(false);
2582 this.binding.scrollToBottomButton.hide();
2583 this.binding.unreadCountCustomView.setVisibility(View.GONE);
2584 }
2585
2586 private void setSelection(int pos, boolean jumpToBottom) {
2587 ListViewUtils.setSelection(this.binding.messagesView, pos, jumpToBottom);
2588 this.binding.messagesView.post(
2589 () -> ListViewUtils.setSelection(this.binding.messagesView, pos, jumpToBottom));
2590 this.binding.messagesView.post(this::fireReadEvent);
2591 }
2592
2593 private boolean scrolledToBottom() {
2594 return this.binding != null && scrolledToBottom(this.binding.messagesView);
2595 }
2596
2597 private void processExtras(final Bundle extras) {
2598 final String downloadUuid = extras.getString(ConversationsActivity.EXTRA_DOWNLOAD_UUID);
2599 final String text = extras.getString(Intent.EXTRA_TEXT);
2600 final String nick = extras.getString(ConversationsActivity.EXTRA_NICK);
2601 final String postInitAction =
2602 extras.getString(ConversationsActivity.EXTRA_POST_INIT_ACTION);
2603 final boolean asQuote = extras.getBoolean(ConversationsActivity.EXTRA_AS_QUOTE);
2604 final boolean pm = extras.getBoolean(ConversationsActivity.EXTRA_IS_PRIVATE_MESSAGE, false);
2605 final boolean doNotAppend =
2606 extras.getBoolean(ConversationsActivity.EXTRA_DO_NOT_APPEND, false);
2607 final String type = extras.getString(ConversationsActivity.EXTRA_TYPE);
2608 final List<Uri> uris = extractUris(extras);
2609 if (uris != null && uris.size() > 0) {
2610 if (uris.size() == 1 && "geo".equals(uris.get(0).getScheme())) {
2611 mediaPreviewAdapter.addMediaPreviews(
2612 Attachment.of(getActivity(), uris.get(0), Attachment.Type.LOCATION));
2613 } else {
2614 final List<Uri> cleanedUris = cleanUris(new ArrayList<>(uris));
2615 mediaPreviewAdapter.addMediaPreviews(
2616 Attachment.of(getActivity(), cleanedUris, type));
2617 }
2618 toggleInputMethod();
2619 return;
2620 }
2621 if (nick != null) {
2622 if (pm) {
2623 Jid jid = conversation.getJid();
2624 try {
2625 Jid next = Jid.of(jid.getLocal(), jid.getDomain(), nick);
2626 privateMessageWith(next);
2627 } catch (final IllegalArgumentException ignored) {
2628 // do nothing
2629 }
2630 } else {
2631 final MucOptions mucOptions = conversation.getMucOptions();
2632 if (mucOptions.participating() || conversation.getNextCounterpart() != null) {
2633 highlightInConference(nick);
2634 }
2635 }
2636 } else {
2637 if (text != null && Patterns.URI_GEO.matcher(text).matches()) {
2638 mediaPreviewAdapter.addMediaPreviews(
2639 Attachment.of(getActivity(), Uri.parse(text), Attachment.Type.LOCATION));
2640 toggleInputMethod();
2641 return;
2642 } else if (text != null && asQuote) {
2643 quoteText(text);
2644 } else {
2645 appendText(text, doNotAppend);
2646 }
2647 }
2648 if (ConversationsActivity.POST_ACTION_RECORD_VOICE.equals(postInitAction)) {
2649 attachFile(ATTACHMENT_CHOICE_RECORD_VOICE, false);
2650 return;
2651 }
2652 final Message message =
2653 downloadUuid == null ? null : conversation.findMessageWithFileAndUuid(downloadUuid);
2654 if (message != null) {
2655 startDownloadable(message);
2656 }
2657 }
2658
2659 private List<Uri> extractUris(final Bundle extras) {
2660 final List<Uri> uris = extras.getParcelableArrayList(Intent.EXTRA_STREAM);
2661 if (uris != null) {
2662 return uris;
2663 }
2664 final Uri uri = extras.getParcelable(Intent.EXTRA_STREAM);
2665 if (uri != null) {
2666 return Collections.singletonList(uri);
2667 } else {
2668 return null;
2669 }
2670 }
2671
2672 private List<Uri> cleanUris(final List<Uri> uris) {
2673 final Iterator<Uri> iterator = uris.iterator();
2674 while (iterator.hasNext()) {
2675 final Uri uri = iterator.next();
2676 if (FileBackend.dangerousFile(uri)) {
2677 iterator.remove();
2678 Toast.makeText(
2679 requireActivity(),
2680 R.string.security_violation_not_attaching_file,
2681 Toast.LENGTH_SHORT)
2682 .show();
2683 }
2684 }
2685 return uris;
2686 }
2687
2688 private boolean showBlockSubmenu(View view) {
2689 final Jid jid = conversation.getJid();
2690 final boolean showReject =
2691 !conversation.isWithStranger()
2692 && conversation
2693 .getContact()
2694 .getOption(Contact.Options.PENDING_SUBSCRIPTION_REQUEST);
2695 PopupMenu popupMenu = new PopupMenu(getActivity(), view);
2696 popupMenu.inflate(R.menu.block);
2697 popupMenu.getMenu().findItem(R.id.block_contact).setVisible(jid.getLocal() != null);
2698 popupMenu.getMenu().findItem(R.id.reject).setVisible(showReject);
2699 popupMenu.setOnMenuItemClickListener(
2700 menuItem -> {
2701 Blockable blockable;
2702 switch (menuItem.getItemId()) {
2703 case R.id.reject:
2704 activity.xmppConnectionService.stopPresenceUpdatesTo(
2705 conversation.getContact());
2706 updateSnackBar(conversation);
2707 return true;
2708 case R.id.block_domain:
2709 blockable =
2710 conversation
2711 .getAccount()
2712 .getRoster()
2713 .getContact(jid.getDomain());
2714 break;
2715 default:
2716 blockable = conversation;
2717 }
2718 BlockContactDialog.show(activity, blockable);
2719 return true;
2720 });
2721 popupMenu.show();
2722 return true;
2723 }
2724
2725 private void updateSnackBar(final Conversation conversation) {
2726 final Account account = conversation.getAccount();
2727 final XmppConnection connection = account.getXmppConnection();
2728 final int mode = conversation.getMode();
2729 final Contact contact = mode == Conversation.MODE_SINGLE ? conversation.getContact() : null;
2730 if (conversation.getStatus() == Conversation.STATUS_ARCHIVED) {
2731 return;
2732 }
2733 if (account.getStatus() == Account.State.DISABLED) {
2734 showSnackbar(
2735 R.string.this_account_is_disabled,
2736 R.string.enable,
2737 this.mEnableAccountListener);
2738 } else if (account.getStatus() == Account.State.LOGGED_OUT) {
2739 showSnackbar(
2740 R.string.this_account_is_logged_out,
2741 R.string.log_in,
2742 this.mEnableAccountListener);
2743 } else if (conversation.isBlocked()) {
2744 showSnackbar(R.string.contact_blocked, R.string.unblock, this.mUnblockClickListener);
2745 } else if (contact != null
2746 && !contact.showInRoster()
2747 && contact.getOption(Contact.Options.PENDING_SUBSCRIPTION_REQUEST)) {
2748 showSnackbar(
2749 R.string.contact_added_you,
2750 R.string.add_back,
2751 this.mAddBackClickListener,
2752 this.mLongPressBlockListener);
2753 } else if (contact != null
2754 && contact.getOption(Contact.Options.PENDING_SUBSCRIPTION_REQUEST)) {
2755 showSnackbar(
2756 R.string.contact_asks_for_presence_subscription,
2757 R.string.allow,
2758 this.mAllowPresenceSubscription,
2759 this.mLongPressBlockListener);
2760 } else if (mode == Conversation.MODE_MULTI
2761 && !conversation.getMucOptions().online()
2762 && account.getStatus() == Account.State.ONLINE) {
2763 switch (conversation.getMucOptions().getError()) {
2764 case NICK_IN_USE:
2765 showSnackbar(R.string.nick_in_use, R.string.edit, clickToMuc);
2766 break;
2767 case NO_RESPONSE:
2768 showSnackbar(R.string.joining_conference, 0, null);
2769 break;
2770 case SERVER_NOT_FOUND:
2771 if (conversation.receivedMessagesCount() > 0) {
2772 showSnackbar(R.string.remote_server_not_found, R.string.try_again, joinMuc);
2773 } else {
2774 showSnackbar(R.string.remote_server_not_found, R.string.leave, leaveMuc);
2775 }
2776 break;
2777 case REMOTE_SERVER_TIMEOUT:
2778 if (conversation.receivedMessagesCount() > 0) {
2779 showSnackbar(R.string.remote_server_timeout, R.string.try_again, joinMuc);
2780 } else {
2781 showSnackbar(R.string.remote_server_timeout, R.string.leave, leaveMuc);
2782 }
2783 break;
2784 case PASSWORD_REQUIRED:
2785 showSnackbar(
2786 R.string.conference_requires_password,
2787 R.string.enter_password,
2788 enterPassword);
2789 break;
2790 case BANNED:
2791 showSnackbar(R.string.conference_banned, R.string.leave, leaveMuc);
2792 break;
2793 case MEMBERS_ONLY:
2794 showSnackbar(R.string.conference_members_only, R.string.leave, leaveMuc);
2795 break;
2796 case RESOURCE_CONSTRAINT:
2797 showSnackbar(
2798 R.string.conference_resource_constraint, R.string.try_again, joinMuc);
2799 break;
2800 case KICKED:
2801 showSnackbar(R.string.conference_kicked, R.string.join, joinMuc);
2802 break;
2803 case TECHNICAL_PROBLEMS:
2804 showSnackbar(
2805 R.string.conference_technical_problems, R.string.try_again, joinMuc);
2806 break;
2807 case UNKNOWN:
2808 showSnackbar(R.string.conference_unknown_error, R.string.try_again, joinMuc);
2809 break;
2810 case INVALID_NICK:
2811 showSnackbar(R.string.invalid_muc_nick, R.string.edit, clickToMuc);
2812 case SHUTDOWN:
2813 showSnackbar(R.string.conference_shutdown, R.string.try_again, joinMuc);
2814 break;
2815 case DESTROYED:
2816 showSnackbar(R.string.conference_destroyed, R.string.leave, leaveMuc);
2817 break;
2818 case NON_ANONYMOUS:
2819 showSnackbar(
2820 R.string.group_chat_will_make_your_jabber_id_public,
2821 R.string.join,
2822 acceptJoin);
2823 break;
2824 default:
2825 hideSnackbar();
2826 break;
2827 }
2828 } else if (account.hasPendingPgpIntent(conversation)) {
2829 showSnackbar(R.string.openpgp_messages_found, R.string.decrypt, clickToDecryptListener);
2830 } else if (connection != null
2831 && connection.getFeatures().blocking()
2832 && conversation.countMessages() != 0
2833 && !conversation.isBlocked()
2834 && conversation.isWithStranger()) {
2835 showSnackbar(
2836 R.string.received_message_from_stranger, R.string.block, mBlockClickListener);
2837 } else {
2838 hideSnackbar();
2839 }
2840 }
2841
2842 @Override
2843 public void refresh() {
2844 if (this.binding == null) {
2845 Log.d(
2846 Config.LOGTAG,
2847 "ConversationFragment.refresh() skipped updated because view binding was null");
2848 return;
2849 }
2850 if (this.conversation != null
2851 && this.activity != null
2852 && this.activity.xmppConnectionService != null) {
2853 if (!activity.xmppConnectionService.isConversationStillOpen(this.conversation)) {
2854 activity.onConversationArchived(this.conversation);
2855 return;
2856 }
2857 }
2858 this.refresh(true);
2859 }
2860
2861 private void refresh(boolean notifyConversationRead) {
2862 synchronized (this.messageList) {
2863 if (this.conversation != null) {
2864 conversation.populateWithMessages(this.messageList);
2865 updateSnackBar(conversation);
2866 updateStatusMessages();
2867 if (conversation.getReceivedMessagesCountSinceUuid(lastMessageUuid) != 0) {
2868 binding.unreadCountCustomView.setVisibility(View.VISIBLE);
2869 binding.unreadCountCustomView.setUnreadCount(
2870 conversation.getReceivedMessagesCountSinceUuid(lastMessageUuid));
2871 }
2872 this.messageListAdapter.notifyDataSetChanged();
2873 updateChatMsgHint();
2874 if (notifyConversationRead && activity != null) {
2875 binding.messagesView.post(this::fireReadEvent);
2876 }
2877 updateSendButton();
2878 updateEditablity();
2879 }
2880 }
2881 }
2882
2883 protected void messageSent() {
2884 mSendingPgpMessage.set(false);
2885 this.binding.textinput.setText("");
2886 if (conversation.setCorrectingMessage(null)) {
2887 this.binding.textinput.append(conversation.getDraftMessage());
2888 conversation.setDraftMessage(null);
2889 }
2890 storeNextMessage();
2891 updateChatMsgHint();
2892 SharedPreferences p = PreferenceManager.getDefaultSharedPreferences(activity);
2893 final boolean prefScrollToBottom =
2894 p.getBoolean(
2895 "scroll_to_bottom",
2896 activity.getResources().getBoolean(R.bool.scroll_to_bottom));
2897 if (prefScrollToBottom || scrolledToBottom()) {
2898 new Handler()
2899 .post(
2900 () -> {
2901 int size = messageList.size();
2902 this.binding.messagesView.setSelection(size - 1);
2903 });
2904 }
2905 }
2906
2907 private boolean storeNextMessage() {
2908 return storeNextMessage(this.binding.textinput.getText().toString());
2909 }
2910
2911 private boolean storeNextMessage(String msg) {
2912 final boolean participating =
2913 conversation.getMode() == Conversational.MODE_SINGLE
2914 || conversation.getMucOptions().participating();
2915 if (this.conversation.getStatus() != Conversation.STATUS_ARCHIVED
2916 && participating
2917 && this.conversation.setNextMessage(msg)) {
2918 this.activity.xmppConnectionService.updateConversation(this.conversation);
2919 return true;
2920 }
2921 return false;
2922 }
2923
2924 public void doneSendingPgpMessage() {
2925 mSendingPgpMessage.set(false);
2926 }
2927
2928 public Long getMaxHttpUploadSize(final Conversation conversation) {
2929
2930 final var connection = conversation.getAccount().getXmppConnection();
2931 final var httpUploadService = connection.getManager(HttpUploadManager.class).getService();
2932 if (httpUploadService == null) {
2933 return -1L;
2934 }
2935 return httpUploadService.getMaxFileSize();
2936 }
2937
2938 private void updateEditablity() {
2939 boolean canWrite =
2940 this.conversation.getMode() == Conversation.MODE_SINGLE
2941 || this.conversation.getMucOptions().participating()
2942 || this.conversation.getNextCounterpart() != null;
2943 this.binding.textinput.setFocusable(canWrite);
2944 this.binding.textinput.setFocusableInTouchMode(canWrite);
2945 this.binding.textSendButton.setEnabled(canWrite);
2946 this.binding.textinput.setCursorVisible(canWrite);
2947 this.binding.textinput.setEnabled(canWrite);
2948 }
2949
2950 public void updateSendButton() {
2951 boolean hasAttachments =
2952 mediaPreviewAdapter != null && mediaPreviewAdapter.hasAttachments();
2953 final Conversation c = this.conversation;
2954 final Presence.Availability status;
2955 final String text =
2956 this.binding.textinput == null ? "" : this.binding.textinput.getText().toString();
2957 final SendButtonAction action;
2958 if (hasAttachments) {
2959 action = SendButtonAction.TEXT;
2960 } else {
2961 action = SendButtonTool.getAction(getActivity(), c, text);
2962 }
2963 if (c.getAccount().getStatus() == Account.State.ONLINE) {
2964 if (activity != null
2965 && activity.xmppConnectionService != null
2966 && activity.xmppConnectionService.getMessageArchiveService().isCatchingUp(c)) {
2967 status = Presence.Availability.OFFLINE;
2968 } else if (c.getMode() == Conversation.MODE_SINGLE) {
2969 status = c.getContact().getShownStatus();
2970 } else {
2971 status =
2972 c.getMucOptions().online()
2973 ? Presence.Availability.ONLINE
2974 : Presence.Availability.OFFLINE;
2975 }
2976 } else {
2977 status = Presence.Availability.OFFLINE;
2978 }
2979 this.binding.textSendButton.setTag(action);
2980 this.binding.textSendButton.setIconResource(
2981 SendButtonTool.getSendButtonImageResource(action));
2982 this.binding.textSendButton.setIconTint(
2983 ColorStateList.valueOf(
2984 SendButtonTool.getSendButtonColor(this.binding.textSendButton, status)));
2985 // TODO send button color
2986 final Activity activity = getActivity();
2987 if (activity != null) {}
2988 }
2989
2990 protected void updateStatusMessages() {
2991 DateSeparator.addAll(this.messageList);
2992 if (showLoadMoreMessages(conversation)) {
2993 this.messageList.add(0, Message.createLoadMoreMessage(conversation));
2994 }
2995 if (conversation.getMode() == Conversation.MODE_SINGLE) {
2996 ChatState state = conversation.getIncomingChatState();
2997 if (state == ChatState.COMPOSING) {
2998 this.messageList.add(
2999 Message.createStatusMessage(
3000 conversation,
3001 getString(R.string.contact_is_typing, conversation.getName())));
3002 } else if (state == ChatState.PAUSED) {
3003 this.messageList.add(
3004 Message.createStatusMessage(
3005 conversation,
3006 getString(
3007 R.string.contact_has_stopped_typing,
3008 conversation.getName())));
3009 } else {
3010 for (int i = this.messageList.size() - 1; i >= 0; --i) {
3011 final Message message = this.messageList.get(i);
3012 if (message.getType() != Message.TYPE_STATUS) {
3013 if (message.getStatus() == Message.STATUS_RECEIVED) {
3014 return;
3015 } else {
3016 if (message.getStatus() == Message.STATUS_SEND_DISPLAYED) {
3017 this.messageList.add(
3018 i + 1,
3019 Message.createStatusMessage(
3020 conversation,
3021 getString(
3022 R.string.contact_has_read_up_to_this_point,
3023 conversation.getName())));
3024 return;
3025 }
3026 }
3027 }
3028 }
3029 }
3030 } else {
3031 final MucOptions mucOptions = conversation.getMucOptions();
3032 final List<MucOptions.User> allUsers = mucOptions.getUsers();
3033 final Set<ReadByMarker> addedMarkers = new HashSet<>();
3034 ChatState state = ChatState.COMPOSING;
3035 List<MucOptions.User> users =
3036 conversation.getMucOptions().getUsersWithChatState(state, 5);
3037 if (users.size() == 0) {
3038 state = ChatState.PAUSED;
3039 users = conversation.getMucOptions().getUsersWithChatState(state, 5);
3040 }
3041 if (mucOptions.isPrivateAndNonAnonymous()) {
3042 for (int i = this.messageList.size() - 1; i >= 0; --i) {
3043 final Set<ReadByMarker> markersForMessage =
3044 messageList.get(i).getReadByMarkers();
3045 final List<MucOptions.User> shownMarkers = new ArrayList<>();
3046 for (ReadByMarker marker : markersForMessage) {
3047 if (!ReadByMarker.contains(marker, addedMarkers)) {
3048 addedMarkers.add(
3049 marker); // may be put outside this condition. set should do
3050 // dedup anyway
3051 MucOptions.User user = mucOptions.findUser(marker);
3052 if (user != null && !users.contains(user)) {
3053 shownMarkers.add(user);
3054 }
3055 }
3056 }
3057 final ReadByMarker markerForSender = ReadByMarker.from(messageList.get(i));
3058 final Message statusMessage;
3059 final int size = shownMarkers.size();
3060 if (size > 1) {
3061 final String body;
3062 if (size <= 4) {
3063 body =
3064 getString(
3065 R.string.contacts_have_read_up_to_this_point,
3066 UIHelper.concatNames(shownMarkers));
3067 } else if (ReadByMarker.allUsersRepresented(
3068 allUsers, markersForMessage, markerForSender)) {
3069 body = getString(R.string.everyone_has_read_up_to_this_point);
3070 } else {
3071 body =
3072 getString(
3073 R.string.contacts_and_n_more_have_read_up_to_this_point,
3074 UIHelper.concatNames(shownMarkers, 3),
3075 size - 3);
3076 }
3077 statusMessage = Message.createStatusMessage(conversation, body);
3078 statusMessage.setCounterparts(shownMarkers);
3079 } else if (size == 1) {
3080 statusMessage =
3081 Message.createStatusMessage(
3082 conversation,
3083 getString(
3084 R.string.contact_has_read_up_to_this_point,
3085 UIHelper.getDisplayName(shownMarkers.get(0))));
3086 statusMessage.setCounterpart(shownMarkers.get(0).getFullJid());
3087 statusMessage.setTrueCounterpart(shownMarkers.get(0).getRealJid());
3088 } else {
3089 statusMessage = null;
3090 }
3091 if (statusMessage != null) {
3092 this.messageList.add(i + 1, statusMessage);
3093 }
3094 addedMarkers.add(markerForSender);
3095 if (ReadByMarker.allUsersRepresented(allUsers, addedMarkers)) {
3096 break;
3097 }
3098 }
3099 }
3100 if (users.size() > 0) {
3101 Message statusMessage;
3102 if (users.size() == 1) {
3103 MucOptions.User user = users.get(0);
3104 int id =
3105 state == ChatState.COMPOSING
3106 ? R.string.contact_is_typing
3107 : R.string.contact_has_stopped_typing;
3108 statusMessage =
3109 Message.createStatusMessage(
3110 conversation, getString(id, UIHelper.getDisplayName(user)));
3111 statusMessage.setTrueCounterpart(user.getRealJid());
3112 statusMessage.setCounterpart(user.getFullJid());
3113 } else {
3114 int id =
3115 state == ChatState.COMPOSING
3116 ? R.string.contacts_are_typing
3117 : R.string.contacts_have_stopped_typing;
3118 statusMessage =
3119 Message.createStatusMessage(
3120 conversation, getString(id, UIHelper.concatNames(users)));
3121 statusMessage.setCounterparts(users);
3122 }
3123 this.messageList.add(statusMessage);
3124 }
3125 }
3126 }
3127
3128 private void stopScrolling() {
3129 long now = SystemClock.uptimeMillis();
3130 MotionEvent cancel = MotionEvent.obtain(now, now, MotionEvent.ACTION_CANCEL, 0, 0, 0);
3131 binding.messagesView.dispatchTouchEvent(cancel);
3132 }
3133
3134 private boolean showLoadMoreMessages(final Conversation c) {
3135 if (activity == null || activity.xmppConnectionService == null) {
3136 return false;
3137 }
3138 final boolean mam = hasMamSupport(c) && !c.getContact().isBlocked();
3139 final MessageArchiveService service =
3140 activity.xmppConnectionService.getMessageArchiveService();
3141 return mam
3142 && (c.getLastClearHistory().getTimestamp() != 0
3143 || (c.countMessages() == 0
3144 && c.messagesLoaded.get()
3145 && c.hasMessagesLeftOnServer()
3146 && !service.queryInProgress(c)));
3147 }
3148
3149 private boolean hasMamSupport(final Conversation c) {
3150 if (c.getMode() == Conversation.MODE_SINGLE) {
3151 final XmppConnection connection = c.getAccount().getXmppConnection();
3152 return connection != null && connection.getFeatures().mam();
3153 } else {
3154 return c.getMucOptions().mamSupport();
3155 }
3156 }
3157
3158 protected void showSnackbar(
3159 final int message, final int action, final OnClickListener clickListener) {
3160 showSnackbar(message, action, clickListener, null);
3161 }
3162
3163 protected void showSnackbar(
3164 final int message,
3165 final int action,
3166 final OnClickListener clickListener,
3167 final View.OnLongClickListener longClickListener) {
3168 this.binding.snackbar.setVisibility(View.VISIBLE);
3169 this.binding.snackbar.setOnClickListener(null);
3170 this.binding.snackbarMessage.setText(message);
3171 this.binding.snackbarMessage.setOnClickListener(null);
3172 this.binding.snackbarAction.setVisibility(clickListener == null ? View.GONE : View.VISIBLE);
3173 if (action != 0) {
3174 this.binding.snackbarAction.setText(action);
3175 }
3176 this.binding.snackbarAction.setOnClickListener(clickListener);
3177 this.binding.snackbarAction.setOnLongClickListener(longClickListener);
3178 }
3179
3180 protected void hideSnackbar() {
3181 this.binding.snackbar.setVisibility(View.GONE);
3182 }
3183
3184 protected void sendMessage(Message message) {
3185 activity.xmppConnectionService.sendMessage(message);
3186 messageSent();
3187 }
3188
3189 protected void sendPgpMessage(final Message message) {
3190 final XmppConnectionService xmppService = activity.xmppConnectionService;
3191 final Contact contact = message.getConversation().getContact();
3192 if (!activity.hasPgp()) {
3193 activity.showInstallPgpDialog();
3194 return;
3195 }
3196 if (conversation.getAccount().getPgpSignature() == null) {
3197 activity.announcePgp(
3198 conversation.getAccount(), conversation, null, activity.onOpenPGPKeyPublished);
3199 return;
3200 }
3201 if (!mSendingPgpMessage.compareAndSet(false, true)) {
3202 Log.d(Config.LOGTAG, "sending pgp message already in progress");
3203 }
3204 if (conversation.getMode() == Conversation.MODE_SINGLE) {
3205 if (contact.getPgpKeyId() != 0) {
3206 xmppService
3207 .getPgpEngine()
3208 .hasKey(
3209 contact,
3210 new UiCallback<Contact>() {
3211
3212 @Override
3213 public void userInputRequired(
3214 PendingIntent pi, Contact contact) {
3215 startPendingIntent(pi, REQUEST_ENCRYPT_MESSAGE);
3216 }
3217
3218 @Override
3219 public void success(Contact contact) {
3220 encryptTextMessage(message);
3221 }
3222
3223 @Override
3224 public void error(int error, Contact contact) {
3225 activity.runOnUiThread(
3226 () ->
3227 Toast.makeText(
3228 activity,
3229 R.string
3230 .unable_to_connect_to_keychain,
3231 Toast.LENGTH_SHORT)
3232 .show());
3233 mSendingPgpMessage.set(false);
3234 }
3235 });
3236
3237 } else {
3238 showNoPGPKeyDialog(
3239 false,
3240 (dialog, which) -> {
3241 conversation.setNextEncryption(Message.ENCRYPTION_NONE);
3242 xmppService.updateConversation(conversation);
3243 message.setEncryption(Message.ENCRYPTION_NONE);
3244 xmppService.sendMessage(message);
3245 messageSent();
3246 });
3247 }
3248 } else {
3249 if (conversation.getMucOptions().pgpKeysInUse()) {
3250 if (!conversation.getMucOptions().everybodyHasKeys()) {
3251 Toast warning =
3252 Toast.makeText(
3253 getActivity(), R.string.missing_public_keys, Toast.LENGTH_LONG);
3254 warning.setGravity(Gravity.CENTER_VERTICAL, 0, 0);
3255 warning.show();
3256 }
3257 encryptTextMessage(message);
3258 } else {
3259 showNoPGPKeyDialog(
3260 true,
3261 (dialog, which) -> {
3262 conversation.setNextEncryption(Message.ENCRYPTION_NONE);
3263 message.setEncryption(Message.ENCRYPTION_NONE);
3264 xmppService.updateConversation(conversation);
3265 xmppService.sendMessage(message);
3266 messageSent();
3267 });
3268 }
3269 }
3270 }
3271
3272 public void encryptTextMessage(Message message) {
3273 activity.xmppConnectionService
3274 .getPgpEngine()
3275 .encrypt(
3276 message,
3277 new UiCallback<Message>() {
3278
3279 @Override
3280 public void userInputRequired(PendingIntent pi, Message message) {
3281 startPendingIntent(pi, REQUEST_SEND_MESSAGE);
3282 }
3283
3284 @Override
3285 public void success(Message message) {
3286 // TODO the following two call can be made before the callback
3287 getActivity().runOnUiThread(() -> messageSent());
3288 }
3289
3290 @Override
3291 public void error(final int error, Message message) {
3292 getActivity()
3293 .runOnUiThread(
3294 () -> {
3295 doneSendingPgpMessage();
3296 Toast.makeText(
3297 getActivity(),
3298 error == 0
3299 ? R.string
3300 .unable_to_connect_to_keychain
3301 : error,
3302 Toast.LENGTH_SHORT)
3303 .show();
3304 });
3305 }
3306 });
3307 }
3308
3309 public void showNoPGPKeyDialog(
3310 final boolean plural, final DialogInterface.OnClickListener listener) {
3311 final MaterialAlertDialogBuilder builder =
3312 new MaterialAlertDialogBuilder(requireActivity());
3313 if (plural) {
3314 builder.setTitle(getString(R.string.no_pgp_keys));
3315 builder.setMessage(getText(R.string.contacts_have_no_pgp_keys));
3316 } else {
3317 builder.setTitle(getString(R.string.no_pgp_key));
3318 builder.setMessage(getText(R.string.contact_has_no_pgp_key));
3319 }
3320 builder.setNegativeButton(getString(R.string.cancel), null);
3321 builder.setPositiveButton(getString(R.string.send_unencrypted), listener);
3322 builder.create().show();
3323 }
3324
3325 public void appendText(String text, final boolean doNotAppend) {
3326 if (text == null) {
3327 return;
3328 }
3329 final Editable editable = this.binding.textinput.getText();
3330 String previous = editable == null ? "" : editable.toString();
3331 if (doNotAppend && !TextUtils.isEmpty(previous)) {
3332 Toast.makeText(getActivity(), R.string.already_drafting_message, Toast.LENGTH_LONG)
3333 .show();
3334 return;
3335 }
3336 if (UIHelper.isLastLineQuote(previous)) {
3337 text = '\n' + text;
3338 } else if (previous.length() != 0
3339 && !Character.isWhitespace(previous.charAt(previous.length() - 1))) {
3340 text = " " + text;
3341 }
3342 this.binding.textinput.append(text);
3343 }
3344
3345 @Override
3346 public boolean onEnterPressed(final boolean isCtrlPressed) {
3347 if (isCtrlPressed || enterIsSend()) {
3348 sendMessage();
3349 return true;
3350 }
3351 return false;
3352 }
3353
3354 private boolean enterIsSend() {
3355 final SharedPreferences p = PreferenceManager.getDefaultSharedPreferences(getActivity());
3356 return p.getBoolean("enter_is_send", getResources().getBoolean(R.bool.enter_is_send));
3357 }
3358
3359 public boolean onArrowUpCtrlPressed() {
3360 final Message lastEditableMessage =
3361 conversation == null ? null : conversation.getLastEditableMessage();
3362 if (lastEditableMessage != null) {
3363 correctMessage(lastEditableMessage);
3364 return true;
3365 } else {
3366 Toast.makeText(getActivity(), R.string.could_not_correct_message, Toast.LENGTH_LONG)
3367 .show();
3368 return false;
3369 }
3370 }
3371
3372 @Override
3373 public void onTypingStarted() {
3374 final XmppConnectionService service =
3375 activity == null ? null : activity.xmppConnectionService;
3376 if (service == null) {
3377 return;
3378 }
3379 final Account.State status = conversation.getAccount().getStatus();
3380 if (status == Account.State.ONLINE
3381 && conversation.setOutgoingChatState(ChatState.COMPOSING)) {
3382 service.sendChatState(conversation);
3383 }
3384 runOnUiThread(this::updateSendButton);
3385 }
3386
3387 @Override
3388 public void onTypingStopped() {
3389 final XmppConnectionService service =
3390 activity == null ? null : activity.xmppConnectionService;
3391 if (service == null) {
3392 return;
3393 }
3394 final Account.State status = conversation.getAccount().getStatus();
3395 if (status == Account.State.ONLINE && conversation.setOutgoingChatState(ChatState.PAUSED)) {
3396 service.sendChatState(conversation);
3397 }
3398 }
3399
3400 @Override
3401 public void onTextDeleted() {
3402 final XmppConnectionService service =
3403 activity == null ? null : activity.xmppConnectionService;
3404 if (service == null) {
3405 return;
3406 }
3407 final Account.State status = conversation.getAccount().getStatus();
3408 if (status == Account.State.ONLINE
3409 && conversation.setOutgoingChatState(Config.DEFAULT_CHAT_STATE)) {
3410 service.sendChatState(conversation);
3411 }
3412 if (storeNextMessage()) {
3413 runOnUiThread(
3414 () -> {
3415 if (activity == null) {
3416 return;
3417 }
3418 activity.onConversationsListItemUpdated();
3419 });
3420 }
3421 runOnUiThread(this::updateSendButton);
3422 }
3423
3424 @Override
3425 public void onTextChanged() {
3426 if (conversation != null && conversation.getCorrectingMessage() != null) {
3427 runOnUiThread(this::updateSendButton);
3428 }
3429 }
3430
3431 @Override
3432 public boolean onTabPressed(boolean repeated) {
3433 if (conversation == null || conversation.getMode() == Conversation.MODE_SINGLE) {
3434 return false;
3435 }
3436 if (repeated) {
3437 completionIndex++;
3438 } else {
3439 lastCompletionLength = 0;
3440 completionIndex = 0;
3441 final String content = this.binding.textinput.getText().toString();
3442 lastCompletionCursor = this.binding.textinput.getSelectionEnd();
3443 int start =
3444 lastCompletionCursor > 0
3445 ? content.lastIndexOf(" ", lastCompletionCursor - 1) + 1
3446 : 0;
3447 firstWord = start == 0;
3448 incomplete = content.substring(start, lastCompletionCursor);
3449 }
3450 List<String> completions = new ArrayList<>();
3451 for (MucOptions.User user : conversation.getMucOptions().getUsers()) {
3452 String name = user.getName();
3453 if (name != null && name.startsWith(incomplete)) {
3454 completions.add(name + (firstWord ? ": " : " "));
3455 }
3456 }
3457 Collections.sort(completions);
3458 if (completions.size() > completionIndex) {
3459 String completion = completions.get(completionIndex).substring(incomplete.length());
3460 this.binding
3461 .textinput
3462 .getEditableText()
3463 .delete(lastCompletionCursor, lastCompletionCursor + lastCompletionLength);
3464 this.binding.textinput.getEditableText().insert(lastCompletionCursor, completion);
3465 lastCompletionLength = completion.length();
3466 } else {
3467 completionIndex = -1;
3468 this.binding
3469 .textinput
3470 .getEditableText()
3471 .delete(lastCompletionCursor, lastCompletionCursor + lastCompletionLength);
3472 lastCompletionLength = 0;
3473 }
3474 return true;
3475 }
3476
3477 private void startPendingIntent(PendingIntent pendingIntent, int requestCode) {
3478 try {
3479 getActivity()
3480 .startIntentSenderForResult(
3481 pendingIntent.getIntentSender(),
3482 requestCode,
3483 null,
3484 0,
3485 0,
3486 0,
3487 Compatibility.pgpStartIntentSenderOptions());
3488 } catch (final SendIntentException ignored) {
3489 }
3490 }
3491
3492 @Override
3493 public void onBackendConnected() {
3494 Log.d(Config.LOGTAG, "ConversationFragment.onBackendConnected()");
3495 String uuid = pendingConversationsUuid.pop();
3496 if (uuid != null) {
3497 if (!findAndReInitByUuidOrArchive(uuid)) {
3498 return;
3499 }
3500 } else {
3501 if (!activity.xmppConnectionService.isConversationStillOpen(conversation)) {
3502 clearPending();
3503 activity.onConversationArchived(conversation);
3504 return;
3505 }
3506 }
3507 ActivityResult activityResult = postponedActivityResult.pop();
3508 if (activityResult != null) {
3509 handleActivityResult(activityResult);
3510 }
3511 clearPending();
3512 }
3513
3514 private boolean findAndReInitByUuidOrArchive(@NonNull final String uuid) {
3515 Conversation conversation = activity.xmppConnectionService.findConversationByUuid(uuid);
3516 if (conversation == null) {
3517 clearPending();
3518 activity.onConversationArchived(null);
3519 return false;
3520 }
3521 reInit(conversation);
3522 ScrollState scrollState = pendingScrollState.pop();
3523 String lastMessageUuid = pendingLastMessageUuid.pop();
3524 List<Attachment> attachments = pendingMediaPreviews.pop();
3525 if (scrollState != null) {
3526 setScrollPosition(scrollState, lastMessageUuid);
3527 }
3528 if (attachments != null && attachments.size() > 0) {
3529 Log.d(Config.LOGTAG, "had attachments on restore");
3530 mediaPreviewAdapter.addMediaPreviews(attachments);
3531 toggleInputMethod();
3532 }
3533 return true;
3534 }
3535
3536 private void clearPending() {
3537 if (postponedActivityResult.clear()) {
3538 Log.e(Config.LOGTAG, "cleared pending intent with unhandled result left");
3539 if (pendingTakePhotoUri.clear()) {
3540 Log.e(Config.LOGTAG, "cleared pending photo uri");
3541 }
3542 }
3543 if (pendingScrollState.clear()) {
3544 Log.e(Config.LOGTAG, "cleared scroll state");
3545 }
3546 if (pendingConversationsUuid.clear()) {
3547 Log.e(Config.LOGTAG, "cleared pending conversations uuid");
3548 }
3549 if (pendingMediaPreviews.clear()) {
3550 Log.e(Config.LOGTAG, "cleared pending media previews");
3551 }
3552 }
3553
3554 public Conversation getConversation() {
3555 return conversation;
3556 }
3557
3558 @Override
3559 public void onContactPictureLongClicked(View v, final Message message) {
3560 final String fingerprint;
3561 if (message.getEncryption() == Message.ENCRYPTION_PGP
3562 || message.getEncryption() == Message.ENCRYPTION_DECRYPTED) {
3563 fingerprint = "pgp";
3564 } else {
3565 fingerprint = message.getFingerprint();
3566 }
3567 final PopupMenu popupMenu = new PopupMenu(getActivity(), v);
3568 final Contact contact = message.getContact();
3569 if (message.getStatus() <= Message.STATUS_RECEIVED
3570 && (contact == null || !contact.isSelf())) {
3571 if (message.getConversation().getMode() == Conversation.MODE_MULTI) {
3572 final Jid cp = message.getCounterpart();
3573 if (cp == null || cp.isBareJid()) {
3574 return;
3575 }
3576 final Jid tcp = message.getTrueCounterpart();
3577 final User userByRealJid =
3578 tcp != null
3579 ? conversation.getMucOptions().findOrCreateUserByRealJid(tcp, cp)
3580 : null;
3581 final User user =
3582 userByRealJid != null
3583 ? userByRealJid
3584 : conversation.getMucOptions().findUserByFullJid(cp);
3585 popupMenu.inflate(R.menu.muc_details_context);
3586 final Menu menu = popupMenu.getMenu();
3587 MucDetailsContextMenuHelper.configureMucDetailsContextMenu(
3588 activity, menu, conversation, user);
3589 popupMenu.setOnMenuItemClickListener(
3590 menuItem ->
3591 MucDetailsContextMenuHelper.onContextItemSelected(
3592 menuItem, user, activity, fingerprint));
3593 } else {
3594 popupMenu.inflate(R.menu.one_on_one_context);
3595 popupMenu.setOnMenuItemClickListener(
3596 item -> {
3597 switch (item.getItemId()) {
3598 case R.id.action_contact_details:
3599 activity.switchToContactDetails(
3600 message.getContact(), fingerprint);
3601 break;
3602 case R.id.action_show_qr_code:
3603 activity.showQrCode(
3604 "xmpp:"
3605 + message.getContact()
3606 .getJid()
3607 .asBareJid()
3608 .toString());
3609 break;
3610 }
3611 return true;
3612 });
3613 }
3614 } else {
3615 popupMenu.inflate(R.menu.account_context);
3616 final Menu menu = popupMenu.getMenu();
3617 menu.findItem(R.id.action_manage_accounts)
3618 .setVisible(QuickConversationsService.isConversations());
3619 popupMenu.setOnMenuItemClickListener(
3620 item -> {
3621 final XmppActivity activity = this.activity;
3622 if (activity == null) {
3623 Log.e(Config.LOGTAG, "Unable to perform action. no context provided");
3624 return true;
3625 }
3626 switch (item.getItemId()) {
3627 case R.id.action_show_qr_code:
3628 activity.showQrCode(conversation.getAccount().getShareableUri());
3629 break;
3630 case R.id.action_account_details:
3631 activity.switchToAccount(
3632 message.getConversation().getAccount(), fingerprint);
3633 break;
3634 case R.id.action_manage_accounts:
3635 AccountUtils.launchManageAccounts(activity);
3636 break;
3637 }
3638 return true;
3639 });
3640 }
3641 popupMenu.show();
3642 }
3643
3644 @Override
3645 public void onContactPictureClicked(Message message) {
3646 String fingerprint;
3647 if (message.getEncryption() == Message.ENCRYPTION_PGP
3648 || message.getEncryption() == Message.ENCRYPTION_DECRYPTED) {
3649 fingerprint = "pgp";
3650 } else {
3651 fingerprint = message.getFingerprint();
3652 }
3653 final boolean received = message.getStatus() <= Message.STATUS_RECEIVED;
3654 if (received) {
3655 if (message.getConversation() instanceof Conversation
3656 && message.getConversation().getMode() == Conversation.MODE_MULTI) {
3657 Jid tcp = message.getTrueCounterpart();
3658 Jid user = message.getCounterpart();
3659 if (user != null && !user.isBareJid()) {
3660 final MucOptions mucOptions =
3661 ((Conversation) message.getConversation()).getMucOptions();
3662 if (mucOptions.participating()
3663 || ((Conversation) message.getConversation()).getNextCounterpart()
3664 != null) {
3665 if (!mucOptions.isUserInRoom(user)
3666 && mucOptions.findUserByRealJid(
3667 tcp == null ? null : tcp.asBareJid())
3668 == null) {
3669 Toast.makeText(
3670 getActivity(),
3671 activity.getString(
3672 R.string.user_has_left_conference,
3673 user.getResource()),
3674 Toast.LENGTH_SHORT)
3675 .show();
3676 }
3677 highlightInConference(user.getResource());
3678 } else {
3679 Toast.makeText(
3680 getActivity(),
3681 R.string.you_are_not_participating,
3682 Toast.LENGTH_SHORT)
3683 .show();
3684 }
3685 }
3686 return;
3687 } else {
3688 if (!message.getContact().isSelf()) {
3689 activity.switchToContactDetails(message.getContact(), fingerprint);
3690 return;
3691 }
3692 }
3693 }
3694 activity.switchToAccount(message.getConversation().getAccount(), fingerprint);
3695 }
3696
3697 private Activity requireActivity() {
3698 final Activity activity = getActivity();
3699 if (activity == null) {
3700 throw new IllegalStateException("Activity not attached");
3701 }
3702 return activity;
3703 }
3704}