[SES-1486] Short voice message fix (#1523)

* Initial working push with debug comments

* Fixes #1522

* Cleanup, prevent multi-pointer recording, and don't show short msg toast if locked to record quickly

* Adjusted comment phrasing

* Fix comment phrasing

* Fixed inadvertant short voice message toast on exit conversation activity

* Comment adjustment

* Comment phrasing

* Adjusted AudioRecorder.startRecording to take a callback function rather than the InputBar

* Performed Thomas' PR feedback

* Move comment to more relevant place

* Removed unused / leftover callback definition

* Removed all redundant null checks after asserting binding is not null

* Removed remaining not-null assertions & added some logged feedback to fail states

* Addressed PR feedback

* Implemented additional PR feedback

* Adjusted InputBar property visibility as per PR feedback & adjusted Toast string following discussion with Lucy

* Minor adjustment to inform user if we see an obvious network issue when sending a voice message - also tweak the locked Cancel button size to prevent text entry when locked to voice recording

* Adjust comment phrasing following further testing

* Added TODO comments to replace hard-coded string in toasts

* Addressed Thomas PR feedback suggestion

* Addressed another feedback suggestion

* Adjustment to continue informing user of network / node path issues

* Improved & moved network check method

* Corrected ticket number into TODO comments

* Addressed Andy PR feedback

* Adjust network connectivity checks to just log issues rather than inform the user (as per Rebecca / Kee convo)

---------

Co-authored-by: alansley <aclansley@gmail.com>
This commit is contained in:
AL-Session 2024-07-03 14:44:26 +10:00 committed by GitHub
parent a30f00104e
commit 1e02845fd2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 431 additions and 208 deletions

View File

@ -1,28 +1,22 @@
package org.thoughtcrime.securesms.audio; package org.thoughtcrime.securesms.audio;
import android.annotation.TargetApi;
import android.content.Context; import android.content.Context;
import android.net.Uri; import android.net.Uri;
import android.os.Build;
import android.os.ParcelFileDescriptor; import android.os.ParcelFileDescriptor;
import androidx.annotation.NonNull;
import org.session.libsession.utilities.MediaTypes;
import org.session.libsignal.utilities.Log;
import android.util.Pair; import android.util.Pair;
import androidx.annotation.NonNull;
import java.io.IOException;
import java.util.concurrent.ExecutorService;
import org.session.libsession.utilities.MediaTypes;
import org.session.libsession.utilities.Util;
import org.session.libsignal.utilities.ListenableFuture;
import org.session.libsignal.utilities.Log;
import org.session.libsignal.utilities.SettableFuture;
import org.session.libsignal.utilities.ThreadUtils;
import org.thoughtcrime.securesms.providers.BlobProvider; import org.thoughtcrime.securesms.providers.BlobProvider;
import org.thoughtcrime.securesms.util.MediaUtil; import org.thoughtcrime.securesms.util.MediaUtil;
import org.session.libsignal.utilities.ThreadUtils;
import org.session.libsession.utilities.Util;
import org.session.libsignal.utilities.ListenableFuture;
import org.session.libsignal.utilities.SettableFuture;
import java.io.IOException;
import java.util.concurrent.ExecutorService;
@TargetApi(Build.VERSION_CODES.JELLY_BEAN)
public class AudioRecorder { public class AudioRecorder {
private static final String TAG = AudioRecorder.class.getSimpleName(); private static final String TAG = AudioRecorder.class.getSimpleName();
@ -34,11 +28,16 @@ public class AudioRecorder {
private AudioCodec audioCodec; private AudioCodec audioCodec;
private Uri captureUri; private Uri captureUri;
// Simple interface that allows us to provide a callback method to our `startRecording` method
public interface AudioMessageRecordingFinishedCallback {
void onAudioMessageRecordingFinished();
}
public AudioRecorder(@NonNull Context context) { public AudioRecorder(@NonNull Context context) {
this.context = context; this.context = context;
} }
public void startRecording() { public void startRecording(AudioMessageRecordingFinishedCallback callback) {
Log.i(TAG, "startRecording()"); Log.i(TAG, "startRecording()");
executor.execute(() -> { executor.execute(() -> {
@ -55,9 +54,11 @@ public class AudioRecorder {
.forData(new ParcelFileDescriptor.AutoCloseInputStream(fds[0]), 0) .forData(new ParcelFileDescriptor.AutoCloseInputStream(fds[0]), 0)
.withMimeType(MediaTypes.AUDIO_AAC) .withMimeType(MediaTypes.AUDIO_AAC)
.createForSingleSessionOnDisk(context, e -> Log.w(TAG, "Error during recording", e)); .createForSingleSessionOnDisk(context, e -> Log.w(TAG, "Error during recording", e));
audioCodec = new AudioCodec();
audioCodec = new AudioCodec();
audioCodec.start(new ParcelFileDescriptor.AutoCloseOutputStream(fds[1])); audioCodec.start(new ParcelFileDescriptor.AutoCloseOutputStream(fds[1]));
callback.onAudioMessageRecordingFinished();
} catch (IOException e) { } catch (IOException e) {
Log.w(TAG, e); Log.w(TAG, e);
} }

View File

@ -66,8 +66,6 @@ import network.loki.messenger.libsession_util.util.ExpiryMode
import nl.komponents.kovenant.ui.successUi import nl.komponents.kovenant.ui.successUi
import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.contacts.Contact 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.messages.ExpirationConfiguration import org.session.libsession.messaging.messages.ExpirationConfiguration
import org.session.libsession.messaging.messages.applyExpiryMode import org.session.libsession.messaging.messages.applyExpiryMode
import org.session.libsession.messaging.messages.control.DataExtractionNotification import org.session.libsession.messaging.messages.control.DataExtractionNotification
@ -81,6 +79,7 @@ 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.link_preview.LinkPreview
import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel
import org.session.libsession.messaging.utilities.SessionId import org.session.libsession.messaging.utilities.SessionId
import org.session.libsession.snode.OnionRequestAPI
import org.session.libsession.snode.SnodeAPI import org.session.libsession.snode.SnodeAPI
import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address
import org.session.libsession.utilities.Address.Companion.fromSerialized import org.session.libsession.utilities.Address.Companion.fromSerialized
@ -117,6 +116,9 @@ 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.InputBarButton
import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBarDelegate 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.InputBarRecordingViewDelegate
import org.thoughtcrime.securesms.conversation.v2.input_bar.VoiceRecorderConstants.ANIMATE_LOCK_DURATION_MS
import org.thoughtcrime.securesms.conversation.v2.input_bar.VoiceRecorderConstants.SHOW_HIDE_VOICE_UI_DURATION_MS
import org.thoughtcrime.securesms.conversation.v2.input_bar.VoiceRecorderState
import org.thoughtcrime.securesms.conversation.v2.input_bar.mentions.MentionCandidateAdapter import org.thoughtcrime.securesms.conversation.v2.input_bar.mentions.MentionCandidateAdapter
import org.thoughtcrime.securesms.conversation.v2.mention.MentionViewModel import org.thoughtcrime.securesms.conversation.v2.mention.MentionViewModel
import org.thoughtcrime.securesms.conversation.v2.menus.ConversationActionModeCallback import org.thoughtcrime.securesms.conversation.v2.menus.ConversationActionModeCallback
@ -169,6 +171,7 @@ import org.thoughtcrime.securesms.util.ActivityDispatcher
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.MediaUtil import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.NetworkUtils
import org.thoughtcrime.securesms.util.SaveAttachmentTask import org.thoughtcrime.securesms.util.SaveAttachmentTask
import org.thoughtcrime.securesms.util.SimpleTextWatcher import org.thoughtcrime.securesms.util.SimpleTextWatcher
import org.thoughtcrime.securesms.util.isScrolledToBottom import org.thoughtcrime.securesms.util.isScrolledToBottom
@ -176,6 +179,7 @@ import org.thoughtcrime.securesms.util.isScrolledToWithin30dpOfBottom
import org.thoughtcrime.securesms.util.push import org.thoughtcrime.securesms.util.push
import org.thoughtcrime.securesms.util.show import org.thoughtcrime.securesms.util.show
import org.thoughtcrime.securesms.util.toPx import org.thoughtcrime.securesms.util.toPx
import org.thoughtcrime.securesms.webrtc.NetworkChangeReceiver
import java.lang.ref.WeakReference import java.lang.ref.WeakReference
import java.util.Locale import java.util.Locale
import java.util.concurrent.ExecutionException import java.util.concurrent.ExecutionException
@ -201,7 +205,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
OnReactionSelectedListener, ReactWithAnyEmojiDialogFragment.Callback, ReactionsDialogFragment.Callback, OnReactionSelectedListener, ReactWithAnyEmojiDialogFragment.Callback, ReactionsDialogFragment.Callback,
ConversationMenuHelper.ConversationMenuListener { ConversationMenuHelper.ConversationMenuListener {
private var binding: ActivityConversationV2Binding? = null private lateinit var binding: ActivityConversationV2Binding
@Inject lateinit var textSecurePreferences: TextSecurePreferences @Inject lateinit var textSecurePreferences: TextSecurePreferences
@Inject lateinit var threadDb: ThreadDatabase @Inject lateinit var threadDb: ThreadDatabase
@ -281,13 +285,13 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
private var emojiPickerVisible = false private var emojiPickerVisible = false
private val isScrolledToBottom: Boolean private val isScrolledToBottom: Boolean
get() = binding?.conversationRecyclerView?.isScrolledToBottom ?: true get() = binding.conversationRecyclerView.isScrolledToBottom
private val isScrolledToWithin30dpOfBottom: Boolean private val isScrolledToWithin30dpOfBottom: Boolean
get() = binding?.conversationRecyclerView?.isScrolledToWithin30dpOfBottom ?: true get() = binding.conversationRecyclerView.isScrolledToWithin30dpOfBottom
private val layoutManager: LinearLayoutManager? private val layoutManager: LinearLayoutManager?
get() { return binding?.conversationRecyclerView?.layoutManager as LinearLayoutManager? } get() { return binding.conversationRecyclerView.layoutManager as LinearLayoutManager? }
private val seed by lazy { private val seed by lazy {
var hexEncodedSeed = IdentityKeyUtil.retrieve(this, IdentityKeyUtil.LOKI_SEED) var hexEncodedSeed = IdentityKeyUtil.retrieve(this, IdentityKeyUtil.LOKI_SEED)
@ -299,7 +303,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
val loadFileContents: (String) -> String = { fileName -> val loadFileContents: (String) -> String = { fileName ->
MnemonicUtilities.loadFileContents(appContext, fileName) MnemonicUtilities.loadFileContents(appContext, fileName)
} }
MnemonicCodec(loadFileContents).encode(hexEncodedSeed!!, MnemonicCodec.Language.Configuration.english) MnemonicCodec(loadFileContents).encode(hexEncodedSeed, MnemonicCodec.Language.Configuration.english)
} }
// There is a bug when initially joining a community where all messages will immediately be marked // There is a bug when initially joining a community where all messages will immediately be marked
@ -341,7 +345,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
// Register an AdapterDataObserver to scroll us to the bottom of the RecyclerView for if // Register an AdapterDataObserver to scroll us to the bottom of the RecyclerView for if
// we're already near the the bottom and the data changes. // we're already near the the bottom and the data changes.
adapter.registerAdapterDataObserver(ConversationAdapterDataObserver(binding?.conversationRecyclerView!!, adapter)) adapter.registerAdapterDataObserver(ConversationAdapterDataObserver(binding.conversationRecyclerView, adapter))
adapter adapter
} }
@ -364,6 +368,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
private var currentLastVisibleRecyclerViewIndex: Int = RecyclerView.NO_POSITION private var currentLastVisibleRecyclerViewIndex: Int = RecyclerView.NO_POSITION
private var recyclerScrollState: Int = RecyclerView.SCROLL_STATE_IDLE private var recyclerScrollState: Int = RecyclerView.SCROLL_STATE_IDLE
// Lower limit for the length of voice messages - any lower and we inform the user rather than sending
private val MINIMUM_VOICE_MESSAGE_DURATION_MS = 1000L
// region Settings // region Settings
companion object { companion object {
// Extras // Extras
@ -385,7 +392,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) { override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) {
super.onCreate(savedInstanceState, isReady) super.onCreate(savedInstanceState, isReady)
binding = ActivityConversationV2Binding.inflate(layoutInflater) binding = ActivityConversationV2Binding.inflate(layoutInflater)
setContentView(binding!!.root) setContentView(binding.root)
// messageIdToScroll // messageIdToScroll
messageToScrollTimestamp.set(intent.getLongExtra(SCROLL_MESSAGE_ID, -1)) messageToScrollTimestamp.set(intent.getLongExtra(SCROLL_MESSAGE_ID, -1))
@ -403,12 +410,12 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
restoreDraftIfNeeded() restoreDraftIfNeeded()
setUpUiStateObserver() setUpUiStateObserver()
binding!!.scrollToBottomButton.setOnClickListener { binding.scrollToBottomButton.setOnClickListener {
val layoutManager = (binding?.conversationRecyclerView?.layoutManager as? LinearLayoutManager) ?: return@setOnClickListener val layoutManager = binding.conversationRecyclerView.layoutManager as LinearLayoutManager
val targetPosition = if (reverseMessageList) 0 else adapter.itemCount val targetPosition = if (reverseMessageList) 0 else adapter.itemCount
if (layoutManager.isSmoothScrolling) { if (layoutManager.isSmoothScrolling) {
binding?.conversationRecyclerView?.scrollToPosition(targetPosition) binding.conversationRecyclerView.scrollToPosition(targetPosition)
} else { } else {
// It looks like 'smoothScrollToPosition' will actually load all intermediate items in // 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 // order to do the scroll, this can be very slow if there are a lot of messages so
@ -418,11 +425,11 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
// val position = if (reverseMessageList) layoutManager.findFirstVisibleItemPosition() else layoutManager.findLastVisibleItemPosition() // val position = if (reverseMessageList) layoutManager.findFirstVisibleItemPosition() else layoutManager.findLastVisibleItemPosition()
// val targetBuffer = if (reverseMessageList) 10 else Math.max(0, (adapter.itemCount - 1) - 10) // val targetBuffer = if (reverseMessageList) 10 else Math.max(0, (adapter.itemCount - 1) - 10)
// if (position > targetBuffer) { // if (position > targetBuffer) {
// binding?.conversationRecyclerView?.scrollToPosition(targetBuffer) // binding.conversationRecyclerView?.scrollToPosition(targetBuffer)
// } // }
binding?.conversationRecyclerView?.post { binding.conversationRecyclerView.post {
binding?.conversationRecyclerView?.smoothScrollToPosition(targetPosition) binding.conversationRecyclerView.smoothScrollToPosition(targetPosition)
} }
} }
} }
@ -430,7 +437,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
updateUnreadCountIndicator() updateUnreadCountIndicator()
updatePlaceholder() updatePlaceholder()
setUpBlockedBanner() setUpBlockedBanner()
binding!!.searchBottomBar.setEventListener(this) binding.searchBottomBar.setEventListener(this)
updateSendAfterApprovalText() updateSendAfterApprovalText()
setUpMessageRequestsBar() setUpMessageRequestsBar()
@ -461,7 +468,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
setUpOutdatedClientBanner() setUpOutdatedClientBanner()
if (author != null && messageTimestamp >= 0 && targetPosition >= 0) { if (author != null && messageTimestamp >= 0 && targetPosition >= 0) {
binding?.conversationRecyclerView?.scrollToPosition(targetPosition) binding.conversationRecyclerView.scrollToPosition(targetPosition)
} }
else { else {
scrollToFirstUnreadMessageIfNeeded(true) scrollToFirstUnreadMessageIfNeeded(true)
@ -523,7 +530,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
screenshotObserver screenshotObserver
) )
viewModel.run { viewModel.run {
binding?.toolbarContent?.update(recipient ?: return, openGroup, expirationConfiguration) binding.toolbarContent?.update(recipient ?: return, openGroup, expirationConfiguration)
} }
} }
@ -591,12 +598,12 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
// called from onCreate // called from onCreate
private fun setUpRecyclerView() { private fun setUpRecyclerView() {
binding!!.conversationRecyclerView.adapter = adapter binding.conversationRecyclerView.adapter = adapter
val layoutManager = LinearLayoutManager(this, LinearLayoutManager.VERTICAL, reverseMessageList) val layoutManager = LinearLayoutManager(this, LinearLayoutManager.VERTICAL, reverseMessageList)
binding!!.conversationRecyclerView.layoutManager = layoutManager binding.conversationRecyclerView.layoutManager = layoutManager
// Workaround for the fact that CursorRecyclerViewAdapter doesn't auto-update automatically (even though it says it will) // Workaround for the fact that CursorRecyclerViewAdapter doesn't auto-update automatically (even though it says it will)
LoaderManager.getInstance(this).restartLoader(0, null, this) LoaderManager.getInstance(this).restartLoader(0, null, this)
binding!!.conversationRecyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { binding.conversationRecyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
// The unreadCount check is to prevent us scrolling to the bottom when we first enter a conversation // The unreadCount check is to prevent us scrolling to the bottom when we first enter a conversation
@ -624,7 +631,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
// If the current last visible message index is less than the previous one (i.e. we've // If the current last visible message index is less than the previous one (i.e. we've
// lost visibility of one or more messages due to showing the IME keyboard) AND we're // lost visibility of one or more messages due to showing the IME keyboard) AND we're
// at the bottom of the message feed.. // at the bottom of the message feed..
val atBottomAndTrueLastNoLongerVisible = currentLastVisibleRecyclerViewIndex!! <= previousLastVisibleRecyclerViewIndex!! && !binding?.scrollToBottomButton?.isVisible!! val atBottomAndTrueLastNoLongerVisible = currentLastVisibleRecyclerViewIndex <= previousLastVisibleRecyclerViewIndex &&
!binding.scrollToBottomButton.isVisible
// ..OR we're at the last message or have received a new message.. // ..OR we're at the last message or have received a new message..
val atLastOrReceivedNewMessage = currentLastVisibleRecyclerViewIndex == (adapter.itemCount - 1) val atLastOrReceivedNewMessage = currentLastVisibleRecyclerViewIndex == (adapter.itemCount - 1)
@ -632,8 +640,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
// ..then scroll the recycler view to the last message on resize. Note: We cannot just call // ..then scroll the recycler view to the last message on resize. Note: We cannot just call
// scroll/smoothScroll - we have to `post` it or nothing happens! // scroll/smoothScroll - we have to `post` it or nothing happens!
if (atBottomAndTrueLastNoLongerVisible || atLastOrReceivedNewMessage) { if (atBottomAndTrueLastNoLongerVisible || atLastOrReceivedNewMessage) {
binding?.conversationRecyclerView?.post { binding.conversationRecyclerView.post {
binding?.conversationRecyclerView?.smoothScrollToPosition(adapter.itemCount) binding.conversationRecyclerView.smoothScrollToPosition(adapter.itemCount)
} }
} }
@ -643,14 +651,13 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
// called from onCreate // called from onCreate
private fun setUpToolBar() { private fun setUpToolBar() {
val binding = binding ?: return
setSupportActionBar(binding.toolbar) setSupportActionBar(binding.toolbar)
val actionBar = supportActionBar ?: return val actionBar = supportActionBar ?: return
val recipient = viewModel.recipient ?: return val recipient = viewModel.recipient ?: return
actionBar.title = "" actionBar.title = ""
actionBar.setDisplayHomeAsUpEnabled(true) actionBar.setDisplayHomeAsUpEnabled(true)
actionBar.setHomeButtonEnabled(true) actionBar.setHomeButtonEnabled(true)
binding!!.toolbarContent.bind( binding.toolbarContent.bind(
this, this,
viewModel.threadId, viewModel.threadId,
recipient, recipient,
@ -662,7 +669,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
// called from onCreate // called from onCreate
private fun setUpInputBar() { private fun setUpInputBar() {
val binding = binding ?: return
binding.inputBar.isGone = viewModel.hidesInputBar() binding.inputBar.isGone = viewModel.hidesInputBar()
binding.inputBar.delegate = this binding.inputBar.delegate = this
binding.inputBarRecordingView.delegate = this binding.inputBarRecordingView.delegate = this
@ -708,10 +714,10 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
} }
} else if (intent.hasExtra(Intent.EXTRA_TEXT)) { } else if (intent.hasExtra(Intent.EXTRA_TEXT)) {
val dataTextExtra = intent.getCharSequenceExtra(Intent.EXTRA_TEXT) ?: "" val dataTextExtra = intent.getCharSequenceExtra(Intent.EXTRA_TEXT) ?: ""
binding!!.inputBar.text = dataTextExtra.toString() binding.inputBar.text = dataTextExtra.toString()
} else { } else {
viewModel.getDraft()?.let { text -> viewModel.getDraft()?.let { text ->
binding!!.inputBar.text = text binding.inputBar.text = text
} }
} }
} }
@ -722,12 +728,12 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
val recipients = if (state != null) state.typists else listOf() val recipients = if (state != null) state.typists else listOf()
// FIXME: Also checking isScrolledToBottom is a quick fix for an issue where the // FIXME: Also checking isScrolledToBottom is a quick fix for an issue where the
// typing indicator overlays the recycler view when scrolled up // typing indicator overlays the recycler view when scrolled up
val viewContainer = binding?.typingIndicatorViewContainer ?: return@observe val viewContainer = binding.typingIndicatorViewContainer
viewContainer.isVisible = recipients.isNotEmpty() && isScrolledToBottom viewContainer.isVisible = recipients.isNotEmpty() && isScrolledToBottom
viewContainer.setTypists(recipients) viewContainer.setTypists(recipients)
} }
if (textSecurePreferences.isTypingIndicatorsEnabled()) { if (textSecurePreferences.isTypingIndicatorsEnabled()) {
binding!!.inputBar.addTextChangedListener(object : SimpleTextWatcher() { binding.inputBar.addTextChangedListener(object : SimpleTextWatcher() {
override fun onTextChanged(text: String?) { override fun onTextChanged(text: String?) {
ApplicationContext.getInstance(this@ConversationActivityV2).typingStatusSender.onTypingStarted(viewModel.threadId) ApplicationContext.getInstance(this@ConversationActivityV2).typingStatusSender.onTypingStarted(viewModel.threadId)
@ -747,7 +753,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
private fun getLatestOpenGroupInfoIfNeeded() { private fun getLatestOpenGroupInfoIfNeeded() {
val openGroup = viewModel.openGroup ?: return val openGroup = viewModel.openGroup ?: return
OpenGroupApi.getMemberCount(openGroup.room, openGroup.server) successUi { OpenGroupApi.getMemberCount(openGroup.room, openGroup.server) successUi {
binding?.toolbarContent?.updateSubtitle(viewModel.recipient!!, openGroup, viewModel.expirationConfiguration) binding.toolbarContent.updateSubtitle(viewModel.recipient!!, openGroup, viewModel.expirationConfiguration)
maybeUpdateToolbar(viewModel.recipient!!) maybeUpdateToolbar(viewModel.recipient!!)
} }
} }
@ -757,9 +763,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
val recipient = viewModel.recipient?.takeUnless { it.isGroupRecipient } ?: return val recipient = viewModel.recipient?.takeUnless { it.isGroupRecipient } ?: return
val sessionID = recipient.address.toString() val sessionID = recipient.address.toString()
val name = sessionContactDb.getContactWithSessionID(sessionID)?.displayName(Contact.ContactContext.REGULAR) ?: sessionID val name = sessionContactDb.getContactWithSessionID(sessionID)?.displayName(Contact.ContactContext.REGULAR) ?: sessionID
binding?.blockedBannerTextView?.text = resources.getString(R.string.activity_conversation_blocked_banner_text, name) binding.blockedBannerTextView.text = resources.getString(R.string.activity_conversation_blocked_banner_text, name)
binding?.blockedBanner?.isVisible = recipient.isBlocked binding.blockedBanner.isVisible = recipient.isBlocked
binding?.blockedBanner?.setOnClickListener { viewModel.unblock() } binding.blockedBanner.setOnClickListener { viewModel.unblock() }
} }
private fun setUpOutdatedClientBanner() { private fun setUpOutdatedClientBanner() {
@ -768,9 +774,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
val shouldShowLegacy = ExpirationConfiguration.isNewConfigEnabled && val shouldShowLegacy = ExpirationConfiguration.isNewConfigEnabled &&
legacyRecipient != null legacyRecipient != null
binding?.outdatedBanner?.isVisible = shouldShowLegacy binding.outdatedBanner.isVisible = shouldShowLegacy
if (shouldShowLegacy) { if (shouldShowLegacy) {
binding?.outdatedBannerTextView?.text = binding.outdatedBannerTextView.text =
resources.getString(R.string.activity_conversation_outdated_client_banner_text, legacyRecipient!!.name) resources.getString(R.string.activity_conversation_outdated_client_banner_text, legacyRecipient!!.name)
} }
} }
@ -783,13 +789,13 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
if (previewState == null) return@observe if (previewState == null) return@observe
when { when {
previewState.isLoading -> { previewState.isLoading -> {
binding?.inputBar?.draftLinkPreview() binding.inputBar.draftLinkPreview()
} }
previewState.linkPreview.isPresent -> { previewState.linkPreview.isPresent -> {
binding?.inputBar?.updateLinkPreviewDraft(glide, previewState.linkPreview.get()) binding.inputBar.updateLinkPreviewDraft(glide, previewState.linkPreview.get())
} }
else -> { else -> {
binding?.inputBar?.cancelLinkPreviewDraft() binding.inputBar.cancelLinkPreviewDraft()
} }
} }
} }
@ -803,7 +809,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
viewModel.messageShown(it.id) viewModel.messageShown(it.id)
} }
if (uiState.isMessageRequestAccepted == true) { if (uiState.isMessageRequestAccepted == true) {
binding?.messageRequestBar?.visibility = View.GONE binding.messageRequestBar.visibility = View.GONE
} }
if (!uiState.conversationExists && !isFinishing) { if (!uiState.conversationExists && !isFinishing) {
// Conversation should be deleted now, just go back // Conversation should be deleted now, just go back
@ -827,12 +833,12 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
if (lastSeenItemPosition <= 3) { return lastSeenItemPosition } if (lastSeenItemPosition <= 3) { return lastSeenItemPosition }
binding?.conversationRecyclerView?.scrollToPosition(lastSeenItemPosition) binding.conversationRecyclerView.scrollToPosition(lastSeenItemPosition)
return lastSeenItemPosition return lastSeenItemPosition
} }
private fun highlightViewAtPosition(position: Int) { private fun highlightViewAtPosition(position: Int) {
binding?.conversationRecyclerView?.post { binding.conversationRecyclerView.post {
(layoutManager?.findViewByPosition(position) as? VisibleMessageView)?.playHighlight() (layoutManager?.findViewByPosition(position) as? VisibleMessageView)?.playHighlight()
} }
} }
@ -852,11 +858,10 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
} }
override fun onDestroy() { override fun onDestroy() {
viewModel.saveDraft(binding?.inputBar?.text?.trim() ?: "") viewModel.saveDraft(binding.inputBar.text.trim())
cancelVoiceMessage() cancelVoiceMessage()
tearDownRecipientObserver() tearDownRecipientObserver()
super.onDestroy() super.onDestroy()
binding = null
} }
// endregion // endregion
@ -867,7 +872,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
runOnUiThread { runOnUiThread {
val threadRecipient = viewModel.recipient ?: return@runOnUiThread val threadRecipient = viewModel.recipient ?: return@runOnUiThread
if (threadRecipient.isContactRecipient) { if (threadRecipient.isContactRecipient) {
binding?.blockedBanner?.isVisible = threadRecipient.isBlocked binding.blockedBanner.isVisible = threadRecipient.isBlocked
} }
setUpMessageRequestsBar() setUpMessageRequestsBar()
invalidateOptionsMenu() invalidateOptionsMenu()
@ -879,29 +884,29 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
} }
private fun maybeUpdateToolbar(recipient: Recipient) { private fun maybeUpdateToolbar(recipient: Recipient) {
binding?.toolbarContent?.update(recipient, viewModel.openGroup, viewModel.expirationConfiguration) binding.toolbarContent.update(recipient, viewModel.openGroup, viewModel.expirationConfiguration)
} }
private fun updateSendAfterApprovalText() { private fun updateSendAfterApprovalText() {
binding?.textSendAfterApproval?.isVisible = viewModel.showSendAfterApprovalText binding.textSendAfterApproval.isVisible = viewModel.showSendAfterApprovalText
} }
private fun showOrHideInputIfNeeded() { private fun showOrHideInputIfNeeded() {
binding?.inputBar?.showInput = viewModel.recipient?.takeIf { it.isClosedGroupRecipient } binding.inputBar.showInput = viewModel.recipient?.takeIf { it.isClosedGroupRecipient }
?.run { address.toGroupString().let(groupDb::getGroup).orNull()?.isActive == true } ?.run { address.toGroupString().let(groupDb::getGroup).orNull()?.isActive == true }
?: true ?: true
} }
private fun setUpMessageRequestsBar() { private fun setUpMessageRequestsBar() {
binding?.inputBar?.showMediaControls = !isOutgoingMessageRequestThread() binding.inputBar.showMediaControls = !isOutgoingMessageRequestThread()
binding?.messageRequestBar?.isVisible = isIncomingMessageRequestThread() binding.messageRequestBar.isVisible = isIncomingMessageRequestThread()
binding?.acceptMessageRequestButton?.setOnClickListener { binding.acceptMessageRequestButton.setOnClickListener {
acceptMessageRequest() acceptMessageRequest()
} }
binding?.messageRequestBlock?.setOnClickListener { binding.messageRequestBlock.setOnClickListener {
block(deleteThread = true) block(deleteThread = true)
} }
binding?.declineMessageRequestButton?.setOnClickListener { binding.declineMessageRequestButton.setOnClickListener {
viewModel.declineMessageRequest() viewModel.declineMessageRequest()
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@ConversationActivityV2) ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@ConversationActivityV2)
@ -911,7 +916,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
} }
private fun acceptMessageRequest() { private fun acceptMessageRequest() {
binding?.messageRequestBar?.isVisible = false binding.messageRequestBar.isVisible = false
viewModel.acceptMessageRequest() viewModel.acceptMessageRequest()
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
@ -930,7 +935,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
} ?: false } ?: false
override fun inputBarEditTextContentChanged(newContent: CharSequence) { override fun inputBarEditTextContentChanged(newContent: CharSequence) {
val inputBarText = binding?.inputBar?.text ?: return // TODO check if we should be referencing newContent here instead val inputBarText = binding.inputBar.text // TODO check if we should be referencing newContent here instead
if (textSecurePreferences.isLinkPreviewsEnabled()) { if (textSecurePreferences.isLinkPreviewsEnabled()) {
linkPreviewViewModel.onTextChanged(this, inputBarText, 0, 0) linkPreviewViewModel.onTextChanged(this, inputBarText, 0, 0)
} }
@ -948,10 +953,10 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
override fun toggleAttachmentOptions() { override fun toggleAttachmentOptions() {
val targetAlpha = if (isShowingAttachmentOptions) 0.0f else 1.0f val targetAlpha = if (isShowingAttachmentOptions) 0.0f else 1.0f
val allButtonContainers = listOfNotNull( val allButtonContainers = listOfNotNull(
binding?.cameraButtonContainer, binding.cameraButtonContainer,
binding?.libraryButtonContainer, binding.libraryButtonContainer,
binding?.documentButtonContainer, binding.documentButtonContainer,
binding?.gifButtonContainer binding.gifButtonContainer
) )
val isReversed = isShowingAttachmentOptions // Run the animation in reverse val isReversed = isShowingAttachmentOptions // Run the animation in reverse
val count = allButtonContainers.size val count = allButtonContainers.size
@ -971,20 +976,20 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
} }
override fun showVoiceMessageUI() { override fun showVoiceMessageUI() {
binding?.inputBarRecordingView?.show(lifecycleScope) binding.inputBarRecordingView.show(lifecycleScope)
binding?.inputBar?.alpha = 0.0f binding.inputBar.alpha = 0.0f
val animation = ValueAnimator.ofObject(FloatEvaluator(), 1.0f, 0.0f) val animation = ValueAnimator.ofObject(FloatEvaluator(), 1.0f, 0.0f)
animation.duration = 250L animation.duration = SHOW_HIDE_VOICE_UI_DURATION_MS
animation.addUpdateListener { animator -> animation.addUpdateListener { animator ->
binding?.inputBar?.alpha = animator.animatedValue as Float binding.inputBar.alpha = animator.animatedValue as Float
} }
animation.start() animation.start()
} }
private fun expandVoiceMessageLockView() { private fun expandVoiceMessageLockView() {
val lockView = binding?.inputBarRecordingView?.lockView ?: return val lockView = binding.inputBarRecordingView.lockView
val animation = ValueAnimator.ofObject(FloatEvaluator(), lockView.scaleX, 1.10f) val animation = ValueAnimator.ofObject(FloatEvaluator(), lockView.scaleX, 1.10f)
animation.duration = 250L animation.duration = ANIMATE_LOCK_DURATION_MS
animation.addUpdateListener { animator -> animation.addUpdateListener { animator ->
lockView.scaleX = animator.animatedValue as Float lockView.scaleX = animator.animatedValue as Float
lockView.scaleY = animator.animatedValue as Float lockView.scaleY = animator.animatedValue as Float
@ -993,9 +998,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
} }
private fun collapseVoiceMessageLockView() { private fun collapseVoiceMessageLockView() {
val lockView = binding?.inputBarRecordingView?.lockView ?: return val lockView = binding.inputBarRecordingView.lockView
val animation = ValueAnimator.ofObject(FloatEvaluator(), lockView.scaleX, 1.0f) val animation = ValueAnimator.ofObject(FloatEvaluator(), lockView.scaleX, 1.0f)
animation.duration = 250L animation.duration = ANIMATE_LOCK_DURATION_MS
animation.addUpdateListener { animator -> animation.addUpdateListener { animator ->
lockView.scaleX = animator.animatedValue as Float lockView.scaleX = animator.animatedValue as Float
lockView.scaleY = animator.animatedValue as Float lockView.scaleY = animator.animatedValue as Float
@ -1004,24 +1009,24 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
} }
private fun hideVoiceMessageUI() { private fun hideVoiceMessageUI() {
val chevronImageView = binding?.inputBarRecordingView?.chevronImageView ?: return val chevronImageView = binding.inputBarRecordingView.chevronImageView
val slideToCancelTextView = binding?.inputBarRecordingView?.slideToCancelTextView ?: return val slideToCancelTextView = binding.inputBarRecordingView.slideToCancelTextView
listOf( chevronImageView, slideToCancelTextView ).forEach { view -> listOf( chevronImageView, slideToCancelTextView ).forEach { view ->
val animation = ValueAnimator.ofObject(FloatEvaluator(), view.translationX, 0.0f) val animation = ValueAnimator.ofObject(FloatEvaluator(), view.translationX, 0.0f)
animation.duration = 250L animation.duration = ANIMATE_LOCK_DURATION_MS
animation.addUpdateListener { animator -> animation.addUpdateListener { animator ->
view.translationX = animator.animatedValue as Float view.translationX = animator.animatedValue as Float
} }
animation.start() animation.start()
} }
binding?.inputBarRecordingView?.hide() binding.inputBarRecordingView.hide()
} }
override fun handleVoiceMessageUIHidden() { override fun handleVoiceMessageUIHidden() {
val inputBar = binding?.inputBar ?: return val inputBar = binding.inputBar
inputBar.alpha = 1.0f inputBar.alpha = 1.0f
val animation = ValueAnimator.ofObject(FloatEvaluator(), 0.0f, 1.0f) val animation = ValueAnimator.ofObject(FloatEvaluator(), 0.0f, 1.0f)
animation.duration = 250L animation.duration = SHOW_HIDE_VOICE_UI_DURATION_MS
animation.addUpdateListener { animator -> animation.addUpdateListener { animator ->
inputBar.alpha = animator.animatedValue as Float inputBar.alpha = animator.animatedValue as Float
} }
@ -1029,8 +1034,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
} }
private fun handleRecyclerViewScrolled() { private fun handleRecyclerViewScrolled() {
val binding = binding ?: return
// Note: The typing indicate is whether the other person / other people are typing - it has // Note: The typing indicate is whether the other person / other people are typing - it has
// nothing to do with the IME keyboard state. // nothing to do with the IME keyboard state.
val wasTypingIndicatorVisibleBefore = binding.typingIndicatorViewContainer.isVisible val wasTypingIndicatorVisibleBefore = binding.typingIndicatorViewContainer.isVisible
@ -1058,10 +1061,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
} }
private fun updatePlaceholder() { private fun updatePlaceholder() {
val recipient = viewModel.recipient val recipient = viewModel.recipient ?: return Log.w("Loki", "recipient was null in placeholder update")
?: return Log.w("Loki", "recipient was null in placeholder update")
val blindedRecipient = viewModel.blindedRecipient val blindedRecipient = viewModel.blindedRecipient
val binding = binding ?: return
val openGroup = viewModel.openGroup val openGroup = viewModel.openGroup
val (textResource, insertParam) = when { val (textResource, insertParam) = when {
@ -1091,11 +1092,10 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
} }
private fun showScrollToBottomButtonIfApplicable() { private fun showScrollToBottomButtonIfApplicable() {
binding?.scrollToBottomButton?.isVisible = !emojiPickerVisible && !isScrolledToBottom && adapter.itemCount > 0 binding.scrollToBottomButton.isVisible = !emojiPickerVisible && !isScrolledToBottom && adapter.itemCount > 0
} }
private fun updateUnreadCountIndicator() { private fun updateUnreadCountIndicator() {
val binding = binding ?: return
val formattedUnreadCount = if (unreadCount < 10000) unreadCount.toString() else "9999+" val formattedUnreadCount = if (unreadCount < 10000) unreadCount.toString() else "9999+"
binding.unreadCountTextView.text = formattedUnreadCount binding.unreadCountTextView.text = formattedUnreadCount
val textSize = if (unreadCount < 10000) 12.0f else 9.0f val textSize = if (unreadCount < 10000) 12.0f else 9.0f
@ -1145,7 +1145,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
override fun copyOpenGroupUrl(thread: Recipient) { override fun copyOpenGroupUrl(thread: Recipient) {
if (!thread.isCommunityRecipient) { return } if (!thread.isCommunityRecipient) { return }
val threadId = threadDb.getThreadIdIfExistsFor(thread) ?: return val threadId = threadDb.getThreadIdIfExistsFor(thread)
val openGroup = lokiThreadDb.getOpenGroupChat(threadId) ?: return val openGroup = lokiThreadDb.getOpenGroupChat(threadId) ?: return
val clip = ClipData.newPlainText("Community URL", openGroup.joinURL) val clip = ClipData.newPlainText("Community URL", openGroup.joinURL)
@ -1204,7 +1204,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
private fun handleSwipeToReply(message: MessageRecord) { private fun handleSwipeToReply(message: MessageRecord) {
if (message.isOpenGroupInvitation) return if (message.isOpenGroupInvitation) return
val recipient = viewModel.recipient ?: return val recipient = viewModel.recipient ?: return
binding?.inputBar?.draftQuote(recipient, message, glide) binding.inputBar.draftQuote(recipient, message, glide)
} }
// `position` is the adapter position; not the visual position // `position` is the adapter position; not the visual position
@ -1233,9 +1233,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
Log.e("Loki", "Failed to show emoji picker", e) Log.e("Loki", "Failed to show emoji picker", e)
return return
} }
val binding = binding ?: return
emojiPickerVisible = true emojiPickerVisible = true
ViewUtil.hideKeyboard(this, visibleMessageView) ViewUtil.hideKeyboard(this, visibleMessageView)
binding.reactionsShade.isVisible = true binding.reactionsShade.isVisible = true
@ -1288,11 +1285,16 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
private fun sendEmojiReaction(emoji: String, originalMessage: MessageRecord) { private fun sendEmojiReaction(emoji: String, originalMessage: MessageRecord) {
// Create the message // Create the message
val recipient = viewModel.recipient ?: return val recipient = viewModel.recipient ?: return Log.w(TAG, "Could not locate recipient when sending emoji reaction")
val reactionMessage = VisibleMessage() val reactionMessage = VisibleMessage()
val emojiTimestamp = SnodeAPI.nowWithOffset val emojiTimestamp = SnodeAPI.nowWithOffset
reactionMessage.sentTimestamp = emojiTimestamp reactionMessage.sentTimestamp = emojiTimestamp
val author = textSecurePreferences.getLocalNumber()!! val author = textSecurePreferences.getLocalNumber()
if (author == null) {
Log.w(TAG, "Unable to locate local number when sending emoji reaction - aborting.")
return
} else {
// Put the message in the database // Put the message in the database
val reaction = ReactionRecord( val reaction = ReactionRecord(
messageId = originalMessage.id, messageId = originalMessage.id,
@ -1304,28 +1306,40 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
dateReceived = emojiTimestamp dateReceived = emojiTimestamp
) )
reactionDb.addReaction(MessageId(originalMessage.id, originalMessage.isMms), reaction, false) reactionDb.addReaction(MessageId(originalMessage.id, originalMessage.isMms), reaction, false)
val originalAuthor = if (originalMessage.isOutgoing) { val originalAuthor = if (originalMessage.isOutgoing) {
fromSerialized(viewModel.blindedPublicKey ?: textSecurePreferences.getLocalNumber()!!) fromSerialized(viewModel.blindedPublicKey ?: textSecurePreferences.getLocalNumber()!!)
} else originalMessage.individualRecipient.address } else originalMessage.individualRecipient.address
// Send it // Send it
reactionMessage.reaction = Reaction.from(originalMessage.timestamp, originalAuthor.serialize(), emoji, true) reactionMessage.reaction = Reaction.from(originalMessage.timestamp, originalAuthor.serialize(), emoji, true)
if (recipient.isCommunityRecipient) { if (recipient.isCommunityRecipient) {
val messageServerId = lokiMessageDb.getServerID(originalMessage.id, !originalMessage.isMms) ?: return
val messageServerId = lokiMessageDb.getServerID(originalMessage.id, !originalMessage.isMms) ?:
return Log.w(TAG, "Failed to find message server ID when adding emoji reaction")
viewModel.openGroup?.let { viewModel.openGroup?.let {
OpenGroupApi.addReaction(it.room, it.server, messageServerId, emoji) OpenGroupApi.addReaction(it.room, it.server, messageServerId, emoji)
} }
} else { } else {
MessageSender.send(reactionMessage, recipient.address) MessageSender.send(reactionMessage, recipient.address)
} }
LoaderManager.getInstance(this).restartLoader(0, null, this) LoaderManager.getInstance(this).restartLoader(0, null, this)
} }
}
private fun sendEmojiRemoval(emoji: String, originalMessage: MessageRecord) { private fun sendEmojiRemoval(emoji: String, originalMessage: MessageRecord) {
val recipient = viewModel.recipient ?: return val recipient = viewModel.recipient ?: return
val message = VisibleMessage() val message = VisibleMessage()
val emojiTimestamp = SnodeAPI.nowWithOffset val emojiTimestamp = SnodeAPI.nowWithOffset
message.sentTimestamp = emojiTimestamp message.sentTimestamp = emojiTimestamp
val author = textSecurePreferences.getLocalNumber()!! val author = textSecurePreferences.getLocalNumber()
if (author == null) {
Log.w(TAG, "Unable to locate local number when removing emoji reaction - aborting.")
return
} else {
reactionDb.deleteReaction(emoji, MessageId(originalMessage.id, originalMessage.isMms), author, false) reactionDb.deleteReaction(emoji, MessageId(originalMessage.id, originalMessage.isMms), author, false)
val originalAuthor = if (originalMessage.isOutgoing) { val originalAuthor = if (originalMessage.isOutgoing) {
@ -1334,7 +1348,10 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
message.reaction = Reaction.from(originalMessage.timestamp, originalAuthor.serialize(), emoji, false) message.reaction = Reaction.from(originalMessage.timestamp, originalAuthor.serialize(), emoji, false)
if (recipient.isCommunityRecipient) { if (recipient.isCommunityRecipient) {
val messageServerId = lokiMessageDb.getServerID(originalMessage.id, !originalMessage.isMms) ?: return
val messageServerId = lokiMessageDb.getServerID(originalMessage.id, !originalMessage.isMms) ?:
return Log.w(TAG, "Failed to find message server ID when removing emoji reaction")
viewModel.openGroup?.let { viewModel.openGroup?.let {
OpenGroupApi.deleteReaction(it.room, it.server, messageServerId, emoji) OpenGroupApi.deleteReaction(it.room, it.server, messageServerId, emoji)
} }
@ -1343,6 +1360,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
} }
LoaderManager.getInstance(this).restartLoader(0, null, this) LoaderManager.getInstance(this).restartLoader(0, null, this)
} }
}
override fun onCustomReactionSelected(messageRecord: MessageRecord, hasAddedCustomEmoji: Boolean) { override fun onCustomReactionSelected(messageRecord: MessageRecord, hasAddedCustomEmoji: Boolean) {
val oldRecord = messageRecord.reactions.find { record -> record.author == textSecurePreferences.getLocalNumber() } val oldRecord = messageRecord.reactions.find { record -> record.author == textSecurePreferences.getLocalNumber() }
@ -1399,8 +1417,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
override fun onMicrophoneButtonMove(event: MotionEvent) { override fun onMicrophoneButtonMove(event: MotionEvent) {
val rawX = event.rawX val rawX = event.rawX
val chevronImageView = binding?.inputBarRecordingView?.chevronImageView ?: return val chevronImageView = binding.inputBarRecordingView.chevronImageView
val slideToCancelTextView = binding?.inputBarRecordingView?.slideToCancelTextView ?: return val slideToCancelTextView = binding.inputBarRecordingView.slideToCancelTextView
if (rawX < screenWidth / 2) { if (rawX < screenWidth / 2) {
val translationX = rawX - screenWidth / 2 val translationX = rawX - screenWidth / 2
val sign = -1.0f val sign = -1.0f
@ -1434,16 +1452,54 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
override fun onMicrophoneButtonUp(event: MotionEvent) { override fun onMicrophoneButtonUp(event: MotionEvent) {
val x = event.rawX.roundToInt() val x = event.rawX.roundToInt()
val y = event.rawY.roundToInt() val y = event.rawY.roundToInt()
if (isValidLockViewLocation(x, y)) { val inputBar = binding.inputBar
binding?.inputBarRecordingView?.lock()
} else { // Lock voice recording on if the button is released over the lock area AND the
val recordButtonOverlay = binding?.inputBarRecordingView?.recordButtonOverlay ?: return // voice recording has currently lasted for at least the time it takes to animate
// the lock area into position. Without this time check we can accidentally lock
// to recording audio on a quick tap as the lock area animates out from the record
// audio message button and the pointer-up event catches it mid-animation.
//
// Further, by limiting this to AnimateLockDurationMS rather than our minimum voice
// message length we get a fast, responsive UI that can lock 'straight away' - BUT
// we then have to artificially bump the voice message duration because if you press
// and slide to lock then release in one quick motion the pointer up event may be
// less than our minimum voice message duration - so we'll bump our recorded duration
// slightly to make sure we don't see the "Tap and hold to record..." toast when we
// finish recording the message.
if (isValidLockViewLocation(x, y) && inputBar.voiceMessageDurationMS >= ANIMATE_LOCK_DURATION_MS) {
binding.inputBarRecordingView.lock()
// Artificially bump message duration on lock if required
if (inputBar.voiceMessageDurationMS < MINIMUM_VOICE_MESSAGE_DURATION_MS) {
inputBar.voiceMessageDurationMS = MINIMUM_VOICE_MESSAGE_DURATION_MS
}
// If the user put the record audio button into the lock state then we are still recording audio
binding.inputBar.voiceRecorderState = VoiceRecorderState.Recording
}
else // If the user didn't attempt to lock voice recording on..
{
// Regardless of where the button up event occurred we're now shutting down the recording (whether we send it or not)
binding.inputBar.voiceRecorderState = VoiceRecorderState.ShuttingDownAfterRecord
val rba = binding.inputBarRecordingView?.recordButtonOverlay
if (rba != null) {
val location = IntArray(2) { 0 } val location = IntArray(2) { 0 }
recordButtonOverlay.getLocationOnScreen(location) rba.getLocationOnScreen(location)
val hitRect = Rect(location[0], location[1], location[0] + recordButtonOverlay.width, location[1] + recordButtonOverlay.height) val hitRect = Rect(location[0], location[1], location[0] + rba.width, location[1] + rba.height)
// If the up event occurred over the record button overlay we send the voice message..
if (hitRect.contains(x, y)) { if (hitRect.contains(x, y)) {
sendVoiceMessage() sendVoiceMessage()
} else { } else {
// ..otherwise if they've released off the button we'll cancel sending.
cancelVoiceMessage()
}
}
else
{
// Just to cover all our bases, if for whatever reason the record button overlay was null we'll also cancel recording
cancelVoiceMessage() cancelVoiceMessage()
} }
} }
@ -1452,7 +1508,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
private fun isValidLockViewLocation(x: Int, y: Int): Boolean { 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` // We can be anywhere above the lock view and a bit to the side of it (at most `lockViewHitMargin`
// to the side) // to the side)
val binding = binding ?: return false
val lockViewLocation = IntArray(2) { 0 } val lockViewLocation = IntArray(2) { 0 }
binding.inputBarRecordingView.lockView.getLocationOnScreen(lockViewLocation) binding.inputBarRecordingView.lockView.getLocationOnScreen(lockViewLocation)
val hitRect = Rect(lockViewLocation[0] - lockViewHitMargin, 0, val hitRect = Rect(lockViewLocation[0] - lockViewHitMargin, 0,
@ -1460,10 +1515,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
return hitRect.contains(x, y) return hitRect.contains(x, y)
} }
override fun scrollToMessageIfPossible(timestamp: Long) { override fun scrollToMessageIfPossible(timestamp: Long) {
val lastSeenItemPosition = adapter.getItemPositionForTimestamp(timestamp) ?: return val lastSeenItemPosition = adapter.getItemPositionForTimestamp(timestamp) ?: return
binding?.conversationRecyclerView?.scrollToPosition(lastSeenItemPosition) binding.conversationRecyclerView?.scrollToPosition(lastSeenItemPosition)
} }
override fun onReactionClicked(emoji: String, messageId: MessageId, userWasSender: Boolean) { override fun onReactionClicked(emoji: String, messageId: MessageId, userWasSender: Boolean) {
@ -1494,7 +1548,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
if (!textSecurePreferences.autoplayAudioMessages()) return if (!textSecurePreferences.autoplayAudioMessages()) return
if (indexInAdapter < 0 || indexInAdapter >= adapter.itemCount) { return } if (indexInAdapter < 0 || indexInAdapter >= adapter.itemCount) { return }
val viewHolder = binding?.conversationRecyclerView?.findViewHolderForAdapterPosition(indexInAdapter) as? ConversationAdapter.VisibleMessageViewHolder ?: return val viewHolder = binding.conversationRecyclerView.findViewHolderForAdapterPosition(indexInAdapter) as? ConversationAdapter.VisibleMessageViewHolder ?: return
viewHolder.view.playVoiceMessage() viewHolder.view.playVoiceMessage()
} }
@ -1504,7 +1558,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
BlockedDialog(recipient, this).show(supportFragmentManager, "Blocked Dialog") BlockedDialog(recipient, this).show(supportFragmentManager, "Blocked Dialog")
return return
} }
val binding = binding ?: return
val sentMessageInfo = if (binding.inputBar.linkPreview != null || binding.inputBar.quote != null) { val sentMessageInfo = if (binding.inputBar.linkPreview != null || binding.inputBar.quote != null) {
sendAttachments(listOf(), getMessageBody(), binding.inputBar.quote, binding.inputBar.linkPreview) sendAttachments(listOf(), getMessageBody(), binding.inputBar.quote, binding.inputBar.linkPreview)
} else { } else {
@ -1551,13 +1604,13 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
message.text = text message.text = text
val expiresInMillis = viewModel.expirationConfiguration?.expiryMode?.expiryMillis ?: 0 val expiresInMillis = viewModel.expirationConfiguration?.expiryMode?.expiryMillis ?: 0
val expireStartedAt = if (viewModel.expirationConfiguration?.expiryMode is ExpiryMode.AfterSend) { val expireStartedAt = if (viewModel.expirationConfiguration?.expiryMode is ExpiryMode.AfterSend) {
message.sentTimestamp!! message.sentTimestamp
} else 0 } else 0
val outgoingTextMessage = OutgoingTextMessage.from(message, recipient, expiresInMillis, expireStartedAt) val outgoingTextMessage = OutgoingTextMessage.from(message, recipient, expiresInMillis, expireStartedAt!!)
// Clear the input bar // Clear the input bar
binding?.inputBar?.text = "" binding.inputBar.text = ""
binding?.inputBar?.cancelQuoteDraft() binding.inputBar.cancelQuoteDraft()
binding?.inputBar?.cancelLinkPreviewDraft() binding.inputBar.cancelLinkPreviewDraft()
// Put the message in the database // Put the message in the database
message.id = smsDb.insertMessageOutbox(viewModel.threadId, outgoingTextMessage, false, message.sentTimestamp!!, null, true) message.id = smsDb.insertMessageOutbox(viewModel.threadId, outgoingTextMessage, false, message.sentTimestamp!!, null, true)
// Send it // Send it
@ -1570,7 +1623,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
private fun sendAttachments( private fun sendAttachments(
attachments: List<Attachment>, attachments: List<Attachment>,
body: String?, body: String?,
quotedMessage: MessageRecord? = binding?.inputBar?.quote, quotedMessage: MessageRecord? = binding.inputBar?.quote,
linkPreview: LinkPreview? = null linkPreview: LinkPreview? = null
): Pair<Address, Long>? { ): Pair<Address, Long>? {
val recipient = viewModel.recipient ?: return null val recipient = viewModel.recipient ?: return null
@ -1599,9 +1652,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
} else 0 } else 0
val outgoingTextMessage = OutgoingMediaMessage.from(message, recipient, attachments, localQuote, linkPreview, expiresInMs, expireStartedAtMs) val outgoingTextMessage = OutgoingMediaMessage.from(message, recipient, attachments, localQuote, linkPreview, expiresInMs, expireStartedAtMs)
// Clear the input bar // Clear the input bar
binding?.inputBar?.text = "" binding.inputBar.text = ""
binding?.inputBar?.cancelQuoteDraft() binding.inputBar.cancelQuoteDraft()
binding?.inputBar?.cancelLinkPreviewDraft() binding.inputBar.cancelLinkPreviewDraft()
// Reset the attachment manager // Reset the attachment manager
attachmentManager.clear() attachmentManager.clear()
// Reset attachments button if needed // Reset attachments button if needed
@ -1640,7 +1693,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
private fun pickFromLibrary() { private fun pickFromLibrary() {
val recipient = viewModel.recipient ?: return val recipient = viewModel.recipient ?: return
binding?.inputBar?.text?.trim()?.let { text -> binding.inputBar.text?.trim()?.let { text ->
AttachmentManager.selectGallery(this, PICK_FROM_LIBRARY, recipient, text) AttachmentManager.selectGallery(this, PICK_FROM_LIBRARY, recipient, text)
} }
} }
@ -1733,7 +1786,20 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
if (Permissions.hasAll(this, Manifest.permission.RECORD_AUDIO)) { if (Permissions.hasAll(this, Manifest.permission.RECORD_AUDIO)) {
showVoiceMessageUI() showVoiceMessageUI()
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
audioRecorder.startRecording()
// Allow the caller (us!) to define what should happen when the voice recording finishes.
// Specifically in this instance, if we just tap the record audio button then by the time
// we actually finish setting up and get here the recording has been cancelled and the voice
// recorder state is Idle! As such we'll only tick the recorder state over to Recording if
// we were still in the SettingUpToRecord state when we got here (i.e., the record voice
// message button is still held or is locked to keep recording audio without being held).
val callback: () -> Unit = {
if (binding.inputBar.voiceRecorderState == VoiceRecorderState.SettingUpToRecord) {
binding.inputBar.voiceRecorderState = VoiceRecorderState.Recording
}
}
audioRecorder.startRecording(callback)
stopAudioHandler.postDelayed(stopVoiceMessageRecordingTask, 300000) // Limit voice messages to 5 minute each stopAudioHandler.postDelayed(stopVoiceMessageRecordingTask, 300000) // Limit voice messages to 5 minute each
} else { } else {
Permissions.with(this) Permissions.with(this)
@ -1744,11 +1810,61 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
} }
} }
private fun informUserIfNetworkOrSessionNodePathIsInvalid() {
// Check that we have a valid network network connection & inform the user if not
val connectedToInternet = NetworkUtils.haveValidNetworkConnection(applicationContext)
if (!connectedToInternet)
{
// TODO: Adjust to display error to user with official localised string when SES-2319 is addressed
Log.e(TAG, "Cannot sent voice message - no network connection.")
}
// Check that we have a suite of Session Nodes to route through.
// Note: We can have the entry node plus the 2 Session Nodes and the data _still_ might not
// send due to any node flakiness - but without doing some manner of test-ping through
// there's no way to test our client -> destination connectivity (unless we abuse the typing
// indicators?)
val paths = OnionRequestAPI.paths
if (paths.isNullOrEmpty() || paths.count() != 2) {
// TODO: Adjust to display error to user with official localised string when SES-2319 is addressed
Log.e(TAG, "Cannot send voice message - bad Session Node path.")
}
}
override fun sendVoiceMessage() { override fun sendVoiceMessage() {
// When the record voice message button is released we always need to reset the UI and cancel
// any further recording operation..
hideVoiceMessageUI() hideVoiceMessageUI()
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
val future = audioRecorder.stopRecording() val future = audioRecorder.stopRecording()
stopAudioHandler.removeCallbacks(stopVoiceMessageRecordingTask) stopAudioHandler.removeCallbacks(stopVoiceMessageRecordingTask)
// ..but we'll bail without sending the voice message & inform the user that they need to press and HOLD
// the record voice message button if their message was less than 1 second long.
val inputBar = binding.inputBar
val voiceMessageDurationMS = inputBar.voiceMessageDurationMS
// Now tear-down is complete we can move back into the idle state ready to record another voice message.
// CAREFUL: This state must be set BEFORE we show any warning toast about short messages because it early
// exits before transmitting the audio!
inputBar.voiceRecorderState = VoiceRecorderState.Idle
// Voice message too short? Warn with toast instead of sending.
// Note: The 0L check prevents the warning toast being shown when leaving the conversation activity.
if (voiceMessageDurationMS != 0L && voiceMessageDurationMS < MINIMUM_VOICE_MESSAGE_DURATION_MS) {
Toast.makeText(this@ConversationActivityV2, R.string.messageVoiceErrorShort, Toast.LENGTH_SHORT).show()
inputBar.voiceMessageDurationMS = 0L
return
}
informUserIfNetworkOrSessionNodePathIsInvalid()
// Note: We could return here if there was a network or node path issue, but instead we'll try
// our best to send the voice message even if it might fail - because in that case it'll get put
// into the draft database and can be retried when we regain network connectivity and a working
// node path.
// Attempt to send it the voice message
future.addListener(object : ListenableFuture.Listener<Pair<Uri, Long>> { future.addListener(object : ListenableFuture.Listener<Pair<Uri, Long>> {
override fun onSuccess(result: Pair<Uri, Long>) { override fun onSuccess(result: Pair<Uri, Long>) {
@ -1765,10 +1881,23 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
} }
override fun cancelVoiceMessage() { override fun cancelVoiceMessage() {
val inputBar = binding.inputBar
hideVoiceMessageUI() hideVoiceMessageUI()
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
audioRecorder.stopRecording() audioRecorder.stopRecording()
stopAudioHandler.removeCallbacks(stopVoiceMessageRecordingTask) stopAudioHandler.removeCallbacks(stopVoiceMessageRecordingTask)
// Note: The 0L check prevents the warning toast being shown when leaving the conversation activity
val voiceMessageDuration = inputBar.voiceMessageDurationMS
if (voiceMessageDuration != 0L && voiceMessageDuration < MINIMUM_VOICE_MESSAGE_DURATION_MS) {
Toast.makeText(applicationContext, applicationContext.getString(R.string.messageVoiceErrorShort), Toast.LENGTH_SHORT).show()
inputBar.voiceMessageDurationMS = 0L
}
// When tear-down is complete (via cancelling) we can move back into the idle state ready to record
// another voice message.
inputBar.voiceRecorderState = VoiceRecorderState.Idle
} }
override fun selectMessages(messages: Set<MessageRecord>) { override fun selectMessages(messages: Set<MessageRecord>) {
@ -2002,7 +2131,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
override fun reply(messages: Set<MessageRecord>) { override fun reply(messages: Set<MessageRecord>) {
val recipient = viewModel.recipient ?: return val recipient = viewModel.recipient ?: return
messages.firstOrNull()?.let { binding?.inputBar?.draftQuote(recipient, it, glide) } messages.firstOrNull()?.let { binding.inputBar.draftQuote(recipient, it, glide) }
endActionMode() endActionMode()
} }
@ -2049,28 +2178,28 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
searchViewModel.onMissingResult() } searchViewModel.onMissingResult() }
} }
} }
binding?.searchBottomBar?.setData(result.position, result.getResults().size) binding.searchBottomBar.setData(result.position, result.getResults().size)
}) })
} }
fun onSearchOpened() { fun onSearchOpened() {
searchViewModel.onSearchOpened() searchViewModel.onSearchOpened()
binding?.searchBottomBar?.visibility = View.VISIBLE binding.searchBottomBar.visibility = View.VISIBLE
binding?.searchBottomBar?.setData(0, 0) binding.searchBottomBar.setData(0, 0)
binding?.inputBar?.visibility = View.INVISIBLE binding.inputBar.visibility = View.INVISIBLE
} }
fun onSearchClosed() { fun onSearchClosed() {
searchViewModel.onSearchClosed() searchViewModel.onSearchClosed()
binding?.searchBottomBar?.visibility = View.GONE binding.searchBottomBar.visibility = View.GONE
binding?.inputBar?.visibility = View.VISIBLE binding.inputBar.visibility = View.VISIBLE
adapter.onSearchQueryUpdated(null) adapter.onSearchQueryUpdated(null)
invalidateOptionsMenu() invalidateOptionsMenu()
} }
fun onSearchQueryUpdated(query: String) { fun onSearchQueryUpdated(query: String) {
searchViewModel.onQueryUpdated(query, viewModel.threadId) searchViewModel.onQueryUpdated(query, viewModel.threadId)
binding?.searchBottomBar?.showLoading() binding.searchBottomBar.showLoading()
adapter.onSearchQueryUpdated(query) adapter.onSearchQueryUpdated(query)
} }
@ -2090,7 +2219,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
private fun moveToMessagePosition(position: Int, highlight: Boolean, onMessageNotFound: Runnable?) { private fun moveToMessagePosition(position: Int, highlight: Boolean, onMessageNotFound: Runnable?) {
if (position >= 0) { if (position >= 0) {
binding?.conversationRecyclerView?.scrollToPosition(position) binding.conversationRecyclerView.scrollToPosition(position)
if (highlight) { if (highlight) {
runOnUiThread { runOnUiThread {

View File

@ -1,5 +1,6 @@
package org.thoughtcrime.securesms.conversation.v2.input_bar package org.thoughtcrime.securesms.conversation.v2.input_bar
import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.content.res.Resources import android.content.res.Resources
import android.graphics.PointF import android.graphics.PointF
@ -11,6 +12,7 @@ import android.util.AttributeSet
import android.view.KeyEvent import android.view.KeyEvent
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.MotionEvent import android.view.MotionEvent
import android.view.View
import android.view.inputmethod.EditorInfo import android.view.inputmethod.EditorInfo
import android.widget.RelativeLayout import android.widget.RelativeLayout
import android.widget.TextView import android.widget.TextView
@ -32,6 +34,15 @@ import org.thoughtcrime.securesms.util.contains
import org.thoughtcrime.securesms.util.toDp import org.thoughtcrime.securesms.util.toDp
import org.thoughtcrime.securesms.util.toPx import org.thoughtcrime.securesms.util.toPx
// Enums to keep track of the state of our voice recording mechanism as the user can
// manipulate the UI faster than we can setup & teardown.
enum class VoiceRecorderState {
Idle,
SettingUpToRecord,
Recording,
ShuttingDownAfterRecord
}
class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, LinkPreviewDraftViewDelegate, class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, LinkPreviewDraftViewDelegate,
TextView.OnEditorActionListener { TextView.OnEditorActionListener {
private lateinit var binding: ViewInputBarBinding private lateinit var binding: ViewInputBarBinding
@ -57,6 +68,12 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li
get() { return binding.inputBarEditText.text?.toString() ?: "" } get() { return binding.inputBarEditText.text?.toString() ?: "" }
set(value) { binding.inputBarEditText.setText(value) } set(value) { binding.inputBarEditText.setText(value) }
// Keep track of when the user pressed the record voice message button, the duration that
// they held record, and the current audio recording mechanism state.
private var voiceMessageStartMS = 0L
var voiceMessageDurationMS = 0L
var voiceRecorderState = VoiceRecorderState.Idle
val attachmentButtonsContainerHeight: Int val attachmentButtonsContainerHeight: Int
get() = binding.attachmentsButtonContainer.height get() = binding.attachmentsButtonContainer.height
@ -69,19 +86,63 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize() } constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize() }
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() } constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() }
@SuppressLint("ClickableViewAccessibility")
private fun initialize() { private fun initialize() {
binding = ViewInputBarBinding.inflate(LayoutInflater.from(context), this, true) binding = ViewInputBarBinding.inflate(LayoutInflater.from(context), this, true)
// Attachments button // Attachments button
binding.attachmentsButtonContainer.addView(attachmentsButton) binding.attachmentsButtonContainer.addView(attachmentsButton)
attachmentsButton.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) attachmentsButton.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
attachmentsButton.onPress = { toggleAttachmentOptions() } attachmentsButton.onPress = { toggleAttachmentOptions() }
// Microphone button // Microphone button
binding.microphoneOrSendButtonContainer.addView(microphoneButton) binding.microphoneOrSendButtonContainer.addView(microphoneButton)
microphoneButton.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) microphoneButton.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
microphoneButton.onLongPress = { startRecordingVoiceMessage() }
microphoneButton.onMove = { delegate?.onMicrophoneButtonMove(it) } microphoneButton.onMove = { delegate?.onMicrophoneButtonMove(it) }
microphoneButton.onCancel = { delegate?.onMicrophoneButtonCancel(it) } microphoneButton.onCancel = { delegate?.onMicrophoneButtonCancel(it) }
microphoneButton.onUp = { delegate?.onMicrophoneButtonUp(it) }
// Use a separate 'raw' OnTouchListener to record the microphone button down/up timestamps because
// they don't get delayed by any multi-threading or delegates which throw off the timestamp accuracy.
// For example: If we bind something to `microphoneButton.onPress` and also log something in
// `microphoneButton.onUp` and tap the button then the logged output order is onUp and THEN onPress!
microphoneButton.setOnTouchListener(object : OnTouchListener {
override fun onTouch(v: View, event: MotionEvent): Boolean {
// We only handle single finger touch events so just consume the event and bail if there are more
if (event.pointerCount > 1) return true
when (event.action) {
MotionEvent.ACTION_DOWN -> {
// Only start spinning up the voice recorder if we're not already recording, setting up, or tearing down
if (voiceRecorderState == VoiceRecorderState.Idle) {
// Take note of when we start recording so we can figure out how long the record button was held for
voiceMessageStartMS = System.currentTimeMillis()
// We are now setting up to record, and when we actually start recording then
// AudioRecorder.startRecording will move us into the Recording state.
voiceRecorderState = VoiceRecorderState.SettingUpToRecord
startRecordingVoiceMessage()
}
}
MotionEvent.ACTION_UP -> {
// Work out how long the record audio button was held for
voiceMessageDurationMS = System.currentTimeMillis() - voiceMessageStartMS;
// Regardless of our current recording state we'll always call the onMicrophoneButtonUp method
// and let the logic in that take the appropriate action as we cannot guarantee that letting
// go of the record button should always stop recording audio because the user may have moved
// the button into the 'locked' state so they don't have to keep it held down to record a voice
// message.
// Also: We need to tear down the voice recorder if it has been recording and is now stopping.
delegate?.onMicrophoneButtonUp(event)
}
}
// Return false to propagate the event rather than consuming it
return false
}
})
// Send button // Send button
binding.microphoneOrSendButtonContainer.addView(sendButton) binding.microphoneOrSendButtonContainer.addView(sendButton)
sendButton.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) sendButton.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
@ -91,6 +152,7 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li
delegate?.sendMessage() delegate?.sendMessage()
} }
} }
// Edit text // Edit text
binding.inputBarEditText.setOnEditorActionListener(this) binding.inputBarEditText.setOnEditorActionListener(this)
if (TextSecurePreferences.isEnterSendsEnabled(context)) { if (TextSecurePreferences.isEnterSendsEnabled(context)) {
@ -126,20 +188,13 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li
delegate?.inputBarEditTextContentChanged(text) delegate?.inputBarEditTextContentChanged(text)
} }
override fun inputBarEditTextHeightChanged(newValue: Int) { override fun inputBarEditTextHeightChanged(newValue: Int) { }
}
override fun commitInputContent(contentUri: Uri) { override fun commitInputContent(contentUri: Uri) { delegate?.commitInputContent(contentUri) }
delegate?.commitInputContent(contentUri)
}
private fun toggleAttachmentOptions() { private fun toggleAttachmentOptions() { delegate?.toggleAttachmentOptions() }
delegate?.toggleAttachmentOptions()
}
private fun startRecordingVoiceMessage() { private fun startRecordingVoiceMessage() { delegate?.startRecordingVoiceMessage() }
delegate?.startRecordingVoiceMessage()
}
fun draftQuote(thread: Recipient, message: MessageRecord, glide: GlideRequests) { fun draftQuote(thread: Recipient, message: MessageRecord, glide: GlideRequests) {
quoteView?.let(binding.inputBarAdditionalContentContainer::removeView) quoteView?.let(binding.inputBarAdditionalContentContainer::removeView)
@ -228,6 +283,7 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li
fun setInputBarEditableFactory(factory: Editable.Factory) { fun setInputBarEditableFactory(factory: Editable.Factory) {
binding.inputBarEditText.setEditableFactory(factory) binding.inputBarEditText.setEditableFactory(factory)
} }
// endregion // endregion
} }

View File

@ -25,6 +25,14 @@ import org.thoughtcrime.securesms.util.disableClipping
import org.thoughtcrime.securesms.util.toPx import org.thoughtcrime.securesms.util.toPx
import java.util.Date import java.util.Date
// Constants for animation durations in milliseconds
object VoiceRecorderConstants {
const val ANIMATE_LOCK_DURATION_MS = 250L
const val DOT_ANIMATION_DURATION_MS = 500L
const val DOT_PULSE_ANIMATION_DURATION_MS = 1000L
const val SHOW_HIDE_VOICE_UI_DURATION_MS = 250L
}
class InputBarRecordingView : RelativeLayout { class InputBarRecordingView : RelativeLayout {
private lateinit var binding: ViewInputBarRecordingBinding private lateinit var binding: ViewInputBarRecordingBinding
private var startTimestamp = 0L private var startTimestamp = 0L
@ -79,7 +87,7 @@ class InputBarRecordingView : RelativeLayout {
fun hide() { fun hide() {
alpha = 1.0f alpha = 1.0f
val animation = ValueAnimator.ofObject(FloatEvaluator(), 1.0f, 0.0f) val animation = ValueAnimator.ofObject(FloatEvaluator(), 1.0f, 0.0f)
animation.duration = 250L animation.duration = VoiceRecorderConstants.SHOW_HIDE_VOICE_UI_DURATION_MS
animation.addUpdateListener { animator -> animation.addUpdateListener { animator ->
alpha = animator.animatedValue as Float alpha = animator.animatedValue as Float
if (animator.animatedFraction == 1.0f) { if (animator.animatedFraction == 1.0f) {
@ -113,7 +121,7 @@ class InputBarRecordingView : RelativeLayout {
private fun animateDotView() { private fun animateDotView() {
val animation = ValueAnimator.ofObject(FloatEvaluator(), 1.0f, 0.0f) val animation = ValueAnimator.ofObject(FloatEvaluator(), 1.0f, 0.0f)
dotViewAnimation = animation dotViewAnimation = animation
animation.duration = 500L animation.duration = VoiceRecorderConstants.DOT_ANIMATION_DURATION_MS
animation.addUpdateListener { animator -> animation.addUpdateListener { animator ->
binding.dotView.alpha = animator.animatedValue as Float binding.dotView.alpha = animator.animatedValue as Float
} }
@ -128,7 +136,7 @@ class InputBarRecordingView : RelativeLayout {
binding.pulseView.animateSizeChange(collapsedSize, expandedSize, 1000) binding.pulseView.animateSizeChange(collapsedSize, expandedSize, 1000)
val animation = ValueAnimator.ofObject(FloatEvaluator(), 0.5, 0.0f) val animation = ValueAnimator.ofObject(FloatEvaluator(), 0.5, 0.0f)
pulseAnimation = animation pulseAnimation = animation
animation.duration = 1000L animation.duration = VoiceRecorderConstants.DOT_PULSE_ANIMATION_DURATION_MS
animation.addUpdateListener { animator -> animation.addUpdateListener { animator ->
binding.pulseView.alpha = animator.animatedValue as Float binding.pulseView.alpha = animator.animatedValue as Float
if (animator.animatedFraction == 1.0f && isVisible) { pulse() } if (animator.animatedFraction == 1.0f && isVisible) { pulse() }
@ -143,7 +151,7 @@ class InputBarRecordingView : RelativeLayout {
layoutParams.bottomMargin = startMarginBottom layoutParams.bottomMargin = startMarginBottom
binding.lockView.layoutParams = layoutParams binding.lockView.layoutParams = layoutParams
val animation = ValueAnimator.ofObject(IntEvaluator(), startMarginBottom, endMarginBottom) val animation = ValueAnimator.ofObject(IntEvaluator(), startMarginBottom, endMarginBottom)
animation.duration = 250L animation.duration = VoiceRecorderConstants.ANIMATE_LOCK_DURATION_MS
animation.addUpdateListener { animator -> animation.addUpdateListener { animator ->
layoutParams.bottomMargin = animator.animatedValue as Int layoutParams.bottomMargin = animator.animatedValue as Int
binding.lockView.layoutParams = layoutParams binding.lockView.layoutParams = layoutParams
@ -153,21 +161,25 @@ class InputBarRecordingView : RelativeLayout {
fun lock() { fun lock() {
val fadeOutAnimation = ValueAnimator.ofObject(FloatEvaluator(), 1.0f, 0.0f) val fadeOutAnimation = ValueAnimator.ofObject(FloatEvaluator(), 1.0f, 0.0f)
fadeOutAnimation.duration = 250L fadeOutAnimation.duration = VoiceRecorderConstants.ANIMATE_LOCK_DURATION_MS
fadeOutAnimation.addUpdateListener { animator -> fadeOutAnimation.addUpdateListener { animator ->
binding.inputBarMiddleContentContainer.alpha = animator.animatedValue as Float binding.inputBarMiddleContentContainer.alpha = animator.animatedValue as Float
binding.lockView.alpha = animator.animatedValue as Float binding.lockView.alpha = animator.animatedValue as Float
} }
fadeOutAnimation.start() fadeOutAnimation.start()
val fadeInAnimation = ValueAnimator.ofObject(FloatEvaluator(), 0.0f, 1.0f) val fadeInAnimation = ValueAnimator.ofObject(FloatEvaluator(), 0.0f, 1.0f)
fadeInAnimation.duration = 250L fadeInAnimation.duration = VoiceRecorderConstants.ANIMATE_LOCK_DURATION_MS
fadeInAnimation.addUpdateListener { animator -> fadeInAnimation.addUpdateListener { animator ->
binding.inputBarCancelButton.alpha = animator.animatedValue as Float binding.inputBarCancelButton.alpha = animator.animatedValue as Float
} }
fadeInAnimation.start() fadeInAnimation.start()
binding.recordButtonOverlayImageView.setImageDrawable(ResourcesCompat.getDrawable(resources, R.drawable.ic_arrow_up, context.theme)) binding.recordButtonOverlayImageView.setImageDrawable(ResourcesCompat.getDrawable(resources, R.drawable.ic_arrow_up, context.theme))
binding.recordButtonOverlay.setOnClickListener { delegate?.sendVoiceMessage() }
binding.inputBarCancelButton.setOnClickListener { delegate?.cancelVoiceMessage() } binding.inputBarCancelButton.setOnClickListener { delegate?.cancelVoiceMessage() }
// When the user has locked the voice recorder button on then THIS is where the next click
// is registered to actually send the voice message - it does NOT hit the microphone button
// onTouch listener again.
binding.recordButtonOverlay.setOnClickListener { delegate?.sendVoiceMessage() }
} }
} }

View File

@ -0,0 +1,28 @@
package org.thoughtcrime.securesms.util
import android.content.Context
import android.content.Context.CONNECTIVITY_SERVICE
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
class NetworkUtils {
companion object {
// Method to determine if we have a valid Internet connection or not
fun haveValidNetworkConnection(context: Context) : Boolean {
val cm = context.getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager
// Early exit if we have no active network..
if (cm.activeNetwork == null) return false
// ..otherwise determine what capabilities are available to the active network.
val networkCapabilities = cm.getNetworkCapabilities(cm.activeNetwork)
val internetConnectionValid = cm.activeNetwork != null &&
networkCapabilities != null &&
networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
return internetConnectionValid
}
}
}

View File

@ -7,12 +7,13 @@ import android.content.IntentFilter
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.net.Network import android.net.Network
import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Log
import org.thoughtcrime.securesms.util.NetworkUtils
class NetworkChangeReceiver(private val onNetworkChangedCallback: (Boolean)->Unit) { class NetworkChangeReceiver(private val onNetworkChangedCallback: (Boolean)->Unit) {
private val networkList: MutableSet<Network> = mutableSetOf() private val networkList: MutableSet<Network> = mutableSetOf()
val broadcastDelegate = object: BroadcastReceiver() { private val broadcastDelegate = object: BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
receiveBroadcast(context, intent) receiveBroadcast(context, intent)
} }
@ -41,16 +42,11 @@ class NetworkChangeReceiver(private val onNetworkChangedCallback: (Boolean)->Uni
} }
fun receiveBroadcast(context: Context, intent: Intent) { fun receiveBroadcast(context: Context, intent: Intent) {
val connected = context.isConnected() val connected = NetworkUtils.haveValidNetworkConnection(context)
Log.i("Loki", "received broadcast, network connected: $connected") Log.i("Loki", "received broadcast, network connected: $connected")
onNetworkChangedCallback(connected) onNetworkChangedCallback(connected)
} }
fun Context.isConnected() : Boolean {
val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
return cm.activeNetwork != null
}
fun register(context: Context) { fun register(context: Context) {
val intentFilter = IntentFilter("android.net.conn.CONNECTIVITY_CHANGE") val intentFilter = IntentFilter("android.net.conn.CONNECTIVITY_CHANGE")
context.registerReceiver(broadcastDelegate, intentFilter) context.registerReceiver(broadcastDelegate, intentFilter)

View File

@ -84,7 +84,7 @@
<TextView <TextView
android:id="@+id/inputBarCancelButton" android:id="@+id/inputBarCancelButton"
android:layout_width="100dp" android:layout_width="match_parent"
android:layout_height="40dp" android:layout_height="40dp"
android:layout_centerInParent="true" android:layout_centerInParent="true"
android:alpha="0" android:alpha="0"

View File

@ -74,6 +74,7 @@
<!-- RecipientProvider --> <!-- RecipientProvider -->
<string name="RecipientProvider_unnamed_group">Unnamed group</string> <string name="RecipientProvider_unnamed_group">Unnamed group</string>
<string name="messageVoiceErrorShort">Hold to record a voice message.</string>
<string name="clearDataErrorDescriptionGeneric">An unknown error occurred and your data was not deleted. Do you want to delete your data from just this device instead?</string> <string name="clearDataErrorDescriptionGeneric">An unknown error occurred and your data was not deleted. Do you want to delete your data from just this device instead?</string>
<string name="errorUnknown">An unknown error occurred.</string> <string name="errorUnknown">An unknown error occurred.</string>
<string name="clearDevice">Clear Device</string> <string name="clearDevice">Clear Device</string>