package org.thoughtcrime.securesms.conversation.v2 import android.Manifest import android.animation.FloatEvaluator import android.animation.ValueAnimator import android.content.ClipData import android.content.ClipboardManager import android.content.Context import android.content.Intent import android.content.res.Resources import android.database.Cursor import android.graphics.Rect import android.graphics.Typeface import android.net.Uri import android.os.AsyncTask import android.os.Build import android.os.Bundle import android.os.Handler import android.os.Looper import android.provider.MediaStore import android.text.SpannableStringBuilder import android.text.SpannedString import android.text.TextUtils import android.text.style.StyleSpan import android.util.Pair import android.util.TypedValue import android.view.ActionMode import android.view.Menu import android.view.MenuItem import android.view.MotionEvent import android.view.View import android.view.WindowManager import android.widget.LinearLayout import android.widget.RelativeLayout import android.widget.Toast import androidx.activity.result.ActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.annotation.DimenRes import androidx.core.text.set import androidx.core.text.toSpannable import androidx.core.view.drawToBitmap import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.fragment.app.DialogFragment import androidx.lifecycle.Lifecycle import androidx.lifecycle.Observer import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.loader.app.LoaderManager import androidx.loader.content.Loader import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.annimon.stream.Stream import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.consumeAsFlow import kotlinx.coroutines.flow.filter import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import network.loki.messenger.R import network.loki.messenger.databinding.ActivityConversationV2Binding import network.loki.messenger.databinding.ViewVisibleMessageBinding import nl.komponents.kovenant.ui.successUi import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.jobs.AttachmentDownloadJob import org.session.libsession.messaging.jobs.JobQueue import org.session.libsession.messaging.mentions.Mention import org.session.libsession.messaging.mentions.MentionsManager import org.session.libsession.messaging.messages.control.DataExtractionNotification import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate import org.session.libsession.messaging.messages.signal.OutgoingMediaMessage import org.session.libsession.messaging.messages.signal.OutgoingTextMessage import org.session.libsession.messaging.messages.visible.Reaction import org.session.libsession.messaging.messages.visible.VisibleMessage import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.messaging.sending_receiving.attachments.Attachment import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel import org.session.libsession.snode.SnodeAPI import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.fromSerialized import org.session.libsession.utilities.GroupUtil import org.session.libsession.utilities.MediaTypes import org.session.libsession.utilities.Stub import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.concurrent.SimpleTask import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.RecipientModifiedListener import org.session.libsignal.crypto.MnemonicCodec import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.ListenableFuture import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.SessionId import org.session.libsignal.utilities.guava.Optional import org.session.libsignal.utilities.hexEncodedPrivateKey import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.attachments.ScreenshotObserver import org.thoughtcrime.securesms.audio.AudioRecorder import org.thoughtcrime.securesms.contacts.SelectContactsActivity.Companion.selectedContactsKey import org.thoughtcrime.securesms.conversation.settings.ConversationSettingsActivityContract import org.thoughtcrime.securesms.conversation.settings.ConversationSettingsActivityResult import org.thoughtcrime.securesms.conversation.v2.ConversationReactionOverlay.OnActionSelectedListener import org.thoughtcrime.securesms.conversation.v2.ConversationReactionOverlay.OnReactionSelectedListener import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companion.MESSAGE_TIMESTAMP import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companion.ON_DELETE import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companion.ON_REPLY import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companion.ON_RESEND import org.thoughtcrime.securesms.conversation.v2.dialogs.BlockedDialog import org.thoughtcrime.securesms.conversation.v2.dialogs.LinkPreviewDialog import org.thoughtcrime.securesms.conversation.v2.dialogs.SendSeedDialog import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBarButton import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBarDelegate import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBarRecordingViewDelegate import org.thoughtcrime.securesms.conversation.v2.input_bar.mentions.MentionCandidatesView import org.thoughtcrime.securesms.conversation.v2.menus.ConversationActionModeCallback import org.thoughtcrime.securesms.conversation.v2.menus.ConversationActionModeCallbackDelegate import org.thoughtcrime.securesms.conversation.v2.menus.ConversationMenuHelper import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageView import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageViewDelegate import org.thoughtcrime.securesms.conversation.v2.search.SearchBottomBar import org.thoughtcrime.securesms.conversation.v2.search.SearchViewModel import org.thoughtcrime.securesms.conversation.v2.utilities.AttachmentManager import org.thoughtcrime.securesms.conversation.v2.utilities.MentionManagerUtilities import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities import org.thoughtcrime.securesms.conversation.v2.utilities.ResendMessageUtilities import org.thoughtcrime.securesms.crypto.IdentityKeyUtil import org.thoughtcrime.securesms.crypto.MnemonicUtilities import org.thoughtcrime.securesms.database.GroupDatabase import org.thoughtcrime.securesms.database.LokiAPIDatabase import org.thoughtcrime.securesms.database.LokiMessageDatabase import org.thoughtcrime.securesms.database.LokiThreadDatabase import org.thoughtcrime.securesms.database.MmsDatabase import org.thoughtcrime.securesms.database.MmsSmsDatabase import org.thoughtcrime.securesms.database.ReactionDatabase import org.thoughtcrime.securesms.database.RecipientDatabase import org.thoughtcrime.securesms.database.SessionContactDatabase import org.thoughtcrime.securesms.database.SmsDatabase import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.model.MessageId import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.database.model.ReactionRecord import org.thoughtcrime.securesms.giph.ui.GiphyActivity import org.thoughtcrime.securesms.groups.OpenGroupManager import org.thoughtcrime.securesms.linkpreview.LinkPreviewRepository import org.thoughtcrime.securesms.linkpreview.LinkPreviewUtil import org.thoughtcrime.securesms.linkpreview.LinkPreviewViewModel import org.thoughtcrime.securesms.linkpreview.LinkPreviewViewModel.LinkPreviewState import org.thoughtcrime.securesms.mediasend.Media import org.thoughtcrime.securesms.mediasend.MediaSendActivity import org.thoughtcrime.securesms.mms.AudioSlide import org.thoughtcrime.securesms.mms.GifSlide import org.thoughtcrime.securesms.mms.GlideApp import org.thoughtcrime.securesms.mms.ImageSlide import org.thoughtcrime.securesms.mms.MediaConstraints import org.thoughtcrime.securesms.mms.Slide import org.thoughtcrime.securesms.mms.SlideDeck import org.thoughtcrime.securesms.mms.VideoSlide import org.thoughtcrime.securesms.permissions.Permissions import org.thoughtcrime.securesms.reactions.ReactionsDialogFragment import org.thoughtcrime.securesms.reactions.any.ReactWithAnyEmojiDialogFragment import org.thoughtcrime.securesms.showExpirationDialog import org.thoughtcrime.securesms.showSessionDialog import org.thoughtcrime.securesms.util.ActivityDispatcher import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.MediaUtil import org.thoughtcrime.securesms.util.SaveAttachmentTask import org.thoughtcrime.securesms.util.SimpleTextWatcher import org.thoughtcrime.securesms.util.isScrolledToBottom import org.thoughtcrime.securesms.util.push import org.thoughtcrime.securesms.util.toPx import java.lang.ref.WeakReference import java.util.Locale import java.util.concurrent.ExecutionException import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicReference import javax.inject.Inject import kotlin.math.abs import kotlin.math.min import kotlin.math.roundToInt import kotlin.math.sqrt // Some things that seemingly belong to the input bar (e.g. the voice message recording UI) are actually // part of the conversation activity layout. This is just because it makes the layout a lot simpler. The // price we pay is a bit of back and forth between the input bar and the conversation activity. @AndroidEntryPoint class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDelegate, InputBarRecordingViewDelegate, AttachmentManager.AttachmentListener, ActivityDispatcher, ConversationActionModeCallbackDelegate, VisibleMessageViewDelegate, RecipientModifiedListener, SearchBottomBar.EventListener, LoaderManager.LoaderCallbacks, OnReactionSelectedListener, ReactWithAnyEmojiDialogFragment.Callback, ReactionsDialogFragment.Callback, ConversationMenuHelper.ConversationMenuListener, View.OnClickListener { private var binding: ActivityConversationV2Binding? = null @Inject lateinit var textSecurePreferences: TextSecurePreferences @Inject lateinit var threadDb: ThreadDatabase @Inject lateinit var mmsSmsDb: MmsSmsDatabase @Inject lateinit var lokiThreadDb: LokiThreadDatabase @Inject lateinit var sessionContactDb: SessionContactDatabase @Inject lateinit var groupDb: GroupDatabase @Inject lateinit var recipientDb: RecipientDatabase @Inject lateinit var lokiApiDb: LokiAPIDatabase @Inject lateinit var smsDb: SmsDatabase @Inject lateinit var mmsDb: MmsDatabase @Inject lateinit var lokiMessageDb: LokiMessageDatabase @Inject lateinit var storage: StorageProtocol @Inject lateinit var reactionDb: ReactionDatabase @Inject lateinit var viewModelFactory: ConversationViewModel.AssistedFactory private val screenshotObserver by lazy { ScreenshotObserver(this, Handler(Looper.getMainLooper())) { // post screenshot message sendScreenshotNotification() } } private val conversationSettingsCallback = registerForActivityResult(ConversationSettingsActivityContract()) { result -> if (result is ConversationSettingsActivityResult.SearchConversation) { // open search binding?.toolbar?.menu?.findItem(R.id.menu_search)?.expandActionView() } } private val screenWidth = Resources.getSystem().displayMetrics.widthPixels private val linkPreviewViewModel: LinkPreviewViewModel by lazy { ViewModelProvider(this, LinkPreviewViewModel.Factory(LinkPreviewRepository())) .get(LinkPreviewViewModel::class.java) } private val viewModel: ConversationViewModel by viewModels { var threadId = intent.getLongExtra(THREAD_ID, -1L) if (threadId == -1L) { intent.getParcelableExtra
(ADDRESS)?.let { it -> threadId = threadDb.getThreadIdIfExistsFor(it.serialize()) if (threadId == -1L) { val sessionId = SessionId(it.serialize()) val openGroup = lokiThreadDb.getOpenGroupChat(intent.getLongExtra(FROM_GROUP_THREAD_ID, -1)) val address = if (sessionId.prefix == IdPrefix.BLINDED && openGroup != null) { storage.getOrCreateBlindedIdMapping(sessionId.hexString(), openGroup.server, openGroup.publicKey).sessionId?.let { fromSerialized(it) } ?: GroupUtil.getEncodedOpenGroupInboxID(openGroup, sessionId) } else { it } val recipient = Recipient.from(this, address, false) threadId = storage.getOrCreateThreadIdFor(recipient.address) } } ?: finish() } viewModelFactory.create(threadId, MessagingModuleConfiguration.shared.getUserED25519KeyPair()) } private var actionMode: ActionMode? = null private var unreadCount = 0 // Attachments private val audioRecorder = AudioRecorder(this) private val stopAudioHandler = Handler(Looper.getMainLooper()) private val stopVoiceMessageRecordingTask = Runnable { sendVoiceMessage() } private val attachmentManager by lazy { AttachmentManager(this, this) } private var isLockViewExpanded = false private var isShowingAttachmentOptions = false // Mentions private val mentions = mutableListOf() private var mentionCandidatesView: MentionCandidatesView? = null private var previousText: CharSequence = "" private var currentMentionStartIndex = -1 private var isShowingMentionCandidatesView = false // Search val searchViewModel: SearchViewModel by viewModels() var searchViewItem: MenuItem? = null private val bufferedLastSeenChannel = Channel(capacity = 512, onBufferOverflow = BufferOverflow.DROP_OLDEST) private var emojiPickerVisible = false private val isScrolledToBottom: Boolean get() = binding?.conversationRecyclerView?.isScrolledToBottom ?: true private val layoutManager: LinearLayoutManager? get() { return binding?.conversationRecyclerView?.layoutManager as LinearLayoutManager? } private val seed by lazy { var hexEncodedSeed = IdentityKeyUtil.retrieve(this, IdentityKeyUtil.LOKI_SEED) if (hexEncodedSeed == null) { hexEncodedSeed = IdentityKeyUtil.getIdentityKeyPair(this).hexEncodedPrivateKey // Legacy account } val loadFileContents: (String) -> String = { fileName -> MnemonicUtilities.loadFileContents(this, fileName) } MnemonicCodec(loadFileContents).encode(hexEncodedSeed!!, MnemonicCodec.Language.Configuration.english) } // There is a bug when initially joining a community where all messages will immediately be marked // as read if we reverse the message list so this is now hard-coded to false private val reverseMessageList = false private val adapter by lazy { val cursor = mmsSmsDb.getConversation(viewModel.threadId, reverseMessageList) val adapter = ConversationAdapter( this, cursor, storage.getLastSeen(viewModel.threadId), reverseMessageList, onItemPress = { message, position, view, event -> handlePress(message, position, view, event) }, onItemSwipeToReply = { message, _ -> handleSwipeToReply(message) }, onItemLongPress = { message, position, view -> if (!viewModel.isMessageRequestThread && viewModel.canReactToMessages ) { showEmojiPicker(message, view) } else { handleLongPress(message, position) } }, onDeselect = { message, position -> actionMode?.let { onDeselect(message, position, it) } }, onAttachmentNeedsDownload = { attachmentId, mmsId -> // Start download (on IO thread) lifecycleScope.launch(Dispatchers.IO) { JobQueue.shared.add(AttachmentDownloadJob(attachmentId, mmsId)) } }, glide = glide, lifecycleCoroutineScope = lifecycleScope ) adapter.visibleMessageViewDelegate = this adapter } private val glide by lazy { GlideApp.with(this) } private val lockViewHitMargin by lazy { toPx(40, resources) } private val gifButton by lazy { InputBarButton(this, R.drawable.ic_gif_white_24dp, hasOpaqueBackground = true, isGIFButton = true) } private val documentButton by lazy { InputBarButton(this, R.drawable.ic_document_small_dark, hasOpaqueBackground = true) } private val libraryButton by lazy { InputBarButton(this, R.drawable.ic_baseline_photo_library_24, hasOpaqueBackground = true) } private val cameraButton by lazy { InputBarButton(this, R.drawable.ic_baseline_photo_camera_24, hasOpaqueBackground = true) } private val messageToScrollTimestamp = AtomicLong(-1) private val messageToScrollAuthor = AtomicReference(null) private val firstLoad = AtomicBoolean(true) private lateinit var reactionDelegate: ConversationReactionDelegate private val reactWithAnyEmojiStartPage = -1 // region Settings companion object { // Extras const val THREAD_ID = "thread_id" const val ADDRESS = "address" const val FROM_GROUP_THREAD_ID = "from_group_thread_id" const val SCROLL_MESSAGE_ID = "scroll_message_id" const val SCROLL_MESSAGE_AUTHOR = "scroll_message_author" // Request codes const val PICK_DOCUMENT = 2 const val TAKE_PHOTO = 7 const val PICK_GIF = 10 const val PICK_FROM_LIBRARY = 12 const val INVITE_CONTACTS = 124 const val CONVERSATION_SETTINGS = 125 // used to open conversation search on result } // endregion // region Lifecycle override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) { super.onCreate(savedInstanceState, isReady) binding = ActivityConversationV2Binding.inflate(layoutInflater) setContentView(binding!!.root) // messageIdToScroll messageToScrollTimestamp.set(intent.getLongExtra(SCROLL_MESSAGE_ID, -1)) messageToScrollAuthor.set(intent.getParcelableExtra(SCROLL_MESSAGE_AUTHOR)) val recipient = viewModel.recipient val openGroup = recipient.let { viewModel.openGroup } if (recipient == null || (recipient.isOpenGroupRecipient && openGroup == null)) { Toast.makeText(this, "This thread has been deleted.", Toast.LENGTH_LONG).show() return finish() } setUpToolBar() setUpInputBar() setUpLinkPreviewObserver() restoreDraftIfNeeded() setUpUiStateObserver() binding!!.scrollToBottomButton.setOnClickListener { val layoutManager = (binding?.conversationRecyclerView?.layoutManager as? LinearLayoutManager) ?: return@setOnClickListener val targetPosition = if (reverseMessageList) 0 else adapter.itemCount if (layoutManager.isSmoothScrolling) { binding?.conversationRecyclerView?.scrollToPosition(targetPosition) } else { // It looks like 'smoothScrollToPosition' will actually load all intermediate items in // order to do the scroll, this can be very slow if there are a lot of messages so // instead we check the current position and if there are more than 10 items to scroll // we jump instantly to the 10th item and scroll from there (this should happen quick // enough to give a similar scroll effect without having to load everything) // val position = if (reverseMessageList) layoutManager.findFirstVisibleItemPosition() else layoutManager.findLastVisibleItemPosition() // val targetBuffer = if (reverseMessageList) 10 else Math.max(0, (adapter.itemCount - 1) - 10) // if (position > targetBuffer) { // binding?.conversationRecyclerView?.scrollToPosition(targetBuffer) // } binding?.conversationRecyclerView?.post { binding?.conversationRecyclerView?.smoothScrollToPosition(targetPosition) } } } updateUnreadCountIndicator() updateSubtitle() updatePlaceholder() setUpBlockedBanner() binding!!.searchBottomBar.setEventListener(this) binding!!.toolbarContent.profilePictureView.setOnClickListener(this) updateSendAfterApprovalText() showOrHideInputIfNeeded() setUpMessageRequestsBar() val weakActivity = WeakReference(this) lifecycleScope.launch(Dispatchers.IO) { // Note: We are accessing the `adapter` property because we want it to be loaded on // the background thread to avoid blocking the UI thread and potentially hanging when // transitioning to the activity weakActivity.get()?.adapter ?: return@launch // 'Get' instead of 'GetAndSet' here because we want to trigger the highlight in 'onFirstLoad' // by triggering 'jumpToMessage' using these values val messageTimestamp = messageToScrollTimestamp.get() val author = messageToScrollAuthor.get() val targetPosition = if (author != null && messageTimestamp >= 0) mmsSmsDb.getMessagePositionInConversation(viewModel.threadId, messageTimestamp, author, reverseMessageList) else -1 withContext(Dispatchers.Main) { setUpRecyclerView() setUpTypingObserver() setUpRecipientObserver() getLatestOpenGroupInfoIfNeeded() setUpSearchResultObserver() if (author != null && messageTimestamp >= 0 && targetPosition >= 0) { binding?.conversationRecyclerView?.scrollToPosition(targetPosition) } else { scrollToFirstUnreadMessageIfNeeded(true) } } } val reactionOverlayStub: Stub = ViewUtil.findStubById(this, R.id.conversation_reaction_scrubber_stub) reactionDelegate = ConversationReactionDelegate(reactionOverlayStub) reactionDelegate.setOnReactionSelectedListener(this) lifecycleScope.launch { lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) { // only update the conversation every 3 seconds maximum // channel is rendezvous and shouldn't block on try send calls as often as we want val bufferedFlow = bufferedLastSeenChannel.consumeAsFlow() bufferedFlow.filter { it > storage.getLastSeen(viewModel.threadId) }.collectLatest { latestMessageRead -> withContext(Dispatchers.IO) { storage.markConversationAsRead(viewModel.threadId, latestMessageRead) } } } } } override fun onResume() { super.onResume() ApplicationContext.getInstance(this).messageNotifier.setVisibleThread(viewModel.threadId) contentResolver.registerContentObserver( MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true, screenshotObserver ) } override fun onPause() { super.onPause() ApplicationContext.getInstance(this).messageNotifier.setVisibleThread(-1) contentResolver.unregisterContentObserver(screenshotObserver) } override fun getSystemService(name: String): Any? { if (name == ActivityDispatcher.SERVICE) { return this } return super.getSystemService(name) } override fun dispatchIntent(body: (Context) -> Intent?) { val intent = body(this) ?: return push(intent, false) } override fun showDialog(dialogFragment: DialogFragment, tag: String?) { dialogFragment.show(supportFragmentManager, tag) } override fun onCreateLoader(id: Int, bundle: Bundle?): Loader { return ConversationLoader(viewModel.threadId, reverseMessageList, this@ConversationActivityV2) } override fun onLoadFinished(loader: Loader, cursor: Cursor?) { val oldCount = adapter.itemCount val newCount = cursor?.count ?: 0 adapter.changeCursor(cursor) if (cursor != null) { val messageTimestamp = messageToScrollTimestamp.getAndSet(-1) val author = messageToScrollAuthor.getAndSet(null) val initialUnreadCount = mmsSmsDb.getUnreadCount(viewModel.threadId) // Update the unreadCount value to be loaded from the database since we got a new message if (firstLoad.get() || oldCount != newCount || initialUnreadCount != unreadCount) { // Update the unreadCount value to be loaded from the database since we got a new // message (we need to store it in a local variable as it can get overwritten on // another thread before the 'firstLoad.getAndSet(false)' case below) unreadCount = initialUnreadCount updateUnreadCountIndicator() } if (author != null && messageTimestamp >= 0) { jumpToMessage(author, messageTimestamp, firstLoad.get(), null) } else if (firstLoad.getAndSet(false)) { scrollToFirstUnreadMessageIfNeeded(true) handleRecyclerViewScrolled() } else if (oldCount != newCount) { handleRecyclerViewScrolled() } } updatePlaceholder() } override fun onLoaderReset(cursor: Loader) { adapter.changeCursor(null) } // called from onCreate private fun setUpRecyclerView() { binding!!.conversationRecyclerView.adapter = adapter val layoutManager = LinearLayoutManager(this, LinearLayoutManager.VERTICAL, reverseMessageList) binding!!.conversationRecyclerView.layoutManager = layoutManager // Workaround for the fact that CursorRecyclerViewAdapter doesn't auto-update automatically (even though it says it will) LoaderManager.getInstance(this).restartLoader(0, null, this) binding!!.conversationRecyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { handleRecyclerViewScrolled() } override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { } }) binding!!.conversationRecyclerView.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> showScrollToBottomButtonIfApplicable() } } // called from onCreate private fun setUpToolBar() { val binding = binding ?: return setSupportActionBar(binding.toolbar) val actionBar = supportActionBar ?: return val recipient = viewModel.recipient ?: return actionBar.title = "" actionBar.setDisplayHomeAsUpEnabled(true) actionBar.setHomeButtonEnabled(true) binding.toolbarContent.conversationTitleView.text = when { recipient.isLocalNumber -> getString(R.string.note_to_self) else -> recipient.toShortString() } @DimenRes val sizeID: Int = if (viewModel.recipient?.isLegacyClosedGroupRecipient == true) { R.dimen.medium_profile_picture_size } else { R.dimen.small_profile_picture_size } val size = resources.getDimension(sizeID).roundToInt() binding.toolbarContent.profilePictureView.layoutParams = LinearLayout.LayoutParams(size, size) MentionManagerUtilities.populateUserPublicKeyCacheIfNeeded(viewModel.threadId, this) val profilePictureView = binding.toolbarContent.profilePictureView viewModel.recipient?.let(profilePictureView::update) } // called from onCreate private fun setUpInputBar() { val binding = binding ?: return binding.inputBar.isGone = viewModel.hidesInputBar() binding.inputBar.delegate = this binding.inputBarRecordingView.delegate = this // GIF button binding.gifButtonContainer.addView(gifButton) gifButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT) gifButton.onUp = { showGIFPicker() } gifButton.snIsEnabled = false // Document button binding.documentButtonContainer.addView(documentButton) documentButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT) documentButton.onUp = { showDocumentPicker() } documentButton.snIsEnabled = false // Library button binding.libraryButtonContainer.addView(libraryButton) libraryButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT) libraryButton.onUp = { pickFromLibrary() } libraryButton.snIsEnabled = false // Camera button binding.cameraButtonContainer.addView(cameraButton) cameraButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT) cameraButton.onUp = { showCamera() } cameraButton.snIsEnabled = false } // called from onCreate private fun restoreDraftIfNeeded() { val mediaURI = intent.data val mediaType = AttachmentManager.MediaType.from(intent.type) if (mediaURI != null && mediaType != null) { if (AttachmentManager.MediaType.IMAGE == mediaType || AttachmentManager.MediaType.GIF == mediaType || AttachmentManager.MediaType.VIDEO == mediaType) { val media = Media(mediaURI, MediaUtil.getMimeType(this, mediaURI)!!, 0, 0, 0, 0, Optional.absent(), Optional.absent()) startActivityForResult(MediaSendActivity.buildEditorIntent(this, listOf( media ), viewModel.recipient!!, ""), PICK_FROM_LIBRARY) return } else { prepMediaForSending(mediaURI, mediaType).addListener(object : ListenableFuture.Listener { override fun onSuccess(result: Boolean?) { sendAttachments(attachmentManager.buildSlideDeck().asAttachments(), null) } override fun onFailure(e: ExecutionException?) { Toast.makeText(this@ConversationActivityV2, R.string.activity_conversation_attachment_prep_failed, Toast.LENGTH_LONG).show() } }) return } } else if (intent.hasExtra(Intent.EXTRA_TEXT)) { val dataTextExtra = intent.getCharSequenceExtra(Intent.EXTRA_TEXT) ?: "" binding!!.inputBar.text = dataTextExtra.toString() } else { viewModel.getDraft()?.let { text -> binding!!.inputBar.text = text } } } // called from onCreate private fun setUpTypingObserver() { ApplicationContext.getInstance(this).typingStatusRepository.getTypists(viewModel.threadId).observe(this) { state -> val recipients = if (state != null) state.typists else listOf() // FIXME: Also checking isScrolledToBottom is a quick fix for an issue where the // typing indicator overlays the recycler view when scrolled up val viewContainer = binding?.typingIndicatorViewContainer ?: return@observe viewContainer.isVisible = recipients.isNotEmpty() && isScrolledToBottom viewContainer.setTypists(recipients) } if (textSecurePreferences.isTypingIndicatorsEnabled()) { binding!!.inputBar.addTextChangedListener(object : SimpleTextWatcher() { override fun onTextChanged(text: String?) { ApplicationContext.getInstance(this@ConversationActivityV2).typingStatusSender.onTypingStarted(viewModel.threadId) } }) } } private fun setUpRecipientObserver() { viewModel.recipient?.addListener(this) } private fun tearDownRecipientObserver() { viewModel.recipient?.removeListener(this) } private fun getLatestOpenGroupInfoIfNeeded() { viewModel.openGroup?.let { OpenGroupApi.getMemberCount(it.room, it.server).successUi { updateSubtitle() } } } // called from onCreate private fun setUpBlockedBanner() { val recipient = viewModel.recipient ?: return if (recipient.isGroupRecipient) { return } val sessionID = recipient.address.toString() val contact = sessionContactDb.getContactWithSessionID(sessionID) val name = contact?.displayName(Contact.ContactContext.REGULAR) ?: sessionID binding?.blockedBannerTextView?.text = resources.getString(R.string.activity_conversation_blocked_banner_text, name) binding?.blockedBanner?.isVisible = recipient.isBlocked binding?.blockedBanner?.setOnClickListener { viewModel.unblock() } } private fun setUpLinkPreviewObserver() { if (!textSecurePreferences.isLinkPreviewsEnabled()) { linkPreviewViewModel.onUserCancel(); return } linkPreviewViewModel.linkPreviewState.observe(this) { previewState: LinkPreviewState? -> if (previewState == null) return@observe when { previewState.isLoading -> { binding?.inputBar?.draftLinkPreview() } previewState.linkPreview.isPresent -> { binding?.inputBar?.updateLinkPreviewDraft(glide, previewState.linkPreview.get()) } else -> { binding?.inputBar?.cancelLinkPreviewDraft() } } } } private fun setUpUiStateObserver() { lifecycleScope.launchWhenStarted { viewModel.uiState.collect { uiState -> uiState.uiMessages.firstOrNull()?.let { Toast.makeText(this@ConversationActivityV2, it.message, Toast.LENGTH_LONG).show() viewModel.messageShown(it.id) } if (uiState.isMessageRequestAccepted == true) { binding?.messageRequestBar?.visibility = View.GONE } if (!uiState.conversationExists && !isFinishing) { // Conversation should be deleted now, just go back finish() } } } } private fun scrollToFirstUnreadMessageIfNeeded(isFirstLoad: Boolean = false, shouldHighlight: Boolean = false): Int { val lastSeenTimestamp = threadDb.getLastSeenAndHasSent(viewModel.threadId).first() val lastSeenItemPosition = adapter.findLastSeenItemPosition(lastSeenTimestamp) ?: return -1 // If this is triggered when first opening a conversation then we want to position the top // of the first unread message in the middle of the screen if (isFirstLoad && !reverseMessageList) { layoutManager?.scrollToPositionWithOffset(lastSeenItemPosition, ((layoutManager?.height ?: 0) / 2)) if (shouldHighlight) { highlightViewAtPosition(lastSeenItemPosition) } return lastSeenItemPosition } if (lastSeenItemPosition <= 3) { return lastSeenItemPosition } binding?.conversationRecyclerView?.scrollToPosition(lastSeenItemPosition) return lastSeenItemPosition } private fun highlightViewAtPosition(position: Int) { binding?.conversationRecyclerView?.post { (layoutManager?.findViewByPosition(position) as? VisibleMessageView)?.playHighlight() } } override fun onPrepareOptionsMenu(menu: Menu): Boolean { val recipient = viewModel.recipient ?: return false if (!viewModel.isMessageRequestThread) { ConversationMenuHelper.onPrepareOptionsMenu( menu, menuInflater, recipient, viewModel.threadId, this ) { onOptionsItemSelected(it) } } return true } override fun onDestroy() { viewModel.saveDraft(binding?.inputBar?.text?.trim() ?: "") tearDownRecipientObserver() super.onDestroy() binding = null // actionBarBinding = null } // endregion // region Animation & Updating override fun onModified(recipient: Recipient) { viewModel.updateRecipient() runOnUiThread { val threadRecipient = viewModel.recipient ?: return@runOnUiThread if (threadRecipient.isContactRecipient) { binding?.blockedBanner?.isVisible = threadRecipient.isBlocked } setUpMessageRequestsBar() invalidateOptionsMenu() updateSubtitle() updateSendAfterApprovalText() showOrHideInputIfNeeded() binding?.toolbarContent?.profilePictureView?.update(threadRecipient) binding?.toolbarContent?.conversationTitleView?.text = when { threadRecipient.isLocalNumber -> getString(R.string.note_to_self) else -> threadRecipient.toShortString() } } } private fun updateSendAfterApprovalText() { binding?.textSendAfterApproval?.isVisible = viewModel.showSendAfterApprovalText } private fun showOrHideInputIfNeeded() { val recipient = viewModel.recipient ?: return if (recipient.isLegacyClosedGroupRecipient) { val group = groupDb.getGroup(recipient.address.toGroupString()).orNull() val isActive = (group?.isActive == true) binding?.inputBar?.showInput = isActive } else { binding?.inputBar?.showInput = true } } private fun setUpMessageRequestsBar() { val recipient = viewModel.recipient ?: return binding?.inputBar?.showMediaControls = !isOutgoingMessageRequestThread() binding?.messageRequestBar?.isVisible = isIncomingMessageRequestThread() binding?.sendAcceptsTextView?.setText( if (recipient.isClosedGroupRecipient) R.string.message_requests_send_group_notice else R.string.message_requests_send_notice ) binding?.acceptMessageRequestButton?.setOnClickListener { acceptMessageRequest() } binding?.messageRequestBlock?.setOnClickListener { block(deleteThread = true) } binding?.declineMessageRequestButton?.setOnClickListener { viewModel.declineMessageRequest() lifecycleScope.launch(Dispatchers.IO) { ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@ConversationActivityV2) } finish() } } private fun acceptMessageRequest() { binding?.messageRequestBar?.isVisible = false viewModel.acceptMessageRequest() lifecycleScope.launch(Dispatchers.IO) { ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@ConversationActivityV2) } } private fun isOutgoingMessageRequestThread(): Boolean { val recipient = viewModel.recipient ?: return false return !recipient.isGroupRecipient && !recipient.isLocalNumber && !(recipient.hasApprovedMe() || viewModel.hasReceived()) } private fun isIncomingMessageRequestThread(): Boolean { val recipient = viewModel.recipient ?: return false return !recipient.isLegacyClosedGroupRecipient && !recipient.isOpenGroupRecipient && !recipient.isApproved && !recipient.isLocalNumber && !threadDb.getLastSeenAndHasSent(viewModel.threadId).second() && (threadDb.getMessageCount(viewModel.threadId) > 0 || recipient.isClosedGroupRecipient) } override fun inputBarEditTextContentChanged(newContent: CharSequence) { val inputBarText = binding?.inputBar?.text ?: return // TODO check if we should be referencing newContent here instead if (textSecurePreferences.isLinkPreviewsEnabled()) { linkPreviewViewModel.onTextChanged(this, inputBarText, 0, 0) } showOrHideMentionCandidatesIfNeeded(newContent) if (LinkPreviewUtil.findWhitelistedUrls(newContent.toString()).isNotEmpty() && !textSecurePreferences.isLinkPreviewsEnabled() && !textSecurePreferences.hasSeenLinkPreviewSuggestionDialog()) { LinkPreviewDialog { setUpLinkPreviewObserver() linkPreviewViewModel.onEnabled() linkPreviewViewModel.onTextChanged(this, inputBarText, 0, 0) }.show(supportFragmentManager, "Link Preview Dialog") textSecurePreferences.setHasSeenLinkPreviewSuggestionDialog() } } private fun showOrHideMentionCandidatesIfNeeded(text: CharSequence) { if (text.length < previousText.length) { currentMentionStartIndex = -1 hideMentionCandidates() val mentionsToRemove = mentions.filter { !text.contains(it.displayName) } mentions.removeAll(mentionsToRemove) } if (text.isNotEmpty()) { val lastCharIndex = text.lastIndex val lastChar = text[lastCharIndex] // Check if there is whitespace before the '@' or the '@' is the first character val isCharacterBeforeLastWhiteSpaceOrStartOfLine: Boolean if (text.length == 1) { isCharacterBeforeLastWhiteSpaceOrStartOfLine = true // Start of line } else { val charBeforeLast = text[lastCharIndex - 1] isCharacterBeforeLastWhiteSpaceOrStartOfLine = Character.isWhitespace(charBeforeLast) } if (lastChar == '@' && isCharacterBeforeLastWhiteSpaceOrStartOfLine) { currentMentionStartIndex = lastCharIndex showOrUpdateMentionCandidatesIfNeeded() } else if (Character.isWhitespace(lastChar) || lastChar == '@') { // the lastCharacter == "@" is to check for @@ currentMentionStartIndex = -1 hideMentionCandidates() } else if (currentMentionStartIndex != -1) { val query = text.substring(currentMentionStartIndex + 1) // + 1 to get rid of the "@" showOrUpdateMentionCandidatesIfNeeded(query) } } else { currentMentionStartIndex = -1 hideMentionCandidates() } previousText = text } private fun showOrUpdateMentionCandidatesIfNeeded(query: String = "") { val additionalContentContainer = binding?.additionalContentContainer ?: return val recipient = viewModel.recipient ?: return if (!isShowingMentionCandidatesView) { additionalContentContainer.removeAllViews() val view = MentionCandidatesView(this).apply { contentDescription = context.getString(R.string.AccessibilityId_mentions_list) } view.glide = glide view.onCandidateSelected = { handleMentionSelected(it) } additionalContentContainer.addView(view) val candidates = MentionsManager.getMentionCandidates(query, viewModel.threadId, recipient.isOpenGroupRecipient) this.mentionCandidatesView = view view.show(candidates, viewModel.threadId) } else { val candidates = MentionsManager.getMentionCandidates(query, viewModel.threadId, recipient.isOpenGroupRecipient) this.mentionCandidatesView!!.setMentionCandidates(candidates) } isShowingMentionCandidatesView = true } private fun hideMentionCandidates() { if (isShowingMentionCandidatesView) { val mentionCandidatesView = mentionCandidatesView ?: return val animation = ValueAnimator.ofObject(FloatEvaluator(), mentionCandidatesView.alpha, 0.0f) animation.duration = 250L animation.addUpdateListener { animator -> mentionCandidatesView.alpha = animator.animatedValue as Float if (animator.animatedFraction == 1.0f) { binding?.additionalContentContainer?.removeAllViews() } } animation.start() } isShowingMentionCandidatesView = false } override fun toggleAttachmentOptions() { val targetAlpha = if (isShowingAttachmentOptions) 0.0f else 1.0f val allButtonContainers = listOfNotNull( binding?.cameraButtonContainer, binding?.libraryButtonContainer, binding?.documentButtonContainer, binding?.gifButtonContainer ) val isReversed = isShowingAttachmentOptions // Run the animation in reverse val count = allButtonContainers.size allButtonContainers.indices.forEach { index -> val view = allButtonContainers[index] val animation = ValueAnimator.ofObject(FloatEvaluator(), view.alpha, targetAlpha) animation.duration = 250L animation.startDelay = if (isReversed) 50L * (count - index.toLong()) else 50L * index.toLong() animation.addUpdateListener { animator -> view.alpha = animator.animatedValue as Float } animation.start() } isShowingAttachmentOptions = !isShowingAttachmentOptions val allButtons = listOf( cameraButton, libraryButton, documentButton, gifButton ) allButtons.forEach { it.snIsEnabled = isShowingAttachmentOptions } } override fun showVoiceMessageUI() { binding?.inputBarRecordingView?.show() binding?.inputBar?.alpha = 0.0f val animation = ValueAnimator.ofObject(FloatEvaluator(), 1.0f, 0.0f) animation.duration = 250L animation.addUpdateListener { animator -> binding?.inputBar?.alpha = animator.animatedValue as Float } animation.start() } private fun expandVoiceMessageLockView() { val lockView = binding?.inputBarRecordingView?.lockView ?: return val animation = ValueAnimator.ofObject(FloatEvaluator(), lockView.scaleX, 1.10f) animation.duration = 250L animation.addUpdateListener { animator -> lockView.scaleX = animator.animatedValue as Float lockView.scaleY = animator.animatedValue as Float } animation.start() } private fun collapseVoiceMessageLockView() { val lockView = binding?.inputBarRecordingView?.lockView ?: return val animation = ValueAnimator.ofObject(FloatEvaluator(), lockView.scaleX, 1.0f) animation.duration = 250L animation.addUpdateListener { animator -> lockView.scaleX = animator.animatedValue as Float lockView.scaleY = animator.animatedValue as Float } animation.start() } private fun hideVoiceMessageUI() { val chevronImageView = binding?.inputBarRecordingView?.chevronImageView ?: return val slideToCancelTextView = binding?.inputBarRecordingView?.slideToCancelTextView ?: return listOf( chevronImageView, slideToCancelTextView ).forEach { view -> val animation = ValueAnimator.ofObject(FloatEvaluator(), view.translationX, 0.0f) animation.duration = 250L animation.addUpdateListener { animator -> view.translationX = animator.animatedValue as Float } animation.start() } binding?.inputBarRecordingView?.hide() } override fun handleVoiceMessageUIHidden() { val inputBar = binding?.inputBar ?: return inputBar.alpha = 1.0f val animation = ValueAnimator.ofObject(FloatEvaluator(), 0.0f, 1.0f) animation.duration = 250L animation.addUpdateListener { animator -> inputBar.alpha = animator.animatedValue as Float } animation.start() } private fun handleRecyclerViewScrolled() { val binding = binding ?: return val wasTypingIndicatorVisibleBefore = binding.typingIndicatorViewContainer.isVisible binding.typingIndicatorViewContainer.isVisible = wasTypingIndicatorVisibleBefore && isScrolledToBottom showScrollToBottomButtonIfApplicable() val maybeTargetVisiblePosition = if (reverseMessageList) layoutManager?.findFirstVisibleItemPosition() else layoutManager?.findLastVisibleItemPosition() val targetVisiblePosition = maybeTargetVisiblePosition ?: RecyclerView.NO_POSITION if (!firstLoad.get() && targetVisiblePosition != RecyclerView.NO_POSITION) { val visibleItemTimestamp = adapter.getTimestampForItemAt(targetVisiblePosition) if (visibleItemTimestamp != null) { bufferedLastSeenChannel.trySend(visibleItemTimestamp) } } if (reverseMessageList) { unreadCount = min(unreadCount, targetVisiblePosition).coerceAtLeast(0) } else { val layoutUnreadCount = layoutManager?.let { (it.itemCount - 1) - it.findLastVisibleItemPosition() } ?: RecyclerView.NO_POSITION unreadCount = min(unreadCount, layoutUnreadCount).coerceAtLeast(0) } updateUnreadCountIndicator() } private fun updatePlaceholder() { val recipient = viewModel.recipient ?: return Log.w("Loki", "recipient was null in placeholder update") val blindedRecipient = viewModel.blindedRecipient val binding = binding ?: return val openGroup = viewModel.openGroup val (textResource, insertParam) = when { recipient.isLocalNumber -> R.string.activity_conversation_empty_state_note_to_self to null openGroup != null && !openGroup.canWrite -> R.string.activity_conversation_empty_state_read_only to recipient.toShortString() blindedRecipient?.blocksCommunityMessageRequests == true -> R.string.activity_conversation_empty_state_blocks_community_requests to recipient.toShortString() else -> R.string.activity_conversation_empty_state_default to recipient.toShortString() } val showPlaceholder = adapter.itemCount == 0 binding.placeholderText.isVisible = showPlaceholder if (showPlaceholder) { if (insertParam != null) { val span = getText(textResource) as SpannedString val annotations = span.getSpans(0, span.length, StyleSpan::class.java) val boldSpan = annotations.first() val spannedParam = insertParam.toSpannable() spannedParam[0 until spannedParam.length] = StyleSpan(boldSpan.style) val originalStart = span.getSpanStart(boldSpan) val originalEnd = span.getSpanEnd(boldSpan) val newString = SpannableStringBuilder(span) .replace(originalStart, originalEnd, spannedParam) binding.placeholderText.text = newString } else { binding.placeholderText.setText(textResource) } } } private fun showScrollToBottomButtonIfApplicable() { binding?.scrollToBottomButton?.isVisible = !emojiPickerVisible && !isScrolledToBottom && adapter.itemCount > 0 } private fun updateUnreadCountIndicator() { val binding = binding ?: return val formattedUnreadCount = if (unreadCount < 10000) unreadCount.toString() else "9999+" binding.unreadCountTextView.text = formattedUnreadCount val textSize = if (unreadCount < 10000) 12.0f else 9.0f binding.unreadCountTextView.setTextSize(TypedValue.COMPLEX_UNIT_DIP, textSize) binding.unreadCountTextView.setTypeface(Typeface.DEFAULT, if (unreadCount < 100) Typeface.BOLD else Typeface.NORMAL) binding.unreadCountIndicator.isVisible = (unreadCount != 0) } private fun updateSubtitle() { val actionBarBinding = binding?.toolbarContent ?: return val recipient = viewModel.recipient ?: return actionBarBinding.muteIconImageView.isVisible = recipient.isMuted actionBarBinding.conversationSubtitleView.isVisible = true if (recipient.isMuted) { if (recipient.mutedUntil != Long.MAX_VALUE) { actionBarBinding.conversationSubtitleView.text = getString(R.string.ConversationActivity_muted_until_date, DateUtils.getFormattedDateTime(recipient.mutedUntil, "EEE, MMM d, yyyy HH:mm", Locale.getDefault())) } else { actionBarBinding.conversationSubtitleView.text = getString(R.string.ConversationActivity_muted_forever) } } else if (recipient.isGroupRecipient) { when { recipient.isOpenGroupRecipient -> { viewModel.openGroup?.let { openGroup -> val userCount = lokiApiDb.getUserCount(openGroup.room, openGroup.server) ?: 0 actionBarBinding.conversationSubtitleView.text = getString(R.string.ConversationActivity_active_member_count, userCount) } } recipient.isLegacyClosedGroupRecipient -> { val userCount = groupDb.getGroupMemberAddresses(recipient.address.toGroupString(), true).size actionBarBinding.conversationSubtitleView.text = getString(R.string.ConversationActivity_member_count, userCount) } recipient.isClosedGroupRecipient -> { val userCount = viewModel.closedGroupMembers.size actionBarBinding.conversationSubtitleView.text = getString(R.string.ConversationActivity_member_count, userCount) } } } else { actionBarBinding.conversationSubtitleView.isVisible = false } } // endregion // region Interaction override fun onOptionsItemSelected(item: MenuItem): Boolean { if (item.itemId == android.R.id.home) { return false } return viewModel.recipient?.let { recipient -> ConversationMenuHelper.onOptionItemSelected(this, item, recipient) } ?: false } override fun onClick(v: View?) { if (v === binding?.toolbarContent?.profilePictureView) { // open conversation settings conversationSettingsCallback.launch(viewModel.threadId) } } override fun block(deleteThread: Boolean) { showSessionDialog { title(R.string.RecipientPreferenceActivity_block_this_contact_question) text(R.string.RecipientPreferenceActivity_you_will_no_longer_receive_messages_and_calls_from_this_contact) destructiveButton(R.string.RecipientPreferenceActivity_block, R.string.AccessibilityId_block_confirm) { viewModel.block() if (deleteThread) { viewModel.deleteThread() finish() } } cancelButton() } } override fun copySessionID(sessionId: String) { val clip = ClipData.newPlainText("Session ID", sessionId) val manager = getSystemService(PassphraseRequiredActionBarActivity.CLIPBOARD_SERVICE) as ClipboardManager manager.setPrimaryClip(clip) Toast.makeText(this, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show() } override fun copyOpenGroupUrl(thread: Recipient) { if (!thread.isOpenGroupRecipient) { return } val threadId = threadDb.getThreadIdIfExistsFor(thread) ?: return val openGroup = lokiThreadDb.getOpenGroupChat(threadId) ?: return val clip = ClipData.newPlainText("Community URL", openGroup.joinURL) val manager = getSystemService(PassphraseRequiredActionBarActivity.CLIPBOARD_SERVICE) as ClipboardManager manager.setPrimaryClip(clip) Toast.makeText(this, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show() } // TODO: don't need to allow new closed group check here, removed in new disappearing messages override fun showExpiringMessagesDialog(thread: Recipient) { if (thread.isLegacyClosedGroupRecipient) { val group = groupDb.getGroup(thread.address.toGroupString()).orNull() if (group?.isActive == false) { return } } showExpirationDialog(thread.expireMessages) { expirationTime -> storage.setExpirationTimer(thread.address.serialize(), expirationTime) val message = ExpirationTimerUpdate(expirationTime) message.recipient = thread.address.serialize() message.sentTimestamp = SnodeAPI.nowWithOffset ApplicationContext.getInstance(this).expiringMessageManager.setExpirationTimer(message) MessageSender.send(message, thread.address) invalidateOptionsMenu() } } override fun unblock() { showSessionDialog { title(R.string.ConversationActivity_unblock_this_contact_question) text(R.string.ConversationActivity_you_will_once_again_be_able_to_receive_messages_and_calls_from_this_contact) destructiveButton( R.string.ConversationActivity_unblock, R.string.AccessibilityId_block_confirm ) { viewModel.unblock() } cancelButton() } } // `position` is the adapter position; not the visual position private fun handlePress(message: MessageRecord, position: Int, view: VisibleMessageView, event: MotionEvent) { val actionMode = this.actionMode if (actionMode != null) { onDeselect(message, position, actionMode) } else { // NOTE: // We have to use onContentClick (rather than a click listener directly on // the view) so as to not interfere with all the other gestures. Do not add // onClickListeners directly to message content views. view.onContentClick(event) } } private fun onDeselect(message: MessageRecord, position: Int, actionMode: ActionMode) { adapter.toggleSelection(message, position) val actionModeCallback = ConversationActionModeCallback(adapter, viewModel.threadId, this) actionModeCallback.delegate = this actionModeCallback.updateActionModeMenu(actionMode.menu) if (adapter.selectedItems.isEmpty()) { actionMode.finish() this.actionMode = null } } // `position` is the adapter position; not the visual position private fun handleSwipeToReply(message: MessageRecord) { val recipient = viewModel.recipient ?: return binding?.inputBar?.draftQuote(recipient, message, glide) } // `position` is the adapter position; not the visual position private fun handleLongPress(message: MessageRecord, position: Int) { val actionMode = this.actionMode val actionModeCallback = ConversationActionModeCallback(adapter, viewModel.threadId, this) actionModeCallback.delegate = this searchViewItem?.collapseActionView() if (actionMode == null) { // Nothing should be selected if this is the case adapter.toggleSelection(message, position) this.actionMode = startActionMode(actionModeCallback, ActionMode.TYPE_PRIMARY) } else { adapter.toggleSelection(message, position) actionModeCallback.updateActionModeMenu(actionMode.menu) if (adapter.selectedItems.isEmpty()) { actionMode.finish() this.actionMode = null } } } private fun showEmojiPicker(message: MessageRecord, visibleMessageView: VisibleMessageView) { val messageContentBitmap = try { visibleMessageView.messageContentView.drawToBitmap() } catch (e: Exception) { Log.e("Loki", "Failed to show emoji picker", e) return } val binding = binding ?: return emojiPickerVisible = true ViewUtil.hideKeyboard(this, visibleMessageView) binding.reactionsShade.isVisible = true binding.scrollToBottomButton.isVisible = false binding.conversationRecyclerView.suppressLayout(true) reactionDelegate.setOnActionSelectedListener(ReactionsToolbarListener(message)) reactionDelegate.setOnHideListener(object: ConversationReactionOverlay.OnHideListener { override fun startHide() { emojiPickerVisible = false binding.reactionsShade.let { ViewUtil.fadeOut(it, resources.getInteger(R.integer.reaction_scrubber_hide_duration), View.GONE) } showScrollToBottomButtonIfApplicable() } override fun onHide() { binding.conversationRecyclerView.suppressLayout(false) WindowUtil.setLightStatusBarFromTheme(this@ConversationActivityV2); WindowUtil.setLightNavigationBarFromTheme(this@ConversationActivityV2); } }) val topLeft = intArrayOf(0, 0).also { visibleMessageView.messageContentView.getLocationInWindow(it) } val selectedConversationModel = SelectedConversationModel( messageContentBitmap, topLeft[0].toFloat(), topLeft[1].toFloat(), visibleMessageView.messageContentView.width, message.isOutgoing, visibleMessageView.messageContentView ) reactionDelegate.show(this, message, selectedConversationModel, viewModel.blindedPublicKey) } override fun dispatchTouchEvent(ev: MotionEvent): Boolean { return reactionDelegate.applyTouchEvent(ev) || super.dispatchTouchEvent(ev) } override fun onReactionSelected(messageRecord: MessageRecord, emoji: String) { reactionDelegate.hide() val oldRecord = messageRecord.reactions.find { it.author == textSecurePreferences.getLocalNumber() } if (oldRecord != null && oldRecord.emoji == emoji) { sendEmojiRemoval(emoji, messageRecord) } else { sendEmojiReaction(emoji, messageRecord) } } private fun sendEmojiReaction(emoji: String, originalMessage: MessageRecord) { // Create the message val recipient = viewModel.recipient ?: return val reactionMessage = VisibleMessage() val emojiTimestamp = SnodeAPI.nowWithOffset reactionMessage.sentTimestamp = emojiTimestamp val author = textSecurePreferences.getLocalNumber()!! // Put the message in the database val reaction = ReactionRecord( messageId = originalMessage.id, isMms = originalMessage.isMms, author = author, emoji = emoji, count = 1, dateSent = emojiTimestamp, dateReceived = emojiTimestamp ) reactionDb.addReaction(MessageId(originalMessage.id, originalMessage.isMms), reaction, false) val originalAuthor = if (originalMessage.isOutgoing) { fromSerialized(viewModel.blindedPublicKey ?: textSecurePreferences.getLocalNumber()!!) } else originalMessage.individualRecipient.address // Send it reactionMessage.reaction = Reaction.from(originalMessage.timestamp, originalAuthor.serialize(), emoji, true) if (recipient.isOpenGroupRecipient) { val messageServerId = lokiMessageDb.getServerID(originalMessage.id, !originalMessage.isMms) ?: return viewModel.openGroup?.let { OpenGroupApi.addReaction(it.room, it.server, messageServerId, emoji) } } else { MessageSender.send(reactionMessage, recipient.address) } LoaderManager.getInstance(this).restartLoader(0, null, this) } private fun sendEmojiRemoval(emoji: String, originalMessage: MessageRecord) { val recipient = viewModel.recipient ?: return val message = VisibleMessage() val emojiTimestamp = SnodeAPI.nowWithOffset message.sentTimestamp = emojiTimestamp val author = textSecurePreferences.getLocalNumber()!! reactionDb.deleteReaction(emoji, MessageId(originalMessage.id, originalMessage.isMms), author, false) val originalAuthor = if (originalMessage.isOutgoing) { fromSerialized(viewModel.blindedPublicKey ?: textSecurePreferences.getLocalNumber()!!) } else originalMessage.individualRecipient.address message.reaction = Reaction.from(originalMessage.timestamp, originalAuthor.serialize(), emoji, false) if (recipient.isOpenGroupRecipient) { val messageServerId = lokiMessageDb.getServerID(originalMessage.id, !originalMessage.isMms) ?: return viewModel.openGroup?.let { OpenGroupApi.deleteReaction(it.room, it.server, messageServerId, emoji) } } else { MessageSender.send(message, recipient.address) } LoaderManager.getInstance(this).restartLoader(0, null, this) } override fun onCustomReactionSelected(messageRecord: MessageRecord, hasAddedCustomEmoji: Boolean) { val oldRecord = messageRecord.reactions.find { record -> record.author == textSecurePreferences.getLocalNumber() } if (oldRecord != null && hasAddedCustomEmoji) { reactionDelegate.hide() sendEmojiRemoval(oldRecord.emoji, messageRecord) } else { reactionDelegate.hideForReactWithAny() ReactWithAnyEmojiDialogFragment .createForMessageRecord(messageRecord, reactWithAnyEmojiStartPage) .show(supportFragmentManager, "BOTTOM"); } } override fun onReactWithAnyEmojiDialogDismissed() { reactionDelegate.hide() } override fun onReactWithAnyEmojiSelected(emoji: String, messageId: MessageId) { reactionDelegate.hide() val message = if (messageId.mms) { mmsDb.getMessageRecord(messageId.id) } else { smsDb.getMessageRecord(messageId.id) } val oldRecord = reactionDb.getReactions(messageId).find { it.author == textSecurePreferences.getLocalNumber() } if (oldRecord?.emoji == emoji) { sendEmojiRemoval(emoji, message) } else { sendEmojiReaction(emoji, message) } } override fun onRemoveReaction(emoji: String, messageId: MessageId) { val message = if (messageId.mms) { mmsDb.getMessageRecord(messageId.id) } else { smsDb.getMessageRecord(messageId.id) } sendEmojiRemoval(emoji, message) } override fun onClearAll(emoji: String, messageId: MessageId) { reactionDb.deleteEmojiReactions(emoji, messageId) viewModel.openGroup?.let { openGroup -> lokiMessageDb.getServerID(messageId.id, !messageId.mms)?.let { serverId -> OpenGroupApi.deleteAllReactions(openGroup.room, openGroup.server, serverId, emoji) } } threadDb.notifyThreadUpdated(viewModel.threadId) } override fun onMicrophoneButtonMove(event: MotionEvent) { val rawX = event.rawX val chevronImageView = binding?.inputBarRecordingView?.chevronImageView ?: return val slideToCancelTextView = binding?.inputBarRecordingView?.slideToCancelTextView ?: return if (rawX < screenWidth / 2) { val translationX = rawX - screenWidth / 2 val sign = -1.0f val chevronDamping = 4.0f val labelDamping = 3.0f val chevronX = (chevronDamping * (sqrt(abs(translationX)) / sqrt(chevronDamping))) * sign val labelX = (labelDamping * (sqrt(abs(translationX)) / sqrt(labelDamping))) * sign chevronImageView.translationX = chevronX slideToCancelTextView.translationX = labelX } else { chevronImageView.translationX = 0.0f slideToCancelTextView.translationX = 0.0f } if (isValidLockViewLocation(event.rawX.roundToInt(), event.rawY.roundToInt())) { if (!isLockViewExpanded) { expandVoiceMessageLockView() isLockViewExpanded = true } } else { if (isLockViewExpanded) { collapseVoiceMessageLockView() isLockViewExpanded = false } } } override fun onMicrophoneButtonCancel(event: MotionEvent) { hideVoiceMessageUI() } override fun onMicrophoneButtonUp(event: MotionEvent) { val x = event.rawX.roundToInt() val y = event.rawY.roundToInt() if (isValidLockViewLocation(x, y)) { binding?.inputBarRecordingView?.lock() } else { val recordButtonOverlay = binding?.inputBarRecordingView?.recordButtonOverlay ?: return val location = IntArray(2) { 0 } recordButtonOverlay.getLocationOnScreen(location) val hitRect = Rect(location[0], location[1], location[0] + recordButtonOverlay.width, location[1] + recordButtonOverlay.height) if (hitRect.contains(x, y)) { sendVoiceMessage() } else { cancelVoiceMessage() } } } private fun isValidLockViewLocation(x: Int, y: Int): Boolean { // We can be anywhere above the lock view and a bit to the side of it (at most `lockViewHitMargin` // to the side) val binding = binding ?: return false val lockViewLocation = IntArray(2) { 0 } binding.inputBarRecordingView.lockView.getLocationOnScreen(lockViewLocation) val hitRect = Rect(lockViewLocation[0] - lockViewHitMargin, 0, lockViewLocation[0] + binding.inputBarRecordingView.lockView.width + lockViewHitMargin, lockViewLocation[1] + binding.inputBarRecordingView.lockView.height) return hitRect.contains(x, y) } private fun handleMentionSelected(mention: Mention) { val binding = binding ?: return if (currentMentionStartIndex == -1) { return } mentions.add(mention) val previousText = binding.inputBar.text val newText = previousText.substring(0, currentMentionStartIndex) + "@" + mention.displayName + " " binding.inputBar.text = newText binding.inputBar.setSelection(newText.length) currentMentionStartIndex = -1 hideMentionCandidates() this.previousText = newText } override fun scrollToMessageIfPossible(timestamp: Long) { val lastSeenItemPosition = adapter.getItemPositionForTimestamp(timestamp) ?: return binding?.conversationRecyclerView?.scrollToPosition(lastSeenItemPosition) } override fun onReactionClicked(emoji: String, messageId: MessageId, userWasSender: Boolean) { val message = if (messageId.mms) { mmsDb.getMessageRecord(messageId.id) } else { smsDb.getMessageRecord(messageId.id) } if (userWasSender) { sendEmojiRemoval(emoji, message) } else { sendEmojiReaction(emoji, message) } } override fun onReactionLongClicked(messageId: MessageId) { if (viewModel.recipient?.isGroupRecipient == true) { val isUserModerator = viewModel.openGroup?.let { openGroup -> val userPublicKey = textSecurePreferences.getLocalNumber() ?: return@let false OpenGroupManager.isUserModerator(this, openGroup.id, userPublicKey, viewModel.blindedPublicKey) } ?: false val fragment = ReactionsDialogFragment.create(messageId, isUserModerator) fragment.show(supportFragmentManager, null) } } override fun playVoiceMessageAtIndexIfPossible(indexInAdapter: Int) { if (!textSecurePreferences.autoplayAudioMessages()) return if (indexInAdapter < 0 || indexInAdapter >= adapter.itemCount) { return } val viewHolder = binding?.conversationRecyclerView?.findViewHolderForAdapterPosition(indexInAdapter) as? ConversationAdapter.VisibleMessageViewHolder ?: return val visibleMessageView = ViewVisibleMessageBinding.bind(viewHolder.view).visibleMessageView visibleMessageView.playVoiceMessage() } override fun sendMessage() { val recipient = viewModel.recipient ?: return if (recipient.isContactRecipient && recipient.isBlocked) { BlockedDialog(recipient, this).show(supportFragmentManager, "Blocked Dialog") return } val binding = binding ?: return val sentMessageInfo = if (binding.inputBar.linkPreview != null || binding.inputBar.quote != null) { sendAttachments(listOf(), getMessageBody(), binding.inputBar.quote, binding.inputBar.linkPreview) } else { sendTextOnlyMessage() } // Jump to the newly sent message once it gets added if (sentMessageInfo != null) { messageToScrollAuthor.set(sentMessageInfo.first) messageToScrollTimestamp.set(sentMessageInfo.second) } } override fun commitInputContent(contentUri: Uri) { val recipient = viewModel.recipient ?: return val media = Media(contentUri, MediaUtil.getMimeType(this, contentUri)!!, 0, 0, 0, 0, Optional.absent(), Optional.absent()) startActivityForResult(MediaSendActivity.buildEditorIntent(this, listOf( media ), recipient, getMessageBody()), PICK_FROM_LIBRARY) } private fun processMessageRequestApproval() { if (isIncomingMessageRequestThread()) { acceptMessageRequest() } else if (viewModel.recipient?.isApproved == false) { // edge case for new outgoing thread on new recipient without sending approval messages viewModel.setRecipientApproved() } } private fun sendTextOnlyMessage(hasPermissionToSendSeed: Boolean = false): Pair? { val recipient = viewModel.recipient ?: return null val sentTimestamp = SnodeAPI.nowWithOffset processMessageRequestApproval() val text = getMessageBody() val userPublicKey = textSecurePreferences.getLocalNumber() val isNoteToSelf = (recipient.isContactRecipient && recipient.address.toString() == userPublicKey) if (text.contains(seed) && !isNoteToSelf && !hasPermissionToSendSeed) { val dialog = SendSeedDialog { sendTextOnlyMessage(true) } dialog.show(supportFragmentManager, "Send Seed Dialog") return null } // Create the message val message = VisibleMessage() message.sentTimestamp = sentTimestamp message.text = text val outgoingTextMessage = OutgoingTextMessage.from(message, recipient) // Clear the input bar binding?.inputBar?.text = "" binding?.inputBar?.cancelQuoteDraft() binding?.inputBar?.cancelLinkPreviewDraft() // Clear mentions previousText = "" currentMentionStartIndex = -1 mentions.clear() // Put the message in the database message.id = smsDb.insertMessageOutbox(viewModel.threadId, outgoingTextMessage, false, message.sentTimestamp!!, null, true) // Send it MessageSender.send(message, recipient.address) // Send a typing stopped message ApplicationContext.getInstance(this).typingStatusSender.onTypingStopped(viewModel.threadId) return Pair(recipient.address, sentTimestamp) } private fun sendAttachments( attachments: List, body: String?, quotedMessage: MessageRecord? = binding?.inputBar?.quote, linkPreview: LinkPreview? = null ): Pair? { val recipient = viewModel.recipient ?: return null val sentTimestamp = SnodeAPI.nowWithOffset processMessageRequestApproval() // Create the message val message = VisibleMessage() message.sentTimestamp = sentTimestamp message.text = body val quote = quotedMessage?.let { val quotedAttachments = (it as? MmsMessageRecord)?.slideDeck?.asAttachments() ?: listOf() val sender = if (it.isOutgoing) { fromSerialized(viewModel.blindedPublicKey ?: textSecurePreferences.getLocalNumber()!!) } else it.individualRecipient.address QuoteModel(it.dateSent, sender, it.body, false, quotedAttachments) } val localQuote = quotedMessage?.let { val sender = if (it.isOutgoing) fromSerialized(textSecurePreferences.getLocalNumber()!!) else it.individualRecipient.address quote?.copy(author = sender) } val outgoingTextMessage = OutgoingMediaMessage.from(message, recipient, attachments, localQuote, linkPreview) // Clear the input bar binding?.inputBar?.text = "" binding?.inputBar?.cancelQuoteDraft() binding?.inputBar?.cancelLinkPreviewDraft() // Clear mentions previousText = "" currentMentionStartIndex = -1 mentions.clear() // Reset the attachment manager attachmentManager.clear() // Reset attachments button if needed if (isShowingAttachmentOptions) { toggleAttachmentOptions() } // Put the message in the database message.id = mmsDb.insertMessageOutbox(outgoingTextMessage, viewModel.threadId, false, null, runThreadUpdate = true) // Send it MessageSender.send(message, recipient.address, attachments, quote, linkPreview) // Send a typing stopped message ApplicationContext.getInstance(this).typingStatusSender.onTypingStopped(viewModel.threadId) return Pair(recipient.address, sentTimestamp) } private fun showGIFPicker() { val hasSeenGIFMetaDataWarning: Boolean = textSecurePreferences.hasSeenGIFMetaDataWarning() if (!hasSeenGIFMetaDataWarning) { showSessionDialog { title(R.string.giphy_permission_title) text(R.string.giphy_permission_message) button(R.string.continue_2) { textSecurePreferences.setHasSeenGIFMetaDataWarning() selectGif() } cancelButton() } } else { selectGif() } } private fun selectGif() = AttachmentManager.selectGif(this, PICK_GIF) private fun showDocumentPicker() { AttachmentManager.selectDocument(this, PICK_DOCUMENT) } private fun pickFromLibrary() { val recipient = viewModel.recipient ?: return binding?.inputBar?.text?.trim()?.let { text -> AttachmentManager.selectGallery(this, PICK_FROM_LIBRARY, recipient, text) } } private fun showCamera() { attachmentManager.capturePhoto(this, TAKE_PHOTO, viewModel.recipient); } override fun onAttachmentChanged() { // Do nothing } override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { super.onRequestPermissionsResult(requestCode, permissions, grantResults) Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults) } override fun onActivityResult(requestCode: Int, resultCode: Int, intent: Intent?) { super.onActivityResult(requestCode, resultCode, intent) val mediaPreppedListener = object : ListenableFuture.Listener { override fun onSuccess(result: Boolean?) { sendAttachments(attachmentManager.buildSlideDeck().asAttachments(), null) } override fun onFailure(e: ExecutionException?) { Toast.makeText(this@ConversationActivityV2, R.string.activity_conversation_attachment_prep_failed, Toast.LENGTH_LONG).show() } } when (requestCode) { PICK_DOCUMENT -> { val uri = intent?.data ?: return prepMediaForSending(uri, AttachmentManager.MediaType.DOCUMENT).addListener(mediaPreppedListener) } PICK_GIF -> { intent ?: return val uri = intent.data ?: return val type = AttachmentManager.MediaType.GIF val width = intent.getIntExtra(GiphyActivity.EXTRA_WIDTH, 0) val height = intent.getIntExtra(GiphyActivity.EXTRA_HEIGHT, 0) prepMediaForSending(uri, type, width, height).addListener(mediaPreppedListener) } PICK_FROM_LIBRARY, TAKE_PHOTO -> { intent ?: return val body = intent.getStringExtra(MediaSendActivity.EXTRA_MESSAGE) val media = intent.getParcelableArrayListExtra(MediaSendActivity.EXTRA_MEDIA) ?: return val slideDeck = SlideDeck() for (item in media) { when { MediaUtil.isVideoType(item.mimeType) -> { slideDeck.addSlide(VideoSlide(this, item.uri, 0, item.caption.orNull())) } MediaUtil.isGif(item.mimeType) -> { slideDeck.addSlide(GifSlide(this, item.uri, 0, item.width, item.height, item.caption.orNull())) } MediaUtil.isImageType(item.mimeType) -> { slideDeck.addSlide(ImageSlide(this, item.uri, 0, item.width, item.height, item.caption.orNull())) } else -> { Log.d("Loki", "Asked to send an unexpected media type: '" + item.mimeType + "'. Skipping.") } } } sendAttachments(slideDeck.asAttachments(), body) } INVITE_CONTACTS -> { if (viewModel.recipient?.isOpenGroupRecipient != true) { return } val extras = intent?.extras ?: return if (!intent.hasExtra(selectedContactsKey)) { return } val selectedContacts = extras.getStringArray(selectedContactsKey)!! val recipients = selectedContacts.map { contact -> Recipient.from(this, fromSerialized(contact), true) } viewModel.inviteContacts(recipients) } } } private fun prepMediaForSending(uri: Uri, type: AttachmentManager.MediaType): ListenableFuture { return prepMediaForSending(uri, type, null, null) } private fun prepMediaForSending(uri: Uri, type: AttachmentManager.MediaType, width: Int?, height: Int?): ListenableFuture { return attachmentManager.setMedia(glide, uri, type, MediaConstraints.getPushMediaConstraints(), width ?: 0, height ?: 0) } override fun startRecordingVoiceMessage() { if (Permissions.hasAll(this, Manifest.permission.RECORD_AUDIO)) { showVoiceMessageUI() window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) audioRecorder.startRecording() stopAudioHandler.postDelayed(stopVoiceMessageRecordingTask, 300000) // Limit voice messages to 5 minute each } else { Permissions.with(this) .request(Manifest.permission.RECORD_AUDIO) .withRationaleDialog(getString(R.string.ConversationActivity_to_send_audio_messages_allow_signal_access_to_your_microphone), R.drawable.ic_baseline_mic_48) .withPermanentDenialDialog(getString(R.string.ConversationActivity_signal_requires_the_microphone_permission_in_order_to_send_audio_messages)) .execute() } } override fun sendVoiceMessage() { hideVoiceMessageUI() window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) val future = audioRecorder.stopRecording() stopAudioHandler.removeCallbacks(stopVoiceMessageRecordingTask) future.addListener(object : ListenableFuture.Listener> { override fun onSuccess(result: Pair) { val audioSlide = AudioSlide(this@ConversationActivityV2, result.first, result.second, MediaTypes.AUDIO_AAC, true) val slideDeck = SlideDeck() slideDeck.addSlide(audioSlide) sendAttachments(slideDeck.asAttachments(), null) } override fun onFailure(e: ExecutionException) { Toast.makeText(this@ConversationActivityV2, R.string.ConversationActivity_unable_to_record_audio, Toast.LENGTH_LONG).show() } }) } override fun cancelVoiceMessage() { hideVoiceMessageUI() window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) audioRecorder.stopRecording() stopAudioHandler.removeCallbacks(stopVoiceMessageRecordingTask) } override fun selectMessages(messages: Set) { handleLongPress(messages.first(), 0) //TODO: begin selection mode } override fun deleteMessages(messages: Set) { val recipient = viewModel.recipient ?: return val allSentByCurrentUser = messages.all { it.isOutgoing } val allHasHash = messages.all { lokiMessageDb.getMessageServerHash(it.id) != null } if (recipient.isOpenGroupRecipient) { val messageCount = 1 showSessionDialog { title(resources.getQuantityString(R.plurals.ConversationFragment_delete_selected_messages, messageCount, messageCount)) text(resources.getQuantityString(R.plurals.ConversationFragment_this_will_permanently_delete_all_n_selected_messages, messageCount, messageCount)) button(R.string.delete) { messages.forEach(viewModel::deleteForEveryone); endActionMode() } cancelButton { endActionMode() } } } else if (allSentByCurrentUser && allHasHash) { val bottomSheet = DeleteOptionsBottomSheet() bottomSheet.recipient = recipient bottomSheet.onDeleteForMeTapped = { messages.forEach(viewModel::deleteLocally) bottomSheet.dismiss() endActionMode() } bottomSheet.onDeleteForEveryoneTapped = { messages.forEach(viewModel::deleteForEveryone) bottomSheet.dismiss() endActionMode() } bottomSheet.onCancelTapped = { bottomSheet.dismiss() endActionMode() } bottomSheet.show(supportFragmentManager, bottomSheet.tag) } else { val messageCount = 1 showSessionDialog { title(resources.getQuantityString(R.plurals.ConversationFragment_delete_selected_messages, messageCount, messageCount)) text(resources.getQuantityString(R.plurals.ConversationFragment_this_will_permanently_delete_all_n_selected_messages, messageCount, messageCount)) button(R.string.delete) { messages.forEach(viewModel::deleteLocally); endActionMode() } cancelButton(::endActionMode) } } } override fun banUser(messages: Set) { showSessionDialog { title(R.string.ConversationFragment_ban_selected_user) text("This will ban the selected user from this room. It won't ban them from other rooms.") button(R.string.ban) { viewModel.banUser(messages.first().individualRecipient); endActionMode() } cancelButton(::endActionMode) } } override fun banAndDeleteAll(messages: Set) { showSessionDialog { title(R.string.ConversationFragment_ban_selected_user) text("This will ban the selected user from this room and delete all messages sent by them. It won't ban them from other rooms or delete the messages they sent there.") button(R.string.ban) { viewModel.banAndDeleteAll(messages.first().individualRecipient); endActionMode() } cancelButton(::endActionMode) } } override fun copyMessages(messages: Set) { val sortedMessages = messages.sortedBy { it.dateSent } val messageSize = sortedMessages.size val builder = StringBuilder() val messageIterator = sortedMessages.iterator() while (messageIterator.hasNext()) { val message = messageIterator.next() val body = MentionUtilities.highlightMentions(message.body, viewModel.threadId, this) if (TextUtils.isEmpty(body)) { continue } if (messageSize > 1) { val formattedTimestamp = DateUtils.getDisplayFormattedTimeSpanString(this, Locale.getDefault(), message.timestamp) builder.append("$formattedTimestamp: ") } builder.append(body) if (messageIterator.hasNext()) { builder.append('\n') } } if (builder.isNotEmpty() && builder[builder.length - 1] == '\n') { builder.deleteCharAt(builder.length - 1) } val result = builder.toString() if (TextUtils.isEmpty(result)) { return } val manager = getSystemService(CLIPBOARD_SERVICE) as ClipboardManager manager.setPrimaryClip(ClipData.newPlainText("Message Content", result)) Toast.makeText(this, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show() endActionMode() } override fun copySessionID(messages: Set) { val sessionID = messages.first().individualRecipient.address.toString() val clip = ClipData.newPlainText("Session ID", sessionID) val manager = getSystemService(CLIPBOARD_SERVICE) as ClipboardManager manager.setPrimaryClip(clip) Toast.makeText(this, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show() endActionMode() } override fun resyncMessage(messages: Set) { messages.iterator().forEach { messageRecord -> ResendMessageUtilities.resend(this, messageRecord, viewModel.blindedPublicKey, isResync = true) } endActionMode() } override fun resendMessage(messages: Set) { messages.iterator().forEach { messageRecord -> ResendMessageUtilities.resend(this, messageRecord, viewModel.blindedPublicKey) } endActionMode() } private val handleMessageDetail = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult -> val message = result.data?.extras?.getLong(MESSAGE_TIMESTAMP) ?.let(mmsSmsDb::getMessageForTimestamp) val set = setOfNotNull(message) when (result.resultCode) { ON_REPLY -> reply(set) ON_RESEND -> resendMessage(set) ON_DELETE -> deleteMessages(set) } } override fun showMessageDetail(messages: Set) { Intent(this, MessageDetailActivity::class.java) .apply { putExtra(MESSAGE_TIMESTAMP, messages.first().timestamp) } .let { handleMessageDetail.launch(it) } endActionMode() } override fun saveAttachment(messages: Set) { val message = messages.first() as MmsMessageRecord SaveAttachmentTask.showWarningDialog(this) { Permissions.with(this) .request(Manifest.permission.WRITE_EXTERNAL_STORAGE) .maxSdkVersion(Build.VERSION_CODES.P) .withPermanentDenialDialog(getString(R.string.MediaPreviewActivity_signal_needs_the_storage_permission_in_order_to_write_to_external_storage_but_it_has_been_permanently_denied)) .onAnyDenied { endActionMode() Toast.makeText(this@ConversationActivityV2, R.string.MediaPreviewActivity_unable_to_write_to_external_storage_without_permission, Toast.LENGTH_LONG).show() } .onAllGranted { endActionMode() val attachments: List = Stream.of(message.slideDeck.slides) .filter { s: Slide -> s.uri != null && (s.hasImage() || s.hasVideo() || s.hasAudio() || s.hasDocument()) } .map { s: Slide -> SaveAttachmentTask.Attachment(s.uri!!, s.contentType, message.dateReceived, s.fileName.orNull()) } .toList() if (attachments.isNotEmpty()) { val saveTask = SaveAttachmentTask(this) saveTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, *attachments.toTypedArray()) if (!message.isOutgoing) { sendMediaSavedNotification() } return@onAllGranted } Toast.makeText(this, resources.getQuantityString(R.plurals.ConversationFragment_error_while_saving_attachments_to_sd_card, 1), Toast.LENGTH_LONG).show() } .execute() } } override fun reply(messages: Set) { val recipient = viewModel.recipient ?: return messages.firstOrNull()?.let { binding?.inputBar?.draftQuote(recipient, it, glide) } endActionMode() } override fun destroyActionMode() { this.actionMode = null } private fun sendScreenshotNotification() { val recipient = viewModel.recipient ?: return if (recipient.isGroupRecipient) return val kind = DataExtractionNotification.Kind.Screenshot() val message = DataExtractionNotification(kind) MessageSender.send(message, recipient.address) } private fun sendMediaSavedNotification() { val recipient = viewModel.recipient ?: return if (recipient.isGroupRecipient) { return } val timestamp = SnodeAPI.nowWithOffset val kind = DataExtractionNotification.Kind.MediaSaved(timestamp) val message = DataExtractionNotification(kind) MessageSender.send(message, recipient.address) } private fun endActionMode() { actionMode?.finish() actionMode = null } // endregion // region General private fun getMessageBody(): String { var result = binding?.inputBar?.text?.trim() ?: return "" for (mention in mentions) { try { val startIndex = result.indexOf("@" + mention.displayName) val endIndex = startIndex + mention.displayName.count() + 1 // + 1 to include the "@" result = result.substring(0, startIndex) + "@" + mention.publicKey + result.substring(endIndex) } catch (exception: Exception) { Log.d("Loki", "Failed to process mention due to error: $exception") } } return result } // endregion // region Search private fun setUpSearchResultObserver() { searchViewModel.searchResults.observe(this, Observer { result: SearchViewModel.SearchResult? -> if (result == null) return@Observer if (result.getResults().isNotEmpty()) { result.getResults()[result.position]?.let { jumpToMessage(it.messageRecipient.address, it.sentTimestampMs, true) { searchViewModel.onMissingResult() } } } binding?.searchBottomBar?.setData(result.position, result.getResults().size) }) } fun onSearchOpened() { searchViewModel.onSearchOpened() binding?.searchBottomBar?.visibility = View.VISIBLE binding?.searchBottomBar?.setData(0, 0) binding?.inputBar?.visibility = View.INVISIBLE } fun onSearchClosed() { searchViewModel.onSearchClosed() binding?.searchBottomBar?.visibility = View.GONE binding?.inputBar?.visibility = View.VISIBLE adapter.onSearchQueryUpdated(null) invalidateOptionsMenu() } fun onSearchQueryUpdated(query: String) { searchViewModel.onQueryUpdated(query, viewModel.threadId) binding?.searchBottomBar?.showLoading() adapter.onSearchQueryUpdated(query) } override fun onSearchMoveUpPressed() { this.searchViewModel.onMoveUp() } override fun onSearchMoveDownPressed() { this.searchViewModel.onMoveDown() } private fun jumpToMessage(author: Address, timestamp: Long, highlight: Boolean, onMessageNotFound: Runnable?) { SimpleTask.run(lifecycle, { mmsSmsDb.getMessagePositionInConversation(viewModel.threadId, timestamp, author, reverseMessageList) }) { p: Int -> moveToMessagePosition(p, highlight, onMessageNotFound) } } private fun moveToMessagePosition(position: Int, highlight: Boolean, onMessageNotFound: Runnable?) { if (position >= 0) { binding?.conversationRecyclerView?.scrollToPosition(position) if (highlight) { runOnUiThread { highlightViewAtPosition(position) } } } else { onMessageNotFound?.run() } } // endregion inner class ReactionsToolbarListener constructor(val message: MessageRecord) : OnActionSelectedListener { override fun onActionSelected(action: ConversationReactionOverlay.Action) { val selectedItems = setOf(message) when (action) { ConversationReactionOverlay.Action.REPLY -> reply(selectedItems) ConversationReactionOverlay.Action.RESYNC -> resyncMessage(selectedItems) ConversationReactionOverlay.Action.RESEND -> resendMessage(selectedItems) ConversationReactionOverlay.Action.DOWNLOAD -> saveAttachment(selectedItems) ConversationReactionOverlay.Action.COPY_MESSAGE -> copyMessages(selectedItems) ConversationReactionOverlay.Action.VIEW_INFO -> showMessageDetail(selectedItems) ConversationReactionOverlay.Action.SELECT -> selectMessages(selectedItems) ConversationReactionOverlay.Action.DELETE -> deleteMessages(selectedItems) ConversationReactionOverlay.Action.BAN_AND_DELETE_ALL -> banAndDeleteAll(selectedItems) ConversationReactionOverlay.Action.BAN_USER -> banUser(selectedItems) ConversationReactionOverlay.Action.COPY_SESSION_ID -> copySessionID(selectedItems) } } } }