diff --git a/app/src/main/java/org/thoughtcrime/securesms/audio/AudioSlidePlayer.java b/app/src/main/java/org/thoughtcrime/securesms/audio/AudioSlidePlayer.java index a7622306b..61a92105a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/audio/AudioSlidePlayer.java +++ b/app/src/main/java/org/thoughtcrime/securesms/audio/AudioSlidePlayer.java @@ -23,6 +23,7 @@ import com.google.android.exoplayer2.DefaultRenderersFactory; import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.ExoPlayerFactory; import com.google.android.exoplayer2.LoadControl; +import com.google.android.exoplayer2.PlaybackParameters; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.SimpleExoPlayer; import com.google.android.exoplayer2.audio.AudioAttributes; @@ -103,9 +104,9 @@ public class AudioSlidePlayer implements SensorEventListener { } private void play(final double progress, boolean earpiece) throws IOException { - if (this.mediaPlayer != null) return; + if (this.mediaPlayer != null) { stop(); } - LoadControl loadControl = new DefaultLoadControl.Builder().setBufferDurationsMs(Integer.MAX_VALUE, Integer.MAX_VALUE, Integer.MAX_VALUE, Integer.MAX_VALUE).createDefaultLoadControl(); + LoadControl loadControl = new DefaultLoadControl.Builder().setBufferDurationsMs(Integer.MAX_VALUE, Integer.MAX_VALUE, Integer.MAX_VALUE, Integer.MAX_VALUE).createDefaultLoadControl(); this.mediaPlayer = ExoPlayerFactory.newSimpleInstance(context, new DefaultRenderersFactory(context), new DefaultTrackSelector(), loadControl); this.audioAttachmentServer = new AttachmentServer(context, slide.asAttachment()); this.startTime = System.currentTimeMillis(); @@ -184,8 +185,6 @@ public class AudioSlidePlayer implements SensorEventListener { public void onPlayerError(ExoPlaybackException error) { Log.w(TAG, "MediaPlayer Error: " + error); - Toast.makeText(context, R.string.AudioSlidePlayer_error_playing_audio, Toast.LENGTH_SHORT).show(); - synchronized (AudioSlidePlayer.this) { mediaPlayer = null; @@ -267,8 +266,17 @@ public class AudioSlidePlayer implements SensorEventListener { return slide; } + public Long getDuration() { + if (mediaPlayer == null) { return 0L; } + return mediaPlayer.getDuration(); + } - private Pair getProgress() { + public Double getProgress() { + if (mediaPlayer == null) { return 0.0; } + return (double) mediaPlayer.getCurrentPosition() / (double) mediaPlayer.getDuration(); + } + + private Pair getProgressTuple() { if (mediaPlayer == null || mediaPlayer.getCurrentPosition() <= 0 || mediaPlayer.getDuration() <= 0) { return new Pair<>(0D, 0); } else { @@ -277,6 +285,16 @@ public class AudioSlidePlayer implements SensorEventListener { } } + public float getPlaybackSpeed() { + if (mediaPlayer == null) { return 1.0f; } + return mediaPlayer.getPlaybackParameters().speed; + } + + public void setPlaybackSpeed(float speed) { + if (mediaPlayer == null) { return; } + mediaPlayer.setPlaybackParameters(new PlaybackParameters(speed)); + } + private void notifyOnStart() { Util.runOnMain(() -> getListener().onPlayerStart(AudioSlidePlayer.this)); } @@ -383,7 +401,7 @@ public class AudioSlidePlayer implements SensorEventListener { return; } - Pair progress = player.getProgress(); + Pair progress = player.getProgressTuple(); player.notifyOnProgress(progress.first, progress.second); sendEmptyMessageDelayed(0, 50); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt index df917e50c..d90dc964f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt @@ -1,8 +1,12 @@ package org.thoughtcrime.securesms.conversation.v2 +import android.Manifest import android.animation.FloatEvaluator import android.animation.ValueAnimator import android.content.Context +import android.content.ClipData +import android.content.ClipboardManager +import android.content.DialogInterface import android.content.Intent import android.content.res.Resources import android.database.Cursor @@ -23,6 +27,7 @@ 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 kotlinx.android.synthetic.main.activity_conversation_v2.* import kotlinx.android.synthetic.main.activity_conversation_v2.view.* import kotlinx.android.synthetic.main.activity_conversation_v2_action_bar.* @@ -36,12 +41,17 @@ import nl.komponents.kovenant.ui.successUi import org.session.libsession.messaging.contacts.Contact 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.DataExtractionNotification.Kind.MediaSaved import org.session.libsession.messaging.messages.signal.OutgoingMediaMessage import org.session.libsession.messaging.messages.signal.OutgoingTextMessage import org.session.libsession.messaging.messages.visible.VisibleMessage import org.session.libsession.messaging.open_groups.OpenGroupAPIV2 import org.session.libsession.messaging.sending_receiving.MessageSender +import org.session.libsession.messaging.sending_receiving.MessageSender.send 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.utilities.MediaTypes import org.session.libsession.utilities.TextSecurePreferences import org.session.libsignal.utilities.ListenableFuture @@ -55,6 +65,7 @@ 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.utilities.AttachmentManager @@ -62,6 +73,7 @@ import org.thoughtcrime.securesms.database.DatabaseFactory import org.thoughtcrime.securesms.database.DraftDatabase import org.thoughtcrime.securesms.database.DraftDatabase.Drafts import org.thoughtcrime.securesms.database.model.MessageRecord +import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.giph.ui.GiphyActivity import org.thoughtcrime.securesms.linkpreview.LinkPreviewRepository import org.thoughtcrime.securesms.linkpreview.LinkPreviewViewModel @@ -73,8 +85,10 @@ import org.thoughtcrime.securesms.mediasend.Media import org.thoughtcrime.securesms.mediasend.MediaSendActivity import org.thoughtcrime.securesms.mms.* import org.thoughtcrime.securesms.notifications.MarkReadReceiver +import org.thoughtcrime.securesms.permissions.Permissions import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.MediaUtil +import org.thoughtcrime.securesms.util.SaveAttachmentTask import java.util.* import java.util.concurrent.ExecutionException import kotlin.math.* @@ -84,7 +98,8 @@ import kotlin.math.* // price we pay is a bit of back and forth between the input bar and the conversation activity. class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDelegate, - InputBarRecordingViewDelegate, AttachmentManager.AttachmentListener, ActivityDispatcher { + InputBarRecordingViewDelegate, AttachmentManager.AttachmentListener, ActivityDispatcher, + ConversationActionModeCallbackDelegate { private val screenWidth = Resources.getSystem().displayMetrics.widthPixels private var linkPreviewViewModel: LinkPreviewViewModel? = null private var threadID: Long = -1 @@ -575,6 +590,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe 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() @@ -598,6 +614,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe 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) { @@ -678,7 +695,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } private fun unblock() { - // TODO: Implement + if (!thread.isContactRecipient) { return } + DatabaseFactory.getRecipientDatabase(this).setBlocked(thread, false) } private fun handleMentionSelected(mention: Mention) { @@ -694,6 +712,18 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } override fun sendMessage() { + if (thread.isContactRecipient && thread.isBlocked) { + BlockedDialog(thread).show(supportFragmentManager, "Blocked Dialog") + return + } + if (inputBar.linkPreview != null || inputBar.quote != null) { + sendAttachments(listOf(), getMessageBody(), inputBar.quote, inputBar.linkPreview) + } else { + sendTextOnlyMessage() + } + } + + private fun sendTextOnlyMessage() { // Create the message val message = VisibleMessage() message.sentTimestamp = System.currentTimeMillis() @@ -713,13 +743,16 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe ApplicationContext.getInstance(this).typingStatusSender.onTypingStopped(threadID) } - private fun sendAttachments(attachments: List, body: String?) { - // TODO: Quotes & link previews + private fun sendAttachments(attachments: List, body: String?, quotedMessage: MessageRecord? = null, linkPreview: LinkPreview? = null) { // Create the message val message = VisibleMessage() message.sentTimestamp = System.currentTimeMillis() message.text = body - val outgoingTextMessage = OutgoingMediaMessage.from(message, thread, attachments, null, null) + 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) // Clear the input bar inputBar.text = "" // Clear mentions @@ -733,7 +766,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe // Put the message in the database message.id = DatabaseFactory.getMmsDatabase(this).insertMessageOutbox(outgoingTextMessage, threadID, false) { } // Send it - MessageSender.send(message, thread.address, attachments, null, null) + MessageSender.send(message, thread.address, attachments, quote, linkPreview) // Send a typing stopped message ApplicationContext.getInstance(this).typingStatusSender.onTypingStopped(threadID) } @@ -854,6 +887,75 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe audioRecorder.stopRecording() stopAudioHandler.removeCallbacks(stopVoiceMessageRecordingTask) } + + override fun deleteMessage(messages: Set) { + // TODO: Implement + } + + override fun banUser(messages: Set) { + // TODO: Implement + } + + override fun copyMessage(messages: Set) { + // TODO: Implement + } + + 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() + actionMode?.finish() + actionMode = null + } + + override fun resendMessage(messages: Set) { + // TODO: Implement + } + + override fun saveAttachment(messages: Set) { + val message = messages.first() as MmsMessageRecord + SaveAttachmentTask.showWarningDialog(this, { dialog: DialogInterface?, which: Int -> + 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 { Toast.makeText(this@ConversationActivityV2, R.string.MediaPreviewActivity_unable_to_write_to_external_storage_without_permission, Toast.LENGTH_LONG).show() } + .onAllGranted { + 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) { + inputBar.draftQuote(messages.first()) + actionMode?.finish() + actionMode = null + } + + 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) + } // endregion // region General diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/BlockedDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/BlockedDialog.kt index d42e39d1f..3013ab890 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/BlockedDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/BlockedDialog.kt @@ -36,6 +36,7 @@ class BlockedDialog(private val recipient: Recipient) : BaseDialog() { } private fun unblock() { - // TODO: Implement + DatabaseFactory.getRecipientDatabase(requireContext()).setBlocked(recipient, false) + dismiss() } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt index d00dc10f1..b1ba60bc9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt @@ -29,6 +29,8 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li private var linkPreviewDraftView: LinkPreviewDraftView? = null var delegate: InputBarDelegate? = null var additionalContentHeight = 0 + var quote: MessageRecord? = null + var linkPreview: LinkPreview? = null var text: String get() { return inputBarEditText.text.toString() } @@ -100,6 +102,8 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li // a quote and a link preview at the same time. fun draftQuote(message: MessageRecord) { + quote = message + linkPreview = null linkPreviewDraftView = null inputBarAdditionalContentContainer.removeAllViews() val quoteView = QuoteView(context, QuoteView.Mode.Draft) @@ -121,6 +125,7 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li } override fun cancelQuoteDraft() { + quote = null inputBarAdditionalContentContainer.removeAllViews() val newHeight = max(inputBarEditText.height + 2 * vMargin, minHeight) additionalContentHeight = 0 @@ -128,6 +133,7 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li } fun draftLinkPreview() { + quote = null val linkPreviewDraftHeight = toPx(88, resources) inputBarAdditionalContentContainer.removeAllViews() val linkPreviewDraftView = LinkPreviewDraftView(context) @@ -140,11 +146,14 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li } fun updateLinkPreviewDraft(glide: GlideRequests, linkPreview: LinkPreview) { + this.linkPreview = linkPreview val linkPreviewDraftView = this.linkPreviewDraftView ?: return linkPreviewDraftView.update(glide, linkPreview) } override fun cancelLinkPreviewDraft() { + if (quote != null) { return } + linkPreview = null inputBarAdditionalContentContainer.removeAllViews() val newHeight = max(inputBarEditText.height + 2 * vMargin, minHeight) additionalContentHeight = 0 diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationActionModeCallback.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationActionModeCallback.kt index 00d62efcb..750dc3495 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationActionModeCallback.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationActionModeCallback.kt @@ -1,6 +1,7 @@ package org.thoughtcrime.securesms.conversation.v2.menus import android.content.Context +import android.util.Log import android.view.ActionMode import android.view.Menu import android.view.MenuItem @@ -10,9 +11,11 @@ import org.session.libsession.utilities.TextSecurePreferences import org.thoughtcrime.securesms.conversation.v2.ConversationAdapter import org.thoughtcrime.securesms.database.DatabaseFactory import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord +import org.thoughtcrime.securesms.database.model.MessageRecord class ConversationActionModeCallback(private val adapter: ConversationAdapter, private val threadID: Long, private val context: Context) : ActionMode.Callback { + var delegate: ConversationActionModeCallbackDelegate? = null override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { val inflater = mode.menuInflater @@ -44,8 +47,6 @@ class ConversationActionModeCallback(private val adapter: ConversationAdapter, p if (selectedUsers.size > 1) { return false } return OpenGroupAPIV2.isUserModerator(userPublicKey, openGroup.room, openGroup.server) } - // Message info - menu.findItem(R.id.menu_context_details).isVisible = (selectedItems.size == 1) // Delete message menu.findItem(R.id.menu_context_delete_message).isVisible = userCanDeleteSelectedItems() // Ban user @@ -70,6 +71,16 @@ class ConversationActionModeCallback(private val adapter: ConversationAdapter, p } override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { + val selectedItems = adapter.selectedItems + when (item.itemId) { + R.id.menu_context_delete_message -> delegate?.deleteMessage(selectedItems) + R.id.menu_context_ban_user -> delegate?.banUser(selectedItems) + R.id.menu_context_copy -> delegate?.copyMessage(selectedItems) + R.id.menu_context_copy_public_key -> delegate?.copySessionID(selectedItems) + R.id.menu_context_resend -> delegate?.resendMessage(selectedItems) + R.id.menu_context_save_attachment -> delegate?.saveAttachment(selectedItems) + R.id.menu_context_reply -> delegate?.reply(selectedItems) + } return true } @@ -77,4 +88,15 @@ class ConversationActionModeCallback(private val adapter: ConversationAdapter, p adapter.selectedItems.clear() adapter.notifyDataSetChanged() } +} + +interface ConversationActionModeCallbackDelegate { + + fun deleteMessage(messages: Set) + fun banUser(messages: Set) + fun copyMessage(messages: Set) + fun copySessionID(messages: Set) + fun resendMessage(messages: Set) + fun saveAttachment(messages: Set) + fun reply(messages: Set) } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/ControlMessageView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/ControlMessageView.kt index ab6c11dcb..b4f3810e1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/ControlMessageView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/ControlMessageView.kt @@ -37,7 +37,7 @@ class ControlMessageView : LinearLayout { } fun recycle() { - // TODO: Implement + } // endregion } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/DocumentView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/DocumentView.kt index 4a9c18996..c9daca6c7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/DocumentView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/DocumentView.kt @@ -30,9 +30,5 @@ class DocumentView : LinearLayout { documentTitleTextView.setTextColor(textColor) documentViewIconImageView.imageTintList = ColorStateList.valueOf(textColor) } - - fun recycle() { - // TODO: Implement - } // endregion } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/LinkPreviewView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/LinkPreviewView.kt index 00a98d1d3..aae48a622 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/LinkPreviewView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/LinkPreviewView.kt @@ -64,9 +64,5 @@ class LinkPreviewView : LinearLayout { super.dispatchDraw(canvas) cornerMask.mask(canvas) } - - fun recycle() { - // TODO: Implement - } // endregion } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt index 7a3aea223..9cb505103 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt @@ -7,7 +7,6 @@ import android.graphics.Color import android.graphics.drawable.Drawable import android.text.util.Linkify import android.util.AttributeSet -import android.util.Log import android.util.TypedValue import android.view.LayoutInflater import android.widget.LinearLayout @@ -28,12 +27,12 @@ import org.thoughtcrime.securesms.components.emoji.EmojiTextView import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.loki.utilities.* -import org.thoughtcrime.securesms.loki.utilities.MentionUtilities.highlightMentions import org.thoughtcrime.securesms.mms.GlideRequests import kotlin.math.roundToInt class VisibleMessageContentView : LinearLayout { var onContentClick: ((rawRect: Rect) -> Unit)? = null + var onContentDoubleTap: (() -> Unit)? = null // region Lifecycle constructor(context: Context) : super(context) { initialize() } @@ -58,6 +57,7 @@ class VisibleMessageContentView : LinearLayout { // Body mainContainer.removeAllViews() onContentClick = null + onContentDoubleTap = null if (message is MmsMessageRecord && message.linkPreviews.isNotEmpty()) { val linkPreviewView = LinkPreviewView(context) linkPreviewView.bind(message, glide, isStartOfMessageCluster, isEndOfMessageCluster) @@ -83,6 +83,7 @@ class VisibleMessageContentView : LinearLayout { // We have to use onContentClick (rather than a click listener directly on the voice // message view) so as to not interfere with all the other gestures. onContentClick = { voiceMessageView.togglePlayback() } + onContentDoubleTap = { voiceMessageView.handleDoubleTap() } } else if (message is MmsMessageRecord && message.slideDeck.documentSlide != null) { val documentView = DocumentView(context) documentView.bind(message, VisibleMessageContentView.getTextColor(context, message)) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt index e0ad3930a..049796764 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt @@ -4,21 +4,15 @@ import android.content.Context import android.content.res.Resources import android.graphics.Canvas import android.graphics.Rect -import android.graphics.Region import android.graphics.drawable.ColorDrawable import android.os.Build import android.os.Handler import android.os.Looper import android.util.AttributeSet -import android.util.Log import android.view.* import android.widget.LinearLayout -import androidx.annotation.ColorRes -import androidx.annotation.DrawableRes import androidx.core.content.ContextCompat -import androidx.core.graphics.withClip import androidx.core.view.isVisible -import kotlinx.android.synthetic.main.view_conversation.view.* import kotlinx.android.synthetic.main.view_visible_message.view.* import kotlinx.android.synthetic.main.view_visible_message.view.profilePictureView import network.loki.messenger.R @@ -27,7 +21,6 @@ import org.session.libsession.messaging.open_groups.OpenGroupAPIV2 import org.session.libsession.utilities.ViewUtil import org.thoughtcrime.securesms.database.DatabaseFactory import org.thoughtcrime.securesms.database.model.MessageRecord -import org.thoughtcrime.securesms.loki.utilities.disableClipping import org.thoughtcrime.securesms.loki.utilities.getColorWithID import org.thoughtcrime.securesms.loki.utilities.toDp import org.thoughtcrime.securesms.loki.utilities.toPx @@ -46,8 +39,10 @@ class VisibleMessageView : LinearLayout { private var dx = 0.0f private var previousTranslationX = 0.0f private val gestureHandler = Handler(Looper.getMainLooper()) + private var pressCallback: Runnable? = null private var longPressCallback: Runnable? = null private var onDownTimestamp = 0L + private var onDoubleTap: (() -> Unit)? = null var snIsSelected = false set(value) { field = value; handleIsSelectedChanged()} var onPress: ((rawX: Int, rawY: Int) -> Unit)? = null @@ -58,6 +53,7 @@ class VisibleMessageView : LinearLayout { const val swipeToReplyThreshold = 80.0f // dp const val longPressMovementTreshold = 10.0f // dp const val longPressDurationThreshold = 250L // ms + const val maxDoubleTapInterval = 200L } // region Lifecycle @@ -143,6 +139,7 @@ class VisibleMessageView : LinearLayout { if (profilePictureContainer.visibility != View.GONE) { maxWidth -= profilePictureContainer.width } // Populate content view messageContentView.bind(message, isStartOfMessageCluster, isEndOfMessageCluster, glide, maxWidth, thread) + onDoubleTap = { messageContentView.onContentDoubleTap?.invoke() } } private fun setMessageSpacing(isStartOfMessageCluster: Boolean, isEndOfMessageCluster: Boolean) { @@ -195,7 +192,7 @@ class VisibleMessageView : LinearLayout { val spacing = context.resources.getDimensionPixelSize(R.dimen.small_spacing) val threshold = VisibleMessageView.swipeToReplyThreshold val iconSize = toPx(24, context.resources) - val bottomVOffset = paddingBottom + (messageContentView.height - iconSize) / 2 + val bottomVOffset = paddingBottom + messageStatusImageView.height + (messageContentView.height - iconSize) / 2 swipeToReplyIconRect.left = messageContentContainer.right + spacing swipeToReplyIconRect.top = height - bottomVOffset - iconSize swipeToReplyIconRect.right = messageContentContainer.right + iconSize + spacing @@ -272,7 +269,18 @@ class VisibleMessageView : LinearLayout { onSwipeToReply?.invoke() } else if ((Date().time - onDownTimestamp) < VisibleMessageView.longPressDurationThreshold) { longPressCallback?.let { gestureHandler.removeCallbacks(it) } - onPress?.invoke(event.rawX.toInt(), event.rawY.toInt()) + val pressCallback = this.pressCallback + if (pressCallback != null) { + // If we're here and pressCallback isn't null, it means that we tapped again within + // maxDoubleTapInterval ms and we should count this as a double tap + gestureHandler.removeCallbacks(pressCallback) + this.pressCallback = null + onDoubleTap?.invoke() + } else { + val newPressCallback = Runnable { onPress(event.rawX.toInt(), event.rawY.toInt()) } + this.pressCallback = newPressCallback + gestureHandler.postDelayed(newPressCallback, VisibleMessageView.maxDoubleTapInterval) + } } resetPosition() } @@ -300,5 +308,14 @@ class VisibleMessageView : LinearLayout { fun onContentClick(rawRect: Rect) { messageContentView.onContentClick?.invoke(rawRect) } + + private fun onPress(rawX: Int, rawY: Int) { + onPress?.invoke(rawX, rawY) + pressCallback = null + } + + fun onContentClick() { + messageContentView.onContentClick?.invoke() + } // endregion } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VoiceMessageView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VoiceMessageView.kt index 111f25180..b5fea727c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VoiceMessageView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VoiceMessageView.kt @@ -2,31 +2,29 @@ package org.thoughtcrime.securesms.conversation.v2.messages import android.content.Context import android.graphics.Canvas -import android.graphics.drawable.Drawable -import android.os.Handler -import android.os.Looper import android.util.AttributeSet -import android.view.LayoutInflater -import android.view.ViewOutlineProvider +import android.util.Log +import android.view.* import android.widget.LinearLayout import android.widget.RelativeLayout import androidx.core.view.isVisible import kotlinx.android.synthetic.main.view_voice_message.view.* import network.loki.messenger.R +import org.thoughtcrime.securesms.audio.AudioSlidePlayer import org.thoughtcrime.securesms.components.CornerMask import org.thoughtcrime.securesms.conversation.v2.utilities.MessageBubbleUtilities import org.thoughtcrime.securesms.database.model.MmsMessageRecord import java.util.concurrent.TimeUnit import kotlin.math.roundToInt +import kotlin.math.roundToLong -class VoiceMessageView : LinearLayout { - private val snHandler = Handler(Looper.getMainLooper()) +class VoiceMessageView : LinearLayout, AudioSlidePlayer.Listener { private val cornerMask by lazy { CornerMask(this) } - private var runnable: Runnable? = null - private var mockIsPlaying = false - private var mockProgress = 0L - set(value) { field = value; handleProgressChanged() } - private var mockDuration = 12000L + private var isPlaying = false + private var progress = 0.0 + private var duration = 0L + private var player: AudioSlidePlayer? = null + private var isPreparing = false // region Lifecycle constructor(context: Context) : super(context) { initialize() } @@ -36,14 +34,18 @@ class VoiceMessageView : LinearLayout { private fun initialize() { LayoutInflater.from(context).inflate(R.layout.view_voice_message, this) voiceMessageViewDurationTextView.text = String.format("%01d:%02d", - TimeUnit.MILLISECONDS.toMinutes(mockDuration), - TimeUnit.MILLISECONDS.toSeconds(mockDuration)) + TimeUnit.MILLISECONDS.toMinutes(0), + TimeUnit.MILLISECONDS.toSeconds(0)) } // endregion // region Updating fun bind(message: MmsMessageRecord, isStartOfMessageCluster: Boolean, isEndOfMessageCluster: Boolean) { val audio = message.slideDeck.audioSlide!! + val player = AudioSlidePlayer.createFor(context, audio, this) + this.player = player + isPreparing = true + player.play(0.0) voiceMessageViewLoader.isVisible = audio.isPendingDownload val cornerRadii = MessageBubbleUtilities.calculateRadii(context, isStartOfMessageCluster, isEndOfMessageCluster, message.isOutgoing) cornerMask.setTopLeftRadius(cornerRadii[0]) @@ -52,43 +54,59 @@ class VoiceMessageView : LinearLayout { cornerMask.setBottomLeftRadius(cornerRadii[3]) } - private fun handleProgressChanged() { + override fun onPlayerStart(player: AudioSlidePlayer) { + if (!isPreparing) { return } + isPreparing = false + duration = player.duration voiceMessageViewDurationTextView.text = String.format("%01d:%02d", - TimeUnit.MILLISECONDS.toMinutes(mockDuration - mockProgress), - TimeUnit.MILLISECONDS.toSeconds(mockDuration - mockProgress)) + TimeUnit.MILLISECONDS.toMinutes(duration), + TimeUnit.MILLISECONDS.toSeconds(duration)) + player.stop() + } + + override fun onPlayerProgress(player: AudioSlidePlayer, progress: Double, unused: Long) { + if (progress == 1.0) { + togglePlayback() + handleProgressChanged(0.0) + } else { + handleProgressChanged(progress) + } + } + + private fun handleProgressChanged(progress: Double) { + this.progress = progress + voiceMessageViewDurationTextView.text = String.format("%01d:%02d", + TimeUnit.MILLISECONDS.toMinutes(duration - (progress * duration.toDouble()).roundToLong()), + TimeUnit.MILLISECONDS.toSeconds(duration - (progress * duration.toDouble()).roundToLong())) val layoutParams = progressView.layoutParams as RelativeLayout.LayoutParams - val fraction = mockProgress.toFloat() / mockDuration.toFloat() - layoutParams.width = (width.toFloat() * fraction).roundToInt() + layoutParams.width = (width.toFloat() * progress.toFloat()).roundToInt() progressView.layoutParams = layoutParams } + override fun onPlayerStop(player: AudioSlidePlayer) { } + override fun dispatchDraw(canvas: Canvas) { super.dispatchDraw(canvas) cornerMask.mask(canvas) } - - fun recycle() { - // TODO: Implement - } // endregion // region Interaction fun togglePlayback() { - mockIsPlaying = !mockIsPlaying - val iconID = if (mockIsPlaying) R.drawable.exo_icon_pause else R.drawable.exo_icon_play + val player = this.player ?: return + isPlaying = !isPlaying + val iconID = if (isPlaying) R.drawable.exo_icon_pause else R.drawable.exo_icon_play voiceMessagePlaybackImageView.setImageResource(iconID) - if (mockIsPlaying) { - updateProgress() + if (isPlaying) { + player.play(progress) } else { - runnable?.let { snHandler.removeCallbacks(it) } + player.stop() } } - private fun updateProgress() { - mockProgress += 20L - val runnable = Runnable { updateProgress() } - this.runnable = runnable - snHandler.postDelayed(runnable, 20L) + fun handleDoubleTap() { + val player = this.player ?: return + player.playbackSpeed = if (player.playbackSpeed == 1.0f) 1.5f else 1.0f } // endregion -} \ No newline at end of file +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/AudioSlide.java b/app/src/main/java/org/thoughtcrime/securesms/mms/AudioSlide.java index 9a1885ab9..3d45e6a6e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/AudioSlide.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/AudioSlide.java @@ -31,7 +31,6 @@ import org.session.libsession.utilities.MediaTypes; import org.thoughtcrime.securesms.database.AttachmentDatabase; import org.thoughtcrime.securesms.util.ResUtil; - public class AudioSlide extends Slide { public AudioSlide(Context context, Uri uri, long dataSize, boolean voiceNote) { diff --git a/app/src/main/res/menu/menu_conversation_item_action.xml b/app/src/main/res/menu/menu_conversation_item_action.xml index b2006d81e..ffa6fc7ed 100644 --- a/app/src/main/res/menu/menu_conversation_item_action.xml +++ b/app/src/main/res/menu/menu_conversation_item_action.xml @@ -32,11 +32,6 @@ android:id="@+id/menu_context_resend" app:showAsAction="never" /> - -