session-android/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt

1143 lines
54 KiB
Kotlin
Raw Normal View History

package org.thoughtcrime.securesms.conversation.v2
import android.Manifest
2021-06-16 07:49:39 +02:00
import android.animation.FloatEvaluator
import android.animation.ValueAnimator
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
2021-06-25 08:30:23 +02:00
import android.content.Intent
2021-06-17 02:53:56 +02:00
import android.content.res.Resources
import android.database.Cursor
2021-06-17 05:18:09 +02:00
import android.graphics.Rect
2021-06-24 02:18:52 +02:00
import android.graphics.Typeface
2021-06-28 02:00:18 +02:00
import android.net.Uri
2021-06-28 03:11:29 +02:00
import android.os.*
2021-06-29 02:05:39 +02:00
import android.text.TextUtils
2021-06-25 06:42:04 +02:00
import android.util.Log
2021-06-28 03:11:29 +02:00
import android.util.Pair
import android.util.TypedValue
2021-06-23 05:11:21 +02:00
import android.view.*
2021-06-16 01:51:50 +02:00
import android.widget.RelativeLayout
2021-06-28 02:44:00 +02:00
import android.widget.Toast
2021-06-29 02:05:39 +02:00
import androidx.appcompat.app.AlertDialog
2021-06-24 02:18:52 +02:00
import androidx.core.view.isVisible
2021-06-24 07:46:36 +02:00
import androidx.lifecycle.ViewModelProviders
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
2021-05-31 06:29:11 +02:00
import kotlinx.android.synthetic.main.activity_conversation_v2.*
2021-06-17 02:53:56 +02:00
import kotlinx.android.synthetic.main.activity_conversation_v2.view.*
import kotlinx.android.synthetic.main.activity_conversation_v2_action_bar.*
2021-06-23 05:11:21 +02:00
import kotlinx.android.synthetic.main.activity_home.*
2021-06-24 02:18:52 +02:00
import kotlinx.android.synthetic.main.view_conversation.view.*
2021-06-15 06:55:57 +02:00
import kotlinx.android.synthetic.main.view_input_bar.view.*
2021-06-17 05:18:09 +02:00
import kotlinx.android.synthetic.main.view_input_bar_recording.*
2021-06-17 02:53:56 +02:00
import kotlinx.android.synthetic.main.view_input_bar_recording.view.*
import network.loki.messenger.R
2021-06-29 02:05:39 +02:00
import nl.komponents.kovenant.ui.failUi
import nl.komponents.kovenant.ui.successUi
2021-06-29 02:05:39 +02:00
import org.session.libsession.messaging.MessagingModuleConfiguration
2021-06-24 06:21:05 +02:00
import org.session.libsession.messaging.contacts.Contact
2021-06-25 06:42:04 +02:00
import org.session.libsession.messaging.mentions.Mention
2021-06-18 03:00:52 +02:00
import org.session.libsession.messaging.mentions.MentionsManager
import org.session.libsession.messaging.messages.control.DataExtractionNotification
2021-06-28 02:00:18 +02:00
import org.session.libsession.messaging.messages.signal.OutgoingMediaMessage
2021-06-25 07:20:54 +02:00
import org.session.libsession.messaging.messages.signal.OutgoingTextMessage
2021-06-29 03:14:58 +02:00
import org.session.libsession.messaging.messages.visible.OpenGroupInvitation
2021-06-25 07:20:54 +02:00
import org.session.libsession.messaging.messages.visible.VisibleMessage
import org.session.libsession.messaging.open_groups.OpenGroupAPIV2
2021-06-25 07:20:54 +02:00
import org.session.libsession.messaging.sending_receiving.MessageSender
2021-06-28 02:00:18 +02:00
import org.session.libsession.messaging.sending_receiving.attachments.Attachment
2021-06-28 05:29:17 +02:00
import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview
import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel
2021-06-29 03:14:58 +02:00
import org.session.libsession.utilities.Address.Companion.fromSerialized
2021-06-28 03:11:29 +02:00
import org.session.libsession.utilities.MediaTypes
2021-06-24 07:46:36 +02:00
import org.session.libsession.utilities.TextSecurePreferences
2021-06-29 03:14:58 +02:00
import org.session.libsession.utilities.recipients.Recipient
2021-06-30 02:45:31 +02:00
import org.session.libsession.utilities.recipients.RecipientModifiedListener
2021-06-28 02:44:00 +02:00
import org.session.libsignal.utilities.ListenableFuture
2021-06-29 07:17:14 +02:00
import org.session.libsignal.utilities.ThreadUtils
2021-06-24 03:22:32 +02:00
import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
2021-06-28 03:11:29 +02:00
import org.thoughtcrime.securesms.audio.AudioRecorder
import org.thoughtcrime.securesms.contactshare.SimpleTextWatcher
2021-06-24 06:05:55 +02:00
import org.thoughtcrime.securesms.conversation.v2.dialogs.*
2021-06-17 06:34:50 +02:00
import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBarButton
2021-06-16 01:51:50 +02:00
import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBarDelegate
import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBarRecordingViewDelegate
2021-06-18 03:00:52 +02:00
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.VisibleMessageContentViewDelegate
import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageView
2021-06-28 03:11:29 +02:00
import org.thoughtcrime.securesms.conversation.v2.utilities.AttachmentManager
import org.thoughtcrime.securesms.database.DatabaseFactory
2021-06-22 08:23:47 +02:00
import org.thoughtcrime.securesms.database.DraftDatabase
import org.thoughtcrime.securesms.database.DraftDatabase.Drafts
import org.thoughtcrime.securesms.database.model.MessageRecord
2021-06-28 05:29:17 +02:00
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
2021-06-25 08:09:37 +02:00
import org.thoughtcrime.securesms.giph.ui.GiphyActivity
2021-06-24 07:46:36 +02:00
import org.thoughtcrime.securesms.linkpreview.LinkPreviewRepository
2021-06-29 07:48:40 +02:00
import org.thoughtcrime.securesms.linkpreview.LinkPreviewUtil
2021-06-24 07:46:36 +02:00
import org.thoughtcrime.securesms.linkpreview.LinkPreviewViewModel
import org.thoughtcrime.securesms.linkpreview.LinkPreviewViewModel.LinkPreviewState
2021-06-29 03:14:58 +02:00
import org.thoughtcrime.securesms.loki.activities.SelectContactsActivity
import org.thoughtcrime.securesms.loki.activities.SelectContactsActivity.Companion.selectedContactsKey
import org.thoughtcrime.securesms.loki.utilities.ActivityDispatcher
2021-06-29 02:05:39 +02:00
import org.thoughtcrime.securesms.loki.utilities.MentionUtilities
import org.thoughtcrime.securesms.loki.utilities.push
2021-06-17 05:18:09 +02:00
import org.thoughtcrime.securesms.loki.utilities.toPx
2021-06-25 08:09:37 +02:00
import org.thoughtcrime.securesms.mediasend.Media
import org.thoughtcrime.securesms.mediasend.MediaSendActivity
import org.thoughtcrime.securesms.mms.*
2021-06-25 02:18:04 +02:00
import org.thoughtcrime.securesms.notifications.MarkReadReceiver
import org.thoughtcrime.securesms.permissions.Permissions
2021-06-24 03:38:06 +02:00
import org.thoughtcrime.securesms.util.DateUtils
2021-06-25 08:09:37 +02:00
import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.SaveAttachmentTask
2021-06-24 03:38:06 +02:00
import java.util.*
2021-06-28 02:44:00 +02:00
import java.util.concurrent.ExecutionException
2021-06-23 06:48:29 +02:00
import kotlin.math.*
2021-06-25 01:19:21 +02:00
// 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.
2021-06-23 06:48:29 +02:00
class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDelegate,
InputBarRecordingViewDelegate, AttachmentManager.AttachmentListener, ActivityDispatcher,
2021-06-30 02:45:31 +02:00
ConversationActionModeCallbackDelegate, VisibleMessageContentViewDelegate, RecipientModifiedListener {
2021-06-18 07:54:24 +02:00
private val screenWidth = Resources.getSystem().displayMetrics.widthPixels
2021-06-24 07:46:36 +02:00
private var linkPreviewViewModel: LinkPreviewViewModel? = null
private var threadID: Long = -1
private var actionMode: ActionMode? = null
2021-06-25 06:42:04 +02:00
private var unreadCount = 0
// Attachments
2021-06-28 03:11:29 +02:00
private val audioRecorder = AudioRecorder(this)
private val stopAudioHandler = Handler(Looper.getMainLooper())
private val stopVoiceMessageRecordingTask = Runnable { sendVoiceMessage() }
2021-06-25 07:53:47 +02:00
private val attachmentManager by lazy { AttachmentManager(this, this) }
2021-06-17 05:18:09 +02:00
private var isLockViewExpanded = false
2021-06-17 08:29:57 +02:00
private var isShowingAttachmentOptions = false
2021-06-25 06:42:04 +02:00
// Mentions
private val mentions = mutableListOf<Mention>()
2021-06-18 03:05:14 +02:00
private var mentionCandidatesView: MentionCandidatesView? = null
2021-06-25 06:42:04 +02:00
private var previousText: CharSequence = ""
private var currentMentionStartIndex = -1
private var isShowingMentionCandidatesView = false
2021-06-25 02:02:59 +02:00
private val layoutManager: LinearLayoutManager
get() { return conversationRecyclerView.layoutManager as LinearLayoutManager }
private val adapter by lazy {
val cursor = DatabaseFactory.getMmsSmsDatabase(this).getConversation(threadID)
val adapter = ConversationAdapter(
this,
cursor,
2021-06-25 08:30:23 +02:00
onItemPress = { message, position, view, rawRect ->
handlePress(message, position, view, rawRect)
},
2021-06-09 03:37:50 +02:00
onItemSwipeToReply = { message, position ->
handleSwipeToReply(message, position)
},
onItemLongPress = { message, position ->
handleLongPress(message, position)
2021-06-21 07:26:09 +02:00
},
glide
)
adapter.visibleMessageContentViewDelegate = this
adapter
}
private val thread by lazy {
DatabaseFactory.getThreadDatabase(this).getRecipientForThreadId(threadID)!!
}
private val glide by lazy { GlideApp.with(this) }
2021-06-18 07:54:24 +02:00
private val lockViewHitMargin by lazy { toPx(40, resources) }
2021-06-17 07:20:19 +02:00
private val gifButton by lazy { InputBarButton(this, R.drawable.ic_gif_white_24dp, hasOpaqueBackground = true, isGIFButton = true) }
2021-06-17 06:34:50 +02:00
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) }
2021-06-17 02:53:56 +02:00
// region Settings
companion object {
const val THREAD_ID = "thread_id"
2021-06-25 07:53:47 +02:00
const val PICK_DOCUMENT = 2
const val TAKE_PHOTO = 7
const val PICK_GIF = 10
const val PICK_FROM_LIBRARY = 12
2021-06-29 03:14:58 +02:00
const val INVITE_CONTACTS = 124
}
// endregion
// region Lifecycle
override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) {
super.onCreate(savedInstanceState, isReady)
setContentView(R.layout.activity_conversation_v2)
threadID = intent.getLongExtra(THREAD_ID, -1)
setUpRecyclerView()
2021-06-15 06:55:57 +02:00
setUpToolBar()
2021-06-17 06:34:50 +02:00
setUpInputBar()
2021-06-22 08:23:47 +02:00
restoreDraftIfNeeded()
2021-06-23 05:11:21 +02:00
addOpenGroupGuidelinesIfNeeded()
2021-06-23 08:08:30 +02:00
scrollToBottomButton.setOnClickListener { conversationRecyclerView.smoothScrollToPosition(0) }
2021-06-25 02:02:59 +02:00
unreadCount = DatabaseFactory.getMmsSmsDatabase(this).getUnreadCount(threadID)
updateUnreadCountIndicator()
2021-06-24 03:22:32 +02:00
setUpTypingObserver()
2021-06-30 02:45:31 +02:00
setUpRecipientObserver()
2021-06-24 03:38:06 +02:00
updateSubtitle()
getLatestOpenGroupInfoIfNeeded()
2021-06-24 06:21:05 +02:00
setUpBlockedBanner()
2021-06-24 07:46:36 +02:00
setUpLinkPreviewObserver()
2021-06-25 02:18:04 +02:00
scrollToFirstUnreadMessageIfNeeded()
markAllAsRead()
}
override fun onResume() {
super.onResume()
ApplicationContext.getInstance(this).messageNotifier.setVisibleThread(threadID)
}
override fun onPause() {
super.onPause()
ApplicationContext.getInstance(this).messageNotifier.setVisibleThread(-1)
}
2021-06-25 08:30:23 +02:00
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
2021-06-25 08:30:23 +02:00
push(intent, false)
}
private fun setUpRecyclerView() {
2021-05-31 06:29:11 +02:00
conversationRecyclerView.adapter = adapter
2021-06-07 08:36:05 +02:00
val layoutManager = LinearLayoutManager(this, LinearLayoutManager.VERTICAL, true)
2021-06-02 05:03:22 +02:00
conversationRecyclerView.layoutManager = layoutManager
2021-06-07 01:48:01 +02:00
// Workaround for the fact that CursorRecyclerViewAdapter doesn't auto-update automatically (even though it says it will)
LoaderManager.getInstance(this).restartLoader(0, null, object : LoaderManager.LoaderCallbacks<Cursor> {
override fun onCreateLoader(id: Int, bundle: Bundle?): Loader<Cursor> {
return ConversationLoader(threadID, this@ConversationActivityV2)
}
override fun onLoadFinished(loader: Loader<Cursor>, cursor: Cursor?) {
adapter.changeCursor(cursor)
}
override fun onLoaderReset(cursor: Loader<Cursor>) {
adapter.changeCursor(null)
}
})
conversationRecyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
handleRecyclerViewScrolled()
}
})
}
2021-06-15 06:55:57 +02:00
private fun setUpToolBar() {
val actionBar = supportActionBar!!
actionBar.setCustomView(R.layout.activity_conversation_v2_action_bar)
actionBar.setDisplayShowCustomEnabled(true)
conversationTitleView.text = thread.toShortString()
2021-06-02 05:28:02 +02:00
profilePictureView.glide = glide
profilePictureView.update(thread, threadID)
}
2021-06-07 01:48:01 +02:00
2021-06-17 06:34:50 +02:00
private fun setUpInputBar() {
inputBar.delegate = this
inputBarRecordingView.delegate = this
2021-06-17 06:34:50 +02:00
// GIF button
gifButtonContainer.addView(gifButton)
gifButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT)
2021-06-25 07:53:47 +02:00
gifButton.onUp = { showGIFPicker() }
gifButton.snIsEnabled = false
2021-06-17 06:34:50 +02:00
// Document button
documentButtonContainer.addView(documentButton)
documentButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT)
2021-06-25 07:53:47 +02:00
documentButton.onUp = { showDocumentPicker() }
documentButton.snIsEnabled = false
2021-06-17 06:34:50 +02:00
// Library button
libraryButtonContainer.addView(libraryButton)
libraryButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT)
2021-06-25 07:53:47 +02:00
libraryButton.onUp = { pickFromLibrary() }
libraryButton.snIsEnabled = false
2021-06-17 06:34:50 +02:00
// Camera button
cameraButtonContainer.addView(cameraButton)
cameraButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT)
2021-06-25 07:53:47 +02:00
cameraButton.onUp = { showCamera() }
cameraButton.snIsEnabled = false
2021-06-17 06:34:50 +02:00
}
2021-06-22 08:23:47 +02:00
private fun restoreDraftIfNeeded() {
val draftDB = DatabaseFactory.getDraftDatabase(this)
val drafts = draftDB.getDrafts(threadID)
draftDB.clearDrafts(threadID)
val text = drafts.find { it.type == DraftDatabase.Draft.TEXT }?.value ?: return
inputBar.text = text
}
2021-06-23 05:11:21 +02:00
private fun addOpenGroupGuidelinesIfNeeded() {
val openGroup = DatabaseFactory.getLokiThreadDatabase(this).getOpenGroupChat(threadID) ?: return
val isOxenHostedOpenGroup = openGroup.room == "session" || openGroup.room == "oxen"
|| openGroup.room == "lokinet" || openGroup.room == "crypto"
if (!isOxenHostedOpenGroup) { return }
openGroupGuidelinesView.visibility = View.VISIBLE
val recyclerViewLayoutParams = conversationRecyclerView.layoutParams as RelativeLayout.LayoutParams
2021-06-23 05:57:13 +02:00
recyclerViewLayoutParams.topMargin = toPx(57, resources) // The height of the open group guidelines view is hardcoded to this
2021-06-23 05:11:21 +02:00
conversationRecyclerView.layoutParams = recyclerViewLayoutParams
}
2021-06-24 03:22:32 +02:00
private fun setUpTypingObserver() {
ApplicationContext.getInstance(this).typingStatusRepository.getTypists(threadID).observe(this) { state ->
val recipients = if (state != null) state.typists else listOf()
typingIndicatorViewContainer.isVisible = recipients.isNotEmpty()
typingIndicatorViewContainer.setTypists(recipients)
inputBarHeightChanged(inputBar.height)
}
if (TextSecurePreferences.isTypingIndicatorsEnabled(this)) {
inputBar.inputBarEditText.addTextChangedListener(object : SimpleTextWatcher() {
override fun onTextChanged(text: String?) {
ApplicationContext.getInstance(this@ConversationActivityV2).typingStatusSender.onTypingStarted(threadID)
}
})
}
2021-06-24 03:22:32 +02:00
}
2021-06-30 02:45:31 +02:00
private fun setUpRecipientObserver() {
thread.addListener(this)
}
private fun getLatestOpenGroupInfoIfNeeded() {
val openGroup = DatabaseFactory.getLokiThreadDatabase(this).getOpenGroupChat(threadID) ?: return
OpenGroupAPIV2.getMemberCount(openGroup.room, openGroup.server).successUi { updateSubtitle() }
}
2021-06-24 06:21:05 +02:00
private fun setUpBlockedBanner() {
if (thread.isGroupRecipient) { return }
val contactDB = DatabaseFactory.getSessionContactDatabase(this)
val sessionID = thread.address.toString()
val contact = contactDB.getContactWithSessionID(sessionID)
val name = contact?.displayName(Contact.ContactContext.REGULAR) ?: sessionID
blockedBannerTextView.text = resources.getString(R.string.activity_conversation_blocked_banner_text, name)
blockedBanner.isVisible = thread.isBlocked
blockedBanner.setOnClickListener { unblock() }
}
2021-06-24 07:46:36 +02:00
private fun setUpLinkPreviewObserver() {
val linkPreviewViewModel = ViewModelProviders.of(this, LinkPreviewViewModel.Factory(LinkPreviewRepository(this)))[LinkPreviewViewModel::class.java]
this.linkPreviewViewModel = linkPreviewViewModel
if (!TextSecurePreferences.isLinkPreviewsEnabled(this)) {
linkPreviewViewModel.onUserCancel(); return
}
linkPreviewViewModel.linkPreviewState.observe(this, { previewState: LinkPreviewState? ->
if (previewState == null) return@observe
if (previewState.isLoading) {
2021-06-24 08:23:37 +02:00
inputBar.draftLinkPreview()
2021-06-25 01:19:21 +02:00
} else if (previewState.linkPreview.isPresent) {
2021-06-24 08:23:37 +02:00
inputBar.updateLinkPreviewDraft(glide, previewState.linkPreview.get())
2021-06-25 01:19:21 +02:00
} else {
inputBar.cancelLinkPreviewDraft()
2021-06-24 07:46:36 +02:00
}
})
}
2021-06-25 02:18:04 +02:00
private fun scrollToFirstUnreadMessageIfNeeded() {
val lastSeenTimestamp = DatabaseFactory.getThreadDatabase(this).getLastSeenAndHasSent(threadID).first()
val lastSeenItemPosition = adapter.findLastSeenItemPosition(lastSeenTimestamp) ?: return
2021-06-25 02:18:04 +02:00
if (lastSeenItemPosition <= 3) { return }
conversationRecyclerView.scrollToPosition(lastSeenItemPosition)
}
2021-06-07 01:48:01 +02:00
override fun onPrepareOptionsMenu(menu: Menu): Boolean {
ConversationMenuHelper.onPrepareOptionsMenu(menu, menuInflater, thread, this) { onOptionsItemSelected(it) }
super.onPrepareOptionsMenu(menu)
return true
2021-06-07 01:48:01 +02:00
}
2021-06-22 08:23:47 +02:00
override fun onDestroy() {
saveDraft()
super.onDestroy()
}
// endregion
2021-06-30 02:45:31 +02:00
override fun onModified(recipient: Recipient) {
if (thread.isContactRecipient) {
blockedBanner.isVisible = thread.isBlocked
}
updateSubtitle()
}
private fun markAllAsRead() {
2021-06-25 02:18:04 +02:00
val messages = DatabaseFactory.getThreadDatabase(this).setRead(threadID, true)
if (thread.isGroupRecipient) {
for (message in messages) {
MarkReadReceiver.scheduleDeletion(this, message.expirationInfo)
}
} else {
MarkReadReceiver.process(this, messages)
}
ApplicationContext.getInstance(this).messageNotifier.updateNotification(this)
}
2021-06-16 01:51:50 +02:00
override fun inputBarHeightChanged(newValue: Int) {
2021-06-24 07:20:33 +02:00
@Suppress("NAME_SHADOWING") val newValue = max(newValue, resources.getDimension(R.dimen.input_bar_height).roundToInt())
2021-06-24 03:22:32 +02:00
// 36 DP is the exact height of the typing indicator view. It's also exactly 18 * 2, and 18 is the large message
// corner radius. This makes 36 DP look "correct" in the context of other messages on the screen.
val typingIndicatorHeight = if (typingIndicatorViewContainer.isVisible) toPx(36, resources) else 0
2021-06-18 03:00:52 +02:00
// Recycler view
2021-06-16 01:51:50 +02:00
val recyclerViewLayoutParams = conversationRecyclerView.layoutParams as RelativeLayout.LayoutParams
2021-06-25 07:11:38 +02:00
recyclerViewLayoutParams.bottomMargin = newValue + typingIndicatorHeight
2021-06-16 01:51:50 +02:00
conversationRecyclerView.layoutParams = recyclerViewLayoutParams
2021-06-18 07:11:41 +02:00
// Additional content container
val additionalContentContainerLayoutParams = additionalContentContainer.layoutParams as RelativeLayout.LayoutParams
additionalContentContainerLayoutParams.bottomMargin = newValue
additionalContentContainer.layoutParams = additionalContentContainerLayoutParams
2021-06-18 03:00:52 +02:00
// Attachment options
val attachmentButtonHeight = inputBar.attachmentsButtonContainer.height
2021-06-18 08:24:56 +02:00
val bottomMargin = (newValue - inputBar.additionalContentHeight - attachmentButtonHeight) / 2
val margin = toPx(8, resources)
val attachmentOptionsContainerLayoutParams = attachmentOptionsContainer.layoutParams as RelativeLayout.LayoutParams
attachmentOptionsContainerLayoutParams.bottomMargin = bottomMargin + attachmentButtonHeight + margin
attachmentOptionsContainer.layoutParams = attachmentOptionsContainerLayoutParams
2021-06-23 07:14:19 +02:00
// Scroll to bottom button
val scrollToBottomButtonLayoutParams = scrollToBottomButton.layoutParams as RelativeLayout.LayoutParams
scrollToBottomButtonLayoutParams.bottomMargin = newValue + additionalContentContainer.height + toPx(12, resources)
scrollToBottomButton.layoutParams = scrollToBottomButtonLayoutParams
2021-06-16 01:51:50 +02:00
}
2021-06-16 06:50:41 +02:00
2021-06-18 03:00:52 +02:00
override fun inputBarEditTextContentChanged(newContent: CharSequence) {
2021-06-29 07:48:40 +02:00
if (TextSecurePreferences.isLinkPreviewsEnabled(this)) {
linkPreviewViewModel?.onTextChanged(this, inputBar.text, 0, 0)
}
2021-06-25 06:42:04 +02:00
showOrHideMentionCandidatesIfNeeded(newContent)
2021-06-29 07:48:40 +02:00
if (LinkPreviewUtil.findWhitelistedUrls(newContent.toString()).isNotEmpty()
&& !TextSecurePreferences.isLinkPreviewsEnabled(this) && !TextSecurePreferences.hasSeenLinkPreviewSuggestionDialog(this)) {
LinkPreviewDialog {
setUpLinkPreviewObserver()
linkPreviewViewModel?.onEnabled()
linkPreviewViewModel?.onTextChanged(this, inputBar.text, 0, 0)
}.show(supportFragmentManager, "Link Preview Dialog")
TextSecurePreferences.setHasSeenLinkPreviewSuggestionDialog(this)
}
2021-06-18 03:00:52 +02:00
}
2021-06-25 06:42:04 +02:00
private fun showOrHideMentionCandidatesIfNeeded(text: CharSequence) {
2021-06-25 07:11:38 +02:00
if (text.length < previousText.length) {
2021-06-25 06:42:04 +02:00
currentMentionStartIndex = -1
hideMentionCandidates()
val mentionsToRemove = mentions.filter { !text.contains(it.displayName) }
mentions.removeAll(mentionsToRemove)
2021-06-18 03:05:14 +02:00
}
2021-06-25 06:42:04 +02:00
if (text.isNotEmpty()) {
val lastCharIndex = text.lastIndex
val lastChar = text[lastCharIndex]
2021-06-25 07:11:38 +02:00
// 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) {
2021-06-25 06:42:04 +02:00
currentMentionStartIndex = lastCharIndex
showOrUpdateMentionCandidatesIfNeeded()
2021-06-25 07:11:38 +02:00
} else if (Character.isWhitespace(lastChar) || lastChar == '@') { // the lastCharacter == "@" is to check for @@
2021-06-25 06:42:04 +02:00
currentMentionStartIndex = -1
hideMentionCandidates()
} else if (currentMentionStartIndex != -1) {
val query = text.substring(currentMentionStartIndex + 1) // + 1 to get rid of the "@"
showOrUpdateMentionCandidatesIfNeeded(query)
}
}
previousText = text
}
private fun showOrUpdateMentionCandidatesIfNeeded(query: String = "") {
if (!isShowingMentionCandidatesView) {
additionalContentContainer.removeAllViews()
val view = MentionCandidatesView(this)
view.glide = glide
2021-06-25 07:11:38 +02:00
view.onCandidateSelected = { handleMentionSelected(it) }
2021-06-25 06:42:04 +02:00
additionalContentContainer.addView(view)
val candidates = MentionsManager.getMentionCandidates(query, threadID, thread.isOpenGroupRecipient)
this.mentionCandidatesView = view
view.show(candidates, threadID)
view.alpha = 0.0f
val animation = ValueAnimator.ofObject(FloatEvaluator(), view.alpha, 1.0f)
animation.duration = 250L
animation.addUpdateListener { animator ->
view.alpha = animator.animatedValue as Float
}
animation.start()
} else {
val candidates = MentionsManager.getMentionCandidates(query, threadID, thread.isOpenGroupRecipient)
this.mentionCandidatesView!!.setMentionCandidates(candidates)
}
isShowingMentionCandidatesView = true
2021-06-18 03:05:14 +02:00
}
private fun hideMentionCandidates() {
2021-06-25 06:42:04 +02:00
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) { additionalContentContainer.removeAllViews() }
}
animation.start()
2021-06-18 03:05:14 +02:00
}
2021-06-25 06:42:04 +02:00
isShowingMentionCandidatesView = false
2021-06-18 03:00:52 +02:00
}
2021-06-17 08:29:57 +02:00
override fun toggleAttachmentOptions() {
val targetAlpha = if (isShowingAttachmentOptions) 0.0f else 1.0f
val allButtonContainers = listOf( cameraButtonContainer, libraryButtonContainer, documentButtonContainer, gifButtonContainer)
2021-06-18 01:51:44 +02:00
val isReversed = isShowingAttachmentOptions // Run the animation in reverse
val count = allButtonContainers.size
allButtonContainers.indices.forEach { index ->
val view = allButtonContainers[index]
2021-06-17 08:29:57 +02:00
val animation = ValueAnimator.ofObject(FloatEvaluator(), view.alpha, targetAlpha)
animation.duration = 250L
2021-06-18 01:51:44 +02:00
animation.startDelay = if (isReversed) 50L * (count - index.toLong()) else 50L * index.toLong()
2021-06-17 08:29:57 +02:00
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 }
2021-06-17 08:29:57 +02:00
}
2021-06-16 06:50:41 +02:00
override fun showVoiceMessageUI() {
2021-06-16 07:49:39 +02:00
inputBarRecordingView.show()
inputBar.alpha = 0.0f
val animation = ValueAnimator.ofObject(FloatEvaluator(), 1.0f, 0.0f)
animation.duration = 250L
animation.addUpdateListener { animator ->
inputBar.alpha = animator.animatedValue as Float
}
animation.start()
}
2021-06-18 07:54:24 +02:00
private fun expandVoiceMessageLockView() {
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 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 = inputBarRecordingView.inputBarChevronImageView
val slideToCancelTextView = inputBarRecordingView.inputBarSlideToCancelTextView
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()
}
inputBarRecordingView.hide()
}
override fun handleVoiceMessageUIHidden() {
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()
2021-06-16 06:50:41 +02:00
}
2021-06-23 06:48:29 +02:00
private fun handleRecyclerViewScrolled() {
val position = layoutManager.findFirstCompletelyVisibleItemPosition()
val alpha = if (position > 0) 1.0f else 0.0f
2021-06-23 07:14:19 +02:00
scrollToBottomButton.alpha = alpha
unreadCount = min(unreadCount, layoutManager.findFirstVisibleItemPosition())
2021-06-25 02:02:59 +02:00
updateUnreadCountIndicator()
}
2021-06-25 02:02:59 +02:00
private fun updateUnreadCountIndicator() {
val formattedUnreadCount = if (unreadCount < 100) unreadCount.toString() else "99+"
unreadCountTextView.text = formattedUnreadCount
val textSize = if (unreadCount < 100) 12.0f else 9.0f
unreadCountTextView.setTextSize(TypedValue.COMPLEX_UNIT_DIP, textSize)
2021-06-24 02:18:52 +02:00
unreadCountTextView.setTypeface(Typeface.DEFAULT, if (unreadCount < 100) Typeface.BOLD else Typeface.NORMAL)
unreadCountIndicator.isVisible = (unreadCount != 0)
2021-06-23 06:48:29 +02:00
}
2021-06-24 03:38:06 +02:00
private fun updateSubtitle() {
muteIconImageView.isVisible = thread.isMuted
conversationSubtitleView.isVisible = true
if (thread.isMuted) {
conversationSubtitleView.text = getString(R.string.ConversationActivity_muted_until_date, DateUtils.getFormattedDateTime(thread.mutedUntil, "EEE, MMM d, yyyy HH:mm", Locale.getDefault()))
} else if (thread.isGroupRecipient) {
val openGroup = DatabaseFactory.getLokiThreadDatabase(this).getOpenGroupChat(threadID)
if (openGroup != null) {
val userCount = DatabaseFactory.getLokiAPIDatabase(this).getUserCount(openGroup.room, openGroup.server) ?: 0
conversationSubtitleView.text = getString(R.string.ConversationActivity_member_count, userCount)
} else {
conversationSubtitleView.isVisible = false
}
} else {
conversationSubtitleView.isVisible = false
}
2021-06-23 06:48:29 +02:00
}
2021-06-16 01:51:50 +02:00
// endregion
// region Interaction
2021-06-07 01:48:01 +02:00
override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == android.R.id.home) {
return false
}
2021-06-29 02:39:00 +02:00
return ConversationMenuHelper.onOptionItemSelected(this, item, thread)
}
2021-06-04 05:15:43 +02:00
2021-06-09 03:37:50 +02:00
// `position` is the adapter position; not the visual position
2021-06-25 08:30:23 +02:00
private fun handlePress(message: MessageRecord, position: Int, view: VisibleMessageView, rawRect: Rect) {
val actionMode = this.actionMode
if (actionMode != null) {
adapter.toggleSelection(message, position)
val actionModeCallback = ConversationActionModeCallback(adapter, threadID, this)
actionModeCallback.delegate = this
actionModeCallback.updateActionModeMenu(actionMode.menu)
if (adapter.selectedItems.isEmpty()) {
actionMode.finish()
this.actionMode = null
}
} else {
2021-06-24 03:24:25 +02:00
// 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.
2021-06-25 08:30:23 +02:00
view.onContentClick(rawRect)
}
}
2021-06-09 03:37:50 +02:00
// `position` is the adapter position; not the visual position
private fun handleSwipeToReply(message: MessageRecord, position: Int) {
2021-06-29 06:05:32 +02:00
inputBar.draftQuote(message, glide)
2021-06-09 03:37:50 +02:00
}
// `position` is the adapter position; not the visual position
private fun handleLongPress(message: MessageRecord, position: Int) {
val actionMode = this.actionMode
val actionModeCallback = ConversationActionModeCallback(adapter, threadID, this)
actionModeCallback.delegate = this
if (actionMode == null) { // Nothing should be selected if this is the case
adapter.toggleSelection(message, position)
this.actionMode = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) {
startActionMode(actionModeCallback, ActionMode.TYPE_PRIMARY)
} else {
startActionMode(actionModeCallback)
}
2021-06-04 07:10:58 +02:00
} else {
adapter.toggleSelection(message, position)
actionModeCallback.updateActionModeMenu(actionMode.menu)
if (adapter.selectedItems.isEmpty()) {
actionMode.finish()
this.actionMode = null
}
2021-06-04 07:10:58 +02:00
}
}
2021-06-17 02:53:56 +02:00
override fun onMicrophoneButtonMove(event: MotionEvent) {
val rawX = event.rawX
val chevronImageView = inputBarRecordingView.inputBarChevronImageView
val slideToCancelTextView = inputBarRecordingView.inputBarSlideToCancelTextView
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
}
2021-06-17 05:18:09 +02:00
if (isValidLockViewLocation(event.rawX.roundToInt(), event.rawY.roundToInt())) {
if (!isLockViewExpanded) {
2021-06-18 07:54:24 +02:00
expandVoiceMessageLockView()
2021-06-17 05:18:09 +02:00
isLockViewExpanded = true
}
} else {
if (isLockViewExpanded) {
2021-06-18 07:54:24 +02:00
collapseVoiceMessageLockView()
2021-06-17 05:18:09 +02:00
isLockViewExpanded = false
}
}
}
2021-06-17 02:53:56 +02:00
override fun onMicrophoneButtonCancel(event: MotionEvent) {
2021-06-18 07:54:24 +02:00
hideVoiceMessageUI()
2021-06-17 02:53:56 +02:00
}
override fun onMicrophoneButtonUp(event: MotionEvent) {
val x = event.rawX.roundToInt()
val y = event.rawY.roundToInt()
if (isValidLockViewLocation(x, y)) {
2021-06-17 06:01:43 +02:00
inputBarRecordingView.lock()
} else {
val recordButtonOverlay = inputBarRecordingView.recordButtonOverlay
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()
}
2021-06-17 06:01:43 +02:00
}
2021-06-17 02:53:56 +02:00
}
2021-06-18 07:54:24 +02:00
private fun isValidLockViewLocation(x: Int, y: Int): Boolean {
2021-06-23 05:57:13 +02:00
// We can be anywhere above the lock view and a bit to the side of it (at most `lockViewHitMargin`
// to the side)
2021-06-18 07:54:24 +02:00
val lockViewLocation = IntArray(2) { 0 }
lockView.getLocationOnScreen(lockViewLocation)
val hitRect = Rect(lockViewLocation[0] - lockViewHitMargin, 0,
lockViewLocation[0] + lockView.width + lockViewHitMargin, lockViewLocation[1] + lockView.height)
return hitRect.contains(x, y)
2021-06-17 02:53:56 +02:00
}
2021-06-24 06:21:05 +02:00
private fun unblock() {
if (!thread.isContactRecipient) { return }
DatabaseFactory.getRecipientDatabase(this).setBlocked(thread, false)
2021-06-24 06:21:05 +02:00
}
2021-06-25 06:42:04 +02:00
2021-06-25 07:11:38 +02:00
private fun handleMentionSelected(mention: Mention) {
if (currentMentionStartIndex == -1) { return }
mentions.add(mention)
val previousText = inputBar.text
val newText = previousText.substring(0, currentMentionStartIndex) + "@" + mention.displayName + " "
inputBar.text = newText
inputBar.inputBarEditText.setSelection(newText.length)
currentMentionStartIndex = -1
hideMentionCandidates()
this.previousText = newText
}
override fun scrollToMessageIfPossible(timestamp: Long) {
val lastSeenItemPosition = adapter.getItemPositionForTimestamp(timestamp) ?: return
conversationRecyclerView.scrollToPosition(lastSeenItemPosition)
}
2021-06-28 02:00:18 +02:00
override fun sendMessage() {
2021-06-28 05:36:15 +02:00
if (thread.isContactRecipient && thread.isBlocked) {
BlockedDialog(thread).show(supportFragmentManager, "Blocked Dialog")
return
}
2021-06-28 05:29:17 +02:00
if (inputBar.linkPreview != null || inputBar.quote != null) {
sendAttachments(listOf(), getMessageBody(), inputBar.quote, inputBar.linkPreview)
} else {
sendTextOnlyMessage()
}
}
private fun sendTextOnlyMessage() {
2021-06-25 07:24:34 +02:00
// Create the message
2021-06-25 07:20:54 +02:00
val message = VisibleMessage()
message.sentTimestamp = System.currentTimeMillis()
message.text = getMessageBody()
val outgoingTextMessage = OutgoingTextMessage.from(message, thread)
2021-06-25 07:24:34 +02:00
// Clear the input bar
inputBar.text = ""
2021-06-29 02:05:39 +02:00
inputBar.cancelQuoteDraft()
inputBar.cancelLinkPreviewDraft()
2021-06-25 07:24:34 +02:00
// Clear mentions
previousText = ""
currentMentionStartIndex = -1
mentions.clear()
// Put the message in the database
message.id = DatabaseFactory.getSmsDatabase(this).insertMessageOutbox(threadID, outgoingTextMessage, false, message.sentTimestamp!!) { }
// Send it
2021-06-25 07:20:54 +02:00
MessageSender.send(message, thread.address)
2021-06-25 07:24:34 +02:00
// Send a typing stopped message
ApplicationContext.getInstance(this).typingStatusSender.onTypingStopped(threadID)
2021-06-25 06:42:04 +02:00
}
2021-06-25 07:53:47 +02:00
2021-06-28 05:29:17 +02:00
private fun sendAttachments(attachments: List<Attachment>, body: String?, quotedMessage: MessageRecord? = null, linkPreview: LinkPreview? = null) {
2021-06-28 02:00:18 +02:00
// Create the message
val message = VisibleMessage()
message.sentTimestamp = System.currentTimeMillis()
message.text = body
2021-06-28 05:29:17 +02:00
val quote = quotedMessage?.let {
val quotedAttachments = (it as? MmsMessageRecord)?.slideDeck?.asAttachments() ?: listOf()
QuoteModel(it.dateSent, it.individualRecipient.address, it.body, false, quotedAttachments)
}
val outgoingTextMessage = OutgoingMediaMessage.from(message, thread, attachments, quote, linkPreview)
2021-06-28 02:00:18 +02:00
// Clear the input bar
inputBar.text = ""
2021-06-29 02:05:39 +02:00
inputBar.cancelQuoteDraft()
inputBar.cancelLinkPreviewDraft()
2021-06-28 02:00:18 +02:00
// Clear mentions
previousText = ""
currentMentionStartIndex = -1
mentions.clear()
// Reset the attachment manager
2021-06-28 02:44:00 +02:00
attachmentManager.clear()
// Reset attachments button if needed
if (isShowingAttachmentOptions) { toggleAttachmentOptions() }
2021-06-28 02:00:18 +02:00
// Put the message in the database
message.id = DatabaseFactory.getMmsDatabase(this).insertMessageOutbox(outgoingTextMessage, threadID, false) { }
// Send it
2021-06-28 05:29:17 +02:00
MessageSender.send(message, thread.address, attachments, quote, linkPreview)
2021-06-28 02:00:18 +02:00
// Send a typing stopped message
ApplicationContext.getInstance(this).typingStatusSender.onTypingStopped(threadID)
}
2021-06-25 07:53:47 +02:00
private fun showGIFPicker() {
AttachmentManager.selectGif(this, ConversationActivityV2.PICK_GIF)
}
private fun showDocumentPicker() {
AttachmentManager.selectDocument(this, ConversationActivityV2.PICK_DOCUMENT)
}
private fun pickFromLibrary() {
AttachmentManager.selectGallery(this, ConversationActivityV2.PICK_FROM_LIBRARY, thread, inputBar.text.trim())
}
private fun showCamera() {
attachmentManager.capturePhoto(this, ConversationActivityV2.TAKE_PHOTO)
}
override fun onAttachmentChanged() {
2021-06-28 03:11:29 +02:00
// Do nothing
2021-06-25 07:53:47 +02:00
}
2021-06-25 08:09:37 +02:00
override fun onActivityResult(requestCode: Int, resultCode: Int, intent: Intent?) {
super.onActivityResult(requestCode, resultCode, intent)
2021-06-28 02:44:00 +02:00
val mediaPreppedListener = object : ListenableFuture.Listener<Boolean> {
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()
}
}
2021-06-25 08:09:37 +02:00
when (requestCode) {
PICK_DOCUMENT -> {
2021-06-28 02:50:35 +02:00
val uri = intent?.data ?: return
2021-06-28 02:44:00 +02:00
prepMediaForSending(uri, AttachmentManager.MediaType.DOCUMENT).addListener(mediaPreppedListener)
2021-06-25 08:09:37 +02:00
}
TAKE_PHOTO -> {
2021-06-29 07:17:14 +02:00
if (resultCode != RESULT_OK) { return }
2021-06-25 08:09:37 +02:00
val uri = attachmentManager.captureUri ?: return
2021-06-28 02:44:00 +02:00
prepMediaForSending(uri, AttachmentManager.MediaType.IMAGE).addListener(mediaPreppedListener)
2021-06-25 08:09:37 +02:00
}
PICK_GIF -> {
2021-06-28 02:50:35 +02:00
intent ?: return
2021-06-28 02:00:18 +02:00
val uri = intent.data ?: return
2021-06-25 08:09:37 +02:00
val type = AttachmentManager.MediaType.GIF
val width = intent.getIntExtra(GiphyActivity.EXTRA_WIDTH, 0)
val height = intent.getIntExtra(GiphyActivity.EXTRA_HEIGHT, 0)
2021-06-28 02:44:00 +02:00
prepMediaForSending(uri, type, width, height).addListener(mediaPreppedListener)
2021-06-25 08:09:37 +02:00
}
PICK_FROM_LIBRARY -> {
2021-06-28 02:50:35 +02:00
intent ?: return
2021-06-28 02:00:18 +02:00
val body = intent.getStringExtra(MediaSendActivity.EXTRA_MESSAGE)
2021-06-25 08:09:37 +02:00
val media = intent.getParcelableArrayListExtra<Media>(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.")
}
}
}
2021-06-28 02:00:18 +02:00
sendAttachments(slideDeck.asAttachments(), body)
2021-06-25 08:09:37 +02:00
}
2021-06-29 03:14:58 +02:00
INVITE_CONTACTS -> {
if (!thread.isOpenGroupRecipient) { return }
val extras = intent?.extras ?: return
if (!intent.hasExtra(SelectContactsActivity.selectedContactsKey)) { return }
val selectedContacts = extras.getStringArray(selectedContactsKey)!!
val openGroup = DatabaseFactory.getLokiThreadDatabase(this).getOpenGroupChat(threadID)
for (contact in selectedContacts) {
val recipient = Recipient.from(this, fromSerialized(contact), true)
val message = VisibleMessage()
message.sentTimestamp = System.currentTimeMillis()
val openGroupInvitation = OpenGroupInvitation()
openGroupInvitation.name = openGroup!!.name
openGroupInvitation.url = openGroup!!.joinURL
message.openGroupInvitation = openGroupInvitation
val outgoingTextMessage = OutgoingTextMessage.fromOpenGroupInvitation(openGroupInvitation, recipient, message.sentTimestamp)
DatabaseFactory.getSmsDatabase(this).insertMessageOutbox(-1, outgoingTextMessage, message.sentTimestamp!!)
MessageSender.send(message, recipient.address)
}
}
2021-06-25 08:09:37 +02:00
}
}
2021-06-28 02:00:18 +02:00
2021-06-28 02:44:00 +02:00
private fun prepMediaForSending(uri: Uri, type: AttachmentManager.MediaType): ListenableFuture<Boolean> {
return prepMediaForSending(uri, type, null, null)
2021-06-28 02:00:18 +02:00
}
2021-06-28 02:44:00 +02:00
private fun prepMediaForSending(uri: Uri, type: AttachmentManager.MediaType, width: Int?, height: Int?): ListenableFuture<Boolean> {
return attachmentManager.setMedia(glide, uri, type, MediaConstraints.getPushMediaConstraints(), width ?: 0, height ?: 0)
2021-06-28 02:00:18 +02:00
}
2021-06-28 03:11:29 +02:00
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, 60000) // Limit voice messages to 1 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()
}
2021-06-28 03:11:29 +02:00
}
override fun sendVoiceMessage() {
hideVoiceMessageUI()
2021-06-28 03:11:29 +02:00
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
val future = audioRecorder.stopRecording()
stopAudioHandler.removeCallbacks(stopVoiceMessageRecordingTask)
future.addListener(object : ListenableFuture.Listener<Pair<Uri?, Long?>> {
override fun onSuccess(result: Pair<Uri?, Long?>) {
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)
}
2021-06-29 02:05:39 +02:00
override fun deleteMessages(messages: Set<MessageRecord>) {
val messageCount = messages.size
val messageDataProvider = MessagingModuleConfiguration.shared.messageDataProvider
val messageDB = DatabaseFactory.getLokiMessageDatabase(this@ConversationActivityV2)
val builder = AlertDialog.Builder(this)
builder.setTitle(resources.getQuantityString(R.plurals.ConversationFragment_delete_selected_messages, messageCount, messageCount))
builder.setMessage(resources.getQuantityString(R.plurals.ConversationFragment_this_will_permanently_delete_all_n_selected_messages, messageCount, messageCount))
builder.setCancelable(true)
val openGroup = DatabaseFactory.getLokiThreadDatabase(this).getOpenGroupChat(threadID)
builder.setPositiveButton(R.string.delete) { _, _ ->
if (openGroup != null) {
val messageServerIDs = mutableMapOf<Long, MessageRecord>()
for (message in messages) {
val messageServerID = messageDB.getServerID(message.id, !message.isMms) ?: continue
messageServerIDs[messageServerID] = message
}
for ((messageServerID, message) in messageServerIDs) {
OpenGroupAPIV2.deleteMessage(messageServerID, openGroup.room, openGroup.server)
.success {
messageDataProvider.deleteMessage(message.id, !message.isMms)
}.failUi { error ->
Toast.makeText(this@ConversationActivityV2, "Couldn't delete message due to error: $error", Toast.LENGTH_LONG).show()
}
}
} else {
2021-06-29 07:17:14 +02:00
ThreadUtils.queue {
for (message in messages) {
if (message.isMms) {
DatabaseFactory.getMmsDatabase(this@ConversationActivityV2).delete(message.id)
} else {
DatabaseFactory.getSmsDatabase(this@ConversationActivityV2).deleteMessage(message.id)
}
2021-06-29 02:05:39 +02:00
}
}
}
endActionMode()
}
builder.setNegativeButton(android.R.string.cancel) { dialog, _ ->
dialog.dismiss()
endActionMode()
}
builder.show()
}
override fun banUser(messages: Set<MessageRecord>) {
2021-06-29 02:05:39 +02:00
val builder = AlertDialog.Builder(this)
val sessionID = messages.first().individualRecipient.address.toString()
builder.setTitle(R.string.ConversationFragment_ban_selected_user)
builder.setMessage("This will ban the selected user from this room. It won't ban them from other rooms. The selected user won't know that they've been banned.")
builder.setCancelable(true)
val openGroup = DatabaseFactory.getLokiThreadDatabase(this).getOpenGroupChat(threadID)!!
builder.setPositiveButton(R.string.ban) { _, _ ->
OpenGroupAPIV2.ban(sessionID, openGroup.room, openGroup.server).successUi {
Toast.makeText(this@ConversationActivityV2, "Successfully banned user", Toast.LENGTH_LONG).show()
}.failUi { error ->
Toast.makeText(this@ConversationActivityV2, "Couldn't ban user due to error: $error", Toast.LENGTH_LONG).show()
}
endActionMode()
}
builder.setNegativeButton(android.R.string.cancel) { dialog, _ ->
dialog.dismiss()
endActionMode()
}
builder.show()
}
2021-06-29 02:05:39 +02:00
override fun copyMessages(messages: Set<MessageRecord>) {
val sortedMessages = messages.sortedBy { it.dateSent }
val builder = StringBuilder()
for (message in sortedMessages) {
val body = MentionUtilities.highlightMentions(message.body, message.threadId, this)
if (TextUtils.isEmpty(body)) { continue }
val formattedTimestamp = DateUtils.getExtendedRelativeTimeSpanString(this, Locale.getDefault(), message.timestamp)
builder.append("$formattedTimestamp: $body").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<MessageRecord>) {
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()
2021-06-29 02:05:39 +02:00
endActionMode()
}
override fun resendMessage(messages: Set<MessageRecord>) {
// TODO: Implement
}
override fun saveAttachment(messages: Set<MessageRecord>) {
val message = messages.first() as MmsMessageRecord
2021-06-29 02:05:39 +02:00
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))
2021-06-29 02:05:39 +02:00
.onAnyDenied {
endActionMode()
Toast.makeText(this@ConversationActivityV2, R.string.MediaPreviewActivity_unable_to_write_to_external_storage_without_permission, Toast.LENGTH_LONG).show()
}
.onAllGranted {
2021-06-29 02:05:39 +02:00
endActionMode()
val attachments: List<SaveAttachmentTask.Attachment?> = 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<MessageRecord>) {
2021-06-29 06:05:32 +02:00
inputBar.draftQuote(messages.first(), glide)
2021-06-29 02:05:39 +02:00
endActionMode()
}
private fun sendMediaSavedNotification() {
if (thread.isGroupRecipient) { return }
val timestamp = System.currentTimeMillis()
val kind = DataExtractionNotification.Kind.MediaSaved(timestamp)
val message = DataExtractionNotification(kind)
MessageSender.send(message, thread.address)
}
2021-06-29 02:05:39 +02:00
private fun endActionMode() {
actionMode?.finish()
actionMode = null
}
// endregion
2021-06-22 08:23:47 +02:00
// region General
2021-06-25 07:20:54 +02:00
private fun getMessageBody(): String {
2021-06-25 06:42:04 +02:00
var result = inputBar.inputBarEditText.text?.trim() ?: ""
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")
}
}
2021-06-25 07:20:54 +02:00
return result.toString()
2021-06-25 06:42:04 +02:00
}
2021-06-22 08:23:47 +02:00
private fun saveDraft() {
val text = inputBar.text.trim()
if (text.isEmpty()) { return }
val drafts = Drafts()
drafts.add(DraftDatabase.Draft(DraftDatabase.Draft.TEXT, text))
val draftDB = DatabaseFactory.getDraftDatabase(this)
draftDB.insertDrafts(threadID, drafts)
}
// endregion
2021-06-29 02:05:39 +02:00
}