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