From 1e02845fd2ee506ca41fca978663bbad004c770b Mon Sep 17 00:00:00 2001 From: AL-Session <160798022+AL-Session@users.noreply.github.com> Date: Wed, 3 Jul 2024 14:44:26 +1000 Subject: [PATCH] [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 --- .../securesms/audio/AudioRecorder.java | 37 +- .../conversation/v2/ConversationActivityV2.kt | 453 +++++++++++------- .../conversation/v2/input_bar/InputBar.kt | 82 +++- .../v2/input_bar/InputBarRecordingView.kt | 26 +- .../securesms/util/NetworkUtils.kt | 28 ++ .../securesms/webrtc/NetworkChangeReceiver.kt | 10 +- .../res/layout/view_input_bar_recording.xml | 2 +- libsession/src/main/res/values/strings.xml | 1 + 8 files changed, 431 insertions(+), 208 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/util/NetworkUtils.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/audio/AudioRecorder.java b/app/src/main/java/org/thoughtcrime/securesms/audio/AudioRecorder.java index fd265337f9..7b4bde9c4c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/audio/AudioRecorder.java +++ b/app/src/main/java/org/thoughtcrime/securesms/audio/AudioRecorder.java @@ -1,28 +1,22 @@ package org.thoughtcrime.securesms.audio; -import android.annotation.TargetApi; + import android.content.Context; import android.net.Uri; -import android.os.Build; 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 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.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 { private static final String TAG = AudioRecorder.class.getSimpleName(); @@ -34,11 +28,16 @@ public class AudioRecorder { private AudioCodec audioCodec; 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) { this.context = context; } - public void startRecording() { + public void startRecording(AudioMessageRecordingFinishedCallback callback) { Log.i(TAG, "startRecording()"); executor.execute(() -> { @@ -55,9 +54,11 @@ public class AudioRecorder { .forData(new ParcelFileDescriptor.AutoCloseInputStream(fds[0]), 0) .withMimeType(MediaTypes.AUDIO_AAC) .createForSingleSessionOnDisk(context, e -> Log.w(TAG, "Error during recording", e)); - audioCodec = new AudioCodec(); + audioCodec = new AudioCodec(); audioCodec.start(new ParcelFileDescriptor.AutoCloseOutputStream(fds[1])); + + callback.onAudioMessageRecordingFinished(); } catch (IOException e) { Log.w(TAG, e); } 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 e7f413b6fc..5e2e886085 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 @@ -66,8 +66,6 @@ import network.loki.messenger.libsession_util.util.ExpiryMode import nl.komponents.kovenant.ui.successUi import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.contacts.Contact -import org.session.libsession.messaging.jobs.AttachmentDownloadJob -import org.session.libsession.messaging.jobs.JobQueue import org.session.libsession.messaging.messages.ExpirationConfiguration import org.session.libsession.messaging.messages.applyExpiryMode 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.quotes.QuoteModel import org.session.libsession.messaging.utilities.SessionId +import org.session.libsession.snode.OnionRequestAPI import org.session.libsession.snode.SnodeAPI import org.session.libsession.utilities.Address 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.InputBarDelegate 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.mention.MentionViewModel 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.DateUtils import org.thoughtcrime.securesms.util.MediaUtil +import org.thoughtcrime.securesms.util.NetworkUtils import org.thoughtcrime.securesms.util.SaveAttachmentTask import org.thoughtcrime.securesms.util.SimpleTextWatcher 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.show import org.thoughtcrime.securesms.util.toPx +import org.thoughtcrime.securesms.webrtc.NetworkChangeReceiver import java.lang.ref.WeakReference import java.util.Locale import java.util.concurrent.ExecutionException @@ -201,7 +205,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe OnReactionSelectedListener, ReactWithAnyEmojiDialogFragment.Callback, ReactionsDialogFragment.Callback, ConversationMenuHelper.ConversationMenuListener { - private var binding: ActivityConversationV2Binding? = null + private lateinit var binding: ActivityConversationV2Binding @Inject lateinit var textSecurePreferences: TextSecurePreferences @Inject lateinit var threadDb: ThreadDatabase @@ -281,13 +285,13 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe private var emojiPickerVisible = false private val isScrolledToBottom: Boolean - get() = binding?.conversationRecyclerView?.isScrolledToBottom ?: true + get() = binding.conversationRecyclerView.isScrolledToBottom private val isScrolledToWithin30dpOfBottom: Boolean - get() = binding?.conversationRecyclerView?.isScrolledToWithin30dpOfBottom ?: true + get() = binding.conversationRecyclerView.isScrolledToWithin30dpOfBottom private val layoutManager: LinearLayoutManager? - get() { return binding?.conversationRecyclerView?.layoutManager as LinearLayoutManager? } + get() { return binding.conversationRecyclerView.layoutManager as LinearLayoutManager? } private val seed by lazy { var hexEncodedSeed = IdentityKeyUtil.retrieve(this, IdentityKeyUtil.LOKI_SEED) @@ -299,7 +303,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe val loadFileContents: (String) -> String = { 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 @@ -341,7 +345,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe // 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. - adapter.registerAdapterDataObserver(ConversationAdapterDataObserver(binding?.conversationRecyclerView!!, adapter)) + adapter.registerAdapterDataObserver(ConversationAdapterDataObserver(binding.conversationRecyclerView, adapter)) adapter } @@ -364,6 +368,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe private var currentLastVisibleRecyclerViewIndex: Int = RecyclerView.NO_POSITION 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 companion object { // Extras @@ -385,7 +392,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) { super.onCreate(savedInstanceState, isReady) binding = ActivityConversationV2Binding.inflate(layoutInflater) - setContentView(binding!!.root) + setContentView(binding.root) // messageIdToScroll messageToScrollTimestamp.set(intent.getLongExtra(SCROLL_MESSAGE_ID, -1)) @@ -403,12 +410,12 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe restoreDraftIfNeeded() setUpUiStateObserver() - binding!!.scrollToBottomButton.setOnClickListener { - val layoutManager = (binding?.conversationRecyclerView?.layoutManager as? LinearLayoutManager) ?: return@setOnClickListener + binding.scrollToBottomButton.setOnClickListener { + val layoutManager = binding.conversationRecyclerView.layoutManager as LinearLayoutManager val targetPosition = if (reverseMessageList) 0 else adapter.itemCount if (layoutManager.isSmoothScrolling) { - binding?.conversationRecyclerView?.scrollToPosition(targetPosition) + binding.conversationRecyclerView.scrollToPosition(targetPosition) } else { // It looks like 'smoothScrollToPosition' will actually load all intermediate items in // order to do the scroll, this can be very slow if there are a lot of messages so @@ -418,11 +425,11 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe // val position = if (reverseMessageList) layoutManager.findFirstVisibleItemPosition() else layoutManager.findLastVisibleItemPosition() // val targetBuffer = if (reverseMessageList) 10 else Math.max(0, (adapter.itemCount - 1) - 10) // if (position > targetBuffer) { -// binding?.conversationRecyclerView?.scrollToPosition(targetBuffer) +// binding.conversationRecyclerView?.scrollToPosition(targetBuffer) // } - binding?.conversationRecyclerView?.post { - binding?.conversationRecyclerView?.smoothScrollToPosition(targetPosition) + binding.conversationRecyclerView.post { + binding.conversationRecyclerView.smoothScrollToPosition(targetPosition) } } } @@ -430,7 +437,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe updateUnreadCountIndicator() updatePlaceholder() setUpBlockedBanner() - binding!!.searchBottomBar.setEventListener(this) + binding.searchBottomBar.setEventListener(this) updateSendAfterApprovalText() setUpMessageRequestsBar() @@ -461,7 +468,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe setUpOutdatedClientBanner() if (author != null && messageTimestamp >= 0 && targetPosition >= 0) { - binding?.conversationRecyclerView?.scrollToPosition(targetPosition) + binding.conversationRecyclerView.scrollToPosition(targetPosition) } else { scrollToFirstUnreadMessageIfNeeded(true) @@ -523,7 +530,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe screenshotObserver ) 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 private fun setUpRecyclerView() { - binding!!.conversationRecyclerView.adapter = adapter + binding.conversationRecyclerView.adapter = adapter 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) 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) { // 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 // lost visibility of one or more messages due to showing the IME keyboard) AND we're // 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.. 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 // scroll/smoothScroll - we have to `post` it or nothing happens! if (atBottomAndTrueLastNoLongerVisible || atLastOrReceivedNewMessage) { - binding?.conversationRecyclerView?.post { - binding?.conversationRecyclerView?.smoothScrollToPosition(adapter.itemCount) + binding.conversationRecyclerView.post { + binding.conversationRecyclerView.smoothScrollToPosition(adapter.itemCount) } } @@ -643,14 +651,13 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe // called from onCreate private fun setUpToolBar() { - val binding = binding ?: return setSupportActionBar(binding.toolbar) val actionBar = supportActionBar ?: return val recipient = viewModel.recipient ?: return actionBar.title = "" actionBar.setDisplayHomeAsUpEnabled(true) actionBar.setHomeButtonEnabled(true) - binding!!.toolbarContent.bind( + binding.toolbarContent.bind( this, viewModel.threadId, recipient, @@ -662,7 +669,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe // called from onCreate private fun setUpInputBar() { - val binding = binding ?: return binding.inputBar.isGone = viewModel.hidesInputBar() binding.inputBar.delegate = this binding.inputBarRecordingView.delegate = this @@ -708,10 +714,10 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } } else if (intent.hasExtra(Intent.EXTRA_TEXT)) { val dataTextExtra = intent.getCharSequenceExtra(Intent.EXTRA_TEXT) ?: "" - binding!!.inputBar.text = dataTextExtra.toString() + binding.inputBar.text = dataTextExtra.toString() } else { 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() // FIXME: Also checking isScrolledToBottom is a quick fix for an issue where the // typing indicator overlays the recycler view when scrolled up - val viewContainer = binding?.typingIndicatorViewContainer ?: return@observe + val viewContainer = binding.typingIndicatorViewContainer viewContainer.isVisible = recipients.isNotEmpty() && isScrolledToBottom viewContainer.setTypists(recipients) } if (textSecurePreferences.isTypingIndicatorsEnabled()) { - binding!!.inputBar.addTextChangedListener(object : SimpleTextWatcher() { + binding.inputBar.addTextChangedListener(object : SimpleTextWatcher() { override fun onTextChanged(text: String?) { ApplicationContext.getInstance(this@ConversationActivityV2).typingStatusSender.onTypingStarted(viewModel.threadId) @@ -747,7 +753,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe private fun getLatestOpenGroupInfoIfNeeded() { val openGroup = viewModel.openGroup ?: return 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!!) } } @@ -757,9 +763,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe val recipient = viewModel.recipient?.takeUnless { it.isGroupRecipient } ?: return val sessionID = recipient.address.toString() val name = sessionContactDb.getContactWithSessionID(sessionID)?.displayName(Contact.ContactContext.REGULAR) ?: sessionID - binding?.blockedBannerTextView?.text = resources.getString(R.string.activity_conversation_blocked_banner_text, name) - binding?.blockedBanner?.isVisible = recipient.isBlocked - binding?.blockedBanner?.setOnClickListener { viewModel.unblock() } + binding.blockedBannerTextView.text = resources.getString(R.string.activity_conversation_blocked_banner_text, name) + binding.blockedBanner.isVisible = recipient.isBlocked + binding.blockedBanner.setOnClickListener { viewModel.unblock() } } private fun setUpOutdatedClientBanner() { @@ -768,9 +774,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe val shouldShowLegacy = ExpirationConfiguration.isNewConfigEnabled && legacyRecipient != null - binding?.outdatedBanner?.isVisible = shouldShowLegacy + binding.outdatedBanner.isVisible = shouldShowLegacy if (shouldShowLegacy) { - binding?.outdatedBannerTextView?.text = + binding.outdatedBannerTextView.text = 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 when { previewState.isLoading -> { - binding?.inputBar?.draftLinkPreview() + binding.inputBar.draftLinkPreview() } previewState.linkPreview.isPresent -> { - binding?.inputBar?.updateLinkPreviewDraft(glide, previewState.linkPreview.get()) + binding.inputBar.updateLinkPreviewDraft(glide, previewState.linkPreview.get()) } else -> { - binding?.inputBar?.cancelLinkPreviewDraft() + binding.inputBar.cancelLinkPreviewDraft() } } } @@ -803,7 +809,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe viewModel.messageShown(it.id) } if (uiState.isMessageRequestAccepted == true) { - binding?.messageRequestBar?.visibility = View.GONE + binding.messageRequestBar.visibility = View.GONE } if (!uiState.conversationExists && !isFinishing) { // Conversation should be deleted now, just go back @@ -827,12 +833,12 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe if (lastSeenItemPosition <= 3) { return lastSeenItemPosition } - binding?.conversationRecyclerView?.scrollToPosition(lastSeenItemPosition) + binding.conversationRecyclerView.scrollToPosition(lastSeenItemPosition) return lastSeenItemPosition } private fun highlightViewAtPosition(position: Int) { - binding?.conversationRecyclerView?.post { + binding.conversationRecyclerView.post { (layoutManager?.findViewByPosition(position) as? VisibleMessageView)?.playHighlight() } } @@ -852,11 +858,10 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } override fun onDestroy() { - viewModel.saveDraft(binding?.inputBar?.text?.trim() ?: "") + viewModel.saveDraft(binding.inputBar.text.trim()) cancelVoiceMessage() tearDownRecipientObserver() super.onDestroy() - binding = null } // endregion @@ -867,7 +872,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe runOnUiThread { val threadRecipient = viewModel.recipient ?: return@runOnUiThread if (threadRecipient.isContactRecipient) { - binding?.blockedBanner?.isVisible = threadRecipient.isBlocked + binding.blockedBanner.isVisible = threadRecipient.isBlocked } setUpMessageRequestsBar() invalidateOptionsMenu() @@ -879,29 +884,29 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } private fun maybeUpdateToolbar(recipient: Recipient) { - binding?.toolbarContent?.update(recipient, viewModel.openGroup, viewModel.expirationConfiguration) + binding.toolbarContent.update(recipient, viewModel.openGroup, viewModel.expirationConfiguration) } private fun updateSendAfterApprovalText() { - binding?.textSendAfterApproval?.isVisible = viewModel.showSendAfterApprovalText + binding.textSendAfterApproval.isVisible = viewModel.showSendAfterApprovalText } 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 } ?: true } private fun setUpMessageRequestsBar() { - binding?.inputBar?.showMediaControls = !isOutgoingMessageRequestThread() - binding?.messageRequestBar?.isVisible = isIncomingMessageRequestThread() - binding?.acceptMessageRequestButton?.setOnClickListener { + binding.inputBar.showMediaControls = !isOutgoingMessageRequestThread() + binding.messageRequestBar.isVisible = isIncomingMessageRequestThread() + binding.acceptMessageRequestButton.setOnClickListener { acceptMessageRequest() } - binding?.messageRequestBlock?.setOnClickListener { + binding.messageRequestBlock.setOnClickListener { block(deleteThread = true) } - binding?.declineMessageRequestButton?.setOnClickListener { + binding.declineMessageRequestButton.setOnClickListener { viewModel.declineMessageRequest() lifecycleScope.launch(Dispatchers.IO) { ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@ConversationActivityV2) @@ -911,7 +916,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } private fun acceptMessageRequest() { - binding?.messageRequestBar?.isVisible = false + binding.messageRequestBar.isVisible = false viewModel.acceptMessageRequest() lifecycleScope.launch(Dispatchers.IO) { @@ -930,7 +935,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } ?: false 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()) { linkPreviewViewModel.onTextChanged(this, inputBarText, 0, 0) } @@ -948,10 +953,10 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe override fun toggleAttachmentOptions() { val targetAlpha = if (isShowingAttachmentOptions) 0.0f else 1.0f val allButtonContainers = listOfNotNull( - binding?.cameraButtonContainer, - binding?.libraryButtonContainer, - binding?.documentButtonContainer, - binding?.gifButtonContainer + binding.cameraButtonContainer, + binding.libraryButtonContainer, + binding.documentButtonContainer, + binding.gifButtonContainer ) val isReversed = isShowingAttachmentOptions // Run the animation in reverse val count = allButtonContainers.size @@ -971,20 +976,20 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } override fun showVoiceMessageUI() { - binding?.inputBarRecordingView?.show(lifecycleScope) - binding?.inputBar?.alpha = 0.0f + binding.inputBarRecordingView.show(lifecycleScope) + binding.inputBar.alpha = 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 -> - binding?.inputBar?.alpha = animator.animatedValue as Float + binding.inputBar.alpha = animator.animatedValue as Float } animation.start() } private fun expandVoiceMessageLockView() { - val lockView = binding?.inputBarRecordingView?.lockView ?: return + val lockView = binding.inputBarRecordingView.lockView val animation = ValueAnimator.ofObject(FloatEvaluator(), lockView.scaleX, 1.10f) - animation.duration = 250L + animation.duration = ANIMATE_LOCK_DURATION_MS animation.addUpdateListener { animator -> lockView.scaleX = animator.animatedValue as Float lockView.scaleY = animator.animatedValue as Float @@ -993,9 +998,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } private fun collapseVoiceMessageLockView() { - val lockView = binding?.inputBarRecordingView?.lockView ?: return + val lockView = binding.inputBarRecordingView.lockView val animation = ValueAnimator.ofObject(FloatEvaluator(), lockView.scaleX, 1.0f) - animation.duration = 250L + animation.duration = ANIMATE_LOCK_DURATION_MS animation.addUpdateListener { animator -> lockView.scaleX = animator.animatedValue as Float lockView.scaleY = animator.animatedValue as Float @@ -1004,24 +1009,24 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } private fun hideVoiceMessageUI() { - val chevronImageView = binding?.inputBarRecordingView?.chevronImageView ?: return - val slideToCancelTextView = binding?.inputBarRecordingView?.slideToCancelTextView ?: return + val chevronImageView = binding.inputBarRecordingView.chevronImageView + val slideToCancelTextView = binding.inputBarRecordingView.slideToCancelTextView listOf( chevronImageView, slideToCancelTextView ).forEach { view -> val animation = ValueAnimator.ofObject(FloatEvaluator(), view.translationX, 0.0f) - animation.duration = 250L + animation.duration = ANIMATE_LOCK_DURATION_MS animation.addUpdateListener { animator -> view.translationX = animator.animatedValue as Float } animation.start() } - binding?.inputBarRecordingView?.hide() + binding.inputBarRecordingView.hide() } override fun handleVoiceMessageUIHidden() { - val inputBar = binding?.inputBar ?: return + val inputBar = binding.inputBar inputBar.alpha = 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 -> inputBar.alpha = animator.animatedValue as Float } @@ -1029,8 +1034,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } private fun handleRecyclerViewScrolled() { - val binding = binding ?: return - // Note: The typing indicate is whether the other person / other people are typing - it has // nothing to do with the IME keyboard state. val wasTypingIndicatorVisibleBefore = binding.typingIndicatorViewContainer.isVisible @@ -1058,10 +1061,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } private fun updatePlaceholder() { - val recipient = viewModel.recipient - ?: return Log.w("Loki", "recipient was null in placeholder update") + val recipient = viewModel.recipient ?: return Log.w("Loki", "recipient was null in placeholder update") val blindedRecipient = viewModel.blindedRecipient - val binding = binding ?: return val openGroup = viewModel.openGroup val (textResource, insertParam) = when { @@ -1091,11 +1092,10 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } private fun showScrollToBottomButtonIfApplicable() { - binding?.scrollToBottomButton?.isVisible = !emojiPickerVisible && !isScrolledToBottom && adapter.itemCount > 0 + binding.scrollToBottomButton.isVisible = !emojiPickerVisible && !isScrolledToBottom && adapter.itemCount > 0 } private fun updateUnreadCountIndicator() { - val binding = binding ?: return val formattedUnreadCount = if (unreadCount < 10000) unreadCount.toString() else "9999+" binding.unreadCountTextView.text = formattedUnreadCount val textSize = if (unreadCount < 10000) 12.0f else 9.0f @@ -1145,7 +1145,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe override fun copyOpenGroupUrl(thread: Recipient) { if (!thread.isCommunityRecipient) { return } - val threadId = threadDb.getThreadIdIfExistsFor(thread) ?: return + val threadId = threadDb.getThreadIdIfExistsFor(thread) val openGroup = lokiThreadDb.getOpenGroupChat(threadId) ?: return val clip = ClipData.newPlainText("Community URL", openGroup.joinURL) @@ -1204,7 +1204,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe private fun handleSwipeToReply(message: MessageRecord) { if (message.isOpenGroupInvitation) 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 @@ -1233,9 +1233,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe Log.e("Loki", "Failed to show emoji picker", e) return } - - val binding = binding ?: return - emojiPickerVisible = true ViewUtil.hideKeyboard(this, visibleMessageView) binding.reactionsShade.isVisible = true @@ -1288,36 +1285,48 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe private fun sendEmojiReaction(emoji: String, originalMessage: MessageRecord) { // 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 emojiTimestamp = SnodeAPI.nowWithOffset reactionMessage.sentTimestamp = emojiTimestamp - val author = textSecurePreferences.getLocalNumber()!! - // Put the message in the database - val reaction = ReactionRecord( - messageId = originalMessage.id, - isMms = originalMessage.isMms, - author = author, - emoji = emoji, - count = 1, - dateSent = emojiTimestamp, - dateReceived = emojiTimestamp - ) - reactionDb.addReaction(MessageId(originalMessage.id, originalMessage.isMms), reaction, false) - val originalAuthor = if (originalMessage.isOutgoing) { - fromSerialized(viewModel.blindedPublicKey ?: textSecurePreferences.getLocalNumber()!!) - } else originalMessage.individualRecipient.address - // Send it - reactionMessage.reaction = Reaction.from(originalMessage.timestamp, originalAuthor.serialize(), emoji, true) - if (recipient.isCommunityRecipient) { - val messageServerId = lokiMessageDb.getServerID(originalMessage.id, !originalMessage.isMms) ?: return - viewModel.openGroup?.let { - OpenGroupApi.addReaction(it.room, it.server, messageServerId, emoji) - } + val author = textSecurePreferences.getLocalNumber() + + if (author == null) { + Log.w(TAG, "Unable to locate local number when sending emoji reaction - aborting.") + return } else { - MessageSender.send(reactionMessage, recipient.address) + // Put the message in the database + val reaction = ReactionRecord( + messageId = originalMessage.id, + isMms = originalMessage.isMms, + author = author, + emoji = emoji, + count = 1, + dateSent = emojiTimestamp, + dateReceived = emojiTimestamp + ) + reactionDb.addReaction(MessageId(originalMessage.id, originalMessage.isMms), reaction, false) + + val originalAuthor = if (originalMessage.isOutgoing) { + fromSerialized(viewModel.blindedPublicKey ?: textSecurePreferences.getLocalNumber()!!) + } else originalMessage.individualRecipient.address + + // Send it + reactionMessage.reaction = Reaction.from(originalMessage.timestamp, originalAuthor.serialize(), emoji, true) + if (recipient.isCommunityRecipient) { + + 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 { + OpenGroupApi.addReaction(it.room, it.server, messageServerId, emoji) + } + } else { + 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) { @@ -1325,23 +1334,32 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe val message = VisibleMessage() val emojiTimestamp = SnodeAPI.nowWithOffset message.sentTimestamp = emojiTimestamp - val author = textSecurePreferences.getLocalNumber()!! - reactionDb.deleteReaction(emoji, MessageId(originalMessage.id, originalMessage.isMms), author, false) + val author = textSecurePreferences.getLocalNumber() - val originalAuthor = if (originalMessage.isOutgoing) { - fromSerialized(viewModel.blindedPublicKey ?: textSecurePreferences.getLocalNumber()!!) - } else originalMessage.individualRecipient.address - - message.reaction = Reaction.from(originalMessage.timestamp, originalAuthor.serialize(), emoji, false) - if (recipient.isCommunityRecipient) { - val messageServerId = lokiMessageDb.getServerID(originalMessage.id, !originalMessage.isMms) ?: return - viewModel.openGroup?.let { - OpenGroupApi.deleteReaction(it.room, it.server, messageServerId, emoji) - } + if (author == null) { + Log.w(TAG, "Unable to locate local number when removing emoji reaction - aborting.") + return } else { - MessageSender.send(message, recipient.address) + reactionDb.deleteReaction(emoji, MessageId(originalMessage.id, originalMessage.isMms), author, false) + + val originalAuthor = if (originalMessage.isOutgoing) { + fromSerialized(viewModel.blindedPublicKey ?: textSecurePreferences.getLocalNumber()!!) + } else originalMessage.individualRecipient.address + + message.reaction = Reaction.from(originalMessage.timestamp, originalAuthor.serialize(), emoji, false) + if (recipient.isCommunityRecipient) { + + 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 { + OpenGroupApi.deleteReaction(it.room, it.server, messageServerId, emoji) + } + } else { + MessageSender.send(message, recipient.address) + } + LoaderManager.getInstance(this).restartLoader(0, null, this) } - LoaderManager.getInstance(this).restartLoader(0, null, this) } override fun onCustomReactionSelected(messageRecord: MessageRecord, hasAddedCustomEmoji: Boolean) { @@ -1399,8 +1417,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe override fun onMicrophoneButtonMove(event: MotionEvent) { val rawX = event.rawX - val chevronImageView = binding?.inputBarRecordingView?.chevronImageView ?: return - val slideToCancelTextView = binding?.inputBarRecordingView?.slideToCancelTextView ?: return + val chevronImageView = binding.inputBarRecordingView.chevronImageView + val slideToCancelTextView = binding.inputBarRecordingView.slideToCancelTextView if (rawX < screenWidth / 2) { val translationX = rawX - screenWidth / 2 val sign = -1.0f @@ -1434,16 +1452,54 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe override fun onMicrophoneButtonUp(event: MotionEvent) { val x = event.rawX.roundToInt() val y = event.rawY.roundToInt() - if (isValidLockViewLocation(x, y)) { - binding?.inputBarRecordingView?.lock() - } else { - val recordButtonOverlay = binding?.inputBarRecordingView?.recordButtonOverlay ?: return - val location = IntArray(2) { 0 } - recordButtonOverlay.getLocationOnScreen(location) - val hitRect = Rect(location[0], location[1], location[0] + recordButtonOverlay.width, location[1] + recordButtonOverlay.height) - if (hitRect.contains(x, y)) { - sendVoiceMessage() - } else { + val inputBar = binding.inputBar + + // Lock voice recording on if the button is released over the lock area AND the + // 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 } + rba.getLocationOnScreen(location) + 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)) { + sendVoiceMessage() + } 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() } } @@ -1452,7 +1508,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe private fun isValidLockViewLocation(x: Int, y: Int): Boolean { // We can be anywhere above the lock view and a bit to the side of it (at most `lockViewHitMargin` // to the side) - val binding = binding ?: return false val lockViewLocation = IntArray(2) { 0 } binding.inputBarRecordingView.lockView.getLocationOnScreen(lockViewLocation) val hitRect = Rect(lockViewLocation[0] - lockViewHitMargin, 0, @@ -1460,10 +1515,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe return hitRect.contains(x, y) } - override fun scrollToMessageIfPossible(timestamp: Long) { val lastSeenItemPosition = adapter.getItemPositionForTimestamp(timestamp) ?: return - binding?.conversationRecyclerView?.scrollToPosition(lastSeenItemPosition) + binding.conversationRecyclerView?.scrollToPosition(lastSeenItemPosition) } override fun onReactionClicked(emoji: String, messageId: MessageId, userWasSender: Boolean) { @@ -1494,7 +1548,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe if (!textSecurePreferences.autoplayAudioMessages()) 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() } @@ -1504,7 +1558,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe BlockedDialog(recipient, this).show(supportFragmentManager, "Blocked Dialog") return } - val binding = binding ?: return val sentMessageInfo = if (binding.inputBar.linkPreview != null || binding.inputBar.quote != null) { sendAttachments(listOf(), getMessageBody(), binding.inputBar.quote, binding.inputBar.linkPreview) } else { @@ -1551,13 +1604,13 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe message.text = text val expiresInMillis = viewModel.expirationConfiguration?.expiryMode?.expiryMillis ?: 0 val expireStartedAt = if (viewModel.expirationConfiguration?.expiryMode is ExpiryMode.AfterSend) { - message.sentTimestamp!! + message.sentTimestamp } else 0 - val outgoingTextMessage = OutgoingTextMessage.from(message, recipient, expiresInMillis, expireStartedAt) + val outgoingTextMessage = OutgoingTextMessage.from(message, recipient, expiresInMillis, expireStartedAt!!) // Clear the input bar - binding?.inputBar?.text = "" - binding?.inputBar?.cancelQuoteDraft() - binding?.inputBar?.cancelLinkPreviewDraft() + binding.inputBar.text = "" + binding.inputBar.cancelQuoteDraft() + binding.inputBar.cancelLinkPreviewDraft() // Put the message in the database message.id = smsDb.insertMessageOutbox(viewModel.threadId, outgoingTextMessage, false, message.sentTimestamp!!, null, true) // Send it @@ -1570,7 +1623,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe private fun sendAttachments( attachments: List, body: String?, - quotedMessage: MessageRecord? = binding?.inputBar?.quote, + quotedMessage: MessageRecord? = binding.inputBar?.quote, linkPreview: LinkPreview? = null ): Pair? { val recipient = viewModel.recipient ?: return null @@ -1599,9 +1652,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } else 0 val outgoingTextMessage = OutgoingMediaMessage.from(message, recipient, attachments, localQuote, linkPreview, expiresInMs, expireStartedAtMs) // Clear the input bar - binding?.inputBar?.text = "" - binding?.inputBar?.cancelQuoteDraft() - binding?.inputBar?.cancelLinkPreviewDraft() + binding.inputBar.text = "" + binding.inputBar.cancelQuoteDraft() + binding.inputBar.cancelLinkPreviewDraft() // Reset the attachment manager attachmentManager.clear() // Reset attachments button if needed @@ -1640,7 +1693,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe private fun pickFromLibrary() { 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) } } @@ -1733,7 +1786,20 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe if (Permissions.hasAll(this, Manifest.permission.RECORD_AUDIO)) { showVoiceMessageUI() 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 } else { 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() { + // When the record voice message button is released we always need to reset the UI and cancel + // any further recording operation.. hideVoiceMessageUI() window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) val future = audioRecorder.stopRecording() 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> { override fun onSuccess(result: Pair) { @@ -1765,10 +1881,23 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } override fun cancelVoiceMessage() { + val inputBar = binding.inputBar + hideVoiceMessageUI() window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) audioRecorder.stopRecording() 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) { @@ -2002,7 +2131,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe override fun reply(messages: Set) { val recipient = viewModel.recipient ?: return - messages.firstOrNull()?.let { binding?.inputBar?.draftQuote(recipient, it, glide) } + messages.firstOrNull()?.let { binding.inputBar.draftQuote(recipient, it, glide) } endActionMode() } @@ -2049,28 +2178,28 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe searchViewModel.onMissingResult() } } } - binding?.searchBottomBar?.setData(result.position, result.getResults().size) + binding.searchBottomBar.setData(result.position, result.getResults().size) }) } fun onSearchOpened() { searchViewModel.onSearchOpened() - binding?.searchBottomBar?.visibility = View.VISIBLE - binding?.searchBottomBar?.setData(0, 0) - binding?.inputBar?.visibility = View.INVISIBLE + binding.searchBottomBar.visibility = View.VISIBLE + binding.searchBottomBar.setData(0, 0) + binding.inputBar.visibility = View.INVISIBLE } fun onSearchClosed() { searchViewModel.onSearchClosed() - binding?.searchBottomBar?.visibility = View.GONE - binding?.inputBar?.visibility = View.VISIBLE + binding.searchBottomBar.visibility = View.GONE + binding.inputBar.visibility = View.VISIBLE adapter.onSearchQueryUpdated(null) invalidateOptionsMenu() } fun onSearchQueryUpdated(query: String) { searchViewModel.onQueryUpdated(query, viewModel.threadId) - binding?.searchBottomBar?.showLoading() + binding.searchBottomBar.showLoading() adapter.onSearchQueryUpdated(query) } @@ -2090,7 +2219,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe private fun moveToMessagePosition(position: Int, highlight: Boolean, onMessageNotFound: Runnable?) { if (position >= 0) { - binding?.conversationRecyclerView?.scrollToPosition(position) + binding.conversationRecyclerView.scrollToPosition(position) if (highlight) { runOnUiThread { 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 2304149367..b3de59f9ee 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 @@ -1,5 +1,6 @@ package org.thoughtcrime.securesms.conversation.v2.input_bar +import android.annotation.SuppressLint import android.content.Context import android.content.res.Resources import android.graphics.PointF @@ -11,6 +12,7 @@ import android.util.AttributeSet import android.view.KeyEvent import android.view.LayoutInflater import android.view.MotionEvent +import android.view.View import android.view.inputmethod.EditorInfo import android.widget.RelativeLayout 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.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, TextView.OnEditorActionListener { private lateinit var binding: ViewInputBarBinding @@ -57,6 +68,12 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li get() { return binding.inputBarEditText.text?.toString() ?: "" } 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 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, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() } + @SuppressLint("ClickableViewAccessibility") private fun initialize() { binding = ViewInputBarBinding.inflate(LayoutInflater.from(context), this, true) // Attachments button binding.attachmentsButtonContainer.addView(attachmentsButton) attachmentsButton.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) attachmentsButton.onPress = { toggleAttachmentOptions() } + // Microphone button binding.microphoneOrSendButtonContainer.addView(microphoneButton) microphoneButton.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) - microphoneButton.onLongPress = { startRecordingVoiceMessage() } + microphoneButton.onMove = { delegate?.onMicrophoneButtonMove(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 binding.microphoneOrSendButtonContainer.addView(sendButton) sendButton.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) @@ -91,6 +152,7 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li delegate?.sendMessage() } } + // Edit text binding.inputBarEditText.setOnEditorActionListener(this) if (TextSecurePreferences.isEnterSendsEnabled(context)) { @@ -126,20 +188,13 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li delegate?.inputBarEditTextContentChanged(text) } - override fun inputBarEditTextHeightChanged(newValue: Int) { - } + override fun inputBarEditTextHeightChanged(newValue: Int) { } - override fun commitInputContent(contentUri: Uri) { - delegate?.commitInputContent(contentUri) - } + override fun commitInputContent(contentUri: Uri) { delegate?.commitInputContent(contentUri) } - private fun toggleAttachmentOptions() { - delegate?.toggleAttachmentOptions() - } + private fun toggleAttachmentOptions() { delegate?.toggleAttachmentOptions() } - private fun startRecordingVoiceMessage() { - delegate?.startRecordingVoiceMessage() - } + private fun startRecordingVoiceMessage() { delegate?.startRecordingVoiceMessage() } fun draftQuote(thread: Recipient, message: MessageRecord, glide: GlideRequests) { quoteView?.let(binding.inputBarAdditionalContentContainer::removeView) @@ -228,6 +283,7 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li fun setInputBarEditableFactory(factory: Editable.Factory) { binding.inputBarEditText.setEditableFactory(factory) } + // endregion } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarRecordingView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarRecordingView.kt index 6d7281dc47..24b48ecdf7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarRecordingView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarRecordingView.kt @@ -25,6 +25,14 @@ import org.thoughtcrime.securesms.util.disableClipping import org.thoughtcrime.securesms.util.toPx 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 { private lateinit var binding: ViewInputBarRecordingBinding private var startTimestamp = 0L @@ -79,7 +87,7 @@ class InputBarRecordingView : RelativeLayout { fun hide() { alpha = 1.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 -> alpha = animator.animatedValue as Float if (animator.animatedFraction == 1.0f) { @@ -113,7 +121,7 @@ class InputBarRecordingView : RelativeLayout { private fun animateDotView() { val animation = ValueAnimator.ofObject(FloatEvaluator(), 1.0f, 0.0f) dotViewAnimation = animation - animation.duration = 500L + animation.duration = VoiceRecorderConstants.DOT_ANIMATION_DURATION_MS animation.addUpdateListener { animator -> binding.dotView.alpha = animator.animatedValue as Float } @@ -128,7 +136,7 @@ class InputBarRecordingView : RelativeLayout { binding.pulseView.animateSizeChange(collapsedSize, expandedSize, 1000) val animation = ValueAnimator.ofObject(FloatEvaluator(), 0.5, 0.0f) pulseAnimation = animation - animation.duration = 1000L + animation.duration = VoiceRecorderConstants.DOT_PULSE_ANIMATION_DURATION_MS animation.addUpdateListener { animator -> binding.pulseView.alpha = animator.animatedValue as Float if (animator.animatedFraction == 1.0f && isVisible) { pulse() } @@ -143,7 +151,7 @@ class InputBarRecordingView : RelativeLayout { layoutParams.bottomMargin = startMarginBottom binding.lockView.layoutParams = layoutParams val animation = ValueAnimator.ofObject(IntEvaluator(), startMarginBottom, endMarginBottom) - animation.duration = 250L + animation.duration = VoiceRecorderConstants.ANIMATE_LOCK_DURATION_MS animation.addUpdateListener { animator -> layoutParams.bottomMargin = animator.animatedValue as Int binding.lockView.layoutParams = layoutParams @@ -153,21 +161,25 @@ class InputBarRecordingView : RelativeLayout { fun lock() { val fadeOutAnimation = ValueAnimator.ofObject(FloatEvaluator(), 1.0f, 0.0f) - fadeOutAnimation.duration = 250L + fadeOutAnimation.duration = VoiceRecorderConstants.ANIMATE_LOCK_DURATION_MS fadeOutAnimation.addUpdateListener { animator -> binding.inputBarMiddleContentContainer.alpha = animator.animatedValue as Float binding.lockView.alpha = animator.animatedValue as Float } fadeOutAnimation.start() val fadeInAnimation = ValueAnimator.ofObject(FloatEvaluator(), 0.0f, 1.0f) - fadeInAnimation.duration = 250L + fadeInAnimation.duration = VoiceRecorderConstants.ANIMATE_LOCK_DURATION_MS fadeInAnimation.addUpdateListener { animator -> binding.inputBarCancelButton.alpha = animator.animatedValue as Float } fadeInAnimation.start() binding.recordButtonOverlayImageView.setImageDrawable(ResourcesCompat.getDrawable(resources, R.drawable.ic_arrow_up, context.theme)) - binding.recordButtonOverlay.setOnClickListener { delegate?.sendVoiceMessage() } 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() } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/NetworkUtils.kt b/app/src/main/java/org/thoughtcrime/securesms/util/NetworkUtils.kt new file mode 100644 index 0000000000..b4756192b1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/NetworkUtils.kt @@ -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 + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/NetworkChangeReceiver.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/NetworkChangeReceiver.kt index 52ee5583d2..bff332fbd4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/NetworkChangeReceiver.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/NetworkChangeReceiver.kt @@ -7,12 +7,13 @@ import android.content.IntentFilter import android.net.ConnectivityManager import android.net.Network import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.util.NetworkUtils class NetworkChangeReceiver(private val onNetworkChangedCallback: (Boolean)->Unit) { private val networkList: MutableSet = mutableSetOf() - val broadcastDelegate = object: BroadcastReceiver() { + private val broadcastDelegate = object: BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { receiveBroadcast(context, intent) } @@ -41,16 +42,11 @@ class NetworkChangeReceiver(private val onNetworkChangedCallback: (Boolean)->Uni } fun receiveBroadcast(context: Context, intent: Intent) { - val connected = context.isConnected() + val connected = NetworkUtils.haveValidNetworkConnection(context) Log.i("Loki", "received broadcast, network connected: $connected") onNetworkChangedCallback(connected) } - fun Context.isConnected() : Boolean { - val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - return cm.activeNetwork != null - } - fun register(context: Context) { val intentFilter = IntentFilter("android.net.conn.CONNECTIVITY_CHANGE") context.registerReceiver(broadcastDelegate, intentFilter) diff --git a/app/src/main/res/layout/view_input_bar_recording.xml b/app/src/main/res/layout/view_input_bar_recording.xml index 1b19ea8154..d4b8ba1fa4 100644 --- a/app/src/main/res/layout/view_input_bar_recording.xml +++ b/app/src/main/res/layout/view_input_bar_recording.xml @@ -84,7 +84,7 @@ Unnamed group + Hold to record a voice message. An unknown error occurred and your data was not deleted. Do you want to delete your data from just this device instead? An unknown error occurred. Clear Device