mirror of
https://github.com/oxen-io/session-android.git
synced 2024-11-23 18:15:22 +00:00
[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:
parent
a30f00104e
commit
1e02845fd2
@ -1,28 +1,22 @@
|
|||||||
package org.thoughtcrime.securesms.audio;
|
package org.thoughtcrime.securesms.audio;
|
||||||
|
|
||||||
import android.annotation.TargetApi;
|
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.net.Uri;
|
import android.net.Uri;
|
||||||
import android.os.Build;
|
|
||||||
import android.os.ParcelFileDescriptor;
|
import android.os.ParcelFileDescriptor;
|
||||||
import androidx.annotation.NonNull;
|
|
||||||
|
|
||||||
import org.session.libsession.utilities.MediaTypes;
|
|
||||||
import org.session.libsignal.utilities.Log;
|
|
||||||
import android.util.Pair;
|
import android.util.Pair;
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import org.session.libsession.utilities.MediaTypes;
|
||||||
|
import org.session.libsession.utilities.Util;
|
||||||
|
import org.session.libsignal.utilities.ListenableFuture;
|
||||||
|
import org.session.libsignal.utilities.Log;
|
||||||
|
import org.session.libsignal.utilities.SettableFuture;
|
||||||
|
import org.session.libsignal.utilities.ThreadUtils;
|
||||||
import org.thoughtcrime.securesms.providers.BlobProvider;
|
import org.thoughtcrime.securesms.providers.BlobProvider;
|
||||||
import org.thoughtcrime.securesms.util.MediaUtil;
|
import org.thoughtcrime.securesms.util.MediaUtil;
|
||||||
|
|
||||||
import org.session.libsignal.utilities.ThreadUtils;
|
|
||||||
import org.session.libsession.utilities.Util;
|
|
||||||
import org.session.libsignal.utilities.ListenableFuture;
|
|
||||||
import org.session.libsignal.utilities.SettableFuture;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.util.concurrent.ExecutorService;
|
|
||||||
|
|
||||||
@TargetApi(Build.VERSION_CODES.JELLY_BEAN)
|
|
||||||
public class AudioRecorder {
|
public class AudioRecorder {
|
||||||
|
|
||||||
private static final String TAG = AudioRecorder.class.getSimpleName();
|
private static final String TAG = AudioRecorder.class.getSimpleName();
|
||||||
@ -34,11 +28,16 @@ public class AudioRecorder {
|
|||||||
private AudioCodec audioCodec;
|
private AudioCodec audioCodec;
|
||||||
private Uri captureUri;
|
private Uri captureUri;
|
||||||
|
|
||||||
|
// Simple interface that allows us to provide a callback method to our `startRecording` method
|
||||||
|
public interface AudioMessageRecordingFinishedCallback {
|
||||||
|
void onAudioMessageRecordingFinished();
|
||||||
|
}
|
||||||
|
|
||||||
public AudioRecorder(@NonNull Context context) {
|
public AudioRecorder(@NonNull Context context) {
|
||||||
this.context = context;
|
this.context = context;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void startRecording() {
|
public void startRecording(AudioMessageRecordingFinishedCallback callback) {
|
||||||
Log.i(TAG, "startRecording()");
|
Log.i(TAG, "startRecording()");
|
||||||
|
|
||||||
executor.execute(() -> {
|
executor.execute(() -> {
|
||||||
@ -55,9 +54,11 @@ public class AudioRecorder {
|
|||||||
.forData(new ParcelFileDescriptor.AutoCloseInputStream(fds[0]), 0)
|
.forData(new ParcelFileDescriptor.AutoCloseInputStream(fds[0]), 0)
|
||||||
.withMimeType(MediaTypes.AUDIO_AAC)
|
.withMimeType(MediaTypes.AUDIO_AAC)
|
||||||
.createForSingleSessionOnDisk(context, e -> Log.w(TAG, "Error during recording", e));
|
.createForSingleSessionOnDisk(context, e -> Log.w(TAG, "Error during recording", e));
|
||||||
audioCodec = new AudioCodec();
|
|
||||||
|
|
||||||
|
audioCodec = new AudioCodec();
|
||||||
audioCodec.start(new ParcelFileDescriptor.AutoCloseOutputStream(fds[1]));
|
audioCodec.start(new ParcelFileDescriptor.AutoCloseOutputStream(fds[1]));
|
||||||
|
|
||||||
|
callback.onAudioMessageRecordingFinished();
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
Log.w(TAG, e);
|
Log.w(TAG, e);
|
||||||
}
|
}
|
||||||
|
@ -66,8 +66,6 @@ import network.loki.messenger.libsession_util.util.ExpiryMode
|
|||||||
import nl.komponents.kovenant.ui.successUi
|
import nl.komponents.kovenant.ui.successUi
|
||||||
import org.session.libsession.messaging.MessagingModuleConfiguration
|
import org.session.libsession.messaging.MessagingModuleConfiguration
|
||||||
import org.session.libsession.messaging.contacts.Contact
|
import org.session.libsession.messaging.contacts.Contact
|
||||||
import org.session.libsession.messaging.jobs.AttachmentDownloadJob
|
|
||||||
import org.session.libsession.messaging.jobs.JobQueue
|
|
||||||
import org.session.libsession.messaging.messages.ExpirationConfiguration
|
import org.session.libsession.messaging.messages.ExpirationConfiguration
|
||||||
import org.session.libsession.messaging.messages.applyExpiryMode
|
import org.session.libsession.messaging.messages.applyExpiryMode
|
||||||
import org.session.libsession.messaging.messages.control.DataExtractionNotification
|
import org.session.libsession.messaging.messages.control.DataExtractionNotification
|
||||||
@ -81,6 +79,7 @@ import org.session.libsession.messaging.sending_receiving.attachments.Attachment
|
|||||||
import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview
|
import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview
|
||||||
import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel
|
import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel
|
||||||
import org.session.libsession.messaging.utilities.SessionId
|
import org.session.libsession.messaging.utilities.SessionId
|
||||||
|
import org.session.libsession.snode.OnionRequestAPI
|
||||||
import org.session.libsession.snode.SnodeAPI
|
import org.session.libsession.snode.SnodeAPI
|
||||||
import org.session.libsession.utilities.Address
|
import org.session.libsession.utilities.Address
|
||||||
import org.session.libsession.utilities.Address.Companion.fromSerialized
|
import org.session.libsession.utilities.Address.Companion.fromSerialized
|
||||||
@ -117,6 +116,9 @@ import org.thoughtcrime.securesms.conversation.v2.dialogs.SendSeedDialog
|
|||||||
import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBarButton
|
import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBarButton
|
||||||
import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBarDelegate
|
import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBarDelegate
|
||||||
import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBarRecordingViewDelegate
|
import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBarRecordingViewDelegate
|
||||||
|
import org.thoughtcrime.securesms.conversation.v2.input_bar.VoiceRecorderConstants.ANIMATE_LOCK_DURATION_MS
|
||||||
|
import org.thoughtcrime.securesms.conversation.v2.input_bar.VoiceRecorderConstants.SHOW_HIDE_VOICE_UI_DURATION_MS
|
||||||
|
import org.thoughtcrime.securesms.conversation.v2.input_bar.VoiceRecorderState
|
||||||
import org.thoughtcrime.securesms.conversation.v2.input_bar.mentions.MentionCandidateAdapter
|
import org.thoughtcrime.securesms.conversation.v2.input_bar.mentions.MentionCandidateAdapter
|
||||||
import org.thoughtcrime.securesms.conversation.v2.mention.MentionViewModel
|
import org.thoughtcrime.securesms.conversation.v2.mention.MentionViewModel
|
||||||
import org.thoughtcrime.securesms.conversation.v2.menus.ConversationActionModeCallback
|
import org.thoughtcrime.securesms.conversation.v2.menus.ConversationActionModeCallback
|
||||||
@ -169,6 +171,7 @@ import org.thoughtcrime.securesms.util.ActivityDispatcher
|
|||||||
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
|
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
|
||||||
import org.thoughtcrime.securesms.util.DateUtils
|
import org.thoughtcrime.securesms.util.DateUtils
|
||||||
import org.thoughtcrime.securesms.util.MediaUtil
|
import org.thoughtcrime.securesms.util.MediaUtil
|
||||||
|
import org.thoughtcrime.securesms.util.NetworkUtils
|
||||||
import org.thoughtcrime.securesms.util.SaveAttachmentTask
|
import org.thoughtcrime.securesms.util.SaveAttachmentTask
|
||||||
import org.thoughtcrime.securesms.util.SimpleTextWatcher
|
import org.thoughtcrime.securesms.util.SimpleTextWatcher
|
||||||
import org.thoughtcrime.securesms.util.isScrolledToBottom
|
import org.thoughtcrime.securesms.util.isScrolledToBottom
|
||||||
@ -176,6 +179,7 @@ import org.thoughtcrime.securesms.util.isScrolledToWithin30dpOfBottom
|
|||||||
import org.thoughtcrime.securesms.util.push
|
import org.thoughtcrime.securesms.util.push
|
||||||
import org.thoughtcrime.securesms.util.show
|
import org.thoughtcrime.securesms.util.show
|
||||||
import org.thoughtcrime.securesms.util.toPx
|
import org.thoughtcrime.securesms.util.toPx
|
||||||
|
import org.thoughtcrime.securesms.webrtc.NetworkChangeReceiver
|
||||||
import java.lang.ref.WeakReference
|
import java.lang.ref.WeakReference
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import java.util.concurrent.ExecutionException
|
import java.util.concurrent.ExecutionException
|
||||||
@ -201,7 +205,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
OnReactionSelectedListener, ReactWithAnyEmojiDialogFragment.Callback, ReactionsDialogFragment.Callback,
|
OnReactionSelectedListener, ReactWithAnyEmojiDialogFragment.Callback, ReactionsDialogFragment.Callback,
|
||||||
ConversationMenuHelper.ConversationMenuListener {
|
ConversationMenuHelper.ConversationMenuListener {
|
||||||
|
|
||||||
private var binding: ActivityConversationV2Binding? = null
|
private lateinit var binding: ActivityConversationV2Binding
|
||||||
|
|
||||||
@Inject lateinit var textSecurePreferences: TextSecurePreferences
|
@Inject lateinit var textSecurePreferences: TextSecurePreferences
|
||||||
@Inject lateinit var threadDb: ThreadDatabase
|
@Inject lateinit var threadDb: ThreadDatabase
|
||||||
@ -281,13 +285,13 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
private var emojiPickerVisible = false
|
private var emojiPickerVisible = false
|
||||||
|
|
||||||
private val isScrolledToBottom: Boolean
|
private val isScrolledToBottom: Boolean
|
||||||
get() = binding?.conversationRecyclerView?.isScrolledToBottom ?: true
|
get() = binding.conversationRecyclerView.isScrolledToBottom
|
||||||
|
|
||||||
private val isScrolledToWithin30dpOfBottom: Boolean
|
private val isScrolledToWithin30dpOfBottom: Boolean
|
||||||
get() = binding?.conversationRecyclerView?.isScrolledToWithin30dpOfBottom ?: true
|
get() = binding.conversationRecyclerView.isScrolledToWithin30dpOfBottom
|
||||||
|
|
||||||
private val layoutManager: LinearLayoutManager?
|
private val layoutManager: LinearLayoutManager?
|
||||||
get() { return binding?.conversationRecyclerView?.layoutManager as LinearLayoutManager? }
|
get() { return binding.conversationRecyclerView.layoutManager as LinearLayoutManager? }
|
||||||
|
|
||||||
private val seed by lazy {
|
private val seed by lazy {
|
||||||
var hexEncodedSeed = IdentityKeyUtil.retrieve(this, IdentityKeyUtil.LOKI_SEED)
|
var hexEncodedSeed = IdentityKeyUtil.retrieve(this, IdentityKeyUtil.LOKI_SEED)
|
||||||
@ -299,7 +303,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
val loadFileContents: (String) -> String = { fileName ->
|
val loadFileContents: (String) -> String = { fileName ->
|
||||||
MnemonicUtilities.loadFileContents(appContext, fileName)
|
MnemonicUtilities.loadFileContents(appContext, fileName)
|
||||||
}
|
}
|
||||||
MnemonicCodec(loadFileContents).encode(hexEncodedSeed!!, MnemonicCodec.Language.Configuration.english)
|
MnemonicCodec(loadFileContents).encode(hexEncodedSeed, MnemonicCodec.Language.Configuration.english)
|
||||||
}
|
}
|
||||||
|
|
||||||
// There is a bug when initially joining a community where all messages will immediately be marked
|
// There is a bug when initially joining a community where all messages will immediately be marked
|
||||||
@ -341,7 +345,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
|
|
||||||
// Register an AdapterDataObserver to scroll us to the bottom of the RecyclerView for if
|
// Register an AdapterDataObserver to scroll us to the bottom of the RecyclerView for if
|
||||||
// we're already near the the bottom and the data changes.
|
// we're already near the the bottom and the data changes.
|
||||||
adapter.registerAdapterDataObserver(ConversationAdapterDataObserver(binding?.conversationRecyclerView!!, adapter))
|
adapter.registerAdapterDataObserver(ConversationAdapterDataObserver(binding.conversationRecyclerView, adapter))
|
||||||
|
|
||||||
adapter
|
adapter
|
||||||
}
|
}
|
||||||
@ -364,6 +368,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
private var currentLastVisibleRecyclerViewIndex: Int = RecyclerView.NO_POSITION
|
private var currentLastVisibleRecyclerViewIndex: Int = RecyclerView.NO_POSITION
|
||||||
private var recyclerScrollState: Int = RecyclerView.SCROLL_STATE_IDLE
|
private var recyclerScrollState: Int = RecyclerView.SCROLL_STATE_IDLE
|
||||||
|
|
||||||
|
// Lower limit for the length of voice messages - any lower and we inform the user rather than sending
|
||||||
|
private val MINIMUM_VOICE_MESSAGE_DURATION_MS = 1000L
|
||||||
|
|
||||||
// region Settings
|
// region Settings
|
||||||
companion object {
|
companion object {
|
||||||
// Extras
|
// Extras
|
||||||
@ -385,7 +392,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) {
|
override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) {
|
||||||
super.onCreate(savedInstanceState, isReady)
|
super.onCreate(savedInstanceState, isReady)
|
||||||
binding = ActivityConversationV2Binding.inflate(layoutInflater)
|
binding = ActivityConversationV2Binding.inflate(layoutInflater)
|
||||||
setContentView(binding!!.root)
|
setContentView(binding.root)
|
||||||
|
|
||||||
// messageIdToScroll
|
// messageIdToScroll
|
||||||
messageToScrollTimestamp.set(intent.getLongExtra(SCROLL_MESSAGE_ID, -1))
|
messageToScrollTimestamp.set(intent.getLongExtra(SCROLL_MESSAGE_ID, -1))
|
||||||
@ -403,12 +410,12 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
restoreDraftIfNeeded()
|
restoreDraftIfNeeded()
|
||||||
setUpUiStateObserver()
|
setUpUiStateObserver()
|
||||||
|
|
||||||
binding!!.scrollToBottomButton.setOnClickListener {
|
binding.scrollToBottomButton.setOnClickListener {
|
||||||
val layoutManager = (binding?.conversationRecyclerView?.layoutManager as? LinearLayoutManager) ?: return@setOnClickListener
|
val layoutManager = binding.conversationRecyclerView.layoutManager as LinearLayoutManager
|
||||||
val targetPosition = if (reverseMessageList) 0 else adapter.itemCount
|
val targetPosition = if (reverseMessageList) 0 else adapter.itemCount
|
||||||
|
|
||||||
if (layoutManager.isSmoothScrolling) {
|
if (layoutManager.isSmoothScrolling) {
|
||||||
binding?.conversationRecyclerView?.scrollToPosition(targetPosition)
|
binding.conversationRecyclerView.scrollToPosition(targetPosition)
|
||||||
} else {
|
} else {
|
||||||
// It looks like 'smoothScrollToPosition' will actually load all intermediate items in
|
// It looks like 'smoothScrollToPosition' will actually load all intermediate items in
|
||||||
// order to do the scroll, this can be very slow if there are a lot of messages so
|
// order to do the scroll, this can be very slow if there are a lot of messages so
|
||||||
@ -418,11 +425,11 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
// val position = if (reverseMessageList) layoutManager.findFirstVisibleItemPosition() else layoutManager.findLastVisibleItemPosition()
|
// val position = if (reverseMessageList) layoutManager.findFirstVisibleItemPosition() else layoutManager.findLastVisibleItemPosition()
|
||||||
// val targetBuffer = if (reverseMessageList) 10 else Math.max(0, (adapter.itemCount - 1) - 10)
|
// val targetBuffer = if (reverseMessageList) 10 else Math.max(0, (adapter.itemCount - 1) - 10)
|
||||||
// if (position > targetBuffer) {
|
// if (position > targetBuffer) {
|
||||||
// binding?.conversationRecyclerView?.scrollToPosition(targetBuffer)
|
// binding.conversationRecyclerView?.scrollToPosition(targetBuffer)
|
||||||
// }
|
// }
|
||||||
|
|
||||||
binding?.conversationRecyclerView?.post {
|
binding.conversationRecyclerView.post {
|
||||||
binding?.conversationRecyclerView?.smoothScrollToPosition(targetPosition)
|
binding.conversationRecyclerView.smoothScrollToPosition(targetPosition)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -430,7 +437,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
updateUnreadCountIndicator()
|
updateUnreadCountIndicator()
|
||||||
updatePlaceholder()
|
updatePlaceholder()
|
||||||
setUpBlockedBanner()
|
setUpBlockedBanner()
|
||||||
binding!!.searchBottomBar.setEventListener(this)
|
binding.searchBottomBar.setEventListener(this)
|
||||||
updateSendAfterApprovalText()
|
updateSendAfterApprovalText()
|
||||||
setUpMessageRequestsBar()
|
setUpMessageRequestsBar()
|
||||||
|
|
||||||
@ -461,7 +468,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
setUpOutdatedClientBanner()
|
setUpOutdatedClientBanner()
|
||||||
|
|
||||||
if (author != null && messageTimestamp >= 0 && targetPosition >= 0) {
|
if (author != null && messageTimestamp >= 0 && targetPosition >= 0) {
|
||||||
binding?.conversationRecyclerView?.scrollToPosition(targetPosition)
|
binding.conversationRecyclerView.scrollToPosition(targetPosition)
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
scrollToFirstUnreadMessageIfNeeded(true)
|
scrollToFirstUnreadMessageIfNeeded(true)
|
||||||
@ -523,7 +530,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
screenshotObserver
|
screenshotObserver
|
||||||
)
|
)
|
||||||
viewModel.run {
|
viewModel.run {
|
||||||
binding?.toolbarContent?.update(recipient ?: return, openGroup, expirationConfiguration)
|
binding.toolbarContent?.update(recipient ?: return, openGroup, expirationConfiguration)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -591,12 +598,12 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
|
|
||||||
// called from onCreate
|
// called from onCreate
|
||||||
private fun setUpRecyclerView() {
|
private fun setUpRecyclerView() {
|
||||||
binding!!.conversationRecyclerView.adapter = adapter
|
binding.conversationRecyclerView.adapter = adapter
|
||||||
val layoutManager = LinearLayoutManager(this, LinearLayoutManager.VERTICAL, reverseMessageList)
|
val layoutManager = LinearLayoutManager(this, LinearLayoutManager.VERTICAL, reverseMessageList)
|
||||||
binding!!.conversationRecyclerView.layoutManager = layoutManager
|
binding.conversationRecyclerView.layoutManager = layoutManager
|
||||||
// Workaround for the fact that CursorRecyclerViewAdapter doesn't auto-update automatically (even though it says it will)
|
// Workaround for the fact that CursorRecyclerViewAdapter doesn't auto-update automatically (even though it says it will)
|
||||||
LoaderManager.getInstance(this).restartLoader(0, null, this)
|
LoaderManager.getInstance(this).restartLoader(0, null, this)
|
||||||
binding!!.conversationRecyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
|
binding.conversationRecyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
|
||||||
|
|
||||||
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
|
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
|
||||||
// The unreadCount check is to prevent us scrolling to the bottom when we first enter a conversation
|
// The unreadCount check is to prevent us scrolling to the bottom when we first enter a conversation
|
||||||
@ -624,7 +631,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
// If the current last visible message index is less than the previous one (i.e. we've
|
// If the current last visible message index is less than the previous one (i.e. we've
|
||||||
// lost visibility of one or more messages due to showing the IME keyboard) AND we're
|
// lost visibility of one or more messages due to showing the IME keyboard) AND we're
|
||||||
// at the bottom of the message feed..
|
// at the bottom of the message feed..
|
||||||
val atBottomAndTrueLastNoLongerVisible = currentLastVisibleRecyclerViewIndex!! <= previousLastVisibleRecyclerViewIndex!! && !binding?.scrollToBottomButton?.isVisible!!
|
val atBottomAndTrueLastNoLongerVisible = currentLastVisibleRecyclerViewIndex <= previousLastVisibleRecyclerViewIndex &&
|
||||||
|
!binding.scrollToBottomButton.isVisible
|
||||||
|
|
||||||
// ..OR we're at the last message or have received a new message..
|
// ..OR we're at the last message or have received a new message..
|
||||||
val atLastOrReceivedNewMessage = currentLastVisibleRecyclerViewIndex == (adapter.itemCount - 1)
|
val atLastOrReceivedNewMessage = currentLastVisibleRecyclerViewIndex == (adapter.itemCount - 1)
|
||||||
@ -632,8 +640,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
// ..then scroll the recycler view to the last message on resize. Note: We cannot just call
|
// ..then scroll the recycler view to the last message on resize. Note: We cannot just call
|
||||||
// scroll/smoothScroll - we have to `post` it or nothing happens!
|
// scroll/smoothScroll - we have to `post` it or nothing happens!
|
||||||
if (atBottomAndTrueLastNoLongerVisible || atLastOrReceivedNewMessage) {
|
if (atBottomAndTrueLastNoLongerVisible || atLastOrReceivedNewMessage) {
|
||||||
binding?.conversationRecyclerView?.post {
|
binding.conversationRecyclerView.post {
|
||||||
binding?.conversationRecyclerView?.smoothScrollToPosition(adapter.itemCount)
|
binding.conversationRecyclerView.smoothScrollToPosition(adapter.itemCount)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -643,14 +651,13 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
|
|
||||||
// called from onCreate
|
// called from onCreate
|
||||||
private fun setUpToolBar() {
|
private fun setUpToolBar() {
|
||||||
val binding = binding ?: return
|
|
||||||
setSupportActionBar(binding.toolbar)
|
setSupportActionBar(binding.toolbar)
|
||||||
val actionBar = supportActionBar ?: return
|
val actionBar = supportActionBar ?: return
|
||||||
val recipient = viewModel.recipient ?: return
|
val recipient = viewModel.recipient ?: return
|
||||||
actionBar.title = ""
|
actionBar.title = ""
|
||||||
actionBar.setDisplayHomeAsUpEnabled(true)
|
actionBar.setDisplayHomeAsUpEnabled(true)
|
||||||
actionBar.setHomeButtonEnabled(true)
|
actionBar.setHomeButtonEnabled(true)
|
||||||
binding!!.toolbarContent.bind(
|
binding.toolbarContent.bind(
|
||||||
this,
|
this,
|
||||||
viewModel.threadId,
|
viewModel.threadId,
|
||||||
recipient,
|
recipient,
|
||||||
@ -662,7 +669,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
|
|
||||||
// called from onCreate
|
// called from onCreate
|
||||||
private fun setUpInputBar() {
|
private fun setUpInputBar() {
|
||||||
val binding = binding ?: return
|
|
||||||
binding.inputBar.isGone = viewModel.hidesInputBar()
|
binding.inputBar.isGone = viewModel.hidesInputBar()
|
||||||
binding.inputBar.delegate = this
|
binding.inputBar.delegate = this
|
||||||
binding.inputBarRecordingView.delegate = this
|
binding.inputBarRecordingView.delegate = this
|
||||||
@ -708,10 +714,10 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
}
|
}
|
||||||
} else if (intent.hasExtra(Intent.EXTRA_TEXT)) {
|
} else if (intent.hasExtra(Intent.EXTRA_TEXT)) {
|
||||||
val dataTextExtra = intent.getCharSequenceExtra(Intent.EXTRA_TEXT) ?: ""
|
val dataTextExtra = intent.getCharSequenceExtra(Intent.EXTRA_TEXT) ?: ""
|
||||||
binding!!.inputBar.text = dataTextExtra.toString()
|
binding.inputBar.text = dataTextExtra.toString()
|
||||||
} else {
|
} else {
|
||||||
viewModel.getDraft()?.let { text ->
|
viewModel.getDraft()?.let { text ->
|
||||||
binding!!.inputBar.text = text
|
binding.inputBar.text = text
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -722,12 +728,12 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
val recipients = if (state != null) state.typists else listOf()
|
val recipients = if (state != null) state.typists else listOf()
|
||||||
// FIXME: Also checking isScrolledToBottom is a quick fix for an issue where the
|
// FIXME: Also checking isScrolledToBottom is a quick fix for an issue where the
|
||||||
// typing indicator overlays the recycler view when scrolled up
|
// typing indicator overlays the recycler view when scrolled up
|
||||||
val viewContainer = binding?.typingIndicatorViewContainer ?: return@observe
|
val viewContainer = binding.typingIndicatorViewContainer
|
||||||
viewContainer.isVisible = recipients.isNotEmpty() && isScrolledToBottom
|
viewContainer.isVisible = recipients.isNotEmpty() && isScrolledToBottom
|
||||||
viewContainer.setTypists(recipients)
|
viewContainer.setTypists(recipients)
|
||||||
}
|
}
|
||||||
if (textSecurePreferences.isTypingIndicatorsEnabled()) {
|
if (textSecurePreferences.isTypingIndicatorsEnabled()) {
|
||||||
binding!!.inputBar.addTextChangedListener(object : SimpleTextWatcher() {
|
binding.inputBar.addTextChangedListener(object : SimpleTextWatcher() {
|
||||||
|
|
||||||
override fun onTextChanged(text: String?) {
|
override fun onTextChanged(text: String?) {
|
||||||
ApplicationContext.getInstance(this@ConversationActivityV2).typingStatusSender.onTypingStarted(viewModel.threadId)
|
ApplicationContext.getInstance(this@ConversationActivityV2).typingStatusSender.onTypingStarted(viewModel.threadId)
|
||||||
@ -747,7 +753,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
private fun getLatestOpenGroupInfoIfNeeded() {
|
private fun getLatestOpenGroupInfoIfNeeded() {
|
||||||
val openGroup = viewModel.openGroup ?: return
|
val openGroup = viewModel.openGroup ?: return
|
||||||
OpenGroupApi.getMemberCount(openGroup.room, openGroup.server) successUi {
|
OpenGroupApi.getMemberCount(openGroup.room, openGroup.server) successUi {
|
||||||
binding?.toolbarContent?.updateSubtitle(viewModel.recipient!!, openGroup, viewModel.expirationConfiguration)
|
binding.toolbarContent.updateSubtitle(viewModel.recipient!!, openGroup, viewModel.expirationConfiguration)
|
||||||
maybeUpdateToolbar(viewModel.recipient!!)
|
maybeUpdateToolbar(viewModel.recipient!!)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -757,9 +763,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
val recipient = viewModel.recipient?.takeUnless { it.isGroupRecipient } ?: return
|
val recipient = viewModel.recipient?.takeUnless { it.isGroupRecipient } ?: return
|
||||||
val sessionID = recipient.address.toString()
|
val sessionID = recipient.address.toString()
|
||||||
val name = sessionContactDb.getContactWithSessionID(sessionID)?.displayName(Contact.ContactContext.REGULAR) ?: sessionID
|
val name = sessionContactDb.getContactWithSessionID(sessionID)?.displayName(Contact.ContactContext.REGULAR) ?: sessionID
|
||||||
binding?.blockedBannerTextView?.text = resources.getString(R.string.activity_conversation_blocked_banner_text, name)
|
binding.blockedBannerTextView.text = resources.getString(R.string.activity_conversation_blocked_banner_text, name)
|
||||||
binding?.blockedBanner?.isVisible = recipient.isBlocked
|
binding.blockedBanner.isVisible = recipient.isBlocked
|
||||||
binding?.blockedBanner?.setOnClickListener { viewModel.unblock() }
|
binding.blockedBanner.setOnClickListener { viewModel.unblock() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setUpOutdatedClientBanner() {
|
private fun setUpOutdatedClientBanner() {
|
||||||
@ -768,9 +774,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
val shouldShowLegacy = ExpirationConfiguration.isNewConfigEnabled &&
|
val shouldShowLegacy = ExpirationConfiguration.isNewConfigEnabled &&
|
||||||
legacyRecipient != null
|
legacyRecipient != null
|
||||||
|
|
||||||
binding?.outdatedBanner?.isVisible = shouldShowLegacy
|
binding.outdatedBanner.isVisible = shouldShowLegacy
|
||||||
if (shouldShowLegacy) {
|
if (shouldShowLegacy) {
|
||||||
binding?.outdatedBannerTextView?.text =
|
binding.outdatedBannerTextView.text =
|
||||||
resources.getString(R.string.activity_conversation_outdated_client_banner_text, legacyRecipient!!.name)
|
resources.getString(R.string.activity_conversation_outdated_client_banner_text, legacyRecipient!!.name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -783,13 +789,13 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
if (previewState == null) return@observe
|
if (previewState == null) return@observe
|
||||||
when {
|
when {
|
||||||
previewState.isLoading -> {
|
previewState.isLoading -> {
|
||||||
binding?.inputBar?.draftLinkPreview()
|
binding.inputBar.draftLinkPreview()
|
||||||
}
|
}
|
||||||
previewState.linkPreview.isPresent -> {
|
previewState.linkPreview.isPresent -> {
|
||||||
binding?.inputBar?.updateLinkPreviewDraft(glide, previewState.linkPreview.get())
|
binding.inputBar.updateLinkPreviewDraft(glide, previewState.linkPreview.get())
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
binding?.inputBar?.cancelLinkPreviewDraft()
|
binding.inputBar.cancelLinkPreviewDraft()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -803,7 +809,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
viewModel.messageShown(it.id)
|
viewModel.messageShown(it.id)
|
||||||
}
|
}
|
||||||
if (uiState.isMessageRequestAccepted == true) {
|
if (uiState.isMessageRequestAccepted == true) {
|
||||||
binding?.messageRequestBar?.visibility = View.GONE
|
binding.messageRequestBar.visibility = View.GONE
|
||||||
}
|
}
|
||||||
if (!uiState.conversationExists && !isFinishing) {
|
if (!uiState.conversationExists && !isFinishing) {
|
||||||
// Conversation should be deleted now, just go back
|
// Conversation should be deleted now, just go back
|
||||||
@ -827,12 +833,12 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
|
|
||||||
if (lastSeenItemPosition <= 3) { return lastSeenItemPosition }
|
if (lastSeenItemPosition <= 3) { return lastSeenItemPosition }
|
||||||
|
|
||||||
binding?.conversationRecyclerView?.scrollToPosition(lastSeenItemPosition)
|
binding.conversationRecyclerView.scrollToPosition(lastSeenItemPosition)
|
||||||
return lastSeenItemPosition
|
return lastSeenItemPosition
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun highlightViewAtPosition(position: Int) {
|
private fun highlightViewAtPosition(position: Int) {
|
||||||
binding?.conversationRecyclerView?.post {
|
binding.conversationRecyclerView.post {
|
||||||
(layoutManager?.findViewByPosition(position) as? VisibleMessageView)?.playHighlight()
|
(layoutManager?.findViewByPosition(position) as? VisibleMessageView)?.playHighlight()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -852,11 +858,10 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
viewModel.saveDraft(binding?.inputBar?.text?.trim() ?: "")
|
viewModel.saveDraft(binding.inputBar.text.trim())
|
||||||
cancelVoiceMessage()
|
cancelVoiceMessage()
|
||||||
tearDownRecipientObserver()
|
tearDownRecipientObserver()
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
binding = null
|
|
||||||
}
|
}
|
||||||
// endregion
|
// endregion
|
||||||
|
|
||||||
@ -867,7 +872,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
runOnUiThread {
|
runOnUiThread {
|
||||||
val threadRecipient = viewModel.recipient ?: return@runOnUiThread
|
val threadRecipient = viewModel.recipient ?: return@runOnUiThread
|
||||||
if (threadRecipient.isContactRecipient) {
|
if (threadRecipient.isContactRecipient) {
|
||||||
binding?.blockedBanner?.isVisible = threadRecipient.isBlocked
|
binding.blockedBanner.isVisible = threadRecipient.isBlocked
|
||||||
}
|
}
|
||||||
setUpMessageRequestsBar()
|
setUpMessageRequestsBar()
|
||||||
invalidateOptionsMenu()
|
invalidateOptionsMenu()
|
||||||
@ -879,29 +884,29 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun maybeUpdateToolbar(recipient: Recipient) {
|
private fun maybeUpdateToolbar(recipient: Recipient) {
|
||||||
binding?.toolbarContent?.update(recipient, viewModel.openGroup, viewModel.expirationConfiguration)
|
binding.toolbarContent.update(recipient, viewModel.openGroup, viewModel.expirationConfiguration)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateSendAfterApprovalText() {
|
private fun updateSendAfterApprovalText() {
|
||||||
binding?.textSendAfterApproval?.isVisible = viewModel.showSendAfterApprovalText
|
binding.textSendAfterApproval.isVisible = viewModel.showSendAfterApprovalText
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showOrHideInputIfNeeded() {
|
private fun showOrHideInputIfNeeded() {
|
||||||
binding?.inputBar?.showInput = viewModel.recipient?.takeIf { it.isClosedGroupRecipient }
|
binding.inputBar.showInput = viewModel.recipient?.takeIf { it.isClosedGroupRecipient }
|
||||||
?.run { address.toGroupString().let(groupDb::getGroup).orNull()?.isActive == true }
|
?.run { address.toGroupString().let(groupDb::getGroup).orNull()?.isActive == true }
|
||||||
?: true
|
?: true
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setUpMessageRequestsBar() {
|
private fun setUpMessageRequestsBar() {
|
||||||
binding?.inputBar?.showMediaControls = !isOutgoingMessageRequestThread()
|
binding.inputBar.showMediaControls = !isOutgoingMessageRequestThread()
|
||||||
binding?.messageRequestBar?.isVisible = isIncomingMessageRequestThread()
|
binding.messageRequestBar.isVisible = isIncomingMessageRequestThread()
|
||||||
binding?.acceptMessageRequestButton?.setOnClickListener {
|
binding.acceptMessageRequestButton.setOnClickListener {
|
||||||
acceptMessageRequest()
|
acceptMessageRequest()
|
||||||
}
|
}
|
||||||
binding?.messageRequestBlock?.setOnClickListener {
|
binding.messageRequestBlock.setOnClickListener {
|
||||||
block(deleteThread = true)
|
block(deleteThread = true)
|
||||||
}
|
}
|
||||||
binding?.declineMessageRequestButton?.setOnClickListener {
|
binding.declineMessageRequestButton.setOnClickListener {
|
||||||
viewModel.declineMessageRequest()
|
viewModel.declineMessageRequest()
|
||||||
lifecycleScope.launch(Dispatchers.IO) {
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@ConversationActivityV2)
|
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@ConversationActivityV2)
|
||||||
@ -911,7 +916,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun acceptMessageRequest() {
|
private fun acceptMessageRequest() {
|
||||||
binding?.messageRequestBar?.isVisible = false
|
binding.messageRequestBar.isVisible = false
|
||||||
viewModel.acceptMessageRequest()
|
viewModel.acceptMessageRequest()
|
||||||
|
|
||||||
lifecycleScope.launch(Dispatchers.IO) {
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
@ -930,7 +935,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
} ?: false
|
} ?: false
|
||||||
|
|
||||||
override fun inputBarEditTextContentChanged(newContent: CharSequence) {
|
override fun inputBarEditTextContentChanged(newContent: CharSequence) {
|
||||||
val inputBarText = binding?.inputBar?.text ?: return // TODO check if we should be referencing newContent here instead
|
val inputBarText = binding.inputBar.text // TODO check if we should be referencing newContent here instead
|
||||||
if (textSecurePreferences.isLinkPreviewsEnabled()) {
|
if (textSecurePreferences.isLinkPreviewsEnabled()) {
|
||||||
linkPreviewViewModel.onTextChanged(this, inputBarText, 0, 0)
|
linkPreviewViewModel.onTextChanged(this, inputBarText, 0, 0)
|
||||||
}
|
}
|
||||||
@ -948,10 +953,10 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
override fun toggleAttachmentOptions() {
|
override fun toggleAttachmentOptions() {
|
||||||
val targetAlpha = if (isShowingAttachmentOptions) 0.0f else 1.0f
|
val targetAlpha = if (isShowingAttachmentOptions) 0.0f else 1.0f
|
||||||
val allButtonContainers = listOfNotNull(
|
val allButtonContainers = listOfNotNull(
|
||||||
binding?.cameraButtonContainer,
|
binding.cameraButtonContainer,
|
||||||
binding?.libraryButtonContainer,
|
binding.libraryButtonContainer,
|
||||||
binding?.documentButtonContainer,
|
binding.documentButtonContainer,
|
||||||
binding?.gifButtonContainer
|
binding.gifButtonContainer
|
||||||
)
|
)
|
||||||
val isReversed = isShowingAttachmentOptions // Run the animation in reverse
|
val isReversed = isShowingAttachmentOptions // Run the animation in reverse
|
||||||
val count = allButtonContainers.size
|
val count = allButtonContainers.size
|
||||||
@ -971,20 +976,20 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun showVoiceMessageUI() {
|
override fun showVoiceMessageUI() {
|
||||||
binding?.inputBarRecordingView?.show(lifecycleScope)
|
binding.inputBarRecordingView.show(lifecycleScope)
|
||||||
binding?.inputBar?.alpha = 0.0f
|
binding.inputBar.alpha = 0.0f
|
||||||
val animation = ValueAnimator.ofObject(FloatEvaluator(), 1.0f, 0.0f)
|
val animation = ValueAnimator.ofObject(FloatEvaluator(), 1.0f, 0.0f)
|
||||||
animation.duration = 250L
|
animation.duration = SHOW_HIDE_VOICE_UI_DURATION_MS
|
||||||
animation.addUpdateListener { animator ->
|
animation.addUpdateListener { animator ->
|
||||||
binding?.inputBar?.alpha = animator.animatedValue as Float
|
binding.inputBar.alpha = animator.animatedValue as Float
|
||||||
}
|
}
|
||||||
animation.start()
|
animation.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun expandVoiceMessageLockView() {
|
private fun expandVoiceMessageLockView() {
|
||||||
val lockView = binding?.inputBarRecordingView?.lockView ?: return
|
val lockView = binding.inputBarRecordingView.lockView
|
||||||
val animation = ValueAnimator.ofObject(FloatEvaluator(), lockView.scaleX, 1.10f)
|
val animation = ValueAnimator.ofObject(FloatEvaluator(), lockView.scaleX, 1.10f)
|
||||||
animation.duration = 250L
|
animation.duration = ANIMATE_LOCK_DURATION_MS
|
||||||
animation.addUpdateListener { animator ->
|
animation.addUpdateListener { animator ->
|
||||||
lockView.scaleX = animator.animatedValue as Float
|
lockView.scaleX = animator.animatedValue as Float
|
||||||
lockView.scaleY = animator.animatedValue as Float
|
lockView.scaleY = animator.animatedValue as Float
|
||||||
@ -993,9 +998,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun collapseVoiceMessageLockView() {
|
private fun collapseVoiceMessageLockView() {
|
||||||
val lockView = binding?.inputBarRecordingView?.lockView ?: return
|
val lockView = binding.inputBarRecordingView.lockView
|
||||||
val animation = ValueAnimator.ofObject(FloatEvaluator(), lockView.scaleX, 1.0f)
|
val animation = ValueAnimator.ofObject(FloatEvaluator(), lockView.scaleX, 1.0f)
|
||||||
animation.duration = 250L
|
animation.duration = ANIMATE_LOCK_DURATION_MS
|
||||||
animation.addUpdateListener { animator ->
|
animation.addUpdateListener { animator ->
|
||||||
lockView.scaleX = animator.animatedValue as Float
|
lockView.scaleX = animator.animatedValue as Float
|
||||||
lockView.scaleY = animator.animatedValue as Float
|
lockView.scaleY = animator.animatedValue as Float
|
||||||
@ -1004,24 +1009,24 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun hideVoiceMessageUI() {
|
private fun hideVoiceMessageUI() {
|
||||||
val chevronImageView = binding?.inputBarRecordingView?.chevronImageView ?: return
|
val chevronImageView = binding.inputBarRecordingView.chevronImageView
|
||||||
val slideToCancelTextView = binding?.inputBarRecordingView?.slideToCancelTextView ?: return
|
val slideToCancelTextView = binding.inputBarRecordingView.slideToCancelTextView
|
||||||
listOf( chevronImageView, slideToCancelTextView ).forEach { view ->
|
listOf( chevronImageView, slideToCancelTextView ).forEach { view ->
|
||||||
val animation = ValueAnimator.ofObject(FloatEvaluator(), view.translationX, 0.0f)
|
val animation = ValueAnimator.ofObject(FloatEvaluator(), view.translationX, 0.0f)
|
||||||
animation.duration = 250L
|
animation.duration = ANIMATE_LOCK_DURATION_MS
|
||||||
animation.addUpdateListener { animator ->
|
animation.addUpdateListener { animator ->
|
||||||
view.translationX = animator.animatedValue as Float
|
view.translationX = animator.animatedValue as Float
|
||||||
}
|
}
|
||||||
animation.start()
|
animation.start()
|
||||||
}
|
}
|
||||||
binding?.inputBarRecordingView?.hide()
|
binding.inputBarRecordingView.hide()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun handleVoiceMessageUIHidden() {
|
override fun handleVoiceMessageUIHidden() {
|
||||||
val inputBar = binding?.inputBar ?: return
|
val inputBar = binding.inputBar
|
||||||
inputBar.alpha = 1.0f
|
inputBar.alpha = 1.0f
|
||||||
val animation = ValueAnimator.ofObject(FloatEvaluator(), 0.0f, 1.0f)
|
val animation = ValueAnimator.ofObject(FloatEvaluator(), 0.0f, 1.0f)
|
||||||
animation.duration = 250L
|
animation.duration = SHOW_HIDE_VOICE_UI_DURATION_MS
|
||||||
animation.addUpdateListener { animator ->
|
animation.addUpdateListener { animator ->
|
||||||
inputBar.alpha = animator.animatedValue as Float
|
inputBar.alpha = animator.animatedValue as Float
|
||||||
}
|
}
|
||||||
@ -1029,8 +1034,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun handleRecyclerViewScrolled() {
|
private fun handleRecyclerViewScrolled() {
|
||||||
val binding = binding ?: return
|
|
||||||
|
|
||||||
// Note: The typing indicate is whether the other person / other people are typing - it has
|
// Note: The typing indicate is whether the other person / other people are typing - it has
|
||||||
// nothing to do with the IME keyboard state.
|
// nothing to do with the IME keyboard state.
|
||||||
val wasTypingIndicatorVisibleBefore = binding.typingIndicatorViewContainer.isVisible
|
val wasTypingIndicatorVisibleBefore = binding.typingIndicatorViewContainer.isVisible
|
||||||
@ -1058,10 +1061,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun updatePlaceholder() {
|
private fun updatePlaceholder() {
|
||||||
val recipient = viewModel.recipient
|
val recipient = viewModel.recipient ?: return Log.w("Loki", "recipient was null in placeholder update")
|
||||||
?: return Log.w("Loki", "recipient was null in placeholder update")
|
|
||||||
val blindedRecipient = viewModel.blindedRecipient
|
val blindedRecipient = viewModel.blindedRecipient
|
||||||
val binding = binding ?: return
|
|
||||||
val openGroup = viewModel.openGroup
|
val openGroup = viewModel.openGroup
|
||||||
|
|
||||||
val (textResource, insertParam) = when {
|
val (textResource, insertParam) = when {
|
||||||
@ -1091,11 +1092,10 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun showScrollToBottomButtonIfApplicable() {
|
private fun showScrollToBottomButtonIfApplicable() {
|
||||||
binding?.scrollToBottomButton?.isVisible = !emojiPickerVisible && !isScrolledToBottom && adapter.itemCount > 0
|
binding.scrollToBottomButton.isVisible = !emojiPickerVisible && !isScrolledToBottom && adapter.itemCount > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateUnreadCountIndicator() {
|
private fun updateUnreadCountIndicator() {
|
||||||
val binding = binding ?: return
|
|
||||||
val formattedUnreadCount = if (unreadCount < 10000) unreadCount.toString() else "9999+"
|
val formattedUnreadCount = if (unreadCount < 10000) unreadCount.toString() else "9999+"
|
||||||
binding.unreadCountTextView.text = formattedUnreadCount
|
binding.unreadCountTextView.text = formattedUnreadCount
|
||||||
val textSize = if (unreadCount < 10000) 12.0f else 9.0f
|
val textSize = if (unreadCount < 10000) 12.0f else 9.0f
|
||||||
@ -1145,7 +1145,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
override fun copyOpenGroupUrl(thread: Recipient) {
|
override fun copyOpenGroupUrl(thread: Recipient) {
|
||||||
if (!thread.isCommunityRecipient) { return }
|
if (!thread.isCommunityRecipient) { return }
|
||||||
|
|
||||||
val threadId = threadDb.getThreadIdIfExistsFor(thread) ?: return
|
val threadId = threadDb.getThreadIdIfExistsFor(thread)
|
||||||
val openGroup = lokiThreadDb.getOpenGroupChat(threadId) ?: return
|
val openGroup = lokiThreadDb.getOpenGroupChat(threadId) ?: return
|
||||||
|
|
||||||
val clip = ClipData.newPlainText("Community URL", openGroup.joinURL)
|
val clip = ClipData.newPlainText("Community URL", openGroup.joinURL)
|
||||||
@ -1204,7 +1204,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
private fun handleSwipeToReply(message: MessageRecord) {
|
private fun handleSwipeToReply(message: MessageRecord) {
|
||||||
if (message.isOpenGroupInvitation) return
|
if (message.isOpenGroupInvitation) return
|
||||||
val recipient = viewModel.recipient ?: return
|
val recipient = viewModel.recipient ?: return
|
||||||
binding?.inputBar?.draftQuote(recipient, message, glide)
|
binding.inputBar.draftQuote(recipient, message, glide)
|
||||||
}
|
}
|
||||||
|
|
||||||
// `position` is the adapter position; not the visual position
|
// `position` is the adapter position; not the visual position
|
||||||
@ -1233,9 +1233,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
Log.e("Loki", "Failed to show emoji picker", e)
|
Log.e("Loki", "Failed to show emoji picker", e)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val binding = binding ?: return
|
|
||||||
|
|
||||||
emojiPickerVisible = true
|
emojiPickerVisible = true
|
||||||
ViewUtil.hideKeyboard(this, visibleMessageView)
|
ViewUtil.hideKeyboard(this, visibleMessageView)
|
||||||
binding.reactionsShade.isVisible = true
|
binding.reactionsShade.isVisible = true
|
||||||
@ -1288,36 +1285,48 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
|
|
||||||
private fun sendEmojiReaction(emoji: String, originalMessage: MessageRecord) {
|
private fun sendEmojiReaction(emoji: String, originalMessage: MessageRecord) {
|
||||||
// Create the message
|
// Create the message
|
||||||
val recipient = viewModel.recipient ?: return
|
val recipient = viewModel.recipient ?: return Log.w(TAG, "Could not locate recipient when sending emoji reaction")
|
||||||
val reactionMessage = VisibleMessage()
|
val reactionMessage = VisibleMessage()
|
||||||
val emojiTimestamp = SnodeAPI.nowWithOffset
|
val emojiTimestamp = SnodeAPI.nowWithOffset
|
||||||
reactionMessage.sentTimestamp = emojiTimestamp
|
reactionMessage.sentTimestamp = emojiTimestamp
|
||||||
val author = textSecurePreferences.getLocalNumber()!!
|
val author = textSecurePreferences.getLocalNumber()
|
||||||
// Put the message in the database
|
|
||||||
val reaction = ReactionRecord(
|
if (author == null) {
|
||||||
messageId = originalMessage.id,
|
Log.w(TAG, "Unable to locate local number when sending emoji reaction - aborting.")
|
||||||
isMms = originalMessage.isMms,
|
return
|
||||||
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)
|
|
||||||
}
|
|
||||||
} else {
|
} 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) {
|
private fun sendEmojiRemoval(emoji: String, originalMessage: MessageRecord) {
|
||||||
@ -1325,23 +1334,32 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
val message = VisibleMessage()
|
val message = VisibleMessage()
|
||||||
val emojiTimestamp = SnodeAPI.nowWithOffset
|
val emojiTimestamp = SnodeAPI.nowWithOffset
|
||||||
message.sentTimestamp = emojiTimestamp
|
message.sentTimestamp = emojiTimestamp
|
||||||
val author = textSecurePreferences.getLocalNumber()!!
|
val author = textSecurePreferences.getLocalNumber()
|
||||||
reactionDb.deleteReaction(emoji, MessageId(originalMessage.id, originalMessage.isMms), author, false)
|
|
||||||
|
|
||||||
val originalAuthor = if (originalMessage.isOutgoing) {
|
if (author == null) {
|
||||||
fromSerialized(viewModel.blindedPublicKey ?: textSecurePreferences.getLocalNumber()!!)
|
Log.w(TAG, "Unable to locate local number when removing emoji reaction - aborting.")
|
||||||
} else originalMessage.individualRecipient.address
|
return
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
} else {
|
} 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) {
|
override fun onCustomReactionSelected(messageRecord: MessageRecord, hasAddedCustomEmoji: Boolean) {
|
||||||
@ -1399,8 +1417,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
|
|
||||||
override fun onMicrophoneButtonMove(event: MotionEvent) {
|
override fun onMicrophoneButtonMove(event: MotionEvent) {
|
||||||
val rawX = event.rawX
|
val rawX = event.rawX
|
||||||
val chevronImageView = binding?.inputBarRecordingView?.chevronImageView ?: return
|
val chevronImageView = binding.inputBarRecordingView.chevronImageView
|
||||||
val slideToCancelTextView = binding?.inputBarRecordingView?.slideToCancelTextView ?: return
|
val slideToCancelTextView = binding.inputBarRecordingView.slideToCancelTextView
|
||||||
if (rawX < screenWidth / 2) {
|
if (rawX < screenWidth / 2) {
|
||||||
val translationX = rawX - screenWidth / 2
|
val translationX = rawX - screenWidth / 2
|
||||||
val sign = -1.0f
|
val sign = -1.0f
|
||||||
@ -1434,16 +1452,54 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
override fun onMicrophoneButtonUp(event: MotionEvent) {
|
override fun onMicrophoneButtonUp(event: MotionEvent) {
|
||||||
val x = event.rawX.roundToInt()
|
val x = event.rawX.roundToInt()
|
||||||
val y = event.rawY.roundToInt()
|
val y = event.rawY.roundToInt()
|
||||||
if (isValidLockViewLocation(x, y)) {
|
val inputBar = binding.inputBar
|
||||||
binding?.inputBarRecordingView?.lock()
|
|
||||||
} else {
|
// Lock voice recording on if the button is released over the lock area AND the
|
||||||
val recordButtonOverlay = binding?.inputBarRecordingView?.recordButtonOverlay ?: return
|
// voice recording has currently lasted for at least the time it takes to animate
|
||||||
val location = IntArray(2) { 0 }
|
// the lock area into position. Without this time check we can accidentally lock
|
||||||
recordButtonOverlay.getLocationOnScreen(location)
|
// to recording audio on a quick tap as the lock area animates out from the record
|
||||||
val hitRect = Rect(location[0], location[1], location[0] + recordButtonOverlay.width, location[1] + recordButtonOverlay.height)
|
// audio message button and the pointer-up event catches it mid-animation.
|
||||||
if (hitRect.contains(x, y)) {
|
//
|
||||||
sendVoiceMessage()
|
// Further, by limiting this to AnimateLockDurationMS rather than our minimum voice
|
||||||
} else {
|
// 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()
|
cancelVoiceMessage()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1452,7 +1508,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
private fun isValidLockViewLocation(x: Int, y: Int): Boolean {
|
private fun isValidLockViewLocation(x: Int, y: Int): Boolean {
|
||||||
// We can be anywhere above the lock view and a bit to the side of it (at most `lockViewHitMargin`
|
// We can be anywhere above the lock view and a bit to the side of it (at most `lockViewHitMargin`
|
||||||
// to the side)
|
// to the side)
|
||||||
val binding = binding ?: return false
|
|
||||||
val lockViewLocation = IntArray(2) { 0 }
|
val lockViewLocation = IntArray(2) { 0 }
|
||||||
binding.inputBarRecordingView.lockView.getLocationOnScreen(lockViewLocation)
|
binding.inputBarRecordingView.lockView.getLocationOnScreen(lockViewLocation)
|
||||||
val hitRect = Rect(lockViewLocation[0] - lockViewHitMargin, 0,
|
val hitRect = Rect(lockViewLocation[0] - lockViewHitMargin, 0,
|
||||||
@ -1460,10 +1515,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
return hitRect.contains(x, y)
|
return hitRect.contains(x, y)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
override fun scrollToMessageIfPossible(timestamp: Long) {
|
override fun scrollToMessageIfPossible(timestamp: Long) {
|
||||||
val lastSeenItemPosition = adapter.getItemPositionForTimestamp(timestamp) ?: return
|
val lastSeenItemPosition = adapter.getItemPositionForTimestamp(timestamp) ?: return
|
||||||
binding?.conversationRecyclerView?.scrollToPosition(lastSeenItemPosition)
|
binding.conversationRecyclerView?.scrollToPosition(lastSeenItemPosition)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onReactionClicked(emoji: String, messageId: MessageId, userWasSender: Boolean) {
|
override fun onReactionClicked(emoji: String, messageId: MessageId, userWasSender: Boolean) {
|
||||||
@ -1494,7 +1548,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
if (!textSecurePreferences.autoplayAudioMessages()) return
|
if (!textSecurePreferences.autoplayAudioMessages()) return
|
||||||
|
|
||||||
if (indexInAdapter < 0 || indexInAdapter >= adapter.itemCount) { return }
|
if (indexInAdapter < 0 || indexInAdapter >= adapter.itemCount) { return }
|
||||||
val viewHolder = binding?.conversationRecyclerView?.findViewHolderForAdapterPosition(indexInAdapter) as? ConversationAdapter.VisibleMessageViewHolder ?: return
|
val viewHolder = binding.conversationRecyclerView.findViewHolderForAdapterPosition(indexInAdapter) as? ConversationAdapter.VisibleMessageViewHolder ?: return
|
||||||
viewHolder.view.playVoiceMessage()
|
viewHolder.view.playVoiceMessage()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1504,7 +1558,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
BlockedDialog(recipient, this).show(supportFragmentManager, "Blocked Dialog")
|
BlockedDialog(recipient, this).show(supportFragmentManager, "Blocked Dialog")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val binding = binding ?: return
|
|
||||||
val sentMessageInfo = if (binding.inputBar.linkPreview != null || binding.inputBar.quote != null) {
|
val sentMessageInfo = if (binding.inputBar.linkPreview != null || binding.inputBar.quote != null) {
|
||||||
sendAttachments(listOf(), getMessageBody(), binding.inputBar.quote, binding.inputBar.linkPreview)
|
sendAttachments(listOf(), getMessageBody(), binding.inputBar.quote, binding.inputBar.linkPreview)
|
||||||
} else {
|
} else {
|
||||||
@ -1551,13 +1604,13 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
message.text = text
|
message.text = text
|
||||||
val expiresInMillis = viewModel.expirationConfiguration?.expiryMode?.expiryMillis ?: 0
|
val expiresInMillis = viewModel.expirationConfiguration?.expiryMode?.expiryMillis ?: 0
|
||||||
val expireStartedAt = if (viewModel.expirationConfiguration?.expiryMode is ExpiryMode.AfterSend) {
|
val expireStartedAt = if (viewModel.expirationConfiguration?.expiryMode is ExpiryMode.AfterSend) {
|
||||||
message.sentTimestamp!!
|
message.sentTimestamp
|
||||||
} else 0
|
} else 0
|
||||||
val outgoingTextMessage = OutgoingTextMessage.from(message, recipient, expiresInMillis, expireStartedAt)
|
val outgoingTextMessage = OutgoingTextMessage.from(message, recipient, expiresInMillis, expireStartedAt!!)
|
||||||
// Clear the input bar
|
// Clear the input bar
|
||||||
binding?.inputBar?.text = ""
|
binding.inputBar.text = ""
|
||||||
binding?.inputBar?.cancelQuoteDraft()
|
binding.inputBar.cancelQuoteDraft()
|
||||||
binding?.inputBar?.cancelLinkPreviewDraft()
|
binding.inputBar.cancelLinkPreviewDraft()
|
||||||
// Put the message in the database
|
// Put the message in the database
|
||||||
message.id = smsDb.insertMessageOutbox(viewModel.threadId, outgoingTextMessage, false, message.sentTimestamp!!, null, true)
|
message.id = smsDb.insertMessageOutbox(viewModel.threadId, outgoingTextMessage, false, message.sentTimestamp!!, null, true)
|
||||||
// Send it
|
// Send it
|
||||||
@ -1570,7 +1623,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
private fun sendAttachments(
|
private fun sendAttachments(
|
||||||
attachments: List<Attachment>,
|
attachments: List<Attachment>,
|
||||||
body: String?,
|
body: String?,
|
||||||
quotedMessage: MessageRecord? = binding?.inputBar?.quote,
|
quotedMessage: MessageRecord? = binding.inputBar?.quote,
|
||||||
linkPreview: LinkPreview? = null
|
linkPreview: LinkPreview? = null
|
||||||
): Pair<Address, Long>? {
|
): Pair<Address, Long>? {
|
||||||
val recipient = viewModel.recipient ?: return null
|
val recipient = viewModel.recipient ?: return null
|
||||||
@ -1599,9 +1652,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
} else 0
|
} else 0
|
||||||
val outgoingTextMessage = OutgoingMediaMessage.from(message, recipient, attachments, localQuote, linkPreview, expiresInMs, expireStartedAtMs)
|
val outgoingTextMessage = OutgoingMediaMessage.from(message, recipient, attachments, localQuote, linkPreview, expiresInMs, expireStartedAtMs)
|
||||||
// Clear the input bar
|
// Clear the input bar
|
||||||
binding?.inputBar?.text = ""
|
binding.inputBar.text = ""
|
||||||
binding?.inputBar?.cancelQuoteDraft()
|
binding.inputBar.cancelQuoteDraft()
|
||||||
binding?.inputBar?.cancelLinkPreviewDraft()
|
binding.inputBar.cancelLinkPreviewDraft()
|
||||||
// Reset the attachment manager
|
// Reset the attachment manager
|
||||||
attachmentManager.clear()
|
attachmentManager.clear()
|
||||||
// Reset attachments button if needed
|
// Reset attachments button if needed
|
||||||
@ -1640,7 +1693,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
|
|
||||||
private fun pickFromLibrary() {
|
private fun pickFromLibrary() {
|
||||||
val recipient = viewModel.recipient ?: return
|
val recipient = viewModel.recipient ?: return
|
||||||
binding?.inputBar?.text?.trim()?.let { text ->
|
binding.inputBar.text?.trim()?.let { text ->
|
||||||
AttachmentManager.selectGallery(this, PICK_FROM_LIBRARY, recipient, text)
|
AttachmentManager.selectGallery(this, PICK_FROM_LIBRARY, recipient, text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1733,7 +1786,20 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
if (Permissions.hasAll(this, Manifest.permission.RECORD_AUDIO)) {
|
if (Permissions.hasAll(this, Manifest.permission.RECORD_AUDIO)) {
|
||||||
showVoiceMessageUI()
|
showVoiceMessageUI()
|
||||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||||
audioRecorder.startRecording()
|
|
||||||
|
// Allow the caller (us!) to define what should happen when the voice recording finishes.
|
||||||
|
// Specifically in this instance, if we just tap the record audio button then by the time
|
||||||
|
// we actually finish setting up and get here the recording has been cancelled and the voice
|
||||||
|
// recorder state is Idle! As such we'll only tick the recorder state over to Recording if
|
||||||
|
// we were still in the SettingUpToRecord state when we got here (i.e., the record voice
|
||||||
|
// message button is still held or is locked to keep recording audio without being held).
|
||||||
|
val callback: () -> Unit = {
|
||||||
|
if (binding.inputBar.voiceRecorderState == VoiceRecorderState.SettingUpToRecord) {
|
||||||
|
binding.inputBar.voiceRecorderState = VoiceRecorderState.Recording
|
||||||
|
}
|
||||||
|
}
|
||||||
|
audioRecorder.startRecording(callback)
|
||||||
|
|
||||||
stopAudioHandler.postDelayed(stopVoiceMessageRecordingTask, 300000) // Limit voice messages to 5 minute each
|
stopAudioHandler.postDelayed(stopVoiceMessageRecordingTask, 300000) // Limit voice messages to 5 minute each
|
||||||
} else {
|
} else {
|
||||||
Permissions.with(this)
|
Permissions.with(this)
|
||||||
@ -1744,11 +1810,61 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun informUserIfNetworkOrSessionNodePathIsInvalid() {
|
||||||
|
|
||||||
|
// Check that we have a valid network network connection & inform the user if not
|
||||||
|
val connectedToInternet = NetworkUtils.haveValidNetworkConnection(applicationContext)
|
||||||
|
if (!connectedToInternet)
|
||||||
|
{
|
||||||
|
// TODO: Adjust to display error to user with official localised string when SES-2319 is addressed
|
||||||
|
Log.e(TAG, "Cannot sent voice message - no network connection.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that we have a suite of Session Nodes to route through.
|
||||||
|
// Note: We can have the entry node plus the 2 Session Nodes and the data _still_ might not
|
||||||
|
// send due to any node flakiness - but without doing some manner of test-ping through
|
||||||
|
// there's no way to test our client -> destination connectivity (unless we abuse the typing
|
||||||
|
// indicators?)
|
||||||
|
val paths = OnionRequestAPI.paths
|
||||||
|
if (paths.isNullOrEmpty() || paths.count() != 2) {
|
||||||
|
// TODO: Adjust to display error to user with official localised string when SES-2319 is addressed
|
||||||
|
Log.e(TAG, "Cannot send voice message - bad Session Node path.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun sendVoiceMessage() {
|
override fun sendVoiceMessage() {
|
||||||
|
// When the record voice message button is released we always need to reset the UI and cancel
|
||||||
|
// any further recording operation..
|
||||||
hideVoiceMessageUI()
|
hideVoiceMessageUI()
|
||||||
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||||
val future = audioRecorder.stopRecording()
|
val future = audioRecorder.stopRecording()
|
||||||
stopAudioHandler.removeCallbacks(stopVoiceMessageRecordingTask)
|
stopAudioHandler.removeCallbacks(stopVoiceMessageRecordingTask)
|
||||||
|
|
||||||
|
// ..but we'll bail without sending the voice message & inform the user that they need to press and HOLD
|
||||||
|
// the record voice message button if their message was less than 1 second long.
|
||||||
|
val inputBar = binding.inputBar
|
||||||
|
val voiceMessageDurationMS = inputBar.voiceMessageDurationMS
|
||||||
|
|
||||||
|
// Now tear-down is complete we can move back into the idle state ready to record another voice message.
|
||||||
|
// CAREFUL: This state must be set BEFORE we show any warning toast about short messages because it early
|
||||||
|
// exits before transmitting the audio!
|
||||||
|
inputBar.voiceRecorderState = VoiceRecorderState.Idle
|
||||||
|
|
||||||
|
// Voice message too short? Warn with toast instead of sending.
|
||||||
|
// Note: The 0L check prevents the warning toast being shown when leaving the conversation activity.
|
||||||
|
if (voiceMessageDurationMS != 0L && voiceMessageDurationMS < MINIMUM_VOICE_MESSAGE_DURATION_MS) {
|
||||||
|
Toast.makeText(this@ConversationActivityV2, R.string.messageVoiceErrorShort, Toast.LENGTH_SHORT).show()
|
||||||
|
inputBar.voiceMessageDurationMS = 0L
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
informUserIfNetworkOrSessionNodePathIsInvalid()
|
||||||
|
// Note: We could return here if there was a network or node path issue, but instead we'll try
|
||||||
|
// our best to send the voice message even if it might fail - because in that case it'll get put
|
||||||
|
// into the draft database and can be retried when we regain network connectivity and a working
|
||||||
|
// node path.
|
||||||
|
|
||||||
|
// Attempt to send it the voice message
|
||||||
future.addListener(object : ListenableFuture.Listener<Pair<Uri, Long>> {
|
future.addListener(object : ListenableFuture.Listener<Pair<Uri, Long>> {
|
||||||
|
|
||||||
override fun onSuccess(result: Pair<Uri, Long>) {
|
override fun onSuccess(result: Pair<Uri, Long>) {
|
||||||
@ -1765,10 +1881,23 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun cancelVoiceMessage() {
|
override fun cancelVoiceMessage() {
|
||||||
|
val inputBar = binding.inputBar
|
||||||
|
|
||||||
hideVoiceMessageUI()
|
hideVoiceMessageUI()
|
||||||
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||||
audioRecorder.stopRecording()
|
audioRecorder.stopRecording()
|
||||||
stopAudioHandler.removeCallbacks(stopVoiceMessageRecordingTask)
|
stopAudioHandler.removeCallbacks(stopVoiceMessageRecordingTask)
|
||||||
|
|
||||||
|
// Note: The 0L check prevents the warning toast being shown when leaving the conversation activity
|
||||||
|
val voiceMessageDuration = inputBar.voiceMessageDurationMS
|
||||||
|
if (voiceMessageDuration != 0L && voiceMessageDuration < MINIMUM_VOICE_MESSAGE_DURATION_MS) {
|
||||||
|
Toast.makeText(applicationContext, applicationContext.getString(R.string.messageVoiceErrorShort), Toast.LENGTH_SHORT).show()
|
||||||
|
inputBar.voiceMessageDurationMS = 0L
|
||||||
|
}
|
||||||
|
|
||||||
|
// When tear-down is complete (via cancelling) we can move back into the idle state ready to record
|
||||||
|
// another voice message.
|
||||||
|
inputBar.voiceRecorderState = VoiceRecorderState.Idle
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun selectMessages(messages: Set<MessageRecord>) {
|
override fun selectMessages(messages: Set<MessageRecord>) {
|
||||||
@ -2002,7 +2131,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
|
|
||||||
override fun reply(messages: Set<MessageRecord>) {
|
override fun reply(messages: Set<MessageRecord>) {
|
||||||
val recipient = viewModel.recipient ?: return
|
val recipient = viewModel.recipient ?: return
|
||||||
messages.firstOrNull()?.let { binding?.inputBar?.draftQuote(recipient, it, glide) }
|
messages.firstOrNull()?.let { binding.inputBar.draftQuote(recipient, it, glide) }
|
||||||
endActionMode()
|
endActionMode()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2049,28 +2178,28 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
searchViewModel.onMissingResult() }
|
searchViewModel.onMissingResult() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
binding?.searchBottomBar?.setData(result.position, result.getResults().size)
|
binding.searchBottomBar.setData(result.position, result.getResults().size)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onSearchOpened() {
|
fun onSearchOpened() {
|
||||||
searchViewModel.onSearchOpened()
|
searchViewModel.onSearchOpened()
|
||||||
binding?.searchBottomBar?.visibility = View.VISIBLE
|
binding.searchBottomBar.visibility = View.VISIBLE
|
||||||
binding?.searchBottomBar?.setData(0, 0)
|
binding.searchBottomBar.setData(0, 0)
|
||||||
binding?.inputBar?.visibility = View.INVISIBLE
|
binding.inputBar.visibility = View.INVISIBLE
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onSearchClosed() {
|
fun onSearchClosed() {
|
||||||
searchViewModel.onSearchClosed()
|
searchViewModel.onSearchClosed()
|
||||||
binding?.searchBottomBar?.visibility = View.GONE
|
binding.searchBottomBar.visibility = View.GONE
|
||||||
binding?.inputBar?.visibility = View.VISIBLE
|
binding.inputBar.visibility = View.VISIBLE
|
||||||
adapter.onSearchQueryUpdated(null)
|
adapter.onSearchQueryUpdated(null)
|
||||||
invalidateOptionsMenu()
|
invalidateOptionsMenu()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onSearchQueryUpdated(query: String) {
|
fun onSearchQueryUpdated(query: String) {
|
||||||
searchViewModel.onQueryUpdated(query, viewModel.threadId)
|
searchViewModel.onQueryUpdated(query, viewModel.threadId)
|
||||||
binding?.searchBottomBar?.showLoading()
|
binding.searchBottomBar.showLoading()
|
||||||
adapter.onSearchQueryUpdated(query)
|
adapter.onSearchQueryUpdated(query)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2090,7 +2219,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
|
|
||||||
private fun moveToMessagePosition(position: Int, highlight: Boolean, onMessageNotFound: Runnable?) {
|
private fun moveToMessagePosition(position: Int, highlight: Boolean, onMessageNotFound: Runnable?) {
|
||||||
if (position >= 0) {
|
if (position >= 0) {
|
||||||
binding?.conversationRecyclerView?.scrollToPosition(position)
|
binding.conversationRecyclerView.scrollToPosition(position)
|
||||||
|
|
||||||
if (highlight) {
|
if (highlight) {
|
||||||
runOnUiThread {
|
runOnUiThread {
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
package org.thoughtcrime.securesms.conversation.v2.input_bar
|
package org.thoughtcrime.securesms.conversation.v2.input_bar
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.res.Resources
|
import android.content.res.Resources
|
||||||
import android.graphics.PointF
|
import android.graphics.PointF
|
||||||
@ -11,6 +12,7 @@ import android.util.AttributeSet
|
|||||||
import android.view.KeyEvent
|
import android.view.KeyEvent
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.MotionEvent
|
import android.view.MotionEvent
|
||||||
|
import android.view.View
|
||||||
import android.view.inputmethod.EditorInfo
|
import android.view.inputmethod.EditorInfo
|
||||||
import android.widget.RelativeLayout
|
import android.widget.RelativeLayout
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
@ -32,6 +34,15 @@ import org.thoughtcrime.securesms.util.contains
|
|||||||
import org.thoughtcrime.securesms.util.toDp
|
import org.thoughtcrime.securesms.util.toDp
|
||||||
import org.thoughtcrime.securesms.util.toPx
|
import org.thoughtcrime.securesms.util.toPx
|
||||||
|
|
||||||
|
// Enums to keep track of the state of our voice recording mechanism as the user can
|
||||||
|
// manipulate the UI faster than we can setup & teardown.
|
||||||
|
enum class VoiceRecorderState {
|
||||||
|
Idle,
|
||||||
|
SettingUpToRecord,
|
||||||
|
Recording,
|
||||||
|
ShuttingDownAfterRecord
|
||||||
|
}
|
||||||
|
|
||||||
class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, LinkPreviewDraftViewDelegate,
|
class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, LinkPreviewDraftViewDelegate,
|
||||||
TextView.OnEditorActionListener {
|
TextView.OnEditorActionListener {
|
||||||
private lateinit var binding: ViewInputBarBinding
|
private lateinit var binding: ViewInputBarBinding
|
||||||
@ -57,6 +68,12 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li
|
|||||||
get() { return binding.inputBarEditText.text?.toString() ?: "" }
|
get() { return binding.inputBarEditText.text?.toString() ?: "" }
|
||||||
set(value) { binding.inputBarEditText.setText(value) }
|
set(value) { binding.inputBarEditText.setText(value) }
|
||||||
|
|
||||||
|
// Keep track of when the user pressed the record voice message button, the duration that
|
||||||
|
// they held record, and the current audio recording mechanism state.
|
||||||
|
private var voiceMessageStartMS = 0L
|
||||||
|
var voiceMessageDurationMS = 0L
|
||||||
|
var voiceRecorderState = VoiceRecorderState.Idle
|
||||||
|
|
||||||
val attachmentButtonsContainerHeight: Int
|
val attachmentButtonsContainerHeight: Int
|
||||||
get() = binding.attachmentsButtonContainer.height
|
get() = binding.attachmentsButtonContainer.height
|
||||||
|
|
||||||
@ -69,19 +86,63 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li
|
|||||||
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize() }
|
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize() }
|
||||||
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() }
|
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() }
|
||||||
|
|
||||||
|
@SuppressLint("ClickableViewAccessibility")
|
||||||
private fun initialize() {
|
private fun initialize() {
|
||||||
binding = ViewInputBarBinding.inflate(LayoutInflater.from(context), this, true)
|
binding = ViewInputBarBinding.inflate(LayoutInflater.from(context), this, true)
|
||||||
// Attachments button
|
// Attachments button
|
||||||
binding.attachmentsButtonContainer.addView(attachmentsButton)
|
binding.attachmentsButtonContainer.addView(attachmentsButton)
|
||||||
attachmentsButton.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
|
attachmentsButton.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
|
||||||
attachmentsButton.onPress = { toggleAttachmentOptions() }
|
attachmentsButton.onPress = { toggleAttachmentOptions() }
|
||||||
|
|
||||||
// Microphone button
|
// Microphone button
|
||||||
binding.microphoneOrSendButtonContainer.addView(microphoneButton)
|
binding.microphoneOrSendButtonContainer.addView(microphoneButton)
|
||||||
microphoneButton.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
|
microphoneButton.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
|
||||||
microphoneButton.onLongPress = { startRecordingVoiceMessage() }
|
|
||||||
microphoneButton.onMove = { delegate?.onMicrophoneButtonMove(it) }
|
microphoneButton.onMove = { delegate?.onMicrophoneButtonMove(it) }
|
||||||
microphoneButton.onCancel = { delegate?.onMicrophoneButtonCancel(it) }
|
microphoneButton.onCancel = { delegate?.onMicrophoneButtonCancel(it) }
|
||||||
microphoneButton.onUp = { delegate?.onMicrophoneButtonUp(it) }
|
|
||||||
|
// Use a separate 'raw' OnTouchListener to record the microphone button down/up timestamps because
|
||||||
|
// they don't get delayed by any multi-threading or delegates which throw off the timestamp accuracy.
|
||||||
|
// For example: If we bind something to `microphoneButton.onPress` and also log something in
|
||||||
|
// `microphoneButton.onUp` and tap the button then the logged output order is onUp and THEN onPress!
|
||||||
|
microphoneButton.setOnTouchListener(object : OnTouchListener {
|
||||||
|
override fun onTouch(v: View, event: MotionEvent): Boolean {
|
||||||
|
|
||||||
|
// We only handle single finger touch events so just consume the event and bail if there are more
|
||||||
|
if (event.pointerCount > 1) return true
|
||||||
|
|
||||||
|
when (event.action) {
|
||||||
|
MotionEvent.ACTION_DOWN -> {
|
||||||
|
// Only start spinning up the voice recorder if we're not already recording, setting up, or tearing down
|
||||||
|
if (voiceRecorderState == VoiceRecorderState.Idle) {
|
||||||
|
// Take note of when we start recording so we can figure out how long the record button was held for
|
||||||
|
voiceMessageStartMS = System.currentTimeMillis()
|
||||||
|
|
||||||
|
// We are now setting up to record, and when we actually start recording then
|
||||||
|
// AudioRecorder.startRecording will move us into the Recording state.
|
||||||
|
voiceRecorderState = VoiceRecorderState.SettingUpToRecord
|
||||||
|
startRecordingVoiceMessage()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MotionEvent.ACTION_UP -> {
|
||||||
|
// Work out how long the record audio button was held for
|
||||||
|
voiceMessageDurationMS = System.currentTimeMillis() - voiceMessageStartMS;
|
||||||
|
|
||||||
|
// Regardless of our current recording state we'll always call the onMicrophoneButtonUp method
|
||||||
|
// and let the logic in that take the appropriate action as we cannot guarantee that letting
|
||||||
|
// go of the record button should always stop recording audio because the user may have moved
|
||||||
|
// the button into the 'locked' state so they don't have to keep it held down to record a voice
|
||||||
|
// message.
|
||||||
|
// Also: We need to tear down the voice recorder if it has been recording and is now stopping.
|
||||||
|
delegate?.onMicrophoneButtonUp(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return false to propagate the event rather than consuming it
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// Send button
|
// Send button
|
||||||
binding.microphoneOrSendButtonContainer.addView(sendButton)
|
binding.microphoneOrSendButtonContainer.addView(sendButton)
|
||||||
sendButton.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
|
sendButton.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
|
||||||
@ -91,6 +152,7 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li
|
|||||||
delegate?.sendMessage()
|
delegate?.sendMessage()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Edit text
|
// Edit text
|
||||||
binding.inputBarEditText.setOnEditorActionListener(this)
|
binding.inputBarEditText.setOnEditorActionListener(this)
|
||||||
if (TextSecurePreferences.isEnterSendsEnabled(context)) {
|
if (TextSecurePreferences.isEnterSendsEnabled(context)) {
|
||||||
@ -126,20 +188,13 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li
|
|||||||
delegate?.inputBarEditTextContentChanged(text)
|
delegate?.inputBarEditTextContentChanged(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun inputBarEditTextHeightChanged(newValue: Int) {
|
override fun inputBarEditTextHeightChanged(newValue: Int) { }
|
||||||
}
|
|
||||||
|
|
||||||
override fun commitInputContent(contentUri: Uri) {
|
override fun commitInputContent(contentUri: Uri) { delegate?.commitInputContent(contentUri) }
|
||||||
delegate?.commitInputContent(contentUri)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun toggleAttachmentOptions() {
|
private fun toggleAttachmentOptions() { delegate?.toggleAttachmentOptions() }
|
||||||
delegate?.toggleAttachmentOptions()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun startRecordingVoiceMessage() {
|
private fun startRecordingVoiceMessage() { delegate?.startRecordingVoiceMessage() }
|
||||||
delegate?.startRecordingVoiceMessage()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun draftQuote(thread: Recipient, message: MessageRecord, glide: GlideRequests) {
|
fun draftQuote(thread: Recipient, message: MessageRecord, glide: GlideRequests) {
|
||||||
quoteView?.let(binding.inputBarAdditionalContentContainer::removeView)
|
quoteView?.let(binding.inputBarAdditionalContentContainer::removeView)
|
||||||
@ -228,6 +283,7 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li
|
|||||||
fun setInputBarEditableFactory(factory: Editable.Factory) {
|
fun setInputBarEditableFactory(factory: Editable.Factory) {
|
||||||
binding.inputBarEditText.setEditableFactory(factory)
|
binding.inputBarEditText.setEditableFactory(factory)
|
||||||
}
|
}
|
||||||
|
|
||||||
// endregion
|
// endregion
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -25,6 +25,14 @@ import org.thoughtcrime.securesms.util.disableClipping
|
|||||||
import org.thoughtcrime.securesms.util.toPx
|
import org.thoughtcrime.securesms.util.toPx
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
|
|
||||||
|
// Constants for animation durations in milliseconds
|
||||||
|
object VoiceRecorderConstants {
|
||||||
|
const val ANIMATE_LOCK_DURATION_MS = 250L
|
||||||
|
const val DOT_ANIMATION_DURATION_MS = 500L
|
||||||
|
const val DOT_PULSE_ANIMATION_DURATION_MS = 1000L
|
||||||
|
const val SHOW_HIDE_VOICE_UI_DURATION_MS = 250L
|
||||||
|
}
|
||||||
|
|
||||||
class InputBarRecordingView : RelativeLayout {
|
class InputBarRecordingView : RelativeLayout {
|
||||||
private lateinit var binding: ViewInputBarRecordingBinding
|
private lateinit var binding: ViewInputBarRecordingBinding
|
||||||
private var startTimestamp = 0L
|
private var startTimestamp = 0L
|
||||||
@ -79,7 +87,7 @@ class InputBarRecordingView : RelativeLayout {
|
|||||||
fun hide() {
|
fun hide() {
|
||||||
alpha = 1.0f
|
alpha = 1.0f
|
||||||
val animation = ValueAnimator.ofObject(FloatEvaluator(), 1.0f, 0.0f)
|
val animation = ValueAnimator.ofObject(FloatEvaluator(), 1.0f, 0.0f)
|
||||||
animation.duration = 250L
|
animation.duration = VoiceRecorderConstants.SHOW_HIDE_VOICE_UI_DURATION_MS
|
||||||
animation.addUpdateListener { animator ->
|
animation.addUpdateListener { animator ->
|
||||||
alpha = animator.animatedValue as Float
|
alpha = animator.animatedValue as Float
|
||||||
if (animator.animatedFraction == 1.0f) {
|
if (animator.animatedFraction == 1.0f) {
|
||||||
@ -113,7 +121,7 @@ class InputBarRecordingView : RelativeLayout {
|
|||||||
private fun animateDotView() {
|
private fun animateDotView() {
|
||||||
val animation = ValueAnimator.ofObject(FloatEvaluator(), 1.0f, 0.0f)
|
val animation = ValueAnimator.ofObject(FloatEvaluator(), 1.0f, 0.0f)
|
||||||
dotViewAnimation = animation
|
dotViewAnimation = animation
|
||||||
animation.duration = 500L
|
animation.duration = VoiceRecorderConstants.DOT_ANIMATION_DURATION_MS
|
||||||
animation.addUpdateListener { animator ->
|
animation.addUpdateListener { animator ->
|
||||||
binding.dotView.alpha = animator.animatedValue as Float
|
binding.dotView.alpha = animator.animatedValue as Float
|
||||||
}
|
}
|
||||||
@ -128,7 +136,7 @@ class InputBarRecordingView : RelativeLayout {
|
|||||||
binding.pulseView.animateSizeChange(collapsedSize, expandedSize, 1000)
|
binding.pulseView.animateSizeChange(collapsedSize, expandedSize, 1000)
|
||||||
val animation = ValueAnimator.ofObject(FloatEvaluator(), 0.5, 0.0f)
|
val animation = ValueAnimator.ofObject(FloatEvaluator(), 0.5, 0.0f)
|
||||||
pulseAnimation = animation
|
pulseAnimation = animation
|
||||||
animation.duration = 1000L
|
animation.duration = VoiceRecorderConstants.DOT_PULSE_ANIMATION_DURATION_MS
|
||||||
animation.addUpdateListener { animator ->
|
animation.addUpdateListener { animator ->
|
||||||
binding.pulseView.alpha = animator.animatedValue as Float
|
binding.pulseView.alpha = animator.animatedValue as Float
|
||||||
if (animator.animatedFraction == 1.0f && isVisible) { pulse() }
|
if (animator.animatedFraction == 1.0f && isVisible) { pulse() }
|
||||||
@ -143,7 +151,7 @@ class InputBarRecordingView : RelativeLayout {
|
|||||||
layoutParams.bottomMargin = startMarginBottom
|
layoutParams.bottomMargin = startMarginBottom
|
||||||
binding.lockView.layoutParams = layoutParams
|
binding.lockView.layoutParams = layoutParams
|
||||||
val animation = ValueAnimator.ofObject(IntEvaluator(), startMarginBottom, endMarginBottom)
|
val animation = ValueAnimator.ofObject(IntEvaluator(), startMarginBottom, endMarginBottom)
|
||||||
animation.duration = 250L
|
animation.duration = VoiceRecorderConstants.ANIMATE_LOCK_DURATION_MS
|
||||||
animation.addUpdateListener { animator ->
|
animation.addUpdateListener { animator ->
|
||||||
layoutParams.bottomMargin = animator.animatedValue as Int
|
layoutParams.bottomMargin = animator.animatedValue as Int
|
||||||
binding.lockView.layoutParams = layoutParams
|
binding.lockView.layoutParams = layoutParams
|
||||||
@ -153,21 +161,25 @@ class InputBarRecordingView : RelativeLayout {
|
|||||||
|
|
||||||
fun lock() {
|
fun lock() {
|
||||||
val fadeOutAnimation = ValueAnimator.ofObject(FloatEvaluator(), 1.0f, 0.0f)
|
val fadeOutAnimation = ValueAnimator.ofObject(FloatEvaluator(), 1.0f, 0.0f)
|
||||||
fadeOutAnimation.duration = 250L
|
fadeOutAnimation.duration = VoiceRecorderConstants.ANIMATE_LOCK_DURATION_MS
|
||||||
fadeOutAnimation.addUpdateListener { animator ->
|
fadeOutAnimation.addUpdateListener { animator ->
|
||||||
binding.inputBarMiddleContentContainer.alpha = animator.animatedValue as Float
|
binding.inputBarMiddleContentContainer.alpha = animator.animatedValue as Float
|
||||||
binding.lockView.alpha = animator.animatedValue as Float
|
binding.lockView.alpha = animator.animatedValue as Float
|
||||||
}
|
}
|
||||||
fadeOutAnimation.start()
|
fadeOutAnimation.start()
|
||||||
val fadeInAnimation = ValueAnimator.ofObject(FloatEvaluator(), 0.0f, 1.0f)
|
val fadeInAnimation = ValueAnimator.ofObject(FloatEvaluator(), 0.0f, 1.0f)
|
||||||
fadeInAnimation.duration = 250L
|
fadeInAnimation.duration = VoiceRecorderConstants.ANIMATE_LOCK_DURATION_MS
|
||||||
fadeInAnimation.addUpdateListener { animator ->
|
fadeInAnimation.addUpdateListener { animator ->
|
||||||
binding.inputBarCancelButton.alpha = animator.animatedValue as Float
|
binding.inputBarCancelButton.alpha = animator.animatedValue as Float
|
||||||
}
|
}
|
||||||
fadeInAnimation.start()
|
fadeInAnimation.start()
|
||||||
binding.recordButtonOverlayImageView.setImageDrawable(ResourcesCompat.getDrawable(resources, R.drawable.ic_arrow_up, context.theme))
|
binding.recordButtonOverlayImageView.setImageDrawable(ResourcesCompat.getDrawable(resources, R.drawable.ic_arrow_up, context.theme))
|
||||||
binding.recordButtonOverlay.setOnClickListener { delegate?.sendVoiceMessage() }
|
|
||||||
binding.inputBarCancelButton.setOnClickListener { delegate?.cancelVoiceMessage() }
|
binding.inputBarCancelButton.setOnClickListener { delegate?.cancelVoiceMessage() }
|
||||||
|
|
||||||
|
// When the user has locked the voice recorder button on then THIS is where the next click
|
||||||
|
// is registered to actually send the voice message - it does NOT hit the microphone button
|
||||||
|
// onTouch listener again.
|
||||||
|
binding.recordButtonOverlay.setOnClickListener { delegate?.sendVoiceMessage() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -7,12 +7,13 @@ import android.content.IntentFilter
|
|||||||
import android.net.ConnectivityManager
|
import android.net.ConnectivityManager
|
||||||
import android.net.Network
|
import android.net.Network
|
||||||
import org.session.libsignal.utilities.Log
|
import org.session.libsignal.utilities.Log
|
||||||
|
import org.thoughtcrime.securesms.util.NetworkUtils
|
||||||
|
|
||||||
class NetworkChangeReceiver(private val onNetworkChangedCallback: (Boolean)->Unit) {
|
class NetworkChangeReceiver(private val onNetworkChangedCallback: (Boolean)->Unit) {
|
||||||
|
|
||||||
private val networkList: MutableSet<Network> = mutableSetOf()
|
private val networkList: MutableSet<Network> = mutableSetOf()
|
||||||
|
|
||||||
val broadcastDelegate = object: BroadcastReceiver() {
|
private val broadcastDelegate = object: BroadcastReceiver() {
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
receiveBroadcast(context, intent)
|
receiveBroadcast(context, intent)
|
||||||
}
|
}
|
||||||
@ -41,16 +42,11 @@ class NetworkChangeReceiver(private val onNetworkChangedCallback: (Boolean)->Uni
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun receiveBroadcast(context: Context, intent: Intent) {
|
fun receiveBroadcast(context: Context, intent: Intent) {
|
||||||
val connected = context.isConnected()
|
val connected = NetworkUtils.haveValidNetworkConnection(context)
|
||||||
Log.i("Loki", "received broadcast, network connected: $connected")
|
Log.i("Loki", "received broadcast, network connected: $connected")
|
||||||
onNetworkChangedCallback(connected)
|
onNetworkChangedCallback(connected)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun Context.isConnected() : Boolean {
|
|
||||||
val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
|
||||||
return cm.activeNetwork != null
|
|
||||||
}
|
|
||||||
|
|
||||||
fun register(context: Context) {
|
fun register(context: Context) {
|
||||||
val intentFilter = IntentFilter("android.net.conn.CONNECTIVITY_CHANGE")
|
val intentFilter = IntentFilter("android.net.conn.CONNECTIVITY_CHANGE")
|
||||||
context.registerReceiver(broadcastDelegate, intentFilter)
|
context.registerReceiver(broadcastDelegate, intentFilter)
|
||||||
|
@ -84,7 +84,7 @@
|
|||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/inputBarCancelButton"
|
android:id="@+id/inputBarCancelButton"
|
||||||
android:layout_width="100dp"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="40dp"
|
android:layout_height="40dp"
|
||||||
android:layout_centerInParent="true"
|
android:layout_centerInParent="true"
|
||||||
android:alpha="0"
|
android:alpha="0"
|
||||||
|
@ -74,6 +74,7 @@
|
|||||||
<!-- RecipientProvider -->
|
<!-- RecipientProvider -->
|
||||||
<string name="RecipientProvider_unnamed_group">Unnamed group</string>
|
<string name="RecipientProvider_unnamed_group">Unnamed group</string>
|
||||||
|
|
||||||
|
<string name="messageVoiceErrorShort">Hold to record a voice message.</string>
|
||||||
<string name="clearDataErrorDescriptionGeneric">An unknown error occurred and your data was not deleted. Do you want to delete your data from just this device instead?</string>
|
<string name="clearDataErrorDescriptionGeneric">An unknown error occurred and your data was not deleted. Do you want to delete your data from just this device instead?</string>
|
||||||
<string name="errorUnknown">An unknown error occurred.</string>
|
<string name="errorUnknown">An unknown error occurred.</string>
|
||||||
<string name="clearDevice">Clear Device</string>
|
<string name="clearDevice">Clear Device</string>
|
||||||
|
Loading…
Reference in New Issue
Block a user