[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;
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);
}

View File

@ -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<Attachment>,
body: String?,
quotedMessage: MessageRecord? = binding?.inputBar?.quote,
quotedMessage: MessageRecord? = binding.inputBar?.quote,
linkPreview: LinkPreview? = null
): Pair<Address, Long>? {
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<Pair<Uri, Long>> {
override fun onSuccess(result: Pair<Uri, Long>) {
@ -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<MessageRecord>) {
@ -2002,7 +2131,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
override fun reply(messages: Set<MessageRecord>) {
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 {

View File

@ -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
}

View File

@ -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() }
}
}

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.Network
import org.session.libsignal.utilities.Log
import org.thoughtcrime.securesms.util.NetworkUtils
class NetworkChangeReceiver(private val onNetworkChangedCallback: (Boolean)->Unit) {
private val networkList: MutableSet<Network> = 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)

View File

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

View File

@ -74,6 +74,7 @@
<!-- RecipientProvider -->
<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="errorUnknown">An unknown error occurred.</string>
<string name="clearDevice">Clear Device</string>